Create 'completion' command automatically (#1192)

Make it easier for programs to provide shell completion by creating
the 'completion' command automatically.

If a 'completion' command is already provided by the program, Cobra
will use that one instead.

Signed-off-by: Marc Khouzam <marc.khouzam@montreal.ca>
This commit is contained in:
Marc Khouzam 2021-02-15 12:47:01 -05:00 committed by GitHub
parent ded486a867
commit b312f0a8ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 406 additions and 3 deletions

View file

@ -168,6 +168,9 @@ type Command struct {
//FParseErrWhitelist flag parse errors to be ignored //FParseErrWhitelist flag parse errors to be ignored
FParseErrWhitelist FParseErrWhitelist FParseErrWhitelist FParseErrWhitelist
// CompletionOptions is a set of options to control the handling of shell completion
CompletionOptions CompletionOptions
// commandsAreSorted defines, if command slice are sorted or not. // commandsAreSorted defines, if command slice are sorted or not.
commandsAreSorted bool commandsAreSorted bool
// commandCalledAs is the name or alias value used to call this command. // commandCalledAs is the name or alias value used to call this command.
@ -914,9 +917,10 @@ func (c *Command) ExecuteC() (cmd *Command, err error) {
preExecHookFn(c) preExecHookFn(c)
} }
// initialize help as the last point possible to allow for user // initialize help at the last point to allow for user overriding
// overriding
c.InitDefaultHelpCmd() c.InitDefaultHelpCmd()
// initialize completion at the last point to allow for user overriding
c.initDefaultCompletionCmd()
args := c.args args := c.args

View file

@ -63,6 +63,32 @@ const (
ShellCompDirectiveDefault ShellCompDirective = 0 ShellCompDirectiveDefault ShellCompDirective = 0
) )
const (
// Constants for the completion command
compCmdName = "completion"
compCmdNoDescFlagName = "no-descriptions"
compCmdNoDescFlagDesc = "disable 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. // 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 { func (c *Command) RegisterFlagCompletionFunc(flagName string, f func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective)) error {
flag := c.Flag(flagName) flag := c.Flag(flagName)
@ -494,6 +520,150 @@ func checkIfFlagCompletion(finalCmd *Command, args []string, lastArg string) (*p
return flag, trimmedArgs, lastArg, nil 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
}
}
haveNoDescFlag := !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 haveNoDescFlag {
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 haveNoDescFlag {
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,
ValidArgsFunction: NoFileCompletions,
RunE: func(cmd *Command, args []string) error {
if noDesc {
return cmd.Root().GenPowerShellCompletion(out)
}
return cmd.Root().GenPowerShellCompletionWithDesc(out)
},
}
if haveNoDescFlag {
powershell.Flags().BoolVar(&noDesc, compCmdNoDescFlagName, compCmdNoDescFlagDefault, compCmdNoDescFlagDesc)
}
completionCmd.AddCommand(bash, zsh, fish, powershell)
}
func findFlag(cmd *Command, name string) *pflag.Flag { func findFlag(cmd *Command, name string) *pflag.Flag {
flagSet := cmd.Flags() flagSet := cmd.Flags()
if len(name) == 1 { if len(name) == 1 {

View file

@ -75,6 +75,7 @@ func TestCmdNameCompletionInGo(t *testing.T) {
expected := strings.Join([]string{ expected := strings.Join([]string{
"aliased", "aliased",
"completion",
"firstChild", "firstChild",
"help", "help",
"secondChild", "secondChild",
@ -123,6 +124,7 @@ func TestCmdNameCompletionInGo(t *testing.T) {
expected = strings.Join([]string{ expected = strings.Join([]string{
"aliased\tA command with aliases", "aliased\tA command with aliases",
"completion\tgenerate the autocompletion script for the specified shell",
"firstChild\tFirst command", "firstChild\tFirst command",
"help\tHelp about any command", "help\tHelp about any command",
"secondChild", "secondChild",
@ -202,6 +204,7 @@ func TestNoCmdNameCompletionInGo(t *testing.T) {
expected = strings.Join([]string{ expected = strings.Join([]string{
"childCmd1", "childCmd1",
"completion",
"help", "help",
":4", ":4",
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@ -373,6 +376,7 @@ func TestValidArgsAndCmdCompletionInGo(t *testing.T) {
} }
expected := strings.Join([]string{ expected := strings.Join([]string{
"completion",
"help", "help",
"thechild", "thechild",
"one", "one",
@ -423,6 +427,7 @@ func TestValidArgsFuncAndCmdCompletionInGo(t *testing.T) {
} }
expected := strings.Join([]string{ expected := strings.Join([]string{
"completion",
"help", "help",
"thechild", "thechild",
"one", "one",
@ -490,6 +495,7 @@ func TestFlagNameCompletionInGo(t *testing.T) {
expected := strings.Join([]string{ expected := strings.Join([]string{
"childCmd", "childCmd",
"completion",
"help", "help",
":4", ":4",
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@ -573,6 +579,7 @@ func TestFlagNameCompletionInGoWithDesc(t *testing.T) {
expected := strings.Join([]string{ expected := strings.Join([]string{
"childCmd\tfirst command", "childCmd\tfirst command",
"completion\tgenerate the autocompletion script for the specified shell",
"help\tHelp about any command", "help\tHelp about any command",
":4", ":4",
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@ -801,6 +808,7 @@ func TestRequiredFlagNameCompletionInGo(t *testing.T) {
expected := strings.Join([]string{ expected := strings.Join([]string{
"childCmd", "childCmd",
"completion",
"help", "help",
"--requiredFlag", "--requiredFlag",
"-r", "-r",
@ -926,6 +934,7 @@ func TestRequiredFlagNameCompletionInGo(t *testing.T) {
expected = strings.Join([]string{ expected = strings.Join([]string{
"childCmd", "childCmd",
"completion",
"help", "help",
"--requiredFlag", "--requiredFlag",
"-r", "-r",
@ -1918,6 +1927,7 @@ func TestCompleteHelp(t *testing.T) {
expected := strings.Join([]string{ expected := strings.Join([]string{
"child1", "child1",
"child2", "child2",
"completion",
"help", "help",
":4", ":4",
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@ -1935,6 +1945,7 @@ func TestCompleteHelp(t *testing.T) {
expected = strings.Join([]string{ expected = strings.Join([]string{
"child1", "child1",
"child2", "child2",
"completion",
"help", // "<program> help help" is a valid command, so should be completed "help", // "<program> help help" is a valid command, so should be completed
":4", ":4",
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@ -1958,3 +1969,192 @@ func TestCompleteHelp(t *testing.T) {
t.Errorf("expected: %q, got: %q", expected, output) 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
assertNoErr(t, 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
assertNoErr(t, 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
assertNoErr(t, 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
assertNoErr(t, rootCmd.Execute())
for _, shell := range []string{"fish", "powershell", "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"} {
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
assertNoErr(t, 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
assertNoErr(t, 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)
}
}
}

View file

@ -7,6 +7,15 @@ The currently supported shells are:
- fish - fish
- PowerShell - PowerShell
Cobra will automatically provide your program with a fully functional `completion` command,
similarly to how it provides the `help` command.
## Creating your own completion command
If you do not wish to use the default `completion` command, you can choose to
provide your own, which will take precedence over the default one. (This also provides
backwards-compatibility with programs that already have their own `completion` command.)
If you are using the generator, you can create a completion command by running If you are using the generator, you can create a completion command by running
```bash ```bash
@ -70,7 +79,7 @@ PowerShell:
case "fish": case "fish":
cmd.Root().GenFishCompletion(os.Stdout, true) cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell": case "powershell":
cmd.Root().GenPowerShellCompletion(os.Stdout) cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
} }
}, },
} }
@ -78,6 +87,26 @@ PowerShell:
**Note:** The cobra generator may include messages printed to stdout, for example, if the config file is loaded; this will break the auto-completion script so must be removed. **Note:** The cobra generator may include messages printed to stdout, for example, if the config file is loaded; this will break the auto-completion script so must be removed.
## Adapting the default completion command
Cobra provides a few options for the default `completion` command. To configure such options you must set
the `CompletionOptions` field on the *root* command.
To tell Cobra *not* to provide the default `completion` command:
```
rootCmd.CompletionOptions.DisableDefaultCmd = true
```
To tell Cobra *not* to provide the user with the `--no-descriptions` flag to the completion sub-commands:
```
rootCmd.CompletionOptions.DisableNoDescFlag = true
```
To tell Cobra to completely disable descriptions for completions:
```
rootCmd.CompletionOptions.DisableDescriptions = true
```
# Customizing completions # Customizing completions
The generated completion scripts will automatically handle completing commands and flags. However, you can make your completions much more powerful by providing information to complete your program's nouns and flag values. The generated completion scripts will automatically handle completing commands and flags. However, you can make your completions much more powerful by providing information to complete your program's nouns and flag values.