Command line argument parsing improvements (#1048)

* Support negative numbers as command option values
* Support command line options before arguments
* POSIX-compliant handling of quotes (double and single, terminated and unterminated), whitespace, hyphens, and special characters (e.g. emojis)
This commit is contained in:
Frank Ray 2022-12-05 00:07:53 +00:00 committed by GitHub
parent f895bb175d
commit b793482ebb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 812 additions and 326 deletions

View File

@ -91,11 +91,6 @@ public sealed class CommandParseException : CommandRuntimeException
return CommandLineParseExceptionFactory.Create(reader.Original, token, "Invalid long option name.", "Invalid character.");
}
internal static CommandParseException UnterminatedQuote(string input, CommandTreeToken token)
{
return CommandLineParseExceptionFactory.Create(input, token, $"Encountered unterminated quoted string '{token.Value}'.", "Did you forget the closing quotation mark?");
}
internal static CommandParseException UnknownCommand(CommandModel model, CommandTree? node, IEnumerable<string> args, CommandTreeToken token)
{
var suggestion = CommandSuggestor.Suggest(model, node?.Command, token.Value);

View File

@ -308,19 +308,35 @@ internal class CommandTreeParser
{
// Is this a command?
if (current.Command.FindCommand(valueToken.Value, CaseSensitivity) == null)
{
if (parameter != null)
{
if (parameter.ParameterKind == ParameterKind.Flag)
{
if (!CliConstants.AcceptedBooleanValues.Contains(valueToken.Value, StringComparer.OrdinalIgnoreCase))
{
// Flags cannot be assigned a value.
throw CommandParseException.CannotAssignValueToFlag(context.Arguments, token);
}
}
value = stream.Consume(CommandTreeToken.Kind.String)?.Value;
{
if (parameter != null)
{
if (parameter.ParameterKind == ParameterKind.Flag)
{
if (!CliConstants.AcceptedBooleanValues.Contains(valueToken.Value, StringComparer.OrdinalIgnoreCase))
{
if (!valueToken.HadSeparator)
{
// Do nothing
// - assume valueToken is unrelated to the flag parameter (ie. we've parsed it unnecessarily)
// - rely on the "No value?" code below to set the flag to its default value
// - valueToken will be handled on the next pass of the parser
}
else
{
// Flags cannot be assigned a value.
throw CommandParseException.CannotAssignValueToFlag(context.Arguments, token);
}
}
else
{
value = stream.Consume(CommandTreeToken.Kind.String)?.Value;
}
}
else
{
value = stream.Consume(CommandTreeToken.Kind.String)?.Value;
}
}
else
{

View File

@ -6,7 +6,12 @@ internal sealed class CommandTreeToken
public int Position { get; }
public string Value { get; }
public string Representation { get; }
public bool IsGrouped { get; set; }
public bool IsGrouped { get; set; }
/// <summary>
/// Gets or sets a value indicating whether a separater was encountered immediately before the <see cref="CommandTreeToken.Value"/>.
/// </summary>
public bool HadSeparator { get; set; }
public enum Kind
{

View File

@ -29,7 +29,14 @@ internal static class CommandTreeTokenizer
var context = new CommandTreeTokenizerContext();
foreach (var arg in args)
{
{
if (string.IsNullOrEmpty(arg))
{
// Null strings in the args array are still represented as tokens
tokens.Add(new CommandTreeToken(CommandTreeToken.Kind.String, position, string.Empty, string.Empty));
continue;
}
var start = position;
var reader = new TextBuffer(previousReader, arg);
@ -48,39 +55,30 @@ internal static class CommandTreeTokenizer
}
private static int ParseToken(CommandTreeTokenizerContext context, TextBuffer reader, int position, int start, List<CommandTreeToken> tokens)
{
while (reader.Peek() != -1)
{
if (reader.ReachedEnd)
{
position += reader.Position - start;
break;
}
var character = reader.Peek();
// Eat whitespace
if (char.IsWhiteSpace(character))
{
reader.Consume();
continue;
}
if (character == '-')
{
// Option
tokens.AddRange(ScanOptions(context, reader));
}
else
{
// Command or argument
tokens.Add(ScanString(context, reader));
}
// Flush remaining tokens
context.FlushRemaining();
}
{
if (!reader.ReachedEnd && reader.Peek() == '-')
{
// Option
tokens.AddRange(ScanOptions(context, reader));
}
else
{
// Command or argument
while (reader.Peek() != -1)
{
if (reader.ReachedEnd)
{
position += reader.Position - start;
break;
}
tokens.Add(ScanString(context, reader));
// Flush remaining tokens
context.FlushRemaining();
}
}
return position;
}
@ -89,15 +87,6 @@ internal static class CommandTreeTokenizer
TextBuffer reader,
char[]? stop = null)
{
if (reader.TryPeek(out var character))
{
// Is this a quoted string?
if (character == '\"')
{
return ScanQuotedString(context, reader);
}
}
var position = reader.Position;
var builder = new StringBuilder();
while (!reader.ReachedEnd)
@ -113,48 +102,8 @@ internal static class CommandTreeTokenizer
builder.Append(current);
}
var value = builder.ToString();
return new CommandTreeToken(CommandTreeToken.Kind.String, position, value.Trim(), value);
}
private static CommandTreeToken ScanQuotedString(CommandTreeTokenizerContext context, TextBuffer reader)
{
var position = reader.Position;
context.FlushRemaining();
reader.Consume('\"');
var builder = new StringBuilder();
var terminated = false;
while (!reader.ReachedEnd)
{
var character = reader.Peek();
if (character == '\"')
{
terminated = true;
reader.Read();
break;
}
builder.Append(reader.Read());
}
if (!terminated)
{
var unterminatedQuote = builder.ToString();
var token = new CommandTreeToken(CommandTreeToken.Kind.String, position, unterminatedQuote, $"\"{unterminatedQuote}");
throw CommandParseException.UnterminatedQuote(reader.Original, token);
}
var quotedString = builder.ToString();
// Add to the context
context.AddRemaining(quotedString);
return new CommandTreeToken(
CommandTreeToken.Kind.String,
position, quotedString,
quotedString);
var value = builder.ToString();
return new CommandTreeToken(CommandTreeToken.Kind.String, position, value, value);
}
private static IEnumerable<CommandTreeToken> ScanOptions(CommandTreeTokenizerContext context, TextBuffer reader)
@ -166,7 +115,7 @@ internal static class CommandTreeTokenizer
reader.Consume('-');
context.AddRemaining('-');
if (!reader.TryPeek(out var character))
if (!reader.TryPeek(out var character) || character == ' ')
{
var token = new CommandTreeToken(CommandTreeToken.Kind.ShortOption, position, "-", "-");
throw CommandParseException.OptionHasNoName(reader.Original, token);
@ -200,8 +149,10 @@ internal static class CommandTreeTokenizer
var token = new CommandTreeToken(CommandTreeToken.Kind.String, reader.Position, "=", "=");
throw CommandParseException.OptionValueWasExpected(reader.Original, token);
}
result.Add(ScanString(context, reader));
var tokenValue = ScanString(context, reader);
tokenValue.HadSeparator = true;
result.Add(tokenValue);
}
}
@ -235,12 +186,38 @@ internal static class CommandTreeTokenizer
? new CommandTreeToken(CommandTreeToken.Kind.ShortOption, position, value, $"-{value}")
: new CommandTreeToken(CommandTreeToken.Kind.ShortOption, position + result.Count, value, value));
}
else
else if (result.Count == 0 && char.IsDigit(current))
{
// We require short options to be named with letters. Short options that start with a number
// ("-1", "-2ab", "-3..7") may actually mean values (either for options or arguments) and will
// be tokenized as strings. This block handles parsing those cases, but we only allow this
// when the digit is the first character in the token (i.e. "-a1" is always an error), hence the
// result.Count == 0 check above.
string value = string.Empty;
while (!reader.ReachedEnd)
{
char c = reader.Peek();
if (char.IsWhiteSpace(c))
{
break;
}
value += c.ToString(CultureInfo.InvariantCulture);
reader.Read();
}
value = "-" + value; // Prefix with the minus sign that we originally thought to mean a short option
result.Add(new CommandTreeToken(CommandTreeToken.Kind.String, position, value, value));
}
else
{
// Create a token representing the short option.
var tokenPosition = position + 1 + result.Count;
var represntation = current.ToString(CultureInfo.InvariantCulture);
var token = new CommandTreeToken(CommandTreeToken.Kind.ShortOption, tokenPosition, represntation, represntation);
var representation = current.ToString(CultureInfo.InvariantCulture);
var tokenPosition = position + 1 + result.Count;
var token = new CommandTreeToken(CommandTreeToken.Kind.ShortOption, tokenPosition, representation, representation);
throw CommandParseException.InvalidShortOptionName(reader.Original, token);
}
}
@ -271,7 +248,7 @@ internal static class CommandTreeTokenizer
var name = ScanString(context, reader, new[] { '=', ':' });
// Perform validation of the name.
if (name.Value.Length == 0)
if (name.Value == " ")
{
throw CommandParseException.LongOptionNameIsMissing(reader, position);
}

View File

@ -1,4 +1,4 @@
Error: Flags cannot be assigned a value.
dog --alive foo
dog --alive=indeterminate foo
^^^^^^^ Can't assign value

View File

@ -1,4 +1,4 @@
Error: Flags cannot be assigned a value.
dog -a foo
dog -a=indeterminate foo
^^ Can't assign value

View File

@ -1,4 +0,0 @@
Error: Encountered unterminated quoted string 'Rufus'.
--name "Rufus
^^^^^^ Did you forget the closing quotation mark?

View File

@ -15,17 +15,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_When_Command_Is_Unknown()
{
// Given
var fixture = new Fixture();
fixture.Configure(config =>
var app = new CommandAppTester();
app.Configure(config =>
{
config.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("cat", "14");
var result = app.Run("cat", "14");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -33,17 +33,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Unknown_Command_When_Current_Command_Has_No_Arguments()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<EmptyCommand>("empty");
});
// When
var result = fixture.Run("empty", "other");
var result = app.Run("empty", "other");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -51,8 +51,8 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_With_Suggestion_When_Command_Followed_By_Argument_Is_Unknown_And_Distance_Is_Small()
{
// Given
var fixture = new Fixture();
fixture.Configure(config =>
var app = new CommandAppTester();
app.Configure(config =>
{
config.AddBranch<CommandSettings>("dog", a =>
{
@ -61,10 +61,10 @@ public sealed partial class CommandAppTests
});
// When
var result = fixture.Run("dog", "bat", "14");
var result = app.Run("dog", "bat", "14");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -72,17 +72,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_With_Suggestion_When_Root_Command_Followed_By_Argument_Is_Unknown_And_Distance_Is_Small()
{
// Given
var fixture = new Fixture();
fixture.Configure(config =>
var app = new CommandAppTester();
app.Configure(config =>
{
config.AddCommand<CatCommand>("cat");
});
// When
var result = fixture.Run("bat", "14");
var result = app.Run("bat", "14");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -90,18 +90,18 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_With_Suggestion_And_No_Arguments_When_Root_Command_Is_Unknown_And_Distance_Is_Small()
{
// Given
var fixture = new Fixture();
fixture.WithDefaultCommand<GenericCommand<EmptyCommandSettings>>();
fixture.Configure(config =>
var app = new CommandAppTester();
app.SetDefaultCommand<GenericCommand<EmptyCommandSettings>>();
app.Configure(config =>
{
config.AddCommand<GenericCommand<EmptyCommandSettings>>("cat");
});
// When
var result = fixture.Run("bat");
var result = app.Run("bat");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -109,9 +109,9 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_With_Suggestion_And_No_Arguments_When_Command_Is_Unknown_And_Distance_Is_Small()
{
// Given
var fixture = new Fixture();
fixture.WithDefaultCommand<GenericCommand<EmptyCommandSettings>>();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.SetDefaultCommand<GenericCommand<EmptyCommandSettings>>();
app.Configure(configurator =>
{
configurator.AddBranch<CommandSettings>("dog", a =>
{
@ -120,10 +120,10 @@ public sealed partial class CommandAppTests
});
// When
var result = fixture.Run("dog", "bat");
var result = app.Run("dog", "bat");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -131,18 +131,18 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_With_Suggestion_When_Root_Command_After_Argument_Is_Unknown_And_Distance_Is_Small()
{
// Given
var fixture = new Fixture();
fixture.WithDefaultCommand<GenericCommand<FooCommandSettings>>();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.SetDefaultCommand<GenericCommand<FooCommandSettings>>();
app.Configure(configurator =>
{
configurator.AddCommand<GenericCommand<BarCommandSettings>>("bar");
});
// When
var result = fixture.Run("qux", "bat");
var result = app.Run("qux", "bat");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -150,8 +150,8 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_With_Suggestion_When_Command_After_Argument_Is_Unknown_And_Distance_Is_Small()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddBranch<FooCommandSettings>("foo", a =>
{
@ -160,10 +160,10 @@ public sealed partial class CommandAppTests
});
// When
var result = fixture.Run("foo", "qux", "bat");
var result = app.Run("foo", "qux", "bat");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
}
@ -176,17 +176,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Long_Option()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", "--alive", "foo");
var result = app.Run("dog", "--alive=indeterminate", "foo");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -194,17 +194,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Short_Option()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", "-a", "foo");
var result = app.Run("dog", "-a=indeterminate", "foo");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
}
@ -217,17 +217,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Long_Option()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", "--name");
var result = app.Run("dog", "--name");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -235,17 +235,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Short_Option()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", "-n");
var result = app.Run("dog", "-n");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
}
@ -258,17 +258,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<GiraffeCommand>("giraffe");
});
// When
var result = fixture.Run("giraffe", "foo", "bar", "baz");
var result = app.Run("giraffe", "foo", "bar", "baz");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
}
@ -281,17 +281,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Long_Option()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("--foo");
var result = app.Run("--foo");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -299,17 +299,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Short_Option()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("-f");
var result = app.Run("-f");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
}
@ -322,18 +322,18 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Long_Option_If_Strict_Mode_Is_Enabled()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.UseStrictParsing();
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", "--unknown");
var result = app.Run("dog", "--unknown");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -341,42 +341,19 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Short_Option_If_Strict_Mode_Is_Enabled()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.UseStrictParsing();
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", "-u");
var result = app.Run("dog", "-u");
// Then
return Verifier.Verify(result);
}
}
[UsesVerify]
[ExpectationPath("UnterminatedQuote")]
public sealed class UnterminatedQuote
{
[Fact]
[Expectation("Test_1")]
public Task Should_Return_Correct_Text()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("--name", "\"Rufus");
// Then
return Verifier.Verify(result);
}
return Verifier.Verify(result.Output);
}
}
[UsesVerify]
@ -388,17 +365,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Short_Option()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", "-", " ");
var result = app.Run("dog", "-", " ");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -406,17 +383,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Missing_Long_Option_Value_With_Equality_Separator()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", $"--foo=");
var result = app.Run("dog", $"--foo=");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -424,17 +401,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Missing_Long_Option_Value_With_Colon_Separator()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", $"--foo:");
var result = app.Run("dog", $"--foo:");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -442,17 +419,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Missing_Short_Option_Value_With_Equality_Separator()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", $"-f=");
var result = app.Run("dog", $"-f=");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Fact]
@ -460,17 +437,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text_For_Missing_Short_Option_Value_With_Colon_Separator()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", $"-f:");
var result = app.Run("dog", $"-f:");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
}
@ -483,17 +460,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", $"-f0o");
var result = app.Run("dog", $"-f0o");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
}
@ -506,17 +483,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", $"--f");
var result = app.Run("dog", $"--f");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
}
@ -529,17 +506,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", $"-- ");
var result = app.Run("dog", $"-- ");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
}
@ -552,17 +529,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", $"--1foo");
var result = app.Run("dog", $"--1foo");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
}
@ -575,17 +552,17 @@ public sealed partial class CommandAppTests
public Task Should_Return_Correct_Text()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", $"--f€oo");
var result = app.Run("dog", $"--f€oo");
// Then
return Verifier.Verify(result);
return Verifier.Verify(result.Output);
}
[Theory]
@ -596,71 +573,18 @@ public sealed partial class CommandAppTests
public void Should_Allow_Special_Symbols_In_Name(string option)
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
var app = new CommandAppTester();
app.Configure(configurator =>
{
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run("dog", option);
var result = app.Run("dog", option);
// Then
result.ShouldBe("Error: Command 'dog' is missing required argument 'AGE'.");
}
}
[Fact]
[Expectation("Quoted_Strings")]
public Task Should_Parse_Quoted_Strings_Correctly()
{
// Given
var fixture = new Fixture();
fixture.Configure(configurator =>
{
configurator.AddCommand<DumpRemainingCommand>("foo");
});
// When
var result = fixture.Run("foo", "--", "/c", "\"set && pause\"");
// Then
return Verifier.Verify(result);
}
}
internal sealed class Fixture
{
private Action<CommandApp> _appConfiguration = _ => { };
private Action<IConfigurator> _configuration;
public void WithDefaultCommand<T>()
where T : class, ICommand
{
_appConfiguration = (app) => app.SetDefaultCommand<T>();
}
public void Configure(Action<IConfigurator> action)
{
_configuration = action;
}
public string Run(params string[] args)
{
using (var console = new TestConsole())
{
var app = new CommandApp();
_appConfiguration?.Invoke(app);
app.Configure(_configuration);
app.Configure(c => c.ConfigureConsole(console));
app.Run(args);
return console.Output
.NormalizeLineEndings()
.TrimLines()
.Trim();
}
result.Output.ShouldBe("Error: Command 'dog' is missing required argument 'AGE'.");
}
}
}
}

