Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ var (
//go:embed autocomplete
autoCompleteFS embed.FS

// completionShells defines the order in which the shell completion
// subcommands appear in help output. Iterating shellCompletions directly
// would use Go's randomized map order, making the listing nondeterministic.
// Keep this in sync with shellCompletions.
completionShells = []string{"bash", "zsh", "fish", "pwsh"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it possible to get sorted list instead of maintaining hardcoded indexes?

@suzuki-shunsuke suzuki-shunsuke Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In case of the dictionary order, the order is changed.

  1. bash
  2. fish
  3. pwsh
  4. zsh

Is that okay?

I think the current order is more natural.

  1. bash
  2. zsh
  3. fish
  4. pwsh

https://cli.urfave.org/v3/examples/completions/shell-completions/

image


shellCompletions = map[string]renderCompletion{
"bash": func(c *Command, appName string) (string, error) {
b, err := autoCompleteFS.ReadFile("autocomplete/bash_autocomplete")
Expand Down Expand Up @@ -65,8 +71,8 @@ func buildCompletionCommand(appName string) *Command {
isCompletionCommand: true,
}

for shell, render := range shellCompletions {
cmd.Commands = append(cmd.Commands, buildShellCompletionSubcommand(shell, render, appName))
for _, shell := range completionShells {
cmd.Commands = append(cmd.Commands, buildShellCompletionSubcommand(shell, shellCompletions[shell], appName))
}

return cmd
Expand Down
35 changes: 35 additions & 0 deletions completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,31 @@ func TestCompletionShell(t *testing.T) {
}
}

func TestCompletionSubcommandOrder(t *testing.T) {
// The completion subcommands must appear in a deterministic order so that
// help output (and docs generated from it) does not change between runs.
// Previously they were built by iterating a map, whose order Go randomizes.
want := []string{"bash", "zsh", "fish", "pwsh"}

// Build several times to guard against intra-process variation.
for range 10 {
cmd := buildCompletionCommand("foo")

got := make([]string, 0, len(cmd.Commands))
for _, sub := range cmd.Commands {
got = append(got, sub.Name)
}

assert.Equal(t, want, got)
}

// Every shell in shellCompletions must be represented in the ordered list.
assert.Len(t, completionShells, len(shellCompletions))
for shell := range shellCompletions {
assert.Contains(t, completionShells, shell)
}
}

func TestCompletionBashNoShebang(t *testing.T) {
// Regression test for https://github.com/urfave/cli/issues/2259
// Bash completion scripts are sourced, not executed, so they must not
Expand Down Expand Up @@ -494,6 +519,11 @@ func TestCompletionShellRenderError(t *testing.T) {
}
return "something", nil
}
// buildCompletionCommand only turns shells listed in completionShells into
// subcommands, so register the injected shell there too (restoring the
// original slice afterward) for it to be reachable.
defer func(orig []string) { completionShells = orig }(completionShells)
completionShells = append(completionShells, unknownShellName)
defer func() {
delete(shellCompletions, unknownShellName)
}()
Expand Down Expand Up @@ -522,6 +552,11 @@ func TestCompletionShellWriteError(t *testing.T) {
shellCompletions[shellName] = func(c *Command, appName string) (string, error) {
return "something", nil
}
// buildCompletionCommand only turns shells listed in completionShells into
// subcommands, so register the injected shell there too (restoring the
// original slice afterward) for it to be reachable.
defer func(orig []string) { completionShells = orig }(completionShells)
completionShells = append(completionShells, shellName)
defer func() {
delete(shellCompletions, shellName)
}()
Expand Down