diff --git a/ReadMe.md b/ReadMe.md index a9a2d72..96fa72f 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -145,6 +145,7 @@ ConsoleAppFramework offers a rich set of features as a framework. The Source Gen * Registration of nested commands * Setting option aliases and descriptions from code document comment * `System.ComponentModel.DataAnnotations` attribute-based Validation +* Grouped parameter binding via `[AsParameters]` * Dependency Injection for command registration by type and public methods * `Microsoft.Extensions`(Logging, Configuration, etc...) integration * High performance value parsing via `ISpanParsable` @@ -626,6 +627,42 @@ ConsoleApp.Run(args, ([Argument]string input, [Argument]string output, bool dryR ConsoleApp.Run(args, (string message, [Argument]params string[] outputs) => { }); ``` +### AsParameters + +You can group command parameters into a single record class by annotating a command parameter with `[AsParameters]`. Constructor parameters of that record are flattened and treated as normal command parameters. + +```csharp +ConsoleApp.Run(args, ([AsParameters] CreateUserOptions options, int repeat) => +{ + for (var i = 0; i < repeat; i++) + { + Console.WriteLine($"{options.Name}:{options.Age}:{options.Force}"); + } +}); + +public record class CreateUserOptions( + string Name, + [Argument] int Age = 20, + bool Force = false); +``` + +In this case, `Name`, `Age`, and `Force` are parsed from CLI arguments, then `CreateUserOptions` is constructed and passed to the command method. `[AsParameters]` can be mixed with regular parameters, `[FromServices]`, `CancellationToken`, `ConsoleAppContext`, and global options. + +Aliases and descriptions for expanded parameters are also supported via XML documentation comments on the target record constructor parameters. + +```csharp +/// -n, User name. +/// -a, User age. +public record class CreateUserOptions(string Name, int Age); +``` + +Current constraints: + +* The `[AsParameters]` target must be a `record class`. +* The target type must have exactly one public instance constructor. +* Nested `[AsParameters]` on constructor parameters is not supported. +* `params` constructor parameters are not supported. + To convert from string arguments to various types, basic primitive types (`string`, `char`, `sbyte`, `byte`, `short`, `int`, `long`, `uint`, `ushort`, `ulong`, `decimal`, `float`, `double`) use `TryParse`. For types that implement `ISpanParsable` (`DateTime`, `DateTimeOffset`, `Guid`, `BigInteger`, `Complex`, `Half`, `Int128`, etc.), [IParsable.TryParse](https://learn.microsoft.com/en-us/dotnet/api/system.iparsable-1.tryparse?view=net-8.0#system-ispanparsable-1-tryparse(system-readonlyspan((system-char))-system-iformatprovider-0@)) or [ISpanParsable.TryParse](https://learn.microsoft.com/en-us/dotnet/api/system.ispanparsable-1.tryparse?view=net-8.0#system-ispanparsable-1-tryparse(system-readonlyspan((system-char))-system-iformatprovider-0@)) is used. For `enum`, it is parsed using `Enum.TryParse(ignoreCase: true)`. diff --git a/src/ConsoleAppFramework/Command.cs b/src/ConsoleAppFramework/Command.cs index 9a9a585..381f4dd 100644 --- a/src/ConsoleAppFramework/Command.cs +++ b/src/ConsoleAppFramework/Command.cs @@ -25,6 +25,8 @@ public record class Command public required string Name { get; init; } public required EquatableArray Parameters { get; init; } + public required EquatableArray EffectiveParseParameters { get; init; } + public required EquatableArray AsParametersExpansionBindings { get; init; } public required string Description { get; init; } public required MethodKind MethodKind { get; init; } public required DelegateBuildType DelegateBuildType { get; init; } @@ -166,6 +168,13 @@ public string BuildDynamicDependencyAttribute() } } +public record class AsParametersBinding +{ + public required int RuntimeParameterIndex { get; init; } + public required EquatableTypeSymbol TargetType { get; init; } + public required EquatableArray ParseParameterIndexes { get; init; } +} + public record class CommandParameter { public required EquatableTypeSymbol Type { get; init; } @@ -339,7 +348,7 @@ public string DefaultValueToString(bool castValue = true, bool enumIncludeTypeNa } } - // for floating-point number, need to use InvaliantCulture(some culture uses ',' as separator) + // for floating-point number, need to use InvariantCulture(some culture uses ',' as separator) var formattedValue = string.Format(CultureInfo.InvariantCulture, "{0}", DefaultValue); if (!castValue) { diff --git a/src/ConsoleAppFramework/CommandHelpBuilder.cs b/src/ConsoleAppFramework/CommandHelpBuilder.cs index bf940fe..93d72a8 100644 --- a/src/ConsoleAppFramework/CommandHelpBuilder.cs +++ b/src/ConsoleAppFramework/CommandHelpBuilder.cs @@ -179,7 +179,7 @@ static string BuildOptionsMessage(CommandHelpDefinition definition) sb.AppendLine("Options:"); var first = true; - foreach (var opt in optionsFormatted) + foreach (var (Options, Description, IsRequired, IsFlag, DefaultValue, IsDefaultValueHidden) in optionsFormatted) { if (first) { @@ -190,7 +190,7 @@ static string BuildOptionsMessage(CommandHelpDefinition definition) sb.AppendLine(); } - var options = opt.Options; + var options = Options; var padding = maxWidth - options.Length; sb.Append(" "); @@ -201,21 +201,29 @@ static string BuildOptionsMessage(CommandHelpDefinition definition) } sb.Append(" "); - sb.Append(opt.Description); + sb.Append(Description); // Flags are optional by default; leave them untagged. - if (!opt.IsFlag) + if (!IsFlag) { - if (opt.DefaultValue != null) + if (DefaultValue != null) { - if (!opt.IsDefaultValueHidden) + if (!IsDefaultValueHidden) { - sb.Append($" [Default: {opt.DefaultValue}]"); + if (!string.IsNullOrEmpty(Description)) + { + sb.Append(' '); + } + sb.Append($"[Default: {DefaultValue}]"); } } - else if (opt.IsRequired) + else if (IsRequired) { - sb.Append($" [Required]"); + if (!string.IsNullOrEmpty(Description)) + { + sb.Append(' '); + } + sb.Append("[Required]"); } } } diff --git a/src/ConsoleAppFramework/ConsoleAppBaseCode.cs b/src/ConsoleAppFramework/ConsoleAppBaseCode.cs index fd0f373..42a08aa 100644 --- a/src/ConsoleAppFramework/ConsoleAppBaseCode.cs +++ b/src/ConsoleAppFramework/ConsoleAppBaseCode.cs @@ -156,6 +156,11 @@ internal sealed class ArgumentAttribute : Attribute { } +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +internal sealed class AsParametersAttribute : Attribute +{ +} + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] internal sealed class CommandAttribute : Attribute { diff --git a/src/ConsoleAppFramework/ConsoleAppGenerator.cs b/src/ConsoleAppFramework/ConsoleAppGenerator.cs index 1f9c2d1..81f3c4e 100644 --- a/src/ConsoleAppFramework/ConsoleAppGenerator.cs +++ b/src/ConsoleAppFramework/ConsoleAppGenerator.cs @@ -2,11 +2,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using System.Collections.Immutable; -using System.ComponentModel.Design; -using System.Linq.Expressions; using System.Reflection; -using System.Runtime.CompilerServices; -using System.Xml.Linq; namespace ConsoleAppFramework; @@ -219,7 +215,7 @@ static void EmitConsoleAppRun(SourceProductionContext sourceProductionContext, C using (sb.BeginBlock("internal static partial class ConsoleApp")) { var emitter = new Emitter(null); - var requiredParsableParameterCount = command.Parameters.Count(p => p.IsParsable && p.RequireCheckArgumentParsed); + var requiredParsableParameterCount = command.EffectiveParseParameters.Count(p => p.IsParsable && p.RequireCheckArgumentParsed); var withId = new Emitter.CommandWithId(null, command, -1, requiredParsableParameterCount); emitter.EmitRun(sb, withId, commandContext.IsAsync, null); } @@ -230,7 +226,7 @@ static void EmitConsoleAppRun(SourceProductionContext sourceProductionContext, C using (help.BeginBlock("internal static partial class ConsoleApp")) { var emitter = new Emitter(null); - emitter.EmitHelp(help, command); + emitter.EmitHelp(help, command with { Parameters = command.EffectiveParseParameters }); } sourceProductionContext.AddSource("ConsoleApp.Run.Help.g.cs", help.ToString().ReplaceLineEndings()); } @@ -276,7 +272,7 @@ static void EmitConsoleAppBuilder(SourceProductionContext sourceProductionContex Command: x!, Id: i, - RequiredParsableParameterCount: x!.Parameters.Count(p => p.IsParsable && p.RequireCheckArgumentParsed) + RequiredParsableParameterCount: x!.EffectiveParseParameters.Count(p => p.IsParsable && p.RequireCheckArgumentParsed) ); if (delegateDef != null) { @@ -305,6 +301,13 @@ static void EmitConsoleAppBuilder(SourceProductionContext sourceProductionContex using (help.BeginBlock("internal static partial class ConsoleApp")) using (help.BeginBlock("internal partial class ConsoleAppBuilder")) { + commandIds = commandIds + .Select(x => x with + { + Command = x.Command with { Parameters = x.Command.EffectiveParseParameters } + }) + .ToArray(); + if (collectBuilderContext.GlobalOptions.Length != 0) { // quick-hack to override commandIds diff --git a/src/ConsoleAppFramework/DiagnosticDescriptors.cs b/src/ConsoleAppFramework/DiagnosticDescriptors.cs index 6481fc1..6f9f6bc 100644 --- a/src/ConsoleAppFramework/DiagnosticDescriptors.cs +++ b/src/ConsoleAppFramework/DiagnosticDescriptors.cs @@ -131,4 +131,29 @@ public static DiagnosticDescriptor Create(int id, string title, string messageFo public static DiagnosticDescriptor InvalidGlobalOptionsType { get; } = Create( 18, "GlobalOption parameter type only allows compile-time constant(primitives, string, enum) and there nullable."); + + public static DiagnosticDescriptor AsParametersTargetMustBeRecordClass { get; } = Create( + 19, + "[AsParameters] target must be a record class.", + "Parameter '{0}' marked with [AsParameters] must be a record class type, but found '{1}'."); + + public static DiagnosticDescriptor AsParametersTargetMustHaveSinglePublicConstructor { get; } = Create( + 20, + "[AsParameters] target must have exactly one public instance constructor.", + "Type '{0}' used with [AsParameters] must declare exactly one public instance constructor."); + + public static DiagnosticDescriptor AsParametersNestedNotSupported { get; } = Create( + 21, + "Nested [AsParameters] is not supported.", + "Parameter '{0}' in [AsParameters] target '{1}' cannot also be marked with [AsParameters]."); + + public static DiagnosticDescriptor AsParametersParamsNotSupported { get; } = Create( + 22, + "[AsParameters] does not support params constructor parameters.", + "Parameter '{0}' in [AsParameters] target '{1}' cannot use the 'params' modifier."); + + public static DiagnosticDescriptor DuplicateOptionNameOrAlias { get; } = Create( + 23, + "Option name or alias is duplicated.", + "Option name or alias '{0}' is duplicated."); } diff --git a/src/ConsoleAppFramework/Emitter.cs b/src/ConsoleAppFramework/Emitter.cs index 7481bf2..deed223 100644 --- a/src/ConsoleAppFramework/Emitter.cs +++ b/src/ConsoleAppFramework/Emitter.cs @@ -8,13 +8,56 @@ internal class Emitter(DllReference? dllReference) // from EmitConsoleAppRun, nu public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsync, string? methodName) { var command = commandWithId.Command; + var runtimeParameters = command.Parameters; + var effectiveParseParameters = command.EffectiveParseParameters; + var asParametersExpansionBindings = command.AsParametersExpansionBindings + .Select((binding, index) => new { Binding = binding, Index = index }) + .ToArray(); + var hasAsParametersExpansion = asParametersExpansionBindings.Length != 0; + var asParametersExpansionBindingByRuntimeIndex = asParametersExpansionBindings + .ToDictionary(x => x.Binding.RuntimeParameterIndex, x => x); + var runtimeToExpandedParameterIndex = new int[runtimeParameters.Length]; + for (var i = 0; i < runtimeToExpandedParameterIndex.Length; i++) + { + runtimeToExpandedParameterIndex[i] = -1; + } + var expandedParameterInfoSources = new string[effectiveParseParameters.Length]; + if (hasAsParametersExpansion) + { + var expandedParameterIndex = 0; + for (var i = 0; i < runtimeParameters.Length; i++) + { + if (asParametersExpansionBindingByRuntimeIndex.TryGetValue(i, out var bindingWithIndex)) + { + var parseParameterIndexes = bindingWithIndex.Binding.ParseParameterIndexes; + for (var j = 0; j < parseParameterIndexes.Length; j++) + { + expandedParameterInfoSources[parseParameterIndexes[j]] = $"asParametersCtorParameters{bindingWithIndex.Index}[{j}]"; + } + expandedParameterIndex += parseParameterIndexes.Length; + } + else + { + runtimeToExpandedParameterIndex[i] = expandedParameterIndex; + expandedParameterInfoSources[expandedParameterIndex] = $"parameters[{i}]"; + expandedParameterIndex++; + } + } + } + else + { + for (var i = 0; i < runtimeParameters.Length; i++) + { + runtimeToExpandedParameterIndex[i] = i; + expandedParameterInfoSources[i] = $"parameters[{i}]"; + } + } var emitForBuilder = methodName != null; - var hasCancellationToken = command.Parameters.Any(x => x.IsCancellationToken); - var hasConsoleAppContext = command.Parameters.Any(x => x.IsConsoleAppContext); - var hasArgument = command.Parameters.Any(x => x.IsArgument); - var hasValidation = command.Parameters.Any(x => x.HasValidation); - var parsableParameterCount = command.Parameters.Count(x => x.IsParsable); + var hasCancellationToken = effectiveParseParameters.Any(x => x.IsCancellationToken); + var hasConsoleAppContext = effectiveParseParameters.Any(x => x.IsConsoleAppContext); + var hasArgument = effectiveParseParameters.Any(x => x.IsArgument); + var hasValidation = effectiveParseParameters.Any(x => x.HasValidation); if (command.HasFilter) { @@ -47,7 +90,7 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy if (!emitForBuilder) { sb.AppendLine("/// "); - var help = CommandHelpBuilder.BuildCommandHelpMessage(commandWithId.Command); + var help = CommandHelpBuilder.BuildCommandHelpMessage(command with { Parameters = effectiveParseParameters }); #pragma warning disable RS1035 foreach (var line in help.Split([Environment.NewLine], StringSplitOptions.None)) { @@ -64,7 +107,7 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy var methodArgument = command.HasFilter ? Join(", ", commandMethodType, "ConsoleAppContext context", "CancellationToken cancellationToken") - : Join(", ", "string[] args", (emitForBuilder ? "int commandDepth" : ""), commandMethodType, cancellationTokenParameter); + : Join(", ", "string[] args", emitForBuilder ? "int commandDepth" : "", commandMethodType, cancellationTokenParameter); using (sb.BeginBlock($"{accessibility} {unsafeCode}{returnType} {methodName}({methodArgument})")) { @@ -74,7 +117,7 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy var noCommandArgsMemory = false; if (command.HasFilter) { - sb.AppendLine("var commandArgsMemory = context.InternalCommandArgs;"); // already prepared and craeted ConsoleAppContext + sb.AppendLine("var commandArgsMemory = context.InternalCommandArgs;"); // already prepared and created ConsoleAppContext } else { @@ -154,9 +197,9 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy sb.AppendLine(); } - for (var i = 0; i < command.Parameters.Length; i++) + for (var i = 0; i < effectiveParseParameters.Length; i++) { - var parameter = command.Parameters[i]; + var parameter = effectiveParseParameters[i]; if (parameter.IsParsable) { var defaultValue = parameter.IsParams ? $"({parameter.ToTypeDisplayString()})[]" @@ -195,7 +238,7 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy sb.AppendLine(line); } } - sb.AppendLineIfExists(command.Parameters.AsSpan()); + sb.AppendLineIfExists(effectiveParseParameters.AsSpan()); if (hasArgument) { @@ -215,16 +258,16 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy } sb.AppendLine(); - if (!command.Parameters.All(p => !p.IsParsable || p.IsArgument)) + if (!effectiveParseParameters.All(p => !p.IsParsable || p.IsArgument)) { using (hasArgument ? sb.BeginBlock("if (optionCandidate)") : sb.Nop) { using (sb.BeginBlock("switch (name)")) { // parse argument(fast, switch directly) - for (int i = 0; i < command.Parameters.Length; i++) + for (int i = 0; i < effectiveParseParameters.Length; i++) { - var parameter = command.Parameters[i]; + var parameter = effectiveParseParameters[i]; if (!parameter.IsParsable) continue; if (parameter.IsArgument) continue; @@ -247,9 +290,9 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy using (sb.BeginIndent("default:")) { // parse argument(slow, ignorecase) - for (int i = 0; i < command.Parameters.Length; i++) + for (int i = 0; i < effectiveParseParameters.Length; i++) { - var parameter = command.Parameters[i]; + var parameter = effectiveParseParameters[i]; if (!parameter.IsParsable) continue; if (parameter.IsArgument) continue; @@ -280,9 +323,9 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy // parse indexed argument([Argument] parameter) if (hasArgument) { - for (int i = 0; i < command.Parameters.Length; i++) + for (int i = 0; i < effectiveParseParameters.Length; i++) { - var parameter = command.Parameters[i]; + var parameter = effectiveParseParameters[i]; if (!parameter.IsArgument) continue; sb.AppendLine($"if (argumentPosition == {parameter.ArgumentIndex})"); @@ -307,9 +350,9 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy } // validate parsed - for (int i = 0; i < command.Parameters.Length; i++) + for (int i = 0; i < effectiveParseParameters.Length; i++) { - var parameter = command.Parameters[i]; + var parameter = effectiveParseParameters[i]; if (!parameter.IsParsable) continue; if (parameter.RequireCheckArgumentParsed) @@ -331,13 +374,21 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy { sb.AppendLine($"var parameters = typeof({command.CommandMethodInfo.TypeFullName}).GetMethod(\"{command.CommandMethodInfo.MethodName}\").GetParameters();"); } + if (hasAsParametersExpansion) + { + foreach (var binding in asParametersExpansionBindings) + { + sb.AppendLine($"var asParametersCtorParameters{binding.Index} = typeof({binding.Binding.TargetType.ToFullyQualifiedFormatDisplayString()}).GetConstructors(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)[0].GetParameters();"); + } + } sb.AppendLine("System.Text.StringBuilder? errorMessages = null;"); - for (int i = 0; i < command.Parameters.Length; i++) + for (int i = 0; i < effectiveParseParameters.Length; i++) { - var parameter = command.Parameters[i]; + var parameter = effectiveParseParameters[i]; if (!parameter.HasValidation) continue; - sb.AppendLine($"ValidateParameter(arg{i}, parameters[{i}], validationContext, ref errorMessages);"); + var parameterInfoSource = expandedParameterInfoSources[i]; + sb.AppendLine($"ValidateParameter(arg{i}, {parameterInfoSource}, validationContext, ref errorMessages);"); } sb.AppendLine("if (errorMessages != null)"); using (sb.BeginBlock()) @@ -348,7 +399,30 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy // invoke for sync/async, void/int sb.AppendLine(); - var methodArguments = string.Join(", ", command.Parameters.Select((x, i) => $"arg{i}!")); + string methodArguments; + if (!hasAsParametersExpansion) + { + methodArguments = string.Join(", ", runtimeParameters.Select((_, i) => $"arg{i}!")); + } + else + { + for (var i = 0; i < runtimeParameters.Length; i++) + { + if (runtimeToExpandedParameterIndex[i] != -1) + { + sb.AppendLine($"var runtimeArg{i} = arg{runtimeToExpandedParameterIndex[i]}!;"); + } + else + { + var bindingWithIndex = asParametersExpansionBindingByRuntimeIndex[i]; + var parseIndexes = string.Join(", ", bindingWithIndex.Binding.ParseParameterIndexes.Select(x => $"arg{x}!")); + sb.AppendLine($"var runtimeArg{i} = new {bindingWithIndex.Binding.TargetType.ToFullyQualifiedFormatDisplayString()}({parseIndexes});"); + } + } + sb.AppendLine(); + methodArguments = string.Join(", ", runtimeParameters.Select((_, i) => $"runtimeArg{i}!")); + } + string invokeCommand; if (command.CommandMethodInfo == null) { @@ -544,7 +618,7 @@ public void EmitBuilder(SourceBuilder sb, CommandWithId[] commandIds, bool emitS } // static sync command function - HashSet emittedCommand = new(); + HashSet emittedCommand = []; if (emitSync) { sb.AppendLine(); @@ -838,7 +912,7 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) sb.AppendLine(); using (sb.BeginBlock("public ConsoleApp.ConsoleAppBuilder ConfigureServices(Action configure)")) { - // for backward-compatiblity, we chooce (IConfiguration, IServiceCollection) for two arguments overload + // for backward-compatibility, we choose (IConfiguration, IServiceCollection) for two arguments overload sb.AppendLine("this.requireConfiguration = true;"); sb.AppendLine("this.configureServices = (_, configuration, services) => configure(configuration, services);"); sb.AppendLine("this.isRequireCallBuildAndSetServiceProvider = true;"); diff --git a/src/ConsoleAppFramework/Parser.cs b/src/ConsoleAppFramework/Parser.cs index 22e7324..3fcbec5 100644 --- a/src/ConsoleAppFramework/Parser.cs +++ b/src/ConsoleAppFramework/Parser.cs @@ -1,10 +1,7 @@ -using Microsoft.CodeAnalysis; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using System.Collections.Immutable; -using System.Runtime.InteropServices.ComTypes; -using System.Security.Cryptography; -using System.Xml.Linq; namespace ConsoleAppFramework; @@ -178,8 +175,7 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag public GlobalOptionInfo[] ParseGlobalOptions() { - var lambdaExpr = (node as InvocationExpressionSyntax); - if (lambdaExpr == null) return []; + if (node is not InvocationExpressionSyntax lambdaExpr) return []; var addOptions = node .DescendantNodes() @@ -339,8 +335,11 @@ bool IsParsableType(ITypeSymbol type) Command? ExpressionToCommand(ExpressionSyntax expression, string commandName) { - var lambda = expression as ParenthesizedLambdaExpressionSyntax; - if (lambda == null) + if (expression is ParenthesizedLambdaExpressionSyntax lambda) + { + return ParseFromLambda(lambda, commandName); + } + else { if (expression.IsKind(SyntaxKind.AddressOfExpression)) { @@ -366,10 +365,6 @@ bool IsParsableType(ITypeSymbol type) } } } - else - { - return ParseFromLambda(lambda, commandName); - } return null; } @@ -423,175 +418,16 @@ bool IsParsableType(ITypeSymbol type) } } - var parsableIndex = 0; - var argumentIndexCounter = 0; - var parameters = lambda.ParameterList.Parameters + var parameterSymbols = lambda.ParameterList.Parameters .Where(x => x.Type != null) - .Select(x => - { - var type = model.GetTypeInfo(x.Type!); - - var hasDefault = x.Default != null; - object? defaultValue = null; - if (x.Default?.Value is LiteralExpressionSyntax literal) - { - var token = literal.Token; - if (token.IsKind(SyntaxKind.DefaultKeyword)) - { - defaultValue = null; - } - else - { - defaultValue = token.Value; - } - } - else if (x.Default != null) - { - var value = model.GetConstantValue(x.Default.Value); - if (value.HasValue) - { - defaultValue = value.Value; - } - } - - var hasParams = x.Modifiers.Any(x => x.IsKind(SyntaxKind.ParamsKeyword)); - - var isHidden = x.AttributeLists - .SelectMany(x => x.Attributes) - .Any(x => model.GetTypeInfo(x).Type?.Name == "HiddenAttribute"); - - var isDefaultValueHidden = x.AttributeLists - .SelectMany(x => x.Attributes) - .Any(x => model.GetTypeInfo(x).Type?.Name == "HideDefaultValueAttribute"); - - var customParserType = x.AttributeLists.SelectMany(x => x.Attributes) - .Select(x => - { - var attr = model.GetTypeInfo(x).Type; - if (attr != null && attr.AllInterfaces.Any(x => x.Name == "IArgumentParser")) - { - return attr; - } - return null; - }) - .FirstOrDefault(x => x != null); - - var hasValidation = x.AttributeLists.SelectMany(x => x.Attributes) - .Any(x => - { - var attr = model.GetTypeInfo(x).Type as INamedTypeSymbol; - if (attr != null && attr.GetBaseTypes().Any(x => x.Name == "ValidationAttribute")) - { - return true; - } - return false; - }); - - var isFromServices = x.AttributeLists.SelectMany(x => x.Attributes) - .Any(x => - { - var name = x.Name; - if (x.Name is QualifiedNameSyntax qns) - { - name = qns.Right; - } - - var identifier = name.ToString(); - return identifier is "FromServices" or "FromServicesAttribute"; - }); - - object? keyedServiceKey = null; - var isFromKeyedServices = x.AttributeLists.SelectMany(x => x.Attributes) - .Any(x => - { - var name = x.Name; - if (x.Name is QualifiedNameSyntax qns) - { - name = qns.Right; - } - - var identifier = name.ToString(); - var result = identifier is "FromKeyedServices" or "FromKeyedServicesAttribute"; - if (result) - { - SemanticModel semanticModel = model; // we can use SemanticModel - if (x.ArgumentList?.Arguments.Count > 0) - { - var argumentExpression = x.ArgumentList.Arguments[0].Expression; - - var constantValue = semanticModel.GetConstantValue(argumentExpression); - if (constantValue.HasValue) - { - keyedServiceKey = constantValue.Value; - } - else if (argumentExpression is TypeOfExpressionSyntax typeOf) - { - var typeInfo = semanticModel.GetTypeInfo(typeOf.Type); - keyedServiceKey = typeInfo.Type; - } - } - } - return result; - }); - - var hasArgument = x.AttributeLists.SelectMany(x => x.Attributes) - .Any(x => - { - var name = x.Name; - if (x.Name is QualifiedNameSyntax qns) - { - name = qns.Right; - } + .Select(x => model.GetDeclaredSymbol(x)) + .OfType() + .ToImmutableArray(); - var identifier = name.ToString(); - return identifier is "Argument" or "ArgumentAttribute"; - }); - - var isCancellationToken = SymbolEqualityComparer.Default.Equals(type.Type!, wellKnownTypes.CancellationToken); - var isConsoleAppContext = type.Type!.Name == "ConsoleAppContext"; - - var argumentIndex = -1; - if (!(isFromServices || isCancellationToken || isConsoleAppContext)) - { - if (hasArgument) - { - argumentIndex = argumentIndexCounter++; - } - else - { - parsableIndex++; - } - } - - var isNullableReference = x.Type.IsKind(SyntaxKind.NullableType) && type.Type?.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T; - - return new CommandParameter - { - Name = generatorOptions.DisableNamingConversion ? x.Identifier.Text : NameConverter.ToKebabCase(x.Identifier.Text), - WellKnownTypes = wellKnownTypes, - OriginalParameterName = x.Identifier.Text, - IsNullableReference = isNullableReference, - IsConsoleAppContext = isConsoleAppContext, - IsParams = hasParams, - IsHidden = isHidden, - IsDefaultValueHidden = isDefaultValueHidden, - Type = new EquatableTypeSymbol(type.Type!), - Location = x.GetLocation(), - HasDefaultValue = hasDefault, - DefaultValue = defaultValue, - CustomParserType = customParserType?.ToEquatable(), - HasValidation = hasValidation, - IsCancellationToken = isCancellationToken, - IsFromServices = isFromServices, - IsFromKeyedServices = isFromKeyedServices, - KeyedServiceKey = keyedServiceKey, - Aliases = [], - Description = "", - ArgumentIndex = argumentIndex, - }; - }) - .Where(x => x.Type != null) - .ToArray(); + if (!TryBuildRuntimeAndParseParameters(parameterSymbols, null, out var parameters, out var effectiveParseParameters, out var asParametersExpansionBindings)) + { + return null; + } var cmd = new Command { @@ -600,6 +436,8 @@ bool IsParsableType(ITypeSymbol type) IsVoid = isVoid, IsHidden = false, // Anonymous lambda don't support attribute. Parameters = parameters, + EffectiveParseParameters = effectiveParseParameters, + AsParametersExpansionBindings = asParametersExpansionBindings, MethodKind = MethodKind.Lambda, Description = "", DelegateBuildType = delegateBuildType, @@ -635,8 +473,7 @@ bool IsParsableType(ITypeSymbol type) isVoid = true; // check `async void` - var syntax = methodSymbol.DeclaringSyntaxReferences[0].GetSyntax() as MethodDeclarationSyntax; - if (syntax != null) + if (methodSymbol.DeclaringSyntaxReferences[0].GetSyntax() is MethodDeclarationSyntax syntax) { var asyncKeyword = syntax.Modifiers.FirstOrDefault(x => x.IsKind(SyntaxKind.AsyncKeyword)); if (asyncKeyword != default) @@ -727,76 +564,22 @@ bool IsParsableType(ITypeSymbol type) } } - var parsableIndex = 0; - var argumentIndexCounter = 0; - var parameters = methodSymbol.Parameters - .Select(x => - { - var customParserType = x.GetAttributes().FirstOrDefault(x => x.AttributeClass?.AllInterfaces.Any(y => y.Name == "IArgumentParser") ?? false); - var hasFromServices = x.GetAttributes().Any(x => x.AttributeClass?.Name == "FromServicesAttribute"); - var hasFromKeyedServices = x.GetAttributes().Any(x => x.AttributeClass?.Name == "FromKeyedServicesAttribute"); - var hasArgument = x.GetAttributes().Any(x => x.AttributeClass?.Name == "ArgumentAttribute"); - var hasValidation = x.GetAttributes().Any(x => x.AttributeClass?.GetBaseTypes().Any(y => y.Name == "ValidationAttribute") ?? false); - var isCancellationToken = SymbolEqualityComparer.Default.Equals(x.Type, wellKnownTypes.CancellationToken); - var isConsoleAppContext = x.Type!.Name == "ConsoleAppContext"; - var isHiddenParameter = x.GetAttributes().Any(x => x.AttributeClass?.Name == "HiddenAttribute"); - var isDefaultValueHidden = x.GetAttributes().Any(x => x.AttributeClass?.Name == "HideDefaultValueAttribute"); - - object? keyedServiceKey = null; - if (hasFromKeyedServices) - { - var attr = x.GetAttributes().First(x => x.AttributeClass?.Name == "FromKeyedServicesAttribute"); - keyedServiceKey = attr.ConstructorArguments[0].Value; - } - - string description = ""; - string[] aliases = []; - if (parameterDescriptions != null && parameterDescriptions.TryGetValue(x.Name, out var desc)) - { - ParseParameterDescription(desc, out aliases, out description); - } - - var argumentIndex = -1; - if (!(hasFromServices || isCancellationToken)) + Dictionary? parameterDescriptionMetadata = null; + if (parameterDescriptions != null) + { + parameterDescriptionMetadata = parameterDescriptions.ToDictionary( + static x => x.Key, + x => { - if (hasArgument) - { - argumentIndex = argumentIndexCounter++; - } - else - { - parsableIndex++; - } - } + ParseParameterDescription(x.Value, out var aliases, out var description); + return new ParameterDescription(aliases, description); + }); + } - var isNullableReference = x.NullableAnnotation == NullableAnnotation.Annotated && x.Type.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T; - - return new CommandParameter - { - Name = generatorOptions.DisableNamingConversion ? x.Name : NameConverter.ToKebabCase(x.Name), - WellKnownTypes = wellKnownTypes, - OriginalParameterName = x.Name, - IsNullableReference = isNullableReference, - IsConsoleAppContext = isConsoleAppContext, - IsParams = x.IsParams, - IsHidden = isHiddenParameter, - IsDefaultValueHidden = isDefaultValueHidden, - Location = x.DeclaringSyntaxReferences[0].GetSyntax().GetLocation(), - Type = new EquatableTypeSymbol(x.Type), - HasDefaultValue = x.HasExplicitDefaultValue, - DefaultValue = x.HasExplicitDefaultValue ? x.ExplicitDefaultValue : null, - CustomParserType = customParserType?.AttributeClass?.ToEquatable(), - IsCancellationToken = isCancellationToken, - IsFromServices = hasFromServices, - IsFromKeyedServices = hasFromKeyedServices, - KeyedServiceKey = keyedServiceKey, - HasValidation = hasValidation, - Aliases = aliases, - ArgumentIndex = argumentIndex, - Description = description - }; - }) - .ToArray(); + if (!TryBuildRuntimeAndParseParameters(methodSymbol.Parameters, parameterDescriptionMetadata, out var parameters, out var effectiveParseParameters, out var asParametersExpansionBindings)) + { + return null; + } var cmd = new Command { @@ -805,6 +588,8 @@ bool IsParsableType(ITypeSymbol type) IsVoid = isVoid, IsHidden = isHiddenCommand, Parameters = parameters, + EffectiveParseParameters = effectiveParseParameters, + AsParametersExpansionBindings = asParametersExpansionBindings, MethodKind = addressOf ? MethodKind.FunctionPointer : MethodKind.Method, Description = summary, DelegateBuildType = delegateBuildType, @@ -840,7 +625,7 @@ bool IsParsableType(ITypeSymbol type) // FunctionPointer can not use validation if (command.MethodKind == MethodKind.FunctionPointer) { - foreach (var p in command.Parameters) + foreach (var p in command.EffectiveParseParameters) { if (p.HasValidation) { @@ -850,11 +635,403 @@ bool IsParsableType(ITypeSymbol type) } } + var optionIdentifiers = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var parameter in command.EffectiveParseParameters) + { + if (!parameter.IsParsable || parameter.IsArgument) continue; + + if (!optionIdentifiers.Add("--" + parameter.Name)) + { + context.ReportDiagnostic(DiagnosticDescriptors.DuplicateOptionNameOrAlias, parameter.Location, "--" + parameter.Name); + hasDiagnostic = true; + } + + foreach (var alias in parameter.Aliases) + { + if (!optionIdentifiers.Add(alias)) + { + context.ReportDiagnostic(DiagnosticDescriptors.DuplicateOptionNameOrAlias, parameter.Location, alias); + hasDiagnostic = true; + } + } + } + if (hasDiagnostic) return null; return command; } + readonly record struct ParameterDescription(EquatableArray Aliases, string Description) + { + public static ParameterDescription Empty { get; } = new([], ""); + } + + struct ParameterBuildState + { + public int ParsableIndex; + public int ArgumentIndexCounter; + } + + bool TryBuildRuntimeAndParseParameters( + IEnumerable runtimeParameterSymbols, + IReadOnlyDictionary? runtimeParameterDescriptions, + out EquatableArray runtimeParameters, + out EquatableArray effectiveParseParameters, + out EquatableArray asParametersExpansionBindings) + { + var runtimeParameterCount = runtimeParameterSymbols switch + { + IReadOnlyCollection readOnlyCollection => readOnlyCollection.Count, + ICollection collection => collection.Count, + _ => -1 + }; + var runtimeParameterBuffer = runtimeParameterCount >= 0 ? new CommandParameter[runtimeParameterCount] : null; + var runtimeParameterList = runtimeParameterBuffer == null ? new List() : null; + List? parseParameterList = null; + var bindingBuffer = runtimeParameterCount > 0 ? new AsParametersBinding[runtimeParameterCount] : null; + var bindingCount = 0; + List? bindingList = null; + + var runtimeBuildState = new ParameterBuildState(); + var parseBuildState = new ParameterBuildState(); + + var runtimeParameterIndex = 0; + foreach (var runtimeParameterSymbol in runtimeParameterSymbols) + { + var parameterDescription = TryGetParameterDescription(runtimeParameterDescriptions, runtimeParameterSymbol.Name); + var runtimeParameter = BuildCommandParameter(runtimeParameterSymbol, parameterDescription, ref runtimeBuildState); + if (runtimeParameterBuffer == null) + { + runtimeParameterList!.Add(runtimeParameter); + } + else + { + runtimeParameterBuffer[runtimeParameterIndex] = runtimeParameter; + } + + if (!HasAttribute(runtimeParameterSymbol, "AsParametersAttribute")) + { + if (parseParameterList != null) + { + parseParameterList.Add(BuildEffectiveParseParameter(runtimeParameter, ref parseBuildState)); + } + else + { + AdvanceEffectiveParseState(runtimeParameter, ref parseBuildState); + } + runtimeParameterIndex++; + continue; + } + + if (parseParameterList == null) + { + parseParameterList = new List(runtimeParameterIndex + 4); + if (runtimeParameterBuffer == null) + { + for (int i = 0; i < runtimeParameterList!.Count - 1; i++) + { + parseParameterList.Add(runtimeParameterList[i]); + } + } + else + { + for (int i = 0; i < runtimeParameterIndex; i++) + { + parseParameterList.Add(runtimeParameterBuffer[i]); + } + } + } + + if (!TryGetAsParametersConstructor(runtimeParameterSymbol, out var targetType, out var constructor)) + { + runtimeParameters = []; + effectiveParseParameters = []; + asParametersExpansionBindings = []; + return false; + } + + if (!TryBuildAsParametersConstructorParameterDescriptions(constructor, out var constructorParameterDescriptions)) + { + runtimeParameters = []; + effectiveParseParameters = []; + asParametersExpansionBindings = []; + return false; + } + var parseIndexes = new int[constructor.Parameters.Length]; + var parseIndex = 0; + foreach (var constructorParameter in constructor.Parameters) + { + if (HasAttribute(constructorParameter, "AsParametersAttribute")) + { + context.ReportDiagnostic( + DiagnosticDescriptors.AsParametersNestedNotSupported, + GetParameterLocation(constructorParameter), + constructorParameter.Name, + targetType.ToDisplayString()); + runtimeParameters = []; + effectiveParseParameters = []; + asParametersExpansionBindings = []; + return false; + } + + if (constructorParameter.IsParams) + { + context.ReportDiagnostic( + DiagnosticDescriptors.AsParametersParamsNotSupported, + GetParameterLocation(constructorParameter), + constructorParameter.Name, + targetType.ToDisplayString()); + runtimeParameters = []; + effectiveParseParameters = []; + asParametersExpansionBindings = []; + return false; + } + + parseIndexes[parseIndex++] = parseParameterList.Count; + var constructorParameterDescription = TryGetParameterDescription(constructorParameterDescriptions, constructorParameter.Name); + parseParameterList.Add(BuildCommandParameter(constructorParameter, constructorParameterDescription, ref parseBuildState)); + } + + var binding = new AsParametersBinding + { + RuntimeParameterIndex = runtimeParameterIndex, + TargetType = new EquatableTypeSymbol(targetType), + ParseParameterIndexes = parseIndexes + }; + if (bindingBuffer == null) + { + (bindingList ??= new List(1)).Add(binding); + } + else + { + bindingBuffer[bindingCount++] = binding; + } + + runtimeParameterIndex++; + } + + runtimeParameters = runtimeParameterBuffer ?? runtimeParameterList!.ToArray(); + effectiveParseParameters = parseParameterList == null ? runtimeParameters : parseParameterList.ToArray(); + if (bindingBuffer == null) + { + asParametersExpansionBindings = bindingList == null ? [] : bindingList.ToArray(); + } + else if (bindingCount == 0) + { + asParametersExpansionBindings = []; + } + else if (bindingCount == bindingBuffer.Length) + { + asParametersExpansionBindings = bindingBuffer; + } + else + { + var trimmedBindings = new AsParametersBinding[bindingCount]; + Array.Copy(bindingBuffer, trimmedBindings, bindingCount); + asParametersExpansionBindings = trimmedBindings; + } + return true; + } + + bool TryBuildAsParametersConstructorParameterDescriptions(IMethodSymbol constructor, out IReadOnlyDictionary? parameterDescriptions) + { + parameterDescriptions = null; + if (constructor.DeclaringSyntaxReferences.Length == 0) + { + return true; + } + + var constructorSyntax = constructor.DeclaringSyntaxReferences[0].GetSyntax(); + var docComment = constructorSyntax.GetDocumentationCommentTriviaSyntax(); + if (docComment == null) + { + return true; + } + + var constructorParameterNames = new HashSet(StringComparer.Ordinal); + foreach (var constructorParameter in constructor.Parameters) + { + constructorParameterNames.Add(constructorParameter.Name); + } + + Dictionary? descriptionMap = null; + foreach (var (Name, Description) in docComment.GetParams()) + { + if (!constructorParameterNames.Contains(Name)) + { + context.ReportDiagnostic(DiagnosticDescriptors.DocCommentParameterNameNotMatched, constructorSyntax.GetLocation(), Name); + return false; + } + + ParseParameterDescription(Description, out var aliases, out var description); + descriptionMap ??= new(StringComparer.Ordinal); + descriptionMap[Name] = new ParameterDescription(aliases, description); + } + + parameterDescriptions = descriptionMap; + return true; + } + + CommandParameter BuildEffectiveParseParameter(CommandParameter runtimeParameter, ref ParameterBuildState buildState) + { + var argumentIndex = -1; + if (runtimeParameter.IsParsable) + { + if (runtimeParameter.IsArgument) + { + argumentIndex = buildState.ArgumentIndexCounter++; + } + else + { + buildState.ParsableIndex++; + } + } + + return argumentIndex == runtimeParameter.ArgumentIndex + ? runtimeParameter + : runtimeParameter with { ArgumentIndex = argumentIndex }; + } + + void AdvanceEffectiveParseState(CommandParameter runtimeParameter, ref ParameterBuildState buildState) + { + if (!runtimeParameter.IsParsable) return; + + if (runtimeParameter.IsArgument) + { + buildState.ArgumentIndexCounter++; + } + else + { + buildState.ParsableIndex++; + } + } + + bool TryGetAsParametersConstructor(IParameterSymbol runtimeParameter, out INamedTypeSymbol targetType, out IMethodSymbol constructor) + { + targetType = null!; + constructor = null!; + + if (runtimeParameter.Type is not INamedTypeSymbol namedType || !namedType.IsRecord || namedType.TypeKind != TypeKind.Class) + { + context.ReportDiagnostic( + DiagnosticDescriptors.AsParametersTargetMustBeRecordClass, + GetParameterLocation(runtimeParameter), + runtimeParameter.Name, + runtimeParameter.Type.ToDisplayString()); + return false; + } + + var publicConstructors = namedType.InstanceConstructors + .Where(x => x.DeclaredAccessibility == Accessibility.Public) + .ToArray(); + + if (publicConstructors.Length != 1) + { + context.ReportDiagnostic( + DiagnosticDescriptors.AsParametersTargetMustHaveSinglePublicConstructor, + GetParameterLocation(runtimeParameter), + namedType.ToDisplayString()); + return false; + } + + targetType = namedType; + constructor = publicConstructors[0]; + return true; + } + + CommandParameter BuildCommandParameter(IParameterSymbol parameterSymbol, ParameterDescription parameterDescription, ref ParameterBuildState buildState) + { + var attributes = parameterSymbol.GetAttributes(); + + var customParserType = attributes.FirstOrDefault(x => x.AttributeClass?.AllInterfaces.Any(y => y.Name == "IArgumentParser") ?? false); + var hasFromServices = attributes.Any(x => x.AttributeClass?.Name == "FromServicesAttribute"); + var hasFromKeyedServices = attributes.Any(x => x.AttributeClass?.Name == "FromKeyedServicesAttribute"); + var hasArgument = attributes.Any(x => x.AttributeClass?.Name == "ArgumentAttribute"); + var hasValidation = attributes.Any(x => x.AttributeClass?.GetBaseTypes().Any(y => y.Name == "ValidationAttribute") ?? false); + var isCancellationToken = SymbolEqualityComparer.Default.Equals(parameterSymbol.Type, wellKnownTypes.CancellationToken); + var isConsoleAppContext = parameterSymbol.Type.Name == "ConsoleAppContext"; + var isHiddenParameter = attributes.Any(x => x.AttributeClass?.Name == "HiddenAttribute"); + var isDefaultValueHidden = attributes.Any(x => x.AttributeClass?.Name == "HideDefaultValueAttribute"); + + object? keyedServiceKey = null; + if (hasFromKeyedServices) + { + var attr = attributes.First(x => x.AttributeClass?.Name == "FromKeyedServicesAttribute"); + if (attr.ConstructorArguments.Length != 0) + { + keyedServiceKey = attr.ConstructorArguments[0].Value; + } + } + + var argumentIndex = -1; + if (!(hasFromServices || hasFromKeyedServices || isCancellationToken || isConsoleAppContext)) + { + if (hasArgument) + { + argumentIndex = buildState.ArgumentIndexCounter++; + } + else + { + buildState.ParsableIndex++; + } + } + + var isNullableReference = parameterSymbol.NullableAnnotation == NullableAnnotation.Annotated + && parameterSymbol.Type.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T; + + return new CommandParameter + { + Name = generatorOptions.DisableNamingConversion ? parameterSymbol.Name : NameConverter.ToKebabCase(parameterSymbol.Name), + WellKnownTypes = wellKnownTypes, + OriginalParameterName = parameterSymbol.Name, + IsNullableReference = isNullableReference, + IsConsoleAppContext = isConsoleAppContext, + IsParams = parameterSymbol.IsParams, + IsHidden = isHiddenParameter, + IsDefaultValueHidden = isDefaultValueHidden, + Location = GetParameterLocation(parameterSymbol), + Type = new EquatableTypeSymbol(parameterSymbol.Type), + HasDefaultValue = parameterSymbol.HasExplicitDefaultValue, + DefaultValue = parameterSymbol.HasExplicitDefaultValue ? parameterSymbol.ExplicitDefaultValue : null, + CustomParserType = customParserType?.AttributeClass?.ToEquatable(), + IsCancellationToken = isCancellationToken, + IsFromServices = hasFromServices, + IsFromKeyedServices = hasFromKeyedServices, + KeyedServiceKey = keyedServiceKey, + HasValidation = hasValidation, + Aliases = parameterDescription.Aliases, + ArgumentIndex = argumentIndex, + Description = parameterDescription.Description + }; + } + + static ParameterDescription TryGetParameterDescription(IReadOnlyDictionary? map, string parameterName) + { + if (map != null && map.TryGetValue(parameterName, out var result)) + { + return result; + } + return ParameterDescription.Empty; + } + + static bool HasAttribute(IParameterSymbol parameterSymbol, string attributeName) + { + return parameterSymbol.GetAttributes().Any(x => x.AttributeClass?.Name == attributeName); + } + + Location GetParameterLocation(IParameterSymbol parameterSymbol) + { + if (parameterSymbol.DeclaringSyntaxReferences.Length != 0) + { + return parameterSymbol.DeclaringSyntaxReferences[0].GetSyntax().GetLocation(); + } + if (parameterSymbol.Locations.Length != 0) + { + return parameterSymbol.Locations[0]; + } + return node.GetLocation(); + } + void ParseParameterDescription(string originalDescription, out string[] aliases, out string description) { // Example: diff --git a/src/ConsoleAppFramework/RoslynExtensions.cs b/src/ConsoleAppFramework/RoslynExtensions.cs index b94f195..0d215e7 100644 --- a/src/ConsoleAppFramework/RoslynExtensions.cs +++ b/src/ConsoleAppFramework/RoslynExtensions.cs @@ -149,7 +149,7 @@ public static string GetSummary(this DocumentationCommentTriviaSyntax docComment var summary = docComment.Content.GetXmlElements("summary").FirstOrDefault() as XmlElementSyntax; if (summary == null) return ""; - return summary.Content.ToString().Replace("///", "").Trim(); + return NormalizeDocCommentText(summary.Content.ToString()); } public static IEnumerable<(string Name, string Description)> GetParams(this DocumentationCommentTriviaSyntax docComment) @@ -164,6 +164,31 @@ public static string GetSummary(this DocumentationCommentTriviaSyntax docComment yield break; } + static string NormalizeDocCommentText(string text) + { + var lines = text.Replace("///", "").Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + + var start = 0; + var end = lines.Length - 1; + while (start <= end && string.IsNullOrWhiteSpace(lines[start])) + { + start++; + } + while (end >= start && string.IsNullOrWhiteSpace(lines[end])) + { + end--; + } + + if (start > end) return ""; + + var normalized = new string[end - start + 1]; + for (var i = start; i <= end; i++) + { + normalized[i - start] = lines[i].Trim(); + } + return string.Join("\n", normalized); + } + public static void GetConstantValues(this ArgumentListSyntax argumentListSyntax, SemanticModel model, string name1, string name2, ref object? value1, ref object? value2) diff --git a/tests/ConsoleAppFramework.GeneratorTests/AsParametersTest.cs b/tests/ConsoleAppFramework.GeneratorTests/AsParametersTest.cs new file mode 100644 index 0000000..15a9cea --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/AsParametersTest.cs @@ -0,0 +1,421 @@ +namespace ConsoleAppFramework.GeneratorTests; + +[ClassDataSource] +public class AsParametersTest(VerifyHelper verifier) +{ + [Test] + public async Task BasicFlattenAndInvoke() + { + await verifier.Execute(""" +ConsoleApp.Run(args, ([AsParameters] CreateUserOptions options) => +{ + Console.Write($"{options.Name}:{options.Age}:{options.Level}"); +}); + +public record class CreateUserOptions( + string Name, + [Argument] int Age = 20, + int Level = 1); +""", "--name Alice 33 --level 4", "Alice:33:4"); + } + + [Test] + public async Task DefaultsAndNullableDefaults() + { + await verifier.Execute(""" +ConsoleApp.Run(args, ([AsParameters] Options options) => +{ + Console.Write($"{options.Name}:{(options.Level.HasValue ? options.Level.Value.ToString() : "null")}"); +}); + +public record class Options( + string Name = "anon", + int? Level = null); +""", "", "anon:null"); + } + + [Test] + public async Task ValidationFromConstructorParameter() + { + var (Stdout, ExitCode) = verifier.Error(""" +ConsoleApp.Log = x => Console.Write(x); +ConsoleApp.Run(args, ([AsParameters] Options options) => Console.Write("OK")); + +public record class Options( + [Range(1, 10)] int Level); +""", "--level 42"); + + await Assert.That(Stdout).Contains("between 1 and 10"); + await Assert.That(ExitCode).IsEqualTo(1); + } + + [Test] + public async Task ConstructorFromServices() + { + await verifier.Execute(""" +var di = new MiniDI(); +di.Register(typeof(MyService), new MyService("svc")); +ConsoleApp.ServiceProvider = di; + +ConsoleApp.Run(args, ([AsParameters] Options options) => +{ + Console.Write($"{options.Service.Name}:{options.Count}"); +}); + +public record class Options( + [FromServices] MyService Service, + int Count); + +public class MyService(string name) +{ + public string Name => name; +} + +class MiniDI : IServiceProvider +{ + System.Collections.Generic.Dictionary dict = new(); + + public void Register(Type type, object instance) + { + dict[type] = instance; + } + + public object? GetService(Type serviceType) + { + return dict.TryGetValue(serviceType, out var instance) ? instance : null; + } +} +""", "--count 9", "svc:9"); + } + + [Test] + public async Task HelpParityWithEquivalentExpandedCommand() + { + var (Stdout, ExitCode) = verifier.Error(""" +ConsoleApp.Log = x => Console.WriteLine(x); +ConsoleApp.Run(args, ([AsParameters] Options options) => { }); + +public record class Options( + string Name, + [Argument] int Age = 20, + bool Force = false); +""", "--help"); + + var expanded = verifier.Error(""" +ConsoleApp.Log = x => Console.WriteLine(x); +ConsoleApp.Run(args, (string name, [Argument] int age = 20, bool force = false) => { }); +""", "--help"); + + await Assert.That(Stdout).IsEqualTo(expanded.Stdout); + await Assert.That(ExitCode).IsEqualTo(expanded.ExitCode); + } + + [Test] + public async Task DirectMethodReference() + { + await verifier.Execute(""" +ConsoleApp.Run(args, Run); + +void Run([AsParameters] Options options) +{ + Console.Write($"{options.Value}:{options.Tag}"); +} + +public record class Options(int Value, string Tag = "x"); +""", "--value 7", "7:x"); + } + + [Test] + public async Task BuilderAddDelegateMethodReference() + { + await verifier.Execute(""" +var app = ConsoleApp.Create(); +app.Add("go", Go); +app.Run(args); + +void Go([AsParameters] Options options) +{ + for (var i = 0; i < options.Times; i++) + { + Console.Write(options.Name); + } +} + +public record class Options(string Name, int Times = 1); +""", "go --name hi --times 3", "hihihi"); + } + + [Test] + public async Task BuilderAddClassRegistration() + { + await verifier.Execute(""" +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +public class Commands +{ + public void Do([AsParameters] Options options) + { + Console.Write($"{options.First}:{options.Second}"); + } +} + +public record class Options(string First, [Argument] int Second); +""", "do --first A 9", "A:9"); + } + + [Test] + public async Task MixedWithRegularParameter() + { + await verifier.Execute(""" +ConsoleApp.Run(args, ([AsParameters] Options options, int repeat) => +{ + for (var i = 0; i < repeat; i++) + { + Console.Write(options.Name); + } +}); + +public record class Options(string Name); +""", "--name ab --repeat 3", "ababab"); + } + + [Test] + public async Task MixedWithArgumentOrdering() + { + await verifier.Execute(""" +ConsoleApp.Run(args, ([AsParameters] Options options, [Argument] int tail, int value) => +{ + Console.Write($"{options.Head}:{tail}:{value}"); +}); + +public record class Options([Argument] int Head); +""", "10 20 --value 30", "10:20:30"); + } + + [Test] + public async Task MixedWithCancellationToken() + { + await verifier.Execute(""" +ConsoleApp.Run(args, ([AsParameters] Options options, CancellationToken cancellationToken) => +{ + Console.Write($"{options.Name}:{cancellationToken.IsCancellationRequested}"); +}); + +public record class Options(string Name); +""", "--name test", "test:False"); + } + + [Test] + public async Task MixedWithConsoleAppContext() + { + await verifier.Execute(""" +var app = ConsoleApp.Create(); +app.Add("go", ([AsParameters] Options options, ConsoleAppContext context) => +{ + Console.Write($"{context.CommandName}:{options.Name}"); +}); +app.Run(args); + +public record class Options(string Name); +""", "go --name abc", "go:abc"); + } + + [Test] + public async Task MixedWithGlobalOptions() + { + await verifier.Execute(""" +var app = ConsoleApp.Create(); +app.ConfigureGlobalOptions((ref ConsoleApp.GlobalOptionsBuilder builder) => +{ + var env = builder.AddGlobalOption("--env", "", "dev"); + return env; +}); +app.Add("", ([AsParameters] Options options, ConsoleAppContext context) => +{ + Console.Write($"{context.GlobalOptions}:{options.Name}"); +}); +app.Run(args); + +public record class Options(string Name); +""", "--name neo --env prod", "prod:neo"); + } + + [Test] + public async Task MixedWithRegularFromServices() + { + await verifier.Execute(""" +var di = new MiniDI(); +di.Register(typeof(Service), new Service("svc")); +ConsoleApp.ServiceProvider = di; + +ConsoleApp.Run(args, ([AsParameters] Options options, [FromServices] Service service) => +{ + Console.Write($"{service.Name}:{options.Name}"); +}); + +public record class Options(string Name); + +public class Service(string name) +{ + public string Name => name; +} + +class MiniDI : IServiceProvider +{ + System.Collections.Generic.Dictionary dict = new(); + + public void Register(Type type, object instance) + { + dict[type] = instance; + } + + public object? GetService(Type serviceType) + { + return dict.TryGetValue(serviceType, out var instance) ? instance : null; + } +} +""", "--name mixed", "svc:mixed"); + } + + [Test] + public async Task MixedWithMultipleAsParameters() + { + await verifier.Execute(""" +ConsoleApp.Run(args, ([AsParameters] UserOptions user, int level, [AsParameters] ModeOptions mode) => +{ + Console.Write($"{user.Name}:{level}:{mode.Enabled}"); +}); + +public record class UserOptions(string Name); +public record class ModeOptions(bool Enabled = false); +""", "--name Bob --level 9 --enabled", "Bob:9:True"); + } + + [Test] + public async Task ConstructorXmlParamAliasesAndDescriptions() + { + var code = """ +ConsoleApp.Log = x => Console.WriteLine(x); +ConsoleApp.Run(args, ([AsParameters] Options options) => +{ + Console.Write($"{options.Name}:{options.Age}"); +}); + +/// +/// Options for command. +/// +/// -n, Name from doc. +/// -a, Age from doc. +public record class Options(string Name, int Age); +"""; + + await verifier.Execute(code, "-n Bob -a 21", "Bob:21"); + + var (stdout, exitCode) = verifier.Error(code, "--help"); + await Assert.That(stdout).Contains("-n, --name "); + await Assert.That(stdout).Contains("Name from doc."); + await Assert.That(stdout).Contains("-a, --age "); + await Assert.That(stdout).Contains("Age from doc."); + await Assert.That(exitCode).IsEqualTo(0); + } + + [Test] + public async Task ConstructorFromKeyedServices() + { + await verifier.Execute(""" +var di = new MiniDI(); +di.Register(typeof(MyService), "svc-key", new MyService("svc")); +ConsoleApp.ServiceProvider = di; + +ConsoleApp.Run(args, ([AsParameters] Options options) => +{ + Console.Write($"{options.Service.Name}:{options.Count}"); +}); + +public record class Options( + [FromKeyedServices("svc-key")] MyService Service, + int Count); + +public class MyService(string name) +{ + public string Name => name; +} + +namespace Microsoft.Extensions.DependencyInjection +{ + public interface IKeyedServiceProvider : IServiceProvider + { + object? GetKeyedService(Type serviceType, object? serviceKey); + } +} + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +public sealed class FromKeyedServicesAttribute(object? key) : Attribute +{ + public object? Key { get; } = key; +} + +class MiniDI : Microsoft.Extensions.DependencyInjection.IKeyedServiceProvider +{ + System.Collections.Generic.Dictionary<(Type Type, object? Key), object> dict = new(); + + public void Register(Type type, object? key, object instance) + { + dict[(type, key)] = instance; + } + + public object? GetService(Type serviceType) + { + return null; + } + + public object? GetKeyedService(Type serviceType, object? serviceKey) + { + return dict.TryGetValue((serviceType, serviceKey), out var instance) ? instance : null; + } +} +""", "--count 9", "svc:9"); + } + + [Test] + public async Task ConstructorHiddenAndHideDefaultValue() + { + var (stdout, exitCode) = verifier.Error(""" +ConsoleApp.Log = x => Console.WriteLine(x); +ConsoleApp.Run(args, ([AsParameters] Options options) => { }); + +public record class Options( + [Hidden] int Secret, + [HideDefaultValue] int Level = 10); +""", "--help"); + + await Assert.That(stdout.Contains("--secret")).IsFalse(); + await Assert.That(stdout).Contains("--level "); + await Assert.That(stdout.Contains("[Default: 10]")).IsFalse(); + await Assert.That(exitCode).IsEqualTo(0); + } + + [Test] + public async Task ConstructorCustomParser() + { + await verifier.Execute(""" +ConsoleApp.Run(args, ([AsParameters] Options options) => +{ + Console.Write(options.Value); +}); + +public record class Options([HexIntParser] int Value); + +[AttributeUsage(AttributeTargets.Parameter)] +public class HexIntParserAttribute : Attribute, IArgumentParser +{ + public static bool TryParse(ReadOnlySpan s, out int result) + { + return int.TryParse(s, global::System.Globalization.NumberStyles.HexNumber, null, out result); + } +} +""", "--value ff", "255"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/DiagnosticsTest.cs b/tests/ConsoleAppFramework.GeneratorTests/DiagnosticsTest.cs index ae9d5d7..bbb235a 100644 --- a/tests/ConsoleAppFramework.GeneratorTests/DiagnosticsTest.cs +++ b/tests/ConsoleAppFramework.GeneratorTests/DiagnosticsTest.cs @@ -455,6 +455,17 @@ public async Task Bar(string msg) } + [Test] + public async Task AsParametersDocCommentName() + { + await verifier.Verify(15, """ +ConsoleApp.Run(args, ([AsParameters] Options options) => { }); + +/// -n, does not exist. +public record class Options(string Name); +""", "public record class Options(string Name);"); + } + [Test] public async Task AsyncVoid() { @@ -510,4 +521,89 @@ await verifier.Verify(18, """ """, "builder.AddGlobalOption(\"foo\")"); } + [Test] + public async Task AsParametersTargetMustBeRecordClass() + { + await verifier.Verify(19, """ +ConsoleApp.Run(args, ([AsParameters] Options options) => { }); + +public class Options +{ + public string Name { get; set; } = ""; +} +""", "[AsParameters] Options options"); + } + + [Test] + public async Task AsParametersTargetMustHaveSinglePublicConstructor() + { + await verifier.Verify(20, """ +ConsoleApp.Run(args, ([AsParameters] Options options) => { }); + +public record class Options(string Name) +{ + public Options() : this("x") + { + } +} +""", "[AsParameters] Options options"); + } + + [Test] + public async Task AsParametersNestedNotSupported() + { + await verifier.Verify(21, """ +ConsoleApp.Run(args, ([AsParameters] Outer options) => { }); + +public record class Inner(string Name); +public record class Outer([AsParameters] Inner Inner); +""", "[AsParameters] Inner Inner"); + } + + [Test] + public async Task AsParametersParamsNotSupported() + { + await verifier.Verify(22, """ +ConsoleApp.Run(args, ([AsParameters] Options options) => { }); + +public record class Options(params string[] Values); +""", "params string[] Values"); + } + + [Test] + public async Task AsParametersFunctionPointerValidation() + { + await verifier.Verify(5, """ +unsafe +{ + ConsoleApp.Run(args, &Run2); + static void Run2([AsParameters] Options options) + { + } +} + +public record class Options([Range(1,10)] int X); +""", "[Range(1,10)] int X"); + } + + [Test] + public async Task DuplicateOptionName_AsParametersAndRegular() + { + await verifier.Verify(23, """ +ConsoleApp.Run(args, ([AsParameters] Options options, string name) => { }); + +public record class Options(string Name); +""", "string name"); + } + + [Test] + public async Task DuplicateAlias_AsParametersAndRegular() + { + await verifier.Verify(23, """ +ConsoleApp.Run(args, ([AsParameters] Options options, string value) => { }); + +/// --value, duplicate alias. +public record class Options(string Name); +""", "string value"); + } } diff --git a/tests/ConsoleAppFramework.GeneratorTests/HelpTest.cs b/tests/ConsoleAppFramework.GeneratorTests/HelpTest.cs index 95fb0b5..b013802 100644 --- a/tests/ConsoleAppFramework.GeneratorTests/HelpTest.cs +++ b/tests/ConsoleAppFramework.GeneratorTests/HelpTest.cs @@ -66,8 +66,8 @@ await verifier.Execute(code: """ Usage: [options...] [-h|--help] [--version] Options: - --x [Required] - --y [Required] + --x [Required] + --y [Required] """); } @@ -100,8 +100,8 @@ await verifier.Execute(code: """ Usage: [options...] [-h|--help] [--version] Options: - --x [Required] - --y [Required] + --x [Required] + --y [Required] """); } @@ -144,8 +144,8 @@ await verifier.Execute(code, args: "--help", expected: """ Usage: [command] [options...] [-h|--help] [--version] Options: - --x [Required] - --y [Required] + --x [Required] + --y [Required] Commands: a @@ -189,8 +189,8 @@ await verifier.Execute(code, args: "a b c --help", expected: """ Usage: a b c [options...] [-h|--help] [--version] Options: - --x [Required] - --y [Required] + --x [Required] + --y [Required] """); } @@ -232,6 +232,34 @@ hello my world. Options: -f, -fb, --foo-bar my foo is not bar. [Required] +"""); + } + + [Test] + public async Task SummaryMultiline() + { + await verifier.Execute(code: """ +ConsoleApp.Log = x => Console.WriteLine(x); +ConsoleApp.Run(args, Root); + +/// +/// Processes an input file. +/// Writes processed output to standard output. +/// +void Root(string name) +{ +} +""", + args: "--help", + expected: """ +Usage: [options...] [-h|--help] [--version] + +Processes an input file. +Writes processed output to standard output. + +Options: + --name [Required] + """); } @@ -288,8 +316,8 @@ await verifier.Execute(code: """ Usage: [options...] [-h|--help] [--version] Options: - --x [Default: null] - --y [Default: null] + --x [Default: null] + --y [Default: null] """); } @@ -311,8 +339,8 @@ enum Fruit Usage: [options...] [-h|--help] [--version] Options: - --my-fruit [Default: Apple] - --more-fruit [Default: null] + --my-fruit [Default: Apple] + --more-fruit [Default: null] """); } @@ -406,8 +434,8 @@ await verifier.Execute(code, args: "a --help", expected: """ Usage: a [options...] [-h|--help] [--version] Options: - --x [Required] - --y [Required] + --x [Required] + --y [Required] --parameter param global [Default: 1000] --dry-run run dry dry --p2, --p3 param 2 [Required] diff --git a/tests/ConsoleAppFramework.GeneratorTests/HiddenAttributeTest.cs b/tests/ConsoleAppFramework.GeneratorTests/HiddenAttributeTest.cs index ff140cb..c25180b 100644 --- a/tests/ConsoleAppFramework.GeneratorTests/HiddenAttributeTest.cs +++ b/tests/ConsoleAppFramework.GeneratorTests/HiddenAttributeTest.cs @@ -18,7 +18,7 @@ await verifier.Execute(code, args: "--help", expected: Usage: [options...] [-h|--help] [--version] Options: - --x [Required] + --x [Required] """); } @@ -73,7 +73,7 @@ await verifier.Execute(code, args: "command3 --help", expected: Usage: command3 [options...] [-h|--help] [--version] Options: - --x [Required] + --x [Required] """);