Merge branch 'spf13:main' into dm/local-flags-doc

This commit is contained in:
Dmytro Milinevskyi 2024-01-06 18:14:14 +01:00 committed by GitHub
commit d35fd5fad3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 566 additions and 61 deletions

19
.github/labeler.yml vendored
View file

@ -1,17 +1,24 @@
# changes to documentation generation # 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 # changes to the core cobra command
"area/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 # 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 # changes to Github workflows
"area/github": .github/**/* "area/github":
- changed-files:
- any-glob-to-any-file: '.github/**'
# changes to shell completions # changes to shell completions
"area/shell-completion": "area/shell-completion":
- ./*completions* - changed-files:
- any-glob-to-any-file: './*completions*'

View file

@ -12,7 +12,7 @@ jobs:
pull-requests: write # for actions/labeler to add labels to PRs pull-requests: write # for actions/labeler to add labels to PRs
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/labeler@v4 - uses: actions/labeler@v5
with: with:
repo-token: "${{ github.token }}" repo-token: "${{ github.token }}"

View file

@ -41,14 +41,12 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v4 - uses: actions/setup-go@v5
with: with:
go-version: '^1.21' go-version: '^1.21'
check-latest: true check-latest: true
cache: true cache: true
- uses: actions/checkout@v4
- uses: golangci/golangci-lint-action@v3.7.0 - uses: golangci/golangci-lint-action@v3.7.0
with: with:
version: latest version: latest
@ -74,7 +72,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v4 - uses: actions/setup-go@v5
with: with:
go-version: 1.${{ matrix.go }}.x go-version: 1.${{ matrix.go }}.x
cache: true cache: true

View file

@ -17,21 +17,17 @@ package cobra
import ( import (
"fmt" "fmt"
"os" "os"
"regexp"
"strings"
) )
const ( const (
activeHelpMarker = "_activeHelp_ " activeHelpMarker = "_activeHelp_ "
// The below values should not be changed: programs will be using them explicitly // The below values should not be changed: programs will be using them explicitly
// in their user documentation, and users will be using them explicitly. // in their user documentation, and users will be using them explicitly.
activeHelpEnvVarSuffix = "_ACTIVE_HELP" activeHelpEnvVarSuffix = "ACTIVE_HELP"
activeHelpGlobalEnvVar = "COBRA_ACTIVE_HELP" activeHelpGlobalEnvVar = configEnvVarGlobalPrefix + "_" + activeHelpEnvVarSuffix
activeHelpGlobalDisable = "0" activeHelpGlobalDisable = "0"
) )
var activeHelpEnvVarPrefixSubstRegexp = regexp.MustCompile(`[^A-Z0-9_]`)
// AppendActiveHelp adds the specified string to the specified array to be used as ActiveHelp. // 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 // Such strings will be processed by the completion script and will be shown as ActiveHelp
// to the user. // to the user.
@ -60,8 +56,5 @@ func GetActiveHelpConfig(cmd *Command) string {
// variable. It has the format <PROGRAM>_ACTIVE_HELP where <PROGRAM> is the name of the // variable. It has the format <PROGRAM>_ACTIVE_HELP where <PROGRAM> is the name of the
// root command in upper case, with all non-ASCII-alphanumeric characters replaced by `_`. // root command in upper case, with all non-ASCII-alphanumeric characters replaced by `_`.
func activeHelpEnvVar(name string) string { func activeHelpEnvVar(name string) string {
// This format should not be changed: users will be using it explicitly. return configEnvVar(name, activeHelpEnvVarSuffix)
activeHelpEnvVar := strings.ToUpper(fmt.Sprintf("%s%s", name, activeHelpEnvVarSuffix))
activeHelpEnvVar = activeHelpEnvVarPrefixSubstRegexp.ReplaceAllString(activeHelpEnvVar, "_")
return activeHelpEnvVar
} }

View file

@ -52,9 +52,9 @@ func OnlyValidArgs(cmd *Command, args []string) error {
if len(cmd.ValidArgs) > 0 { if len(cmd.ValidArgs) > 0 {
// Remove any description that may be included in ValidArgs. // Remove any description that may be included in ValidArgs.
// A description is following a tab character. // A description is following a tab character.
var validArgs []string validArgs := make([]string, 0, len(cmd.ValidArgs))
for _, v := range 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 { for _, v := range args {
if !stringInSlice(v, validArgs) { if !stringInSlice(v, validArgs) {

View file

@ -621,7 +621,7 @@ func writeRequiredNouns(buf io.StringWriter, cmd *Command) {
for _, value := range cmd.ValidArgs { for _, value := range cmd.ValidArgs {
// Remove any description that may be included following a tab character. // Remove any description that may be included following a tab character.
// Descriptions are not supported by bash completion. // 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)) WriteStringAndCheck(buf, fmt.Sprintf(" must_have_one_noun+=(%q)\n", value))
} }
if cmd.ValidArgsFunction != nil { if cmd.ValidArgsFunction != nil {

View file

@ -193,8 +193,6 @@ func ld(s, t string, ignoreCase bool) int {
d := make([][]int, len(s)+1) d := make([][]int, len(s)+1)
for i := range d { for i := range d {
d[i] = make([]int, len(t)+1) d[i] = make([]int, len(t)+1)
}
for i := range d {
d[i][0] = i d[i][0] = i
} }
for j := range d[0] { for j := range d[0] {

View file

@ -40,3 +40,185 @@ func TestAddTemplateFunctions(t *testing.T) {
t.Errorf("Expected UsageString: %v\nGot: %v", expected, got) 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)
}
})
}
}

View file

@ -708,7 +708,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, // 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. // return the args, excluding the one at this position.
if s == x { if s == x {
ret := []string{} ret := make([]string, 0, len(args)-1)
ret = append(ret, args[:pos]...) ret = append(ret, args[:pos]...)
ret = append(ret, args[pos+1:]...) ret = append(ret, args[pos+1:]...)
return ret return ret
@ -756,14 +756,14 @@ func (c *Command) findSuggestions(arg string) string {
if c.SuggestionsMinimumDistance <= 0 { if c.SuggestionsMinimumDistance <= 0 {
c.SuggestionsMinimumDistance = 2 c.SuggestionsMinimumDistance = 2
} }
suggestionsString := "" var sb strings.Builder
if suggestions := c.SuggestionsFor(arg); len(suggestions) > 0 { 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 { 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 { func (c *Command) findNext(next string) *Command {
@ -1189,10 +1189,11 @@ func (c *Command) InitDefaultHelpFlag() {
c.mergePersistentFlags() c.mergePersistentFlags()
if c.Flags().Lookup("help") == nil { if c.Flags().Lookup("help") == nil {
usage := "help for " usage := "help for "
if c.Name() == "" { name := c.displayName()
if name == "" {
usage += "this command" usage += "this command"
} else { } else {
usage += c.Name() usage += name
} }
c.Flags().BoolP("help", "h", false, usage) c.Flags().BoolP("help", "h", false, usage)
_ = c.Flags().SetAnnotation("help", FlagSetByCobraAnnotation, []string{"true"}) _ = c.Flags().SetAnnotation("help", FlagSetByCobraAnnotation, []string{"true"})
@ -1238,7 +1239,7 @@ func (c *Command) InitDefaultHelpCmd() {
Use: "help [command]", Use: "help [command]",
Short: "Help about any command", Short: "Help about any command",
Long: `Help provides help for any command in the application. 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) { ValidArgsFunction: func(c *Command, args []string, toComplete string) ([]string, ShellCompDirective) {
var completions []string var completions []string
cmd, _, e := c.Root().Find(args) cmd, _, e := c.Root().Find(args)
@ -1429,6 +1430,10 @@ func (c *Command) CommandPath() string {
if c.HasParent() { if c.HasParent() {
return c.Parent().CommandPath() + " " + c.Name() return c.Parent().CommandPath() + " " + c.Name()
} }
return c.displayName()
}
func (c *Command) displayName() string {
if displayName, ok := c.Annotations[CommandDisplayNameAnnotation]; ok { if displayName, ok := c.Annotations[CommandDisplayNameAnnotation]; ok {
return displayName return displayName
} }
@ -1438,10 +1443,11 @@ func (c *Command) CommandPath() string {
// UseLine puts out the full usage for a given command (including parents). // UseLine puts out the full usage for a given command (including parents).
func (c *Command) UseLine() string { func (c *Command) UseLine() string {
var useline string var useline string
use := strings.Replace(c.Use, c.Name(), c.displayName(), 1)
if c.HasParent() { if c.HasParent() {
useline = c.parent.CommandPath() + " " + c.Use useline = c.parent.CommandPath() + " " + use
} else { } else {
useline = c.Use useline = use
} }
if c.DisableFlagsInUseLine { if c.DisableFlagsInUseLine {
return useline return useline
@ -1644,7 +1650,7 @@ func (c *Command) GlobalNormalizationFunc() func(f *flag.FlagSet, name string) f
// to this command (local and persistent declared here and by all parents). // to this command (local and persistent declared here and by all parents).
func (c *Command) Flags() *flag.FlagSet { func (c *Command) Flags() *flag.FlagSet {
if c.flags == nil { if c.flags == nil {
c.flags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) c.flags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError)
if c.flagErrorBuf == nil { if c.flagErrorBuf == nil {
c.flagErrorBuf = new(bytes.Buffer) c.flagErrorBuf = new(bytes.Buffer)
} }
@ -1659,7 +1665,7 @@ func (c *Command) Flags() *flag.FlagSet {
func (c *Command) LocalNonPersistentFlags() *flag.FlagSet { func (c *Command) LocalNonPersistentFlags() *flag.FlagSet {
persistentFlags := c.PersistentFlags() persistentFlags := c.PersistentFlags()
out := flag.NewFlagSet(c.Name(), flag.ContinueOnError) out := flag.NewFlagSet(c.displayName(), flag.ContinueOnError)
c.LocalFlags().VisitAll(func(f *flag.Flag) { c.LocalFlags().VisitAll(func(f *flag.Flag) {
if persistentFlags.Lookup(f.Name) == nil { if persistentFlags.Lookup(f.Name) == nil {
out.AddFlag(f) out.AddFlag(f)
@ -1674,7 +1680,7 @@ func (c *Command) LocalFlags() *flag.FlagSet {
c.mergePersistentFlags() c.mergePersistentFlags()
if c.lflags == nil { if c.lflags == nil {
c.lflags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) c.lflags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError)
if c.flagErrorBuf == nil { if c.flagErrorBuf == nil {
c.flagErrorBuf = new(bytes.Buffer) c.flagErrorBuf = new(bytes.Buffer)
} }
@ -1702,7 +1708,7 @@ func (c *Command) InheritedFlags() *flag.FlagSet {
c.mergePersistentFlags() c.mergePersistentFlags()
if c.iflags == nil { if c.iflags == nil {
c.iflags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) c.iflags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError)
if c.flagErrorBuf == nil { if c.flagErrorBuf == nil {
c.flagErrorBuf = new(bytes.Buffer) c.flagErrorBuf = new(bytes.Buffer)
} }
@ -1731,7 +1737,7 @@ func (c *Command) NonInheritedFlags() *flag.FlagSet {
// PersistentFlags returns the persistent FlagSet specifically set in the current command. // PersistentFlags returns the persistent FlagSet specifically set in the current command.
func (c *Command) PersistentFlags() *flag.FlagSet { func (c *Command) PersistentFlags() *flag.FlagSet {
if c.pflags == nil { if c.pflags == nil {
c.pflags = flag.NewFlagSet(c.Name(), flag.ContinueOnError) c.pflags = flag.NewFlagSet(c.displayName(), flag.ContinueOnError)
if c.flagErrorBuf == nil { if c.flagErrorBuf == nil {
c.flagErrorBuf = new(bytes.Buffer) c.flagErrorBuf = new(bytes.Buffer)
} }
@ -1744,9 +1750,9 @@ func (c *Command) PersistentFlags() *flag.FlagSet {
func (c *Command) ResetFlags() { func (c *Command) ResetFlags() {
c.flagErrorBuf = new(bytes.Buffer) c.flagErrorBuf = new(bytes.Buffer)
c.flagErrorBuf.Reset() 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.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.pflags.SetOutput(c.flagErrorBuf)
c.lflags = nil c.lflags = nil
@ -1863,7 +1869,7 @@ func (c *Command) mergePersistentFlags() {
// If c.parentsPflags == nil, it makes new. // If c.parentsPflags == nil, it makes new.
func (c *Command) updateParentsPflags() { func (c *Command) updateParentsPflags() {
if c.parentsPflags == nil { 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.SetOutput(c.flagErrorBuf)
c.parentsPflags.SortFlags = false c.parentsPflags.SortFlags = false
} }

View file

@ -370,8 +370,28 @@ func TestAliasPrefixMatching(t *testing.T) {
// executable is `kubectl-plugin`, but we run it as `kubectl plugin`. The help // executable is `kubectl-plugin`, but we run it as `kubectl plugin`. The help
// text should reflect the way we run the command. // text should reflect the way we run the command.
func TestPlugin(t *testing.T) { 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{ rootCmd := &Command{
Use: "plugin", Use: "kubectl-plugin",
Args: NoArgs, Args: NoArgs,
Annotations: map[string]string{ Annotations: map[string]string{
CommandDisplayNameAnnotation: "kubectl plugin", CommandDisplayNameAnnotation: "kubectl plugin",
@ -387,6 +407,8 @@ func TestPlugin(t *testing.T) {
} }
checkStringContains(t, rootHelp, "kubectl plugin [command]") 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") childHelp, err := executeCommand(rootCmd, "sub", "-h")
if err != nil { if err != nil {
@ -394,6 +416,15 @@ func TestPlugin(t *testing.T) {
} }
checkStringContains(t, childHelp, "kubectl plugin sub [flags]") 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, // TestChildSameName checks the correct behaviour of cobra in cases,

View file

@ -17,6 +17,8 @@ package cobra
import ( import (
"fmt" "fmt"
"os" "os"
"regexp"
"strconv"
"strings" "strings"
"sync" "sync"
@ -211,24 +213,29 @@ func (c *Command) initCompleteCmd(args []string) {
// 2- Even without completions, we need to print the directive // 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 { for _, comp := range completions {
if GetActiveHelpConfig(finalCmd) == activeHelpGlobalDisable { if noActiveHelp && strings.HasPrefix(comp, activeHelpMarker) {
// Remove all activeHelp entries in this case // Remove all activeHelp entries if it's disabled.
if strings.HasPrefix(comp, activeHelpMarker) { continue
continue
}
} }
if noDescriptions { if noDescriptions {
// Remove any description that may be included following a tab character. // 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. // Make sure we only write the first line to the output.
// This is needed if a description contains a linebreak. // This is needed if a description contains a linebreak.
// Otherwise the shell scripts will interpret the other lines as new flags // Otherwise the shell scripts will interpret the other lines as new flags
// and could therefore provide a wrong completion. // 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 // Finally trim the completion. This is especially important to get rid
// of a trailing tab when there are no description following it. // of a trailing tab when there are no description following it.
@ -237,14 +244,14 @@ func (c *Command) initCompleteCmd(args []string) {
// although there is no description). // although there is no description).
comp = strings.TrimSpace(comp) comp = strings.TrimSpace(comp)
// Print each possible completion to stdout for the completion script to consume. // Print each possible completion to the output for the completion script to consume.
fmt.Fprintln(finalCmd.OutOrStdout(), comp) fmt.Fprintln(out, comp)
} }
// As the last printout, print the completion directive for the completion script to parse. // 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 directive integer must be that last character following a single colon (:).
// The completion script expects :<directive> // The completion script expects :<directive>
fmt.Fprintf(finalCmd.OutOrStdout(), ":%d\n", directive) fmt.Fprintf(out, ":%d\n", directive)
// Print some helpful info to stderr for the user to understand. // Print some helpful info to stderr for the user to understand.
// Output from stderr must be ignored by the completion script. // Output from stderr must be ignored by the completion script.
@ -899,3 +906,34 @@ func CompError(msg string) {
func CompErrorln(msg string) { func CompErrorln(msg string) {
CompError(fmt.Sprintf("%s\n", msg)) 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 <PROGRAM>_<SUFFIX> where <PROGRAM> 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
// <PROGRAM>_<SUFFIX> where <PROGRAM> 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_<SUFFIX> 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
}

View file

@ -18,6 +18,7 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"os"
"strings" "strings"
"sync" "sync"
"testing" "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()
}
})
}
}

View file

@ -130,7 +130,7 @@ func processFlagForGroupAnnotation(flags *flag.FlagSet, pflag *flag.Flag, annota
continue continue
} }
groupStatus[group] = map[string]bool{} groupStatus[group] = make(map[string]bool, len(flagnames))
for _, name := range flagnames { for _, name := range flagnames {
groupStatus[group][name] = false 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 // 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 // of that group required so that the shell completion suggests them automatically
for flagList, flagnameAndStatus := range oneRequiredGroupStatus { for flagList, flagnameAndStatus := range oneRequiredGroupStatus {
set := 0 isSet := false
for _, isSet := range flagnameAndStatus { for _, isSet = range flagnameAndStatus {
if isSet { if isSet {
set++ break
} }
} }
// None of the flags of the group are set, mark all flags in the group // None of the flags of the group are set, mark all flags in the group
// as required // as required
if set == 0 { if !isSet {
for _, fName := range strings.Split(flagList, " ") { for _, fName := range strings.Split(flagList, " ") {
_ = c.MarkFlagRequired(fName) _ = c.MarkFlagRequired(fName)
} }

View file

@ -393,6 +393,9 @@ $ source <(helm completion bash --no-descriptions)
$ helm completion [tab][tab] $ helm completion [tab][tab]
bash fish powershell zsh bash fish powershell zsh
``` ```
Setting the `<PROGRAM>_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. `<PROGRAM>` is the name of your program with all non-ASCII-alphanumeric characters replaced by `_`.
## Bash completions ## Bash completions
### Dependencies ### Dependencies

View file

@ -12,6 +12,7 @@
- [Datree](https://github.com/datreeio/datree) - [Datree](https://github.com/datreeio/datree)
- [Delve](https://github.com/derekparker/delve) - [Delve](https://github.com/derekparker/delve)
- [Docker (distribution)](https://github.com/docker/distribution) - [Docker (distribution)](https://github.com/docker/distribution)
- [Encore](https://encore.dev)
- [Etcd](https://etcd.io/) - [Etcd](https://etcd.io/)
- [Gardener](https://github.com/gardener/gardenctl) - [Gardener](https://github.com/gardener/gardenctl)
- [Giant Swarm's gsctl](https://github.com/giantswarm/gsctl) - [Giant Swarm's gsctl](https://github.com/giantswarm/gsctl)
@ -30,6 +31,7 @@
- [Kubescape](https://github.com/kubescape/kubescape) - [Kubescape](https://github.com/kubescape/kubescape)
- [KubeVirt](https://github.com/kubevirt/kubevirt) - [KubeVirt](https://github.com/kubevirt/kubevirt)
- [Linkerd](https://linkerd.io/) - [Linkerd](https://linkerd.io/)
- [LXC](https://github.com/canonical/lxd)
- [Mattermost-server](https://github.com/mattermost/mattermost-server) - [Mattermost-server](https://github.com/mattermost/mattermost-server)
- [Mercure](https://mercure.rocks/) - [Mercure](https://mercure.rocks/)
- [Meroxa CLI](https://github.com/meroxa/cli) - [Meroxa CLI](https://github.com/meroxa/cli)
@ -61,4 +63,5 @@
- [Vitess](https://vitess.io) - [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) - 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/) - [Werf](https://werf.io/)
- [Zarf](https://github.com/defenseunicorns/zarf)
- [ZITADEL](https://github.com/zitadel/zitadel) - [ZITADEL](https://github.com/zitadel/zitadel)

View file

@ -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. 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. Active Help are messages (hints, warnings, etc) printed as the program is being used.
Read more about it in [Active Help](active_help.md). 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.
```