diff --git a/.gitignore b/.gitignore index 60b3ecb5..26d6407a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ node_modules/ .idea/ *.iml .build/ +/.claude/settings.local.json diff --git a/CommandLineUtils.sln b/CommandLineUtils.sln index fe20fcee..536170db 100644 --- a/CommandLineUtils.sln +++ b/CommandLineUtils.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.0.0 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11312.210 d18.3 MinimumVisualStudioVersion = 16.0.0.0 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{95D4B35E-0A21-4D64-8BAF-27DD6C019FC5}" EndProject @@ -32,28 +32,78 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "McMaster.Extensions.Hosting EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "McMaster.Extensions.Hosting.CommandLine.Tests", "test\Hosting.CommandLine.Tests\McMaster.Extensions.Hosting.CommandLine.Tests.csproj", "{04A5D2B8-18E4-4C75-AEF9-79D171FAC210}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McMaster.Extensions.CommandLineUtils.Generators", "src\CommandLineUtils.Generators\McMaster.Extensions.CommandLineUtils.Generators.csproj", "{A8567BA8-088A-4742-8109-E4F6BCA344A5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Debug|x64.ActiveCfg = Debug|Any CPU + {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Debug|x64.Build.0 = Debug|Any CPU + {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Debug|x86.ActiveCfg = Debug|Any CPU + {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Debug|x86.Build.0 = Debug|Any CPU {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Release|Any CPU.ActiveCfg = Release|Any CPU {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Release|Any CPU.Build.0 = Release|Any CPU + {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Release|x64.ActiveCfg = Release|Any CPU + {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Release|x64.Build.0 = Release|Any CPU + {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Release|x86.ActiveCfg = Release|Any CPU + {CBCFAFF3-A3B1-4C41-B2D1-092BF7307A4E}.Release|x86.Build.0 = Release|Any CPU {1258544C-1FDE-4810-9A1B-189A925E9B45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1258544C-1FDE-4810-9A1B-189A925E9B45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1258544C-1FDE-4810-9A1B-189A925E9B45}.Debug|x64.ActiveCfg = Debug|Any CPU + {1258544C-1FDE-4810-9A1B-189A925E9B45}.Debug|x64.Build.0 = Debug|Any CPU + {1258544C-1FDE-4810-9A1B-189A925E9B45}.Debug|x86.ActiveCfg = Debug|Any CPU + {1258544C-1FDE-4810-9A1B-189A925E9B45}.Debug|x86.Build.0 = Debug|Any CPU {1258544C-1FDE-4810-9A1B-189A925E9B45}.Release|Any CPU.ActiveCfg = Release|Any CPU {1258544C-1FDE-4810-9A1B-189A925E9B45}.Release|Any CPU.Build.0 = Release|Any CPU + {1258544C-1FDE-4810-9A1B-189A925E9B45}.Release|x64.ActiveCfg = Release|Any CPU + {1258544C-1FDE-4810-9A1B-189A925E9B45}.Release|x64.Build.0 = Release|Any CPU + {1258544C-1FDE-4810-9A1B-189A925E9B45}.Release|x86.ActiveCfg = Release|Any CPU + {1258544C-1FDE-4810-9A1B-189A925E9B45}.Release|x86.Build.0 = Release|Any CPU {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Debug|x64.ActiveCfg = Debug|Any CPU + {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Debug|x64.Build.0 = Debug|Any CPU + {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Debug|x86.ActiveCfg = Debug|Any CPU + {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Debug|x86.Build.0 = Debug|Any CPU {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Release|Any CPU.ActiveCfg = Release|Any CPU {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Release|Any CPU.Build.0 = Release|Any CPU + {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Release|x64.ActiveCfg = Release|Any CPU + {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Release|x64.Build.0 = Release|Any CPU + {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Release|x86.ActiveCfg = Release|Any CPU + {407245F7-3F2C-4634-8578-7EFCA9BD26BD}.Release|x86.Build.0 = Release|Any CPU {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Debug|Any CPU.Build.0 = Debug|Any CPU + {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Debug|x64.ActiveCfg = Debug|Any CPU + {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Debug|x64.Build.0 = Debug|Any CPU + {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Debug|x86.ActiveCfg = Debug|Any CPU + {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Debug|x86.Build.0 = Debug|Any CPU {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Release|Any CPU.ActiveCfg = Release|Any CPU {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Release|Any CPU.Build.0 = Release|Any CPU + {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Release|x64.ActiveCfg = Release|Any CPU + {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Release|x64.Build.0 = Release|Any CPU + {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Release|x86.ActiveCfg = Release|Any CPU + {04A5D2B8-18E4-4C75-AEF9-79D171FAC210}.Release|x86.Build.0 = Release|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Debug|x64.ActiveCfg = Debug|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Debug|x64.Build.0 = Debug|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Debug|x86.ActiveCfg = Debug|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Debug|x86.Build.0 = Debug|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Release|Any CPU.Build.0 = Release|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Release|x64.ActiveCfg = Release|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Release|x64.Build.0 = Release|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Release|x86.ActiveCfg = Release|Any CPU + {A8567BA8-088A-4742-8109-E4F6BCA344A5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -63,6 +113,7 @@ Global {1258544C-1FDE-4810-9A1B-189A925E9B45} = {C4842A1B-019E-40FF-A396-CF5AFDE8FA54} {407245F7-3F2C-4634-8578-7EFCA9BD26BD} = {95D4B35E-0A21-4D64-8BAF-27DD6C019FC5} {04A5D2B8-18E4-4C75-AEF9-79D171FAC210} = {C4842A1B-019E-40FF-A396-CF5AFDE8FA54} + {A8567BA8-088A-4742-8109-E4F6BCA344A5} = {95D4B35E-0A21-4D64-8BAF-27DD6C019FC5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {55FD25E0-565D-49F9-9370-28DA7196E539} diff --git a/Directory.Build.props b/Directory.Build.props index 22ffa8c3..2e972097 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -27,6 +27,7 @@ + latest $(WarningsNotAsErrors);1591 true enable diff --git a/aot-plan.md b/aot-plan.md new file mode 100644 index 00000000..05accd0c --- /dev/null +++ b/aot-plan.md @@ -0,0 +1,425 @@ +# AOT Compatibility Plan for McMaster.Extensions.CommandLineUtils + +## Summary + +Replace runtime reflection with compile-time source generators to enable Native AOT compilation for applications using the attribute-based API. + +**Target:** .NET 8+ only +**Compatibility:** Dual-mode (reflection fallback for non-AOT scenarios) +**Scope:** Full feature parity +**Opt-in:** Automatic detection (generator runs on any `[Command]` class) + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User's Command Class │ +│ [Command] class MyCmd { [Option] string Name; OnExecute(); } │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────┴───────────────────┐ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────┐ +│ Source Generator │ │ Reflection Path │ +│ (compile-time) │ │ (runtime fallback) │ +└──────────────────────┘ └──────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────┐ +│ Generated Metadata │ │ ReflectionMetadataProvider│ +│ Provider + Registry │ │ (existing conventions) │ +└──────────────────────┘ └──────────────────────────┘ + │ │ + └───────────────────┬───────────────────┘ + ▼ + ┌───────────────────────────────────┐ + │ ICommandMetadataProvider │ + │ (common abstraction layer) │ + └───────────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────────┐ + │ Modified Conventions │ + │ (use metadata, not reflect) │ + └───────────────────────────────────┘ +``` + +--- + +## New Project Structure + +``` +src/ + CommandLineUtils/ + SourceGeneration/ # NEW: Abstraction layer + ICommandMetadataProvider.cs # Core metadata interface + CommandMetadataRegistry.cs # Static registry for generated providers + IMetadataResolver.cs # Bridge between generated/reflection + ReflectionMetadataProvider.cs # Fallback using reflection + Metadata/ + OptionMetadata.cs + ArgumentMetadata.cs + SubcommandMetadata.cs + CommandMetadata.cs + Handlers/ + IExecuteHandler.cs + IValidateHandler.cs + IModelFactory.cs + + CommandLineUtils.Generators/ # NEW: Source generator project + McMaster.Extensions.CommandLineUtils.Generators.csproj + CommandGenerator.cs # Incremental generator entry point + Analyzers/ + CommandModelAnalyzer.cs # Extracts metadata from syntax + Emitters/ + CommandConfiguratorEmitter.cs # Generates configurator code + MetadataProviderEmitter.cs # Generates ICommandMetadataProvider impl +``` + +--- + +## Implementation Phases + +### Phase 1: Abstraction Layer (Foundation) + +Create the metadata interfaces that both reflection and generated code will implement. + +**Files to create:** +- `src/CommandLineUtils/SourceGeneration/ICommandMetadataProvider.cs` +- `src/CommandLineUtils/SourceGeneration/CommandMetadataRegistry.cs` +- `src/CommandLineUtils/SourceGeneration/Metadata/*.cs` +- `src/CommandLineUtils/SourceGeneration/Handlers/*.cs` + +**Key interface:** +```csharp +public interface ICommandMetadataProvider +{ + Type ModelType { get; } + IReadOnlyList Options { get; } + IReadOnlyList Arguments { get; } + IReadOnlyList Subcommands { get; } + CommandMetadata? CommandInfo { get; } + IExecuteHandler? ExecuteHandler { get; } + IValidateHandler? ValidateHandler { get; } + IModelFactory GetModelFactory(IServiceProvider? services); +} +``` + +**Registry pattern:** +```csharp +public static class CommandMetadataRegistry +{ + private static readonly ConcurrentDictionary _providers = new(); + + public static void Register(ICommandMetadataProvider provider) where T : class + => _providers[typeof(T)] = provider; + + public static bool TryGetProvider(Type type, out ICommandMetadataProvider? provider) + => _providers.TryGetValue(type, out provider); +} +``` + +### Phase 2: Reflection Wrapper + +Wrap existing reflection logic in `ICommandMetadataProvider` for fallback. + +**Files to create:** +- `src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs` +- `src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs` + +**Key implementation:** +```csharp +[RequiresUnreferencedCode("Uses reflection to analyze the model type")] +internal sealed class ReflectionMetadataProvider : ICommandMetadataProvider +{ + // Wraps existing ReflectionHelper and convention logic + // Provides same metadata via reflection when generator hasn't run +} + +public sealed class DefaultMetadataResolver : IMetadataResolver +{ + public ICommandMetadataProvider GetProvider(Type modelType) + { + // Check registry first (generated), fall back to reflection + if (CommandMetadataRegistry.TryGetProvider(modelType, out var provider)) + return provider; + return new ReflectionMetadataProvider(modelType); + } +} +``` + +### Phase 3: Convention Modifications + +Modify existing conventions to use `ICommandMetadataProvider` instead of direct reflection. + +**Files to modify:** +- `src/CommandLineUtils/Conventions/ExecuteMethodConvention.cs` +- `src/CommandLineUtils/Conventions/ValidateMethodConvention.cs` +- `src/CommandLineUtils/Conventions/OptionAttributeConvention.cs` +- `src/CommandLineUtils/Conventions/ArgumentAttributeConvention.cs` +- `src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs` +- `src/CommandLineUtils/Conventions/ConstructorInjectionConvention.cs` + +**Example modification (ExecuteMethodConvention.cs):** +```csharp +public class ExecuteMethodConvention : IConvention +{ + public virtual void Apply(ConventionContext context) + { + if (context.ModelType == null) return; + context.Application.OnExecuteAsync(async ct => await OnExecute(context, ct)); + } + + private async Task OnExecute(ConventionContext context, CancellationToken ct) + { + var resolver = DefaultMetadataResolver.Instance; + var provider = resolver.GetProvider(context.ModelType!); + + if (provider.ExecuteHandler != null) + { + // Use generated/resolved handler - no reflection! + return await provider.ExecuteHandler.InvokeAsync( + context.ModelAccessor!.GetModel(), + context.Application, + ct); + } + + throw new InvalidOperationException(Strings.NoOnExecuteMethodFound); + } +} +``` + +### Phase 4: Source Generator Project + +Create the incremental source generator. + +**Files to create:** +- `src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj` +- `src/CommandLineUtils.Generators/CommandGenerator.cs` +- `src/CommandLineUtils.Generators/Analyzers/CommandModelAnalyzer.cs` +- `src/CommandLineUtils.Generators/Emitters/MetadataProviderEmitter.cs` + +**Generator project file:** +```xml + + + netstandard2.0 + 12.0 + true + true + false + + + + + + + + + +``` + +**Incremental generator entry point:** +```csharp +[Generator(LanguageNames.CSharp)] +public class CommandGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Find classes with [Command] attribute + var commandClasses = context.SyntaxProvider + .ForAttributeWithMetadataName( + "McMaster.Extensions.CommandLineUtils.CommandAttribute", + predicate: static (node, _) => node is ClassDeclarationSyntax, + transform: static (ctx, _) => CommandModelAnalyzer.Analyze(ctx)); + + // Also detect classes used with Execute() + var executeTypes = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: IsExecuteInvocation, + transform: ExtractTypeArgument); + + // Generate metadata providers + context.RegisterSourceOutput(commandClasses.Collect(), GenerateMetadataProviders); + + // Generate registry initialization + context.RegisterSourceOutput(commandClasses.Collect(), GenerateRegistryInitializer); + } +} +``` + +### Phase 5: Generated Code Structure + +**For user's command:** +```csharp +[Command("greet")] +public class GreetCommand +{ + [Option("-n|--name")] public string Name { get; set; } = "World"; + [Argument(0)] public string Message { get; set; } + public int OnExecute(IConsole console) => /* ... */; +} +``` + +**Generator produces:** +```csharp +// +namespace MyApp.Generated +{ + [ModuleInitializer] + internal static class CommandMetadataRegistration + { + public static void Initialize() + { + CommandMetadataRegistry.Register(new GreetCommandMetadataProvider()); + } + } + + internal sealed class GreetCommandMetadataProvider : ICommandMetadataProvider + { + public Type ModelType => typeof(GreetCommand); + + public IReadOnlyList Options { get; } = new[] + { + new OptionMetadata + { + PropertyName = "Name", + PropertyType = typeof(string), + Template = "-n|--name", + OptionType = CommandOptionType.SingleValue, + Getter = static obj => ((GreetCommand)obj).Name, + Setter = static (obj, val) => ((GreetCommand)obj).Name = (string)val! + } + }; + + public IReadOnlyList Arguments { get; } = new[] + { + new ArgumentMetadata + { + PropertyName = "Message", + PropertyType = typeof(string), + Order = 0, + Getter = static obj => ((GreetCommand)obj).Message, + Setter = static (obj, val) => ((GreetCommand)obj).Message = (string)val! + } + }; + + public IExecuteHandler ExecuteHandler { get; } = new GreetCommandExecuteHandler(); + + public IModelFactory GetModelFactory(IServiceProvider? _) + => new GreetCommandFactory(); + } + + internal sealed class GreetCommandExecuteHandler : IExecuteHandler + { + public bool IsAsync => false; + + public Task InvokeAsync(object model, CommandLineApplication app, CancellationToken ct) + { + var cmd = (GreetCommand)model; + var console = app._context.Console; + return Task.FromResult(cmd.OnExecute(console)); + } + } + + internal sealed class GreetCommandFactory : IModelFactory + { + public GreetCommand Create() => new GreetCommand(); + object IModelFactory.Create() => Create(); + } +} +``` + +### Phase 6: Trimmer Annotations + +Add annotations to warn when reflection paths are used. + +**Files to modify:** +- `src/CommandLineUtils/Internal/ReflectionHelper.cs` - Add `[RequiresUnreferencedCode]` +- `src/CommandLineUtils/CommandLineApplication.Execute.cs` - Add trimmer warnings +- `src/CommandLineUtils/Conventions/*.cs` - Annotate reflection usage + +**Add to main library csproj:** +```xml + + true + true + +``` + +### Phase 7: Testing & Samples + +**New test projects:** +- `test/CommandLineUtils.Generators.Tests/` - Generator unit tests +- `test/CommandLineUtils.Aot.Tests/` - AOT integration tests (PublishAot) + +**Sample updates:** +- Add AOT-compatible sample project +- Update existing samples to verify they still work + +--- + +## Critical Files to Modify + +| File | Change Type | Description | +|------|-------------|-------------| +| `src/CommandLineUtils/CommandLineApplication{T}.cs:86` | Modify | Replace `Activator.CreateInstance()` with factory | +| `src/CommandLineUtils/Conventions/ExecuteMethodConvention.cs:38-39` | Modify | Use `IExecuteHandler` instead of `GetMethod()` | +| `src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs` | Modify | Remove `MakeGenericMethod()` usage | +| `src/CommandLineUtils/Conventions/ConstructorInjectionConvention.cs` | Modify | Use generated factory | +| `src/CommandLineUtils/Internal/ReflectionHelper.cs` | Annotate | Add `[RequiresUnreferencedCode]` | +| `src/CommandLineUtils/Abstractions/ValueParserProvider.cs` | Modify | Remove `MakeGenericMethod()` | + +--- + +## Reflection APIs to Replace + +| Current Reflection | Generated Replacement | +|--------------------|----------------------| +| `GetMethod("OnExecute")` | `IExecuteHandler.InvokeAsync()` | +| `GetMethod("OnValidate")` | `IValidateHandler.Invoke()` | +| `GetProperties()` + `GetCustomAttribute()` | `ICommandMetadataProvider.Options` | +| `Activator.CreateInstance()` | `IModelFactory.Create()` | +| `ConstructorInfo.Invoke()` | Generated factory with `new T(dep1, dep2)` | +| `MakeGenericMethod()` | Pre-generated typed methods | +| `PropertyInfo.GetValue/SetValue` | Generated `Getter`/`Setter` delegates | + +--- + +## Deliverables Checklist + +- [ ] `ICommandMetadataProvider` interface and related types +- [ ] `CommandMetadataRegistry` static registry +- [ ] `ReflectionMetadataProvider` fallback implementation +- [ ] Modified conventions using metadata abstraction +- [ ] `CommandLineUtils.Generators` project +- [ ] Incremental source generator implementation +- [ ] Module initializer for registry population +- [ ] Trimmer annotations on reflection paths +- [ ] Unit tests for generator +- [ ] AOT integration tests +- [ ] Migration documentation + +--- + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Generator complexity | High dev effort | Start with core features, iterate | +| Breaking changes | User migration | Dual-mode ensures backward compat | +| Partial types conflict | Compile errors | Use separate generated classes | +| Complex type hierarchies | Edge cases | Handle inheritance in analyzer | +| DI edge cases | Runtime errors | Match existing ConstructorInjectionConvention behavior | + +--- + +## Success Criteria + +1. Existing apps compile and run without changes (reflection fallback) +2. Apps with generator can `PublishAot` successfully +3. No runtime reflection in generated path +4. All existing tests pass +5. AOT-specific tests validate trimming works diff --git a/docs/docfx.json b/docs/docfx.json index ed3bed3c..b1c94305 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -1,74 +1,75 @@ -{ - "$schema": "https://dotnet.github.io/docfx/schemas/v1.0/schema.json", - "metadata": [ - { - "src": [ - { - "files": [ - "src/**.csproj" - ], - "src": "../", - "exclude": [ - "**/bin/**", - "**/obj/**" - ] - } - ], - "dest": "api/", - "filter": "filterConfig.yml", - "properties": { - "TargetFramework": "net8.0" - } - } - ], - "build": { - "content": [ - { - "files": [ - "**/*.yml", - "**/*.md" - ], - "exclude": [ - "obj/", - "**.meta", - "README.md", - "filterConfig.yml" - ] - } - ], - "resource": [ - { - "files": [ - ".nojekyll", - ".gitattributes", - "images/**", - "*.png" - ], - "exclude": [ - "obj/", - "**.meta" - ] - } - ], - "overwrite": "apispec/*.md", - "dest": "../.build/docs/gh-pages", - "template": [ - "default", - "modern", - "./theme" - ], - "postProcessors": [ - "ExtractSearchIndex" - ], - "noLangKeyword": false, - "keepFileLink": false, - "cleanupCacheHistory": false, - "disableGitFeatures": false, - "globalMetadata": { - "_appName": "CommandLineUtils", - "_appFooter": " ", - "_appLogoPath": "logo.png", - "_enableSearch": true - } - } -} +{ + "$schema": "https://dotnet.github.io/docfx/schemas/v1.0/schema.json", + "metadata": [ + { + "src": [ + { + "files": [ + "src/**.csproj" + ], + "src": "../", + "exclude": [ + "**/bin/**", + "**/obj/**", + "**/CommandLineUtils.Generators/**" + ] + } + ], + "dest": "api/", + "filter": "filterConfig.yml", + "properties": { + "TargetFramework": "net8.0" + } + } + ], + "build": { + "content": [ + { + "files": [ + "**/*.yml", + "**/*.md" + ], + "exclude": [ + "obj/", + "**.meta", + "README.md", + "filterConfig.yml" + ] + } + ], + "resource": [ + { + "files": [ + ".nojekyll", + ".gitattributes", + "images/**", + "*.png" + ], + "exclude": [ + "obj/", + "**.meta" + ] + } + ], + "overwrite": "apispec/*.md", + "dest": "../.build/docs/gh-pages", + "template": [ + "default", + "modern", + "./theme" + ], + "postProcessors": [ + "ExtractSearchIndex" + ], + "noLangKeyword": false, + "keepFileLink": false, + "cleanupCacheHistory": false, + "disableGitFeatures": false, + "globalMetadata": { + "_appName": "CommandLineUtils", + "_appFooter": " ", + "_appLogoPath": "logo.png", + "_enableSearch": true + } + } +} diff --git a/docs/samples/aot-sample/ConsoleLogger.cs b/docs/samples/aot-sample/ConsoleLogger.cs new file mode 100644 index 00000000..bf5bf12a --- /dev/null +++ b/docs/samples/aot-sample/ConsoleLogger.cs @@ -0,0 +1,15 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace AotSample +{ + + /// + /// Console logger implementation. + /// + public class ConsoleLogger : ILogger + { + public void Log(string message) => Console.WriteLine($"[LOG] {message}"); + } + +} diff --git a/docs/samples/aot-sample/DiCommand.cs b/docs/samples/aot-sample/DiCommand.cs new file mode 100644 index 00000000..263d2220 --- /dev/null +++ b/docs/samples/aot-sample/DiCommand.cs @@ -0,0 +1,47 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using McMaster.Extensions.CommandLineUtils; + +namespace AotSample +{ + + /// + /// Command that demonstrates constructor injection. + /// + [Command(Name = "di", Description = "Demonstrate DI constructor injection")] + public class DiCommand + { + private readonly ILogger _logger; + + /// + /// Reference to the parent command (set by convention). + /// + public Program? Parent { get; set; } + + /// + /// Constructor that accepts an ILogger via dependency injection. + /// + public DiCommand(ILogger logger) + { + _logger = logger; + } + + [Option("-m|--message", Description = "Message to log")] + public string Message { get; set; } = "Hello from DI!"; + + internal int OnExecute() + { + _logger.Log(Message); + + if (Parent?.Verbose == true) + { + Console.WriteLine(" (Verbose mode enabled via parent)"); + } + + return 0; + } + + } + +} diff --git a/docs/samples/aot-sample/EchoCommand.cs b/docs/samples/aot-sample/EchoCommand.cs new file mode 100644 index 00000000..84a231b9 --- /dev/null +++ b/docs/samples/aot-sample/EchoCommand.cs @@ -0,0 +1,52 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using McMaster.Extensions.CommandLineUtils; + +namespace AotSample +{ + + /// + /// Command that demonstrates RemainingArguments support. + /// + [Command(Name = "echo", Description = "Echo remaining arguments", UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue)] + public class EchoCommand + { + + /// + /// Reference to the parent command (set by convention). + /// + public Program? Parent { get; set; } + + /// + /// All remaining arguments (set by convention). + /// + public string[]? RemainingArguments { get; set; } + + internal int OnExecute() + { + if (RemainingArguments == null || RemainingArguments.Length == 0) + { + Console.WriteLine("No arguments to echo."); + } + else + { + Console.WriteLine("Echoing arguments:"); + + foreach (var arg in RemainingArguments) + { + Console.WriteLine($" - {arg}"); + } + } + + if (Parent?.Verbose == true) + { + Console.WriteLine($" (Total: {RemainingArguments?.Length ?? 0} arguments)"); + } + + return 0; + } + + } + +} diff --git a/docs/samples/aot-sample/GreetCommand.cs b/docs/samples/aot-sample/GreetCommand.cs new file mode 100644 index 00000000..d2ad766e --- /dev/null +++ b/docs/samples/aot-sample/GreetCommand.cs @@ -0,0 +1,51 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; +using McMaster.Extensions.CommandLineUtils; + +namespace AotSample +{ + + /// + /// Command that demonstrates basic option and argument handling. + /// + [Command(Name = "greet", Description = "Greet someone")] + public class GreetCommand + { + + /// + /// Reference to the parent command (set by convention). + /// + public Program? Parent { get; set; } + + [Argument(0, Name = "name", Description = "The name of the person to greet")] + [Required] + public string Name { get; set; } = ""; + + [Option("-l|--loud", Description = "Use uppercase")] + public bool Loud { get; set; } + + internal int OnExecute() + { + var greeting = $"Hello, {Name}!"; + + if (Loud) + { + greeting = greeting.ToUpperInvariant(); + } + + Console.WriteLine(greeting); + + // Show parent's verbose setting if available + if (Parent?.Verbose == true) + { + Console.WriteLine(" (Verbose mode enabled via parent)"); + } + + return 0; + } + + } + +} diff --git a/docs/samples/aot-sample/ILogger.cs b/docs/samples/aot-sample/ILogger.cs new file mode 100644 index 00000000..a3500c16 --- /dev/null +++ b/docs/samples/aot-sample/ILogger.cs @@ -0,0 +1,17 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace AotSample +{ + + /// + /// Simple logger service interface for testing DI. + /// + public interface ILogger + { + + void Log(string message); + + } + +} diff --git a/docs/samples/aot-sample/InfoCommand.cs b/docs/samples/aot-sample/InfoCommand.cs new file mode 100644 index 00000000..cec58307 --- /dev/null +++ b/docs/samples/aot-sample/InfoCommand.cs @@ -0,0 +1,44 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using McMaster.Extensions.CommandLineUtils; + +namespace AotSample +{ + + /// + /// Command that displays runtime information. + /// + [Command(Name = "info", Description = "Display runtime information")] + public class InfoCommand + { + + /// + /// Reference to the parent command (set by convention). + /// + public Program? Parent { get; set; } + + internal int OnExecute() + { + Console.WriteLine("AOT Sample Application"); + Console.WriteLine($" Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); + Console.WriteLine($" OS: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}"); + Console.WriteLine($" Architecture: {System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture}"); + + // Check if we have generated metadata + var hasGenerated = McMaster.Extensions.CommandLineUtils.SourceGeneration + .CommandMetadataRegistry.HasMetadata(typeof(Program)); + Console.WriteLine($" Generated Metadata: {hasGenerated}"); + + // Show parent's verbose setting if available + if (Parent?.Verbose == true) + { + Console.WriteLine(" (Verbose mode enabled)"); + } + + return 0; + } + + } + +} diff --git a/docs/samples/aot-sample/Program.cs b/docs/samples/aot-sample/Program.cs new file mode 100644 index 00000000..1720b0c5 --- /dev/null +++ b/docs/samples/aot-sample/Program.cs @@ -0,0 +1,57 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using McMaster.Extensions.CommandLineUtils; + +namespace AotSample +{ + + /// + /// This sample demonstrates AOT-compatible command-line application usage. + /// + /// When the source generator is active (which it is when referencing the main library), + /// it automatically generates ICommandMetadataProvider implementations for all [Command] classes. + /// This allows the application to work without runtime reflection, enabling Native AOT compilation. + /// + /// To publish as a native AOT executable: + /// dotnet publish -c Release + /// + [Command(Name = "aot-sample", Description = "An AOT-compatible CLI sample")] + [HelpOption] + [VersionOptionFromMember(MemberName = nameof(GetVersion))] + [Subcommand(typeof(GreetCommand), typeof(InfoCommand), typeof(EchoCommand), typeof(DiCommand), typeof(ShowVersionCommand))] + public class Program + { + public static int Main(string[] args) + { + // Create a simple service provider for DI testing + var services = new SimpleServiceProvider(); + services.Register(new ConsoleLogger()); + + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions().UseConstructorInjection(services); + return app.Execute(args); + } + + /// + /// Gets the version string dynamically. + /// + public string GetVersion => "2.0.0-dynamic"; + + [Option("-v|--verbose", Description = "Enable verbose output")] + public bool Verbose { get; set; } + + /// + /// The selected subcommand (set by convention). + /// + public object? Subcommand { get; set; } + + internal int OnExecute(CommandLineApplication app) + { + app.ShowHelp(); + return 0; + } + + } + +} diff --git a/docs/samples/aot-sample/ShowVersionCommand.cs b/docs/samples/aot-sample/ShowVersionCommand.cs new file mode 100644 index 00000000..c2908fdb --- /dev/null +++ b/docs/samples/aot-sample/ShowVersionCommand.cs @@ -0,0 +1,37 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using McMaster.Extensions.CommandLineUtils; + +namespace AotSample +{ + + /// + /// Command that demonstrates name inference - "ShowVersionCommand" becomes "show-version". + /// Note: No explicit Name property in [Command] attribute. + /// + [Command(Description = "Show version info (name inferred from class)")] + public class ShowVersionCommand + { + + /// + /// Reference to the parent command (set by convention). + /// + public Program? Parent { get; set; } + + internal int OnExecute() + { + Console.WriteLine("Version: 2.0.0"); + Console.WriteLine("This command name was inferred from class name 'ShowVersionCommand'"); + + if (Parent?.Verbose == true) + { + Console.WriteLine(" (Verbose mode enabled via parent)"); + } + + return 0; + } + + } + +} diff --git a/docs/samples/aot-sample/SimpleServiceProvider.cs b/docs/samples/aot-sample/SimpleServiceProvider.cs new file mode 100644 index 00000000..3df407f2 --- /dev/null +++ b/docs/samples/aot-sample/SimpleServiceProvider.cs @@ -0,0 +1,27 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace AotSample +{ + + /// + /// Simple IServiceProvider implementation for DI testing. + /// + public class SimpleServiceProvider : IServiceProvider + { + + private readonly Dictionary _services = new(); + + public void Register(T instance) where T : notnull + { + _services[typeof(T)] = instance; + } + + public object? GetService(Type serviceType) + { + return _services.TryGetValue(serviceType, out var service) ? service : null; + } + + } + +} diff --git a/docs/samples/aot-sample/aot-sample.csproj b/docs/samples/aot-sample/aot-sample.csproj new file mode 100644 index 00000000..64a834e9 --- /dev/null +++ b/docs/samples/aot-sample/aot-sample.csproj @@ -0,0 +1,44 @@ + + + + Exe + net10.0 + enable + enable + + + true + true + true + + + true + false + false + false + false + false + + + $(NoWarn);IL2026;IL2060;IL2072;IL2075;IL2087;IL2091;IL3050 + + + + + + + + + + + + + + + + + + + diff --git a/docs/samples/aot-sample/rd.xml b/docs/samples/aot-sample/rd.xml new file mode 100644 index 00000000..e58099b0 --- /dev/null +++ b/docs/samples/aot-sample/rd.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/CommandLineUtils.Generators/ArgumentData.cs b/src/CommandLineUtils.Generators/ArgumentData.cs new file mode 100644 index 00000000..10210f07 --- /dev/null +++ b/src/CommandLineUtils.Generators/ArgumentData.cs @@ -0,0 +1,24 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Data for an argument property. + /// + internal sealed class ArgumentData + { + public string PropertyName { get; set; } = ""; + public string PropertyType { get; set; } = ""; + public int Order { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool? ShowInHelpText { get; set; } + /// + /// Validation attributes applied to this argument. + /// + public List Validators { get; } = new List(); + } +} diff --git a/src/CommandLineUtils.Generators/CommandAttributeData.cs b/src/CommandLineUtils.Generators/CommandAttributeData.cs new file mode 100644 index 00000000..f26907a5 --- /dev/null +++ b/src/CommandLineUtils.Generators/CommandAttributeData.cs @@ -0,0 +1,24 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Data from [Command] attribute. + /// + internal sealed class CommandAttributeData + { + public string? Name { get; set; } + public string[]? AdditionalNames { get; set; } + public string? Description { get; set; } + public string? FullName { get; set; } + public string? ExtendedHelpText { get; set; } + public bool? ShowInHelpText { get; set; } + public bool? AllowArgumentSeparator { get; set; } + public bool? ClusterOptions { get; set; } + public bool? UsePagerForHelpText { get; set; } + public int? ResponseFileHandling { get; set; } + public int? OptionsComparison { get; set; } + public int? UnrecognizedArgumentHandling { get; set; } + } +} diff --git a/src/CommandLineUtils.Generators/CommandInfo.cs b/src/CommandLineUtils.Generators/CommandInfo.cs new file mode 100644 index 00000000..a31338d9 --- /dev/null +++ b/src/CommandLineUtils.Generators/CommandInfo.cs @@ -0,0 +1,42 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Information about a command class. + /// + internal sealed class CommandInfo + { + public INamedTypeSymbol TypeSymbol { get; set; } = null!; + public string FullTypeName { get; set; } = ""; + public string Namespace { get; set; } = ""; + public string ClassName { get; set; } = ""; + /// + /// The inferred command name (kebab-case, minus "Command" suffix). + /// + public string InferredName { get; set; } = ""; + public CommandAttributeData CommandAttribute { get; set; } = new(); + public List Options { get; } = new(); + public List Arguments { get; } = new(); + public List Subcommands { get; } = new(); + public HelpOptionData? HelpOption { get; set; } + public VersionOptionData? VersionOption { get; set; } + public SpecialPropertiesData SpecialProperties { get; set; } = new(); + public bool HasOnExecute { get; set; } + public bool OnExecuteIsAsync { get; set; } + public bool OnExecuteReturnsInt { get; set; } + public bool OnExecuteHasAppParameter { get; set; } + public bool OnExecuteHasCancellationToken { get; set; } + public bool HasOnValidate { get; set; } + public bool HasOnValidationError { get; set; } + + /// + /// Public constructors of the command class, ordered by parameter count descending. + /// + public List Constructors { get; } = new(); + } +} diff --git a/src/CommandLineUtils.Generators/CommandMetadataGenerator.cs b/src/CommandLineUtils.Generators/CommandMetadataGenerator.cs new file mode 100644 index 00000000..6fc8fc57 --- /dev/null +++ b/src/CommandLineUtils.Generators/CommandMetadataGenerator.cs @@ -0,0 +1,1489 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Source generator that creates ICommandMetadataProvider implementations + /// for classes marked with the [Command] attribute. + /// + [Generator(LanguageNames.CSharp)] + public sealed class CommandMetadataGenerator : IIncrementalGenerator + { + private const string CommandAttributeFullName = "McMaster.Extensions.CommandLineUtils.CommandAttribute"; + private const string OptionAttributeFullName = "McMaster.Extensions.CommandLineUtils.OptionAttribute"; + private const string ArgumentAttributeFullName = "McMaster.Extensions.CommandLineUtils.ArgumentAttribute"; + private const string SubcommandAttributeFullName = "McMaster.Extensions.CommandLineUtils.SubcommandAttribute"; + private const string HelpOptionAttributeFullName = "McMaster.Extensions.CommandLineUtils.HelpOptionAttribute"; + private const string VersionOptionAttributeFullName = "McMaster.Extensions.CommandLineUtils.VersionOptionAttribute"; + private const string VersionOptionFromMemberAttributeFullName = "McMaster.Extensions.CommandLineUtils.VersionOptionFromMemberAttribute"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Register the attribute source (always available for reference) + context.RegisterPostInitializationOutput(ctx => + { + ctx.AddSource("CommandMetadataGeneratorAttribute.g.cs", SourceText.From(AttributeSource, Encoding.UTF8)); + }); + + // Check if PublishAot is enabled - only generate metadata when AOT is on + var isAotEnabled = context.AnalyzerConfigOptionsProvider + .Select(static (options, _) => + { + options.GlobalOptions.TryGetValue("build_property.PublishAot", out var publishAot); + return string.Equals(publishAot, "true", StringComparison.OrdinalIgnoreCase); + }); + + // Find all classes with [Command] attribute + var commandClasses = context.SyntaxProvider + .ForAttributeWithMetadataName( + CommandAttributeFullName, + predicate: static (node, _) => node is ClassDeclarationSyntax, + transform: static (ctx, ct) => GetCommandInfo(ctx, ct)) + .Where(static m => m is not null) + .Select(static (m, _) => m!); + + // Combine command classes with AOT flag + var commandsWithAot = commandClasses.Combine(isAotEnabled); + + // Generate the metadata providers (only if AOT is enabled) + context.RegisterSourceOutput(commandsWithAot, static (spc, tuple) => + { + var (commandInfo, isAot) = tuple; + if (!isAot) return; // Skip generation for non-AOT builds + + var source = GenerateMetadataProvider(commandInfo); + spc.AddSource($"{commandInfo.FullTypeName.Replace(".", "_")}_Metadata.g.cs", SourceText.From(source, Encoding.UTF8)); + }); + + // Generate the module initializer that registers all providers (only if AOT is enabled) + var allCommandsWithAot = commandClasses.Collect().Combine(isAotEnabled); + context.RegisterSourceOutput(allCommandsWithAot, static (spc, tuple) => + { + var (commands, isAot) = tuple; + if (!isAot || commands.Length == 0) + { + return; + } + + var source = GenerateModuleInitializer(commands); + spc.AddSource("CommandMetadataRegistration.g.cs", SourceText.From(source, Encoding.UTF8)); + }); + } + + private static CommandInfo? GetCommandInfo(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) + { + return null; + } + + var classDeclaration = (ClassDeclarationSyntax)context.TargetNode; + + // Get command attribute data + var commandAttr = context.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == CommandAttributeFullName); + + if (commandAttr is null) + { + return null; + } + + var info = new CommandInfo + { + TypeSymbol = typeSymbol, + FullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)), + Namespace = typeSymbol.ContainingNamespace?.ToDisplayString() ?? "", + ClassName = typeSymbol.Name, + InferredName = InferCommandName(typeSymbol.Name), + CommandAttribute = ExtractCommandAttributeData(commandAttr) + }; + + // Extract options + foreach (var member in typeSymbol.GetMembers()) + { + if (member is IPropertySymbol property) + { + ExtractPropertyMetadata(property, info); + } + } + + // Extract subcommands and type-level options from attributes + foreach (var attr in typeSymbol.GetAttributes()) + { + var attrName = attr.AttributeClass?.ToDisplayString(); + if (attrName == SubcommandAttributeFullName) + { + ExtractSubcommandMetadata(attr, info); + } + else if (attrName == HelpOptionAttributeFullName && info.HelpOption == null) + { + info.HelpOption = ExtractTypeLevelHelpOptionData(attr); + } + else if (attrName == VersionOptionAttributeFullName && info.VersionOption == null) + { + info.VersionOption = ExtractTypeLevelVersionOptionData(attr); + } + else if (attrName == VersionOptionFromMemberAttributeFullName && info.VersionOption == null) + { + info.VersionOption = ExtractVersionOptionFromMemberData(attr); + } + } + + // Check for OnExecute/OnExecuteAsync methods + ExtractExecuteMethods(typeSymbol, info); + + // Extract special properties (Parent, Subcommand, RemainingArguments) + ExtractSpecialProperties(typeSymbol, info); + + // Extract constructor info for dependency injection + ExtractConstructors(typeSymbol, info); + + return info; + } + + private static void ExtractSpecialProperties(INamedTypeSymbol typeSymbol, CommandInfo info) + { + foreach (var member in typeSymbol.GetMembers()) + { + if (member is not IPropertySymbol property) + continue; + + // Check for Parent property + if (property.Name == "Parent" && property.SetMethod != null) + { + info.SpecialProperties.ParentPropertyName = property.Name; + info.SpecialProperties.ParentPropertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + // Check for Subcommand property + if (property.Name == "Subcommand" && property.SetMethod != null) + { + info.SpecialProperties.SubcommandPropertyName = property.Name; + info.SpecialProperties.SubcommandPropertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + // Check for RemainingArguments or RemainingArgs property + if ((property.Name == "RemainingArguments" || property.Name == "RemainingArgs") && property.SetMethod != null) + { + var typeStr = property.Type.ToDisplayString(); + // Must be string[] or IReadOnlyList or compatible + // Handle nullable annotations (string?[], string[]?, etc.) + if (typeStr.Contains("string[]") || + typeStr.Contains("String[]") || + typeStr.Contains("IReadOnlyList") || + typeStr.Contains("List") || + typeStr.Contains("IEnumerable") || + (property.Type is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_String)) + { + info.SpecialProperties.RemainingArgumentsPropertyName = property.Name; + info.SpecialProperties.RemainingArgumentsPropertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + } + } + } + + private static void ExtractConstructors(INamedTypeSymbol typeSymbol, CommandInfo info) + { + // Get all public instance constructors, ordered by parameter count descending + var constructors = typeSymbol.InstanceConstructors + .Where(c => c.DeclaredAccessibility == Accessibility.Public) + .OrderByDescending(c => c.Parameters.Length) + .ToArray(); + + foreach (var ctor in constructors) + { + var ctorData = new ConstructorData(); + foreach (var param in ctor.Parameters) + { + ctorData.Parameters.Add(new ConstructorParameterData + { + TypeName = param.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + Name = param.Name + }); + } + info.Constructors.Add(ctorData); + } + } + + private static CommandAttributeData ExtractCommandAttributeData(AttributeData attr) + { + var data = new CommandAttributeData(); + + // Get constructor arguments (Name, additional names) + if (attr.ConstructorArguments.Length > 0) + { + var firstArg = attr.ConstructorArguments[0]; + if (firstArg.Kind == TypedConstantKind.Array) + { + var names = firstArg.Values.Select(v => v.Value?.ToString()).Where(n => n != null).ToArray(); + if (names.Length > 0) + { + data.Name = names[0]; + data.AdditionalNames = names.Skip(1).ToArray()!; + } + } + else if (firstArg.Value is string name) + { + data.Name = name; + } + } + + // Get named arguments + foreach (var arg in attr.NamedArguments) + { + switch (arg.Key) + { + case "Name": + data.Name = arg.Value.Value?.ToString(); + break; + case "Description": + data.Description = arg.Value.Value?.ToString(); + break; + case "FullName": + data.FullName = arg.Value.Value?.ToString(); + break; + case "ExtendedHelpText": + data.ExtendedHelpText = arg.Value.Value?.ToString(); + break; + case "ShowInHelpText": + data.ShowInHelpText = (bool?)arg.Value.Value; + break; + case "AllowArgumentSeparator": + data.AllowArgumentSeparator = (bool?)arg.Value.Value; + break; + case "ClusterOptions": + data.ClusterOptions = (bool?)arg.Value.Value; + break; + case "UsePagerForHelpText": + data.UsePagerForHelpText = (bool?)arg.Value.Value; + break; + case "ResponseFileHandling": + data.ResponseFileHandling = (int?)arg.Value.Value; + break; + case "OptionsComparison": + data.OptionsComparison = (int?)arg.Value.Value; + break; + case "UnrecognizedArgumentHandling": + data.UnrecognizedArgumentHandling = (int?)arg.Value.Value; + break; + } + } + + return data; + } + + private static void ExtractPropertyMetadata(IPropertySymbol property, CommandInfo info) + { + foreach (var attr in property.GetAttributes()) + { + var attrName = attr.AttributeClass?.ToDisplayString(); + + switch (attrName) + { + case OptionAttributeFullName: + info.Options.Add(ExtractOptionData(property, attr)); + break; + case ArgumentAttributeFullName: + info.Arguments.Add(ExtractArgumentData(property, attr)); + break; + case HelpOptionAttributeFullName: + info.HelpOption = ExtractHelpOptionData(property, attr); + break; + case VersionOptionAttributeFullName: + info.VersionOption = ExtractVersionOptionData(property, attr); + break; + } + } + } + + private static OptionData ExtractOptionData(IPropertySymbol property, AttributeData attr) + { + var data = new OptionData + { + PropertyName = property.Name, + PropertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + + // Template from constructor + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string template) + { + data.Template = template; + } + + foreach (var arg in attr.NamedArguments) + { + switch (arg.Key) + { + case "Template": + data.Template = arg.Value.Value?.ToString(); + break; + case "ShortName": + data.ShortName = arg.Value.Value?.ToString(); + break; + case "LongName": + data.LongName = arg.Value.Value?.ToString(); + break; + case "SymbolName": + data.SymbolName = arg.Value.Value?.ToString(); + break; + case "ValueName": + data.ValueName = arg.Value.Value?.ToString(); + break; + case "Description": + data.Description = arg.Value.Value?.ToString(); + break; + case "ShowInHelpText": + data.ShowInHelpText = (bool?)arg.Value.Value; + break; + case "Inherited": + data.Inherited = (bool?)arg.Value.Value; + break; + case "CommandOptionType": + data.OptionType = (int?)arg.Value.Value; + data.OptionTypeExplicitlySet = true; + break; + } + } + + // Infer OptionType from property type if not explicitly set + data.InferredOptionType = InferOptionType(property.Type); + + // Extract validation attributes + ExtractValidationAttributes(property, data.Validators); + + return data; + } + + /// + /// Infers CommandOptionType from property type. + /// CommandOptionType enum values: + /// MultipleValue = 0 + /// SingleValue = 1 + /// SingleOrNoValue = 2 + /// NoValue = 3 + /// + private static int InferOptionType(ITypeSymbol type) + { + // Handle nullable types - unwrap to get underlying type + if (type is INamedTypeSymbol namedType && namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + type = namedType.TypeArguments[0]; + } + + var typeFullName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Boolean -> NoValue (flag) + if (type.SpecialType == SpecialType.System_Boolean) + { + return 3; // NoValue + } + + // Boolean array -> NoValue (counting flag) + if (type is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Boolean) + { + return 3; // NoValue + } + + // ValueTuple or Nullable value type -> SingleOrNoValue + if (type is INamedTypeSymbol namedType2) + { + // Check for ValueTuple + if (namedType2.IsTupleType && namedType2.TupleElements.Length == 2) + { + var first = namedType2.TupleElements[0]; + if (first.Type.SpecialType == SpecialType.System_Boolean) + { + return 2; // SingleOrNoValue + } + } + } + + // Arrays and collections -> MultipleValue + if (type is IArrayTypeSymbol) + { + return 0; // MultipleValue + } + + // Check for common collection types + if (type is INamedTypeSymbol collType) + { + var originalDef = collType.OriginalDefinition.ToDisplayString(); + if (originalDef.StartsWith("System.Collections.Generic.IEnumerable<") || + originalDef.StartsWith("System.Collections.Generic.ICollection<") || + originalDef.StartsWith("System.Collections.Generic.IList<") || + originalDef.StartsWith("System.Collections.Generic.List<") || + originalDef.StartsWith("System.Collections.Generic.HashSet<") || + originalDef == "System.Collections.IEnumerable" || + originalDef == "System.Collections.ICollection" || + originalDef == "System.Collections.IList") + { + // String is IEnumerable but should be SingleValue + if (type.SpecialType != SpecialType.System_String) + { + return 0; // MultipleValue + } + } + } + + // Default: SingleValue + return 1; // SingleValue + } + + private static ArgumentData ExtractArgumentData(IPropertySymbol property, AttributeData attr) + { + var data = new ArgumentData + { + PropertyName = property.Name, + PropertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + + // Order from constructor + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is int order) + { + data.Order = order; + } + + foreach (var arg in attr.NamedArguments) + { + switch (arg.Key) + { + case "Name": + data.Name = arg.Value.Value?.ToString(); + break; + case "Description": + data.Description = arg.Value.Value?.ToString(); + break; + case "ShowInHelpText": + data.ShowInHelpText = (bool?)arg.Value.Value; + break; + } + } + + // Extract validation attributes + ExtractValidationAttributes(property, data.Validators); + + return data; + } + + private static HelpOptionData ExtractHelpOptionData(IPropertySymbol property, AttributeData attr) + { + var data = new HelpOptionData + { + PropertyName = property.Name + }; + + // Template from constructor + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string template) + { + data.Template = template; + } + + foreach (var arg in attr.NamedArguments) + { + switch (arg.Key) + { + case "Template": + data.Template = arg.Value.Value?.ToString(); + break; + case "Description": + data.Description = arg.Value.Value?.ToString(); + break; + case "Inherited": + data.Inherited = (bool?)arg.Value.Value; + break; + } + } + + return data; + } + + private static VersionOptionData ExtractVersionOptionData(IPropertySymbol property, AttributeData attr) + { + var data = new VersionOptionData + { + PropertyName = property.Name + }; + + // Template from constructor + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string template) + { + data.Template = template; + } + + foreach (var arg in attr.NamedArguments) + { + switch (arg.Key) + { + case "Template": + data.Template = arg.Value.Value?.ToString(); + break; + case "Version": + data.Version = arg.Value.Value?.ToString(); + break; + case "Description": + data.Description = arg.Value.Value?.ToString(); + break; + } + } + + return data; + } + + private static HelpOptionData ExtractTypeLevelHelpOptionData(AttributeData attr) + { + var data = new HelpOptionData(); + + // Template from constructor + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string template) + { + data.Template = template; + } + + foreach (var arg in attr.NamedArguments) + { + switch (arg.Key) + { + case "Template": + data.Template = arg.Value.Value?.ToString(); + break; + case "Description": + data.Description = arg.Value.Value?.ToString(); + break; + case "Inherited": + data.Inherited = (bool?)arg.Value.Value; + break; + } + } + + return data; + } + + private static VersionOptionData ExtractVersionOptionFromMemberData(AttributeData attr) + { + var data = new VersionOptionData(); + + // Template from constructor (optional first argument) + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string template) + { + data.Template = template; + } + + foreach (var arg in attr.NamedArguments) + { + switch (arg.Key) + { + case "Template": + data.Template = arg.Value.Value?.ToString(); + break; + case "MemberName": + data.MemberName = arg.Value.Value?.ToString(); + break; + case "Description": + data.Description = arg.Value.Value?.ToString(); + break; + } + } + + return data; + } + + private static VersionOptionData ExtractTypeLevelVersionOptionData(AttributeData attr) + { + var data = new VersionOptionData(); + + // Version from first constructor argument (single-arg constructor) + if (attr.ConstructorArguments.Length == 1 && attr.ConstructorArguments[0].Value is string version) + { + data.Version = version; + } + // Two-arg constructor: (template, version) + else if (attr.ConstructorArguments.Length >= 2) + { + if (attr.ConstructorArguments[0].Value is string tmpl) + data.Template = tmpl; + if (attr.ConstructorArguments[1].Value is string ver) + data.Version = ver; + } + + foreach (var arg in attr.NamedArguments) + { + switch (arg.Key) + { + case "Template": + data.Template = arg.Value.Value?.ToString(); + break; + case "Version": + data.Version = arg.Value.Value?.ToString(); + break; + case "Description": + data.Description = arg.Value.Value?.ToString(); + break; + } + } + + return data; + } + + /// + /// Extracts validation attributes (e.g., [Required], [Range], [StringLength]) from a property. + /// + private static void ExtractValidationAttributes(IPropertySymbol property, List validators) + { + foreach (var attr in property.GetAttributes()) + { + var attrClass = attr.AttributeClass; + if (attrClass == null) continue; + + // Check if attribute inherits from ValidationAttribute + if (!InheritsFromValidationAttribute(attrClass)) continue; + + var validatorData = new ValidationAttributeData + { + TypeName = attrClass.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + + // Extract constructor arguments + foreach (var ctorArg in attr.ConstructorArguments) + { + validatorData.ConstructorArguments.Add(FormatTypedConstant(ctorArg)); + } + + // Extract named arguments + foreach (var namedArg in attr.NamedArguments) + { + validatorData.NamedArguments[namedArg.Key] = FormatTypedConstant(namedArg.Value); + } + + validators.Add(validatorData); + } + } + + private static bool InheritsFromValidationAttribute(INamedTypeSymbol? typeSymbol) + { + while (typeSymbol != null) + { + if (typeSymbol.ToDisplayString() == "System.ComponentModel.DataAnnotations.ValidationAttribute") + { + return true; + } + typeSymbol = typeSymbol.BaseType; + } + return false; + } + + private static string FormatTypedConstant(TypedConstant constant) + { + if (constant.IsNull) + { + return "null"; + } + + switch (constant.Kind) + { + case TypedConstantKind.Primitive: + if (constant.Value is string s) + { + return $"\"{EscapeString(s)}\""; + } + if (constant.Value is bool b) + { + return b ? "true" : "false"; + } + if (constant.Value is char c) + { + return $"'{c}'"; + } + return constant.Value?.ToString() ?? "null"; + + case TypedConstantKind.Enum: + return $"({constant.Type!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}){constant.Value}"; + + case TypedConstantKind.Type: + return $"typeof({((INamedTypeSymbol)constant.Value!).ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)})"; + + case TypedConstantKind.Array: + var elements = constant.Values.Select(v => FormatTypedConstant(v)); + return $"new[] {{ {string.Join(", ", elements)} }}"; + + default: + return constant.Value?.ToString() ?? "null"; + } + } + + private static void ExtractSubcommandMetadata(AttributeData attr, CommandInfo info) + { + if (attr.ConstructorArguments.Length > 0) + { + var types = attr.ConstructorArguments[0]; + if (types.Kind == TypedConstantKind.Array) + { + foreach (var typeArg in types.Values) + { + if (typeArg.Value is INamedTypeSymbol subType) + { + info.Subcommands.Add(new SubcommandData + { + TypeName = subType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }); + } + } + } + else if (types.Value is INamedTypeSymbol singleType) + { + info.Subcommands.Add(new SubcommandData + { + TypeName = singleType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }); + } + } + } + + private static void ExtractExecuteMethods(INamedTypeSymbol typeSymbol, CommandInfo info) + { + foreach (var member in typeSymbol.GetMembers()) + { + if (member is IMethodSymbol method) + { + if (method.Name == "OnExecute") + { + info.HasOnExecute = true; + info.OnExecuteIsAsync = false; + AnalyzeExecuteMethod(method, info); + } + else if (method.Name == "OnExecuteAsync") + { + info.HasOnExecute = true; + info.OnExecuteIsAsync = true; + AnalyzeExecuteMethod(method, info); + } + else if (method.Name == "OnValidate") + { + info.HasOnValidate = true; + } + else if (method.Name == "OnValidationError") + { + info.HasOnValidationError = true; + } + } + } + } + + private static void AnalyzeExecuteMethod(IMethodSymbol method, CommandInfo info) + { + // Check return type + if (method.ReturnType.SpecialType == SpecialType.System_Int32) + { + info.OnExecuteReturnsInt = true; + } + else if (method.ReturnType is INamedTypeSymbol namedType) + { + // Check for Task + if (namedType.Name == "Task" && namedType.TypeArguments.Length == 1 && + namedType.TypeArguments[0].SpecialType == SpecialType.System_Int32) + { + info.OnExecuteReturnsInt = true; + } + } + + // Check parameters + foreach (var param in method.Parameters) + { + var typeName = param.Type.ToDisplayString(); + if (typeName.Contains("CommandLineApplication")) + { + info.OnExecuteHasAppParameter = true; + } + else if (typeName.Contains("CancellationToken")) + { + info.OnExecuteHasCancellationToken = true; + } + } + } + + private static string GenerateMetadataProvider(CommandInfo info) + { + var sb = new StringBuilder(); + + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine("using System.ComponentModel.DataAnnotations;"); + sb.AppendLine("using System.Threading;"); + sb.AppendLine("using System.Threading.Tasks;"); + sb.AppendLine("using McMaster.Extensions.CommandLineUtils;"); + sb.AppendLine("using McMaster.Extensions.CommandLineUtils.Abstractions;"); + sb.AppendLine("using McMaster.Extensions.CommandLineUtils.SourceGeneration;"); + sb.AppendLine(); + + if (!string.IsNullOrEmpty(info.Namespace)) + { + sb.AppendLine($"namespace {info.Namespace}"); + sb.AppendLine("{"); + } + + var indent = string.IsNullOrEmpty(info.Namespace) ? "" : " "; + + // Generate the metadata provider class + sb.AppendLine($"{indent}internal sealed class {info.ClassName}__GeneratedMetadataProvider : ICommandMetadataProvider<{info.ClassName}>"); + sb.AppendLine($"{indent}{{"); + + // Singleton instance + sb.AppendLine($"{indent} public static readonly {info.ClassName}__GeneratedMetadataProvider Instance = new();"); + sb.AppendLine(); + + // ModelType + sb.AppendLine($"{indent} public Type ModelType => typeof({info.ClassName});"); + sb.AppendLine(); + + // Options + GenerateOptionsProperty(sb, info, indent); + + // Arguments + GenerateArgumentsProperty(sb, info, indent); + + // Subcommands + GenerateSubcommandsProperty(sb, info, indent); + + // CommandInfo + GenerateCommandInfoProperty(sb, info, indent); + + // ExecuteHandler + GenerateExecuteHandler(sb, info, indent); + + // ValidateHandler + GenerateValidateHandler(sb, info, indent); + + // ValidationErrorHandler + GenerateValidationErrorHandler(sb, info, indent); + + // SpecialProperties + GenerateSpecialPropertiesProperty(sb, info, indent); + + // HelpOption + GenerateHelpOptionProperty(sb, info, indent); + + // VersionOption + GenerateVersionOptionProperty(sb, info, indent); + + // GetModelFactory + sb.AppendLine($"{indent} public IModelFactory<{info.ClassName}> GetModelFactory(IServiceProvider? services)"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} return new GeneratedModelFactory(services);"); + sb.AppendLine($"{indent} }}"); + sb.AppendLine(); + sb.AppendLine($"{indent} IModelFactory ICommandMetadataProvider.GetModelFactory(IServiceProvider? services)"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} return GetModelFactory(services);"); + sb.AppendLine($"{indent} }}"); + sb.AppendLine(); + + // Nested model factory + GenerateModelFactory(sb, info, indent); + + sb.AppendLine($"{indent}}}"); + + if (!string.IsNullOrEmpty(info.Namespace)) + { + sb.AppendLine("}"); + } + + return sb.ToString(); + } + + private static void GenerateOptionsProperty(StringBuilder sb, CommandInfo info, string indent) + { + if (info.Options.Count == 0) + { + sb.AppendLine($"{indent} public IReadOnlyList Options => Array.Empty();"); + } + else + { + sb.AppendLine($"{indent} private static readonly IReadOnlyList _options = new OptionMetadata[]"); + sb.AppendLine($"{indent} {{"); + foreach (var opt in info.Options) + { + sb.AppendLine($"{indent} new OptionMetadata("); + sb.AppendLine($"{indent} propertyName: \"{opt.PropertyName}\","); + sb.AppendLine($"{indent} propertyType: typeof({opt.PropertyType}),"); + sb.AppendLine($"{indent} getter: static obj => (({info.ClassName})obj).{opt.PropertyName},"); + sb.AppendLine($"{indent} setter: static (obj, val) => (({info.ClassName})obj).{opt.PropertyName} = ({opt.PropertyType})val!)"); + sb.AppendLine($"{indent} {{"); + if (opt.Template != null) + sb.AppendLine($"{indent} Template = \"{EscapeString(opt.Template)}\","); + if (opt.ShortName != null) + sb.AppendLine($"{indent} ShortName = \"{EscapeString(opt.ShortName)}\","); + if (opt.LongName != null) + sb.AppendLine($"{indent} LongName = \"{EscapeString(opt.LongName)}\","); + if (opt.Description != null) + sb.AppendLine($"{indent} Description = \"{EscapeString(opt.Description)}\","); + if (opt.ShowInHelpText.HasValue) + sb.AppendLine($"{indent} ShowInHelpText = {opt.ShowInHelpText.Value.ToString().ToLowerInvariant()},"); + if (opt.Inherited.HasValue) + sb.AppendLine($"{indent} Inherited = {opt.Inherited.Value.ToString().ToLowerInvariant()},"); + // Always emit OptionType and OptionTypeExplicitlySet + var optionType = opt.OptionType ?? opt.InferredOptionType; + sb.AppendLine($"{indent} OptionType = (CommandOptionType){optionType},"); + sb.AppendLine($"{indent} OptionTypeExplicitlySet = {opt.OptionTypeExplicitlySet.ToString().ToLowerInvariant()},"); + // Emit validators if any + if (opt.Validators.Count > 0) + { + GenerateValidatorsProperty(sb, opt.Validators, indent + " "); + } + sb.AppendLine($"{indent} }},"); + } + sb.AppendLine($"{indent} }};"); + sb.AppendLine(); + sb.AppendLine($"{indent} public IReadOnlyList Options => _options;"); + } + sb.AppendLine(); + } + + private static void GenerateArgumentsProperty(StringBuilder sb, CommandInfo info, string indent) + { + if (info.Arguments.Count == 0) + { + sb.AppendLine($"{indent} public IReadOnlyList Arguments => Array.Empty();"); + } + else + { + sb.AppendLine($"{indent} private static readonly IReadOnlyList _arguments = new ArgumentMetadata[]"); + sb.AppendLine($"{indent} {{"); + foreach (var arg in info.Arguments.OrderBy(a => a.Order)) + { + sb.AppendLine($"{indent} new ArgumentMetadata("); + sb.AppendLine($"{indent} propertyName: \"{arg.PropertyName}\","); + sb.AppendLine($"{indent} propertyType: typeof({arg.PropertyType}),"); + sb.AppendLine($"{indent} order: {arg.Order},"); + sb.AppendLine($"{indent} getter: static obj => (({info.ClassName})obj).{arg.PropertyName},"); + sb.AppendLine($"{indent} setter: static (obj, val) => (({info.ClassName})obj).{arg.PropertyName} = ({arg.PropertyType})val!)"); + sb.AppendLine($"{indent} {{"); + if (arg.Name != null) + sb.AppendLine($"{indent} Name = \"{EscapeString(arg.Name)}\","); + if (arg.Description != null) + sb.AppendLine($"{indent} Description = \"{EscapeString(arg.Description)}\","); + if (arg.ShowInHelpText.HasValue) + sb.AppendLine($"{indent} ShowInHelpText = {arg.ShowInHelpText.Value.ToString().ToLowerInvariant()},"); + // Emit validators if any + if (arg.Validators.Count > 0) + { + GenerateValidatorsProperty(sb, arg.Validators, indent + " "); + } + sb.AppendLine($"{indent} }},"); + } + sb.AppendLine($"{indent} }};"); + sb.AppendLine(); + sb.AppendLine($"{indent} public IReadOnlyList Arguments => _arguments;"); + } + sb.AppendLine(); + } + + private static void GenerateValidatorsProperty(StringBuilder sb, List validators, string indent) + { + sb.AppendLine($"{indent}Validators = new ValidationAttribute[]"); + sb.AppendLine($"{indent}{{"); + foreach (var validator in validators) + { + var ctorArgs = string.Join(", ", validator.ConstructorArguments); + sb.Append($"{indent} new {validator.TypeName}({ctorArgs})"); + + if (validator.NamedArguments.Count > 0) + { + sb.AppendLine(); + sb.AppendLine($"{indent} {{"); + foreach (var namedArg in validator.NamedArguments) + { + sb.AppendLine($"{indent} {namedArg.Key} = {namedArg.Value},"); + } + sb.Append($"{indent} }}"); + } + sb.AppendLine(","); + } + sb.AppendLine($"{indent}}},"); + } + + private static void GenerateSubcommandsProperty(StringBuilder sb, CommandInfo info, string indent) + { + if (info.Subcommands.Count == 0) + { + sb.AppendLine($"{indent} public IReadOnlyList Subcommands => Array.Empty();"); + } + else + { + sb.AppendLine($"{indent} private static readonly IReadOnlyList _subcommands = new SubcommandMetadata[]"); + sb.AppendLine($"{indent} {{"); + foreach (var sub in info.Subcommands) + { + sb.AppendLine($"{indent} new SubcommandMetadata(typeof({sub.TypeName}))"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} MetadataProviderFactory = () => CommandMetadataRegistry.TryGetProvider(typeof({sub.TypeName}), out var p) ? p : throw new InvalidOperationException(\"No metadata provider for \" + typeof({sub.TypeName}))"); + sb.AppendLine($"{indent} }},"); + } + sb.AppendLine($"{indent} }};"); + sb.AppendLine(); + sb.AppendLine($"{indent} public IReadOnlyList Subcommands => _subcommands;"); + } + sb.AppendLine(); + } + + private static void GenerateCommandInfoProperty(StringBuilder sb, CommandInfo info, string indent) + { + var cmd = info.CommandAttribute; + sb.AppendLine($"{indent} public CommandMetadata? CommandInfo => new CommandMetadata"); + sb.AppendLine($"{indent} {{"); + // Use explicit name if set, otherwise use inferred name from class name + var commandName = cmd.Name ?? info.InferredName; + sb.AppendLine($"{indent} Name = \"{EscapeString(commandName)}\","); + if (cmd.AdditionalNames?.Length > 0) + sb.AppendLine($"{indent} AdditionalNames = new[] {{ {string.Join(", ", cmd.AdditionalNames.Select(n => $"\"{EscapeString(n)}\""))} }},"); + if (cmd.Description != null) + sb.AppendLine($"{indent} Description = \"{EscapeString(cmd.Description)}\","); + if (cmd.FullName != null) + sb.AppendLine($"{indent} FullName = \"{EscapeString(cmd.FullName)}\","); + if (cmd.ExtendedHelpText != null) + sb.AppendLine($"{indent} ExtendedHelpText = \"{EscapeString(cmd.ExtendedHelpText)}\","); + if (cmd.ShowInHelpText.HasValue) + sb.AppendLine($"{indent} ShowInHelpText = {cmd.ShowInHelpText.Value.ToString().ToLowerInvariant()},"); + if (cmd.AllowArgumentSeparator.HasValue) + sb.AppendLine($"{indent} AllowArgumentSeparator = {cmd.AllowArgumentSeparator.Value.ToString().ToLowerInvariant()},"); + if (cmd.ClusterOptions.HasValue) + sb.AppendLine($"{indent} ClusterOptions = {cmd.ClusterOptions.Value.ToString().ToLowerInvariant()},"); + if (cmd.UsePagerForHelpText.HasValue) + sb.AppendLine($"{indent} UsePagerForHelpText = {cmd.UsePagerForHelpText.Value.ToString().ToLowerInvariant()},"); + if (cmd.ResponseFileHandling.HasValue) + sb.AppendLine($"{indent} ResponseFileHandling = (ResponseFileHandling){cmd.ResponseFileHandling.Value},"); + if (cmd.OptionsComparison.HasValue) + sb.AppendLine($"{indent} OptionsComparison = (StringComparison){cmd.OptionsComparison.Value},"); + if (cmd.UnrecognizedArgumentHandling.HasValue) + sb.AppendLine($"{indent} UnrecognizedArgumentHandling = (UnrecognizedArgumentHandling){cmd.UnrecognizedArgumentHandling.Value},"); + sb.AppendLine($"{indent} }};"); + sb.AppendLine(); + } + + private static void GenerateExecuteHandler(StringBuilder sb, CommandInfo info, string indent) + { + if (!info.HasOnExecute) + { + sb.AppendLine($"{indent} public IExecuteHandler? ExecuteHandler => null;"); + } + else + { + sb.AppendLine($"{indent} public IExecuteHandler? ExecuteHandler => GeneratedExecuteHandler.Instance;"); + sb.AppendLine(); + sb.AppendLine($"{indent} private sealed class GeneratedExecuteHandler : IExecuteHandler"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} public static readonly GeneratedExecuteHandler Instance = new();"); + sb.AppendLine(); + sb.AppendLine($"{indent} public bool IsAsync => {(info.OnExecuteIsAsync ? "true" : "false")};"); + sb.AppendLine(); + sb.AppendLine($"{indent} public async Task InvokeAsync(object model, CommandLineApplication app, CancellationToken cancellationToken)"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} var typedModel = ({info.ClassName})model;"); + + // Build the argument list based on what parameters the method has + var args = new List(); + if (info.OnExecuteHasAppParameter) + { + args.Add("app"); + } + if (info.OnExecuteHasCancellationToken) + { + args.Add("cancellationToken"); + } + var argsString = string.Join(", ", args); + + if (info.OnExecuteIsAsync) + { + // OnExecuteAsync method + if (info.OnExecuteReturnsInt) + { + sb.AppendLine($"{indent} return await typedModel.OnExecuteAsync({argsString});"); + } + else + { + sb.AppendLine($"{indent} await typedModel.OnExecuteAsync({argsString});"); + sb.AppendLine($"{indent} return 0;"); + } + } + else + { + // OnExecute method + if (info.OnExecuteReturnsInt) + { + sb.AppendLine($"{indent} return typedModel.OnExecute({argsString});"); + } + else + { + sb.AppendLine($"{indent} typedModel.OnExecute({argsString});"); + sb.AppendLine($"{indent} return 0;"); + } + } + + sb.AppendLine($"{indent} }}"); + sb.AppendLine($"{indent} }}"); + } + sb.AppendLine(); + } + + private static void GenerateValidateHandler(StringBuilder sb, CommandInfo info, string indent) + { + if (!info.HasOnValidate) + { + sb.AppendLine($"{indent} public IValidateHandler? ValidateHandler => null;"); + } + else + { + sb.AppendLine($"{indent} public IValidateHandler? ValidateHandler => GeneratedValidateHandler.Instance;"); + sb.AppendLine(); + sb.AppendLine($"{indent} private sealed class GeneratedValidateHandler : IValidateHandler"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} public static readonly GeneratedValidateHandler Instance = new();"); + sb.AppendLine(); + sb.AppendLine($"{indent} public ValidationResult Invoke(object model, ValidationContext context)"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} var typedModel = ({info.ClassName})model;"); + sb.AppendLine($"{indent} return typedModel.OnValidate(context);"); + sb.AppendLine($"{indent} }}"); + sb.AppendLine($"{indent} }}"); + } + sb.AppendLine(); + } + + private static void GenerateValidationErrorHandler(StringBuilder sb, CommandInfo info, string indent) + { + if (!info.HasOnValidationError) + { + sb.AppendLine($"{indent} public IValidationErrorHandler? ValidationErrorHandler => null;"); + } + else + { + sb.AppendLine($"{indent} public IValidationErrorHandler? ValidationErrorHandler => GeneratedValidationErrorHandler.Instance;"); + sb.AppendLine(); + sb.AppendLine($"{indent} private sealed class GeneratedValidationErrorHandler : IValidationErrorHandler"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} public static readonly GeneratedValidationErrorHandler Instance = new();"); + sb.AppendLine(); + sb.AppendLine($"{indent} public int Invoke(object model, ValidationResult result)"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} var typedModel = ({info.ClassName})model;"); + sb.AppendLine($"{indent} return typedModel.OnValidationError(result);"); + sb.AppendLine($"{indent} }}"); + sb.AppendLine($"{indent} }}"); + } + sb.AppendLine(); + } + + private static void GenerateSpecialPropertiesProperty(StringBuilder sb, CommandInfo info, string indent) + { + var sp = info.SpecialProperties; + if (!sp.HasAny) + { + sb.AppendLine($"{indent} public SpecialPropertiesMetadata? SpecialProperties => null;"); + } + else + { + sb.AppendLine($"{indent} public SpecialPropertiesMetadata? SpecialProperties => new SpecialPropertiesMetadata"); + sb.AppendLine($"{indent} {{"); + + if (sp.ParentPropertyName != null) + { + sb.AppendLine($"{indent} ParentSetter = static (obj, val) => (({info.ClassName})obj).{sp.ParentPropertyName} = ({sp.ParentPropertyType})val!,"); + sb.AppendLine($"{indent} ParentType = typeof({sp.ParentPropertyType}),"); + } + + if (sp.SubcommandPropertyName != null) + { + sb.AppendLine($"{indent} SubcommandSetter = static (obj, val) => (({info.ClassName})obj).{sp.SubcommandPropertyName} = ({sp.SubcommandPropertyType})val!,"); + sb.AppendLine($"{indent} SubcommandType = typeof({sp.SubcommandPropertyType}),"); + } + + if (sp.RemainingArgumentsPropertyName != null) + { + // Handle string[] vs IReadOnlyList + if (sp.RemainingArgumentsPropertyType == "string[]") + { + sb.AppendLine($"{indent} RemainingArgumentsSetter = static (obj, val) => (({info.ClassName})obj).{sp.RemainingArgumentsPropertyName} = val is string[] arr ? arr : ((System.Collections.Generic.IReadOnlyList)val!).ToArray(),"); + } + else + { + sb.AppendLine($"{indent} RemainingArgumentsSetter = static (obj, val) => (({info.ClassName})obj).{sp.RemainingArgumentsPropertyName} = ({sp.RemainingArgumentsPropertyType})val!,"); + } + sb.AppendLine($"{indent} RemainingArgumentsType = typeof({sp.RemainingArgumentsPropertyType}),"); + } + + sb.AppendLine($"{indent} }};"); + } + sb.AppendLine(); + } + + private static void GenerateHelpOptionProperty(StringBuilder sb, CommandInfo info, string indent) + { + if (info.HelpOption == null) + { + sb.AppendLine($"{indent} public HelpOptionMetadata? HelpOption => null;"); + } + else + { + sb.AppendLine($"{indent} public HelpOptionMetadata? HelpOption => new HelpOptionMetadata"); + sb.AppendLine($"{indent} {{"); + if (info.HelpOption.Template != null) + sb.AppendLine($"{indent} Template = \"{EscapeString(info.HelpOption.Template)}\","); + if (info.HelpOption.Description != null) + sb.AppendLine($"{indent} Description = \"{EscapeString(info.HelpOption.Description)}\","); + if (info.HelpOption.Inherited.HasValue) + sb.AppendLine($"{indent} Inherited = {info.HelpOption.Inherited.Value.ToString().ToLowerInvariant()},"); + sb.AppendLine($"{indent} }};"); + } + sb.AppendLine(); + } + + private static void GenerateVersionOptionProperty(StringBuilder sb, CommandInfo info, string indent) + { + if (info.VersionOption == null) + { + sb.AppendLine($"{indent} public VersionOptionMetadata? VersionOption => null;"); + } + else + { + sb.AppendLine($"{indent} public VersionOptionMetadata? VersionOption => new VersionOptionMetadata"); + sb.AppendLine($"{indent} {{"); + if (info.VersionOption.Template != null) + sb.AppendLine($"{indent} Template = \"{EscapeString(info.VersionOption.Template)}\","); + if (info.VersionOption.Version != null) + sb.AppendLine($"{indent} Version = \"{EscapeString(info.VersionOption.Version)}\","); + if (info.VersionOption.Description != null) + sb.AppendLine($"{indent} Description = \"{EscapeString(info.VersionOption.Description)}\","); + if (info.VersionOption.MemberName != null) + sb.AppendLine($"{indent} VersionGetter = static (obj) => (({info.ClassName})obj).{info.VersionOption.MemberName},"); + sb.AppendLine($"{indent} }};"); + } + sb.AppendLine(); + } + + private static void GenerateModelFactory(StringBuilder sb, CommandInfo info, string indent) + { + sb.AppendLine($"{indent} private sealed class GeneratedModelFactory : IModelFactory<{info.ClassName}>"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} private readonly IServiceProvider? _services;"); + sb.AppendLine(); + sb.AppendLine($"{indent} public GeneratedModelFactory(IServiceProvider? services) => _services = services;"); + sb.AppendLine(); + sb.AppendLine($"{indent} public {info.ClassName} Create()"); + sb.AppendLine($"{indent} {{"); + + // First, try to get the model type directly from the service provider + sb.AppendLine($"{indent} if (_services != null)"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} var instance = _services.GetService(typeof({info.ClassName})) as {info.ClassName};"); + sb.AppendLine($"{indent} if (instance != null) return instance;"); + + // Generate code for each constructor with parameters (ordered by parameter count descending) + var constructorsWithParams = info.Constructors.Where(c => c.Parameters.Count > 0).ToList(); + if (constructorsWithParams.Count > 0) + { + sb.AppendLine(); + sb.AppendLine($"{indent} // Try to create using constructor injection"); + + for (int ctorIdx = 0; ctorIdx < constructorsWithParams.Count; ctorIdx++) + { + var ctor = constructorsWithParams[ctorIdx]; + + // Generate variable declarations for each parameter + for (int paramIdx = 0; paramIdx < ctor.Parameters.Count; paramIdx++) + { + var param = ctor.Parameters[paramIdx]; + sb.AppendLine($"{indent} var p{ctorIdx}_{paramIdx} = _services.GetService(typeof({param.TypeName})) as {param.TypeName};"); + } + + // Check if all parameters were resolved + var allParamsCheck = string.Join(" && ", Enumerable.Range(0, ctor.Parameters.Count).Select(i => $"p{ctorIdx}_{i} != null")); + sb.AppendLine($"{indent} if ({allParamsCheck})"); + sb.AppendLine($"{indent} {{"); + + // Create instance with resolved parameters + var paramList = string.Join(", ", Enumerable.Range(0, ctor.Parameters.Count).Select(i => $"p{ctorIdx}_{i}")); + sb.AppendLine($"{indent} return new {info.ClassName}({paramList});"); + sb.AppendLine($"{indent} }}"); + } + } + + sb.AppendLine($"{indent} }}"); + + // Check if there's a default constructor + var hasDefaultConstructor = info.Constructors.Any(c => c.Parameters.Count == 0); + if (hasDefaultConstructor) + { + sb.AppendLine($"{indent} return new {info.ClassName}();"); + } + else if (info.Constructors.Count > 0) + { + // No default constructor - we need to throw if we get here + sb.AppendLine($"{indent} throw new InvalidOperationException(\"Unable to create instance of {info.ClassName}. No services registered for constructor parameters.\");"); + } + else + { + // No public constructors at all - try anyway (will fail at compile time if not possible) + sb.AppendLine($"{indent} return new {info.ClassName}();"); + } + + sb.AppendLine($"{indent} }}"); + sb.AppendLine(); + sb.AppendLine($"{indent} object IModelFactory.Create() => Create();"); + sb.AppendLine($"{indent} }}"); + } + + private static string GenerateModuleInitializer(ImmutableArray commands) + { + var sb = new StringBuilder(); + + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using System.Runtime.CompilerServices;"); + sb.AppendLine("using McMaster.Extensions.CommandLineUtils.SourceGeneration;"); + sb.AppendLine(); + sb.AppendLine("namespace McMaster.Extensions.CommandLineUtils.Generated"); + sb.AppendLine("{"); + sb.AppendLine(" internal static class CommandMetadataRegistration"); + sb.AppendLine(" {"); + sb.AppendLine(" [ModuleInitializer]"); + sb.AppendLine(" internal static void RegisterAllProviders()"); + sb.AppendLine(" {"); + + foreach (var cmd in commands) + { + var providerType = string.IsNullOrEmpty(cmd.Namespace) + ? $"global::{cmd.ClassName}__GeneratedMetadataProvider" + : $"global::{cmd.Namespace}.{cmd.ClassName}__GeneratedMetadataProvider"; + + sb.AppendLine($" CommandMetadataRegistry.Register(typeof(global::{cmd.FullTypeName}), {providerType}.Instance);"); + } + + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static string EscapeString(string s) + { + return s + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t"); + } + + /// + /// Infers a command name from a type name by stripping the "Command" suffix + /// and converting to kebab-case. + /// + private static string InferCommandName(string typeName) + { + const string cmd = "Command"; + if (typeName.Length > cmd.Length && typeName.EndsWith(cmd)) + { + typeName = typeName.Substring(0, typeName.Length - cmd.Length); + } + + return ToKebabCase(typeName); + } + + /// + /// Converts a PascalCase or camelCase string to kebab-case. + /// + private static string ToKebabCase(string str) + { + if (string.IsNullOrEmpty(str)) + { + return str; + } + + var sb = new StringBuilder(); + var i = 0; + var addDash = false; + + for (; i < str.Length; i++) + { + var ch = str[i]; + if (!char.IsLetterOrDigit(ch)) + { + continue; + } + + addDash = !char.IsUpper(ch); + sb.Append(char.ToLowerInvariant(ch)); + i++; + break; + } + + for (; i < str.Length; i++) + { + var ch = str[i]; + if (char.IsUpper(ch)) + { + if (addDash) + { + addDash = false; + sb.Append('-'); + } + + sb.Append(char.ToLowerInvariant(ch)); + } + else if (char.IsLetterOrDigit(ch)) + { + addDash = true; + sb.Append(ch); + } + else + { + addDash = false; + sb.Append('-'); + } + } + + // trim trailing slashes + while (sb.Length > 0 && sb[sb.Length - 1] == '-') + { + sb.Remove(sb.Length - 1, 1); + } + + return sb.ToString(); + } + + // Marker attribute that can be used to opt-in to generation + private const string AttributeSource = @"// +#nullable enable + +namespace McMaster.Extensions.CommandLineUtils +{ + /// + /// Marker attribute to indicate that source generation should create + /// an ICommandMetadataProvider for this command. This attribute is optional + /// when the CommandLineUtils.Generators package is referenced - all [Command] + /// classes will automatically get generated providers. + /// + [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + internal sealed class GenerateMetadataAttribute : System.Attribute + { + } +} +"; + } +} diff --git a/src/CommandLineUtils.Generators/ConstructorData.cs b/src/CommandLineUtils.Generators/ConstructorData.cs new file mode 100644 index 00000000..a01cbd44 --- /dev/null +++ b/src/CommandLineUtils.Generators/ConstructorData.cs @@ -0,0 +1,34 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Data for a constructor parameter. + /// + internal sealed class ConstructorParameterData + { + /// + /// The fully qualified type name of the parameter. + /// + public string TypeName { get; set; } = ""; + + /// + /// The name of the parameter. + /// + public string Name { get; set; } = ""; + } + + /// + /// Data for a constructor. + /// + internal sealed class ConstructorData + { + /// + /// The parameters of the constructor. + /// + public List Parameters { get; } = new(); + } +} diff --git a/src/CommandLineUtils.Generators/HelpOptionData.cs b/src/CommandLineUtils.Generators/HelpOptionData.cs new file mode 100644 index 00000000..81437df8 --- /dev/null +++ b/src/CommandLineUtils.Generators/HelpOptionData.cs @@ -0,0 +1,16 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Data for [HelpOption] attribute. + /// + internal sealed class HelpOptionData + { + public string? PropertyName { get; set; } + public string? Template { get; set; } + public string? Description { get; set; } + public bool? Inherited { get; set; } + } +} diff --git a/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj b/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj new file mode 100644 index 00000000..2afadaf5 --- /dev/null +++ b/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0 + latest + enable + true + true + true + false + McMaster.Extensions.CommandLineUtils.Generators + false + + + + + + + + + + diff --git a/src/CommandLineUtils.Generators/OptionData.cs b/src/CommandLineUtils.Generators/OptionData.cs new file mode 100644 index 00000000..e43ed041 --- /dev/null +++ b/src/CommandLineUtils.Generators/OptionData.cs @@ -0,0 +1,37 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Data for an option property. + /// + internal sealed class OptionData + { + public string PropertyName { get; set; } = ""; + public string PropertyType { get; set; } = ""; + public string? Template { get; set; } + public string? ShortName { get; set; } + public string? LongName { get; set; } + public string? SymbolName { get; set; } + public string? ValueName { get; set; } + public string? Description { get; set; } + public bool? ShowInHelpText { get; set; } + public bool? Inherited { get; set; } + public int? OptionType { get; set; } + /// + /// True if OptionType was explicitly set in the attribute, false if inferred from property type. + /// + public bool OptionTypeExplicitlySet { get; set; } + /// + /// The inferred OptionType based on property type (used when OptionType is not explicitly set). + /// + public int InferredOptionType { get; set; } + /// + /// Validation attributes applied to this option. + /// + public List Validators { get; } = new List(); + } +} diff --git a/src/CommandLineUtils.Generators/SpecialPropertiesData.cs b/src/CommandLineUtils.Generators/SpecialPropertiesData.cs new file mode 100644 index 00000000..2a32e14d --- /dev/null +++ b/src/CommandLineUtils.Generators/SpecialPropertiesData.cs @@ -0,0 +1,49 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Data for special properties (Parent, Subcommand, RemainingArguments). + /// + internal sealed class SpecialPropertiesData + { + /// + /// The name of the Parent property, if one exists. + /// + public string? ParentPropertyName { get; set; } + + /// + /// The type of the Parent property. + /// + public string? ParentPropertyType { get; set; } + + /// + /// The name of the Subcommand property, if one exists. + /// + public string? SubcommandPropertyName { get; set; } + + /// + /// The type of the Subcommand property. + /// + public string? SubcommandPropertyType { get; set; } + + /// + /// The name of the RemainingArguments property, if one exists. + /// + public string? RemainingArgumentsPropertyName { get; set; } + + /// + /// The type of the RemainingArguments property. + /// + public string? RemainingArgumentsPropertyType { get; set; } + + /// + /// Whether any special properties exist. + /// + public bool HasAny => + ParentPropertyName != null || + SubcommandPropertyName != null || + RemainingArgumentsPropertyName != null; + } +} diff --git a/src/CommandLineUtils.Generators/SubcommandData.cs b/src/CommandLineUtils.Generators/SubcommandData.cs new file mode 100644 index 00000000..29dae050 --- /dev/null +++ b/src/CommandLineUtils.Generators/SubcommandData.cs @@ -0,0 +1,13 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Data for a subcommand. + /// + internal sealed class SubcommandData + { + public string TypeName { get; set; } = ""; + } +} diff --git a/src/CommandLineUtils.Generators/ValidationAttributeData.cs b/src/CommandLineUtils.Generators/ValidationAttributeData.cs new file mode 100644 index 00000000..68bbf518 --- /dev/null +++ b/src/CommandLineUtils.Generators/ValidationAttributeData.cs @@ -0,0 +1,29 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Data for a validation attribute applied to a property. + /// + internal sealed class ValidationAttributeData + { + /// + /// The fully qualified type name of the validation attribute. + /// + public string TypeName { get; set; } = ""; + + /// + /// Constructor arguments for the validation attribute, as code strings. + /// + public List ConstructorArguments { get; } = new List(); + + /// + /// Named property assignments for the validation attribute. + /// Key is property name, value is the code string for the value. + /// + public Dictionary NamedArguments { get; } = new Dictionary(); + } +} diff --git a/src/CommandLineUtils.Generators/VersionOptionData.cs b/src/CommandLineUtils.Generators/VersionOptionData.cs new file mode 100644 index 00000000..dc8fa225 --- /dev/null +++ b/src/CommandLineUtils.Generators/VersionOptionData.cs @@ -0,0 +1,20 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace McMaster.Extensions.CommandLineUtils.Generators +{ + /// + /// Data for [VersionOption] or [VersionOptionFromMember] attribute. + /// + internal sealed class VersionOptionData + { + public string? PropertyName { get; set; } + public string? Template { get; set; } + public string? Version { get; set; } + public string? Description { get; set; } + /// + /// For [VersionOptionFromMember]: the name of the method or property that returns the version. + /// + public string? MemberName { get; set; } + } +} diff --git a/src/CommandLineUtils.Generators/update_generator.ps1 b/src/CommandLineUtils.Generators/update_generator.ps1 new file mode 100644 index 00000000..66e50e39 --- /dev/null +++ b/src/CommandLineUtils.Generators/update_generator.ps1 @@ -0,0 +1,120 @@ +$content = Get-Content -Path CommandMetadataGenerator.cs -Raw + +# Add validation extraction call to ExtractOptionData +$content = $content -replace '(// Infer OptionType from property type if not explicitly set\s+data\.InferredOptionType = InferOptionType\(property\.Type\);)\s+(return data;\s+\})\s+(/// \s+/// Infers CommandOptionType)', @' +$1 + + // Extract validation attributes + ExtractValidationAttributes(property, data.Validators); + + $2 + + $3 +'@ + +# Add validation extraction call to ExtractArgumentData (after ShowInHelpText) +$content = $content -replace '(case "ShowInHelpText":\s+data\.ShowInHelpText = \(bool\?\)arg\.Value\.Value;\s+break;\s+\}\s+\})\s+(return data;\s+\})\s+(private static HelpOptionData)', @' +$1 + + // Extract validation attributes + ExtractValidationAttributes(property, data.Validators); + + $2 + + $3 +'@ + +# Add ExtractValidationAttributes method after ExtractVersionOptionData (before ExtractSubcommandMetadata) +$content = $content -replace '(return data;\s+\})\s+(private static void ExtractSubcommandMetadata\(AttributeData attr, CommandInfo info\))', @' +$1 + + /// + /// Extracts validation attributes (e.g., [Required], [Range], [StringLength]) from a property. + /// + private static void ExtractValidationAttributes(IPropertySymbol property, List validators) + { + foreach (var attr in property.GetAttributes()) + { + var attrClass = attr.AttributeClass; + if (attrClass == null) continue; + + // Check if attribute inherits from ValidationAttribute + if (!InheritsFromValidationAttribute(attrClass)) continue; + + var validatorData = new ValidationAttributeData + { + TypeName = attrClass.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + + // Extract constructor arguments + foreach (var ctorArg in attr.ConstructorArguments) + { + validatorData.ConstructorArguments.Add(FormatTypedConstant(ctorArg)); + } + + // Extract named arguments + foreach (var namedArg in attr.NamedArguments) + { + validatorData.NamedArguments[namedArg.Key] = FormatTypedConstant(namedArg.Value); + } + + validators.Add(validatorData); + } + } + + private static bool InheritsFromValidationAttribute(INamedTypeSymbol? typeSymbol) + { + while (typeSymbol != null) + { + if (typeSymbol.ToDisplayString() == "System.ComponentModel.DataAnnotations.ValidationAttribute") + { + return true; + } + typeSymbol = typeSymbol.BaseType; + } + return false; + } + + private static string FormatTypedConstant(TypedConstant constant) + { + if (constant.IsNull) + { + return "null"; + } + + switch (constant.Kind) + { + case TypedConstantKind.Primitive: + if (constant.Value is string s) + { + return $"\"{EscapeString(s)}\""; + } + if (constant.Value is bool b) + { + return b ? "true" : "false"; + } + if (constant.Value is char c) + { + return $"'{c}'"; + } + return constant.Value?.ToString() ?? "null"; + + case TypedConstantKind.Enum: + return $"({constant.Type!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}){constant.Value}"; + + case TypedConstantKind.Type: + return $"typeof({((INamedTypeSymbol)constant.Value!).ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)})"; + + case TypedConstantKind.Array: + var elements = constant.Values.Select(v => FormatTypedConstant(v)); + return $"new[] {{ {string.Join(", ", elements)} }}"; + + default: + return constant.Value?.ToString() ?? "null"; + } + } + + $2 +'@ + +Set-Content -Path CommandMetadataGenerator.cs -Value $content -NoNewline diff --git a/src/CommandLineUtils/Attributes/CommandAttribute.cs b/src/CommandLineUtils/Attributes/CommandAttribute.cs index be789853..42aa286c 100644 --- a/src/CommandLineUtils/Attributes/CommandAttribute.cs +++ b/src/CommandLineUtils/Attributes/CommandAttribute.cs @@ -161,6 +161,16 @@ public bool ClusterOptions private bool? _clusterOptions; + /// + /// Gets whether was explicitly set. + /// + internal bool ClusterOptionsWasSet => _clusterOptions.HasValue; + + /// + /// Gets whether was explicitly set. + /// + internal bool UnrecognizedArgumentHandlingWasSet => _unrecognizedArgumentHandling.HasValue; + internal void Configure(CommandLineApplication app) { // this might have been set from SubcommandAttribute diff --git a/src/CommandLineUtils/CommandLineApplication.cs b/src/CommandLineUtils/CommandLineApplication.cs index 7fc9a5e3..9a9e5bd7 100644 --- a/src/CommandLineUtils/CommandLineApplication.cs +++ b/src/CommandLineUtils/CommandLineApplication.cs @@ -106,10 +106,31 @@ internal CommandLineApplication(CommandLineApplication parent, string name) } } + /// + /// Constructor for subcommands with a known model type (used for AOT-compatible subcommand creation). + /// + internal CommandLineApplication(CommandLineApplication parent, string name, Type modelType) + : this(parent, parent._helpTextGenerator, parent._context, modelType) + { + if (name != null) + { + Name = name; + } + } + internal CommandLineApplication( CommandLineApplication? parent, IHelpTextGenerator helpTextGenerator, CommandLineContext context) + : this(parent, helpTextGenerator, context, null) + { + } + + internal CommandLineApplication( + CommandLineApplication? parent, + IHelpTextGenerator helpTextGenerator, + CommandLineContext context, + Type? modelType) { _context = context ?? throw new ArgumentNullException(nameof(context)); Parent = parent; @@ -125,7 +146,7 @@ internal CommandLineApplication( _clusterOptions = parent?._clusterOptions; UsePagerForHelpText = parent?.UsePagerForHelpText ?? false; - _conventionContext = CreateConventionContext(); + _conventionContext = CreateConventionContext(modelType); if (Parent != null) { @@ -457,6 +478,25 @@ public void AddSubcommand(CommandLineApplication subcommand) _subcommands.Add(subcommand); } + /// + /// Add a subcommand using a model factory (AOT-compatible). + /// + /// The name of the subcommand. + /// The type of the model. + /// Factory to create instances of the model. + /// The created subcommand. + internal CommandLineApplication AddSubcommand(string name, Type modelType, SourceGeneration.IModelFactory modelFactory) + { + AssertCommandNameIsUnique(name, null); + + var command = new CommandLineApplicationWithModel(this, name, modelType, modelFactory); + + command.Parent = this; + _subcommands.Add(command); + + return command; + } + private void AssertCommandNameIsUnique(string? name, CommandLineApplication? commandToIgnore) { if (string.IsNullOrEmpty(name)) @@ -473,7 +513,10 @@ private void AssertCommandNameIsUnique(string? name, CommandLineApplication? com if (cmd.MatchesName(name)) { - throw new InvalidOperationException(Strings.DuplicateSubcommandName(name)); + // Find which name of the existing command matches (case-insensitively) + // For the error message, use the existing command's matching name in its original case + var matchingName = cmd.Names.FirstOrDefault(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)) ?? name; + throw new InvalidOperationException(Strings.DuplicateSubcommandName(matchingName)); } } } @@ -1112,7 +1155,7 @@ IConventionBuilder IConventionBuilder.AddConvention(IConvention convention) /// public IConventionBuilder Conventions => _builder ??= new Builder(this); - private protected virtual ConventionContext CreateConventionContext() => new(this, null); + private protected virtual ConventionContext CreateConventionContext(Type? modelType) => new(this, modelType); private bool _settingContext; internal void SetContext(CommandLineContext context) diff --git a/src/CommandLineUtils/CommandLineApplicationWithModel.cs b/src/CommandLineUtils/CommandLineApplicationWithModel.cs new file mode 100644 index 00000000..68e515da --- /dev/null +++ b/src/CommandLineUtils/CommandLineApplicationWithModel.cs @@ -0,0 +1,41 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using McMaster.Extensions.CommandLineUtils.Abstractions; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; + +namespace McMaster.Extensions.CommandLineUtils +{ + /// + /// A command line application that uses a model factory instead of reflection. + /// This is used for AOT-compatible subcommand creation. + /// + internal sealed class CommandLineApplicationWithModel : CommandLineApplication, IModelAccessor + { + private readonly Type _modelType; + private readonly IModelFactory _modelFactory; + private readonly Lazy _lazyModel; + + internal CommandLineApplicationWithModel( + CommandLineApplication parent, + string name, + Type modelType, + IModelFactory modelFactory) + : base(parent, name, modelType) + { + _modelType = modelType; + _modelFactory = modelFactory; + _lazyModel = new Lazy(() => _modelFactory.Create()); + } + + /// + /// Gets the model instance. + /// + public object Model => _lazyModel.Value; + + Type IModelAccessor.GetModelType() => _modelType; + + object IModelAccessor.GetModel() => Model; + } +} diff --git a/src/CommandLineUtils/CommandLineApplication{T}.cs b/src/CommandLineUtils/CommandLineApplication{T}.cs index 6ea6ab53..73fd1e1f 100644 --- a/src/CommandLineUtils/CommandLineApplication{T}.cs +++ b/src/CommandLineUtils/CommandLineApplication{T}.cs @@ -115,7 +115,7 @@ public Func ModelFactory set => _modelFactory = value ?? throw new ArgumentNullException(nameof(value)); } - private protected override ConventionContext CreateConventionContext() => new(this, typeof(TModel)); + private protected override ConventionContext CreateConventionContext(Type? modelType) => new(this, typeof(TModel)); /// public override void Dispose() diff --git a/src/CommandLineUtils/Conventions/ArgumentAttributeConvention.cs b/src/CommandLineUtils/Conventions/ArgumentAttributeConvention.cs index 5b7949a9..4be7467e 100644 --- a/src/CommandLineUtils/Conventions/ArgumentAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/ArgumentAttributeConvention.cs @@ -4,11 +4,8 @@ using System; using System.Collections; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Reflection; using McMaster.Extensions.CommandLineUtils.Abstractions; -using McMaster.Extensions.CommandLineUtils.Validation; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; namespace McMaster.Extensions.CommandLineUtils.Conventions { @@ -21,35 +18,40 @@ public class ArgumentAttributeConvention : IConvention /// public virtual void Apply(ConventionContext context) { - if (context.ModelType == null) + // MetadataProvider is always available (generated or reflection-based via DefaultMetadataResolver) + var provider = context.MetadataProvider; + if (provider == null) { return; } - var props = ReflectionHelper.GetProperties(context.ModelType); - if (props == null) - { - return; - } + ApplyFromMetadata(context, provider); + } + private void ApplyFromMetadata(ConventionContext context, ICommandMetadataProvider provider) + { var argOrder = new SortedList(); - var argPropOrder = new Dictionary(); + var argMetaByOrder = new Dictionary(); - foreach (var prop in props) + foreach (var argMeta in provider.Arguments) { - var argumentAttr = prop.GetCustomAttribute(); - if (argumentAttr == null) - { - continue; - } - - if (prop.GetCustomAttributes().OfType().Any()) + // Check for duplicate argument positions + if (argMetaByOrder.TryGetValue(argMeta.Order, out var existingMeta)) { + // List the duplicate (current) property first, then the existing one throw new InvalidOperationException( - Strings.BothOptionAndArgumentAttributesCannotBeSpecified(prop)); + Strings.DuplicateArgumentPosition( + argMeta.Order, + argMeta.PropertyName, + argMeta.DeclaringType, + existingMeta.PropertyName, + existingMeta.DeclaringType)); } - AddArgument(prop, argumentAttr, context, argOrder, argPropOrder); + var argument = CreateArgumentFromMetadata(argMeta); + argOrder.Add(argMeta.Order, argument); + argMetaByOrder.Add(argMeta.Order, argMeta); + AddArgumentFromMetadata(context, argument, argMeta); } foreach (var arg in argOrder) @@ -68,118 +70,126 @@ public virtual void Apply(ConventionContext context) } } - private void AddArgument(PropertyInfo prop, - ArgumentAttribute argumentAttr, - ConventionContext convention, - SortedList argOrder, - Dictionary argPropOrder) + private static CommandArgument CreateArgumentFromMetadata(ArgumentMetadata meta) { - var argument = argumentAttr.Configure(prop); - - foreach (var attr in prop.GetCustomAttributes().OfType()) + var argument = new CommandArgument { - argument.Validators.Add(new AttributeValidator(attr)); - } + Name = meta.Name ?? meta.PropertyName, + Description = meta.Description ?? string.Empty + }; argument.MultipleValues = - prop.PropertyType.IsArray - || (typeof(IEnumerable).IsAssignableFrom(prop.PropertyType) - && prop.PropertyType != typeof(string)); + meta.PropertyType.IsArray + || (typeof(IEnumerable).IsAssignableFrom(meta.PropertyType) + && meta.PropertyType != typeof(string)); - if (argPropOrder.TryGetValue(argumentAttr.Order, out var otherProp)) + argument.ShowInHelpText = meta.ShowInHelpText; + + // Set underlying type for help text generator (enum allowed values display) + argument.UnderlyingType = meta.PropertyType; + + // Apply validation attributes from metadata + foreach (var validator in meta.Validators) { - throw new InvalidOperationException( - Strings.DuplicateArgumentPosition(argumentAttr.Order, prop, otherProp)); + argument.Validators.Add(new Validation.AttributeValidator(validator)); } - argPropOrder.Add(argumentAttr.Order, prop); - argOrder.Add(argumentAttr.Order, argument); + return argument; + } + + private void AddArgumentFromMetadata(ConventionContext context, CommandArgument argument, ArgumentMetadata meta) + { + var modelAccessor = context.ModelAccessor; + if (modelAccessor == null) + { + return; + } - var getter = ReflectionHelper.GetPropertyGetter(prop); - var setter = ReflectionHelper.GetPropertySetter(prop); + var getter = meta.Getter; + var setter = meta.Setter; if (argument.MultipleValues) { - convention.Application.OnParsingComplete(r => + context.Application.OnParsingComplete(r => { var collectionParser = CollectionParserProvider.Default.GetParser( - prop.PropertyType, - convention.Application.ValueParsers); + meta.PropertyType, + context.Application.ValueParsers); if (collectionParser == null) { - throw new InvalidOperationException(Strings.CannotDetermineParserType(prop)); + throw new InvalidOperationException( + $"Cannot determine parser type for property '{meta.PropertyName}'"); } if (argument.Values.Count == 0) { - return; - } - - if (r.SelectedCommand is IModelAccessor cmd) - { - if (argument.Values.Count == 0) + // Read the initial property value and use as default + if (!ReflectionHelper.IsSpecialValueTupleType(meta.PropertyType, out _)) { - if (!ReflectionHelper.IsSpecialValueTupleType(prop.PropertyType, out _)) + if (getter(modelAccessor.GetModel()) is IEnumerable values + && meta.PropertyType != typeof(string)) { - if (getter.Invoke(cmd.GetModel()) is IEnumerable values) + var valueList = new System.Collections.Generic.List(); + foreach (var value in values) { - foreach (var value in values) + if (value != null) { - argument.TryParse(value?.ToString()); + valueList.Add(value.ToString() ?? string.Empty); } - argument.DefaultValue = string.Join(", ", values.Select(x => x?.ToString())); + } + if (valueList.Count > 0) + { + argument.DefaultValue = string.Join(", ", valueList); } } } - else - { - setter.Invoke(cmd.GetModel(), collectionParser.Parse(argument.Name, argument.Values)); - } + return; + } + + if (r.SelectedCommand is IModelAccessor cmd) + { + setter(cmd.GetModel(), collectionParser.Parse(argument.Name, argument.Values)); } }); } else { - convention.Application.OnParsingComplete(r => + context.Application.OnParsingComplete(r => { - var parser = convention.Application.ValueParsers.GetParser(prop.PropertyType); + var parser = context.Application.ValueParsers.GetParser(meta.PropertyType); if (parser == null) { - throw new InvalidOperationException(Strings.CannotDetermineParserType(prop)); + throw new InvalidOperationException( + $"Cannot determine parser type for property '{meta.PropertyName}'"); } - if (r.SelectedCommand is IModelAccessor cmd) + if (argument.Values.Count == 0) { - var model = cmd.GetModel(); -#pragma warning disable CS8602 // Dereference of a possibly null reference. - if (prop.DeclaringType.IsAssignableFrom(model.GetType())) + // Read the initial property value and use as default + if (!ReflectionHelper.IsSpecialValueTupleType(meta.PropertyType, out _)) { - if (argument.Values.Count == 0) - { - if (!ReflectionHelper.IsSpecialValueTupleType(prop.PropertyType, out _)) - { - var value = getter.Invoke(model); - if (value != null) - { - argument.TryParse(value.ToString()); - argument.DefaultValue = value.ToString(); - } - } - } - else + var value = getter(modelAccessor.GetModel()); + if (value != null) { - setter.Invoke( - model, - parser.Parse( - argument.Name, - argument.Value, - convention.Application.ValueParsers.ParseCulture)); + argument.DefaultValue = value.ToString(); } } -#pragma warning restore CS8602 // Dereference of a possibly null reference. + return; + } + + if (r.SelectedCommand is IModelAccessor cmd) + { + var model = cmd.GetModel(); + setter( + model, + parser.Parse( + argument.Name, + argument.Value, + context.Application.ValueParsers.ParseCulture)); } }); } } + } } diff --git a/src/CommandLineUtils/Conventions/CommandAttributeConvention.cs b/src/CommandLineUtils/Conventions/CommandAttributeConvention.cs index e49764c6..68ccad72 100644 --- a/src/CommandLineUtils/Conventions/CommandAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/CommandAttributeConvention.cs @@ -22,8 +22,19 @@ public virtual void Apply(ConventionContext context) return; } - var attribute = context.ModelType.GetCustomAttribute(); - attribute?.Configure(context.Application); + // Use the metadata provider - it uses generated metadata if available, + // or falls back to reflection-based extraction. + var provider = context.MetadataProvider; + if (provider?.CommandInfo != null) + { + provider.CommandInfo.ApplyTo(context.Application); + } + else + { + // Fallback: direct attribute access (for backward compatibility if MetadataProvider is null) + var attribute = context.ModelType.GetCustomAttribute(); + attribute?.Configure(context.Application); + } foreach (var subcommand in context.Application.Commands) { @@ -33,6 +44,9 @@ public virtual void Apply(ConventionContext context) } } + // Note: ValidationAttribute processing still uses reflection as this is + // about adding validators, not extracting command metadata. + // The validation attributes are processed by the validation conventions. foreach (var attr in context.ModelType.GetCustomAttributes()) { context.Application.Validators.Add(new AttributeValidator(attr)); diff --git a/src/CommandLineUtils/Conventions/ConventionContext.cs b/src/CommandLineUtils/Conventions/ConventionContext.cs index 82473716..8d1a059d 100644 --- a/src/CommandLineUtils/Conventions/ConventionContext.cs +++ b/src/CommandLineUtils/Conventions/ConventionContext.cs @@ -3,6 +3,7 @@ using System; using McMaster.Extensions.CommandLineUtils.Abstractions; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; namespace McMaster.Extensions.CommandLineUtils.Conventions { @@ -11,6 +12,8 @@ namespace McMaster.Extensions.CommandLineUtils.Conventions /// public class ConventionContext { + private ICommandMetadataProvider? _metadataProvider; + /// /// Initializes an instance of . /// @@ -39,5 +42,39 @@ public ConventionContext(CommandLineApplication application, Type? modelType) /// . /// public IModelAccessor? ModelAccessor => Application as IModelAccessor; + + /// + /// Gets the metadata provider for the model type. + /// Returns null if is null. + /// This provider may contain source-generated metadata (AOT-friendly) + /// or fall back to reflection-based metadata extraction. + /// + public ICommandMetadataProvider? MetadataProvider + { + get + { + if (_metadataProvider == null && ModelType != null) + { + _metadataProvider = DefaultMetadataResolver.Instance.GetProvider(ModelType); + } + return _metadataProvider; + } + } + + /// + /// Gets a value indicating whether source-generated metadata is available for the model type. + /// When true, metadata was generated at compile time and can be used without reflection. + /// + public bool HasGeneratedMetadata + { + get + { + if (ModelType == null) + { + return false; + } + return DefaultMetadataResolver.Instance.HasGeneratedMetadata(ModelType); + } + } } } diff --git a/src/CommandLineUtils/Conventions/ExecuteMethodConvention.cs b/src/CommandLineUtils/Conventions/ExecuteMethodConvention.cs index b469ad41..d3254e14 100644 --- a/src/CommandLineUtils/Conventions/ExecuteMethodConvention.cs +++ b/src/CommandLineUtils/Conventions/ExecuteMethodConvention.cs @@ -2,8 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Reflection; -using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; @@ -19,108 +17,32 @@ public class ExecuteMethodConvention : IConvention /// public virtual void Apply(ConventionContext context) { - if (context.ModelType == null) - { - return; - } - - context.Application.OnExecuteAsync(async ct => await OnExecute(context, ct)); - } - - private async Task OnExecute(ConventionContext context, CancellationToken cancellationToken) - { - const BindingFlags binding = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; - - MethodInfo? method; - MethodInfo? asyncMethod; - try - { - method = context.ModelType?.GetMethod("OnExecute", binding); - asyncMethod = context.ModelType?.GetMethod("OnExecuteAsync", binding); - } - catch (AmbiguousMatchException ex) - { - throw new InvalidOperationException(Strings.AmbiguousOnExecuteMethod, ex); - } - - if (method != null && asyncMethod != null) - { - throw new InvalidOperationException(Strings.AmbiguousOnExecuteMethod); - } - - method ??= asyncMethod; - - if (method == null) - { - throw new InvalidOperationException(Strings.NoOnExecuteMethodFound); - } - - var arguments = ReflectionHelper.BindParameters(method, context.Application, cancellationToken); var modelAccessor = context.ModelAccessor; if (modelAccessor == null) { - throw new InvalidOperationException(Strings.ConventionRequiresModel); - } - var model = modelAccessor.GetModel(); - - if (method.ReturnType == typeof(Task) || method.ReturnType == typeof(Task)) - { - return await InvokeAsync(method, model, arguments); - } - else if (method.ReturnType == typeof(void) || method.ReturnType == typeof(int)) - { - return Invoke(method, model, arguments); + return; // No model, nothing to do } - throw new InvalidOperationException(Strings.InvalidOnExecuteReturnType(method.Name)); - } + // MetadataProvider is always available (generated or reflection-based via DefaultMetadataResolver) + var provider = context.MetadataProvider; + var handler = provider?.ExecuteHandler; - private async Task InvokeAsync(MethodInfo method, object instance, object?[] arguments) - { - try + if (handler == null) { -#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. - var result = (Task)method.Invoke(instance, arguments); -#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type. - if (result is Task intResult) - { - return await intResult; - } - -#pragma warning disable CS8602 // Dereference of a possibly null reference. - await result; -#pragma warning restore CS8602 // Dereference of a possibly null reference. - } - catch (TargetInvocationException e) - { -#pragma warning disable CS8604 // Possible null reference argument. - ExceptionDispatchInfo.Capture(e.InnerException).Throw(); -#pragma warning restore CS8604 // Possible null reference argument. + // If no OnExecute method exists, set a handler that throws when invoked. + // This allows commands with subcommands to work (the subcommand will be selected instead), + // but will throw if the main command is executed directly. + context.Application.OnExecuteAsync(_ => + throw new InvalidOperationException(Strings.NoOnExecuteMethodFound)); + return; } - return 0; - } - - private int Invoke(MethodInfo method, object instance, object?[] arguments) - { - try + // Use the execute handler from metadata (works for both generated and reflection providers) + context.Application.OnExecuteAsync(async ct => { - var result = method.Invoke(instance, arguments); - if (method.ReturnType == typeof(int)) - { -#pragma warning disable CS8605 // Unboxing a possibly null value. - return (int)result; -#pragma warning restore CS8605 // Unboxing a possibly null value. - } - } - catch (TargetInvocationException e) - { -#pragma warning disable CS8604 // Possible null reference argument. - ExceptionDispatchInfo.Capture(e.InnerException).Throw(); -#pragma warning restore CS8604 // Possible null reference argument. - } - - return 0; + var model = modelAccessor.GetModel(); + return await handler.InvokeAsync(model, context.Application, ct); + }); } } } diff --git a/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs b/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs index d349764e..5ac89455 100644 --- a/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs @@ -2,7 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Reflection; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; namespace McMaster.Extensions.CommandLineUtils.Conventions { @@ -15,47 +15,286 @@ public class OptionAttributeConvention : OptionAttributeConventionBase public virtual void Apply(ConventionContext context) { - if (context.ModelType == null) + // MetadataProvider is always available (generated or reflection-based via DefaultMetadataResolver) + var provider = context.MetadataProvider; + if (provider == null) { return; } - var props = ReflectionHelper.GetProperties(context.ModelType); - foreach (var prop in props) + // Track options added by this provider to detect same-class conflicts + var addedShortOptions = new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase); + var addedLongOptions = new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var optMeta in provider.Options) { - var attr = prop.GetCustomAttribute(); - if (attr == null) + // Calculate the option names first to check for duplicates BEFORE adding + var (template, shortName, longName) = GetOptionNames(optMeta); + + // Check for same-class conflicts (options in the same provider with conflicting names) + if (!string.IsNullOrEmpty(shortName) && addedShortOptions.TryGetValue(shortName, out var existingShort)) { - continue; + throw new InvalidOperationException( + Strings.OptionNameIsAmbiguous(shortName, optMeta.PropertyName, optMeta.DeclaringType, existingShort.PropertyName, existingShort.DeclaringType)); + } + if (!string.IsNullOrEmpty(longName) && addedLongOptions.TryGetValue(longName, out var existingLong)) + { + throw new InvalidOperationException( + Strings.OptionNameIsAmbiguous(longName, optMeta.PropertyName, optMeta.DeclaringType, existingLong.PropertyName, existingLong.DeclaringType)); } - EnsureDoesNotHaveHelpOptionAttribute(prop); - EnsureDoesNotHaveVersionOptionAttribute(prop); - EnsureDoesNotHaveArgumentAttribute(prop); + // Check if option already exists from parent command (inherited options) + if (!string.IsNullOrEmpty(shortName) && context.Application._shortOptions.ContainsKey(shortName)) + { + continue; // Skip - option already registered by parent + } + if (!string.IsNullOrEmpty(longName) && context.Application._longOptions.ContainsKey(longName)) + { + continue; // Skip - option already registered by parent + } - var option = attr.Configure(context.Application, prop); - AddOption(context, option, prop); + // Track this option + if (!string.IsNullOrEmpty(shortName)) + { + addedShortOptions[shortName] = optMeta; + } + if (!string.IsNullOrEmpty(longName)) + { + addedLongOptions[longName] = optMeta; + } + + var option = CreateOptionFromMetadata(context.Application, optMeta, template); + AddOptionFromMetadata(context, option, optMeta); } } - private static void EnsureDoesNotHaveVersionOptionAttribute(PropertyInfo prop) + private static (string template, string? shortName, string? longName) GetOptionNames(OptionMetadata meta) { - var versionOptionAttr = prop.GetCustomAttribute(); - if (versionOptionAttr != null) + var template = meta.Template; + string? shortName = meta.ShortName; + string? longName = meta.LongName; + + if (string.IsNullOrEmpty(template)) { - throw new InvalidOperationException( - Strings.BothHelpOptionAndVersionOptionAttributesCannotBeSpecified(prop)); + // Build template from ShortName/LongName + if (!string.IsNullOrEmpty(shortName) && !string.IsNullOrEmpty(longName)) + { + template = $"-{shortName}|--{longName}"; + } + else if (!string.IsNullOrEmpty(longName)) + { + template = $"--{longName}"; + } + else if (!string.IsNullOrEmpty(shortName)) + { + template = $"-{shortName}"; + } + else + { + // Use property name as default + longName = meta.PropertyName.ToLowerInvariant(); + template = $"--{longName}"; + } + } + else + { + // Parse short/long names from template if not already set + if (string.IsNullOrEmpty(shortName) || string.IsNullOrEmpty(longName)) + { + var parts = template.Split('|'); + foreach (var part in parts) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith("--") && string.IsNullOrEmpty(longName)) + { + longName = trimmed.Substring(2).Split(' ', '<', ':', '=')[0]; + } + else if (trimmed.StartsWith("-") && string.IsNullOrEmpty(shortName)) + { + shortName = trimmed.Substring(1).Split(' ', '<', ':', '=')[0]; + } + } + } } + + return (template, shortName, longName); } - private static void EnsureDoesNotHaveHelpOptionAttribute(PropertyInfo prop) + private static CommandOption CreateOptionFromMetadata(CommandLineApplication app, OptionMetadata meta, string template) { - var versionOptionAttr = prop.GetCustomAttribute(); - if (versionOptionAttr != null) + // Validate that the property type can be parsed, but only if OptionType was NOT explicitly set. + // When OptionType is explicitly set, the user knows what they're doing and may add a custom parser later. + if (!meta.OptionTypeExplicitlySet && meta.OptionType != CommandOptionType.NoValue) + { + if (!CommandOptionTypeMapper.Default.TryGetOptionType(meta.PropertyType, app.ValueParsers, out _) + && app.ValueParsers.GetParser(meta.PropertyType) == null) + { + throw new InvalidOperationException(Strings.CannotDetermineOptionType(meta.PropertyName, meta.PropertyType, meta.DeclaringType)); + } + } + + // Use the option type from metadata (already correctly set from attribute or inferred) + var option = app.Option(template, meta.Description ?? string.Empty, meta.OptionType); + + // Apply explicit ShortName/LongName from metadata (may be empty string to explicitly unset) + // null means use what was parsed from template, non-null overrides it + if (meta.ShortName != null) { - throw new InvalidOperationException( - Strings.BothHelpOptionAndVersionOptionAttributesCannotBeSpecified(prop)); + option.ShortName = meta.ShortName; + } + if (meta.LongName != null) + { + option.LongName = meta.LongName; + } + if (!string.IsNullOrEmpty(meta.ValueName)) + { + option.ValueName = meta.ValueName; + } + + option.ShowInHelpText = meta.ShowInHelpText; + option.Inherited = meta.Inherited; + + // Set underlying type for help text generator (enum allowed values display) + option.UnderlyingType = meta.PropertyType; + + return option; + } + + private void AddOptionFromMetadata(ConventionContext context, CommandOption option, OptionMetadata meta) + { + var modelAccessor = context.ModelAccessor; + if (modelAccessor == null) + { + throw new InvalidOperationException(Strings.ConventionRequiresModel); + } + + // Apply validation attributes from metadata + foreach (var validator in meta.Validators) + { + option.Validators.Add(new Validation.AttributeValidator(validator)); + } + + // Register names for duplicate checking + if (!string.IsNullOrEmpty(option.ShortName)) + { + context.Application._shortOptions.TryAdd(option.ShortName, null!); + } + + if (!string.IsNullOrEmpty(option.LongName)) + { + context.Application._longOptions.TryAdd(option.LongName, null!); + } + + // Use the generated getter/setter delegates + var getter = meta.Getter; + var setter = meta.Setter; + + switch (option.OptionType) + { + case CommandOptionType.MultipleValue: + context.Application.OnParsingComplete(r => + { + var collectionParser = CollectionParserProvider.Default.GetParser( + meta.PropertyType, context.Application.ValueParsers); + + if (collectionParser == null) + { + throw new InvalidOperationException( + $"Cannot determine parser type for property '{meta.PropertyName}'"); + } + + if (!option.HasValue()) + { + // Read the initial property value and use as default + if (!ReflectionHelper.IsSpecialValueTupleType(meta.PropertyType, out _)) + { + if (getter(modelAccessor.GetModel()) is System.Collections.IEnumerable values + && meta.PropertyType != typeof(string)) + { + var valueList = new System.Collections.Generic.List(); + foreach (var value in values) + { + if (value != null) + { + valueList.Add(value.ToString() ?? string.Empty); + option.TryParse(value.ToString()); + } + } + if (valueList.Count > 0) + { + option.DefaultValue = string.Join(", ", valueList); + } + } + } + } + else + { + setter(modelAccessor.GetModel(), collectionParser.Parse(option.LongName, option.Values)); + } + }); + break; + + case CommandOptionType.SingleOrNoValue: + case CommandOptionType.SingleValue: + context.Application.OnParsingComplete(r => + { + var parser = context.Application.ValueParsers.GetParser(meta.PropertyType); + if (parser == null) + { + throw new InvalidOperationException( + $"Cannot determine parser type for property '{meta.PropertyName}'"); + } + + if (!option.HasValue()) + { + // Read the initial property value and use as default + if (!ReflectionHelper.IsSpecialValueTupleType(meta.PropertyType, out _)) + { + var value = getter(modelAccessor.GetModel()); + if (value != null) + { + option.TryParse(value.ToString()); + option.DefaultValue = value.ToString(); + } + } + } + else + { + setter(modelAccessor.GetModel(), + parser.Parse(option.LongName, option.Value(), context.Application.ValueParsers.ParseCulture)); + } + }); + break; + + case CommandOptionType.NoValue: + context.Application.OnParsingComplete(r => + { + if (meta.PropertyType == typeof(bool[])) + { + if (!option.HasValue()) + { + setter(modelAccessor.GetModel(), Array.Empty()); + return; + } + + var count = new bool[option.Values.Count]; + for (var i = 0; i < count.Length; i++) + { + count[i] = true; + } + setter(modelAccessor.GetModel(), count); + } + else + { + if (option.HasValue()) + { + setter(modelAccessor.GetModel(), option.HasValue()); + } + } + }); + break; } } + } } diff --git a/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs b/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs index fe9446ab..d9c484a5 100644 --- a/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs +++ b/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs @@ -42,6 +42,11 @@ private protected void AddOption(ConventionContext context, CommandOption option { if (context.Application._shortOptions.TryGetValue(option.ShortName, out var otherProp)) { + // If it's the same property (inherited from base class), skip rather than throw + if (prop == otherProp || (prop.DeclaringType == otherProp.DeclaringType && prop.Name == otherProp.Name)) + { + return; // Already registered, skip + } throw new InvalidOperationException( Strings.OptionNameIsAmbiguous(option.ShortName, prop, otherProp)); } @@ -52,6 +57,11 @@ private protected void AddOption(ConventionContext context, CommandOption option { if (context.Application._longOptions.TryGetValue(option.LongName, out var otherProp)) { + // If it's the same property (inherited from base class), skip rather than throw + if (prop == otherProp || (prop.DeclaringType == otherProp.DeclaringType && prop.Name == otherProp.Name)) + { + return; // Already registered, skip + } throw new InvalidOperationException( Strings.OptionNameIsAmbiguous(option.LongName, prop, otherProp)); } diff --git a/src/CommandLineUtils/Conventions/ParentPropertyConvention.cs b/src/CommandLineUtils/Conventions/ParentPropertyConvention.cs index 2f24dbb8..e28f2dd6 100644 --- a/src/CommandLineUtils/Conventions/ParentPropertyConvention.cs +++ b/src/CommandLineUtils/Conventions/ParentPropertyConvention.cs @@ -1,6 +1,7 @@ // Copyright (c) Nate McMaster. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Reflection; using McMaster.Extensions.CommandLineUtils.Abstractions; @@ -21,13 +22,22 @@ public virtual void Apply(ConventionContext context) return; } - var parentProp = context.ModelType.GetProperty("Parent", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - if (parentProp == null) + // Try to get setter from generated metadata first (AOT-friendly) + var specialProperties = context.MetadataProvider?.SpecialProperties; + var setter = specialProperties?.ParentSetter; + + // Fall back to reflection if no generated metadata + if (setter == null) { - return; + var parentProp = context.ModelType.GetProperty("Parent", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (parentProp == null) + { + return; + } + var reflectionSetter = ReflectionHelper.GetPropertySetter(parentProp); + setter = (obj, val) => reflectionSetter(obj, val); } - var setter = ReflectionHelper.GetPropertySetter(parentProp); context.Application.OnParsingComplete(r => { var subcommand = r.SelectedCommand; diff --git a/src/CommandLineUtils/Conventions/RemainingArgsPropertyConvention.cs b/src/CommandLineUtils/Conventions/RemainingArgsPropertyConvention.cs index 94940ce7..e93f7e34 100644 --- a/src/CommandLineUtils/Conventions/RemainingArgsPropertyConvention.cs +++ b/src/CommandLineUtils/Conventions/RemainingArgsPropertyConvention.cs @@ -27,23 +27,34 @@ public virtual void Apply(ConventionContext context) return; } - var prop = context.ModelType.GetProperty("RemainingArguments", PropertyBindingFlags); - prop ??= context.ModelType.GetProperty("RemainingArgs", PropertyBindingFlags); - if (prop == null) + // Try to get setter from generated metadata first (AOT-friendly) + var specialProperties = context.MetadataProvider?.SpecialProperties; + var setter = specialProperties?.RemainingArgumentsSetter; + var propType = specialProperties?.RemainingArgumentsType; + + // Fall back to reflection if no generated metadata + if (setter == null) { - return; - } + var prop = context.ModelType.GetProperty("RemainingArguments", PropertyBindingFlags); + prop ??= context.ModelType.GetProperty("RemainingArgs", PropertyBindingFlags); + if (prop == null) + { + return; + } - var setter = ReflectionHelper.GetPropertySetter(prop); + var reflectionSetter = ReflectionHelper.GetPropertySetter(prop); + setter = (obj, val) => reflectionSetter(obj, val); + propType = prop.PropertyType; + } - if (prop.PropertyType == typeof(string[])) + if (propType == typeof(string[])) { context.Application.OnParsingComplete(r => setter(modelAccessor.GetModel(), r.SelectedCommand.RemainingArguments.ToArray())); return; } - if (!typeof(IReadOnlyList).IsAssignableFrom(prop.PropertyType)) + if (!typeof(IReadOnlyList).IsAssignableFrom(propType)) { throw new InvalidOperationException(Strings.RemainingArgsPropsIsUnassignable(context.ModelType)); } diff --git a/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs b/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs index 6997b2a0..089267a0 100644 --- a/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs @@ -2,10 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Linq; using System.Reflection; using McMaster.Extensions.CommandLineUtils.Abstractions; using McMaster.Extensions.CommandLineUtils.Errors; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; namespace McMaster.Extensions.CommandLineUtils.Conventions { @@ -18,33 +18,86 @@ public class SubcommandAttributeConvention : IConvention /// public virtual void Apply(ConventionContext context) { - var modelAccessor = context.ModelAccessor; - if (context.ModelType == null || modelAccessor == null) + // MetadataProvider is always available (generated or reflection-based via DefaultMetadataResolver) + var provider = context.MetadataProvider; + if (provider == null || context.ModelAccessor == null) { return; } - var attributes = context.ModelType.GetCustomAttributes(); + foreach (var subMeta in provider.Subcommands) + { + AssertSubcommandIsNotCycled(subMeta.SubcommandType, context.Application); + + // Get the subcommand's metadata provider (from factory or registry, with fallback to reflection) + var subProvider = subMeta.MetadataProviderFactory?.Invoke() + ?? DefaultMetadataResolver.Instance.GetProvider(subMeta.SubcommandType); + + // Get the subcommand name + var name = GetSubcommandName(subMeta.SubcommandType, subProvider); + + // AddSubcommandFromMetadata will call AddSubcommand which validates + // for duplicate names and throws if necessary + AddSubcommandFromMetadata(context, subMeta.SubcommandType, subProvider, name); + } + } - foreach (var attribute in attributes) + private static string GetSubcommandName(Type subcommandType, ICommandMetadataProvider provider) + { + var commandInfo = provider.CommandInfo; + if (!string.IsNullOrEmpty(commandInfo?.Name)) { - var contextArgs = new object[] { context }; - foreach (var type in attribute.Types) - { - AssertSubcommandIsNotCycled(type, context.Application); - - var impl = s_addSubcommandMethod.MakeGenericMethod(type); - try - { - impl.Invoke(this, contextArgs); - } - catch (TargetInvocationException ex) - { - // unwrap - throw ex.InnerException ?? ex; - } - } + // Use the explicit name as-is + return commandInfo.Name; + } + + // Infer name from type name using CommandNameFromTypeConvention logic + return CommandNameFromTypeConvention.GetCommandName(subcommandType.Name); + } + + private void AddSubcommandFromMetadata(ConventionContext context, Type subcommandType, ICommandMetadataProvider provider, string name) + { + var commandInfo = provider.CommandInfo; + + // Use reflection to create the proper generic CommandLineApplication type + // This maintains compatibility with code that expects CommandLineApplication + var genericType = typeof(CommandLineApplication<>).MakeGenericType(subcommandType); + + // Get the internal constructor: CommandLineApplication(CommandLineApplication parent, string name) + var constructor = genericType.GetConstructor( + BindingFlags.NonPublic | BindingFlags.Instance, + null, + new[] { typeof(CommandLineApplication), typeof(string) }, + null); + + if (constructor == null) + { + throw new InvalidOperationException( + $"Could not find internal constructor for CommandLineApplication<{subcommandType.Name}>"); + } + + CommandLineApplication subApp; + try + { + subApp = (CommandLineApplication)constructor.Invoke(new object[] { context.Application, name }); + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + // Unwrap TargetInvocationException to throw the actual exception + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; // Unreachable, but required for compiler } + + // Register the subcommand with the parent + context.Application.AddSubcommand(subApp); + + // Apply command metadata using the ApplyTo method which handles all properties + commandInfo?.ApplyTo(subApp); + + // Note: Do NOT call UseDefaultConventions() here! + // Conventions are automatically inherited from the parent in the subcommand constructor. + // Calling UseDefaultConventions() would cause conventions to be applied twice, + // resulting in duplicate options, arguments, and subcommands. } private void AssertSubcommandIsNotCycled(Type modelType, CommandLineApplication? parentCommand) @@ -59,15 +112,5 @@ private void AssertSubcommandIsNotCycled(Type modelType, CommandLineApplication? parentCommand = parentCommand.Parent; } } - - private static readonly MethodInfo s_addSubcommandMethod - = typeof(SubcommandAttributeConvention).GetRuntimeMethods() - .Single(m => m.Name == nameof(AddSubcommandImpl)); - - private void AddSubcommandImpl(ConventionContext context) - where TSubCommand : class - { - context.Application.Command(null!, null!); // Hmm, should probably rethink this... - } } } diff --git a/src/CommandLineUtils/Conventions/SubcommandPropertyConvention.cs b/src/CommandLineUtils/Conventions/SubcommandPropertyConvention.cs index 2ab2182c..5e7c67d0 100644 --- a/src/CommandLineUtils/Conventions/SubcommandPropertyConvention.cs +++ b/src/CommandLineUtils/Conventions/SubcommandPropertyConvention.cs @@ -1,6 +1,7 @@ // Copyright (c) Nate McMaster. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Reflection; using McMaster.Extensions.CommandLineUtils.Abstractions; @@ -21,13 +22,22 @@ public virtual void Apply(ConventionContext context) return; } - var subcommandProp = context.ModelType.GetProperty("Subcommand", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - if (subcommandProp == null) + // Try to get setter from generated metadata first (AOT-friendly) + var specialProperties = context.MetadataProvider?.SpecialProperties; + var setter = specialProperties?.SubcommandSetter; + + // Fall back to reflection if no generated metadata + if (setter == null) { - return; + var subcommandProp = context.ModelType.GetProperty("Subcommand", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (subcommandProp == null) + { + return; + } + var reflectionSetter = ReflectionHelper.GetPropertySetter(subcommandProp); + setter = (obj, val) => reflectionSetter(obj, val); } - var setter = ReflectionHelper.GetPropertySetter(subcommandProp); context.Application.OnParsingComplete(r => { var subCommand = r.SelectedCommand; diff --git a/src/CommandLineUtils/Conventions/ValidateMethodConvention.cs b/src/CommandLineUtils/Conventions/ValidateMethodConvention.cs index bddc1856..377627a0 100644 --- a/src/CommandLineUtils/Conventions/ValidateMethodConvention.cs +++ b/src/CommandLineUtils/Conventions/ValidateMethodConvention.cs @@ -1,10 +1,7 @@ -// Copyright (c) Nate McMaster. +// Copyright (c) Nate McMaster. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using System.ComponentModel.DataAnnotations; -using System.Reflection; -using McMaster.Extensions.CommandLineUtils.Abstractions; using McMaster.Extensions.CommandLineUtils.Conventions; namespace McMaster.Extensions.CommandLineUtils @@ -18,52 +15,22 @@ public class ValidateMethodConvention : IConvention public void Apply(ConventionContext context) { var modelAccessor = context.ModelAccessor; - if (context.ModelType == null || modelAccessor == null) + if (modelAccessor == null) { return; } - const BindingFlags MethodFlags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; - - var method = context.ModelType.GetMethod("OnValidate", MethodFlags); - if (method == null) + // MetadataProvider is always available (generated or reflection-based via DefaultMetadataResolver) + var provider = context.MetadataProvider; + if (provider?.ValidateHandler == null) { return; } - if (method.ReturnType != typeof(ValidationResult)) - { - throw new InvalidOperationException(Strings.InvalidOnValidateReturnType(context.ModelType)); - } - - var methodParams = method.GetParameters(); context.Application.OnValidate(ctx => { - var arguments = new object[methodParams.Length]; - - for (var i = 0; i < methodParams.Length; i++) - { - var methodParam = methodParams[i]; - - if (typeof(ValidationContext).IsAssignableFrom(methodParam.ParameterType)) - { - arguments[i] = ctx; - } - else if (typeof(CommandLineContext).IsAssignableFrom(methodParam.ParameterType)) - { - arguments[i] = context.Application._context; - } - else - { - throw new InvalidOperationException(Strings.UnsupportedParameterTypeOnMethod(method.Name, methodParam)); - } - } - -#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. -#pragma warning disable CS8603 // Possible null reference return. - return (ValidationResult)method.Invoke(modelAccessor.GetModel(), arguments); -#pragma warning restore CS8603 // Possible null reference return. -#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type. + return provider.ValidateHandler.Invoke(modelAccessor.GetModel(), ctx, context.Application._context) + ?? ValidationResult.Success!; }); } } diff --git a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj index 2a487a49..ab9ee63c 100644 --- a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj +++ b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -30,4 +30,27 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper + + + + + + + + + + + + + + + diff --git a/src/CommandLineUtils/Properties/Strings.cs b/src/CommandLineUtils/Properties/Strings.cs index 3d58d6ac..34fd28d9 100644 --- a/src/CommandLineUtils/Properties/Strings.cs +++ b/src/CommandLineUtils/Properties/Strings.cs @@ -42,9 +42,16 @@ public static string CannotDetermineOptionType(PropertyInfo member) => $"Could not automatically determine the {nameof(CommandOptionType)} for type {member.PropertyType.FullName}. " + $"Set the {nameof(OptionAttribute.OptionType)} on the {nameof(OptionAttribute)} declaration for {member.DeclaringType?.FullName}.{member.Name}."; + public static string CannotDetermineOptionType(string propertyName, Type propertyType, Type? declaringType) + => $"Could not automatically determine the {nameof(CommandOptionType)} for type {propertyType.FullName}. " + + $"Set the {nameof(OptionAttribute.OptionType)} on the {nameof(OptionAttribute)} declaration for {declaringType?.FullName}.{propertyName}."; + public static string OptionNameIsAmbiguous(string optionName, PropertyInfo first, PropertyInfo second) => $"Ambiguous option name. Both {first.DeclaringType?.FullName}.{first.Name} and {second.DeclaringType?.FullName}.{second.Name} produce a CommandOption with the name '{optionName}'."; + public static string OptionNameIsAmbiguous(string optionName, string firstPropertyName, Type? firstDeclaringType, string secondPropertyName, Type? secondDeclaringType) + => $"Ambiguous option name. Both {firstDeclaringType?.FullName}.{firstPropertyName} and {secondDeclaringType?.FullName}.{secondPropertyName} produce a CommandOption with the name '{optionName}'."; + public static string DuplicateSubcommandName(string commandName) => $"The subcommand name '{commandName}' has already been been specified. Subcommand names must be unique."; @@ -66,6 +73,9 @@ public static string BothHelpOptionAndVersionOptionAttributesCannotBeSpecified(P public static string DuplicateArgumentPosition(int order, PropertyInfo first, PropertyInfo second) => $"Duplicate value for argument order. Both {first.DeclaringType?.FullName}.{first.Name} and {second.DeclaringType?.FullName}.{second.Name} have set Order = {order}."; + public static string DuplicateArgumentPosition(int order, string firstPropertyName, Type? firstDeclaringType, string secondPropertyName, Type? secondDeclaringType) + => $"Duplicate value for argument order. Both {firstDeclaringType?.FullName}.{firstPropertyName} and {secondDeclaringType?.FullName}.{secondPropertyName} have set Order = {order}."; + public static string OnlyLastArgumentCanAllowMultipleValues(string? lastArgName) => $"The last argument '{lastArgName}' accepts multiple values. No more argument can be added."; diff --git a/src/CommandLineUtils/PublicAPI.Unshipped.txt b/src/CommandLineUtils/PublicAPI.Unshipped.txt index 7dc5c581..544de87f 100644 --- a/src/CommandLineUtils/PublicAPI.Unshipped.txt +++ b/src/CommandLineUtils/PublicAPI.Unshipped.txt @@ -1 +1,166 @@ #nullable enable +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.ArgumentMetadata(string! propertyName, System.Type! propertyType, int order, System.Func! getter, System.Action! setter) -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.Description.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.Description.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.DeclaringType.get -> System.Type? +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.DeclaringType.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.Getter.get -> System.Func! +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.MultipleValues.get -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.MultipleValues.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.Name.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.Name.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.Order.get -> int +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.PropertyName.get -> string! +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.PropertyType.get -> System.Type! +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.Setter.get -> System.Action! +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.ShowInHelpText.get -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.ShowInHelpText.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.Validators.get -> System.Collections.Generic.IReadOnlyList! +McMaster.Extensions.CommandLineUtils.SourceGeneration.ArgumentMetadata.Validators.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.AdditionalNames.get -> string![]? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.AdditionalNames.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.AllowArgumentSeparator.get -> bool? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.AllowArgumentSeparator.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ApplyTo(McMaster.Extensions.CommandLineUtils.CommandLineApplication! app) -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ClusterOptions.get -> bool? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ClusterOptions.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.CommandMetadata() -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.Description.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.Description.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ExtendedHelpText.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ExtendedHelpText.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.FullName.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.FullName.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.Name.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.Name.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.OptionsComparison.get -> System.StringComparison? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.OptionsComparison.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ParseCulture.get -> System.Globalization.CultureInfo? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ParseCulture.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ResponseFileHandling.get -> McMaster.Extensions.CommandLineUtils.ResponseFileHandling? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ResponseFileHandling.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ShowInHelpText.get -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.ShowInHelpText.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.UnrecognizedArgumentHandling.get -> McMaster.Extensions.CommandLineUtils.UnrecognizedArgumentHandling? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.UnrecognizedArgumentHandling.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.UsePagerForHelpText.get -> bool? +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata.UsePagerForHelpText.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadataRegistry +static McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadataRegistry.HasMetadata(System.Type! modelType) -> bool +static McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadataRegistry.HasMetadata() -> bool +static McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadataRegistry.Register(System.Type! modelType, McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider! provider) -> void +static McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadataRegistry.Register(McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider! provider) -> void +static McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadataRegistry.TryGetProvider(System.Type! modelType, out McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider? provider) -> bool +static McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadataRegistry.TryGetProvider(out McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider? provider) -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.DefaultMetadataResolver +McMaster.Extensions.CommandLineUtils.SourceGeneration.DefaultMetadataResolver.GetProvider(System.Type! modelType) -> McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider! +McMaster.Extensions.CommandLineUtils.SourceGeneration.DefaultMetadataResolver.GetProvider() -> McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider! +McMaster.Extensions.CommandLineUtils.SourceGeneration.DefaultMetadataResolver.HasGeneratedMetadata(System.Type! modelType) -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.HelpOptionMetadata +McMaster.Extensions.CommandLineUtils.SourceGeneration.HelpOptionMetadata.Description.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.HelpOptionMetadata.Description.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.HelpOptionMetadata.HelpOptionMetadata() -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.HelpOptionMetadata.Inherited.get -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.HelpOptionMetadata.Inherited.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.HelpOptionMetadata.Template.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.HelpOptionMetadata.Template.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.Arguments.get -> System.Collections.Generic.IReadOnlyList! +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.CommandInfo.get -> McMaster.Extensions.CommandLineUtils.SourceGeneration.CommandMetadata? +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.ExecuteHandler.get -> McMaster.Extensions.CommandLineUtils.SourceGeneration.IExecuteHandler? +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.GetModelFactory(System.IServiceProvider? services) -> McMaster.Extensions.CommandLineUtils.SourceGeneration.IModelFactory! +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.HelpOption.get -> McMaster.Extensions.CommandLineUtils.SourceGeneration.HelpOptionMetadata? +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.ModelType.get -> System.Type! +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.Options.get -> System.Collections.Generic.IReadOnlyList! +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.SpecialProperties.get -> McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata? +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.Subcommands.get -> System.Collections.Generic.IReadOnlyList! +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.ValidateHandler.get -> McMaster.Extensions.CommandLineUtils.SourceGeneration.IValidateHandler? +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.ValidationErrorHandler.get -> McMaster.Extensions.CommandLineUtils.SourceGeneration.IValidationErrorHandler? +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.VersionOption.get -> McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata? +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider +McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider.GetModelFactory(System.IServiceProvider? services) -> McMaster.Extensions.CommandLineUtils.SourceGeneration.IModelFactory! +McMaster.Extensions.CommandLineUtils.SourceGeneration.IExecuteHandler +McMaster.Extensions.CommandLineUtils.SourceGeneration.IExecuteHandler.InvokeAsync(object! model, McMaster.Extensions.CommandLineUtils.CommandLineApplication! app, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +McMaster.Extensions.CommandLineUtils.SourceGeneration.IExecuteHandler.IsAsync.get -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.IExecuteHandler +McMaster.Extensions.CommandLineUtils.SourceGeneration.IExecuteHandler.InvokeAsync(TModel! model, McMaster.Extensions.CommandLineUtils.CommandLineApplication! app, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +McMaster.Extensions.CommandLineUtils.SourceGeneration.IMetadataResolver +McMaster.Extensions.CommandLineUtils.SourceGeneration.IMetadataResolver.GetProvider(System.Type! modelType) -> McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider! +McMaster.Extensions.CommandLineUtils.SourceGeneration.IMetadataResolver.GetProvider() -> McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider! +McMaster.Extensions.CommandLineUtils.SourceGeneration.IMetadataResolver.HasGeneratedMetadata(System.Type! modelType) -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.IModelFactory +McMaster.Extensions.CommandLineUtils.SourceGeneration.IModelFactory.Create() -> object! +McMaster.Extensions.CommandLineUtils.SourceGeneration.IModelFactory +McMaster.Extensions.CommandLineUtils.SourceGeneration.IModelFactory.Create() -> TModel! +McMaster.Extensions.CommandLineUtils.SourceGeneration.IValidateHandler +McMaster.Extensions.CommandLineUtils.SourceGeneration.IValidateHandler.Invoke(object! model, System.ComponentModel.DataAnnotations.ValidationContext! validationContext, McMaster.Extensions.CommandLineUtils.Abstractions.CommandLineContext! commandContext) -> System.ComponentModel.DataAnnotations.ValidationResult? +McMaster.Extensions.CommandLineUtils.SourceGeneration.IValidateHandler +McMaster.Extensions.CommandLineUtils.SourceGeneration.IValidateHandler.Invoke(TModel! model, System.ComponentModel.DataAnnotations.ValidationContext! validationContext, McMaster.Extensions.CommandLineUtils.Abstractions.CommandLineContext! commandContext) -> System.ComponentModel.DataAnnotations.ValidationResult? +McMaster.Extensions.CommandLineUtils.SourceGeneration.IValidationErrorHandler +McMaster.Extensions.CommandLineUtils.SourceGeneration.IValidationErrorHandler.Invoke(object! model, System.ComponentModel.DataAnnotations.ValidationResult! validationResult) -> int +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.Description.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.Description.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.DeclaringType.get -> System.Type? +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.DeclaringType.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.Getter.get -> System.Func! +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.Inherited.get -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.Inherited.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.LongName.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.LongName.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.OptionMetadata(string! propertyName, System.Type! propertyType, System.Func! getter, System.Action! setter) -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.OptionType.get -> McMaster.Extensions.CommandLineUtils.CommandOptionType +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.OptionType.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.OptionTypeExplicitlySet.get -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.OptionTypeExplicitlySet.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.PropertyName.get -> string! +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.PropertyType.get -> System.Type! +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.Setter.get -> System.Action! +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.ShortName.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.ShortName.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.ShowInHelpText.get -> bool +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.ShowInHelpText.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.SymbolName.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.SymbolName.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.Template.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.Template.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.Validators.get -> System.Collections.Generic.IReadOnlyList! +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.Validators.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.ValueName.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.OptionMetadata.ValueName.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.ParentSetter.get -> System.Action? +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.ParentSetter.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.ParentType.get -> System.Type? +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.ParentType.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.RemainingArgumentsSetter.get -> System.Action? +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.RemainingArgumentsSetter.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.RemainingArgumentsType.get -> System.Type? +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.RemainingArgumentsType.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.SpecialPropertiesMetadata() -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.SubcommandSetter.get -> System.Action? +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.SubcommandSetter.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.SubcommandType.get -> System.Type? +McMaster.Extensions.CommandLineUtils.SourceGeneration.SpecialPropertiesMetadata.SubcommandType.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SubcommandMetadata +McMaster.Extensions.CommandLineUtils.SourceGeneration.SubcommandMetadata.CommandName.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.SubcommandMetadata.CommandName.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SubcommandMetadata.MetadataProviderFactory.get -> System.Func? +McMaster.Extensions.CommandLineUtils.SourceGeneration.SubcommandMetadata.MetadataProviderFactory.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SubcommandMetadata.SubcommandMetadata(System.Type! subcommandType) -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.SubcommandMetadata.SubcommandType.get -> System.Type! +McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata +McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata.Description.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata.Description.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata.Template.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata.Template.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata.Version.get -> string? +McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata.Version.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata.VersionGetter.get -> System.Func? +McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata.VersionGetter.init -> void +McMaster.Extensions.CommandLineUtils.SourceGeneration.VersionOptionMetadata.VersionOptionMetadata() -> void +static readonly McMaster.Extensions.CommandLineUtils.SourceGeneration.DefaultMetadataResolver.Instance -> McMaster.Extensions.CommandLineUtils.SourceGeneration.DefaultMetadataResolver! +McMaster.Extensions.CommandLineUtils.Conventions.ConventionContext.HasGeneratedMetadata.get -> bool +McMaster.Extensions.CommandLineUtils.Conventions.ConventionContext.MetadataProvider.get -> McMaster.Extensions.CommandLineUtils.SourceGeneration.ICommandMetadataProvider? diff --git a/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs b/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs new file mode 100644 index 00000000..2ec3c8d9 --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs @@ -0,0 +1,93 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Model factory that uses Activator.CreateInstance or DI with constructor injection. + /// + [RequiresUnreferencedCode("Uses Activator.CreateInstance or DI with constructor injection")] + internal sealed class ActivatorModelFactory : IModelFactory + { + private readonly Type _modelType; + private readonly IServiceProvider? _services; + + public ActivatorModelFactory(Type modelType, IServiceProvider? services = null) + { + _modelType = modelType; + _services = services; + } + + public object Create() + { + // Try DI first if services are available + if (_services != null) + { + // Try to get the model directly from services + var instance = _services.GetService(_modelType); + if (instance != null) + { + return instance; + } + + // Try constructor injection + instance = TryCreateWithConstructorInjection(); + if (instance != null) + { + return instance; + } + } + + // Fall back to Activator (parameterless constructor) + return Activator.CreateInstance(_modelType) + ?? throw new InvalidOperationException($"Failed to create instance of {_modelType.FullName}"); + } + + private object? TryCreateWithConstructorInjection() + { + // Get all public constructors, ordered by parameter count (descending) + var constructors = _modelType.GetConstructors(BindingFlags.Instance | BindingFlags.Public) + .OrderByDescending(c => c.GetParameters().Length) + .ToList(); + + foreach (var constructor in constructors) + { + var parameters = constructor.GetParameters(); + if (parameters.Length == 0) + { + // Skip parameterless constructor, handled by Activator.CreateInstance + continue; + } + + var args = new object?[parameters.Length]; + var allResolved = true; + + for (int i = 0; i < parameters.Length; i++) + { + var paramType = parameters[i].ParameterType; + var service = _services!.GetService(paramType); + + if (service == null && !parameters[i].HasDefaultValue) + { + allResolved = false; + break; + } + + args[i] = service ?? parameters[i].DefaultValue; + } + + if (allResolved) + { + return constructor.Invoke(args); + } + } + + return null; + } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/CommandMetadataRegistry.cs b/src/CommandLineUtils/SourceGeneration/CommandMetadataRegistry.cs new file mode 100644 index 00000000..2dff84c4 --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/CommandMetadataRegistry.cs @@ -0,0 +1,100 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Registry for source-generated command metadata. + /// Source generators register metadata providers here via module initializers. + /// + public static class CommandMetadataRegistry + { + private static readonly ConcurrentDictionary s_providers = new(); + + /// + /// Registers a metadata provider for a type. + /// Called by generated code in module initializers. + /// + /// The model type. + /// The metadata provider. + public static void Register(ICommandMetadataProvider provider) + where TModel : class + { + s_providers[typeof(TModel)] = provider; + } + + /// + /// Registers a metadata provider for a type. + /// + /// The model type. + /// The metadata provider. + public static void Register(Type modelType, ICommandMetadataProvider provider) + { + s_providers[modelType] = provider; + } + + /// + /// Tries to get the metadata provider for a type. + /// + /// The model type. + /// The metadata provider, if found. + /// True if a provider was found; otherwise, false. + public static bool TryGetProvider(Type modelType, [NotNullWhen(true)] out ICommandMetadataProvider? provider) + { + return s_providers.TryGetValue(modelType, out provider); + } + + /// + /// Tries to get the metadata provider for a type. + /// + /// The model type. + /// The metadata provider, if found. + /// True if a provider was found; otherwise, false. + public static bool TryGetProvider([NotNullWhen(true)] out ICommandMetadataProvider? provider) + where TModel : class + { + if (s_providers.TryGetValue(typeof(TModel), out var untypedProvider) && + untypedProvider is ICommandMetadataProvider typedProvider) + { + provider = typedProvider; + return true; + } + + provider = null; + return false; + } + + /// + /// Checks if metadata is available for a type. + /// + /// The model type. + /// True if generated metadata is available; otherwise, false. + public static bool HasMetadata(Type modelType) + { + return s_providers.ContainsKey(modelType); + } + + /// + /// Checks if metadata is available for a type. + /// + /// The model type. + /// True if generated metadata is available; otherwise, false. + public static bool HasMetadata() + where TModel : class + { + return s_providers.ContainsKey(typeof(TModel)); + } + + /// + /// Clears all registered providers. Primarily for testing. + /// + internal static void Clear() + { + s_providers.Clear(); + } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs b/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs new file mode 100644 index 00000000..302a4a4d --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs @@ -0,0 +1,97 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Default implementation that checks the registry first, falls back to reflection. + /// + public sealed class DefaultMetadataResolver : IMetadataResolver + { + /// + /// Gets the singleton instance of the default metadata resolver. + /// + public static readonly DefaultMetadataResolver Instance = new(); + + private readonly ConcurrentDictionary _reflectionProviders = new(); + + private DefaultMetadataResolver() + { + } + + /// + /// + /// This method first checks for source-generated metadata (AOT-friendly). + /// If no generated metadata exists, it falls back to reflection-based metadata extraction. + /// For full AOT compatibility, ensure the CommandLineUtils.Generators package is referenced + /// and the source generator runs during compilation. + /// + [RequiresUnreferencedCode("Falls back to reflection when no generated metadata is available. Use the source generator for AOT compatibility.")] + public ICommandMetadataProvider GetProvider(Type modelType) + { + // Check for generated metadata first (AOT-safe path) + if (CommandMetadataRegistry.TryGetProvider(modelType, out var provider)) + { + return provider; + } + + // Fall back to reflection-based provider (requires reflection) + return _reflectionProviders.GetOrAdd(modelType, CreateReflectionProvider); + } + + /// + /// + /// This method first checks for source-generated metadata (AOT-friendly). + /// If no generated metadata exists, it falls back to reflection-based metadata extraction. + /// For full AOT compatibility, ensure the CommandLineUtils.Generators package is referenced + /// and the source generator runs during compilation. + /// + [RequiresUnreferencedCode("Falls back to reflection when no generated metadata is available. Use the source generator for AOT compatibility.")] + public ICommandMetadataProvider GetProvider() where TModel : class + { + // Check for generated metadata first (AOT-safe path) + if (CommandMetadataRegistry.TryGetProvider(out var provider)) + { + return provider; + } + + // Fall back to reflection-based provider (requires reflection) + var untypedProvider = _reflectionProviders.GetOrAdd(typeof(TModel), CreateReflectionProvider); + + // The reflection provider should implement the generic interface + if (untypedProvider is ICommandMetadataProvider typedProvider) + { + return typedProvider; + } + + // Wrap it if needed + return new TypedMetadataProviderWrapper(untypedProvider); + } + + /// + public bool HasGeneratedMetadata(Type modelType) + { + return CommandMetadataRegistry.HasMetadata(modelType); + } + + [RequiresUnreferencedCode("Uses reflection to analyze the model type")] + private static ICommandMetadataProvider CreateReflectionProvider(Type modelType) + { + // This creates a reflection-based implementation of ICommandMetadataProvider + // Will be implemented in Phase 2 + return new ReflectionMetadataProvider(modelType); + } + + /// + /// Clears all cached reflection providers. Primarily for testing. + /// + internal void ClearCache() + { + _reflectionProviders.Clear(); + } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/ErrorExecuteHandler.cs b/src/CommandLineUtils/SourceGeneration/ErrorExecuteHandler.cs new file mode 100644 index 00000000..3366b5ae --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/ErrorExecuteHandler.cs @@ -0,0 +1,30 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Execute handler that throws an error when invoked. + /// Used for error cases like ambiguous methods. + /// + internal sealed class ErrorExecuteHandler : IExecuteHandler + { + private readonly string _errorMessage; + + public ErrorExecuteHandler(string errorMessage) + { + _errorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage)); + } + + public bool IsAsync => false; + + public Task InvokeAsync(object model, CommandLineApplication app, CancellationToken cancellationToken) + { + throw new InvalidOperationException(_errorMessage); + } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/Handlers/IExecuteHandler.cs b/src/CommandLineUtils/SourceGeneration/Handlers/IExecuteHandler.cs new file mode 100644 index 00000000..ae30ce97 --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/Handlers/IExecuteHandler.cs @@ -0,0 +1,45 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Handles execution of the OnExecute/OnExecuteAsync method without reflection. + /// + public interface IExecuteHandler + { + /// + /// Whether this is an async handler (OnExecuteAsync vs OnExecute). + /// + bool IsAsync { get; } + + /// + /// Invokes the execute method on the model. + /// + /// The model instance. + /// The command line application. + /// A cancellation token. + /// The exit code. + Task InvokeAsync(object model, CommandLineApplication app, CancellationToken cancellationToken); + } + + /// + /// Strongly-typed handler for executing the OnExecute/OnExecuteAsync method. + /// + /// The model type. + public interface IExecuteHandler : IExecuteHandler + where TModel : class + { + /// + /// Invokes the execute method on the model. + /// + /// The model instance. + /// The command line application. + /// A cancellation token. + /// The exit code. + Task InvokeAsync(TModel model, CommandLineApplication app, CancellationToken cancellationToken); + } +} diff --git a/src/CommandLineUtils/SourceGeneration/Handlers/IModelFactory.cs b/src/CommandLineUtils/SourceGeneration/Handlers/IModelFactory.cs new file mode 100644 index 00000000..6d312eba --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/Handlers/IModelFactory.cs @@ -0,0 +1,33 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Factory for creating model instances without reflection. + /// + public interface IModelFactory + { + /// + /// Creates a new model instance. + /// + /// A new instance of the model. + object Create(); + } + + /// + /// Strongly-typed factory for creating model instances without reflection. + /// + /// The model type to create. + public interface IModelFactory : IModelFactory + where TModel : class + { + /// + /// Creates a new model instance. + /// + /// A new instance of the model. + new TModel Create(); + } +} diff --git a/src/CommandLineUtils/SourceGeneration/Handlers/IValidateHandler.cs b/src/CommandLineUtils/SourceGeneration/Handlers/IValidateHandler.cs new file mode 100644 index 00000000..f1519efa --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/Handlers/IValidateHandler.cs @@ -0,0 +1,54 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; +using McMaster.Extensions.CommandLineUtils.Abstractions; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Handles validation via OnValidate method without reflection. + /// + public interface IValidateHandler + { + /// + /// Invokes the OnValidate method on the model. + /// + /// The model instance. + /// The validation context. + /// The command line context. + /// The validation result, or null if validation passed. + ValidationResult? Invoke(object model, ValidationContext validationContext, CommandLineContext commandContext); + } + + /// + /// Strongly-typed handler for validation via OnValidate method. + /// + /// The model type. + public interface IValidateHandler : IValidateHandler + where TModel : class + { + /// + /// Invokes the OnValidate method on the model. + /// + /// The model instance. + /// The validation context. + /// The command line context. + /// The validation result, or null if validation passed. + ValidationResult? Invoke(TModel model, ValidationContext validationContext, CommandLineContext commandContext); + } + + /// + /// Handles validation errors via OnValidationError method without reflection. + /// + public interface IValidationErrorHandler + { + /// + /// Invokes the OnValidationError method on the model. + /// + /// The model instance. + /// The validation result containing errors. + /// The exit code. + int Invoke(object model, ValidationResult validationResult); + } +} diff --git a/src/CommandLineUtils/SourceGeneration/ICommandMetadataProvider.cs b/src/CommandLineUtils/SourceGeneration/ICommandMetadataProvider.cs new file mode 100644 index 00000000..ae86956f --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/ICommandMetadataProvider.cs @@ -0,0 +1,92 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Provides metadata about a command model type. + /// Implemented by both reflection-based and source-generated providers. + /// + public interface ICommandMetadataProvider + { + /// + /// Gets the model type this provider handles. + /// + Type ModelType { get; } + + /// + /// Gets metadata for all options defined on the model. + /// + IReadOnlyList Options { get; } + + /// + /// Gets metadata for all arguments defined on the model. + /// + IReadOnlyList Arguments { get; } + + /// + /// Gets metadata for subcommand types. + /// + IReadOnlyList Subcommands { get; } + + /// + /// Gets metadata about the command itself (name, description, etc.). + /// + CommandMetadata? CommandInfo { get; } + + /// + /// Gets the execute handler if one is defined (OnExecute/OnExecuteAsync). + /// + IExecuteHandler? ExecuteHandler { get; } + + /// + /// Gets the validate handler if one is defined (OnValidate). + /// + IValidateHandler? ValidateHandler { get; } + + /// + /// Gets the validation error handler if one is defined (OnValidationError). + /// + IValidationErrorHandler? ValidationErrorHandler { get; } + + /// + /// Creates a model factory for the type. + /// + /// Optional service provider for dependency injection. + /// A factory that can create model instances. + IModelFactory GetModelFactory(IServiceProvider? services); + + /// + /// Gets property accessors for setting Parent, RemainingArgs, Subcommand. + /// + SpecialPropertiesMetadata? SpecialProperties { get; } + + /// + /// Gets help option metadata if defined. + /// + HelpOptionMetadata? HelpOption { get; } + + /// + /// Gets version option metadata if defined. + /// + VersionOptionMetadata? VersionOption { get; } + } + + /// + /// Strongly-typed metadata provider for a specific model type. + /// + /// The model type. + public interface ICommandMetadataProvider : ICommandMetadataProvider + where TModel : class + { + /// + /// Creates a model factory for the type. + /// + /// Optional service provider for dependency injection. + /// A factory that can create model instances. + new IModelFactory GetModelFactory(IServiceProvider? services); + } +} diff --git a/src/CommandLineUtils/SourceGeneration/IMetadataResolver.cs b/src/CommandLineUtils/SourceGeneration/IMetadataResolver.cs new file mode 100644 index 00000000..7b6c823c --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/IMetadataResolver.cs @@ -0,0 +1,36 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Resolves command metadata for a type using either generated or reflection-based providers. + /// + public interface IMetadataResolver + { + /// + /// Gets the metadata provider for a type. + /// Returns generated metadata if available, otherwise uses reflection. + /// + /// The model type. + /// A metadata provider for the type. + ICommandMetadataProvider GetProvider(Type modelType); + + /// + /// Gets the metadata provider for a type. + /// Returns generated metadata if available, otherwise uses reflection. + /// + /// The model type. + /// A metadata provider for the type. + ICommandMetadataProvider GetProvider() where TModel : class; + + /// + /// Checks if generated metadata is available for a type. + /// + /// The model type. + /// True if generated metadata is available; otherwise, false. + bool HasGeneratedMetadata(Type modelType); + } +} diff --git a/src/CommandLineUtils/SourceGeneration/Metadata/ArgumentMetadata.cs b/src/CommandLineUtils/SourceGeneration/Metadata/ArgumentMetadata.cs new file mode 100644 index 00000000..a3f72a02 --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/Metadata/ArgumentMetadata.cs @@ -0,0 +1,92 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Metadata about a command argument, extracted at compile time or via reflection. + /// + public sealed class ArgumentMetadata + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the property this argument maps to. + /// The CLR type of the property. + /// The order in which the argument is expected. + /// Delegate to get the property value from a model instance. + /// Delegate to set the property value on a model instance. + public ArgumentMetadata( + string propertyName, + Type propertyType, + int order, + Func getter, + Action setter) + { + PropertyName = propertyName ?? throw new ArgumentNullException(nameof(propertyName)); + PropertyType = propertyType ?? throw new ArgumentNullException(nameof(propertyType)); + Order = order; + Getter = getter ?? throw new ArgumentNullException(nameof(getter)); + Setter = setter ?? throw new ArgumentNullException(nameof(setter)); + } + + /// + /// The name of the property this argument maps to. + /// + public string PropertyName { get; } + + /// + /// The CLR type of the property. + /// + public Type PropertyType { get; } + + /// + /// The type that declares this property. + /// + public Type? DeclaringType { get; init; } + + /// + /// The order in which the argument is expected, relative to other arguments. + /// + public int Order { get; } + + /// + /// The name of the argument. + /// + public string? Name { get; init; } + + /// + /// A description of the argument. + /// + public string? Description { get; init; } + + /// + /// Whether this argument appears in generated help text. + /// + public bool ShowInHelpText { get; init; } = true; + + /// + /// Whether this argument accepts multiple values. + /// + public bool MultipleValues { get; init; } + + /// + /// Validation attributes applied to this argument. + /// + public IReadOnlyList Validators { get; init; } = Array.Empty(); + + /// + /// Delegate to get the property value from a model instance. + /// + public Func Getter { get; } + + /// + /// Delegate to set the property value on a model instance. + /// + public Action Setter { get; } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/Metadata/CommandMetadata.cs b/src/CommandLineUtils/SourceGeneration/Metadata/CommandMetadata.cs new file mode 100644 index 00000000..e14e1226 --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/Metadata/CommandMetadata.cs @@ -0,0 +1,151 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Metadata about a command from the [Command] attribute. + /// + public sealed class CommandMetadata + { + /// + /// The primary name of the command. + /// + public string? Name { get; init; } + + /// + /// Additional names/aliases for the command. + /// + public string[]? AdditionalNames { get; init; } + + /// + /// The full name of the command to show in help text. + /// + public string? FullName { get; init; } + + /// + /// A description of the command. + /// + public string? Description { get; init; } + + /// + /// Whether this command appears in generated help text. + /// + public bool ShowInHelpText { get; init; } = true; + + /// + /// Additional text that appears at the bottom of generated help text. + /// + public string? ExtendedHelpText { get; init; } + + /// + /// How to handle unrecognized arguments. + /// + public UnrecognizedArgumentHandling? UnrecognizedArgumentHandling { get; init; } + + /// + /// Whether to allow '--' to stop parsing arguments. + /// + public bool? AllowArgumentSeparator { get; init; } + + /// + /// How to handle response files. + /// + public ResponseFileHandling? ResponseFileHandling { get; init; } + + /// + /// The way arguments and options are matched. + /// + public StringComparison? OptionsComparison { get; init; } + + /// + /// The culture used to convert values into types. + /// + public CultureInfo? ParseCulture { get; init; } + + /// + /// Whether a Pager should be used to display help text. + /// + public bool? UsePagerForHelpText { get; init; } + + /// + /// Whether options can be clustered behind one '-' delimiter. + /// + public bool? ClusterOptions { get; init; } + + /// + /// Applies this metadata to a command line application. + /// + /// The application to configure. + public void ApplyTo(CommandLineApplication app) + { + if (Name != null) + { + app.Name = Name; + } + + if (AdditionalNames != null) + { + foreach (var name in AdditionalNames) + { + app.AddName(name); + } + } + + if (FullName != null) + { + app.FullName = FullName; + } + + if (Description != null) + { + app.Description = Description; + } + + app.ShowInHelpText = ShowInHelpText; + + if (ExtendedHelpText != null) + { + app.ExtendedHelpText = ExtendedHelpText; + } + + if (UnrecognizedArgumentHandling.HasValue) + { + app.UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.Value; + } + + if (AllowArgumentSeparator.HasValue) + { + app.AllowArgumentSeparator = AllowArgumentSeparator.Value; + } + + if (ResponseFileHandling.HasValue) + { + app.ResponseFileHandling = ResponseFileHandling.Value; + } + + if (OptionsComparison.HasValue) + { + app.OptionsComparison = OptionsComparison.Value; + } + + if (ParseCulture != null) + { + app.ValueParsers.ParseCulture = ParseCulture; + } + + if (UsePagerForHelpText.HasValue) + { + app.UsePagerForHelpText = UsePagerForHelpText.Value; + } + + if (ClusterOptions.HasValue) + { + app.ClusterOptions = ClusterOptions.Value; + } + } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/Metadata/OptionMetadata.cs b/src/CommandLineUtils/SourceGeneration/Metadata/OptionMetadata.cs new file mode 100644 index 00000000..cb087fb5 --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/Metadata/OptionMetadata.cs @@ -0,0 +1,115 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Metadata about a command option, extracted at compile time or via reflection. + /// + public sealed class OptionMetadata + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the property this option maps to. + /// The CLR type of the property. + /// Delegate to get the property value from a model instance. + /// Delegate to set the property value on a model instance. + public OptionMetadata( + string propertyName, + Type propertyType, + Func getter, + Action setter) + { + PropertyName = propertyName ?? throw new ArgumentNullException(nameof(propertyName)); + PropertyType = propertyType ?? throw new ArgumentNullException(nameof(propertyType)); + Getter = getter ?? throw new ArgumentNullException(nameof(getter)); + Setter = setter ?? throw new ArgumentNullException(nameof(setter)); + } + + /// + /// The name of the property this option maps to. + /// + public string PropertyName { get; } + + /// + /// The CLR type of the property. + /// + public Type PropertyType { get; } + + /// + /// The type that declares this property. + /// + public Type? DeclaringType { get; init; } + + /// + /// The option template (e.g., "-n|--name <VALUE>"). + /// + public string? Template { get; init; } + + /// + /// The short name (e.g., "n"). + /// + public string? ShortName { get; init; } + + /// + /// The long name (e.g., "name"). + /// + public string? LongName { get; init; } + + /// + /// The symbol name (e.g., "?"). + /// + public string? SymbolName { get; init; } + + /// + /// The name of value(s) shown in help text. + /// + public string? ValueName { get; init; } + + /// + /// A description of this option. + /// + public string? Description { get; init; } + + /// + /// The option type. + /// + public CommandOptionType OptionType { get; init; } + + /// + /// Whether the OptionType was explicitly set in the attribute (vs. inferred from property type). + /// If true, skip parser validation at convention-apply time since a custom parser may be added later. + /// + public bool OptionTypeExplicitlySet { get; init; } + + /// + /// Whether this option appears in generated help text. + /// + public bool ShowInHelpText { get; init; } = true; + + /// + /// Whether subcommands should also have access to this option. + /// + public bool Inherited { get; init; } + + /// + /// Validation attributes applied to this option. + /// + public IReadOnlyList Validators { get; init; } = Array.Empty(); + + /// + /// Delegate to get the property value from a model instance. + /// + public Func Getter { get; } + + /// + /// Delegate to set the property value on a model instance. + /// + public Action Setter { get; } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/Metadata/SpecialPropertiesMetadata.cs b/src/CommandLineUtils/SourceGeneration/Metadata/SpecialPropertiesMetadata.cs new file mode 100644 index 00000000..d318914f --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/Metadata/SpecialPropertiesMetadata.cs @@ -0,0 +1,90 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Metadata for special well-known properties (Parent, Subcommand, RemainingArguments). + /// + public sealed class SpecialPropertiesMetadata + { + /// + /// Setter for the Parent property, if one exists. + /// + public Action? ParentSetter { get; init; } + + /// + /// The type of the Parent property. + /// + public Type? ParentType { get; init; } + + /// + /// Setter for the Subcommand property, if one exists. + /// + public Action? SubcommandSetter { get; init; } + + /// + /// The type of the Subcommand property. + /// + public Type? SubcommandType { get; init; } + + /// + /// Setter for the RemainingArguments property, if one exists. + /// + public Action? RemainingArgumentsSetter { get; init; } + + /// + /// The type of the RemainingArguments property. + /// + public Type? RemainingArgumentsType { get; init; } + } + + /// + /// Metadata for the [HelpOption] attribute. + /// + public sealed class HelpOptionMetadata + { + /// + /// The option template. + /// + public string? Template { get; init; } + + /// + /// The description. + /// + public string? Description { get; init; } + + /// + /// Whether this option is inherited by subcommands. + /// + public bool Inherited { get; init; } + } + + /// + /// Metadata for the [VersionOption] attribute. + /// + public sealed class VersionOptionMetadata + { + /// + /// The option template. + /// + public string? Template { get; init; } + + /// + /// The description. + /// + public string? Description { get; init; } + + /// + /// The version string, or null if version is provided by a member. + /// + public string? Version { get; init; } + + /// + /// Delegate to get the version from a member, if applicable. + /// + public Func? VersionGetter { get; init; } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/Metadata/SubcommandMetadata.cs b/src/CommandLineUtils/SourceGeneration/Metadata/SubcommandMetadata.cs new file mode 100644 index 00000000..47e0f509 --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/Metadata/SubcommandMetadata.cs @@ -0,0 +1,38 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Metadata about a subcommand, extracted at compile time or via reflection. + /// + public sealed class SubcommandMetadata + { + /// + /// Initializes a new instance of the class. + /// + /// The type of the subcommand. + public SubcommandMetadata(Type subcommandType) + { + SubcommandType = subcommandType ?? throw new ArgumentNullException(nameof(subcommandType)); + } + + /// + /// The type of the subcommand. + /// + public Type SubcommandType { get; } + + /// + /// The command name (if specified, otherwise derived from type name). + /// + public string? CommandName { get; init; } + + /// + /// Factory to get the metadata provider for the subcommand type. + /// This avoids reflection when the subcommand also has generated metadata. + /// + public Func? MetadataProviderFactory { get; init; } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs new file mode 100644 index 00000000..7ed1237c --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs @@ -0,0 +1,85 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Execute handler that uses reflection to invoke OnExecute/OnExecuteAsync. + /// + [RequiresUnreferencedCode("Uses reflection to invoke method")] + internal sealed class ReflectionExecuteHandler : IExecuteHandler + { + private readonly MethodInfo _method; + + public ReflectionExecuteHandler(MethodInfo method, bool isAsync) + { + _method = method; + IsAsync = isAsync; + } + + public bool IsAsync { get; } + + public async Task InvokeAsync(object model, CommandLineApplication app, CancellationToken cancellationToken) + { + var arguments = ReflectionHelper.BindParameters(_method, app, cancellationToken); + + if (_method.ReturnType == typeof(Task) || _method.ReturnType == typeof(Task)) + { + return await InvokeAsyncMethod(model, arguments); + } + else if (_method.ReturnType == typeof(void) || _method.ReturnType == typeof(int)) + { + return InvokeSyncMethod(model, arguments); + } + + throw new System.InvalidOperationException(Strings.InvalidOnExecuteReturnType(_method.Name)); + } + + private async Task InvokeAsyncMethod(object instance, object?[] arguments) + { + try + { + var result = (Task?)_method.Invoke(instance, arguments); + if (result is Task intResult) + { + return await intResult; + } + + if (result != null) + { + await result; + } + } + catch (TargetInvocationException e) when (e.InnerException != null) + { + ExceptionDispatchInfo.Capture(e.InnerException).Throw(); + } + + return 0; + } + + private int InvokeSyncMethod(object instance, object?[] arguments) + { + try + { + var result = _method.Invoke(instance, arguments); + if (_method.ReturnType == typeof(int) && result != null) + { + return (int)result; + } + } + catch (TargetInvocationException e) when (e.InnerException != null) + { + ExceptionDispatchInfo.Capture(e.InnerException).Throw(); + } + + return 0; + } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs b/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs new file mode 100644 index 00000000..e5b26f7d --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs @@ -0,0 +1,484 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Provides command metadata by analyzing a type using reflection. + /// This is the fallback when generated metadata is not available. + /// + [RequiresUnreferencedCode("Uses reflection to analyze the model type")] + internal sealed class ReflectionMetadataProvider : ICommandMetadataProvider + { + private const BindingFlags MethodLookup = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + + private readonly Type _modelType; + private readonly Lazy> _options; + private readonly Lazy> _arguments; + private readonly Lazy> _subcommands; + private readonly Lazy _commandInfo; + private readonly Lazy _executeHandler; + private readonly Lazy _validateHandler; + private readonly Lazy _validationErrorHandler; + private readonly Lazy _specialProperties; + private readonly Lazy _helpOption; + private readonly Lazy _versionOption; + + /// + /// Initializes a new instance of . + /// + /// The model type to analyze. + public ReflectionMetadataProvider(Type modelType) + { + _modelType = modelType ?? throw new ArgumentNullException(nameof(modelType)); + + _options = new Lazy>(ExtractOptions); + _arguments = new Lazy>(ExtractArguments); + _subcommands = new Lazy>(ExtractSubcommands); + _commandInfo = new Lazy(ExtractCommandInfo); + _executeHandler = new Lazy(ExtractExecuteHandler); + _validateHandler = new Lazy(ExtractValidateHandler); + _validationErrorHandler = new Lazy(ExtractValidationErrorHandler); + _specialProperties = new Lazy(ExtractSpecialProperties); + _helpOption = new Lazy(ExtractHelpOption); + _versionOption = new Lazy(ExtractVersionOption); + } + + /// + public Type ModelType => _modelType; + + /// + public IReadOnlyList Options => _options.Value; + + /// + public IReadOnlyList Arguments => _arguments.Value; + + /// + public IReadOnlyList Subcommands => _subcommands.Value; + + /// + public CommandMetadata? CommandInfo => _commandInfo.Value; + + /// + public IExecuteHandler? ExecuteHandler => _executeHandler.Value; + + /// + public IValidateHandler? ValidateHandler => _validateHandler.Value; + + /// + public IValidationErrorHandler? ValidationErrorHandler => _validationErrorHandler.Value; + + /// + public SpecialPropertiesMetadata? SpecialProperties => _specialProperties.Value; + + /// + public HelpOptionMetadata? HelpOption => _helpOption.Value; + + /// + public VersionOptionMetadata? VersionOption => _versionOption.Value; + + /// + public IModelFactory GetModelFactory(IServiceProvider? services) + { + return new ActivatorModelFactory(_modelType, services); + } + + private IReadOnlyList ExtractOptions() + { + var options = new List(); + var props = ReflectionHelper.GetProperties(_modelType); + + foreach (var prop in props) + { + var attr = prop.GetCustomAttribute(); + if (attr == null) + { + continue; + } + + // Skip if it has HelpOption or VersionOption attribute (handled separately) + if (prop.GetCustomAttribute() != null || + prop.GetCustomAttribute() != null) + { + continue; + } + + // Check for conflicting Argument attribute + if (prop.GetCustomAttribute() != null) + { + throw new InvalidOperationException( + Strings.BothOptionAndArgumentAttributesCannotBeSpecified(prop)); + } + + var getter = ReflectionHelper.GetPropertyGetter(prop); + var setter = ReflectionHelper.GetPropertySetter(prop); + var validators = prop.GetCustomAttributes().ToList(); + + // Infer option names from property name if not specified in attribute + string? template = attr.Template; + string? shortName = attr.ShortName; + string? longName = attr.LongName; + string? valueName = attr.ValueName; + + // If no template specified, infer names from property name + if (string.IsNullOrEmpty(template)) + { + // Infer longName from property name if not specified + // (empty string means "no long name", null means "infer from property") + if (longName == null) + { + longName = prop.Name.ToKebabCase(); + } + // Infer shortName from longName if not specified + // (empty string means "no short name", null means "infer from long name") + if (shortName == null) + { + shortName = !string.IsNullOrEmpty(longName) ? longName.Substring(0, 1) : null; + } + // Infer valueName from property name if not specified + valueName ??= prop.Name.ToConstantCase(); + } + + // Use explicit OptionType if specified, otherwise try to infer from property type + CommandOptionType optionType; + if (attr.OptionType.HasValue) + { + optionType = attr.OptionType.Value; + } + else if (!CommandOptionTypeMapper.Default.TryGetOptionType(prop.PropertyType, null!, out optionType)) + { + // For types that have custom parsers (like Uri), default to SingleValue + // This matches the behavior of the original OptionAttribute.GetOptionType() + // which would also require a custom parser for such types + optionType = CommandOptionType.SingleValue; + } + + options.Add(new OptionMetadata( + propertyName: prop.Name, + propertyType: prop.PropertyType, + getter: obj => getter(obj), + setter: (obj, val) => setter(obj, val)) + { + Template = template, + ShortName = shortName, + LongName = longName, + SymbolName = attr.SymbolName, + ValueName = valueName, + Description = attr.Description, + OptionType = optionType, + OptionTypeExplicitlySet = attr.OptionType.HasValue, + ShowInHelpText = attr.ShowInHelpText, + Inherited = attr.Inherited, + Validators = validators, + DeclaringType = prop.DeclaringType + }); + } + + return options; + } + + private IReadOnlyList ExtractArguments() + { + var arguments = new List(); + var props = ReflectionHelper.GetProperties(_modelType); + + foreach (var prop in props) + { + var attr = prop.GetCustomAttribute(); + if (attr == null) + { + continue; + } + + var getter = ReflectionHelper.GetPropertyGetter(prop); + var setter = ReflectionHelper.GetPropertySetter(prop); + var validators = prop.GetCustomAttributes().ToList(); + + var multipleValues = prop.PropertyType.IsArray || + (typeof(System.Collections.IEnumerable).IsAssignableFrom(prop.PropertyType) && + prop.PropertyType != typeof(string)); + + arguments.Add(new ArgumentMetadata( + propertyName: prop.Name, + propertyType: prop.PropertyType, + order: attr.Order, + getter: obj => getter(obj), + setter: (obj, val) => setter(obj, val)) + { + Name = attr.Name, + Description = attr.Description, + ShowInHelpText = attr.ShowInHelpText, + MultipleValues = multipleValues, + Validators = validators, + DeclaringType = prop.DeclaringType + }); + } + + // Sort by order + return arguments.OrderBy(a => a.Order).ToList(); + } + + private IReadOnlyList ExtractSubcommands() + { + var subcommands = new List(); + var attributes = _modelType.GetCustomAttributes(); + + foreach (var attr in attributes) + { + foreach (var type in attr.Types) + { + var cmdAttr = type.GetCustomAttribute(); + subcommands.Add(new SubcommandMetadata(type) + { + CommandName = cmdAttr?.Name, + MetadataProviderFactory = () => new ReflectionMetadataProvider(type) + }); + } + } + + return subcommands; + } + + private CommandMetadata? ExtractCommandInfo() + { + var attr = _modelType.GetCustomAttribute(); + if (attr == null) + { + return null; + } + + return new CommandMetadata + { + Name = attr.Name, + AdditionalNames = attr.Names?.Skip(1).ToArray(), + FullName = attr.FullName, + Description = attr.Description, + ExtendedHelpText = attr.ExtendedHelpText, + ShowInHelpText = attr.ShowInHelpText, + AllowArgumentSeparator = attr.AllowArgumentSeparator, + // Only set ClusterOptions if explicitly specified in the attribute + ClusterOptions = attr.ClusterOptionsWasSet ? attr.ClusterOptions : null, + OptionsComparison = attr.OptionsComparison, + ParseCulture = attr.ParseCulture, + ResponseFileHandling = attr.ResponseFileHandling, + // Only set UnrecognizedArgumentHandling if explicitly specified in the attribute + UnrecognizedArgumentHandling = attr.UnrecognizedArgumentHandlingWasSet ? attr.UnrecognizedArgumentHandling : null, + UsePagerForHelpText = attr.UsePagerForHelpText + }; + } + + private IExecuteHandler? ExtractExecuteHandler() + { + MethodInfo? method; + MethodInfo? asyncMethod; + try + { + method = _modelType.GetMethod("OnExecute", MethodLookup); + asyncMethod = _modelType.GetMethod("OnExecuteAsync", MethodLookup); + } + catch (AmbiguousMatchException) + { + // Return handler that throws when invoked + return new ErrorExecuteHandler(Strings.AmbiguousOnExecuteMethod); + } + + if (method != null && asyncMethod != null) + { + // Return handler that throws when invoked + return new ErrorExecuteHandler(Strings.AmbiguousOnExecuteMethod); + } + + method ??= asyncMethod; + if (method == null) + { + return null; // No OnExecute method - not an error, just no handler + } + + var isAsync = method.ReturnType == typeof(Task) || method.ReturnType == typeof(Task); + return new ReflectionExecuteHandler(method, isAsync); + } + + private IValidateHandler? ExtractValidateHandler() + { + var method = _modelType.GetMethod("OnValidate", MethodLookup); + if (method == null) + { + return null; + } + + // Validate return type - must return ValidationResult + if (method.ReturnType != typeof(ValidationResult)) + { + throw new InvalidOperationException(Strings.InvalidOnValidateReturnType(_modelType)); + } + + return new ReflectionValidateHandler(method); + } + + private IValidationErrorHandler? ExtractValidationErrorHandler() + { + var method = _modelType.GetMethod("OnValidationError", MethodLookup); + if (method == null) + { + return null; + } + + return new ReflectionValidationErrorHandler(method); + } + + private SpecialPropertiesMetadata? ExtractSpecialProperties() + { + Action? parentSetter = null; + Type? parentType = null; + Action? subcommandSetter = null; + Type? subcommandType = null; + Action? remainingArgumentsSetter = null; + Type? remainingArgumentsType = null; + + const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + + // Parent property - detected by name "Parent" + var parentProp = _modelType.GetProperty("Parent", bindingFlags); + if (parentProp != null) + { + var setter = ReflectionHelper.GetPropertySetter(parentProp); + parentSetter = (obj, val) => setter(obj, val); + parentType = parentProp.PropertyType; + } + + // Subcommand property - detected by name "Subcommand" + var subcommandProp = _modelType.GetProperty("Subcommand", bindingFlags); + if (subcommandProp != null) + { + var setter = ReflectionHelper.GetPropertySetter(subcommandProp); + subcommandSetter = (obj, val) => setter(obj, val); + subcommandType = subcommandProp.PropertyType; + } + + // RemainingArguments property - detected by name "RemainingArguments" or "RemainingArgs" + var remainingProp = _modelType.GetProperty("RemainingArguments", bindingFlags); + remainingProp ??= _modelType.GetProperty("RemainingArgs", bindingFlags); + if (remainingProp != null) + { + var setter = ReflectionHelper.GetPropertySetter(remainingProp); + remainingArgumentsSetter = (obj, val) => setter(obj, val); + remainingArgumentsType = remainingProp.PropertyType; + } + + if (parentSetter == null && subcommandSetter == null && remainingArgumentsSetter == null) + { + return null; + } + + return new SpecialPropertiesMetadata + { + ParentSetter = parentSetter, + ParentType = parentType, + SubcommandSetter = subcommandSetter, + SubcommandType = subcommandType, + RemainingArgumentsSetter = remainingArgumentsSetter, + RemainingArgumentsType = remainingArgumentsType + }; + } + + private HelpOptionMetadata? ExtractHelpOption() + { + // Check type-level attribute + var typeAttr = _modelType.GetCustomAttribute(); + if (typeAttr != null) + { + return new HelpOptionMetadata + { + Template = typeAttr.Template, + Description = typeAttr.Description, + Inherited = typeAttr.Inherited + }; + } + + // Check property-level attribute + var props = ReflectionHelper.GetProperties(_modelType); + foreach (var prop in props) + { + var propAttr = prop.GetCustomAttribute(); + if (propAttr != null) + { + return new HelpOptionMetadata + { + Template = propAttr.Template, + Description = propAttr.Description, + Inherited = propAttr.Inherited + }; + } + } + + return null; + } + + private VersionOptionMetadata? ExtractVersionOption() + { + // Check type-level VersionOptionAttribute + var typeAttr = _modelType.GetCustomAttribute(); + if (typeAttr != null) + { + return new VersionOptionMetadata + { + Template = typeAttr.Template, + Description = typeAttr.Description, + Version = typeAttr.Version + }; + } + + // Check type-level VersionOptionFromMemberAttribute + var fromMemberAttr = _modelType.GetCustomAttribute(); + if (fromMemberAttr != null) + { + Func? versionGetter = null; + + if (!string.IsNullOrEmpty(fromMemberAttr.MemberName)) + { + var members = ReflectionHelper.GetPropertyOrMethod(_modelType, fromMemberAttr.MemberName); + if (members.Length > 0) + { + var method = members[0]; + versionGetter = obj => method.Invoke(obj, Array.Empty()) as string; + } + } + + return new VersionOptionMetadata + { + Template = fromMemberAttr.Template, + Description = fromMemberAttr.Description, + VersionGetter = versionGetter + }; + } + + // Check property-level VersionOptionAttribute + var props = ReflectionHelper.GetProperties(_modelType); + foreach (var prop in props) + { + var propAttr = prop.GetCustomAttribute(); + if (propAttr != null) + { + var getter = ReflectionHelper.GetPropertyGetter(prop); + return new VersionOptionMetadata + { + Template = propAttr.Template, + Description = propAttr.Description, + Version = propAttr.Version, + VersionGetter = obj => getter(obj)?.ToString() + }; + } + } + + return null; + } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs new file mode 100644 index 00000000..bb473e9f --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs @@ -0,0 +1,54 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.ExceptionServices; +using McMaster.Extensions.CommandLineUtils.Abstractions; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Validate handler that uses reflection to invoke OnValidate. + /// + [RequiresUnreferencedCode("Uses reflection to invoke method")] + internal sealed class ReflectionValidateHandler : IValidateHandler + { + private readonly MethodInfo _method; + + public ReflectionValidateHandler(MethodInfo method) + { + _method = method; + } + + public ValidationResult? Invoke(object model, ValidationContext validationContext, CommandLineContext commandContext) + { + var parameters = _method.GetParameters(); + var args = new object?[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var paramType = parameters[i].ParameterType; + if (typeof(ValidationContext).IsAssignableFrom(paramType)) + { + args[i] = validationContext; + } + else if (typeof(CommandLineContext).IsAssignableFrom(paramType)) + { + args[i] = commandContext; + } + } + + try + { + return (ValidationResult?)_method.Invoke(model, args); + } + catch (TargetInvocationException e) when (e.InnerException != null) + { + ExceptionDispatchInfo.Capture(e.InnerException).Throw(); + throw; // Never reached + } + } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs new file mode 100644 index 00000000..0619433f --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs @@ -0,0 +1,54 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.ExceptionServices; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Validation error handler that uses reflection to invoke OnValidationError. + /// + [RequiresUnreferencedCode("Uses reflection to invoke method")] + internal sealed class ReflectionValidationErrorHandler : IValidationErrorHandler + { + private readonly MethodInfo _method; + + public ReflectionValidationErrorHandler(MethodInfo method) + { + _method = method; + } + + public int Invoke(object model, ValidationResult validationResult) + { + var parameters = _method.GetParameters(); + var args = new object?[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var paramType = parameters[i].ParameterType; + if (typeof(ValidationResult).IsAssignableFrom(paramType)) + { + args[i] = validationResult; + } + } + + try + { + var result = _method.Invoke(model, args); + if (_method.ReturnType == typeof(int) && result != null) + { + return (int)result; + } + return 1; // Default error code + } + catch (TargetInvocationException e) when (e.InnerException != null) + { + ExceptionDispatchInfo.Capture(e.InnerException).Throw(); + throw; // Never reached + } + } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/TypedMetadataProviderWrapper.cs b/src/CommandLineUtils/SourceGeneration/TypedMetadataProviderWrapper.cs new file mode 100644 index 00000000..e81a1865 --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/TypedMetadataProviderWrapper.cs @@ -0,0 +1,46 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Wrapper to provide typed access to an untyped metadata provider. + /// + internal sealed class TypedMetadataProviderWrapper : ICommandMetadataProvider + where TModel : class + { + private readonly ICommandMetadataProvider _inner; + + public TypedMetadataProviderWrapper(ICommandMetadataProvider inner) + { + _inner = inner; + } + + public Type ModelType => _inner.ModelType; + public IReadOnlyList Options => _inner.Options; + public IReadOnlyList Arguments => _inner.Arguments; + public IReadOnlyList Subcommands => _inner.Subcommands; + public CommandMetadata? CommandInfo => _inner.CommandInfo; + public IExecuteHandler? ExecuteHandler => _inner.ExecuteHandler; + public IValidateHandler? ValidateHandler => _inner.ValidateHandler; + public IValidationErrorHandler? ValidationErrorHandler => _inner.ValidationErrorHandler; + public SpecialPropertiesMetadata? SpecialProperties => _inner.SpecialProperties; + public HelpOptionMetadata? HelpOption => _inner.HelpOption; + public VersionOptionMetadata? VersionOption => _inner.VersionOption; + + IModelFactory ICommandMetadataProvider.GetModelFactory(IServiceProvider? services) => _inner.GetModelFactory(services); + + public IModelFactory GetModelFactory(IServiceProvider? services) + { + var factory = _inner.GetModelFactory(services); + if (factory is IModelFactory typedFactory) + { + return typedFactory; + } + return new TypedModelFactoryWrapper(factory); + } + } +} diff --git a/src/CommandLineUtils/SourceGeneration/TypedModelFactoryWrapper.cs b/src/CommandLineUtils/SourceGeneration/TypedModelFactoryWrapper.cs new file mode 100644 index 00000000..803e0d21 --- /dev/null +++ b/src/CommandLineUtils/SourceGeneration/TypedModelFactoryWrapper.cs @@ -0,0 +1,22 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace McMaster.Extensions.CommandLineUtils.SourceGeneration +{ + /// + /// Wrapper to provide typed access to an untyped model factory. + /// + internal sealed class TypedModelFactoryWrapper : IModelFactory + where TModel : class + { + private readonly IModelFactory _inner; + + public TypedModelFactoryWrapper(IModelFactory inner) + { + _inner = inner; + } + + public TModel Create() => (TModel)_inner.Create(); + object IModelFactory.Create() => _inner.Create(); + } +} diff --git a/src/CommandLineUtils/build/McMaster.Extensions.CommandLineUtils.props b/src/CommandLineUtils/build/McMaster.Extensions.CommandLineUtils.props new file mode 100644 index 00000000..2bcf4c47 --- /dev/null +++ b/src/CommandLineUtils/build/McMaster.Extensions.CommandLineUtils.props @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/CommandLineUtils.Tests/CommandAttributeTests.cs b/test/CommandLineUtils.Tests/CommandAttributeTests.cs index 9a5755ea..7f5b0d8e 100644 --- a/test/CommandLineUtils.Tests/CommandAttributeTests.cs +++ b/test/CommandLineUtils.Tests/CommandAttributeTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Nate McMaster. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.Linq; using Xunit; namespace McMaster.Extensions.CommandLineUtils.Tests @@ -24,5 +26,434 @@ public void HandlesParsingOptionsAttribute() Assert.Equal(UnrecognizedArgumentHandling.StopParsingAndCollect, app.UnrecognizedArgumentHandling); Assert.True(app.AllowArgumentSeparator); } + + #region ClusterOptionsWasSet and UnrecognizedArgumentHandlingWasSet Tests + + [Command(ClusterOptions = true)] + private class ClusterOptionsExplicitlySet + { } + + [Command] + private class ClusterOptionsNotSet + { } + + [Fact] + public void ClusterOptionsWasSet_ReturnsTrue_WhenExplicitlySet() + { + var attr = new CommandAttribute { ClusterOptions = true }; + + Assert.True(attr.ClusterOptionsWasSet); + } + + [Fact] + public void ClusterOptionsWasSet_ReturnsFalse_WhenNotSet() + { + var attr = new CommandAttribute(); + + Assert.False(attr.ClusterOptionsWasSet); + } + + [Fact] + public void Configure_SetsClusterOptions_WhenExplicitlySet() + { + var app = new CommandLineApplication(); + // Set it to false first so we can verify it gets changed + app.ClusterOptions = false; + app.Conventions.UseCommandAttribute(); + + Assert.True(app.ClusterOptions); + } + + [Fact] + public void Configure_DoesNotSetClusterOptions_WhenNotExplicitlySet() + { + var app = new CommandLineApplication(); + // Set it to false first - it should remain unchanged if not explicitly set + app.ClusterOptions = false; + app.Conventions.UseCommandAttribute(); + + Assert.False(app.ClusterOptions); + } + + [Command(UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue)] + private class UnrecognizedArgumentHandlingExplicitlySet + { } + + [Command] + private class UnrecognizedArgumentHandlingNotSet + { } + + [Fact] + public void UnrecognizedArgumentHandlingWasSet_ReturnsTrue_WhenExplicitlySet() + { + var attr = new CommandAttribute { UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue }; + + Assert.True(attr.UnrecognizedArgumentHandlingWasSet); + } + + [Fact] + public void UnrecognizedArgumentHandlingWasSet_ReturnsFalse_WhenNotSet() + { + var attr = new CommandAttribute(); + + Assert.False(attr.UnrecognizedArgumentHandlingWasSet); + } + + [Fact] + public void Configure_SetsUnrecognizedArgumentHandling_WhenExplicitlySet() + { + var app = new CommandLineApplication(); + app.Conventions.UseCommandAttribute(); + + Assert.Equal(UnrecognizedArgumentHandling.CollectAndContinue, app.UnrecognizedArgumentHandling); + } + + [Fact] + public void Configure_DoesNotSetUnrecognizedArgumentHandling_WhenNotExplicitlySet() + { + var app = new CommandLineApplication(); + // Set to a specific value - it should remain unchanged if not explicitly set + app.UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.StopParsingAndCollect; + app.Conventions.UseCommandAttribute(); + + // Default when not set via attribute should be Throw (per the getter default) + // But since we're not calling UseDefaultConventions, the app's value should be preserved + Assert.Equal(UnrecognizedArgumentHandling.StopParsingAndCollect, app.UnrecognizedArgumentHandling); + } + + [Fact] + public void ClusterOptions_DefaultsToTrue() + { + var attr = new CommandAttribute(); + + Assert.True(attr.ClusterOptions); + } + + [Fact] + public void UnrecognizedArgumentHandling_DefaultsToThrow() + { + var attr = new CommandAttribute(); + + Assert.Equal(UnrecognizedArgumentHandling.Throw, attr.UnrecognizedArgumentHandling); + } + + #endregion + + #region Name Property Tests + + [Fact] + public void Name_SetToNull_ClearsNames() + { + // This tests lines 57-59: when Name is set to null, _names becomes empty + var attr = new CommandAttribute("initial-name"); + Assert.Equal("initial-name", attr.Name); + + attr.Name = null; + + Assert.Null(attr.Name); + Assert.Empty(attr.Names); + } + + [Fact] + public void Name_SetToValue_CreatesSingleElementArray() + { + var attr = new CommandAttribute(); + Assert.Null(attr.Name); + + attr.Name = "test-name"; + + Assert.Equal("test-name", attr.Name); + Assert.Single(attr.Names); + } + + [Fact] + public void Names_WithMultipleNames_FirstIsName() + { + var attr = new CommandAttribute("primary", "alias1", "alias2"); + + Assert.Equal("primary", attr.Name); + Assert.Equal(new[] { "primary", "alias1", "alias2" }, attr.Names); + } + + [Fact] + public void Name_Getter_ReturnsNull_WhenNamesEmpty() + { + var attr = new CommandAttribute(); + + Assert.Null(attr.Name); + Assert.Empty(attr.Names); + } + + #endregion + + #region Configure Method Tests + + [Command("cmd", "alias1", "alias2")] + private class CommandWithAliases + { } + + [Fact] + public void Configure_AddsAliases_FromNames() + { + // This tests lines 179-182: foreach loop adding aliases + var app = new CommandLineApplication(); + app.Conventions.UseCommandAttribute(); + + Assert.Equal("cmd", app.Name); + Assert.Contains("alias1", app.Names); + Assert.Contains("alias2", app.Names); + } + + [Command( + Name = "full-test", + FullName = "Full Test Command", + Description = "A test description", + ExtendedHelpText = "Extended help here", + ShowInHelpText = false, + AllowArgumentSeparator = true, + ResponseFileHandling = ResponseFileHandling.ParseArgsAsSpaceSeparated, + OptionsComparison = StringComparison.OrdinalIgnoreCase, + UsePagerForHelpText = true)] + private class CommandWithAllProperties + { } + + [Fact] + public void Configure_SetsAllProperties() + { + // This tests lines 184-192 in Configure method + var app = new CommandLineApplication(); + app.Conventions.UseCommandAttribute(); + + Assert.Equal("full-test", app.Name); + Assert.Equal("Full Test Command", app.FullName); + Assert.Equal("A test description", app.Description); + Assert.Equal("Extended help here", app.ExtendedHelpText); + Assert.False(app.ShowInHelpText); + Assert.True(app.AllowArgumentSeparator); + Assert.Equal(ResponseFileHandling.ParseArgsAsSpaceSeparated, app.ResponseFileHandling); + Assert.Equal(StringComparison.OrdinalIgnoreCase, app.OptionsComparison); + Assert.True(app.UsePagerForHelpText); + } + + [Command] + private class CommandWithNoName + { } + + [Fact] + public void Configure_PreservesExistingName_WhenAttributeNameIsNull() + { + // This tests line 177: app.Name = Name ?? app.Name + var app = new CommandLineApplication(); + app.Name = "existing-name"; + app.Conventions.UseCommandAttribute(); + + Assert.Equal("existing-name", app.Name); + } + + [Command("override-name")] + private class CommandWithName + { } + + [Fact] + public void Configure_OverridesExistingName_WhenAttributeNameIsSet() + { + var app = new CommandLineApplication(); + app.Name = "original-name"; + app.Conventions.UseCommandAttribute(); + + Assert.Equal("override-name", app.Name); + } + + [Fact] + public void Configure_SetsParseCulture() + { + // Test line 191: app.ValueParsers.ParseCulture = ParseCulture + var app = new CommandLineApplication(); + app.Conventions.UseCommandAttribute(); + + // The default ParseCulture is CurrentCulture + Assert.Equal(System.Globalization.CultureInfo.CurrentCulture, app.ValueParsers.ParseCulture); + } + + #endregion + + #region Constructor Tests + + [Fact] + public void Constructor_Default_HasEmptyNames() + { + var attr = new CommandAttribute(); + + Assert.Null(attr.Name); + Assert.Empty(attr.Names); + } + + [Fact] + public void Constructor_WithSingleName_SetsName() + { + var attr = new CommandAttribute("single"); + + Assert.Equal("single", attr.Name); + Assert.Single(attr.Names); + } + + [Fact] + public void Constructor_WithMultipleNames_SetsAllNames() + { + var attr = new CommandAttribute("primary", "secondary", "tertiary"); + + Assert.Equal("primary", attr.Name); + Assert.Equal(3, attr.Names.Count()); + } + + #endregion + + #region Configure Method Direct Tests + + [Fact] + public void Configure_Direct_SetsAllProperties() + { + // Directly test the internal Configure method + var attr = new CommandAttribute("test-cmd") + { + FullName = "Test Command Full Name", + Description = "Test description", + ExtendedHelpText = "Extended help text", + ShowInHelpText = false, + AllowArgumentSeparator = true, + ResponseFileHandling = ResponseFileHandling.ParseArgsAsLineSeparated, + OptionsComparison = StringComparison.OrdinalIgnoreCase, + UsePagerForHelpText = true + }; + + var app = new CommandLineApplication(); + attr.Configure(app); + + Assert.Equal("test-cmd", app.Name); + Assert.Equal("Test Command Full Name", app.FullName); + Assert.Equal("Test description", app.Description); + Assert.Equal("Extended help text", app.ExtendedHelpText); + Assert.False(app.ShowInHelpText); + Assert.True(app.AllowArgumentSeparator); + Assert.Equal(ResponseFileHandling.ParseArgsAsLineSeparated, app.ResponseFileHandling); + Assert.Equal(StringComparison.OrdinalIgnoreCase, app.OptionsComparison); + Assert.True(app.UsePagerForHelpText); + } + + [Fact] + public void Configure_Direct_WithAliases_AddsAllNames() + { + // Test lines 179-182: foreach loop for aliases + var attr = new CommandAttribute("primary", "alias1", "alias2"); + + var app = new CommandLineApplication(); + attr.Configure(app); + + Assert.Equal("primary", app.Name); + Assert.Contains("alias1", app.Names); + Assert.Contains("alias2", app.Names); + Assert.Equal(3, app.Names.Count()); + } + + [Fact] + public void Configure_Direct_WithNullName_PreservesExistingName() + { + // Test line 177: app.Name = Name ?? app.Name + var attr = new CommandAttribute(); + Assert.Null(attr.Name); + + var app = new CommandLineApplication { Name = "existing" }; + attr.Configure(app); + + Assert.Equal("existing", app.Name); + } + + [Fact] + public void Configure_Direct_WithClusterOptionsSet_SetsValue() + { + // Test lines 194-197 + var attr = new CommandAttribute { ClusterOptions = false }; + + var app = new CommandLineApplication { ClusterOptions = true }; + attr.Configure(app); + + Assert.False(app.ClusterOptions); + } + + [Fact] + public void Configure_Direct_WithClusterOptionsNotSet_PreservesDefault() + { + // Test lines 194-197 (branch not taken) + var attr = new CommandAttribute(); + Assert.False(attr.ClusterOptionsWasSet); + + var app = new CommandLineApplication { ClusterOptions = false }; + attr.Configure(app); + + // Should remain false because ClusterOptions wasn't explicitly set + Assert.False(app.ClusterOptions); + } + + [Fact] + public void Configure_Direct_WithUnrecognizedArgumentHandlingSet_SetsValue() + { + // Test lines 199-202 + var attr = new CommandAttribute + { + UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.StopParsingAndCollect + }; + + var app = new CommandLineApplication(); + attr.Configure(app); + + Assert.Equal(UnrecognizedArgumentHandling.StopParsingAndCollect, app.UnrecognizedArgumentHandling); + } + + [Fact] + public void Configure_Direct_WithUnrecognizedArgumentHandlingNotSet_PreservesDefault() + { + // Test lines 199-202 (branch not taken) + var attr = new CommandAttribute(); + Assert.False(attr.UnrecognizedArgumentHandlingWasSet); + + var app = new CommandLineApplication + { + UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue + }; + attr.Configure(app); + + // Should remain CollectAndContinue because UnrecognizedArgumentHandling wasn't explicitly set + Assert.Equal(UnrecognizedArgumentHandling.CollectAndContinue, app.UnrecognizedArgumentHandling); + } + + [Fact] + public void Configure_Direct_SetsParseCulture() + { + // Test line 191 + var attr = new CommandAttribute + { + ParseCulture = System.Globalization.CultureInfo.InvariantCulture + }; + + var app = new CommandLineApplication(); + attr.Configure(app); + + Assert.Equal(System.Globalization.CultureInfo.InvariantCulture, app.ValueParsers.ParseCulture); + } + + [Fact] + public void Configure_Direct_NoAliases_DoesNotAddNames() + { + // Test lines 179-182 with empty iteration + var attr = new CommandAttribute("single-name"); + + var app = new CommandLineApplication(); + attr.Configure(app); + + Assert.Equal("single-name", app.Name); + Assert.Single(app.Names); + } + + #endregion } } diff --git a/test/CommandLineUtils.Tests/Conventions/CommandAttributeConventionTests.cs b/test/CommandLineUtils.Tests/Conventions/CommandAttributeConventionTests.cs new file mode 100644 index 00000000..1428b301 --- /dev/null +++ b/test/CommandLineUtils.Tests/Conventions/CommandAttributeConventionTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; +using System.Linq; +using McMaster.Extensions.CommandLineUtils.Conventions; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.Conventions +{ + public class CommandAttributeConventionTests + { + #region ValidationAttribute on Model Type Tests + + /// + /// Custom validation attribute for testing. + /// + private class CustomModelValidationAttribute : ValidationAttribute + { + public override bool IsValid(object? value) + { + return true; + } + } + + [CustomModelValidation] + private class ModelWithValidationAttribute + { + [Option("-n|--name")] + public string? Name { get; set; } + } + + [Fact] + public void Apply_AddsValidationAttributeFromModelType() + { + // This tests lines 50-53: processing ValidationAttribute on model type + var app = new CommandLineApplication(); + app.Conventions.AddConvention(new CommandAttributeConvention()); + + // Verify that a validator was added + Assert.NotEmpty(app.Validators); + Assert.Single(app.Validators); + } + + private class SecondCustomValidationAttribute : ValidationAttribute + { + public override bool IsValid(object? value) => true; + } + + [CustomModelValidation] + [SecondCustomValidation] + private class ModelWithMultipleValidationAttributes + { + [Option] + public string? Value { get; set; } + } + + [Fact] + public void Apply_AddsMultipleValidationAttributesFromModelType() + { + var app = new CommandLineApplication(); + app.Conventions.AddConvention(new CommandAttributeConvention()); + + // Should have two validators from the two attributes + Assert.Equal(2, app.Validators.Count); + } + + #endregion + + #region Fallback Path Tests + + private class ModelWithoutCommandAttribute + { + [Option("-v|--verbose")] + public bool Verbose { get; set; } + } + + [Fact] + public void Apply_HandlesModelWithoutCommandAttribute() + { + // Tests line 36: attribute?.Configure() when attribute is null + var app = new CommandLineApplication(); + app.Name = "test-app"; + app.Conventions.AddConvention(new CommandAttributeConvention()); + + // Name should remain unchanged since no CommandAttribute + Assert.Equal("test-app", app.Name); + } + + [Command("my-cmd", Description = "Test command")] + private class ModelWithCommandAttribute + { + [Option] + public string? Option1 { get; set; } + } + + [Fact] + public void Apply_ProcessesCommandAttribute() + { + var app = new CommandLineApplication(); + app.Conventions.AddConvention(new CommandAttributeConvention()); + + Assert.Equal("my-cmd", app.Name); + Assert.Equal("Test command", app.Description); + } + + #endregion + + #region Subcommand Processing Tests + + [Command("parent")] + [Subcommand(typeof(ChildCommand))] + private class ParentCommand + { + } + + [Command("child", Description = "Child command")] + private class ChildCommand + { + } + + [Fact] + public void Apply_ProcessesSubcommands() + { + // Tests lines 39-45: recursive processing of subcommands + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + Assert.Equal("parent", app.Name); + var subCmd = app.Commands.FirstOrDefault(); + Assert.NotNull(subCmd); + Assert.Equal("child", subCmd.Name); + Assert.Equal("Child command", subCmd.Description); + } + + #endregion + + #region Non-Generic Application Tests + + [Fact] + public void Apply_ReturnsEarly_WhenModelTypeIsNull() + { + // Tests lines 20-22: early return when ModelType is null + var app = new CommandLineApplication(); + var convention = new CommandAttributeConvention(); + + // Should not throw, just return early + convention.Apply(new ConventionContext(app, null)); + + // App should be unchanged + Assert.Null(app.Description); + } + + #endregion + } +} diff --git a/test/CommandLineUtils.Tests/Conventions/OptionAttributeConventionBaseTests.cs b/test/CommandLineUtils.Tests/Conventions/OptionAttributeConventionBaseTests.cs new file mode 100644 index 00000000..692f774e --- /dev/null +++ b/test/CommandLineUtils.Tests/Conventions/OptionAttributeConventionBaseTests.cs @@ -0,0 +1,502 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using McMaster.Extensions.CommandLineUtils.Conventions; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.Conventions +{ + /// + /// Comprehensive tests for OptionAttributeConventionBase to achieve full code coverage. + /// + public class OptionAttributeConventionBaseTests + { + #region Test Helper Convention + + /// + /// A test convention that exposes the protected AddOption method for testing. + /// + private class TestOptionConvention : OptionAttributeConventionBase, IConvention + { + private readonly PropertyInfo _property; + private readonly CommandOption _option; + + public TestOptionConvention(PropertyInfo property, CommandOption option) + { + _property = property; + _option = option; + } + + public void Apply(ConventionContext context) + { + AddOption(context, _option, _property); + } + + public static void CallAddOption(ConventionContext context, CommandOption option, PropertyInfo prop) + { + var convention = new TestOptionConvention(prop, option); + convention.AddOption(context, option, prop); + } + } + + #endregion + + #region Test Models + + private class ModelWithStringOption + { + public string? Value { get; set; } + } + + private class ModelWithStringOptionWithDefault + { + public string Value { get; set; } = "default"; + } + + private class ModelWithIntOption + { + public int Count { get; set; } + } + + private class ModelWithStringArrayOption + { + public string[]? Values { get; set; } + } + + private class ModelWithStringArrayOptionWithDefault + { + public string[] Values { get; set; } = new[] { "a", "b" }; + } + + private class ModelWithBoolOption + { + public bool Flag { get; set; } + } + + private class ModelWithBoolArrayOption + { + public bool[]? Flags { get; set; } + } + + private class ModelWithNullableBoolOption + { + public bool? Flag { get; set; } + } + + private class ModelWithValidation + { + [Required] + public string? Name { get; set; } + } + + private class ModelWithValueTuple + { + public (bool hasValue, string? value) Option { get; set; } + } + + #endregion + + #region Null ModelAccessor Tests + + [Fact] + public void AddOption_ThrowsWhenModelAccessorIsNull() + { + var app = new CommandLineApplication(); // Non-generic, no model + var context = new ConventionContext(app, null); + var prop = typeof(ModelWithStringOption).GetProperty(nameof(ModelWithStringOption.Value))!; + var option = new CommandOption("-v|--value", CommandOptionType.SingleValue); + + var ex = Assert.Throws(() => + TestOptionConvention.CallAddOption(context, option, prop)); + + Assert.Equal(Strings.ConventionRequiresModel, ex.Message); + } + + #endregion + + #region ValidationAttribute Tests + + [Fact] + public void AddOption_AddsValidationAttributes() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithValidation)); + var prop = typeof(ModelWithValidation).GetProperty(nameof(ModelWithValidation.Name))!; + var option = app.Option("-n|--name", "Name", CommandOptionType.SingleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + + Assert.Single(option.Validators); + } + + #endregion + + #region NoValue Option Type Tests + + [Fact] + public void AddOption_NoValue_ThrowsForNonBooleanType() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithStringOption)); + var prop = typeof(ModelWithStringOption).GetProperty(nameof(ModelWithStringOption.Value))!; + var option = new CommandOption("-v|--value", CommandOptionType.NoValue); + app.AddOption(option); + + var ex = Assert.Throws(() => + TestOptionConvention.CallAddOption(context, option, prop)); + + Assert.Equal(Strings.NoValueTypesMustBeBoolean, ex.Message); + } + + [Fact] + public void AddOption_NoValue_WorksWithBool() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithBoolOption)); + var prop = typeof(ModelWithBoolOption).GetProperty(nameof(ModelWithBoolOption.Flag))!; + var option = app.Option("-f|--flag", "Flag", CommandOptionType.NoValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse("-f"); + + Assert.True(app.Model.Flag); + } + + [Fact] + public void AddOption_NoValue_WorksWithNullableBool() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithNullableBoolOption)); + var prop = typeof(ModelWithNullableBoolOption).GetProperty(nameof(ModelWithNullableBoolOption.Flag))!; + var option = app.Option("-f|--flag", "Flag", CommandOptionType.NoValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse("-f"); + + Assert.True(app.Model.Flag); + } + + [Fact] + public void AddOption_NoValue_WorksWithBoolArray() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithBoolArrayOption)); + var prop = typeof(ModelWithBoolArrayOption).GetProperty(nameof(ModelWithBoolArrayOption.Flags))!; + var option = app.Option("-f|--flag", "Flag", CommandOptionType.NoValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse("-f", "-f", "-f"); + + Assert.NotNull(app.Model.Flags); + Assert.Equal(3, app.Model.Flags!.Length); + Assert.All(app.Model.Flags, f => Assert.True(f)); + } + + [Fact] + public void AddOption_NoValue_BoolArrayWithNoValue_SetsEmptyArray() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithBoolArrayOption)); + var prop = typeof(ModelWithBoolArrayOption).GetProperty(nameof(ModelWithBoolArrayOption.Flags))!; + var option = app.Option("-f|--flag", "Flag", CommandOptionType.NoValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse(); // No flag provided + + // The model should have an empty array set + Assert.NotNull(app.Model.Flags); + Assert.Empty(app.Model.Flags!); + } + + [Fact] + public void AddOption_NoValue_BoolWithNoValue_DoesNotSetProperty() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithBoolOption)); + var prop = typeof(ModelWithBoolOption).GetProperty(nameof(ModelWithBoolOption.Flag))!; + var option = app.Option("-f|--flag", "Flag", CommandOptionType.NoValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse(); // No flag provided + + Assert.False(app.Model.Flag); + } + + #endregion + + #region SingleValue Option Type Tests + + [Fact] + public void AddOption_SingleValue_ParsesValue() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithStringOption)); + var prop = typeof(ModelWithStringOption).GetProperty(nameof(ModelWithStringOption.Value))!; + var option = app.Option("-v|--value", "Value", CommandOptionType.SingleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse("-v", "test"); + + Assert.Equal("test", app.Model.Value); + } + + [Fact] + public void AddOption_SingleValue_UsesDefaultValue() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithStringOptionWithDefault)); + var prop = typeof(ModelWithStringOptionWithDefault).GetProperty(nameof(ModelWithStringOptionWithDefault.Value))!; + var option = app.Option("-v|--value", "Value", CommandOptionType.SingleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse(); // No value provided + + Assert.Equal("default", app.Model.Value); + Assert.Equal("default", option.DefaultValue); + } + + [Fact] + public void AddOption_SingleValue_ParsesIntValue() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithIntOption)); + var prop = typeof(ModelWithIntOption).GetProperty(nameof(ModelWithIntOption.Count))!; + var option = app.Option("-c|--count", "Count", CommandOptionType.SingleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse("-c", "42"); + + Assert.Equal(42, app.Model.Count); + } + + [Fact] + public void AddOption_SingleValue_WithValueTuple_SkipsDefaultValueProcessing() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithValueTuple)); + var prop = typeof(ModelWithValueTuple).GetProperty(nameof(ModelWithValueTuple.Option))!; + var option = app.Option("-o|--option", "Option", CommandOptionType.SingleOrNoValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse(); // No value provided + + // Should not throw and should not set default value for value tuple + Assert.Null(option.DefaultValue); + } + + #endregion + + #region MultipleValue Option Type Tests + + [Fact] + public void AddOption_MultipleValue_ParsesValues() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithStringArrayOption)); + var prop = typeof(ModelWithStringArrayOption).GetProperty(nameof(ModelWithStringArrayOption.Values))!; + var option = app.Option("-v|--values", "Values", CommandOptionType.MultipleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse("-v", "a", "-v", "b", "-v", "c"); + + Assert.NotNull(app.Model.Values); + Assert.Equal(new[] { "a", "b", "c" }, app.Model.Values); + } + + [Fact] + public void AddOption_MultipleValue_UsesDefaultValues() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithStringArrayOptionWithDefault)); + var prop = typeof(ModelWithStringArrayOptionWithDefault).GetProperty(nameof(ModelWithStringArrayOptionWithDefault.Values))!; + var option = app.Option("-v|--values", "Values", CommandOptionType.MultipleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse(); // No values provided + + Assert.Equal(new[] { "a", "b" }, app.Model.Values); + Assert.Equal("a, b", option.DefaultValue); + } + + [Fact] + public void AddOption_MultipleValue_NoDefaultWhenNull() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithStringArrayOption)); + var prop = typeof(ModelWithStringArrayOption).GetProperty(nameof(ModelWithStringArrayOption.Values))!; + var option = app.Option("-v|--values", "Values", CommandOptionType.MultipleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse(); // No values provided + + Assert.Null(app.Model.Values); + Assert.Null(option.DefaultValue); + } + + private class ModelWithObjectArrayOptionWithNulls + { + public object?[] Values { get; set; } = new object?[] { "a", null, "b" }; + } + + [Fact] + public void AddOption_MultipleValue_HandlesNullValuesInDefaults() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithObjectArrayOptionWithNulls)); + var prop = typeof(ModelWithObjectArrayOptionWithNulls).GetProperty(nameof(ModelWithObjectArrayOptionWithNulls.Values))!; + var option = app.Option("-v|--values", "Values", CommandOptionType.MultipleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse(); // No values provided, uses default with nulls + + // Default value should handle nulls gracefully + Assert.NotNull(option.DefaultValue); + Assert.Contains("a", option.DefaultValue); + Assert.Contains("b", option.DefaultValue); + } + + private class ModelWithEmptyArrayOption + { + public string[] Values { get; set; } = Array.Empty(); + } + + [Fact] + public void AddOption_MultipleValue_NoDefaultWhenEmptyArray() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithEmptyArrayOption)); + var prop = typeof(ModelWithEmptyArrayOption).GetProperty(nameof(ModelWithEmptyArrayOption.Values))!; + var option = app.Option("-v|--values", "Values", CommandOptionType.MultipleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + app.Parse(); // No values provided + + // Empty array should not set default value + Assert.Null(option.DefaultValue); + } + + #endregion + + #region Ambiguous Option Name Tests + + [Fact] + public void AddOption_ThrowsForAmbiguousShortName_DifferentProperties() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithStringOption)); + + // Add first option + var prop1 = typeof(ModelWithStringOption).GetProperty(nameof(ModelWithStringOption.Value))!; + var option1 = app.Option("-v|--value1", "Value 1", CommandOptionType.SingleValue); + TestOptionConvention.CallAddOption(context, option1, prop1); + + // Try to add second option with same short name but different property + var prop2 = typeof(ModelWithIntOption).GetProperty(nameof(ModelWithIntOption.Count))!; + var option2 = new CommandOption("-v|--value2", CommandOptionType.SingleValue); + app.AddOption(option2); + + var ex = Assert.Throws(() => + TestOptionConvention.CallAddOption(context, option2, prop2)); + + Assert.Contains("v", ex.Message); + } + + [Fact] + public void AddOption_ThrowsForAmbiguousLongName_DifferentProperties() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithStringOption)); + + // Add first option with only long name + var prop1 = typeof(ModelWithStringOption).GetProperty(nameof(ModelWithStringOption.Value))!; + var option1 = app.Option("--value", "Value 1", CommandOptionType.SingleValue); + option1.ShortName = ""; // Clear short name + TestOptionConvention.CallAddOption(context, option1, prop1); + + // Try to add second option with same long name but different property + var prop2 = typeof(ModelWithIntOption).GetProperty(nameof(ModelWithIntOption.Count))!; + var option2 = new CommandOption("--value", CommandOptionType.SingleValue); + option2.ShortName = ""; // Clear short name + app.AddOption(option2); + + var ex = Assert.Throws(() => + TestOptionConvention.CallAddOption(context, option2, prop2)); + + Assert.Contains("value", ex.Message); + } + + #endregion + + #region Error Cases for Parser Not Found + + private class ModelWithUnsupportedCollectionType + { + // A custom type that has no collection parser registered + public CustomCollection? Items { get; set; } + } + + private class CustomCollection : System.Collections.Generic.List + { + } + + private class ModelWithUnsupportedValueType + { + // A custom type that has no value parser registered + public CustomValueType Value { get; set; } + } + + private struct CustomValueType + { + public int Inner { get; set; } + } + + [Fact] + public void AddOption_MultipleValue_ThrowsWhenNoCollectionParser() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithUnsupportedCollectionType)); + var prop = typeof(ModelWithUnsupportedCollectionType).GetProperty(nameof(ModelWithUnsupportedCollectionType.Items))!; + var option = app.Option("-i|--items", "Items", CommandOptionType.MultipleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + + // The exception is thrown during parsing, not during setup + var ex = Assert.Throws(() => app.Parse("-i", "value")); + Assert.Contains("Could not automatically determine", ex.Message); + } + + [Fact] + public void AddOption_SingleValue_ThrowsWhenNoValueParser() + { + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ModelWithUnsupportedValueType)); + var prop = typeof(ModelWithUnsupportedValueType).GetProperty(nameof(ModelWithUnsupportedValueType.Value))!; + var option = app.Option("-v|--value", "Value", CommandOptionType.SingleValue); + + TestOptionConvention.CallAddOption(context, option, prop); + + // The exception is thrown during parsing, not during setup + var ex = Assert.Throws(() => app.Parse("-v", "test")); + Assert.Contains("Could not automatically determine", ex.Message); + } + + #endregion + + #region EnsureDoesNotHaveArgumentAttribute Tests + + private class ModelWithArgumentAndOption + { + [Argument(0)] + [Option] + public string? Value { get; set; } + } + + // Note: EnsureDoesNotHaveArgumentAttribute is tested indirectly through the existing + // OptionAttributeTests.ThrowsWhenOptionAndArgumentAreSpecified test + + #endregion + } +} diff --git a/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs b/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs index e3a6f9d6..7e40b5f5 100644 --- a/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs @@ -223,5 +223,75 @@ public void NestedHelpOptionsChoosesHelpOptionNearestSelectedCommand(string[] ar Assert.Contains(helpNeedle, outData); } + + #region Inherited HelpOption Tests + + private class BaseWithHelpOption + { + [HelpOption("-h|--help")] + public bool ShowHelp { get; set; } + } + + private class DerivedFromHelpBase : BaseWithHelpOption + { + [Option("-n|--name")] + public string? Name { get; set; } + } + + [Fact] + public void InheritedHelpOption_IsRecognized() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + Assert.NotNull(app.OptionHelp); + Assert.Equal("h", app.OptionHelp?.ShortName); + Assert.Equal("help", app.OptionHelp?.LongName); + } + + [Fact] + public void ApplyingHelpOptionConventionTwice_DoesNotThrow() + { + // This tests the skip logic in OptionAttributeConventionBase.AddOption + // When the same HelpOption is processed twice, it should skip rather than throw + var app = new CommandLineApplication(); + + // Apply HelpOption convention twice - second application should skip + app.Conventions.UseHelpOptionAttribute(); + app.Conventions.UseHelpOptionAttribute(); + + Assert.NotNull(app.OptionHelp); + Assert.Equal("h", app.OptionHelp?.ShortName); + } + + private class BaseWithLongOnlyHelpOption + { + [HelpOption("--help")] + public bool ShowHelp { get; set; } + } + + private class DerivedFromLongOnlyHelpBase : BaseWithLongOnlyHelpOption + { + [Option("--name")] + public string? Name { get; set; } + } + + [Fact] + public void ApplyingHelpOptionConventionTwice_WithLongOnlyOption_DoesNotThrow() + { + // This tests the skip logic in OptionAttributeConventionBase.AddOption lines 61-63 + // When HelpOption has only long name (no short name), the long name skip logic is tested + var app = new CommandLineApplication(); + + // Apply HelpOption convention twice - second application should skip via long name check + app.Conventions.UseHelpOptionAttribute(); + app.Conventions.UseHelpOptionAttribute(); + + Assert.NotNull(app.OptionHelp); + Assert.Empty(app.OptionHelp?.ShortName ?? ""); + Assert.Equal("help", app.OptionHelp?.LongName); + } + + #endregion } } diff --git a/test/CommandLineUtils.Tests/OptionAttributeTests.cs b/test/CommandLineUtils.Tests/OptionAttributeTests.cs index cb1da1c5..feb8f475 100644 --- a/test/CommandLineUtils.Tests/OptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/OptionAttributeTests.cs @@ -350,5 +350,124 @@ private CommandOption CreateOption(Type propType, string propName) app.Conventions.UseOptionAttributes(); return app.Options.First(); } + + #region Inherited Option Tests + + private class BaseCommand + { + [Option("-v|--verbose", Description = "Enable verbose output")] + public bool Verbose { get; set; } + + [Option("-n|--name ", Description = "The name")] + public string? Name { get; set; } + } + + private class DerivedCommand : BaseCommand + { + [Option("-c|--count ", Description = "The count")] + public int Count { get; set; } + } + + [Fact] + public void InheritedOptions_DoNotCauseAmbiguityError() + { + // This tests that when the same property is processed multiple times + // (which can happen with inheritance), it doesn't throw an ambiguity error + var app = Create(); + + // Should have options from both base and derived class + Assert.Equal(3, app.Options.Count); + + var verbose = Assert.Single(app.Options, o => o.LongName == "verbose"); + Assert.Equal("v", verbose.ShortName); + + var name = Assert.Single(app.Options, o => o.LongName == "name"); + Assert.Equal("n", name.ShortName); + + var count = Assert.Single(app.Options, o => o.LongName == "count"); + Assert.Equal("c", count.ShortName); + } + + [Fact] + public void InheritedShortOption_DoesNotConflict_WhenSameProperty() + { + // Create an app with inherited options and verify short names work + var app = Create(); + app.Parse("-v", "-n", "test", "-c", "5"); + + var model = app.Model; + Assert.True(model.Verbose); + Assert.Equal("test", model.Name); + Assert.Equal(5, model.Count); + } + + [Fact] + public void InheritedLongOption_DoesNotConflict_WhenSameProperty() + { + // Create an app with inherited options and verify long names work + var app = Create(); + app.Parse("--verbose", "--name", "test", "--count", "10"); + + var model = app.Model; + Assert.True(model.Verbose); + Assert.Equal("test", model.Name); + Assert.Equal(10, model.Count); + } + + [Fact] + public void ApplyingOptionConventionTwice_DoesNotThrow() + { + // This tests the skip logic in OptionAttributeConventionBase.AddOption + // When the same option is processed twice, it should skip rather than throw + var app = new CommandLineApplication(); + + // Apply OptionAttribute convention twice - second application should skip + app.Conventions.UseOptionAttributes(); + app.Conventions.UseOptionAttributes(); + + // Should have options from both base and derived class (not duplicates) + Assert.Equal(3, app.Options.Count); + Assert.Single(app.Options, o => o.LongName == "verbose"); + Assert.Single(app.Options, o => o.LongName == "name"); + Assert.Single(app.Options, o => o.LongName == "count"); + } + + private class BaseCommandWithLongOnlyOptions + { + [Option(ShortName = "", LongName = "verbose", Description = "Enable verbose output")] + public bool Verbose { get; set; } + + [Option(ShortName = "", LongName = "name", Description = "The name")] + public string? Name { get; set; } + } + + private class DerivedFromLongOnlyBase : BaseCommandWithLongOnlyOptions + { + [Option(ShortName = "", LongName = "count", Description = "The count")] + public int Count { get; set; } + } + + [Fact] + public void ApplyingOptionConventionTwice_WithLongOnlyOptions_DoesNotThrow() + { + // This tests the skip logic in OptionAttributeConventionBase.AddOption lines 61-63 + // When options have only long names (no short names), the long name skip logic is tested + var app = new CommandLineApplication(); + + // Apply OptionAttribute convention twice - second application should skip + app.Conventions.UseOptionAttributes(); + app.Conventions.UseOptionAttributes(); + + // Should have options from both base and derived class (not duplicates) + Assert.Equal(3, app.Options.Count); + Assert.Single(app.Options, o => o.LongName == "verbose"); + Assert.Single(app.Options, o => o.LongName == "name"); + Assert.Single(app.Options, o => o.LongName == "count"); + + // Verify short names are empty + Assert.All(app.Options, o => Assert.Empty(o.ShortName)); + } + + #endregion } } diff --git a/test/CommandLineUtils.Tests/RemainingArgsPropertyConventionTests.cs b/test/CommandLineUtils.Tests/RemainingArgsPropertyConventionTests.cs index 8f81b27b..fda30b1d 100644 --- a/test/CommandLineUtils.Tests/RemainingArgsPropertyConventionTests.cs +++ b/test/CommandLineUtils.Tests/RemainingArgsPropertyConventionTests.cs @@ -1,8 +1,10 @@ // Copyright (c) Nate McMaster. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Collections.Generic; using McMaster.Extensions.CommandLineUtils.Conventions; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; using Xunit; using Xunit.Abstractions; @@ -82,5 +84,148 @@ public void ItSetsRemainingArguments_List() app.Parse("a", "b"); Assert.Equal(new[] { "a", "b" }, app.Model.RemainingArguments); } + + #region Reflection Fallback and Error Tests + + private class RemainingArgs_InvalidType + { + // This type is not assignable to IReadOnlyList + public int RemainingArguments { get; set; } + } + + [Fact] + public void ThrowsWhenPropertyTypeIsInvalid() + { + // This tests lines 57-59: InvalidOperationException when property type + // is not assignable to IReadOnlyList + var app = new CommandLineApplication + { + UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.StopParsingAndCollect, + }; + + var ex = Assert.Throws(() => + app.Conventions.AddConvention(new RemainingArgsPropertyConvention())); + + Assert.Contains("RemainingArgs", ex.Message); + } + + private class RemainingArgs_IReadOnlyList + { + public IReadOnlyList? RemainingArguments { get; set; } + } + + [Fact] + public void ItSetsRemainingArguments_IReadOnlyList() + { + // Tests the IReadOnlyList path (lines 62-63) + var app = Create(); + app.Parse("x", "y", "z"); + Assert.Equal(new[] { "x", "y", "z" }, app.Model.RemainingArguments); + } + + private class RemainingArgs_IEnumerable + { + public IEnumerable? RemainingArguments { get; set; } + } + + [Fact] + public void ThrowsWhenPropertyTypeIsIEnumerable() + { + // IEnumerable is NOT valid because the convention assigns IReadOnlyList + // and IEnumerable is not assignable from IReadOnlyList + var app = new CommandLineApplication + { + UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.StopParsingAndCollect, + }; + + var ex = Assert.Throws(() => + app.Conventions.AddConvention(new RemainingArgsPropertyConvention())); + + Assert.Contains("RemainingArgs", ex.Message); + } + + private class NoRemainingArgsProperty + { + public string? SomeOtherProperty { get; set; } + } + + [Fact] + public void DoesNotThrowWhenNoRemainingArgsProperty() + { + // Tests lines 40-42: early return when no matching property + var app = new CommandLineApplication + { + UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.StopParsingAndCollect, + }; + + // Should not throw, just silently skip + app.Conventions.AddConvention(new RemainingArgsPropertyConvention()); + app.Parse("extra", "args"); + + // No property to set, so nothing happens + Assert.Null(app.Model.SomeOtherProperty); + } + + /// + /// Tests the reflection fallback path (lines 45-48) when no generated metadata is available. + /// We need to ensure the registry is clear so no metadata is found. + /// + private class RemainingArgsReflectionFallback + { + public string[]? RemainingArguments { get; set; } + } + + [Fact] + public void UsesReflectionFallback_WhenNoGeneratedMetadata() + { + // Ensure no registered metadata + CommandMetadataRegistry.Clear(); + + var app = new CommandLineApplication + { + UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.StopParsingAndCollect, + }; + + // Apply convention directly - this uses reflection fallback since no metadata is registered + app.Conventions.AddConvention(new RemainingArgsPropertyConvention()); + app.Parse("arg1", "arg2", "arg3"); + + Assert.Equal(new[] { "arg1", "arg2", "arg3" }, app.Model.RemainingArguments); + } + + private class RemainingArgsPrivateProperty + { + private string[]? RemainingArguments { get; set; } + + public string[]? GetRemainingArgs() => RemainingArguments; + } + + [Fact] + public void FindsPrivateRemainingArgsProperty() + { + // Tests that the property binding flags include NonPublic + var app = Create(); + app.Parse("private", "args"); + Assert.Equal(new[] { "private", "args" }, app.Model.GetRemainingArgs()); + } + + private class RemainingArgs_StaticProperty + { + public static string[]? RemainingArguments { get; set; } + } + + [Fact] + public void FindsStaticRemainingArgsProperty() + { + // Reset static property + RemainingArgs_StaticProperty.RemainingArguments = null; + + // Tests that the property binding flags include Static + var app = Create(); + app.Parse("static", "args"); + Assert.Equal(new[] { "static", "args" }, RemainingArgs_StaticProperty.RemainingArguments); + } + + #endregion } } diff --git a/test/CommandLineUtils.Tests/SourceGeneration/ActivatorModelFactoryTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/ActivatorModelFactoryTests.cs new file mode 100644 index 00000000..485a3904 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/ActivatorModelFactoryTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class ActivatorModelFactoryTests + { + private class SimpleModel + { + public string? Value { get; set; } + } + + private class ModelWithDefaultValue + { + public string Name { get; set; } = "default"; + } + + private class ModelWithoutParameterlessConstructor + { + public ModelWithoutParameterlessConstructor(string required) + { + Required = required; + } + + public string Required { get; } + } + + private class TestServiceProvider : IServiceProvider + { + private readonly object? _service; + private readonly Type? _serviceType; + + public TestServiceProvider(Type? serviceType = null, object? service = null) + { + _serviceType = serviceType; + _service = service; + } + + public object? GetService(Type serviceType) + { + if (_serviceType != null && serviceType == _serviceType) + { + return _service; + } + return null; + } + } + + [Fact] + public void Create_WithNoServices_UsesActivator() + { + var factory = new ActivatorModelFactory(typeof(SimpleModel)); + + var instance = factory.Create(); + + Assert.NotNull(instance); + Assert.IsType(instance); + } + + [Fact] + public void Create_ReturnsNewInstanceEachTime() + { + var factory = new ActivatorModelFactory(typeof(SimpleModel)); + + var instance1 = factory.Create(); + var instance2 = factory.Create(); + + Assert.NotSame(instance1, instance2); + } + + [Fact] + public void Create_PreservesDefaultValues() + { + var factory = new ActivatorModelFactory(typeof(ModelWithDefaultValue)); + + var instance = (ModelWithDefaultValue)factory.Create(); + + Assert.Equal("default", instance.Name); + } + + [Fact] + public void Create_WithServices_UsesDI_WhenServiceExists() + { + var expectedModel = new SimpleModel { Value = "from DI" }; + var services = new TestServiceProvider(typeof(SimpleModel), expectedModel); + var factory = new ActivatorModelFactory(typeof(SimpleModel), services); + + var instance = factory.Create(); + + Assert.Same(expectedModel, instance); + } + + [Fact] + public void Create_WithServices_FallsBackToActivator_WhenServiceNotFound() + { + var services = new TestServiceProvider(); // No services registered + var factory = new ActivatorModelFactory(typeof(SimpleModel), services); + + var instance = factory.Create(); + + Assert.NotNull(instance); + Assert.IsType(instance); + } + + [Fact] + public void Create_WithNullServices_UsesActivator() + { + var factory = new ActivatorModelFactory(typeof(SimpleModel), services: null); + + var instance = factory.Create(); + + Assert.NotNull(instance); + Assert.IsType(instance); + } + + [Fact] + public void Create_TypeWithoutParameterlessConstructor_ThrowsException() + { + var factory = new ActivatorModelFactory(typeof(ModelWithoutParameterlessConstructor)); + + Assert.Throws(() => factory.Create()); + } + + [Fact] + public void Create_TypeWithoutParameterlessConstructor_WorksWithDI() + { + var expectedModel = new ModelWithoutParameterlessConstructor("from DI"); + var services = new TestServiceProvider(typeof(ModelWithoutParameterlessConstructor), expectedModel); + var factory = new ActivatorModelFactory(typeof(ModelWithoutParameterlessConstructor), services); + + var instance = factory.Create(); + + Assert.Same(expectedModel, instance); + Assert.Equal("from DI", ((ModelWithoutParameterlessConstructor)instance).Required); + } + + [Fact] + public void Create_ImplementsIModelFactory() + { + var factory = new ActivatorModelFactory(typeof(SimpleModel)); + + Assert.IsAssignableFrom(factory); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/AdvancedMetadataProviderTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/AdvancedMetadataProviderTests.cs new file mode 100644 index 00000000..1968c322 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/AdvancedMetadataProviderTests.cs @@ -0,0 +1,440 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + /// + /// Tests for advanced metadata provider features including: + /// - SpecialPropertiesMetadata (Parent, Subcommand, RemainingArguments) + /// - Type-level HelpOption and VersionOption + /// - VersionOptionFromMember + /// - Constructor injection + /// - Command name inference + /// + public class AdvancedMetadataProviderTests + { + #region SpecialPropertiesMetadata Tests + + [Command(Name = "parent")] + [Subcommand(typeof(ChildWithParent))] + private class ParentWithSubcommandProperty + { + public object? Subcommand { get; set; } + } + + [Command(Name = "child")] + private class ChildWithParent + { + public ParentWithSubcommandProperty? Parent { get; set; } + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsParentProperty() + { + var provider = new ReflectionMetadataProvider(typeof(ChildWithParent)); + + Assert.NotNull(provider.SpecialProperties); + Assert.NotNull(provider.SpecialProperties!.ParentSetter); + Assert.Equal(typeof(ParentWithSubcommandProperty), provider.SpecialProperties.ParentType); + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsSubcommandProperty() + { + var provider = new ReflectionMetadataProvider(typeof(ParentWithSubcommandProperty)); + + Assert.NotNull(provider.SpecialProperties); + Assert.NotNull(provider.SpecialProperties!.SubcommandSetter); + Assert.Equal(typeof(object), provider.SpecialProperties.SubcommandType); + } + + [Command(Name = "echo", UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue)] + private class CommandWithRemainingArgs + { + public string[]? RemainingArguments { get; set; } + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsRemainingArgumentsProperty() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithRemainingArgs)); + + Assert.NotNull(provider.SpecialProperties); + Assert.NotNull(provider.SpecialProperties!.RemainingArgumentsSetter); + Assert.Equal(typeof(string[]), provider.SpecialProperties.RemainingArgumentsType); + } + + [Fact] + public void SpecialProperties_ParentSetter_SetsValue() + { + var provider = new ReflectionMetadataProvider(typeof(ChildWithParent)); + var child = new ChildWithParent(); + var parent = new ParentWithSubcommandProperty(); + + provider.SpecialProperties!.ParentSetter!(child, parent); + + Assert.Same(parent, child.Parent); + } + + [Fact] + public void SpecialProperties_SubcommandSetter_SetsValue() + { + var provider = new ReflectionMetadataProvider(typeof(ParentWithSubcommandProperty)); + var parent = new ParentWithSubcommandProperty(); + var child = new ChildWithParent(); + + provider.SpecialProperties!.SubcommandSetter!(parent, child); + + Assert.Same(child, parent.Subcommand); + } + + [Fact] + public void SpecialProperties_RemainingArgumentsSetter_SetsValue() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithRemainingArgs)); + var cmd = new CommandWithRemainingArgs(); + var args = new[] { "arg1", "arg2" }; + + provider.SpecialProperties!.RemainingArgumentsSetter!(cmd, args); + + Assert.Equal(args, cmd.RemainingArguments); + } + + #endregion + + #region Type-Level HelpOption and VersionOption Tests + + [Command(Name = "app")] + [HelpOption("-h|--help")] + private class CommandWithTypeLevelHelpOption + { + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsTypeLevelHelpOption() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithTypeLevelHelpOption)); + + Assert.NotNull(provider.HelpOption); + Assert.Equal("-h|--help", provider.HelpOption!.Template); + } + + [Command(Name = "app")] + [VersionOption("1.0.0")] + private class CommandWithTypeLevelVersionOption + { + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsTypeLevelVersionOption() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithTypeLevelVersionOption)); + + Assert.NotNull(provider.VersionOption); + Assert.Equal("1.0.0", provider.VersionOption!.Version); + } + + [Command(Name = "app")] + [VersionOption("-v|--version", "2.0.0")] + private class CommandWithVersionOptionTemplateAndVersion + { + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsVersionOptionWithTemplate() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithVersionOptionTemplateAndVersion)); + + Assert.NotNull(provider.VersionOption); + Assert.Equal("-v|--version", provider.VersionOption!.Template); + Assert.Equal("2.0.0", provider.VersionOption.Version); + } + + #endregion + + #region VersionOptionFromMember Tests + + [Command(Name = "app")] + [VersionOptionFromMember(MemberName = nameof(GetVersion))] + private class CommandWithVersionFromMember + { + public string GetVersion => "3.0.0-dynamic"; + } + + [Fact] + public void VersionOptionFromMember_WorksWithConventions() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + // Verify version option was configured + Assert.NotNull(app.OptionVersion); + } + + [Command(Name = "app")] + [VersionOptionFromMember("-V|--ver", MemberName = nameof(Version))] + private class CommandWithVersionFromMemberAndTemplate + { + public string Version => "4.0.0"; + } + + [Fact] + public void VersionOptionFromMember_WorksWithCustomTemplate() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + // Verify version option was configured + Assert.NotNull(app.OptionVersion); + // ShortName is just "V" (without the dash) + Assert.Equal("V", app.OptionVersion!.ShortName); + } + + #endregion + + #region Constructor Injection Tests + + public interface ITestService + { + string GetMessage(); + } + + private class TestService : ITestService + { + public string GetMessage() => "Hello from service"; + } + + [Command(Name = "inject")] + private class CommandWithConstructorInjection + { + private readonly ITestService _service; + + public CommandWithConstructorInjection(ITestService service) + { + _service = service; + } + + public string GetServiceMessage() => _service.GetMessage(); + } + + private class SimpleServiceProvider : IServiceProvider + { + private readonly Dictionary _services = new(); + + public void Register(T instance) where T : notnull + { + _services[typeof(T)] = instance; + } + + public object? GetService(Type serviceType) + { + return _services.TryGetValue(serviceType, out var service) ? service : null; + } + } + + [Fact] + public void ConstructorInjection_WorksWithServices() + { + var services = new SimpleServiceProvider(); + var testService = new TestService(); + services.Register(testService); + + var app = new CommandLineApplication(); + app.Conventions.UseConstructorInjection(services); + + Assert.NotNull(app.Model); + Assert.Equal("Hello from service", app.Model.GetServiceMessage()); + } + + [Command(Name = "multi")] + private class CommandWithMultipleConstructors + { + public ITestService? Service { get; } + public bool UsedParameterlessConstructor { get; } + + public CommandWithMultipleConstructors() + { + UsedParameterlessConstructor = true; + } + + public CommandWithMultipleConstructors(ITestService service) + { + Service = service; + UsedParameterlessConstructor = false; + } + } + + [Fact] + public void ConstructorInjection_PrefersParameterizedConstructor() + { + var services = new SimpleServiceProvider(); + var testService = new TestService(); + services.Register(testService); + + var app = new CommandLineApplication(); + app.Conventions.UseConstructorInjection(services); + + Assert.NotNull(app.Model); + Assert.False(app.Model.UsedParameterlessConstructor); + Assert.Same(testService, app.Model.Service); + } + + [Fact] + public void ConstructorInjection_FallsBackToParameterlessConstructor() + { + var services = new SimpleServiceProvider(); + // Not registering ITestService + + var app = new CommandLineApplication(); + app.Conventions.UseConstructorInjection(services); + + Assert.NotNull(app.Model); + Assert.True(app.Model.UsedParameterlessConstructor); + Assert.Null(app.Model.Service); + } + + #endregion + + #region Command Name Inference Tests + + [Command(Name = "parent")] + [Subcommand(typeof(MyTestCommand), typeof(AddInferredCommand), typeof(RemoveItemCommand), typeof(ExplicitNameCommand))] + private class NameInferenceParent + { + } + + [Command(Description = "No explicit name")] + private class MyTestCommand + { + } + + [Fact] + public void CommandNameFromType_InfersName() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + var sub = app.Commands.FirstOrDefault(c => c.Name == "my-test"); + Assert.NotNull(sub); + } + + [Command(Description = "Should become 'add-inferred'")] + private class AddInferredCommand + { + } + + [Fact] + public void CommandNameFromType_StripsCommandSuffix() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + var sub = app.Commands.FirstOrDefault(c => c.Name == "add-inferred"); + Assert.NotNull(sub); + } + + [Command(Description = "Should become 'remove-item'")] + private class RemoveItemCommand + { + } + + [Fact] + public void CommandNameFromType_ConvertsToKebabCase() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + var sub = app.Commands.FirstOrDefault(c => c.Name == "remove-item"); + Assert.NotNull(sub); + } + + [Command(Name = "explicit-name", Description = "Explicit name should be used")] + private class ExplicitNameCommand + { + } + + [Fact] + public void CommandNameFromType_PreservesExplicitName() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + var sub = app.Commands.FirstOrDefault(c => c.Name == "explicit-name"); + Assert.NotNull(sub); + } + + #endregion + + #region Integration Tests + + [Command(Name = "parent")] + [HelpOption("-?|-h|--help")] + [VersionOptionFromMember(MemberName = nameof(Version))] + [Subcommand(typeof(IntegrationChildCommand))] + private class IntegrationParentCommand + { + public string Version => "1.0.0-integration"; + public object? Subcommand { get; set; } + } + + [Command(Name = "child")] + private class IntegrationChildCommand + { + public IntegrationParentCommand? Parent { get; set; } + + [Option("-n|--name")] + public string? Name { get; set; } + } + + [Fact] + public void Integration_AllFeaturesWorkTogether() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + // Parse a command + var result = app.Parse("child", "-n", "test"); + + // Verify parent command + Assert.NotNull(app.Model); + + // Verify subcommand was set + Assert.NotNull(app.Model.Subcommand); + var child = Assert.IsType(app.Model.Subcommand); + + // Verify parent was set on child + Assert.Same(app.Model, child.Parent); + + // Verify option was parsed + Assert.Equal("test", child.Name); + } + + [Fact] + public void Integration_HelpOptionWorks() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + // Verify help option was configured + Assert.NotNull(app.OptionHelp); + } + + [Fact] + public void Integration_VersionOptionWorks() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + // Verify version option was configured + Assert.NotNull(app.OptionVersion); + } + + #endregion + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/ArgumentMetadataTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/ArgumentMetadataTests.cs new file mode 100644 index 00000000..f7d0eecf --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/ArgumentMetadataTests.cs @@ -0,0 +1,171 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class ArgumentMetadataTests + { + private class TestModel + { + public string? File { get; set; } + public string[]? Files { get; set; } + public int Priority { get; set; } + } + + [Fact] + public void Constructor_SetsRequiredProperties() + { + var metadata = new ArgumentMetadata( + propertyName: "File", + propertyType: typeof(string), + order: 0, + getter: obj => ((TestModel)obj).File, + setter: (obj, val) => ((TestModel)obj).File = (string?)val); + + Assert.Equal("File", metadata.PropertyName); + Assert.Equal(typeof(string), metadata.PropertyType); + Assert.Equal(0, metadata.Order); + } + + [Fact] + public void GetterAndSetter_WorkCorrectly() + { + var metadata = new ArgumentMetadata( + propertyName: "File", + propertyType: typeof(string), + order: 0, + getter: obj => ((TestModel)obj).File, + setter: (obj, val) => ((TestModel)obj).File = (string?)val); + + var model = new TestModel(); + + metadata.Setter(model, "test-file.txt"); + Assert.Equal("test-file.txt", model.File); + + var retrieved = metadata.Getter(model); + Assert.Equal("test-file.txt", retrieved); + } + + [Fact] + public void OptionalProperties_HaveDefaults() + { + var metadata = new ArgumentMetadata( + propertyName: "File", + propertyType: typeof(string), + order: 0, + getter: obj => ((TestModel)obj).File, + setter: (obj, val) => ((TestModel)obj).File = (string?)val); + + Assert.Null(metadata.Name); + Assert.Null(metadata.Description); + Assert.True(metadata.ShowInHelpText); + Assert.False(metadata.MultipleValues); + Assert.NotNull(metadata.Validators); + Assert.Empty(metadata.Validators); + } + + [Fact] + public void OptionalProperties_CanBeSet() + { + var validators = new List { new RequiredAttribute() }; + + var metadata = new ArgumentMetadata( + propertyName: "File", + propertyType: typeof(string), + order: 1, + getter: obj => ((TestModel)obj).File, + setter: (obj, val) => ((TestModel)obj).File = (string?)val) + { + Name = "file", + Description = "The file to process", + ShowInHelpText = false, + MultipleValues = false, + Validators = validators + }; + + Assert.Equal("file", metadata.Name); + Assert.Equal("The file to process", metadata.Description); + Assert.False(metadata.ShowInHelpText); + Assert.False(metadata.MultipleValues); + Assert.Same(validators, metadata.Validators); + } + + [Fact] + public void Order_DeterminesSortOrder() + { + var first = new ArgumentMetadata( + propertyName: "First", + propertyType: typeof(string), + order: 0, + getter: obj => null, + setter: (obj, val) => { }); + + var second = new ArgumentMetadata( + propertyName: "Second", + propertyType: typeof(string), + order: 1, + getter: obj => null, + setter: (obj, val) => { }); + + var third = new ArgumentMetadata( + propertyName: "Third", + propertyType: typeof(string), + order: 2, + getter: obj => null, + setter: (obj, val) => { }); + + var arguments = new List { third, first, second }; + arguments.Sort((a, b) => a.Order.CompareTo(b.Order)); + + Assert.Equal("First", arguments[0].PropertyName); + Assert.Equal("Second", arguments[1].PropertyName); + Assert.Equal("Third", arguments[2].PropertyName); + } + + [Fact] + public void WorksWithArrayType_MultipleValues() + { + var metadata = new ArgumentMetadata( + propertyName: "Files", + propertyType: typeof(string[]), + order: 0, + getter: obj => ((TestModel)obj).Files, + setter: (obj, val) => ((TestModel)obj).Files = (string[]?)val) + { + MultipleValues = true + }; + + var model = new TestModel(); + var values = new[] { "file1.txt", "file2.txt", "file3.txt" }; + + metadata.Setter(model, values); + Assert.Same(values, model.Files); + Assert.True(metadata.MultipleValues); + } + + [Fact] + public void WorksWithValueType() + { + var metadata = new ArgumentMetadata( + propertyName: "Priority", + propertyType: typeof(int), + order: 0, + getter: obj => ((TestModel)obj).Priority, + setter: (obj, val) => ((TestModel)obj).Priority = (int)val!); + + var model = new TestModel(); + + metadata.Setter(model, 5); + Assert.Equal(5, model.Priority); + + var retrieved = metadata.Getter(model); + Assert.Equal(5, retrieved); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/CommandLineApplicationWithModelTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/CommandLineApplicationWithModelTests.cs new file mode 100644 index 00000000..9644c44c --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/CommandLineApplicationWithModelTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using McMaster.Extensions.CommandLineUtils.Abstractions; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class CommandLineApplicationWithModelTests + { + private class TestModel { } + + private class MockModelFactory : IModelFactory + { + private readonly object _model; + public int CreateCallCount { get; private set; } + + public MockModelFactory(object model) + { + _model = model; + } + + public object Create() + { + CreateCallCount++; + return _model; + } + } + + [Fact] + public void Constructor_InitializesCorrectly() + { + var parent = new CommandLineApplication(); + var model = new TestModel(); + var factory = new MockModelFactory(model); + + var app = new CommandLineApplicationWithModel(parent, "test", typeof(TestModel), factory); + + Assert.Equal("test", app.Name); + Assert.Same(parent, app.Parent); + } + + [Fact] + public void Model_ReturnsCreatedInstance() + { + var parent = new CommandLineApplication(); + var model = new TestModel(); + var factory = new MockModelFactory(model); + var app = new CommandLineApplicationWithModel(parent, "test", typeof(TestModel), factory); + + var result = app.Model; + + Assert.Same(model, result); + } + + [Fact] + public void Model_IsLazyInitialized() + { + var parent = new CommandLineApplication(); + var model = new TestModel(); + var factory = new MockModelFactory(model); + var app = new CommandLineApplicationWithModel(parent, "test", typeof(TestModel), factory); + + // Factory should not be called until Model is accessed + Assert.Equal(0, factory.CreateCallCount); + + _ = app.Model; + + Assert.Equal(1, factory.CreateCallCount); + } + + [Fact] + public void Model_CachesInstance() + { + var parent = new CommandLineApplication(); + var model = new TestModel(); + var factory = new MockModelFactory(model); + var app = new CommandLineApplicationWithModel(parent, "test", typeof(TestModel), factory); + + var first = app.Model; + var second = app.Model; + + Assert.Same(first, second); + Assert.Equal(1, factory.CreateCallCount); + } + + [Fact] + public void IModelAccessor_GetModelType_ReturnsModelType() + { + var parent = new CommandLineApplication(); + var model = new TestModel(); + var factory = new MockModelFactory(model); + var app = new CommandLineApplicationWithModel(parent, "test", typeof(TestModel), factory); + + var accessor = (IModelAccessor)app; + + Assert.Equal(typeof(TestModel), accessor.GetModelType()); + } + + [Fact] + public void IModelAccessor_GetModel_ReturnsModel() + { + var parent = new CommandLineApplication(); + var model = new TestModel(); + var factory = new MockModelFactory(model); + var app = new CommandLineApplicationWithModel(parent, "test", typeof(TestModel), factory); + + var accessor = (IModelAccessor)app; + + Assert.Same(model, accessor.GetModel()); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/CommandMetadataRegistryTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/CommandMetadataRegistryTests.cs new file mode 100644 index 00000000..ecdea865 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/CommandMetadataRegistryTests.cs @@ -0,0 +1,229 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class CommandMetadataRegistryTests + { + [Command(Name = "test1")] + private class TestCommand1 { } + + [Command(Name = "test2")] + private class TestCommand2 { } + + [Command(Name = "generic")] + private class GenericTestCommand { } + + /// + /// A mock typed metadata provider for testing the generic Register and TryGetProvider methods. + /// + private class MockTypedMetadataProvider : ICommandMetadataProvider + where TModel : class + { + public Type ModelType => typeof(TModel); + public IReadOnlyList Options => Array.Empty(); + public IReadOnlyList Arguments => Array.Empty(); + public IReadOnlyList Subcommands => Array.Empty(); + public CommandMetadata? CommandInfo => null; + public IExecuteHandler? ExecuteHandler => null; + public IValidateHandler? ValidateHandler => null; + public IValidationErrorHandler? ValidationErrorHandler => null; + public SpecialPropertiesMetadata? SpecialProperties => null; + public HelpOptionMetadata? HelpOption => null; + public VersionOptionMetadata? VersionOption => null; + + public IModelFactory GetModelFactory(IServiceProvider? services) + => throw new NotImplementedException(); + + IModelFactory ICommandMetadataProvider.GetModelFactory(IServiceProvider? services) + => throw new NotImplementedException(); + } + + public CommandMetadataRegistryTests() + { + // Clean up before each test + CommandMetadataRegistry.Clear(); + } + + [Fact] + public void Register_AddsProviderToRegistry() + { + var provider = new ReflectionMetadataProvider(typeof(TestCommand1)); + + CommandMetadataRegistry.Register(typeof(TestCommand1), provider); + + Assert.True(CommandMetadataRegistry.HasMetadata(typeof(TestCommand1))); + } + + [Fact] + public void TryGetProvider_ReturnsTrue_WhenRegistered() + { + var provider = new ReflectionMetadataProvider(typeof(TestCommand1)); + CommandMetadataRegistry.Register(typeof(TestCommand1), provider); + + var result = CommandMetadataRegistry.TryGetProvider(typeof(TestCommand1), out var retrieved); + + Assert.True(result); + Assert.Same(provider, retrieved); + } + + [Fact] + public void TryGetProvider_ReturnsFalse_WhenNotRegistered() + { + var result = CommandMetadataRegistry.TryGetProvider(typeof(TestCommand1), out var retrieved); + + Assert.False(result); + Assert.Null(retrieved); + } + + [Fact] + public void HasMetadata_ReturnsTrue_WhenRegistered() + { + var provider = new ReflectionMetadataProvider(typeof(TestCommand1)); + CommandMetadataRegistry.Register(typeof(TestCommand1), provider); + + Assert.True(CommandMetadataRegistry.HasMetadata(typeof(TestCommand1))); + } + + [Fact] + public void HasMetadata_ReturnsFalse_WhenNotRegistered() + { + Assert.False(CommandMetadataRegistry.HasMetadata(typeof(TestCommand1))); + } + + [Fact] + public void Clear_RemovesAllRegistrations() + { + var provider1 = new ReflectionMetadataProvider(typeof(TestCommand1)); + var provider2 = new ReflectionMetadataProvider(typeof(TestCommand2)); + CommandMetadataRegistry.Register(typeof(TestCommand1), provider1); + CommandMetadataRegistry.Register(typeof(TestCommand2), provider2); + + CommandMetadataRegistry.Clear(); + + Assert.False(CommandMetadataRegistry.HasMetadata(typeof(TestCommand1))); + Assert.False(CommandMetadataRegistry.HasMetadata(typeof(TestCommand2))); + } + + [Fact] + public void Register_OverwritesExisting() + { + var provider1 = new ReflectionMetadataProvider(typeof(TestCommand1)); + var provider2 = new ReflectionMetadataProvider(typeof(TestCommand1)); + CommandMetadataRegistry.Register(typeof(TestCommand1), provider1); + + CommandMetadataRegistry.Register(typeof(TestCommand1), provider2); + + CommandMetadataRegistry.TryGetProvider(typeof(TestCommand1), out var retrieved); + Assert.Same(provider2, retrieved); + } + + [Fact] + public void MultipleTypes_CanBeRegistered() + { + var provider1 = new ReflectionMetadataProvider(typeof(TestCommand1)); + var provider2 = new ReflectionMetadataProvider(typeof(TestCommand2)); + + CommandMetadataRegistry.Register(typeof(TestCommand1), provider1); + CommandMetadataRegistry.Register(typeof(TestCommand2), provider2); + + Assert.True(CommandMetadataRegistry.HasMetadata(typeof(TestCommand1))); + Assert.True(CommandMetadataRegistry.HasMetadata(typeof(TestCommand2))); + + CommandMetadataRegistry.TryGetProvider(typeof(TestCommand1), out var retrieved1); + CommandMetadataRegistry.TryGetProvider(typeof(TestCommand2), out var retrieved2); + + Assert.Same(provider1, retrieved1); + Assert.Same(provider2, retrieved2); + } + + [Fact] + public void TryGetProvider_Generic_ReturnsTrue_WhenRegisteredWithTypedProvider() + { + // The generic TryGetProvider requires ICommandMetadataProvider + // which ReflectionMetadataProvider does not implement. + // Use the untyped TryGetProvider with typeof(T) for ReflectionMetadataProvider + var provider = new ReflectionMetadataProvider(typeof(GenericTestCommand)); + CommandMetadataRegistry.Register(typeof(GenericTestCommand), provider); + + // The generic version checks if the provider implements ICommandMetadataProvider + // ReflectionMetadataProvider only implements ICommandMetadataProvider (non-generic) + var genericResult = CommandMetadataRegistry.TryGetProvider(out var genericRetrieved); + Assert.False(genericResult); // ReflectionMetadataProvider doesn't implement ICommandMetadataProvider + Assert.Null(genericRetrieved); + + // But the non-generic version works + var untypedResult = CommandMetadataRegistry.TryGetProvider(typeof(GenericTestCommand), out var untypedRetrieved); + Assert.True(untypedResult); + Assert.Same(provider, untypedRetrieved); + } + + [Fact] + public void TryGetProvider_Generic_ReturnsFalse_WhenNotRegistered() + { + var result = CommandMetadataRegistry.TryGetProvider(out var retrieved); + + Assert.False(result); + Assert.Null(retrieved); + } + + #region Generic Method Coverage Tests + + [Fact] + public void RegisterGeneric_AddsTypedProviderToRegistry() + { + var provider = new MockTypedMetadataProvider(); + + CommandMetadataRegistry.Register(provider); + + Assert.True(CommandMetadataRegistry.HasMetadata(typeof(GenericTestCommand))); + } + + [Fact] + public void TryGetProviderGeneric_ReturnsTrue_WhenTypedProviderRegistered() + { + var provider = new MockTypedMetadataProvider(); + CommandMetadataRegistry.Register(provider); + + var result = CommandMetadataRegistry.TryGetProvider(out var retrieved); + + Assert.True(result); + Assert.Same(provider, retrieved); + } + + [Fact] + public void HasMetadataGeneric_ReturnsTrue_WhenRegistered() + { + var provider = new MockTypedMetadataProvider(); + CommandMetadataRegistry.Register(provider); + + Assert.True(CommandMetadataRegistry.HasMetadata()); + } + + [Fact] + public void HasMetadataGeneric_ReturnsFalse_WhenNotRegistered() + { + Assert.False(CommandMetadataRegistry.HasMetadata()); + } + + [Fact] + public void RegisterGeneric_OverwritesExistingTypedProvider() + { + var provider1 = new MockTypedMetadataProvider(); + var provider2 = new MockTypedMetadataProvider(); + CommandMetadataRegistry.Register(provider1); + + CommandMetadataRegistry.Register(provider2); + + CommandMetadataRegistry.TryGetProvider(out var retrieved); + Assert.Same(provider2, retrieved); + } + + #endregion + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/CommandMetadataTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/CommandMetadataTests.cs new file mode 100644 index 00000000..a9cde385 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/CommandMetadataTests.cs @@ -0,0 +1,234 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class CommandMetadataTests + { + [Fact] + public void DefaultValues_AreCorrect() + { + var metadata = new CommandMetadata(); + + Assert.Null(metadata.Name); + Assert.Null(metadata.AdditionalNames); + Assert.Null(metadata.FullName); + Assert.Null(metadata.Description); + Assert.Null(metadata.ExtendedHelpText); + Assert.True(metadata.ShowInHelpText); + Assert.Null(metadata.AllowArgumentSeparator); + Assert.Null(metadata.ClusterOptions); + Assert.Null(metadata.OptionsComparison); + Assert.Null(metadata.ParseCulture); + Assert.Null(metadata.ResponseFileHandling); + Assert.Null(metadata.UnrecognizedArgumentHandling); + Assert.Null(metadata.UsePagerForHelpText); + } + + [Fact] + public void AllProperties_CanBeSet() + { + var metadata = new CommandMetadata + { + Name = "myapp", + AdditionalNames = new[] { "alias1", "alias2" }, + FullName = "My Application", + Description = "A test application", + ExtendedHelpText = "Extended help here", + ShowInHelpText = false, + AllowArgumentSeparator = true, + ClusterOptions = false, + OptionsComparison = StringComparison.OrdinalIgnoreCase, + ParseCulture = System.Globalization.CultureInfo.InvariantCulture, + ResponseFileHandling = ResponseFileHandling.ParseArgsAsLineSeparated, + UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.StopParsingAndCollect, + UsePagerForHelpText = true + }; + + Assert.Equal("myapp", metadata.Name); + Assert.Equal(new[] { "alias1", "alias2" }, metadata.AdditionalNames); + Assert.Equal("My Application", metadata.FullName); + Assert.Equal("A test application", metadata.Description); + Assert.Equal("Extended help here", metadata.ExtendedHelpText); + Assert.False(metadata.ShowInHelpText); + Assert.True(metadata.AllowArgumentSeparator); + Assert.False(metadata.ClusterOptions); + Assert.Equal(StringComparison.OrdinalIgnoreCase, metadata.OptionsComparison); + Assert.Equal(System.Globalization.CultureInfo.InvariantCulture, metadata.ParseCulture); + Assert.Equal(ResponseFileHandling.ParseArgsAsLineSeparated, metadata.ResponseFileHandling); + Assert.Equal(UnrecognizedArgumentHandling.StopParsingAndCollect, metadata.UnrecognizedArgumentHandling); + Assert.True(metadata.UsePagerForHelpText); + } + + [Fact] + public void ApplyTo_SetsAppName() + { + var metadata = new CommandMetadata { Name = "testapp" }; + var app = new CommandLineApplication(); + + metadata.ApplyTo(app); + + Assert.Equal("testapp", app.Name); + } + + [Fact] + public void ApplyTo_SetsDescription() + { + var metadata = new CommandMetadata { Description = "Test description" }; + var app = new CommandLineApplication(); + + metadata.ApplyTo(app); + + Assert.Equal("Test description", app.Description); + } + + [Fact] + public void ApplyTo_SetsFullName() + { + var metadata = new CommandMetadata { FullName = "Full Name Here" }; + var app = new CommandLineApplication(); + + metadata.ApplyTo(app); + + Assert.Equal("Full Name Here", app.FullName); + } + + [Fact] + public void ApplyTo_SetsExtendedHelpText() + { + var metadata = new CommandMetadata { ExtendedHelpText = "Extended help text" }; + var app = new CommandLineApplication(); + + metadata.ApplyTo(app); + + Assert.Equal("Extended help text", app.ExtendedHelpText); + } + + [Fact] + public void ApplyTo_SetsAllowArgumentSeparator() + { + var metadata = new CommandMetadata { AllowArgumentSeparator = true }; + var app = new CommandLineApplication(); + + metadata.ApplyTo(app); + + Assert.True(app.AllowArgumentSeparator); + } + + [Fact] + public void ApplyTo_SetsClusterOptions_WhenSpecified() + { + var metadata = new CommandMetadata { ClusterOptions = false }; + var app = new CommandLineApplication(); + + metadata.ApplyTo(app); + + Assert.False(app.ClusterOptions); + } + + [Fact] + public void ApplyTo_DoesNotSetClusterOptions_WhenNull() + { + var metadata = new CommandMetadata { ClusterOptions = null }; + var app = new CommandLineApplication { ClusterOptions = true }; + + metadata.ApplyTo(app); + + // Should retain original value + Assert.True(app.ClusterOptions); + } + + [Fact] + public void ApplyTo_SetsUnrecognizedArgumentHandling_WhenSpecified() + { + var metadata = new CommandMetadata + { + UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue + }; + var app = new CommandLineApplication(); + + metadata.ApplyTo(app); + + Assert.Equal(UnrecognizedArgumentHandling.CollectAndContinue, app.UnrecognizedArgumentHandling); + } + + [Fact] + public void ApplyTo_DoesNotSetUnrecognizedArgumentHandling_WhenNull() + { + var metadata = new CommandMetadata { UnrecognizedArgumentHandling = null }; + var app = new CommandLineApplication + { + UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.StopParsingAndCollect + }; + + metadata.ApplyTo(app); + + // Should retain original value + Assert.Equal(UnrecognizedArgumentHandling.StopParsingAndCollect, app.UnrecognizedArgumentHandling); + } + + [Fact] + public void ApplyTo_SetsUsePagerForHelpText() + { + var metadata = new CommandMetadata { UsePagerForHelpText = true }; + var app = new CommandLineApplication(); + + metadata.ApplyTo(app); + + Assert.True(app.UsePagerForHelpText); + } + + [Fact] + public void ApplyTo_SetsOptionsComparison_WhenSpecified() + { + var metadata = new CommandMetadata + { + OptionsComparison = StringComparison.OrdinalIgnoreCase + }; + var app = new CommandLineApplication(); + + metadata.ApplyTo(app); + + Assert.Equal(StringComparison.OrdinalIgnoreCase, app.OptionsComparison); + } + + [Fact] + public void ApplyTo_SetsResponseFileHandling_WhenSpecified() + { + var metadata = new CommandMetadata + { + ResponseFileHandling = ResponseFileHandling.ParseArgsAsLineSeparated + }; + var app = new CommandLineApplication(); + + metadata.ApplyTo(app); + + Assert.Equal(ResponseFileHandling.ParseArgsAsLineSeparated, app.ResponseFileHandling); + } + + [Fact] + public void ApplyTo_DoesNotOverwriteWithNull_Values() + { + var metadata = new CommandMetadata(); // All defaults/nulls + var app = new CommandLineApplication + { + Name = "original", + Description = "original desc", + FullName = "Original Full", + ExtendedHelpText = "Original Extended" + }; + + metadata.ApplyTo(app); + + // When metadata has null values, existing app values should be preserved + Assert.Equal("original", app.Name); + Assert.Equal("original desc", app.Description); + Assert.Equal("Original Full", app.FullName); + Assert.Equal("Original Extended", app.ExtendedHelpText); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/ComprehensiveMetadataProviderTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/ComprehensiveMetadataProviderTests.cs new file mode 100644 index 00000000..8fb85585 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/ComprehensiveMetadataProviderTests.cs @@ -0,0 +1,692 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + /// + /// Comprehensive tests for metadata providers covering edge cases and full feature parity. + /// This ensures both ReflectionMetadataProvider and generated providers behave consistently. + /// + public class ComprehensiveMetadataProviderTests + { + #region ReflectionMetadataProvider Edge Cases + + [Fact] + public void ReflectionMetadataProvider_CommandWithoutSpecialProperties_HasNullSpecialProperties() + { + var provider = new ReflectionMetadataProvider(typeof(SimpleCommandWithNoSpecialProps)); + + // SpecialProperties should be null or have all null setters + if (provider.SpecialProperties != null) + { + Assert.Null(provider.SpecialProperties.ParentSetter); + Assert.Null(provider.SpecialProperties.SubcommandSetter); + Assert.Null(provider.SpecialProperties.RemainingArgumentsSetter); + } + } + + [Command(Name = "simple")] + private class SimpleCommandWithNoSpecialProps + { + [Option("-n|--name")] + public string? Name { get; set; } + } + + [Fact] + public void ReflectionMetadataProvider_CommandWithoutCommandAttribute_HasNullCommandInfo() + { + var provider = new ReflectionMetadataProvider(typeof(ClassWithoutCommandAttribute)); + + Assert.Null(provider.CommandInfo); + } + + private class ClassWithoutCommandAttribute + { + public string? Value { get; set; } + } + + [Fact] + public void ReflectionMetadataProvider_CommandWithOnlyHelpOption_ExtractsHelpOption() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithOnlyHelpOption)); + + Assert.NotNull(provider.HelpOption); + Assert.Equal("-?|-h|--help", provider.HelpOption!.Template); + } + + [Command(Name = "help-only")] + [HelpOption("-?|-h|--help")] + private class CommandWithOnlyHelpOption + { + } + + [Fact] + public void ReflectionMetadataProvider_HelpOption_ExtractsInheritedFlag() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithInheritedHelpOption)); + + Assert.NotNull(provider.HelpOption); + Assert.True(provider.HelpOption!.Inherited); + } + + [Command(Name = "inherited-help")] + [HelpOption(Inherited = true)] + private class CommandWithInheritedHelpOption + { + } + + [Fact] + public void ReflectionMetadataProvider_CommandWithAllSpecialProperties_ExtractsAll() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithAllSpecialProps)); + + Assert.NotNull(provider.SpecialProperties); + Assert.NotNull(provider.SpecialProperties!.ParentSetter); + Assert.Equal(typeof(ParentForAllSpecialProps), provider.SpecialProperties.ParentType); + Assert.NotNull(provider.SpecialProperties.SubcommandSetter); + Assert.Equal(typeof(object), provider.SpecialProperties.SubcommandType); + Assert.NotNull(provider.SpecialProperties.RemainingArgumentsSetter); + Assert.Equal(typeof(string[]), provider.SpecialProperties.RemainingArgumentsType); + } + + [Command(Name = "parent-all")] + [Subcommand(typeof(CommandWithAllSpecialProps))] + private class ParentForAllSpecialProps + { + } + + [Command(Name = "all-special", UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue)] + private class CommandWithAllSpecialProps + { + public ParentForAllSpecialProps? Parent { get; set; } + public object? Subcommand { get; set; } + public string[]? RemainingArguments { get; set; } + } + + [Fact] + public void ReflectionMetadataProvider_RemainingArguments_WithListType_ExtractsCorrectly() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithRemainingArgsList)); + + Assert.NotNull(provider.SpecialProperties); + Assert.NotNull(provider.SpecialProperties!.RemainingArgumentsSetter); + Assert.Equal(typeof(List), provider.SpecialProperties.RemainingArgumentsType); + } + + [Command(Name = "remaining-list", UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue)] + private class CommandWithRemainingArgsList + { + public List? RemainingArguments { get; set; } + } + + #endregion + + #region VersionOptionFromMember Tests + + [Fact] + public void ReflectionMetadataProvider_VersionOptionFromMember_WithProperty_ExtractsGetter() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithVersionProperty)); + + Assert.NotNull(provider.VersionOption); + Assert.NotNull(provider.VersionOption!.VersionGetter); + + var instance = new CommandWithVersionProperty(); + var version = provider.VersionOption.VersionGetter!(instance); + Assert.Equal("1.2.3-prop", version); + } + + [Command(Name = "ver-prop")] + [VersionOptionFromMember(MemberName = nameof(CommandWithVersionProperty.AppVersion))] + private class CommandWithVersionProperty + { + public string AppVersion => "1.2.3-prop"; + } + + [Fact] + public void ReflectionMetadataProvider_VersionOptionFromMember_WithMethod_ExtractsGetter() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithVersionMethod)); + + Assert.NotNull(provider.VersionOption); + Assert.NotNull(provider.VersionOption!.VersionGetter); + + var instance = new CommandWithVersionMethod(); + var version = provider.VersionOption.VersionGetter!(instance); + Assert.Equal("2.0.0-method", version); + } + + [Command(Name = "ver-method")] + [VersionOptionFromMember(MemberName = nameof(CommandWithVersionMethod.GetVersion))] + private class CommandWithVersionMethod + { + public string GetVersion() => "2.0.0-method"; + } + + [Fact] + public void ReflectionMetadataProvider_VersionOptionFromMember_WithTemplate_ExtractsBoth() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithVersionTemplate)); + + Assert.NotNull(provider.VersionOption); + Assert.Equal("-V|--ver", provider.VersionOption!.Template); + Assert.NotNull(provider.VersionOption.VersionGetter); + + var instance = new CommandWithVersionTemplate(); + var version = provider.VersionOption.VersionGetter!(instance); + Assert.Equal("3.0.0", version); + } + + [Command(Name = "ver-template")] + [VersionOptionFromMember("-V|--ver", MemberName = nameof(Ver))] + private class CommandWithVersionTemplate + { + public string Ver => "3.0.0"; + } + + [Fact] + public void ReflectionMetadataProvider_VersionOptionFromMember_WithDescription_ExtractsAll() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithVersionDescription)); + + Assert.NotNull(provider.VersionOption); + Assert.Equal("Show version", provider.VersionOption!.Description); + } + + [Command(Name = "ver-desc")] + [VersionOptionFromMember(MemberName = nameof(Version), Description = "Show version")] + private class CommandWithVersionDescription + { + public string Version => "4.0.0"; + } + + #endregion + + #region Constructor Injection Tests (Factory Creation) + + [Fact] + public void ReflectionMetadataProvider_ModelFactory_CreatesInstanceWithParameterlessConstructor() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithParameterlessConstructor)); + var factory = provider.GetModelFactory(null); + + var instance = factory.Create(); + + Assert.NotNull(instance); + Assert.IsType(instance); + } + + [Command(Name = "parameterless")] + private class CommandWithParameterlessConstructor + { + public bool WasCreated { get; } = true; + } + + [Fact] + public void ReflectionMetadataProvider_ModelFactory_UsesServiceProvider() + { + var services = new TestServiceProvider(); + var testService = new TestServiceImpl(); + services.Register(testService); + + var provider = new ReflectionMetadataProvider(typeof(CommandWithServiceDependency)); + var factory = provider.GetModelFactory(services); + + var instance = (CommandWithServiceDependency)factory.Create(); + + Assert.NotNull(instance.Service); + Assert.Same(testService, instance.Service); + } + + public interface ITestServiceForDI + { + string GetValue(); + } + + private class TestServiceImpl : ITestServiceForDI + { + public string GetValue() => "test-value"; + } + + [Command(Name = "with-service")] + private class CommandWithServiceDependency + { + public ITestServiceForDI? Service { get; } + + public CommandWithServiceDependency(ITestServiceForDI service) + { + Service = service; + } + } + + [Fact] + public void ReflectionMetadataProvider_ModelFactory_FallsBackToParameterless_WhenServiceNotAvailable() + { + var services = new TestServiceProvider(); + // Not registering the service + + var provider = new ReflectionMetadataProvider(typeof(CommandWithOptionalService)); + var factory = provider.GetModelFactory(services); + + var instance = (CommandWithOptionalService)factory.Create(); + + Assert.True(instance.UsedParameterlessConstructor); + } + + [Command(Name = "optional-service")] + private class CommandWithOptionalService + { + public bool UsedParameterlessConstructor { get; } + + public CommandWithOptionalService() + { + UsedParameterlessConstructor = true; + } + + public CommandWithOptionalService(ITestServiceForDI service) + { + UsedParameterlessConstructor = false; + } + } + + private class TestServiceProvider : IServiceProvider + { + private readonly Dictionary _services = new(); + + public void Register(T instance) where T : notnull + { + _services[typeof(T)] = instance; + } + + public object? GetService(Type serviceType) + { + return _services.TryGetValue(serviceType, out var service) ? service : null; + } + } + + #endregion + + #region Execute Handler Tests + + [Fact] + public void ReflectionMetadataProvider_ExtractsOnExecute_SyncMethod() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithSyncExecute)); + + Assert.NotNull(provider.ExecuteHandler); + Assert.False(provider.ExecuteHandler!.IsAsync); + } + + [Command(Name = "sync-exec")] + private class CommandWithSyncExecute + { + public bool Executed { get; private set; } + + internal int OnExecute() + { + Executed = true; + return 42; + } + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsOnExecuteAsync_AsyncMethod() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithAsyncExecute)); + + Assert.NotNull(provider.ExecuteHandler); + Assert.True(provider.ExecuteHandler!.IsAsync); + } + + [Command(Name = "async-exec")] + private class CommandWithAsyncExecute + { + internal Task OnExecuteAsync(CancellationToken cancellationToken) + { + return Task.FromResult(0); + } + } + + [Fact] + public async Task ReflectionMetadataProvider_ExecuteHandler_InvokesMethod() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithSyncExecute)); + var instance = new CommandWithSyncExecute(); + var app = new CommandLineApplication(); + + var result = await provider.ExecuteHandler!.InvokeAsync(instance, app, CancellationToken.None); + + Assert.True(instance.Executed); + Assert.Equal(42, result); + } + + [Fact] + public void ReflectionMetadataProvider_ExecuteHandler_WithAppParameter() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithAppParameter)); + + Assert.NotNull(provider.ExecuteHandler); + } + + [Command(Name = "app-param")] + private class CommandWithAppParameter + { + public CommandLineApplication? ReceivedApp { get; private set; } + + internal int OnExecute(CommandLineApplication app) + { + ReceivedApp = app; + return 0; + } + } + + [Fact] + public async Task ReflectionMetadataProvider_ExecuteHandler_PassesAppParameter() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithAppParameter)); + var instance = new CommandWithAppParameter(); + var app = new CommandLineApplication(); + + await provider.ExecuteHandler!.InvokeAsync(instance, app, CancellationToken.None); + + Assert.Same(app, instance.ReceivedApp); + } + + #endregion + + #region Integration Tests - End-to-End Behavior + + [Fact] + public void Integration_ParentChildRelationship_WorksEndToEnd() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + app.Parse("child", "-m", "hello"); + + Assert.NotNull(app.Model.Subcommand); + var child = Assert.IsType(app.Model.Subcommand); + Assert.Same(app.Model, child.Parent); + Assert.Equal("hello", child.Message); + } + + [Command(Name = "parent")] + [Subcommand(typeof(IntegrationChild))] + private class IntegrationParent + { + public object? Subcommand { get; set; } + } + + [Command(Name = "child")] + private class IntegrationChild + { + public IntegrationParent? Parent { get; set; } + + [Option("-m|--message")] + public string? Message { get; set; } + } + + [Fact] + public void Integration_RemainingArguments_CollectedCorrectly() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + app.Parse("arg1", "arg2", "arg3"); + + Assert.NotNull(app.Model.RemainingArguments); + Assert.Equal(new[] { "arg1", "arg2", "arg3" }, app.Model.RemainingArguments); + } + + [Command(Name = "remaining", UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue)] + private class IntegrationRemainingArgs + { + public string[]? RemainingArguments { get; set; } + } + + [Fact] + public void Integration_HelpOption_AppliedCorrectly() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + Assert.NotNull(app.OptionHelp); + Assert.Equal("h", app.OptionHelp.ShortName); + Assert.Equal("help", app.OptionHelp.LongName); + } + + [Command(Name = "help-cmd")] + [HelpOption("-h|--help", Description = "Get help")] + private class IntegrationHelpCommand + { + } + + [Fact] + public void Integration_VersionOption_AppliedCorrectly() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + Assert.NotNull(app.OptionVersion); + Assert.Equal("v", app.OptionVersion.ShortName); + } + + [Command(Name = "version-cmd")] + [VersionOption("-v|--version", "1.0.0")] + private class IntegrationVersionCommand + { + } + + [Fact] + public void Integration_VersionOptionFromMember_AppliedCorrectly() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + Assert.NotNull(app.OptionVersion); + } + + [Command(Name = "version-member")] + [VersionOptionFromMember(MemberName = nameof(Version))] + private class IntegrationVersionFromMember + { + public string Version => "dynamic-version"; + } + + [Fact] + public void Integration_ConstructorInjection_WorksWithConventions() + { + var services = new TestServiceProvider(); + var testService = new TestServiceImpl(); + services.Register(testService); + + var app = new CommandLineApplication(); + app.Conventions.UseConstructorInjection(services); + + Assert.NotNull(app.Model); + Assert.Same(testService, app.Model.Service); + } + + [Command(Name = "require-service")] + private class CommandRequiringService + { + public ITestServiceForDI Service { get; } + + public CommandRequiringService(ITestServiceForDI service) + { + Service = service; + } + } + + [Fact] + public void Integration_CommandNameInference_WorksForAllPatterns() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + // Check all inferred names + Assert.NotNull(app.Commands.FirstOrDefault(c => c.Name == "simple")); + Assert.NotNull(app.Commands.FirstOrDefault(c => c.Name == "my-complex-name")); + Assert.NotNull(app.Commands.FirstOrDefault(c => c.Name == "add-user")); + Assert.NotNull(app.Commands.FirstOrDefault(c => c.Name == "explicit-name")); + } + + [Command(Name = "host")] + [Subcommand(typeof(SimpleCommand), typeof(MyComplexNameCommand), typeof(AddUserCommand), typeof(ExplicitlyNamedCommand))] + private class NameInferenceHost + { + } + + [Command] + private class SimpleCommand + { + } + + [Command] + private class MyComplexNameCommand + { + } + + [Command] + private class AddUserCommand + { + } + + [Command(Name = "explicit-name")] + private class ExplicitlyNamedCommand + { + } + + #endregion + + #region Validation Handler Tests + + [Fact] + public void ReflectionMetadataProvider_ExtractsOnValidate_WhenPresent() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithValidation)); + + Assert.NotNull(provider.ValidateHandler); + } + + [Command(Name = "with-validation")] + private class CommandWithValidation + { + [Option("-n|--number")] + public int Number { get; set; } + + internal ValidationResult OnValidate() + { + if (Number < 0) + { + return new ValidationResult("Number must be non-negative"); + } + return ValidationResult.Success!; + } + } + + [Fact] + public void ReflectionMetadataProvider_ValidateHandler_IsNull_WhenNoValidation() + { + var provider = new ReflectionMetadataProvider(typeof(SimpleCommandWithNoSpecialProps)); + + Assert.Null(provider.ValidateHandler); + } + + #endregion + + #region Subcommand Metadata Tests + + [Fact] + public void ReflectionMetadataProvider_ExtractsMultipleSubcommands() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithMultipleSubcommands)); + + Assert.Equal(3, provider.Subcommands.Count); + Assert.Contains(provider.Subcommands, s => s.SubcommandType == typeof(Sub1)); + Assert.Contains(provider.Subcommands, s => s.SubcommandType == typeof(Sub2)); + Assert.Contains(provider.Subcommands, s => s.SubcommandType == typeof(Sub3)); + } + + [Command(Name = "multi-sub")] + [Subcommand(typeof(Sub1), typeof(Sub2), typeof(Sub3))] + private class CommandWithMultipleSubcommands + { + } + + [Command(Name = "sub1")] + private class Sub1 { } + + [Command(Name = "sub2")] + private class Sub2 { } + + [Command(Name = "sub3")] + private class Sub3 { } + + [Fact] + public void ReflectionMetadataProvider_SubcommandOrder_IsPreserved() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithMultipleSubcommands)); + + var types = provider.Subcommands.Select(s => s.SubcommandType).ToArray(); + Assert.Equal(new[] { typeof(Sub1), typeof(Sub2), typeof(Sub3) }, types); + } + + #endregion + + #region Option and Argument Metadata Tests + + [Fact] + public void ReflectionMetadataProvider_ExtractsAllOptionProperties() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithDetailedOption)); + + var option = provider.Options.FirstOrDefault(o => o.PropertyName == "Value"); + Assert.NotNull(option); + Assert.Equal("-v|--value", option!.Template); + Assert.Equal("A value", option.Description); + } + + [Command(Name = "detailed-option")] + private class CommandWithDetailedOption + { + [Option("-v|--value", Description = "A value")] + public string? Value { get; set; } + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsArgumentOrder() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithMultipleArguments)); + + Assert.Equal(3, provider.Arguments.Count); + Assert.Equal("First", provider.Arguments[0].PropertyName); + Assert.Equal("Second", provider.Arguments[1].PropertyName); + Assert.Equal("Third", provider.Arguments[2].PropertyName); + } + + [Command(Name = "multi-arg")] + private class CommandWithMultipleArguments + { + [Argument(0)] + public string? First { get; set; } + + [Argument(1)] + public string? Second { get; set; } + + [Argument(2)] + public string? Third { get; set; } + } + + #endregion + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/ConventionAotPathTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/ConventionAotPathTests.cs new file mode 100644 index 00000000..f8c5d2a6 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/ConventionAotPathTests.cs @@ -0,0 +1,413 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using McMaster.Extensions.CommandLineUtils.Conventions; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + /// + /// Tests for the AOT-friendly code paths in conventions. + /// These tests exercise the generated metadata paths in conventions. + /// + public class ConventionAotPathTests : IDisposable + { + public ConventionAotPathTests() + { + // Clean up registry before each test + CommandMetadataRegistry.Clear(); + } + + public void Dispose() + { + // Clean up registry after each test + CommandMetadataRegistry.Clear(); + } + + #region Test Models + + private class ParentModel + { + public string? Name { get; set; } + } + + private class ChildModel + { + public ParentModel? Parent { get; set; } + public object? Subcommand { get; set; } + public string[]? RemainingArguments { get; set; } + } + + private class SubcommandModel + { + public string? Value { get; set; } + } + + #endregion + + #region Mock Metadata Provider + + /// + /// A mock metadata provider that provides SpecialPropertiesMetadata + /// to exercise the AOT code paths in conventions. + /// + private class MockMetadataProviderWithSpecialProperties : ICommandMetadataProvider + { + public Type ModelType { get; } + public IReadOnlyList Options { get; } = Array.Empty(); + public IReadOnlyList Arguments { get; } = Array.Empty(); + public IReadOnlyList Subcommands { get; } = Array.Empty(); + public CommandMetadata? CommandInfo { get; set; } + public IExecuteHandler? ExecuteHandler { get; set; } + public IValidateHandler? ValidateHandler { get; set; } + public IValidationErrorHandler? ValidationErrorHandler { get; set; } + public SpecialPropertiesMetadata? SpecialProperties { get; set; } + public HelpOptionMetadata? HelpOption { get; set; } + public VersionOptionMetadata? VersionOption { get; set; } + + public MockMetadataProviderWithSpecialProperties(Type modelType) + { + ModelType = modelType; + } + + public IModelFactory GetModelFactory(IServiceProvider? services) + { + return new ActivatorModelFactory(ModelType); + } + } + + #endregion + + #region RemainingArgsPropertyConvention AOT Path Tests + + [Fact] + public void RemainingArgsPropertyConvention_UsesGeneratedSetter_WhenAvailable() + { + // Arrange - register a mock provider with SpecialProperties + var provider = new MockMetadataProviderWithSpecialProperties(typeof(ChildModel)) + { + SpecialProperties = new SpecialPropertiesMetadata + { + RemainingArgumentsSetter = (obj, val) => ((ChildModel)obj).RemainingArguments = (string[]?)val, + RemainingArgumentsType = typeof(string[]) + } + }; + CommandMetadataRegistry.Register(typeof(ChildModel), provider); + + var app = new CommandLineApplication(); + app.UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.StopParsingAndCollect; + app.Conventions.AddConvention(new RemainingArgsPropertyConvention()); + + // Act + app.Parse("arg1", "arg2", "arg3"); + + // Assert + Assert.NotNull(app.Model.RemainingArguments); + Assert.Equal(new[] { "arg1", "arg2", "arg3" }, app.Model.RemainingArguments); + } + + [Fact] + public void RemainingArgsPropertyConvention_UsesGeneratedSetter_WithListType() + { + // Create a model that uses List for remaining args + var provider = new MockMetadataProviderWithSpecialProperties(typeof(RemainingArgsListModel)) + { + SpecialProperties = new SpecialPropertiesMetadata + { + RemainingArgumentsSetter = (obj, val) => ((RemainingArgsListModel)obj).RemainingArgs = (List?)val, + RemainingArgumentsType = typeof(List) + } + }; + CommandMetadataRegistry.Register(typeof(RemainingArgsListModel), provider); + + var app = new CommandLineApplication(); + app.UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.StopParsingAndCollect; + app.Conventions.AddConvention(new RemainingArgsPropertyConvention()); + + // Act + app.Parse("a", "b"); + + // Assert + Assert.NotNull(app.Model.RemainingArgs); + Assert.Equal(new[] { "a", "b" }, app.Model.RemainingArgs); + } + + private class RemainingArgsListModel + { + public List? RemainingArgs { get; set; } + } + + #endregion + + #region ParentPropertyConvention AOT Path Tests + + [Fact] + public void ParentPropertyConvention_UsesGeneratedSetter_WhenAvailable() + { + // Arrange + var childProvider = new MockMetadataProviderWithSpecialProperties(typeof(ChildModel)) + { + SpecialProperties = new SpecialPropertiesMetadata + { + ParentSetter = (obj, val) => ((ChildModel)obj).Parent = (ParentModel?)val, + ParentType = typeof(ParentModel) + } + }; + CommandMetadataRegistry.Register(typeof(ChildModel), childProvider); + + var app = new CommandLineApplication(); + var subApp = app.Command("child", _ => { }); + subApp.Conventions.AddConvention(new ParentPropertyConvention()); + + // Act + app.Parse("child"); + + // Assert - Parent should be set via the generated setter + Assert.NotNull(subApp.Model.Parent); + Assert.Same(app.Model, subApp.Model.Parent); + } + + #endregion + + #region SubcommandPropertyConvention AOT Path Tests + + [Fact] + public void SubcommandPropertyConvention_UsesGeneratedSetter_WhenAvailable() + { + // Arrange + var parentProvider = new MockMetadataProviderWithSpecialProperties(typeof(ChildModel)) + { + SpecialProperties = new SpecialPropertiesMetadata + { + SubcommandSetter = (obj, val) => ((ChildModel)obj).Subcommand = val, + SubcommandType = typeof(object) + } + }; + CommandMetadataRegistry.Register(typeof(ChildModel), parentProvider); + + var app = new CommandLineApplication(); + var subApp = app.Command("sub", _ => { }); + app.Conventions.AddConvention(new SubcommandPropertyConvention()); + + // Act + app.Parse("sub"); + + // Assert - Subcommand should be set via the generated setter + Assert.NotNull(app.Model.Subcommand); + Assert.IsType(app.Model.Subcommand); + } + + #endregion + + #region ConventionContext Tests + + [Fact] + public void ConventionContext_MetadataProvider_ReturnsRegisteredProvider() + { + // Arrange + var provider = new MockMetadataProviderWithSpecialProperties(typeof(ChildModel)); + CommandMetadataRegistry.Register(typeof(ChildModel), provider); + + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ChildModel)); + + // Act + var metadataProvider = context.MetadataProvider; + + // Assert + Assert.NotNull(metadataProvider); + } + + [Fact] + public void ConventionContext_HasGeneratedMetadata_ReturnsTrue_WhenRegistered() + { + // Arrange + var provider = new MockMetadataProviderWithSpecialProperties(typeof(ChildModel)); + CommandMetadataRegistry.Register(typeof(ChildModel), provider); + + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ChildModel)); + + // Act + var hasGenerated = context.HasGeneratedMetadata; + + // Assert + Assert.True(hasGenerated); + } + + [Fact] + public void ConventionContext_HasGeneratedMetadata_ReturnsFalse_WhenNotRegistered() + { + // Arrange - Don't register any provider + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ChildModel)); + + // Act + var hasGenerated = context.HasGeneratedMetadata; + + // Assert - Falls back to reflection, which is not "generated" + Assert.False(hasGenerated); + } + + [Fact] + public void ConventionContext_HasGeneratedMetadata_ReturnsFalse_WhenModelTypeIsNull() + { + // Arrange + var app = new CommandLineApplication(); + var context = new ConventionContext(app, null); + + // Act + var hasGenerated = context.HasGeneratedMetadata; + + // Assert + Assert.False(hasGenerated); + } + + [Fact] + public void ConventionContext_MetadataProvider_CachesResult() + { + // Arrange + var provider = new MockMetadataProviderWithSpecialProperties(typeof(ChildModel)); + CommandMetadataRegistry.Register(typeof(ChildModel), provider); + + var app = new CommandLineApplication(); + var context = new ConventionContext(app, typeof(ChildModel)); + + // Act + var first = context.MetadataProvider; + var second = context.MetadataProvider; + + // Assert + Assert.Same(first, second); + } + + #endregion + + #region CommandAttributeConvention AOT Path Tests + + [Fact] + public void CommandAttributeConvention_UsesMetadataProvider_WhenAvailable() + { + // Arrange + var commandInfo = new CommandMetadata + { + Name = "test-cmd", + Description = "A test command" + }; + var provider = new MockMetadataProviderWithSpecialProperties(typeof(ChildModel)) + { + CommandInfo = commandInfo + }; + CommandMetadataRegistry.Register(typeof(ChildModel), provider); + + var app = new CommandLineApplication(); + app.Conventions.AddConvention(new CommandAttributeConvention()); + + // Assert - Command info should be applied + Assert.Equal("test-cmd", app.Name); + Assert.Equal("A test command", app.Description); + } + + [Fact] + public void CommandAttributeConvention_AppliesCommandMetadata_WithAllProperties() + { + // Arrange - ClusterOptions is nullable, so setting it means it "was set" + var commandInfo = new CommandMetadata + { + Name = "full-cmd", + Description = "Full description", + FullName = "Full Name", + ExtendedHelpText = "Extended help", + ShowInHelpText = false, + AllowArgumentSeparator = true, + ResponseFileHandling = ResponseFileHandling.ParseArgsAsLineSeparated, + ClusterOptions = false // Setting it means it was explicitly set + }; + var provider = new MockMetadataProviderWithSpecialProperties(typeof(ChildModel)) + { + CommandInfo = commandInfo + }; + CommandMetadataRegistry.Register(typeof(ChildModel), provider); + + var app = new CommandLineApplication(); + app.Conventions.AddConvention(new CommandAttributeConvention()); + + // Assert + Assert.Equal("full-cmd", app.Name); + Assert.Equal("Full description", app.Description); + Assert.Equal("Full Name", app.FullName); + Assert.Equal("Extended help", app.ExtendedHelpText); + Assert.False(app.ShowInHelpText); + Assert.True(app.AllowArgumentSeparator); + Assert.Equal(ResponseFileHandling.ParseArgsAsLineSeparated, app.ResponseFileHandling); + Assert.False(app.ClusterOptions); + } + + #endregion + + #region SubcommandAttributeConvention AOT Path Tests + + [Fact] + public void SubcommandAttributeConvention_UsesMetadataProvider_WhenAvailable() + { + // Arrange - SubcommandMetadata requires subcommandType in constructor + var subMeta = new SubcommandMetadata(typeof(SubcommandModel)) + { + MetadataProviderFactory = () => new MockMetadataProviderWithSpecialProperties(typeof(SubcommandModel)) + { + CommandInfo = new CommandMetadata { Name = "sub-from-meta" } + } + }; + + // Create a custom provider class that has mutable Subcommands + var mockProvider = new MockProviderWithSubcommands(typeof(ChildModel)); + mockProvider.SetSubcommands(new[] { subMeta }); + CommandMetadataRegistry.Register(typeof(ChildModel), mockProvider); + + var app = new CommandLineApplication(); + app.Conventions.AddConvention(new SubcommandAttributeConvention()); + + // Assert - Use First() since Commands is IReadOnlyCollection + Assert.Single(app.Commands); + Assert.Equal("sub-from-meta", app.Commands.First().Name); + } + + private class MockProviderWithSubcommands : ICommandMetadataProvider + { + private IReadOnlyList _subcommands = Array.Empty(); + + public Type ModelType { get; } + public IReadOnlyList Options => Array.Empty(); + public IReadOnlyList Arguments => Array.Empty(); + public IReadOnlyList Subcommands => _subcommands; + public CommandMetadata? CommandInfo { get; set; } + public IExecuteHandler? ExecuteHandler { get; set; } + public IValidateHandler? ValidateHandler { get; set; } + public IValidationErrorHandler? ValidationErrorHandler { get; set; } + public SpecialPropertiesMetadata? SpecialProperties { get; set; } + public HelpOptionMetadata? HelpOption { get; set; } + public VersionOptionMetadata? VersionOption { get; set; } + + public MockProviderWithSubcommands(Type modelType) + { + ModelType = modelType; + } + + public void SetSubcommands(IReadOnlyList subcommands) + { + _subcommands = subcommands; + } + + public IModelFactory GetModelFactory(IServiceProvider? services) + { + return new ActivatorModelFactory(ModelType); + } + } + + #endregion + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/DefaultMetadataResolverTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/DefaultMetadataResolverTests.cs new file mode 100644 index 00000000..b526785f --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/DefaultMetadataResolverTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + /// + /// Collection for tests that use CommandMetadataRegistry (shared state). + /// Tests in this collection run sequentially to avoid interference. + /// + [CollectionDefinition("MetadataRegistry", DisableParallelization = true)] + public class MetadataRegistryCollection { } + + [Collection("MetadataRegistry")] + public class DefaultMetadataResolverTests : IDisposable + { + [Command(Name = "registered")] + private class RegisteredCommand { } + + [Command(Name = "unregistered")] + private class UnregisteredCommand { } + + [Command(Name = "generic")] + private class GenericCommand { } + + public DefaultMetadataResolverTests() + { + // Clean up before each test + CommandMetadataRegistry.Clear(); + DefaultMetadataResolver.Instance.ClearCache(); + } + + [Fact] + public void Instance_IsSingleton() + { + var instance1 = DefaultMetadataResolver.Instance; + var instance2 = DefaultMetadataResolver.Instance; + + Assert.Same(instance1, instance2); + } + + [Fact] + public void GetProvider_ReturnsRegisteredProvider_WhenAvailable() + { + var registered = new ReflectionMetadataProvider(typeof(RegisteredCommand)); + CommandMetadataRegistry.Register(typeof(RegisteredCommand), registered); + + var provider = DefaultMetadataResolver.Instance.GetProvider(typeof(RegisteredCommand)); + + Assert.Same(registered, provider); + } + + [Fact] + public void GetProvider_FallsBackToReflection_WhenNotRegistered() + { + var provider = DefaultMetadataResolver.Instance.GetProvider(typeof(UnregisteredCommand)); + + Assert.NotNull(provider); + Assert.Equal(typeof(UnregisteredCommand), provider.ModelType); + Assert.IsType(provider); + } + + [Fact] + public void GetProvider_CachesReflectionProvider() + { + var provider1 = DefaultMetadataResolver.Instance.GetProvider(typeof(UnregisteredCommand)); + var provider2 = DefaultMetadataResolver.Instance.GetProvider(typeof(UnregisteredCommand)); + + Assert.Same(provider1, provider2); + } + + [Fact] + public void GetProvider_Generic_ReturnsRegisteredProvider_WhenAvailable() + { + var registered = new ReflectionMetadataProvider(typeof(GenericCommand)); + CommandMetadataRegistry.Register(typeof(GenericCommand), registered); + + var provider = DefaultMetadataResolver.Instance.GetProvider(); + + Assert.NotNull(provider); + Assert.Equal(typeof(GenericCommand), provider.ModelType); + } + + [Fact] + public void GetProvider_Generic_FallsBackToReflection_WhenNotRegistered() + { + var provider = DefaultMetadataResolver.Instance.GetProvider(); + + Assert.NotNull(provider); + Assert.Equal(typeof(GenericCommand), provider.ModelType); + } + + [Fact] + public void GetProvider_Generic_ReturnsTypedProvider() + { + var provider = DefaultMetadataResolver.Instance.GetProvider(); + + Assert.IsAssignableFrom>(provider); + } + + [Fact] + public void HasGeneratedMetadata_ReturnsTrue_WhenRegistered() + { + var registered = new ReflectionMetadataProvider(typeof(RegisteredCommand)); + CommandMetadataRegistry.Register(typeof(RegisteredCommand), registered); + + Assert.True(DefaultMetadataResolver.Instance.HasGeneratedMetadata(typeof(RegisteredCommand))); + } + + [Fact] + public void HasGeneratedMetadata_ReturnsFalse_WhenNotRegistered() + { + Assert.False(DefaultMetadataResolver.Instance.HasGeneratedMetadata(typeof(UnregisteredCommand))); + } + + [Fact] + public void ClearCache_RemovesCachedReflectionProviders() + { + var provider1 = DefaultMetadataResolver.Instance.GetProvider(typeof(UnregisteredCommand)); + + DefaultMetadataResolver.Instance.ClearCache(); + + var provider2 = DefaultMetadataResolver.Instance.GetProvider(typeof(UnregisteredCommand)); + + Assert.NotSame(provider1, provider2); + } + + [Fact] + public void ImplementsIMetadataResolver() + { + Assert.IsAssignableFrom(DefaultMetadataResolver.Instance); + } + + public void Dispose() + { + CommandMetadataRegistry.Clear(); + DefaultMetadataResolver.Instance.ClearCache(); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/ErrorExecuteHandlerTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/ErrorExecuteHandlerTests.cs new file mode 100644 index 00000000..10285d59 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/ErrorExecuteHandlerTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class ErrorExecuteHandlerTests + { + [Fact] + public void Constructor_WithNullErrorMessage_ThrowsArgumentNullException() + { + var ex = Assert.Throws(() => new ErrorExecuteHandler(null!)); + + Assert.Equal("errorMessage", ex.ParamName); + } + + [Fact] + public void Constructor_WithValidErrorMessage_DoesNotThrow() + { + var handler = new ErrorExecuteHandler("Test error message"); + + Assert.NotNull(handler); + } + + [Fact] + public void IsAsync_ReturnsFalse() + { + var handler = new ErrorExecuteHandler("Test error message"); + + Assert.False(handler.IsAsync); + } + + [Fact] + public async Task InvokeAsync_ThrowsInvalidOperationException_WithErrorMessage() + { + var errorMessage = "Ambiguous OnExecute methods detected"; + var handler = new ErrorExecuteHandler(errorMessage); + var app = new CommandLineApplication(); + + var ex = await Assert.ThrowsAsync( + () => handler.InvokeAsync(new object(), app, CancellationToken.None)); + + Assert.Equal(errorMessage, ex.Message); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/HelpOptionMetadataTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/HelpOptionMetadataTests.cs new file mode 100644 index 00000000..562032c4 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/HelpOptionMetadataTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class HelpOptionMetadataTests + { + [Fact] + public void DefaultValues_AreCorrect() + { + var metadata = new HelpOptionMetadata(); + + Assert.Null(metadata.Template); + Assert.Null(metadata.Description); + Assert.False(metadata.Inherited); + } + + [Fact] + public void Template_CanBeSet() + { + var metadata = new HelpOptionMetadata + { + Template = "-h|--help" + }; + + Assert.Equal("-h|--help", metadata.Template); + } + + [Fact] + public void Description_CanBeSet() + { + var metadata = new HelpOptionMetadata + { + Description = "Show help information" + }; + + Assert.Equal("Show help information", metadata.Description); + } + + [Fact] + public void Inherited_CanBeSet() + { + var metadata = new HelpOptionMetadata + { + Inherited = true + }; + + Assert.True(metadata.Inherited); + } + + [Fact] + public void AllProperties_CanBeSetTogether() + { + var metadata = new HelpOptionMetadata + { + Template = "-?|-h|--help", + Description = "Display help for this command", + Inherited = true + }; + + Assert.Equal("-?|-h|--help", metadata.Template); + Assert.Equal("Display help for this command", metadata.Description); + Assert.True(metadata.Inherited); + } + + [Fact] + public void DifferentInstances_AreSeparate() + { + var metadata1 = new HelpOptionMetadata + { + Template = "-h", + Description = "Help 1", + Inherited = false + }; + + var metadata2 = new HelpOptionMetadata + { + Template = "--help", + Description = "Help 2", + Inherited = true + }; + + Assert.NotEqual(metadata1.Template, metadata2.Template); + Assert.NotEqual(metadata1.Description, metadata2.Description); + Assert.NotEqual(metadata1.Inherited, metadata2.Inherited); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/MetadataProviderTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/MetadataProviderTests.cs new file mode 100644 index 00000000..2219bcee --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/MetadataProviderTests.cs @@ -0,0 +1,222 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class MetadataProviderTests + { + [Command(Name = "test", Description = "A test command")] + private class TestCommand + { + [Option("-n|--name", Description = "The name")] + public string? Name { get; set; } + + [Option("-v|--verbose", Description = "Verbose output")] + public bool Verbose { get; set; } + + [Argument(0, Name = "file", Description = "The file to process")] + public string? File { get; set; } + } + + [Command(Name = "parent")] + [Subcommand(typeof(ChildCommand))] + private class ParentCommand + { + [Option("-g|--global")] + public bool Global { get; set; } + } + + [Command(Name = "child")] + private class ChildCommand + { + [Option("-l|--local")] + public bool Local { get; set; } + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsCommandInfo() + { + var provider = new ReflectionMetadataProvider(typeof(TestCommand)); + + Assert.Equal(typeof(TestCommand), provider.ModelType); + Assert.NotNull(provider.CommandInfo); + Assert.Equal("test", provider.CommandInfo!.Name); + Assert.Equal("A test command", provider.CommandInfo.Description); + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsOptions() + { + var provider = new ReflectionMetadataProvider(typeof(TestCommand)); + + Assert.Equal(2, provider.Options.Count); + + var nameOption = provider.Options.FirstOrDefault(o => o.PropertyName == "Name"); + Assert.NotNull(nameOption); + Assert.Equal("-n|--name", nameOption!.Template); + Assert.Equal("The name", nameOption.Description); + Assert.Equal(typeof(string), nameOption.PropertyType); + + var verboseOption = provider.Options.FirstOrDefault(o => o.PropertyName == "Verbose"); + Assert.NotNull(verboseOption); + Assert.Equal("-v|--verbose", verboseOption!.Template); + Assert.Equal(typeof(bool), verboseOption.PropertyType); + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsArguments() + { + var provider = new ReflectionMetadataProvider(typeof(TestCommand)); + + Assert.Single(provider.Arguments); + + var fileArg = provider.Arguments[0]; + Assert.Equal("File", fileArg.PropertyName); + Assert.Equal("file", fileArg.Name); + Assert.Equal("The file to process", fileArg.Description); + Assert.Equal(0, fileArg.Order); + Assert.Equal(typeof(string), fileArg.PropertyType); + } + + [Fact] + public void ReflectionMetadataProvider_ExtractsSubcommands() + { + var provider = new ReflectionMetadataProvider(typeof(ParentCommand)); + + Assert.Single(provider.Subcommands); + Assert.Equal(typeof(ChildCommand), provider.Subcommands[0].SubcommandType); + } + + [Fact] + public void ReflectionMetadataProvider_GetterAndSetterWork() + { + var provider = new ReflectionMetadataProvider(typeof(TestCommand)); + var instance = new TestCommand(); + + var nameOption = provider.Options.First(o => o.PropertyName == "Name"); + + // Test setter + nameOption.Setter(instance, "test-value"); + Assert.Equal("test-value", instance.Name); + + // Test getter + var value = nameOption.Getter(instance); + Assert.Equal("test-value", value); + } + + [Fact] + public void CommandMetadataRegistry_RegisterAndRetrieve() + { + // Clear any existing registrations + CommandMetadataRegistry.Clear(); + + var provider = new ReflectionMetadataProvider(typeof(TestCommand)); + CommandMetadataRegistry.Register(typeof(TestCommand), provider); + + Assert.True(CommandMetadataRegistry.HasMetadata(typeof(TestCommand))); + Assert.True(CommandMetadataRegistry.TryGetProvider(typeof(TestCommand), out var retrieved)); + Assert.Same(provider, retrieved); + + // Clean up + CommandMetadataRegistry.Clear(); + } + + [Fact] + public void DefaultMetadataResolver_ReturnsRegisteredProvider() + { + // Clear any existing registrations + CommandMetadataRegistry.Clear(); + DefaultMetadataResolver.Instance.ClearCache(); + + var provider = new ReflectionMetadataProvider(typeof(TestCommand)); + CommandMetadataRegistry.Register(typeof(TestCommand), provider); + + var resolved = DefaultMetadataResolver.Instance.GetProvider(typeof(TestCommand)); + Assert.Same(provider, resolved); + + // Clean up + CommandMetadataRegistry.Clear(); + DefaultMetadataResolver.Instance.ClearCache(); + } + + [Fact] + public void DefaultMetadataResolver_FallsBackToReflection() + { + // Clear any existing registrations + CommandMetadataRegistry.Clear(); + DefaultMetadataResolver.Instance.ClearCache(); + + // No registration, should create reflection provider + var resolved = DefaultMetadataResolver.Instance.GetProvider(typeof(TestCommand)); + + Assert.NotNull(resolved); + Assert.Equal(typeof(TestCommand), resolved.ModelType); + Assert.NotNull(resolved.CommandInfo); + Assert.Equal("test", resolved.CommandInfo!.Name); + + // Clean up + CommandMetadataRegistry.Clear(); + DefaultMetadataResolver.Instance.ClearCache(); + } + + [Fact] + public void DefaultMetadataResolver_HasGeneratedMetadata_ReturnsFalse_WhenNotRegistered() + { + CommandMetadataRegistry.Clear(); + + Assert.False(DefaultMetadataResolver.Instance.HasGeneratedMetadata(typeof(TestCommand))); + + CommandMetadataRegistry.Clear(); + } + + [Fact] + public void DefaultMetadataResolver_HasGeneratedMetadata_ReturnsTrue_WhenRegistered() + { + CommandMetadataRegistry.Clear(); + + var provider = new ReflectionMetadataProvider(typeof(TestCommand)); + CommandMetadataRegistry.Register(typeof(TestCommand), provider); + + Assert.True(DefaultMetadataResolver.Instance.HasGeneratedMetadata(typeof(TestCommand))); + + CommandMetadataRegistry.Clear(); + } + + [Fact] + public void CommandMetadata_ApplyTo_SetsProperties() + { + var metadata = new CommandMetadata + { + Name = "myapp", + Description = "My application", + FullName = "My Full Application Name", + ExtendedHelpText = "Extended help here" + }; + + var app = new CommandLineApplication(); + metadata.ApplyTo(app); + + Assert.Equal("myapp", app.Name); + Assert.Equal("My application", app.Description); + Assert.Equal("My Full Application Name", app.FullName); + Assert.Equal("Extended help here", app.ExtendedHelpText); + } + + [Fact] + public void ModelFactory_CreatesInstance() + { + var provider = new ReflectionMetadataProvider(typeof(TestCommand)); + var factory = provider.GetModelFactory(null); + + var instance = factory.Create(); + + Assert.NotNull(instance); + Assert.IsType(instance); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/OptionMetadataTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/OptionMetadataTests.cs new file mode 100644 index 00000000..63a99b9f --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/OptionMetadataTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class OptionMetadataTests + { + private class TestModel + { + public string? Name { get; set; } + public int Count { get; set; } + public bool Verbose { get; set; } + public string[]? Values { get; set; } + } + + [Fact] + public void Constructor_SetsRequiredProperties() + { + var metadata = new OptionMetadata( + propertyName: "Name", + propertyType: typeof(string), + getter: obj => ((TestModel)obj).Name, + setter: (obj, val) => ((TestModel)obj).Name = (string?)val); + + Assert.Equal("Name", metadata.PropertyName); + Assert.Equal(typeof(string), metadata.PropertyType); + } + + [Fact] + public void GetterAndSetter_WorkCorrectly() + { + var metadata = new OptionMetadata( + propertyName: "Name", + propertyType: typeof(string), + getter: obj => ((TestModel)obj).Name, + setter: (obj, val) => ((TestModel)obj).Name = (string?)val); + + var model = new TestModel(); + + metadata.Setter(model, "test-value"); + Assert.Equal("test-value", model.Name); + + var retrieved = metadata.Getter(model); + Assert.Equal("test-value", retrieved); + } + + [Fact] + public void OptionalProperties_HaveDefaults() + { + var metadata = new OptionMetadata( + propertyName: "Name", + propertyType: typeof(string), + getter: obj => ((TestModel)obj).Name, + setter: (obj, val) => ((TestModel)obj).Name = (string?)val); + + Assert.Null(metadata.Template); + Assert.Null(metadata.ShortName); + Assert.Null(metadata.LongName); + Assert.Null(metadata.SymbolName); + Assert.Null(metadata.ValueName); + Assert.Null(metadata.Description); + // Default is MultipleValue (enum value 0) + Assert.Equal(CommandOptionType.MultipleValue, metadata.OptionType); + Assert.True(metadata.ShowInHelpText); + Assert.False(metadata.Inherited); + Assert.NotNull(metadata.Validators); + Assert.Empty(metadata.Validators); + } + + [Fact] + public void OptionalProperties_CanBeSet() + { + var validators = new List { new RequiredAttribute() }; + + var metadata = new OptionMetadata( + propertyName: "Count", + propertyType: typeof(int), + getter: obj => ((TestModel)obj).Count, + setter: (obj, val) => ((TestModel)obj).Count = (int)val!) + { + Template = "-c|--count", + ShortName = "c", + LongName = "count", + SymbolName = "COUNT", + ValueName = "NUMBER", + Description = "The count value", + OptionType = CommandOptionType.SingleValue, + ShowInHelpText = false, + Inherited = true, + Validators = validators + }; + + Assert.Equal("-c|--count", metadata.Template); + Assert.Equal("c", metadata.ShortName); + Assert.Equal("count", metadata.LongName); + Assert.Equal("COUNT", metadata.SymbolName); + Assert.Equal("NUMBER", metadata.ValueName); + Assert.Equal("The count value", metadata.Description); + Assert.Equal(CommandOptionType.SingleValue, metadata.OptionType); + Assert.False(metadata.ShowInHelpText); + Assert.True(metadata.Inherited); + Assert.Same(validators, metadata.Validators); + } + + [Fact] + public void WorksWithValueTypes() + { + var metadata = new OptionMetadata( + propertyName: "Count", + propertyType: typeof(int), + getter: obj => ((TestModel)obj).Count, + setter: (obj, val) => ((TestModel)obj).Count = (int)val!); + + var model = new TestModel(); + + metadata.Setter(model, 42); + Assert.Equal(42, model.Count); + + var retrieved = metadata.Getter(model); + Assert.Equal(42, retrieved); + } + + [Fact] + public void WorksWithBooleanType() + { + var metadata = new OptionMetadata( + propertyName: "Verbose", + propertyType: typeof(bool), + getter: obj => ((TestModel)obj).Verbose, + setter: (obj, val) => ((TestModel)obj).Verbose = (bool)val!); + + var model = new TestModel(); + + metadata.Setter(model, true); + Assert.True(model.Verbose); + + var retrieved = metadata.Getter(model); + Assert.Equal(true, retrieved); + } + + [Fact] + public void WorksWithArrayType() + { + var metadata = new OptionMetadata( + propertyName: "Values", + propertyType: typeof(string[]), + getter: obj => ((TestModel)obj).Values, + setter: (obj, val) => ((TestModel)obj).Values = (string[]?)val) + { + OptionType = CommandOptionType.MultipleValue + }; + + var model = new TestModel(); + var values = new[] { "a", "b", "c" }; + + metadata.Setter(model, values); + Assert.Same(values, model.Values); + + var retrieved = metadata.Getter(model); + Assert.Same(values, retrieved); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/ReflectionExecuteHandlerTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/ReflectionExecuteHandlerTests.cs new file mode 100644 index 00000000..a08541da --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/ReflectionExecuteHandlerTests.cs @@ -0,0 +1,219 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class ReflectionExecuteHandlerTests + { + private class SyncVoidCommand + { + public bool WasExecuted { get; private set; } + + public void OnExecute() + { + WasExecuted = true; + } + } + + private class SyncIntCommand + { + public int ReturnCode { get; set; } = 42; + + public int OnExecute() + { + return ReturnCode; + } + } + + private class AsyncVoidCommand + { + public bool WasExecuted { get; private set; } + + public async Task OnExecuteAsync() + { + await Task.Delay(1); + WasExecuted = true; + } + } + + private class AsyncIntCommand + { + public int ReturnCode { get; set; } = 99; + + public async Task OnExecuteAsync() + { + await Task.Delay(1); + return ReturnCode; + } + } + + private class CommandWithAppParameter + { + public CommandLineApplication? ReceivedApp { get; private set; } + + public void OnExecute(CommandLineApplication app) + { + ReceivedApp = app; + } + } + + private class CommandWithCancellationToken + { + public CancellationToken ReceivedToken { get; private set; } + + public void OnExecute(CancellationToken token) + { + ReceivedToken = token; + } + } + + private class CommandThatThrows + { + public void OnExecute() + { + throw new InvalidOperationException("Test exception"); + } + } + + private class AsyncCommandThatThrows + { + public async Task OnExecuteAsync() + { + await Task.Delay(1); + throw new InvalidOperationException("Async test exception"); + } + } + + [Fact] + public async Task SyncVoidMethod_ExecutesAndReturnsZero() + { + var model = new SyncVoidCommand(); + var method = typeof(SyncVoidCommand).GetMethod("OnExecute")!; + var handler = new ReflectionExecuteHandler(method, isAsync: false); + var app = new CommandLineApplication(); + + var result = await handler.InvokeAsync(model, app, CancellationToken.None); + + Assert.True(model.WasExecuted); + Assert.Equal(0, result); + Assert.False(handler.IsAsync); + } + + [Fact] + public async Task SyncIntMethod_ReturnsCorrectValue() + { + var model = new SyncIntCommand { ReturnCode = 42 }; + var method = typeof(SyncIntCommand).GetMethod("OnExecute")!; + var handler = new ReflectionExecuteHandler(method, isAsync: false); + var app = new CommandLineApplication(); + + var result = await handler.InvokeAsync(model, app, CancellationToken.None); + + Assert.Equal(42, result); + } + + [Fact] + public async Task AsyncVoidMethod_ExecutesAndReturnsZero() + { + var model = new AsyncVoidCommand(); + var method = typeof(AsyncVoidCommand).GetMethod("OnExecuteAsync")!; + var handler = new ReflectionExecuteHandler(method, isAsync: true); + var app = new CommandLineApplication(); + + var result = await handler.InvokeAsync(model, app, CancellationToken.None); + + Assert.True(model.WasExecuted); + Assert.Equal(0, result); + Assert.True(handler.IsAsync); + } + + [Fact] + public async Task AsyncIntMethod_ReturnsCorrectValue() + { + var model = new AsyncIntCommand { ReturnCode = 99 }; + var method = typeof(AsyncIntCommand).GetMethod("OnExecuteAsync")!; + var handler = new ReflectionExecuteHandler(method, isAsync: true); + var app = new CommandLineApplication(); + + var result = await handler.InvokeAsync(model, app, CancellationToken.None); + + Assert.Equal(99, result); + } + + [Fact] + public async Task PassesCommandLineApplication_ToMethod() + { + var model = new CommandWithAppParameter(); + var method = typeof(CommandWithAppParameter).GetMethod("OnExecute")!; + var handler = new ReflectionExecuteHandler(method, isAsync: false); + var app = new CommandLineApplication(); + + await handler.InvokeAsync(model, app, CancellationToken.None); + + Assert.Same(app, model.ReceivedApp); + } + + [Fact] + public async Task PassesCancellationToken_ToMethod() + { + var model = new CommandWithCancellationToken(); + var method = typeof(CommandWithCancellationToken).GetMethod("OnExecute")!; + var handler = new ReflectionExecuteHandler(method, isAsync: false); + var app = new CommandLineApplication(); + using var cts = new CancellationTokenSource(); + var token = cts.Token; + + await handler.InvokeAsync(model, app, token); + + Assert.Equal(token, model.ReceivedToken); + } + + [Fact] + public async Task SyncMethod_ThrowsException_Propagates() + { + var model = new CommandThatThrows(); + var method = typeof(CommandThatThrows).GetMethod("OnExecute")!; + var handler = new ReflectionExecuteHandler(method, isAsync: false); + var app = new CommandLineApplication(); + + var ex = await Assert.ThrowsAsync( + () => handler.InvokeAsync(model, app, CancellationToken.None)); + + Assert.Equal("Test exception", ex.Message); + } + + [Fact] + public async Task AsyncMethod_ThrowsException_Propagates() + { + var model = new AsyncCommandThatThrows(); + var method = typeof(AsyncCommandThatThrows).GetMethod("OnExecuteAsync")!; + var handler = new ReflectionExecuteHandler(method, isAsync: true); + var app = new CommandLineApplication(); + + var ex = await Assert.ThrowsAsync( + () => handler.InvokeAsync(model, app, CancellationToken.None)); + + Assert.Equal("Async test exception", ex.Message); + } + + [Fact] + public void IsAsync_ReflectsConstructorParameter() + { + var syncMethod = typeof(SyncVoidCommand).GetMethod("OnExecute")!; + var asyncMethod = typeof(AsyncVoidCommand).GetMethod("OnExecuteAsync")!; + + var syncHandler = new ReflectionExecuteHandler(syncMethod, isAsync: false); + var asyncHandler = new ReflectionExecuteHandler(asyncMethod, isAsync: true); + + Assert.False(syncHandler.IsAsync); + Assert.True(asyncHandler.IsAsync); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/ReflectionMetadataProviderIntegrationTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/ReflectionMetadataProviderIntegrationTests.cs new file mode 100644 index 00000000..07247ce0 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/ReflectionMetadataProviderIntegrationTests.cs @@ -0,0 +1,490 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class ReflectionMetadataProviderIntegrationTests + { + [Command( + Name = "full-command", + Description = "A fully featured command", + FullName = "Full Command Name", + ExtendedHelpText = "Extended help text here")] + [Subcommand(typeof(SubCmd1), typeof(SubCmd2))] + [HelpOption("-h|--help")] + private class FullFeaturedCommand + { + [Option("-n|--name", Description = "Your name")] + [Required] + public string? Name { get; set; } + + [Option("-v|--verbose", Description = "Enable verbose output")] + public bool Verbose { get; set; } + + [Option("-c|--count", Description = "A count value")] + [Range(1, 100)] + public int Count { get; set; } = 1; + + [Argument(0, Name = "file", Description = "Input file")] + [Required] + public string? File { get; set; } + + [Argument(1, Name = "output", Description = "Output file")] + public string? Output { get; set; } + + public object? Subcommand { get; set; } + + public int OnExecute() + { + return 0; + } + } + + [Command(Name = "sub1")] + private class SubCmd1 + { + public FullFeaturedCommand? Parent { get; set; } + } + + [Command(Name = "sub2")] + private class SubCmd2 { } + + [Command(Name = "async-command")] + private class AsyncCommand + { + public async Task OnExecuteAsync() + { + await Task.Delay(1); + return 42; + } + } + + [Command(Name = "validating-command")] + private class ValidatingCommand + { + public ValidationResult? OnValidate() + { + return ValidationResult.Success; + } + + public int OnValidationError(ValidationResult result) + { + return 99; + } + } + + [VersionOption("--version", "1.0.0", Description = "Show version")] + private class CommandWithVersion + { + public int OnExecute() => 0; + } + + [Fact] + public void ExtractsAllOptions() + { + var provider = new ReflectionMetadataProvider(typeof(FullFeaturedCommand)); + + Assert.Equal(3, provider.Options.Count); + + var nameOption = provider.Options.First(o => o.PropertyName == "Name"); + Assert.Equal("-n|--name", nameOption.Template); + Assert.Equal("Your name", nameOption.Description); + Assert.Single(nameOption.Validators); + Assert.IsType(nameOption.Validators[0]); + + var verboseOption = provider.Options.First(o => o.PropertyName == "Verbose"); + Assert.Equal("-v|--verbose", verboseOption.Template); + Assert.Equal(typeof(bool), verboseOption.PropertyType); + + var countOption = provider.Options.First(o => o.PropertyName == "Count"); + Assert.Equal("-c|--count", countOption.Template); + Assert.Single(countOption.Validators); // Just the Range attribute + Assert.IsType(countOption.Validators[0]); + } + + [Fact] + public void ExtractsAllArguments() + { + var provider = new ReflectionMetadataProvider(typeof(FullFeaturedCommand)); + + Assert.Equal(2, provider.Arguments.Count); + + var fileArg = provider.Arguments.First(a => a.PropertyName == "File"); + Assert.Equal("file", fileArg.Name); + Assert.Equal(0, fileArg.Order); + Assert.Single(fileArg.Validators); + + var outputArg = provider.Arguments.First(a => a.PropertyName == "Output"); + Assert.Equal("output", outputArg.Name); + Assert.Equal(1, outputArg.Order); + } + + [Fact] + public void ExtractsSubcommands() + { + var provider = new ReflectionMetadataProvider(typeof(FullFeaturedCommand)); + + Assert.Equal(2, provider.Subcommands.Count); + Assert.Contains(provider.Subcommands, s => s.SubcommandType == typeof(SubCmd1)); + Assert.Contains(provider.Subcommands, s => s.SubcommandType == typeof(SubCmd2)); + } + + [Fact] + public void ExtractsCommandInfo() + { + var provider = new ReflectionMetadataProvider(typeof(FullFeaturedCommand)); + + Assert.NotNull(provider.CommandInfo); + Assert.Equal("full-command", provider.CommandInfo!.Name); + Assert.Equal("A fully featured command", provider.CommandInfo.Description); + Assert.Equal("Full Command Name", provider.CommandInfo.FullName); + Assert.Equal("Extended help text here", provider.CommandInfo.ExtendedHelpText); + } + + [Fact] + public void ExtractsHelpOption() + { + var provider = new ReflectionMetadataProvider(typeof(FullFeaturedCommand)); + + Assert.NotNull(provider.HelpOption); + Assert.Equal("-h|--help", provider.HelpOption!.Template); + } + + [Fact] + public void ExtractsVersionOption() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithVersion)); + + Assert.NotNull(provider.VersionOption); + Assert.Equal("--version", provider.VersionOption!.Template); + Assert.Equal("1.0.0", provider.VersionOption.Version); + Assert.Equal("Show version", provider.VersionOption.Description); + } + + [Fact] + public void ExtractsSyncExecuteHandler() + { + var provider = new ReflectionMetadataProvider(typeof(FullFeaturedCommand)); + + Assert.NotNull(provider.ExecuteHandler); + Assert.False(provider.ExecuteHandler!.IsAsync); + } + + [Fact] + public void ExtractsAsyncExecuteHandler() + { + var provider = new ReflectionMetadataProvider(typeof(AsyncCommand)); + + Assert.NotNull(provider.ExecuteHandler); + Assert.True(provider.ExecuteHandler!.IsAsync); + } + + [Fact] + public void ExtractsValidateHandler() + { + var provider = new ReflectionMetadataProvider(typeof(ValidatingCommand)); + + Assert.NotNull(provider.ValidateHandler); + } + + [Fact] + public void ExtractsValidationErrorHandler() + { + var provider = new ReflectionMetadataProvider(typeof(ValidatingCommand)); + + Assert.NotNull(provider.ValidationErrorHandler); + } + + [Fact] + public void ExtractsSpecialProperties() + { + var provider = new ReflectionMetadataProvider(typeof(FullFeaturedCommand)); + + Assert.NotNull(provider.SpecialProperties); + Assert.NotNull(provider.SpecialProperties!.SubcommandSetter); + Assert.Equal(typeof(object), provider.SpecialProperties.SubcommandType); + } + + [Fact] + public void ExtractsParentProperty() + { + var provider = new ReflectionMetadataProvider(typeof(SubCmd1)); + + Assert.NotNull(provider.SpecialProperties); + Assert.NotNull(provider.SpecialProperties!.ParentSetter); + Assert.Equal(typeof(FullFeaturedCommand), provider.SpecialProperties.ParentType); + } + + [Fact] + public void GetModelFactory_CreatesInstances() + { + var provider = new ReflectionMetadataProvider(typeof(FullFeaturedCommand)); + var factory = provider.GetModelFactory(null); + + var instance = factory.Create(); + + Assert.IsType(instance); + } + + [Fact] + public void PropertyAccessors_WorkCorrectly() + { + var provider = new ReflectionMetadataProvider(typeof(FullFeaturedCommand)); + var model = new FullFeaturedCommand(); + + var nameOption = provider.Options.First(o => o.PropertyName == "Name"); + + nameOption.Setter(model, "Test Name"); + Assert.Equal("Test Name", model.Name); + + var value = nameOption.Getter(model); + Assert.Equal("Test Name", value); + } + + [Fact] + public void SubcommandMetadataProviderFactory_CreatesProviders() + { + var provider = new ReflectionMetadataProvider(typeof(FullFeaturedCommand)); + var subCmd = provider.Subcommands.First(s => s.SubcommandType == typeof(SubCmd1)); + + Assert.NotNull(subCmd.MetadataProviderFactory); + + var subProvider = subCmd.MetadataProviderFactory!(); + + Assert.Equal(typeof(SubCmd1), subProvider.ModelType); + Assert.NotNull(subProvider.SpecialProperties?.ParentSetter); + } + + #region Additional Coverage Tests + + [Command(Name = "prop-help")] + private class CommandWithPropertyLevelHelpOption + { + [HelpOption("-h|--help")] + public bool ShowHelp { get; set; } + } + + [Fact] + public void ExtractsHelpOption_FromProperty() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithPropertyLevelHelpOption)); + + Assert.NotNull(provider.HelpOption); + Assert.Equal("-h|--help", provider.HelpOption!.Template); + } + + [Command(Name = "prop-version")] + private class CommandWithPropertyLevelVersionOption + { + [VersionOption("--version")] + public string Version => "1.0.0"; + } + + [Fact] + public void ExtractsVersionOption_FromProperty() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithPropertyLevelVersionOption)); + + Assert.NotNull(provider.VersionOption); + Assert.Equal("--version", provider.VersionOption!.Template); + Assert.NotNull(provider.VersionOption.VersionGetter); + } + + [Command(Name = "ambiguous-execute")] + private class CommandWithBothExecuteMethods + { + internal int OnExecute() => 0; + internal System.Threading.Tasks.Task OnExecuteAsync(System.Threading.CancellationToken ct) => System.Threading.Tasks.Task.FromResult(0); + } + + [Fact] + public async System.Threading.Tasks.Task AmbiguousOnExecute_ReturnsErrorHandler() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithBothExecuteMethods)); + + Assert.NotNull(provider.ExecuteHandler); + + var instance = new CommandWithBothExecuteMethods(); + var app = new CommandLineApplication(); + + await Assert.ThrowsAsync( + () => provider.ExecuteHandler!.InvokeAsync(instance, app, System.Threading.CancellationToken.None)); + } + + [Command(Name = "invalid-validate")] + private class CommandWithInvalidOnValidate + { + internal int OnValidate() => 0; + } + + [Fact] + public void OnValidate_WithWrongReturnType_Throws() + { + Assert.Throws(() => + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithInvalidOnValidate)); + _ = provider.ValidateHandler; + }); + } + + [Command(Name = "void-async")] + private class CommandWithVoidAsyncExecute + { + internal Task OnExecuteAsync() => Task.CompletedTask; + } + + [Fact] + public void ExtractsOnExecuteAsync_WithTaskReturnType() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithVoidAsyncExecute)); + + Assert.NotNull(provider.ExecuteHandler); + Assert.True(provider.ExecuteHandler!.IsAsync); + } + + [Command(Name = "inferred-names")] + private class CommandWithInferredOptionNames + { + [Option(Description = "An option with inferred names")] + public string? MyLongOption { get; set; } + } + + [Fact] + public void InfersOptionNames_WhenNoTemplateSpecified() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithInferredOptionNames)); + + var option = provider.Options.FirstOrDefault(o => o.PropertyName == "MyLongOption"); + Assert.NotNull(option); + Assert.Equal("my-long-option", option!.LongName); + Assert.Equal("m", option.ShortName); + } + + [Command(Name = "uri-option")] + private class CommandWithUriOption + { + [Option("-u|--url")] + public Uri? Url { get; set; } + } + + [Fact] + public void InfersOptionType_ForCustomParserTypes() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithUriOption)); + + var option = provider.Options.FirstOrDefault(o => o.PropertyName == "Url"); + Assert.NotNull(option); + Assert.Equal(CommandOptionType.SingleValue, option!.OptionType); + } + + [Command(Name = "remaining-short", UnrecognizedArgumentHandling = UnrecognizedArgumentHandling.CollectAndContinue)] + private class CommandWithRemainingArgsShortName + { + public string[]? RemainingArgs { get; set; } + } + + [Fact] + public void ExtractsRemainingArgs_WithShortName() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithRemainingArgsShortName)); + + Assert.NotNull(provider.SpecialProperties); + Assert.NotNull(provider.SpecialProperties!.RemainingArgumentsSetter); + } + + [Command(Name = "missing-member")] + [VersionOptionFromMember(MemberName = "NonExistentMember")] + private class CommandWithMissingVersionMember + { + } + + [Fact] + public void VersionOptionFromMember_WithMissingMember_HasNullGetter() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithMissingVersionMember)); + + Assert.NotNull(provider.VersionOption); + Assert.Null(provider.VersionOption!.VersionGetter); + } + + [Command(Name = "hidden-arg")] + private class CommandWithHiddenArgument + { + [Argument(0, ShowInHelpText = false)] + public string? HiddenArg { get; set; } + } + + [Fact] + public void ExtractsArgumentShowInHelpText() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithHiddenArgument)); + + var hiddenArg = provider.Arguments.FirstOrDefault(a => a.PropertyName == "HiddenArg"); + Assert.NotNull(hiddenArg); + Assert.False(hiddenArg!.ShowInHelpText); + } + + [Command(Name = "conflicting")] + private class CommandWithConflictingAttributes + { + [Option("-v|--value")] + [Argument(0)] + public string? Value { get; set; } + } + + [Fact] + public void ThrowsOnConflictingOptionAndArgument() + { + Assert.Throws(() => + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithConflictingAttributes)); + _ = provider.Options; + }); + } + + [Command(Name = "help-prop")] + private class CommandWithHelpOptionProperty + { + [Option("-n|--name")] + public string? Name { get; set; } + + [HelpOption] + public bool ShowHelp { get; set; } + } + + [Fact] + public void ExcludesHelpOptionFromOptions() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithHelpOptionProperty)); + + Assert.DoesNotContain(provider.Options, o => o.PropertyName == "ShowHelp"); + } + + [Command(Name = "version-prop")] + private class CommandWithVersionOptionProperty + { + [Option("-n|--name")] + public string? Name { get; set; } + + [VersionOption("-v|--version")] + public string Version => "1.0.0"; + } + + [Fact] + public void ExcludesVersionOptionFromOptions() + { + var provider = new ReflectionMetadataProvider(typeof(CommandWithVersionOptionProperty)); + + Assert.DoesNotContain(provider.Options, o => o.PropertyName == "Version"); + } + + #endregion + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/ReflectionValidateHandlerTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/ReflectionValidateHandlerTests.cs new file mode 100644 index 00000000..c60c69f1 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/ReflectionValidateHandlerTests.cs @@ -0,0 +1,176 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Reflection; +using McMaster.Extensions.CommandLineUtils.Abstractions; +using McMaster.Extensions.CommandLineUtils.Internal; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class ReflectionValidateHandlerTests + { + private class ValidCommand + { + public bool WasValidated { get; private set; } + + public ValidationResult? OnValidate() + { + WasValidated = true; + return ValidationResult.Success; + } + } + + private class InvalidCommand + { + public ValidationResult? OnValidate() + { + return new ValidationResult("Validation failed"); + } + } + + private class CommandWithValidationContext + { + public ValidationContext? ReceivedContext { get; private set; } + + public ValidationResult? OnValidate(ValidationContext context) + { + ReceivedContext = context; + return ValidationResult.Success; + } + } + + private class CommandWithCommandLineContext + { + public CommandLineContext? ReceivedContext { get; private set; } + + public ValidationResult? OnValidate(CommandLineContext context) + { + ReceivedContext = context; + return ValidationResult.Success; + } + } + + private class CommandWithBothContexts + { + public ValidationContext? ReceivedValidationContext { get; private set; } + public CommandLineContext? ReceivedCommandContext { get; private set; } + + public ValidationResult? OnValidate(ValidationContext validationContext, CommandLineContext commandContext) + { + ReceivedValidationContext = validationContext; + ReceivedCommandContext = commandContext; + return ValidationResult.Success; + } + } + + private class CommandThatThrows + { + public ValidationResult? OnValidate() + { + throw new InvalidOperationException("Validation error"); + } + } + + [Fact] + public void InvokesMethod_ReturnsSuccess() + { + var model = new ValidCommand(); + var method = typeof(ValidCommand).GetMethod("OnValidate")!; + var handler = new ReflectionValidateHandler(method); + var validationContext = new ValidationContext(model); + var commandContext = CreateCommandLineContext(); + + var result = handler.Invoke(model, validationContext, commandContext); + + Assert.True(model.WasValidated); + Assert.Same(ValidationResult.Success, result); + } + + [Fact] + public void InvokesMethod_ReturnsFailure() + { + var model = new InvalidCommand(); + var method = typeof(InvalidCommand).GetMethod("OnValidate")!; + var handler = new ReflectionValidateHandler(method); + var validationContext = new ValidationContext(model); + var commandContext = CreateCommandLineContext(); + + var result = handler.Invoke(model, validationContext, commandContext); + + Assert.NotNull(result); + Assert.Equal("Validation failed", result!.ErrorMessage); + } + + [Fact] + public void PassesValidationContext_ToMethod() + { + var model = new CommandWithValidationContext(); + var method = typeof(CommandWithValidationContext).GetMethod("OnValidate")!; + var handler = new ReflectionValidateHandler(method); + var validationContext = new ValidationContext(model); + var commandContext = CreateCommandLineContext(); + + handler.Invoke(model, validationContext, commandContext); + + Assert.Same(validationContext, model.ReceivedContext); + } + + [Fact] + public void PassesCommandLineContext_ToMethod() + { + var model = new CommandWithCommandLineContext(); + var method = typeof(CommandWithCommandLineContext).GetMethod("OnValidate")!; + var handler = new ReflectionValidateHandler(method); + var validationContext = new ValidationContext(model); + var commandContext = CreateCommandLineContext(); + + handler.Invoke(model, validationContext, commandContext); + + Assert.Same(commandContext, model.ReceivedContext); + } + + [Fact] + public void PassesBothContexts_ToMethod() + { + var model = new CommandWithBothContexts(); + var method = typeof(CommandWithBothContexts).GetMethod("OnValidate")!; + var handler = new ReflectionValidateHandler(method); + var validationContext = new ValidationContext(model); + var commandContext = CreateCommandLineContext(); + + handler.Invoke(model, validationContext, commandContext); + + Assert.Same(validationContext, model.ReceivedValidationContext); + Assert.Same(commandContext, model.ReceivedCommandContext); + } + + [Fact] + public void MethodThrows_PropagatesException() + { + var model = new CommandThatThrows(); + var method = typeof(CommandThatThrows).GetMethod("OnValidate")!; + var handler = new ReflectionValidateHandler(method); + var validationContext = new ValidationContext(model); + var commandContext = CreateCommandLineContext(); + + var ex = Assert.Throws( + () => handler.Invoke(model, validationContext, commandContext)); + + Assert.Equal("Validation error", ex.Message); + } + + private static CommandLineContext CreateCommandLineContext() + { + var app = new CommandLineApplication(); + return new DefaultCommandLineContext( + PhysicalConsole.Singleton, + Directory.GetCurrentDirectory(), + Array.Empty()); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/ReflectionValidationErrorHandlerTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/ReflectionValidationErrorHandlerTests.cs new file mode 100644 index 00000000..f2cb5b18 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/ReflectionValidationErrorHandlerTests.cs @@ -0,0 +1,144 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel.DataAnnotations; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class ReflectionValidationErrorHandlerTests + { + private class DefaultErrorHandler + { + public bool WasCalled { get; private set; } + + public void OnValidationError() + { + WasCalled = true; + } + } + + private class ErrorHandlerWithCustomCode + { + public int OnValidationError() + { + return 42; + } + } + + private class ErrorHandlerWithValidationResult + { + public ValidationResult? ReceivedResult { get; private set; } + + public int OnValidationError(ValidationResult result) + { + ReceivedResult = result; + return 5; + } + } + + private class ErrorHandlerThatThrows + { + public int OnValidationError() + { + throw new InvalidOperationException("Error handler failed"); + } + } + + private class ErrorHandlerReturnsVoid + { + public bool WasCalled { get; private set; } + + public void OnValidationError(ValidationResult result) + { + WasCalled = true; + } + } + + [Fact] + public void InvokesMethod_ReturnsDefaultErrorCode() + { + var model = new DefaultErrorHandler(); + var method = typeof(DefaultErrorHandler).GetMethod("OnValidationError")!; + var handler = new ReflectionValidationErrorHandler(method); + var validationResult = new ValidationResult("Error"); + + var result = handler.Invoke(model, validationResult); + + Assert.True(model.WasCalled); + Assert.Equal(1, result); // Default error code + } + + [Fact] + public void InvokesMethod_ReturnsCustomErrorCode() + { + var model = new ErrorHandlerWithCustomCode(); + var method = typeof(ErrorHandlerWithCustomCode).GetMethod("OnValidationError")!; + var handler = new ReflectionValidationErrorHandler(method); + var validationResult = new ValidationResult("Error"); + + var result = handler.Invoke(model, validationResult); + + Assert.Equal(42, result); + } + + [Fact] + public void PassesValidationResult_ToMethod() + { + var model = new ErrorHandlerWithValidationResult(); + var method = typeof(ErrorHandlerWithValidationResult).GetMethod("OnValidationError")!; + var handler = new ReflectionValidationErrorHandler(method); + var validationResult = new ValidationResult("Specific error"); + + handler.Invoke(model, validationResult); + + Assert.Same(validationResult, model.ReceivedResult); + } + + [Fact] + public void MethodThrows_PropagatesException() + { + var model = new ErrorHandlerThatThrows(); + var method = typeof(ErrorHandlerThatThrows).GetMethod("OnValidationError")!; + var handler = new ReflectionValidationErrorHandler(method); + var validationResult = new ValidationResult("Error"); + + var ex = Assert.Throws( + () => handler.Invoke(model, validationResult)); + + Assert.Equal("Error handler failed", ex.Message); + } + + [Fact] + public void VoidReturnType_ReturnsDefaultErrorCode() + { + var model = new ErrorHandlerReturnsVoid(); + var method = typeof(ErrorHandlerReturnsVoid).GetMethod("OnValidationError")!; + var handler = new ReflectionValidationErrorHandler(method); + var validationResult = new ValidationResult("Error"); + + var result = handler.Invoke(model, validationResult); + + Assert.True(model.WasCalled); + Assert.Equal(1, result); // Default error code for void return + } + + [Fact] + public void ValidationResult_WithMemberNames_PassedCorrectly() + { + var model = new ErrorHandlerWithValidationResult(); + var method = typeof(ErrorHandlerWithValidationResult).GetMethod("OnValidationError")!; + var handler = new ReflectionValidationErrorHandler(method); + var validationResult = new ValidationResult("Field error", new[] { "Field1", "Field2" }); + + handler.Invoke(model, validationResult); + + Assert.NotNull(model.ReceivedResult); + Assert.Equal("Field error", model.ReceivedResult!.ErrorMessage); + Assert.Contains("Field1", model.ReceivedResult.MemberNames); + Assert.Contains("Field2", model.ReceivedResult.MemberNames); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/SpecialPropertiesMetadataTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/SpecialPropertiesMetadataTests.cs new file mode 100644 index 00000000..e009ba29 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/SpecialPropertiesMetadataTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class SpecialPropertiesMetadataTests + { + private class ParentCommand + { + public string? ParentName { get; set; } + } + + private class ChildCommand + { + public ParentCommand? Parent { get; set; } + public object? Subcommand { get; set; } + public string[]? RemainingArguments { get; set; } + } + + [Fact] + public void DefaultValues_AreNull() + { + var metadata = new SpecialPropertiesMetadata(); + + Assert.Null(metadata.ParentSetter); + Assert.Null(metadata.ParentType); + Assert.Null(metadata.SubcommandSetter); + Assert.Null(metadata.SubcommandType); + Assert.Null(metadata.RemainingArgumentsSetter); + Assert.Null(metadata.RemainingArgumentsType); + } + + [Fact] + public void ParentSetter_CanBeSetAndInvoked() + { + var metadata = new SpecialPropertiesMetadata + { + ParentSetter = (obj, val) => ((ChildCommand)obj).Parent = (ParentCommand?)val, + ParentType = typeof(ParentCommand) + }; + + var child = new ChildCommand(); + var parent = new ParentCommand { ParentName = "TestParent" }; + + metadata.ParentSetter!(child, parent); + + Assert.Same(parent, child.Parent); + Assert.Equal(typeof(ParentCommand), metadata.ParentType); + } + + [Fact] + public void SubcommandSetter_CanBeSetAndInvoked() + { + var metadata = new SpecialPropertiesMetadata + { + SubcommandSetter = (obj, val) => ((ChildCommand)obj).Subcommand = val, + SubcommandType = typeof(object) + }; + + var command = new ChildCommand(); + var subcommand = new object(); + + metadata.SubcommandSetter!(command, subcommand); + + Assert.Same(subcommand, command.Subcommand); + Assert.Equal(typeof(object), metadata.SubcommandType); + } + + [Fact] + public void RemainingArgumentsSetter_CanBeSetAndInvoked() + { + var metadata = new SpecialPropertiesMetadata + { + RemainingArgumentsSetter = (obj, val) => ((ChildCommand)obj).RemainingArguments = (string[]?)val, + RemainingArgumentsType = typeof(string[]) + }; + + var command = new ChildCommand(); + var args = new[] { "arg1", "arg2", "arg3" }; + + metadata.RemainingArgumentsSetter!(command, args); + + Assert.Same(args, command.RemainingArguments); + Assert.Equal(typeof(string[]), metadata.RemainingArgumentsType); + } + + [Fact] + public void AllProperties_CanBeSetTogether() + { + var metadata = new SpecialPropertiesMetadata + { + ParentSetter = (obj, val) => ((ChildCommand)obj).Parent = (ParentCommand?)val, + ParentType = typeof(ParentCommand), + SubcommandSetter = (obj, val) => ((ChildCommand)obj).Subcommand = val, + SubcommandType = typeof(object), + RemainingArgumentsSetter = (obj, val) => ((ChildCommand)obj).RemainingArguments = (string[]?)val, + RemainingArgumentsType = typeof(string[]) + }; + + var command = new ChildCommand(); + var parent = new ParentCommand(); + var subcommand = new object(); + var args = new[] { "arg1" }; + + metadata.ParentSetter!(command, parent); + metadata.SubcommandSetter!(command, subcommand); + metadata.RemainingArgumentsSetter!(command, args); + + Assert.Same(parent, command.Parent); + Assert.Same(subcommand, command.Subcommand); + Assert.Same(args, command.RemainingArguments); + } + + [Fact] + public void ParentSetter_CanSetNull() + { + var metadata = new SpecialPropertiesMetadata + { + ParentSetter = (obj, val) => ((ChildCommand)obj).Parent = (ParentCommand?)val, + ParentType = typeof(ParentCommand) + }; + + var child = new ChildCommand { Parent = new ParentCommand() }; + + metadata.ParentSetter!(child, null); + + Assert.Null(child.Parent); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/SubcommandMetadataTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/SubcommandMetadataTests.cs new file mode 100644 index 00000000..fee39c17 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/SubcommandMetadataTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class SubcommandMetadataTests + { + [Command(Name = "test")] + private class TestSubcommand + { + [Option("-n|--name")] + public string? Name { get; set; } + } + + [Command(Name = "other")] + private class OtherSubcommand + { + [Option("-v|--verbose")] + public bool Verbose { get; set; } + } + + [Fact] + public void Constructor_SetsSubcommandType() + { + var metadata = new SubcommandMetadata(typeof(TestSubcommand)); + + Assert.Equal(typeof(TestSubcommand), metadata.SubcommandType); + } + + [Fact] + public void CommandName_DefaultsToNull() + { + var metadata = new SubcommandMetadata(typeof(TestSubcommand)); + + Assert.Null(metadata.CommandName); + } + + [Fact] + public void CommandName_CanBeSet() + { + var metadata = new SubcommandMetadata(typeof(TestSubcommand)) + { + CommandName = "test-cmd" + }; + + Assert.Equal("test-cmd", metadata.CommandName); + } + + [Fact] + public void MetadataProviderFactory_DefaultsToNull() + { + var metadata = new SubcommandMetadata(typeof(TestSubcommand)); + + Assert.Null(metadata.MetadataProviderFactory); + } + + [Fact] + public void MetadataProviderFactory_CanBeSetAndInvoked() + { + var providerCreated = false; + + var metadata = new SubcommandMetadata(typeof(TestSubcommand)) + { + MetadataProviderFactory = () => + { + providerCreated = true; + return new ReflectionMetadataProvider(typeof(TestSubcommand)); + } + }; + + Assert.False(providerCreated); + + var provider = metadata.MetadataProviderFactory!(); + + Assert.True(providerCreated); + Assert.NotNull(provider); + Assert.Equal(typeof(TestSubcommand), provider.ModelType); + } + + [Fact] + public void MultipleSubcommands_CanBeCreated() + { + var testMeta = new SubcommandMetadata(typeof(TestSubcommand)) + { + CommandName = "test" + }; + + var otherMeta = new SubcommandMetadata(typeof(OtherSubcommand)) + { + CommandName = "other" + }; + + Assert.NotEqual(testMeta.SubcommandType, otherMeta.SubcommandType); + Assert.NotEqual(testMeta.CommandName, otherMeta.CommandName); + } + + [Fact] + public void DifferentInstancesAreSeparate() + { + var metadata1 = new SubcommandMetadata(typeof(TestSubcommand)) + { + CommandName = "cmd1" + }; + + var metadata2 = new SubcommandMetadata(typeof(TestSubcommand)) + { + CommandName = "cmd2" + }; + + // Modifying one should not affect the other + Assert.Equal("cmd1", metadata1.CommandName); + Assert.Equal("cmd2", metadata2.CommandName); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/TypedWrapperTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/TypedWrapperTests.cs new file mode 100644 index 00000000..7dba49a9 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/TypedWrapperTests.cs @@ -0,0 +1,109 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class TypedWrapperTests + { + [Command(Name = "test")] + private class TestCommand + { + [Option("-n|--name")] + public string? Name { get; set; } + } + + [Fact] + public void TypedMetadataProviderWrapper_DelegatesToInner() + { + var inner = new ReflectionMetadataProvider(typeof(TestCommand)); + var wrapper = new TypedMetadataProviderWrapper(inner); + + Assert.Equal(inner.ModelType, wrapper.ModelType); + Assert.Same(inner.Options, wrapper.Options); + Assert.Same(inner.Arguments, wrapper.Arguments); + Assert.Same(inner.Subcommands, wrapper.Subcommands); + Assert.Same(inner.CommandInfo, wrapper.CommandInfo); + Assert.Same(inner.ExecuteHandler, wrapper.ExecuteHandler); + Assert.Same(inner.ValidateHandler, wrapper.ValidateHandler); + Assert.Same(inner.ValidationErrorHandler, wrapper.ValidationErrorHandler); + Assert.Same(inner.SpecialProperties, wrapper.SpecialProperties); + Assert.Same(inner.HelpOption, wrapper.HelpOption); + Assert.Same(inner.VersionOption, wrapper.VersionOption); + } + + [Fact] + public void TypedMetadataProviderWrapper_GetModelFactory_ReturnsTypedFactory() + { + var inner = new ReflectionMetadataProvider(typeof(TestCommand)); + var wrapper = new TypedMetadataProviderWrapper(inner); + + var factory = wrapper.GetModelFactory(null); + + Assert.IsAssignableFrom>(factory); + + var instance = factory.Create(); + Assert.IsType(instance); + } + + [Fact] + public void TypedMetadataProviderWrapper_ImplementsGenericInterface() + { + var inner = new ReflectionMetadataProvider(typeof(TestCommand)); + var wrapper = new TypedMetadataProviderWrapper(inner); + + Assert.IsAssignableFrom>(wrapper); + } + + [Fact] + public void TypedMetadataProviderWrapper_UntypedGetModelFactory_Works() + { + var inner = new ReflectionMetadataProvider(typeof(TestCommand)); + var wrapper = new TypedMetadataProviderWrapper(inner); + + ICommandMetadataProvider untypedWrapper = wrapper; + var factory = untypedWrapper.GetModelFactory(null); + + Assert.NotNull(factory); + var instance = factory.Create(); + Assert.IsType(instance); + } + + [Fact] + public void TypedModelFactoryWrapper_Create_ReturnsTypedInstance() + { + var inner = new ActivatorModelFactory(typeof(TestCommand)); + var wrapper = new TypedModelFactoryWrapper(inner); + + TestCommand instance = wrapper.Create(); + + Assert.NotNull(instance); + } + + [Fact] + public void TypedModelFactoryWrapper_UntypedCreate_Works() + { + var inner = new ActivatorModelFactory(typeof(TestCommand)); + var wrapper = new TypedModelFactoryWrapper(inner); + + IModelFactory untypedWrapper = wrapper; + var instance = untypedWrapper.Create(); + + Assert.IsType(instance); + } + + [Fact] + public void TypedModelFactoryWrapper_ImplementsBothInterfaces() + { + var inner = new ActivatorModelFactory(typeof(TestCommand)); + var wrapper = new TypedModelFactoryWrapper(inner); + + Assert.IsAssignableFrom(wrapper); + Assert.IsAssignableFrom>(wrapper); + } + } +} diff --git a/test/CommandLineUtils.Tests/SourceGeneration/VersionOptionMetadataTests.cs b/test/CommandLineUtils.Tests/SourceGeneration/VersionOptionMetadataTests.cs new file mode 100644 index 00000000..38556741 --- /dev/null +++ b/test/CommandLineUtils.Tests/SourceGeneration/VersionOptionMetadataTests.cs @@ -0,0 +1,123 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using McMaster.Extensions.CommandLineUtils.SourceGeneration; +using Xunit; + +namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration +{ + public class VersionOptionMetadataTests + { + private class TestModel + { + public string? AppVersion { get; set; } + } + + [Fact] + public void DefaultValues_AreCorrect() + { + var metadata = new VersionOptionMetadata(); + + Assert.Null(metadata.Template); + Assert.Null(metadata.Description); + Assert.Null(metadata.Version); + Assert.Null(metadata.VersionGetter); + } + + [Fact] + public void Template_CanBeSet() + { + var metadata = new VersionOptionMetadata + { + Template = "-v|--version" + }; + + Assert.Equal("-v|--version", metadata.Template); + } + + [Fact] + public void Description_CanBeSet() + { + var metadata = new VersionOptionMetadata + { + Description = "Show version information" + }; + + Assert.Equal("Show version information", metadata.Description); + } + + [Fact] + public void Version_CanBeSet() + { + var metadata = new VersionOptionMetadata + { + Version = "1.0.0" + }; + + Assert.Equal("1.0.0", metadata.Version); + } + + [Fact] + public void VersionGetter_CanBeSetAndInvoked() + { + var metadata = new VersionOptionMetadata + { + VersionGetter = obj => ((TestModel)obj).AppVersion + }; + + var model = new TestModel { AppVersion = "2.0.0-beta" }; + + var version = metadata.VersionGetter!(model); + + Assert.Equal("2.0.0-beta", version); + } + + [Fact] + public void AllProperties_CanBeSetTogether() + { + var metadata = new VersionOptionMetadata + { + Template = "--ver|--version", + Description = "Display application version", + Version = "3.1.0", + VersionGetter = obj => ((TestModel)obj).AppVersion + }; + + Assert.Equal("--ver|--version", metadata.Template); + Assert.Equal("Display application version", metadata.Description); + Assert.Equal("3.1.0", metadata.Version); + Assert.NotNull(metadata.VersionGetter); + } + + [Fact] + public void VersionGetter_ReturnsNull_WhenPropertyIsNull() + { + var metadata = new VersionOptionMetadata + { + VersionGetter = obj => ((TestModel)obj).AppVersion + }; + + var model = new TestModel { AppVersion = null }; + + var version = metadata.VersionGetter!(model); + + Assert.Null(version); + } + + [Fact] + public void DifferentInstances_AreSeparate() + { + var metadata1 = new VersionOptionMetadata + { + Version = "1.0.0" + }; + + var metadata2 = new VersionOptionMetadata + { + Version = "2.0.0" + }; + + Assert.NotEqual(metadata1.Version, metadata2.Version); + } + } +} diff --git a/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs b/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs index b910dada..2afe5da4 100644 --- a/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs @@ -163,5 +163,75 @@ public void OnExecuteIsNotInvokedWhenVersionOptionSpecified(string arg) { Assert.Equal(0, CommandLineApplication.Execute(new TestConsole(_output), arg)); } + + #region Inherited VersionOption Tests + + private class BaseWithVersionOption + { + [VersionOption("-V|--version", "1.0.0")] + public bool ShowVersion { get; set; } + } + + private class DerivedFromVersionBase : BaseWithVersionOption + { + [Option("-n|--name")] + public string? Name { get; set; } + } + + [Fact] + public void InheritedVersionOption_IsRecognized() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + Assert.NotNull(app.OptionVersion); + Assert.Equal("V", app.OptionVersion?.ShortName); + Assert.Equal("version", app.OptionVersion?.LongName); + } + + [Fact] + public void ApplyingVersionOptionConventionTwice_DoesNotThrow() + { + // This tests the skip logic in OptionAttributeConventionBase.AddOption + // When the same VersionOption is processed twice, it should skip rather than throw + var app = new CommandLineApplication(); + + // Apply VersionOption convention twice - second application should skip + app.Conventions.UseVersionOptionAttribute(); + app.Conventions.UseVersionOptionAttribute(); + + Assert.NotNull(app.OptionVersion); + Assert.Equal("V", app.OptionVersion?.ShortName); + } + + private class BaseWithLongOnlyVersionOption + { + [VersionOption("--version", "1.0.0")] + public bool ShowVersion { get; set; } + } + + private class DerivedFromLongOnlyVersionBase : BaseWithLongOnlyVersionOption + { + [Option("--name")] + public string? Name { get; set; } + } + + [Fact] + public void ApplyingVersionOptionConventionTwice_WithLongOnlyOption_DoesNotThrow() + { + // This tests the skip logic in OptionAttributeConventionBase.AddOption lines 61-63 + // When VersionOption has only long name (no short name), the long name skip logic is tested + var app = new CommandLineApplication(); + + // Apply VersionOption convention twice - second application should skip via long name check + app.Conventions.UseVersionOptionAttribute(); + app.Conventions.UseVersionOptionAttribute(); + + Assert.NotNull(app.OptionVersion); + Assert.Empty(app.OptionVersion?.ShortName ?? ""); + Assert.Equal("version", app.OptionVersion?.LongName); + } + + #endregion } }