diff --git a/src/Spectre.Console.Cli.Tests/CommandLineParserTests.cs b/src/Spectre.Console.Cli.Tests/CommandLineParserTests.cs new file mode 100644 index 0000000..0d4b3d5 --- /dev/null +++ b/src/Spectre.Console.Cli.Tests/CommandLineParserTests.cs @@ -0,0 +1,426 @@ +using Spectre.Console.Cli.Completion; + +namespace Spectre.Console.Tests.Unit.Cli; + +public sealed class CommandLineParserTests +{ + [Fact] + public void Should_Throw_If_Model_Is_Null() + { + // Given + var settings = new FakeCommandAppSettings(); + + // When + var exception = Record.Exception(() => CommandLineParser.Parse(null!, settings, [])); + + // Then + exception.ShouldBeOfType() + .ParamName.ShouldBe("model"); + } + + [Fact] + public void Should_Throw_If_Settings_Is_Null() + { + // Given + var model = new FakeCommandModel(); + + // When + var exception = Record.Exception(() => CommandLineParser.Parse(model, null!, [])); + + // Then + exception.ShouldBeOfType() + .ParamName.ShouldBe("settings"); + } + + [Fact] + public void Should_Throw_If_Model_Is_Not_Created_By_Spectre_Console_Cli() + { + // Given + var model = new FakeCommandModel(); + var settings = new FakeCommandAppSettings(); + + // When + var exception = Record.Exception(() => CommandLineParser.Parse(model, settings, [])); + + // Then + exception.ShouldBeOfType() + .ParamName.ShouldBe("model"); + } + + [Fact] + public void Should_Handle_Null_Args_As_Empty() + { + // Given + var (model, settings) = BuildModel(config => + { + config.SetDefaultCommand(); + }); + + // When + var result = CommandLineParser.Parse(model, settings, args: null!); + + // Then + result.Command.ShouldNotBeNull(); + result.Command.IsDefaultCommand.ShouldBeTrue(); + result.CommandType.ShouldBe(typeof(DogCommand)); + result.SettingsType.ShouldBe(typeof(DogSettings)); + } + + [Fact] + public void Should_Return_Null_Command_For_Help() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + + // When + var result = CommandLineParser.Parse(model, settings, ["-h"]); + + // Then + result.Command.ShouldBeNull(); + result.CommandType.ShouldBeNull(); + result.SettingsType.ShouldBeNull(); + result.MappedParameters.Count.ShouldBe(0); + } + + [Fact] + public void Should_Return_Types_And_Mapped_Parameters_For_Leaf_Command() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + + // When + var result = CommandLineParser.Parse(model, settings, new[] + { + "dog", "12", "4", + "--good-boy", + "--name", "Rufus", + "--alive", + }); + + // Then + result.Command.ShouldNotBeNull(); + result.Command!.Name.ShouldBe("dog"); + result.CommandType.ShouldBe(typeof(DogCommand)); + result.SettingsType.ShouldBe(typeof(DogSettings)); + + GetMappedValue(result, nameof(AnimalSettings.Legs)).ShouldBe("12"); + GetMappedValue(result, nameof(DogSettings.Age)).ShouldBe("4"); + GetMappedValue(result, nameof(DogSettings.GoodBoy)).ShouldBe("true"); + GetMappedValue(result, nameof(MammalSettings.Name)).ShouldBe("Rufus"); + GetMappedValue(result, nameof(AnimalSettings.IsAlive)).ShouldBe("true"); + + result.RemainingArguments.Parsed.Count.ShouldBe(0); + result.RemainingArguments.Raw.Count.ShouldBe(0); + } + + [Fact] + public void Should_Select_Default_Command_At_Root_When_No_Arguments() + { + // Given + var (model, settings) = BuildModel(config => + { + config.SetDefaultCommand(); + config.AddCommand("cat"); + }); + + // When + var result = CommandLineParser.Parse(model, settings, []); + + // Then + result.Command.ShouldNotBeNull(); + result.Command!.IsDefaultCommand.ShouldBeTrue(); + result.CommandType.ShouldBe(typeof(DogCommand)); + result.SettingsType.ShouldBe(typeof(DogSettings)); + } + + [Fact] + public void Should_Put_Unknown_Options_In_Remaining_When_Relaxed() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + settings.StrictParsing = false; + + // When + var result = CommandLineParser.Parse(model, settings, new[] + { + "dog", "12", "4", + "--unknown", "value", + }); + + // Then + result.CommandType.ShouldBe(typeof(DogCommand)); + result.RemainingArguments.Parsed.Contains("--unknown").ShouldBeTrue(); + result.RemainingArguments.Parsed["--unknown"].ShouldContain("value"); + result.RemainingArguments.Raw.Count.ShouldBe(0); + } + + [Fact] + public void Should_Throw_On_Unknown_Options_When_Strict() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + settings.StrictParsing = true; + + // When / Then + Should.Throw(() => + CommandLineParser.Parse(model, settings, ["dog", "12", "4", "--unknown", "value"])); + } + + [Fact] + public void Should_Allow_Unknown_Options_After_Delimiter_When_Strict() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + settings.StrictParsing = true; + + // When + var result = CommandLineParser.Parse(model, settings, ["dog", "12", "4", "--", "--unknown", "value"]); + + // Then + result.CommandType.ShouldBe(typeof(DogCommand)); + result.RemainingArguments.Parsed.Contains("--unknown").ShouldBeTrue(); + result.RemainingArguments.Parsed["--unknown"].ShouldContain("value"); + result.RemainingArguments.Raw.Count.ShouldBe(2); + result.RemainingArguments.Raw.ShouldBe(["--unknown", "value"]); + } + + [Fact] + public void Should_Infer_The_Default_Command_On_A_Branch_When_Relaxed() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddBranch("animal", animal => + { + animal.SetDefaultCommand(); + }); + }); + settings.StrictParsing = false; + + // When + var result = CommandLineParser.Parse(model, settings, new[] + { + "animal", "4", "-a", "false", + "--name", "Kitty", + "--agility", "four", + "--nick-name", "Felix", + }); + + // Then + result.Command.ShouldNotBeNull(); + result.Command!.IsDefaultCommand.ShouldBeTrue(); + result.CommandType.ShouldBe(typeof(CatCommand)); + result.SettingsType.ShouldBe(typeof(CatSettings)); + + GetMappedValue(result, nameof(MammalSettings.Name)).ShouldBe("Kitty"); + GetMappedValue(result, nameof(CatSettings.Agility)).ShouldBe("four"); + + result.RemainingArguments.Parsed.Contains("--nick-name").ShouldBeTrue(); + result.RemainingArguments.Parsed["--nick-name"].ShouldContain("Felix"); + result.RemainingArguments.Raw.Count.ShouldBe(0); + } + + [Fact] + public void Should_Infer_The_Default_Command_On_A_Branch_When_Strict() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddBranch("animal", animal => + { + animal.SetDefaultCommand(); + }); + }); + settings.StrictParsing = true; + + // When + var result = CommandLineParser.Parse(model, settings, new[] + { + "animal", "4", "-a", "false", + "--name", "Kitty", + "--agility", "four", + "--", "--nick-name", "Felix", + }); + + // Then + result.Command.ShouldNotBeNull(); + result.Command!.IsDefaultCommand.ShouldBeTrue(); + result.CommandType.ShouldBe(typeof(CatCommand)); + result.SettingsType.ShouldBe(typeof(CatSettings)); + + GetMappedValue(result, nameof(MammalSettings.Name)).ShouldBe("Kitty"); + GetMappedValue(result, nameof(CatSettings.Agility)).ShouldBe("four"); + + result.RemainingArguments.Parsed.Contains("--nick-name").ShouldBeTrue(); + result.RemainingArguments.Parsed["--nick-name"].ShouldContain("Felix"); + result.RemainingArguments.Raw.Count.ShouldBe(2); + result.RemainingArguments.Raw.ShouldBe(["--nick-name", "Felix"]); + } + + [Fact] + public void Should_Throw_When_Assigning_A_Value_To_A_Flag_And_Conversion_Is_Disabled() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + settings.StrictParsing = false; + settings.ConvertFlagsToRemainingArguments = false; + + // When / Then + Should.Throw(() => + CommandLineParser.Parse(model, settings, ["dog", "--alive=indeterminate", "12", "4"])); + } + + [Fact] + public void Should_Convert_Flag_With_Invalid_Value_To_Remaining_When_Enabled() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + settings.StrictParsing = false; + settings.ConvertFlagsToRemainingArguments = true; + + // When + var result = CommandLineParser.Parse(model, settings, ["dog", "--alive=indeterminate", "12", "4"]); + + // Then + result.CommandType.ShouldBe(typeof(DogCommand)); + result.MappedParameters.Any(p => p.Parameter.PropertyName == nameof(AnimalSettings.IsAlive)).ShouldBeFalse(); + result.RemainingArguments.Parsed.Contains("--alive").ShouldBeTrue(); + result.RemainingArguments.Parsed["--alive"].ShouldContain("indeterminate"); + } + + [Fact] + public void Should_Parse_Command_When_CaseInsensitive() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + settings.StrictParsing = true; + settings.CaseSensitivity = CaseSensitivity.None; + + // When + var result = CommandLineParser.Parse(model, settings, ["DOG", "12", "4"]); + + // Then + result.CommandType.ShouldBe(typeof(DogCommand)); + } + + [Fact] + public void Should_Throw_When_Commands_Are_Case_Sensitive() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + settings.StrictParsing = true; + + settings.CaseSensitivity = CaseSensitivity.Commands; + + // When / Then + Should.Throw(() => + CommandLineParser.Parse(model, settings, ["DOG", "12", "4"])); + } + + [Fact] + public void Should_Throw_When_LongOption_CaseSensitive() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + settings.StrictParsing = true; + + // Long options are case-sensitive by default. + settings.CaseSensitivity = CaseSensitivity.All; + + Should.Throw(() => + CommandLineParser.Parse(model, settings, ["dog", "12", "4", "--NAME", "Rufus"])); + } + + [Fact] + public void Should_Map_LongOption_When_CaseInsensitive() + { + // Given + var (model, settings) = BuildModel(config => + { + config.AddCommand("dog"); + }); + settings.StrictParsing = true; + + // Allow long options to be case-insensitive. + settings.CaseSensitivity = CaseSensitivity.Commands; + var result = CommandLineParser.Parse(model, settings, ["dog", "12", "4", "--NAME", "Rufus"]); + GetMappedValue(result, nameof(MammalSettings.Name)).ShouldBe("Rufus"); + } + + private static string? GetMappedValue(CommandLineParseResult result, string propertyName) + { + return result.MappedParameters + .Single(p => p.Parameter.PropertyName == propertyName) + .Value; + } + + private static (ICommandModel Model, CommandAppSettings Settings) BuildModel(Action configure) + { + var registrar = new FakeTypeRegistrar(); + var config = new Configurator(registrar); + configure(config); + + var model = CommandModelBuilder.Build(config); + return (model, config.Settings); + } + + private sealed class FakeCommandModel : ICommandModel + { + public string ApplicationName => "fake"; + public string? ApplicationVersion => null; + public IReadOnlyList Examples => Array.Empty(); + public IReadOnlyList Commands => Array.Empty(); + public ICommandInfo? DefaultCommand => null; + } + + private sealed class FakeCommandAppSettings : ICommandAppSettings + { + public CultureInfo? Culture { get; set; } + public string? ApplicationName { get; set; } + public string? ApplicationVersion { get; set; } + public int MaximumIndirectExamples { get; set; } + public bool ShowOptionDefaultValues { get; set; } + public bool TrimTrailingPeriod { get; set; } + public HelpProviderStyle? HelpProviderStyles { get; set; } + public IAnsiConsole? Console { get; set; } + public ICommandInterceptor? Interceptor { get; set; } + public ITypeRegistrarFrontend Registrar { get; } = new TypeRegistrar(new FakeTypeRegistrar()); + public CaseSensitivity CaseSensitivity { get; set; } = CaseSensitivity.All; + public bool StrictParsing { get; set; } + public bool ConvertFlagsToRemainingArguments { get; set; } + public bool PropagateExceptions { get; set; } + public int CancellationExitCode { get; set; } + public bool ValidateExamples { get; set; } + public Func? ExceptionHandler { get; set; } + } +} diff --git a/src/Spectre.Console.Cli.Tests/TypeResolverAccessorTests.cs b/src/Spectre.Console.Cli.Tests/TypeResolverAccessorTests.cs new file mode 100644 index 0000000..4f43215 --- /dev/null +++ b/src/Spectre.Console.Cli.Tests/TypeResolverAccessorTests.cs @@ -0,0 +1,177 @@ +using Spectre.Console.Cli.Completion; + +namespace Spectre.Console.Tests.Unit.Cli; + +public sealed class TypeResolverAccessorTests +{ + [Fact] + public void Should_Inject_CommandModel_Settings_And_ResolverAccessor() + { + // Given + var sink = new CaptureSink(); + var app = new CommandAppTester(); + app.Configure(config => + { + config.PropagateExceptions(); + config.Settings.Registrar.RegisterInstance(sink); + + config.AddBranch("animal", animal => + { + animal.SetDefaultCommand(); + }); + + config.AddCommand("capture"); + }); + + // When + var result = app.Run("capture"); + + // Then + result.ExitCode.ShouldBe(0); + + sink.Model.ShouldNotBeNull(); + sink.Settings.ShouldNotBeNull(); + sink.Accessor.ShouldNotBeNull(); + + sink.ResolverDuringExecution.ShouldNotBeNull(); + sink.Accessor!.Resolver.ShouldBeNull(); + + sink.ResolvedModelViaAccessor.ShouldNotBeNull(); + sink.ResolvedSettingsViaAccessor.ShouldNotBeNull(); + + sink.ParseResult.ShouldNotBeNull(); + sink.ParseResult!.CommandType.ShouldBe(typeof(CatCommand)); + sink.ParseResult.SettingsType.ShouldBe(typeof(CatSettings)); + sink.ParseResult.Command.ShouldNotBeNull(); + sink.ParseResult.Command!.IsDefaultCommand.ShouldBeTrue(); + GetMappedValue(sink.ParseResult, nameof(MammalSettings.Name)).ShouldBe("Kitty"); + } + + [Fact] + public void Should_Reset_ResolverAccessor_When_Command_Throws() + { + // Given + var sink = new CaptureSink(); + var app = new CommandAppTester(); + app.Configure(config => + { + config.PropagateExceptions(); + config.Settings.Registrar.RegisterInstance(sink); + config.AddCommand("throw"); + }); + + // When + var exception = Record.Exception(() => app.Run("throw")); + + // Then + exception.ShouldBeOfType(); + sink.Accessor.ShouldNotBeNull(); + sink.ResolverDuringExecution.ShouldNotBeNull(); + sink.Accessor!.Resolver.ShouldBeNull(); + } + + [Fact] + public void Should_Set_And_Clear_ResolverAccessor_On_Multiple_Runs() + { + // Given + var sink = new CaptureSink(); + var app = new CommandAppTester(); + app.Configure(config => + { + config.PropagateExceptions(); + config.Settings.Registrar.RegisterInstance(sink); + config.AddBranch("animal", animal => + { + animal.SetDefaultCommand(); + }); + config.AddCommand("capture"); + }); + + // When + app.Run("capture"); + app.Run("capture"); + + // Then + sink.Executions.ShouldBe(2); + sink.ResolverNonNullExecutions.ShouldBe(2); + sink.Accessor.ShouldNotBeNull(); + sink.Accessor!.Resolver.ShouldBeNull(); + } + + private static string? GetMappedValue(CommandLineParseResult result, string propertyName) + { + return result.MappedParameters + .Single(p => p.Parameter.PropertyName == propertyName) + .Value; + } + + public sealed class CaptureCommand : Command + { + private readonly ICommandModel _model; + private readonly ICommandAppSettings _settings; + private readonly ITypeResolverAccessor _resolverAccessor; + private readonly CaptureSink _sink; + + public CaptureCommand(ICommandModel model, ICommandAppSettings settings, ITypeResolverAccessor resolverAccessor, CaptureSink sink) + { + _model = model ?? throw new ArgumentNullException(nameof(model)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _resolverAccessor = resolverAccessor ?? throw new ArgumentNullException(nameof(resolverAccessor)); + _sink = sink ?? throw new ArgumentNullException(nameof(sink)); + } + + protected override int Execute(CommandContext context, EmptyCommandSettings settings, CancellationToken cancellationToken) + { + _sink.Executions++; + _sink.Model = _model; + _sink.Settings = _settings; + _sink.Accessor = _resolverAccessor; + + _sink.ResolverDuringExecution = _resolverAccessor.Resolver; + if (_resolverAccessor.Resolver != null) + { + _sink.ResolverNonNullExecutions++; + _sink.ResolvedModelViaAccessor = _resolverAccessor.Resolver.Resolve(typeof(ICommandModel)); + _sink.ResolvedSettingsViaAccessor = _resolverAccessor.Resolver.Resolve(typeof(ICommandAppSettings)); + } + + _sink.ParseResult = CommandLineParser.Parse(_model, _settings, ["animal", "4", "--name", "Kitty"]); + return 0; + } + } + + public sealed class ThrowingCaptureCommand : Command + { + private readonly ITypeResolverAccessor _resolverAccessor; + private readonly CaptureSink _sink; + + public ThrowingCaptureCommand(ITypeResolverAccessor resolverAccessor, CaptureSink sink) + { + _resolverAccessor = resolverAccessor ?? throw new ArgumentNullException(nameof(resolverAccessor)); + _sink = sink ?? throw new ArgumentNullException(nameof(sink)); + } + + protected override int Execute(CommandContext context, EmptyCommandSettings settings, CancellationToken cancellationToken) + { + _sink.Accessor = _resolverAccessor; + _sink.ResolverDuringExecution = _resolverAccessor.Resolver; + throw new InvalidOperationException("Boom"); + } + } + + public sealed class CaptureSink + { + public int Executions { get; set; } + public int ResolverNonNullExecutions { get; set; } + + public ICommandModel? Model { get; set; } + public ICommandAppSettings? Settings { get; set; } + public ITypeResolverAccessor? Accessor { get; set; } + + public ITypeResolver? ResolverDuringExecution { get; set; } + public object? ResolvedModelViaAccessor { get; set; } + public object? ResolvedSettingsViaAccessor { get; set; } + + public CommandLineParseResult? ParseResult { get; set; } + } +} diff --git a/src/Spectre.Console.Cli.Tests/VerifyConfiguration.cs b/src/Spectre.Console.Cli.Tests/VerifyConfiguration.cs index d404c85..a14af45 100644 --- a/src/Spectre.Console.Cli.Tests/VerifyConfiguration.cs +++ b/src/Spectre.Console.Cli.Tests/VerifyConfiguration.cs @@ -5,6 +5,12 @@ public static class VerifyConfiguration [ModuleInitializer] public static void Init() { + // Set the culture to invariant to ensure consistent test results regardless of the environment. (e.g., german host makes spectre.console.cli.tests fail because of i18n help output) + CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + Verifier.DerivePathInfo(Expectations.Initialize); } } \ No newline at end of file diff --git a/src/Spectre.Console.Cli/Completion/CommandLineMappedParameter.cs b/src/Spectre.Console.Cli/Completion/CommandLineMappedParameter.cs new file mode 100644 index 0000000..ef4bb33 --- /dev/null +++ b/src/Spectre.Console.Cli/Completion/CommandLineMappedParameter.cs @@ -0,0 +1,26 @@ +namespace Spectre.Console.Cli.Completion; + +/// +/// Represents a mapped parameter/value pair from a parsed command line. +/// +public sealed class CommandLineMappedParameter +{ + /// + /// Gets the mapped parameter. + /// + public ICommandParameterInfo Parameter { get; } + + /// + /// Gets the mapped value, if any. + /// + public string? Value { get; } + + /// + /// Initializes a new instance of the class. + /// + public CommandLineMappedParameter(ICommandParameterInfo parameter, string? value) + { + Parameter = parameter ?? throw new ArgumentNullException(nameof(parameter)); + Value = value; + } +} diff --git a/src/Spectre.Console.Cli/Completion/CommandLineParseResult.cs b/src/Spectre.Console.Cli/Completion/CommandLineParseResult.cs new file mode 100644 index 0000000..f9fba3b --- /dev/null +++ b/src/Spectre.Console.Cli/Completion/CommandLineParseResult.cs @@ -0,0 +1,49 @@ +namespace Spectre.Console.Cli.Completion; + +/// +/// Represents the result of parsing a command line against a command model. +/// +public sealed class CommandLineParseResult +{ + /// + /// Gets the parsed command context (leaf command), if any. + /// + public Help.ICommandInfo? Command { get; } + + /// + /// Gets the parsed command type, if any. + /// + public Type? CommandType { get; } + + /// + /// Gets the parsed settings type, if any. + /// + public Type? SettingsType { get; } + + /// + /// Gets the mapped parameters for the leaf command. + /// + public IReadOnlyList MappedParameters { get; } + + /// + /// Gets the remaining arguments. + /// + public IRemainingArguments RemainingArguments { get; } + + /// + /// Initializes a new instance of the class. + /// + public CommandLineParseResult( + Help.ICommandInfo? command, + Type? commandType, + Type? settingsType, + IReadOnlyList mappedParameters, + IRemainingArguments remainingArguments) + { + Command = command; + CommandType = commandType; + SettingsType = settingsType; + MappedParameters = mappedParameters ?? throw new ArgumentNullException(nameof(mappedParameters)); + RemainingArguments = remainingArguments ?? throw new ArgumentNullException(nameof(remainingArguments)); + } +} diff --git a/src/Spectre.Console.Cli/Completion/CommandLineParser.cs b/src/Spectre.Console.Cli/Completion/CommandLineParser.cs new file mode 100644 index 0000000..0f319b5 --- /dev/null +++ b/src/Spectre.Console.Cli/Completion/CommandLineParser.cs @@ -0,0 +1,52 @@ +namespace Spectre.Console.Cli.Completion; + +/// +/// Parses command line arguments using the Spectre.Console.Cli command model. +/// +public static class CommandLineParser +{ + /// + /// Parses the command line against the specified model. + /// + /// + /// This parser supports models created by Spectre.Console.Cli itself. + /// + public static CommandLineParseResult Parse(Help.ICommandModel model, ICommandAppSettings settings, IEnumerable args) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + var internalModel = model as CommandModel; + if (internalModel == null) + { + throw new ArgumentException("The command model must be created by Spectre.Console.Cli.", nameof(model)); + } + + var arguments = args.ToSafeReadOnlyList(); + var parsedResult = CommandLineArgumentParser.ParseWithDefaults(internalModel, settings, arguments); + + var leaf = parsedResult.Tree?.GetLeafCommand(); + var command = (Help.ICommandInfo?)leaf?.Command; + var mapped = leaf?.Mapped + .Select(p => new CommandLineMappedParameter(p.Parameter, p.Value)) + .ToList() + ?? new List(); + + return new CommandLineParseResult( + command, + leaf?.Command.CommandType, + leaf?.Command.SettingsType, + mapped.AsReadOnly(), + parsedResult.Remaining); + } + + +} + diff --git a/src/Spectre.Console.Cli/ITypeResolverAccessor.cs b/src/Spectre.Console.Cli/ITypeResolverAccessor.cs new file mode 100644 index 0000000..d1fccc7 --- /dev/null +++ b/src/Spectre.Console.Cli/ITypeResolverAccessor.cs @@ -0,0 +1,12 @@ +namespace Spectre.Console.Cli; + +/// +/// Provides access to the current during command execution. +/// +public interface ITypeResolverAccessor +{ + /// + /// Gets the current resolver, if available. + /// + ITypeResolver? Resolver { get; } +} \ No newline at end of file diff --git a/src/Spectre.Console.Cli/Internal/CommandExecutor.cs b/src/Spectre.Console.Cli/Internal/CommandExecutor.cs index 66ec642..f5883e3 100644 --- a/src/Spectre.Console.Cli/Internal/CommandExecutor.cs +++ b/src/Spectre.Console.Cli/Internal/CommandExecutor.cs @@ -1,5 +1,3 @@ -using static Spectre.Console.Cli.CommandTreeTokenizer; - namespace Spectre.Console.Cli; internal sealed class CommandExecutor @@ -24,11 +22,16 @@ public async Task ExecuteAsync(IConfiguration configuration, IEnumerable configuration.Settings.Console.GetConsole()); + var resolverAccessor = new TypeResolverAccessor(); + _registrar.RegisterInstance(typeof(ITypeResolverAccessor), resolverAccessor); + // Create the command model. var model = CommandModelBuilder.Build(configuration); _registrar.RegisterInstance(typeof(CommandModel), model); + _registrar.RegisterInstance(typeof(Spectre.Console.Cli.Help.ICommandModel), model); _registrar.RegisterDependencies(model); // Got at least one argument? @@ -46,7 +49,7 @@ public async Task ExecuteAsync(IConfiguration configuration, IEnumerable ExecuteAsync(IConfiguration configuration, IEnumerable ExecuteAsync(IConfiguration configuration, IEnumerable)) as IEnumerable; - var helpProvider = helpProviders?.LastOrDefault() ?? new HelpProvider(configuration.Settings); - - // Currently the root? - if (parsedResult.Tree == null) - { - // Display help. - configuration.Settings.Console.SafeRender(helpProvider.Write(model, null)); - return 0; - } - - // Get the command to execute. - var leaf = parsedResult.Tree.GetLeafCommand(); - if (leaf.Command.IsBranch || leaf.ShowHelp) + try { - // Branches can't be executed. Show help. - configuration.Settings.Console.SafeRender(helpProvider.Write(model, leaf.Command)); - return leaf.ShowHelp ? 0 : 1; - } - - // Is this the default and is it called without arguments when there are required arguments? - if (leaf.Command.IsDefaultCommand && arguments.Count == 0 && leaf.Command.Parameters.Any(p => p.IsRequired)) - { - // Display help for default command. - configuration.Settings.Console.SafeRender(helpProvider.Write(model, leaf.Command)); - return 1; - } + resolverAccessor.Resolver = resolver; - // Create the content. - var context = new CommandContext( - arguments, - parsedResult.Remaining, - leaf.Command.Name, - leaf.Command.Data); + // Get the registered help provider, falling back to the default provider + // if no custom implementations have been registered. + var helpProviders = resolver.Resolve(typeof(IEnumerable)) as IEnumerable; + var helpProvider = helpProviders?.LastOrDefault() ?? new HelpProvider(configuration.Settings); - // Execute the command tree. - return await ExecuteAsync(leaf, parsedResult.Tree, context, resolver, configuration, cancellationToken).ConfigureAwait(false); - } - } - - [SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1513:Closing brace should be followed by blank line", Justification = "Improves code readability by grouping together related statements into a block")] - private CommandTreeParserResult ParseCommandLineArguments(CommandModel model, CommandAppSettings settings, IReadOnlyList args) - { - CommandTreeParserResult? parsedResult = null; - - try - { - (parsedResult, var tokenizerResult) = InternalParseCommandLineArguments(model, settings, args); - - var lastParsedLeaf = parsedResult.Tree?.GetLeafCommand(); - var lastParsedCommand = lastParsedLeaf?.Command; - - if (lastParsedLeaf != null && lastParsedCommand is { IsBranch: true } && !lastParsedLeaf.ShowHelp && - lastParsedCommand.DefaultCommand != null) - { - // Adjust for any parsed remaining arguments by - // inserting the default command ahead of them. - var position = tokenizerResult.Tokens.Position; - foreach (var parsedRemaining in parsedResult.Remaining.Parsed) + // Currently the root? + if (parsedResult.Tree == null) { - position--; - position -= parsedRemaining.Count(value => value != null); + // Display help. + configuration.Settings.Console.SafeRender(helpProvider.Write(model, null)); + return 0; } - position = position < 0 ? 0 : position; - // Insert this branch's default command into the command line - // arguments and try again to see if it will parse. - var argsWithDefaultCommand = new List(args); - argsWithDefaultCommand.Insert(position, lastParsedCommand.DefaultCommand.Name); - - (parsedResult, _) = InternalParseCommandLineArguments(model, settings, argsWithDefaultCommand); - } - } - catch (CommandParseException) when (parsedResult == null && settings.ParsingMode == ParsingMode.Strict) - { - // The parsing exception might be resolved by adding in the default command, - // but we can't know for sure. Take a brute force approach and try this for - // every position between the arguments. - for (var i = 0; i < args.Count; i++) - { - var argsWithDefaultCommand = new List(args); - argsWithDefaultCommand.Insert(args.Count - i, "__default_command"); - - try + // Get the command to execute. + var leaf = parsedResult.Tree.GetLeafCommand(); + if (leaf.Command.IsBranch || leaf.ShowHelp) { - (parsedResult, _) = InternalParseCommandLineArguments(model, settings, argsWithDefaultCommand); - break; + // Branches can't be executed. Show help. + configuration.Settings.Console.SafeRender(helpProvider.Write(model, leaf.Command)); + return leaf.ShowHelp ? 0 : 1; } - catch (CommandParseException) + + // Is this the default and is it called without arguments when there are required arguments? + if (leaf.Command.IsDefaultCommand && arguments.Count == 0 && leaf.Command.Parameters.Any(p => p.IsRequired)) { - // Continue. + // Display help for default command. + configuration.Settings.Console.SafeRender(helpProvider.Write(model, leaf.Command)); + return 1; } - } - if (parsedResult == null) + // Create the content. + var context = new CommandContext( + arguments, + parsedResult.Remaining, + leaf.Command.Name, + leaf.Command.Data); + + // Execute the command tree. + return await ExecuteAsync(leaf, parsedResult.Tree, context, resolver, configuration, cancellationToken).ConfigureAwait(false); + } + finally { - // Failed to parse having inserted the default command between each argument. - // Repeat the parsing of the original arguments to throw the correct exception. - InternalParseCommandLineArguments(model, settings, args); + resolverAccessor.Resolver = null; } } - - if (parsedResult == null) - { - // The arguments failed to parse despite everything we tried above. - // Exceptions should be thrown above before ever getting this far, - // however the following is the ultimately backstop and avoids - // the compiler from complaining about returning null. - throw CommandParseException.UnknownParsingError(); - } - - return parsedResult; - } - - /// - /// Parse the command line arguments using the specified and , - /// returning the parser and tokenizer results. - /// - /// The parser and tokenizer results as a tuple. - private (CommandTreeParserResult ParserResult, CommandTreeTokenizerResult TokenizerResult) InternalParseCommandLineArguments(CommandModel model, CommandAppSettings settings, IReadOnlyList args) - { - var parser = new CommandTreeParser(model, settings.CaseSensitivity, settings.ParsingMode, settings.ConvertFlagsToRemainingArguments); - - var parserContext = new CommandTreeParserContext(args, settings.ParsingMode); - var tokenizerResult = CommandTreeTokenizer.Tokenize(args); - var parsedResult = parser.Parse(parserContext, tokenizerResult); - - return (parsedResult, tokenizerResult); } private static async Task ExecuteAsync( @@ -267,4 +189,4 @@ private static async Task ExecuteAsync( return configuration.Settings.ExceptionHandler(ex, resolver); } } -} \ No newline at end of file +} diff --git a/src/Spectre.Console.Cli/Internal/Parsing/CommandLineArgumentParser.cs b/src/Spectre.Console.Cli/Internal/Parsing/CommandLineArgumentParser.cs new file mode 100644 index 0000000..284461f --- /dev/null +++ b/src/Spectre.Console.Cli/Internal/Parsing/CommandLineArgumentParser.cs @@ -0,0 +1,96 @@ +using static Spectre.Console.Cli.CommandTreeTokenizer; + +namespace Spectre.Console.Cli; + +internal static class CommandLineArgumentParser +{ + internal static CommandTreeParserResult ParseWithDefaults(CommandModel model, ICommandAppSettings settings, IReadOnlyList args) + { + CommandTreeParserResult? parsedResult = null; + + try + { + (parsedResult, var tokenizerResult) = InternalParse(model, settings, args); + + var lastParsedLeaf = parsedResult.Tree?.GetLeafCommand(); + var lastParsedCommand = lastParsedLeaf?.Command; + + if (lastParsedLeaf != null && lastParsedCommand is { IsBranch: true } && !lastParsedLeaf.ShowHelp && + lastParsedCommand.DefaultCommand != null) + { + // Adjust for any parsed remaining arguments by + // inserting the default command ahead of them. + var position = tokenizerResult.Tokens.Position; + foreach (var parsedRemaining in parsedResult.Remaining.Parsed) + { + position--; + position -= parsedRemaining.Count(value => value != null); + } + position = position < 0 ? 0 : position; + + // Insert this branch's default command into the command line + // arguments and try again to see if it will parse. + var argsWithDefaultCommand = new List(args); + argsWithDefaultCommand.Insert(position, lastParsedCommand.DefaultCommand.Name); + + (parsedResult, _) = InternalParse(model, settings, argsWithDefaultCommand); + } + } + catch (CommandParseException) when (parsedResult == null && GetParsingMode(settings) == ParsingMode.Strict) + { + // The parsing exception might be resolved by adding in the default command, + // but we can't know for sure. Take a brute force approach and try this for + // every position between the arguments. + for (var i = 0; i < args.Count; i++) + { + var argsWithDefaultCommand = new List(args); + argsWithDefaultCommand.Insert(args.Count - i, CliConstants.DefaultCommandName); + + try + { + (parsedResult, _) = InternalParse(model, settings, argsWithDefaultCommand); + break; + } + catch (CommandParseException) + { + // Continue. + } + } + + if (parsedResult == null) + { + // Failed to parse having inserted the default command between each argument. + // Repeat the parsing of the original arguments to throw the correct exception. + _ = InternalParse(model, settings, args); + } + } + + if (parsedResult == null) + { + throw CommandParseException.UnknownParsingError(); + } + + return parsedResult; + } + + internal static (CommandTreeParserResult ParserResult, CommandTreeTokenizerResult TokenizerResult) InternalParse( + CommandModel model, + ICommandAppSettings settings, + IReadOnlyList args) + { + var parsingMode = GetParsingMode(settings); + var parser = new CommandTreeParser(model, settings.CaseSensitivity, parsingMode, settings.ConvertFlagsToRemainingArguments); + + var parserContext = new CommandTreeParserContext(args, parsingMode); + var tokenizerResult = CommandTreeTokenizer.Tokenize(args); + var parsedResult = parser.Parse(parserContext, tokenizerResult); + + return (parsedResult, tokenizerResult); + } + + internal static ParsingMode GetParsingMode(ICommandAppSettings settings) + { + return settings.StrictParsing ? ParsingMode.Strict : ParsingMode.Relaxed; + } +} + diff --git a/src/Spectre.Console.Cli/Internal/TypeResolverAccessor.cs b/src/Spectre.Console.Cli/Internal/TypeResolverAccessor.cs new file mode 100644 index 0000000..fda88cc --- /dev/null +++ b/src/Spectre.Console.Cli/Internal/TypeResolverAccessor.cs @@ -0,0 +1,6 @@ +namespace Spectre.Console.Cli; + +internal sealed class TypeResolverAccessor : ITypeResolverAccessor +{ + public ITypeResolver? Resolver { get; set; } +} \ No newline at end of file