Command line improvements (#1103)

Closes #187
Closes #203
Closes #1059
This commit is contained in:
Frank Ray 2023-04-02 21:43:21 +01:00 committed by GitHub
parent 70da3f40ff
commit 714cf179cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1052 additions and 269 deletions

View File

@ -51,6 +51,14 @@ public interface ICommandAppSettings
/// </summary> /// </summary>
bool StrictParsing { get; set; } bool StrictParsing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether or not flags found on the commnd line
/// that would normally result in a <see cref="CommandParseException"/> being thrown
/// during parsing with the message "Flags cannot be assigned a value."
/// should instead be added to the remaining arguments collection.
/// </summary>
bool ConvertFlagsToRemainingArguments { get; set; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether or not exceptions should be propagated. /// Gets or sets a value indicating whether or not exceptions should be propagated.
/// <para>Setting this to <c>true</c> will disable default Exception handling and /// <para>Setting this to <c>true</c> will disable default Exception handling and

View File

@ -19,6 +19,18 @@ public interface IConfigurator<in TSettings>
/// <param name="args">The example arguments.</param> /// <param name="args">The example arguments.</param>
void AddExample(string[] args); void AddExample(string[] args);
/// <summary>
/// Adds a default command.
/// </summary>
/// <remarks>
/// This is the command that will run if the user doesn't specify one on the command line.
/// It must be able to execute successfully by itself ie. without requiring any command line
/// arguments, flags or option values.
/// </remarks>
/// <typeparam name="TDefaultCommand">The default command type.</typeparam>
void SetDefaultCommand<TDefaultCommand>()
where TDefaultCommand : class, ICommandLimiter<TSettings>;
/// <summary> /// <summary>
/// Marks the branch as hidden. /// Marks the branch as hidden.
/// Hidden branches do not show up in help documentation or /// Hidden branches do not show up in help documentation or

View File

@ -45,12 +45,10 @@ internal sealed class CommandExecutor
} }
// Parse and map the model against the arguments. // Parse and map the model against the arguments.
var parser = new CommandTreeParser(model, configuration.Settings); var parsedResult = ParseCommandLineArguments(model, configuration.Settings, args);
var parsedResult = parser.Parse(args);
_registrar.RegisterInstance(typeof(CommandTreeParserResult), parsedResult);
// Currently the root? // Currently the root?
if (parsedResult.Tree == null) if (parsedResult?.Tree == null)
{ {
// Display help. // Display help.
configuration.Settings.Console.SafeRender(HelpWriter.Write(model, configuration.Settings.ShowOptionDefaultValues)); configuration.Settings.Console.SafeRender(HelpWriter.Write(model, configuration.Settings.ShowOptionDefaultValues));
@ -75,6 +73,7 @@ internal sealed class CommandExecutor
} }
// Register the arguments with the container. // Register the arguments with the container.
_registrar.RegisterInstance(typeof(CommandTreeParserResult), parsedResult);
_registrar.RegisterInstance(typeof(IRemainingArguments), parsedResult.Remaining); _registrar.RegisterInstance(typeof(IRemainingArguments), parsedResult.Remaining);
// Create the resolver and the context. // Create the resolver and the context.
@ -87,6 +86,34 @@ internal sealed class CommandExecutor
} }
} }
private CommandTreeParserResult? ParseCommandLineArguments(CommandModel model, CommandAppSettings settings, IEnumerable<string> 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);
var lastParsedLeaf = parsedResult?.Tree?.GetLeafCommand();
var lastParsedCommand = lastParsedLeaf?.Command;
if (lastParsedLeaf != null && lastParsedCommand != null &&
lastParsedCommand.IsBranch && !lastParsedLeaf.ShowHelp &&
lastParsedCommand.DefaultCommand != null)
{
// Insert this branch's default command into the command line
// arguments and try again to see if it will parse.
var argsWithDefaultCommand = new List<string>(args);
argsWithDefaultCommand.Insert(tokenizerResult.Tokens.Position, lastParsedCommand.DefaultCommand.Name);
parserContext = new CommandTreeParserContext(argsWithDefaultCommand, settings.ParsingMode);
tokenizerResult = CommandTreeTokenizer.Tokenize(argsWithDefaultCommand);
parsedResult = parser.Parse(parserContext, tokenizerResult);
}
return parsedResult;
}
private static string ResolveApplicationVersion(IConfiguration configuration) private static string ResolveApplicationVersion(IConfiguration configuration)
{ {
return return

View File

@ -13,6 +13,7 @@ internal sealed class CommandAppSettings : ICommandAppSettings
public bool ValidateExamples { get; set; } public bool ValidateExamples { get; set; }
public bool TrimTrailingPeriod { get; set; } = true; public bool TrimTrailingPeriod { get; set; } = true;
public bool StrictParsing { get; set; } public bool StrictParsing { get; set; }
public bool ConvertFlagsToRemainingArguments { get; set; } = false;
public ParsingMode ParsingMode => public ParsingMode ParsingMode =>
StrictParsing ? ParsingMode.Strict : ParsingMode.Relaxed; StrictParsing ? ParsingMode.Strict : ParsingMode.Relaxed;

View File

@ -36,7 +36,7 @@ internal sealed class Configurator : IUnsafeConfigurator, IConfigurator, IConfig
public ICommandConfigurator AddCommand<TCommand>(string name) public ICommandConfigurator AddCommand<TCommand>(string name)
where TCommand : class, ICommand where TCommand : class, ICommand
{ {
var command = Commands.AddAndReturn(ConfiguredCommand.FromType<TCommand>(name, false)); var command = Commands.AddAndReturn(ConfiguredCommand.FromType<TCommand>(name, isDefaultCommand: false));
return new CommandConfigurator(command); return new CommandConfigurator(command);
} }

View File

@ -22,6 +22,14 @@ internal sealed class Configurator<TSettings> : IUnsafeBranchConfigurator, IConf
_command.Examples.Add(args); _command.Examples.Add(args);
} }
public void SetDefaultCommand<TDefaultCommand>()
where TDefaultCommand : class, ICommandLimiter<TSettings>
{
var defaultCommand = ConfiguredCommand.FromType<TDefaultCommand>(
CliConstants.DefaultCommandName, isDefaultCommand: true);
_command.Children.Add(defaultCommand);
}
public void HideBranch() public void HideBranch()
{ {
_command.IsHidden = true; _command.IsHidden = true;
@ -30,7 +38,7 @@ internal sealed class Configurator<TSettings> : IUnsafeBranchConfigurator, IConf
public ICommandConfigurator AddCommand<TCommand>(string name) public ICommandConfigurator AddCommand<TCommand>(string name)
where TCommand : class, ICommandLimiter<TSettings> where TCommand : class, ICommandLimiter<TSettings>
{ {
var command = ConfiguredCommand.FromType<TCommand>(name); var command = ConfiguredCommand.FromType<TCommand>(name, isDefaultCommand: false);
var configurator = new CommandConfigurator(command); var configurator = new CommandConfigurator(command);
_command.Children.Add(command); _command.Children.Add(command);

View File

@ -29,6 +29,9 @@ internal sealed class ConfiguredCommand
Delegate = @delegate; Delegate = @delegate;
IsDefaultCommand = isDefaultCommand; IsDefaultCommand = isDefaultCommand;
// Default commands are always created as hidden.
IsHidden = IsDefaultCommand;
Children = new List<ConfiguredCommand>(); Children = new List<ConfiguredCommand>();
Examples = new List<string[]>(); Examples = new List<string[]>();
} }

View File

@ -6,10 +6,6 @@ internal static class TypeRegistrarExtensions
{ {
var stack = new Stack<CommandInfo>(); var stack = new Stack<CommandInfo>();
model.Commands.ForEach(c => stack.Push(c)); model.Commands.ForEach(c => stack.Push(c));
if (model.DefaultCommand != null)
{
stack.Push(model.DefaultCommand);
}
while (stack.Count > 0) while (stack.Count > 0)
{ {

View File

@ -10,7 +10,6 @@ internal sealed class CommandInfo : ICommandContainer
public Type SettingsType { get; } public Type SettingsType { get; }
public Func<CommandContext, CommandSettings, int>? Delegate { get; } public Func<CommandContext, CommandSettings, int>? Delegate { get; }
public bool IsDefaultCommand { get; } public bool IsDefaultCommand { get; }
public bool IsHidden { get; }
public CommandInfo? Parent { get; } public CommandInfo? Parent { get; }
public IList<CommandInfo> Children { get; } public IList<CommandInfo> Children { get; }
public IList<CommandParameter> Parameters { get; } public IList<CommandParameter> Parameters { get; }
@ -19,6 +18,10 @@ internal sealed class CommandInfo : ICommandContainer
public bool IsBranch => CommandType == null && Delegate == null; public bool IsBranch => CommandType == null && Delegate == null;
IList<CommandInfo> ICommandContainer.Commands => Children; IList<CommandInfo> ICommandContainer.Commands => Children;
// only branches can have a default command
public CommandInfo? DefaultCommand => IsBranch ? Children.FirstOrDefault(c => c.IsDefaultCommand) : null;
public bool IsHidden { get; }
public CommandInfo(CommandInfo? parent, ConfiguredCommand prototype) public CommandInfo(CommandInfo? parent, ConfiguredCommand prototype)
{ {
Parent = parent; Parent = parent;

View File

@ -4,21 +4,20 @@ internal sealed class CommandModel : ICommandContainer
{ {
public string? ApplicationName { get; } public string? ApplicationName { get; }
public ParsingMode ParsingMode { get; } public ParsingMode ParsingMode { get; }
public CommandInfo? DefaultCommand { get; }
public IList<CommandInfo> Commands { get; } public IList<CommandInfo> Commands { get; }
public IList<string[]> Examples { get; } public IList<string[]> Examples { get; }
public bool TrimTrailingPeriod { get; } public bool TrimTrailingPeriod { get; }
public CommandInfo? DefaultCommand => Commands.FirstOrDefault(c => c.IsDefaultCommand);
public CommandModel( public CommandModel(
CommandAppSettings settings, CommandAppSettings settings,
CommandInfo? defaultCommand,
IEnumerable<CommandInfo> commands, IEnumerable<CommandInfo> commands,
IEnumerable<string[]> examples) IEnumerable<string[]> examples)
{ {
ApplicationName = settings.ApplicationName; ApplicationName = settings.ApplicationName;
ParsingMode = settings.ParsingMode; ParsingMode = settings.ParsingMode;
TrimTrailingPeriod = settings.TrimTrailingPeriod; TrimTrailingPeriod = settings.TrimTrailingPeriod;
DefaultCommand = defaultCommand;
Commands = new List<CommandInfo>(commands ?? Array.Empty<CommandInfo>()); Commands = new List<CommandInfo>(commands ?? Array.Empty<CommandInfo>());
Examples = new List<string[]>(examples ?? Array.Empty<string[]>()); Examples = new List<string[]>(examples ?? Array.Empty<string[]>());
} }

View File

@ -25,18 +25,19 @@ internal static class CommandModelBuilder
result.Add(Build(null, command)); result.Add(Build(null, command));
} }
var defaultCommand = default(CommandInfo);
if (configuration.DefaultCommand != null) if (configuration.DefaultCommand != null)
{ {
// Add the examples from the configuration to the default command. // Add the examples from the configuration to the default command.
configuration.DefaultCommand.Examples.AddRange(configuration.Examples); configuration.DefaultCommand.Examples.AddRange(configuration.Examples);
// Build the default command. // Build the default command.
defaultCommand = Build(null, configuration.DefaultCommand); var defaultCommand = Build(null, configuration.DefaultCommand);
result.Add(defaultCommand);
} }
// Create the command model and validate it. // Create the command model and validate it.
var model = new CommandModel(configuration.Settings, defaultCommand, result, configuration.Examples); var model = new CommandModel(configuration.Settings, result, configuration.Examples);
CommandModelValidator.Validate(model, configuration.Settings); CommandModelValidator.Validate(model, configuration.Settings);
return model; return model;

View File

@ -14,7 +14,7 @@ internal static class CommandModelValidator
throw new ArgumentNullException(nameof(settings)); throw new ArgumentNullException(nameof(settings));
} }
if (model.Commands.Count == 0 && model.DefaultCommand == null) if (model.Commands.Count == 0)
{ {
throw CommandConfigurationException.NoCommandConfigured(); throw CommandConfigurationException.NoCommandConfigured();
} }
@ -31,7 +31,6 @@ internal static class CommandModelValidator
} }
} }
Validate(model.DefaultCommand);
foreach (var command in model.Commands) foreach (var command in model.Commands)
{ {
Validate(command); Validate(command);
@ -147,7 +146,7 @@ internal static class CommandModelValidator
{ {
try try
{ {
var parser = new CommandTreeParser(model, settings, ParsingMode.Strict); var parser = new CommandTreeParser(model, settings.CaseSensitivity, ParsingMode.Strict);
parser.Parse(example); parser.Parse(example);
} }
catch (Exception ex) catch (Exception ex)

View File

@ -9,4 +9,12 @@ internal interface ICommandContainer
/// Gets all commands in the container. /// Gets all commands in the container.
/// </summary> /// </summary>
IList<CommandInfo> Commands { get; } IList<CommandInfo> Commands { get; }
/// <summary>
/// Gets the default command for the container.
/// </summary>
/// <remarks>
/// Returns null if a default command has not been set.
/// </remarks>
CommandInfo? DefaultCommand { get; }
} }

View File

@ -1,3 +1,5 @@
using static Spectre.Console.Cli.CommandTreeTokenizer;
namespace Spectre.Console.Cli; namespace Spectre.Console.Cli;
internal class CommandTreeParser internal class CommandTreeParser
@ -5,6 +7,7 @@ internal class CommandTreeParser
private readonly CommandModel _configuration; private readonly CommandModel _configuration;
private readonly ParsingMode _parsingMode; private readonly ParsingMode _parsingMode;
private readonly CommandOptionAttribute _help; private readonly CommandOptionAttribute _help;
private readonly bool _convertFlagsToRemainingArguments;
public CaseSensitivity CaseSensitivity { get; } public CaseSensitivity CaseSensitivity { get; }
@ -14,25 +17,26 @@ internal class CommandTreeParser
Remaining = 1, Remaining = 1,
} }
public CommandTreeParser(CommandModel configuration, ICommandAppSettings settings, ParsingMode? parsingMode = null) public CommandTreeParser(CommandModel configuration, CaseSensitivity caseSensitivity, ParsingMode? parsingMode = null, bool? convertFlagsToRemainingArguments = null)
{ {
if (settings is null) _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
{
throw new ArgumentNullException(nameof(settings));
}
_configuration = configuration;
_parsingMode = parsingMode ?? _configuration.ParsingMode; _parsingMode = parsingMode ?? _configuration.ParsingMode;
_help = new CommandOptionAttribute("-h|--help"); _help = new CommandOptionAttribute("-h|--help");
_convertFlagsToRemainingArguments = convertFlagsToRemainingArguments ?? false;
CaseSensitivity = settings.CaseSensitivity; CaseSensitivity = caseSensitivity;
} }
public CommandTreeParserResult Parse(IEnumerable<string> args) public CommandTreeParserResult Parse(IEnumerable<string> args)
{ {
var context = new CommandTreeParserContext(args, _parsingMode); var parserContext = new CommandTreeParserContext(args, _parsingMode);
var tokenizerResult = CommandTreeTokenizer.Tokenize(args);
var tokenizerResult = CommandTreeTokenizer.Tokenize(context.Arguments); return Parse(parserContext, tokenizerResult);
}
public CommandTreeParserResult Parse(CommandTreeParserContext context, CommandTreeTokenizerResult tokenizerResult)
{
var tokens = tokenizerResult.Tokens; var tokens = tokenizerResult.Tokens;
var rawRemaining = tokenizerResult.Remaining; var rawRemaining = tokenizerResult.Remaining;
@ -255,9 +259,7 @@ internal class CommandTreeParser
var option = node.FindOption(token.Value, isLongOption, CaseSensitivity); var option = node.FindOption(token.Value, isLongOption, CaseSensitivity);
if (option != null) if (option != null)
{ {
node.Mapped.Add(new MappedCommandParameter( ParseOptionValue(context, stream, token, node, option);
option, ParseOptionValue(context, stream, token, node, option)));
return; return;
} }
@ -271,7 +273,7 @@ internal class CommandTreeParser
if (context.State == State.Remaining) if (context.State == State.Remaining)
{ {
ParseOptionValue(context, stream, token, node, null); ParseOptionValue(context, stream, token, node);
return; return;
} }
@ -281,17 +283,19 @@ internal class CommandTreeParser
} }
else else
{ {
ParseOptionValue(context, stream, token, node, null); ParseOptionValue(context, stream, token, node);
} }
} }
private string? ParseOptionValue( private void ParseOptionValue(
CommandTreeParserContext context, CommandTreeParserContext context,
CommandTreeTokenStream stream, CommandTreeTokenStream stream,
CommandTreeToken token, CommandTreeToken token,
CommandTree current, CommandTree current,
CommandParameter? parameter) CommandParameter? parameter = null)
{ {
bool addToMappedCommandParameters = parameter != null;
var value = default(string); var value = default(string);
// Parse the value of the token (if any). // Parse the value of the token (if any).
@ -325,9 +329,23 @@ internal class CommandTreeParser
else else
{ {
// Flags cannot be assigned a value. // Flags cannot be assigned a value.
if (_convertFlagsToRemainingArguments)
{
value = stream.Consume(CommandTreeToken.Kind.String)?.Value;
context.AddRemainingArgument(token.Value, value);
// Prevent the option and it's non-boolean value from being added to
// mapped parameters (otherwise an exception will be thrown later
// when binding the value to the flag in the comand settings)
addToMappedCommandParameters = false;
}
else
{
throw CommandParseException.CannotAssignValueToFlag(context.Arguments, token); throw CommandParseException.CannotAssignValueToFlag(context.Arguments, token);
} }
} }
}
else else
{ {
value = stream.Consume(CommandTreeToken.Kind.String)?.Value; value = stream.Consume(CommandTreeToken.Kind.String)?.Value;
@ -358,7 +376,8 @@ internal class CommandTreeParser
} }
else else
{ {
if (context.State == State.Remaining || context.ParsingMode == ParsingMode.Relaxed) if (parameter == null && // Only add tokens which have not been matched to a command parameter
(context.State == State.Remaining || context.ParsingMode == ParsingMode.Relaxed))
{ {
context.AddRemainingArgument(token.Value, null); context.AddRemainingArgument(token.Value, null);
} }
@ -379,11 +398,13 @@ internal class CommandTreeParser
{ {
if (parameter.IsFlagValue()) if (parameter.IsFlagValue())
{ {
return null; value = null;
} }
else
{
throw CommandParseException.OptionHasNoValue(context.Arguments, token, option); throw CommandParseException.OptionHasNoValue(context.Arguments, token, option);
} }
}
else else
{ {
// This should not happen at all. If it does, it's because we've added a new // This should not happen at all. If it does, it's because we've added a new
@ -394,6 +415,9 @@ internal class CommandTreeParser
} }
} }
return value; if (parameter != null && addToMappedCommandParameters)
{
current.Mapped.Add(new MappedCommandParameter(parameter, value));
}
} }
} }

