Skip to content

Commit af14e6d

Browse files
Suggest directive - first pass
1 parent 862ef20 commit af14e6d

File tree

11 files changed

+339
-26
lines changed

11 files changed

+339
-26
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using System;
2+
using System.Diagnostics.CodeAnalysis;
3+
using System.Threading.Tasks;
4+
using CommandDotNet.Parsing;
5+
using CommandDotNet.Tests.FeatureTests.Suggestions;
6+
using CommandDotNet.TestTools.Scenarios;
7+
using Xunit;
8+
using Xunit.Abstractions;
9+
using static System.Environment;
10+
11+
namespace CommandDotNet.Tests.FeatureTests.SuggestDirective;
12+
13+
public class SuggestDirectiveTests
14+
{
15+
public SuggestDirectiveTests(ITestOutputHelper output)
16+
{
17+
Ambient.Output = output;
18+
}
19+
20+
/* Test list:
21+
* - operands
22+
* - extra operand
23+
* - spaces
24+
* - after argument
25+
* - after partial **
26+
* - FileInfo
27+
* - file names
28+
* - DirectoryInfo
29+
* - directory names
30+
* - after argument separator
31+
* ? - how to know if after arg separator vs looking for options?
32+
* - response files
33+
* - file names
34+
* - directory names
35+
* - clubbed options
36+
*
37+
* - check position argument
38+
* ? - is this used commonly? The tests in System.CommandLine all
39+
* seem to have the position as the end of the string.
40+
*
41+
* - check feature list for other considerations
42+
*/
43+
44+
[SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")]
45+
46+
[Theory]
47+
[InlineData(
48+
"command - includes subcommands, options and next operand allowed values",
49+
"", "--togo/nClosed/nOpened/nOrder/nReserve")]
50+
[InlineData("command - invalid name", "Blah", "")]
51+
[InlineData("command - partial name", "Or", "Order")]
52+
[InlineData("subcommand", "Order ", "--juices/n--water/nBreakfast/nDinner/nLunch")]
53+
[InlineData("subcommand - not returned when not available", "Opened", "--togo")]
54+
[InlineData("option - show allowed values", "Reserve --meal", "Breakfast/nDinner/nLunch")]
55+
[InlineData("option - option prefix shows only options 1", "Order --", "--juices/n--water")]
56+
[InlineData("option - option prefix shows only options 2", "Order -", "--juices/n--water")]
57+
[InlineData("option - option prefix shows only options 3", "Order /", "/juices/n/water")]
58+
[InlineData("option - does not show already provided option", "Order --water --", "--juices")]
59+
[InlineData("option - does not show already provided option using short name", "Order -w --", "--juices")]
60+
[InlineData("option - partial name", "Order --jui", "--juices")]
61+
[InlineData("option - partial name with backslash", "Order /jui", "/juices")]
62+
[InlineData("option - partial allowed value", "Reserve --meal Br", "Breakfast")]
63+
[InlineData("option - trailing space", "Order --juices", "Apple/nBanana/nCherry")]
64+
[InlineData("operand - partial allowed value", "Op", "Opened")]
65+
[InlineData("typo before request for autocompletion 1", "Or --jui", "", 1)]
66+
[InlineData("typo before request for autocompletion 2", "Reserv --meal Br", "", 1)]
67+
[InlineData("typo before request for autocompletion 3", "Reserve --mea Br", "", 1)]
68+
public void Suggest(string scenario, string input, string expected, int exitCode = 0)
69+
{
70+
new AppRunner<DinerApp>()
71+
.UseSuggestDirective_Experimental()
72+
.Verify(new Scenario
73+
{
74+
When = { Args = $"[suggest] {input}"},
75+
Then =
76+
{
77+
Output = NewLine == "/n" ? expected : expected.Replace("/n", NewLine),
78+
ExitCode = exitCode
79+
}
80+
});
81+
}
82+
83+
[Fact]
84+
public void Suggest_works_with_default_middleware()
85+
{
86+
var expected = "--togo/n--version/nClosed/nOpened/nOrder/nReserve";
87+
new AppRunner<DinerApp>()
88+
.UseDefaultMiddleware()
89+
.UseCommandLogger()
90+
.UseSuggestDirective_Experimental()
91+
.Verify(new Scenario
92+
{
93+
When = { Args = "[suggest]"},
94+
Then =
95+
{
96+
Output = NewLine == "/n" ? expected : expected.Replace("/n", NewLine)
97+
}
98+
});
99+
}
100+
101+
public class DinerApp
102+
{
103+
public enum Status{ Opened, Closed }
104+
105+
public enum PartySize{one,two,three,four,five,six,seven,eight,nine,ten}
106+
107+
public Task<int> Interceptor(InterceptorExecutionDelegate next, IConsole console, [Option] bool togo)
108+
{
109+
console.WriteLine("DinerApp.Interceptor");
110+
return Task.FromResult(0);
111+
}
112+
113+
[DefaultCommand]
114+
public void Default(IConsole console, Status status)
115+
{
116+
console.WriteLine("DinerApp.Default");
117+
}
118+
119+
public void Reserve(IConsole console,
120+
[Operand] PartySize partySize, [Operand] string name,
121+
[Operand] DateOnly date, [Operand] TimeOnly time, [Option] Meal meal)
122+
{
123+
console.WriteLine("DinerApp.Reserve");
124+
}
125+
126+
public void Order(IConsole console,
127+
[Operand] Meal meal, [Operand] Main main, [Operand] Vegetable vegetable, [Operand] Fruit fruit,
128+
[Option('w')] bool water, [Option] Fruit juices)
129+
{
130+
console.WriteLine("DinerApp.Order");
131+
}
132+
}
133+
}

CommandDotNet.Tests/FeatureTests/Suggestions/CafeApp.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,16 @@ namespace CommandDotNet.Tests.FeatureTests.Suggestions
55

66
public enum Fruit { Apple, Banana, Cherry }
77
public enum Vegetable { Asparagus, Broccoli, Carrot }
8+
public enum Main { Chicken, Steak, Fish, Veggie}
89
public enum Meal { Breakfast, Lunch, Dinner }
910

1011
public class CafeApp
1112
{
12-
public void Eat(IConsole console,
13+
public void Eat(
1314
[Operand] Meal meal,
1415
[Option] Vegetable vegetable,
1516
[Option] Fruit fruit)
1617
{
17-
console.Out.WriteLine($"{nameof(meal)} :{meal}");
18-
console.Out.WriteLine($"{nameof(fruit)} :{fruit}");
19-
console.Out.WriteLine($"{nameof(vegetable)}:{vegetable}");
2018
}
2119
}
2220
}

CommandDotNet/AppRunnerConfigExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using CommandDotNet.Diagnostics;
99
using CommandDotNet.Execution;
1010
using CommandDotNet.Localization;
11+
using CommandDotNet.Parsing;
1112
using CommandDotNet.Parsing.Typos;
1213
using CommandDotNet.Prompts;
1314
using CommandDotNet.Tokens;
@@ -71,6 +72,9 @@ static void Register(bool exclude, string paramName, Action register)
7172
()=> appRunner.UseTypoSuggestions());
7273
return appRunner;
7374
}
75+
76+
public static AppRunner UseAutoSuggest_Experimental(this AppRunner appRunner) =>
77+
appRunner.UseSuggestDirective_Experimental();
7478

