From 2a02d39dba3b4027eb32d4db270da92f016a396d Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Mon, 12 May 2025 22:53:08 +0300 Subject: [PATCH] Allow user-defined options to shadow implicit ones (`--help`, `--version`) (#159) --- CliFx.Tests/HelpTextSpecs.cs | 82 ++++++++++++++++++++++++++++--- CliFx.Tests/OptionBindingSpecs.cs | 80 ++++++++++++++++++++++++++++++ CliFx/CliApplication.cs | 5 +- CliFx/Input/OptionInput.cs | 4 +- CliFx/Schema/CommandSchema.cs | 33 ++++++++----- CliFx/Schema/OptionSchema.cs | 4 +- 6 files changed, 183 insertions(+), 25 deletions(-) diff --git a/CliFx.Tests/HelpTextSpecs.cs b/CliFx.Tests/HelpTextSpecs.cs index 4fae4bb..e8c66b6 100644 --- a/CliFx.Tests/HelpTextSpecs.cs +++ b/CliFx.Tests/HelpTextSpecs.cs @@ -34,7 +34,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) } [Fact] - public async Task I_can_request_the_help_text_by_running_the_application_with_the_help_option() + public async Task I_can_request_the_help_text_by_running_the_application_with_the_implicit_help_option() { // Arrange var commandType = DynamicCommandBuilder.Compile( @@ -65,7 +65,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) } [Fact] - public async Task I_can_request_the_help_text_by_running_the_application_with_the_help_option_even_if_the_default_command_is_not_defined() + public async Task I_can_request_the_help_text_by_running_the_application_with_the_implicit_help_option_even_if_the_default_command_is_not_defined() { // Arrange var commandTypes = DynamicCommandBuilder.CompileMany( @@ -102,7 +102,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) } [Fact] - public async Task I_can_request_the_help_text_for_a_specific_command_by_running_the_application_and_specifying_its_name_with_the_help_option() + public async Task I_can_request_the_help_text_for_a_specific_command_by_running_the_application_and_specifying_its_name_with_the_implicit_help_option() { // Arrange var commandTypes = DynamicCommandBuilder.CompileMany( @@ -147,7 +147,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) } [Fact] - public async Task I_can_request_the_help_text_for_a_specific_nested_command_by_running_the_application_and_specifying_its_name_with_the_help_option() + public async Task I_can_request_the_help_text_for_a_specific_nested_command_by_running_the_application_and_specifying_its_name_with_the_implicit_help_option() { // Arrange var commandTypes = DynamicCommandBuilder.CompileMany( @@ -476,7 +476,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) } [Fact] - public async Task I_can_request_the_help_text_to_see_the_help_and_version_options() + public async Task I_can_request_the_help_text_to_see_the_help_and_implicit_version_options() { // Arrange var commandType = DynamicCommandBuilder.Compile( @@ -515,7 +515,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) } [Fact] - public async Task I_can_request_the_help_text_on_a_named_command_to_see_the_help_option() + public async Task I_can_request_the_help_text_on_a_named_command_to_see_the_implicit_help_option() { // Arrange var commandType = DynamicCommandBuilder.Compile( @@ -974,7 +974,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) } [Fact] - public async Task I_can_request_the_version_text_by_running_the_application_with_the_version_option() + public async Task I_can_request_the_version_text_by_running_the_application_with_the_implicit_version_option() { // Arrange var application = new CliApplicationBuilder() @@ -992,4 +992,72 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) var stdOut = FakeConsole.ReadOutputString(); stdOut.Trim().Should().Be("v6.9"); } + + [Fact] + public async Task I_cannot_request_the_help_text_by_running_the_application_with_the_implicit_help_option_if_there_is_an_option_with_the_same_identifier() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // lang=csharp + """ + [Command] + public class DefaultCommand : ICommand + { + [CommandOption("help", 'h')] + public string? Foo { get; init; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } + """ + ); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .SetDescription("This will be in help text") + .Build(); + + // Act + var exitCode = await application.RunAsync(["--help"], new Dictionary()); + + // Assert + exitCode.Should().Be(0); + + var stdOut = FakeConsole.ReadOutputString(); + stdOut.Should().NotContain("This will be in help text"); + } + + [Fact] + public async Task I_cannot_request_the_version_text_by_running_the_application_with_the_implicit_version_option_if_there_is_an_option_with_the_same_identifier() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // lang=csharp + """ + [Command] + public class DefaultCommand : ICommand + { + [CommandOption("version")] + public string? Foo { get; init; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } + """ + ); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .SetVersion("v6.9") + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync(["--version"], new Dictionary()); + + // Assert + exitCode.Should().Be(0); + + var stdOut = FakeConsole.ReadOutputString(); + stdOut.Trim().Should().NotBe("v6.9"); + } } diff --git a/CliFx.Tests/OptionBindingSpecs.cs b/CliFx.Tests/OptionBindingSpecs.cs index 4e7dbb6..3f3e37f 100644 --- a/CliFx.Tests/OptionBindingSpecs.cs +++ b/CliFx.Tests/OptionBindingSpecs.cs @@ -587,6 +587,86 @@ public class OptionBindingSpecs(ITestOutputHelper testOutput) : SpecsBase(testOu stdOut.Trim().Should().Be("-13"); } + [Fact] + public async Task I_can_bind_an_option_to_a_property_with_the_same_identifier_as_the_implicit_help_option_and_get_the_correct_value() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // lang=csharp + """ + [Command] + public class Command : ICommand + { + [CommandOption("help", 'h')] + public string? Foo { get; init; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.WriteLine(Foo); + return default; + } + } + """ + ); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + ["--help", "me"], + new Dictionary() + ); + + // Assert + exitCode.Should().Be(0); + + var stdOut = FakeConsole.ReadOutputString(); + stdOut.Trim().Should().Be("me"); + } + + [Fact] + public async Task I_can_bind_an_option_to_a_property_with_the_same_identifier_as_the_implicit_version_option_and_get_the_correct_value() + { + // Arrange + var commandType = DynamicCommandBuilder.Compile( + // lang=csharp + """ + [Command] + public class Command : ICommand + { + [CommandOption("version")] + public string? Foo { get; init; } + + public ValueTask ExecuteAsync(IConsole console) + { + console.WriteLine(Foo); + return default; + } + } + """ + ); + + var application = new CliApplicationBuilder() + .AddCommand(commandType) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + ["--version", "1.2.0"], + new Dictionary() + ); + + // Assert + exitCode.Should().Be(0); + + var stdOut = FakeConsole.ReadOutputString(); + stdOut.Trim().Should().Be("1.2.0"); + } + [Fact] public async Task I_can_try_to_bind_a_required_option_to_a_property_and_get_an_error_if_the_user_does_not_provide_the_corresponding_argument() { diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 945feef..3a13577 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Runtime.InteropServices; using System.Threading.Tasks; using CliFx.Exceptions; using CliFx.Formatting; @@ -43,14 +42,14 @@ public class CliApplication( Configuration.IsPreviewModeAllowed && commandInput.IsPreviewDirectiveSpecified; private bool ShouldShowHelpText(CommandSchema commandSchema, CommandInput commandInput) => - commandSchema.IsHelpOptionAvailable && commandInput.IsHelpOptionSpecified + commandSchema.IsImplicitHelpOptionAvailable && commandInput.IsHelpOptionSpecified || // Show help text also if the fallback default command is executed without any arguments commandSchema == FallbackDefaultCommand.Schema && !commandInput.HasArguments; private bool ShouldShowVersionText(CommandSchema commandSchema, CommandInput commandInput) => - commandSchema.IsVersionOptionAvailable && commandInput.IsVersionOptionSpecified; + commandSchema.IsImplicitVersionOptionAvailable && commandInput.IsVersionOptionSpecified; private async ValueTask PromptDebuggerAsync() { diff --git a/CliFx/Input/OptionInput.cs b/CliFx/Input/OptionInput.cs index 805d4f3..443cc6e 100644 --- a/CliFx/Input/OptionInput.cs +++ b/CliFx/Input/OptionInput.cs @@ -9,9 +9,9 @@ internal class OptionInput(string identifier, IReadOnlyList values) public IReadOnlyList Values { get; } = values; - public bool IsHelpOption => OptionSchema.HelpOption.MatchesIdentifier(Identifier); + public bool IsHelpOption => OptionSchema.ImplicitHelpOption.MatchesIdentifier(Identifier); - public bool IsVersionOption => OptionSchema.VersionOption.MatchesIdentifier(Identifier); + public bool IsVersionOption => OptionSchema.ImplicitVersionOption.MatchesIdentifier(Identifier); public string GetFormattedIdentifier() => Identifier switch diff --git a/CliFx/Schema/CommandSchema.cs b/CliFx/Schema/CommandSchema.cs index 0587984..946958d 100644 --- a/CliFx/Schema/CommandSchema.cs +++ b/CliFx/Schema/CommandSchema.cs @@ -28,9 +28,10 @@ internal partial class CommandSchema( public bool IsDefault => string.IsNullOrWhiteSpace(Name); - public bool IsHelpOptionAvailable => Options.Contains(OptionSchema.HelpOption); + public bool IsImplicitHelpOptionAvailable => Options.Contains(OptionSchema.ImplicitHelpOption); - public bool IsVersionOptionAvailable => Options.Contains(OptionSchema.VersionOption); + public bool IsImplicitVersionOptionAvailable => + Options.Contains(OptionSchema.ImplicitVersionOption); public bool MatchesName(string? name) => !string.IsNullOrWhiteSpace(Name) @@ -74,10 +75,6 @@ internal partial class CommandSchema var name = attribute?.Name?.Trim(); var description = attribute?.Description?.Trim(); - var implicitOptionSchemas = string.IsNullOrWhiteSpace(name) - ? new[] { OptionSchema.HelpOption, OptionSchema.VersionOption } - : new[] { OptionSchema.HelpOption }; - var properties = type // Get properties directly on the command type .GetProperties() @@ -103,11 +100,25 @@ internal partial class CommandSchema .WhereNotNull() .ToArray(); - var optionSchemas = properties - .Select(OptionSchema.TryResolve) - .WhereNotNull() - .Concat(implicitOptionSchemas) - .ToArray(); + var optionSchemas = properties.Select(OptionSchema.TryResolve).WhereNotNull().ToList(); + + // Include implicit options, if appropriate + var isImplicitHelpOptionAvailable = + // If the command implements its own help option, don't include the implicit one + !optionSchemas.Any(o => o.MatchesShortName('h') || o.MatchesName("help")); + + if (isImplicitHelpOptionAvailable) + optionSchemas.Add(OptionSchema.ImplicitHelpOption); + + var isImplicitVersionOptionAvailable = + // Only the default command can have the version option + string.IsNullOrWhiteSpace(name) + && + // If the command implements its own version option, don't include the implicit one + !optionSchemas.Any(o => o.MatchesName("version")); + + if (isImplicitVersionOptionAvailable) + optionSchemas.Add(OptionSchema.ImplicitVersionOption); return new CommandSchema(type, name, description, parameterSchemas, optionSchemas); } diff --git a/CliFx/Schema/OptionSchema.cs b/CliFx/Schema/OptionSchema.cs index c169e1e..e482f11 100644 --- a/CliFx/Schema/OptionSchema.cs +++ b/CliFx/Schema/OptionSchema.cs @@ -103,7 +103,7 @@ internal partial class OptionSchema internal partial class OptionSchema { - public static OptionSchema HelpOption { get; } = + public static OptionSchema ImplicitHelpOption { get; } = new( NullPropertyDescriptor.Instance, "help", @@ -115,7 +115,7 @@ internal partial class OptionSchema Array.Empty() ); - public static OptionSchema VersionOption { get; } = + public static OptionSchema ImplicitVersionOption { get; } = new( NullPropertyDescriptor.Instance, "version",