View File

@ -29,8 +29,6 @@ internal class CommandTreeParserContext
} }
public void AddRemainingArgument(string key, string? value) public void AddRemainingArgument(string key, string? value)
{
if (State == CommandTreeParser.State.Remaining || ParsingMode == ParsingMode.Relaxed)
{ {
if (!_remaining.ContainsKey(key)) if (!_remaining.ContainsKey(key))
{ {
@ -39,7 +37,6 @@ internal class CommandTreeParserContext
_remaining[key].Add(value); _remaining[key].Add(value);
} }
}
[SuppressMessage("Style", "IDE0004:Remove Unnecessary Cast", Justification = "Bug in analyzer?")] [SuppressMessage("Style", "IDE0004:Remove Unnecessary Cast", Justification = "Bug in analyzer?")]
public ILookup<string, string?> GetRemainingArguments() public ILookup<string, string?> GetRemainingArguments()

View File

@ -6,6 +6,7 @@ internal sealed class CommandTreeTokenStream : IReadOnlyList<CommandTreeToken>
private int _position; private int _position;
public int Count => _tokens.Count; public int Count => _tokens.Count;
public int Position => _position;
public CommandTreeToken this[int index] => _tokens[index]; public CommandTreeToken this[int index] => _tokens[index];

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<Model>
<!--ANIMAL-->
<Command Name="animal" IsBranch="true" Settings="Spectre.Console.Tests.Data.AnimalSettings">
<Parameters>
<Argument Name="LEGS" Position="0" Required="false" Kind="scalar" ClrType="System.Int32">
<Description>The number of legs.</Description>
<Validators>
<Validator ClrType="Spectre.Console.Tests.Data.EvenNumberValidatorAttribute" Message="Animals must have an even number of legs." />
<Validator ClrType="Spectre.Console.Tests.Data.PositiveNumberValidatorAttribute" Message="Number of legs must be greater than 0." />
</Validators>
</Argument>
<Option Short="a" Long="alive,not-dead" Value="NULL" Required="false" Kind="flag" ClrType="System.Boolean">
<Description>Indicates whether or not the animal is alive.</Description>
</Option>
</Parameters>
<!--MAMMAL-->
<Command Name="mammal" IsBranch="true" Settings="Spectre.Console.Tests.Data.MammalSettings">
<Parameters>
<Option Short="n,p" Long="name,pet-name" Value="VALUE" Required="false" Kind="scalar" ClrType="System.String" />
</Parameters>
<!--__DEFAULT_COMMAND-->
<Command Name="__default_command" IsBranch="false" ClrType="Spectre.Console.Tests.Data.HorseCommand" Settings="Spectre.Console.Tests.Data.HorseSettings">
<Parameters>
<Option Short="d" Long="day" Value="NULL" Required="false" Kind="scalar" ClrType="System.DayOfWeek" />
<Option Short="" Long="directory" Value="NULL" Required="false" Kind="scalar" ClrType="System.IO.DirectoryInfo" />
<Option Short="" Long="file" Value="NULL" Required="false" Kind="scalar" ClrType="System.IO.FileInfo" />
</Parameters>
</Command>
</Command>
</Command>
</Model>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<Model>
<!--ANIMAL-->
<Command Name="animal" IsBranch="true" Settings="Spectre.Console.Tests.Data.AnimalSettings">
<Parameters>
<Argument Name="LEGS" Position="0" Required="false" Kind="scalar" ClrType="System.Int32">
<Description>The number of legs.</Description>
<Validators>
<Validator ClrType="Spectre.Console.Tests.Data.EvenNumberValidatorAttribute" Message="Animals must have an even number of legs." />
<Validator ClrType="Spectre.Console.Tests.Data.PositiveNumberValidatorAttribute" Message="Number of legs must be greater than 0." />
</Validators>
</Argument>
<Option Short="a" Long="alive,not-dead" Value="NULL" Required="false" Kind="flag" ClrType="System.Boolean">
<Description>Indicates whether or not the animal is alive.</Description>
</Option>
</Parameters>
<!--DOG-->
<Command Name="dog" IsBranch="false" ClrType="Spectre.Console.Tests.Data.DogCommand" Settings="Spectre.Console.Tests.Data.DogSettings">
<Parameters>
<Argument Name="AGE" Position="0" Required="true" Kind="scalar" ClrType="System.Int32" />
<Option Short="g" Long="good-boy" Value="NULL" Required="false" Kind="flag" ClrType="System.Boolean" />
<Option Short="n,p" Long="name,pet-name" Value="VALUE" Required="false" Kind="scalar" ClrType="System.String" />
</Parameters>
</Command>
<!--__DEFAULT_COMMAND-->
<Command Name="__default_command" IsBranch="false" ClrType="Spectre.Console.Tests.Data.HorseCommand" Settings="Spectre.Console.Tests.Data.HorseSettings">
<Parameters>
<Option Short="d" Long="day" Value="NULL" Required="false" Kind="scalar" ClrType="System.DayOfWeek" />
<Option Short="" Long="directory" Value="NULL" Required="false" Kind="scalar" ClrType="System.IO.DirectoryInfo" />
<Option Short="" Long="file" Value="NULL" Required="false" Kind="scalar" ClrType="System.IO.FileInfo" />
<Option Short="n,p" Long="name,pet-name" Value="VALUE" Required="false" Kind="scalar" ClrType="System.String" />
</Parameters>
</Command>
</Command>
</Model>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<Model>
<!--DEFAULT COMMAND-->
<Command Name="__default_command" IsBranch="false" IsDefault="true" ClrType="Spectre.Console.Tests.Data.EmptyCommand" Settings="Spectre.Console.Cli.EmptyCommandSettings" />
<!--ANIMAL-->
<Command Name="animal" IsBranch="true" Settings="Spectre.Console.Tests.Data.AnimalSettings">
<Parameters>
<Argument Name="LEGS" Position="0" Required="false" Kind="scalar" ClrType="System.Int32">
<Description>The number of legs.</Description>
<Validators>
<Validator ClrType="Spectre.Console.Tests.Data.EvenNumberValidatorAttribute" Message="Animals must have an even number of legs." />
<Validator ClrType="Spectre.Console.Tests.Data.PositiveNumberValidatorAttribute" Message="Number of legs must be greater than 0." />
</Validators>
</Argument>
<Option Short="a" Long="alive,not-dead" Value="NULL" Required="false" Kind="flag" ClrType="System.Boolean">
<Description>Indicates whether or not the animal is alive.</Description>
</Option>
</Parameters>
<!--DOG-->
<Command Name="dog" IsBranch="false" ClrType="Spectre.Console.Tests.Data.DogCommand" Settings="Spectre.Console.Tests.Data.DogSettings">
<Parameters>
<Argument Name="AGE" Position="0" Required="true" Kind="scalar" ClrType="System.Int32" />
<Option Short="g" Long="good-boy" Value="NULL" Required="false" Kind="flag" ClrType="System.Boolean" />
<Option Short="n,p" Long="name,pet-name" Value="VALUE" Required="false" Kind="scalar" ClrType="System.String" />
</Parameters>
</Command>
<!--__DEFAULT_COMMAND-->
<Command Name="__default_command" IsBranch="false" ClrType="Spectre.Console.Tests.Data.HorseCommand" Settings="Spectre.Console.Tests.Data.HorseSettings">
<Parameters>
<Option Short="d" Long="day" Value="NULL" Required="false" Kind="scalar" ClrType="System.DayOfWeek" />
<Option Short="" Long="directory" Value="NULL" Required="false" Kind="scalar" ClrType="System.IO.DirectoryInfo" />
<Option Short="" Long="file" Value="NULL" Required="false" Kind="scalar" ClrType="System.IO.FileInfo" />
<Option Short="n,p" Long="name,pet-name" Value="VALUE" Required="false" Kind="scalar" ClrType="System.String" />
</Parameters>
</Command>
</Command>
</Model>

View File

@ -0,0 +1,351 @@
namespace Spectre.Console.Tests.Unit.Cli;
public sealed partial class CommandAppTests
{
public sealed class Branches
{
[Fact]
public void Should_Run_The_Default_Command_On_Branch()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.SetDefaultCommand<CatCommand>();
});
});
// When
var result = app.Run(new[]
{
"animal", "4",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<CatSettings>();
}
[Fact]
public void Should_Throw_When_No_Default_Command_On_Branch()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal => { });
});
// When
var result = Record.Exception(() =>
{
app.Run(new[]
{
"animal", "4",
});
});
// Then
result.ShouldBeOfType<CommandConfigurationException>().And(ex =>
{
ex.Message.ShouldBe("The branch 'animal' does not define any commands.");
});
}
[SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1515:SingleLineCommentMustBePrecededByBlankLine", Justification = "Helps to illustrate the expected behaviour of this unit test.")]
[SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1005:SingleLineCommentsMustBeginWithSingleSpace", Justification = "Helps to illustrate the expected behaviour of this unit test.")]
[Fact]
public void Should_Be_Unable_To_Parse_Default_Command_Arguments_Relaxed_Parsing()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.SetDefaultCommand<CatCommand>();
});
});
// When
var result = app.Run(new[]
{
// The CommandTreeParser should be unable to determine which command line
// arguments belong to the branch and which belong to the branch's
// default command (once inserted).
"animal", "4", "--name", "Kitty",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<CatSettings>().And(cat =>
{
cat.Legs.ShouldBe(4);
//cat.Name.ShouldBe("Kitty"); //<-- Should normally be correct, but instead name will be added to the remaining arguments (see below).
});
result.Context.Remaining.Parsed.Count.ShouldBe(1);
result.Context.ShouldHaveRemainingArgument("name", values: new[] { "Kitty", });
}
[Fact]
public void Should_Be_Unable_To_Parse_Default_Command_Arguments_Strict_Parsing()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.UseStrictParsing();
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.SetDefaultCommand<CatCommand>();
});
});
// When
var result = Record.Exception(() =>
{
app.Run(new[]
{
// The CommandTreeParser should be unable to determine which command line
// arguments belong to the branch and which belong to the branch's
// default command (once inserted).
"animal", "4", "--name", "Kitty",
});
});
// Then
result.ShouldBeOfType<CommandParseException>().And(ex =>
{
ex.Message.ShouldBe("Unknown option 'name'.");
});
}
[Fact]
public void Should_Run_The_Default_Command_On_Branch_On_Branch()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddBranch<MammalSettings>("mammal", mammal =>
{
mammal.SetDefaultCommand<CatCommand>();
});
});
});
// When
var result = app.Run(new[]
{
"animal", "4", "mammal",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<CatSettings>();
}
[Fact]
public void Should_Run_The_Default_Command_On_Branch_On_Branch_With_Arguments()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddBranch<MammalSettings>("mammal", mammal =>
{
mammal.SetDefaultCommand<CatCommand>();
});
});
});
// When
var result = app.Run(new[]
{
"animal", "4", "mammal", "--name", "Kitty",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<CatSettings>().And(cat =>
{
cat.Legs.ShouldBe(4);
cat.Name.ShouldBe("Kitty");
});
}
[Fact]
public void Should_Run_The_Default_Command_Not_The_Named_Command_On_Branch()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddCommand<DogCommand>("dog");
animal.SetDefaultCommand<CatCommand>();
});
});
// When
var result = app.Run(new[]
{
"animal", "4",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<CatSettings>();
}
[Fact]
public void Should_Run_The_Named_Command_Not_The_Default_Command_On_Branch()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddCommand<DogCommand>("dog");
animal.SetDefaultCommand<LionCommand>();
});
});
// When
var result = app.Run(new[]
{
"animal", "4", "dog", "12", "--good-boy", "--name", "Rufus",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(dog =>
{
dog.Legs.ShouldBe(4);
dog.Age.ShouldBe(12);
dog.GoodBoy.ShouldBe(true);
dog.Name.ShouldBe("Rufus");
});
}
[Fact]
public void Should_Allow_Multiple_Branches_Multiple_Commands()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddBranch<MammalSettings>("mammal", mammal =>
{
mammal.AddCommand<DogCommand>("dog");
mammal.AddCommand<CatCommand>("cat");
});
});
});
// When
var result = app.Run(new[]
{
"animal", "--alive", "mammal", "--name",
"Rufus", "dog", "12", "--good-boy",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(dog =>
{
dog.Age.ShouldBe(12);
dog.GoodBoy.ShouldBe(true);
dog.Name.ShouldBe("Rufus");
dog.IsAlive.ShouldBe(true);
});
}
[Fact]
public void Should_Allow_Single_Branch_Multiple_Commands()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddCommand<DogCommand>("dog");
animal.AddCommand<CatCommand>("cat");
});
});
// When
var result = app.Run(new[]
{
"animal", "dog", "12", "--good-boy",
"--name", "Rufus",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(dog =>
{
dog.Age.ShouldBe(12);
dog.GoodBoy.ShouldBe(true);
dog.Name.ShouldBe("Rufus");
dog.IsAlive.ShouldBe(false);
});
}
[Fact]
public void Should_Allow_Single_Branch_Single_Command()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddCommand<DogCommand>("dog");
});
});
// When
var result = app.Run(new[]
{
"animal", "4", "dog", "12", "--good-boy",
"--name", "Rufus",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(dog =>
{
dog.Legs.ShouldBe(4);
dog.Age.ShouldBe(12);
dog.GoodBoy.ShouldBe(true);
dog.IsAlive.ShouldBe(false);
dog.Name.ShouldBe("Rufus");
});
}
}
}

View File

@ -4,6 +4,166 @@ public sealed partial class CommandAppTests
{ {
public sealed class Remaining public sealed class Remaining
{ {
[Theory]
[InlineData("-a")]
[InlineData("--alive")]
public void Should_Not_Add_Known_Flags_To_Remaining_Arguments_RelaxedParsing(string knownFlag)
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(new[]
{
"dog", "12", "4",
knownFlag,
});
// Then
result.Settings.ShouldBeOfType<DogSettings>().And(dog =>
{
dog.IsAlive.ShouldBe(true);
});
result.Context.Remaining.Parsed.Count.ShouldBe(0);
result.Context.Remaining.Raw.Count.ShouldBe(0);
}
[Theory]
[InlineData("-r")]
[InlineData("--romeo")]
public void Should_Add_Unknown_Flags_To_Remaining_Arguments_RelaxedParsing(string unknownFlag)
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(new[]
{
"dog", "12", "4",
unknownFlag,
});
// Then
result.Context.Remaining.Parsed.Count.ShouldBe(1);
result.Context.ShouldHaveRemainingArgument(unknownFlag.TrimStart('-'), values: new[] { (string)null });
result.Context.Remaining.Raw.Count.ShouldBe(0);
}
[Fact]
public void Should_Add_Unknown_Flags_When_Grouped_To_Remaining_Arguments_RelaxedParsing()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(new[]
{
"dog", "12", "4",
"-agr",
});
// Then
result.Context.Remaining.Parsed.Count.ShouldBe(1);
result.Context.ShouldHaveRemainingArgument("r", values: new[] { (string)null });
result.Context.Remaining.Raw.Count.ShouldBe(0);
}
[Theory]
[InlineData("-a")]
[InlineData("--alive")]
public void Should_Not_Add_Known_Flags_To_Remaining_Arguments_StrictParsing(string knownFlag)
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.UseStrictParsing();
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(new[]
{
"dog", "12", "4",
knownFlag,
});
// Then
result.Context.Remaining.Parsed.Count.ShouldBe(0);
result.Context.Remaining.Raw.Count.ShouldBe(0);
}
[Theory]
[InlineData("-r")]
[InlineData("--romeo")]
public void Should_Not_Add_Unknown_Flags_To_Remaining_Arguments_StrictParsing(string unknownFlag)
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.UseStrictParsing();
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = Record.Exception(() => app.Run(new[]
{
"dog", "12", "4",
unknownFlag,
}));
// Then
result.ShouldBeOfType<CommandParseException>().And(ex =>
{
ex.Message.ShouldBe($"Unknown option '{unknownFlag.TrimStart('-')}'.");
});
}
[Fact]
public void Should_Not_Add_Unknown_Flags_When_Grouped_To_Remaining_Arguments_StrictParsing()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.UseStrictParsing();
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = Record.Exception(() => app.Run(new[]
{
"dog", "12", "4",
"-agr",
}));
// Then
result.ShouldBeOfType<CommandParseException>().And(ex =>
{
ex.Message.ShouldBe($"Unknown option 'r'.");
});
}
[Fact] [Fact]
public void Should_Register_Remaining_Parsed_Arguments_With_Context() public void Should_Register_Remaining_Parsed_Arguments_With_Context()
{ {
@ -95,5 +255,34 @@ public sealed partial class CommandAppTests
result.Context.Remaining.Raw[1].ShouldBe("\"set && pause\""); result.Context.Remaining.Raw[1].ShouldBe("\"set && pause\"");
result.Context.Remaining.Raw[2].ShouldBe("Name=\" -Rufus --' "); result.Context.Remaining.Raw[2].ShouldBe("Name=\" -Rufus --' ");
} }
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Should_Convert_Flags_To_Remaining_Arguments_If_Cannot_Be_Assigned(bool useStrictParsing)
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.Settings.ConvertFlagsToRemainingArguments = true;
config.Settings.StrictParsing = useStrictParsing;
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(new[]
{
"dog", "12", "4",
"--good-boy=Please be good Rufus!",
});
// Then
result.Context.Remaining.Parsed.Count.ShouldBe(1);
result.Context.ShouldHaveRemainingArgument("good-boy", values: new[] { "Please be good Rufus!" });
result.Context.Remaining.Raw.Count.ShouldBe(0); // nb. there are no "raw" remaining arguments on the command line
}
} }
} }

