From 24ada7fe71e3a3a8741dd52e0a7fc3b97450535a Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Sat, 15 Feb 2025 13:07:02 -0500 Subject: [PATCH] Remove the default "completion" cmd if it is alone (#1559) When a program has no sub-commands, its root command can accept arguments. If we add the default "completion" command to such programs they will now have a sub-command and will no longer accept arguments. What we do instead for this special case, is only add the "completion" command if it is being called, or if it is being completed itself. We want to have the "completion" command for such programs because it will allow the completion of flags and of arguments (if provided by the program). Signed-off-by: Marc Khouzam --- command.go | 15 +-- completions.go | 30 +++++- completions_test.go | 144 ++++++++++++++++++++++++++++- site/content/completions/_index.md | 3 +- 4 files changed, 179 insertions(+), 13 deletions(-) diff --git a/command.go b/command.go index b23ece3db..fbefc053d 100644 --- a/command.go +++ b/command.go @@ -1097,12 +1097,6 @@ func (c *Command) ExecuteC() (cmd *Command, err error) { // initialize help at the last point to allow for user overriding c.InitDefaultHelpCmd() - // initialize completion at the last point to allow for user overriding - c.InitDefaultCompletionCmd() - - // Now that all commands have been created, let's make sure all groups - // are properly created also - c.checkCommandGroups() args := c.args @@ -1114,9 +1108,16 @@ func (c *Command) ExecuteC() (cmd *Command, err error) { args = os.Args[1:] } - // initialize the hidden command to be used for shell completion + // initialize the __complete command to be used for shell completion c.initCompleteCmd(args) + // initialize the default completion command + c.InitDefaultCompletionCmd(args...) + + // Now that all commands have been created, let's make sure all groups + // are properly created also + c.checkCommandGroups() + var flags []string if c.TraverseChildren { cmd, flags, err = c.Traverse(args) diff --git a/completions.go b/completions.go index fd30e76cc..cedb4d7c2 100644 --- a/completions.go +++ b/completions.go @@ -727,8 +727,8 @@ func checkIfFlagCompletion(finalCmd *Command, args []string, lastArg string) (*p // 1- the feature has been explicitly disabled by the program, // 2- c has no subcommands (to avoid creating one), // 3- c already has a 'completion' command provided by the program. -func (c *Command) InitDefaultCompletionCmd() { - if c.CompletionOptions.DisableDefaultCmd || !c.HasSubCommands() { +func (c *Command) InitDefaultCompletionCmd(args ...string) { + if c.CompletionOptions.DisableDefaultCmd { return } @@ -741,6 +741,16 @@ func (c *Command) InitDefaultCompletionCmd() { haveNoDescFlag := !c.CompletionOptions.DisableNoDescFlag && !c.CompletionOptions.DisableDescriptions + // Special case to know if there are sub-commands or not. + hasSubCommands := false + for _, cmd := range c.commands { + if cmd.Name() != ShellCompRequestCmd && cmd.Name() != helpCommandName { + // We found a real sub-command (not 'help' or '__complete') + hasSubCommands = true + break + } + } + completionCmd := &Command{ Use: compCmdName, Short: "Generate the autocompletion script for the specified shell", @@ -754,6 +764,22 @@ See each sub-command's help for details on how to use the generated script. } c.AddCommand(completionCmd) + if !hasSubCommands { + // If the 'completion' command will be the only sub-command, + // we only create it if it is actually being called. + // This avoids breaking programs that would suddenly find themselves with + // a subcommand, which would prevent them from accepting arguments. + // We also create the 'completion' command if the user is triggering + // shell completion for it (prog __complete completion '') + subCmd, cmdArgs, err := c.Find(args) + if err != nil || subCmd.Name() != compCmdName && + !(subCmd.Name() == ShellCompRequestCmd && len(cmdArgs) > 1 && cmdArgs[0] == compCmdName) { + // The completion command is not being called or being completed so we remove it. + c.RemoveCommand(completionCmd) + return + } + } + out := c.OutOrStdout() noDesc := c.CompletionOptions.DisableDescriptions shortDesc := "Generate the autocompletion script for %s" diff --git a/completions_test.go b/completions_test.go index 62c203e16..964e389a3 100644 --- a/completions_test.go +++ b/completions_test.go @@ -2465,7 +2465,7 @@ func TestDefaultCompletionCmd(t *testing.T) { Run: emptyRun, } - // Test that no completion command is created if there are not other sub-commands + // Test that when there are no sub-commands, the completion command is not created if it is not called directly. assertNoErr(t, rootCmd.Execute()) for _, cmd := range rootCmd.commands { if cmd.Name() == compCmdName { @@ -2474,6 +2474,17 @@ func TestDefaultCompletionCmd(t *testing.T) { } } + // Test that when there are no sub-commands, the completion command is created when it is called directly. + _, err := executeCommand(rootCmd, compCmdName) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + // Reset the arguments + rootCmd.args = nil + // Remove completion command for the next test + removeCompCmd(rootCmd) + + // Add a sub-command subCmd := &Command{ Use: "sub", Run: emptyRun, @@ -2595,6 +2606,42 @@ func TestDefaultCompletionCmd(t *testing.T) { func TestCompleteCompletion(t *testing.T) { rootCmd := &Command{Use: "root", Args: NoArgs, Run: emptyRun} + + // Test that when there are no sub-commands, the 'completion' command is not completed + // (because it is not created). + output, err := executeCommand(rootCmd, ShellCompNoDescRequestCmd, "completion") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected := strings.Join([]string{ + ":0", + "Completion ended with directive: ShellCompDirectiveDefault", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } + + // Test that when there are no sub-commands, completion can be triggered for the default + // 'completion' command + output, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, "completion", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected = strings.Join([]string{ + "bash", + "fish", + "powershell", + "zsh", + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } + + // Add a sub-command subCmd := &Command{ Use: "sub", Run: emptyRun, @@ -2602,12 +2649,12 @@ func TestCompleteCompletion(t *testing.T) { rootCmd.AddCommand(subCmd) // Test sub-commands of the completion command - output, err := executeCommand(rootCmd, ShellCompNoDescRequestCmd, "completion", "") + output, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, "completion", "") if err != nil { t.Errorf("Unexpected error: %v", err) } - expected := strings.Join([]string{ + expected = strings.Join([]string{ "bash", "fish", "powershell", @@ -3792,3 +3839,94 @@ func TestDisableDescriptions(t *testing.T) { }) } } + +// A test to make sure the InitDefaultCompletionCmd function works as expected +// in case a project calls it directly. +func TestInitDefaultCompletionCmd(t *testing.T) { + + testCases := []struct { + desc string + hasChildCmd bool + args []string + expectCompCmd bool + }{ + { + desc: "no child command and not calling the completion command", + hasChildCmd: false, + args: []string{"somearg"}, + expectCompCmd: false, + }, + { + desc: "no child command but calling the completion command", + hasChildCmd: false, + args: []string{"completion"}, + expectCompCmd: true, + }, + { + desc: "no child command but calling __complete on the root command", + hasChildCmd: false, + args: []string{"__complete", ""}, + expectCompCmd: false, + }, + { + desc: "no child command but calling __complete on the completion command", + hasChildCmd: false, + args: []string{"__complete", "completion", ""}, + expectCompCmd: true, + }, + { + desc: "with child command", + hasChildCmd: true, + args: []string{"child"}, + expectCompCmd: true, + }, + { + desc: "no child command not passing args", + hasChildCmd: false, + args: nil, + expectCompCmd: false, + }, + { + desc: "with child command not passing args", + hasChildCmd: true, + args: nil, + expectCompCmd: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + rootCmd := &Command{Use: "root", Run: emptyRun} + childCmd := &Command{Use: "child", Run: emptyRun} + + expectedNumSubCommands := 0 + if tc.hasChildCmd { + rootCmd.AddCommand(childCmd) + expectedNumSubCommands++ + } + + if tc.expectCompCmd { + expectedNumSubCommands++ + } + + if len(tc.args) > 0 && tc.args[0] == "__complete" { + expectedNumSubCommands++ + } + + // Setup the __complete command to mimic real world scenarios + rootCmd.initCompleteCmd(tc.args) + + // Call the InitDefaultCompletionCmd function directly + if tc.args == nil { + rootCmd.InitDefaultCompletionCmd() + } else { + rootCmd.InitDefaultCompletionCmd(tc.args...) + } + + // Check if the completion command was added + if len(rootCmd.Commands()) != expectedNumSubCommands { + t.Errorf("Expected %d subcommands, got %d", expectedNumSubCommands, len(rootCmd.Commands())) + } + }) + } +} diff --git a/site/content/completions/_index.md b/site/content/completions/_index.md index fbe780145..5996707a1 100644 --- a/site/content/completions/_index.md +++ b/site/content/completions/_index.md @@ -8,7 +8,8 @@ The currently supported shells are: - PowerShell Cobra will automatically provide your program with a fully functional `completion` command, -similarly to how it provides the `help` command. +similarly to how it provides the `help` command. If there are no other subcommands, the +default `completion` command will be hidden, but still functional. ## Creating your own completion command