From 7b3a69a7ce813d5148e7f4135145a4a457775a57 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 6 Apr 2026 19:07:17 +0100 Subject: [PATCH 1/4] Enhance fish shell completions with static+dynamic hybrid generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fish shell provider's simple dynamic one-liner with a full static+dynamic completion generator, matching the approach taken by the Bash, Zsh, and PowerShell providers. The generated script uses a state-machine that walks the tokenized command line to determine the current subcommand context, then emits static completions for subcommands, options, and positional arguments — falling back to dynamic complete calls where IsDynamic is set. Co-Authored-By: Claude Opus 4.6 --- .../shells/FishShellProvider.cs | 294 +++++++++++++++++- .../FishShellProviderTests.cs | 77 +++++ .../VerifyConfiguration.cs | 1 + ...DynamicCompletionsGeneration.verified.fish | 40 +++ ...iderTests.GenericCompletions.verified.fish | 21 ++ ...s.NestedSubcommandCompletion.verified.fish | 35 +++ ...Tests.SimpleOptionCompletion.verified.fish | 38 +++ ...iderTests.StaticOptionValues.verified.fish | 43 +++ ...mmandAndOptionInTopLevelList.verified.fish | 42 +++ 9 files changed, 584 insertions(+), 7 deletions(-) create mode 100644 test/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs create mode 100644 test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish create mode 100644 test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish create mode 100644 test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish create mode 100644 test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish create mode 100644 test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish create mode 100644 test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish diff --git a/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs b/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs index b9555424d493..8639d6015846 100644 --- a/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs +++ b/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.CodeDom.Compiler; +using System.CommandLine.Completions; using System.CommandLine.StaticCompletions.Resources; namespace System.CommandLine.StaticCompletions.Shells; @@ -16,13 +18,291 @@ public class FishShellProvider : IShellProvider // override the ToString method to return the argument name so that CLI help is cleaner for 'default' values public override string ToString() => ArgumentName; - private static readonly string _dynamicCompletionScript = - """ - # fish parameter completion for the dotnet CLI - # add the following to your config.fish to enable completions + public string GenerateCompletions(Command command) + { + var safeName = command.Name.MakeSafeFunctionName(); - complete -f -c dotnet -a "(dotnet complete (commandline -cp))" - """; + using var textWriter = new StringWriter { NewLine = "\n" }; + using var writer = new IndentedTextWriter(textWriter); - public string GenerateCompletions(System.CommandLine.Command command) => _dynamicCompletionScript; + writer.WriteLine($"# fish completions for {command.Name}"); + writer.WriteLine(); + + // Collect all commands in a flat list and assign numeric state IDs + var states = new List<(int id, Command cmd)>(); + CollectStates(command, states); + + // Write the main completion function + writer.WriteLine($"function _{safeName}"); + writer.Indent++; + + WriteTokenization(writer); + writer.WriteLine(); + WriteStateWalker(writer, states); + writer.WriteLine(); + WriteOptionValueCompletions(writer, states); + WriteStateCompletions(writer, states); + + writer.Indent--; + writer.WriteLine("end"); + writer.WriteLine(); + + writer.WriteLine($"complete -c {command.Name} -f -a '(_{safeName})'"); + + writer.Flush(); + return textWriter.ToString(); + } + + /// + /// Recursively collect all visible commands into a flat list with numeric state IDs. + /// State 0 is the root command. + /// + private static void CollectStates(Command cmd, List<(int id, Command cmd)> states) + { + states.Add((states.Count, cmd)); + foreach (var sub in cmd.Subcommands.Where(c => !c.Hidden)) + { + CollectStates(sub, states); + } + } + + /// + /// Write the command line tokenization logic. + /// Uses fish's commandline builtin to get completed tokens and the current partial word. + /// + private static void WriteTokenization(IndentedTextWriter writer) + { + // -opc: tokenize, cut at cursor, only completed tokens (excludes current partial word) + writer.WriteLine("set -l tokens (commandline -opc)"); + // -ct: the current token being completed (may be empty or partial) + writer.WriteLine("set -l current (commandline -ct)"); + } + + /// + /// Generate the state machine that walks completed tokens to determine which subcommand context we're in. + /// For each state, we check if the current word matches a known subcommand (transitioning to that subcommand's state) + /// or a value-taking option (skipping the next token which is the option's value). + /// + private static void WriteStateWalker(IndentedTextWriter writer, List<(int id, Command cmd)> states) + { + writer.WriteLine("set -l state 0"); + writer.WriteLine("set -l i 2"); // start after the command name (fish arrays are 1-based) + writer.WriteLine("while test $i -le (count $tokens)"); + writer.Indent++; + writer.WriteLine("set -l word $tokens[$i]"); + + writer.WriteLine("switch $state"); + writer.Indent++; + + foreach (var (stateId, cmd) in states) + { + var visibleSubs = cmd.Subcommands.Where(c => !c.Hidden).ToArray(); + var valueOptionNames = cmd.HierarchicalOptions() + .Where(o => !o.Hidden && !o.IsFlag()) + .SelectMany(o => o.Names()) + .ToArray(); + + // Skip states that have no transitions to emit + if (visibleSubs.Length == 0 && valueOptionNames.Length == 0) + continue; + + writer.WriteLine($"case {stateId}"); + writer.Indent++; + writer.WriteLine("switch $word"); + writer.Indent++; + + // Subcommand transitions + foreach (var sub in visibleSubs) + { + var subStateId = states.First(s => s.cmd == sub).id; + var names = string.Join(" ", sub.Names()); + writer.WriteLine($"case {names}"); + writer.Indent++; + writer.WriteLine($"set state {subStateId}"); + writer.Indent--; + } + + // Value-taking option transitions: skip the next token (the option's value) + if (valueOptionNames.Length > 0) + { + writer.WriteLine($"case {string.Join(" ", valueOptionNames)}"); + writer.Indent++; + writer.WriteLine("set i (math $i + 1)"); + writer.Indent--; + } + + writer.Indent--; + writer.WriteLine("end"); + writer.Indent--; + } + + writer.Indent--; + writer.WriteLine("end"); + + writer.WriteLine("set i (math $i + 1)"); + writer.Indent--; + writer.WriteLine("end"); + } + + /// + /// Generate option value completions. + /// When the previous completed token is a value-taking option, we emit completions for that option's values + /// instead of the general completions for the current state. + /// + private static void WriteOptionValueCompletions(IndentedTextWriter writer, List<(int id, Command cmd)> states) + { + var hasAnyValueOptions = states.Any(s => + s.cmd.HierarchicalOptions().Any(o => !o.Hidden && !o.IsFlag())); + + if (!hasAnyValueOptions) return; + + writer.WriteLine("if set -q tokens[2]"); + writer.Indent++; + writer.WriteLine("set -l prev $tokens[-1]"); + writer.WriteLine("switch $state"); + writer.Indent++; + + foreach (var (stateId, cmd) in states) + { + var valueOptions = cmd.HierarchicalOptions() + .Where(o => !o.Hidden && !o.IsFlag()) + .ToArray(); + + if (valueOptions.Length == 0) + continue; + + writer.WriteLine($"case {stateId}"); + writer.Indent++; + writer.WriteLine("switch $prev"); + writer.Indent++; + + foreach (var option in valueOptions) + { + var names = string.Join(" ", option.Names()); + writer.WriteLine($"case {names}"); + writer.Indent++; + + if (option.IsDynamic) + { + writer.WriteLine(GenerateDynamicCall()); + } + else + { + var completions = option.GetCompletions(CompletionContext.Empty).ToArray(); + foreach (var c in completions) + { + WriteCompletionCandidate(writer, c); + } + } + writer.WriteLine("return"); + writer.Indent--; + } + + writer.Indent--; + writer.WriteLine("end"); + writer.Indent--; + } + + writer.Indent--; + writer.WriteLine("end"); + writer.Indent--; + writer.WriteLine("end"); + writer.WriteLine(); + } + + /// + /// Generate the main completion output for each state. + /// Emits subcommands, options, and positional argument completions for the current context. + /// + private static void WriteStateCompletions(IndentedTextWriter writer, List<(int id, Command cmd)> states) + { + writer.WriteLine("switch $state"); + writer.Indent++; + + foreach (var (stateId, cmd) in states) + { + writer.WriteLine($"case {stateId}"); + writer.Indent++; + + // Subcommand completions + foreach (var sub in cmd.Subcommands.Where(c => !c.Hidden)) + { + WriteCandidate(writer, sub.Name, SanitizeDescription(sub.Description)); + } + + // Option completions - emit all aliases so both -h and --help are completable + foreach (var option in cmd.HierarchicalOptions().Where(o => !o.Hidden)) + { + var desc = SanitizeDescription(option.Description); + foreach (var name in option.Names()) + { + WriteCandidate(writer, name, desc); + } + } + + // Positional argument completions + foreach (var arg in cmd.Arguments.Where(a => !a.Hidden)) + { + if (arg.IsDynamic) + { + writer.WriteLine(GenerateDynamicCall()); + } + else + { + var completions = arg.GetCompletions(CompletionContext.Empty).ToArray(); + foreach (var c in completions) + { + WriteCompletionCandidate(writer, c); + } + } + } + + writer.Indent--; + } + + writer.Indent--; + writer.WriteLine("end"); + } + + /// + /// Write a single completion candidate line with an optional description. + /// Fish uses tab-separated format: candidate\tdescription + /// + private static void WriteCompletionCandidate(IndentedTextWriter writer, CompletionItem completion) + { + var label = completion.InsertText ?? completion.Label; + var desc = completion.Documentation ?? completion.Detail; + WriteCandidate(writer, label, desc); + } + + private static void WriteCandidate(IndentedTextWriter writer, string label, string? description) + { + if (!string.IsNullOrEmpty(description)) + writer.WriteLine($"printf '%s\\t%s\\n' {FishEscape(label)} {FishEscape(description)}"); + else + writer.WriteLine($"printf '%s\\n' {FishEscape(label)}"); + } + + /// + /// Generate a dynamic completion call that invokes the binary's 'complete' subcommand. + /// Uses the actual command from the command line ($tokens[1]) so it works regardless of how the binary was invoked. + /// + /// TODO: this is currently bound to the .NET CLI's 'complete' command pattern - this should be definable/injectable per-host instead. + internal static string GenerateDynamicCall() + { + return "command $tokens[1] complete --position (commandline -C) (commandline -cp) 2>/dev/null"; + } + + /// + /// Escape a string for use in a fish single-quoted string. + /// Fish single-quoted strings support \' and \\ as escape sequences. + /// + internal static string FishEscape(string? s) + { + if (string.IsNullOrEmpty(s)) return "''"; + return "'" + s.Replace("\\", "\\\\").Replace("'", "\\'") + "'"; + } + + private static string SanitizeDescription(string? s) => + s?.Replace("\r\n", " ").Replace('\n', ' ').Replace('\r', ' ').Replace('\t', ' ') ?? ""; } diff --git a/test/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs b/test/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs new file mode 100644 index 000000000000..2c45c2ce8d07 --- /dev/null +++ b/test/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace System.CommandLine.StaticCompletions.Tests; + +using System.CommandLine.StaticCompletions.Shells; + +public class FishShellProviderTests(ITestOutputHelper log) +{ + private IShellProvider provider = new FishShellProvider(); + + [Fact] + public async Task GenericCompletions() + { + await provider.Verify(new("mycommand"), log); + } + + [Fact] + public async Task SimpleOptionCompletion() + { + await provider.Verify(new("mycommand") { + new Option("--name") + }, log); + } + + [Fact] + public async Task SubcommandAndOptionInTopLevelList() + { + await provider.Verify(new("mycommand") { + new Option("--name"), + new Command("subcommand") + }, log); + } + + [Fact] + public async Task NestedSubcommandCompletion() + { + await provider.Verify(new("mycommand") { + new Command("subcommand") { + new Command("nested") + } + }, log); + } + + [Fact] + public async Task DynamicCompletionsGeneration() + { + var dynamicOption = new Option("--dynamic") + { + IsDynamic = true + }; + var dynamicArg = new Argument("values") + { + IsDynamic = true + }; + Command command = new Command("mycommand") + { + dynamicOption, + dynamicArg + }; + await provider.Verify(command, log); + } + + [Fact] + public async Task StaticOptionValues() + { + var staticOption = new Option("--verbosity"); + staticOption.AcceptOnlyFromAmong("quiet", "minimal", "normal", "detailed", "diagnostic"); + Command command = new Command("mycommand") + { + staticOption + }; + await provider.Verify(command, log); + } +} diff --git a/test/System.CommandLine.StaticCompletions.Tests/VerifyConfiguration.cs b/test/System.CommandLine.StaticCompletions.Tests/VerifyConfiguration.cs index 4eeca67880c1..71dd56b1d276 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/VerifyConfiguration.cs +++ b/test/System.CommandLine.StaticCompletions.Tests/VerifyConfiguration.cs @@ -25,5 +25,6 @@ public static void Initialize() EmptyFiles.FileExtensions.AddTextExtension("ps1"); EmptyFiles.FileExtensions.AddTextExtension("nu"); + EmptyFiles.FileExtensions.AddTextExtension("fish"); } } diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish new file mode 100644 index 000000000000..36155f67258e --- /dev/null +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish @@ -0,0 +1,40 @@ +# fish completions for mycommand + +function _mycommand + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l state 0 + set -l i 2 + while test $i -le (count $tokens) + set -l word $tokens[$i] + switch $state + case 0 + switch $word + case --dynamic + set i (math $i + 1) + end + end + set i (math $i + 1) + end + + if set -q tokens[2] + set -l prev $tokens[-1] + switch $state + case 0 + switch $prev + case --dynamic + command $tokens[1] complete --position (commandline -C) (commandline -cp) 2>/dev/null + return + end + end + end + + switch $state + case 0 + printf '%s\n' '--dynamic' + command $tokens[1] complete --position (commandline -C) (commandline -cp) 2>/dev/null + end +end + +complete -c mycommand -f -a '(_mycommand)' diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish new file mode 100644 index 000000000000..2494e00474f2 --- /dev/null +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish @@ -0,0 +1,21 @@ +# fish completions for mycommand + +function _mycommand + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l state 0 + set -l i 2 + while test $i -le (count $tokens) + set -l word $tokens[$i] + switch $state + end + set i (math $i + 1) + end + + switch $state + case 0 + end +end + +complete -c mycommand -f -a '(_mycommand)' diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish new file mode 100644 index 000000000000..e5113b32aef3 --- /dev/null +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish @@ -0,0 +1,35 @@ +# fish completions for mycommand + +function _mycommand + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l state 0 + set -l i 2 + while test $i -le (count $tokens) + set -l word $tokens[$i] + switch $state + case 0 + switch $word + case subcommand + set state 1 + end + case 1 + switch $word + case nested + set state 2 + end + end + set i (math $i + 1) + end + + switch $state + case 0 + printf '%s\n' 'subcommand' + case 1 + printf '%s\n' 'nested' + case 2 + end +end + +complete -c mycommand -f -a '(_mycommand)' diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish new file mode 100644 index 000000000000..d44763e96804 --- /dev/null +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish @@ -0,0 +1,38 @@ +# fish completions for mycommand + +function _mycommand + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l state 0 + set -l i 2 + while test $i -le (count $tokens) + set -l word $tokens[$i] + switch $state + case 0 + switch $word + case --name + set i (math $i + 1) + end + end + set i (math $i + 1) + end + + if set -q tokens[2] + set -l prev $tokens[-1] + switch $state + case 0 + switch $prev + case --name + return + end + end + end + + switch $state + case 0 + printf '%s\n' '--name' + end +end + +complete -c mycommand -f -a '(_mycommand)' diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish new file mode 100644 index 000000000000..182250a2c07d --- /dev/null +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish @@ -0,0 +1,43 @@ +# fish completions for mycommand + +function _mycommand + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l state 0 + set -l i 2 + while test $i -le (count $tokens) + set -l word $tokens[$i] + switch $state + case 0 + switch $word + case --verbosity + set i (math $i + 1) + end + end + set i (math $i + 1) + end + + if set -q tokens[2] + set -l prev $tokens[-1] + switch $state + case 0 + switch $prev + case --verbosity + printf '%s\n' 'detailed' + printf '%s\n' 'diagnostic' + printf '%s\n' 'minimal' + printf '%s\n' 'normal' + printf '%s\n' 'quiet' + return + end + end + end + + switch $state + case 0 + printf '%s\n' '--verbosity' + end +end + +complete -c mycommand -f -a '(_mycommand)' diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish new file mode 100644 index 000000000000..51d693560436 --- /dev/null +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish @@ -0,0 +1,42 @@ +# fish completions for mycommand + +function _mycommand + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l state 0 + set -l i 2 + while test $i -le (count $tokens) + set -l word $tokens[$i] + switch $state + case 0 + switch $word + case subcommand + set state 1 + case --name + set i (math $i + 1) + end + end + set i (math $i + 1) + end + + if set -q tokens[2] + set -l prev $tokens[-1] + switch $state + case 0 + switch $prev + case --name + return + end + end + end + + switch $state + case 0 + printf '%s\n' 'subcommand' + printf '%s\n' '--name' + case 1 + end +end + +complete -c mycommand -f -a '(_mycommand)' From cd80f0e4ec3e3db0d7d204a123fd29f1c8e9a6b5 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Wed, 8 Apr 2026 18:39:52 +0100 Subject: [PATCH 2/4] Add multi-value option support and fix seq warning in fish completions Handle options with arity > 1 (bounded and unbounded) in the fish shell state walker and option value completions. Fix fish seq warning when token count is less than 2 by guarding the scan-back loop. Co-Authored-By: Claude Opus 4.6 --- .../shells/FishShellProvider.cs | 122 ++++++++++++++++-- .../FishShellProviderTests.cs | 60 +++++++++ ...ests.BoundedMultiValueOption.verified.fish | 68 ++++++++++ ...DynamicCompletionsGeneration.verified.fish | 23 +++- ...viderTests.MixedArityOptions.verified.fish | 88 +++++++++++++ ...Tests.SimpleOptionCompletion.verified.fish | 21 ++- ...iderTests.StaticOptionValues.verified.fish | 31 +++-- ...mmandAndOptionInTopLevelList.verified.fish | 21 ++- ...ts.UnboundedMultiValueOption.verified.fish | 69 ++++++++++ 9 files changed, 469 insertions(+), 34 deletions(-) create mode 100644 test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish create mode 100644 test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish create mode 100644 test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish diff --git a/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs b/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs index 8639d6015846..f0971af9e25b 100644 --- a/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs +++ b/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs @@ -78,10 +78,15 @@ private static void WriteTokenization(IndentedTextWriter writer) writer.WriteLine("set -l current (commandline -ct)"); } + // Options with MaximumNumberOfValues at or above this threshold are treated as unbounded + // (i.e. consume tokens until an option-like token is encountered). + // System.CommandLine uses 100_000 as its internal sentinel for ZeroOrMore/OneOrMore. + private const int UnboundedArityThreshold = 100; + /// /// Generate the state machine that walks completed tokens to determine which subcommand context we're in. /// For each state, we check if the current word matches a known subcommand (transitioning to that subcommand's state) - /// or a value-taking option (skipping the next token which is the option's value). + /// or a value-taking option (skipping tokens for the option's value(s), respecting the option's arity). /// private static void WriteStateWalker(IndentedTextWriter writer, List<(int id, Command cmd)> states) { @@ -97,13 +102,12 @@ private static void WriteStateWalker(IndentedTextWriter writer, List<(int id, Co foreach (var (stateId, cmd) in states) { var visibleSubs = cmd.Subcommands.Where(c => !c.Hidden).ToArray(); - var valueOptionNames = cmd.HierarchicalOptions() + var valueOptions = cmd.HierarchicalOptions() .Where(o => !o.Hidden && !o.IsFlag()) - .SelectMany(o => o.Names()) .ToArray(); // Skip states that have no transitions to emit - if (visibleSubs.Length == 0 && valueOptionNames.Length == 0) + if (visibleSubs.Length == 0 && valueOptions.Length == 0) continue; writer.WriteLine($"case {stateId}"); @@ -122,15 +126,33 @@ private static void WriteStateWalker(IndentedTextWriter writer, List<(int id, Co writer.Indent--; } - // Value-taking option transitions: skip the next token (the option's value) - if (valueOptionNames.Length > 0) + // Single-value options (arity exactly 1): skip the next token + var singleValueNames = valueOptions + .Where(o => o.Arity.MaximumNumberOfValues == 1) + .SelectMany(o => o.Names()) + .ToArray(); + if (singleValueNames.Length > 0) { - writer.WriteLine($"case {string.Join(" ", valueOptionNames)}"); + writer.WriteLine($"case {string.Join(" ", singleValueNames)}"); writer.Indent++; writer.WriteLine("set i (math $i + 1)"); writer.Indent--; } + // Multi-value options (arity > 1): skip up to N tokens, stopping at option-like tokens. + // Group by arity so options with the same max can share a case branch. + var multiValueByArity = valueOptions + .Where(o => o.Arity.MaximumNumberOfValues > 1) + .GroupBy(o => o.Arity.MaximumNumberOfValues); + foreach (var group in multiValueByArity) + { + var names = string.Join(" ", group.SelectMany(o => o.Names())); + writer.WriteLine($"case {names}"); + writer.Indent++; + WriteMultiValueSkipLoop(writer, group.Key); + writer.Indent--; + } + writer.Indent--; writer.WriteLine("end"); writer.Indent--; @@ -144,10 +166,48 @@ private static void WriteStateWalker(IndentedTextWriter writer, List<(int id, Co writer.WriteLine("end"); } + /// + /// Emit a fish loop that skips value tokens after a multi-value option. + /// Stops when it has consumed maxValues tokens, or encounters a token starting with '-', + /// whichever comes first. For unbounded arities, only the '-' check applies. + /// + private static void WriteMultiValueSkipLoop(IndentedTextWriter writer, int maxValues) + { + bool isBounded = maxValues < UnboundedArityThreshold; + + if (isBounded) + { + writer.WriteLine($"set -l skip_max {maxValues}"); + writer.WriteLine("set -l skipped 0"); + writer.WriteLine("while test $skipped -lt $skip_max -a (math $i + 1) -le (count $tokens)"); + } + else + { + writer.WriteLine("while test (math $i + 1) -le (count $tokens)"); + } + + writer.Indent++; + writer.WriteLine("set -l next $tokens[(math $i + 1)]"); + writer.WriteLine("if string match -q -- '-*' $next"); + writer.Indent++; + writer.WriteLine("break"); + writer.Indent--; + writer.WriteLine("end"); + writer.WriteLine("set i (math $i + 1)"); + if (isBounded) + { + writer.WriteLine("set skipped (math $skipped + 1)"); + } + writer.Indent--; + writer.WriteLine("end"); + } + /// /// Generate option value completions. - /// When the previous completed token is a value-taking option, we emit completions for that option's values - /// instead of the general completions for the current state. + /// Scans backward through completed tokens to find the nearest option, then checks whether + /// we're still within that option's arity. This correctly handles both single-value and + /// multi-value options (e.g. --sources foo bar with arity 3 still offers completions + /// for the third value). /// private static void WriteOptionValueCompletions(IndentedTextWriter writer, List<(int id, Command cmd)> states) { @@ -156,9 +216,29 @@ private static void WriteOptionValueCompletions(IndentedTextWriter writer, List< if (!hasAnyValueOptions) return; - writer.WriteLine("if set -q tokens[2]"); + // Scan backward through tokens to find the nearest option (token starting with -) + writer.WriteLine("set -l opt_index 0"); + writer.WriteLine("if test (count $tokens) -ge 2"); + writer.Indent++; + writer.WriteLine("for j in (seq (count $tokens) -1 2)"); writer.Indent++; - writer.WriteLine("set -l prev $tokens[-1]"); + writer.WriteLine("if string match -q -- '-*' $tokens[$j]"); + writer.Indent++; + writer.WriteLine("set opt_index $j"); + writer.WriteLine("break"); + writer.Indent--; + writer.WriteLine("end"); + writer.Indent--; + writer.WriteLine("end"); + writer.Indent--; + writer.WriteLine("end"); + writer.WriteLine(); + + writer.WriteLine("if test $opt_index -gt 0"); + writer.Indent++; + writer.WriteLine("set -l opt $tokens[$opt_index]"); + // values_after = number of non-option tokens between the option and the cursor + writer.WriteLine("set -l values_after (math (count $tokens) - $opt_index)"); writer.WriteLine("switch $state"); writer.Indent++; @@ -173,15 +253,26 @@ private static void WriteOptionValueCompletions(IndentedTextWriter writer, List< writer.WriteLine($"case {stateId}"); writer.Indent++; - writer.WriteLine("switch $prev"); + writer.WriteLine("switch $opt"); writer.Indent++; foreach (var option in valueOptions) { var names = string.Join(" ", option.Names()); + var maxValues = option.Arity.MaximumNumberOfValues; + bool isBounded = maxValues < UnboundedArityThreshold; + writer.WriteLine($"case {names}"); writer.Indent++; + // For bounded options, check that we haven't exceeded the arity. + // For unbounded options, always offer completions (any number of values is valid). + if (isBounded) + { + writer.WriteLine($"if test $values_after -lt {maxValues}"); + writer.Indent++; + } + if (option.IsDynamic) { writer.WriteLine(GenerateDynamicCall()); @@ -195,6 +286,13 @@ private static void WriteOptionValueCompletions(IndentedTextWriter writer, List< } } writer.WriteLine("return"); + + if (isBounded) + { + writer.Indent--; + writer.WriteLine("end"); + } + writer.Indent--; } diff --git a/test/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs b/test/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs index 2c45c2ce8d07..c8f385a5013b 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs +++ b/test/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs @@ -74,4 +74,64 @@ public async Task StaticOptionValues() }; await provider.Verify(command, log); } + + [Fact] + public async Task BoundedMultiValueOption() + { + var multiOption = new Option("--sources") + { + Arity = new ArgumentArity(1, 3) + }; + multiOption.AcceptOnlyFromAmong("src1", "src2", "src3", "src4"); + Command command = new Command("mycommand") + { + multiOption, + new Command("subcommand") + }; + await provider.Verify(command, log); + } + + [Fact] + public async Task UnboundedMultiValueOption() + { + var unboundedOption = new Option("--items") + { + Arity = ArgumentArity.ZeroOrMore + }; + unboundedOption.AcceptOnlyFromAmong("a", "b", "c"); + Command command = new Command("mycommand") + { + unboundedOption, + new Option("--name"), + new Command("subcommand") + }; + await provider.Verify(command, log); + } + + [Fact] + public async Task MixedArityOptions() + { + var singleOption = new Option("--config"); + singleOption.AcceptOnlyFromAmong("debug", "release"); + + var multiOption = new Option("--framework", "-f") + { + Arity = new ArgumentArity(1, 3) + }; + multiOption.AcceptOnlyFromAmong("net8.0", "net9.0", "net10.0"); + + var unboundedOption = new Option("--sources") + { + Arity = ArgumentArity.OneOrMore + }; + + Command command = new Command("mycommand") + { + singleOption, + multiOption, + unboundedOption, + new Command("build") + }; + await provider.Verify(command, log); + } } diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish new file mode 100644 index 000000000000..db6e13bc073b --- /dev/null +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish @@ -0,0 +1,68 @@ +# fish completions for mycommand + +function _mycommand + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l state 0 + set -l i 2 + while test $i -le (count $tokens) + set -l word $tokens[$i] + switch $state + case 0 + switch $word + case subcommand + set state 1 + case --sources + set -l skip_max 3 + set -l skipped 0 + while test $skipped -lt $skip_max -a (math $i + 1) -le (count $tokens) + set -l next $tokens[(math $i + 1)] + if string match -q -- '-*' $next + break + end + set i (math $i + 1) + set skipped (math $skipped + 1) + end + end + end + set i (math $i + 1) + end + + set -l opt_index 0 + if test (count $tokens) -ge 2 + for j in (seq (count $tokens) -1 2) + if string match -q -- '-*' $tokens[$j] + set opt_index $j + break + end + end + end + + if test $opt_index -gt 0 + set -l opt $tokens[$opt_index] + set -l values_after (math (count $tokens) - $opt_index) + switch $state + case 0 + switch $opt + case --sources + if test $values_after -lt 3 + printf '%s\n' 'src1' + printf '%s\n' 'src2' + printf '%s\n' 'src3' + printf '%s\n' 'src4' + return + end + end + end + end + + switch $state + case 0 + printf '%s\n' 'subcommand' + printf '%s\n' '--sources' + case 1 + end +end + +complete -c mycommand -f -a '(_mycommand)' diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish index 36155f67258e..e90e630b1226 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish @@ -18,14 +18,27 @@ function _mycommand set i (math $i + 1) end - if set -q tokens[2] - set -l prev $tokens[-1] + set -l opt_index 0 + if test (count $tokens) -ge 2 + for j in (seq (count $tokens) -1 2) + if string match -q -- '-*' $tokens[$j] + set opt_index $j + break + end + end + end + + if test $opt_index -gt 0 + set -l opt $tokens[$opt_index] + set -l values_after (math (count $tokens) - $opt_index) switch $state case 0 - switch $prev + switch $opt case --dynamic - command $tokens[1] complete --position (commandline -C) (commandline -cp) 2>/dev/null - return + if test $values_after -lt 1 + command $tokens[1] complete --position (commandline -C) (commandline -cp) 2>/dev/null + return + end end end end diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish new file mode 100644 index 000000000000..353740c0bf44 --- /dev/null +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish @@ -0,0 +1,88 @@ +# fish completions for mycommand + +function _mycommand + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l state 0 + set -l i 2 + while test $i -le (count $tokens) + set -l word $tokens[$i] + switch $state + case 0 + switch $word + case build + set state 1 + case --config + set i (math $i + 1) + case --framework -f + set -l skip_max 3 + set -l skipped 0 + while test $skipped -lt $skip_max -a (math $i + 1) -le (count $tokens) + set -l next $tokens[(math $i + 1)] + if string match -q -- '-*' $next + break + end + set i (math $i + 1) + set skipped (math $skipped + 1) + end + case --sources + while test (math $i + 1) -le (count $tokens) + set -l next $tokens[(math $i + 1)] + if string match -q -- '-*' $next + break + end + set i (math $i + 1) + end + end + end + set i (math $i + 1) + end + + set -l opt_index 0 + if test (count $tokens) -ge 2 + for j in (seq (count $tokens) -1 2) + if string match -q -- '-*' $tokens[$j] + set opt_index $j + break + end + end + end + + if test $opt_index -gt 0 + set -l opt $tokens[$opt_index] + set -l values_after (math (count $tokens) - $opt_index) + switch $state + case 0 + switch $opt + case --config + if test $values_after -lt 1 + printf '%s\n' 'debug' + printf '%s\n' 'release' + return + end + case --framework -f + if test $values_after -lt 3 + printf '%s\n' 'net10.0' + printf '%s\n' 'net8.0' + printf '%s\n' 'net9.0' + return + end + case --sources + return + end + end + end + + switch $state + case 0 + printf '%s\n' 'build' + printf '%s\n' '--config' + printf '%s\n' '--framework' + printf '%s\n' '-f' + printf '%s\n' '--sources' + case 1 + end +end + +complete -c mycommand -f -a '(_mycommand)' diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish index d44763e96804..7f0fa5832661 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish @@ -18,13 +18,26 @@ function _mycommand set i (math $i + 1) end - if set -q tokens[2] - set -l prev $tokens[-1] + set -l opt_index 0 + if test (count $tokens) -ge 2 + for j in (seq (count $tokens) -1 2) + if string match -q -- '-*' $tokens[$j] + set opt_index $j + break + end + end + end + + if test $opt_index -gt 0 + set -l opt $tokens[$opt_index] + set -l values_after (math (count $tokens) - $opt_index) switch $state case 0 - switch $prev + switch $opt case --name - return + if test $values_after -lt 1 + return + end end end end diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish index 182250a2c07d..d075ff51ced4 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish @@ -18,18 +18,31 @@ function _mycommand set i (math $i + 1) end - if set -q tokens[2] - set -l prev $tokens[-1] + set -l opt_index 0 + if test (count $tokens) -ge 2 + for j in (seq (count $tokens) -1 2) + if string match -q -- '-*' $tokens[$j] + set opt_index $j + break + end + end + end + + if test $opt_index -gt 0 + set -l opt $tokens[$opt_index] + set -l values_after (math (count $tokens) - $opt_index) switch $state case 0 - switch $prev + switch $opt case --verbosity - printf '%s\n' 'detailed' - printf '%s\n' 'diagnostic' - printf '%s\n' 'minimal' - printf '%s\n' 'normal' - printf '%s\n' 'quiet' - return + if test $values_after -lt 1 + printf '%s\n' 'detailed' + printf '%s\n' 'diagnostic' + printf '%s\n' 'minimal' + printf '%s\n' 'normal' + printf '%s\n' 'quiet' + return + end end end end diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish index 51d693560436..208f8a2d310e 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish @@ -20,13 +20,26 @@ function _mycommand set i (math $i + 1) end - if set -q tokens[2] - set -l prev $tokens[-1] + set -l opt_index 0 + if test (count $tokens) -ge 2 + for j in (seq (count $tokens) -1 2) + if string match -q -- '-*' $tokens[$j] + set opt_index $j + break + end + end + end + + if test $opt_index -gt 0 + set -l opt $tokens[$opt_index] + set -l values_after (math (count $tokens) - $opt_index) switch $state case 0 - switch $prev + switch $opt case --name - return + if test $values_after -lt 1 + return + end end end end diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish new file mode 100644 index 000000000000..12109db9316a --- /dev/null +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish @@ -0,0 +1,69 @@ +# fish completions for mycommand + +function _mycommand + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l state 0 + set -l i 2 + while test $i -le (count $tokens) + set -l word $tokens[$i] + switch $state + case 0 + switch $word + case subcommand + set state 1 + case --name + set i (math $i + 1) + case --items + while test (math $i + 1) -le (count $tokens) + set -l next $tokens[(math $i + 1)] + if string match -q -- '-*' $next + break + end + set i (math $i + 1) + end + end + end + set i (math $i + 1) + end + + set -l opt_index 0 + if test (count $tokens) -ge 2 + for j in (seq (count $tokens) -1 2) + if string match -q -- '-*' $tokens[$j] + set opt_index $j + break + end + end + end + + if test $opt_index -gt 0 + set -l opt $tokens[$opt_index] + set -l values_after (math (count $tokens) - $opt_index) + switch $state + case 0 + switch $opt + case --items + printf '%s\n' 'a' + printf '%s\n' 'b' + printf '%s\n' 'c' + return + case --name + if test $values_after -lt 1 + return + end + end + end + end + + switch $state + case 0 + printf '%s\n' 'subcommand' + printf '%s\n' '--items' + printf '%s\n' '--name' + case 1 + end +end + +complete -c mycommand -f -a '(_mycommand)' From f8ac1917fbd284537b24faf599c27adf31b44794 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Wed, 8 Apr 2026 19:40:15 +0100 Subject: [PATCH 3/4] Remove unused current token variable and use dictionary for state lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: remove dead `set -l current` variable from generated fish script, and replace O(n²) states.First() lookups with a pre-built Dictionary for O(1) state ID resolution. Co-Authored-By: Claude Opus 4.6 --- .../shells/FishShellProvider.cs | 11 +++++------ ...roviderTests.BoundedMultiValueOption.verified.fish | 1 - ...erTests.DynamicCompletionsGeneration.verified.fish | 1 - ...hellProviderTests.GenericCompletions.verified.fish | 1 - ...ShellProviderTests.MixedArityOptions.verified.fish | 1 - ...iderTests.NestedSubcommandCompletion.verified.fish | 1 - ...ProviderTests.SimpleOptionCompletion.verified.fish | 1 - ...hellProviderTests.StaticOptionValues.verified.fish | 1 - ...ts.SubcommandAndOptionInTopLevelList.verified.fish | 1 - ...viderTests.UnboundedMultiValueOption.verified.fish | 1 - 10 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs b/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs index f0971af9e25b..830d15ba5b97 100644 --- a/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs +++ b/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs @@ -31,6 +31,7 @@ public string GenerateCompletions(Command command) // Collect all commands in a flat list and assign numeric state IDs var states = new List<(int id, Command cmd)>(); CollectStates(command, states); + var stateIdByCommand = states.ToDictionary(s => s.cmd, s => s.id); // Write the main completion function writer.WriteLine($"function _{safeName}"); @@ -38,7 +39,7 @@ public string GenerateCompletions(Command command) WriteTokenization(writer); writer.WriteLine(); - WriteStateWalker(writer, states); + WriteStateWalker(writer, states, stateIdByCommand); writer.WriteLine(); WriteOptionValueCompletions(writer, states); WriteStateCompletions(writer, states); @@ -68,14 +69,12 @@ private static void CollectStates(Command cmd, List<(int id, Command cmd)> state /// /// Write the command line tokenization logic. - /// Uses fish's commandline builtin to get completed tokens and the current partial word. + /// Uses fish's commandline builtin to get completed tokens up to the cursor. /// private static void WriteTokenization(IndentedTextWriter writer) { // -opc: tokenize, cut at cursor, only completed tokens (excludes current partial word) writer.WriteLine("set -l tokens (commandline -opc)"); - // -ct: the current token being completed (may be empty or partial) - writer.WriteLine("set -l current (commandline -ct)"); } // Options with MaximumNumberOfValues at or above this threshold are treated as unbounded @@ -88,7 +87,7 @@ private static void WriteTokenization(IndentedTextWriter writer) /// For each state, we check if the current word matches a known subcommand (transitioning to that subcommand's state) /// or a value-taking option (skipping tokens for the option's value(s), respecting the option's arity). /// - private static void WriteStateWalker(IndentedTextWriter writer, List<(int id, Command cmd)> states) + private static void WriteStateWalker(IndentedTextWriter writer, List<(int id, Command cmd)> states, Dictionary stateIdByCommand) { writer.WriteLine("set -l state 0"); writer.WriteLine("set -l i 2"); // start after the command name (fish arrays are 1-based) @@ -118,7 +117,7 @@ private static void WriteStateWalker(IndentedTextWriter writer, List<(int id, Co // Subcommand transitions foreach (var sub in visibleSubs) { - var subStateId = states.First(s => s.cmd == sub).id; + var subStateId = stateIdByCommand[sub]; var names = string.Join(" ", sub.Names()); writer.WriteLine($"case {names}"); writer.Indent++; diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish index db6e13bc073b..b33eebee909a 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish @@ -2,7 +2,6 @@ function _mycommand set -l tokens (commandline -opc) - set -l current (commandline -ct) set -l state 0 set -l i 2 diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish index e90e630b1226..f7f353523b26 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish @@ -2,7 +2,6 @@ function _mycommand set -l tokens (commandline -opc) - set -l current (commandline -ct) set -l state 0 set -l i 2 diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish index 2494e00474f2..c86386e997c8 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish @@ -2,7 +2,6 @@ function _mycommand set -l tokens (commandline -opc) - set -l current (commandline -ct) set -l state 0 set -l i 2 diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish index 353740c0bf44..a5728a17ce22 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish @@ -2,7 +2,6 @@ function _mycommand set -l tokens (commandline -opc) - set -l current (commandline -ct) set -l state 0 set -l i 2 diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish index e5113b32aef3..400d43cf29c3 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish @@ -2,7 +2,6 @@ function _mycommand set -l tokens (commandline -opc) - set -l current (commandline -ct) set -l state 0 set -l i 2 diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish index 7f0fa5832661..68d8ab21e67b 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish @@ -2,7 +2,6 @@ function _mycommand set -l tokens (commandline -opc) - set -l current (commandline -ct) set -l state 0 set -l i 2 diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish index d075ff51ced4..98a413a4497f 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish @@ -2,7 +2,6 @@ function _mycommand set -l tokens (commandline -opc) - set -l current (commandline -ct) set -l state 0 set -l i 2 diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish index 208f8a2d310e..0936b3b28cf8 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish @@ -2,7 +2,6 @@ function _mycommand set -l tokens (commandline -opc) - set -l current (commandline -ct) set -l state 0 set -l i 2 diff --git a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish index 12109db9316a..92f17b003bcb 100644 --- a/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish +++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish @@ -2,7 +2,6 @@ function _mycommand set -l tokens (commandline -opc) - set -l current (commandline -ct) set -l state 0 set -l i 2 From 0d421ac85c1b017bbacf4b8c58813635ea46dce6 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Wed, 8 Apr 2026 20:02:14 +0100 Subject: [PATCH 4/4] Use exact System.CommandLine sentinel for unbounded arity threshold Change UnboundedArityThreshold from 100 to 100_000 to match the actual sentinel value used by System.CommandLine for ZeroOrMore/OneOrMore. Co-Authored-By: Claude Opus 4.6 --- .../shells/FishShellProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs b/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs index 830d15ba5b97..27b4fc71fa34 100644 --- a/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs +++ b/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs @@ -80,7 +80,7 @@ private static void WriteTokenization(IndentedTextWriter writer) // Options with MaximumNumberOfValues at or above this threshold are treated as unbounded // (i.e. consume tokens until an option-like token is encountered). // System.CommandLine uses 100_000 as its internal sentinel for ZeroOrMore/OneOrMore. - private const int UnboundedArityThreshold = 100; + private const int UnboundedArityThreshold = 100_000; /// /// Generate the state machine that walks completed tokens to determine which subcommand context we're in.