diff --git a/command.go b/command.go index 27d39d54e..dd0f9db82 100644 --- a/command.go +++ b/command.go @@ -81,6 +81,9 @@ type Command struct { // BashCompletionFunction is custom functions used by the bash autocompletion generator. BashCompletionFunction string + // CompletionOptions is a set of options to control the handling of shell completion + CompletionOptions CompletionOptions + // Deprecated defines, if this command is deprecated and should print this string when used. Deprecated string @@ -912,9 +915,10 @@ func (c *Command) ExecuteC() (cmd *Command, err error) { preExecHookFn(c) } - // initialize help as the last point possible to allow for user - // overriding + // 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() args := c.args diff --git a/custom_completions.go b/custom_completions.go index c25c03e40..ede64089f 100644 --- a/custom_completions.go +++ b/custom_completions.go @@ -63,6 +63,32 @@ const ( ShellCompDirectiveDefault ShellCompDirective = 0 ) +const ( + // Constants for the completion command + compCmdName = "completion" + compCmdNoDescFlagName = "no-descriptions" + compCmdNoDescFlagDesc = "disable the use of completion descriptions" + compCmdNoDescFlagDefault = false +) + +// CompletionOptions are the options to control shell completion +type CompletionOptions struct { + // DisableDefaultCmd prevents Cobra from creating a default 'completion' command + DisableDefaultCmd bool + // DisableNoDescFlag prevents Cobra from creating the '--no-descriptions' flag + // for shells that support completion descriptions + DisableNoDescFlag bool + // DisableDescriptions turns off all completion descriptions for shells + // that support them + DisableDescriptions bool +} + +// NoFileCompletions can be used to disable file completion for commands that should +// not trigger file completions. +func NoFileCompletions(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + return nil, ShellCompDirectiveNoFileComp +} + // RegisterFlagCompletionFunc should be called to register a function to provide completion for a flag. func (c *Command) RegisterFlagCompletionFunc(flagName string, f func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective)) error { flag := c.Flag(flagName) @@ -476,6 +502,143 @@ func checkIfFlagCompletion(finalCmd *Command, args []string, lastArg string) (*p return flag, trimmedArgs, lastArg, nil } +// initDefaultCompletionCmd adds a default 'completion' command to c. +// This function will do nothing if any of the following is true: +// 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() { + return + } + + for _, cmd := range c.commands { + if cmd.Name() == compCmdName || cmd.HasAlias(compCmdName) { + // A completion command is already available + return + } + } + + haveFlag := !c.CompletionOptions.DisableNoDescFlag && !c.CompletionOptions.DisableDescriptions + + completionCmd := &Command{ + Use: compCmdName, + Short: "generate the autocompletion script for the specified shell", + Long: fmt.Sprintf(` +Generate the autocompletion script for %[1]s for the specified shell. +See each sub-command's help for details on how to use the generated script. +`, c.Root().Name()), + Args: NoArgs, + ValidArgsFunction: NoFileCompletions, + } + c.AddCommand(completionCmd) + + out := c.OutOrStdout() + noDesc := c.CompletionOptions.DisableDescriptions + shortDesc := "generate the autocompletion script for %s" + bash := &Command{ + Use: "bash", + Short: fmt.Sprintf(shortDesc, "bash"), + Long: fmt.Sprintf(` +Generate the autocompletion script for the bash shell. + +This script depends on the 'bash-completion' package. +If it is not installed already, you can install it via your OS's package manager. + +To load completions in your current shell session: +$ source <(%[1]s completion bash) + +To load completions for every new session, execute once: +Linux: + $ %[1]s completion bash > /etc/bash_completion.d/%[1]s +MacOS: + $ %[1]s completion bash > /usr/local/etc/bash_completion.d/%[1]s + +You will need to start a new shell for this setup to take effect. + `, c.Root().Name()), + Args: NoArgs, + DisableFlagsInUseLine: true, + ValidArgsFunction: NoFileCompletions, + RunE: func(cmd *Command, args []string) error { + return cmd.Root().GenBashCompletion(out) + }, + } + + zsh := &Command{ + Use: "zsh", + Short: fmt.Sprintf(shortDesc, "zsh"), + Long: fmt.Sprintf(` +Generate the autocompletion script for the zsh shell. + +If shell completion is not already enabled in your environment you will need +to enable it. You can execute the following once: + +$ echo "autoload -U compinit; compinit" >> ~/.zshrc + +To load completions for every new session, execute once: +$ %[1]s completion zsh > "${fpath[1]}/_%[1]s" + +You will need to start a new shell for this setup to take effect. +`, c.Root().Name()), + Args: NoArgs, + ValidArgsFunction: NoFileCompletions, + RunE: func(cmd *Command, args []string) error { + if noDesc { + return cmd.Root().GenZshCompletionNoDesc(out) + } + return cmd.Root().GenZshCompletion(out) + }, + } + if haveFlag { + zsh.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc) + } + + fish := &Command{ + Use: "fish", + Short: fmt.Sprintf(shortDesc, "fish"), + Long: fmt.Sprintf(` +Generate the autocompletion script for the fish shell. + +To load completions in your current shell session: +$ %[1]s completion fish | source + +To load completions for every new session, execute once: +$ %[1]s completion fish > ~/.config/fish/completions/%[1]s.fish + +You will need to start a new shell for this setup to take effect. +`, c.Root().Name()), + Args: NoArgs, + ValidArgsFunction: NoFileCompletions, + RunE: func(cmd *Command, args []string) error { + return cmd.Root().GenFishCompletion(out, !noDesc) + }, + } + if haveFlag { + fish.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc) + } + + powershell := &Command{ + Use: "powershell", + Short: fmt.Sprintf(shortDesc, "powershell"), + Long: fmt.Sprintf(` +Generate the autocompletion script for powershell. + +To load completions in your current shell session: +PS C:\> %[1]s completion powershell | Out-String | Invoke-Expression + +To load completions for every new session, add the output of the above command +to your powershell profile. +`, c.Root().Name()), + Args: NoArgs, + DisableFlagsInUseLine: true, + ValidArgsFunction: NoFileCompletions, + RunE: func(cmd *Command, args []string) error { + return cmd.Root().GenPowerShellCompletion(out) + }, + } + completionCmd.AddCommand(bash, zsh, fish, powershell) +} + func findFlag(cmd *Command, name string) *pflag.Flag { flagSet := cmd.Flags() if len(name) == 1 { diff --git a/custom_completions_test.go b/custom_completions_test.go index 276b8a77b..dcb612636 100644 --- a/custom_completions_test.go +++ b/custom_completions_test.go @@ -75,6 +75,7 @@ func TestCmdNameCompletionInGo(t *testing.T) { expected := strings.Join([]string{ "aliased", + "completion", "firstChild", "help", "secondChild", @@ -123,6 +124,7 @@ func TestCmdNameCompletionInGo(t *testing.T) { expected = strings.Join([]string{ "aliased\tA command with aliases", + "completion\tgenerate the autocompletion script for the specified shell", "firstChild\tFirst command", "help\tHelp about any command", "secondChild", @@ -313,6 +315,7 @@ func TestValidArgsAndCmdCompletionInGo(t *testing.T) { } expected := strings.Join([]string{ + "completion", "help", "thechild", "one", @@ -363,6 +366,7 @@ func TestValidArgsFuncAndCmdCompletionInGo(t *testing.T) { } expected := strings.Join([]string{ + "completion", "help", "thechild", "one", @@ -430,6 +434,7 @@ func TestFlagNameCompletionInGo(t *testing.T) { expected := strings.Join([]string{ "childCmd", + "completion", "help", ":4", "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") @@ -513,6 +518,7 @@ func TestFlagNameCompletionInGoWithDesc(t *testing.T) { expected := strings.Join([]string{ "childCmd\tfirst command", + "completion\tgenerate the autocompletion script for the specified shell", "help\tHelp about any command", ":4", "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") @@ -741,6 +747,7 @@ func TestRequiredFlagNameCompletionInGo(t *testing.T) { expected := strings.Join([]string{ "childCmd", + "completion", "help", "--requiredFlag", "-r", @@ -866,6 +873,7 @@ func TestRequiredFlagNameCompletionInGo(t *testing.T) { expected = strings.Join([]string{ "childCmd", + "completion", "help", "--requiredFlag", "-r", @@ -1858,6 +1866,7 @@ func TestCompleteHelp(t *testing.T) { expected := strings.Join([]string{ "child1", "child2", + "completion", "help", ":4", "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") @@ -1875,6 +1884,7 @@ func TestCompleteHelp(t *testing.T) { expected = strings.Join([]string{ "child1", "child2", + "completion", "help", // " help help" is a valid command, so should be completed ":4", "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") @@ -1898,3 +1908,192 @@ func TestCompleteHelp(t *testing.T) { t.Errorf("expected: %q, got: %q", expected, output) } } + +func removeCompCmd(rootCmd *Command) { + // Remove completion command for the next test + for _, cmd := range rootCmd.commands { + if cmd.Name() == compCmdName { + rootCmd.RemoveCommand(cmd) + return + } + } +} + +func TestDefaultCompletionCmd(t *testing.T) { + rootCmd := &Command{ + Use: "root", + Args: NoArgs, + Run: emptyRun, + } + + // Test that no completion command is created if there are not other sub-commands + rootCmd.Execute() + for _, cmd := range rootCmd.commands { + if cmd.Name() == compCmdName { + t.Errorf("Should not have a 'completion' command when there are no other sub-commands of root") + break + } + } + + subCmd := &Command{ + Use: "sub", + Run: emptyRun, + } + rootCmd.AddCommand(subCmd) + + // Test that a completion command is created if there are other sub-commands + found := false + rootCmd.Execute() + for _, cmd := range rootCmd.commands { + if cmd.Name() == compCmdName { + found = true + break + } + } + if !found { + t.Errorf("Should have a 'completion' command when there are other sub-commands of root") + } + // Remove completion command for the next test + removeCompCmd(rootCmd) + + // Test that the default completion command can be disabled + rootCmd.CompletionOptions.DisableDefaultCmd = true + rootCmd.Execute() + for _, cmd := range rootCmd.commands { + if cmd.Name() == compCmdName { + t.Errorf("Should not have a 'completion' command when the feature is disabled") + break + } + } + // Re-enable for next test + rootCmd.CompletionOptions.DisableDefaultCmd = false + + // Test that completion descriptions are enabled by default + output, err := executeCommand(rootCmd, compCmdName, "zsh") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + check(t, output, ShellCompRequestCmd) + checkOmit(t, output, ShellCompNoDescRequestCmd) + // Remove completion command for the next test + removeCompCmd(rootCmd) + + // Test that completion descriptions can be disabled completely + rootCmd.CompletionOptions.DisableDescriptions = true + output, err = executeCommand(rootCmd, compCmdName, "zsh") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + check(t, output, ShellCompNoDescRequestCmd) + // Re-enable for next test + rootCmd.CompletionOptions.DisableDescriptions = false + // Remove completion command for the next test + removeCompCmd(rootCmd) + + var compCmd *Command + // Test that the --no-descriptions flag is present for the relevant shells only + rootCmd.Execute() + for _, shell := range []string{"fish", "zsh"} { + if compCmd, _, err = rootCmd.Find([]string{compCmdName, shell}); err != nil { + t.Errorf("Unexpected error: %v", err) + } + if flag := compCmd.Flags().Lookup(compCmdNoDescFlagName); flag == nil { + t.Errorf("Missing --%s flag for %s shell", compCmdNoDescFlagName, shell) + } + } + for _, shell := range []string{"bash", "powershell"} { + if compCmd, _, err = rootCmd.Find([]string{compCmdName, shell}); err != nil { + t.Errorf("Unexpected error: %v", err) + } + if flag := compCmd.Flags().Lookup(compCmdNoDescFlagName); flag != nil { + t.Errorf("Unexpected --%s flag for %s shell", compCmdNoDescFlagName, shell) + } + } + // Remove completion command for the next test + removeCompCmd(rootCmd) + + // Test that the '--no-descriptions' flag can be disabled + rootCmd.CompletionOptions.DisableNoDescFlag = true + rootCmd.Execute() + for _, shell := range []string{"fish", "zsh", "bash", "powershell"} { + if compCmd, _, err = rootCmd.Find([]string{compCmdName, shell}); err != nil { + t.Errorf("Unexpected error: %v", err) + } + if flag := compCmd.Flags().Lookup(compCmdNoDescFlagName); flag != nil { + t.Errorf("Unexpected --%s flag for %s shell", compCmdNoDescFlagName, shell) + } + } + // Re-enable for next test + rootCmd.CompletionOptions.DisableNoDescFlag = false + // Remove completion command for the next test + removeCompCmd(rootCmd) + + // Test that the '--no-descriptions' flag is disabled when descriptions are disabled + rootCmd.CompletionOptions.DisableDescriptions = true + rootCmd.Execute() + for _, shell := range []string{"fish", "zsh", "bash", "powershell"} { + if compCmd, _, err = rootCmd.Find([]string{compCmdName, shell}); err != nil { + t.Errorf("Unexpected error: %v", err) + } + if flag := compCmd.Flags().Lookup(compCmdNoDescFlagName); flag != nil { + t.Errorf("Unexpected --%s flag for %s shell", compCmdNoDescFlagName, shell) + } + } + // Re-enable for next test + rootCmd.CompletionOptions.DisableDescriptions = false + // Remove completion command for the next test + removeCompCmd(rootCmd) +} + +func TestCompleteCompletion(t *testing.T) { + rootCmd := &Command{Use: "root", Args: NoArgs, Run: emptyRun} + subCmd := &Command{ + Use: "sub", + Run: emptyRun, + } + rootCmd.AddCommand(subCmd) + + // Test sub-commands of the 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) + } + + // Test there are no completions for the sub-commands of the completion command + var compCmd *Command + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == compCmdName { + compCmd = cmd + break + } + } + + for _, shell := range compCmd.Commands() { + output, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, compCmdName, shell.Name(), "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected = strings.Join([]string{ + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } + } +}