Skip to content

Commit 001dd30

Browse files
Jon Sequeirajonsequitur
authored andcommitted
fix #2760
1 parent 0b720d0 commit 001dd30

File tree

4 files changed

+312
-5
lines changed

4 files changed

+312
-5
lines changed

src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
System.CommandLine
22
public abstract class Argument : Symbol
33
public ArgumentArity Arity { get; set; }
4+
public System.Boolean CaptureRemainingTokens { get; set; }
45
public System.Collections.Generic.List<System.Func<System.CommandLine.Completions.CompletionContext,System.Collections.Generic.IEnumerable<System.CommandLine.Completions.CompletionItem>>> CompletionSources { get; }
56
public System.Boolean HasDefaultValue { get; }
67
public System.String HelpName { get; set; }
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright (c) .NET Foundation and contributors. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System.CommandLine.Tests.Utility;
5+
using FluentAssertions;
6+
using FluentAssertions.Execution;
7+
using Xunit;
8+
9+
namespace System.CommandLine.Tests
10+
{
11+
public partial class ParserTests
12+
{
13+
public class CaptureRemainingTokens
14+
{
15+
[Fact]
16+
public void Option_like_tokens_after_capturing_argument_are_captured()
17+
{
18+
var option = new Option<string>("--source");
19+
var toolName = new Argument<string>("toolName");
20+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
21+
var command = new RootCommand
22+
{
23+
option,
24+
toolName,
25+
toolArgs
26+
};
27+
28+
var result = command.Parse("--source https://nuget.org myTool -a 1 --help");
29+
30+
using var _ = new AssertionScope();
31+
result.GetValue(option).Should().Be("https://nuget.org");
32+
result.GetValue(toolName).Should().Be("myTool");
33+
result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("-a", "1", "--help");
34+
result.Errors.Should().BeEmpty();
35+
result.UnmatchedTokens.Should().BeEmpty();
36+
}
37+
38+
[Fact]
39+
public void Known_options_after_capturing_argument_are_captured()
40+
{
41+
var verbose = new Option<bool>("--verbose");
42+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
43+
var command = new RootCommand
44+
{
45+
verbose,
46+
toolArgs
47+
};
48+
49+
var result = command.Parse("foo --verbose bar");
50+
51+
using var _ = new AssertionScope();
52+
result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("foo", "--verbose", "bar");
53+
result.GetValue(verbose).Should().BeFalse();
54+
result.Errors.Should().BeEmpty();
55+
}
56+
57+
[Fact]
58+
public void Tokens_matching_subcommand_names_are_captured()
59+
{
60+
var sub = new Command("sub");
61+
var toolName = new Argument<string>("toolName");
62+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
63+
var command = new RootCommand
64+
{
65+
sub,
66+
toolName,
67+
toolArgs
68+
};
69+
70+
var result = command.Parse("myTool sub --flag");
71+
72+
using var _ = new AssertionScope();
73+
result.GetValue(toolName).Should().Be("myTool");
74+
result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("sub", "--flag");
75+
result.UnmatchedTokens.Should().BeEmpty();
76+
}
77+
78+
[Fact]
79+
public void Options_before_capturing_argument_are_parsed_normally()
80+
{
81+
var source = new Option<string>("--source");
82+
var toolName = new Argument<string>("toolName");
83+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
84+
var command = new RootCommand
85+
{
86+
source,
87+
toolName,
88+
toolArgs
89+
};
90+
91+
var result = command.Parse("--source https://nuget.org myTool --help");
92+
93+
using var _ = new AssertionScope();
94+
result.GetValue(source).Should().Be("https://nuget.org");
95+
result.GetValue(toolName).Should().Be("myTool");
96+
result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("--help");
97+
result.Errors.Should().BeEmpty();
98+
}
99+
100+
[Fact]
101+
public void Double_dash_before_capturing_argument_works()
102+
{
103+
var option = new Option<string>("--source");
104+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
105+
var command = new RootCommand
106+
{
107+
option,
108+
toolArgs
109+
};
110+
111+
var result = command.Parse("--source foo -- --help --version");
112+
113+
using var _ = new AssertionScope();
114+
result.GetValue(option).Should().Be("foo");
115+
result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("--help", "--version");
116+
result.Errors.Should().BeEmpty();
117+
}
118+
119+
[Fact]
120+
public void Double_dash_during_capture_is_captured()
121+
{
122+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
123+
var command = new RootCommand
124+
{
125+
toolArgs
126+
};
127+
128+
var result = command.Parse("foo -- --help");
129+
130+
using var _ = new AssertionScope();
131+
result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("foo", "--", "--help");
132+
result.Errors.Should().BeEmpty();
133+
}
134+
135+
[Fact]
136+
public void Non_capturing_arguments_are_unaffected()
137+
{
138+
var option = new Option<bool>("--verbose");
139+
var arg = new Argument<string[]>("arg");
140+
var command = new RootCommand
141+
{
142+
option,
143+
arg
144+
};
145+
146+
var result = command.Parse("foo --verbose bar");
147+
148+
using var _ = new AssertionScope();
149+
result.GetValue(option).Should().Be(true);
150+
result.GetValue(arg).Should().BeEquivalentSequenceTo("foo", "bar");
151+
}
152+
153+
[Fact]
154+
public void Arity_limits_are_still_respected()
155+
{
156+
var toolArg = new Argument<string>("toolArg") { CaptureRemainingTokens = true };
157+
var command = new RootCommand
158+
{
159+
toolArg
160+
};
161+
162+
var result = command.Parse("first --extra");
163+
164+
using var _ = new AssertionScope();
165+
result.GetValue(toolArg).Should().Be("first");
166+
result.UnmatchedTokens.Should().BeEquivalentTo("--extra");
167+
}
168+
169+
[Fact]
170+
public void Empty_input_with_capturing_argument_produces_no_errors_when_arity_allows()
171+
{
172+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
173+
var command = new RootCommand
174+
{
175+
toolArgs
176+
};
177+
178+
var result = command.Parse("");
179+
180+
using var _ = new AssertionScope();
181+
result.GetValue(toolArgs).Should().BeEmpty();
182+
result.Errors.Should().BeEmpty();
183+
}
184+
185+
[Fact]
186+
public void Option_with_value_syntax_is_captured_as_single_token()
187+
{
188+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
189+
var command = new RootCommand
190+
{
191+
toolArgs
192+
};
193+
194+
var result = command.Parse("--key=value -x:y");
195+
196+
using var _ = new AssertionScope();
197+
result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("--key=value", "-x:y");
198+
result.Errors.Should().BeEmpty();
199+
}
200+
201+
[Fact]
202+
public void Capturing_argument_on_subcommand_works()
203+
{
204+
var toolName = new Argument<string>("toolName");
205+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
206+
var exec = new Command("exec")
207+
{
208+
toolName,
209+
toolArgs
210+
};
211+
exec.Options.Add(new Option<bool>("--verbose"));
212+
var command = new RootCommand
213+
{
214+
exec
215+
};
216+
217+
var result = command.Parse("exec foo --verbose bar");
218+
219+
using var _ = new AssertionScope();
220+
result.GetValue(toolName).Should().Be("foo");
221+
result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("--verbose", "bar");
222+
result.Errors.Should().BeEmpty();
223+
}
224+
225+
[Fact]
226+
public void Trailing_double_dash_is_captured()
227+
{
228+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
229+
var command = new RootCommand
230+
{
231+
toolArgs
232+
};
233+
234+
var result = command.Parse("foo --");
235+
236+
using var _ = new AssertionScope();
237+
result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("foo", "--");
238+
result.Errors.Should().BeEmpty();
239+
}
240+
241+
[Fact]
242+
public void Leading_known_option_is_parsed_normally_when_capture_is_first_argument()
243+
{
244+
var verbose = new Option<bool>("--verbose");
245+
var toolArgs = new Argument<string[]>("toolArgs") { CaptureRemainingTokens = true };
246+
var command = new RootCommand
247+
{
248+
verbose,
249+
toolArgs
250+
};
251+
252+
var result = command.Parse("--verbose foo --unknown");
253+
254+
using var _ = new AssertionScope();
255+
result.GetValue(verbose).Should().BeTrue();
256+
result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("foo", "--unknown");
257+
result.Errors.Should().BeEmpty();
258+
}
259+
}
260+
}
261+
}