7579
/// <summary>When an invalid arguments is entered, suggests context based alternatives</summary>
7680
public static AppRunner UseTypoSuggestions(this AppRunner appRunner, int maxSuggestionCount = 5)

CommandDotNet/Execution/MiddlewareSteps.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,20 @@ public static class DependencyResolver
3737
public static MiddlewareStep ParseInput { get; } =
3838
new(MiddlewareStages.ParseInput, 0);
3939

40+
public static class AutoSuggest
41+
{
42+
/// <summary>
43+
/// Runs after <see cref="ParseInput"/> to suggest next possible argument or value
44+
/// </summary>
45+
public static MiddlewareStep Directive { get; } = ParseInput + 1000;
46+
}
47+
4048
/// <summary>
4149
/// Runs after <see cref="ParseInput"/> to respond to parse errors
4250
/// </summary>
43-
public static MiddlewareStep TypoSuggest { get; } = ParseInput + 1000;
51+
public static MiddlewareStep TypoSuggest { get; } = AutoSuggest.Directive + 1000;
4452

45-
public static MiddlewareStep AssembleInvocationPipeline { get; } = ParseInput + 2000;
53+
public static MiddlewareStep AssembleInvocationPipeline { get; } = TypoSuggest + 1000;
4654

4755
/// <summary>Runs before <see cref="Help"/> to ensure default values are included in the help output</summary>
4856
public static MiddlewareStep Version { get; } = Help.CheckIfShouldShowHelp - 2000;

