Skip to content

Commit

Permalink
feat: add CompletionWithDesc helper (#2231)
Browse files Browse the repository at this point in the history
The code has also been refactored to use a type alias for completion and a completion helper

Using a type alias is a non-breaking change and it makes the code more readable and easier to understand.

Signed-off-by: ccoVeille <3875889+ccoVeille@users.noreply.github.com>
Co-authored-by: Marc Khouzam <marc.khouzam@gmail.com>
  • Loading branch information
ccoVeille and marckhouzam authored Feb 9, 2025
1 parent 17b6dca commit 8cb30f9
Show file tree
Hide file tree
Showing 6 changed files with 118 additions and 46 deletions.
2 changes: 1 addition & 1 deletion active_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const (
// 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 {
func AppendActiveHelp(compArray []Completion, activeHelpStr string) []Completion {
return append(compArray, fmt.Sprintf("%s%s", activeHelpMarker, activeHelpStr))
}

Expand Down
8 changes: 4 additions & 4 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ type Command struct {
Example string

// ValidArgs is list of all valid non-flag arguments that are accepted in shell completions
ValidArgs []string
ValidArgs []Completion
// ValidArgsFunction is an optional function that provides valid non-flag arguments for shell completion.
// It is a dynamic version of using ValidArgs.
// Only one of ValidArgs and ValidArgsFunction can be used for a command.
Expand Down Expand Up @@ -1272,8 +1272,8 @@ func (c *Command) InitDefaultHelpCmd() {
Short: "Help about any command",
Long: `Help provides help for any command in the application.
Simply type ` + c.DisplayName() + ` help [path to command] for full details.`,
ValidArgsFunction: func(c *Command, args []string, toComplete string) ([]string, ShellCompDirective) {
var completions []string
ValidArgsFunction: func(c *Command, args []string, toComplete string) ([]Completion, ShellCompDirective) {
var completions []Completion
cmd, _, e := c.Root().Find(args)
if e != nil {
return nil, ShellCompDirectiveNoFileComp
Expand All @@ -1285,7 +1285,7 @@ Simply type ` + c.DisplayName() + ` help [path to command] for full details.`,
for _, subCmd := range cmd.Commands() {
if subCmd.IsAvailableCommand() || subCmd == cmd.helpCommand {
if strings.HasPrefix(subCmd.Name(), toComplete) {
completions = append(completions, fmt.Sprintf("%s\t%s", subCmd.Name(), subCmd.Short))
completions = append(completions, CompletionWithDesc(subCmd.Name(), subCmd.Short))
}
}
}
Expand Down
58 changes: 37 additions & 21 deletions completions.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,31 @@ type CompletionOptions struct {
HiddenDefaultCmd bool
}

// Completion is a string that can be used for completions
//
// two formats are supported:
// - the completion choice
// - the completion choice with a textual description (separated by a TAB).
//
// [CompletionWithDesc] can be used to create a completion string with a textual description.
//
// Note: Go type alias is used to provide a more descriptive name in the documentation, but any string can be used.
type Completion = string

// CompletionFunc is a function that provides completion results.
type CompletionFunc func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective)
type CompletionFunc func(cmd *Command, args []string, toComplete string) ([]Completion, ShellCompDirective)

// CompletionWithDesc returns a [Completion] with a description by using the TAB delimited format.
func CompletionWithDesc(choice string, description string) Completion {
return choice + "\t" + description
}

// NoFileCompletions can be used to disable file completion for commands that should
// not trigger file completions.
//
// This method satisfies [CompletionFunc].
// It can be used with [Command.RegisterFlagCompletionFunc] and for [Command.ValidArgsFunction].
func NoFileCompletions(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) {
func NoFileCompletions(cmd *Command, args []string, toComplete string) ([]Completion, ShellCompDirective) {
return nil, ShellCompDirectiveNoFileComp
}

Expand All @@ -134,8 +150,8 @@ func NoFileCompletions(cmd *Command, args []string, toComplete string) ([]string
//
// This method returns a function that satisfies [CompletionFunc]
// It can be used with [Command.RegisterFlagCompletionFunc] and for [Command.ValidArgsFunction].
func FixedCompletions(choices []string, directive ShellCompDirective) CompletionFunc {
return func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) {
func FixedCompletions(choices []Completion, directive ShellCompDirective) CompletionFunc {
return func(cmd *Command, args []string, toComplete string) ([]Completion, ShellCompDirective) {
return choices, directive
}
}
Expand Down Expand Up @@ -290,7 +306,7 @@ type SliceValue interface {
GetSlice() []string
}

func (c *Command) getCompletions(args []string) (*Command, []string, ShellCompDirective, error) {
func (c *Command) getCompletions(args []string) (*Command, []Completion, ShellCompDirective, error) {
// The last argument, which is not completely typed by the user,
// should not be part of the list of arguments
toComplete := args[len(args)-1]
Expand Down Expand Up @@ -318,7 +334,7 @@ func (c *Command) getCompletions(args []string) (*Command, []string, ShellCompDi
}
if err != nil {
// Unable to find the real command. E.g., <program> someInvalidCmd <TAB>
return c, []string{}, ShellCompDirectiveDefault, fmt.Errorf("unable to find a command for arguments: %v", trimmedArgs)
return c, []Completion{}, ShellCompDirectiveDefault, fmt.Errorf("unable to find a command for arguments: %v", trimmedArgs)
}
finalCmd.ctx = c.ctx

Expand Down Expand Up @@ -348,7 +364,7 @@ func (c *Command) getCompletions(args []string) (*Command, []string, ShellCompDi

// Parse the flags early so we can check if required flags are set
if err = finalCmd.ParseFlags(finalArgs); err != nil {
return finalCmd, []string{}, ShellCompDirectiveDefault, fmt.Errorf("Error while parsing flags from args %v: %s", finalArgs, err.Error())
return finalCmd, []Completion{}, ShellCompDirectiveDefault, fmt.Errorf("Error while parsing flags from args %v: %s", finalArgs, err.Error())
}

realArgCount := finalCmd.Flags().NArg()
Expand All @@ -360,14 +376,14 @@ func (c *Command) getCompletions(args []string) (*Command, []string, ShellCompDi
if flagErr != nil {
// If error type is flagCompError and we don't want flagCompletion we should ignore the error
if _, ok := flagErr.(*flagCompError); !(ok && !flagCompletion) {
return finalCmd, []string{}, ShellCompDirectiveDefault, flagErr
return finalCmd, []Completion{}, ShellCompDirectiveDefault, flagErr
}
}

// Look for the --help or --version flags. If they are present,
// there should be no further completions.
if helpOrVersionFlagPresent(finalCmd) {
return finalCmd, []string{}, ShellCompDirectiveNoFileComp, nil
return finalCmd, []Completion{}, ShellCompDirectiveNoFileComp, nil
}

// We only remove the flags from the arguments if DisableFlagParsing is not set.
Expand Down Expand Up @@ -396,11 +412,11 @@ func (c *Command) getCompletions(args []string) (*Command, []string, ShellCompDi
return finalCmd, subDir, ShellCompDirectiveFilterDirs, nil
}
// Directory completion
return finalCmd, []string{}, ShellCompDirectiveFilterDirs, nil
return finalCmd, []Completion{}, ShellCompDirectiveFilterDirs, nil
}
}

var completions []string
var completions []Completion
var directive ShellCompDirective

// Enforce flag groups before doing flag completions
Expand Down Expand Up @@ -486,7 +502,7 @@ func (c *Command) getCompletions(args []string) (*Command, []string, ShellCompDi
for _, subCmd := range finalCmd.Commands() {
if subCmd.IsAvailableCommand() || subCmd == finalCmd.helpCommand {
if strings.HasPrefix(subCmd.Name(), toComplete) {
completions = append(completions, fmt.Sprintf("%s\t%s", subCmd.Name(), subCmd.Short))
completions = append(completions, CompletionWithDesc(subCmd.Name(), subCmd.Short))
}
directive = ShellCompDirectiveNoFileComp
}
Expand Down Expand Up @@ -542,7 +558,7 @@ func (c *Command) getCompletions(args []string) (*Command, []string, ShellCompDi
if completionFn != nil {
// Go custom completion defined for this flag or command.
// Call the registered completion function to get the completions.
var comps []string
var comps []Completion
comps, directive = completionFn(finalCmd, finalArgs, toComplete)
completions = append(completions, comps...)
}
Expand All @@ -562,16 +578,16 @@ func helpOrVersionFlagPresent(cmd *Command) bool {
return false
}

func getFlagNameCompletions(flag *pflag.Flag, toComplete string) []string {
func getFlagNameCompletions(flag *pflag.Flag, toComplete string) []Completion {
if nonCompletableFlag(flag) {
return []string{}
return []Completion{}
}

var completions []string
var completions []Completion
flagName := "--" + flag.Name
if strings.HasPrefix(flagName, toComplete) {
// Flag without the =
completions = append(completions, fmt.Sprintf("%s\t%s", flagName, flag.Usage))
completions = append(completions, CompletionWithDesc(flagName, flag.Usage))

// Why suggest both long forms: --flag and --flag= ?
// This forces the user to *always* have to type either an = or a space after the flag name.
Expand All @@ -583,20 +599,20 @@ func getFlagNameCompletions(flag *pflag.Flag, toComplete string) []string {
// if len(flag.NoOptDefVal) == 0 {
// // Flag requires a value, so it can be suffixed with =
// flagName += "="
// completions = append(completions, fmt.Sprintf("%s\t%s", flagName, flag.Usage))
// completions = append(completions, CompletionWithDesc(flagName, flag.Usage))
// }
}

flagName = "-" + flag.Shorthand
if len(flag.Shorthand) > 0 && strings.HasPrefix(flagName, toComplete) {
completions = append(completions, fmt.Sprintf("%s\t%s", flagName, flag.Usage))
completions = append(completions, CompletionWithDesc(flagName, flag.Usage))
}

return completions
}

func completeRequireFlags(finalCmd *Command, toComplete string) []string {
var completions []string
func completeRequireFlags(finalCmd *Command, toComplete string) []Completion {
var completions []Completion

doCompleteRequiredFlags := func(flag *pflag.Flag) {
if _, present := flag.Annotations[BashCompOneRequiredFlag]; present {
Expand Down
50 changes: 50 additions & 0 deletions completions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2872,6 +2872,56 @@ func TestFixedCompletions(t *testing.T) {
}
}

func TestFixedCompletionsWithCompletionHelpers(t *testing.T) {
rootCmd := &Command{Use: "root", Args: NoArgs, Run: emptyRun}
// here we are mixing string, [Completion] and [CompletionWithDesc]
choices := []string{"apple", Completion("banana"), CompletionWithDesc("orange", "orange are orange")}
childCmd := &Command{
Use: "child",
ValidArgsFunction: FixedCompletions(choices, ShellCompDirectiveNoFileComp),
Run: emptyRun,
}
rootCmd.AddCommand(childCmd)

t.Run("completion with description", func(t *testing.T) {
output, err := executeCommand(rootCmd, ShellCompRequestCmd, "child", "a")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}

expected := strings.Join([]string{
"apple",
"banana",
"orange\torange are orange", // this one has the description as expected with [ShellCompRequestCmd] flag
":4",
"Completion ended with directive: ShellCompDirectiveNoFileComp", "",
}, "\n")

if output != expected {
t.Errorf("expected: %q, got: %q", expected, output)
}
})

t.Run("completion with no description", func(t *testing.T) {
output, err := executeCommand(rootCmd, ShellCompNoDescRequestCmd, "child", "a")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}

expected := strings.Join([]string{
"apple",
"banana",
"orange", // the description is absent as expected with [ShellCompNoDescRequestCmd] flag
":4",
"Completion ended with directive: ShellCompDirectiveNoFileComp", "",
}, "\n")

if output != expected {
t.Errorf("expected: %q, got: %q", expected, output)
}
})
}

func TestCompletionForGroupedFlags(t *testing.T) {
getCmd := func() *Command {
rootCmd := &Command{
Expand Down
10 changes: 5 additions & 5 deletions site/content/active_help.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ cmd := &cobra.Command{
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
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
var comps []cobra.Completion
if len(args) == 0 {
comps = cobra.AppendActiveHelp(comps, "You must choose a name for the repo you are adding")
} else if len(args) == 1 {
Expand Down Expand Up @@ -75,7 +75,7 @@ This command does not take any more arguments
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) {
_ = cmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, 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
}
Expand Down Expand Up @@ -112,10 +112,10 @@ should or should not be added (instead of reading the environment variable direc
For example:

```go
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
activeHelpLevel := cobra.GetActiveHelpConfig(cmd)

var comps []string
var comps []cobra.Completion
if len(args) == 0 {
if activeHelpLevel != "off" {
comps = cobra.AppendActiveHelp(comps, "You must choose a name for the repo you are adding")
Expand Down
36 changes: 21 additions & 15 deletions site/content/completions/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) {
RunGet(args[0])
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
Expand Down Expand Up @@ -211,21 +211,21 @@ ShellCompDirectiveNoFileComp

// Indicates that the returned completions should be used as file extension filters.
// For example, to complete only files of the form *.json or *.yaml:
// return []string{"yaml", "json"}, ShellCompDirectiveFilterFileExt
// return []cobra.Completion{"yaml", "json"}, cobra.ShellCompDirectiveFilterFileExt
// For flags, using MarkFlagFilename() and MarkPersistentFlagFilename()
// is a shortcut to using this directive explicitly.
//
ShellCompDirectiveFilterFileExt

// Indicates that only directory names should be provided in file completion.
// For example:
// return nil, ShellCompDirectiveFilterDirs
// return nil, cobra.ShellCompDirectiveFilterDirs
// For flags, using MarkFlagDirname() is a shortcut to using this directive explicitly.
//
// To request directory names within another directory, the returned completions
// should specify a single directory name within which to search. For example,
// to complete directories within "themes/":
// return []string{"themes"}, ShellCompDirectiveFilterDirs
// return []cobra.Completion{"themes"}, cobra.ShellCompDirectiveFilterDirs
//
ShellCompDirectiveFilterDirs

Expand Down Expand Up @@ -293,8 +293,8 @@ As for nouns, Cobra provides a way of defining dynamic completion of flags. To
```go
flagName := "output"
cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "table", "yaml"}, cobra.ShellCompDirectiveDefault
cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
return []cobra.Completion{"json", "table", "yaml"}, cobra.ShellCompDirectiveDefault
})
```
Notice that calling `RegisterFlagCompletionFunc()` is done through the `command` with which the flag is associated. In our example this dynamic completion will give results like so:
Expand Down Expand Up @@ -327,8 +327,8 @@ cmd.MarkFlagFilename(flagName, "yaml", "json")
or
```go
flagName := "output"
cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"yaml", "json"}, ShellCompDirectiveFilterFileExt})
cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
return []cobra.Completion{"yaml", "json"}, cobra.ShellCompDirectiveFilterFileExt})
```
### Limit flag completions to directory names
Expand All @@ -341,15 +341,15 @@ cmd.MarkFlagDirname(flagName)
or
```go
flagName := "output"
cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveFilterDirs
})
```
To limit completions of flag values to directory names *within another directory* you can use a combination of `RegisterFlagCompletionFunc()` and `ShellCompDirectiveFilterDirs` like so:
```go
flagName := "output"
cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"themes"}, cobra.ShellCompDirectiveFilterDirs
cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
return []cobra.Completion{"themes"}, cobra.ShellCompDirectiveFilterDirs
})
```
### Descriptions for completions
Expand All @@ -370,15 +370,21 @@ $ helm s[tab]
search (search for a keyword in charts) show (show information of a chart) status (displays the status of the named release)
```
Cobra allows you to add descriptions to your own completions. Simply add the description text after each completion, following a `\t` separator. This technique applies to completions returned by `ValidArgs`, `ValidArgsFunction` and `RegisterFlagCompletionFunc()`. For example:
Cobra allows you to add descriptions to your own completions. Simply add the description text after each completion, following a `\t` separator. Cobra provides the helper function `CompletionWithDesc(string, string)` to create a completion with a description. This technique applies to completions returned by `ValidArgs`, `ValidArgsFunction` and `RegisterFlagCompletionFunc()`. For example:
```go
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"harbor\tAn image registry", "thanos\tLong-term metrics"}, cobra.ShellCompDirectiveNoFileComp
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
return []cobra.Completion{
cobra.CompletionWithDesc("harbor", "An image registry"),
cobra.CompletionWithDesc("thanos", "Long-term metrics")
}, cobra.ShellCompDirectiveNoFileComp
}}
```
or
```go
ValidArgs: []string{"bash\tCompletions for bash", "zsh\tCompletions for zsh"}
ValidArgs: []cobra.Completion{
cobra.CompletionWithDesc("bash", "Completions for bash"),
cobra.CompletionWithDesc("zsh", "Completions for zsh")
}
```

If you don't want to show descriptions in the completions, you can add `--no-descriptions` to the default `completion` command to disable them, like:
Expand Down

0 comments on commit 8cb30f9

Please sign in to comment.