src/System.CommandLine/Argument.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@ public ArgumentArity Arity
4444
set => _arity = value;
4545
}
4646

47+
/// <summary>
48+
/// Gets or sets a value indicating whether this argument captures all remaining tokens.
49+
/// </summary>
50+
/// <remarks>
51+
/// When set to <see langword="true"/>, once the parser starts filling this argument,
52+
/// all subsequent tokens are consumed as argument values regardless of whether they
53+
/// match known options or commands. This behaves as if <c>--</c> were implicitly
54+
/// inserted before the argument's first value.
55+
/// <para>
56+
/// An argument with this property set to <see langword="true"/> must be the last
57+
/// argument defined on its parent command.
58+
/// </para>
59+
/// </remarks>
60+
public bool CaptureRemainingTokens { get; set; }
61+
4762
/// <summary>
4863
/// Gets or sets the placeholder name shown in usage help for the argument's value.
4964
/// The value will be wrapped in angle brackets (<c>&lt;</c> and <c>&gt;</c>).

src/System.CommandLine/Parsing/ParseOperation.cs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,29 @@ private void ParseCommandChildren()
9898

9999
while (More(out TokenType currentTokenType))
100100
{
101-
if (currentTokenType == TokenType.Command)
101+
// Advance past arguments whose arity has been filled so that
102+
// IsCapturingRemainingTokens checks the correct argument.
103+
var arguments = _innermostCommandResult.Command.Arguments;
104+
while (currentArgumentIndex < arguments.Count &&
105+
currentArgumentCount >= arguments[currentArgumentIndex].Arity.MaximumNumberOfValues)
106+
{
107+
currentArgumentCount = 0;
108+
currentArgumentIndex++;
109+
}
110+
111+
// When the next argument to fill captures remaining tokens,
112+
// consume tokens regardless of type. DoubleDash tokens encountered
113+
// before capture starts (in this dispatch) are still handled normally,
114+
// but once inside ParseCommandArguments they are captured as values.
115+
// For non-Argument tokens (options, commands), only capture after at least one
116+
// positional argument has been filled, so that leading options are parsed normally.
117+
if (currentTokenType != TokenType.DoubleDash &&
118+
IsCapturingRemainingTokens(currentArgumentIndex) &&
119+
(currentTokenType == TokenType.Argument || currentArgumentIndex > 0 || currentArgumentCount > 0))
120+
{
121+
ParseCommandArguments(ref currentArgumentCount, ref currentArgumentIndex, captureRemaining: true);
122+
}
123+
else if (currentTokenType == TokenType.Command)
102124
{
103125
ParseSubcommand();
104126
}
@@ -118,19 +140,27 @@ private void ParseCommandChildren()
118140
}
119141
}
120142

