diff --git a/README.md b/README.md index 981da8f..ef6187f 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ using MinimalCli; public class HelloWorld { - // Just decorate the method with the command attribute! - [Handler("hello")] + // Just decorate the method with the RootHandler attribute! + [RootHandler] public void Execute(string message) { Console.WriteLine("Hello World! {0}", message); @@ -74,6 +74,7 @@ using MinimalCli; public class MyCommand { + // use the handler attribute to add additional commands [Handler("my-command")] public void Run(string myArgument, string? myOption = null) { diff --git a/src/HelloWorld/HelloWorldClass.cs b/src/HelloWorld/HelloWorldClass.cs index 4aa4945..6d2c83f 100644 --- a/src/HelloWorld/HelloWorldClass.cs +++ b/src/HelloWorld/HelloWorldClass.cs @@ -4,7 +4,7 @@ namespace Hello; public class HelloWorldClass { - [Handler("hello-world")] + [RootHandler] public void Execute( string message, string option1 = "opt 1 default value", diff --git a/src/HelloWorld/Properties/launchSettings.json b/src/HelloWorld/Properties/launchSettings.json index 369c655..bc2f766 100644 --- a/src/HelloWorld/Properties/launchSettings.json +++ b/src/HelloWorld/Properties/launchSettings.json @@ -3,9 +3,8 @@ "HelloWorld": { "commandName": "Project", //"commandLineArgs": "-h" - "commandLineArgs": "hi -h" - //"commandLineArgs": "hi \"Hello everybody..\" --option1 \"My Option 1\" --option2 \"My Option 2\"" - + "commandLineArgs": "\"Hello everybody..\"" + //"commandLineArgs": "\"Hello everybody..\" --option1 \"My Option 1\" --option2 \"My Option 2\"" } } } \ No newline at end of file diff --git a/src/MinimalCli.Core/Bindings/CommandBindingFactory.cs b/src/MinimalCli.Core/Bindings/CommandBindingFactory.cs index ca50724..e868964 100644 --- a/src/MinimalCli.Core/Bindings/CommandBindingFactory.cs +++ b/src/MinimalCli.Core/Bindings/CommandBindingFactory.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace MinimalCli.Bindings; +namespace MinimalCli.Bindings; public class CommandBindingFactory { @@ -11,6 +9,11 @@ public CommandBindingFactory() this.options = []; } + public void AddRootCommand(CommandOptions rootOptions) + { + this.options.Add(rootOptions.Command.Name, rootOptions); + } + public void AddCommandOptions(string commandName, CommandOptions options) { this.options.Add(commandName, options); diff --git a/src/MinimalCli.Core/MinimalCommandLineApp.cs b/src/MinimalCli.Core/MinimalCommandLineApp.cs index 7575c82..63ca4fb 100644 --- a/src/MinimalCli.Core/MinimalCommandLineApp.cs +++ b/src/MinimalCli.Core/MinimalCommandLineApp.cs @@ -1,8 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using System.Collections.Generic; -using MinimalCli.Bindings; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -13,15 +11,13 @@ public class MinimalCommandLineApp : IHostedService { private readonly string[] args; private readonly CommandExecutionMode cmdExecutionMode; - private readonly IReadOnlyList commandOptionsCollection; private CommandExecutorCli CliCommandExecutor => this.Services.GetRequiredService(); private CommandExecutorShell ShellCommandExecutor => this.Services.GetRequiredService(); - internal MinimalCommandLineApp(MinimalCommandLineBuilder builder, IReadOnlyList commandOptionsCollection, string[] args) + internal MinimalCommandLineApp(MinimalCommandLineBuilder builder, string[] args) { this.Host = builder.builder.Build(); - this.commandOptionsCollection = commandOptionsCollection; this.cmdExecutionMode = builder.cmdExecutionMode; this.Configuration = builder.Configuration; this.args = args; diff --git a/src/MinimalCli.Core/MinimalCommandLineBuilder.cs b/src/MinimalCli.Core/MinimalCommandLineBuilder.cs index 26a3775..166ff78 100644 --- a/src/MinimalCli.Core/MinimalCommandLineBuilder.cs +++ b/src/MinimalCli.Core/MinimalCommandLineBuilder.cs @@ -3,8 +3,8 @@ using Microsoft.Extensions.Diagnostics.Metrics; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using System.Collections.Generic; using MinimalCli.Bindings; +using System.Linq; namespace MinimalCli; @@ -47,7 +47,7 @@ public TOptions TryRegisterCommandOptions() return newOptions; } - internal RootCommand RootCommand { get; } = new(); + internal RootCommand? RootCommand { get; private set; } public MinimalCommandLineApp Build() { // add required services @@ -58,6 +58,19 @@ public MinimalCommandLineApp Build() CommandBindingFactory cmdBindingFactory = new(); this.Services.AddSingleton(cmdBindingFactory); + // check if there was a generated root command + var rootOptions = commandOptionsCollection.FirstOrDefault(opt => opt.Command is RootCommand); + if (rootOptions is not null) + { + this.RootCommand = (RootCommand)rootOptions.Command; + cmdBindingFactory.AddRootCommand(rootOptions); + commandOptionsCollection.Remove(rootOptions); + ParameterBinding[] bindings = rootOptions.SetupCommandParameterBindings(); + } + else + { + this.RootCommand = new(); + } // NOTES: going to have to create a factory to get the correct instance of the CommandOptions // for the command that was invoked, inside the Handler(serivces) function, can call // var factory = services.GetRequiredService(); @@ -107,7 +120,7 @@ public MinimalCommandLineApp Build() } // pass in args from builder - MinimalCommandLineApp app = new(this, this.commandOptionsCollection, this.args); + MinimalCommandLineApp app = new(this, this.args); return app; } diff --git a/src/MinimalCli.Core/RootHandlerAttribute.cs b/src/MinimalCli.Core/RootHandlerAttribute.cs new file mode 100644 index 0000000..9af431f --- /dev/null +++ b/src/MinimalCli.Core/RootHandlerAttribute.cs @@ -0,0 +1,10 @@ +namespace MinimalCli; + +/// +/// Designates this method as the root handler for the command. This will become the default command if no other command matches. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class RootHandlerAttribute : Attribute +{ + public RootHandlerAttribute() { } +} diff --git a/src/MinimalCli.SourceGenerator/ArgumentBinding.cs b/src/MinimalCli.SourceGenerator/ArgumentBinding.cs new file mode 100644 index 0000000..2a26304 --- /dev/null +++ b/src/MinimalCli.SourceGenerator/ArgumentBinding.cs @@ -0,0 +1,13 @@ +using MinimalCli.SourceGeneration.Conventions; + +namespace MinimalCli.SourceGeneration; + +internal record ArgumentBinding(string OriginalParameterName, string Type, string? DefaultValueConstant, bool IsCollectionType) + : ParameterBinding(OriginalParameterName, Type, DefaultValueConstant, IsCollectionType) +{ + /// + /// The Argument name in title case, e.g. "myParameterName" becomes "My Parameter Name" + /// + public string ConventionalArgumentName + => ParameterNameConversion.ToArgumentName(this.OriginalParameterName); +} diff --git a/src/MinimalCli.SourceGenerator/CommandOptionsWriter.cs b/src/MinimalCli.SourceGenerator/CommandOptionsWriter.cs index b3a488b..5290a64 100644 --- a/src/MinimalCli.SourceGenerator/CommandOptionsWriter.cs +++ b/src/MinimalCli.SourceGenerator/CommandOptionsWriter.cs @@ -6,6 +6,217 @@ namespace MinimalCli.SourceGeneration; internal static class CommandOptionsWriter { + internal static string? GenerateRootCommandOptions(GeneratingRootCommandBinder binder) + { + if (binder.Bindings is not null) + { + StringBuilder sb = new(); + sb.AppendLine( + """ + using System; + using System.CommandLine; + using System.CommandLine.Invocation; + using MinimalCli.Bindings; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.DependencyInjection.Extensions; + + namespace MinimalCli + { + """ + ); + sb.AppendLine(); + + sb.AppendLine($" // Root command, handler: {binder.FullMethodName}"); + + // an extension onto builder that allows the developer to configure the options + + #region public static ConfigureCommandExtensions + sb.AppendLine( + $$""" + public static class ConfigureRootCommandBuilderExtensions + { + /// Configure any additional options on your root command. + public static MinimalCommandLineBuilder MapRootCommand(this MinimalCommandLineBuilder builder, Action<{{binder.CommandOptionsName}}> configure) + { + // register 'Root' Command + """ + ); + // do not add the class to services if it's static + if (!binder.MethodIsStatic) + sb.AppendLine($" builder.Services.TryAddTransient<{binder.FullClassName}>();"); + sb.AppendLine( + $$""" + {{binder.CommandOptionsName}} cliOptions = builder.TryRegisterCommandOptions<{{binder.CommandOptionsName}}>(); + + // apply developer's configuration changes + configure(cliOptions); + + return builder; + } + } + """ + ); + #endregion + + // Use this generated class to add the conventional Command, Arguments, and Options for the class + + // iterate once through the bindings and create 3 stringbuilders + StringBuilder writePublicPropertiesSb = new(); + StringBuilder linkCommandToSymbolsSb = new(); + StringBuilder createParametersSb = new(); + string[] parameterNames = new string[binder.Bindings.Value.Length]; + // a count of parameter bindings that are arguments or options but not fromServices bindings. + int validCount = 0; + for (int i = 0; i < binder.Bindings.Value.Length; i++) + { + ParameterBinding param = binder.Bindings.Value[i]; + parameterNames[i] = param.OriginalParameterName; + if (param is ArgumentBinding arg) + { + // public property for Argument + writePublicPropertiesSb.Append($" public Argument<{arg.Type}> {arg.NameTitleCase}Argument {{ get; }} = new Argument<{arg.Type}>(\"{arg.HelpName}\")"); + WriteDefaultValueFactory(writePublicPropertiesSb, arg); + + // link together the Command and the Arguments + linkCommandToSymbolsSb.AppendLine( + $$""" + this.Command.Arguments.Add(this.{{arg.NameTitleCase}}Argument); + bindings[{{validCount++}}] = new ArgumentBinding( + ParameterName: "{{arg.OriginalParameterName}}", + ParameterType: typeof({{arg.Type}}), + Argument: this.{{arg.NameTitleCase}}Argument + ); + + """ + ); + // instantiate argument parameter + createParametersSb.AppendLine($" {arg.Type} {arg.OriginalParameterName} = parseResult.GetValue(this.{arg.NameTitleCase}Argument);"); + } + else if (param is OptionBinding opt) + { + // public property for option + writePublicPropertiesSb.Append($" public Option<{opt.Type}> {opt.NameTitleCase}Option {{ get; }} = new Option<{opt.Type}>(\"{opt.OptionName}\")"); + WriteDefaultValueFactory(writePublicPropertiesSb, opt); + + // link together the Command and the Options + linkCommandToSymbolsSb.AppendLine( + $$""" + this.Command.Options.Add(this.{{opt.NameTitleCase}}Option); + bindings[{{validCount++}}] = new OptionBinding( + ParameterName: "{{opt.OriginalParameterName}}", + ParameterType: typeof({{opt.Type}}), + Option: this.{{opt.NameTitleCase}}Option + ); + + """ + ); + // instantiate options parameter + createParametersSb.AppendLine($" {opt.Type} {opt.OriginalParameterName} = parseResult.GetValue(this.{opt.NameTitleCase}Option);"); + } + else if (param is FromServicesBinding svcs) + { + // get FromServices parameter + createParametersSb.AppendLine($" {svcs.Type} {svcs.OriginalParameterName} = services.GetRequiredService<{svcs.Type}>();"); + } + } + + #region implement CommandOptions class + sb.AppendLine($" public sealed class " + binder.CommandOptionsName + " : CommandOptions"); + sb.AppendLine(" {"); + // add property accessor for the actual command + sb.AppendLine($" public override Command Command {{ get; }} = new RootCommand();"); + sb.AppendLine(); + // *** public Command, Argument, and Option properties (see above additions to writePublicPropertiesSb) + sb.AppendLine(writePublicPropertiesSb.ToString()); + sb.AppendLine(); + sb.AppendLine(" public override ParameterBinding[] SetupCommandParameterBindings()"); + sb.AppendLine(" {"); + sb.AppendLine($" ParameterBinding[] bindings = new ParameterBinding[{validCount}];\r\n"); + // *** The bindings array (see above additions to linkCommandToSymbolsSb) + sb.AppendLine(linkCommandToSymbolsSb.ToString()); + sb.AppendLine(" return bindings;"); + sb.AppendLine(" }"); + + sb.AppendLine(); + + // override Handler + { + // create a Handler that takes in a (ParseResult parseResult) here + sb.AppendLine(" public override Func> Handler(IServiceProvider services)"); + sb.AppendLine(" {"); + // return function + { + sb.AppendLine($" return {(binder.MethodReturnType.Contains("System.Threading.Task") ? "async " : "")}(ParseResult parseResult, CancellationToken cancellationToken) => "); + sb.AppendLine(" {"); + // if method is a static method + string methodQualifier; + if (binder.MethodIsStatic) + { + // if static qualify with class name + methodQualifier = binder.FullClassName; + } + else + { + // if instance method, qualify with 'commandService' + methodQualifier = "commandService"; + // get command service from dependency injections + sb.AppendLine($" var commandService = services.GetRequiredService<{binder.FullClassName}>();"); + } + + // *** The parameter creation fro command (see above additions to createParametersSb) + sb.AppendLine(createParametersSb.ToString()); + + string parameterList = string.Join(",\r\n", parameterNames.Select(str => " " + str)); + if (binder.MethodReturnType == "void") + { + sb.AppendLine($" {methodQualifier}.{binder.MethodName}("); + sb.AppendLine(parameterList); + sb.AppendLine(" );"); + sb.AppendLine(" return Task.FromResult(0);"); + } + else if (binder.MethodReturnType == "int") + { + sb.AppendLine(" return Task.FromResult("); + sb.AppendLine($" {methodQualifier}.{binder.MethodName}("); + sb.AppendLine(parameterList); + sb.AppendLine(" )"); + sb.AppendLine(" );"); + } + else if (binder.MethodReturnType.EndsWith("System.Threading.Tasks.Task")) + { + sb.AppendLine($" await {methodQualifier}.{binder.MethodName}("); + sb.AppendLine(parameterList); + sb.AppendLine(" );"); + sb.AppendLine(" return 0;"); + } + else if (binder.MethodReturnType.EndsWith("System.Threading.Tasks.Task")) + { + sb.AppendLine($" return await {methodQualifier}.{binder.MethodName}("); + sb.AppendLine(parameterList); + sb.AppendLine(" );"); + } + // end return function + sb.AppendLine(" };"); + } + + // end override Handler + sb.AppendLine(" }"); + } + + sb.AppendLine(); + // end CommandOptions class + sb.AppendLine(" }"); + sb.AppendLine(); + #endregion + + // end namespace MinimalCli + sb.AppendLine("}"); + + return sb.ToString(); + } + + return null; + } /// /// Generates the CommandOptions class. /// @@ -30,7 +241,7 @@ namespace MinimalCli sb.AppendLine(); sb.AppendLine($" // \"{binder.CommandName}\" command, handler: {binder.FullMethodName}"); - if(binder.CommandName is not null) + if (binder.CommandName is not null) { // an extension onto builder that allows the developer to configure the options @@ -75,7 +286,7 @@ public static class Configure{{binder.CommandNameTitleCase}}BuilderExtensions { ParameterBinding param = binder.Bindings.Value[i]; parameterNames[i] = param.OriginalParameterName; - if(param is ArgumentBinding arg) + if (param is ArgumentBinding arg) { // public property for Argument writePublicPropertiesSb.Append($" public Argument<{arg.Type}> {arg.NameTitleCase}Argument {{ get; }} = new Argument<{arg.Type}>(\"{arg.HelpName}\")"); @@ -96,7 +307,7 @@ public static class Configure{{binder.CommandNameTitleCase}}BuilderExtensions // instantiate argument parameter createParametersSb.AppendLine($" {arg.Type} {arg.OriginalParameterName} = parseResult.GetValue(this.{arg.NameTitleCase}Argument);"); } - else if(param is OptionBinding opt) + else if (param is OptionBinding opt) { // public property for option writePublicPropertiesSb.Append($" public Option<{opt.Type}> {opt.NameTitleCase}Option {{ get; }} = new Option<{opt.Type}>(\"{opt.OptionName}\")"); @@ -117,7 +328,7 @@ public static class Configure{{binder.CommandNameTitleCase}}BuilderExtensions // instantiate options parameter createParametersSb.AppendLine($" {opt.Type} {opt.OriginalParameterName} = parseResult.GetValue(this.{opt.NameTitleCase}Option);"); } - else if(param is FromServicesBinding svcs) + else if (param is FromServicesBinding svcs) { // get FromServices parameter createParametersSb.AppendLine($" {svcs.Type} {svcs.OriginalParameterName} = services.GetRequiredService<{svcs.Type}>();"); @@ -154,7 +365,7 @@ public static class Configure{{binder.CommandNameTitleCase}}BuilderExtensions sb.AppendLine(" {"); // if method is a static method string methodQualifier; - if(binder.MethodIsStatic) + if (binder.MethodIsStatic) { // if static qualify with class name methodQualifier = binder.FullClassName; diff --git a/src/MinimalCli.SourceGenerator/CommandSourceGenerator.cs b/src/MinimalCli.SourceGenerator/CommandSourceGenerator.cs index d83631b..b918708 100644 --- a/src/MinimalCli.SourceGenerator/CommandSourceGenerator.cs +++ b/src/MinimalCli.SourceGenerator/CommandSourceGenerator.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using MinimalCli.SourceGenerator; using System.Threading; +using System.Linq; namespace MinimalCli.SourceGeneration; @@ -16,18 +17,31 @@ public void Initialize(IncrementalGeneratorInitializationContext context) //context.RegisterPostInitializationOutput(GenerateMainFunctionCode); // attributes - IncrementalValueProvider> bindersProvider = context.SyntaxProvider + IncrementalValueProvider> rootHandlerProvider = context.SyntaxProvider + .ForAttributeWithMetadataName( + "MinimalCli.RootHandlerAttribute", + predicate: MethodDeclPredicate, + transform: GeneratorBindingsProvider.TransformForRoot + ).Collect(); + + IncrementalValueProvider> handlersProvider = context.SyntaxProvider .ForAttributeWithMetadataName( "MinimalCli.HandlerAttribute", predicate: MethodDeclPredicate, transform: GeneratorBindingsProvider.Transform ).Collect(); - context.RegisterSourceOutput(bindersProvider, (spc, binders) => { + IncrementalValueProvider<( + ImmutableArray RootBinders, + ImmutableArray CommandBinders + )> combined = rootHandlerProvider.Combine(handlersProvider); + + // register output for commands + context.RegisterSourceOutput(combined, static (spc, binders) => { HashSet commandNames = new(); // first generate and create all of the CommandOptions classes - foreach (GeneratingCommandBinder? binder in binders) + foreach (GeneratingCommandBinder? binder in binders.CommandBinders) { if(binder.CommandName is null || string.IsNullOrWhiteSpace(binder.CommandName)) { @@ -49,16 +63,35 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.AddSource($"{binder.ClassName}_{binder.MethodName}_Command.g.cs", code); } } - + + if(binders.RootBinders.Length > 1) + { + // should only be 1 root handler + foreach(var root in binders.RootBinders) + spc.ReportTooManyRootHandlersError(root.CommandNameLocation); + } + else + { + GeneratingRootCommandBinder? rootHandler = binders.RootBinders.FirstOrDefault(); + if(rootHandler is not null) + { + // generate root command options + string? rootCode = CommandOptionsWriter.GenerateRootCommandOptions(rootHandler); + if (rootCode is not null) + { + spc.AddSource("RootCommand.g.cs", rootCode); + } + } + } + // Emit the aggregated Register method - string? registryCode = MapAllCommandsExtensionWriter.GenerateMapAllCommandsExt(binders); + string? registryCode = MapAllCommandsExtensionWriter.GenerateMapAllCommandsExt(binders.CommandBinders, binders.RootBinders); if(registryCode is not null) { spc.AddSource("MapAllCommandsExtension.g.cs", registryCode); } }); - } internal static bool MethodDeclPredicate(SyntaxNode node, CancellationToken _) diff --git a/src/MinimalCli.SourceGenerator/DiagnosticErrors.cs b/src/MinimalCli.SourceGenerator/DiagnosticErrors.cs index 055ce80..add7932 100644 --- a/src/MinimalCli.SourceGenerator/DiagnosticErrors.cs +++ b/src/MinimalCli.SourceGenerator/DiagnosticErrors.cs @@ -1,10 +1,12 @@ using Microsoft.CodeAnalysis; +using System.Collections; namespace MinimalCli.SourceGenerator { internal static class DiagnosticErrors { const string CommandNameCategory = "Command Name"; + const string RootHandlerCategory = "Root Handler"; static readonly DiagnosticDescriptor CommandNameConflict = new DiagnosticDescriptor( "MIN0001", @@ -23,6 +25,23 @@ internal static class DiagnosticErrors DiagnosticSeverity.Error, true ); + static readonly DiagnosticDescriptor TooManyRootCommands = + new DiagnosticDescriptor( + "MIN0003", + "Too many RootHandlers", + "The project should only have 1 root handler", + RootHandlerCategory, + DiagnosticSeverity.Error, + true + ); + + /// + /// MIN0003 Too many RootHandlers. + /// + internal static void ReportTooManyRootHandlersError( + this SourceProductionContext ctx, + Location? location) + => ctx.ReportDiagnostic(Diagnostic.Create(TooManyRootCommands, location)); /// /// MIN0002 empty command name. diff --git a/src/MinimalCli.SourceGenerator/FromServicesBinding.cs b/src/MinimalCli.SourceGenerator/FromServicesBinding.cs new file mode 100644 index 0000000..67b655f --- /dev/null +++ b/src/MinimalCli.SourceGenerator/FromServicesBinding.cs @@ -0,0 +1,6 @@ +namespace MinimalCli.SourceGeneration; + +internal record FromServicesBinding(string OriginalParameterName, string Type) + : ParameterBinding(OriginalParameterName, Type, DefaultValueConstant:null, IsCollectionType:false) +{ +} diff --git a/src/MinimalCli.SourceGenerator/GeneratingCommandBinder.cs b/src/MinimalCli.SourceGenerator/GeneratingCommandBinder.cs new file mode 100644 index 0000000..cc56ff3 --- /dev/null +++ b/src/MinimalCli.SourceGenerator/GeneratingCommandBinder.cs @@ -0,0 +1,39 @@ +using Microsoft.CodeAnalysis; +using System.Collections.Immutable; +using MinimalCli.SourceGeneration.Conventions; + +namespace MinimalCli.SourceGeneration; + +internal record GeneratingCommandBinder( + string? CommandName, + string ClassNamespace, + string ClassName, + string MethodName, + string MethodReturnType, + bool MethodIsStatic, + Location? CommandNameLocation, + ImmutableArray? Bindings +) +{ + public string CommandHelpName => Conventions.ParameterNameConversion.ToArgumentName(this.CommandName ?? ""); + public string CommandNameTitleCase => this.CommandName?.ToSymbolName() ?? ""; + public string CommandOptionsName => $"{this.CommandNameTitleCase}CommandOptions"; + public string FullClassName => $"{this.ClassNamespace}.{this.ClassName}"; + public string FullMethodName => $"{this.FullClassName}.{this.MethodName}"; +} + +internal record GeneratingRootCommandBinder( + string ClassNamespace, + string ClassName, + string MethodName, + string MethodReturnType, + bool MethodIsStatic, + Location? CommandNameLocation, + ImmutableArray? Bindings +) +{ + public string CommandHelpName => "RootCommand"; + public string CommandOptionsName => $"RootCommandOptions"; + public string FullClassName => $"{this.ClassNamespace}.{this.ClassName}"; + public string FullMethodName => $"{this.FullClassName}.{this.MethodName}"; +} diff --git a/src/MinimalCli.SourceGenerator/GeneratorBindingsProvider.cs b/src/MinimalCli.SourceGenerator/GeneratorBindingsProvider.cs index fbabc90..a4a182b 100644 --- a/src/MinimalCli.SourceGenerator/GeneratorBindingsProvider.cs +++ b/src/MinimalCli.SourceGenerator/GeneratorBindingsProvider.cs @@ -2,8 +2,8 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using System.Collections.Generic; using System.Collections.Immutable; -using MinimalCli.SourceGeneration.Conventions; using System.Linq; +using System.Reflection.Metadata; using System.Threading; namespace MinimalCli.SourceGeneration; @@ -14,15 +14,19 @@ internal static class GeneratorBindingsProvider { /// /// The transform is the part where we gather the information from the function that we need later. - /// This information should be deterministic and should not change if the underling method signature doesn't change. + /// This information should be deterministic and should not change if the underlying method signature doesn't change. /// public static GeneratingCommandBinder Transform(GeneratorAttributeSyntaxContext ctx, CancellationToken cancellationToken) { if (ctx.TargetSymbol is not IMethodSymbol methodSymbol) return null!; + const string globalHandlerAttribute = "global::MinimalCli.HandlerAttribute"; + + //AttributeData? rootHandlerAttribute = ctx.Attributes.FirstOrDefault(a => a.AttributeClass is not null + // && a.AttributeClass.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::MinimalCli.RootHandlerAttribute"); AttributeData? handlerAttribute = ctx.Attributes.FirstOrDefault(a => a.AttributeClass is not null - && a.AttributeClass.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::MinimalCli.HandlerAttribute"); + && a.AttributeClass.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == globalHandlerAttribute); string? commandName = handlerAttribute?.ConstructorArguments.FirstOrDefault().Value as string; Location? argumentLocation = null; @@ -33,22 +37,62 @@ public static GeneratingCommandBinder Transform(GeneratorAttributeSyntaxContext .FirstOrDefault(attr => ctx.SemanticModel .GetTypeInfo(attr) .Type? - .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::MinimalCli.HandlerAttribute" + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == globalHandlerAttribute ); argumentLocation = handlerAttrSyntax?.ArgumentList?.Arguments.FirstOrDefault()?.GetLocation(); } - //// Find the AttributeSyntax node for the HandlerAttribute - //AttributeSyntax? handlerAttributeSyntax = ctx.TargetNode switch - //{ - // MethodDeclarationSyntax methodDecl => methodDecl.AttributeLists - // .SelectMany(list => list.Attributes) - // .FirstOrDefault(attr => - // ctx.SemanticModel.GetTypeInfo(attr).Type?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - // == "global::MinimalCli.HandlerAttribute"), - // _ => null - //}; - //Location? argumentLocation = handlerAttributeSyntax?.ArgumentList?.Arguments.FirstOrDefault()?.GetLocation(); + string classNamespace = methodSymbol.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + string className = methodSymbol.ContainingType.Name; + string methodName = methodSymbol.Name; + string methodReturnType = methodSymbol.ReturnType.ToDisplayString(NullableFlowState.None, SymbolDisplayFormat.FullyQualifiedFormat); + bool methodIsStatic = methodSymbol.IsStatic; + + ImmutableArray bindings = GetParameterBindings(ctx, methodSymbol.Parameters); + + return new GeneratingCommandBinder( + CommandName: commandName, + ClassNamespace: classNamespace, + ClassName: className, + MethodName: methodName, + MethodReturnType: methodReturnType, + MethodIsStatic: methodIsStatic, + CommandNameLocation: argumentLocation, + Bindings: bindings); + } + + + /// + /// The transform is the part where we gather the information from the function that we need later. + /// This information should be deterministic and should not change if the underlying method signature doesn't change. + /// + public static GeneratingRootCommandBinder TransformForRoot(GeneratorAttributeSyntaxContext ctx, CancellationToken cancellationToken) + { + if (ctx.TargetSymbol is not IMethodSymbol methodSymbol) + return null!; + + const string globalRootHandlerAttribute = "global::MinimalCli.RootHandlerAttribute"; + + + AttributeData? rootHandlerAttribute = ctx.Attributes.FirstOrDefault(a => a.AttributeClass is not null + && a.AttributeClass.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == globalRootHandlerAttribute); + + // may want to do an analyzer warning if this method has both a RootHandler attribute and a Handler attribute. + //AttributeData? handlerAttribute = ctx.Attributes.FirstOrDefault(a => a.AttributeClass is not null + // && a.AttributeClass.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::MinimalCli.HandlerAttribute"); + + Location? argumentLocation = null; + if (ctx.TargetNode is MethodDeclarationSyntax methodDecl) + { + AttributeSyntax? handlerAttrSyntax = methodDecl.AttributeLists + .SelectMany(attrs => attrs.Attributes) + .FirstOrDefault(attr => ctx.SemanticModel + .GetTypeInfo(attr) + .Type? + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == globalRootHandlerAttribute + ); + argumentLocation = handlerAttrSyntax?.GetLocation(); + } string classNamespace = methodSymbol.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); string className = methodSymbol.ContainingType.Name; @@ -56,10 +100,23 @@ public static GeneratingCommandBinder Transform(GeneratorAttributeSyntaxContext string methodReturnType = methodSymbol.ReturnType.ToDisplayString(NullableFlowState.None, SymbolDisplayFormat.FullyQualifiedFormat); bool methodIsStatic = methodSymbol.IsStatic; - ImmutableArray parameters = methodSymbol.Parameters; + ImmutableArray bindings = GetParameterBindings(ctx, methodSymbol.Parameters); + return new GeneratingRootCommandBinder( + ClassNamespace: classNamespace, + ClassName: className, + MethodName: methodName, + MethodReturnType: methodReturnType, + MethodIsStatic: methodIsStatic, + CommandNameLocation: argumentLocation, + Bindings: bindings); + } + + private static ImmutableArray GetParameterBindings(GeneratorAttributeSyntaxContext ctx, ImmutableArray parameters) + { List bindings = new(); - foreach(IParameterSymbol param in parameters) + + foreach (IParameterSymbol param in parameters) { ImmutableArray attributes = param.GetAttributes(); @@ -81,7 +138,7 @@ public static GeneratingCommandBinder Transform(GeneratorAttributeSyntaxContext { bindings.BindServiceDescriptor(name, type); } - else if(bindingType == BindingType.Option) + else if (bindingType == BindingType.Option) { bindings.BindOption(name, type, GetSymbolDefaultValue(ctx, param), IsParamCollectionType(param)); } @@ -91,15 +148,7 @@ public static GeneratingCommandBinder Transform(GeneratorAttributeSyntaxContext } } - return new GeneratingCommandBinder( - CommandName: commandName, - ClassNamespace: classNamespace, - ClassName: className, - MethodName: methodName, - MethodReturnType: methodReturnType, - MethodIsStatic: methodIsStatic, - CommandNameLocation: argumentLocation, - Bindings: bindings.ToImmutableArray()); + return bindings.ToImmutableArray(); } private static BindingType DetermineBindingType( @@ -239,62 +288,3 @@ static string FormatLiteral(object value) return null; } } - -internal record GeneratingCommandBinder( - string? CommandName, - string ClassNamespace, - string ClassName, - string MethodName, - string MethodReturnType, - bool MethodIsStatic, - Location? CommandNameLocation, - ImmutableArray? Bindings -) -{ - public string CommandHelpName => Conventions.ParameterNameConversion.ToArgumentName(this.CommandName ?? ""); - public string CommandNameTitleCase => this.CommandName?.ToSymbolName() ?? ""; - public string CommandOptionsName => $"{this.CommandNameTitleCase}CommandOptions"; - public string FullClassName => $"{this.ClassNamespace}.{this.ClassName}"; - public string FullMethodName => $"{this.FullClassName}.{this.MethodName}"; -} - -internal abstract record ParameterBinding(string OriginalParameterName, string Type, string? DefaultValueConstant, bool IsCollectionType) -{ - public string NameTitleCase - { - get - { - return this.OriginalParameterName.ToSymbolName(); - } - } - public string HelpName - { - get - { - // split and add spaces - char c = char.ToUpper(this.OriginalParameterName[0]); - return c + this.OriginalParameterName.Substring(1); - } - } -} -internal record ArgumentBinding(string OriginalParameterName, string Type, string? DefaultValueConstant, bool IsCollectionType) - : ParameterBinding(OriginalParameterName, Type, DefaultValueConstant, IsCollectionType) -{ - /// - /// The Argument name in title case, e.g. "myParameterName" becomes "My Parameter Name" - /// - public string ConventionalArgumentName - => ParameterNameConversion.ToArgumentName(this.OriginalParameterName); -} -internal record OptionBinding(string OriginalParameterName, string Type, string? DefaultValueConstant, bool IsCollectionType) - : ParameterBinding(OriginalParameterName, Type, DefaultValueConstant, IsCollectionType) -{ - /// - /// The option name in kebab case, e.g. "myParameterName" becomes "--my-parameter-name" - /// - public string OptionName => ParameterNameConversion.ToOptionName(this.OriginalParameterName); -} -internal record FromServicesBinding(string OriginalParameterName, string Type) - : ParameterBinding(OriginalParameterName, Type, DefaultValueConstant:null, IsCollectionType:false) -{ -} diff --git a/src/MinimalCli.SourceGenerator/MapAllCommandsExtensionWriter.cs b/src/MinimalCli.SourceGenerator/MapAllCommandsExtensionWriter.cs index c298bb0..51b98c2 100644 --- a/src/MinimalCli.SourceGenerator/MapAllCommandsExtensionWriter.cs +++ b/src/MinimalCli.SourceGenerator/MapAllCommandsExtensionWriter.cs @@ -1,4 +1,6 @@ using System.Collections.Immutable; +using System.Linq; +using System.Reflection; using System.Text; namespace MinimalCli.SourceGeneration; @@ -10,11 +12,11 @@ internal static class MapAllCommandsExtensionWriter /// the Command classes with dependency injection and the CommandOptions with /// the builder. /// - /// The generated command binding information + /// The generated command binding information /// - internal static string? GenerateMapAllCommandsExt(ImmutableArray binders) + internal static string? GenerateMapAllCommandsExt(ImmutableArray commandBinders, ImmutableArray rootCommandBinders) { - if(binders.Length > 0) + if(commandBinders.Length > 0 || rootCommandBinders.Length > 0) { StringBuilder sb = new( """ @@ -30,7 +32,26 @@ public static class MapAllCommandsExtension public static MinimalCommandLineBuilder MapAllCommands(this MinimalCommandLineBuilder builder) { """); - foreach(GeneratingCommandBinder binder in binders) + + // generate code for root command (there should only be one) + if(rootCommandBinders.Length == 1) + { + GeneratingRootCommandBinder? rootBinder = rootCommandBinders.FirstOrDefault(); + if(rootBinder is not null) + { + sb.AppendLine(); + sb.AppendLine($" // register Root Command"); + if (!rootBinder.MethodIsStatic) + { + sb.AppendLine($" builder.Services.TryAddTransient<{rootBinder.FullClassName}>();"); + } + sb.AppendLine($" builder.TryRegisterCommandOptions<{rootBinder.CommandOptionsName}>();"); + sb.AppendLine(); + } + } + + // generate code for all commands + foreach(GeneratingCommandBinder binder in commandBinders) { if(binder.CommandName is not null) { @@ -44,6 +65,7 @@ public static MinimalCommandLineBuilder MapAllCommands(this MinimalCommandLineBu sb.AppendLine(); } } + sb.AppendLine(""" return builder; diff --git a/src/MinimalCli.SourceGenerator/OptionBinding.cs b/src/MinimalCli.SourceGenerator/OptionBinding.cs new file mode 100644 index 0000000..2f22e4d --- /dev/null +++ b/src/MinimalCli.SourceGenerator/OptionBinding.cs @@ -0,0 +1,12 @@ +using MinimalCli.SourceGeneration.Conventions; + +namespace MinimalCli.SourceGeneration; + +internal record OptionBinding(string OriginalParameterName, string Type, string? DefaultValueConstant, bool IsCollectionType) + : ParameterBinding(OriginalParameterName, Type, DefaultValueConstant, IsCollectionType) +{ + /// + /// The option name in kebab case, e.g. "myParameterName" becomes "--my-parameter-name" + /// + public string OptionName => ParameterNameConversion.ToOptionName(this.OriginalParameterName); +} diff --git a/src/MinimalCli.SourceGenerator/ParameterBinding.cs b/src/MinimalCli.SourceGenerator/ParameterBinding.cs new file mode 100644 index 0000000..55824a5 --- /dev/null +++ b/src/MinimalCli.SourceGenerator/ParameterBinding.cs @@ -0,0 +1,23 @@ +using MinimalCli.SourceGeneration.Conventions; + +namespace MinimalCli.SourceGeneration; + +internal abstract record ParameterBinding(string OriginalParameterName, string Type, string? DefaultValueConstant, bool IsCollectionType) +{ + public string NameTitleCase + { + get + { + return this.OriginalParameterName.ToSymbolName(); + } + } + public string HelpName + { + get + { + // split and add spaces + char c = char.ToUpper(this.OriginalParameterName[0]); + return c + this.OriginalParameterName.Substring(1); + } + } +}