From 2169adb5749372c64cdd303864ae8a444da6350f Mon Sep 17 00:00:00 2001 From: aawsome <37850842+aawsome@users.noreply.github.com> Date: Mon, 10 Oct 2022 22:59:11 +0200 Subject: [PATCH] Add groups for commands in help (#1003) * Add tests for grouping commands * Adds Additional Command section in help Signed-off-by: Marc Khouzam Co-authored-by: Marc Khouzam --- README.md | 1 + command.go | 80 +++++++++++++++++++++++++++++++++++++++-- command_test.go | 95 +++++++++++++++++++++++++++++++++++++++++++++++++ completions.go | 1 + user_guide.md | 7 ++++ 5 files changed, 181 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 08e4fa528..7cc726beb 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Cobra provides: * Global, local and cascading flags * Intelligent suggestions (`app srver`... did you mean `app server`?) * Automatic help generation for commands and flags +* Grouping help for subcommands * Automatic help flag recognition of `-h`, `--help`, etc. * Automatically generated shell autocomplete for your application (bash, zsh, fish, powershell) * Automatically generated man pages for your application diff --git a/command.go b/command.go index 64f1d5f47..ad2650738 100644 --- a/command.go +++ b/command.go @@ -35,6 +35,12 @@ const FlagSetByCobraAnnotation = "cobra_annotation_flag_set_by_cobra" // FParseErrWhitelist configures Flag parse errors to be ignored type FParseErrWhitelist flag.ParseErrorsWhitelist +// Structure to manage groups for commands +type Group struct { + ID string + Title string +} + // Command is just that, a command for your application. // E.g. 'go run ...' - 'run' is the command. Cobra requires // you to define the usage and description as part of your command @@ -61,6 +67,9 @@ type Command struct { // Short is the short description shown in the 'help' output. Short string + // The group id under which this subcommand is grouped in the 'help' output of its parent. + GroupID string + // Long is the long message shown in the 'help ' output. Long string @@ -128,6 +137,9 @@ type Command struct { // PersistentPostRunE: PersistentPostRun but returns an error. PersistentPostRunE func(cmd *Command, args []string) error + // groups for subcommands + commandgroups []*Group + // args is actual args parsed from flags. args []string // flagErrorBuf contains all error messages from pflag. @@ -160,6 +172,12 @@ type Command struct { // helpCommand is command with usage 'help'. If it's not defined by user, // cobra uses default help command. helpCommand *Command + // helpCommandGroupID is the group id for the helpCommand + helpCommandGroupID string + + // completionCommandGroupID is the group id for the completion command + completionCommandGroupID string + // versionTemplate is the version template defined by user. versionTemplate string @@ -303,6 +321,21 @@ func (c *Command) SetHelpCommand(cmd *Command) { c.helpCommand = cmd } +// SetHelpCommandGroup sets the group id of the help command. +func (c *Command) SetHelpCommandGroupID(groupID string) { + if c.helpCommand != nil { + c.helpCommand.GroupID = groupID + } + // helpCommandGroupID is used if no helpCommand is defined by the user + c.helpCommandGroupID = groupID +} + +// SetCompletionCommandGroup sets the group id of the completion command. +func (c *Command) SetCompletionCommandGroupID(groupID string) { + // completionCommandGroupID is used if no completion command is defined by the user + c.Root().completionCommandGroupID = groupID +} + // SetHelpTemplate sets help template to be used. Application can use it to set custom template. func (c *Command) SetHelpTemplate(s string) { c.helpTemplate = s @@ -511,10 +544,16 @@ Aliases: {{.NameAndAliases}}{{end}}{{if .HasExample}} Examples: -{{.Example}}{{end}}{{if .HasAvailableSubCommands}} +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} -Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} - {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} Flags: {{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} @@ -1140,6 +1179,7 @@ Simply type ` + c.Name() + ` help [path to command] for full details.`, CheckErr(cmd.Help()) } }, + GroupID: c.helpCommandGroupID, } } c.RemoveCommand(c.helpCommand) @@ -1178,6 +1218,10 @@ func (c *Command) AddCommand(cmds ...*Command) { panic("Command can't be a child of itself") } cmds[i].parent = c + // if Group is not defined let the developer know right away + if x.GroupID != "" && !c.ContainsGroup(x.GroupID) { + panic(fmt.Sprintf("Group id '%s' is not defined for subcommand '%s'", x.GroupID, cmds[i].CommandPath())) + } // update max lengths usageLen := len(x.Use) if usageLen > c.commandsMaxUseLen { @@ -1200,6 +1244,36 @@ func (c *Command) AddCommand(cmds ...*Command) { } } +// Groups returns a slice of child command groups. +func (c *Command) Groups() []*Group { + return c.commandgroups +} + +// AllChildCommandsHaveGroup returns if all subcommands are assigned to a group +func (c *Command) AllChildCommandsHaveGroup() bool { + for _, sub := range c.commands { + if (sub.IsAvailableCommand() || sub == c.helpCommand) && sub.GroupID == "" { + return false + } + } + return true +} + +// ContainGroups return if groupID exists in the list of command groups. +func (c *Command) ContainsGroup(groupID string) bool { + for _, x := range c.commandgroups { + if x.ID == groupID { + return true + } + } + return false +} + +// AddGroup adds one or more command groups to this parent command. +func (c *Command) AddGroup(groups ...*Group) { + c.commandgroups = append(c.commandgroups, groups...) +} + // RemoveCommand removes one or more commands from a parent command. func (c *Command) RemoveCommand(cmds ...*Command) { commands := []*Command{} diff --git a/command_test.go b/command_test.go index b3dd03040..0adec93de 100644 --- a/command_test.go +++ b/command_test.go @@ -1767,6 +1767,101 @@ func TestEnableCommandSortingIsDisabled(t *testing.T) { EnableCommandSorting = defaultCommandSorting } +func TestUsageWithGroup(t *testing.T) { + var rootCmd = &Command{Use: "root", Short: "test", Run: emptyRun} + rootCmd.CompletionOptions.DisableDefaultCmd = true + + rootCmd.AddGroup(&Group{ID: "group1", Title: "group1"}) + rootCmd.AddGroup(&Group{ID: "group2", Title: "group2"}) + + rootCmd.AddCommand(&Command{Use: "cmd1", GroupID: "group1", Run: emptyRun}) + rootCmd.AddCommand(&Command{Use: "cmd2", GroupID: "group2", Run: emptyRun}) + + output, err := executeCommand(rootCmd, "--help") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // help should be ungrouped here + checkStringContains(t, output, "\nAdditional Commands:\n help") + checkStringContains(t, output, "\ngroup1\n cmd1") + checkStringContains(t, output, "\ngroup2\n cmd2") +} + +func TestUsageHelpGroup(t *testing.T) { + var rootCmd = &Command{Use: "root", Short: "test", Run: emptyRun} + rootCmd.CompletionOptions.DisableDefaultCmd = true + + rootCmd.AddGroup(&Group{ID: "group", Title: "group"}) + rootCmd.AddCommand(&Command{Use: "xxx", GroupID: "group", Run: emptyRun}) + rootCmd.SetHelpCommandGroupID("group") + + output, err := executeCommand(rootCmd, "--help") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // now help should be grouped under "group" + checkStringOmits(t, output, "\nAdditional Commands:\n help") + checkStringContains(t, output, "\ngroup\n help") +} + +func TestUsageCompletionGroup(t *testing.T) { + var rootCmd = &Command{Use: "root", Short: "test", Run: emptyRun} + + rootCmd.AddGroup(&Group{ID: "group", Title: "group"}) + rootCmd.AddGroup(&Group{ID: "help", Title: "help"}) + + rootCmd.AddCommand(&Command{Use: "xxx", GroupID: "group", Run: emptyRun}) + rootCmd.SetHelpCommandGroupID("help") + rootCmd.SetCompletionCommandGroupID("group") + + output, err := executeCommand(rootCmd, "--help") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // now completion should be grouped under "group" + checkStringOmits(t, output, "\nAdditional Commands:\n completion") + checkStringContains(t, output, "\ngroup\n completion") +} + +func TestUngroupedCommand(t *testing.T) { + var rootCmd = &Command{Use: "root", Short: "test", Run: emptyRun} + + rootCmd.AddGroup(&Group{ID: "group", Title: "group"}) + rootCmd.AddGroup(&Group{ID: "help", Title: "help"}) + + rootCmd.AddCommand(&Command{Use: "xxx", GroupID: "group", Run: emptyRun}) + rootCmd.SetHelpCommandGroupID("help") + rootCmd.SetCompletionCommandGroupID("group") + + // Add a command without a group + rootCmd.AddCommand(&Command{Use: "yyy", Run: emptyRun}) + + output, err := executeCommand(rootCmd, "--help") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // The yyy command should be in the additional command "group" + checkStringContains(t, output, "\nAdditional Commands:\n yyy") +} + +func TestAddGroup(t *testing.T) { + var rootCmd = &Command{Use: "root", Short: "test", Run: emptyRun} + + rootCmd.AddGroup(&Group{ID: "group", Title: "Test group"}) + rootCmd.AddCommand(&Command{Use: "cmd", GroupID: "group", Run: emptyRun}) + + output, err := executeCommand(rootCmd, "--help") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + checkStringContains(t, output, "\nTest group\n cmd") +} + func TestSetOutput(t *testing.T) { c := &Command{} c.SetOutput(nil) diff --git a/completions.go b/completions.go index d8fa1f774..122d75299 100644 --- a/completions.go +++ b/completions.go @@ -673,6 +673,7 @@ See each sub-command's help for details on how to use the generated script. Args: NoArgs, ValidArgsFunction: NoFileCompletions, Hidden: c.CompletionOptions.HiddenDefaultCmd, + GroupID: c.completionCommandGroupID, } c.AddCommand(completionCmd) diff --git a/user_guide.md b/user_guide.md index c694d8719..977306aa8 100644 --- a/user_guide.md +++ b/user_guide.md @@ -490,6 +490,13 @@ command and flag definitions are needed. Help is just a command like any other. There is no special logic or behavior around it. In fact, you can provide your own if you want. +### Grouping commands in help + +Cobra supports grouping of available commands. Groups must be explicitly defined by `AddGroup` and set by +the `GroupId` element of a subcommand. The groups will appear in the same order as they are defined. +If you use the generated `help` or `completion` commands, you can set the group ids by `SetHelpCommandGroupId` +and `SetCompletionCommandGroupId`, respectively. + ### Defining your own help You can provide your own Help command or your own template for the default command to use