Repository: dotnet/command-line-api (2026-04-10T01:10:21Z)

Issue:
Typo suggestions don't seem to look at recursive options
As per the title. I've had a quick look through the code and I think this is because it generates the list of possible tokens from the children of the subcommand, but doesn't then also add the recursive options from the parent commands.


Discussion:

Patch:
diff --git a/src/System.CommandLine/Invocation/ParseErrorAction.cs b/src/System.CommandLine/Invocation/ParseErrorAction.cs
index fd939ccfea..2e2c80a4de 100644
--- a/src/System.CommandLine/Invocation/ParseErrorAction.cs
+++ b/src/System.CommandLine/Invocation/ParseErrorAction.cs
@@ -94,7 +94,7 @@ private static void WriteTypoCorrectionSuggestions(ParseResult parseResult)
             var token = unmatchedTokens[i];
 
             bool first = true;
-            foreach (string suggestion in GetPossibleTokens(parseResult.CommandResult.Command, token))
+            foreach (string suggestion in GetPossibleTokens(parseResult.CommandResult, token))
             {
                 if (first)
                 {
@@ -111,16 +111,23 @@ private static void WriteTypoCorrectionSuggestions(ParseResult parseResult)
             parseResult.InvocationConfiguration.Output.WriteLine();
         }
 
-        static IEnumerable<string> GetPossibleTokens(Command targetSymbol, string token)
+        static IEnumerable<string> GetPossibleTokens(CommandResult commandResult, string token)
         {
-            if (targetSymbol is { HasOptions: false, HasSubcommands: false })
+            Command targetSymbol = commandResult.Command;
+
+            // Collect symbols from the current command's children (options + subcommands)
+            // plus Recursive options from every ancestor command.
+            IEnumerable<Symbol> candidates = targetSymbol.HasOptions || targetSymbol.HasSubcommands
+                ? targetSymbol.Children.Where(x => !x.Hidden && x is Option or Command)
+                : Enumerable.Empty<Symbol>();
+
+            for (var parent = commandResult.Parent as CommandResult; parent is not null; parent = parent.Parent as CommandResult)
             {
-                return Array.Empty<string>();
+                candidates = candidates.Concat(
+                    parent.Command.Options.Where(o => o.Recursive && !o.Hidden));
             }
 
-            IEnumerable<string> possibleMatches = targetSymbol
-                                                  .Children
-                                                  .Where(x => !x.Hidden && x is Option or Command)
+            IEnumerable<string> possibleMatches = candidates
                                                   .Select(symbol =>
                                                   {
                                                       AliasSet? aliasSet = symbol is Option option ? option._aliases : ((Command)symbol)._aliases;
@@ -233,3 +240,4 @@ static int GetDistance(string first, string second)
 
     private const int MaxLevenshteinDistance = 3;
 }
+


Test patch:
diff --git a/src/System.CommandLine.Tests/Invocation/TypoCorrectionTests.cs b/src/System.CommandLine.Tests/Invocation/TypoCorrectionTests.cs
index 1bac77d500..245317031c 100644
--- a/src/System.CommandLine.Tests/Invocation/TypoCorrectionTests.cs
+++ b/src/System.CommandLine.Tests/Invocation/TypoCorrectionTests.cs
@@ -188,5 +188,106 @@ public async Task Suggestions_favor_matches_with_prefix()
 
             output.ToString().Should().Contain($"'-all' was not matched. Did you mean one of the following?{NewLine}-call");
         }
+
+        [Fact]
+        public async Task Recursive_options_from_parent_command_are_suggested_for_subcommand_token()
+        {
+            var subcommand = new Command("sub");
+            var rootCommand = new RootCommand
+            {
+                subcommand,
+                new Option<string>("--verbose") { Recursive = true }
+            };
+
+            var output = new StringWriter();
+            var result = rootCommand.Parse("sub --verbos");
+
+            await result.InvokeAsync(new() { Output = output }, CancellationToken.None);
+
+            output.ToString().Should().Contain($"'--verbos' was not matched. Did you mean one of the following?{NewLine}--verbose");
+        }
+
+        [Fact]
+        public async Task Non_recursive_options_from_parent_command_are_not_suggested_for_subcommand_token()
+        {
+            var subcommand = new Command("sub");
+            var rootCommand = new RootCommand
+            {
+                subcommand,
+                new Option<string>("--verbose") { Recursive = false }
+            };
+
+            var output = new StringWriter();
+            var result = rootCommand.Parse("sub --verbos");
+
+            await result.InvokeAsync(new() { Output = output }, CancellationToken.None);
+
+            output.ToString().Should().NotContain("--verbose");
+        }
+
+        [Fact]
+        public async Task Hidden_recursive_options_from_parent_command_are_not_suggested_for_subcommand_token()
+        {
+            var subcommand = new Command("sub");
+            var rootCommand = new RootCommand
+            {
+                subcommand,
+                new Option<string>("--verbose") { Recursive = true, Hidden = true }
+            };
+
+            var output = new StringWriter();
+            var result = rootCommand.Parse("sub --verbos");
+
+            await result.InvokeAsync(new() { Output = output }, CancellationToken.None);
+
+            output.ToString().Should().NotContain("--verbose");
+        }
+
+        [Fact]
+        public async Task Recursive_options_from_grandparent_command_are_suggested_for_deeply_nested_subcommand_token()
+        {
+            var grandchild = new Command("grandchild");
+            var child = new Command("child") { grandchild };
+            var rootCommand = new RootCommand
+            {
+                child,
+                new Option<string>("--output") { Recursive = true }
+            };
+
+            var output = new StringWriter();
+            var result = rootCommand.Parse("child grandchild --outpu");
+
+            await result.InvokeAsync(new() { Output = output }, CancellationToken.None);
+
+            output.ToString().Should().Contain($"'--outpu' was not matched. Did you mean one of the following?{NewLine}--output");
+        }
+
+        [Fact]
+        public async Task Recursive_options_from_parent_and_own_options_are_both_suggested()
+        {
+            var subcommand = new Command("sub")
+            {
+                new Option<string>("--format")
+            };
+            var rootCommand = new RootCommand
+            {
+                subcommand,
+                new Option<string>("--verbose") { Recursive = true }
+            };
+
+            var output = new StringWriter();
+            // "forma" is close to "--format" but not close to "--verbose"
+            var result = rootCommand.Parse("sub forma");
+
+            if (result.Action is ParseErrorAction parseError)
+            {
+                parseError.ShowHelp = false;
+            }
+
+            await result.InvokeAsync(new() { Output = output }, CancellationToken.None);
+
+            output.ToString().Should().Contain("--format");
+            output.ToString().Should().NotContain("--verbose");
+        }
     }
 }
\ No newline at end of file