From 67c623da5d31c986e6ed39350f8e7af44e080d07 Mon Sep 17 00:00:00 2001 From: Steven Maillet Date: Sat, 10 Jan 2026 16:01:54 -0800 Subject: [PATCH 1/4] Added Source generator support * Added the source generator itself * Added unit tests for the generator - Basic golden path only at present. * Added Generator attributes to `Ubiquity.NET.CommandLine * Added Demo srcGen project to consume the package itself - This verifies the build functions as packaged as it is possible to miss a dependency but still work in unit tests. * Updated build scripts to build the demo app to test the packaging. * Suppressed messages in shared source to silence bogus errors/warnings about "banned" types. The values retrieved through System.Environment are not environment variables but actual fixed things that are runtime specific for the analyzer itself. (Not the target runtime) While these APIs are often unused in a Roslyn component they are sometimes legit and are doing exactly as intended - the current runtime. --- Build-Source.ps1 | 8 +- Directory.Packages.props | 2 +- .../DemoCommandLineSrcGen.csproj | 14 + .../DemoCommandLineSrcGen.slnx | 3 + src/DemoCommandLineSrcGen/Program.cs | 56 +++ src/DemoCommandLineSrcGen/TestOptions.cs | 38 +++ .../PackageReadMe.md | 86 +++++ src/Ubiquity.NET.CommandLine.Pkg/ReadMe.md | 24 ++ .../Ubiquity.NET.CommandLine.Pkg.csproj | 81 +++++ .../Ubiquity.NET.CommandLine.nuspec | 45 +++ .../AssemblyInfo.cs | 19 ++ .../GlobalNamespaceImports.cs | 19 ++ .../GlobalSuppression.cs | 12 + .../RootCommandAttributeTests.cs | 85 +++++ .../TestFiles/ReadMe.md | 39 +++ .../RootCommandAttributeTests/expected.cs | 74 ++++ .../RootCommandAttributeTests/input.cs | 26 ++ .../TestHelpers.cs | 28 ++ .../Ubiquity.NET.CommandLine.SrcGen.UT.csproj | 47 +++ .../UniDiffExtensions.cs | 27 ++ .../CommandGenerator.cs | 88 +++++ .../CommandLinePolyFill.cs | 71 ++++ .../Constants.cs | 109 ++++++ .../GlobalNamespaceImports.cs | 16 + .../OptionInfo.cs | 66 ++++ .../PackageReadMe.md | 105 ++++++ .../PropertyInfo.cs | 96 ++++++ .../RootCommandInfo.cs | 83 +++++ .../Templates/ISourceGenTemplate.cs | 25 ++ .../Templates/RootCommandClassTemplate.cs | 323 ++++++++++++++++++ .../Templates/ToolInfo.cs | 14 + .../TrackingNames.cs | 13 + .../Ubiquity.NET.CommandLine.SrcGen.csproj | 99 ++++++ .../GeneratorAttributes/CommandAttribute.cs | 21 ++ .../FileValidationAttribute.cs | 44 +++ .../FolderValidationAttribute.cs | 47 +++ .../GeneratorAttributes/OptionAttribute.cs | 100 ++++++ .../RootCommandAttribute.cs | 44 +++ .../Ubiquity.NET.CommandLine.csproj | 8 +- .../PolyFillExceptionValidators.cs | 12 +- .../PolyFillOperatingSystem.cs | 1 + .../PolyFillStringExtensions.cs | 7 + src/Ubiquity.NET.Utils.slnx | 3 + 43 files changed, 2124 insertions(+), 4 deletions(-) create mode 100644 src/DemoCommandLineSrcGen/DemoCommandLineSrcGen.csproj create mode 100644 src/DemoCommandLineSrcGen/DemoCommandLineSrcGen.slnx create mode 100644 src/DemoCommandLineSrcGen/Program.cs create mode 100644 src/DemoCommandLineSrcGen/TestOptions.cs create mode 100644 src/Ubiquity.NET.CommandLine.Pkg/PackageReadMe.md create mode 100644 src/Ubiquity.NET.CommandLine.Pkg/ReadMe.md create mode 100644 src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.Pkg.csproj create mode 100644 src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.nuspec create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen.UT/AssemblyInfo.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalNamespaceImports.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalSuppression.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen.UT/RootCommandAttributeTests.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/ReadMe.md create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/expected.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/input.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen.UT/TestHelpers.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen.UT/UniDiffExtensions.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/CommandGenerator.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/CommandLinePolyFill.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/Constants.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/GlobalNamespaceImports.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/OptionInfo.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/PackageReadMe.md create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/PropertyInfo.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/RootCommandInfo.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/Templates/ISourceGenTemplate.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/Templates/RootCommandClassTemplate.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/Templates/ToolInfo.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/TrackingNames.cs create mode 100644 src/Ubiquity.NET.CommandLine.SrcGen/Ubiquity.NET.CommandLine.SrcGen.csproj create mode 100644 src/Ubiquity.NET.CommandLine/GeneratorAttributes/CommandAttribute.cs create mode 100644 src/Ubiquity.NET.CommandLine/GeneratorAttributes/FileValidationAttribute.cs create mode 100644 src/Ubiquity.NET.CommandLine/GeneratorAttributes/FolderValidationAttribute.cs create mode 100644 src/Ubiquity.NET.CommandLine/GeneratorAttributes/OptionAttribute.cs create mode 100644 src/Ubiquity.NET.CommandLine/GeneratorAttributes/RootCommandAttribute.cs diff --git a/Build-Source.ps1 b/Build-Source.ps1 index 3c1c7b0..169afe3 100644 --- a/Build-Source.ps1 +++ b/Build-Source.ps1 @@ -30,9 +30,15 @@ try # script. But for a local "inner loop" development this might be the only script used. $buildInfo = Initialize-BuildEnvironment -FullInit:$FullInit - # build the Managed code support + # build the core library support Write-Information "dotnet build --tl:off 'src\Ubiquity.NET.Utils.slnx' -c $Configuration" Invoke-External dotnet build --tl:off 'src\Ubiquity.NET.Utils.slnx' '-c' $Configuration + + # build the demo that consumes the output packages as a package reference only. + # This ensures that the proper references are present and work. The demo uses the + # library and the source generation to validate it is actually functional. + Write-Information "dotnet build --tl:off 'src\DemoCommandLineSrcGen\DemoCommandLineSrcGen.slnx' -c $Configuration" + Invoke-External dotnet build --tl:off 'src\DemoCommandLineSrcGen\DemoCommandLineSrcGen.slnx' '-c' $Configuration } catch { diff --git a/Directory.Packages.props b/Directory.Packages.props index b54992b..a221a7f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -41,7 +41,7 @@ - + diff --git a/src/DemoCommandLineSrcGen/DemoCommandLineSrcGen.csproj b/src/DemoCommandLineSrcGen/DemoCommandLineSrcGen.csproj new file mode 100644 index 0000000..d4edd88 --- /dev/null +++ b/src/DemoCommandLineSrcGen/DemoCommandLineSrcGen.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + true + + + + + + + diff --git a/src/DemoCommandLineSrcGen/DemoCommandLineSrcGen.slnx b/src/DemoCommandLineSrcGen/DemoCommandLineSrcGen.slnx new file mode 100644 index 0000000..99e0a57 --- /dev/null +++ b/src/DemoCommandLineSrcGen/DemoCommandLineSrcGen.slnx @@ -0,0 +1,3 @@ + + + diff --git a/src/DemoCommandLineSrcGen/Program.cs b/src/DemoCommandLineSrcGen/Program.cs new file mode 100644 index 0000000..b43693a --- /dev/null +++ b/src/DemoCommandLineSrcGen/Program.cs @@ -0,0 +1,56 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +#pragma warning disable IDE0130 // Namespace does not match folder structure +#pragma warning disable SA1600 // Elements should be documented + +using System; +using System.Threading; +using System.Threading.Tasks; + +using Ubiquity.NET.CommandLine; + +namespace TestNamespace +{ + public static class Program + { + public static async Task Main( string[] args ) + { + // pressing CTRL-C cancels the entire operation + using CancellationTokenSource cts = new(); + Console.CancelKeyPress += ( _, e ) => + { + e.Cancel = true; + cts.Cancel(); + }; + + // start with information level for parsing; parsed options might specify different level + var reporter = new ColoredConsoleReporter( MsgLevel.Information ); + + return await TestOptions.BuildRootCommand( ( options, ct ) => AppMainAsync( options, reporter, ct ) ) + .ParseAndInvokeResultAsync( reporter, cts.Token, args ); + } + + private static async Task AppMainAsync( + TestOptions options, + ColoredConsoleReporter reporter, + CancellationToken ct + ) + { + // Now that args are parsed, if a distinct verbosity level is specified use that + if(options.Verbosity != reporter.Level) + { + reporter = new ColoredConsoleReporter( options.Verbosity ); + } + + reporter.Verbose( "AppMainAsync" ); + + // Core application code here... + + // Use the cancellation token to indicate cancellation + // This is set when CTRL-C is pressed in Main() above. + ct.ThrowIfCancellationRequested(); + return 0; + } + } +} diff --git a/src/DemoCommandLineSrcGen/TestOptions.cs b/src/DemoCommandLineSrcGen/TestOptions.cs new file mode 100644 index 0000000..7f6a0fc --- /dev/null +++ b/src/DemoCommandLineSrcGen/TestOptions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +#pragma warning disable IDE0130 // Namespace does not match folder structure + +using System.CommandLine; +using System.IO; + +using Ubiquity.NET.CommandLine; +using Ubiquity.NET.CommandLine.GeneratorAttributes; + +namespace TestNamespace +{ + [RootCommand( Description = "Root command for tests" )] + internal partial class TestOptions + { + [Option( "-o", Description = "Test SomePath" )] + [FolderValidation( FolderValidation.CreateIfNotExist )] + public required DirectoryInfo SomePath { get; init; } + + [Option( "-v", Description = "Verbosity Level" )] + public MsgLevel Verbosity { get; init; } = MsgLevel.Information; + + [Option( "-b", Description = "Test Some existing Path" )] + [FolderValidation( FolderValidation.ExistingOnly )] + public required DirectoryInfo SomeExistingPath { get; init; } + + [Option( "--thing1", Aliases = [ "-t" ], Required = true, Description = "Test Thing1", HelpName = "Help name for thing1" )] + public bool Thing1 { get; init; } + + // This should be ignored by generator + public string? NotAnOption { get; set; } + + [Option( "-a", Hidden = true, Required = false, ArityMin = 0, ArityMax = 3, Description = "Test SomeOtherPath" )] + [FileValidation( FileValidation.ExistingOnly )] + public required FileInfo SomeOtherPath { get; init; } + } +} diff --git a/src/Ubiquity.NET.CommandLine.Pkg/PackageReadMe.md b/src/Ubiquity.NET.CommandLine.Pkg/PackageReadMe.md new file mode 100644 index 0000000..4ce264d --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.Pkg/PackageReadMe.md @@ -0,0 +1,86 @@ +# Ubiquity.NET.CommandLine +Common Text CommandLine support. This provides a number of support classes for +Text based UI/UX, including command line parsing extensions. This is generally only relevant +for console based apps. + +## Example Command line parsing +Normal single command application: +Options binding types are normally split into two partial classes. The normal types that +would contain attributes for source generation and the generated partial implementation. +While there is no source generator (yet) for the options [That is intended for a future +release] this keeps the responsibilities clearer and aids in migration to a source generator. + +### Root command type +Options.cs: +``` +using System.IO; + +using Ubiquity.NET.CommandLine; + +namespace TestSample +{ + // Root command for the app, Disable the default options and directive support + // This puts the app in total control of the parsing. Normally an app would leave the + // defaults but it might not want to for command line syntax compat with a previous + // release... + [RootCommand(DefaultOptions = DefaultOption.None, DefaultDirectives = DefaultDirective.None )] + internal partial class ParsedArgs + { + [Option("-v", Description="Verbosity Level")] + internal MsgLevel Verbosity? { get; init; } + + // Additional options here... + } +} +``` + +### Usage in common single command/no command case +Most applications don't have or use any concept of commands/sub-commands. Thus, there is +support to make this scenario as simple as possible. [This uses the previously listed +`ParsedArgs` class. + +``` C# +public static async Task Main( string[] args ) +{ + // pressing CTRL-C cancels the entire operation + using CancellationTokenSource cts = new(); + Console.CancelKeyPress += ( _, e ) => + { + e.Cancel = true; + cts.Cancel(); + }; + + // start with information level for parsing; parsed options might specify a different + // level `AppMain` will adapt to any new value. + var reporter = new ColoredConsoleReporter( MsgLevel.Information ); + + return await TestOptions.BuildRootCommand( ( options, ct ) => AppMainAsync( options, reporter, ct ) ) + .ParseAndInvokeResultAsync( reporter, cts.Token, args ); +} + +private static int AppMain( ParsedArgs args, ColoredConsoleReporter reporter ) +{ + // Real main here. + // The pre-parsed and validated command line arguments and the reporter are provided as + // arguments to this function + + // Now that args are parsed, if a distinct verbosity level is specified use that + if(args.Verbosity != reporter.Level) + { + reporter = new ColoredConsoleReporter( args.Verbosity ); + } + + // ... +} +``` +This is a simplified usage for the common case of an app without commands/sub-commands. + +## Supported Functionality +`IDiagnosticReporter` interface is at the core of the UX. It is similar in many ways to many +of the logging interfaces available. The primary distinction is with the ***intention*** of +use. `IDiagnosticReporter`, specifically, assumes the use for UI/UX rather than a +debugging/diagnostic log. These have VERY distinct use cases and purposes and generally show +very different information. (Not to mention the overly complex requirements of +the anti-pattern DI container/Service Locator assumed in `Microsoft.Extensions.Logging`) + + diff --git a/src/Ubiquity.NET.CommandLine.Pkg/ReadMe.md b/src/Ubiquity.NET.CommandLine.Pkg/ReadMe.md new file mode 100644 index 0000000..a89fc93 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.Pkg/ReadMe.md @@ -0,0 +1,24 @@ +# Ubiquity.NET.CommandLine.Pkg +This is a special project that creates the meta package to reference the library AND the +associated Roslyn components. This is done without restoring any packages by a "NOTARGETS" +project that uses an explicit NUSPEC. This is used instead of the CSPROJ built-in support +for generation of the NuSpec as that would try and restore ALL references first. This causes +problems for build ordering. Since a CSPROJ will try to restore all packages it will fail to +restore anything that isn't built yet. While it is plausible to construct build order +dependencies, such things are not honored when restoring. NuGet package restore happens +***BEFORE*** build so the packages won't exist. + +That limitation is strictly in how CSPROJ system works and not an actual limit of NuGet +itself. Thus, to workaround that, this project uses a NuSpec file and generates the meta +package directly (even if the dependencies are not resolved). The package is created +directly referencing what will exist in the future breaking the dependency cycle/problem. + +## Build Caveat +Due to the nature of this, the version reference must remain constant across all project +builds. This is true for command line builds but NOT for IDE builds. Each project in an IDE +build gets a new version. This means that the version references in this package are wrong +and won't exist. The solution is to build this and all packages from the command line to +guarantee correct references. This is done automatically in all automated builds as they +don't use any IDE. Thus, the problem is for local loop development only. It is only an issue +when using the demo project as that is designed to leverage ONLY the package without any +project references to validate that works as expected. diff --git a/src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.Pkg.csproj b/src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.Pkg.csproj new file mode 100644 index 0000000..2bc29f1 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.Pkg.csproj @@ -0,0 +1,81 @@ + + + + net10.0;net8.0 + + + false + false + true + true + + + true + Ubiquity.NET.CommandLine.nuspec + + + Ubiquity.NET.CommandLine + + 4.9.0 + .NET Foundation,Ubiquity.NET + false + General use Support for Command line parsing based on System.CommandLine. This is a meta package that references the library and the associated Roslyn components + Extensions,.NET,Ubiquity.NET, Console + PackageReadMe.md + https://github.com/UbiquityDotNET/Ubiquity.NET.Utils + https://github.com/UbiquityDotNET/Ubiquity.NET.Utils.git + git + Apache-2.0 WITH LLVM-exception + + + false + $(NoWarn);NU5128 + + + + + + + + + + + + + + + + + + configuration=$(Configuration) + $(NuspecProperties);packageID=$(PackageID) + $(NuspecProperties);version=$(PackageVersion) + $(NuspecProperties);authors=$(Authors) + $(NuspecProperties);projectUrl=$(PackageProjectUrl) + $(NuspecProperties);description=$(Description) + $(NuspecProperties);tags=$(PackageTags) + $(NuSpecProperties);licExpression=$(PackageLicenseExpression) + $(NuSpecProperties);tfmGroup=$(TargetFramework) + + + diff --git a/src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.nuspec b/src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.nuspec new file mode 100644 index 0000000..cb18069 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.nuspec @@ -0,0 +1,45 @@ + + + + + $packageID$ + $version$ + $authors$ + $description$ + $tags$ + $licExpression$ + $projectUrl$ + PackageReadMe.md + + + + + + + + + + + diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/AssemblyInfo.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/AssemblyInfo.cs new file mode 100644 index 0000000..ee0fc35 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/AssemblyInfo.cs @@ -0,0 +1,19 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +// In SDK-style projects such as this one, several assembly attributes that were historically +// defined in this file are now automatically added during build and populated with +// values defined in project properties. For details of which attributes are included +// and how to customize this process see: https://aka.ms/assembly-info-properties + +// Setting ComVisible to false makes the types in this assembly not visible to COM +// components. If you need to access a type in this assembly from COM, set the ComVisible +// attribute to true on that type. + +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM. + +[assembly: Guid("f17052e2-d54d-454d-b677-f9f8df9b3db8")] + +[assembly: Parallelize( Scope = ExecutionScope.MethodLevel )] diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalNamespaceImports.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalNamespaceImports.cs new file mode 100644 index 0000000..ac6dd60 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalNamespaceImports.cs @@ -0,0 +1,19 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +global using System; +global using System.Collections.Generic; +global using System.Collections.Immutable; +global using System.Diagnostics.CodeAnalysis; +global using System.IO; +global using System.Runtime.InteropServices; +global using System.Text; + +global using Basic.Reference.Assemblies; + +global using Microsoft.CodeAnalysis; +global using Microsoft.CodeAnalysis.CSharp; +global using Microsoft.CodeAnalysis.Text; +global using Microsoft.VisualStudio.TestTools.UnitTesting; + +global using Ubiquity.NET.SourceGenerator.Test.Utils; diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalSuppression.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalSuppression.cs new file mode 100644 index 0000000..a79c00f --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalSuppression.cs @@ -0,0 +1,12 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Unit Tests" )] +[assembly: SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1652:Enable XML documentation output", Justification = "Unit Tests" )] diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/RootCommandAttributeTests.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/RootCommandAttributeTests.cs new file mode 100644 index 0000000..3373111 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/RootCommandAttributeTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.SrcGen.UT +{ + [TestClass] + public sealed class RootCommandAttributeTests + { + public TestContext TestContext { get; set; } + + [TestMethod] + public void Basic_golden_path_succeeds( ) + { + var sourceGenerator = new CommandGenerator().AsSourceGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: [sourceGenerator], + driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true) + ); + + SourceText input = TestHelpers.GetTestText(nameof(RootCommandAttributeTests), "input.cs"); + SourceText expected = TestHelpers.GetTestText(nameof(RootCommandAttributeTests), "expected.cs"); + + CSharpCompilation compilation = CreateCompilation(input, TestProgramCSPath); + var diagnostics = compilation.GetDiagnostics( TestContext.CancellationToken ); + foreach(var diagnostic in diagnostics) + { + TestContext.WriteLine( diagnostic.ToString() ); + } + + Assert.HasCount( 0, diagnostics ); + + var results = driver.RunGeneratorAndAssertResults(compilation, [TrackingNames.CommandClass]); + Assert.IsEmpty( results.Diagnostics, "Should not have ANY diagnostics reported during generation" ); + + // validate the generated trees have the correct count and names + Assert.HasCount( 1, results.GeneratedTrees, "Should create 1 'files' during generation" ); + for(int i = 0; i < results.GeneratedTrees.Length; ++i) + { + string expectedName = GeneratedFilePaths[i]; + SyntaxTree tree = results.GeneratedTrees[i]; + + Assert.AreEqual( expectedName, tree.FilePath, "Generated files should use correct name" ); + Assert.AreEqual( Encoding.UTF8, tree.Encoding, $"Generated files should use UTF8. [{expectedName}]" ); + } + + SourceText actual = results.GeneratedTrees[0].GetText( TestContext.CancellationToken ); + string uniDiff = expected.UniDiff(actual); + if(!string.IsNullOrWhiteSpace( uniDiff )) + { + TestContext.WriteLine( uniDiff ); + Assert.Fail( "No Differences Expected" ); + } + } + + // simple helper for these tests to create a C# Compilation + internal static CSharpCompilation CreateCompilation( + SourceText source, + string path, + CSharpParseOptions? parseOptions = default, + CSharpCompilationOptions? compileOptions = default, + List? references = default + ) + { + parseOptions ??= new CSharpParseOptions(LanguageVersion.CSharp14, DocumentationMode.None); + compileOptions ??= new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable ); + + // Default to .NET 10 if not specified. + references ??= [ .. Net100.References.All ]; + references.Add( MetadataReference.CreateFromFile( Path.Combine( Environment.CurrentDirectory, "Ubiquity.NET.CommandLine.dll" ) ) ); + + return CSharpCompilation.Create( "TestAssembly", + [ CSharpSyntaxTree.ParseText( source, parseOptions, path ) ], + references, + compileOptions + ); + } + + private const string TestProgramCSPath = @"input.cs"; + + private readonly ImmutableArray GeneratedFilePaths + = [ + @"Ubiquity.NET.CommandLine.SrcGen\Ubiquity.NET.CommandLine.SrcGen.CommandGenerator\TestNamespace.TestOptions.g.cs", + ]; + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/ReadMe.md b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/ReadMe.md new file mode 100644 index 0000000..e2436eb --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/ReadMe.md @@ -0,0 +1,39 @@ +# TestFiles +These are test files used as input, expected content etc... + +They are `Embedded Resources` that are retrievable by `Assembly.GetManafiestResourceStream()`. +To simplify things the `TestHelpers` class provides overloads that follow the patterns +described here. + +## Naming Patterns + +``` +📁 TestFiles +└─📁 + ├─📄 + └─📄 +``` +Where `` is the name of the test (in code that is usually `nameof(T)`). + +>[!IMPORTANT] +> It is important to note that .NET considers resource names as case sensitive. While +> Microsoft Windows normally uses a case preserving but insensitive match for file names the +> resource names are captured from the underlying FS and are case preserving. Thus, the case +> of file names ***MUST*** match the name used to retrieve it as a resource. + +### Final Resource Name +The full resource name pattern is: +`...` + +where: + +| Name | Description | +|------|-------------| +| `` | Default namespace for the assembly (Technically the namespace that contains `TestHelpers`.| +| `` | The name of the folder containing this file. | +| `` | The name of the tests (as a sub-folder of the one containing this file) | +| `` | The name of the file used by the tests. | + +This ensures the contents are easily retrieved using just a test name (via `nameof(T)`) and +the name of the file of interest. Everything else is formed from the pattern and doesn't +change for each file/test. diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/expected.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/expected.cs new file mode 100644 index 0000000..d70daab --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/expected.cs @@ -0,0 +1,74 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// testhost [18.0.1] +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +using static global::Ubiquity.NET.CommandLine.SymbolValidationExtensions; + +namespace TestNamespace +{ + internal partial class TestOptions + : global::Ubiquity.NET.CommandLine.IRootCommandBuilderWithSettings + , global::Ubiquity.NET.CommandLine.ICommandBinder + { + public static global::Ubiquity.NET.CommandLine.CommandLineSettings Settings => new(); + + public static TestOptions Bind( global::System.CommandLine.ParseResult parseResult ) + { + return new() + { + SomePath = parseResult.GetValue( Descriptors.SomePath ), + SomeExistingPath = parseResult.GetValue( Descriptors.SomeExistingPath ), + Thing1 = parseResult.GetRequiredValue( Descriptors.Thing1 ), + SomeOtherPath = parseResult.GetValue( Descriptors.SomeOtherPath ), + }; + } + + public static global::Ubiquity.NET.CommandLine.AppControlledDefaultsRootCommand Build( ) + { + return new global::Ubiquity.NET.CommandLine.AppControlledDefaultsRootCommand( Settings, "Root command for tests" ) + { + Descriptors.SomePath, + Descriptors.SomeExistingPath, + Descriptors.Thing1, + Descriptors.SomeOtherPath, + }; + } + } + + file static class Descriptors + { + internal static readonly global::System.CommandLine.Option SomePath + = new global::System.CommandLine.Option("-o") + { + Description = "Test SomePath", + }.EnsureFolder(); + + internal static readonly global::System.CommandLine.Option SomeExistingPath + = new global::System.CommandLine.Option("-b") + { + Description = "Test Some existing Path", + }.AcceptExistingFolderOnly(); + + internal static readonly global::System.CommandLine.Option Thing1 + = new global::System.CommandLine.Option("--thing1", "-t") + { + HelpName = "Help name for thing1", + Description = "Test Thing1", + Required = true, + }; + + internal static readonly global::System.CommandLine.Option SomeOtherPath + = new global::System.CommandLine.Option("-a") + { + Description = "Test SomeOtherPath", + Required = false, + Hidden = true, + Arity = new global::System.CommandLine.ArgumentArity(0, 3), + }.AcceptExistingFileOnly(); + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/input.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/input.cs new file mode 100644 index 0000000..0f3bfe8 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/input.cs @@ -0,0 +1,26 @@ +using System.IO; +using Ubiquity.NET.CommandLine.GeneratorAttributes; + +namespace TestNamespace; + +[RootCommand( Description = "Root command for tests" )] +internal partial class TestOptions +{ + [Option( "-o", Description = "Test SomePath" )] + [FolderValidation( FolderValidation.CreateIfNotExist )] + public required DirectoryInfo SomePath { get; init; } + + [Option( "-b", Description = "Test Some existing Path" )] + [FolderValidation( FolderValidation.ExistingOnly )] + public required DirectoryInfo SomeExistingPath { get; init; } + + [Option( "--thing1", Aliases = [ "-t" ], Required = true, Description = "Test Thing1", HelpName = "Help name for thing1" )] + public bool Thing1 { get; init; } + + // This should be ignored by generator + public string? NotAnOption { get; set; } + + [Option( "-a", Hidden = true, Required = false, ArityMin = 0, ArityMax = 3, Description = "Test SomeOtherPath" )] + [FileValidation( FileValidation.ExistingOnly )] + public required FileInfo SomeOtherPath { get; init; } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestHelpers.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestHelpers.cs new file mode 100644 index 0000000..e266a2e --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestHelpers.cs @@ -0,0 +1,28 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.SrcGen.UT +{ + internal static class TestHelpers + { + public static Stream GetTestResourceStream( string testName, string name ) + { + // Example: + // Ubiquity.NET.CommandLine.SrcGen.UT.TestFiles.RootCommandAttributeTests.expected.cs + // nameof(T) doesn't work for the namespace, so use reflection information to form the + // name with a well defined pattern. (refactoring safe) + var myType = typeof(TestHelpers); + string resourceName = $"{myType.Namespace}.{TestFilesFolderName}.{testName}.{name}"; + return myType.Assembly.GetManifestResourceStream( resourceName ) + ?? throw new InvalidOperationException( $"Resource '{resourceName}' not found" ); + } + + public static SourceText GetTestText( string testName, string name ) + { + using var strm = GetTestResourceStream( testName, name ); + return SourceText.From( strm ); + } + + private const string TestFilesFolderName = "TestFiles"; + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj b/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj new file mode 100644 index 0000000..aa0478d --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj @@ -0,0 +1,47 @@ + + + + net10.0 + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + + + + + diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/UniDiffExtensions.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/UniDiffExtensions.cs new file mode 100644 index 0000000..8af12f4 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/UniDiffExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using DiffPlex.Renderer; + +namespace Ubiquity.NET.CommandLine.SrcGen.UT +{ + internal static class UniDiffExtensions + { + public static string UniDiff( this SourceText self, SourceText other, string leftFileName = "expected", string rightFileName = "actual" ) + { + return UniDiff( self.ToString(), other, leftFileName, rightFileName ); + } + + public static string UniDiff( this string self, SourceText other, string leftFileName = "expected", string rightFileName = "actual" ) + { + var renderer = new UnidiffRenderer(contextLines: 1); + return renderer.Generate( + self, + other.ToString(), + leftFileName, + rightFileName, + ignoreWhitespace: false + ); + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/CommandGenerator.cs b/src/Ubiquity.NET.CommandLine.SrcGen/CommandGenerator.cs new file mode 100644 index 0000000..fecf04c --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/CommandGenerator.cs @@ -0,0 +1,88 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis.CSharp; + +namespace Ubiquity.NET.CommandLine.SrcGen +{ + /// Roslyn Source generator to generate backing support for command line parsing + [Generator] + public class CommandGenerator + : IIncrementalGenerator + { + /// + public void Initialize( IncrementalGeneratorInitializationContext context ) + { + // NOTE: if the tests aren't finding anything double check that Constants.* contains the correct namespace name. + + string rootCommandAttribName = Constants.RootCommandAttribute.ToString(); // Don't use alias or global prefix for this + + var optionClasses + = context.SyntaxProvider + .ForAttributeWithMetadataName( + rootCommandAttribName, + predicate: static (s, _) => true, + transform: static (ctx, _) => CollectCommandAttributeData( ctx ) + ) // filter for command attribute only + .Where( static m => m is not null ) + .Select( static (m, ct) => (RootCommandInfo)m! ) // convert nullable type to non null as preceding where clause filters out null values + .WithTrackingName( TrackingNames.CommandClass ); + + context.RegisterSourceOutput( optionClasses, Execute ); + } + + private static RootCommandInfo? CollectCommandAttributeData( GeneratorAttributeSyntaxContext context ) + { + // Do nothing if the target doesn't support what the generated code needs or something is wrong. + // Errors are detected by a distinct analyzer; code generators just NOP as fast as possible. + var compilation = context.SemanticModel.Compilation; + if( context.Attributes.Length != 1 // Multiple instances not allowed and 0 is just broken. + || compilation.Language != "C#" + || !compilation.HasLanguageVersionAtLeastEqualTo(LanguageVersion.CSharp13) + || context.TargetSymbol is not INamedTypeSymbol namedTypeSymbol + || context.TargetNode is not ClassDeclarationSyntax commandClass + ) + { + return null; + } + + var theAttribute = context.Attributes[0]; + + // Capture the names of all properties with a generating attribute. + // This starts out assuming all members are a property. This is a bit aggressive + // but ensures only one allocation of the result is needed for all (That is, all + // members is the max size needed so allocate that amount once). + var members = namedTypeSymbol.GetMembers(); + var propertyInfoBuilder = ImmutableArray.CreateBuilder( members.Length ); + foreach(ISymbol member in members) + { + // filter to referenceable properties + if(member.CanBeReferencedByName && member is IPropertySymbol propSym) + { + var attributes = propSym.CaptureMatchingAttributes( Constants.GeneratingAttributeNames ); + if(attributes.Length > 0 ) + { + var propInfo = new PropertyInfo( propSym.Name, propSym.Type.GetNamespaceQualifiedName(), attributes ); + propertyInfoBuilder.Add( propInfo ); + } + } + } + + return new RootCommandInfo( + namedTypeSymbol.GetNamespaceQualifiedName(), + theAttribute, + propertyInfoBuilder.ToImmutable() + ); + } + + private static void Execute( SourceProductionContext context, RootCommandInfo source ) + { + var template = new Templates.RootCommandClassTemplate(source); + var generatedSource = template.GenerateText(); + context.AddSource( $"{source.TargetName:R}.g.cs", generatedSource ); + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/CommandLinePolyFill.cs b/src/Ubiquity.NET.CommandLine.SrcGen/CommandLinePolyFill.cs new file mode 100644 index 0000000..0baf59b --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/CommandLinePolyFill.cs @@ -0,0 +1,71 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.SrcGen +{ + // Roslyn generators **MUST** target .NET standard 2.0; However the CommandLine assembly + // only supports .NET 8.0 and .NET 10. So this fills in the gaps on enumerations as the + // library with the declarations can't be referenced directly. + + /// Flags to determine the default Options for an + [Flags] + internal enum DefaultOption + { + /// No default options used + None = 0, + + /// Include the default help option + Help, + + /// Include the default version option + Version, + } + + /// Flags to determine the default directives supported for an + [Flags] + internal enum DefaultDirective + { + /// No default directives included + None = 0, + + /// Include support for + Suggest, + + /// Include support for + Diagram, + + /// Include support for + EnvironmentVariables, + } + + /// Enumeration for folder validation + internal enum FileValidation + { + /// No validation + None, + + /// Existing files only accepted. + /// + /// If a file specified does not exist then an exception results from the validation stage of + /// processing the command line. + /// + ExistingOnly, + } + + /// Enumeration for folder validation + internal enum FolderValidation + { + /// No validation + None, + + /// Creates the folder if it doesn't exist + CreateIfNotExist, + + /// Existing folders only accepted. + /// + /// If a folder specified does not exist then an exception results from the validation stage of + /// processing the command line. + /// + ExistingOnly, + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/Constants.cs b/src/Ubiquity.NET.CommandLine.SrcGen/Constants.cs new file mode 100644 index 0000000..44d76a6 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/Constants.cs @@ -0,0 +1,109 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Collections.Immutable; +using System.Linq; + +namespace Ubiquity.NET.CommandLine.SrcGen +{ + // Implementation Note: + // use of pattern `string X = nameof(X)` allows for refactoring the name without the problem + // of repetitive typo errors. + // + // These name values are needed as the target DLL is NOT referenceable by this generator + // and therefore does not have anyway to resolve the names. + + internal static class Constants + { + internal static readonly IEnumerable SystemIONamespaceParts = [ "System", "IO" ]; + + internal static readonly IEnumerable SystemCommandLineNamespaceParts = [ "System", "CommandLine" ]; + + internal static readonly IEnumerable UbiquityNETCommandLineNamespaceParts = [ "Ubiquity", "NET", "CommandLine" ]; + + internal static readonly IEnumerable GeneratorAttributesNamespaceParts = UbiquityNETCommandLineNamespaceParts.Append("GeneratorAttributes"); + + internal static string UbiquityNETCommandLineNamespaceName { get; } = string.Join(".", UbiquityNETCommandLineNamespaceParts); + + internal static readonly NamespaceQualifiedName RootCommandAttribute + = new( GeneratorAttributesNamespaceParts, nameof(RootCommandAttribute)); + + internal static readonly NamespaceQualifiedName OptionAttribute + = new( GeneratorAttributesNamespaceParts, nameof(OptionAttribute)); + + internal static readonly NamespaceQualifiedName FileValidationAttribute + = new( GeneratorAttributesNamespaceParts, nameof(FileValidationAttribute)); + + internal static readonly NamespaceQualifiedName FolderValidationAttribute + = new( GeneratorAttributesNamespaceParts, nameof(FolderValidationAttribute)); + + internal static readonly NamespaceQualifiedName IRootCommandBuilderWithSettings + = new( UbiquityNETCommandLineNamespaceParts, nameof(IRootCommandBuilderWithSettings)); + + [SuppressMessage( "StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Generic type - no other way to represent name in C# literal" )] + internal static readonly NamespaceQualifiedName ICommandBinder + = new( UbiquityNETCommandLineNamespaceParts, nameof(ICommandBinder)); // Actual name is ICommandBinder`1, but that is not needed for code gen + + internal static readonly NamespaceQualifiedName AppControlledDefaultsRootCommand + = new( UbiquityNETCommandLineNamespaceParts, nameof(AppControlledDefaultsRootCommand)); + + internal static readonly NamespaceQualifiedName SymbolValidationExtensions + = new( UbiquityNETCommandLineNamespaceParts, nameof(SymbolValidationExtensions)); + + internal static readonly NamespaceQualifiedName CommandLineSettings + = new( UbiquityNETCommandLineNamespaceParts, nameof(CommandLineSettings)); + + internal static readonly NamespaceQualifiedName ArgumentArity + = new( SystemCommandLineNamespaceParts, nameof(ArgumentArity)); + + internal static readonly NamespaceQualifiedName Option + = new( SystemCommandLineNamespaceParts, nameof(Option)); + + internal static readonly NamespaceQualifiedName ParseResult + = new( SystemCommandLineNamespaceParts, nameof(ParseResult)); + + internal static readonly NamespaceQualifiedName DirectoryInfo + = new( SystemIONamespaceParts, nameof(DirectoryInfo)); + + internal static readonly NamespaceQualifiedName FileInfo + = new( SystemIONamespaceParts, nameof(FileInfo)); + + // Names for the attributes to allow quickly filtering to a matching + // simple name before testing if it's full name is a match + internal static readonly ImmutableArray GeneratingAttributeNames + = [ + RootCommandAttribute, + OptionAttribute, + FolderValidationAttribute, + FileValidationAttribute, + ]; + + internal static class CommonAttributeNamedArgs + { + internal const string Required = nameof(Required); + } + + internal static class RootCommandAttributeNamedArgs + { + internal const string Description = nameof(Description); + internal const string ShowHelpOnErrors = nameof(ShowHelpOnErrors); + internal const string ShowTypoCorrections = nameof(ShowTypoCorrections); + internal const string EnablePosixBundling = nameof(EnablePosixBundling); + internal const string DefaultOptions = nameof(DefaultOptions); + internal const string DefaultDirectives = nameof(DefaultDirectives); + } + + internal static class OptionAttributeNamedArgs + { + internal const string HelpName = nameof(HelpName); + internal const string Aliases = nameof(Aliases); + internal const string Description = nameof(Description); + internal const string Required = CommonAttributeNamedArgs.Required; + internal const string Hidden = nameof(Hidden); + internal const string ArityMin = nameof(ArityMin); + internal const string ArityMax = nameof(ArityMax); + internal const string DefaultValueFactoryName = nameof(DefaultValueFactoryName); + internal const string CustomParserName = nameof(CustomParserName); + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/GlobalNamespaceImports.cs b/src/Ubiquity.NET.CommandLine.SrcGen/GlobalNamespaceImports.cs new file mode 100644 index 0000000..12dfc85 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/GlobalNamespaceImports.cs @@ -0,0 +1,16 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +global using System; +global using System.CodeDom.Compiler; +global using System.Collections.Generic; +global using System.Diagnostics.CodeAnalysis; +global using System.Text; + +global using Microsoft.CodeAnalysis; +global using Microsoft.CodeAnalysis.CSharp.Syntax; +global using Microsoft.CodeAnalysis.Text; + +global using Ubiquity.NET.CodeAnalysis.Utils; +global using Ubiquity.NET.SrcGeneration; +global using Ubiquity.NET.SrcGeneration.CSharp; diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/OptionInfo.cs b/src/Ubiquity.NET.CommandLine.SrcGen/OptionInfo.cs new file mode 100644 index 0000000..dcabdb8 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/OptionInfo.cs @@ -0,0 +1,66 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Collections.Immutable; + +namespace Ubiquity.NET.CommandLine.SrcGen +{ + /// Wrapper for for an Option Attribute + /// + /// This is a simple wrapper around a managed reference. The size of this struct is no different + /// than the managed reference it wraps as that is the only field. This type supplies a validating + /// constructor and accessor to simplify access to the captured data. + /// + internal readonly record struct OptionInfo + { + public OptionInfo( EquatableAttributeData attributeInfo ) + { + // Runtime sanity check in debug mode + DebugAssert.StructSizeOK(); + + if( attributeInfo.Name != Constants.OptionAttribute) + { + throw new ArgumentException("Not an option attribute!"); + } + + AttributeInfo = attributeInfo; + } + + public bool IsValid => !string.IsNullOrWhiteSpace(Name); + + public string Name => AttributeInfo.ConstructorArguments.Length < 1 + ? string.Empty + : AttributeInfo.ConstructorArguments[0].Value as string ?? string.Empty; + + public Optional HelpName => AttributeInfo.GetNamedArgValue( Constants.OptionAttributeNamedArgs.HelpName ); + + public Optional> Aliases => AttributeInfo.GetNamedArgValueArray( Constants.OptionAttributeNamedArgs.Aliases ); + + public Optional Description => AttributeInfo.GetNamedArgValue( Constants.OptionAttributeNamedArgs.Description ); + + public Optional Required => AttributeInfo.GetNamedArgValue( Constants.OptionAttributeNamedArgs.Required ); + + public Optional Hidden => AttributeInfo.GetNamedArgValue( Constants.OptionAttributeNamedArgs.Hidden ); + + public Optional<(int Min, int Max)> Arity + { + get + { + // ignore argument if both aren't available + Optional min = AttributeInfo.GetNamedArgValue( Constants.OptionAttributeNamedArgs.ArityMin); + Optional max = AttributeInfo.GetNamedArgValue( Constants.OptionAttributeNamedArgs.ArityMax); + + return min.HasValue && max.HasValue + ? new((min.Value, max.Value)) + : default; + } + } + + private readonly EquatableAttributeData AttributeInfo; + + public static implicit operator OptionInfo(EquatableAttributeData attributeInfo) + { + return new(attributeInfo); + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/PackageReadMe.md b/src/Ubiquity.NET.CommandLine.SrcGen/PackageReadMe.md new file mode 100644 index 0000000..1fcc108 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/PackageReadMe.md @@ -0,0 +1,105 @@ +# Ubiquity.NET.CommandLine.SrcGen +This is the Roslyn Source generator for Ubiquity.NET.CommandLine. + +## Attributes Controlling generation +| Name | Description | +|------------------|-------------| +| RootCommandAttribute | Applied to a class to generate bindings for the root command | +| CommandAttribute | Applied to a sub-command class to indicate support for a sub-command | +| FileValidationAttribute | Applied `System.IO.FileInfo` properties to validate the input | +| FolderValidationAttribute | Applied `System.IO.DirectoryInfo` properties to validate the input | +| OptionAttribute | Applied to properties of a class marked with either 'CommandAttribute' or `RootCommandAttribute`] + +### Example + +CommandLineOptions.cs +``` C# +using System.IO; +using Ubiquity.NET.CommandLine.GeneratorAttributes; + +namespace TestNamespace; + +[RootCommand( Description = "Root command for tests" )] +internal partial class TestOptions +{ + [Option( "-o", Description = "Test SomePath" )] + [FolderValidation( FolderValidation.CreateIfNotExist )] + public required DirectoryInfo SomePath { get; init; } + + [Option( "-v", Description = "Verbosity Level" )] + public MsgLevel Verbosity { get; init; } = MsgLevel.Information; + + [Option( "-b", Description = "Test Some existing Path" )] + [FolderValidation( FolderValidation.ExistingOnly )] + public required DirectoryInfo SomeExistingPath { get; init; } + + [Option( "--thing1", Aliases = [ "-t" ], Required = true, Description = "Test Thing1", HelpName = "Help name for thing1" )] + public bool Thing1 { get; init; } + + // This should be ignored by generator + public string? NotAnOption { get; set; } + + [Option( "-a", Hidden = true, Required = false, ArityMin = 0, ArityMax = 3, Description = "Test SomeOtherPath" )] + [FileValidation( FileValidation.ExistingOnly )] + public required FileInfo SomeOtherPath { get; init; } +} +``` + +Program.cs +``` C# +using System; +using System.Threading; +using System.Threading.Tasks; + +using Ubiquity.NET.CommandLine; + +namespace TestNamespace +{ + public static class Program + { + public static async Task Main( string[] args ) + { + // pressing CTRL-C cancels the entire operation + using CancellationTokenSource cts = new(); + Console.CancelKeyPress += ( _, e ) => + { + e.Cancel = true; + cts.Cancel(); + }; + + // start with information level for parsing; parsed options might specify different level + var reporter = new ColoredConsoleReporter( MsgLevel.Information ); + + return await TestOptions.BuildRootCommand( ( options, ct ) => AppMainAsync( options, reporter, ct ) ) + .ParseAndInvokeResultAsync( reporter, cts.Token, args ); + } + + private static async Task AppMainAsync( + TestOptions options, + ColoredConsoleReporter reporter, + CancellationToken ct + ) + { + // Now that args are parsed, if a distinct verbosity level is specified use that + if(options.Verbosity != reporter.Level) + { + reporter = new ColoredConsoleReporter( options.Verbosity ); + } + + reporter.Verbose( "AppMainAsync" ); + + // Core application code here... + + // Use the cancellation token to indicate cancellation + // This is set when CTRL-C is pressed in Main() above. + ct.ThrowIfCancellationRequested(); + return 0; + } + } +} +``` + +--- +> [!NOTE] +> `CommandAttribute`, and sub-commands in general, is not ***currently*** supported. + diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/PropertyInfo.cs b/src/Ubiquity.NET.CommandLine.SrcGen/PropertyInfo.cs new file mode 100644 index 0000000..a2e363f --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/PropertyInfo.cs @@ -0,0 +1,96 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Collections; + +namespace Ubiquity.NET.CommandLine.SrcGen +{ + internal class PropertyInfo + : IEquatable + { + /// Initializes a new instance of the class. + /// Simple name of this property + /// Name of the type of this property + /// Map of Attributes for this property, keyed by the name of the attribute + public PropertyInfo( + string simpleName, + NamespaceQualifiedName typeName, + EquatableAttributeDataCollection attributes + ) + { + SimpleName = simpleName; + TypeName = typeName; + Attributes = attributes; + } + + /// Gets a value indicating whether this instance is default constructed + public bool IsDefault => SimpleName is null + && TypeName is null + && Attributes is null; + + /// Gets the simple name of this property + public string SimpleName { get; } + + /// Gets the namespace qualified name of the type of this property + public NamespaceQualifiedName TypeName { get; } + + /// Gets a dictionary of generating attributes for this property + /// + /// Ordinarily a generator pipeline doesn't capture this data and instead has a pipeline for each attribute. + /// However, in the case of generating the binder, it needs to generate different code if the contributing + /// property is "required". + /// + public EquatableAttributeDataCollection Attributes { get; } + + /// Gets a value indicating whether this property is required + /// + /// The concept "required" applies to the execution of the action for the command line. If a required + /// value is not provided (and no default is set), then an error is produced at the time of invocation. + /// + /// Each attribute is different but the "Required" named argument is designed for consistency across all. + /// Therefore, this will use a common name. + /// + /// + public bool IsRequired + { + get + { + foreach(var attr in Attributes) + { + var required = attr.GetNamedArgValue(Constants.CommonAttributeNamedArgs.Required); + if(required.HasValue && (bool)(required.Value.Value!)) + { + return true; + } + } + + // no attribute with the correct named arg set to "true" + return false; + } + } + + public bool Equals( PropertyInfo other ) + { + bool retVal = SimpleName == other.SimpleName + && TypeName == other.TypeName + && StructuralComparisons.StructuralEqualityComparer.Equals(Attributes, other.Attributes); + + return retVal; + } + + public override bool Equals( object obj ) + { + return obj is PropertyInfo other + && Equals( other ); + } + + public override int GetHashCode( ) + { + return HashCode.Combine( + SimpleName, + TypeName, + StructuralComparisons.StructuralEqualityComparer.GetHashCode(Attributes) + ); + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/RootCommandInfo.cs b/src/Ubiquity.NET.CommandLine.SrcGen/RootCommandInfo.cs new file mode 100644 index 0000000..ed49766 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/RootCommandInfo.cs @@ -0,0 +1,83 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Collections; +using System.Collections.Immutable; + +namespace Ubiquity.NET.CommandLine.SrcGen +{ + internal readonly struct RootCommandInfo + : IEquatable + { + public RootCommandInfo( + NamespaceQualifiedName targetName, + EquatableAttributeData attributeInfo, + ImmutableArray properties + ) + { + TargetName = targetName ?? throw new ArgumentNullException( nameof( targetName ) ); + AttributeInfo = attributeInfo; + Properties = properties; + } + + public NamespaceQualifiedName TargetName { get; } + + public EquatableAttributeData AttributeInfo { get; } + + public ImmutableArray Properties { get; } + + // This is used to simplify the code generation to limit + // to the default settings if not explicitly overridden. + public bool HasSettings + { + get + { + // If the attribute has any named args, beside "Description" it's a setting + // so as soon as one is found report settings are present. + foreach(string argName in AttributeInfo.NamedArguments.Keys) + { + if(argName != Constants.RootCommandAttributeNamedArgs.Description) + { + // Currently, all non-description args are settings + return true; + } + } + + // no named args or none that are not the description + return false; + } + } + + public Optional Description => AttributeInfo.GetNamedArgValue( Constants.RootCommandAttributeNamedArgs.Description ); + + public Optional ShowHelpOnErrors => AttributeInfo.GetNamedArgValue( Constants.RootCommandAttributeNamedArgs.ShowHelpOnErrors ); + + public Optional ShowTypoCorrections => AttributeInfo.GetNamedArgValue( Constants.RootCommandAttributeNamedArgs.ShowTypoCorrections ); + + public Optional EnablePosixBundling => AttributeInfo.GetNamedArgValue( Constants.RootCommandAttributeNamedArgs.EnablePosixBundling ); + + public Optional DefaultOptions => AttributeInfo.GetNamedArgValue( Constants.RootCommandAttributeNamedArgs.DefaultOptions ); + + public Optional DefaultDirectives => AttributeInfo.GetNamedArgValue( Constants.RootCommandAttributeNamedArgs.DefaultDirectives ); + + public bool Equals( RootCommandInfo other ) + { + bool retVal = TargetName.Equals(other.TargetName) + && AttributeInfo.Equals(other.AttributeInfo) + && StructuralComparisons.StructuralEqualityComparer.Equals(Properties, other.Properties); + + return retVal; + } + + public override bool Equals( object obj ) + { + return obj is RootCommandInfo other + && Equals( other ); + } + + public override int GetHashCode( ) + { + return HashCode.Combine(TargetName, AttributeInfo, Properties); + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/Templates/ISourceGenTemplate.cs b/src/Ubiquity.NET.CommandLine.SrcGen/Templates/ISourceGenTemplate.cs new file mode 100644 index 0000000..1eda713 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/Templates/ISourceGenTemplate.cs @@ -0,0 +1,25 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.SrcGen.Templates +{ + // Implementation Note: This is NOT in Ubiquity.NET.SrcGeneration to prevent the need for + // that to take a dependency on Mirosoft.CodeAnalyis just for the SourceText + // result. Though a version that returns a StringBuilder might be useful... + + /// Interface for a source generating template + /// + /// This simple interface defines the minimal requirements of a source generating + /// template. It is based on the common use of generated T4 wrapper classes and + /// intended for compatibility with those. (Though it uses + /// instead of `TransformText` and that method returns + /// instead of ) This aids in transitioning such implementations + /// to this new form of templating, especially for Roslyn Source generators. + /// + public interface ISourceGenTemplate + { + /// Transforms the input and properties for the template into a + /// Generated textual representation + SourceText GenerateText( ); + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/Templates/RootCommandClassTemplate.cs b/src/Ubiquity.NET.CommandLine.SrcGen/Templates/RootCommandClassTemplate.cs new file mode 100644 index 0000000..3704b30 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/Templates/RootCommandClassTemplate.cs @@ -0,0 +1,323 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; + +using Microsoft.CodeAnalysis.CSharp; + +namespace Ubiquity.NET.CommandLine.SrcGen.Templates +{ + internal class RootCommandClassTemplate + : ISourceGenTemplate + { + internal RootCommandClassTemplate( RootCommandInfo info ) + { + Info = info; + } + + public RootCommandInfo Info { get; } + + public ImmutableArray Properties => Info.Properties; + + public string ClassName => Info.TargetName.SimpleName; + + public string Namespace => Info.TargetName.Namespace; + + [SuppressMessage("Style", "IDE0063:Use simple 'using' statement", Justification = "Scoping intent is clearer without them")] + public SourceText GenerateText( ) + { + var srcTxt = new StringBuilderText(Encoding.UTF8); + using var simpleWriter = srcTxt.CreateWriter(); + using var writer = new IndentedTextWriter(simpleWriter); + + writer.WriteAutoGeneratedComment(ToolInfo.Name, ToolInfo.Version); + writer.WriteEmptyLine(); + + writer.WriteLine($"using static {Constants.SymbolValidationExtensions:G};"); + writer.WriteEmptyLine(); + + using (writer.Namespace(Namespace, writeClosingNewLine: true )) + { + writer.WriteLine("internal partial class {0}", ClassName); + using (var scope = writer.PushIndent()) + { + writer.WriteLine($": {Constants.IRootCommandBuilderWithSettings:G}"); + writer.WriteLine($", {Constants.ICommandBinder:G}<{ClassName}>"); + } + + using (var typeScope = writer.Scope(writeClosingNewLine: true)) + { + WriteSettingsProperty(writer); + writer.WriteEmptyLine(); + + WriteBindMethod(writer); + writer.WriteEmptyLine(); + + WriteBuildCommandMethod(writer); + } + + writer.WriteEmptyLine(); + WriteDescriptors( writer ); + } + + writer.Flush(); + return srcTxt; + } + + private void WriteBindMethod( IndentedTextWriter writer ) + { + using var bindMethodScope = writer.Scope($"public static {ClassName} Bind( {Constants.ParseResult:G} parseResult )", writeClosingNewLine: true); + using (var allocationScope = writer.Scope("return new()")) + { + foreach (PropertyInfo info in Properties) + { + string methodName = info.IsRequired ? "GetRequiredValue" : "GetValue"; + writer.WriteLine($"{info.SimpleName} = parseResult.{methodName}( Descriptors.{info.SimpleName} ),"); + } + } + + writer.WriteLine(";"); + } + + private void WriteBuildCommandMethod( IndentedTextWriter writer ) + { + string methodDecl = $"public static {Constants.AppControlledDefaultsRootCommand:G} Build( )"; + using var buildRootCommandScope = writer.Scope(methodDecl, writeClosingNewLine: true); + + string returnLine = Info.Description.HasValue + ? $"return new {Constants.AppControlledDefaultsRootCommand:G}( Settings, \"{Info.Description.Value}\" )" + : $"return new {Constants.AppControlledDefaultsRootCommand:G}( Settings )"; + + using (var allocationScope = writer.Scope(returnLine)) + { + foreach (PropertyInfo property in Properties) + { + writer.WriteLine($"Descriptors.{property.SimpleName},"); + } + } + + writer.WriteLine(";"); + } + + private void WriteSettingsProperty( IndentedTextWriter writer ) + { + writer.Write($"public static {Constants.CommandLineSettings:G} Settings => new()"); + if (Info.HasSettings) + { + writer.WriteLine(); + using var offset = writer.PushIndent(); + using var initScope = writer.Scope(writeClosingNewLine: false); + { + if (Info.ShowHelpOnErrors.HasValue) + { + writer.WriteLine("ShowHelpOnErrors = {0},", CSharpLanguage.AsLiteral( Info.ShowHelpOnErrors.Value )); + } + + if (Info.ShowTypoCorrections.HasValue) + { + writer.WriteLine("ShowTypoCorrections = {0},", CSharpLanguage.AsLiteral( Info.ShowTypoCorrections.Value )); + } + + if (Info.EnablePosixBundling.HasValue) + { + writer.WriteLine("EnablePosixBundling = {0},", CSharpLanguage.AsLiteral( Info.EnablePosixBundling.Value )); + } + + if (Info.DefaultOptions.HasValue) + { + writer.WriteLine("DefaultOptions = {0},", Info.DefaultOptions.Value); + } + + if (Info.DefaultDirectives.HasValue) + { + writer.WriteLine("DefaultDirectives = {0},", Info.DefaultDirectives.Value); + } + + // TODO: ResponseFileTokenReplacer + } + } + + writer.WriteLine(";"); + } + + private void WriteDescriptors( IndentedTextWriter writer ) + { + using var descriptorScope = writer.Class("file static", "Descriptors", writeClosingNewLine: true); + for (int i = 0; i < Properties.Length; ++i) + { + var property = Properties[i]; + if (property.Attributes.TryGetValue(Constants.OptionAttribute, out EquatableAttributeData? attr)) + { + WriteOptionDescriptor(writer, property, attr); + } + + // if not the last property generate an empty line between entries + if (i != Properties.Length - 1) + { + writer.WriteEmptyLine(); + } + } + } + + private static void WriteOptionDescriptor( IndentedTextWriter writer, PropertyInfo property, OptionInfo info ) + { + // NOP if the attribute does not contain valid data. + // Analyzer should detect that case and complain. + if (!info.IsValid) + { + Debug.WriteLine("!!Skipping Invalid OptionInfo!!"); + return; + } + + string propTypeName = $"{Constants.Option:G}<{property.TypeName:AG}>"; + writer.WriteLine($"internal static readonly {propTypeName} {property.SimpleName}"); + using (writer.PushIndent()) // property initializer indentation + { + // Aliases are only set via the constructor + // CONSIDER: Should this match behavior for the attribute? + // What does syntax look like for named parameters AND the params keyword... + writer.Write( $"= new {propTypeName}({CSharpLanguage.AsLiteral( info.Name )}" ); + var aliases = info.Aliases; + if(aliases.HasValue) + { + writer.Write( ", " ); + writer.Write( string.Join( ", ", aliases.Value.Select( CSharpLanguage.AsLiteral ) ) ); + } + + writer.WriteLine( ')' ); + using(var valueInitializerScope = writer.Scope()) + { + // This, intentionally, does not take into account any "default" values + // The source generator should remain ignorant of the default values for the + // generated source OR the attributes. If the setting is present the value is + // used, even if it's the same as a default. This keeps the generator independent + // of such details; allowing them to change without changing the generator! + var helpName = info.HelpName; + if(helpName.HasValue) + { + writer.WriteLine( "HelpName = {0},", CSharpLanguage.AsLiteral( helpName.Value ) ); + } + + var description = info.Description; + if(description.HasValue) + { + writer.WriteLine( "Description = {0},", CSharpLanguage.AsLiteral( description.Value ) ); + } + + var required = info.Required; + if(required.HasValue) + { + writer.WriteLine( "Required = {0},", CSharpLanguage.AsLiteral( required.Value ) ); + } + + var hidden = info.Hidden; + if(hidden.HasValue) + { + writer.WriteLine( "Hidden = {0},", CSharpLanguage.AsLiteral( hidden.Value ) ); + } + + var arity = info.Arity; + if(arity.HasValue) + { + // while there are static instance values for convenience + // there is no way to know the *OrMore variants as the + // max value used for them is a private const int. Besides, + // that the type is a struct and therefore use of those results + // in a copy anyway. Thus, this just emits construction of the + // correct type. + + (int min, int max) = arity.Value; + writer.WriteLine( $"Arity = new {Constants.ArgumentArity:G}({min}, {max})," ); + } + } + + // Handle the indentation for validation, it isn't a full indentation scope, it's just alignment + string extraSpace=string.Empty; + + // ANALYZER VALIDATION: These are mutually exclusive. + // Generator ignores the file validation attribute if the folder validation attribute + // is present. Analyzer should generate a diagnostic. + if(!WriteFolderValidation( writer, property, ref extraSpace )) + { + WriteFileValidation( writer, property, ref extraSpace ); + } + + // TODO: DefaultValueFactory + // TODO: Custom validators... + + writer.WriteLine( ";" ); + } + } + + private static bool WriteFolderValidation( IndentedTextWriter writer, PropertyInfo property, ref string extraSpace ) + { + if(!property.Attributes.TryGetValue( Constants.FolderValidationAttribute, out var folderValidationAttrib)) + { + return false; + } + + if((property.TypeName == Constants.DirectoryInfo) && !folderValidationAttrib.ConstructorArguments[ 0 ].IsNull) + { + int enumVal = (int)folderValidationAttrib.ConstructorArguments[ 0 ].Value!; + switch((FolderValidation)enumVal) + { + case FolderValidation.CreateIfNotExist: + if(extraSpace.Length > 0) + { + writer.WriteLine(); + } + + writer.Write( $"{extraSpace}.EnsureFolder()" ); + extraSpace = " "; + + break; + + case FolderValidation.ExistingOnly: + if(extraSpace.Length > 0) + { + writer.WriteLine(); + } + + writer.Write( $"{extraSpace}.AcceptExistingFolderOnly()" ); + extraSpace = " "; + break; + + case FolderValidation.None: + default: + break; + } + } + + return true; + } + + private static void WriteFileValidation( IndentedTextWriter writer, PropertyInfo property, ref string extraSpace ) + { + if(property.Attributes.TryGetValue( Constants.FileValidationAttribute, out var fileValidationAttrib ) + && (property.TypeName == Constants.FileInfo) + && !fileValidationAttrib.ConstructorArguments[ 0 ].IsNull + ) + { + int enumVal = (int)fileValidationAttrib.ConstructorArguments[ 0 ].Value!; + switch((FileValidation)enumVal) + { + case FileValidation.ExistingOnly: + if(extraSpace.Length > 0) + { + writer.WriteLine(); + } + + writer.Write( $"{extraSpace}.AcceptExistingFileOnly()" ); + extraSpace = " "; + break; + + case FileValidation.None: + default: + break; + } + } + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/Templates/ToolInfo.cs b/src/Ubiquity.NET.CommandLine.SrcGen/Templates/ToolInfo.cs new file mode 100644 index 0000000..6093819 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/Templates/ToolInfo.cs @@ -0,0 +1,14 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using Ubiquity.NET.Extensions; + +namespace Ubiquity.NET.CommandLine.SrcGen.Templates +{ + internal static class ToolInfo + { + public static string Name => ProcessInfo.ExecutableName; + + public static string Version => ProcessInfo.ActiveAssembly?.GetInformationalVersion() ?? ""; + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/TrackingNames.cs b/src/Ubiquity.NET.CommandLine.SrcGen/TrackingNames.cs new file mode 100644 index 0000000..26f3dec --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/TrackingNames.cs @@ -0,0 +1,13 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.SrcGen +{ + /// Tracking names used by the generators in this assembly + /// These are useful for testing and diagnostics + public static class TrackingNames + { + /// Tracking name of the class generation based off the CommandAttribute + public const string CommandClass = "CommandAttributeTracking"; + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/Ubiquity.NET.CommandLine.SrcGen.csproj b/src/Ubiquity.NET.CommandLine.SrcGen/Ubiquity.NET.CommandLine.SrcGen.csproj new file mode 100644 index 0000000..5cd96a9 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/Ubiquity.NET.CommandLine.SrcGen.csproj @@ -0,0 +1,99 @@ + + + + netstandard2.0 + + 12 + enable + true + false + true + True + + + true + 4.9.0 + .NET Foundation,Ubiquity.NET + PackageReadMe.md + false + Roslyn source generator for Ubiquity.NET.CommandLine + generator,.NET,Ubiquity.NET, Console + https://github.com/UbiquityDotNET/Ubiquity.NET.Utils + https://github.com/UbiquityDotNET/Ubiquity.NET.Utils.git + git + Apache-2.0 WITH LLVM-exception + + $(NoWarn);NU5128 + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + <__AssemblyPath>$(PkgMicrosoft_Bcl_HashCode)\lib\$(TargetFramework)\*.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ubiquity.NET.CommandLine/GeneratorAttributes/CommandAttribute.cs b/src/Ubiquity.NET.CommandLine/GeneratorAttributes/CommandAttribute.cs new file mode 100644 index 0000000..75c7eda --- /dev/null +++ b/src/Ubiquity.NET.CommandLine/GeneratorAttributes/CommandAttribute.cs @@ -0,0 +1,21 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.GeneratorAttributes +{ + /// Attribute to mark a command for generation of the backing implementation + [AttributeUsage( AttributeTargets.Class, Inherited = false, AllowMultiple = false )] + public sealed class CommandAttribute + : Attribute + { + /// Initializes a new instance of the class. + /// Description for the command (Default: string.Empty) + public CommandAttribute( string? description = null ) + { + Description = description ?? string.Empty; + } + + /// Gets the Description for this command + public string Description { get; } + } +} diff --git a/src/Ubiquity.NET.CommandLine/GeneratorAttributes/FileValidationAttribute.cs b/src/Ubiquity.NET.CommandLine/GeneratorAttributes/FileValidationAttribute.cs new file mode 100644 index 0000000..1ccbaf4 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine/GeneratorAttributes/FileValidationAttribute.cs @@ -0,0 +1,44 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.GeneratorAttributes +{ + /// Enumeration for folder validation + public enum FileValidation + { + /// No validation + None, + + /// Existing files only accepted. + /// + /// If a file specified does not exist then an exception results from the validation stage of + /// processing the command line. + /// + ExistingOnly, + } + + // Analyzer checks: Only used on a type that is FileInfo + // Only used on a type that has an OptionAttribute as well. + // + // Generator ignores this attribute if all analyzer checks are not valid + + /// Attribute to declare common validation for a property of type + [AttributeUsage( AttributeTargets.Property, Inherited = false, AllowMultiple = false )] + public sealed class FileValidationAttribute + : Attribute + { + /// Initializes a new instance of the class. + /// Validation to perform for this folder + /// + /// A that is is the same + /// as not specifying this attribute. + /// + public FileValidationAttribute( FileValidation validation ) + { + Validation = validation; + } + + /// Gets the to use for this + public FileValidation Validation { get; } + } +} diff --git a/src/Ubiquity.NET.CommandLine/GeneratorAttributes/FolderValidationAttribute.cs b/src/Ubiquity.NET.CommandLine/GeneratorAttributes/FolderValidationAttribute.cs new file mode 100644 index 0000000..be6a3a6 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine/GeneratorAttributes/FolderValidationAttribute.cs @@ -0,0 +1,47 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.GeneratorAttributes +{ + /// Enumeration for folder validation + public enum FolderValidation + { + /// No validation + None, + + /// Creates the folder if it doesn't exist + CreateIfNotExist, + + /// Existing folders only accepted. + /// + /// If a folder specified does not exist then an exception results from the validation stage of + /// processing the command line. + /// + ExistingOnly, + } + + // Analyzer checks: Only used on a type that is DirectoryInfo + // Only used on a type that has an OptionAttribute as well. + // + // Generator ignores this attribute if all analyzer checks are not valid + + /// Attribute to declare common validation for a property of type + [AttributeUsage( AttributeTargets.Property, Inherited = false, AllowMultiple = false )] + public sealed class FolderValidationAttribute + : Attribute + { + /// Initializes a new instance of the class. + /// Validation to perform for this folder + /// + /// A that is is the same + /// as not specifying this attribute. + /// + public FolderValidationAttribute( FolderValidation validation ) + { + Validation = validation; + } + + /// Gets the to use for this + public FolderValidation Validation { get; } + } +} diff --git a/src/Ubiquity.NET.CommandLine/GeneratorAttributes/OptionAttribute.cs b/src/Ubiquity.NET.CommandLine/GeneratorAttributes/OptionAttribute.cs new file mode 100644 index 0000000..441ca80 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine/GeneratorAttributes/OptionAttribute.cs @@ -0,0 +1,100 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.GeneratorAttributes +{ + /// Attribute to apply to properties of a command that indicates generation of implementation + /// + [AttributeUsage( AttributeTargets.Property, Inherited = false, AllowMultiple = false )] + public sealed class OptionAttribute + : Attribute + { + /// Initializes a new instance of the class. + /// Name of the option + public OptionAttribute( string name ) + { + ArgumentException.ThrowIfNullOrWhiteSpace( name ); + Name = name; + } + + /// Gets the name of the option (Including any "prefix" for the switch character) + public string Name { get; } + + /// Gets the help name for this option + /// + public string? HelpName { get; init; } + + /* + ANALYZER VALIDATION: + var bldr = ImmutableArray.CreateBuilder(value.Length); + for(int i = 0; i < value.Length; ++i) + { + if(string.IsNullOrWhiteSpace(value[i])) + { + bldr.Add(i); + } + } + + if(bldr.Count > 0) + { + var badIndexArray = bldr.ToImmutable(); + throw new ArgumentException($"Aliases cannot be null or whitespace. Invalid indexes: {badIndexArray.Format()}", nameof(value)); + } + */ + + /// Gets the aliases for this option + public string[] Aliases { get; init; } = []; + + /// Gets the description for this option + public string? Description { get; init; } + + /// Gets a value indicating whether this option is required for execution of the command + /// + /// Options that are required and not provided by the user AND don't have a default are reported as an error. + /// That is, the "required" concept applies to the execution of the command and ***not*** the parsing of data. + /// + public bool Required { get; init; } + + /// Gets a value indicating whether this option is hidden in help information + /// + /// This is often used for internal debugging/diagnostics and potentially preview functionality. + /// + /// + public bool Hidden { get; init; } + + // ANALYZER VERIFY: ArityMin > = 0 + // ArityMin < = ArgumentArity.MaximumArity [Sadly, an internal const] + // BOTH values are set or NONE is set. + // GENERATOR: ignore if not valid + + /// Gets the minimum arity + /// + /// Instances of are not allowed as const args for an attribute. + /// Thus, this pair of properties represents the arity of an option. If not specified the default depends + /// on the type as defined by the underlying command line system. + /// + /// + /// + public int ArityMin { get; init; } + + /// Gets the maximum arity + /// + public int ArityMax { get; init; } + + // ANALYZER check: name refers to a Func + // where T is the type of the property + // GENERATOR: Ignore if analyzer validity checks fail + + /// Gets the default value factory for the option + /// If no value is provided via parsing the command line, this is called to produce the default + public string? DefaultValueFactoryName { get; init; } + + // ANALYZER check: name refers to a Func + // where T is the type of the property + // GENERATOR: Ignore if analyzer validity checks fail + + /// Gets a Custom parser for the option + /// If provided, this parser is called to convert the argument to a value + public string? CustomParserName { get; init; } + } +} diff --git a/src/Ubiquity.NET.CommandLine/GeneratorAttributes/RootCommandAttribute.cs b/src/Ubiquity.NET.CommandLine/GeneratorAttributes/RootCommandAttribute.cs new file mode 100644 index 0000000..f473841 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine/GeneratorAttributes/RootCommandAttribute.cs @@ -0,0 +1,44 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.GeneratorAttributes +{ + /// Attribute to mark a command for generation of the backing implementation + [AttributeUsage( AttributeTargets.Class, Inherited = false, AllowMultiple = false )] + public sealed class RootCommandAttribute + : Attribute + { + /// Initializes a new instance of the class. + public RootCommandAttribute( ) + { + } + + /// Gets or sets the Description for this command (Default: null) + public string? Description { get; set; } = null; + + /// Gets a value indicating whether errors reported should also show the help message [Default: ] + /// This has a default, which is the opposite of the default from + public bool ShowHelpOnErrors { get; init; } = false; + + /// Gets a value indicating whether errors reported should also show Typo corrections [Default: ] + /// This has a default, which is the opposite of the default from + public bool ShowTypoCorrections { get; init; } = false; + + /// + public bool EnablePosixBundling { get; init; } = true; + + /// Gets a value that indicates the default options to include + /// + /// Default handling includes and . + /// This allows overriding that to specify behavior as desired. + /// + public DefaultOption DefaultOptions { get; init; } = DefaultOption.Help | DefaultOption.Version; + + /// Gets a value that indicates the default Directives to include + /// + /// Default handling includes . + /// This allows overriding that to specify behavior as needed. + /// + public DefaultDirective DefaultDirectives { get; init; } = DefaultDirective.Suggest; + } +} diff --git a/src/Ubiquity.NET.CommandLine/Ubiquity.NET.CommandLine.csproj b/src/Ubiquity.NET.CommandLine/Ubiquity.NET.CommandLine.csproj index 0d9cd97..1290f49 100644 --- a/src/Ubiquity.NET.CommandLine/Ubiquity.NET.CommandLine.csproj +++ b/src/Ubiquity.NET.CommandLine/Ubiquity.NET.CommandLine.csproj @@ -4,8 +4,11 @@ net10.0;net8.0 enable True + True + + Ubiquity.NET.CommandLine.Lib true 4.9.0 .NET Foundation,Ubiquity.NET @@ -28,10 +31,13 @@ - + + + + diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs index 3ce29de..f668f60 100644 --- a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs @@ -34,7 +34,8 @@ file enum ResourceId // The actual strings used are the same as the values in the official runtime // support so are at least compatible for "en-us". This fakes it to make it // more readable AND make it easier to shift if a means of injecting resources - // is found. + // is found. Also until such a mechanism is found this suppresses the warnings + // (RS1035) about use of banned APIs when used by a Roslyn component. file static class ResourceIdExtensions { internal static string GetResourceString(this ResourceId id) @@ -188,6 +189,7 @@ public static void ThrowIfLessThanOrEqual( T value, T other, [global::System. } [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + [global::System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification="Poly Fill extension API")] private static void ThrowZero( T value, string? paramName ) { string msg = string.Format( global::System.Globalization.CultureInfo.CurrentCulture @@ -200,6 +202,7 @@ private static void ThrowZero( T value, string? paramName ) } [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( "MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification = "Poly Fill extension API" )] private static void ThrowNegative( T value, string? paramName ) { string msg = string.Format( global::System.Globalization.CultureInfo.CurrentCulture @@ -212,6 +215,7 @@ private static void ThrowNegative( T value, string? paramName ) } [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( "MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification = "Poly Fill extension API" )] private static void ThrowNegativeOrZero( T value, string? paramName ) { string msg = string.Format( global::System.Globalization.CultureInfo.CurrentCulture @@ -224,6 +228,7 @@ private static void ThrowNegativeOrZero( T value, string? paramName ) } [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( "MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification = "Poly Fill extension API" )] private static void ThrowGreater( T value, T other, string? paramName ) { var msg = string.Format( @@ -238,6 +243,7 @@ private static void ThrowGreater( T value, T other, string? paramName ) } [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( "MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification = "Poly Fill extension API" )] private static void ThrowGreaterEqual( T value, T other, string? paramName ) { @@ -253,6 +259,7 @@ private static void ThrowGreaterEqual( T value, T other, string? paramName ) } [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( "MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification = "Poly Fill extension API" )] private static void ThrowLess( T value, T other, string? paramName ) { var msg = string.Format( @@ -267,6 +274,7 @@ private static void ThrowLess( T value, T other, string? paramName ) } [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( "MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification = "Poly Fill extension API" )] private static void ThrowLessEqual( T value, T other, string? paramName ) { var msg = string.Format( @@ -281,6 +289,7 @@ private static void ThrowLessEqual( T value, T other, string? paramName ) } [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( "MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification = "Poly Fill extension API" )] private static void ThrowEqual( T value, T other, string? paramName ) { var msg = string.Format( @@ -295,6 +304,7 @@ private static void ThrowEqual( T value, T other, string? paramName ) } [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( "MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification = "Poly Fill extension API" )] private static void ThrowNotEqual( T value, T other, string? paramName ) { var msg = string.Format( diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillOperatingSystem.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillOperatingSystem.cs index ea19790..1343108 100644 --- a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillOperatingSystem.cs +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillOperatingSystem.cs @@ -16,6 +16,7 @@ internal static class PolyFillOperatingSystem #if !NET5_0_OR_GREATER /// Indicates whether the current application is running on Windows. /// if the current application is running on Windows; otherwise. + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( "MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification = "Poly Fill extension API" )] public static bool IsWindows() { return global::System.Environment.OSVersion.Platform switch diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs index 0a4cfbe..5ffb0b8 100644 --- a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs @@ -49,6 +49,13 @@ public static int GetHashCode( this string self, StringComparison comparisonType /// Replace line endings in the string with environment specific forms /// string to change line endings for /// string with environment specific line endings + /// + /// This API will explicitly replace line endings using the Runtime newline format. In most cases that is + /// what is desired. However, when generating files or content consumed by something other than the + /// current runtime it is usually not what is desired. In such a case the more explicit + /// is used to specify the precise line ending form to use. (Or Better yet, use Ubiquity.NET.Extensions.StringNormalizer.NormalizeLineEndings(string?, LineEndingKind)) /> + /// + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( "MicrosoftCodeAnalysis", "RS1035:Banned Symbol", Justification = "This form explicitly uses the runtime form" )] public static string ReplaceLineEndings( this string self ) { return ReplaceLineEndings( self, global::System.Environment.NewLine ); diff --git a/src/Ubiquity.NET.Utils.slnx b/src/Ubiquity.NET.Utils.slnx index 1f05e06..8e264a8 100644 --- a/src/Ubiquity.NET.Utils.slnx +++ b/src/Ubiquity.NET.Utils.slnx @@ -31,6 +31,7 @@ + @@ -41,6 +42,8 @@ + + From 699c64dc1630949720407dada5b9b2afafaf55ef Mon Sep 17 00:00:00 2001 From: Steven Maillet Date: Sun, 11 Jan 2026 08:44:28 -0800 Subject: [PATCH 2/4] * Adapted tests to copylocal the required dependency for the compilation. --- .../Ubiquity.NET.CommandLine.SrcGen.UT.csproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj b/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj index aa0478d..0df5532 100644 --- a/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj @@ -25,7 +25,9 @@ Project reference with `ReferenceOutputAssembly="false"` ensures build dependency but doesn't reference the output. A `None` item is still needed to get a copy of the library in the output folder though... --> - + + True + From a1fbffc8c9d8d0a3d325d29e91c9d475840be1b9 Mon Sep 17 00:00:00 2001 From: Steven Maillet Date: Sun, 11 Jan 2026 09:04:27 -0800 Subject: [PATCH 3/4] * Added logging of directory contents to help determine why it can't find the file... - Automated builds are EXTREMELY difficult to debug when everything works fine locally.. --- Invoke-Tests.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/Invoke-Tests.ps1 b/Invoke-Tests.ps1 index 299769e..0890e9b 100644 --- a/Invoke-Tests.ps1 +++ b/Invoke-Tests.ps1 @@ -38,6 +38,7 @@ try Push-Location $BuildInfo['SrcRootPath'] try { + dir (Join-Path $BuildInfo['BuildOutputPath'] 'bin' 'Ubiquity.NET.CommandLine.SrcGen.UT' "$Configuration" 'net10.0' 'Ubiquity.NET.CommandLine.*') Invoke-External dotnet test Ubiquity.NET.Utils.slnx '-c' $Configuration '-tl:off' '--logger:trx' '--no-build' '-s' '.\x64.runsettings' } finally From a847ad51cbba9f78c253910507d1b869ca370f78 Mon Sep 17 00:00:00 2001 From: Steven Maillet Date: Sun, 11 Jan 2026 09:18:17 -0800 Subject: [PATCH 4/4] * Added a task to force the test dependency copy - Apparently the copylocal and none options don't work on the automated build for reasons unknown... --- .../Ubiquity.NET.CommandLine.SrcGen.UT.csproj | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj b/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj index 0df5532..1cb51da 100644 --- a/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj @@ -25,16 +25,7 @@ Project reference with `ReferenceOutputAssembly="false"` ensures build dependency but doesn't reference the output. A `None` item is still needed to get a copy of the library in the output folder though... --> - - True - - - - - - - PreserveNewest - + @@ -46,4 +37,12 @@ + + + + +