Repository: spf13/cobra
Pull request #1352 (2021-02-14 06:34:40)

Bash completion variable leak fixes
`split` is from bash_completion's `_init_completion -s`.


Patch:
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 0000000..bd2b3bf
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,16 @@
+# changes to documentation generation
+"area/doc-gen": doc/**/*
+
+# changes to the core Go cobra lib package
+"area/lib": ./*.go
+
+# changes to the Cobra CLI
+"area/cli": cobra/**/*
+
+# changes to Github workflows
+"area/github": .github/**/*
+
+# changes to shell completions
+"area/*sh completion":
+  - ./*completions*
+
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
new file mode 100644
index 0000000..ce4fc1f
--- /dev/null
+++ b/.github/workflows/labeler.yml
@@ -0,0 +1,12 @@
+name: "Pull Request Labeler"
+on:
+- pull_request_target
+
+jobs:
+  triage:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/labeler@v3
+      with:
+        repo-token: "${{ github.token }}"
+
diff --git a/bash_completions.go b/bash_completions.go
index 7106147..b47f898 100644
--- a/bash_completions.go
+++ b/bash_completions.go
@@ -384,7 +384,7 @@ func writePostscript(buf io.StringWriter, name string) {
 	name = strings.Replace(name, ":", "__", -1)
 	WriteStringAndCheck(buf, fmt.Sprintf("__start_%s()\n", name))
 	WriteStringAndCheck(buf, fmt.Sprintf(`{
-    local cur prev words cword
+    local cur prev words cword split
     declare -A flaghash 2>/dev/null || :
     declare -A aliashash 2>/dev/null || :
     if declare -F _init_completion >/dev/null 2>&1; then
@@ -400,11 +400,13 @@ func writePostscript(buf io.StringWriter, name string) {
     local flags_with_completion=()
     local flags_completion=()
     local commands=("%[1]s")
+    local command_aliases=()
     local must_have_one_flag=()
     local must_have_one_noun=()
     local has_completion_function
     local last_command
     local nouns=()
+    local noun_aliases=()
 
     __%[1]s_handle_word
 }
diff --git a/command.go b/command.go
index d6732ad..ce94d40 100644
--- a/command.go
+++ b/command.go
@@ -168,6 +168,9 @@ type Command struct {
 	//FParseErrWhitelist flag parse errors to be ignored
 	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 bool
 	// 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)
 	}
 
-	// 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/completions.go
similarity index 79%
rename from custom_completions.go
rename to completions.go
index fa060c1..fea2c6f 100644
--- a/custom_completions.go
+++ b/completions.go
@@ -63,6 +63,32 @@ const (
 	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.
 func (c *Command) RegisterFlagCompletionFunc(flagName string, f func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective)) error {
 	flag := c.Flag(flagName)
@@ -494,6 +520,150 @@ 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
+		}
+	}
+
+	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 {
 	flagSet := cmd.Flags()
 	if len(name) == 1 {
diff --git a/powershell_completions.go b/powershell_completions.go
index c55be71..383ad74 100644
--- a/powershell_completions.go
+++ b/powershell_completions.go
@@ -86,7 +86,7 @@ Register-ArgumentCompleter -CommandName '%[1]s' -ScriptBlock {
         # We add an extra empty parameter so we can indicate this to the go method.
         __%[1]s_debug "Adding extra empty parameter"
 `+"        # We need to use `\"`\" to pass an empty argument a \"\" or '' does not work!!!"+`
-`+"        $RequestComp=\"$RequestComp\" + ' `\"`\"' "+`
+`+"        $RequestComp=\"$RequestComp\" + ' `\"`\"'"+`
     }
 
     __%[1]s_debug "Calling $RequestComp"
diff --git a/shell_completions.md b/shell_completions.md
index cd533ac..0b51cff 100644
--- a/shell_completions.md
+++ b/shell_completions.md
@@ -7,6 +7,15 @@ The currently supported shells are:
 - fish
 - 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
 
 ```bash
@@ -70,7 +79,7 @@ PowerShell:
 		case "fish":
 			cmd.Root().GenFishCompletion(os.Stdout, true)
 		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.
 
+## 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
 
 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.


Test patch:
diff --git a/custom_completions_test.go b/completions_test.go
similarity index 89%
rename from custom_completions_test.go
rename to completions_test.go
index ede809e..603c409 100644
--- a/custom_completions_test.go
+++ b/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",
@@ -202,6 +204,7 @@ func TestNoCmdNameCompletionInGo(t *testing.T) {
 
 	expected = strings.Join([]string{
 		"childCmd1",
+		"completion",
 		"help",
 		":4",
 		"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@@ -373,6 +376,7 @@ func TestValidArgsAndCmdCompletionInGo(t *testing.T) {
 	}
 
 	expected := strings.Join([]string{
+		"completion",
 		"help",
 		"thechild",
 		"one",
@@ -423,6 +427,7 @@ func TestValidArgsFuncAndCmdCompletionInGo(t *testing.T) {
 	}
 
 	expected := strings.Join([]string{
+		"completion",
 		"help",
 		"thechild",
 		"one",
@@ -490,6 +495,7 @@ func TestFlagNameCompletionInGo(t *testing.T) {
 
 	expected := strings.Join([]string{
 		"childCmd",
+		"completion",
 		"help",
 		":4",
 		"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@@ -573,6 +579,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")
@@ -801,6 +808,7 @@ func TestRequiredFlagNameCompletionInGo(t *testing.T) {
 
 	expected := strings.Join([]string{
 		"childCmd",
+		"completion",
 		"help",
 		"--requiredFlag",
 		"-r",
@@ -926,6 +934,7 @@ func TestRequiredFlagNameCompletionInGo(t *testing.T) {
 
 	expected = strings.Join([]string{
 		"childCmd",
+		"completion",
 		"help",
 		"--requiredFlag",
 		"-r",
@@ -1918,6 +1927,7 @@ func TestCompleteHelp(t *testing.T) {
 	expected := strings.Join([]string{
 		"child1",
 		"child2",
+		"completion",
 		"help",
 		":4",
 		"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@@ -1935,6 +1945,7 @@ func TestCompleteHelp(t *testing.T) {
 	expected = strings.Join([]string{
 		"child1",
 		"child2",
+		"completion",
 		"help", // "<program> help help" is a valid command, so should be completed
 		":4",
 		"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n")
@@ -1958,3 +1969,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
+	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)
+		}
+	}
+}