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/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 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..1cb51da --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj @@ -0,0 +1,48 @@ + + + + net10.0 + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + +