diff --git a/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs b/src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs
index b9555424d493..27b4fc71fa34 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,388 @@ 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);
+ var stateIdByCommand = states.ToDictionary(s => s.cmd, s => s.id);
+
+ // Write the main completion function
+ writer.WriteLine($"function _{safeName}");
+ writer.Indent++;
+
+ WriteTokenization(writer);
+ writer.WriteLine();
+ WriteStateWalker(writer, states, stateIdByCommand);
+ 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 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)");
+ }
+
+ // 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_000;
+
+ ///
+ /// 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 tokens for the option's value(s), respecting the option's arity).
+ ///
+ 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)
+ 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 valueOptions = cmd.HierarchicalOptions()
+ .Where(o => !o.Hidden && !o.IsFlag())
+ .ToArray();
+
+ // Skip states that have no transitions to emit
+ if (visibleSubs.Length == 0 && valueOptions.Length == 0)
+ continue;
+
+ writer.WriteLine($"case {stateId}");
+ writer.Indent++;
+ writer.WriteLine("switch $word");
+ writer.Indent++;
+
+ // Subcommand transitions
+ foreach (var sub in visibleSubs)
+ {
+ var subStateId = stateIdByCommand[sub];
+ var names = string.Join(" ", sub.Names());
+ writer.WriteLine($"case {names}");
+ writer.Indent++;
+ writer.WriteLine($"set state {subStateId}");
+ writer.Indent--;
+ }
+
+ // 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(" ", 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--;
+ }
+
+ writer.Indent--;
+ writer.WriteLine("end");
+
+ writer.WriteLine("set i (math $i + 1)");
+ writer.Indent--;
+ 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.
+ /// 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)
+ {
+ var hasAnyValueOptions = states.Any(s =>
+ s.cmd.HierarchicalOptions().Any(o => !o.Hidden && !o.IsFlag()));
+
+ if (!hasAnyValueOptions) return;
+
+ // 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("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++;
+
+ 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 $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());
+ }
+ else
+ {
+ var completions = option.GetCompletions(CompletionContext.Empty).ToArray();
+ foreach (var c in completions)
+ {
+ WriteCompletionCandidate(writer, c);
+ }
+ }
+ writer.WriteLine("return");
+
+ if (isBounded)
+ {
+ writer.Indent--;
+ writer.WriteLine("end");
+ }
+
+ 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..c8f385a5013b
--- /dev/null
+++ b/test/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs
@@ -0,0 +1,137 @@
+// 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);
+ }
+
+ [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/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.BoundedMultiValueOption.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish
new file mode 100644
index 000000000000..b33eebee909a
--- /dev/null
+++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.BoundedMultiValueOption.verified.fish
@@ -0,0 +1,67 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ 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
new file mode 100644
index 000000000000..f7f353523b26
--- /dev/null
+++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.DynamicCompletionsGeneration.verified.fish
@@ -0,0 +1,52 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ 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
+
+ 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 --dynamic
+ if test $values_after -lt 1
+ command $tokens[1] complete --position (commandline -C) (commandline -cp) 2>/dev/null
+ return
+ end
+ 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..c86386e997c8
--- /dev/null
+++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.GenericCompletions.verified.fish
@@ -0,0 +1,20 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ 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.MixedArityOptions.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish
new file mode 100644
index 000000000000..a5728a17ce22
--- /dev/null
+++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.MixedArityOptions.verified.fish
@@ -0,0 +1,87 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ 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.NestedSubcommandCompletion.verified.fish b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish
new file mode 100644
index 000000000000..400d43cf29c3
--- /dev/null
+++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.NestedSubcommandCompletion.verified.fish
@@ -0,0 +1,34 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ 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..68d8ab21e67b
--- /dev/null
+++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SimpleOptionCompletion.verified.fish
@@ -0,0 +1,50 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ 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
+
+ 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 --name
+ if test $values_after -lt 1
+ return
+ end
+ 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..98a413a4497f
--- /dev/null
+++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.StaticOptionValues.verified.fish
@@ -0,0 +1,55 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ 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
+
+ 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 --verbosity
+ 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
+
+ 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..0936b3b28cf8
--- /dev/null
+++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.SubcommandAndOptionInTopLevelList.verified.fish
@@ -0,0 +1,54 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ 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
+
+ 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 --name
+ if test $values_after -lt 1
+ return
+ end
+ 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)'
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..92f17b003bcb
--- /dev/null
+++ b/test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/FishShellProviderTests.UnboundedMultiValueOption.verified.fish
@@ -0,0 +1,68 @@
+# fish completions for mycommand
+
+function _mycommand
+ set -l tokens (commandline -opc)
+
+ 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)'