diff --git a/completion.go b/completion.go index 167b0c0123..f14b01430a 100644 --- a/completion.go +++ b/completion.go @@ -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"} + shellCompletions = map[string]renderCompletion{ "bash": func(c *Command, appName string) (string, error) { b, err := autoCompleteFS.ReadFile("autocomplete/bash_autocomplete") @@ -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 diff --git a/completion_test.go b/completion_test.go index b248e352db..8550f6b41a 100644 --- a/completion_test.go +++ b/completion_test.go @@ -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 @@ -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) }() @@ -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) }()