View File

@ -130,6 +130,77 @@ public sealed partial class CommandAppTests
return Verifier.Verify(result.Output); return Verifier.Verify(result.Output);
} }
[Fact]
[Expectation("Test_7")]
public Task Should_Dump_Correct_Model_For_Model_With_Single_Branch_Single_Branch_Default_Command()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configuration =>
{
configuration.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddBranch<MammalSettings>("mammal", mammal =>
{
mammal.SetDefaultCommand<HorseCommand>();
});
});
});
// When
var result = fixture.Run(Constants.XmlDocCommand);
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Test_8")]
public Task Should_Dump_Correct_Model_For_Model_With_Single_Branch_Single_Command_Default_Command()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configuration =>
{
configuration.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddCommand<DogCommand>("dog");
animal.SetDefaultCommand<HorseCommand>();
});
});
// When
var result = fixture.Run(Constants.XmlDocCommand);
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Test_9")]
public Task Should_Dump_Correct_Model_For_Model_With_Default_Command_Single_Branch_Single_Command_Default_Command()
{
// Given
var fixture = new CommandAppTester();
fixture.SetDefaultCommand<EmptyCommand>();
fixture.Configure(configuration =>
{
configuration.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddCommand<DogCommand>("dog");
animal.SetDefaultCommand<HorseCommand>();
});
});
// When
var result = fixture.Run(Constants.XmlDocCommand);
// Then
return Verifier.Verify(result.Output);
}
[Fact] [Fact]
[Expectation("Hidden_Command_Options")] [Expectation("Hidden_Command_Options")]
public Task Should_Not_Dump_Hidden_Options_On_A_Command() public Task Should_Not_Dump_Hidden_Options_On_A_Command()