View File

@ -62,9 +62,38 @@ public sealed partial class CommandAppTests
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[3].ShouldBe("\"baz\"");
result.Context.Remaining.Raw[4].ShouldBe("qux");
result.Context.Remaining.Raw[5].ShouldBe("foo bar baz qux");
}
[Fact]
public void Should_Preserve_Quotes_Hyphen_Delimiters()
{
// 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", "--",
"/c", "\"set && pause\"",
"Name=\" -Rufus --' ",
});
// Then
result.Context.Remaining.Raw.Count.ShouldBe(3);
result.Context.Remaining.Raw[0].ShouldBe("/c");
result.Context.Remaining.Raw[1].ShouldBe("\"set && pause\"");
result.Context.Remaining.Raw[2].ShouldBe("Name=\" -Rufus --' ");
}
}
}

View File

@ -132,10 +132,43 @@ public sealed partial class CommandAppTests
dog.IsAlive.ShouldBe(false);
dog.Name.ShouldBe("Rufus");
});
}
}
[Fact]
public void Should_Pass_Case_5()
{
// 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", "--alive", "4", "dog", "--good-boy", "12",
"--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(true);
dog.Name.ShouldBe("Rufus");
});
}
[Fact]
public void Should_Pass_Case_6()
{
// Given
var app = new CommandAppTester();
@ -164,7 +197,7 @@ public sealed partial class CommandAppTests
}
[Fact]
public void Should_Pass_Case_6()
public void Should_Pass_Case_7()
{
// Given
var app = new CommandAppTester();
@ -189,6 +222,38 @@ public sealed partial class CommandAppTests
});
}
[Fact]
public void Should_Preserve_Quotes_Hyphen_Delimiters_Spaces()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(new[]
{
"dog", "12", "4",
"--name=\" -Rufus --' ",
"--",
"--order-by", "\"-size\"",
"--order-by", " ",
"--order-by", string.Empty,
});
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(dog =>
{
dog.Name.ShouldBe("\" -Rufus --' ");
});
result.Context.Remaining.Parsed.Count.ShouldBe(1);
result.Context.ShouldHaveRemainingArgument("order-by", values: new[] { "\"-size\"", " ", string.Empty });
}
[Fact]
public void Should_Be_Able_To_Use_Command_Alias()
{
@ -489,6 +554,181 @@ public sealed partial class CommandAppTests
{
dog.IsAlive.ShouldBe(expected);
});
}
[Fact]
public void Should_Set_Short_Option_Before_Argument()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(new[] { "dog", "-a", "-n=Rufus", "4", "12", });
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(settings =>
{
settings.IsAlive.ShouldBeTrue();
settings.Name.ShouldBe("Rufus");
settings.Legs.ShouldBe(4);
settings.Age.ShouldBe(12);
});
}
[Theory]
[InlineData("true", true)]
[InlineData("True", true)]
[InlineData("false", false)]
[InlineData("False", false)]
public void Should_Set_Short_Option_With_Explicit_Boolan_Flag_Before_Argument(string value, bool expected)
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(new[] { "dog", "-a", value, "4", "12", });
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(settings =>
{
settings.IsAlive.ShouldBe(expected);
settings.Legs.ShouldBe(4);
settings.Age.ShouldBe(12);
});
}
[Fact]
public void Should_Set_Long_Option_Before_Argument()
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(new[] { "dog", "--alive", "--name=Rufus", "4", "12" });
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(settings =>
{
settings.IsAlive.ShouldBeTrue();
settings.Name.ShouldBe("Rufus");
settings.Legs.ShouldBe(4);
settings.Age.ShouldBe(12);
});
}
[Theory]
[InlineData("true", true)]
[InlineData("True", true)]
[InlineData("false", false)]
[InlineData("False", false)]
public void Should_Set_Long_Option_With_Explicit_Boolan_Flag_Before_Argument(string value, bool expected)
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(new[] { "dog", "--alive", value, "4", "12", });
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(settings =>
{
settings.IsAlive.ShouldBe(expected);
settings.Legs.ShouldBe(4);
settings.Age.ShouldBe(12);
});
}
[Theory]
// Long options
[InlineData("dog --alive 4 12 --name Rufus", 4, 12, false, true, "Rufus")]
[InlineData("dog --alive=true 4 12 --name Rufus", 4, 12, false, true, "Rufus")]
[InlineData("dog --alive:true 4 12 --name Rufus", 4, 12, false, true, "Rufus")]
[InlineData("dog --alive --good-boy 4 12 --name Rufus", 4, 12, true, true, "Rufus")]
[InlineData("dog --alive=true --good-boy=true 4 12 --name Rufus", 4, 12, true, true, "Rufus")]
[InlineData("dog --alive:true --good-boy:true 4 12 --name Rufus", 4, 12, true, true, "Rufus")]
[InlineData("dog --alive --good-boy --name Rufus 4 12", 4, 12, true, true, "Rufus")]
[InlineData("dog --alive=true --good-boy=true --name Rufus 4 12", 4, 12, true, true, "Rufus")]
[InlineData("dog --alive:true --good-boy:true --name Rufus 4 12", 4, 12, true, true, "Rufus")]
// Short options
[InlineData("dog -a 4 12 --name Rufus", 4, 12, false, true, "Rufus")]
[InlineData("dog -a=true 4 12 --name Rufus", 4, 12, false, true, "Rufus")]
[InlineData("dog -a:true 4 12 --name Rufus", 4, 12, false, true, "Rufus")]
[InlineData("dog -a --good-boy 4 12 --name Rufus", 4, 12, true, true, "Rufus")]
[InlineData("dog -a=true -g=true 4 12 --name Rufus", 4, 12, true, true, "Rufus")]
[InlineData("dog -a:true -g:true 4 12 --name Rufus", 4, 12, true, true, "Rufus")]
[InlineData("dog -a -g --name Rufus 4 12", 4, 12, true, true, "Rufus")]
[InlineData("dog -a=true -g=true --name Rufus 4 12", 4, 12, true, true, "Rufus")]
[InlineData("dog -a:true -g:true --name Rufus 4 12", 4, 12, true, true, "Rufus")]
// Switch around ordering of the options
[InlineData("dog --good-boy:true --name Rufus --alive:true 4 12", 4, 12, true, true, "Rufus")]
[InlineData("dog --name Rufus --alive:true --good-boy:true 4 12", 4, 12, true, true, "Rufus")]
[InlineData("dog --name Rufus --good-boy:true --alive:true 4 12", 4, 12, true, true, "Rufus")]
// Inject the command arguments in between the options
[InlineData("dog 4 12 --good-boy:true --name Rufus --alive:true", 4, 12, true, true, "Rufus")]
[InlineData("dog 4 --good-boy:true 12 --name Rufus --alive:true", 4, 12, true, true, "Rufus")]
[InlineData("dog --good-boy:true 4 12 --name Rufus --alive:true", 4, 12, true, true, "Rufus")]
[InlineData("dog --good-boy:true 4 --name Rufus 12 --alive:true", 4, 12, true, true, "Rufus")]
[InlineData("dog --name Rufus --alive:true 4 12 --good-boy:true", 4, 12, true, true, "Rufus")]
[InlineData("dog --name Rufus --alive:true 4 --good-boy:true 12", 4, 12, true, true, "Rufus")]
// Inject the command arguments in between the options (all flag values set to false)
[InlineData("dog 4 12 --good-boy:false --name Rufus --alive:false", 4, 12, false, false, "Rufus")]
[InlineData("dog 4 --good-boy:false 12 --name Rufus --alive:false", 4, 12, false, false, "Rufus")]
[InlineData("dog --good-boy:false 4 12 --name Rufus --alive:false", 4, 12, false, false, "Rufus")]
[InlineData("dog --good-boy:false 4 --name Rufus 12 --alive:false", 4, 12, false, false, "Rufus")]
[InlineData("dog --name Rufus --alive:false 4 12 --good-boy:false", 4, 12, false, false, "Rufus")]
[InlineData("dog --name Rufus --alive:false 4 --good-boy:false 12", 4, 12, false, false, "Rufus")]
public void Should_Set_Option_Before_Argument(string arguments, int legs, int age, bool goodBoy, bool isAlive, string name)
{
// Given
var app = new CommandAppTester();
app.Configure(config =>
{
config.PropagateExceptions();
config.AddCommand<DogCommand>("dog");
});
// When
var result = app.Run(arguments.Split(' '));
// Then
result.ExitCode.ShouldBe(0);
result.Settings.ShouldBeOfType<DogSettings>().And(settings =>
{
settings.Legs.ShouldBe(legs);
settings.Age.ShouldBe(age);
settings.GoodBoy.ShouldBe(goodBoy);
settings.IsAlive.ShouldBe(isAlive);
settings.Name.ShouldBe(name);
});
}
[Fact]
@ -805,7 +1045,7 @@ public sealed partial class CommandAppTests
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[3].ShouldBe("\"baz\"");
result.Context.Remaining.Raw[4].ShouldBe("qux");
}
}

