From e092b4a60c68f50ef62f4f8f5a6bedc59f4bdb98 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Thu, 21 May 2026 04:54:44 +0300 Subject: [PATCH 1/2] Add Recursive property to Argument and support it in HelpBuilder --- src/System.CommandLine/Argument.cs | 6 ++++++ src/System.CommandLine/Help/HelpBuilder.cs | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/System.CommandLine/Argument.cs b/src/System.CommandLine/Argument.cs index f3566fa7bb..093c48e918 100644 --- a/src/System.CommandLine/Argument.cs +++ b/src/System.CommandLine/Argument.cs @@ -182,5 +182,11 @@ internal NoArgument() : base("@none") public override bool HasDefaultValue => false; } + + /// + /// Gets or sets a value indicating whether this argument is inherited by child commands. + /// + public bool Recursive { get; set; } = true; + } } diff --git a/src/System.CommandLine/Help/HelpBuilder.cs b/src/System.CommandLine/Help/HelpBuilder.cs index e2a95923eb..ac1f17c480 100644 --- a/src/System.CommandLine/Help/HelpBuilder.cs +++ b/src/System.CommandLine/Help/HelpBuilder.cs @@ -137,9 +137,12 @@ IEnumerable GetUsageParts() yield return (parentCommand is RootCommand root ? root.HelpName : null) ?? parentCommand.Name; - if (parentCommand.Arguments.Any()) + var argumentToDisplay = parentCommand == command + ? parentCommand.Arguments.Where(a => !a.Hidden).ToList() + : parentCommand.Arguments.Where(a => a.Recursive && !a.Hidden).ToList(); + if (argumentToDisplay.Any()) { - yield return FormatArgumentUsage(parentCommand.Arguments); + yield return FormatArgumentUsage(argumentToDisplay); } } @@ -168,7 +171,7 @@ private IEnumerable GetCommandArgumentRows(Command command, He command .RecurseWhileNotNull(c => c.Parents.OfType().FirstOrDefault()) .Reverse() - .SelectMany(cmd => cmd.Arguments.Where(a => !a.Hidden)) + .SelectMany(cmd => cmd.Arguments.Where(a => !a.Hidden && (cmd == command || a.Recursive))) .Select(a => GetTwoColumnRow(a, context)) .Distinct(); From f10fcc7d338694fdf489784aeced7606b48f5022 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Thu, 21 May 2026 05:34:41 +0300 Subject: [PATCH 2/2] feat: implement non-recursive parent arguments early interception and update help builder --- ...ommandLine_api_is_not_changed.approved.txt | 1 + .../Help/HelpBuilderTests.cs | 24 ++++++++++++++ src/System.CommandLine.Tests/ParserTests.cs | 20 +++++++++++ .../Parsing/ParseOperation.cs | 33 +++++++++++++++++-- 4 files changed, 76 insertions(+), 2 deletions(-) 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 c6a7ee238d..93426b92d9 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 @@ -5,6 +5,7 @@ public System.Collections.Generic.List>> CompletionSources { get; } public System.Boolean HasDefaultValue { get; } public System.String HelpName { get; set; } + public System.Boolean Recursive { get; set; } public System.Collections.Generic.List> Validators { get; } public System.Type ValueType { get; } public System.Collections.Generic.IEnumerable GetCompletions(System.CommandLine.Completions.CompletionContext context) diff --git a/src/System.CommandLine.Tests/Help/HelpBuilderTests.cs b/src/System.CommandLine.Tests/Help/HelpBuilderTests.cs index 019c4b7e3b..24bd10756e 100644 --- a/src/System.CommandLine.Tests/Help/HelpBuilderTests.cs +++ b/src/System.CommandLine.Tests/Help/HelpBuilderTests.cs @@ -211,6 +211,30 @@ public void Usage_section_for_subcommand_shows_arguments_for_subcommand_and_pare _console.ToString().Should().Contain(expected); } + [Fact] + public void Usage_section_for_subcommand_omits_non_recursive_parent_arguments() + { + var inner = new Command("inner", "command help") + { + new Option("-v") { Description = "Sets the verbosity" }, + new Argument("inner-args") + }; + _ = new Command("outer", "command help") + { + inner, + new Argument("outer-args") { Recursive = false } + }; + + _helpBuilder.Write(inner, _console); + + var expected = + $"Usage:{NewLine}" + + $"{_indentation}outer inner [...] [options]"; + + _console.ToString().Should().Contain(expected); + _console.ToString().Should().NotContain(""); + } + [Fact] public void Usage_section_does_not_show_additional_arguments_when_TreatUnmatchedTokensAsErrors_is_not_specified() { diff --git a/src/System.CommandLine.Tests/ParserTests.cs b/src/System.CommandLine.Tests/ParserTests.cs index dd9eed308b..d7ef91ca2b 100644 --- a/src/System.CommandLine.Tests/ParserTests.cs +++ b/src/System.CommandLine.Tests/ParserTests.cs @@ -1227,6 +1227,26 @@ public void Arguments_can_match_subcommands() .BeEquivalentSequenceTo("one", "two", "three", "subcommand", "four"); } + [Fact] + public void Non_recursive_parent_argument_is_unmatched_when_subcommand_is_invoked() + { + var rootArg = new Argument("rootArg") { Recursive = false }; + var subcommand = new Command("subcommand"); + var root = new RootCommand + { + rootArg, + subcommand + }; + + var result = root.Parse("value subcommand"); + + result.CommandResult.Command.Should().BeSameAs(subcommand); + result.UnmatchedTokens.Should().BeEquivalentTo("value"); + result.Errors.Should().ContainSingle(e => + e.Message == LocalizationResources.UnrecognizedCommandOrArgument("value")); + result.GetValue(rootArg).Should().BeNull(); + } + [Theory] [InlineData("-x=-y")] [InlineData("-x:-y")] diff --git a/src/System.CommandLine/Parsing/ParseOperation.cs b/src/System.CommandLine/Parsing/ParseOperation.cs index d04f2b7cda..0e5d1d112a 100644 --- a/src/System.CommandLine/Parsing/ParseOperation.cs +++ b/src/System.CommandLine/Parsing/ParseOperation.cs @@ -77,20 +77,50 @@ internal ParseResult Parse() private void ParseSubcommand() { Command command = (Command)CurrentToken.Symbol!; + var parentCommandResult = _innermostCommandResult; _innermostCommandResult = new CommandResult( command, CurrentToken, _symbolResultTree, - _innermostCommandResult); + parentCommandResult); _symbolResultTree.Add(command, _innermostCommandResult); + MoveNonRecursiveParentArgumentsToUnmatched(parentCommandResult, _innermostCommandResult); + Advance(); ParseCommandChildren(); } + private void MoveNonRecursiveParentArgumentsToUnmatched(CommandResult parent, CommandResult innermost) + { + if (!parent.Command.HasArguments) + { + return; + } + + var arguments = parent.Command.Arguments; + for (var i = 0; i < arguments.Count; i++) + { + Argument argument = arguments[i]; + + if (!argument.Recursive && + _symbolResultTree.TryGetValue(argument, out SymbolResult? parsedResult) && + parsedResult is ArgumentResult argumentResult && + ReferenceEquals(argumentResult.Parent, parent)) + { + foreach (var token in argumentResult.Tokens) + { + _symbolResultTree.AddUnmatchedToken(token, innermost, _rootCommandResult); + } + + _symbolResultTree.Remove(argument); + } + } + } + private void ParseCommandChildren() { int currentArgumentCount = 0; @@ -411,7 +441,6 @@ private void ValidateAndAddDefaultResults() while (currentResult is not null) { currentResult.Validate(isInnermostCommand: false); - currentResult = currentResult.Parent as CommandResult; }