CommandDotNet/Parsing/CommandParser.OperandQueue.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public OperandQueue(IEnumerable<Operand> operands)
1919
public bool TryDequeue([NotNullWhen(true)] out Operand? operand)
2020
{
2121
operand = Dequeue();
22-
return operand is { };
22+
return operand is not null;
2323
}
2424

2525
public Operand? Dequeue()

CommandDotNet/Parsing/CommandParser.ParseContext.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using CommandDotNet.Extensions;
34
using CommandDotNet.Tokens;
45

56
namespace CommandDotNet.Parsing
@@ -32,6 +33,7 @@ public Command Command
3233
}
3334
}
3435

36+
public int TokensEvaluated { get; set; }
3537

3638
public bool SubcommandsAreAllowed { get; private set; } = true;
3739

@@ -70,6 +72,20 @@ public void ClearOption()
7072
}
7173

7274
public void CommandArgumentParsed() => SubcommandsAreAllowed = false;
75+
76+
public ParseResult ToParseResult() =>
77+
ParserError is null
78+
? new ParseResult(
79+
Command,
80+
RemainingOperands.ToReadOnlyCollection(),
81+
CommandContext.Tokens.Separated,
82+
Operands.Dequeue(),
83+
TokensEvaluated,
84+
!SubcommandsAreAllowed)
85+
: new ParseResult(ParserError,
86+
Operands.Dequeue(),
87+
TokensEvaluated,
88+
!SubcommandsAreAllowed);
7389
}
7490
}
7591
}

CommandDotNet/Parsing/CommandParser.cs

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,15 @@ internal static Task<int> ParseInputMiddleware(CommandContext commandContext, Ex
1818
: null;
1919
var parseContext = new ParseContext(commandContext, new Queue<Token>(commandContext.Tokens.Arguments), separator);
2020
ParseCommand(commandContext, parseContext);
21-
if (parseContext.ParserError is { })
22-
{
23-
commandContext.ParseResult = new ParseResult(parseContext.ParserError);
24-
}
21+
commandContext.ParseResult = parseContext.ToParseResult();
2522
return next(commandContext);
2623
}
2724