View File

@ -4,42 +4,6 @@ public sealed partial class CommandAppTests
{ {
[Fact] [Fact]
public void Should_Pass_Case_1() public void Should_Pass_Case_1()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddBranch<MammalSettings>("mammal", mammal =>
{
mammal.AddCommand<DogCommand>("dog");
mammal.AddCommand<HorseCommand>("horse");
});
});
});
// When
var result = app.Run(new[]
{
"animal", "--alive", "mammal", "--name",
"Rufus", "dog", "12", "--good-boy",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(dog =>
{
dog.Age.ShouldBe(12);
dog.GoodBoy.ShouldBe(true);
dog.Name.ShouldBe("Rufus");
dog.IsAlive.ShouldBe(true);
});
}
[Fact]
public void Should_Pass_Case_2()
{ {
// Given // Given
var app = new CommandAppTester(); var app = new CommandAppTester();
@ -69,7 +33,7 @@ public sealed partial class CommandAppTests
} }
[Fact] [Fact]
public void Should_Pass_Case_3() public void Should_Pass_Case_2()
{ {
// Given // Given
var app = new CommandAppTester(); var app = new CommandAppTester();
@ -197,7 +161,7 @@ public sealed partial class CommandAppTests
} }
[Fact] [Fact]
public void Should_Pass_Case_7() public void Should_Pass_Case_3()
{ {
// Given // Given
var app = new CommandAppTester(); var app = new CommandAppTester();
@ -904,6 +868,86 @@ public sealed partial class CommandAppTests
result.Context.ShouldHaveRemainingArgument("foo", values: new[] { (string)null }); result.Context.ShouldHaveRemainingArgument("foo", values: new[] { (string)null });
} }
[Fact]
public void Should_Run_The_Default_Command()
{
// Given
var app = new CommandAppTester();
app.SetDefaultCommand<DogCommand>();
// When
var result = app.Run(new[]
{
"4", "12", "--good-boy", "--name", "Rufus",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(dog =>
{
dog.Legs.ShouldBe(4);
dog.Age.ShouldBe(12);
dog.GoodBoy.ShouldBe(true);
dog.Name.ShouldBe("Rufus");
});
}
[Fact]
public void Should_Run_The_Default_Command_Not_The_Named_Command()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<HorseCommand>("horse");
});
app.SetDefaultCommand<DogCommand>();
// When
var result = app.Run(new[]
{
"4", "12", "--good-boy", "--name", "Rufus",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(dog =>
{
dog.Legs.ShouldBe(4);
dog.Age.ShouldBe(12);
dog.GoodBoy.ShouldBe(true);
dog.Name.ShouldBe("Rufus");
});
}
[Fact]
public void Should_Run_The_Named_Command_Not_The_Default_Command()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<HorseCommand>("horse");
});
app.SetDefaultCommand<DogCommand>();
// When
var result = app.Run(new[]
{
"horse", "4", "--name", "Arkle",
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<HorseSettings>().And(horse =>
{
horse.Legs.ShouldBe(4);
horse.Name.ShouldBe("Arkle");
});
}
[Fact] [Fact]
public void Should_Set_Command_Name_In_Context() public void Should_Set_Command_Name_In_Context()
{ {
@ -1081,67 +1125,4 @@ public sealed partial class CommandAppTests
data.ShouldBe(2); data.ShouldBe(2);
} }
} }
public sealed class Remaining_Arguments
{
[Fact]
public void Should_Register_Remaining_Parsed_Arguments_With_Context()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddCommand<DogCommand>("dog");
});
});
// When
var result = app.Run(new[]
{
"animal", "4", "dog", "12", "--",
"--foo", "bar", "--foo", "baz",
"-bar", "\"baz\"", "qux",
});
// Then
result.Context.Remaining.Parsed.Count.ShouldBe(4);
result.Context.ShouldHaveRemainingArgument("foo", values: new[] { "bar", "baz" });
result.Context.ShouldHaveRemainingArgument("b", values: new[] { (string)null });
result.Context.ShouldHaveRemainingArgument("a", values: new[] { (string)null });
result.Context.ShouldHaveRemainingArgument("r", values: new[] { (string)null });
}
[Fact]
public void Should_Register_Remaining_Raw_Arguments_With_Context()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddCommand<DogCommand>("dog");
});
});
// When
var result = app.Run(new[]
{
"animal", "4", "dog", "12", "--",
"--foo", "bar", "-bar", "\"baz\"", "qux",
});
// Then
result.Context.Remaining.Raw.Count.ShouldBe(5);
result.Context.Remaining.Raw[0].ShouldBe("--foo");
result.Context.Remaining.Raw[1].ShouldBe("bar");
result.Context.Remaining.Raw[2].ShouldBe("-bar");
result.Context.Remaining.Raw[3].ShouldBe("\"baz\"");
result.Context.Remaining.Raw[4].ShouldBe("qux");
}
}
} }