From 001dd30378ef75f690cdaf4e38f1d6ea45b72054 Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Fri, 13 Mar 2026 10:46:44 -0700 Subject: [PATCH 1/2] fix #2760 --- ...ommandLine_api_is_not_changed.approved.txt | 1 + .../ParserTests.CaptureRemainingTokens.cs | 261 ++++++++++++++++++ src/System.CommandLine/Argument.cs | 15 + .../Parsing/ParseOperation.cs | 40 ++- 4 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 src/System.CommandLine.Tests/ParserTests.CaptureRemainingTokens.cs diff --git a/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt b/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt index 6f542d6fe6..e43f76927e 100644 --- a/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt +++ b/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt @@ -1,6 +1,7 @@ System.CommandLine public abstract class Argument : Symbol public ArgumentArity Arity { get; set; } + public System.Boolean CaptureRemainingTokens { get; set; } public System.Collections.Generic.List>> CompletionSources { get; } public System.Boolean HasDefaultValue { get; } public System.String HelpName { get; set; } diff --git a/src/System.CommandLine.Tests/ParserTests.CaptureRemainingTokens.cs b/src/System.CommandLine.Tests/ParserTests.CaptureRemainingTokens.cs new file mode 100644 index 0000000000..9ee19b14f2 --- /dev/null +++ b/src/System.CommandLine.Tests/ParserTests.CaptureRemainingTokens.cs @@ -0,0 +1,261 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.CommandLine.Tests.Utility; +using FluentAssertions; +using FluentAssertions.Execution; +using Xunit; + +namespace System.CommandLine.Tests +{ + public partial class ParserTests + { + public class CaptureRemainingTokens + { + [Fact] + public void Option_like_tokens_after_capturing_argument_are_captured() + { + var option = new Option("--source"); + var toolName = new Argument("toolName"); + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + option, + toolName, + toolArgs + }; + + var result = command.Parse("--source https://nuget.org myTool -a 1 --help"); + + using var _ = new AssertionScope(); + result.GetValue(option).Should().Be("https://nuget.org"); + result.GetValue(toolName).Should().Be("myTool"); + result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("-a", "1", "--help"); + result.Errors.Should().BeEmpty(); + result.UnmatchedTokens.Should().BeEmpty(); + } + + [Fact] + public void Known_options_after_capturing_argument_are_captured() + { + var verbose = new Option("--verbose"); + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + verbose, + toolArgs + }; + + var result = command.Parse("foo --verbose bar"); + + using var _ = new AssertionScope(); + result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("foo", "--verbose", "bar"); + result.GetValue(verbose).Should().BeFalse(); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void Tokens_matching_subcommand_names_are_captured() + { + var sub = new Command("sub"); + var toolName = new Argument("toolName"); + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + sub, + toolName, + toolArgs + }; + + var result = command.Parse("myTool sub --flag"); + + using var _ = new AssertionScope(); + result.GetValue(toolName).Should().Be("myTool"); + result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("sub", "--flag"); + result.UnmatchedTokens.Should().BeEmpty(); + } + + [Fact] + public void Options_before_capturing_argument_are_parsed_normally() + { + var source = new Option("--source"); + var toolName = new Argument("toolName"); + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + source, + toolName, + toolArgs + }; + + var result = command.Parse("--source https://nuget.org myTool --help"); + + using var _ = new AssertionScope(); + result.GetValue(source).Should().Be("https://nuget.org"); + result.GetValue(toolName).Should().Be("myTool"); + result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("--help"); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void Double_dash_before_capturing_argument_works() + { + var option = new Option("--source"); + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + option, + toolArgs + }; + + var result = command.Parse("--source foo -- --help --version"); + + using var _ = new AssertionScope(); + result.GetValue(option).Should().Be("foo"); + result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("--help", "--version"); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void Double_dash_during_capture_is_captured() + { + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + toolArgs + }; + + var result = command.Parse("foo -- --help"); + + using var _ = new AssertionScope(); + result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("foo", "--", "--help"); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void Non_capturing_arguments_are_unaffected() + { + var option = new Option("--verbose"); + var arg = new Argument("arg"); + var command = new RootCommand + { + option, + arg + }; + + var result = command.Parse("foo --verbose bar"); + + using var _ = new AssertionScope(); + result.GetValue(option).Should().Be(true); + result.GetValue(arg).Should().BeEquivalentSequenceTo("foo", "bar"); + } + + [Fact] + public void Arity_limits_are_still_respected() + { + var toolArg = new Argument("toolArg") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + toolArg + }; + + var result = command.Parse("first --extra"); + + using var _ = new AssertionScope(); + result.GetValue(toolArg).Should().Be("first"); + result.UnmatchedTokens.Should().BeEquivalentTo("--extra"); + } + + [Fact] + public void Empty_input_with_capturing_argument_produces_no_errors_when_arity_allows() + { + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + toolArgs + }; + + var result = command.Parse(""); + + using var _ = new AssertionScope(); + result.GetValue(toolArgs).Should().BeEmpty(); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void Option_with_value_syntax_is_captured_as_single_token() + { + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + toolArgs + }; + + var result = command.Parse("--key=value -x:y"); + + using var _ = new AssertionScope(); + result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("--key=value", "-x:y"); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void Capturing_argument_on_subcommand_works() + { + var toolName = new Argument("toolName"); + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var exec = new Command("exec") + { + toolName, + toolArgs + }; + exec.Options.Add(new Option("--verbose")); + var command = new RootCommand + { + exec + }; + + var result = command.Parse("exec foo --verbose bar"); + + using var _ = new AssertionScope(); + result.GetValue(toolName).Should().Be("foo"); + result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("--verbose", "bar"); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void Trailing_double_dash_is_captured() + { + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + toolArgs + }; + + var result = command.Parse("foo --"); + + using var _ = new AssertionScope(); + result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("foo", "--"); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void Leading_known_option_is_parsed_normally_when_capture_is_first_argument() + { + var verbose = new Option("--verbose"); + var toolArgs = new Argument("toolArgs") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + verbose, + toolArgs + }; + + var result = command.Parse("--verbose foo --unknown"); + + using var _ = new AssertionScope(); + result.GetValue(verbose).Should().BeTrue(); + result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("foo", "--unknown"); + result.Errors.Should().BeEmpty(); + } + } + } +} diff --git a/src/System.CommandLine/Argument.cs b/src/System.CommandLine/Argument.cs index f1c915045e..f3566fa7bb 100644 --- a/src/System.CommandLine/Argument.cs +++ b/src/System.CommandLine/Argument.cs @@ -44,6 +44,21 @@ public ArgumentArity Arity set => _arity = value; } + /// + /// Gets or sets a value indicating whether this argument captures all remaining tokens. + /// + /// + /// When set to , once the parser starts filling this argument, + /// all subsequent tokens are consumed as argument values regardless of whether they + /// match known options or commands. This behaves as if -- were implicitly + /// inserted before the argument's first value. + /// + /// An argument with this property set to must be the last + /// argument defined on its parent command. + /// + /// + public bool CaptureRemainingTokens { get; set; } + /// /// Gets or sets the placeholder name shown in usage help for the argument's value. /// The value will be wrapped in angle brackets (< and >). diff --git a/src/System.CommandLine/Parsing/ParseOperation.cs b/src/System.CommandLine/Parsing/ParseOperation.cs index 0ae9c4bcf3..d2ca948ec3 100644 --- a/src/System.CommandLine/Parsing/ParseOperation.cs +++ b/src/System.CommandLine/Parsing/ParseOperation.cs @@ -98,7 +98,29 @@ private void ParseCommandChildren() while (More(out TokenType currentTokenType)) { - if (currentTokenType == TokenType.Command) + // Advance past arguments whose arity has been filled so that + // IsCapturingRemainingTokens checks the correct argument. + var arguments = _innermostCommandResult.Command.Arguments; + while (currentArgumentIndex < arguments.Count && + currentArgumentCount >= arguments[currentArgumentIndex].Arity.MaximumNumberOfValues) + { + currentArgumentCount = 0; + currentArgumentIndex++; + } + + // When the next argument to fill captures remaining tokens, + // consume tokens regardless of type. DoubleDash tokens encountered + // before capture starts (in this dispatch) are still handled normally, + // but once inside ParseCommandArguments they are captured as values. + // For non-Argument tokens (options, commands), only capture after at least one + // positional argument has been filled, so that leading options are parsed normally. + if (currentTokenType != TokenType.DoubleDash && + IsCapturingRemainingTokens(currentArgumentIndex) && + (currentTokenType == TokenType.Argument || currentArgumentIndex > 0 || currentArgumentCount > 0)) + { + ParseCommandArguments(ref currentArgumentCount, ref currentArgumentIndex, captureRemaining: true); + } + else if (currentTokenType == TokenType.Command) { ParseSubcommand(); } @@ -118,9 +140,18 @@ private void ParseCommandChildren() } } - private void ParseCommandArguments(ref int currentArgumentCount, ref int currentArgumentIndex) + private bool IsCapturingRemainingTokens(int currentArgumentIndex) { - while (More(out TokenType currentTokenType) && currentTokenType == TokenType.Argument) + var arguments = _innermostCommandResult.Command.Arguments; + return currentArgumentIndex < arguments.Count && + arguments[currentArgumentIndex].CaptureRemainingTokens; + } + + private void ParseCommandArguments(ref int currentArgumentCount, ref int currentArgumentIndex, bool captureRemaining = false) + { + while (More(out TokenType currentTokenType) && + (currentTokenType == TokenType.Argument || + captureRemaining)) { while (_innermostCommandResult.Command.HasArguments && currentArgumentIndex < _innermostCommandResult.Command.Arguments.Count) { @@ -128,9 +159,8 @@ private void ParseCommandArguments(ref int currentArgumentCount, ref int current if (currentArgumentCount < argument.Arity.MaximumNumberOfValues) { - if (CurrentToken.Symbol is null) + if (captureRemaining || CurrentToken.Symbol is null) { - // update the token with missing information now, so later stages don't need to modify it CurrentToken.Symbol = argument; } From e744336480aa7ea9a5103a347df70800d741a507 Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Fri, 13 Mar 2026 12:35:46 -0700 Subject: [PATCH 2/2] account for scalar arguments --- .../ParserTests.CaptureRemainingTokens.cs | 41 +++++++++++++++++++ .../Parsing/ParseOperation.cs | 7 ++++ 2 files changed, 48 insertions(+) diff --git a/src/System.CommandLine.Tests/ParserTests.CaptureRemainingTokens.cs b/src/System.CommandLine.Tests/ParserTests.CaptureRemainingTokens.cs index 9ee19b14f2..ee0402fbe0 100644 --- a/src/System.CommandLine.Tests/ParserTests.CaptureRemainingTokens.cs +++ b/src/System.CommandLine.Tests/ParserTests.CaptureRemainingTokens.cs @@ -256,6 +256,47 @@ public void Leading_known_option_is_parsed_normally_when_capture_is_first_argume result.GetValue(toolArgs).Should().BeEquivalentSequenceTo("foo", "--unknown"); result.Errors.Should().BeEmpty(); } + + [Fact] + public void Scalar_capture_escapes_one_token_then_resumes_normal_parsing() + { + var verbose = new Option("--verbose"); + var first = new Argument("first"); + var target = new Argument("target") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + verbose, + first, + target + }; + + var result = command.Parse("foo --verbose --verbose"); + + using var _ = new AssertionScope(); + result.GetValue(first).Should().Be("foo"); + result.GetValue(target).Should().Be("--verbose"); + result.GetValue(verbose).Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void Options_after_scalar_capture_overflow_are_parsed_normally() + { + var verbose = new Option("--verbose"); + var target = new Argument("target") { CaptureRemainingTokens = true }; + var command = new RootCommand + { + verbose, + target + }; + + var result = command.Parse("hello --verbose"); + + using var _ = new AssertionScope(); + result.GetValue(target).Should().Be("hello"); + result.GetValue(verbose).Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } } } } diff --git a/src/System.CommandLine/Parsing/ParseOperation.cs b/src/System.CommandLine/Parsing/ParseOperation.cs index d2ca948ec3..79bc2679eb 100644 --- a/src/System.CommandLine/Parsing/ParseOperation.cs +++ b/src/System.CommandLine/Parsing/ParseOperation.cs @@ -194,6 +194,13 @@ private void ParseCommandArguments(ref int currentArgumentCount, ref int current if (currentArgumentCount == 0) // no matching arguments found { + if (captureRemaining) + { + // Return to ParseCommandChildren so that overflow tokens + // are dispatched normally (e.g. as options or subcommands). + break; + } + AddCurrentTokenToUnmatched(); Advance(); }