diff --git a/.github/workflows/size-labeler.yml b/.github/workflows/size-labeler.yml index af081dc996..f04024fa3b 100644 --- a/.github/workflows/size-labeler.yml +++ b/.github/workflows/size-labeler.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest name: Label the PR size steps: - - uses: codelytv/pr-size-labeler@v1.8.0 + - uses: codelytv/pr-size-labeler@v1.8.1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} xs_label: 'size/XS' diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ce57c31600..a63518f691 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,9 +13,38 @@ jobs: - uses: actions/stale@v5 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: 'This issue is being marked as stale due to a long period of inactivity' - stale-pr-message: 'This PR is being marked as stale due to a long period of inactivity' - stale-issue-label: 'kind/stale' - stale-pr-label: 'kind/stale' - exempt-issue-label: 'kind/stale' - exempt-pr-label: 'kind/stale' + stale-issue-message: "The Cobra project currently lacks enough contributors to adequately respond to all issues. + This bot triages issues and PRs according to the following rules: + + - After 60d of inactivity, lifecycle/stale is applied. + - After 30d of inactivity since lifecycle/stale was applied, lifecycle/rotten is applied and the issue is closed. + + You can: + + - Make a comment to remove the stale label and show your support. The 60 days reset. + - If an issue has lifecycle/rotten and is closed, comment and ask maintainers if they'd be interseted in reopening" + + stale-pr-message: "The Cobra project currently lacks enough contributors to adequately respond to all PRs. + This bot triages issues and PRs according to the following rules: + + - After 60d of inactivity, lifecycle/stale is applied. + - After 30d of inactivity since lifecycle/stale was applied, lifecycle/rotten is applied and the PR is closed. + + You can: + + - Make a comment to remove the stale label and show your support. The 60 days reset. + - If a PR has lifecycle/rotten and is closed, comment and ask maintainers if they'd be interseted in reopening." + + days-before-stale: 60 + days-before-close: 30 + stale-issue-label: 'lifecycle/stale' + stale-pr-label: 'lifecycle/stale' + exempt-issue-label: 'lifecycle/frozen' + exempt-pr-label: 'lifecycle/frozen' + close-issue-label: 'lifecycle/rotten' + close-pr-label: 'lifecycle/rotten' + + # Since cobra has so many legacy issues and PRs that need to be triaged, + # only label new PRs and issues. + start-date: '2022-02-01T00:00:00Z' + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b3fb36903..0ca8367a41 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,13 +13,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/setup-go@v2 + - uses: actions/setup-go@v3 with: go-version: '1.17' - uses: actions/checkout@v3 - - uses: golangci/golangci-lint-action@v3.1.0 + - uses: golangci/golangci-lint-action@v3.2.0 with: version: latest args: --verbose @@ -41,7 +41,7 @@ jobs: runs-on: ${{ matrix.platform }}-latest steps: - - uses: actions/setup-go@v2 + - uses: actions/setup-go@v3 with: go-version: 1.${{ matrix.go }}.x diff --git a/README.md b/README.md index a9dc06af12..a23c61644d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Cobra is a library for creating powerful modern CLI applications. -Cobra is used in many Go projects such as [Kubernetes](http://kubernetes.io/), +Cobra is used in many Go projects such as [Kubernetes](https://kubernetes.io/), [Hugo](https://gohugo.io), and [Github CLI](https://github.com/cli/cli) to name a few. [This list](./projects_using_cobra.md) contains a more extensive list of projects using Cobra. @@ -28,7 +28,7 @@ Cobra provides: * Automatically generated man pages for your application * Command aliases so you can change things without breaking them * The flexibility to define your own help, usage, etc. -* Optional seamless integration with [viper](http://github.com/spf13/viper) for 12-factor apps +* Optional seamless integration with [viper](https://github.com/spf13/viper) for 12-factor apps # Concepts diff --git a/active_help.go b/active_help.go new file mode 100644 index 0000000000..0c631913d4 --- /dev/null +++ b/active_help.go @@ -0,0 +1,49 @@ +package cobra + +import ( + "fmt" + "os" + "strings" +) + +const ( + activeHelpMarker = "_activeHelp_ " + // The below values should not be changed: programs will be using them explicitly + // in their user documentation, and users will be using them explicitly. + activeHelpEnvVarSuffix = "_ACTIVE_HELP" + activeHelpGlobalEnvVar = "COBRA_ACTIVE_HELP" + activeHelpGlobalDisable = "0" +) + +// AppendActiveHelp adds the specified string to the specified array to be used as ActiveHelp. +// Such strings will be processed by the completion script and will be shown as ActiveHelp +// to the user. +// The array parameter should be the array that will contain the completions. +// This function can be called multiple times before and/or after completions are added to +// the array. Each time this function is called with the same array, the new +// ActiveHelp line will be shown below the previous ones when completion is triggered. +func AppendActiveHelp(compArray []string, activeHelpStr string) []string { + return append(compArray, fmt.Sprintf("%s%s", activeHelpMarker, activeHelpStr)) +} + +// GetActiveHelpConfig returns the value of the ActiveHelp environment variable +// _ACTIVE_HELP where is the name of the root command in upper +// case, with all - replaced by _. +// It will always return "0" if the global environment variable COBRA_ACTIVE_HELP +// is set to "0". +func GetActiveHelpConfig(cmd *Command) string { + activeHelpCfg := os.Getenv(activeHelpGlobalEnvVar) + if activeHelpCfg != activeHelpGlobalDisable { + activeHelpCfg = os.Getenv(activeHelpEnvVar(cmd.Root().Name())) + } + return activeHelpCfg +} + +// activeHelpEnvVar returns the name of the program-specific ActiveHelp environment +// variable. It has the format _ACTIVE_HELP where is the name of the +// root command in upper case, with all - replaced by _. +func activeHelpEnvVar(name string) string { + // This format should not be changed: users will be using it explicitly. + activeHelpEnvVar := strings.ToUpper(fmt.Sprintf("%s%s", name, activeHelpEnvVarSuffix)) + return strings.ReplaceAll(activeHelpEnvVar, "-", "_") +} diff --git a/active_help.md b/active_help.md new file mode 100644 index 0000000000..5e7f59af38 --- /dev/null +++ b/active_help.md @@ -0,0 +1,157 @@ +# Active Help + +Active Help is a framework provided by Cobra which allows a program to define messages (hints, warnings, etc) that will be printed during program usage. It aims to make it easier for your users to learn how to use your program. If configured by the program, Active Help is printed when the user triggers shell completion. + +For example, +``` +bash-5.1$ helm repo add [tab] +You must choose a name for the repo you are adding. + +bash-5.1$ bin/helm package [tab] +Please specify the path to the chart to package + +bash-5.1$ bin/helm package [tab][tab] +bin/ internal/ scripts/ pkg/ testdata/ +``` + +**Hint**: A good place to use Active Help messages is when the normal completion system does not provide any suggestions. In such cases, Active Help nicely supplements the normal shell completions to guide the user in knowing what is expected by the program. +## Supported shells + +Active Help is currently only supported for the following shells: +- Bash (using [bash completion V2](shell_completions.md#bash-completion-v2) only). Note that bash 4.4 or higher is required for the prompt to appear when an Active Help message is printed. +- Zsh + +## Adding Active Help messages + +As Active Help uses the shell completion system, the implementation of Active Help messages is done by enhancing custom dynamic completions. If you are not familiar with dynamic completions, please refer to [Shell Completions](shell_completions.md). + +Adding Active Help is done through the use of the `cobra.AppendActiveHelp(...)` function, where the program repeatedly adds Active Help messages to the list of completions. Keep reading for details. + +### Active Help for nouns + +Adding Active Help when completing a noun is done within the `ValidArgsFunction(...)` of a command. Please notice the use of `cobra.AppendActiveHelp(...)` in the following example: + +```go +cmd := &cobra.Command{ + Use: "add [NAME] [URL]", + Short: "add a chart repository", + Args: require.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return addRepo(args) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var comps []string + if len(args) == 0 { + comps = cobra.AppendActiveHelp(comps, "You must choose a name for the repo you are adding") + } else if len(args) == 1 { + comps = cobra.AppendActiveHelp(comps, "You must specify the URL for the repo you are adding") + } else { + comps = cobra.AppendActiveHelp(comps, "This command does not take any more arguments") + } + return comps, cobra.ShellCompDirectiveNoFileComp + }, +} +``` +The example above defines the completions (none, in this specific example) as well as the Active Help messages for the `helm repo add` command. It yields the following behavior: +``` +bash-5.1$ helm repo add [tab] +You must choose a name for the repo you are adding + +bash-5.1$ helm repo add grafana [tab] +You must specify the URL for the repo you are adding + +bash-5.1$ helm repo add grafana https://grafana.github.io/helm-charts [tab] +This command does not take any more arguments +``` +**Hint**: As can be seen in the above example, a good place to use Active Help messages is when the normal completion system does not provide any suggestions. In such cases, Active Help nicely supplements the normal shell completions. + +### Active Help for flags + +Providing Active Help for flags is done in the same fashion as for nouns, but using the completion function registered for the flag. For example: +```go +_ = cmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 2 { + return cobra.AppendActiveHelp(nil, "You must first specify the chart to install before the --version flag can be completed"), cobra.ShellCompDirectiveNoFileComp + } + return compVersionFlag(args[1], toComplete) + }) +``` +The example above prints an Active Help message when not enough information was given by the user to complete the `--version` flag. +``` +bash-5.1$ bin/helm install myrelease --version 2.0.[tab] +You must first specify the chart to install before the --version flag can be completed + +bash-5.1$ bin/helm install myrelease bitnami/solr --version 2.0.[tab][tab] +2.0.1 2.0.2 2.0.3 +``` + +## User control of Active Help + +You may want to allow your users to disable Active Help or choose between different levels of Active Help. It is entirely up to the program to define the type of configurability of Active Help that it wants to offer, if any. +Allowing to configure Active Help is entirely optional; you can use Active Help in your program without doing anything about Active Help configuration. + +The way to configure Active Help is to use the program's Active Help environment +variable. That variable is named `_ACTIVE_HELP` where `` is the name of your +program in uppercase with any `-` replaced by an `_`. The variable should be set by the user to whatever +Active Help configuration values are supported by the program. + +For example, say `helm` has chosen to support three levels for Active Help: `on`, `off`, `local`. Then a user +would set the desired behavior to `local` by doing `export HELM_ACTIVE_HELP=local` in their shell. + +For simplicity, when in `cmd.ValidArgsFunction(...)` or a flag's completion function, the program should read the +Active Help configuration using the `cobra.GetActiveHelpConfig(cmd)` function and select what Active Help messages +should or should not be added (instead of reading the environment variable directly). + +For example: +```go +ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + activeHelpLevel := cobra.GetActiveHelpConfig(cmd) + + var comps []string + if len(args) == 0 { + if activeHelpLevel != "off" { + comps = cobra.AppendActiveHelp(comps, "You must choose a name for the repo you are adding") + } + } else if len(args) == 1 { + if activeHelpLevel != "off" { + comps = cobra.AppendActiveHelp(comps, "You must specify the URL for the repo you are adding") + } + } else { + if activeHelpLevel == "local" { + comps = cobra.AppendActiveHelp(comps, "This command does not take any more arguments") + } + } + return comps, cobra.ShellCompDirectiveNoFileComp +}, +``` +**Note 1**: If the `_ACTIVE_HELP` environment variable is set to the string "0", Cobra will automatically disable all Active Help output (even if some output was specified by the program using the `cobra.AppendActiveHelp(...)` function). Using "0" can simplify your code in situations where you want to blindly disable Active Help without having to call `cobra.GetActiveHelpConfig(cmd)` explicitly. + +**Note 2**: If a user wants to disable Active Help for every single program based on Cobra, she can set the environment variable `COBRA_ACTIVE_HELP` to "0". In this case `cobra.GetActiveHelpConfig(cmd)` will return "0" no matter what the variable `_ACTIVE_HELP` is set to. + +**Note 3**: If the user does not set `_ACTIVE_HELP` or `COBRA_ACTIVE_HELP` (which will be a common case), the default value for the Active Help configuration returned by `cobra.GetActiveHelpConfig(cmd)` will be the empty string. +## Active Help with Cobra's default completion command + +Cobra provides a default `completion` command for programs that wish to use it. +When using the default `completion` command, Active Help is configurable in the same +fashion as described above using environment variables. You may wish to document this in more +details for your users. + +## Debugging Active Help + +Debugging your Active Help code is done in the same way as debugging your dynamic completion code, which is with Cobra's hidden `__complete` command. Please refer to [debugging shell completion](shell_completions.md#debugging) for details. + +When debugging with the `__complete` command, if you want to specify different Active Help configurations, you should use the active help environment variable. That variable is named `_ACTIVE_HELP` where any `-` is replaced by an `_`. For example, we can test deactivating some Active Help as shown below: +``` +$ HELM_ACTIVE_HELP=1 bin/helm __complete install wordpress bitnami/h +bitnami/haproxy +bitnami/harbor +_activeHelp_ WARNING: cannot re-use a name that is still in use +:0 +Completion ended with directive: ShellCompDirectiveDefault + +$ HELM_ACTIVE_HELP=0 bin/helm __complete install wordpress bitnami/h +bitnami/haproxy +bitnami/harbor +:0 +Completion ended with directive: ShellCompDirectiveDefault +``` diff --git a/active_help_test.go b/active_help_test.go new file mode 100644 index 0000000000..524be884b6 --- /dev/null +++ b/active_help_test.go @@ -0,0 +1,386 @@ +package cobra + +import ( + "fmt" + "os" + "strings" + "testing" +) + +const ( + activeHelpMessage = "This is an activeHelp message" + activeHelpMessage2 = "This is the rest of the activeHelp message" +) + +func TestActiveHelpAlone(t *testing.T) { + rootCmd := &Command{ + Use: "root", + Run: emptyRun, + } + + activeHelpFunc := func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + comps := AppendActiveHelp(nil, activeHelpMessage) + return comps, ShellCompDirectiveDefault + } + + // Test that activeHelp can be added to a root command + rootCmd.ValidArgsFunction = activeHelpFunc + + output, err := executeCommand(rootCmd, ShellCompNoDescRequestCmd, "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected := strings.Join([]string{ + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage), + ":0", + "Completion ended with directive: ShellCompDirectiveDefault", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } + + rootCmd.ValidArgsFunction = nil + + // Test that activeHelp can be added to a child command + childCmd := &Command{ + Use: "thechild", + Short: "The child command", + Run: emptyRun, + } + rootCmd.AddCommand(childCmd) + + childCmd.ValidArgsFunction = activeHelpFunc + + output, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected = strings.Join([]string{ + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage), + ":0", + "Completion ended with directive: ShellCompDirectiveDefault", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } +} + +func TestActiveHelpWithComps(t *testing.T) { + rootCmd := &Command{ + Use: "root", + Run: emptyRun, + } + + childCmd := &Command{ + Use: "thechild", + Short: "The child command", + Run: emptyRun, + } + rootCmd.AddCommand(childCmd) + + // Test that activeHelp can be added following other completions + childCmd.ValidArgsFunction = func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + comps := []string{"first", "second"} + comps = AppendActiveHelp(comps, activeHelpMessage) + return comps, ShellCompDirectiveDefault + } + + output, err := executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected := strings.Join([]string{ + "first", + "second", + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage), + ":0", + "Completion ended with directive: ShellCompDirectiveDefault", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } + + // Test that activeHelp can be added preceding other completions + childCmd.ValidArgsFunction = func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + var comps []string + comps = AppendActiveHelp(comps, activeHelpMessage) + comps = append(comps, []string{"first", "second"}...) + return comps, ShellCompDirectiveDefault + } + + output, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected = strings.Join([]string{ + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage), + "first", + "second", + ":0", + "Completion ended with directive: ShellCompDirectiveDefault", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } + + // Test that activeHelp can be added interleaved with other completions + childCmd.ValidArgsFunction = func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + comps := []string{"first"} + comps = AppendActiveHelp(comps, activeHelpMessage) + comps = append(comps, "second") + return comps, ShellCompDirectiveDefault + } + + output, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected = strings.Join([]string{ + "first", + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage), + "second", + ":0", + "Completion ended with directive: ShellCompDirectiveDefault", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } +} + +func TestMultiActiveHelp(t *testing.T) { + rootCmd := &Command{ + Use: "root", + Run: emptyRun, + } + + childCmd := &Command{ + Use: "thechild", + Short: "The child command", + Run: emptyRun, + } + rootCmd.AddCommand(childCmd) + + // Test that multiple activeHelp message can be added + childCmd.ValidArgsFunction = func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + comps := AppendActiveHelp(nil, activeHelpMessage) + comps = AppendActiveHelp(comps, activeHelpMessage2) + return comps, ShellCompDirectiveNoFileComp + } + + output, err := executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected := strings.Join([]string{ + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage), + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage2), + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } + + // Test that multiple activeHelp messages can be used along with completions + childCmd.ValidArgsFunction = func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + comps := []string{"first"} + comps = AppendActiveHelp(comps, activeHelpMessage) + comps = append(comps, "second") + comps = AppendActiveHelp(comps, activeHelpMessage2) + return comps, ShellCompDirectiveNoFileComp + } + + output, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected = strings.Join([]string{ + "first", + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage), + "second", + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage2), + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } +} + +func TestActiveHelpForFlag(t *testing.T) { + rootCmd := &Command{ + Use: "root", + Run: emptyRun, + } + flagname := "flag" + rootCmd.Flags().String(flagname, "", "A flag") + + // Test that multiple activeHelp message can be added + _ = rootCmd.RegisterFlagCompletionFunc(flagname, func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + comps := []string{"first"} + comps = AppendActiveHelp(comps, activeHelpMessage) + comps = append(comps, "second") + comps = AppendActiveHelp(comps, activeHelpMessage2) + return comps, ShellCompDirectiveNoFileComp + }) + + output, err := executeCommand(rootCmd, ShellCompNoDescRequestCmd, "--flag", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected := strings.Join([]string{ + "first", + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage), + "second", + fmt.Sprintf("%s%s", activeHelpMarker, activeHelpMessage2), + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } +} + +func TestConfigActiveHelp(t *testing.T) { + rootCmd := &Command{ + Use: "root", + Run: emptyRun, + } + + childCmd := &Command{ + Use: "thechild", + Short: "The child command", + Run: emptyRun, + } + rootCmd.AddCommand(childCmd) + + activeHelpCfg := "someconfig,anotherconfig" + // Set the variable that the user would be setting + os.Setenv(activeHelpEnvVar(rootCmd.Name()), activeHelpCfg) + + childCmd.ValidArgsFunction = func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + receivedActiveHelpCfg := GetActiveHelpConfig(cmd) + if receivedActiveHelpCfg != activeHelpCfg { + t.Errorf("expected activeHelpConfig: %q, but got: %q", activeHelpCfg, receivedActiveHelpCfg) + } + return nil, ShellCompDirectiveDefault + } + + _, err := executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // Test active help config for a flag + activeHelpCfg = "a config for a flag" + // Set the variable that the completions scripts will be setting + os.Setenv(activeHelpEnvVar(rootCmd.Name()), activeHelpCfg) + + flagname := "flag" + childCmd.Flags().String(flagname, "", "A flag") + + // Test that multiple activeHelp message can be added + _ = childCmd.RegisterFlagCompletionFunc(flagname, func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + receivedActiveHelpCfg := GetActiveHelpConfig(cmd) + if receivedActiveHelpCfg != activeHelpCfg { + t.Errorf("expected activeHelpConfig: %q, but got: %q", activeHelpCfg, receivedActiveHelpCfg) + } + return nil, ShellCompDirectiveDefault + }) + + _, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "--flag", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } +} + +func TestDisableActiveHelp(t *testing.T) { + rootCmd := &Command{ + Use: "root", + Run: emptyRun, + } + + childCmd := &Command{ + Use: "thechild", + Short: "The child command", + Run: emptyRun, + } + rootCmd.AddCommand(childCmd) + + // Test the disabling of activeHelp using the specific program + // environment variable that the completions scripts will be setting. + // Make sure the disabling value is "0" by hard-coding it in the tests; + // this is for backwards-compatibility as programs will be using this value. + os.Setenv(activeHelpEnvVar(rootCmd.Name()), "0") + + childCmd.ValidArgsFunction = func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + comps := []string{"first"} + comps = AppendActiveHelp(comps, activeHelpMessage) + return comps, ShellCompDirectiveDefault + } + + output, err := executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + os.Unsetenv(activeHelpEnvVar(rootCmd.Name())) + + // Make sure there is no ActiveHelp in the output + expected := strings.Join([]string{ + "first", + ":0", + "Completion ended with directive: ShellCompDirectiveDefault", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } + + // Now test the global disabling of ActiveHelp + os.Setenv(activeHelpGlobalEnvVar, "0") + // Set the specific variable, to make sure it is ignored when the global env + // var is set properly + os.Setenv(activeHelpEnvVar(rootCmd.Name()), "1") + + output, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // Make sure there is no ActiveHelp in the output + expected = strings.Join([]string{ + "first", + ":0", + "Completion ended with directive: ShellCompDirectiveDefault", ""}, "\n") + + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } + + // Make sure that if the global env variable is set to anything else than + // the disable value it is ignored + os.Setenv(activeHelpGlobalEnvVar, "on") + // Set the specific variable, to make sure it is used (while ignoring the global env var) + activeHelpCfg := "1" + os.Setenv(activeHelpEnvVar(rootCmd.Name()), activeHelpCfg) + + childCmd.ValidArgsFunction = func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + receivedActiveHelpCfg := GetActiveHelpConfig(cmd) + if receivedActiveHelpCfg != activeHelpCfg { + t.Errorf("expected activeHelpConfig: %q, but got: %q", activeHelpCfg, receivedActiveHelpCfg) + } + return nil, ShellCompDirectiveDefault + } + + _, err = executeCommand(rootCmd, ShellCompNoDescRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } +} diff --git a/bash_completions.go b/bash_completions.go index 6c360c595f..cb7e195378 100644 --- a/bash_completions.go +++ b/bash_completions.go @@ -73,7 +73,8 @@ __%[1]s_handle_go_custom_completion() # Prepare the command to request completions for the program. # Calling ${words[0]} instead of directly %[1]s allows to handle aliases args=("${words[@]:1}") - requestComp="${words[0]} %[2]s ${args[*]}" + # Disable ActiveHelp which is not supported for bash completion v1 + requestComp="%[8]s=0 ${words[0]} %[2]s ${args[*]}" lastParam=${words[$((${#words[@]}-1))]} lastChar=${lastParam:$((${#lastParam}-1)):1} @@ -99,7 +100,7 @@ __%[1]s_handle_go_custom_completion() directive=0 fi __%[1]s_debug "${FUNCNAME[0]}: the completion directive is: ${directive}" - __%[1]s_debug "${FUNCNAME[0]}: the completions are: ${out[*]}" + __%[1]s_debug "${FUNCNAME[0]}: the completions are: ${out}" if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then # Error code. No completion. @@ -125,7 +126,7 @@ __%[1]s_handle_go_custom_completion() local fullFilter filter filteringCmd # Do not use quotes around the $out variable or else newline # characters will be kept. - for filter in ${out[*]}; do + for filter in ${out}; do fullFilter+="$filter|" done @@ -136,7 +137,7 @@ __%[1]s_handle_go_custom_completion() # File completion for directories only local subdir # Use printf to strip any trailing newline - subdir=$(printf "%%s" "${out[0]}") + subdir=$(printf "%%s" "${out}") if [ -n "$subdir" ]; then __%[1]s_debug "Listing directories in $subdir" __%[1]s_handle_subdirs_in_dir_flag "$subdir" @@ -147,7 +148,7 @@ __%[1]s_handle_go_custom_completion() else while IFS='' read -r comp; do COMPREPLY+=("$comp") - done < <(compgen -W "${out[*]}" -- "$cur") + done < <(compgen -W "${out}" -- "$cur") fi } @@ -383,11 +384,11 @@ __%[1]s_handle_word() `, name, ShellCompNoDescRequestCmd, ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp, - ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs)) + ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, activeHelpEnvVar(name))) } func writePostscript(buf io.StringWriter, name string) { - name = strings.Replace(name, ":", "__", -1) + name = strings.ReplaceAll(name, ":", "__") WriteStringAndCheck(buf, fmt.Sprintf("__start_%s()\n", name)) WriteStringAndCheck(buf, fmt.Sprintf(`{ local cur prev words cword split @@ -645,8 +646,8 @@ func gen(buf io.StringWriter, cmd *Command) { gen(buf, c) } commandName := cmd.CommandPath() - commandName = strings.Replace(commandName, " ", "_", -1) - commandName = strings.Replace(commandName, ":", "__", -1) + commandName = strings.ReplaceAll(commandName, " ", "_") + commandName = strings.ReplaceAll(commandName, ":", "__") if cmd.Root() == cmd { WriteStringAndCheck(buf, fmt.Sprintf("_%s_root_command()\n{\n", commandName)) diff --git a/bash_completionsV2.go b/bash_completionsV2.go index 82d26c1756..767bf03120 100644 --- a/bash_completionsV2.go +++ b/bash_completionsV2.go @@ -78,7 +78,7 @@ __%[1]s_get_completion_results() { directive=0 fi __%[1]s_debug "The completion directive is: ${directive}" - __%[1]s_debug "The completions are: ${out[*]}" + __%[1]s_debug "The completions are: ${out}" } __%[1]s_process_completion_results() { @@ -111,13 +111,18 @@ __%[1]s_process_completion_results() { fi fi + # Separate activeHelp from normal completions + local completions=() + local activeHelp=() + __%[1]s_extract_activeHelp + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then # File extension filtering local fullFilter filter filteringCmd - # Do not use quotes around the $out variable or else newline + # Do not use quotes around the $completions variable or else newline # characters will be kept. - for filter in ${out[*]}; do + for filter in ${completions[*]}; do fullFilter+="$filter|" done @@ -129,7 +134,7 @@ __%[1]s_process_completion_results() { # Use printf to strip any trailing newline local subdir - subdir=$(printf "%%s" "${out[0]}") + subdir=$(printf "%%s" "${completions[0]}") if [ -n "$subdir" ]; then __%[1]s_debug "Listing directories in $subdir" pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return @@ -143,6 +148,43 @@ __%[1]s_process_completion_results() { __%[1]s_handle_special_char "$cur" : __%[1]s_handle_special_char "$cur" = + + # Print the activeHelp statements before we finish + if [ ${#activeHelp} -ne 0 ]; then + printf "\n"; + printf "%%s\n" "${activeHelp[@]}" + printf "\n" + + # The prompt format is only available from bash 4.4. + # We test if it is available before using it. + if (x=${PS1@P}) 2> /dev/null; then + printf "%%s" "${PS1@P}${COMP_LINE[@]}" + else + # Can't print the prompt. Just print the + # text the user had typed, it is workable enough. + printf "%%s" "${COMP_LINE[@]}" + fi + fi +} + +# Separate activeHelp lines from real completions. +# Fills the $activeHelp and $completions arrays. +__%[1]s_extract_activeHelp() { + local activeHelpMarker="%[8]s" + local endIndex=${#activeHelpMarker} + + while IFS='' read -r comp; do + if [ "${comp:0:endIndex}" = "$activeHelpMarker" ]; then + comp=${comp:endIndex} + __%[1]s_debug "ActiveHelp found: $comp" + if [ -n "$comp" ]; then + activeHelp+=("$comp") + fi + else + # Not an activeHelp line but a normal completion + completions+=("$comp") + fi + done < <(printf "%%s\n" "${out}") } __%[1]s_handle_completion_types() { @@ -154,17 +196,16 @@ __%[1]s_handle_completion_types() { # If the user requested inserting one completion at a time, or all # completions at once on the command-line we must remove the descriptions. # https://github.com/spf13/cobra/issues/1508 - local tab comp - tab=$(printf '\t') + local tab=$'\t' comp while IFS='' read -r comp; do + [[ -z $comp ]] && continue # Strip any description comp=${comp%%%%$tab*} # Only consider the completions that match - comp=$(compgen -W "$comp" -- "$cur") - if [ -n "$comp" ]; then + if [[ $comp == "$cur"* ]]; then COMPREPLY+=("$comp") fi - done < <(printf "%%s\n" "${out[@]}") + done < <(printf "%%s\n" "${completions[@]}") ;; *) @@ -175,44 +216,37 @@ __%[1]s_handle_completion_types() { } __%[1]s_handle_standard_completion_case() { - local tab comp - tab=$(printf '\t') + local tab=$'\t' comp + + # Short circuit to optimize if we don't have descriptions + if [[ "${completions[*]}" != *$tab* ]]; then + IFS=$'\n' read -ra COMPREPLY -d '' < <(compgen -W "${completions[*]}" -- "$cur") + return 0 + fi local longest=0 + local compline # Look for the longest completion so that we can format things nicely - while IFS='' read -r comp; do + while IFS='' read -r compline; do + [[ -z $compline ]] && continue # Strip any description before checking the length - comp=${comp%%%%$tab*} + comp=${compline%%%%$tab*} # Only consider the completions that match - comp=$(compgen -W "$comp" -- "$cur") + [[ $comp == "$cur"* ]] || continue + COMPREPLY+=("$compline") if ((${#comp}>longest)); then longest=${#comp} fi - done < <(printf "%%s\n" "${out[@]}") - - local completions=() - while IFS='' read -r comp; do - if [ -z "$comp" ]; then - continue - fi - - __%[1]s_debug "Original comp: $comp" - comp="$(__%[1]s_format_comp_descriptions "$comp" "$longest")" - __%[1]s_debug "Final comp: $comp" - completions+=("$comp") - done < <(printf "%%s\n" "${out[@]}") - - while IFS='' read -r comp; do - COMPREPLY+=("$comp") - done < <(compgen -W "${completions[*]}" -- "$cur") + done < <(printf "%%s\n" "${completions[@]}") # If there is a single completion left, remove the description text if [ ${#COMPREPLY[*]} -eq 1 ]; then __%[1]s_debug "COMPREPLY[0]: ${COMPREPLY[0]}" - comp="${COMPREPLY[0]%%%% *}" + comp="${COMPREPLY[0]%%%%$tab*}" __%[1]s_debug "Removed description from single completion, which is now: ${comp}" - COMPREPLY=() - COMPREPLY+=("$comp") + COMPREPLY[0]=$comp + else # Format the descriptions + __%[1]s_format_comp_descriptions $longest fi } @@ -231,45 +265,48 @@ __%[1]s_handle_special_char() __%[1]s_format_comp_descriptions() { - local tab - tab=$(printf '\t') - local comp="$1" - local longest=$2 - - # Properly format the description string which follows a tab character if there is one - if [[ "$comp" == *$tab* ]]; then - desc=${comp#*$tab} - comp=${comp%%%%$tab*} - - # $COLUMNS stores the current shell width. - # Remove an extra 4 because we add 2 spaces and 2 parentheses. - maxdesclength=$(( COLUMNS - longest - 4 )) - - # Make sure we can fit a description of at least 8 characters - # if we are to align the descriptions. - if [[ $maxdesclength -gt 8 ]]; then - # Add the proper number of spaces to align the descriptions - for ((i = ${#comp} ; i < longest ; i++)); do - comp+=" " - done - else - # Don't pad the descriptions so we can fit more text after the completion - maxdesclength=$(( COLUMNS - ${#comp} - 4 )) - fi + local tab=$'\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in ${!COMPREPLY[*]}; do + comp=${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __%[1]s_debug "Original comp: $comp" + desc=${comp#*$tab} + comp=${comp%%%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if [[ $maxdesclength -gt 8 ]]; then + # Add the proper number of spaces to align the descriptions + for ((i = ${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - ${#comp} - 4 )) + fi - # If there is enough space for any description text, - # truncate the descriptions that are too long for the shell width - if [ $maxdesclength -gt 0 ]; then - if [ ${#desc} -gt $maxdesclength ]; then - desc=${desc:0:$(( maxdesclength - 1 ))} - desc+="…" + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if [ $maxdesclength -gt 0 ]; then + if [ ${#desc} -gt $maxdesclength ]; then + desc=${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" fi - comp+=" ($desc)" + COMPREPLY[ci]=$comp + __%[1]s_debug "Final comp: $comp" fi - fi - - # Must use printf to escape all special characters - printf "%%q" "${comp}" + done } __start_%[1]s() @@ -310,7 +347,8 @@ fi # ex: ts=4 sw=4 et filetype=sh `, name, compCmd, ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp, - ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs)) + ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, + activeHelpMarker)) } // GenBashCompletionFileV2 generates Bash completion version 2. diff --git a/bash_completionsV2_test.go b/bash_completionsV2_test.go new file mode 100644 index 0000000000..a9a277ed29 --- /dev/null +++ b/bash_completionsV2_test.go @@ -0,0 +1,19 @@ +package cobra + +import ( + "bytes" + "fmt" + "testing" +) + +func TestBashCompletionV2WithActiveHelp(t *testing.T) { + c := &Command{Use: "c", Run: emptyRun} + + buf := new(bytes.Buffer) + assertNoErr(t, c.GenBashCompletionV2(buf, true)) + output := buf.String() + + // check that active help is not being disabled + activeHelpVar := activeHelpEnvVar(c.Name()) + checkOmit(t, output, fmt.Sprintf("%s=0", activeHelpVar)) +} diff --git a/bash_completions_test.go b/bash_completions_test.go index 21d1fe0eb8..6896e91c79 100644 --- a/bash_completions_test.go +++ b/bash_completions_test.go @@ -261,3 +261,15 @@ func TestBashCompletionTraverseChildren(t *testing.T) { checkOmit(t, output, `local_nonpersistent_flags+=("--bool-flag")`) checkOmit(t, output, `local_nonpersistent_flags+=("-b")`) } + +func TestBashCompletionNoActiveHelp(t *testing.T) { + c := &Command{Use: "c", Run: emptyRun} + + buf := new(bytes.Buffer) + assertNoErr(t, c.GenBashCompletion(buf)) + output := buf.String() + + // check that active help is being disabled + activeHelpVar := activeHelpEnvVar(c.Name()) + check(t, output, fmt.Sprintf("%s=0", activeHelpVar)) +} diff --git a/command.go b/command.go index ee5365bcba..675bb1340a 100644 --- a/command.go +++ b/command.go @@ -18,6 +18,7 @@ package cobra import ( "bytes" "context" + "errors" "fmt" "io" "os" @@ -863,6 +864,10 @@ func (c *Command) execute(a []string) (err error) { if err := c.validateRequiredFlags(); err != nil { return err } + if err := c.validateFlagGroups(); err != nil { + return err + } + if c.RunE != nil { if err := c.RunE(c, argWoFlags); err != nil { return err @@ -986,7 +991,7 @@ func (c *Command) ExecuteC() (cmd *Command, err error) { if err != nil { // Always show help if requested, even if SilenceErrors is in // effect - if err == flag.ErrHelp { + if errors.Is(err, flag.ErrHelp) { cmd.HelpFunc()(cmd, args) return cmd, nil } @@ -1008,7 +1013,7 @@ func (c *Command) ExecuteC() (cmd *Command, err error) { func (c *Command) ValidateArgs(args []string) error { if c.Args == nil { - return nil + return ArbitraryArgs(c, args) } return c.Args(c, args) } diff --git a/command_test.go b/command_test.go index d48fef1a0e..0446e3c1d6 100644 --- a/command_test.go +++ b/command_test.go @@ -1723,6 +1723,38 @@ func TestFlagErrorFunc(t *testing.T) { } } +func TestFlagErrorFuncHelp(t *testing.T) { + c := &Command{Use: "c", Run: emptyRun} + c.PersistentFlags().Bool("help", false, "help for c") + c.SetFlagErrorFunc(func(_ *Command, err error) error { + return fmt.Errorf("wrap error: %w", err) + }) + + out, err := executeCommand(c, "--help") + if err != nil { + t.Errorf("--help should not fail: %v", err) + } + + expected := `Usage: + c [flags] + +Flags: + --help help for c +` + if out != expected { + t.Errorf("Expected: %v, got: %v", expected, out) + } + + out, err = executeCommand(c, "-h") + if err != nil { + t.Errorf("-h should not fail: %v", err) + } + + if out != expected { + t.Errorf("Expected: %v, got: %v", expected, out) + } +} + // TestSortedFlags checks, // if cmd.LocalFlags() is unsorted when cmd.Flags().SortFlags set to false. // Related to https://github.com/spf13/cobra/issues/404. diff --git a/completions.go b/completions.go index ce1caab82a..2c24839988 100644 --- a/completions.go +++ b/completions.go @@ -178,6 +178,12 @@ func (c *Command) initCompleteCmd(args []string) { noDescriptions := (cmd.CalledAs() == ShellCompNoDescRequestCmd) for _, comp := range completions { + if GetActiveHelpConfig(finalCmd) == activeHelpGlobalDisable { + // Remove all activeHelp entries in this case + if strings.HasPrefix(comp, activeHelpMarker) { + continue + } + } if noDescriptions { // Remove any description that may be included following a tab character. comp = strings.Split(comp, "\t")[0] @@ -319,6 +325,9 @@ func (c *Command) getCompletions(args []string) (*Command, []string, ShellCompDi var completions []string var directive ShellCompDirective + // Enforce flag groups before doing flag completions + finalCmd.enforceFlagGroupsForCompletion() + // Note that we want to perform flagname completion even if finalCmd.DisableFlagParsing==true; // doing this allows for completion of persistent flag names even for commands that disable flag parsing. // @@ -652,7 +661,7 @@ To load completions for every new session, execute once: #### macOS: - %[1]s completion bash > /usr/local/etc/bash_completion.d/%[1]s + %[1]s completion bash > $(brew --prefix)/etc/bash_completion.d/%[1]s You will need to start a new shell for this setup to take effect. `, c.Root().Name()), @@ -689,7 +698,7 @@ To load completions for every new session, execute once: #### macOS: - %[1]s completion zsh > /usr/local/share/zsh/site-functions/_%[1]s + %[1]s completion zsh > $(brew --prefix)/share/zsh/site-functions/_%[1]s You will need to start a new shell for this setup to take effect. `, c.Root().Name()), diff --git a/completions_test.go b/completions_test.go index 28a3228e3c..fa087fd9e6 100644 --- a/completions_test.go +++ b/completions_test.go @@ -2691,3 +2691,189 @@ func TestFixedCompletions(t *testing.T) { t.Errorf("expected: %q, got: %q", expected, output) } } + +func TestCompletionForGroupedFlags(t *testing.T) { + getCmd := func() *Command { + rootCmd := &Command{ + Use: "root", + Run: emptyRun, + } + childCmd := &Command{ + Use: "child", + ValidArgsFunction: func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + return []string{"subArg"}, ShellCompDirectiveNoFileComp + }, + Run: emptyRun, + } + rootCmd.AddCommand(childCmd) + + rootCmd.PersistentFlags().Int("ingroup1", -1, "ingroup1") + rootCmd.PersistentFlags().String("ingroup2", "", "ingroup2") + + childCmd.Flags().Bool("ingroup3", false, "ingroup3") + childCmd.Flags().Bool("nogroup", false, "nogroup") + + // Add flags to a group + childCmd.MarkFlagsRequiredTogether("ingroup1", "ingroup2", "ingroup3") + + return rootCmd + } + + // Each test case uses a unique command from the function above. + testcases := []struct { + desc string + args []string + expectedOutput string + }{ + { + desc: "flags in group not suggested without - prefix", + args: []string{"child", ""}, + expectedOutput: strings.Join([]string{ + "subArg", + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"), + }, + { + desc: "flags in group suggested with - prefix", + args: []string{"child", "-"}, + expectedOutput: strings.Join([]string{ + "--ingroup1", + "--ingroup2", + "--ingroup3", + "--nogroup", + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"), + }, + { + desc: "when flag in group present, other flags in group suggested even without - prefix", + args: []string{"child", "--ingroup2", "value", ""}, + expectedOutput: strings.Join([]string{ + "--ingroup1", + "--ingroup3", + "subArg", + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"), + }, + { + desc: "when all flags in group present, flags not suggested without - prefix", + args: []string{"child", "--ingroup1", "8", "--ingroup2", "value2", "--ingroup3", ""}, + expectedOutput: strings.Join([]string{ + "subArg", + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"), + }, + { + desc: "group ignored if some flags not applicable", + args: []string{"--ingroup2", "value", ""}, + expectedOutput: strings.Join([]string{ + "child", + "completion", + "help", + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"), + }, + } + + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + c := getCmd() + args := []string{ShellCompNoDescRequestCmd} + args = append(args, tc.args...) + output, err := executeCommand(c, args...) + switch { + case err == nil && output != tc.expectedOutput: + t.Errorf("expected: %q, got: %q", tc.expectedOutput, output) + case err != nil: + t.Errorf("Unexpected error %q", err) + } + }) + } +} + +func TestCompletionForMutuallyExclusiveFlags(t *testing.T) { + getCmd := func() *Command { + rootCmd := &Command{ + Use: "root", + Run: emptyRun, + } + childCmd := &Command{ + Use: "child", + ValidArgsFunction: func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + return []string{"subArg"}, ShellCompDirectiveNoFileComp + }, + Run: emptyRun, + } + rootCmd.AddCommand(childCmd) + + rootCmd.PersistentFlags().IntSlice("ingroup1", []int{1}, "ingroup1") + rootCmd.PersistentFlags().String("ingroup2", "", "ingroup2") + + childCmd.Flags().Bool("ingroup3", false, "ingroup3") + childCmd.Flags().Bool("nogroup", false, "nogroup") + + // Add flags to a group + childCmd.MarkFlagsMutuallyExclusive("ingroup1", "ingroup2", "ingroup3") + + return rootCmd + } + + // Each test case uses a unique command from the function above. + testcases := []struct { + desc string + args []string + expectedOutput string + }{ + { + desc: "flags in mutually exclusive group not suggested without the - prefix", + args: []string{"child", ""}, + expectedOutput: strings.Join([]string{ + "subArg", + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"), + }, + { + desc: "flags in mutually exclusive group suggested with the - prefix", + args: []string{"child", "-"}, + expectedOutput: strings.Join([]string{ + "--ingroup1", + "--ingroup2", + "--ingroup3", + "--nogroup", + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"), + }, + { + desc: "when flag in mutually exclusive group present, other flags in group not suggested even with the - prefix", + args: []string{"child", "--ingroup1", "8", "-"}, + expectedOutput: strings.Join([]string{ + "--ingroup1", // Should be suggested again since it is a slice + "--nogroup", + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"), + }, + { + desc: "group ignored if some flags not applicable", + args: []string{"--ingroup1", "8", "-"}, + expectedOutput: strings.Join([]string{ + "--ingroup1", + "--ingroup2", + ":4", + "Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"), + }, + } + + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + c := getCmd() + args := []string{ShellCompNoDescRequestCmd} + args = append(args, tc.args...) + output, err := executeCommand(c, args...) + switch { + case err == nil && output != tc.expectedOutput: + t.Errorf("expected: %q, got: %q", tc.expectedOutput, output) + case err != nil: + t.Errorf("Unexpected error %q", err) + } + }) + } +} diff --git a/doc/man_docs.go b/doc/man_docs.go index 916e36144d..8dbce35e9e 100644 --- a/doc/man_docs.go +++ b/doc/man_docs.go @@ -66,7 +66,7 @@ func GenManTreeFromOpts(cmd *cobra.Command, opts GenManTreeOptions) error { if opts.CommandSeparator != "" { separator = opts.CommandSeparator } - basename := strings.Replace(cmd.CommandPath(), " ", separator, -1) + basename := strings.ReplaceAll(cmd.CommandPath(), " ", separator) filename := filepath.Join(opts.Path, basename+"."+section) f, err := os.Create(filename) if err != nil { @@ -116,7 +116,7 @@ func GenMan(cmd *cobra.Command, header *GenManHeader, w io.Writer) error { func fillHeader(header *GenManHeader, name string, disableAutoGen bool) error { if header.Title == "" { - header.Title = strings.ToUpper(strings.Replace(name, " ", "\\-", -1)) + header.Title = strings.ToUpper(strings.ReplaceAll(name, " ", "\\-")) } if header.Section == "" { header.Section = "1" @@ -203,7 +203,7 @@ func genMan(cmd *cobra.Command, header *GenManHeader) []byte { cmd.InitDefaultHelpFlag() // something like `rootcmd-subcmd1-subcmd2` - dashCommandName := strings.Replace(cmd.CommandPath(), " ", "-", -1) + dashCommandName := strings.ReplaceAll(cmd.CommandPath(), " ", "-") buf := new(bytes.Buffer) @@ -218,7 +218,7 @@ func genMan(cmd *cobra.Command, header *GenManHeader) []byte { seealsos := make([]string, 0) if cmd.HasParent() { parentPath := cmd.Parent().CommandPath() - dashParentPath := strings.Replace(parentPath, " ", "-", -1) + dashParentPath := strings.ReplaceAll(parentPath, " ", "-") seealso := fmt.Sprintf("**%s(%s)**", dashParentPath, header.Section) seealsos = append(seealsos, seealso) cmd.VisitParents(func(c *cobra.Command) { diff --git a/doc/man_docs_test.go b/doc/man_docs_test.go index f3fe3d6139..f7994f8992 100644 --- a/doc/man_docs_test.go +++ b/doc/man_docs_test.go @@ -20,7 +20,7 @@ func assertNoErr(t *testing.T, e error) { } func translate(in string) string { - return strings.Replace(in, "-", "\\-", -1) + return strings.ReplaceAll(in, "-", "\\-") } func TestGenManDoc(t *testing.T) { @@ -38,7 +38,7 @@ func TestGenManDoc(t *testing.T) { // Make sure parent has - in CommandPath() in SEE ALSO: parentPath := echoCmd.Parent().CommandPath() - dashParentPath := strings.Replace(parentPath, " ", "-", -1) + dashParentPath := strings.ReplaceAll(parentPath, " ", "-") expected := translate(dashParentPath) expected = expected + "(" + header.Section + ")" checkStringContains(t, output, expected) @@ -73,7 +73,7 @@ func TestGenManNoHiddenParents(t *testing.T) { // Make sure parent has - in CommandPath() in SEE ALSO: parentPath := echoCmd.Parent().CommandPath() - dashParentPath := strings.Replace(parentPath, " ", "-", -1) + dashParentPath := strings.ReplaceAll(parentPath, " ", "-") expected := translate(dashParentPath) expected = expected + "(" + header.Section + ")" checkStringContains(t, output, expected) diff --git a/doc/md_docs.go b/doc/md_docs.go index 5181af8dc2..19d7e931f4 100644 --- a/doc/md_docs.go +++ b/doc/md_docs.go @@ -83,7 +83,7 @@ func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) parent := cmd.Parent() pname := parent.CommandPath() link := pname + ".md" - link = strings.Replace(link, " ", "_", -1) + link = strings.ReplaceAll(link, " ", "_") buf.WriteString(fmt.Sprintf("* [%s](%s)\t - %s\n", pname, linkHandler(link), parent.Short)) cmd.VisitParents(func(c *cobra.Command) { if c.DisableAutoGenTag { @@ -101,7 +101,7 @@ func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) } cname := name + " " + child.Name() link := cname + ".md" - link = strings.Replace(link, " ", "_", -1) + link = strings.ReplaceAll(link, " ", "_") buf.WriteString(fmt.Sprintf("* [%s](%s)\t - %s\n", cname, linkHandler(link), child.Short)) } buf.WriteString("\n") @@ -137,7 +137,7 @@ func GenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender, linkHa } } - basename := strings.Replace(cmd.CommandPath(), " ", "_", -1) + ".md" + basename := strings.ReplaceAll(cmd.CommandPath(), " ", "_") + ".md" filename := filepath.Join(dir, basename) f, err := os.Create(filename) if err != nil { diff --git a/doc/md_docs.md b/doc/md_docs.md index 5c870625f7..1659175cfd 100644 --- a/doc/md_docs.md +++ b/doc/md_docs.md @@ -85,7 +85,7 @@ func GenMarkdownCustom(cmd *Command, out *bytes.Buffer, linkHandler func(string) } ``` -The `filePrepender` will prepend the return value given the full filepath to the rendered Markdown file. A common use case is to add front matter to use the generated documentation with [Hugo](http://gohugo.io/): +The `filePrepender` will prepend the return value given the full filepath to the rendered Markdown file. A common use case is to add front matter to use the generated documentation with [Hugo](https://gohugo.io/): ```go const fmTemplate = `--- diff --git a/doc/rest_docs.go b/doc/rest_docs.go index 051d8dc832..23dca16a3e 100644 --- a/doc/rest_docs.go +++ b/doc/rest_docs.go @@ -70,7 +70,7 @@ func GenReSTCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string, str if len(long) == 0 { long = short } - ref := strings.Replace(name, " ", "_", -1) + ref := strings.ReplaceAll(name, " ", "_") buf.WriteString(".. _" + ref + ":\n\n") buf.WriteString(name + "\n") @@ -99,7 +99,7 @@ func GenReSTCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string, str if cmd.HasParent() { parent := cmd.Parent() pname := parent.CommandPath() - ref = strings.Replace(pname, " ", "_", -1) + ref = strings.ReplaceAll(pname, " ", "_") buf.WriteString(fmt.Sprintf("* %s \t - %s\n", linkHandler(pname, ref), parent.Short)) cmd.VisitParents(func(c *cobra.Command) { if c.DisableAutoGenTag { @@ -116,7 +116,7 @@ func GenReSTCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string, str continue } cname := name + " " + child.Name() - ref = strings.Replace(cname, " ", "_", -1) + ref = strings.ReplaceAll(cname, " ", "_") buf.WriteString(fmt.Sprintf("* %s \t - %s\n", linkHandler(cname, ref), child.Short)) } buf.WriteString("\n") @@ -151,7 +151,7 @@ func GenReSTTreeCustom(cmd *cobra.Command, dir string, filePrepender func(string } } - basename := strings.Replace(cmd.CommandPath(), " ", "_", -1) + ".rst" + basename := strings.ReplaceAll(cmd.CommandPath(), " ", "_") + ".rst" filename := filepath.Join(dir, basename) f, err := os.Create(filename) if err != nil { diff --git a/doc/rest_docs.md b/doc/rest_docs.md index 6098430eff..3041c573ab 100644 --- a/doc/rest_docs.md +++ b/doc/rest_docs.md @@ -85,7 +85,7 @@ func GenReSTCustom(cmd *Command, out *bytes.Buffer, linkHandler func(string, str } ``` -The `filePrepender` will prepend the return value given the full filepath to the rendered ReST file. A common use case is to add front matter to use the generated documentation with [Hugo](http://gohugo.io/): +The `filePrepender` will prepend the return value given the full filepath to the rendered ReST file. A common use case is to add front matter to use the generated documentation with [Hugo](https://gohugo.io/): ```go const fmTemplate = `--- diff --git a/doc/yaml_docs.go b/doc/yaml_docs.go index 96e6ad721e..a79fa40e3b 100644 --- a/doc/yaml_docs.go +++ b/doc/yaml_docs.go @@ -66,7 +66,7 @@ func GenYamlTreeCustom(cmd *cobra.Command, dir string, filePrepender, linkHandle } } - basename := strings.Replace(cmd.CommandPath(), " ", "_", -1) + ".yaml" + basename := strings.ReplaceAll(cmd.CommandPath(), " ", "_") + ".yaml" filename := filepath.Join(dir, basename) f, err := os.Create(filename) if err != nil { diff --git a/doc/yaml_docs.md b/doc/yaml_docs.md index 1a9b7c6a3c..172e61d121 100644 --- a/doc/yaml_docs.md +++ b/doc/yaml_docs.md @@ -82,7 +82,7 @@ func GenYamlCustom(cmd *Command, out *bytes.Buffer, linkHandler func(string) str } ``` -The `filePrepender` will prepend the return value given the full filepath to the rendered Yaml file. A common use case is to add front matter to use the generated documentation with [Hugo](http://gohugo.io/): +The `filePrepender` will prepend the return value given the full filepath to the rendered Yaml file. A common use case is to add front matter to use the generated documentation with [Hugo](https://gohugo.io/): ```go const fmTemplate = `--- diff --git a/fish_completions.go b/fish_completions.go index bb57fd5685..005ee6be73 100644 --- a/fish_completions.go +++ b/fish_completions.go @@ -11,8 +11,8 @@ import ( func genFishComp(buf io.StringWriter, name string, includeDesc bool) { // Variables should not contain a '-' or ':' character nameForVar := name - nameForVar = strings.Replace(nameForVar, "-", "_", -1) - nameForVar = strings.Replace(nameForVar, ":", "_", -1) + nameForVar = strings.ReplaceAll(nameForVar, "-", "_") + nameForVar = strings.ReplaceAll(nameForVar, ":", "_") compCmd := ShellCompRequestCmd if !includeDesc { @@ -38,7 +38,8 @@ function __%[1]s_perform_completion __%[1]s_debug "args: $args" __%[1]s_debug "last arg: $lastArg" - set -l requestComp "$args[1] %[3]s $args[2..-1] $lastArg" + # Disable ActiveHelp which is not supported for fish shell + set -l requestComp "%[9]s=0 $args[1] %[3]s $args[2..-1] $lastArg" __%[1]s_debug "Calling $requestComp" set -l results (eval $requestComp 2> /dev/null) @@ -196,7 +197,7 @@ complete -c %[2]s -n '__%[1]s_prepare_completions' -f -a '$__%[1]s_comp_results' `, nameForVar, name, compCmd, ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp, - ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs)) + ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, activeHelpEnvVar(name))) } // GenFishCompletion generates fish completion file and writes to the passed writer. diff --git a/fish_completions_test.go b/fish_completions_test.go index a3171e481e..381c66770f 100644 --- a/fish_completions_test.go +++ b/fish_completions_test.go @@ -2,6 +2,9 @@ package cobra import ( "bytes" + "fmt" + "log" + "os" "testing" ) @@ -67,3 +70,69 @@ func TestProgWithColon(t *testing.T) { check(t, output, "-c root:colon") checkOmit(t, output, "-c root_colon") } + +func TestFishCompletionNoActiveHelp(t *testing.T) { + c := &Command{Use: "c", Run: emptyRun} + + buf := new(bytes.Buffer) + assertNoErr(t, c.GenFishCompletion(buf, true)) + output := buf.String() + + // check that active help is being disabled + activeHelpVar := activeHelpEnvVar(c.Name()) + check(t, output, fmt.Sprintf("%s=0", activeHelpVar)) +} + +func TestGenFishCompletionFile(t *testing.T) { + err := os.Mkdir("./tmp", 0755) + if err != nil { + log.Fatal(err.Error()) + } + + defer os.RemoveAll("./tmp") + + rootCmd := &Command{Use: "root", Args: NoArgs, Run: emptyRun} + child := &Command{ + Use: "child", + ValidArgsFunction: validArgsFunc, + Run: emptyRun, + } + rootCmd.AddCommand(child) + + assertNoErr(t, rootCmd.GenFishCompletionFile("./tmp/test", false)) +} + +func TestFailGenFishCompletionFile(t *testing.T) { + err := os.Mkdir("./tmp", 0755) + if err != nil { + log.Fatal(err.Error()) + } + + defer os.RemoveAll("./tmp") + + f, _ := os.OpenFile("./tmp/test", os.O_CREATE, 0400) + defer f.Close() + + rootCmd := &Command{Use: "root", Args: NoArgs, Run: emptyRun} + child := &Command{ + Use: "child", + ValidArgsFunction: validArgsFunc, + Run: emptyRun, + } + rootCmd.AddCommand(child) + + got := rootCmd.GenFishCompletionFile("./tmp/test", false) + if got == nil { + t.Error("should raise permission denied error") + } + + if os.Getenv("MSYSTEM") == "MINGW64" { + if got.Error() != "open ./tmp/test: Access is denied." { + t.Errorf("got: %s, want: %s", got.Error(), "open ./tmp/test: Access is denied.") + } + } else { + if got.Error() != "open ./tmp/test: permission denied" { + t.Errorf("got: %s, want: %s", got.Error(), "open ./tmp/test: permission denied") + } + } +} diff --git a/flag_groups.go b/flag_groups.go new file mode 100644 index 0000000000..dc78431194 --- /dev/null +++ b/flag_groups.go @@ -0,0 +1,223 @@ +// Copyright © 2022 Steve Francia . +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cobra + +import ( + "fmt" + "sort" + "strings" + + flag "github.com/spf13/pflag" +) + +const ( + requiredAsGroup = "cobra_annotation_required_if_others_set" + mutuallyExclusive = "cobra_annotation_mutually_exclusive" +) + +// MarkFlagsRequiredTogether marks the given flags with annotations so that Cobra errors +// if the command is invoked with a subset (but not all) of the given flags. +func (c *Command) MarkFlagsRequiredTogether(flagNames ...string) { + c.mergePersistentFlags() + for _, v := range flagNames { + f := c.Flags().Lookup(v) + if f == nil { + panic(fmt.Sprintf("Failed to find flag %q and mark it as being required in a flag group", v)) + } + if err := c.Flags().SetAnnotation(v, requiredAsGroup, append(f.Annotations[requiredAsGroup], strings.Join(flagNames, " "))); err != nil { + // Only errs if the flag isn't found. + panic(err) + } + } +} + +// MarkFlagsMutuallyExclusive marks the given flags with annotations so that Cobra errors +// if the command is invoked with more than one flag from the given set of flags. +func (c *Command) MarkFlagsMutuallyExclusive(flagNames ...string) { + c.mergePersistentFlags() + for _, v := range flagNames { + f := c.Flags().Lookup(v) + if f == nil { + panic(fmt.Sprintf("Failed to find flag %q and mark it as being in a mutually exclusive flag group", v)) + } + // Each time this is called is a single new entry; this allows it to be a member of multiple groups if needed. + if err := c.Flags().SetAnnotation(v, mutuallyExclusive, append(f.Annotations[mutuallyExclusive], strings.Join(flagNames, " "))); err != nil { + panic(err) + } + } +} + +// validateFlagGroups validates the mutuallyExclusive/requiredAsGroup logic and returns the +// first error encountered. +func (c *Command) validateFlagGroups() error { + if c.DisableFlagParsing { + return nil + } + + flags := c.Flags() + + // groupStatus format is the list of flags as a unique ID, + // then a map of each flag name and whether it is set or not. + groupStatus := map[string]map[string]bool{} + mutuallyExclusiveGroupStatus := map[string]map[string]bool{} + flags.VisitAll(func(pflag *flag.Flag) { + processFlagForGroupAnnotation(flags, pflag, requiredAsGroup, groupStatus) + processFlagForGroupAnnotation(flags, pflag, mutuallyExclusive, mutuallyExclusiveGroupStatus) + }) + + if err := validateRequiredFlagGroups(groupStatus); err != nil { + return err + } + if err := validateExclusiveFlagGroups(mutuallyExclusiveGroupStatus); err != nil { + return err + } + return nil +} + +func hasAllFlags(fs *flag.FlagSet, flagnames ...string) bool { + for _, fname := range flagnames { + f := fs.Lookup(fname) + if f == nil { + return false + } + } + return true +} + +func processFlagForGroupAnnotation(flags *flag.FlagSet, pflag *flag.Flag, annotation string, groupStatus map[string]map[string]bool) { + groupInfo, found := pflag.Annotations[annotation] + if found { + for _, group := range groupInfo { + if groupStatus[group] == nil { + flagnames := strings.Split(group, " ") + + // Only consider this flag group at all if all the flags are defined. + if !hasAllFlags(flags, flagnames...) { + continue + } + + groupStatus[group] = map[string]bool{} + for _, name := range flagnames { + groupStatus[group][name] = false + } + } + + groupStatus[group][pflag.Name] = pflag.Changed + } + } +} + +func validateRequiredFlagGroups(data map[string]map[string]bool) error { + keys := sortedKeys(data) + for _, flagList := range keys { + flagnameAndStatus := data[flagList] + + unset := []string{} + for flagname, isSet := range flagnameAndStatus { + if !isSet { + unset = append(unset, flagname) + } + } + if len(unset) == len(flagnameAndStatus) || len(unset) == 0 { + continue + } + + // Sort values, so they can be tested/scripted against consistently. + sort.Strings(unset) + return fmt.Errorf("if any flags in the group [%v] are set they must all be set; missing %v", flagList, unset) + } + + return nil +} + +func validateExclusiveFlagGroups(data map[string]map[string]bool) error { + keys := sortedKeys(data) + for _, flagList := range keys { + flagnameAndStatus := data[flagList] + var set []string + for flagname, isSet := range flagnameAndStatus { + if isSet { + set = append(set, flagname) + } + } + if len(set) == 0 || len(set) == 1 { + continue + } + + // Sort values, so they can be tested/scripted against consistently. + sort.Strings(set) + return fmt.Errorf("if any flags in the group [%v] are set none of the others can be; %v were all set", flagList, set) + } + return nil +} + +func sortedKeys(m map[string]map[string]bool) []string { + keys := make([]string, len(m)) + i := 0 + for k := range m { + keys[i] = k + i++ + } + sort.Strings(keys) + return keys +} + +// enforceFlagGroupsForCompletion will do the following: +// - when a flag in a group is present, other flags in the group will be marked required +// - when a flag in a mutually exclusive group is present, other flags in the group will be marked as hidden +// This allows the standard completion logic to behave appropriately for flag groups +func (c *Command) enforceFlagGroupsForCompletion() { + if c.DisableFlagParsing { + return + } + + flags := c.Flags() + groupStatus := map[string]map[string]bool{} + mutuallyExclusiveGroupStatus := map[string]map[string]bool{} + c.Flags().VisitAll(func(pflag *flag.Flag) { + processFlagForGroupAnnotation(flags, pflag, requiredAsGroup, groupStatus) + processFlagForGroupAnnotation(flags, pflag, mutuallyExclusive, mutuallyExclusiveGroupStatus) + }) + + // If a flag that is part of a group is present, we make all the other flags + // of that group required so that the shell completion suggests them automatically + for flagList, flagnameAndStatus := range groupStatus { + for _, isSet := range flagnameAndStatus { + if isSet { + // One of the flags of the group is set, mark the other ones as required + for _, fName := range strings.Split(flagList, " ") { + _ = c.MarkFlagRequired(fName) + } + } + } + } + + // If a flag that is mutually exclusive to others is present, we hide the other + // flags of that group so the shell completion does not suggest them + for flagList, flagnameAndStatus := range mutuallyExclusiveGroupStatus { + for flagName, isSet := range flagnameAndStatus { + if isSet { + // One of the flags of the mutually exclusive group is set, mark the other ones as hidden + // Don't mark the flag that is already set as hidden because it may be an + // array or slice flag and therefore must continue being suggested + for _, fName := range strings.Split(flagList, " ") { + if fName != flagName { + flag := c.Flags().Lookup(fName) + flag.Hidden = true + } + } + } + } + } +} diff --git a/flag_groups_test.go b/flag_groups_test.go new file mode 100644 index 0000000000..404ede562a --- /dev/null +++ b/flag_groups_test.go @@ -0,0 +1,151 @@ +// Copyright © 2022 Steve Francia . +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cobra + +import ( + "strings" + "testing" +) + +func TestValidateFlagGroups(t *testing.T) { + getCmd := func() *Command { + c := &Command{ + Use: "testcmd", + Run: func(cmd *Command, args []string) { + }} + // Define lots of flags to utilize for testing. + for _, v := range []string{"a", "b", "c", "d"} { + c.Flags().String(v, "", "") + } + for _, v := range []string{"e", "f", "g"} { + c.PersistentFlags().String(v, "", "") + } + subC := &Command{ + Use: "subcmd", + Run: func(cmd *Command, args []string) { + }} + subC.Flags().String("subonly", "", "") + c.AddCommand(subC) + return c + } + + // Each test case uses a unique command from the function above. + testcases := []struct { + desc string + flagGroupsRequired []string + flagGroupsExclusive []string + subCmdFlagGroupsRequired []string + subCmdFlagGroupsExclusive []string + args []string + expectErr string + }{ + { + desc: "No flags no problem", + }, { + desc: "No flags no problem even with conflicting groups", + flagGroupsRequired: []string{"a b"}, + flagGroupsExclusive: []string{"a b"}, + }, { + desc: "Required flag group not satisfied", + flagGroupsRequired: []string{"a b c"}, + args: []string{"--a=foo"}, + expectErr: "if any flags in the group [a b c] are set they must all be set; missing [b c]", + }, { + desc: "Exclusive flag group not satisfied", + flagGroupsExclusive: []string{"a b c"}, + args: []string{"--a=foo", "--b=foo"}, + expectErr: "if any flags in the group [a b c] are set none of the others can be; [a b] were all set", + }, { + desc: "Multiple required flag group not satisfied returns first error", + flagGroupsRequired: []string{"a b c", "a d"}, + args: []string{"--c=foo", "--d=foo"}, + expectErr: `if any flags in the group [a b c] are set they must all be set; missing [a b]`, + }, { + desc: "Multiple exclusive flag group not satisfied returns first error", + flagGroupsExclusive: []string{"a b c", "a d"}, + args: []string{"--a=foo", "--c=foo", "--d=foo"}, + expectErr: `if any flags in the group [a b c] are set none of the others can be; [a c] were all set`, + }, { + desc: "Validation of required groups occurs on groups in sorted order", + flagGroupsRequired: []string{"a d", "a b", "a c"}, + args: []string{"--a=foo"}, + expectErr: `if any flags in the group [a b] are set they must all be set; missing [b]`, + }, { + desc: "Validation of exclusive groups occurs on groups in sorted order", + flagGroupsExclusive: []string{"a d", "a b", "a c"}, + args: []string{"--a=foo", "--b=foo", "--c=foo"}, + expectErr: `if any flags in the group [a b] are set none of the others can be; [a b] were all set`, + }, { + desc: "Persistent flags utilize both features and can fail required groups", + flagGroupsRequired: []string{"a e", "e f"}, + flagGroupsExclusive: []string{"f g"}, + args: []string{"--a=foo", "--f=foo", "--g=foo"}, + expectErr: `if any flags in the group [a e] are set they must all be set; missing [e]`, + }, { + desc: "Persistent flags utilize both features and can fail mutually exclusive groups", + flagGroupsRequired: []string{"a e", "e f"}, + flagGroupsExclusive: []string{"f g"}, + args: []string{"--a=foo", "--e=foo", "--f=foo", "--g=foo"}, + expectErr: `if any flags in the group [f g] are set none of the others can be; [f g] were all set`, + }, { + desc: "Persistent flags utilize both features and can pass", + flagGroupsRequired: []string{"a e", "e f"}, + flagGroupsExclusive: []string{"f g"}, + args: []string{"--a=foo", "--e=foo", "--f=foo"}, + }, { + desc: "Subcmds can use required groups using inherited flags", + subCmdFlagGroupsRequired: []string{"e subonly"}, + args: []string{"subcmd", "--e=foo", "--subonly=foo"}, + }, { + desc: "Subcmds can use exclusive groups using inherited flags", + subCmdFlagGroupsExclusive: []string{"e subonly"}, + args: []string{"subcmd", "--e=foo", "--subonly=foo"}, + expectErr: "if any flags in the group [e subonly] are set none of the others can be; [e subonly] were all set", + }, { + desc: "Subcmds can use exclusive groups using inherited flags and pass", + subCmdFlagGroupsExclusive: []string{"e subonly"}, + args: []string{"subcmd", "--e=foo"}, + }, { + desc: "Flag groups not applied if not found on invoked command", + subCmdFlagGroupsRequired: []string{"e subonly"}, + args: []string{"--e=foo"}, + }, + } + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + c := getCmd() + sub := c.Commands()[0] + for _, flagGroup := range tc.flagGroupsRequired { + c.MarkFlagsRequiredTogether(strings.Split(flagGroup, " ")...) + } + for _, flagGroup := range tc.flagGroupsExclusive { + c.MarkFlagsMutuallyExclusive(strings.Split(flagGroup, " ")...) + } + for _, flagGroup := range tc.subCmdFlagGroupsRequired { + sub.MarkFlagsRequiredTogether(strings.Split(flagGroup, " ")...) + } + for _, flagGroup := range tc.subCmdFlagGroupsExclusive { + sub.MarkFlagsMutuallyExclusive(strings.Split(flagGroup, " ")...) + } + c.SetArgs(tc.args) + err := c.Execute() + switch { + case err == nil && len(tc.expectErr) > 0: + t.Errorf("Expected error %q but got nil", tc.expectErr) + case err != nil && err.Error() != tc.expectErr: + t.Errorf("Expected error %q but got %q", tc.expectErr, err) + } + }) + } +} diff --git a/go.mod b/go.mod index 2d6855911d..1d45d9f9ab 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/spf13/cobra go 1.15 require ( - github.com/cpuguy83/go-md2man/v2 v2.0.1 + github.com/cpuguy83/go-md2man/v2 v2.0.2 github.com/inconshreveable/mousetrap v1.0.0 github.com/spf13/pflag v1.0.5 gopkg.in/yaml.v2 v2.4.0 diff --git a/go.sum b/go.sum index 431058ed0f..8ed228800c 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,12 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= -github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/power_completions_test.go b/power_completions_test.go new file mode 100644 index 0000000000..7713835979 --- /dev/null +++ b/power_completions_test.go @@ -0,0 +1,19 @@ +package cobra + +import ( + "bytes" + "fmt" + "testing" +) + +func TestPwshCompletionNoActiveHelp(t *testing.T) { + c := &Command{Use: "c", Run: emptyRun} + + buf := new(bytes.Buffer) + assertNoErr(t, c.GenPowerShellCompletion(buf)) + output := buf.String() + + // check that active help is being disabled + activeHelpVar := activeHelpEnvVar(c.Name()) + check(t, output, fmt.Sprintf("%s=0", activeHelpVar)) +} diff --git a/powershell_completions.go b/powershell_completions.go index 62d719f0b3..379e7c088a 100644 --- a/powershell_completions.go +++ b/powershell_completions.go @@ -61,6 +61,7 @@ Register-ArgumentCompleter -CommandName '%[1]s' -ScriptBlock { # Prepare the command to request completions for the program. # Split the command at the first space to separate the program and arguments. $Program,$Arguments = $Command.Split(" ",2) + $RequestComp="$Program %[2]s $Arguments" __%[1]s_debug "RequestComp: $RequestComp" @@ -90,11 +91,13 @@ Register-ArgumentCompleter -CommandName '%[1]s' -ScriptBlock { } __%[1]s_debug "Calling $RequestComp" + # First disable ActiveHelp which is not supported for Powershell + $env:%[8]s=0 + #call the command store the output in $out and redirect stderr and stdout to null # $Out is an array contains each line per element Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null - # get directive from last line [int]$Directive = $Out[-1].TrimStart(':') if ($Directive -eq "") { @@ -242,7 +245,7 @@ Register-ArgumentCompleter -CommandName '%[1]s' -ScriptBlock { } `, name, compCmd, ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp, - ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs)) + ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, activeHelpEnvVar(name))) } func (c *Command) genPowerShellCompletion(w io.Writer, includeDesc bool) error { diff --git a/projects_using_cobra.md b/projects_using_cobra.md index ccd02e1815..ab25112bbb 100644 --- a/projects_using_cobra.md +++ b/projects_using_cobra.md @@ -1,8 +1,9 @@ ## Projects using Cobra - [Arduino CLI](https://github.com/arduino/arduino-cli) -- [Bleve](http://www.blevesearch.com/) -- [CockroachDB](http://www.cockroachlabs.com/) +- [Bleve](https://blevesearch.com/) +- [CloudQuery](https://github.com/cloudquery/cloudquery) +- [CockroachDB](https://www.cockroachlabs.com/) - [Cosmos SDK](https://github.com/cosmos/cosmos-sdk) - [Datree](https://github.com/datreeio/datree) - [Delve](https://github.com/derekparker/delve) @@ -14,15 +15,16 @@ - [Github CLI](https://github.com/cli/cli) - [GitHub Labeler](https://github.com/erdaltsksn/gh-label) - [Golangci-lint](https://golangci-lint.run) -- [GopherJS](http://www.gopherjs.org/) +- [GopherJS](https://github.com/gopherjs/gopherjs) - [GoReleaser](https://goreleaser.com) - [Helm](https://helm.sh) - [Hugo](https://gohugo.io) - [Infracost](https://github.com/infracost/infracost) - [Istio](https://istio.io) - [Kool](https://github.com/kool-dev/kool) -- [Kubernetes](http://kubernetes.io/) +- [Kubernetes](https://kubernetes.io/) - [Kubescape](https://github.com/armosec/kubescape) +- [KubeVirt](https://github.com/kubevirt/kubevirt) - [Linkerd](https://linkerd.io/) - [Mattermost-server](https://github.com/mattermost/mattermost-server) - [Mercure](https://mercure.rocks/) @@ -37,9 +39,11 @@ - [Ory Hydra](https://github.com/ory/hydra) - [Ory Kratos](https://github.com/ory/kratos) - [Pixie](https://github.com/pixie-io/pixie) +- [Polygon Edge](https://github.com/0xPolygon/polygon-edge) - [Pouch](https://github.com/alibaba/pouch) -- [ProjectAtomic (enterprise)](http://www.projectatomic.io/) +- [ProjectAtomic (enterprise)](https://www.projectatomic.io/) - [Prototool](https://github.com/uber/prototool) +- [Pulumi](https://www.pulumi.com) - [QRcp](https://github.com/claudiodangelis/qrcp) - [Random](https://github.com/erdaltsksn/random) - [Rclone](https://rclone.org/) diff --git a/shell_completions.md b/shell_completions.md index 33a4c65a5a..1e2058ed62 100644 --- a/shell_completions.md +++ b/shell_completions.md @@ -40,7 +40,7 @@ Bash: # Linux: $ %[1]s completion bash > /etc/bash_completion.d/%[1]s # macOS: - $ %[1]s completion bash > /usr/local/etc/bash_completion.d/%[1]s + $ %[1]s completion bash > $(brew --prefix)/etc/bash_completion.d/%[1]s Zsh: @@ -122,7 +122,7 @@ For example, if you want `kubectl get [tab][tab]` to show a list of valid "nouns Some simplified code from `kubectl get` looks like: ```go -validArgs []string = { "pod", "node", "service", "replicationcontroller" } +validArgs = []string{ "pod", "node", "service", "replicationcontroller" } cmd := &cobra.Command{ Use: "get [(-o|--output=)json|yaml|template|...] (RESOURCE [NAME] | RESOURCE/NAME ...)", @@ -148,7 +148,7 @@ node pod replicationcontroller service If your nouns have aliases, you can define them alongside `ValidArgs` using `ArgAliases`: ```go -argAliases []string = { "pods", "nodes", "services", "svc", "replicationcontrollers", "rc" } +argAliases = []string { "pods", "nodes", "services", "svc", "replicationcontrollers", "rc" } cmd := &cobra.Command{ ... diff --git a/user_guide.md b/user_guide.md index bf101eddcd..30fc1b916b 100644 --- a/user_guide.md +++ b/user_guide.md @@ -51,7 +51,7 @@ var rootCmd = &cobra.Command{ Short: "Hugo is a very fast static site generator", Long: `A Fast and Flexible Static Site Generator built with love by spf13 and friends in Go. - Complete documentation is available at http://hugo.spf13.com`, + Complete documentation is available at https://gohugo.io/documentation/`, Run: func(cmd *cobra.Command, args []string) { // Do Stuff Here }, @@ -300,10 +300,34 @@ rootCmd.PersistentFlags().StringVarP(&Region, "region", "r", "", "AWS region (re rootCmd.MarkPersistentFlagRequired("region") ``` +### Flag Groups + +If you have different flags that must be provided together (e.g. if they provide the `--username` flag they MUST provide the `--password` flag as well) then +Cobra can enforce that requirement: +```go +rootCmd.Flags().StringVarP(&u, "username", "u", "", "Username (required if password is set)") +rootCmd.Flags().StringVarP(&pw, "password", "p", "", "Password (required if username is set)") +rootCmd.MarkFlagsRequiredTogether("username", "password") +``` + +You can also prevent different flags from being provided together if they represent mutually +exclusive options such as specifying an output format as either `--json` or `--yaml` but never both: +```go +rootCmd.Flags().BoolVar(&u, "json", false, "Output in JSON") +rootCmd.Flags().BoolVar(&pw, "yaml", false, "Output in YAML") +rootCmd.MarkFlagsMutuallyExclusive("json", "yaml") +``` + +In both of these cases: + - both local and persistent flags can be used + - **NOTE:** the group is only enforced on commands where every flag is defined + - a flag may appear in multiple groups + - a group may contain any number of flags + ## Positional and Custom Arguments -Validation of positional arguments can be specified using the `Args` field -of `Command`. +Validation of positional arguments can be specified using the `Args` field of `Command`. +If `Args` is undefined or `nil`, it defaults to `ArbitraryArgs`. The following validators are built in: @@ -405,7 +429,7 @@ a count and a string.`, } ``` -For a more complete example of a larger application, please checkout [Hugo](http://gohugo.io/). +For a more complete example of a larger application, please checkout [Hugo](https://gohugo.io/). ## Help Command @@ -639,3 +663,7 @@ Cobra can generate documentation based on subcommands, flags, etc. Read more abo ## Generating shell completions Cobra can generate a shell-completion file for the following shells: bash, zsh, fish, PowerShell. If you add more information to your commands, these completions can be amazingly powerful and flexible. Read more about it in [Shell Completions](shell_completions.md). + +## Providing Active Help + +Cobra makes use of the shell-completion system to define a framework allowing you to provide Active Help to your users. Active Help are messages (hints, warnings, etc) printed as the program is being used. Read more about it in [Active Help](active_help.md). diff --git a/zsh_completions.go b/zsh_completions.go index 624adab537..65cd94c604 100644 --- a/zsh_completions.go +++ b/zsh_completions.go @@ -75,7 +75,7 @@ func genZshComp(buf io.StringWriter, name string, includeDesc bool) { if !includeDesc { compCmd = ShellCompNoDescRequestCmd } - WriteStringAndCheck(buf, fmt.Sprintf(`#compdef _%[1]s %[1]s + WriteStringAndCheck(buf, fmt.Sprintf(`#compdef %[1]s # zsh completion for %-36[1]s -*- shell-script -*- @@ -163,7 +163,24 @@ _%[1]s() return fi + local activeHelpMarker="%[8]s" + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __%[1]s_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __%[1]s_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + if [ -n "$comp" ]; then # If requested, completions are returned with a description. # The description is preceded by a TAB character. @@ -171,7 +188,7 @@ _%[1]s() # We first need to escape any : as part of the completion itself. comp=${comp//:/\\:} - local tab=$(printf '\t') + local tab="$(printf '\t')" comp=${comp//$tab/:} __%[1]s_debug "Adding completion: ${comp}" @@ -180,6 +197,17 @@ _%[1]s() fi done < <(printf "%%s\n" "${out[@]}") + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __%[1]s_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then __%[1]s_debug "Activating nospace." noSpace="-S ''" @@ -254,5 +282,6 @@ if [ "$funcstack[1]" = "_%[1]s" ]; then fi `, name, compCmd, ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp, - ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs)) + ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs, + activeHelpMarker)) } diff --git a/zsh_completions_test.go b/zsh_completions_test.go new file mode 100644 index 0000000000..b7addb4ca9 --- /dev/null +++ b/zsh_completions_test.go @@ -0,0 +1,19 @@ +package cobra + +import ( + "bytes" + "fmt" + "testing" +) + +func TestZshCompletionWithActiveHelp(t *testing.T) { + c := &Command{Use: "c", Run: emptyRun} + + buf := new(bytes.Buffer) + assertNoErr(t, c.GenZshCompletion(buf)) + output := buf.String() + + // check that active help is not being disabled + activeHelpVar := activeHelpEnvVar(c.Name()) + checkOmit(t, output, fmt.Sprintf("%s=0", activeHelpVar)) +}