Skip to content
Closed
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
38 changes: 33 additions & 5 deletions src/Spectre.Console/Cli/Internal/Parsing/CommandTreeTokenizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -235,13 +235,34 @@ private static IEnumerable<CommandTreeToken> ScanShortOptions(CommandTreeTokeniz
? new CommandTreeToken(CommandTreeToken.Kind.ShortOption, position, value, $"-{value}")
: new CommandTreeToken(CommandTreeToken.Kind.ShortOption, position + result.Count, value, value));
}
else if (result.Count == 0 && char.IsDigit(current))
{
/* We require short options to be named with letters. Short options that start with a number
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everywhere else in the codebase, multi-line comments have simply used the // convention at the beginning of each line. I suggest this comment be updated accordingly to ensure consistency (for better or worse) with the rest of the codebase.

* ("-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);
throw CommandParseException.InvalidShortOptionName(reader.Original, token);
ThrowInvalidShortOptionName(current.ToString(CultureInfo.InvariantCulture));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not see the benefit of putting the local three-line logic into a separate private method, which also still references reader.Original from the calling function without passing this in as an explicit argument. I suggest this private method be removed and the logic relocated inline here, instead.

}
}

Expand All @@ -254,6 +275,13 @@ private static IEnumerable<CommandTreeToken> ScanShortOptions(CommandTreeTokeniz
}

return result;

void ThrowInvalidShortOptionName(string representation)
{
var tokenPosition = position + 1 + result.Count;
var token = new CommandTreeToken(CommandTreeToken.Kind.ShortOption, tokenPosition, representation, representation);
throw CommandParseException.InvalidShortOptionName(reader.Original, token);
}
}

private static CommandTreeToken ScanLongOption(CommandTreeTokenizerContext context, TextBuffer reader, int position)
Expand Down
33 changes: 33 additions & 0 deletions test/Spectre.Console.Tests/Unit/Cli/CommandAppTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,39 @@ public void Should_Pass_Case_4()
});
}

[Fact]
public void Should_Pass_Case_4_With_Negative_Integer_Argument()
{
// 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");
});
}

[Fact]
public void Should_Pass_Case_5()
{
Expand Down
90 changes: 90 additions & 0 deletions test/Spectre.Console.Tests/Unit/Cli/CommandTreeTokenizerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
namespace Spectre.Console.Tests.Unit.Cli;

public static class CommandTreeTokenizerTests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic that you have introduced explicit unit tests for the tokenizer! A positive move away from the general approach of 'unit tests' being located at the CommandApp level (nb. more like integration tests really).

Does this class need to be static? Unit test classes aren't generally made static - suggest this is also removed to ensure consistency with the rest of the codebase.

{
public sealed class ShortOptions
{
[Fact]
public void Valueless()
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// When

var result = CommandTreeTokenizer.Tokenize(new[] { "-a" });
Assert.Empty(result.Remaining);
var t = Assert.Single(result.Tokens);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Then

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 With_Value(string param)
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// When

var result = CommandTreeTokenizer.Tokenize(new[] { param });
Assert.Empty(result.Remaining);
Assert.Equal(2, result.Tokens.Count);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Then

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]
[InlineData("-a:-1.5", null)]
[InlineData("-a=-1.5", null)]
[InlineData("-a", "-1.5")]
public void With_Negative_Numeric_Value(string firstArg, string secondArg)
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Given

List<string> args = new List<string>();
args.Add(firstArg);
if (secondArg != null)
{
args.Add(secondArg);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// When

var result = CommandTreeTokenizer.Tokenize(args);
Assert.Empty(result.Remaining);
Assert.Equal(2, result.Tokens.Count);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Then

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("-1.5");
t.Representation.ShouldBe("-1.5");
}

[Fact]
public void With_Negative_Numeric_Prefixed_String_Value()
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// When

var result = CommandTreeTokenizer.Tokenize(new[] { "-6..2 " });
Assert.Empty(result.Remaining);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Then

var t = Assert.Single(result.Tokens);
t.TokenKind.ShouldBe(CommandTreeToken.Kind.String);
t.IsGrouped.ShouldBe(false);
t.Position.ShouldBe(0);
t.Value.ShouldBe("-6..2");
t.Representation.ShouldBe("-6..2");
}
}
}