121-
private void ParseCommandArguments(ref int currentArgumentCount, ref int currentArgumentIndex)
143+
private bool IsCapturingRemainingTokens(int currentArgumentIndex)
122144
{
123-
while (More(out TokenType currentTokenType) && currentTokenType == TokenType.Argument)
145+
var arguments = _innermostCommandResult.Command.Arguments;
146+
return currentArgumentIndex < arguments.Count &&
147+
arguments[currentArgumentIndex].CaptureRemainingTokens;
148+
}
149+
150+
private void ParseCommandArguments(ref int currentArgumentCount, ref int currentArgumentIndex, bool captureRemaining = false)
151+
{
152+
while (More(out TokenType currentTokenType) &&
153+
(currentTokenType == TokenType.Argument ||
154+
captureRemaining))
124155
{
125156
while (_innermostCommandResult.Command.HasArguments && currentArgumentIndex < _innermostCommandResult.Command.Arguments.Count)
126157
{
127158
Argument argument = _innermostCommandResult.Command.Arguments[currentArgumentIndex];
128159

129160
if (currentArgumentCount < argument.Arity.MaximumNumberOfValues)
130161
{
131-
if (CurrentToken.Symbol is null)
162+
if (captureRemaining || CurrentToken.Symbol is null)
132163
{
133-
// update the token with missing information now, so later stages don't need to modify it
134164
CurrentToken.Symbol = argument;
135165
}
136166

0 commit comments

Comments
 (0)