Skip to content

Commit 20ef1b4

Browse files
authored
enable ParseArgument<T> to partially consume tokens (#1136)
* surface parse error when IEnumerable<T> is partly successful * support for ParseArgument to pass on unconsumed tokens to the next argument * automatically pass tokens to the next argument or `UnparsedTokens` if the current `Argument` can't convert them
1 parent 8a3469a commit 20ef1b4

13 files changed

+364
-62
lines changed

src/System.CommandLine.Tests/ArgumentTests.cs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,162 @@ public void Parse_delegate_is_called_once_per_parse_operation()
506506

507507
i.Should().Be(2);
508508
}
509+
510+
[Fact]
511+
public void Custom_parser_can_pass_on_remaining_tokens()
512+
{
513+
var argument1 = new Argument<int[]>(
514+
"one",
515+
result =>
516+
{
517+
result.OnlyTake(3);
518+
519+
return new[]
520+
{
521+
int.Parse(result.Tokens[0].Value),
522+
int.Parse(result.Tokens[1].Value),
523+
int.Parse(result.Tokens[2].Value)
524+
};
525+
});
526+
var argument2 = new Argument<int[]>(
527+
"two",
528+
result => result.Tokens.Select(t => t.Value).Select(int.Parse).ToArray());
529+
var command = new RootCommand
530+
{
531+
argument1,
532+
argument2
533+
};
534+
535+
var parseResult = command.Parse("1 2 3 4 5 6 7 8");
536+
537+
parseResult.FindResultFor(argument1)
538+
.GetValueOrDefault()
539+
.Should()
540+
.BeEquivalentTo(new[] { 1, 2, 3 },
541+
options => options.WithStrictOrdering());
542+
543+
parseResult.FindResultFor(argument2)
544+
.GetValueOrDefault()
545+
.Should()
546+
.BeEquivalentTo(new[] { 4, 5, 6, 7, 8 },
547+
options => options.WithStrictOrdering());
548+
}
549+
550+
[Fact]
551+
public void When_tokens_are_passed_on_by_custom_parser_on_last_argument_then_they_become_unparsed_tokens()
552+
{
553+
554+
var argument1 = new Argument<int[]>(
555+
"one",
556+
result =>
557+
{
558+
result.OnlyTake(3);
559+
560+
return new[]
561+
{
562+
int.Parse(result.Tokens[0].Value),
563+
int.Parse(result.Tokens[1].Value),
564+
int.Parse(result.Tokens[2].Value)
565+
};
566+
});
567+
568+
var command = new RootCommand
569+
{
570+
argument1
571+
};
572+
573+
var parseResult = command.Parse("1 2 3 4 5 6 7 8");
574+
575+
parseResult.UnparsedTokens
576+
.Should()
577+
.BeEquivalentTo(new[] { "4", "5", "6", "7", "8" },
578+
options => options.WithStrictOrdering());
579+
}
580+
581+
[Fact]
582+
public void When_custom_parser_passes_on_tokens_the_argument_result_tokens_reflect_the_change()
583+
{
584+
var argument1 = new Argument<int[]>(
585+
"one",
586+
result =>
587+
{
588+
result.OnlyTake(3);
589+
590+
return new[]
591+
{
592+
int.Parse(result.Tokens[0].Value),
593+
int.Parse(result.Tokens[1].Value),
594+
int.Parse(result.Tokens[2].Value)
595+
};
596+
});
597+
var argument2 = new Argument<int[]>(
598+
"two",
599+
result => result.Tokens.Select(t => t.Value).Select(int.Parse).ToArray());
600+
var command = new RootCommand
601+
{
602+
argument1,
603+
argument2
604+
};
605+
606+
var parseResult = command.Parse("1 2 3 4 5 6 7 8");
607+
608+
parseResult.FindResultFor(argument1)
609+
.Tokens
610+
.Select(t => t.Value)
611+
.Should()
612+
.BeEquivalentTo(new[] { "1", "2", "3" },
613+
options => options.WithStrictOrdering());
614+
615+
parseResult.FindResultFor(argument2)
616+
.Tokens
617+
.Select(t => t.Value)
618+
.Should()
619+
.BeEquivalentTo(new[] { "4", "5", "6", "7", "8" },
620+
options => options.WithStrictOrdering());
621+
}
622+
623+
[Fact]
624+
public void OnlyTake_throws_when_called_with_a_negative_value()
625+
{
626+
var argument = new Argument<int[]>(
627+
"one",
628+
result =>
629+
{
630+
result.OnlyTake(-1);
631+
632+
return null;
633+
});
634+
635+
argument.Invoking(a => a.Parse("1 2 3"))
636+
.Should()
637+
.Throw<ArgumentOutOfRangeException>()
638+
.Which
639+
.Message
640+
.Should()
641+
.ContainAll("Value must be at least 1.", "Actual value was -1.");
642+
}
643+
644+
[Fact]
645+
public void OnlyTake_throws_when_called_twice()
646+
{
647+
var argument = new Argument<int[]>(
648+
"one",
649+
result =>
650+
{
651+
result.OnlyTake(1);
652+
result.OnlyTake(1);
653+
654+
return null;
655+
});
656+
657+
argument.Invoking(a => a.Parse("1 2 3"))
658+
.Should()
659+
.Throw<InvalidOperationException>()
660+
.Which
661+
.Message
662+
.Should()
663+
.Be("OnlyTake can only be called once.");
664+
}
509665
}
510666

