Skip to content

Enhance fish shell completions with static+dynamic hybrid generation#53716

Merged
baronfel merged 4 commits intodotnet:mainfrom
slang25:slang25/fish-static-completions
Apr 8, 2026
Merged

Enhance fish shell completions with static+dynamic hybrid generation#53716
baronfel merged 4 commits intodotnet:mainfrom
slang25:slang25/fish-static-completions

Conversation

@slang25
Copy link
Copy Markdown
Contributor

@slang25 slang25 commented Apr 6, 2026

Summary

  • Replace the fish shell provider's simple dynamic one-liner (complete -f -c dotnet -a "(dotnet complete (commandline -cp))") 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 (including all aliases), and positional arguments — falling back to dynamic complete calls where IsDynamic is set.
  • Add 6 snapshot tests for the fish provider covering generic, option, subcommand, nested subcommand, dynamic, and static option value scenarios.

Test plan

  • All 21 StaticCompletions tests pass (15 existing + 6 new fish tests)
  • Manual verification with a fish shell and a real dotnet binary

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 6, 2026 18:07
@baronfel
Copy link
Copy Markdown
Member

baronfel commented Apr 6, 2026

HECK YEAH! Thanks for sending this, I'll try to review this afternoon.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR upgrades the Fish shell completions generator from a simple dynamic invocation to a hybrid static+dynamic generator (similar in spirit to the existing Bash/Zsh/PowerShell providers), and adds snapshot coverage for the new output.

Changes:

  • Replaced Fish completion generation with a state-machine driven script that emits static completions and falls back to dynamic completions for IsDynamic symbols.
  • Added Fish snapshot tests covering generic, options/subcommands, nested subcommands, dynamic completions, and static option values.
  • Updated Verify configuration to treat .fish snapshot files as text.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

