From 283e32d8896fb1beb9d07c742ed16fa2c182ee65 Mon Sep 17 00:00:00 2001 From: SwagPack Date: Mon, 13 Nov 2023 22:25:06 +0530 Subject: [PATCH 01/12] Add LXC to the list of projects using cobra (#2071) --- site/content/projects_using_cobra.md | 1 + 1 file changed, 1 insertion(+) diff --git a/site/content/projects_using_cobra.md b/site/content/projects_using_cobra.md index 8a291eb2..f85e9fa0 100644 --- a/site/content/projects_using_cobra.md +++ b/site/content/projects_using_cobra.md @@ -30,6 +30,7 @@ - [Kubescape](https://github.com/kubescape/kubescape) - [KubeVirt](https://github.com/kubevirt/kubevirt) - [Linkerd](https://linkerd.io/) +- [LXC](https://github.com/canonical/lxd) - [Mattermost-server](https://github.com/mattermost/mattermost-server) - [Mercure](https://mercure.rocks/) - [Meroxa CLI](https://github.com/meroxa/cli) From 3d8ac432bdad89db04ab0890754b2444d7b4e1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 23 Nov 2023 19:24:33 +0200 Subject: [PATCH 02/12] Micro-optimizations (#1957) * Avoid redundant string splits There likely isn't actually more than once to split in the source strings in these cases, but avoid doing so anyway as we're only interested in the first. * Avoid redundant completion output target evaluations The target is not to be changed while outputting completions, so resolve it only once. * Avoid redundant active help enablement evaluations The enablement state is not to be changed during completion output, so evaluate it only once. * Preallocate some slices and maps with known size * Avoid some unnecessary looping * Use strings.Builder to construct suggestions --- args.go | 4 ++-- bash_completions.go | 2 +- cobra.go | 2 -- command.go | 10 +++++----- completions.go | 20 ++++++++++---------- flag_groups.go | 10 +++++----- 6 files changed, 23 insertions(+), 25 deletions(-) diff --git a/args.go b/args.go index e79ec33a..ed1e70ce 100644 --- a/args.go +++ b/args.go @@ -52,9 +52,9 @@ func OnlyValidArgs(cmd *Command, args []string) error { if len(cmd.ValidArgs) > 0 { // Remove any description that may be included in ValidArgs. // A description is following a tab character. - var validArgs []string + validArgs := make([]string, 0, len(cmd.ValidArgs)) for _, v := range cmd.ValidArgs { - validArgs = append(validArgs, strings.Split(v, "\t")[0]) + validArgs = append(validArgs, strings.SplitN(v, "\t", 2)[0]) } for _, v := range args { if !stringInSlice(v, validArgs) { diff --git a/bash_completions.go b/bash_completions.go index 8a531518..be835704 100644 --- a/bash_completions.go +++ b/bash_completions.go @@ -621,7 +621,7 @@ func writeRequiredNouns(buf io.StringWriter, cmd *Command) { for _, value := range cmd.ValidArgs { // Remove any description that may be included following a tab character. // Descriptions are not supported by bash completion. - value = strings.Split(value, "\t")[0] + value = strings.SplitN(value, "\t", 2)[0] WriteStringAndCheck(buf, fmt.Sprintf(" must_have_one_noun+=(%q)\n", value)) } if cmd.ValidArgsFunction != nil { diff --git a/cobra.go b/cobra.go index a6b160ce..e0b0947b 100644 --- a/cobra.go +++ b/cobra.go @@ -193,8 +193,6 @@ func ld(s, t string, ignoreCase bool) int { d := make([][]int, len(s)+1) for i := range d { d[i] = make([]int, len(t)+1) - } - for i := range d { d[i][0] = i } for j := range d[0] { diff --git a/command.go b/command.go index 2fbe6c13..11a3e9c9 100644 --- a/command.go +++ b/command.go @@ -706,7 +706,7 @@ Loop: // This is not a flag or a flag value. Check to see if it matches what we're looking for, and if so, // return the args, excluding the one at this position. if s == x { - ret := []string{} + ret := make([]string, 0, len(args)-1) ret = append(ret, args[:pos]...) ret = append(ret, args[pos+1:]...) return ret @@ -754,14 +754,14 @@ func (c *Command) findSuggestions(arg string) string { if c.SuggestionsMinimumDistance <= 0 { c.SuggestionsMinimumDistance = 2 } - suggestionsString := "" + var sb strings.Builder if suggestions := c.SuggestionsFor(arg); len(suggestions) > 0 { - suggestionsString += "\n\nDid you mean this?\n" + sb.WriteString("\n\nDid you mean this?\n") for _, s := range suggestions { - suggestionsString += fmt.Sprintf("\t%v\n", s) + _, _ = fmt.Fprintf(&sb, "\t%v\n", s) } } - return suggestionsString + return sb.String() } func (c *Command) findNext(next string) *Command { diff --git a/completions.go b/completions.go index b60f6b20..b0e41df0 100644 --- a/completions.go +++ b/completions.go @@ -212,23 +212,23 @@ func (c *Command) initCompleteCmd(args []string) { } noDescriptions := (cmd.CalledAs() == ShellCompNoDescRequestCmd) + noActiveHelp := GetActiveHelpConfig(finalCmd) == activeHelpGlobalDisable + out := finalCmd.OutOrStdout() for _, comp := range completions { - if GetActiveHelpConfig(finalCmd) == activeHelpGlobalDisable { - // Remove all activeHelp entries in this case - if strings.HasPrefix(comp, activeHelpMarker) { - continue - } + if noActiveHelp && strings.HasPrefix(comp, activeHelpMarker) { + // Remove all activeHelp entries if it's disabled. + continue } if noDescriptions { // Remove any description that may be included following a tab character. - comp = strings.Split(comp, "\t")[0] + comp = strings.SplitN(comp, "\t", 2)[0] } // Make sure we only write the first line to the output. // This is needed if a description contains a linebreak. // Otherwise the shell scripts will interpret the other lines as new flags // and could therefore provide a wrong completion. - comp = strings.Split(comp, "\n")[0] + comp = strings.SplitN(comp, "\n", 2)[0] // Finally trim the completion. This is especially important to get rid // of a trailing tab when there are no description following it. @@ -237,14 +237,14 @@ func (c *Command) initCompleteCmd(args []string) { // although there is no description). comp = strings.TrimSpace(comp) - // Print each possible completion to stdout for the completion script to consume. - fmt.Fprintln(finalCmd.OutOrStdout(), comp) + // Print each possible completion to the output for the completion script to consume. + fmt.Fprintln(out, comp) } // As the last printout, print the completion directive for the completion script to parse. // The directive integer must be that last character following a single colon (:). // The completion script expects : - fmt.Fprintf(finalCmd.OutOrStdout(), ":%d\n", directive) + fmt.Fprintf(out, ":%d\n", directive) // Print some helpful info to stderr for the user to understand. // Output from stderr must be ignored by the completion script. diff --git a/flag_groups.go b/flag_groups.go index 0671ec5f..2be3b18b 100644 --- a/flag_groups.go +++ b/flag_groups.go @@ -130,7 +130,7 @@ func processFlagForGroupAnnotation(flags *flag.FlagSet, pflag *flag.Flag, annota continue } - groupStatus[group] = map[string]bool{} + groupStatus[group] = make(map[string]bool, len(flagnames)) for _, name := range flagnames { groupStatus[group][name] = false } @@ -253,17 +253,17 @@ func (c *Command) enforceFlagGroupsForCompletion() { // If none of the flags of a one-required group are present, we make all the flags // of that group required so that the shell completion suggests them automatically for flagList, flagnameAndStatus := range oneRequiredGroupStatus { - set := 0 + isSet := false - for _, isSet := range flagnameAndStatus { + for _, isSet = range flagnameAndStatus { if isSet { - set++ + break } } // None of the flags of the group are set, mark all flags in the group // as required - if set == 0 { + if !isSet { for _, fName := range strings.Split(flagList, " ") { _ = c.MarkFlagRequired(fName) } From 236f3c0418a1912d32159ad9c0b74f3f03764f5d Mon Sep 17 00:00:00 2001 From: Marcus Kohlberg <78424526+marcuskohlberg@users.noreply.github.com> Date: Tue, 12 Dec 2023 17:26:29 +0100 Subject: [PATCH 03/12] Update projects_using_cobra.md (#2089) Add Encore to the list of projects using Cobra --- site/content/projects_using_cobra.md | 1 + 1 file changed, 1 insertion(+) diff --git a/site/content/projects_using_cobra.md b/site/content/projects_using_cobra.md index f85e9fa0..73e7fea8 100644 --- a/site/content/projects_using_cobra.md +++ b/site/content/projects_using_cobra.md @@ -12,6 +12,7 @@ - [Datree](https://github.com/datreeio/datree) - [Delve](https://github.com/derekparker/delve) - [Docker (distribution)](https://github.com/docker/distribution) +- [Encore](https://encore.dev) - [Etcd](https://etcd.io/) - [Gardener](https://github.com/gardener/gardenctl) - [Giant Swarm's gsctl](https://github.com/giantswarm/gsctl) From e63925d3216142ba2c8d7641d729c6c15f4433e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 17 Dec 2023 20:50:59 +0200 Subject: [PATCH 04/12] Add env variable to suppress completion descriptions on create (#1938) COBRA_COMPLETION_DESCRIPTIONS=0 or _COMPLETION_DESCRIPTIONS=0 can now be used to disable shell completion descriptions. --- active_help.go | 13 +- completions.go | 40 +++++- completions_test.go | 192 +++++++++++++++++++++++++++++ site/content/completions/_index.md | 3 + 4 files changed, 237 insertions(+), 11 deletions(-) diff --git a/active_help.go b/active_help.go index 5f965e05..25c30e3c 100644 --- a/active_help.go +++ b/active_help.go @@ -17,21 +17,17 @@ package cobra import ( "fmt" "os" - "regexp" - "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" + activeHelpEnvVarSuffix = "ACTIVE_HELP" + activeHelpGlobalEnvVar = configEnvVarGlobalPrefix + "_" + activeHelpEnvVarSuffix activeHelpGlobalDisable = "0" ) -var activeHelpEnvVarPrefixSubstRegexp = regexp.MustCompile(`[^A-Z0-9_]`) - // 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. @@ -60,8 +56,5 @@ func GetActiveHelpConfig(cmd *Command) string { // variable. It has the format _ACTIVE_HELP where is the name of the // root command in upper case, with all non-ASCII-alphanumeric characters 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)) - activeHelpEnvVar = activeHelpEnvVarPrefixSubstRegexp.ReplaceAllString(activeHelpEnvVar, "_") - return activeHelpEnvVar + return configEnvVar(name, activeHelpEnvVarSuffix) } diff --git a/completions.go b/completions.go index b0e41df0..ad7b6d0a 100644 --- a/completions.go +++ b/completions.go @@ -17,6 +17,8 @@ package cobra import ( "fmt" "os" + "regexp" + "strconv" "strings" "sync" @@ -211,7 +213,12 @@ func (c *Command) initCompleteCmd(args []string) { // 2- Even without completions, we need to print the directive } - noDescriptions := (cmd.CalledAs() == ShellCompNoDescRequestCmd) + noDescriptions := cmd.CalledAs() == ShellCompNoDescRequestCmd + if !noDescriptions { + if doDescriptions, err := strconv.ParseBool(getEnvConfig(cmd, configEnvVarSuffixDescriptions)); err == nil { + noDescriptions = !doDescriptions + } + } noActiveHelp := GetActiveHelpConfig(finalCmd) == activeHelpGlobalDisable out := finalCmd.OutOrStdout() for _, comp := range completions { @@ -899,3 +906,34 @@ func CompError(msg string) { func CompErrorln(msg string) { CompError(fmt.Sprintf("%s\n", msg)) } + +// These values should not be changed: users will be using them explicitly. +const ( + configEnvVarGlobalPrefix = "COBRA" + configEnvVarSuffixDescriptions = "COMPLETION_DESCRIPTIONS" +) + +var configEnvVarPrefixSubstRegexp = regexp.MustCompile(`[^A-Z0-9_]`) + +// configEnvVar returns the name of the program-specific configuration environment +// variable. It has the format _ where is the name of the +// root command in upper case, with all non-ASCII-alphanumeric characters replaced by `_`. +func configEnvVar(name, suffix string) string { + // This format should not be changed: users will be using it explicitly. + v := strings.ToUpper(fmt.Sprintf("%s_%s", name, suffix)) + v = configEnvVarPrefixSubstRegexp.ReplaceAllString(v, "_") + return v +} + +// getEnvConfig returns the value of the configuration environment variable +// _ where is the name of the root command in upper +// case, with all non-ASCII-alphanumeric characters replaced by `_`. +// If the value is empty or not set, the value of the environment variable +// COBRA_ is returned instead. +func getEnvConfig(cmd *Command, suffix string) string { + v := os.Getenv(configEnvVar(cmd.Root().Name(), suffix)) + if v == "" { + v = os.Getenv(configEnvVar(configEnvVarGlobalPrefix, suffix)) + } + return v +} diff --git a/completions_test.go b/completions_test.go index d5aee250..df153fcf 100644 --- a/completions_test.go +++ b/completions_test.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "fmt" + "os" "strings" "sync" "testing" @@ -3517,3 +3518,194 @@ func TestGetFlagCompletion(t *testing.T) { }) } } + +func TestGetEnvConfig(t *testing.T) { + testCases := []struct { + desc string + use string + suffix string + cmdVar string + globalVar string + cmdVal string + globalVal string + expected string + }{ + { + desc: "Command envvar overrides global", + use: "root", + suffix: "test", + cmdVar: "ROOT_TEST", + globalVar: "COBRA_TEST", + cmdVal: "cmd", + globalVal: "global", + expected: "cmd", + }, + { + desc: "Missing/empty command envvar falls back to global", + use: "root", + suffix: "test", + cmdVar: "ROOT_TEST", + globalVar: "COBRA_TEST", + cmdVal: "", + globalVal: "global", + expected: "global", + }, + { + desc: "Missing/empty command and global envvars fall back to empty", + use: "root", + suffix: "test", + cmdVar: "ROOT_TEST", + globalVar: "COBRA_TEST", + cmdVal: "", + globalVal: "", + expected: "", + }, + { + desc: "Periods in command use transform to underscores in env var name", + use: "foo.bar", + suffix: "test", + cmdVar: "FOO_BAR_TEST", + globalVar: "COBRA_TEST", + cmdVal: "cmd", + globalVal: "global", + expected: "cmd", + }, + { + desc: "Dashes in command use transform to underscores in env var name", + use: "quux-BAZ", + suffix: "test", + cmdVar: "QUUX_BAZ_TEST", + globalVar: "COBRA_TEST", + cmdVal: "cmd", + globalVal: "global", + expected: "cmd", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + // Could make env handling cleaner with t.Setenv with Go >= 1.17 + err := os.Setenv(tc.cmdVar, tc.cmdVal) + defer func() { + assertNoErr(t, os.Unsetenv(tc.cmdVar)) + }() + assertNoErr(t, err) + err = os.Setenv(tc.globalVar, tc.globalVal) + defer func() { + assertNoErr(t, os.Unsetenv(tc.globalVar)) + }() + assertNoErr(t, err) + cmd := &Command{Use: tc.use} + got := getEnvConfig(cmd, tc.suffix) + if got != tc.expected { + t.Errorf("expected: %q, got: %q", tc.expected, got) + } + }) + } +} + +func TestDisableDescriptions(t *testing.T) { + rootCmd := &Command{ + Use: "root", + Run: emptyRun, + } + + childCmd := &Command{ + Use: "thechild", + Short: "The child command", + Run: emptyRun, + } + rootCmd.AddCommand(childCmd) + + specificDescriptionsEnvVar := configEnvVar(rootCmd.Name(), configEnvVarSuffixDescriptions) + globalDescriptionsEnvVar := configEnvVar(configEnvVarGlobalPrefix, configEnvVarSuffixDescriptions) + + const ( + descLineWithDescription = "first\tdescription" + descLineWithoutDescription = "first" + ) + childCmd.ValidArgsFunction = func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) { + comps := []string{descLineWithDescription} + return comps, ShellCompDirectiveDefault + } + + testCases := []struct { + desc string + globalEnvValue string + specificEnvValue string + expectedLine string + }{ + { + "No env variables set", + "", + "", + descLineWithDescription, + }, + { + "Global value false", + "false", + "", + descLineWithoutDescription, + }, + { + "Specific value false", + "", + "false", + descLineWithoutDescription, + }, + { + "Both values false", + "false", + "false", + descLineWithoutDescription, + }, + { + "Both values true", + "true", + "true", + descLineWithDescription, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + if err := os.Setenv(specificDescriptionsEnvVar, tc.specificEnvValue); err != nil { + t.Errorf("Unexpected error setting %s: %v", specificDescriptionsEnvVar, err) + } + if err := os.Setenv(globalDescriptionsEnvVar, tc.globalEnvValue); err != nil { + t.Errorf("Unexpected error setting %s: %v", globalDescriptionsEnvVar, err) + } + + var run = func() { + output, err := executeCommand(rootCmd, ShellCompRequestCmd, "thechild", "") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected := strings.Join([]string{ + tc.expectedLine, + ":0", + "Completion ended with directive: ShellCompDirectiveDefault", ""}, "\n") + if output != expected { + t.Errorf("expected: %q, got: %q", expected, output) + } + } + + run() + + // For empty cases, test also unset state + if tc.specificEnvValue == "" { + if err := os.Unsetenv(specificDescriptionsEnvVar); err != nil { + t.Errorf("Unexpected error unsetting %s: %v", specificDescriptionsEnvVar, err) + } + run() + } + if tc.globalEnvValue == "" { + if err := os.Unsetenv(globalDescriptionsEnvVar); err != nil { + t.Errorf("Unexpected error unsetting %s: %v", globalDescriptionsEnvVar, err) + } + run() + } + }) + } +} diff --git a/site/content/completions/_index.md b/site/content/completions/_index.md index 4efad290..02257ade 100644 --- a/site/content/completions/_index.md +++ b/site/content/completions/_index.md @@ -393,6 +393,9 @@ $ source <(helm completion bash --no-descriptions) $ helm completion [tab][tab] bash fish powershell zsh ``` + +Setting the `_COMPLETION_DESCRIPTIONS` environment variable (falling back to `COBRA_COMPLETION_DESCRIPTIONS` if empty or not set) to a [falsey value](https://pkg.go.dev/strconv#ParseBool) achieves the same. `` is the name of your program with all non-ASCII-alphanumeric characters replaced by `_`. + ## Bash completions ### Dependencies From df547f5fc6ee86071f73c36c16afd885bb4e3f28 Mon Sep 17 00:00:00 2001 From: Nir Soffer Date: Sun, 12 Nov 2023 17:20:47 +0200 Subject: [PATCH 05/12] Fix help text for plugins When using `CommandDisplayNameAnnotation` we want to use it instead of the command name in `--help` message or in the default help command. With current code we get the wrong text in the --help usage text: Flags: -h, --help help for kubectl-plugin And in the long description of the default help command: $ kubectl cobraplugin help -h Help provides help for any command in the application. Simply type kubectl-plugin help [path to command] for full details. The issue was hidden since the test checked only the Usage line. Fixed by extracting a displayName() function and use it when creating FlagSet and when formatting the default help flag usage and the help command long description. Enhance the TestPlugin to check all the lines including the command name. --- command.go | 27 ++++++++++++++++----------- command_test.go | 13 ++++++++++++- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/command.go b/command.go index 11a3e9c9..5c08c007 100644 --- a/command.go +++ b/command.go @@ -1187,10 +1187,11 @@ func (c *Command) InitDefaultHelpFlag() { c.mergePersistentFlags() if c.Flags().Lookup("help") == nil { usage := "help for " - if c.Name() == "" { + name := c.displayName() + if name == "" { usage += "this command" } else { - usage += c.Name() + usage += name } c.Flags().BoolP("help", "h", false, usage) _ = c.Flags().SetAnnotation("help", FlagSetByCobraAnnotation, []string{"true"}) @@ -1236,7 +1237,7 @@ func (c *Command) InitDefaultHelpCmd() { Use: "help [command]", Short: "Help about any command", Long: `Help provides help for any command in the application. -Simply type ` + c.Name() + ` help [path to command] for full details.`, +Simply type ` + c.displayName() + ` help [path to command] for full details.`, ValidArgsFunction: func(c *Command, args []string, toComplete string) ([]string, ShellCompDirective) { var completions []string cmd, _, e := c.Root().Find(args) @@ -1427,6 +1428,10 @@ func (c *Command) CommandPath() string { if c.HasParent() { return c.Parent().CommandPath() + " " + c.Name() } + return c.displayName() +} + +func (c *Command) displayName() string { if displayName, ok := c.Annotations[CommandDisplayNameAnnotation]; ok { return displayName } @@ -1642,7 +1647,7 @@ func (c *Command) GlobalNormalizationFunc() func(f *flag.FlagSet, name string) f // to this command (local and persistent declared here and by all parents). func (c *Command) Flags() *flag.FlagSet { if c.flags == nil { - c.flags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) + c.flags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError) if c.flagErrorBuf == nil { c.flagErrorBuf = new(bytes.Buffer) } @@ -1656,7 +1661,7 @@ func (c *Command) Flags() *flag.FlagSet { func (c *Command) LocalNonPersistentFlags() *flag.FlagSet { persistentFlags := c.PersistentFlags() - out := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + out := flag.NewFlagSet(c.displayName(), flag.ContinueOnError) c.LocalFlags().VisitAll(func(f *flag.Flag) { if persistentFlags.Lookup(f.Name) == nil { out.AddFlag(f) @@ -1670,7 +1675,7 @@ func (c *Command) LocalFlags() *flag.FlagSet { c.mergePersistentFlags() if c.lflags == nil { - c.lflags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) + c.lflags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError) if c.flagErrorBuf == nil { c.flagErrorBuf = new(bytes.Buffer) } @@ -1697,7 +1702,7 @@ func (c *Command) InheritedFlags() *flag.FlagSet { c.mergePersistentFlags() if c.iflags == nil { - c.iflags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) + c.iflags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError) if c.flagErrorBuf == nil { c.flagErrorBuf = new(bytes.Buffer) } @@ -1725,7 +1730,7 @@ func (c *Command) NonInheritedFlags() *flag.FlagSet { // PersistentFlags returns the persistent FlagSet specifically set in the current command. func (c *Command) PersistentFlags() *flag.FlagSet { if c.pflags == nil { - c.pflags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) + c.pflags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError) if c.flagErrorBuf == nil { c.flagErrorBuf = new(bytes.Buffer) } @@ -1738,9 +1743,9 @@ func (c *Command) PersistentFlags() *flag.FlagSet { func (c *Command) ResetFlags() { c.flagErrorBuf = new(bytes.Buffer) c.flagErrorBuf.Reset() - c.flags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) + c.flags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError) c.flags.SetOutput(c.flagErrorBuf) - c.pflags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) + c.pflags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError) c.pflags.SetOutput(c.flagErrorBuf) c.lflags = nil @@ -1857,7 +1862,7 @@ func (c *Command) mergePersistentFlags() { // If c.parentsPflags == nil, it makes new. func (c *Command) updateParentsPflags() { if c.parentsPflags == nil { - c.parentsPflags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) + c.parentsPflags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError) c.parentsPflags.SetOutput(c.flagErrorBuf) c.parentsPflags.SortFlags = false } diff --git a/command_test.go b/command_test.go index 9f686d65..a30c8364 100644 --- a/command_test.go +++ b/command_test.go @@ -371,7 +371,7 @@ func TestAliasPrefixMatching(t *testing.T) { // text should reflect the way we run the command. func TestPlugin(t *testing.T) { rootCmd := &Command{ - Use: "plugin", + Use: "kubectl-plugin", Args: NoArgs, Annotations: map[string]string{ CommandDisplayNameAnnotation: "kubectl plugin", @@ -387,6 +387,8 @@ func TestPlugin(t *testing.T) { } checkStringContains(t, rootHelp, "kubectl plugin [command]") + checkStringContains(t, rootHelp, "help for kubectl plugin") + checkStringContains(t, rootHelp, "kubectl plugin [command] --help") childHelp, err := executeCommand(rootCmd, "sub", "-h") if err != nil { @@ -394,6 +396,15 @@ func TestPlugin(t *testing.T) { } checkStringContains(t, childHelp, "kubectl plugin sub [flags]") + checkStringContains(t, childHelp, "help for sub") + + helpHelp, err := executeCommand(rootCmd, "help", "-h") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + checkStringContains(t, helpHelp, "kubectl plugin help [path to command]") + checkStringContains(t, helpHelp, "kubectl plugin help [command]") } // TestChildSameName checks the correct behaviour of cobra in cases, From a73b9c391a9489d20f5ee1480e75d3b99fc8c7e2 Mon Sep 17 00:00:00 2001 From: Nir Soffer Date: Sun, 12 Nov 2023 20:07:59 +0200 Subject: [PATCH 06/12] Fix help text for runnable plugin command When creating a plugin without sub commands, the help text included the command name (kubectl-plugin) instead of the display name (kubectl plugin): Usage: kubectl-plugin [flags] The issue is that the usage line for this case does not use the command path but the raw `Use` string, and this case was not tested. Add a test for this case and fix UsageLine() to replace the command name with the display name. Tested using https://github.com/nirs/kubernetes/tree/sample-cli-plugin-help --- command.go | 5 +++-- command_test.go | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/command.go b/command.go index 5c08c007..677dc9b6 100644 --- a/command.go +++ b/command.go @@ -1441,10 +1441,11 @@ func (c *Command) displayName() string { // UseLine puts out the full usage for a given command (including parents). func (c *Command) UseLine() string { var useline string + use := strings.Replace(c.Use, c.Name(), c.displayName(), 1) if c.HasParent() { - useline = c.parent.CommandPath() + " " + c.Use + useline = c.parent.CommandPath() + " " + use } else { - useline = c.Use + useline = use } if c.DisableFlagsInUseLine { return useline diff --git a/command_test.go b/command_test.go index a30c8364..b7d88e4d 100644 --- a/command_test.go +++ b/command_test.go @@ -370,6 +370,26 @@ func TestAliasPrefixMatching(t *testing.T) { // executable is `kubectl-plugin`, but we run it as `kubectl plugin`. The help // text should reflect the way we run the command. func TestPlugin(t *testing.T) { + cmd := &Command{ + Use: "kubectl-plugin", + Args: NoArgs, + Annotations: map[string]string{ + CommandDisplayNameAnnotation: "kubectl plugin", + }, + Run: emptyRun, + } + + cmdHelp, err := executeCommand(cmd, "-h") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + checkStringContains(t, cmdHelp, "kubectl plugin [flags]") + checkStringContains(t, cmdHelp, "help for kubectl plugin") +} + +// TestPlugin checks usage as plugin with sub commands. +func TestPluginWithSubCommands(t *testing.T) { rootCmd := &Command{ Use: "kubectl-plugin", Args: NoArgs, From 41227856cd731aeeeb5a2f7203b3a4c16b81fc67 Mon Sep 17 00:00:00 2001 From: Nir Soffer Date: Sun, 12 Nov 2023 15:55:40 +0200 Subject: [PATCH 07/12] Document how to create a plugin Using the new CommandDisplayNameAnnotation annotation introduced in Cobra 1.8.0. --- site/content/user_guide.md | 54 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/site/content/user_guide.md b/site/content/user_guide.md index 4116e8dc..3b42ef04 100644 --- a/site/content/user_guide.md +++ b/site/content/user_guide.md @@ -748,3 +748,57 @@ Read more about it in [Shell Completions](completions/_index.md). 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). + +## Creating a plugin + +When creating a plugin for tools like *kubectl*, the executable is named +`kubectl-myplugin`, but it is used as `kubectl myplugin`. To fix help +messages and completions, annotate the root command with the +`cobra.CommandDisplayNameAnnotation` annotation. + +### Example kubectl plugin + +```go +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func main() { + rootCmd := &cobra.Command{ + Use: "kubectl-myplugin", + Annotations: map[string]string{ + cobra.CommandDisplayNameAnnotation: "kubectl myplugin", + }, + } + subCmd := &cobra.Command{ + Use: "subcmd", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("kubectl myplugin subcmd") + }, + } + rootCmd.AddCommand(subCmd) + rootCmd.Execute() +} +``` + +Example run as a kubectl plugin: + +``` +$ kubectl myplugin +Usage: + kubectl myplugin [command] + +Available Commands: + completion Generate the autocompletion script for the specified shell + help Help about any command + subcmd + +Flags: + -h, --help help for kubectl myplugin + +Use "kubectl myplugin [command] --help" for more information about a command. +``` From c054701f6a5522c259dabfd206c54b41e1788519 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Dec 2023 10:06:36 +0000 Subject: [PATCH 08/12] build(deps): bump actions/setup-go from 4 to 5 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 5. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-go dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a9245322..b0cec77c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: '^1.21' check-latest: true @@ -74,7 +74,7 @@ jobs: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: 1.${{ matrix.go }}.x cache: true From 531ce793e3a44b949b1d0296a382f4f3a2c9da0d Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Sat, 23 Dec 2023 10:11:53 -0500 Subject: [PATCH 09/12] Remove extra actions/checkout Signed-off-by: Marc Khouzam --- .github/workflows/test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b0cec77c..74ed3317 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,8 +47,6 @@ jobs: check-latest: true cache: true - - uses: actions/checkout@v4 - - uses: golangci/golangci-lint-action@v3.7.0 with: version: latest From 199b7abe121caf41a163289041b13b0ca2418a72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Dec 2023 14:52:45 -0500 Subject: [PATCH 10/12] build(deps): bump actions/labeler from 4 to 5 (#2086) * build(deps): bump actions/labeler from 4 to 5 Bumps [actions/labeler](https://github.com/actions/labeler) from 4 to 5. - [Release notes](https://github.com/actions/labeler/releases) - [Commits](https://github.com/actions/labeler/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/labeler dependency-type: direct:production update-type: version-update:semver-major ... * Update labeler configuration for v5 Signed-off-by: dependabot[bot] Signed-off-by: Marc Khouzam Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marc Khouzam --- .github/labeler.yml | 19 +++++++++++++------ .github/workflows/labeler.yml | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 0f0bc3c9..0db3be27 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,17 +1,24 @@ # changes to documentation generation -"area/docs-generation": doc/**/* +"area/docs-generation": +- changed-files: + - any-glob-to-any-file: 'doc/**' # changes to the core cobra command "area/cobra-command": -- any: ['./cobra.go', './cobra_test.go', './*command*.go'] +- changed-files: + - any-glob-to-any-file: ['./cobra.go', './cobra_test.go', './*command*.go'] # changes made to command flags/args -"area/flags": ./args*.go +"area/flags": +- changed-files: + - any-glob-to-any-file: './args*.go' # changes to Github workflows -"area/github": .github/**/* +"area/github": +- changed-files: + - any-glob-to-any-file: '.github/**' # changes to shell completions "area/shell-completion": - - ./*completions* - +- changed-files: + - any-glob-to-any-file: './*completions*' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 17f451fd..cc5c5400 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write # for actions/labeler to add labels to PRs runs-on: ubuntu-latest steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 with: repo-token: "${{ github.token }}" From cbcf75eab98095a631a917692f9667b55d3ccfd6 Mon Sep 17 00:00:00 2001 From: Case Wylie Date: Wed, 27 Dec 2023 21:16:29 -0500 Subject: [PATCH 11/12] [chore]: update projects using cobra (#2093) Signed-off-by: Case Wylie --- site/content/projects_using_cobra.md | 1 + 1 file changed, 1 insertion(+) diff --git a/site/content/projects_using_cobra.md b/site/content/projects_using_cobra.md index 73e7fea8..75cc065c 100644 --- a/site/content/projects_using_cobra.md +++ b/site/content/projects_using_cobra.md @@ -63,4 +63,5 @@ - [Vitess](https://vitess.io) - VMware's [Tanzu Community Edition](https://github.com/vmware-tanzu/community-edition) & [Tanzu Framework](https://github.com/vmware-tanzu/tanzu-framework) - [Werf](https://werf.io/) +- [Zarf](https://github.com/defenseunicorns/zarf) - [ZITADEL](https://github.com/zitadel/zitadel) From 0dec88e7931d4c5d5583e69b12e245741d9f1353 Mon Sep 17 00:00:00 2001 From: Denis <43725617+korovindenis@users.noreply.github.com> Date: Sat, 30 Dec 2023 16:40:15 +0300 Subject: [PATCH 12/12] Add tests for funcs in cobra.go (#2094) --- cobra_test.go | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/cobra_test.go b/cobra_test.go index fbb07f9b..2bba461c 100644 --- a/cobra_test.go +++ b/cobra_test.go @@ -40,3 +40,185 @@ func TestAddTemplateFunctions(t *testing.T) { t.Errorf("Expected UsageString: %v\nGot: %v", expected, got) } } + +func TestLevenshteinDistance(t *testing.T) { + tests := []struct { + name string + s string + t string + ignoreCase bool + expected int + }{ + { + name: "Equal strings (case-sensitive)", + s: "hello", + t: "hello", + ignoreCase: false, + expected: 0, + }, + { + name: "Equal strings (case-insensitive)", + s: "Hello", + t: "hello", + ignoreCase: true, + expected: 0, + }, + { + name: "Different strings (case-sensitive)", + s: "kitten", + t: "sitting", + ignoreCase: false, + expected: 3, + }, + { + name: "Different strings (case-insensitive)", + s: "Kitten", + t: "Sitting", + ignoreCase: true, + expected: 3, + }, + { + name: "Empty strings", + s: "", + t: "", + ignoreCase: false, + expected: 0, + }, + { + name: "One empty string", + s: "abc", + t: "", + ignoreCase: false, + expected: 3, + }, + { + name: "Both empty strings", + s: "", + t: "", + ignoreCase: true, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Act + got := ld(tt.s, tt.t, tt.ignoreCase) + + // Assert + if got != tt.expected { + t.Errorf("Expected ld: %v\nGot: %v", tt.expected, got) + } + }) + } +} + +func TestStringInSlice(t *testing.T) { + tests := []struct { + name string + a string + list []string + expected bool + }{ + { + name: "String in slice (case-sensitive)", + a: "apple", + list: []string{"orange", "banana", "apple", "grape"}, + expected: true, + }, + { + name: "String not in slice (case-sensitive)", + a: "pear", + list: []string{"orange", "banana", "apple", "grape"}, + expected: false, + }, + { + name: "String in slice (case-insensitive)", + a: "APPLE", + list: []string{"orange", "banana", "apple", "grape"}, + expected: false, + }, + { + name: "Empty slice", + a: "apple", + list: []string{}, + expected: false, + }, + { + name: "Empty string", + a: "", + list: []string{"orange", "banana", "apple", "grape"}, + expected: false, + }, + { + name: "Empty strings match", + a: "", + list: []string{"orange", ""}, + expected: true, + }, + { + name: "Empty string in empty slice", + a: "", + list: []string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Act + got := stringInSlice(tt.a, tt.list) + + // Assert + if got != tt.expected { + t.Errorf("Expected stringInSlice: %v\nGot: %v", tt.expected, got) + } + }) + } +} + +func TestRpad(t *testing.T) { + tests := []struct { + name string + inputString string + padding int + expected string + }{ + { + name: "Padding required", + inputString: "Hello", + padding: 10, + expected: "Hello ", + }, + { + name: "No padding required", + inputString: "World", + padding: 5, + expected: "World", + }, + { + name: "Empty string", + inputString: "", + padding: 8, + expected: " ", + }, + { + name: "Zero padding", + inputString: "cobra", + padding: 0, + expected: "cobra", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Act + got := rpad(tt.inputString, tt.padding) + + // Assert + if got != tt.expected { + t.Errorf("Expected rpad: %v\nGot: %v", tt.expected, got) + } + }) + } +}