View File

@ -0,0 +1,314 @@
namespace Spectre.Console.Tests.Unit.Cli.Parsing;
public class CommandTreeTokenizerTests
{
public sealed class ScanString
{
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(" ")]
[InlineData("\t")]
[InlineData("\r\n\t")]
[InlineData("👋🏻")]
[InlineData("🐎👋🏻🔥❤️")]
[InlineData("\"🐎👋🏻🔥❤️\" is an emoji sequence")]
public void Should_Preserve_Edgecase_Inputs(string actualAndExpected)
{
// Given
// When
var result = CommandTreeTokenizer.Tokenize(new string[] { actualAndExpected });
// Then
result.Tokens.Count.ShouldBe(1);
result.Tokens[0].Value.ShouldBe(actualAndExpected);
result.Tokens[0].TokenKind.ShouldBe(CommandTreeToken.Kind.String);
}
[Theory]
// Double-quote handling
[InlineData("\"")]
[InlineData("\"\"")]
[InlineData("\"Rufus\"")]
[InlineData("\" Rufus\"")]
[InlineData("\"-R\"")]
[InlineData("\"-Rufus\"")]
[InlineData("\" -Rufus\"")]
// Single-quote handling
[InlineData("'")]
[InlineData("''")]
[InlineData("'Rufus'")]
[InlineData("' Rufus'")]
[InlineData("'-R'")]
[InlineData("'-Rufus'")]
[InlineData("' -Rufus'")]
public void Should_Preserve_Quotes(string actualAndExpected)
{
// Given
// When
var result = CommandTreeTokenizer.Tokenize(new string[] { actualAndExpected });
// Then
result.Tokens.Count.ShouldBe(1);
result.Tokens[0].Value.ShouldBe(actualAndExpected);
result.Tokens[0].TokenKind.ShouldBe(CommandTreeToken.Kind.String);
}
[Theory]
[InlineData("Rufus-")]
[InlineData("Rufus--")]
[InlineData("R-u-f-u-s")]
public void Should_Preserve_Hyphen_Delimiters(string actualAndExpected)
{
// Given
// When
var result = CommandTreeTokenizer.Tokenize(new string[] { actualAndExpected });
// Then
result.Tokens.Count.ShouldBe(1);
result.Tokens[0].Value.ShouldBe(actualAndExpected);
result.Tokens[0].TokenKind.ShouldBe(CommandTreeToken.Kind.String);
}
[Theory]
[InlineData(" Rufus")]
[InlineData("Rufus ")]
[InlineData(" Rufus ")]
public void Should_Preserve_Spaces(string actualAndExpected)
{
// Given
// When
var result = CommandTreeTokenizer.Tokenize(new string[] { actualAndExpected });
// Then
result.Tokens.Count.ShouldBe(1);
result.Tokens[0].Value.ShouldBe(actualAndExpected);
result.Tokens[0].TokenKind.ShouldBe(CommandTreeToken.Kind.String);
}
[Theory]
[InlineData(" \" -Rufus -- ")]
[InlineData("Name=\" -Rufus --' ")]
public void Should_Preserve_Quotes_Hyphen_Delimiters_Spaces(string actualAndExpected)
{
// Given
// When
var result = CommandTreeTokenizer.Tokenize(new string[] { actualAndExpected });
// Then
result.Tokens.Count.ShouldBe(1);
result.Tokens[0].Value.ShouldBe(actualAndExpected);
result.Tokens[0].TokenKind.ShouldBe(CommandTreeToken.Kind.String);
}
}
public sealed class ScanLongOption
{
[Theory]
[InlineData("--Name-", "Name-")]
[InlineData("--Name_", "Name_")]
public void Should_Allow_Hyphens_And_Underscores_In_Option_Name(string actual, string expected)
{
// Given
// When
var result = CommandTreeTokenizer.Tokenize(new string[] { actual });
// Then
result.Tokens.Count.ShouldBe(1);
result.Tokens[0].Value.ShouldBe(expected);
result.Tokens[0].TokenKind.ShouldBe(CommandTreeToken.Kind.LongOption);
}
[Theory]
[InlineData("-- ")]
[InlineData("--Name ")]
[InlineData("--Name\"")]
[InlineData("--Nam\"e")]
public void Should_Throw_On_Invalid_Option_Name(string actual)
{
// Given
// When
var result = Record.Exception(() => CommandTreeTokenizer.Tokenize(new string[] { actual }));
// Then
result.ShouldBeOfType<CommandParseException>().And(ex =>
{
ex.Message.ShouldBe("Invalid long option name.");
});
}
}
public sealed class ScanShortOptions
{
[Fact]
public void Should_Accept_Option_Without_Value()
{
// Given
// When
var result = CommandTreeTokenizer.Tokenize(new[] { "-a" });
// Then
result.Remaining.ShouldBeEmpty();
result.Tokens.ShouldHaveSingleItem();
var t = result.Tokens[0];
t.TokenKind.ShouldBe(CommandTreeToken.Kind.ShortOption);
t.IsGrouped.ShouldBe(false);
t.Position.ShouldBe(0);
t.Value.ShouldBe("a");
t.Representation.ShouldBe("-a");
}
[Theory]
[InlineData("-a:foo")]
[InlineData("-a=foo")]
public void Should_Accept_Option_With_Value(string param)
{
// Given
// When
var result = CommandTreeTokenizer.Tokenize(new[] { param });
// Then
result.Remaining.ShouldBeEmpty();
result.Tokens.Count.ShouldBe(2);
var t = result.Tokens.Consume();
t.TokenKind.ShouldBe(CommandTreeToken.Kind.ShortOption);
t.IsGrouped.ShouldBe(false);
t.Position.ShouldBe(0);
t.Value.ShouldBe("a");
t.Representation.ShouldBe("-a");
t = result.Tokens.Consume();
t.TokenKind.ShouldBe(CommandTreeToken.Kind.String);
t.IsGrouped.ShouldBe(false);
t.Position.ShouldBe(3);
t.Value.ShouldBe("foo");
t.Representation.ShouldBe("foo");
}
[Theory]
// Positive values
[InlineData("-a:1.5", null, "1.5")]
[InlineData("-a=1.5", null, "1.5")]
[InlineData("-a", "1.5", "1.5")]
// Negative values
[InlineData("-a:-1.5", null, "-1.5")]
[InlineData("-a=-1.5", null, "-1.5")]
[InlineData("-a", "-1.5", "-1.5")]
public void Should_Accept_Option_With_Numeric_Value(string firstArg, string secondArg, string expectedValue)
{
// Given
List<string> args = new List<string>();
args.Add(firstArg);
if (secondArg != null)
{
args.Add(secondArg);
}
// When
var result = CommandTreeTokenizer.Tokenize(args);
// Then
result.Remaining.ShouldBeEmpty();
result.Tokens.Count.ShouldBe(2);
var t = result.Tokens.Consume();
t.TokenKind.ShouldBe(CommandTreeToken.Kind.ShortOption);
t.IsGrouped.ShouldBe(false);
t.Position.ShouldBe(0);
t.Value.ShouldBe("a");
t.Representation.ShouldBe("-a");
t = result.Tokens.Consume();
t.TokenKind.ShouldBe(CommandTreeToken.Kind.String);
t.IsGrouped.ShouldBe(false);
t.Position.ShouldBe(3);
t.Value.ShouldBe(expectedValue);
t.Representation.ShouldBe(expectedValue);
}
[Fact]
public void Should_Accept_Option_With_Negative_Numeric_Prefixed_String_Value()
{
// Given
// When
var result = CommandTreeTokenizer.Tokenize(new[] { "-6..2 " });
// Then
result.Remaining.ShouldBeEmpty();
result.Tokens.ShouldHaveSingleItem();
var t = result.Tokens[0];
t.TokenKind.ShouldBe(CommandTreeToken.Kind.String);
t.IsGrouped.ShouldBe(false);
t.Position.ShouldBe(0);
t.Value.ShouldBe("-6..2");
t.Representation.ShouldBe("-6..2");
}
[Theory]
[InlineData("-N ", "N")]
public void Should_Remove_Trailing_Spaces_In_Option_Name(string actual, string expected)
{
// Given
// When
var result = CommandTreeTokenizer.Tokenize(new string[] { actual });
// Then
result.Tokens.Count.ShouldBe(1);
result.Tokens[0].Value.ShouldBe(expected);
result.Tokens[0].TokenKind.ShouldBe(CommandTreeToken.Kind.ShortOption);
}
[Theory]
[InlineData("-N-")]
[InlineData("-N\"")]
[InlineData("-a1")]
public void Should_Throw_On_Invalid_Option_Name(string actual)
{
// Given
// When
var result = Record.Exception(() => CommandTreeTokenizer.Tokenize(new string[] { actual }));
// Then
result.ShouldBeOfType<CommandParseException>().And(ex =>
{
ex.Message.ShouldBe("Short option does not have a valid name.");
});
}
}
[Theory]
[InlineData("-")]
[InlineData("- ")]
public void Should_Throw_On_Missing_Option_Name(string actual)
{
// Given
// When
var result = Record.Exception(() => CommandTreeTokenizer.Tokenize(new string[] { actual }));
// Then
result.ShouldBeOfType<CommandParseException>().And(ex =>
{
ex.Message.ShouldBe("Option does not have a name.");
});
}
}

View File

@ -1,4 +0,0 @@
Error: Encountered unterminated quoted string 'Rufus'.
--name "Rufus
^^^^^^ Did you forget the closing quotation mark?