File Description
test/System.CommandLine.StaticCompletions.Tests/VerifyConfiguration.cs Registers .fish as a text extension for snapshot verification.
test/System.CommandLine.StaticCompletions.Tests/FishShellProviderTests.cs Adds Fish provider snapshot tests.
src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs Implements the new static+dynamic Fish completion script generator.
test/System.CommandLine.StaticCompletions.Tests/snapshots/fish/*.verified.fish Adds the expected Fish completion script snapshots for the new tests.

Comment on lines +71 to +78
/// Uses fish's commandline builtin to get completed tokens and the current partial word.
/// </summary>
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)");
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated fish script assigns current from commandline -ct but never uses it anywhere in the output. Consider removing this line (or using current to filter/short-circuit) to avoid dead code in the generated completion function.

Suggested change
/// Uses fish's commandline builtin to get completed tokens and the current partial word.
/// </summary>
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)");
/// Uses fish's commandline builtin to get completed tokens up to the cursor.
/// </summary>
private static void WriteTokenization(IndentedTextWriter writer)
{
// -opc: tokenize, cut at cursor, only completed tokens
writer.WriteLine("set -l tokens (commandline -opc)");

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +123
// 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--;
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WriteStateWalker resolves each subcommand’s state id via states.First(...) inside nested loops, making generation O(n^2) in the number of commands. For large command graphs (e.g., dotnet), consider building a Dictionary<Command,int> once (e.g., when collecting states) and using it for O(1) lookups.

Copilot uses AI. Check for mistakes.
Comment thread src/System.CommandLine.StaticCompletions/shells/FishShellProvider.cs Outdated
@baronfel baronfel force-pushed the slang25/fish-static-completions branch from 6fb5dca to 2320bab Compare April 8, 2026 14:04
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 <noreply@anthropic.com>
@slang25 slang25 force-pushed the slang25/fish-static-completions branch from 2320bab to 1977339 Compare April 8, 2026 17:39
@baronfel baronfel added this to the 11.0.1xx milestone Apr 8, 2026
@baronfel baronfel enabled auto-merge (squash) April 8, 2026 17:42
…kups

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<Command, int> for O(1) state ID resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
auto-merge was automatically disabled April 8, 2026 18:40

Head branch was pushed to by a user without write access

@slang25 slang25 requested a review from Copilot April 8, 2026 18:48
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

// 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;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnboundedArityThreshold is set to 100, but the comment indicates System.CommandLine uses 100_000 as the sentinel for unbounded arities (ZeroOrMore/OneOrMore). With the current value, any option with a bounded max >99 will be treated as unbounded, which can change parsing/completion behavior. Consider using the actual sentinel (or a check keyed specifically to the unbounded sentinel) so large-but-bounded arities stay bounded.

Suggested change
private const int UnboundedArityThreshold = 100;
private const int UnboundedArityThreshold = 100_000;

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +193
writer.WriteLine("if string match -q -- '-*' $next");
writer.Indent++;
writer.WriteLine("break");
writer.Indent--;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The multi-value skip loop stops when the next token matches -*. This will incorrectly stop consuming values for legitimate option values that start with - (e.g., negative numbers like -1, or strings beginning with a dash), causing the state machine to mis-parse the command line. Consider checking whether the next token is actually one of the known option names for the current command context (and handling -- end-of-options) instead of using a prefix heuristic.

Suggested change
writer.WriteLine("if string match -q -- '-*' $next");
writer.Indent++;
writer.WriteLine("break");
writer.Indent--;
writer.WriteLine("if test \"$next\" = \"--\"");
writer.Indent++;
writer.WriteLine("break");
writer.Indent--;
writer.WriteLine("else if string match -rq -- '^--.+|^-[^0-9].*' $next");
writer.Indent++;
writer.WriteLine("break");
writer.Indent--;

Copilot uses AI. Check for mistakes.
Comment on lines +218 to +233
// 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");
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option-value completion scans backward for the nearest token matching -* to decide which option we're completing. This can misidentify negative numeric values (or other dash-prefixed values) as the "current option", which will suppress/alter completions incorrectly. Consider restricting the scan to tokens that match known option names in the current state (and treating -- as an end-of-options marker).

Suggested change
// 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");
// Scan backward through tokens to find the nearest known value-taking option for the current state.
// Stop scanning if we encounter the end-of-options marker.
writer.WriteLine("set -l opt_index 0");
writer.WriteLine("if test (count $tokens) -ge 2");
writer.Indent++;
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("for j in (seq (count $tokens) -1 2)");
writer.Indent++;
writer.WriteLine("if test $tokens[$j] = '--'");
writer.Indent++;
writer.WriteLine("break");
writer.Indent--;
writer.WriteLine("end");
writer.WriteLine("switch $tokens[$j]");
writer.Indent++;
foreach (var option in valueOptions)
{
var names = string.Join(" ", option.Names());
writer.WriteLine($"case {names}");
writer.Indent++;
writer.WriteLine("set opt_index $j");
writer.WriteLine("break");
writer.Indent--;
}
writer.Indent--;
writer.WriteLine("end");
writer.Indent--;
writer.WriteLine("end");
writer.Indent--;
}
writer.Indent--;
writer.WriteLine("end");
writer.Indent--;
writer.WriteLine("end");

Copilot uses AI. Check for mistakes.
Comment on lines +118 to +123
foreach (var sub in visibleSubs)
{
var subStateId = stateIdByCommand[sub];
var names = string.Join(" ", sub.Names());
writer.WriteLine($"case {names}");
writer.Indent++;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fish switch/case patterns are glob patterns. Emitting raw command/option names into case clauses (e.g., case {names}) can cause unexpected matches if a name contains glob metacharacters like *, ?, or []. Consider escaping fish glob metacharacters when generating case patterns so symbol names are treated literally.

Copilot uses AI. Check for mistakes.
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 <noreply@anthropic.com>
@baronfel
Copy link
Copy Markdown
Member

baronfel commented Apr 9, 2026

/backport to release/10.0.3xx

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 9, 2026

Started backporting to release/10.0.3xx (link to workflow run)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants