Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
426 changes: 426 additions & 0 deletions src/Spectre.Console.Cli.Tests/CommandLineParserTests.cs

Large diffs are not rendered by default.

177 changes: 177 additions & 0 deletions src/Spectre.Console.Cli.Tests/TypeResolverAccessorTests.cs
Original file line number Diff line number Diff line change
@@ -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<AnimalSettings>("animal", animal =>
{
animal.SetDefaultCommand<CatCommand>();
});

config.AddCommand<CaptureCommand>("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<ThrowingCaptureCommand>("throw");
});

// When
var exception = Record.Exception(() => app.Run("throw"));

// Then
exception.ShouldBeOfType<InvalidOperationException>();
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<AnimalSettings>("animal", animal =>
{
animal.SetDefaultCommand<CatCommand>();
});
config.AddCommand<CaptureCommand>("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<EmptyCommandSettings>
{
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<EmptyCommandSettings>
{
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; }
}
}
6 changes: 6 additions & 0 deletions src/Spectre.Console.Cli.Tests/VerifyConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
26 changes: 26 additions & 0 deletions src/Spectre.Console.Cli/Completion/CommandLineMappedParameter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Spectre.Console.Cli.Completion;

/// <summary>
/// Represents a mapped parameter/value pair from a parsed command line.
/// </summary>
public sealed class CommandLineMappedParameter
{
/// <summary>
/// Gets the mapped parameter.
/// </summary>
public ICommandParameterInfo Parameter { get; }

/// <summary>
/// Gets the mapped value, if any.
/// </summary>
public string? Value { get; }

/// <summary>
/// Initializes a new instance of the <see cref="CommandLineMappedParameter"/> class.
/// </summary>
public CommandLineMappedParameter(ICommandParameterInfo parameter, string? value)
{
Parameter = parameter ?? throw new ArgumentNullException(nameof(parameter));
Value = value;
}
}
49 changes: 49 additions & 0 deletions src/Spectre.Console.Cli/Completion/CommandLineParseResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace Spectre.Console.Cli.Completion;

/// <summary>
/// Represents the result of parsing a command line against a command model.
/// </summary>
public sealed class CommandLineParseResult
{
/// <summary>
/// Gets the parsed command context (leaf command), if any.
/// </summary>
public Help.ICommandInfo? Command { get; }

/// <summary>
/// Gets the parsed command type, if any.
/// </summary>
public Type? CommandType { get; }

/// <summary>
/// Gets the parsed settings type, if any.
/// </summary>
public Type? SettingsType { get; }

/// <summary>
/// Gets the mapped parameters for the leaf command.
/// </summary>
public IReadOnlyList<CommandLineMappedParameter> MappedParameters { get; }

/// <summary>
/// Gets the remaining arguments.
/// </summary>
public IRemainingArguments RemainingArguments { get; }

/// <summary>
/// Initializes a new instance of the <see cref="CommandLineParseResult"/> class.
/// </summary>
public CommandLineParseResult(
Help.ICommandInfo? command,
Type? commandType,
Type? settingsType,
IReadOnlyList<CommandLineMappedParameter> mappedParameters,
IRemainingArguments remainingArguments)
{
Command = command;
CommandType = commandType;
SettingsType = settingsType;
MappedParameters = mappedParameters ?? throw new ArgumentNullException(nameof(mappedParameters));
RemainingArguments = remainingArguments ?? throw new ArgumentNullException(nameof(remainingArguments));
}
}
52 changes: 52 additions & 0 deletions src/Spectre.Console.Cli/Completion/CommandLineParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
namespace Spectre.Console.Cli.Completion;

/// <summary>
/// Parses command line arguments using the Spectre.Console.Cli command model.
/// </summary>
public static class CommandLineParser
{
/// <summary>
/// Parses the command line against the specified model.
/// </summary>
/// <remarks>
/// This parser supports models created by Spectre.Console.Cli itself.
/// </remarks>
public static CommandLineParseResult Parse(Help.ICommandModel model, ICommandAppSettings settings, IEnumerable<string> 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<CommandLineMappedParameter>();

return new CommandLineParseResult(
command,
leaf?.Command.CommandType,
leaf?.Command.SettingsType,
mapped.AsReadOnly(),
parsedResult.Remaining);
}


}

12 changes: 12 additions & 0 deletions src/Spectre.Console.Cli/ITypeResolverAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Spectre.Console.Cli;

/// <summary>
/// Provides access to the current <see cref="ITypeResolver"/> during command execution.
/// </summary>
public interface ITypeResolverAccessor
{
/// <summary>
/// Gets the current resolver, if available.
/// </summary>
ITypeResolver? Resolver { get; }
}
Loading