diff --git a/ReadMe.md b/ReadMe.md index a9a2d72..eca292e 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -428,7 +428,7 @@ app.Run(args); You can also combine this with `Add` or `Add` to add more commands. -### Alias command +### Alias command Similar to option aliases, commands also support aliases. In the `Add` method, separating `commandName` or `[Command]` with `|` defines them as aliases. @@ -598,7 +598,7 @@ The method parameter names and types determine how to parse and bind values from ```csharp ConsoleApp.Run(args, ( [Argument]DateTime dateTime, // Argument - [Argument]Guid guidvalue, // + [Argument]Guid guidvalue, // int intVar, // required bool boolFlag, // flag MyEnum enumValue, // enum @@ -608,7 +608,7 @@ ConsoleApp.Run(args, ( double? nullableValue = null, // nullable params string[] paramsArray // params ) => { }); -``` +``` When using `ConsoleApp.Run`, you can check the syntax of the command line in the tooltip to see how it is generated. @@ -648,6 +648,152 @@ If you want to change the deserialization options, you can set `JsonSerializerOp > NOTE: If they are not set when NativeAOT is used, a runtime exception may occur. If they are included in the parsing process, be sure to set source generated options. +### Class-Based Parameter Binding with [AsParameters] + +When commands have many parameters, defining them inline becomes unwieldy. The `[AsParameters]` attribute enables class-based parameter binding, where a class or record's members become command options. + +```csharp +// Both classes and records are supported +public record ServerConfig( + string Host = "localhost", // Optional with default + int Port = 8080, // Optional with default + bool Verbose = false, // Bool options are flags + string[] AllowedOrigins = null // Arrays use comma-separated values +); + +// All parameters become CLI options: --host, --port, --verbose, --allowed-origins +ConsoleApp.Run(args, ([AsParameters] ServerConfig config) => +{ + Console.WriteLine($"Starting server on {config.Host}:{config.Port}"); +}); +// Usage: app --host 0.0.0.0 --port 3000 --verbose --allowed-origins http://a.com,http://b.com +``` + +**Why use [AsParameters]?** It provides a cleaner way to organize commands with many parameters, enables reuse of parameter groups across commands, and supports primary constructors and records for immutable configuration. + +Supported types include are the same defined in the [Parse and Value Binding](#parse-and-value-binding) section. + +#### Required Parameters + +Use the `required` modifier or constructor parameters without defaults to mark options as required: + +```csharp +public record DeployConfig( + string Environment, // Required (no default) + int Replicas = 1 // Optional +) +{ + public required string ImageTag { get; init; } // Required via 'required' modifier +} +``` + +#### Positional Arguments + +Use `[Argument]` attribute or the `argument,` XML doc tag for positional (non-named) parameters: + +```csharp +public record BuildConfig( + [property: Argument] string ProjectPath, // [0] positional argument + string Configuration = "Release" +); + +// Or using XML doc: +/// Copy options +/// argument, The source file +/// argument, The destination file +public record CopyConfig(string source, string destination); + +ConsoleApp.Run(args, ([AsParameters] CopyConfig config) => { }); +// Usage: app ./source.txt ./dest.txt +``` + +#### Aliases and Descriptions + +Use XML doc comments for aliases and descriptions, same as regular parameters: + +```csharp +public record ServerConfig +{ + /// -h|--host, Server hostname to bind. + public string Host { get; init; } = "localhost"; + + /// -p, Port number. + public int Port { get; init; } = 8080; +} +``` + +#### Mixed Constructor and Property Binding + +Combine constructor parameters with settable properties for flexible initialization: + +```csharp +public record MixedConfig( + string Name, // Required constructor param + int Count = 10 // Optional constructor param +) +{ + public bool Verbose { get; set; } // Optional property (flag) + public required string OutputPath { get; init; } // Required property +} + +ConsoleApp.Run(args, ([AsParameters] MixedConfig config) => { }); +// Usage: app --name myapp --output-path ./out --verbose --count 5 +``` + +#### Multiple [AsParameters] Parameters with Prefixes + +Use multiple `[AsParameters]` parameters to compose configuration from separate types. Use `[AsParameters(Prefix = "prefix")]` to namespace options and avoid conflicts: + +```csharp +public record SourceConfig(string Host = "localhost", int Port = 5432); +public record TargetConfig(string Host = "localhost", int Port = 5432); + +ConsoleApp.Run(args, ( + [AsParameters(Prefix = "source")] SourceConfig source, + [AsParameters(Prefix = "target")] TargetConfig target +) => +{ + Console.WriteLine($"Migrating from {source.Host}:{source.Port} to {target.Host}:{target.Port}"); +}); +// Usage: app --source-host db1.local --source-port 5432 --target-host db2.local --target-port 5433 +``` + +The prefix is prepended to each option name with a hyphen separator. + +#### Combining [AsParameters] with Typed Global Options + +When your `[AsParameters]` type inherits from the global options type registered via `ConfigureGlobalOptions()`, the inherited properties are automatically populated from the parsed global options. This enables shared configuration across commands without repetition. + +```csharp +public record GlobalOptions +{ + /// -v|--verbose, Enable verbose output. + public bool Verbose { get; init; } +} + +// Inherits Verbose from GlobalOptions +public record DeployConfig : GlobalOptions +{ + public string Environment { get; init; } = "staging"; + public int Replicas { get; init; } = 1; +} + +var app = ConsoleApp.Create(); +app.ConfigureGlobalOptions(); // Register global options + +app.Add("deploy", ([AsParameters] DeployConfig config) => +{ + // config.Verbose comes from global options (parsed before command routing) + // config.Environment and config.Replicas are command-specific + if (config.Verbose) Console.WriteLine($"Deploying to {config.Environment}..."); +}); + +app.Run(args); +// Usage: app --verbose deploy --environment production --replicas 3 +``` + +The global options can appear before or after the command name, and the `[AsParameters]` type receives both the inherited global values and command-specific options. + ### GlobalOptions By calling `ConfigureGlobalOptions` on `ConsoleAppBuilder`, you can define global options that are enabled for all commands. For example, `--dry-run` or `--verbose` are suitable as common options. @@ -735,6 +881,36 @@ internal class Commands(GlobalOptions globalOptions) } ``` +### Typed Global Options + +For simpler scenarios, `ConfigureGlobalOptions` provides a type-based approach that avoids manual builder wiring. Define your options as any class or record with a parameterless constructor and the framework handles parsing automatically. + +```csharp +public record GlobalOptions +{ + /// -v|--verbose, Enable verbose output. + public bool Verbose { get; init; } + + /// Log output path. + public string LogPath { get; init; } = "app.log"; +} + +var app = ConsoleApp.Create(); +app.ConfigureGlobalOptions(); // Register the typed global options +app.Add(); +app.Run(args); + +// Access via constructor injection +public class Commands(GlobalOptions options) +{ + public void Run() => Console.WriteLine($"Verbose: {options.Verbose}, Log: {options.LogPath}"); +} +``` + +Typed global options use the same XML doc comment conventions as regular parameters for aliases and descriptions. Global options are parsed before command routing, allowing them to appear anywhere on the command line. + +> NOTE: `ConfigureGlobalOptions` can only be called once. You cannot mix typed `ConfigureGlobalOptions()` with the builder-based `ConfigureGlobalOptions((ref builder) => ...)` approach—they are mutually exclusive alternatives. + ### Custom Value Converter To perform custom binding to existing types that do not support `ISpanParsable`, you can create and set up a custom parser. For example, if you want to pass `System.Numerics.Vector3` as a comma-separated string like `1.3,4.12,5.947` and parse it, you can create an `Attribute` with `AttributeTargets.Parameter` that implements `IArgumentParser`'s `static bool TryParse(ReadOnlySpan s, out Vector3 result)` as follows: @@ -871,7 +1047,7 @@ await ConsoleApp.RunAsync(args, async Task (string url, CancellationToken c }); ``` -If the method throws an unhandled exception, ConsoleAppFramework always set `1` to the exit code. Also, in that case, output `Exception.ToString` to `ConsoleApp.LogError` (the default is `Console.WriteLine`). If you want to modify this code, please create a custom filter. For more details, refer to the [Filter](#filtermiddleware-pipline--consoleappcontext) section. +If the method throws an unhandled exception, ConsoleAppFramework always set `1` to the exit code. Also, in that case, output `Exception.ToString` to `ConsoleApp.LogError` (the default is `Console.WriteLine`). If you want to modify this code, please create a custom filter. For more details, refer to the [Filter](#filtermiddleware-pipline--consoleappcontext) section. Attribute based parameters validation --- diff --git a/src/ConsoleAppFramework/Command.cs b/src/ConsoleAppFramework/Command.cs index 9a9a585..eb0158f 100644 --- a/src/ConsoleAppFramework/Command.cs +++ b/src/ConsoleAppFramework/Command.cs @@ -192,7 +192,9 @@ public record class CommandParameter public bool IsArgument => ArgumentIndex != -1; public required EquatableArray Aliases { get; init; } public required string Description { get; init; } - public bool RequireCheckArgumentParsed => !(HasDefaultValue || IsParams || IsFlag); + public required ObjectBindingInfo? ObjectBinding { get; init; } + public bool IsBound => ObjectBinding != null; + public bool RequireCheckArgumentParsed => !(HasDefaultValue || IsParams || IsFlag || IsBound); // increment = false when passed from [Argument] public string BuildParseMethod(int argCount, string argumentName, bool increment) @@ -518,6 +520,7 @@ public CommandParameter ToDummyCommandParameter() HasValidation = false, ArgumentIndex = -1, IsDefaultValueHidden = false, + ObjectBinding = null, Type = Type, HasDefaultValue = !IsRequired, // if not required, needs defaultValue @@ -528,3 +531,113 @@ public CommandParameter ToDummyCommandParameter() }; } } + +/// +/// Represents typed global options registered via ConfigureGlobalOptions<T>() +/// +public record class TypedGlobalOptionsInfo +{ + public required EquatableTypeSymbol Type { get; init; } + public required ObjectBindingInfo ObjectBinding { get; init; } +} + +/// +/// Represents a bindable property (either from constructor parameter or settable property) +/// +public sealed record class BindablePropertyInfo : IEquatable +{ + public required string CliName { get; init; } // "--force" or "[0]" for arguments + public required EquatableTypeSymbol Type { get; init; } + public required bool HasDefaultValue { get; init; } + public required object? DefaultValue { get; init; } + public required string PropertyName { get; init; } // "Force" + public required string PropertyAccessPath { get; init; } // "Nested.Force" for nested objects + public required EquatableArray ParentPath { get; init; } // ["Nested"] for nested objects + public required string Description { get; init; } + public required EquatableArray Aliases { get; init; } // Short aliases like "-f" for "--force" + public required bool IsRequired { get; init; } + public required bool IsConstructorParameter { get; init; } // True if from primary constructor + public required int ConstructorParameterIndex { get; init; } // Position in constructor (-1 if not) + public required bool IsInitOnly { get; init; } // True if init-only property + public required int ArgumentIndex { get; init; } // -1 if not an argument, otherwise the positional index + public required bool IsFromGlobalOptions { get; init; } // True if inherited from [GlobalOptions] type + public required ParseInfo ParseInfo { get; init; } // Type classification for parsing + public bool IsArgument => ArgumentIndex != -1; + public bool IsFlag => Type.SpecialType == SpecialType.System_Boolean; + + public bool Equals(BindablePropertyInfo? other) + { + if (other is null) return false; + return CliName == other.CliName && + Type.Equals(other.Type) && + HasDefaultValue == other.HasDefaultValue && + Equals(DefaultValue, other.DefaultValue) && + PropertyName == other.PropertyName && + PropertyAccessPath == other.PropertyAccessPath && + ParentPath.Equals(other.ParentPath) && + Description == other.Description && + Aliases.Equals(other.Aliases) && + IsRequired == other.IsRequired && + IsConstructorParameter == other.IsConstructorParameter && + ConstructorParameterIndex == other.ConstructorParameterIndex && + IsInitOnly == other.IsInitOnly && + ArgumentIndex == other.ArgumentIndex && + IsFromGlobalOptions == other.IsFromGlobalOptions && + ParseInfo.Equals(other.ParseInfo); + } + + public override int GetHashCode() => CliName.GetHashCode(); +} + +/// +/// Represents the binding info for an [AsParameters] parameter's type +/// +public sealed record class ObjectBindingInfo : IEquatable +{ + public required EquatableTypeSymbol BoundType { get; init; } + public required EquatableArray Properties { get; init; } + public required bool HasPrimaryConstructor { get; init; } + public required EquatableArray ConstructorParameters { get; init; } + public required string? CustomPrefix { get; init; } // Custom prefix if specified in attribute + public EquatableTypeSymbol? GlobalOptionsBaseType { get; init; } // The [GlobalOptions] base type if inheriting from one + + public bool Equals(ObjectBindingInfo? other) + { + if (other is null) return false; + return BoundType.Equals(other.BoundType) && + Properties.Equals(other.Properties) && + HasPrimaryConstructor == other.HasPrimaryConstructor && + ConstructorParameters.Equals(other.ConstructorParameters) && + CustomPrefix == other.CustomPrefix && + Equals(GlobalOptionsBaseType, other.GlobalOptionsBaseType); + } + + public override int GetHashCode() => BoundType.GetHashCode(); +} + +/// +/// Represents a constructor parameter for object binding +/// +public sealed record class ConstructorParameterInfo : IEquatable +{ + public required string Name { get; init; } + public required EquatableTypeSymbol Type { get; init; } + public required bool HasDefaultValue { get; init; } + public required object? DefaultValue { get; init; } + public required int Index { get; init; } + public required int ArgumentIndex { get; init; } // -1 if not an argument, otherwise the positional index + public bool IsArgument => ArgumentIndex != -1; + + public bool Equals(ConstructorParameterInfo? other) + { + if (other is null) return false; + return Name == other.Name && + Type.Equals(other.Type) && + HasDefaultValue == other.HasDefaultValue && + Equals(DefaultValue, other.DefaultValue) && + Index == other.Index && + ArgumentIndex == other.ArgumentIndex; + } + + public override int GetHashCode() => Name.GetHashCode(); +} diff --git a/src/ConsoleAppFramework/CommandHelpBuilder.cs b/src/ConsoleAppFramework/CommandHelpBuilder.cs index bf940fe..cde0580 100644 --- a/src/ConsoleAppFramework/CommandHelpBuilder.cs +++ b/src/ConsoleAppFramework/CommandHelpBuilder.cs @@ -10,14 +10,14 @@ public static string BuildRootHelpMessage(Command command) return BuildHelpMessageCore(command, showCommandName: false, showCommand: false); } - public static string BuildRootHelpMessage(Command[] commands) + public static string BuildRootHelpMessage(Command[] commands, TypedGlobalOptionsInfo? typedGlobalOptions = null) { var sb = new StringBuilder(); var rootCommand = commands.FirstOrDefault(x => x.IsRootCommand); var withoutRoot = commands.Where(x => !x.IsRootCommand).ToArray(); - if (rootCommand != null && withoutRoot.Length == 0) + if (rootCommand != null && withoutRoot.Length == 0 && typedGlobalOptions == null) { return BuildRootHelpMessage(commands[0]); } @@ -32,6 +32,12 @@ public static string BuildRootHelpMessage(Command[] commands) sb.AppendLine(); } + // Add Global Options section if typed global options are configured + if (typedGlobalOptions != null) + { + sb.AppendLine(BuildTypedGlobalOptionsMessage(typedGlobalOptions)); + } + if (withoutRoot.Length == 0) return sb.ToString(); var helpDefinitions = withoutRoot.OrderBy(x => x.Name).ToArray(); @@ -42,6 +48,84 @@ public static string BuildRootHelpMessage(Command[] commands) return sb.ToString(); } + public static string BuildTypedGlobalOptionsMessage(TypedGlobalOptionsInfo typedGlobalOptions) + { + var sb = new StringBuilder(); + sb.AppendLine("Global Options:"); + + var optionsFormatted = typedGlobalOptions.ObjectBinding.Properties + .Where(p => p.ParentPath.Length == 0 && !p.IsArgument) + .Select(p => + { + // Build option name with aliases (e.g., "-v, --verbose") + var allOptions = new List(); + foreach (var alias in p.Aliases) + { + allOptions.Add(alias); + } + // Only add CliName if not already in aliases + if (!p.Aliases.Contains(p.CliName)) + { + allOptions.Add(p.CliName); + } + var optionName = string.Join(", ", allOptions); + + var isFlag = p.IsFlag; + var typeName = GetShortTypeName(p.Type.TypeSymbol); + var formatted = isFlag ? optionName : $"{optionName} <{typeName}>"; + + string? defaultValue = null; + if (p.HasDefaultValue && p.DefaultValue != null) + { + defaultValue = FormatDefaultValueForHelp(p.DefaultValue); + if (isFlag && p.DefaultValue is false) + { + defaultValue = null; + } + } + + return (Option: formatted, Description: p.Description, IsFlag: isFlag, DefaultValue: defaultValue); + }) + .ToArray(); + + if (optionsFormatted.Length == 0) return ""; + + var maxWidth = optionsFormatted.Max(x => x.Option.Length); + + var first = true; + foreach (var opt in optionsFormatted) + { + if (first) + { + first = false; + } + else + { + sb.AppendLine(); + } + + var padding = maxWidth - opt.Option.Length; + sb.Append(" "); + sb.Append(opt.Option); + + for (var i = 0; i < padding; i++) + { + sb.Append(' '); + } + + sb.Append(" "); + sb.Append(opt.Description); + + if (!opt.IsFlag && opt.DefaultValue != null) + { + sb.Append($" [default: {opt.DefaultValue}]"); + } + } + + sb.AppendLine(); + return sb.ToString(); + } + public static string BuildCommandHelpMessage(Command command) { return BuildHelpMessageCore(command, showCommandName: command.Name != "", showCommand: false); @@ -267,6 +351,82 @@ static CommandHelpDefinition CreateCommandHelpDefinition(Command descriptor) foreach (var item in descriptor.Parameters) { + // Handle [AsParameters] parameters by expanding their properties + if (item.IsBound && item.ObjectBinding != null) + { + // Calculate base argument index for this [AsParameters] parameter + // (after any preceding regular [Argument] params) + var baseArgIndex = descriptor.Parameters + .TakeWhile(p => p != item) + .Where(p => p.IsArgument) + .Count(); + + foreach (var prop in item.ObjectBinding.Properties) + { + // Skip properties inherited from global options (they appear in Global Options section) + if (prop.IsFromGlobalOptions) continue; + + var propOptions = new List(); + int? propIndex = null; + + if (prop.IsArgument) + { + // This is a positional argument + var globalArgIndex = baseArgIndex + prop.ArgumentIndex; + propOptions.Add($"[{globalArgIndex}]"); + propIndex = globalArgIndex; + } + else + { + // Add aliases first (e.g., -h before --host) + foreach (var alias in prop.Aliases) + { + propOptions.Add(alias); + } + // Only add CliName if not already in aliases + if (!prop.Aliases.Contains(prop.CliName)) + { + propOptions.Add(prop.CliName); + } + } + + var propDescription = prop.Description; + var propIsFlag = prop.IsFlag && !prop.IsArgument; // Arguments are never flags + var propIsParams = false; + var propIsHidden = false; + var propIsDefaultValueHidden = false; + + var propDefaultValue = default(string); + if (prop.HasDefaultValue && prop.DefaultValue != null) + { + propDefaultValue = FormatDefaultValueForHelp(prop.DefaultValue); + if (propIsFlag && prop.DefaultValue is false) + { + propDefaultValue = null; + } + } + else if (prop.HasDefaultValue && !prop.IsArgument) + { + // Has default but we don't know the value - use placeholder to prevent [Required] tag + propDefaultValue = "(default)"; + propIsDefaultValueHidden = true; + } + + var propTypeName = GetShortTypeName(prop.Type.TypeSymbol); + parameterDefinitions.Add(new CommandOptionHelpDefinition( + propOptions.ToArray(), + propDescription, + propTypeName, + propDefaultValue, + propIndex, + propIsFlag, + propIsParams, + propIsHidden, + propIsDefaultValueHidden)); + } + continue; + } + // ignore DI params. if (!item.IsParsable) continue; @@ -292,7 +452,7 @@ static CommandHelpDefinition CreateCommandHelpDefinition(Command descriptor) } var description = item.Description; - var isFlag = item.Type.SpecialType == Microsoft.CodeAnalysis.SpecialType.System_Boolean; + var isFlag = item.Type.SpecialType == SpecialType.System_Boolean; var isParams = item.IsParams; var isHidden = item.IsHidden; var isDefaultValueHidden = item.IsDefaultValueHidden; @@ -328,6 +488,52 @@ static CommandHelpDefinition CreateCommandHelpDefinition(Command descriptor) ); } + static string FormatDefaultValueForHelp(object value) + { + if (value is string s) + { + return s; + } + if (value is bool b) + { + return b ? "true" : "false"; + } + return value.ToString() ?? "null"; + } + + static string GetShortTypeName(ITypeSymbol type) + { + // Handle nullable types + if (type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + var namedType = (INamedTypeSymbol)type; + return GetShortTypeName(namedType.TypeArguments[0]) + "?"; + } + + // Use simple names for common types + switch (type.SpecialType) + { + case SpecialType.System_Boolean: return "bool"; + case SpecialType.System_Byte: return "byte"; + case SpecialType.System_SByte: return "sbyte"; + case SpecialType.System_Int16: return "short"; + case SpecialType.System_UInt16: return "ushort"; + case SpecialType.System_Int32: return "int"; + case SpecialType.System_UInt32: return "uint"; + case SpecialType.System_Int64: return "long"; + case SpecialType.System_UInt64: return "ulong"; + case SpecialType.System_Single: return "float"; + case SpecialType.System_Double: return "double"; + case SpecialType.System_Decimal: return "decimal"; + case SpecialType.System_Char: return "char"; + case SpecialType.System_String: return "string"; + case SpecialType.System_DateTime: return "DateTime"; + } + + // For enums and other types, use the simple name + return type.Name; + } + class CommandHelpDefinition { public string CommandName { get; } diff --git a/src/ConsoleAppFramework/ConsoleAppBaseCode.cs b/src/ConsoleAppFramework/ConsoleAppBaseCode.cs index fd0f373..12e6169 100644 --- a/src/ConsoleAppFramework/ConsoleAppBaseCode.cs +++ b/src/ConsoleAppFramework/ConsoleAppBaseCode.cs @@ -151,11 +151,21 @@ internal sealed class FromServicesAttribute : Attribute { } -[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ArgumentAttribute : Attribute { } +/// +/// Groups command-line options into a single parameter object (similar to ASP.NET Core's AsParameters). +/// Maps constructor parameters and properties to options; properties become --property-name options. +/// +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +internal sealed class AsParametersAttribute : Attribute +{ + public string? Prefix { get; set; } +} + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] internal sealed class CommandAttribute : Attribute { @@ -570,6 +580,17 @@ public ConsoleAppBuilder ConfigureGlobalOptions(FuncGlobalOptionsBuilderObject c return this; } + /// + /// Configures typed global options. T can be any type with a parameterless constructor. + /// The source generator will emit parsing code for all properties of T. + /// + /// Type to use for global options + public ConsoleAppBuilder ConfigureGlobalOptions() where T : new() + { + // Marker method - actual implementation generated by source generator + return this; + } + async Task RunWithFilterAsync(string commandName, string[] args, int commandDepth, IFilterFactory filterFactory, CancellationToken cancellationToken) { using var posixSignalHandler = PosixSignalHandler.Register(Timeout, cancellationToken); diff --git a/src/ConsoleAppFramework/ConsoleAppGenerator.cs b/src/ConsoleAppFramework/ConsoleAppGenerator.cs index 1f9c2d1..205e62b 100644 --- a/src/ConsoleAppFramework/ConsoleAppGenerator.cs +++ b/src/ConsoleAppFramework/ConsoleAppGenerator.cs @@ -293,8 +293,14 @@ static void EmitConsoleAppBuilder(SourceProductionContext sourceProductionContex sb.AppendLine(d); } - var emitter = new Emitter(dllReference); + var emitter = new Emitter(dllReference, collectBuilderContext.TypedGlobalOptions); emitter.EmitBuilder(sb, commandIds, hasRun, hasRunAsync); + + // Emit typed global options parser if configured + if (collectBuilderContext.TypedGlobalOptions != null) + { + emitter.EmitTypedGlobalOptionsParsing(sb, collectBuilderContext.TypedGlobalOptions); + } } sourceProductionContext.AddSource("ConsoleApp.Builder.g.cs", sb.ToString().ReplaceLineEndings()); @@ -317,7 +323,7 @@ static void EmitConsoleAppBuilder(SourceProductionContext sourceProductionContex .ToArray(); } - var emitter = new Emitter(dllReference); + var emitter = new Emitter(dllReference, collectBuilderContext.TypedGlobalOptions); emitter.EmitHelp(help, commandIds!); if (dllReference.HasCliSchema) @@ -356,7 +362,7 @@ static void EmitConsoleAppConfigure(SourceProductionContext sourceProductionCont var sb2 = sb.Clone(); sb2.AppendLine("using Microsoft.Extensions.Hosting;"); var emitter = new Emitter(dllReference); - emitter.EmitAsConsoleAppBuilder(sb2, dllReference); + emitter.EmitAsConsoleAppBuilder(sb2); sourceProductionContext.AddSource("ConsoleAppHostBuilderExtensions.g.cs", sb2.ToString().ReplaceLineEndings()); } @@ -400,6 +406,7 @@ class CollectBuilderContext : IEquatable ConsoleAppFrameworkGeneratorOptions generatorOptions { get; } public GlobalOptionInfo[] GlobalOptions { get; } = []; + public TypedGlobalOptionsInfo? TypedGlobalOptions { get; private set; } public CollectBuilderContext(ConsoleAppFrameworkGeneratorOptions generatorOptions, ImmutableArray<(BuilderContext, string?, SymbolKind?)> contexts, CancellationToken cancellationToken) { @@ -433,6 +440,12 @@ public CollectBuilderContext(ConsoleAppFrameworkGeneratorOptions generatorOption return "Add"; } + // Distinguish between generic and non-generic ConfigureGlobalOptions + if (x.Name == "ConfigureGlobalOptions" && ((x.Node.Expression as MemberAccessExpressionSyntax)?.Name.IsKind(SyntaxKind.GenericName) ?? false)) + { + return "ConfigureGlobalOptions"; + } + return x.Name; }); @@ -465,13 +478,67 @@ public CollectBuilderContext(ConsoleAppFrameworkGeneratorOptions generatorOption return; } + // Parse global options FIRST so we can pass the type to command parsers + var configureGlobalOptionsGroup = methodGroup["ConfigureGlobalOptions"]; + var configureTypedGlobalOptionsGroup = methodGroup["ConfigureGlobalOptions"]; + var totalGlobalOptionsCount = configureGlobalOptionsGroup.Count() + configureTypedGlobalOptionsGroup.Count(); + + if (totalGlobalOptionsCount >= 2) + { + // Find the last call (either generic or non-generic) to report the error + InvocationExpressionSyntax lastNode; + if (configureTypedGlobalOptionsGroup.Any()) + { + lastNode = configureTypedGlobalOptionsGroup.Last().Item1.Node; + } + else + { + lastNode = configureGlobalOptionsGroup.Last().Item1.Node; + } + + DiagnosticReporter.ReportDiagnostic(DiagnosticDescriptors.DuplicateConfigureGlobalOptions, lastNode.Expression.GetLocation()); + } + + if (configureGlobalOptionsGroup.Count() == 1 && configureTypedGlobalOptionsGroup.Count() == 0) + { + var configureGlobalOptions = configureGlobalOptionsGroup.First(); + + var node = configureGlobalOptions.Item1.Node; + var model = configureGlobalOptions.Item1.Model; + var wellKnownTypes = new WellKnownTypes(model.Compilation); + + var parser = new Parser(generatorOptions, DiagnosticReporter, node, model, wellKnownTypes, DelegateBuildType.None, globalFilters); + GlobalOptions = parser.ParseGlobalOptions(); + } + + // Handle typed ConfigureGlobalOptions() - parse early to get the type for [AsParameters] inheritance + ITypeSymbol? knownGlobalOptionsType = null; + if (configureTypedGlobalOptionsGroup.Count() == 1 && configureGlobalOptionsGroup.Count() == 0) + { + var configureTypedGlobalOptions = configureTypedGlobalOptionsGroup.First(); + + var node = configureTypedGlobalOptions.Item1.Node; + var model = configureTypedGlobalOptions.Item1.Model; + var wellKnownTypes = new WellKnownTypes(model.Compilation); + + var parser = new Parser(generatorOptions, DiagnosticReporter, node, model, wellKnownTypes, DelegateBuildType.None, globalFilters); + TypedGlobalOptions = parser.ParseTypedGlobalOptions(); + knownGlobalOptionsType = TypedGlobalOptions?.Type.TypeSymbol; + } + + if (DiagnosticReporter.HasDiagnostics) + { + return; + } + + // Parse commands AFTER global options so we can pass the known type for [AsParameters] inheritance var names = new HashSet(); - var commands1 = methodGroup["Add"] + var delegateCommands = methodGroup["Add"] .Select(x => x.Item1) .Select(x => { var wellKnownTypes = new WellKnownTypes(x.Model.Compilation); - var parser = new Parser(generatorOptions, DiagnosticReporter, x.Node, x.Model, wellKnownTypes, DelegateBuildType.MakeCustomDelegateWhenHasDefaultValueOrTooLarge, globalFilters); + var parser = new Parser(generatorOptions, DiagnosticReporter, x.Node, x.Model, wellKnownTypes, DelegateBuildType.MakeCustomDelegateWhenHasDefaultValueOrTooLarge, globalFilters, knownGlobalOptionsType); var command = parser.ParseAndValidateForBuilderDelegateRegistration(); // validation command name duplicate @@ -486,12 +553,12 @@ public CollectBuilderContext(ConsoleAppFrameworkGeneratorOptions generatorOption }) .ToArray(); // evaluate first. - var commands2 = methodGroup["Add"] + var classCommands = methodGroup["Add"] .Select(x => x.Item1) .SelectMany(x => { var wellKnownTypes = new WellKnownTypes(x.Model.Compilation); - var parser = new Parser(generatorOptions, DiagnosticReporter, x.Node, x.Model, wellKnownTypes, DelegateBuildType.None, globalFilters); + var parser = new Parser(generatorOptions, DiagnosticReporter, x.Node, x.Model, wellKnownTypes, DelegateBuildType.None, globalFilters, knownGlobalOptionsType); var commands = parser.ParseAndValidateForBuilderClassRegistration(); // validation command name duplicate @@ -507,33 +574,8 @@ public CollectBuilderContext(ConsoleAppFrameworkGeneratorOptions generatorOption return commands; }); - var configureGlobalOptionsGroup = methodGroup["ConfigureGlobalOptions"]; - if (configureGlobalOptionsGroup.Count() >= 2) - { - var node = configureGlobalOptionsGroup.Last().Item1.Node; - - DiagnosticReporter.ReportDiagnostic(DiagnosticDescriptors.DuplicateConfigureGlobalOptions, node.Expression.GetLocation()); - } - - if (configureGlobalOptionsGroup.Count() == 1) - { - var configureGlobalOptions = configureGlobalOptionsGroup.First(); - - var node = configureGlobalOptions.Item1.Node; - var model = configureGlobalOptions.Item1.Model; - var wellKnownTypes = new WellKnownTypes(model.Compilation); - - var parser = new Parser(generatorOptions, DiagnosticReporter, node, model, wellKnownTypes, DelegateBuildType.None, globalFilters); - GlobalOptions = parser.ParseGlobalOptions(); - } - - if (DiagnosticReporter.HasDiagnostics) - { - return; - } - // set properties - this.Commands = commands1.Concat(commands2!).Where(x => x != null).ToArray()!; + this.Commands = delegateCommands.Concat(classCommands!).Where(x => x != null).ToArray()!; this.HasRun = methodGroup["Run"].Any(); this.HasRunAsync = methodGroup["RunAsync"].Any(); } diff --git a/src/ConsoleAppFramework/DiagnosticDescriptors.cs b/src/ConsoleAppFramework/DiagnosticDescriptors.cs index 6481fc1..b1c3074 100644 --- a/src/ConsoleAppFramework/DiagnosticDescriptors.cs +++ b/src/ConsoleAppFramework/DiagnosticDescriptors.cs @@ -131,4 +131,34 @@ 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 BindTypeNoValidConstructor { get; } = Create( + 20, + "Type used with [AsParameters] must have a parameterless constructor or a primary constructor.", + "Type '{0}' used with [AsParameters] must have a parameterless constructor or a primary constructor."); + + public static DiagnosticDescriptor BindUnsupportedPropertyType { get; } = Create( + 21, + "Property has unsupported type for binding.", + "Property '{0}' has unsupported type for binding."); + + public static DiagnosticDescriptor BindCircularReference { get; } = Create( + 22, + "Circular reference detected in type.", + "Circular reference detected in type '{0}'."); + + public static DiagnosticDescriptor BindConstructorParameterNotMatched { get; } = Create( + 24, + "Constructor parameter cannot be matched to preceding [Argument] parameters.", + "Constructor parameter '{0}' in type '{1}' cannot be matched to preceding [Argument] parameters."); + + public static DiagnosticDescriptor BindMultipleConstructors { get; } = Create( + 25, + "Multiple constructors found; [AsParameters] requires exactly one public constructor.", + "Multiple constructors found for type '{0}'; [AsParameters] requires exactly one public constructor."); + + public static DiagnosticDescriptor GlobalOptionsCannotHaveArguments { get; } = Create( + 26, + "GlobalOptions cannot have positional arguments", + "GlobalOptions type '{0}' cannot have positional arguments. Property '{1}' is marked as an argument with [Argument] or 'argument,' comment."); } diff --git a/src/ConsoleAppFramework/Emitter.AsParameters.cs b/src/ConsoleAppFramework/Emitter.AsParameters.cs new file mode 100644 index 0000000..e13d6d3 --- /dev/null +++ b/src/ConsoleAppFramework/Emitter.AsParameters.cs @@ -0,0 +1,574 @@ +using Microsoft.CodeAnalysis; + +namespace ConsoleAppFramework; + +/// +/// Determines the parse code generation mode for different contexts. +/// +/// +/// The three modes reflect where in the parsing pipeline the code runs: +/// +/// : Direct value access at commandArgs[i], strict validation with immediate throw on parse failure. Used for positional arguments where we know the exact index. +/// : Uses TryIncrementIndex to safely advance past option name to value, throws on failure. Standard parsing for named options like --port 8080. +/// : Silent skip on bounds/parse failure (inline ++i). Used for pre-command parsing where unrecognized options are passed through to command handlers. +/// +/// +internal enum ParseMode +{ + /// Argument: direct access to commandArgs[i], throws on parse failure. + Argument, + /// Option: uses TryIncrementIndex, throws on parse failure. + Option, + /// GlobalOption: inline ++i, silently ignores parse failures. + GlobalOption +} + +/// +/// Categorized properties for object construction, separating required, optional, and inherited global options. +/// +/// Properties that must be initialized (marked required or arguments without defaults). +/// Properties with defaults that are conditionally assigned if parsed. +/// Properties inherited from a [GlobalOptions] base type (copied from typedGlobalOptions). +internal record CategorizedProperties( + IReadOnlyCollection Required, + IReadOnlyCollection Optional, + IReadOnlyCollection GlobalOptions); + +internal partial class Emitter +{ + // [AsParameters] helper methods + + /// + /// Determines if a property should be skipped during processing. + /// + /// The property to check. + /// Whether to skip argument properties. + /// True if the property should be skipped. + static bool ShouldSkipProperty(BindablePropertyInfo prop, bool skipArguments = false) + { + // Skip nested properties (not supported in this version) + if (prop.ParentPath.Length > 0) return true; + // Skip properties from global options (handled separately) + if (prop.IsFromGlobalOptions) return true; + // Optionally skip arguments (parsed in an argument section) + if (skipArguments && prop.IsArgument) return true; + return false; + } + + void EmitBoundVariableDeclarations(SourceBuilder sb, CommandParameter parameter, int paramIndex) + { + var binding = parameter.ObjectBinding!; + + // Emit variable declarations for each bindable property (including arguments) + foreach (var prop in binding.Properties) + { + if (ShouldSkipProperty(prop)) continue; + + var varName = GetBindPropertyVarName(paramIndex, prop); + var typeFullName = prop.Type.ToFullyQualifiedFormatDisplayString(); + + // Get default value + var defaultValue = prop is { HasDefaultValue: true, DefaultValue: not null } + ? FormatDefaultValue(prop.DefaultValue, prop.Type.TypeSymbol) + : $"default({typeFullName})!"; + + sb.AppendLine($"var {varName} = {defaultValue};"); + + // Emit a parsed flag for: + // - All non-constructor properties (needed for conditional assignment) + // - Required constructor properties (needed for validation) + // - Arguments (needed for validation) + if (!prop.IsConstructorParameter || prop.IsRequired || prop.IsArgument) + { + sb.AppendLine($"var {varName}Parsed = false;"); + } + } + } + + void EmitBoundSwitchCases(SourceBuilder sb, CommandParameter parameter, int paramIndex) + { + var binding = parameter.ObjectBinding!; + + foreach (var prop in binding.Properties) + { + if (ShouldSkipProperty(prop, skipArguments: true)) continue; + + var varName = GetBindPropertyVarName(paramIndex, prop); + var cliName = prop.CliName; + + // Add CliName case (only if not already in aliases) + if (!prop.Aliases.Contains(cliName)) + { + sb.AppendLine($"case \"{cliName}\":"); + } + // Add aliases as case labels + foreach (var alias in prop.Aliases) + { + sb.AppendLine($"case \"{alias}\":"); + } + + using var block = sb.BeginBlock(); + EmitPropertyParseCode(sb, prop, varName); + // Always set a parsed flag (needed for conditional assignment) + if (!prop.IsConstructorParameter || prop.IsRequired) + { + sb.AppendLine($"{varName}Parsed = true;"); + } + sb.AppendLine("continue;"); + } + } + + void EmitBoundCaseInsensitiveCases(SourceBuilder sb, CommandParameter parameter, int paramIndex) + { + var binding = parameter.ObjectBinding!; + + foreach (var prop in binding.Properties) + { + if (ShouldSkipProperty(prop, skipArguments: true)) continue; + + var varName = GetBindPropertyVarName(paramIndex, prop); + var cliName = prop.CliName; + + // Build condition including aliases, avoiding duplicates + var allOptions = new List(); + if (!prop.Aliases.Contains(cliName)) + { + allOptions.Add(cliName); + } + allOptions.AddRange(prop.Aliases); + + sb.AppendLine($"if (string.Equals(name, \"{allOptions[0]}\", StringComparison.OrdinalIgnoreCase){(allOptions.Count == 1 ? ")" : "")}"); + for (int j = 1; j < allOptions.Count; j++) + { + sb.AppendLine($" || string.Equals(name, \"{allOptions[j]}\", StringComparison.OrdinalIgnoreCase){(allOptions.Count == j + 1 ? ")" : "")}"); + } + + using var block = sb.BeginBlock(); + EmitPropertyParseCode(sb, prop, varName); + // Always set a parsed flag (needed for conditional assignment) + if (!prop.IsConstructorParameter || prop.IsRequired) + { + sb.AppendLine($"{varName}Parsed = true;"); + } + sb.AppendLine("continue;"); + } + } + + void EmitBoundArgumentParsing(SourceBuilder sb, CommandParameter parameter, int paramIndex, int baseArgumentIndex) + { + var binding = parameter.ObjectBinding!; + + // Get all argument properties sorted by index (excluding global options properties) + var argumentProps = binding.Properties + .Where(p => p.IsArgument && p.ParentPath.Length == 0 && !p.IsFromGlobalOptions) + .OrderBy(p => p.ArgumentIndex) + .ToArray(); + + foreach (var prop in argumentProps) + { + var varName = GetBindPropertyVarName(paramIndex, prop); + var globalArgIndex = baseArgumentIndex + prop.ArgumentIndex; + + sb.AppendLine($"if (argumentPosition == {globalArgIndex})"); + using var block = sb.BeginBlock(); + EmitArgumentPropertyParseCode(sb, prop, varName); + sb.AppendLine($"{varName}Parsed = true;"); + sb.AppendLine("argumentPosition++;"); + sb.AppendLine("continue;"); + } + } + + void EmitArgumentPropertyParseCode(SourceBuilder sb, BindablePropertyInfo prop, string varName) + { + var argName = $"[{prop.ArgumentIndex}]"; + EmitTypeParseCodeCore(sb, prop, varName, argName, ParseMode.Argument, nullable: false); + } + + void EmitPropertyParseCode(SourceBuilder sb, BindablePropertyInfo prop, string varName) + { + var argName = prop.CliName.TrimStart('-'); + EmitTypeParseCodeCore(sb, prop, varName, argName, ParseMode.Option, nullable: false); + } + + /// + /// Unified method for emitting type parsing code across different contexts. + /// + /// + /// For Nullable<T> types, we detect them and set nullable=true for the temp variable pattern. + /// When nullable=true, we use a temporary variable (temp_varName) with TryParse to avoid + /// overwriting the target variable on parse failure - we only assign on success via the else clause. + /// + void EmitTypeParseCodeCore(SourceBuilder sb, BindablePropertyInfo prop, string varName, string argName, ParseMode mode, bool nullable) + { + var type = prop.Type.TypeSymbol; + + // Detect Nullable and set nullable flag (ParseInfo already has the unwrapped type info) + if (type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + nullable = true; + } + + // When nullable=true, use a temp variable to avoid overwriting the target on parse failure + var outArgVar = (!nullable) ? $"out {varName}" : $"out var temp_{varName}"; + var elseExpr = (!nullable) ? "" : $" else {{ {varName} = temp_{varName}; }}"; + + // Use the pre-computed ParseInfo from the property (already unwrapped for Nullable) + var parseInfo = prop.ParseInfo; + + switch (parseInfo.Category) + { + case ParseCategory.String: + EmitStringParse(sb, varName, argName, mode); + return; + + case ParseCategory.Boolean: + EmitBooleanParse(sb, varName, argName, mode, outArgVar, elseExpr); + return; + + case ParseCategory.Primitive: + EmitTryParseable(sb, parseInfo.FullTypeName, argName, mode, outArgVar, elseExpr, isEnum: false); + return; + + case ParseCategory.Enum: + EmitTryParseable(sb, parseInfo.FullTypeName, argName, mode, outArgVar, elseExpr, isEnum: true); + return; + + case ParseCategory.SpanParsable: + EmitSpanParsableParse(sb, parseInfo, argName, mode, outArgVar, elseExpr); + return; + + case ParseCategory.Array: + EmitArrayParse(sb, parseInfo, argName, mode, outArgVar, elseExpr); + return; + + case ParseCategory.Json: + EmitJsonParse(sb, parseInfo, varName, argName, mode); + return; + } + } + + static void EmitBooleanParse(SourceBuilder sb, string varName, string argName, ParseMode mode, string outArgVar, string elseExpr) + { + switch (mode) + { + case ParseMode.Argument: + // Argument: parse "true"/"false" string + sb.AppendLine($"if (!bool.TryParse(commandArgs[i], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argName}\", commandArgs[i]); }}{elseExpr}"); + break; + case ParseMode.Option: + case ParseMode.GlobalOption: + // Option/GlobalOption: presence means true (flag) + sb.AppendLine($"{varName} = true;"); + break; + } + } + + static void EmitStringParse(SourceBuilder sb, string varName, string argName, ParseMode mode) + { + switch (mode) + { + case ParseMode.Argument: + sb.AppendLine($"{varName} = commandArgs[i];"); + break; + case ParseMode.Option: + sb.AppendLine($"if (!TryIncrementIndex(ref i, commandArgs.Length)) {{ ThrowArgumentParseFailed(\"{argName}\", commandArgs[i]); }} else {{ {varName} = commandArgs[i]; }}"); + break; + case ParseMode.GlobalOption: + sb.AppendLine($"if (++i < commandArgs.Length) {{ {varName} = commandArgs[i]; }}"); + break; + } + } + + static void EmitTryParseable(SourceBuilder sb, string typeName, string argName, ParseMode mode, string outArgVar, string elseExpr, bool isEnum) + { + var parseExpr = isEnum + ? $"Enum.TryParse<{typeName}>(commandArgs[i], true, {outArgVar})" + : $"{typeName}.TryParse(commandArgs[i], {outArgVar})"; + + var throwExpr = isEnum + ? $"ThrowArgumentParseFailedEnum(typeof({typeName}), \"{argName}\", commandArgs[i])" + : $"ThrowArgumentParseFailed(\"{argName}\", commandArgs[i])"; + + switch (mode) + { + case ParseMode.Argument: + sb.AppendLine($"if (!{parseExpr}) {{ {throwExpr}; }}{elseExpr}"); + break; + case ParseMode.Option: + sb.AppendLine($"if (!TryIncrementIndex(ref i, commandArgs.Length) || !{parseExpr}) {{ {throwExpr}; }}{elseExpr}"); + break; + case ParseMode.GlobalOption: + sb.AppendLine($"if (++i < commandArgs.Length && {parseExpr}) {{ }}{elseExpr}"); + break; + } + } + + static void EmitSpanParsableParse(SourceBuilder sb, ParseInfo info, string argName, ParseMode mode, string outArgVar, string elseExpr) + { + // ISpanParsable.TryParse(string, IFormatProvider?, out T) + var parseExpr = $"{info.FullTypeName}.TryParse(commandArgs[i], null, {outArgVar})"; + var throwExpr = $"ThrowArgumentParseFailed(\"{argName}\", commandArgs[i])"; + + switch (mode) + { + case ParseMode.Argument: + sb.AppendLine($"if (!{parseExpr}) {{ {throwExpr}; }}{elseExpr}"); + break; + case ParseMode.Option: + sb.AppendLine($"if (!TryIncrementIndex(ref i, commandArgs.Length) || !{parseExpr}) {{ {throwExpr}; }}{elseExpr}"); + break; + case ParseMode.GlobalOption: + sb.AppendLine($"if (++i < commandArgs.Length && {parseExpr}) {{ }}{elseExpr}"); + break; + } + } + + static void EmitArrayParse(SourceBuilder sb, ParseInfo info, string argName, ParseMode mode, string outArgVar, string elseExpr) + { + // Arrays use TrySplitParse which splits on comma and parses each element + var parseExpr = $"TrySplitParse(commandArgs[i], {outArgVar})"; + var throwExpr = $"ThrowArgumentParseFailed(\"{argName}\", commandArgs[i])"; + + switch (mode) + { + case ParseMode.Argument: + sb.AppendLine($"if (!{parseExpr}) {{ {throwExpr}; }}{elseExpr}"); + break; + case ParseMode.Option: + sb.AppendLine($"if (!TryIncrementIndex(ref i, commandArgs.Length) || !{parseExpr}) {{ {throwExpr}; }}{elseExpr}"); + break; + case ParseMode.GlobalOption: + sb.AppendLine($"if (++i < commandArgs.Length && {parseExpr}) {{ }}{elseExpr}"); + break; + } + } + + static void EmitJsonParse(SourceBuilder sb, ParseInfo info, string varName, string argName, ParseMode mode) + { + // JSON fallback for complex types + switch (mode) + { + case ParseMode.Argument: + sb.AppendLine($"try {{ {varName} = System.Text.Json.JsonSerializer.Deserialize<{info.FullTypeName}>(commandArgs[i], JsonSerializerOptions); }} catch {{ ThrowArgumentParseFailed(\"{argName}\", commandArgs[i]); }}"); + break; + case ParseMode.Option: + sb.AppendLine($"if (!TryIncrementIndex(ref i, commandArgs.Length)) {{ ThrowArgumentParseFailed(\"{argName}\", commandArgs[i]); }}"); + sb.AppendLine($"try {{ {varName} = System.Text.Json.JsonSerializer.Deserialize<{info.FullTypeName}>(commandArgs[i], JsonSerializerOptions); }} catch {{ ThrowArgumentParseFailed(\"{argName}\", commandArgs[i]); }}"); + break; + case ParseMode.GlobalOption: + sb.AppendLine($"if (++i < commandArgs.Length) {{ try {{ {varName} = System.Text.Json.JsonSerializer.Deserialize<{info.FullTypeName}>(commandArgs[i], JsonSerializerOptions); }} catch {{ }} }}"); + break; + } + } + + static void EmitBoundValidation(SourceBuilder sb, CommandParameter parameter, int paramIndex) + { + var binding = parameter.ObjectBinding!; + + foreach (var prop in binding.Properties) + { + if (ShouldSkipProperty(prop)) continue; + + // Arguments without defaults are required; arguments with defaults are optional + var isRequiredArg = prop.IsArgument && !prop.HasDefaultValue; + if (prop.IsRequired || isRequiredArg) + { + var varName = GetBindPropertyVarName(paramIndex, prop); + var argName = prop.IsArgument ? $"[{prop.ArgumentIndex}]" : prop.CliName.TrimStart('-'); + sb.AppendLine($"if (!{varName}Parsed) ThrowRequiredArgumentNotParsed(\"{argName}\");"); + } + } + } + + void EmitBoundObjectConstruction(SourceBuilder sb, CommandParameter parameter, int paramIndex) + { + var binding = parameter.ObjectBinding!; + var typeFullName = binding.BoundType.ToFullyQualifiedFormatDisplayString(); + var isRecord = binding.BoundType.TypeSymbol.IsRecord; + var hasGlobalOptionsInheritance = binding.GlobalOptionsBaseType != null && typedGlobalOptions != null; + + if (binding.HasPrimaryConstructor) + { + EmitObjectConstructionWithPrimaryConstructor(sb, binding, typeFullName, isRecord, hasGlobalOptionsInheritance, paramIndex); + } + else + { + EmitObjectConstructionWithParameterlessConstructor(sb, binding, typeFullName, isRecord, hasGlobalOptionsInheritance, paramIndex); + } + } + + void EmitObjectConstructionWithPrimaryConstructor( + SourceBuilder sb, ObjectBindingInfo binding, string typeFullName, bool isRecord, + bool hasGlobalOptionsInheritance, int paramIndex) + { + // Build constructor arguments + var ctorArgs = new List(); + foreach (var ctorParam in binding.ConstructorParameters) + { + var prop = binding.Properties.FirstOrDefault(p => + p.IsConstructorParameter && p.ConstructorParameterIndex == ctorParam.Index); + if (prop != null) + { + ctorArgs.Add(GetPropertyValueSource(prop, hasGlobalOptionsInheritance, paramIndex)); + } + else + { + var defaultVal = ctorParam.HasDefaultValue + ? FormatDefaultValue(ctorParam.DefaultValue, ctorParam.Type.TypeSymbol) + : $"default({ctorParam.Type.ToFullyQualifiedFormatDisplayString()})!"; + ctorArgs.Add(defaultVal); + } + } + + var categorized = CategorizeProperties(binding.Properties, hasGlobalOptionsInheritance, skipConstructorParams: true); + + var ctorArgsStr = string.Join(", ", ctorArgs); + var allInits = BuildPropertyInitializers(categorized.Required, categorized.GlobalOptions, paramIndex); + + sb.AppendLine(allInits.Count > 0 + ? $"var arg{paramIndex} = new {typeFullName}({ctorArgsStr}) {{ {string.Join(", ", allInits)} }};" + : $"var arg{paramIndex} = new {typeFullName}({ctorArgsStr});" + ); + + EmitOptionalPropertyAssignments(sb, categorized.Optional, isRecord, paramIndex); + } + + void EmitObjectConstructionWithParameterlessConstructor( + SourceBuilder sb, ObjectBindingInfo binding, string typeFullName, bool isRecord, + bool hasGlobalOptionsInheritance, int paramIndex) + { + var categorized = CategorizeProperties(binding.Properties, hasGlobalOptionsInheritance, skipConstructorParams: false); + + var allInits = BuildPropertyInitializers(categorized.Required, categorized.GlobalOptions, paramIndex); + + sb.AppendLine(allInits.Count > 0 + ? $"var arg{paramIndex} = new {typeFullName}() {{ {string.Join(", ", allInits)} }};" + : $"var arg{paramIndex} = new {typeFullName}();" + ); + + EmitOptionalPropertyAssignments(sb, categorized.Optional, isRecord, paramIndex); + } + + static string GetPropertyValueSource(BindablePropertyInfo prop, bool hasGlobalOptionsInheritance, int paramIndex) => + prop.IsFromGlobalOptions && hasGlobalOptionsInheritance + ? $"typedGlobalOptions.{prop.PropertyName}" + : GetBindPropertyVarName(paramIndex, prop); + + static CategorizedProperties CategorizeProperties( + EquatableArray properties, bool hasGlobalOptionsInheritance, bool skipConstructorParams) + { + var required = new List(); + var optional = new List(); + var globalOptions = new List(); + + foreach (var prop in properties) + { + if (skipConstructorParams && prop.IsConstructorParameter) continue; + if (prop.ParentPath.Length > 0) continue; + + if (prop.IsFromGlobalOptions && hasGlobalOptionsInheritance) + { + globalOptions.Add(prop); + continue; + } + + var isRequiredArg = prop is { IsArgument: true, HasDefaultValue: false }; + if (prop.IsRequired || isRequiredArg) + { + required.Add(prop); + } + else + { + optional.Add(prop); + } + } + + return new CategorizedProperties(required, optional, globalOptions); + } + + static List BuildPropertyInitializers( + IReadOnlyCollection requiredProps, + IReadOnlyCollection globalOptionsProps, + int paramIndex + ) + { + var inits = new List(); + inits.AddRange(requiredProps.Select(p => $"{p.PropertyName} = {GetBindPropertyVarName(paramIndex, p)}")); + inits.AddRange(globalOptionsProps.Select(p => $"{p.PropertyName} = typedGlobalOptions.{p.PropertyName}")); + return inits; + } + + void EmitOptionalPropertyAssignments( + SourceBuilder sb, IReadOnlyCollection optionalProps, bool isRecord, int paramIndex) + { + foreach (var prop in optionalProps) + { + var varName = GetBindPropertyVarName(paramIndex, prop); + if (isRecord && prop.IsInitOnly) + { + sb.AppendLine($"if ({varName}Parsed) arg{paramIndex} = arg{paramIndex} with {{ {prop.PropertyName} = {varName} }};"); + } + else + { + sb.AppendLine($"if ({varName}Parsed) arg{paramIndex}.{prop.PropertyName} = {varName};"); + } + } + } + + static string GetBindPropertyVarName(int paramIndex, BindablePropertyInfo prop) + { + // Create a unique variable name based on parameter index and property path + var sanitizedName = prop.PropertyAccessPath.Replace(".", "_"); + return $"arg{paramIndex}_{sanitizedName}"; + } + + static string FormatDefaultValue(object? value, ITypeSymbol type) + { + switch (value) + { + case null: + return "null"; + case string s: + return $"\"{s.Replace("\\", "\\\\").Replace("\"", "\\\"")}\""; + case bool b: + return b ? "true" : "false"; + case char c: + return $"'{c}'"; + } + + if (type.TypeKind == TypeKind.Enum) + return $"({type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}){value}"; + + // For numeric types, use the target type to determine the correct suffix/cast + // because the literal value might be stored as a different type (e.g., 0 is int even for ulong) + switch (type.SpecialType) + { + case SpecialType.System_Decimal: + return $"{value}m"; + case SpecialType.System_Single: + return $"{value}f"; + case SpecialType.System_Double: + return $"(double){value}"; + case SpecialType.System_Int64: + return $"{value}L"; + case SpecialType.System_UInt64: + return $"{value}UL"; + case SpecialType.System_UInt32: + return $"{value}U"; + // Small integer types need explicit casts because there's no suffix + case SpecialType.System_Byte: + return $"(byte){value}"; + case SpecialType.System_SByte: + return $"(sbyte){value}"; + case SpecialType.System_Int16: + return $"(short){value}"; + case SpecialType.System_UInt16: + return $"(ushort){value}"; + case SpecialType.System_Int32: + // int is the default for integer literals + return value.ToString() ?? "default"; + } + + return value.ToString() ?? "default"; + } +} diff --git a/src/ConsoleAppFramework/Emitter.GlobalOptions.cs b/src/ConsoleAppFramework/Emitter.GlobalOptions.cs new file mode 100644 index 0000000..4c38297 --- /dev/null +++ b/src/ConsoleAppFramework/Emitter.GlobalOptions.cs @@ -0,0 +1,163 @@ +namespace ConsoleAppFramework; + +internal partial class Emitter +{ + /// + /// Emits code to parse typed global options BEFORE command routing. + /// This allows global options to appear before the command name. + /// + void EmitTypedGlobalOptionsPreParsing(SourceBuilder sb) + { + if (typedGlobalOptions == null) return; + + sb.AppendLine("var (parsedOptions, remainingArgs) = ParseTypedGlobalOptions(args.AsMemory());"); + sb.AppendLine("typedGlobalOptions = parsedOptions;"); + sb.AppendLine("args = remainingArgs.ToArray();"); + sb.AppendLine(); + // Critical: Set configureGlobalOptions so RunWithFilterAsync can access typed global options + sb.AppendLine("this.configureGlobalOptions = (ref GlobalOptionsBuilder _) => typedGlobalOptions!;"); + sb.AppendLine("this.isRequireCallBuildAndSetServiceProvider = true;"); + sb.AppendLine(); + } + + /// + /// Emits the field declaration for typed global options if needed. + /// + void EmitTypedGlobalOptionsField(SourceBuilder sb) + { + if (typedGlobalOptions == null) return; + + var globalOptionsTypeName = typedGlobalOptions.Type.ToFullyQualifiedFormatDisplayString(); + sb.AppendLine($"{globalOptionsTypeName}? typedGlobalOptions;"); + } + + /// + /// Emits the typed global options parser method. + /// Generates code that parses global options from command args and returns remaining args. + /// + public void EmitTypedGlobalOptionsParsing(SourceBuilder sb, TypedGlobalOptionsInfo globalOptions) + { + var binding = globalOptions.ObjectBinding; + var typeFullName = binding.BoundType.ToFullyQualifiedFormatDisplayString(); + + sb.AppendLine(); + sb.AppendLine($"static ({typeFullName} globalOptions, ReadOnlyMemory remainingArgs) ParseTypedGlobalOptions(ReadOnlyMemory commandArgsMemory)"); + using (sb.BeginBlock()) + { + // Create a default instance to get property initializer values + // (property initializers aren't accessible at compile-time via Roslyn) + sb.AppendLine($"var __defaults = new {typeFullName}();"); + sb.AppendLine(); + + // Emit variable declarations for each property, copying defaults from the instance + foreach (var prop in binding.Properties) + { + if (prop.ParentPath.Length > 0) continue; + + var varName = $"global_{prop.PropertyName}"; + + // Use the default instance to get the actual property initializer value + sb.AppendLine($"var {varName} = __defaults.{prop.PropertyName};"); + } + + sb.AppendLine(); + sb.AppendLine("var commandArgs = commandArgsMemory.Span;"); + sb.AppendLine("var remainingArgsList = new System.Collections.Generic.List();"); + sb.AppendLine(); + + using (sb.BeginBlock("for (int i = 0; i < commandArgs.Length; i++)")) + { + sb.AppendLine("var name = commandArgs[i];"); + sb.AppendLine("var consumed = false;"); + sb.AppendLine(); + + // Filter to top-level, non-argument properties (shared by switch and case-insensitive fallback) + var optionProperties = binding.Properties + .Where(p => p.ParentPath.Length == 0 && !p.IsArgument) + .ToArray(); + + using (sb.BeginBlock("switch (name)")) + { + // Emit switch cases for each property + foreach (var prop in optionProperties) + { + var varName = $"global_{prop.PropertyName}"; + sb.AppendLine($"case \"{prop.CliName}\":"); + using (sb.BeginBlock()) + { + EmitGlobalPropertyParseCode(sb, prop, varName); + sb.AppendLine("consumed = true;"); + sb.AppendLine("break;"); + } + } + + using (sb.BeginIndent("default:")) + { + // Case-insensitive fallback + foreach (var prop in optionProperties) + { + var varName = $"global_{prop.PropertyName}"; + sb.AppendLine($"if (string.Equals(name, \"{prop.CliName}\", StringComparison.OrdinalIgnoreCase))"); + using (sb.BeginBlock()) + { + EmitGlobalPropertyParseCode(sb, prop, varName); + sb.AppendLine("consumed = true;"); + } + } + sb.AppendLine("break;"); + } + } + + sb.AppendLine(); + using (sb.BeginBlock("if (!consumed)")) + { + sb.AppendLine("remainingArgsList.Add(name);"); + } + } + + sb.AppendLine(); + + // Construct the global options object + if (binding.HasPrimaryConstructor) + { + var ctorArgs = binding.ConstructorParameters + .Select(p => + { + var prop = binding.Properties.FirstOrDefault(prop => + prop.IsConstructorParameter && prop.ConstructorParameterIndex == p.Index); + return prop != null ? $"global_{prop.PropertyName}" : FormatDefaultValue(p.DefaultValue, p.Type.TypeSymbol); + }) + .ToList(); + + var nonCtorProps = binding.Properties + .Where(p => !p.IsConstructorParameter && p.ParentPath.Length == 0) + .ToList(); + + if (nonCtorProps.Count > 0) + { + var inits = nonCtorProps.Select(p => $"{p.PropertyName} = global_{p.PropertyName}"); + sb.AppendLine($"var globalOptions = new {typeFullName}({string.Join(", ", ctorArgs)}) {{ {string.Join(", ", inits)} }};"); + } + else + { + sb.AppendLine($"var globalOptions = new {typeFullName}({string.Join(", ", ctorArgs)});"); + } + } + else + { + var inits = binding.Properties + .Where(p => p.ParentPath.Length == 0) + .Select(p => $"{p.PropertyName} = global_{p.PropertyName}"); + sb.AppendLine($"var globalOptions = new {typeFullName}() {{ {string.Join(", ", inits)} }};"); + } + + sb.AppendLine("return (globalOptions, remainingArgsList.ToArray());"); + } + } + + void EmitGlobalPropertyParseCode(SourceBuilder sb, BindablePropertyInfo prop, string varName) + { + var argName = prop.CliName.TrimStart('-'); + EmitTypeParseCodeCore(sb, prop, varName, argName, ParseMode.GlobalOption, nullable: false); + } +} diff --git a/src/ConsoleAppFramework/Emitter.cs b/src/ConsoleAppFramework/Emitter.cs index 7481bf2..2461e8b 100644 --- a/src/ConsoleAppFramework/Emitter.cs +++ b/src/ConsoleAppFramework/Emitter.cs @@ -1,9 +1,8 @@ using System.Text; -using Microsoft.CodeAnalysis; namespace ConsoleAppFramework; -internal class Emitter(DllReference? dllReference) // from EmitConsoleAppRun, null. +internal partial class Emitter(DllReference? dllReference, TypedGlobalOptionsInfo? typedGlobalOptions = null) // from EmitConsoleAppRun, null. { public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsync, string? methodName) { @@ -12,7 +11,8 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy 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 hasArgument = command.Parameters.Any(x => x.IsArgument) || + command.Parameters.Any(x => x.IsBound && x.ObjectBinding!.Properties.Any(p => p.IsArgument)); var hasValidation = command.Parameters.Any(x => x.HasValidation); var parsableParameterCount = command.Parameters.Count(x => x.IsParsable); @@ -117,16 +117,24 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy sb.AppendLine("ConsoleAppContext context;"); using (hasConsoleAppContext ? sb.Nop : sb.BeginBlock("if (isRequireCallBuildAndSetServiceProvider)")) { - using (sb.BeginBlock("if (configureGlobalOptions == null)")) + if (typedGlobalOptions != null) { - sb.AppendLine($"context = new ConsoleAppContext(\"{command.Name}\", args, commandArgsMemory, null, null, commandDepth, escapeIndex);"); + // Use the pre-parsed typedGlobalOptions (parsed at RunCore level) + sb.AppendLine($"context = new ConsoleAppContext(\"{command.Name}\", args, commandArgsMemory, null, typedGlobalOptions, commandDepth, escapeIndex);"); } - using (sb.BeginBlock("else")) + else { - sb.AppendLine("var builder = new GlobalOptionsBuilder(commandArgsMemory);"); - sb.AppendLine("var globalOptions = configureGlobalOptions(ref builder);"); - sb.AppendLine($"context = new ConsoleAppContext(\"{command.Name}\", args, builder.RemainingArgs, null, globalOptions, commandDepth, escapeIndex);"); - sb.AppendLine("commandArgsMemory = builder.RemainingArgs;"); + using (sb.BeginBlock("if (configureGlobalOptions == null)")) + { + sb.AppendLine($"context = new ConsoleAppContext(\"{command.Name}\", args, commandArgsMemory, null, null, commandDepth, escapeIndex);"); + } + using (sb.BeginBlock("else")) + { + sb.AppendLine("var builder = new GlobalOptionsBuilder(commandArgsMemory);"); + sb.AppendLine("var globalOptions = configureGlobalOptions(ref builder);"); + sb.AppendLine($"context = new ConsoleAppContext(\"{command.Name}\", args, builder.RemainingArgs, null, globalOptions, commandDepth, escapeIndex);"); + sb.AppendLine("commandArgsMemory = builder.RemainingArgs;"); + } } sb.AppendLine("BuildAndSetServiceProvider(context);"); } @@ -157,7 +165,12 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy for (var i = 0; i < command.Parameters.Length; i++) { var parameter = command.Parameters[i]; - if (parameter.IsParsable) + if (parameter.IsBound) + { + // Emit variables for each bindable property + EmitBoundVariableDeclarations(sb, parameter, i); + } + else if (parameter.IsParsable) { var defaultValue = parameter.IsParams ? $"({parameter.ToTypeDisplayString()})[]" : parameter.HasDefaultValue ? parameter.DefaultValueToString() @@ -215,7 +228,9 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy } sb.AppendLine(); - if (!command.Parameters.All(p => !p.IsParsable || p.IsArgument)) + // Check if there are any non-argument options to parse (including [AsParameters] parameters) + var hasNonArgumentOptions = command.Parameters.Any(p => (p.IsParsable && !p.IsArgument) || p.IsBound); + if (hasNonArgumentOptions) { using (hasArgument ? sb.BeginBlock("if (optionCandidate)") : sb.Nop) { @@ -225,6 +240,12 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy for (int i = 0; i < command.Parameters.Length; i++) { var parameter = command.Parameters[i]; + if (parameter.IsBound) + { + // Emit switch cases for each bindable property + EmitBoundSwitchCases(sb, parameter, i); + continue; + } if (!parameter.IsParsable) continue; if (parameter.IsArgument) continue; @@ -250,6 +271,12 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy for (int i = 0; i < command.Parameters.Length; i++) { var parameter = command.Parameters[i]; + if (parameter.IsBound) + { + // Emit case-insensitive cases for each bindable property + EmitBoundCaseInsensitiveCases(sb, parameter, i); + continue; + } if (!parameter.IsParsable) continue; if (parameter.IsArgument) continue; @@ -280,6 +307,7 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy // parse indexed argument([Argument] parameter) if (hasArgument) { + // Regular [Argument] parameters for (int i = 0; i < command.Parameters.Length; i++) { var parameter = command.Parameters[i]; @@ -297,6 +325,25 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy sb.AppendLine("continue;"); } } + + // [Argument] properties within [AsParameters] parameters + // Calculate base argument index (after regular [Argument] params) + var baseArgIndex = command.Parameters.Count(p => p.IsArgument); + for (int i = 0; i < command.Parameters.Length; i++) + { + var parameter = command.Parameters[i]; + if (!parameter.IsBound) continue; + + var binding = parameter.ObjectBinding!; + var hasBoundArgs = binding.Properties.Any(p => p.IsArgument); + if (hasBoundArgs) + { + EmitBoundArgumentParsing(sb, parameter, i, baseArgIndex); + // Increment base index for next [AsParameters] parameter + baseArgIndex += binding.Properties.Count(p => p.IsArgument); + } + } + sb.AppendLine(); } @@ -310,6 +357,12 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy for (int i = 0; i < command.Parameters.Length; i++) { var parameter = command.Parameters[i]; + if (parameter.IsBound) + { + // Validate required properties for [AsParameters] parameters + EmitBoundValidation(sb, parameter, i); + continue; + } if (!parameter.IsParsable) continue; if (parameter.RequireCheckArgumentParsed) @@ -318,6 +371,15 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy } } + // Construct [AsParameters] objects + for (int i = 0; i < command.Parameters.Length; i++) + { + var parameter = command.Parameters[i]; + if (!parameter.IsBound) continue; + + EmitBoundObjectConstruction(sb, parameter, i); + } + // attribute validation if (hasValidation) { @@ -484,6 +546,9 @@ public void EmitBuilder(SourceBuilder sb, CommandWithId[] commandIds, bool emitS sb.AppendLine($"{item.FieldType} command{item.Id} = default!;"); } + // Typed global options field + EmitTypedGlobalOptionsField(sb); + // AddCore sb.AppendLine(); using (sb.BeginBlock("partial void AddCore(string commandName, Delegate command)")) @@ -511,6 +576,12 @@ public void EmitBuilder(SourceBuilder sb, CommandWithId[] commandIds, bool emitS sb.AppendLine(); using (sb.BeginBlock("partial void RunCore(string[] args, CancellationToken cancellationToken)")) { + // Parse typed global options BEFORE command routing + if (typedGlobalOptions != null) + { + EmitTypedGlobalOptionsPreParsing(sb); + } + if (hasRootCommand) { using (sb.BeginBlock("if (args.Length == 1 && args[0] is \"--help\" or \"-h\")")) @@ -530,6 +601,12 @@ public void EmitBuilder(SourceBuilder sb, CommandWithId[] commandIds, bool emitS sb.AppendLine(); using (sb.BeginBlock("partial void RunAsyncCore(string[] args, CancellationToken cancellationToken, ref Task result)")) { + // Parse typed global options BEFORE command routing + if (typedGlobalOptions != null) + { + EmitTypedGlobalOptionsPreParsing(sb); + } + if (hasRootCommand) { using (sb.BeginBlock("if (args.Length == 1 && args[0] is \"--help\" or \"-h\")")) @@ -748,7 +825,7 @@ public void EmitHelp(SourceBuilder sb, CommandWithId[] commands) using (sb.BeginIndent("default:")) { sb.AppendLine("Log(\"\"\""); - sb.AppendWithoutIndent(CommandHelpBuilder.BuildRootHelpMessage(commands.Select(x => x.Command).ToArray())); + sb.AppendWithoutIndent(CommandHelpBuilder.BuildRootHelpMessage(commands.Select(x => x.Command).ToArray(), typedGlobalOptions)); sb.AppendLineWithoutIndent("\"\"\");"); sb.AppendLine("break;"); } @@ -764,15 +841,15 @@ public void EmitCliSchema(SourceBuilder sb, CommandWithId[] commands) } } - public void EmitConfigure(SourceBuilder sb, DllReference dllReference) + public void EmitConfigure(SourceBuilder sb, DllReference dllRef) { // configuration - if (dllReference.HasConfiguration) + if (dllRef.HasConfiguration) { sb.AppendLine("bool requireConfiguration;"); sb.AppendLine("IConfiguration? configuration;"); - if (dllReference.HasJsonConfiguration) + if (dllRef.HasJsonConfiguration) { sb.AppendLine(); sb.AppendLine("/// Create configuration with SetBasePath(Directory.GetCurrentDirectory()) and AddJsonFile(appsettings.json)."); @@ -810,10 +887,10 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) } // DependencyInjection - if (dllReference.HasDependencyInjection) + if (dllRef.HasDependencyInjection) { // field - if (dllReference.HasConfiguration) + if (dllRef.HasConfiguration) { sb.AppendLine("Action? configureServices;"); } @@ -825,7 +902,7 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) sb.AppendLine("Action? postConfigureServices;"); // methods - if (dllReference.HasConfiguration) + if (dllRef.HasConfiguration) { sb.AppendLine(); using (sb.BeginBlock("public ConsoleApp.ConsoleAppBuilder ConfigureServices(Action configure)")) @@ -900,10 +977,10 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) } // Logging - if (dllReference.HasLogging) + if (dllRef.HasLogging) { // field - if (dllReference.HasConfiguration) + if (dllRef.HasConfiguration) { sb.AppendLine("Action? configureLogging;"); } @@ -913,7 +990,7 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) } // methods - if (dllReference.HasConfiguration) + if (dllRef.HasConfiguration) { sb.AppendLine(); using (sb.BeginBlock("public ConsoleApp.ConsoleAppBuilder ConfigureLogging(Action configure)")) @@ -966,7 +1043,7 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) // Build using (sb.BeginBlock("partial void BuildAndSetServiceProvider(ConsoleAppContext context)")) { - if (dllReference.HasDependencyInjection) + if (dllRef.HasDependencyInjection) { using (sb.BeginBlock("if (!isRequireCallBuildAndSetServiceProvider)")) { @@ -974,7 +1051,7 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) } sb.AppendLine("isRequireCallBuildAndSetServiceProvider = false;"); - if (dllReference.HasConfiguration) + if (dllRef.HasConfiguration) { sb.AppendLine("var config = configuration;"); using (sb.BeginBlock("if (requireConfiguration && config == null)")) @@ -984,7 +1061,7 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) } sb.AppendLine("var services = new ServiceCollection();"); - if (dllReference.HasConfiguration) + if (dllRef.HasConfiguration) { sb.AppendLine("configureServices?.Invoke(context, config!, services);"); } @@ -993,14 +1070,14 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) sb.AppendLine("configureServices?.Invoke(context, services);"); } - if (dllReference.HasLogging) + if (dllRef.HasLogging) { using (sb.BeginBlock("if (configureLogging != null)")) { sb.AppendLine("var configure = configureLogging;"); using (sb.BeginIndent("services.AddLogging(logging => {")) { - if (dllReference.HasConfiguration) + if (dllRef.HasConfiguration) { sb.AppendLine("configure!(context, config!, logging);"); } @@ -1027,7 +1104,7 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) } // HostStart(for filter) - if (dllReference.HasHost) + if (dllRef.HasHost) { sb.AppendLine(); using (sb.BeginBlock("partial void StartHostAsyncIfNeeded(CancellationToken cancellationToken, ref Task task)")) @@ -1041,7 +1118,7 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) } } - public void EmitAsConsoleAppBuilder(SourceBuilder sb, DllReference dllReference) + public void EmitAsConsoleAppBuilder(SourceBuilder sb) { sb.AppendLine(""" @@ -1078,14 +1155,14 @@ public void Dispose() } host.Dispose(); } - + public async ValueTask DisposeAsync() { await CastAndDispose(serviceProvider); await CastAndDispose(scope); await CastAndDispose(serviceServiceProvider); await CastAndDispose(host); - + static async ValueTask CastAndDispose(T resource) { if (resource is IAsyncDisposable resourceAsyncDisposable) @@ -1107,7 +1184,7 @@ internal static ConsoleApp.ConsoleAppBuilder ToConsoleAppBuilder(this IHostBuild var scope = serviceServiceProvider.CreateScope(); var serviceProvider = scope.ServiceProvider; ConsoleApp.ServiceProvider = new CompositeDisposableServiceProvider(host, serviceServiceProvider, scope, serviceProvider); - + return ConsoleApp.Create(); } @@ -1118,7 +1195,7 @@ internal static ConsoleApp.ConsoleAppBuilder ToConsoleAppBuilder(this HostApplic var scope = serviceServiceProvider.CreateScope(); var serviceProvider = scope.ServiceProvider; ConsoleApp.ServiceProvider = new CompositeDisposableServiceProvider(host, serviceServiceProvider, scope, serviceProvider); - + return ConsoleApp.Create(); } } diff --git a/src/ConsoleAppFramework/Parser.AsParameters.cs b/src/ConsoleAppFramework/Parser.AsParameters.cs new file mode 100644 index 0000000..0d04a0e --- /dev/null +++ b/src/ConsoleAppFramework/Parser.AsParameters.cs @@ -0,0 +1,600 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ConsoleAppFramework; + +/// +/// Constants for attribute names used in binding discovery. +/// +internal static class AttributeNames +{ + public const string Argument = "ArgumentAttribute"; +} + +/// +/// Constants for special markers in XML documentation. +/// +internal static class BindingMarkers +{ + public const string Argument = "argument"; +} + +/// +/// Represents a parameter that may have [AsParameters] attribute for object binding. +/// +/// The command parameter. +/// Whether the parameter has [AsParameters] attribute. +/// Explicit prefix from [AsParameters] attribute, if any. +/// The type symbol of the parameter. +internal record BindParameterCandidate( + CommandParameter Parameter, + bool HasBind, + string? BindPrefix, + ITypeSymbol TypeSymbol); + +/// +/// Context for object binding discovery, passed during type analysis. +/// +internal record ObjectBindingDiscoveryContext( + string Prefix, + string[] ParentPath, + Location DiagnosticLocation, + HashSet VisitedTypes, + ITypeSymbol? GlobalOptionsType = null) +{ + /// Creates initial context for starting discovery. + public static ObjectBindingDiscoveryContext Create( + string prefix, Location diagnosticLocation, ITypeSymbol? globalOptionsType = null) => + new(prefix, [], diagnosticLocation, new HashSet(SymbolEqualityComparer.Default), globalOptionsType); +} + +internal partial class Parser +{ + CommandParameter[]? ProcessBindParameters(BindParameterCandidate[] candidates, Location diagnosticLocation) + { + // Count how many parameters have [AsParameters] to determine prefix behavior + var bindCount = candidates.Count(c => c.HasBind); + + var result = new List(); + foreach (var candidate in candidates) + { + if (!candidate.HasBind) + { + result.Add(candidate.Parameter); + continue; + } + + // Determine prefix: + // - If an explicit prefix is provided, use it + // - If multiple [AsParameters] parameters, use parameter name as prefix + // - If single [AsParameters] parameter, use no prefix (empty string) + var prefix = candidate.BindPrefix + ?? (bindCount > 1 ? candidate.Parameter.Name : ""); + + // Create ObjectBindingInfo for this parameter + // Pass knownGlobalOptionsType so inheritance from global options can be detected + var objectBinding = DiscoverObjectBinding( + candidate.TypeSymbol, + prefix, + diagnosticLocation, + knownGlobalOptionsType); + + if (objectBinding == null) + { + return null; // Error already reported + } + + // Create new parameter with ObjectBinding + result.Add(candidate.Parameter with { ObjectBinding = objectBinding }); + } + + return result.ToArray(); + } + + ObjectBindingInfo? DiscoverObjectBinding( + ITypeSymbol type, + string prefix, + Location diagnosticLocation, + ITypeSymbol? globalOptionsType = null) + { + var ctx = ObjectBindingDiscoveryContext.Create(prefix, diagnosticLocation, globalOptionsType); + return DiscoverObjectBindingCore(type, ctx); + } + + ObjectBindingInfo? DiscoverObjectBindingCore(ITypeSymbol type, ObjectBindingDiscoveryContext ctx) + { + // Check for circular references + if (!ctx.VisitedTypes.Add(type)) + { + context.ReportDiagnostic(DiagnosticDescriptors.BindCircularReference, ctx.DiagnosticLocation, type.ToDisplayString()); + return null; + } + + // Check if this type inherits from a [GlobalOptions] type + var (globalOptionsBaseType, globalOptionsType) = FindGlobalOptionsBaseType(type, ctx.GlobalOptionsType); + + // Get constructors + var publicConstructors = type.GetMembers() + .OfType() + .Where(x => x.MethodKind == Microsoft.CodeAnalysis.MethodKind.Constructor && x.DeclaredAccessibility == Accessibility.Public) + .ToArray(); + + if (publicConstructors.Length > 1) + { + // Check if one is parameterless + var parameterlessCtors = publicConstructors.Where(x => x.Parameters.Length == 0).ToArray(); + if (parameterlessCtors.Length == 0) + { + context.ReportDiagnostic(DiagnosticDescriptors.BindMultipleConstructors, ctx.DiagnosticLocation, type.ToDisplayString()); + return null; + } + // Use parameterless constructor + publicConstructors = parameterlessCtors; + } + + if (publicConstructors.Length == 0) + { + context.ReportDiagnostic(DiagnosticDescriptors.BindTypeNoValidConstructor, ctx.DiagnosticLocation, type.ToDisplayString()); + return null; + } + + var constructor = publicConstructors[0]; + var hasPrimaryConstructor = constructor.Parameters.Length > 0; + var ctorParameters = new List(); + var properties = new List(); + + // Extract XML documentation from the type (for primary constructor param docs) + Dictionary? paramDescriptions = null; + if (type.DeclaringSyntaxReferences.Length > 0) + { + var typeSyntax = type.DeclaringSyntaxReferences[0].GetSyntax(); + var typeDocComment = typeSyntax.GetDocumentationCommentTriviaSyntax(); + if (typeDocComment != null) + { + paramDescriptions = typeDocComment.GetParams().ToDictionary(x => x.Name, x => x.Description); + } + } + + // Track argument index counter for [Argument] within the object + int argumentIndexCounter = 0; + + // Process constructor parameters + for (int i = 0; i < constructor.Parameters.Length; i++) + { + var ctorParam = constructor.Parameters[i]; + + // Get description from XML documentation (param tag in type's doc comment) + var rawDescription = ""; + if (paramDescriptions != null && paramDescriptions.TryGetValue(ctorParam.Name, out var desc)) + { + rawDescription = desc; + } + + // Parse aliases and argument marker from description + ParseBindableDescription(rawDescription, out var ctorAliases, out var ctorParamDescription, out var isArgumentByComment); + + // Check for [Argument] attribute on constructor parameter + var hasArgumentAttr = ctorParam.GetAttributes().Any(a => a.AttributeClass?.Name == AttributeNames.Argument); + + int argumentIndex = (hasArgumentAttr || isArgumentByComment) ? argumentIndexCounter++ : -1; + + // Boolean properties default to false because they act as CLI flags: + // presence of the flag means true (e.g., --verbose), absence means false. + // This allows boolean options to work without requiring an explicit value. + var ctorIsBoolType = ctorParam.Type.SpecialType == SpecialType.System_Boolean; + var ctorHasDefault = ctorParam.HasExplicitDefaultValue || ctorIsBoolType; + var ctorDefaultValue = ctorParam.HasExplicitDefaultValue ? ctorParam.ExplicitDefaultValue : ctorIsBoolType ? false : null; + + ctorParameters.Add(new ConstructorParameterInfo + { + Name = ctorParam.Name, + Type = new EquatableTypeSymbol(ctorParam.Type), + HasDefaultValue = ctorHasDefault, + DefaultValue = ctorDefaultValue, + Index = i, + ArgumentIndex = argumentIndex + }); + + // Create a CLI property for this constructor parameter + var isBoolType = ctorParam.Type.SpecialType == SpecialType.System_Boolean; + var hasDefault = ctorParam.HasExplicitDefaultValue || isBoolType; + var defaultValue = ctorParam.HasExplicitDefaultValue ? ctorParam.ExplicitDefaultValue : isBoolType ? false : null; + var isRequired = !hasDefault; + + string cliName; + if (hasArgumentAttr || isArgumentByComment) + { + cliName = $"[{argumentIndex}]"; // Positional argument marker + } + else + { + cliName = BuildCliName(ctx.Prefix, ctorParam.Name); + } + + // Check if this parameter comes from the global options type + var isFromGlobalOptions = globalOptionsType != null && + IsMemberDeclaredInType(ctorParam, globalOptionsType); + + // Compute ParseInfo for this property's type + var ctorParseInfo = TypeParseHelper.AnalyzeType(ctorParam.Type, wellKnownTypes); + + properties.Add(new BindablePropertyInfo + { + CliName = cliName, + Type = new EquatableTypeSymbol(ctorParam.Type), + HasDefaultValue = hasDefault, + DefaultValue = defaultValue, + PropertyName = ctorParam.Name, + PropertyAccessPath = ctorParam.Name, + ParentPath = ctx.ParentPath, + Description = ctorParamDescription, + Aliases = ctorAliases, + IsRequired = isRequired, + IsConstructorParameter = true, + ConstructorParameterIndex = i, + IsInitOnly = false, + ArgumentIndex = argumentIndex, + IsFromGlobalOptions = isFromGlobalOptions, + ParseInfo = ctorParseInfo + }); + } + + // Process public settable properties (that are not already handled by constructor) + // Include inherited properties from base types + var ctorParamNames = new HashSet(constructor.Parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + var settableProperties = GetAllPublicProperties(type) + .Where(p => !p.IsStatic && + !p.IsReadOnly && + (p.SetMethod != null || p.IsRequired) && + !ctorParamNames.Contains(p.Name)) + .ToArray(); + + foreach (var prop in settableProperties) + { + // Check if this is a parsable type or a nested object + if (IsParsableType(prop.Type)) + { + // Get description from property's XML documentation (summary tag) + var rawPropDescription = GetPropertyDescription(prop); + + // Parse aliases and argument marker from description + ParseBindableDescription(rawPropDescription, out var propAliases, out var propDescription, out var isArgumentByComment); + + // Check for [Argument] attribute on property + var hasArgumentAttr = prop.GetAttributes().Any(a => a.AttributeClass?.Name == AttributeNames.Argument); + + int argumentIndex = (hasArgumentAttr || isArgumentByComment) ? argumentIndexCounter++ : -1; + + var hasDefaultValue = !prop.IsRequired && !IsNonNullableReferenceType(prop); + var isRequired = prop.IsRequired || IsNonNullableReferenceType(prop); + var isInitOnly = prop.SetMethod?.IsInitOnly ?? false; + + string cliName; + if (hasArgumentAttr || isArgumentByComment) + { + cliName = $"[{argumentIndex}]"; // Positional argument marker + } + else + { + cliName = BuildCliName(ctx.Prefix, prop.Name); + } + + // Check if this property comes from the global options type + var isFromGlobalOptions = globalOptionsType != null && + IsMemberDeclaredInType(prop, globalOptionsType); + + // Try to get the property initializer value + var propDefaultValue = hasDefaultValue ? GetPropertyInitializerValue(prop) : null; + + // Compute ParseInfo for this property's type + var propParseInfo = TypeParseHelper.AnalyzeType(prop.Type, wellKnownTypes); + + properties.Add(new BindablePropertyInfo + { + CliName = cliName, + Type = new EquatableTypeSymbol(prop.Type), + HasDefaultValue = hasDefaultValue, + DefaultValue = propDefaultValue, + PropertyName = prop.Name, + PropertyAccessPath = prop.Name, + ParentPath = ctx.ParentPath, + Description = propDescription, + Aliases = propAliases, + IsRequired = isRequired, + IsConstructorParameter = false, + ConstructorParameterIndex = -1, + IsInitOnly = isInitOnly, + ArgumentIndex = argumentIndex, + IsFromGlobalOptions = isFromGlobalOptions, + ParseInfo = propParseInfo + }); + } + } + + // Remove the type from visited set when returning (for sibling types) + ctx.VisitedTypes.Remove(type); + + return new ObjectBindingInfo + { + BoundType = new EquatableTypeSymbol(type), + Properties = properties.ToArray(), + HasPrimaryConstructor = hasPrimaryConstructor, + ConstructorParameters = ctorParameters.ToArray(), + CustomPrefix = ctx.Prefix, + GlobalOptionsBaseType = globalOptionsBaseType + }; + } + + /// + /// Gets all public properties from a type including inherited properties from base types. + /// + IEnumerable GetAllPublicProperties(ITypeSymbol type) + { + var seenNames = new HashSet(); + var currentType = type; + + while (currentType != null && currentType.SpecialType != SpecialType.System_Object) + { + foreach (var member in currentType.GetMembers()) + { + if (member is IPropertySymbol { DeclaredAccessibility: Accessibility.Public } prop + && seenNames.Add(prop.Name)) // Only add if not already seen (handles overrides) + { + yield return prop; + } + } + currentType = currentType.BaseType; + } + } + + /// + /// Checks if a member (property or parameter) is declared in a specific type. + /// + bool IsMemberDeclaredInType(ISymbol member, ITypeSymbol targetType) + { + // For parameters, check the containing method's containing type + if (member is IParameterSymbol param) + { + // Parameters in a constructor that come from a base type's primary constructor + // are not directly declared in the base type, so we check the property with the same name + var containingType = param.ContainingSymbol?.ContainingType; + if (containingType != null) + { + // Check if the base type has a property with this name + var baseType = targetType; + while (baseType != null && baseType.SpecialType != SpecialType.System_Object) + { + var matchingProp = baseType.GetMembers(param.Name) + .OfType() + .FirstOrDefault(); + if (matchingProp != null && SymbolEqualityComparer.Default.Equals(matchingProp.ContainingType, targetType)) + { + return true; + } + baseType = baseType.BaseType; + } + } + return false; + } + + // For properties, check if they're declared in the target type + if (member is IPropertySymbol prop) + { + return SymbolEqualityComparer.Default.Equals(prop.ContainingType, targetType); + } + + return false; + } + + string BuildCliName(string prefix, string propertyName) + { + var kebabProperty = generatorOptions.DisableNamingConversion ? propertyName : NameConverter.ToKebabCase(propertyName); + if (string.IsNullOrEmpty(prefix)) + { + return $"--{kebabProperty}"; + } + var kebabPrefix = generatorOptions.DisableNamingConversion ? prefix : NameConverter.ToKebabCase(prefix); + return $"--{kebabPrefix}-{kebabProperty}"; + } + + bool IsParsableType(ITypeSymbol type) + { + // Nullable + if (type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + var underlyingType = ((INamedTypeSymbol)type).TypeArguments[0]; + return IsParsableType(underlyingType); + } + + // Primitives + switch (type.SpecialType) + { + case SpecialType.System_String: + case SpecialType.System_Boolean: + case SpecialType.System_Char: + case SpecialType.System_SByte: + case SpecialType.System_Byte: + case SpecialType.System_Int16: + case SpecialType.System_UInt16: + case SpecialType.System_Int32: + case SpecialType.System_UInt32: + case SpecialType.System_Int64: + case SpecialType.System_UInt64: + case SpecialType.System_Decimal: + case SpecialType.System_Single: + case SpecialType.System_Double: + case SpecialType.System_DateTime: + return true; + } + + // Enum + if (type.TypeKind == TypeKind.Enum) + { + return true; + } + + // Array of ISpanParsable elements + if (type.TypeKind == TypeKind.Array) + { + var elementType = ((IArrayTypeSymbol)type).ElementType; + var parsable = wellKnownTypes.ISpanParsable; + if (parsable != null && elementType.AllInterfaces.Any(x => x.EqualsUnconstructedGenericType(parsable))) + { + return true; + } + // Arrays of non-ISpanParsable elements are handled via JSON fallback + return true; + } + + // Known types with TryParse + if (wellKnownTypes.HasTryParse(type)) + { + return true; + } + + // ISpanParsable + var spanParsable = wellKnownTypes.ISpanParsable; + if (spanParsable != null && type.AllInterfaces.Any(x => x.EqualsUnconstructedGenericType(spanParsable))) + { + return true; + } + + return false; + } + + bool IsNonNullableReferenceType(IPropertySymbol property) => + property is { NullableAnnotation: NullableAnnotation.NotAnnotated, Type.IsReferenceType: true } + && property.Type.SpecialType != SpecialType.System_String; // String is special-cased as nullable + + /// + /// Extracts the XML documentation summary from a property. + /// + string GetPropertyDescription(IPropertySymbol property) + { + if (property.DeclaringSyntaxReferences.Length == 0) + return ""; + + var syntax = property.DeclaringSyntaxReferences[0].GetSyntax(); + var docComment = syntax.GetDocumentationCommentTriviaSyntax(); + if (docComment == null) + return ""; + + return docComment.GetSummary(); + } + + /// + /// Extracts the initializer value from a property if it's a constant literal. + /// + object? GetPropertyInitializerValue(IPropertySymbol property) + { + if (property.DeclaringSyntaxReferences.Length == 0) + return null; + + var syntax = property.DeclaringSyntaxReferences[0].GetSyntax(); + + // Handle PropertyDeclarationSyntax (e.g., public int Port { get; set; } = 8080;) + if (syntax is PropertyDeclarationSyntax propDecl) + { + var initializer = propDecl.Initializer?.Value; + if (initializer != null) + { + return ExtractLiteralValue(initializer); + } + } + + return null; + } + + /// + /// Extracts a constant value from a literal expression. + /// + object? ExtractLiteralValue(SyntaxNode literalNode) + { + if (literalNode is LiteralExpressionSyntax literal) + return literal.Token.Value; + + // Handle prefix unary expressions like -5 + if (literalNode is not PrefixUnaryExpressionSyntax { Operand: LiteralExpressionSyntax operandLiteral } prefix) + return null; + + var value = operandLiteral.Token.Value; + if (prefix.OperatorToken.Text == "-" && value != null) + { + return value switch + { + int i => -i, + long l => -l, + float f => -f, + double d => -d, + decimal m => -m, + _ => null + }; + } + + return null; + } + + /// + /// Parses a description to extract aliases, argument marker, and the actual description text. + /// Format: "-h|--host, Description text" for aliases + /// Format: "argument, Description text" to mark as positional argument (case insensitive) + /// + void ParseBindableDescription(string originalDescription, out string[] aliases, out string description, out bool isArgument) + { + // Examples: + // -h|--help, This is a help. + // argument, This is a positional argument. + + var splitOne = originalDescription.Split([','], 2); + var prefix = splitOne[0].Trim(); + + // Check for "argument" prefix (case insensitive) + if (prefix.Equals(BindingMarkers.Argument, StringComparison.OrdinalIgnoreCase)) + { + aliases = []; + isArgument = true; + description = splitOne.Length > 1 ? splitOne[1].Trim() : string.Empty; + } + // Check for alias prefix (starts with -) + else if (prefix.StartsWith("-")) + { + aliases = prefix.Split(['|'], StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToArray(); + isArgument = false; + description = splitOne.Length > 1 ? splitOne[1].Trim() : string.Empty; + } + else + { + aliases = []; + isArgument = false; + description = originalDescription; + } + } + + /// + /// Finds the global options base type for a given type, if any. + /// Only matches against a known GlobalOptions type (from ConfigureGlobalOptions<T>()). + /// + /// The type to check for GlobalOptions inheritance. + /// The GlobalOptions type registered via ConfigureGlobalOptions<T>(). + /// A tuple of (EquatableTypeSymbol for the base type, the actual ITypeSymbol) or (null, null). + static (EquatableTypeSymbol? baseType, ITypeSymbol? globalOptionsType) FindGlobalOptionsBaseType( + ITypeSymbol type, ITypeSymbol? knownGlobalOptionsType) + { + // Only check if we have a known GlobalOptions type from ConfigureGlobalOptions() + if (knownGlobalOptionsType == null) + { + return (null, null); + } + + // Check if the type inherits from the known GlobalOptions type + for (var baseType = type.BaseType; baseType != null; baseType = baseType.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(baseType, knownGlobalOptionsType)) + { + return (new EquatableTypeSymbol(knownGlobalOptionsType), knownGlobalOptionsType); + } + } + + return (null, null); + } +} diff --git a/src/ConsoleAppFramework/Parser.cs b/src/ConsoleAppFramework/Parser.cs index 22e7324..7b423cf 100644 --- a/src/ConsoleAppFramework/Parser.cs +++ b/src/ConsoleAppFramework/Parser.cs @@ -8,7 +8,7 @@ namespace ConsoleAppFramework; -internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, DiagnosticReporter context, SyntaxNode node, SemanticModel model, WellKnownTypes wellKnownTypes, DelegateBuildType delegateBuildType, FilterInfo[] globalFilters) +internal partial class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, DiagnosticReporter context, SyntaxNode node, SemanticModel model, WellKnownTypes wellKnownTypes, DelegateBuildType delegateBuildType, FilterInfo[] globalFilters, ITypeSymbol? knownGlobalOptionsType = null) { public Command? ParseAndValidateForRun() // for ConsoleApp.Run, lambda or method or &method { @@ -176,6 +176,48 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag .ToArray(); } + /// + /// Parses ConfigureGlobalOptions<T>() and returns TypedGlobalOptionsInfo if T has [GlobalOptions] attribute. + /// + public TypedGlobalOptionsInfo? ParseTypedGlobalOptions() + { + var invocation = node as InvocationExpressionSyntax; + if (invocation == null) return null; + + // Get the generic type argument from ConfigureGlobalOptions() + var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; + if (memberAccess == null) return null; + + var genericName = memberAccess.Name as GenericNameSyntax; + if (genericName == null) return null; + + if (genericName.TypeArgumentList.Arguments.Count != 1) return null; + + var typeArg = genericName.TypeArgumentList.Arguments[0]; + var typeSymbol = model.GetTypeInfo(typeArg).Type; + if (typeSymbol == null) return null; + + // Use DiscoverObjectBinding to analyze the global options type (no prefix) + var objectBinding = DiscoverObjectBinding(typeSymbol, "", node.GetLocation()); + if (objectBinding == null) return null; + + // Validate that no properties are marked as arguments + foreach (var prop in objectBinding.Properties) + { + if (prop.ArgumentIndex >= 0) + { + context.ReportDiagnostic(DiagnosticDescriptors.GlobalOptionsCannotHaveArguments, typeArg.GetLocation(), typeSymbol.ToDisplayString(), prop.PropertyName); + return null; + } + } + + return new TypedGlobalOptionsInfo + { + Type = new EquatableTypeSymbol(typeSymbol), + ObjectBinding = objectBinding + }; + } + public GlobalOptionInfo[] ParseGlobalOptions() { var lambdaExpr = (node as InvocationExpressionSyntax); @@ -547,11 +589,41 @@ bool IsParsableType(ITypeSymbol type) return identifier is "Argument" or "ArgumentAttribute"; }); + // Check for [AsParameters] attribute + string? bindPrefix = null; + var hasBind = x.AttributeLists.SelectMany(x => x.Attributes) + .Any(attr => + { + var name = attr.Name; + if (attr.Name is QualifiedNameSyntax qns) + { + name = qns.Right; + } + + var identifier = name.ToString(); + var result = identifier is "AsParameters" or "AsParametersAttribute"; + if (result && attr.ArgumentList != null) + { + foreach (var arg in attr.ArgumentList.Arguments) + { + if (arg.NameEquals?.Name.Identifier.Text == "Prefix") + { + var value = model.GetConstantValue(arg.Expression); + if (value.HasValue) + { + bindPrefix = value.Value as string; + } + } + } + } + return result; + }); + var isCancellationToken = SymbolEqualityComparer.Default.Equals(type.Type!, wellKnownTypes.CancellationToken); var isConsoleAppContext = type.Type!.Name == "ConsoleAppContext"; var argumentIndex = -1; - if (!(isFromServices || isCancellationToken || isConsoleAppContext)) + if (!(isFromServices || isCancellationToken || isConsoleAppContext || hasBind)) { if (hasArgument) { @@ -565,41 +637,52 @@ bool IsParsableType(ITypeSymbol type) 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, - }; + return new BindParameterCandidate( + Parameter: 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, + ObjectBinding = null, // Will be filled in post-processing + }, + HasBind: hasBind, + BindPrefix: bindPrefix, + TypeSymbol: type.Type! + ); }) - .Where(x => x.Type != null) + .Where(x => x.TypeSymbol != null) .ToArray(); + // Post-process parameters to create ObjectBinding for [AsParameters] parameters + var processedParameters = ProcessBindParameters(parameters, lambda.GetLocation()); + if (processedParameters == null) + return null; // Error already reported + var cmd = new Command { Name = commandName, IsAsync = isAsync, IsVoid = isVoid, IsHidden = false, // Anonymous lambda don't support attribute. - Parameters = parameters, + Parameters = processedParameters, MethodKind = MethodKind.Lambda, Description = "", DelegateBuildType = delegateBuildType, @@ -742,6 +825,21 @@ bool IsParsableType(ITypeSymbol type) var isHiddenParameter = x.GetAttributes().Any(x => x.AttributeClass?.Name == "HiddenAttribute"); var isDefaultValueHidden = x.GetAttributes().Any(x => x.AttributeClass?.Name == "HideDefaultValueAttribute"); + // Check for [AsParameters] attribute + string? bindPrefix = null; + var bindAttr = x.GetAttributes().FirstOrDefault(a => a.AttributeClass?.Name == "AsParametersAttribute"); + var hasBind = bindAttr != null; + if (bindAttr != null) + { + foreach (var namedArg in bindAttr.NamedArguments) + { + if (namedArg.Key == "Prefix" && namedArg.Value.Value is string prefixValue) + { + bindPrefix = prefixValue; + } + } + } + object? keyedServiceKey = null; if (hasFromKeyedServices) { @@ -757,7 +855,7 @@ bool IsParsableType(ITypeSymbol type) } var argumentIndex = -1; - if (!(hasFromServices || isCancellationToken)) + if (!(hasFromServices || isCancellationToken || hasBind)) { if (hasArgument) { @@ -771,40 +869,53 @@ bool IsParsableType(ITypeSymbol type) 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 - }; + return new BindParameterCandidate( + Parameter: 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, + ObjectBinding = null, // Will be filled in post-processing + }, + HasBind: hasBind, + BindPrefix: bindPrefix, + TypeSymbol: x.Type + ); }) .ToArray(); + // Post-process parameters to create ObjectBinding for [AsParameters] parameters + var processedParameters = ProcessBindParameters(parameters, methodSymbol.Locations[0]); + if (processedParameters == null) + { + return null; // Error already reported + } + var cmd = new Command { Name = commandName, IsAsync = isAsync, IsVoid = isVoid, IsHidden = isHiddenCommand, - Parameters = parameters, + Parameters = processedParameters, MethodKind = addressOf ? MethodKind.FunctionPointer : MethodKind.Method, Description = summary, DelegateBuildType = delegateBuildType, diff --git a/src/ConsoleAppFramework/RoslynExtensions.cs b/src/ConsoleAppFramework/RoslynExtensions.cs index b94f195..706253b 100644 --- a/src/ConsoleAppFramework/RoslynExtensions.cs +++ b/src/ConsoleAppFramework/RoslynExtensions.cs @@ -147,9 +147,28 @@ static IEnumerable GetXmlElements(this SyntaxList public static string GetSummary(this DocumentationCommentTriviaSyntax docComment) { var summary = docComment.Content.GetXmlElements("summary").FirstOrDefault() as XmlElementSyntax; - if (summary == null) return ""; + if (summary != null) + return summary.Content.ToString().Replace("///", "").Trim(); - return summary.Content.ToString().Replace("///", "").Trim(); + // Support plain triple slash comments without XML tags + // e.g., /// This is a description + var plainText = new System.Text.StringBuilder(); + foreach (var node in docComment.Content) + { + if (node is XmlTextSyntax textSyntax) + { + foreach (var token in textSyntax.TextTokens) + { + var text = token.Text.Replace("///", "").Trim(); + if (!string.IsNullOrWhiteSpace(text)) + { + if (plainText.Length > 0) plainText.Append(' '); + plainText.Append(text); + } + } + } + } + return plainText.ToString().Trim(); } public static IEnumerable<(string Name, string Description)> GetParams(this DocumentationCommentTriviaSyntax docComment) diff --git a/src/ConsoleAppFramework/TypeParseHelper.cs b/src/ConsoleAppFramework/TypeParseHelper.cs new file mode 100644 index 0000000..fd7c0a7 --- /dev/null +++ b/src/ConsoleAppFramework/TypeParseHelper.cs @@ -0,0 +1,162 @@ +using Microsoft.CodeAnalysis; + +namespace ConsoleAppFramework; + +/// +/// Classifies how a type should be parsed from command-line arguments. +/// +public enum ParseCategory +{ + /// String type - direct assignment, no parsing. + String, + /// Boolean type - flag (presence = true) or explicit "true"/"false". + Boolean, + /// Primitive with simple TryParse(string, out T). + Primitive, + /// Enum type - uses Enum.TryParse<T>. + Enum, + /// Array of ISpanParsable elements - uses TrySplitParse. + Array, + /// ISpanParsable<T> - needs TryParse(string, IFormatProvider?, out T). + SpanParsable, + /// Fallback to JSON deserialization. + Json +} + +/// +/// Information about how to parse a type. +/// +public readonly record struct ParseInfo( + ParseCategory Category, + string FullTypeName, + string? ArrayElementTypeName = null +) : IEquatable +{ + public bool Equals(ParseInfo other) => + Category == other.Category && + FullTypeName == other.FullTypeName && + ArrayElementTypeName == other.ArrayElementTypeName; + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + Category.GetHashCode(); + hash = hash * 31 + (FullTypeName?.GetHashCode() ?? 0); + hash = hash * 31 + (ArrayElementTypeName?.GetHashCode() ?? 0); + return hash; + } + } +} + +/// +/// Shared utility for analyzing types and generating parse expressions. +/// +internal static class TypeParseHelper +{ + /// + /// Analyzes a type and returns how it should be parsed. + /// + public static ParseInfo AnalyzeType(ITypeSymbol type, WellKnownTypes wellKnownTypes) + { + // Unwrap Nullable + if (type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + type = ((INamedTypeSymbol)type).TypeArguments[0]; + } + + var fullTypeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Check SpecialType first + switch (type.SpecialType) + { + case SpecialType.System_String: + return new ParseInfo(ParseCategory.String, fullTypeName); + + case SpecialType.System_Boolean: + return new ParseInfo(ParseCategory.Boolean, fullTypeName); + + case SpecialType.System_Char: + case SpecialType.System_SByte: + case SpecialType.System_Byte: + case SpecialType.System_Int16: + case SpecialType.System_UInt16: + case SpecialType.System_Int32: + case SpecialType.System_UInt32: + case SpecialType.System_Int64: + case SpecialType.System_UInt64: + case SpecialType.System_Decimal: + case SpecialType.System_Single: + case SpecialType.System_Double: + case SpecialType.System_DateTime: + return new ParseInfo(ParseCategory.Primitive, fullTypeName); + } + + // Enum + if (type.TypeKind == TypeKind.Enum) + { + return new ParseInfo(ParseCategory.Enum, fullTypeName); + } + + // Array + if (type.TypeKind == TypeKind.Array) + { + var elementType = ((IArrayTypeSymbol)type).ElementType; + if (ImplementsISpanParsable(elementType, wellKnownTypes)) + { + var elementTypeName = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return new ParseInfo(ParseCategory.Array, fullTypeName, elementTypeName); + } + // Array of non-ISpanParsable elements falls back to JSON + return new ParseInfo(ParseCategory.Json, fullTypeName); + } + + // Known types with simple TryParse (Guid, DateTimeOffset, TimeSpan, etc.) + if (wellKnownTypes.HasTryParse(type)) + { + return new ParseInfo(ParseCategory.Primitive, fullTypeName); + } + + // ISpanParsable (BigInteger, Complex, Half, Int128, etc.) + if (ImplementsISpanParsable(type, wellKnownTypes)) + { + return new ParseInfo(ParseCategory.SpanParsable, fullTypeName); + } + + // Fallback to JSON + return new ParseInfo(ParseCategory.Json, fullTypeName); + } + + /// + /// Builds the TryParse expression for a type (without index handling or error handling). + /// + /// The parse expression, e.g., "int.TryParse(input, out var result)" + public static string BuildParseExpression(ParseInfo info, string inputExpr, string outVar) + { + return info.Category switch + { + ParseCategory.Primitive => $"{info.FullTypeName}.TryParse({inputExpr}, {outVar})", + ParseCategory.Enum => $"Enum.TryParse<{info.FullTypeName}>({inputExpr}, true, {outVar})", + ParseCategory.SpanParsable => $"{info.FullTypeName}.TryParse({inputExpr}, null, {outVar})", + ParseCategory.Array => $"TrySplitParse({inputExpr}, {outVar})", + _ => throw new InvalidOperationException($"Cannot build parse expression for {info.Category}") + }; + } + + /// + /// Builds the error throw expression for parse failures. + /// + public static string BuildThrowExpression(ParseInfo info, string argName, string inputExpr) + { + return info.Category == ParseCategory.Enum + ? $"ThrowArgumentParseFailedEnum(typeof({info.FullTypeName}), \"{argName}\", {inputExpr})" + : $"ThrowArgumentParseFailed(\"{argName}\", {inputExpr})"; + } + + static bool ImplementsISpanParsable(ITypeSymbol type, WellKnownTypes wellKnownTypes) + { + var parsable = wellKnownTypes.ISpanParsable; + return parsable != null && type.AllInterfaces.Any(x => x.EqualsUnconstructedGenericType(parsable)); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersAdvancedTests.cs b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersAdvancedTests.cs new file mode 100644 index 0000000..49261cd --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersAdvancedTests.cs @@ -0,0 +1,139 @@ +namespace ConsoleAppFramework.GeneratorTests.AsParameters; + +[ClassDataSource] +public class AsParametersAdvancedTests(VerifyHelper verifier) +{ + [Test] + public async Task MixedParametersWithAsParameters() + { + // language=csharp + var code = """ +using System; + +public record Options(bool Verbose = false, bool DryRun = false); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ( + string name, + int count, + [AsParameters] Options options + ) => + { + Console.Write($"name={name}, count={count}, verbose={options.Verbose}, dryRun={options.DryRun}"); + }); + } +} +"""; + + await verifier.Execute(code, "--name test --count 5 --verbose", "name=test, count=5, verbose=True, dryRun=False"); + } + + [Test] + public async Task MultipleAsParametersOnSameCommand() + { + // language=csharp + var code = """ +using System; + +public class DatabaseConfig +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 5432; +} + +public class CacheConfig +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 6379; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ( + [AsParameters] DatabaseConfig database, + [AsParameters] CacheConfig cache + ) => + { + Console.Write($"DB={database.Host}:{database.Port}, Cache={cache.Host}:{cache.Port}"); + }); + } +} +"""; + + await verifier.Execute(code, "--database-host db.example.com --cache-host cache.example.com --cache-port 6380", "DB=db.example.com:5432, Cache=cache.example.com:6380"); + } + + [Test] + public async Task MultipleAsParameters_DifferentPrefixes_SamePropertyName() + { + // language=csharp + var code = """ +using System; + +public class DbConfig +{ + public string ConnectionString { get; set; } = ""; + public int Timeout { get; set; } = 30; +} + +public class CacheConfig +{ + public string ConnectionString { get; set; } = ""; + public int Timeout { get; set; } = 10; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ( + [AsParameters(Prefix = "db")] DbConfig db, + [AsParameters(Prefix = "cache")] CacheConfig cache + ) => + { + Console.Write($"DB={db.ConnectionString}:{db.Timeout}, Cache={cache.ConnectionString}:{cache.Timeout}"); + }); + } +} +"""; + + await verifier.Execute(code, "--db-connection-string server=db --db-timeout 60 --cache-connection-string server=cache --cache-timeout 5", "DB=server=db:60, Cache=server=cache:5"); + } + + [Test] + public async Task AsParametersWithRegularParameters() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public bool Verbose { get; set; } = false; + public string Format { get; set; } = "json"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ( + [Argument] string input, + [Argument] string output, + [AsParameters] Config config + ) => + { + Console.Write($"input={input}, output={output}, verbose={config.Verbose}, format={config.Format}"); + }); + } +} +"""; + + await verifier.Execute(code, "in.txt out.txt --verbose --format xml", "input=in.txt, output=out.txt, verbose=True, format=xml"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersArgumentTests.cs b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersArgumentTests.cs new file mode 100644 index 0000000..9e82eab --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersArgumentTests.cs @@ -0,0 +1,257 @@ +namespace ConsoleAppFramework.GeneratorTests.AsParameters; + +[ClassDataSource] +public class AsParametersArgumentTests(VerifyHelper verifier) +{ + [Test] + public async Task ArgumentsOnConstructorParameters() + { + // language=csharp + var code = """ +using System; + +public record MoveArgs([Argument] string Source, [Argument] string Target, bool Force = false); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] MoveArgs moveArgs) => + { + Console.Write($"Source={moveArgs.Source}, Target={moveArgs.Target}, Force={moveArgs.Force}"); + }); + } +} +"""; + + await verifier.Execute(code, "/src /dest --force", "Source=/src, Target=/dest, Force=True"); + await verifier.Execute(code, "/src /dest", "Source=/src, Target=/dest, Force=False"); + } + + [Test] + public async Task ArgumentsOnProperties() + { + // language=csharp + var code = """ +using System; + +public class CopyArgs +{ + [Argument] public string Source { get; set; } = ""; + [Argument] public string Destination { get; set; } = ""; + public bool Recursive { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] CopyArgs copyArgs) => + { + Console.Write($"Source={copyArgs.Source}, Dest={copyArgs.Destination}, Recursive={copyArgs.Recursive}"); + }); + } +} +"""; + + await verifier.Execute(code, "/src /dest --recursive", "Source=/src, Dest=/dest, Recursive=True"); + await verifier.Execute(code, "/src /dest", "Source=/src, Dest=/dest, Recursive=False"); + } + + [Test] + public async Task ArgumentWithDefault_IsOptional() + { + // language=csharp + var code = """ +using System; + +public record CopyOptions([Argument] string Source, [Argument] string Destination = ".", bool Recursive = false); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] CopyOptions opts) => + { + Console.Write($"Source={opts.Source}, Dest={opts.Destination}, Recursive={opts.Recursive}"); + }); + } +} +"""; + + // Both arguments provided + await verifier.Execute(code, "/src /dest", "Source=/src, Dest=/dest, Recursive=False"); + // Only required argument - Destination uses default "." + await verifier.Execute(code, "/src", "Source=/src, Dest=., Recursive=False"); + // With option + await verifier.Execute(code, "/src --recursive", "Source=/src, Dest=., Recursive=True"); + } + + [Test] + public async Task ArgumentWithoutDefault_IsRequired() + { + // language=csharp + var code = """ +using System; + +public record MoveOptions([Argument] string Source, [Argument] string Destination); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] MoveOptions opts) => + { + Console.Write($"Source={opts.Source}, Dest={opts.Destination}"); + }); + } +} +"""; + + // Both required arguments provided + await verifier.Execute(code, "/src /dest", "Source=/src, Dest=/dest"); + + // Missing second required argument should fail + var (_, exitCode1) = verifier.Error(code, "/src"); + await Assert.That(exitCode1).IsNotEqualTo(0); + + // Missing both should fail + var (_, exitCode2) = verifier.Error(code, ""); + await Assert.That(exitCode2).IsNotEqualTo(0); + } + + [Test] + public async Task ArgumentOnPropertyWithDefault_IsOptional() + { + // language=csharp + var code = """ +using System; + +public class SearchArgs +{ + [Argument] public string Pattern { get; set; } = "*"; + [Argument] public string Path { get; set; } = "."; + public bool Recursive { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] SearchArgs searchArgs) => + { + Console.Write($"Pattern={searchArgs.Pattern}, Path={searchArgs.Path}, Recursive={searchArgs.Recursive}"); + }); + } +} +"""; + + // Both arguments provided + await verifier.Execute(code, "*.txt /home", "Pattern=*.txt, Path=/home, Recursive=False"); + // Only first argument - Path uses default "." + await verifier.Execute(code, "*.txt", "Pattern=*.txt, Path=., Recursive=False"); + // No arguments - both use defaults + await verifier.Execute(code, "", "Pattern=*, Path=., Recursive=False"); + // No arguments but with option + await verifier.Execute(code, "--recursive", "Pattern=*, Path=., Recursive=True"); + // First argument with option + await verifier.Execute(code, "*.log --recursive", "Pattern=*.log, Path=., Recursive=True"); + } + + [Test] + public async Task ArgumentCommentOnProperty() + { + // language=csharp + var code = """ +using System; + +public class CopyArgs +{ + /// argument, The source file + public string Source { get; set; } = ""; + + /// argument, The destination file + public string Destination { get; set; } = ""; + + public bool Recursive { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] CopyArgs copyArgs) => + { + Console.Write($"Source={copyArgs.Source}, Dest={copyArgs.Destination}, Recursive={copyArgs.Recursive}"); + }); + } +} +"""; + + // Using "argument," in XML doc comment marks property as positional argument + await verifier.Execute(code, "/src /dest --recursive", "Source=/src, Dest=/dest, Recursive=True"); + await verifier.Execute(code, "/src /dest", "Source=/src, Dest=/dest, Recursive=False"); + } + + [Test] + public async Task ArgumentCommentOnConstructorParam() + { + // language=csharp + var code = """ +using System; + +/// Move arguments +/// argument, The source path +/// argument, The target path +public record MoveArgs(string source, string target, bool force = false); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] MoveArgs moveArgs) => + { + Console.Write($"Source={moveArgs.source}, Target={moveArgs.target}, Force={moveArgs.force}"); + }); + } +} +"""; + + // Using "argument," in doc marks constructor param as positional argument + await verifier.Execute(code, "/src /dest --force", "Source=/src, Target=/dest, Force=True"); + await verifier.Execute(code, "/src /dest", "Source=/src, Target=/dest, Force=False"); + } + + [Test] + public async Task ArgumentCommentWithPlainTripleSlash() + { + // language=csharp + var code = """ +using System; + +public class GrepArgs +{ + /// argument, The search pattern + public string Pattern { get; set; } = ""; + + /// -r, Search recursively + public bool Recursive { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] GrepArgs grepArgs) => + { + Console.Write($"Pattern={grepArgs.Pattern}, Recursive={grepArgs.Recursive}"); + }); + } +} +"""; + + // Plain triple slash comments with "argument," also work + await verifier.Execute(code, "*.txt -r", "Pattern=*.txt, Recursive=True"); + await verifier.Execute(code, "*.log", "Pattern=*.log, Recursive=False"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersBasicTests.cs b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersBasicTests.cs new file mode 100644 index 0000000..145dbb1 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersBasicTests.cs @@ -0,0 +1,121 @@ +namespace ConsoleAppFramework.GeneratorTests.AsParameters; + +[ClassDataSource] +public class AsParametersBasicTests(VerifyHelper verifier) +{ + [Test] + public async Task BasicClassBinding() + { + // language=csharp + var code = """ +using System; + +public class DatabaseConfig +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 5432; + public string Database { get; set; } = "mydb"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] DatabaseConfig config) => + { + Console.Write($"{config.Host}:{config.Port}/{config.Database}"); + }); + } +} +"""; + + await verifier.Execute(code, "--host myhost --port 3306 --database testdb", "myhost:3306/testdb"); + } + + [Test] + public async Task BasicClassBindingWithDefaults() + { + // language=csharp + var code = """ +using System; + +public class DatabaseConfig +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 5432; + public string Database { get; set; } = "mydb"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] DatabaseConfig config) => + { + Console.Write($"{config.Host}:{config.Port}/{config.Database}"); + }); + } +} +"""; + + // Only override Host, use defaults for others + await verifier.Execute(code, "--host myhost", "myhost:5432/mydb"); + } + + [Test] + public async Task CustomPrefix() + { + // language=csharp + var code = """ +using System; + +public class ServerConfig +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 8080; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters(Prefix = "server")] ServerConfig config) => + { + Console.Write($"{config.Host}:{config.Port}"); + }); + } +} +"""; + + await verifier.Execute(code, "--server-host myhost --server-port 9000", "myhost:9000"); + } + + [Test] + public async Task CaseInsensitiveMatching() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 8080; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"{config.Host}:{config.Port}"); + }); + } +} +"""; + + // Test case-insensitive matching + await verifier.Execute(code, "--HOST myhost --PORT 9000", "myhost:9000"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersEdgeCasesTests.cs b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersEdgeCasesTests.cs new file mode 100644 index 0000000..514e071 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersEdgeCasesTests.cs @@ -0,0 +1,503 @@ +namespace ConsoleAppFramework.GeneratorTests.AsParameters; + +[ClassDataSource] +public class AsParametersEdgeCasesTests(VerifyHelper verifier) +{ + [Test] + public async Task DeepInheritance_ThreeLevels() + { + // language=csharp + var code = """ +using System; + +public class BaseOptions +{ + public bool Verbose { get; set; } = false; +} + +public class MiddleOptions : BaseOptions +{ + public string Format { get; set; } = "json"; +} + +public class FinalOptions : MiddleOptions +{ + public string Output { get; set; } = "stdout"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] FinalOptions options) => + { + Console.Write($"verbose={options.Verbose}, format={options.Format}, output={options.Output}"); + }); + } +} +"""; + + await verifier.Execute(code, "--verbose --format xml --output file.txt", "verbose=True, format=xml, output=file.txt"); + } + + [Test] + public async Task DeepInheritance_ThreeLevels_DefaultValues() + { + // language=csharp + var code = """ +using System; + +public class BaseOptions +{ + public bool Debug { get; set; } = true; +} + +public class MiddleOptions : BaseOptions +{ + public int Level { get; set; } = 5; +} + +public class FinalOptions : MiddleOptions +{ + public string Mode { get; set; } = "auto"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] FinalOptions options) => + { + Console.Write($"debug={options.Debug}, level={options.Level}, mode={options.Mode}"); + }); + } +} +"""; + + // Using all defaults + await verifier.Execute(code, "", "debug=True, level=5, mode=auto"); + } + + [Test] + public async Task EmptyAsParametersClass_AllDefaults() + { + // language=csharp + var code = """ +using System; + +public class EmptyConfig +{ + // No properties - but still valid +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] EmptyConfig config) => + { + Console.Write($"config is not null: {config != null}"); + }); + } +} +"""; + + await verifier.Execute(code, "", "config is not null: True"); + } + + [Test] + public async Task InvalidStringToInt_ErrorCase() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public int Port { get; set; } = 8080; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"port={config.Port}"); + }); + } +} +"""; + + // Pass a non-numeric value for an int property - should fail parsing + var (stdout, exitCode) = verifier.Error(code, "--port not-a-number"); + await Assert.That(exitCode).IsNotEqualTo(0); + } + + [Test] + public async Task EnumProperty_ValidValue() + { + // language=csharp + var code = """ +using System; + +public enum LogLevel { Debug, Info, Warning, Error } + +public class Config +{ + public LogLevel Level { get; set; } = LogLevel.Info; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"level={config.Level}"); + }); + } +} +"""; + + await verifier.Execute(code, "--level Error", "level=Error"); + } + + [Test] + public async Task EnumProperty_InvalidValue_ErrorCase() + { + // language=csharp + var code = """ +using System; + +public enum LogLevel { Debug, Info, Warning, Error } + +public class Config +{ + public LogLevel Level { get; set; } = LogLevel.Info; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"level={config.Level}"); + }); + } +} +"""; + + // Pass an invalid enum value - should fail parsing + var (stdout, exitCode) = verifier.Error(code, "--level NotAValidLevel"); + await Assert.That(exitCode).IsNotEqualTo(0); + } + + // Note: Array properties are not yet supported in [AsParameters] types + // Future feature: ArrayProperty_MultipleValues test + + [Test] + public async Task NullableIntProperty() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public int? OptionalCount { get; set; } = null; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"count={config.OptionalCount?.ToString() ?? "null"}"); + }); + } +} +"""; + + // Without the optional value + await verifier.Execute(code, "", "count=null"); + } + + [Test] + public async Task NullableIntProperty_WithValue() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public int? OptionalCount { get; set; } = null; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"count={config.OptionalCount?.ToString() ?? "null"}"); + }); + } +} +"""; + + // With the optional value + await verifier.Execute(code, "--optional-count 42", "count=42"); + } + + [Test] + public async Task SinglePropertyClass() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public string Name { get; set; } = "default"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"name={config.Name}"); + }); + } +} +"""; + + await verifier.Execute(code, "--name test", "name=test"); + } + + [Test] + public async Task ManyProperties_TenPlus() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public string Prop1 { get; set; } = "a"; + public string Prop2 { get; set; } = "b"; + public string Prop3 { get; set; } = "c"; + public string Prop4 { get; set; } = "d"; + public string Prop5 { get; set; } = "e"; + public string Prop6 { get; set; } = "f"; + public string Prop7 { get; set; } = "g"; + public string Prop8 { get; set; } = "h"; + public string Prop9 { get; set; } = "i"; + public string Prop10 { get; set; } = "j"; + public string Prop11 { get; set; } = "k"; + public string Prop12 { get; set; } = "l"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"{config.Prop1},{config.Prop2},{config.Prop3},{config.Prop4},{config.Prop5},{config.Prop6},{config.Prop7},{config.Prop8},{config.Prop9},{config.Prop10},{config.Prop11},{config.Prop12}"); + }); + } +} +"""; + + await verifier.Execute(code, "--prop1 1 --prop2 2 --prop3 3 --prop4 4 --prop5 5 --prop6 6 --prop7 7 --prop8 8 --prop9 9 --prop10 10 --prop11 11 --prop12 12", "1,2,3,4,5,6,7,8,9,10,11,12"); + } + + [Test] + public async Task StructType_Works() + { + // language=csharp + var code = """ +using System; + +public struct Config +{ + public string Name { get; set; } + public int Port { get; set; } +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"name={config.Name}, port={config.Port}"); + }); + } +} +"""; + + await verifier.Execute(code, "--name test --port 8080", "name=test, port=8080"); + } + + [Test] + public async Task RecordStructType_Works() + { + // language=csharp + var code = """ +using System; + +public record struct Config(string Name = "default", int Port = 8080); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"name={config.Name}, port={config.Port}"); + }); + } +} +"""; + + await verifier.Execute(code, "--name test --port 9000", "name=test, port=9000"); + } + + [Test] + public async Task AcronymPropertyName_CorrectKebabCase() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public int HTTPPort { get; set; } = 80; + public string XMLPath { get; set; } = ""; + public bool SSLEnabled { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"port={config.HTTPPort}, xml={config.XMLPath}, ssl={config.SSLEnabled}"); + }); + } +} +"""; + + // Consecutive capitals stay together until the last one before lowercase + // HTTPPort -> http-port, XMLPath -> xml-path, SSLEnabled -> ssl-enabled + await verifier.Execute(code, "--http-port 443 --xml-path /data --ssl-enabled", "port=443, xml=/data, ssl=True"); + } + + [Test] + public async Task UnderscorePropertyName() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public string Log_Level { get; set; } = "info"; + public int Max_Retries { get; set; } = 3; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"level={config.Log_Level}, retries={config.Max_Retries}"); + }); + } +} +"""; + + // Properties with underscores - underscore is preserved, dash added before capitals + // Log_Level -> log_-level, Max_Retries -> max_-retries + await verifier.Execute(code, "--log_-level debug --max_-retries 5", "level=debug, retries=5"); + } + + [Test] + public async Task PropertyNamedHelp_Works() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public string HelpText { get; set; } = ""; + public bool ShowVersion { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"help={config.HelpText}, version={config.ShowVersion}"); + }); + } +} +"""; + + await verifier.Execute(code, "--help-text readme --show-version", "help=readme, version=True"); + } + + [Test] + public async Task DeepInheritance_FourLevels() + { + // language=csharp + var code = """ +using System; + +public class Level1 +{ + public bool Verbose { get; set; } = false; +} + +public class Level2 : Level1 +{ + public string Format { get; set; } = "json"; +} + +public class Level3 : Level2 +{ + public string Output { get; set; } = "stdout"; +} + +public class Level4 : Level3 +{ + public int Count { get; set; } = 1; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Level4 options) => + { + Console.Write($"verbose={options.Verbose}, format={options.Format}, output={options.Output}, count={options.Count}"); + }); + } +} +"""; + + await verifier.Execute(code, "--verbose --format xml --output file.txt --count 5", "verbose=True, format=xml, output=file.txt, count=5"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersRecordTests.cs b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersRecordTests.cs new file mode 100644 index 0000000..1428fa8 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersRecordTests.cs @@ -0,0 +1,86 @@ +namespace ConsoleAppFramework.GeneratorTests.AsParameters; + +[ClassDataSource] +public class AsParametersRecordTests(VerifyHelper verifier) +{ + [Test] + public async Task RecordWithPrimaryConstructor() + { + // language=csharp + var code = """ +using System; + +public record MoveOptions(bool Force, bool Recursive, bool Verbose); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] MoveOptions options) => + { + Console.Write($"Force={options.Force}, Recursive={options.Recursive}, Verbose={options.Verbose}"); + }); + } +} +"""; + + await verifier.Execute(code, "--force --recursive", "Force=True, Recursive=True, Verbose=False"); + } + + [Test] + public async Task RecordWithPrimaryConstructorDefaults() + { + // language=csharp + var code = """ +using System; + +public record CopyOptions(bool Force = false, bool Recursive = true, bool Verbose = false); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] CopyOptions options) => + { + Console.Write($"Force={options.Force}, Recursive={options.Recursive}, Verbose={options.Verbose}"); + }); + } +} +"""; + + await verifier.Execute(code, "--force", "Force=True, Recursive=True, Verbose=False"); + // With no args, all keep their defaults (including Recursive=true) + await verifier.Execute(code, "", "Force=False, Recursive=True, Verbose=False"); + } + + [Test] + public async Task RecordWithMixedConstructorAndProperties() + { + // language=csharp + var code = """ +using System; + +public record Config(string Name, int Priority = 0) +{ + public bool Enabled { get; init; } = true; + public string Description { get; init; } = "default-desc"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Name={config.Name}, Priority={config.Priority}, Enabled={config.Enabled}, Desc={config.Description}"); + }); + } +} +"""; + + // Init-only properties WITHOUT 'required' preserve class defaults when not specified + await verifier.Execute(code, "--name test --priority 5 --description hello --enabled", "Name=test, Priority=5, Enabled=True, Desc=hello"); + // Without --enabled and --description, they keep their class initializer defaults (true and "default-desc") + await verifier.Execute(code, "--name test --priority 5", "Name=test, Priority=5, Enabled=True, Desc=default-desc"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersRequiredTests.cs b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersRequiredTests.cs new file mode 100644 index 0000000..b0eceec --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersRequiredTests.cs @@ -0,0 +1,418 @@ +namespace ConsoleAppFramework.GeneratorTests.AsParameters; + +[ClassDataSource] +public class AsParametersRequiredTests(VerifyHelper verifier) +{ + [Test] + public async Task RequiredInitProperty_MustBeSpecified() + { + // language=csharp + var code = """ +using System; + +public record Config(string Name) +{ + public required string ApiKey { get; init; } +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Name={config.Name}, ApiKey={config.ApiKey}"); + }); + } +} +"""; + + await verifier.Execute(code, "--name test --api-key secret123", "Name=test, ApiKey=secret123"); + } + + [Test] + public async Task RequiredInitProperty_ErrorWhenMissing() + { + // language=csharp + var code = """ +using System; + +public record Config(string Name) +{ + public required string ApiKey { get; init; } +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Name={config.Name}, ApiKey={config.ApiKey}"); + }); + } +} +"""; + + // Missing required property should fail (error messages go to stderr, not captured) + var (_, exitCode) = verifier.Error(code, "--name test"); + await Assert.That(exitCode).IsNotEqualTo(0); + } + + [Test] + public async Task ConstructorParameter_WithoutDefault_IsRequired() + { + // language=csharp + var code = """ +using System; + +public record Config(string Name, string Environment); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Name={config.Name}, Env={config.Environment}"); + }); + } +} +"""; + + // Both constructor params without defaults are required + await verifier.Execute(code, "--name myapp --environment prod", "Name=myapp, Env=prod"); + + // Missing Name should fail + var (_, exitCode1) = verifier.Error(code, "--environment prod"); + await Assert.That(exitCode1).IsNotEqualTo(0); + + // Missing Environment should fail + var (_, exitCode2) = verifier.Error(code, "--name myapp"); + await Assert.That(exitCode2).IsNotEqualTo(0); + + // Missing both should fail + var (_, exitCode3) = verifier.Error(code, ""); + await Assert.That(exitCode3).IsNotEqualTo(0); + } + + [Test] + public async Task ConstructorParameter_WithDefault_IsOptional() + { + // language=csharp + var code = """ +using System; + +public record Config(string Name, string Environment = "development", int Port = 8080); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Name={config.Name}, Env={config.Environment}, Port={config.Port}"); + }); + } +} +"""; + + // Name is required (no default), others are optional + await verifier.Execute(code, "--name myapp", "Name=myapp, Env=development, Port=8080"); + await verifier.Execute(code, "--name myapp --environment prod", "Name=myapp, Env=prod, Port=8080"); + await verifier.Execute(code, "--name myapp --port 9000", "Name=myapp, Env=development, Port=9000"); + + // Missing required Name should fail + var (_, exitCode) = verifier.Error(code, "--environment prod --port 9000"); + await Assert.That(exitCode).IsNotEqualTo(0); + } + + [Test] + public async Task MixedConstructorAndInitProperties_RequiredValidation() + { + // language=csharp + var code = """ +using System; + +public record ServiceConfig(string ServiceName, int Port = 8080) +{ + public required string ApiKey { get; init; } + public string Region { get; init; } = "us-east-1"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] ServiceConfig config) => + { + Console.Write($"Name={config.ServiceName}, Port={config.Port}, Key={config.ApiKey}, Region={config.Region}"); + }); + } +} +"""; + + // ServiceName (ctor, no default) and ApiKey (required init) are both required + await verifier.Execute(code, "--service-name myapp --api-key secret", "Name=myapp, Port=8080, Key=secret, Region=us-east-1"); + + // Missing ServiceName should fail + var (_, exitCode1) = verifier.Error(code, "--api-key secret"); + await Assert.That(exitCode1).IsNotEqualTo(0); + + // Missing ApiKey should fail + var (_, exitCode2) = verifier.Error(code, "--service-name myapp"); + await Assert.That(exitCode2).IsNotEqualTo(0); + + // Missing both required should fail + var (_, exitCode3) = verifier.Error(code, "--port 9000 --region eu-west-1"); + await Assert.That(exitCode3).IsNotEqualTo(0); + } + + [Test] + public async Task OptionalInitProperty_PreservesDefault() + { + // language=csharp + var code = """ +using System; + +public record Config(string Name) +{ + public string Description { get; init; } = "default-description"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Name={config.Name}, Desc={config.Description}"); + }); + } +} +"""; + + // Without specifying description, it keeps the class default + await verifier.Execute(code, "--name test", "Name=test, Desc=default-description"); + // With description specified, it uses the provided value + await verifier.Execute(code, "--name test --description custom", "Name=test, Desc=custom"); + } + + [Test] + public async Task MixedRequiredAndOptionalInitProperties() + { + // language=csharp + var code = """ +using System; + +public record Config(string Name) +{ + public required string ApiKey { get; init; } + public string Environment { get; init; } = "production"; + public int Timeout { get; init; } = 30; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Name={config.Name}, ApiKey={config.ApiKey}, Env={config.Environment}, Timeout={config.Timeout}"); + }); + } +} +"""; + + // Only required property specified - optionals keep defaults + await verifier.Execute(code, "--name app --api-key key123", "Name=app, ApiKey=key123, Env=production, Timeout=30"); + // Override one optional + await verifier.Execute(code, "--name app --api-key key123 --environment staging", "Name=app, ApiKey=key123, Env=staging, Timeout=30"); + // Override all + await verifier.Execute(code, "--name app --api-key key123 --environment dev --timeout 60", "Name=app, ApiKey=key123, Env=dev, Timeout=60"); + } + + [Test] + public async Task RequiredPropertyOnClass() + { + // language=csharp + var code = """ +using System; + +public class DatabaseConfig +{ + public required string ConnectionString { get; set; } + public int MaxConnections { get; set; } = 10; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] DatabaseConfig config) => + { + Console.Write($"CS={config.ConnectionString}, Max={config.MaxConnections}"); + }); + } +} +"""; + + await verifier.Execute(code, "--connection-string localhost:5432", "CS=localhost:5432, Max=10"); + await verifier.Execute(code, "--connection-string localhost:5432 --max-connections 50", "CS=localhost:5432, Max=50"); + } + + [Test] + public async Task RequiredPropertyOnClass_ErrorWhenMissing() + { + // language=csharp + var code = """ +using System; + +public class DatabaseConfig +{ + public required string ConnectionString { get; set; } + public int MaxConnections { get; set; } = 10; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] DatabaseConfig config) => + { + Console.Write($"CS={config.ConnectionString}, Max={config.MaxConnections}"); + }); + } +} +"""; + + // Missing required property should fail (error messages go to stderr, not captured) + var (_, exitCode) = verifier.Error(code, "--max-connections 50"); + await Assert.That(exitCode).IsNotEqualTo(0); + } + + [Test] + public async Task MultipleRequiredProperties() + { + // language=csharp + var code = """ +using System; + +public record Credentials +{ + public required string Username { get; init; } + public required string Password { get; init; } + public string Domain { get; init; } = "default"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Credentials creds) => + { + Console.Write($"User={creds.Username}, Pass={creds.Password}, Domain={creds.Domain}"); + }); + } +} +"""; + + await verifier.Execute(code, "--username admin --password secret", "User=admin, Pass=secret, Domain=default"); + await verifier.Execute(code, "--username admin --password secret --domain corp", "User=admin, Pass=secret, Domain=corp"); + } + + [Test] + public async Task RequiredWithConstructorParameters() + { + // language=csharp + var code = """ +using System; + +public record ServiceConfig(string ServiceName, int Port = 8080) +{ + public required string ApiKey { get; init; } + public bool EnableLogging { get; init; } = true; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] ServiceConfig config) => + { + Console.Write($"Service={config.ServiceName}, Port={config.Port}, Key={config.ApiKey}, Log={config.EnableLogging}"); + }); + } +} +"""; + + // ServiceName is required (ctor param without default), ApiKey is required (required modifier) + // EnableLogging keeps its default (true) when not specified + await verifier.Execute(code, "--service-name myapp --api-key abc123", "Service=myapp, Port=8080, Key=abc123, Log=True"); + } + + [Test] + public async Task BoolInitProperty_PreservesDefaults() + { + // language=csharp + var code = """ +using System; + +public record FeatureFlags(string AppName) +{ + public bool EnableMetrics { get; init; } = true; + public bool EnableTracing { get; init; } = true; + public bool EnableCaching { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] FeatureFlags flags) => + { + Console.Write($"Metrics={flags.EnableMetrics}, Tracing={flags.EnableTracing}, Caching={flags.EnableCaching}"); + }); + } +} +"""; + + // All keep defaults (true, true, false) + await verifier.Execute(code, "--app-name myapp", "Metrics=True, Tracing=True, Caching=False"); + // Boolean flags: presence sets to true, cannot set to false via CLI + // Enable caching (was false by default) + await verifier.Execute(code, "--app-name myapp --enable-caching", "Metrics=True, Tracing=True, Caching=True"); + } + + [Test] + public async Task NullableInitProperty_PreservesNull() + { + // language=csharp + var code = """ +#nullable enable +using System; + +public record Config(string Name) +{ + public string? OptionalTag { get; init; } + public int? OptionalCount { get; init; } +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Name={config.Name}, Tag={config.OptionalTag ?? "null"}, Count={config.OptionalCount?.ToString() ?? "null"}"); + }); + } +} +"""; + + // Nullable properties stay null when not specified + await verifier.Execute(code, "--name test", "Name=test, Tag=null, Count=null"); + // Can be set + await verifier.Execute(code, "--name test --optional-tag mytag --optional-count 42", "Name=test, Tag=mytag, Count=42"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersTypesTests.cs b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersTypesTests.cs new file mode 100644 index 0000000..9142cf8 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersTypesTests.cs @@ -0,0 +1,518 @@ +namespace ConsoleAppFramework.GeneratorTests.AsParameters; + +[ClassDataSource] +public class AsParametersTypesTests(VerifyHelper verifier) +{ + [Test] + public async Task EnumProperty() + { + // language=csharp + var code = """ +using System; + +public enum LogLevel { Debug, Info, Warning, Error } + +public class LogConfig +{ + public LogLevel Level { get; set; } = LogLevel.Info; + public string OutputPath { get; set; } = "log.txt"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] LogConfig config) => + { + Console.Write($"Level={config.Level}, Output={config.OutputPath}"); + }); + } +} +"""; + + await verifier.Execute(code, "--level Warning --output-path errors.log", "Level=Warning, Output=errors.log"); + } + + [Test] + public async Task NullableProperties() + { + // language=csharp + var code = """ +#nullable enable +using System; + +public class Config +{ + public string? OptionalHost { get; set; } + public int? OptionalPort { get; set; } + public string RequiredName { get; set; } = "default"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Host={config.OptionalHost ?? "null"}, Port={config.OptionalPort?.ToString() ?? "null"}, Name={config.RequiredName}"); + }); + } +} +"""; + + await verifier.Execute(code, "--optional-host myhost --required-name test", "Host=myhost, Port=null, Name=test"); + await verifier.Execute(code, "", "Host=null, Port=null, Name=default"); + } + + [Test] + public async Task DateTimeProperty() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public DateTime StartDate { get; set; } = DateTime.MinValue; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"StartDate={config.StartDate:yyyy-MM-dd}"); + }); + } +} +"""; + + await verifier.Execute(code, "--start-date 2024-01-15", "StartDate=2024-01-15"); + } + + [Test] + public async Task GuidProperty() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public Guid Id { get; set; } = Guid.Empty; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Id={config.Id}"); + }); + } +} +"""; + + await verifier.Execute(code, "--id 12345678-1234-1234-1234-123456789abc", "Id=12345678-1234-1234-1234-123456789abc"); + } + + [Test] + public async Task DecimalProperty() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public decimal Price { get; set; } = 0.0m; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Price={config.Price}"); + }); + } +} +"""; + + await verifier.Execute(code, "--price 99.99", "Price=99.99"); + } + + [Test] + public async Task FloatAndDoubleProperties() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public float FloatValue { get; set; } = 0.0f; + public double DoubleValue { get; set; } = 0.0; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Float={config.FloatValue}, Double={config.DoubleValue}"); + }); + } +} +"""; + + await verifier.Execute(code, "--float-value 3.14 --double-value 2.71828", "Float=3.14, Double=2.71828"); + } + + [Test] + public async Task ByteAndShortProperties() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public byte ByteValue { get; set; } = 0; + public sbyte SByteValue { get; set; } = 0; + public short ShortValue { get; set; } = 0; + public ushort UShortValue { get; set; } = 0; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Byte={config.ByteValue}, SByte={config.SByteValue}, Short={config.ShortValue}, UShort={config.UShortValue}"); + }); + } +} +"""; + + // SByteValue -> s-byte-value, UShortValue -> u-short-value + await verifier.Execute(code, "--byte-value 255 --s-byte-value -128 --short-value -32000 --u-short-value 65000", "Byte=255, SByte=-128, Short=-32000, UShort=65000"); + } + + [Test] + public async Task CharProperty() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public char Delimiter { get; set; } = ','; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Delimiter={config.Delimiter}"); + }); + } +} +"""; + + await verifier.Execute(code, "--delimiter |", "Delimiter=|"); + } + + [Test] + public async Task LongProperty() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public long BigNumber { get; set; } = 0; + public ulong UnsignedBigNumber { get; set; } = 0; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Big={config.BigNumber}, UBig={config.UnsignedBigNumber}"); + }); + } +} +"""; + + await verifier.Execute(code, "--big-number -9223372036854775808 --unsigned-big-number 18446744073709551615", "Big=-9223372036854775808, UBig=18446744073709551615"); + } + + [Test] + public async Task TimeSpanProperty() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public TimeSpan Timeout { get; set; } = TimeSpan.Zero; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Timeout={config.Timeout}"); + }); + } +} +"""; + + await verifier.Execute(code, "--timeout 00:05:30", "Timeout=00:05:30"); + } + + [Test] + public async Task NullableEnumProperty() + { + // language=csharp + var code = """ +using System; + +public enum LogLevel { Debug, Info, Warning, Error } + +public class Config +{ + public LogLevel? Level { get; set; } = null; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Level={config.Level?.ToString() ?? "null"}"); + }); + } +} +"""; + + await verifier.Execute(code, "--level Warning", "Level=Warning"); + } + + [Test] + public async Task NullableEnumProperty_NotProvided() + { + // language=csharp + var code = """ +using System; + +public enum LogLevel { Debug, Info, Warning, Error } + +public class Config +{ + public LogLevel? Level { get; set; } = null; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Level={config.Level?.ToString() ?? "null"}"); + }); + } +} +"""; + + await verifier.Execute(code, "", "Level=null"); + } + + // Note: Boolean with explicit true/false values (--verbose true/false) is not supported + // in [AsParameters] mode. Options are treated as flags where presence means true. + // Use regular parameters for explicit boolean value parsing. + + [Test] + public async Task IntArrayProperty_CommaSeparated() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public int[] Ports { get; set; } = Array.Empty(); +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Ports={string.Join(",", config.Ports)}"); + }); + } +} +"""; + + await verifier.Execute(code, "--ports 8080,8081,8082", "Ports=8080,8081,8082"); + } + + [Test] + public async Task IntArrayProperty_SingleValue() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public int[] Ports { get; set; } = Array.Empty(); +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Ports={string.Join(",", config.Ports)}"); + }); + } +} +"""; + + await verifier.Execute(code, "--ports 8080", "Ports=8080"); + } + + [Test] + public async Task StringArrayProperty() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public string[] Tags { get; set; } = Array.Empty(); +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Tags={string.Join(";", config.Tags)}"); + }); + } +} +"""; + + await verifier.Execute(code, "--tags alpha,beta,gamma", "Tags=alpha;beta;gamma"); + } + + [Test] + public async Task BigIntegerProperty() + { + // language=csharp + var code = """ +using System; +using System.Numerics; + +public class Config +{ + public BigInteger Value { get; set; } = BigInteger.Zero; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Value={config.Value}"); + }); + } +} +"""; + + await verifier.Execute(code, "--value 12345678901234567890", "Value=12345678901234567890"); + } + + [Test] + public async Task ArrayProperty_WithConstructor() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public int[] Values { get; set; } + + public Config(int[] values) + { + Values = values; + } +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Values={string.Join(",", config.Values)}"); + }); + } +} +"""; + + await verifier.Execute(code, "--values 1,2,3,4,5", "Values=1,2,3,4,5"); + } + + [Test] + public async Task DoubleArrayProperty() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public double[] Coordinates { get; set; } = Array.Empty(); +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"Coords={string.Join(";", config.Coordinates)}"); + }); + } +} +"""; + + await verifier.Execute(code, "--coordinates 1.5,2.5,3.5", "Coords=1.5;2.5;3.5"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersValidationTests.cs b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersValidationTests.cs new file mode 100644 index 0000000..4541688 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersValidationTests.cs @@ -0,0 +1,67 @@ +namespace ConsoleAppFramework.GeneratorTests.AsParameters; + +[ClassDataSource] +public class AsParametersValidationTests(VerifyHelper verifier) +{ + // Note: Validation attributes like [Range] are not yet applied to [AsParameters] types + // Future feature: RangeValidation_OnIntProperty tests + + [Test] + public async Task RequiredProperty_Missing_Error() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public required string Name { get; set; } + public int Port { get; set; } = 8080; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"name={config.Name}, port={config.Port}"); + }); + } +} +"""; + + // Not providing the required property should fail + var (stdout, exitCode) = verifier.Error(code, "--port 9000"); + await Assert.That(exitCode).IsNotEqualTo(0); + } + + [Test] + public async Task RequiredProperty_Provided_Success() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + public required string Name { get; set; } + public int Port { get; set; } = 8080; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"name={config.Name}, port={config.Port}"); + }); + } +} +"""; + + // Providing the required property should succeed + await verifier.Execute(code, "--name test --port 9000", "name=test, port=9000"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersXmlDocTests.cs b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersXmlDocTests.cs new file mode 100644 index 0000000..2d500c9 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/AsParameters/AsParametersXmlDocTests.cs @@ -0,0 +1,601 @@ +namespace ConsoleAppFramework.GeneratorTests.AsParameters; + +[ClassDataSource] +public class AsParametersXmlDocTests(VerifyHelper verifier) +{ + [Test] + public async Task PropertyXmlDocInHelp() + { + // language=csharp + var code = """ +using System; + +public class ServerConfig +{ + /// The hostname to connect to. + public string Host { get; set; } = "localhost"; + + /// -p|--port, The port number for the connection. + public int Port { get; set; } = 8080; + + /// Enable verbose logging. + public bool Verbose { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + ConsoleApp.Run(args, ([AsParameters] ServerConfig config) => + { + Console.Write($"{config.Host}:{config.Port}"); + }); + } +} +"""; + + var expected = """ +Usage: [options...] [-h|--help] [--version] + +Options: + --host The hostname to connect to. [Default: localhost] + -p, --port The port number for the connection. [Default: 8080] + --verbose Enable verbose logging. + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task ConstructorParamXmlDocInHelp() + { + // language=csharp + var code = """ +using System; + +/// Search options for file operations. +/// The search pattern to match files. +/// -r|--recursive, Search in subdirectories. +public record SearchOptions(string pattern, bool recursive = false); + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + ConsoleApp.Run(args, ([AsParameters] SearchOptions opts) => + { + Console.Write($"{opts.pattern}:{opts.recursive}"); + }); + } +} +"""; + + var expected = """ +Usage: [options...] [-h|--help] [--version] + +Options: + --pattern The search pattern to match files. [Required] + -r, --recursive Search in subdirectories. + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task MixedXmlDocInHelp() + { + // language=csharp + var code = """ +using System; + +/// Copy operation options. +/// -s|--source, The source file path. +/// -d|--destination, The destination file path. +public class CopyOptions +{ + public CopyOptions(string source, string destination) + { + Source = source; + Destination = destination; + } + + public string Source { get; } + public string Destination { get; } + + /// -f|--force, Overwrite existing files. + public bool Force { get; set; } = false; + + /// -v|--verbose, Show detailed progress. + public bool Verbose { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + ConsoleApp.Run(args, ([AsParameters] CopyOptions opts) => + { + Console.Write($"{opts.Source}->{opts.Destination}"); + }); + } +} +"""; + + var expected = """ +Usage: [options...] [-h|--help] [--version] + +Options: + -s, --source The source file path. [Required] + -d, --destination The destination file path. [Required] + -f, --force Overwrite existing files. + -v, --verbose Show detailed progress. + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task NoXmlDocStillWorks() + { + // language=csharp + var code = """ +using System; + +public class SimpleConfig +{ + public string Name { get; set; } = "default"; + public int Count { get; set; } = 0; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] SimpleConfig config) => + { + Console.Write($"{config.Name}:{config.Count}"); + }); + } +} +"""; + + // Test that execution still works without XML docs + await verifier.Execute(code, "--name test --count 5", "test:5"); + } + + [Test] + public async Task ShortOptionAliases() + { + // language=csharp + var code = """ +using System; + +public class AliasConfig +{ + /// -h|--host, The hostname to connect to + public string Host { get; set; } = "localhost"; + + /// -p|--port, The port number + public int Port { get; set; } = 8080; + + /// -v|--verbose, Enable verbose mode + public bool Verbose { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] AliasConfig config) => + { + Console.Write($"{config.Host}:{config.Port}:{config.Verbose}"); + }); + } +} +"""; + + // Test that short options work + await verifier.Execute(code, "-h myhost -p 9000 -v", "myhost:9000:True"); + } + + [Test] + public async Task ShortOptionAliasesInHelp() + { + // language=csharp + var code = """ +using System; + +public class AliasConfig +{ + /// -h|--host, The hostname to connect to + public string Host { get; set; } = "localhost"; + + /// -p|--port, The port number + public int Port { get; set; } = 8080; + + /// -v|--verbose, Enable verbose mode + public bool Verbose { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + ConsoleApp.Run(args, ([AsParameters] AliasConfig config) => + { + Console.Write($"{config.Host}:{config.Port}"); + }); + } +} +"""; + + var expected = """ +Usage: [options...] [-h|--help] [--version] + +Options: + -h, --host The hostname to connect to [Default: localhost] + -p, --port The port number [Default: 8080] + -v, --verbose Enable verbose mode + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task ArgumentCommentProperty() + { + // language=csharp + var code = """ +using System; + +public class CopyConfig +{ + /// argument, The source file path + public string Source { get; set; } = ""; + + /// argument, The destination file path + public string Destination { get; set; } = ""; + + /// -f|--force, Force overwrite + public bool Force { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] CopyConfig config) => + { + Console.Write($"{config.Source}->{config.Destination}:{config.Force}"); + }); + } +} +"""; + + // Properties with "argument," in comment should be treated as positional arguments + await verifier.Execute(code, "/source/file /dest/file -f", "/source/file->/dest/file:True"); + } + + [Test] + public async Task ArgumentCommentPropertyInHelp() + { + // language=csharp + var code = """ +using System; + +public class CopyConfig +{ + /// argument, The source file path + public string Source { get; set; } = ""; + + /// argument, The destination file path + public string Destination { get; set; } = ""; + + /// -f|--force, Force overwrite + public bool Force { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + ConsoleApp.Run(args, ([AsParameters] CopyConfig config) => + { + Console.Write($"{config.Source}->{config.Destination}:{config.Force}"); + }); + } +} +"""; + + var expected = """ +Usage: [arguments...] [options...] [-h|--help] [--version] + +Arguments: + [0] The source file path + [1] The destination file path + +Options: + -f, --force Force overwrite + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task ArgumentCommentConstructorParam() + { + // language=csharp + var code = """ +using System; + +/// Copy options +/// argument, The source file +/// argument, The destination file +public record CopyOptions(string source, string destination) +{ + public bool Force { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] CopyOptions opts) => + { + Console.Write($"{opts.source}->{opts.destination}:{opts.Force}"); + }); + } +} +"""; + + // Constructor params with "argument," in comment should be treated as positional arguments + await verifier.Execute(code, "/src /dst --force", "/src->/dst:True"); + } + + [Test] + public async Task ArgumentCommentConstructorParamInHelp() + { + // language=csharp + var code = """ +using System; + +/// Copy options +/// argument, The source file +/// argument, The destination file +public record CopyOptions(string source, string destination) +{ + /// -f|--force, Force overwrite + public bool Force { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + ConsoleApp.Run(args, ([AsParameters] CopyOptions opts) => + { + Console.Write($"{opts.source}->{opts.destination}:{opts.Force}"); + }); + } +} +"""; + + var expected = """ +Usage: [arguments...] [options...] [-h|--help] [--version] + +Arguments: + [0] The source file + [1] The destination file + +Options: + -f, --force Force overwrite + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task MixedAliasesAndArguments() + { + // language=csharp + var code = """ +using System; + +public class GrepConfig +{ + /// argument, The search pattern + public string Pattern { get; set; } = ""; + + /// -i|--ignore-case, Case insensitive matching + public bool IgnoreCase { get; set; } = false; + + /// -r|--recursive, Recursive search + public bool Recursive { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] GrepConfig config) => + { + Console.Write($"pattern={config.Pattern},i={config.IgnoreCase},r={config.Recursive}"); + }); + } +} +"""; + + // Mix of positional argument and short options + await verifier.Execute(code, "*.txt -i -r", "pattern=*.txt,i=True,r=True"); + } + + [Test] + public async Task MixedAliasesAndArgumentsInHelp() + { + // language=csharp + var code = """ +using System; + +public class GrepConfig +{ + /// argument, The search pattern + public string Pattern { get; set; } = ""; + + /// -i|--ignore-case, Case insensitive matching + public bool IgnoreCase { get; set; } = false; + + /// -r|--recursive, Recursive search + public bool Recursive { get; set; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + ConsoleApp.Run(args, ([AsParameters] GrepConfig config) => + { + Console.Write($"pattern={config.Pattern},i={config.IgnoreCase},r={config.Recursive}"); + }); + } +} +"""; + + var expected = """ +Usage: [arguments...] [options...] [-h|--help] [--version] + +Arguments: + [0] The search pattern + +Options: + -i, --ignore-case Case insensitive matching + -r, --recursive Recursive search + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task ArgumentCommentCaseInsensitive() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + /// ARGUMENT, The file to process + public string File { get; set; } = ""; + + /// Argument, The path to use + public string Path { get; set; } = ""; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"{config.File}|{config.Path}"); + }); + } +} +"""; + + // Case insensitive "argument," in comment + await verifier.Execute(code, "file.txt /path/to/dir", "file.txt|/path/to/dir"); + } + + [Test] + public async Task ThreeAliases() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + /// -h|--host|--hostname, The target hostname + public string Host { get; set; } = "localhost"; + + /// -p|--port|--server-port, The port number + public int Port { get; set; } = 8080; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"{config.Host}:{config.Port}"); + }); + } +} +"""; + + // Test all three aliases work + await verifier.Execute(code, "-h myhost -p 9000", "myhost:9000"); + await verifier.Execute(code, "--host anotherhost --port 8000", "anotherhost:8000"); + await verifier.Execute(code, "--hostname third --server-port 7000", "third:7000"); + } + + [Test] + public async Task ThreeAliasesInHelp() + { + // language=csharp + var code = """ +using System; + +public class Config +{ + /// -h|--host|--hostname, The target hostname + public string Host { get; set; } = "localhost"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + ConsoleApp.Run(args, ([AsParameters] Config config) => + { + Console.Write($"{config.Host}"); + }); + } +} +"""; + + var expected = """ +Usage: [options...] [-h|--help] [--version] + +Options: + -h, --host, --hostname The target hostname [Default: localhost] + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsBasicTests.cs b/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsBasicTests.cs new file mode 100644 index 0000000..1b0c218 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsBasicTests.cs @@ -0,0 +1,98 @@ +namespace ConsoleAppFramework.GeneratorTests.GlobalOptions; + +[ClassDataSource] +public class TypedGlobalOptionsBasicTests(VerifyHelper verifier) +{ + [Test] + public async Task GlobalOptionsBasic() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; + public bool DryRun { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", () => Console.Write("test")); + app.Run(args); + } +} +"""; + + await verifier.Execute(code, "test", "test"); + } + + [Test] + public async Task GlobalOptionsWithVerbose() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; + public bool DryRun { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", (ConsoleAppContext ctx) => + { + var globals = (GlobalSettings)ctx.GlobalOptions!; + Console.Write($"Verbose={globals.Verbose}"); + }); + app.Run(args); + } +} +"""; + + await verifier.Execute(code, "--verbose test", "Verbose=True"); + } + + [Test] + public async Task GlobalOptions_NoAttributeRequired() + { + // language=csharp + var code = """ +using System; + +// No special attribute required - just a plain record +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", (ConsoleAppContext ctx) => + { + var globals = (GlobalSettings)ctx.GlobalOptions!; + Console.Write($"V={globals.Verbose}"); + }); + app.Run(args); + } +} +"""; + + // Plain types work without any special attribute + await verifier.Execute(code, "--verbose test", "V=True"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsDiagnosticsTests.cs b/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsDiagnosticsTests.cs new file mode 100644 index 0000000..649ec7f --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsDiagnosticsTests.cs @@ -0,0 +1,158 @@ +namespace ConsoleAppFramework.GeneratorTests.GlobalOptions; + +[ClassDataSource] +public class TypedGlobalOptionsDiagnosticsTests(VerifyHelper verifier) +{ + [Test] + public async Task GlobalOptionsWithArgumentAttribute_EmitsDiagnostic() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + [Argument] + public string Path { get; init; } = ""; + + public bool Verbose { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", () => Console.Write("test")); + app.Run(args); + } +} +"""; + + // Verify that diagnostic CAF026 is generated + await verifier.Verify(26, code, "GlobalSettings", "Path"); + } + + [Test] + public async Task GlobalOptionsWithArgumentComment_EmitsDiagnostic() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + /// argument, The input file path + public string InputFile { get; init; } = ""; + + public bool Verbose { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", () => Console.Write("test")); + app.Run(args); + } +} +"""; + + // Verify that diagnostic CAF026 is generated for "argument," comment + await verifier.Verify(26, code, "GlobalSettings", "InputFile"); + } + + [Test] + public async Task GlobalOptionsWithArgumentCommentCaseInsensitive_EmitsDiagnostic() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + /// ARGUMENT, The output path + public string OutputPath { get; init; } = ""; + + public bool Verbose { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", () => Console.Write("test")); + app.Run(args); + } +} +"""; + + // Verify that diagnostic CAF026 is generated for case insensitive "ARGUMENT," + await verifier.Verify(26, code, "GlobalSettings", "OutputPath"); + } + + [Test] + public async Task GlobalOptionsWithConstructorArgumentParam_EmitsDiagnostic() + { + // language=csharp + var code = """ +using System; + +/// Global settings +/// argument, The input file +/// Enable verbose +public record GlobalSettings(string inputFile, bool verbose = false); + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", () => Console.Write("test")); + app.Run(args); + } +} +"""; + + // Verify that diagnostic CAF026 is generated for constructor param with "argument," comment + await verifier.Verify(26, code, "GlobalSettings", "inputFile"); + } + + [Test] + public async Task GlobalOptionsWithoutArguments_NoDiagnostic() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + /// -v|--verbose, Enable verbose output + public bool Verbose { get; init; } = false; + + /// --log-level, Set the logging level + public string LogLevel { get; init; } = "Info"; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", () => Console.Write("test")); + app.Run(args); + } +} +"""; + + // No diagnostic should be emitted + await verifier.Execute(code, "test", "test"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsEdgeCasesTests.cs b/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsEdgeCasesTests.cs new file mode 100644 index 0000000..9975f83 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsEdgeCasesTests.cs @@ -0,0 +1,252 @@ +namespace ConsoleAppFramework.GeneratorTests.GlobalOptions; + +[ClassDataSource] +public class TypedGlobalOptionsEdgeCasesTests(VerifyHelper verifier) +{ + [Test] + public async Task GlobalOptions_EmptyClass_NoErrors() + { + // language=csharp + var code = """ +using System; + +public record EmptyGlobalOptions +{ + // Empty class - but still valid +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", () => Console.Write("test executed")); + app.Run(args); + } +} +"""; + + await verifier.Execute(code, "test", "test executed"); + } + + [Test] + public async Task GlobalOptions_OnlyInheritedProperties() + { + // language=csharp + var code = """ +using System; + +public record BaseOptions +{ + public bool Verbose { get; init; } = false; + public string LogLevel { get; init; } = "Info"; +} + +public record DerivedGlobalOptions : BaseOptions +{ + // No new properties, only inherited ones +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", (ConsoleAppContext ctx) => + { + var opts = (DerivedGlobalOptions)ctx.GlobalOptions!; + Console.Write($"Verbose={opts.Verbose},LogLevel={opts.LogLevel}"); + }); + app.Run(args); + } +} +"""; + + await verifier.Execute(code, "--verbose --log-level Debug test", "Verbose=True,LogLevel=Debug"); + } + + // Note: Required properties are NOT supported for ConfigureGlobalOptions() because + // it has a new() constraint that conflicts with required members. + // Use non-required properties with validation instead. + + [Test] + public async Task GlobalOptions_UnknownOption_PassesThrough() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", (string unknownOption, ConsoleAppContext ctx) => + { + var opts = (GlobalSettings)ctx.GlobalOptions!; + Console.Write($"Verbose={opts.Verbose},Unknown={unknownOption}"); + }); + app.Run(args); + } +} +"""; + + // Unknown global option should pass through to command + await verifier.Execute(code, "--verbose test --unknown-option passedthrough", "Verbose=True,Unknown=passedthrough"); + } + + [Test] + public async Task GlobalOptions_CaseSensitivity() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; + public string LogLevel { get; init; } = "Info"; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", (ConsoleAppContext ctx) => + { + var opts = (GlobalSettings)ctx.GlobalOptions!; + Console.Write($"Verbose={opts.Verbose},LogLevel={opts.LogLevel}"); + }); + app.Run(args); + } +} +"""; + + // Options are case-insensitive by default + await verifier.Execute(code, "--VERBOSE --LOG-LEVEL Debug test", "Verbose=True,LogLevel=Debug"); + } + + [Test] + public async Task GlobalOptions_InheritedAndLocalProperties() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; +} + +public record CommandOptions : GlobalSettings +{ + public string Output { get; init; } = "stdout"; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("process", ([AsParameters] CommandOptions opts) => + { + Console.Write($"Verbose={opts.Verbose},Output={opts.Output}"); + }); + app.Run(args); + } +} +"""; + + // Global options set before command, command-specific after + await verifier.Execute(code, "--verbose process --output file.txt", "Verbose=True,Output=file.txt"); + } + + [Test] + public async Task GlobalOptions_MultipleCommands_SharedOptions() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; + public string Format { get; init; } = "json"; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + + app.Add("build", (ConsoleAppContext ctx) => + { + var opts = (GlobalSettings)ctx.GlobalOptions!; + Console.Write($"build:Verbose={opts.Verbose},Format={opts.Format}"); + }); + + app.Add("test", (ConsoleAppContext ctx) => + { + var opts = (GlobalSettings)ctx.GlobalOptions!; + Console.Write($"test:Verbose={opts.Verbose},Format={opts.Format}"); + }); + + app.Run(args); + } +} +"""; + + await verifier.Execute(code, "--verbose --format xml build", "build:Verbose=True,Format=xml"); + await verifier.Execute(code, "--format yaml test", "test:Verbose=False,Format=yaml"); + } + + [Test] + public async Task GlobalOptions_WithDefaults_NotProvided() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; + public string LogLevel { get; init; } = "Info"; + public int MaxRetries { get; init; } = 3; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", (ConsoleAppContext ctx) => + { + var opts = (GlobalSettings)ctx.GlobalOptions!; + Console.Write($"Verbose={opts.Verbose},LogLevel={opts.LogLevel},MaxRetries={opts.MaxRetries}"); + }); + app.Run(args); + } +} +"""; + + // All options use defaults when not provided + await verifier.Execute(code, "test", "Verbose=False,LogLevel=Info,MaxRetries=3"); + } + + // Note: Boolean with explicit true/false values (--verbose false) is not supported + // for GlobalOptions. Boolean options are treated as flags where presence means true. +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsHelpTests.cs b/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsHelpTests.cs new file mode 100644 index 0000000..515076b --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsHelpTests.cs @@ -0,0 +1,344 @@ +namespace ConsoleAppFramework.GeneratorTests.GlobalOptions; + +[ClassDataSource] +public class TypedGlobalOptionsHelpTests(VerifyHelper verifier) +{ + [Test] + public async Task RootHelpShowsGlobalOptions() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + /// -v|--verbose, Enable verbose output + public bool Verbose { get; init; } = false; + + /// --dry-run, Simulate without making changes + public bool DryRun { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", () => Console.Write("test")); + app.Run(args); + } +} +"""; + + var expected = """ +Usage: [command] [-h|--help] [--version] + +Global Options: + -v, --verbose Enable verbose output + --dry-run Simulate without making changes + +Commands: + test + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task RootHelpShowsGlobalOptionsWithDefaults() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + /// --log-level, The logging level + public string LogLevel { get; init; } = "Info"; + + /// -v|--verbose, Enable verbose output + public bool Verbose { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("build", () => Console.Write("build")); + app.Run(args); + } +} +"""; + + var expected = """ +Usage: [command] [-h|--help] [--version] + +Global Options: + --log-level The logging level [default: Info] + -v, --verbose Enable verbose output + +Commands: + build + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task CommandHelpDoesNotShowInheritedGlobalOptions() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + /// -v|--verbose, Enable verbose output + public bool Verbose { get; init; } = false; +} + +public record SearchOptions : GlobalSettings +{ + /// -p|--pattern, The search pattern + public string Pattern { get; init; } = "*"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("search", ([AsParameters] SearchOptions opts) => + { + Console.Write($"Pattern={opts.Pattern}"); + }); + app.Run(args); + } +} +"""; + + // Command help should only show command-specific options, not inherited global options + var expected = """ +Usage: search [options...] [-h|--help] [--version] + +Options: + -p, --pattern The search pattern [Default: *] + +"""; + + var (stdout, _) = verifier.Error(code, "search --help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task MultipleCommandsShowGlobalOptions() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + /// -v|--verbose, Enable verbose output + public bool Verbose { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("search", (string pattern) => Console.Write($"Searching: {pattern}")); + app.Add("copy", (string source, string dest) => Console.Write($"Copying: {source} to {dest}")); + app.Run(args); + } +} +"""; + + var expected = """ +Usage: [command] [-h|--help] [--version] + +Global Options: + -v, --verbose Enable verbose output + +Commands: + copy + search + +"""; + + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).IsEqualTo(expected); + } + + [Test] + public async Task GlobalOptions_NoXmlDoc_HelpStillWorks() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; + public string LogLevel { get; init; } = "Info"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", () => Console.Write("test")); + app.Run(args); + } +} +"""; + + // Note: Help formatting alignment varies, checking actual output pattern + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).Contains("Global Options:"); + await Assert.That(stdout).Contains("--verbose"); + await Assert.That(stdout).Contains("--log-level"); + await Assert.That(stdout).Contains("[default: Info]"); + await Assert.That(stdout).Contains("Commands:"); + await Assert.That(stdout).Contains("test"); + } + + [Test] + public async Task GlobalOptions_AliasInXmlDoc_CreatesAlias() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + /// -v|--verbose, Enable verbose logging + public bool Verbose { get; init; } = false; + + /// -l|--log-level, Set the logging level + public string LogLevel { get; init; } = "Info"; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", (ConsoleAppContext ctx) => + { + var opts = (GlobalSettings)ctx.GlobalOptions!; + Console.Write($"Verbose={opts.Verbose},LogLevel={opts.LogLevel}"); + }); + app.Run(args); + } +} +"""; + + // Test long option for boolean flag works + await verifier.Execute(code, "--verbose test", "Verbose=True,LogLevel=Info"); + // Test long option for value type works + await verifier.Execute(code, "--verbose --log-level Debug test", "Verbose=True,LogLevel=Debug"); + } + + [Test] + public async Task GlobalOptions_AliasInXmlDoc_ShowsInHelp() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + /// -v|--verbose, Enable verbose logging + public bool Verbose { get; init; } = false; + + /// -l|--log-level, Set the logging level + public string LogLevel { get; init; } = "Info"; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("test", () => Console.Write("test")); + app.Run(args); + } +} +"""; + + // Note: Help formatting alignment varies, checking actual output pattern + var (stdout, _) = verifier.Error(code, "--help"); + await Assert.That(stdout).Contains("Global Options:"); + await Assert.That(stdout).Contains("-v, --verbose"); + await Assert.That(stdout).Contains("Enable verbose logging"); + await Assert.That(stdout).Contains("-l, --log-level"); + await Assert.That(stdout).Contains("Set the logging level"); + await Assert.That(stdout).Contains("[default: Info]"); + await Assert.That(stdout).Contains("Commands:"); + await Assert.That(stdout).Contains("test"); + } + + [Test] + public async Task CommandHelpWithGlobalOptions_ShowsBoth() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + /// -v|--verbose, Enable verbose output + public bool Verbose { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + ConsoleApp.Log = x => Console.WriteLine(x); + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("process", ( + string input, + string output = "out.txt" + ) => + { + Console.Write($"Processing {input} to {output}"); + }); + app.Run(args); + } +} +"""; + + // Note: Help formatting alignment varies, checking actual output pattern + // Note: Default string values may be shown with @" prefix + var (stdout, _) = verifier.Error(code, "process --help"); + await Assert.That(stdout).Contains("Usage: process"); + await Assert.That(stdout).Contains("Options:"); + await Assert.That(stdout).Contains("--input"); + await Assert.That(stdout).Contains("[Required]"); + await Assert.That(stdout).Contains("--output"); + await Assert.That(stdout).Contains("out.txt"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsInheritanceTests.cs b/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsInheritanceTests.cs new file mode 100644 index 0000000..2d36424 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/TypedGlobalOptionsInheritanceTests.cs @@ -0,0 +1,329 @@ +namespace ConsoleAppFramework.GeneratorTests.GlobalOptions; + +[ClassDataSource] +public class TypedGlobalOptionsInheritanceTests(VerifyHelper verifier) +{ + [Test] + public async Task InheritanceBasic() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; +} + +public record SearchOptions : GlobalSettings +{ + public string Pattern { get; init; } = "*"; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("search", ([AsParameters] SearchOptions opts) => + { + Console.Write($"Verbose={opts.Verbose},Pattern={opts.Pattern}"); + }); + app.Run(args); + } +} +"""; + + await verifier.Execute(code, "--verbose search --pattern *.txt", "Verbose=True,Pattern=*.txt"); + } + + [Test] + public async Task InheritanceGlobalOptionsFirst() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; + public bool DryRun { get; init; } = false; +} + +public record CopyOptions : GlobalSettings +{ + public string Source { get; init; } = ""; + public string Destination { get; init; } = ""; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("copy", ([AsParameters] CopyOptions opts) => + { + Console.Write($"DryRun={opts.DryRun},Src={opts.Source},Dst={opts.Destination}"); + }); + app.Run(args); + } +} +"""; + + await verifier.Execute(code, "--dry-run copy --source /a --destination /b", "DryRun=True,Src=/a,Dst=/b"); + } + + [Test] + public async Task InheritanceGlobalOptionsAfterCommand() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; +} + +public record SearchOptions : GlobalSettings +{ + public string Pattern { get; init; } = "*"; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("search", ([AsParameters] SearchOptions opts) => + { + Console.Write($"Verbose={opts.Verbose},Pattern={opts.Pattern}"); + }); + app.Run(args); + } +} +"""; + + // Global options can appear after command name too + await verifier.Execute(code, "search --verbose --pattern *.log", "Verbose=True,Pattern=*.log"); + } + + [Test] + public async Task InheritanceWithDefaults() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; + public string LogLevel { get; init; } = "Info"; +} + +public record BuildOptions : GlobalSettings +{ + public string Configuration { get; init; } = "Debug"; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("build", ([AsParameters] BuildOptions opts) => + { + Console.Write($"Verbose={opts.Verbose},LogLevel={opts.LogLevel},Config={opts.Configuration}"); + }); + app.Run(args); + } +} +"""; + + // Use defaults for global options, only set command-specific option + await verifier.Execute(code, "build --configuration Release", "Verbose=False,LogLevel=Info,Config=Release"); + } + + [Test] + public async Task MultipleCommandsWithInheritance() + { + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; +} + +public record SearchOptions : GlobalSettings +{ + public string Pattern { get; init; } = "*"; +} + +public record CopyOptions : GlobalSettings +{ + public string Source { get; init; } = ""; + public string Destination { get; init; } = ""; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + + app.Add("search", ([AsParameters] SearchOptions opts) => + { + Console.Write($"search:Verbose={opts.Verbose},Pattern={opts.Pattern}"); + }); + + app.Add("copy", ([AsParameters] CopyOptions opts) => + { + Console.Write($"copy:Verbose={opts.Verbose},Src={opts.Source},Dst={opts.Destination}"); + }); + + app.Run(args); + } +} +"""; + + // Test search command + await verifier.Execute(code, "--verbose search --pattern test", "search:Verbose=True,Pattern=test"); + } + + [Test] + public async Task NonInheritingGlobalOptions() + { + // When the AsParameters type doesn't inherit from global options, all properties parsed as normal + // language=csharp + var code = """ +using System; + +public record GlobalSettings +{ + public bool Verbose { get; init; } = false; +} + +// Note: Does NOT inherit from GlobalSettings +public record SearchOptions +{ + public string Pattern { get; init; } = "*"; + public bool CaseSensitive { get; init; } = false; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("search", ([AsParameters] SearchOptions opts, ConsoleAppContext ctx) => + { + var globals = (GlobalSettings)ctx.GlobalOptions!; + Console.Write($"Verbose={globals.Verbose},Pattern={opts.Pattern},CaseSensitive={opts.CaseSensitive}"); + }); + app.Run(args); + } +} +"""; + + await verifier.Execute(code, "--verbose search --pattern test --case-sensitive", "Verbose=True,Pattern=test,CaseSensitive=True"); + } + + [Test] + public async Task FourLevelInheritance() + { + // language=csharp + var code = """ +using System; + +public record Level1 +{ + public bool Verbose { get; init; } = false; +} + +public record Level2 : Level1 +{ + public string Format { get; init; } = "json"; +} + +public record Level3 : Level2 +{ + public string Output { get; init; } = "stdout"; +} + +public record Level4 : Level3 +{ + public int Count { get; init; } = 1; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("process", ([AsParameters] Level4 opts) => + { + Console.Write($"Verbose={opts.Verbose},Format={opts.Format},Output={opts.Output},Count={opts.Count}"); + }); + app.Run(args); + } +} +"""; + + await verifier.Execute(code, "--verbose process --format xml --output file.txt --count 5", "Verbose=True,Format=xml,Output=file.txt,Count=5"); + } + + [Test] + public async Task FourLevelInheritance_DefaultValues() + { + // language=csharp + var code = """ +using System; + +public record Level1 +{ + public bool Debug { get; init; } = true; +} + +public record Level2 : Level1 +{ + public int Priority { get; init; } = 5; +} + +public record Level3 : Level2 +{ + public string Mode { get; init; } = "auto"; +} + +public record Level4 : Level3 +{ + public string Tag { get; init; } = "default"; +} + +public class Program +{ + public static void Main(string[] args) + { + var app = ConsoleApp.Create(); + app.ConfigureGlobalOptions(); + app.Add("run", ([AsParameters] Level4 opts) => + { + Console.Write($"Debug={opts.Debug},Priority={opts.Priority},Mode={opts.Mode},Tag={opts.Tag}"); + }); + app.Run(args); + } +} +"""; + + // Use all default values + await verifier.Execute(code, "run", "Debug=True,Priority=5,Mode=auto,Tag=default"); + } +} diff --git a/tests/ConsoleAppFramework.NativeAotTests/NativeAotTest.cs b/tests/ConsoleAppFramework.NativeAotTests/NativeAotTest.cs index 78c3efb..ecce97c 100644 --- a/tests/ConsoleAppFramework.NativeAotTests/NativeAotTest.cs +++ b/tests/ConsoleAppFramework.NativeAotTests/NativeAotTest.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; // This test verifies the behavior of NativeAOT. // Instead of running dotnet test, you must execute the exe generated by dotnet publish. @@ -24,6 +24,10 @@ public NativeAotTest() app.UseFilter(); app.Add("", Commands.Root); app.Add("json", Commands.RecordJson); + // [AsParameters] commands for AOT testing + app.Add("asparameters-basic", Commands.AsParametersBasic); + app.Add("asparameters-args", Commands.AsParametersWithArguments); + app.Add("asparameters-inherited", Commands.AsParametersWithInheritance); } [Test] @@ -54,6 +58,47 @@ public async Task JsonInvalid() await app.RunAsync(runArgs); await Assert.That(Environment.ExitCode).IsNotEqualTo(0); } + + // ======================================== + // [AsParameters] AOT Tests + // ======================================== + + [Test] + public async Task AsParametersBasic_InAOTContext() + { + Environment.ExitCode = 0; + string[] runArgs = ["asparameters-basic", "--host", "myhost", "--port", "3306", "--database", "testdb"]; + await app.RunAsync(runArgs); + await Assert.That(Environment.ExitCode).IsEqualTo(0); + } + + [Test] + public async Task AsParametersBasic_Defaults_InAOTContext() + { + Environment.ExitCode = 0; + // Use defaults - no arguments + string[] runArgs = ["asparameters-basic"]; + await app.RunAsync(runArgs); + await Assert.That(Environment.ExitCode).IsEqualTo(0); + } + + [Test] + public async Task AsParametersWithArguments_InAOTContext() + { + Environment.ExitCode = 0; + string[] runArgs = ["asparameters-args", "input.txt", "output.txt", "--verbose"]; + await app.RunAsync(runArgs); + await Assert.That(Environment.ExitCode).IsEqualTo(0); + } + + [Test] + public async Task AsParametersWithGlobalOptionsInheritance_InAOTContext() + { + Environment.ExitCode = 0; + string[] runArgs = ["asparameters-inherited", "--name", "test", "--verbose", "--log-level", "debug"]; + await app.RunAsync(runArgs); + await Assert.That(Environment.ExitCode).IsEqualTo(0); + } } internal static class Commands @@ -76,6 +121,25 @@ public static void RecordJson(MyRecord record) { Console.WriteLine($"Record: X={record.X}, Y={record.Y}"); } + + // [AsParameters] command handlers for AOT testing + public static int AsParametersBasic([AsParameters] DatabaseConfig config) + { + Console.WriteLine($"[AsParametersBasic] {config.Host}:{config.Port}/{config.Database}"); + return 0; + } + + public static int AsParametersWithArguments([AsParameters] FileProcessingOptions options) + { + Console.WriteLine($"[AsParametersWithArguments] {options.InputPath} -> {options.OutputPath}, verbose={options.Verbose}"); + return 0; + } + + public static int AsParametersWithInheritance([AsParameters] CommandOptions options) + { + Console.WriteLine($"[AsParametersWithInheritance] name={options.Name}, verbose={options.Verbose}, logLevel={options.LogLevel}"); + return 0; + } } @@ -96,3 +160,38 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo } public record MyRecord(int X, int Y); + +// ======================================== +// [AsParameters] attribute types for AOT testing +// ======================================== + +public class DatabaseConfig +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 5432; + public string Database { get; set; } = "mydb"; +} + +public class FileProcessingOptions +{ + /// argument, The input file path + public string InputPath { get; set; } = ""; + + /// argument, The output file path + public string OutputPath { get; set; } = ""; + + public bool Verbose { get; set; } = false; +} + +public class AppGlobalOptions +{ + public bool Verbose { get; set; } = false; + public string LogLevel { get; set; } = "info"; +} + +public class CommandOptions +{ + public string Name { get; set; } = "default"; + public bool Verbose { get; set; } = false; + public string LogLevel { get; set; } = "info"; +}