2825
private static void ParseCommand(CommandContext commandContext, ParseContext parseContext)
2926
{
3027
foreach (var token in commandContext.Tokens.Arguments)
3128
{
29+
parseContext.TokensEvaluated++;
3230
switch (token.TokenType)
3331
{
3432
case TokenType.Argument:
@@ -68,11 +66,6 @@ private static void ParseCommand(CommandContext commandContext, ParseContext par
6866
.TakeWhile(_ => parseContext.ParserError is null)
6967
.ForEach(t => ParseValue(parseContext, t, operandsOnly:true));
7068
}
71-
72-
commandContext.ParseResult = new ParseResult(
73-
parseContext.Command,
74-
parseContext.RemainingOperands.ToReadOnlyCollection(),
75-
commandContext.Tokens.Separated);
7669
}
7770

7871
private static void ParseValue(ParseContext parseContext, Token token, bool operandsOnly = false)
@@ -150,7 +143,8 @@ private static bool TryParseOption(ParseContext parseContext, Token token)
150143
if (node is Command)
151144
{
152145
var suggestion = parseContext.CommandContext.Original.Args.ToCsv(" ").Replace(token.RawValue, optionName);
153-
parseContext.ParserError = new UnrecognizedArgumentParseError(parseContext.Command, token, optionPrefix,
146+
parseContext.ParserError = new UnrecognizedArgumentParseError(
147+
parseContext.Command, token, optionPrefix, null,
154148
Resources.A.Parse_Intended_command_instead_of_option(token.RawValue, optionName, suggestion));
155149
return true;
156150
}
@@ -275,10 +269,11 @@ private static void ParseOperand(ParseContext parseContext, Token token)
275269
{
276270
if (parseContext.Operands.TryDequeue(out var operand))
277271
{
278-
var currentOperand = operand!;
279-
if (ValueIsAllowed(parseContext, currentOperand, token))
272+
// do not combine with the above if statement
273+
// ValueIsAllowed will set the ParserError if the value is not allowed
274+
if (ValueIsAllowed(parseContext, operand, token))
280275
{
281-
currentOperand
276+
operand
282277
.GetAlreadyParsedValues()
283278
.Add(new ValueFromToken(token.Value, token, null));
284279
parseContext.CommandArgumentParsed();
@@ -291,7 +286,8 @@ private static void ParseOperand(ParseContext parseContext, Token token)
291286
}
292287
else
293288
{
294-
parseContext.ParserError = new UnrecognizedArgumentParseError(parseContext.Command, token, null,
289+
parseContext.ParserError = new UnrecognizedArgumentParseError(
290+
parseContext.Command, token, null, operand,
295291
Resources.A.Parse_Unrecognized_command_or_argument(token.RawValue));
296292
}
297293
}

CommandDotNet/Parsing/ParseResult.cs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,23 @@ public class ParseResult : IIndentableToString
1414
/// <summary>The command that addressed by the command line arguments</summary>
1515
public Command TargetCommand { get; }
1616

17+
/// <summary>
18+
/// The next operand that could receive a value.
19+
/// If the operand is a list value, it may already have values assigned.
20+
/// </summary>
21+
public Operand? NextAvailableOperand { get; }
22+
23+
/// <summary>
24+
/// The count of tokens evaluated.
25+
/// If there was an error evaluating the token, it's count is included.
26+
/// </summary>
27+
public int TokensEvaluatedCount { get; }
28+
29+
/// <summary>
30+
/// True if a command was identified. No subcommands could be targets at this point.
31+
/// </summary>
32+
public bool IsCommandIdentified { get; }
33+
1734
/// <summary>
1835
/// If extra operands were provided and <see cref="ParseAppSettings.IgnoreUnexpectedOperands"/> is true,
1936
/// The extra operands will be stored in the <see cref="RemainingOperands"/> collection.
@@ -43,16 +60,28 @@ public bool HelpWasRequested() =>
4360

4461
public ParseResult(Command command,
4562
IReadOnlyCollection<Token> remainingOperands,
46-
IReadOnlyCollection<Token> separatedArguments)
63+
IReadOnlyCollection<Token> separatedArguments,
64+
Operand? nextAvailableOperand,
65+
int tokensEvaluatedCount,
66+
bool isCommandIdentified)
4767
{
4868
TargetCommand = command ?? throw new ArgumentNullException(nameof(command));
4969
RemainingOperands = remainingOperands.ToArgsArray();
5070
SeparatedArguments = separatedArguments.ToArgsArray();
71+
NextAvailableOperand = nextAvailableOperand;
72+
TokensEvaluatedCount = tokensEvaluatedCount;
73+
IsCommandIdentified = isCommandIdentified;
5174
}
5275

53-
public ParseResult(IParseError error)
76+
public ParseResult(IParseError error,
77+
Operand? nextAvailableOperand,
78+
int tokensEvaluatedCount,
79+
bool isCommandIdentified)
5480
{
5581
ParseError = error ?? throw new ArgumentNullException(nameof(error));
82+
NextAvailableOperand = nextAvailableOperand;
83+
TokensEvaluatedCount = tokensEvaluatedCount;
84+
IsCommandIdentified = isCommandIdentified;
5685
TargetCommand = error.Command;
5786
RemainingOperands = Array.Empty<string>();
5887
SeparatedArguments = Array.Empty<string>();
@@ -69,7 +98,9 @@ public string ToString(Indent indent)
6998
$"{indent}{nameof(TargetCommand)}:{TargetCommand}{NewLine}" +
7099
$"{indent}{nameof(RemainingOperands)}:{RemainingOperands.ToCsv()}{NewLine}" +
71100
$"{indent}{nameof(SeparatedArguments)}:{SeparatedArguments.ToCsv()}{NewLine}" +
72-
$"{indent}{nameof(ParseError)}:{ParseError?.Message}";
101+
$"{indent}{nameof(NextAvailableOperand)}:{NextAvailableOperand}{NewLine}" +
102+
$"{indent}{nameof(ParseError)}:" +
103+
(ParseError is null ? null : $"<{ParseError.GetType().Name}> {ParseError.Message}");
73104
}
74105
}
75106
}

0 commit comments

Comments
 (0)