511667
protected override Symbol CreateSymbol(string name)

src/System.CommandLine.Tests/ParserTests.MultipleArguments.cs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ public void Multiple_arguments_of_unspecified_type_are_parsed_correctly()
150150
}
151151

152152
[Fact]
153-
public void arity_ambiguities_can_be_differentiated_by_type_convertibility()
153+
public void tokens_that_cannot_be_converted_by_multiple_arity_argument_flow_to_next_multiple_arity_argument()
154154
{
155155
var ints = new Argument<int[]>();
156156
var strings = new Argument<string[]>();
@@ -161,7 +161,7 @@ public void arity_ambiguities_can_be_differentiated_by_type_convertibility()
161161
strings
162162
};
163163

164-
var result = root.Parse("1 2 3 one", "two");
164+
var result = root.Parse("1 2 3 one two");
165165

166166
var _ = new AssertionScope();
167167

@@ -175,6 +175,62 @@ public void arity_ambiguities_can_be_differentiated_by_type_convertibility()
175175
.BeEquivalentTo(new[] { "one", "two" },
176176
options => options.WithStrictOrdering());
177177
}
178+
179+
[Fact]
180+
public void tokens_that_cannot_be_converted_by_multiple_arity_argument_flow_to_next_single_arity_argument()
181+
{
182+
var ints = new Argument<int[]>();
183+
var strings = new Argument<string>();
184+
185+
var root = new RootCommand
186+
{
187+
ints,
188+
strings
189+
};
190+
191+
var result = root.Parse("1 2 3 four five");
192+
193+
var _ = new AssertionScope();
194+
195+
result.ValueForArgument(ints)
196+
.Should()
197+
.BeEquivalentTo(new[] { 1, 2, 3 },
198+
options => options.WithStrictOrdering());
199+
200+
result.ValueForArgument(strings)
201+
.Should()
202+
.Be("four");
203+
204+
result.UnparsedTokens.Should()
205+
.ContainSingle()
206+
.Which
207+
.Should()
208+
.Be("five");
209+
}
210+
211+
[Fact(Skip = "https://github.com/dotnet/command-line-api/issues/1143")]
212+
public void tokens_that_cannot_be_converted_by_multiple_arity_option_flow_to_next_single_arity_argument()
213+
{
214+
var option = new Option<int[]>("-i");
215+
var argument = new Argument<string>("arg");
216+
217+
var command = new RootCommand
218+
{
219+
option,
220+
argument
221+
};
222+
223+
var result = command.Parse("-i 1 2 3 four");
224+
225+
result.FindResultFor(option)
226+
.GetValueOrDefault()
227+
.Should()
228+
.BeEquivalentTo(new[] { 1, 2, 3 }, options => options.WithStrictOrdering());
229+
230+
result.FindResultFor(argument)
231+
.Should()
232+
.Be("four");
233+
}
178234
}
179235
}
180236
}

src/System.CommandLine/Argument.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,22 +91,23 @@ internal TryConvertArgument? ConvertArguments
9191

9292
return _convertArguments;
9393

94-
bool DefaultConvert(SymbolResult symbol, out object value)
94+
bool DefaultConvert(ArgumentResult argumentResult, out object value)
9595
{
9696
switch (Arity.MaximumNumberOfValues)
9797
{
9898
case 1:
9999
value = ArgumentConverter.ConvertObject(
100100
this,
101101
ArgumentType,
102-
symbol.Tokens.Select(t => t.Value).SingleOrDefault());
102+
argumentResult.Tokens.Select(t => t.Value).SingleOrDefault());
103103
break;
104104

105105
default:
106106
value = ArgumentConverter.ConvertStrings(
107107
this,
108108
ArgumentType,
109-
symbol.Tokens.Select(t => t.Value).ToArray());
109+
argumentResult.Tokens.Select(t => t.Value).ToArray(),
110+
argumentResult);
110111
break;
111112
}
112113

@@ -170,7 +171,7 @@ private protected override string DefaultName
170171
/// to provide custom errors based on user input.
171172
/// </summary>
172173
/// <param name="validate">The delegate to validate the parsed argument.</param>
173-
public void AddValidator(ValidateSymbol<ArgumentResult> validator) => Validators.Add(validator);
174+
public void AddValidator(ValidateSymbol<ArgumentResult> validate) => Validators.Add(validate);
174175

175176
/// <summary>
176177
/// Gets the default value for the argument.

src/System.CommandLine/Binding/ArgumentConversionResult.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ private protected ArgumentConversionResult(IArgument argument)
2020

2121
internal static NoArgumentConversionResult None(IArgument argument) => new NoArgumentConversionResult(argument);
2222
}
23-
}
23+
}

src/System.CommandLine/Binding/ArgumentConverter.cs

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,31 +106,52 @@ private static ArgumentConversionResult ConvertString(
106106
public static ArgumentConversionResult ConvertStrings(
107107
IArgument argument,
108108
Type type,
109-
IReadOnlyCollection<string> arguments)
109+
IReadOnlyCollection<string> tokens,
110+
ArgumentResult? argumentResult = null)
110111
{
111112
if (type is null)
112113
{
113114
throw new ArgumentNullException(nameof(type));
114115
}
115116

116-
if (arguments is null)
117+
if (tokens is null)
117118
{
118-
throw new ArgumentNullException(nameof(arguments));
119+
throw new ArgumentNullException(nameof(tokens));
119120
}
120121

121122
var itemType = type == typeof(string)
122123
? typeof(string)
123124
: GetItemTypeIfEnumerable(type);
124125

125-
var successfulParseResults = arguments
126-
.Select(arg => ConvertString(argument, itemType, arg))
127-
.OfType<SuccessfulArgumentConversionResult>();
126+
var parseResults = tokens
127+
.Select(arg => ConvertString(argument, itemType, arg))
128+
.ToArray();
128129

129130
var list = (IList) Activator.CreateInstance(typeof(List<>).MakeGenericType(itemType));
130131

131-
foreach (var parseResult in successfulParseResults)
132+
for (var i = 0; i < parseResults.Length; i++)
132133
{
133-
list.Add(parseResult.Value);
134+
var result = parseResults[i];
135+
136+
switch (result)
137+
{
138+
case FailedArgumentTypeConversionResult _:
139+
case FailedArgumentConversionResult _:
140+
if (argumentResult is { })
141+
{
142+
argumentResult.OnlyTake(i);
143+
144+
// exit the for loop
145+
i = parseResults.Length;
146+
break;
147+
}
148+
149+
return result;
150+
151+
case SuccessfulArgumentConversionResult success:
152+
list.Add(success.Value);
153+
break;
154+
}
134155
}
135156

136157
var value = type.IsArray
@@ -179,10 +200,10 @@ private static bool HasStringTypeConverter(this Type type)
179200

180201
private static FailedArgumentConversionResult Failure(
181202
IArgument argument,
182-
Type type,
203+
Type expectedType,
183204
string value)
184205
{
185-
return new FailedArgumentTypeConversionResult(argument, type, value);
206+
return new FailedArgumentTypeConversionResult(argument, expectedType, value);
186207
}
187208

188209
public static bool CanBeBoundFromScalarValue(this Type type)

src/System.CommandLine/Binding/FailedArgumentTypeConversionResult.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ internal class FailedArgumentTypeConversionResult : FailedArgumentConversionResu
99
{
1010
internal FailedArgumentTypeConversionResult(
1111
IArgument argument,
12-
Type type,
12+
Type expectedType,
1313
string value) :
14-
base(argument, FormatErrorMessage(argument, type, value))
14+
base(argument, FormatErrorMessage(argument, expectedType, value))
1515
{
1616
}
1717

1818
private static string FormatErrorMessage(
1919
IArgument argument,
20-
Type type,
20+
Type expectedType,
2121
string value)
2222
{
2323
if (argument is Argument a &&
@@ -36,10 +36,10 @@ private static string FormatErrorMessage(
3636

3737
var alias = firstParent.Aliases.First();
3838

39-
return $"Cannot parse argument '{value}' for {symbolType} '{alias}' as expected type {type}.";
39+
return $"Cannot parse argument '{value}' for {symbolType} '{alias}' as expected type {expectedType}.";
4040
}
4141

42-
return $"Cannot parse argument '{value}' as expected type {type}.";
42+
return $"Cannot parse argument '{value}' as expected type {expectedType}.";
4343
}
4444
}
4545
}

0 commit comments

Comments
 (0)