diff --git a/README.md b/README.md index 71fcd00..f0b7a30 100644 --- a/README.md +++ b/README.md @@ -589,6 +589,7 @@ The default help output is usually sufficient, but if not there are two solution 1. Use `ConfigureHelp(HelpOptions)` to configure how help is formatted (see [HelpOptions](https://godoc.org/github.com/alecthomas/kong#HelpOptions) for details). 2. Custom help can be wired into Kong via the `Help(HelpFunc)` option. The `HelpFunc` is passed a `Context`, which contains the parsed context for the current command-line. See the implementation of `PrintHelp` for an example. 3. Use `HelpFormatter(HelpValueFormatter)` if you want to just customize the help text that is accompanied by flags and arguments. +4. Use `Groups([]Group)` if you want to customize group titles or add a header. ### `Bind(...)` - bind values for callback hooks and Run() methods diff --git a/build.go b/build.go index 9037512..8e70674 100644 --- a/build.go +++ b/build.go @@ -142,7 +142,7 @@ func buildChild(k *Kong, node *Node, typ NodeType, v reflect.Value, ft reflect.S child.Parent = node child.Help = tag.Help child.Hidden = tag.Hidden - child.Group = tag.Group + child.Group = buildGroupForKey(k, tag.Group) child.Aliases = tag.Aliases if provider, ok := fv.Addr().Interface().(HelpProvider); ok { @@ -213,7 +213,7 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv Short: tag.Short, PlaceHolder: tag.PlaceHolder, Env: tag.Env, - Group: tag.Group, + Group: buildGroupForKey(k, tag.Group), Xor: tag.Xor, Hidden: tag.Hidden, } @@ -221,3 +221,20 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv node.Flags = append(node.Flags, flag) } } + +func buildGroupForKey(k *Kong, key string) *Group { + if key == "" { + return nil + } + for _, group := range k.groups { + if group.Key == key { + return &group + } + } + + // No group provided with kong.Groups. We create one ad-hoc for this key. + return &Group{ + Key: key, + Title: key, + } +} diff --git a/help.go b/help.go index 4814b1c..31b0b75 100644 --- a/help.go +++ b/help.go @@ -144,22 +144,41 @@ func printNodeDetail(w *helpWriter, node *Node, hide bool) { writePositionals(w.Indent(), node.Positional) } if flags := node.AllFlags(true); len(flags) > 0 { - w.Print("") - w.Print("Flags:") - writeFlags(w.Indent(), flags) + groupedFlags := collectFlagGroups(flags) + for _, group := range groupedFlags { + w.Print("") + if group.Metadata.Title != "" { + w.Print(group.Metadata.Title) + } + if group.Metadata.Header != "" { + w.Print(group.Metadata.Header) + } + writeFlags(w.Indent(), group.Flags) + } } cmds := node.Leaves(hide) if len(cmds) > 0 { - w.Print("") - w.Print("Commands:") + iw := w.Indent() if w.Tree { - writeCommandTree(w, node) + w.Print("") + w.Print("Commands:") + writeCommandTree(iw, node) } else { - iw := w.Indent() - if w.Compact { - writeCompactCommandList(cmds, iw) - } else { - writeCommandList(cmds, iw) + groupedCmds := collectCommandGroups(cmds) + for _, group := range groupedCmds { + w.Print("") + if group.Metadata.Title != "" { + w.Print(group.Metadata.Title) + } + if group.Metadata.Header != "" { + w.Print(group.Metadata.Header) + } + + if w.Compact { + writeCompactCommandList(group.Commands, iw) + } else { + writeCommandList(group.Commands, iw) + } } } } @@ -189,7 +208,6 @@ func writeCompactCommandList(cmds []*Node, iw *helpWriter) { } func writeCommandTree(w *helpWriter, node *Node) { - iw := w.Indent() rows := make([][2]string, 0, len(node.Children)*2) for i, cmd := range node.Children { if cmd.Hidden { @@ -200,27 +218,93 @@ func writeCommandTree(w *helpWriter, node *Node) { rows = append(rows, [2]string{"", ""}) } } - writeTwoColumns(iw, rows) + writeTwoColumns(w, rows) +} + +type helpFlagGroup struct { + Metadata *Group + Flags [][]*Flag +} + +func collectFlagGroups(flags [][]*Flag) []helpFlagGroup { + // Group keys in order of appearance. + groups := []*Group{} + // Flags grouped by their group key. + flagsByGroup := map[string][][]*Flag{} + + for _, levelFlags := range flags { + levelFlagsByGroup := map[string][]*Flag{} + + for _, flag := range levelFlags { + key := "" + if flag.Group != nil { + key = flag.Group.Key + groupAlreadySeen := false + for _, group := range groups { + if key == group.Key { + groupAlreadySeen = true + break + } + } + if !groupAlreadySeen { + groups = append(groups, flag.Group) + } + } + + levelFlagsByGroup[key] = append(levelFlagsByGroup[key], flag) + } + + for key, flags := range levelFlagsByGroup { + flagsByGroup[key] = append(flagsByGroup[key], flags) + } + } + + out := []helpFlagGroup{} + // Ungrouped flags are always displayed first. + if ungroupedFlags, ok := flagsByGroup[""]; ok { + out = append(out, helpFlagGroup{ + Metadata: &Group{Title: "Flags:"}, + Flags: ungroupedFlags, + }) + } + for _, group := range groups { + out = append(out, helpFlagGroup{Metadata: group, Flags: flagsByGroup[group.Key]}) + } + return out } -// nolint: unused type helpCommandGroup struct { - Name string + Metadata *Group Commands []*Node } -// nolint: unused, deadcode func collectCommandGroups(nodes []*Node) []helpCommandGroup { - groups := map[string][]*Node{} + // Groups in order of appearance. + groups := []*Group{} + // Nodes grouped by their group key. + nodesByGroup := map[string][]*Node{} + for _, node := range nodes { - groups[node.Group] = append(groups[node.Group], node) - } - out := []helpCommandGroup{} - for name, nodes := range groups { - if name == "" { - name = "Commands" + key := "" + if group := node.ClosestGroup(); group != nil { + key = group.Key + if _, ok := nodesByGroup[key]; !ok { + groups = append(groups, group) + } } - out = append(out, helpCommandGroup{Name: name, Commands: nodes}) + nodesByGroup[key] = append(nodesByGroup[key], node) + } + + out := []helpCommandGroup{} + // Ungrouped nodes are always displayed first. + if ungroupedNodes, ok := nodesByGroup[""]; ok { + out = append(out, helpCommandGroup{ + Metadata: &Group{Title: "Commands:"}, + Commands: ungroupedNodes, + }) + } + for _, group := range groups { + out = append(out, helpCommandGroup{Metadata: group, Commands: nodesByGroup[group.Key]}) } return out } diff --git a/help_test.go b/help_test.go index 7a61fa8..51b5132 100644 --- a/help_test.go +++ b/help_test.go @@ -136,7 +136,7 @@ func TestHelpTree(t *testing.T) { Other struct { Other string `arg help:"other arg"` } `arg help:"subcommand other"` - } `cmd help:"subcommand one"` + } `cmd help:"subcommand one" group:"Group A"` // Groups are ignored in trees Two struct { Three threeArg `arg help:"Sub-sub-arg."` @@ -248,3 +248,164 @@ func TestCustomHelpFormatter(t *testing.T) { require.NoError(t, err) require.Contains(t, w.String(), "A flag.") } + +func TestHelpGrouping(t *testing.T) { + // nolint: govet + var cli struct { + GroupedAString string `help:"A string flag grouped in A." group:"Group A"` + FreeString string `help:"A non grouped string flag."` + GroupedBString string `help:"A string flag grouped in B." group:"Group B"` + FreeBool bool `help:"A non grouped bool flag."` + GroupedABool bool `help:"A bool flag grouped in A." group:"Group A"` + + One struct { + Flag string `help:"Nested flag."` + // Group is inherited from the parent command + Thing struct { + Arg string `arg help:"argument"` + } `cmd help:"subcommand thing"` + Other struct { + Other string `arg help:"other arg"` + } `arg help:"subcommand other"` + // ... but a subcommand can override it + Stuff struct { + Stuff string `arg help:"argument"` + } `arg help:"subcommand stuff" group:"Group B"` + } `cmd help:"A subcommand grouped in A." group:"Group A"` + + Two struct { + Grouped1String string `help:"A string flag grouped in 1." group:"Group 1"` + AFreeString string `help:"A non grouped string flag."` + Grouped2String string `help:"A string flag grouped in 2." group:"Group 2"` + AGroupedAString bool `help:"A string flag grouped in A." group:"Group A"` + Grouped1Bool bool `help:"A bool flag grouped in 1." group:"Group 1"` + } `cmd help:"A non grouped subcommand."` + + Four struct { + Flag string `help:"Nested flag."` + } `cmd help:"Another subcommand grouped in B." group:"Group B"` + + Three struct { + Flag string `help:"Nested flag."` + } `cmd help:"Another subcommand grouped in A." group:"Group A"` + } + + w := bytes.NewBuffer(nil) + exited := false + app := mustNew(t, &cli, + kong.Name("test-app"), + kong.Description("A test app."), + kong.Groups([]kong.Group{ + { + Key: "Group A", + Title: "Group title taken from the kong.Groups option", + Header: "A group header", + }, + { + Key: "Group 1", + Title: "Another group title, this time without header", + }, + { + Key: "Unknown key", + }, + }), + kong.Writers(w, w), + kong.Exit(func(int) { + exited = true + panic(true) // Panic to fake "exit". + }), + ) + + t.Run("Full", func(t *testing.T) { + require.PanicsWithValue(t, true, func() { + _, err := app.Parse([]string{"--help"}) + require.True(t, exited) + require.NoError(t, err) + }) + expected := `Usage: test-app + +A test app. + +Flags: + -h, --help Show context-sensitive help. + --free-string=STRING A non grouped string flag. + --free-bool A non grouped bool flag. + +Group title taken from the kong.Groups option +A group header + --grouped-a-string=STRING A string flag grouped in A. + --grouped-a-bool A bool flag grouped in A. + +Group B + --grouped-b-string=STRING A string flag grouped in B. + +Commands: + two + A non grouped subcommand. + +Group title taken from the kong.Groups option +A group header + one thing + subcommand thing + + one + subcommand other + + three + Another subcommand grouped in A. + +Group B + one + subcommand stuff + + four + Another subcommand grouped in B. + +Run "test-app --help" for more information on a command. +` + t.Log(w.String()) + t.Log(expected) + require.Equal(t, expected, w.String()) + }) + + t.Run("Selected", func(t *testing.T) { + exited = false + w.Truncate(0) + require.PanicsWithValue(t, true, func() { + _, err := app.Parse([]string{"two", "--help"}) + require.NoError(t, err) + require.True(t, exited) + }) + expected := `Usage: test-app two + +A non grouped subcommand. + +Flags: + -h, --help Show context-sensitive help. + --free-string=STRING A non grouped string flag. + --free-bool A non grouped bool flag. + + --a-free-string=STRING A non grouped string flag. + +Group title taken from the kong.Groups option +A group header + --grouped-a-string=STRING A string flag grouped in A. + --grouped-a-bool A bool flag grouped in A. + + --a-grouped-a-string A string flag grouped in A. + +Group B + --grouped-b-string=STRING A string flag grouped in B. + +Another group title, this time without header + --grouped-1-string=STRING A string flag grouped in 1. + --grouped-1-bool A bool flag grouped in 1. + +Group 2 + --grouped-2-string=STRING A string flag grouped in 2. +` + t.Log(expected) + t.Log(w.String()) + require.Equal(t, expected, w.String()) + }) +} diff --git a/kong.go b/kong.go index b94620a..9b67db9 100644 --- a/kong.go +++ b/kong.go @@ -53,6 +53,7 @@ type Kong struct { helpFormatter HelpValueFormatter helpOptions HelpOptions helpFlag *Flag + groups []Group vars Vars // Set temporarily by Options. These are applied after build(). diff --git a/model.go b/model.go index 72dba3e..c13c693 100644 --- a/model.go +++ b/model.go @@ -46,7 +46,7 @@ type Node struct { Name string Help string // Short help displayed in summaries. Detail string // Detailed help displayed when describing command/arg alone. - Group string + Group *Group Hidden bool Flags []*Flag Positional []*Positional @@ -203,6 +203,18 @@ func (n *Node) Path() (out string) { return strings.TrimSpace(out) } +// ClosestGroup finds the first non-nil group in this node and its ancestors. +func (n *Node) ClosestGroup() *Group { + switch { + case n.Group != nil: + return n.Group + case n.Parent != nil: + return n.Parent.ClosestGroup() + default: + return nil + } +} + // A Value is either a flag or a variable positional argument. type Value struct { Flag *Flag // Nil if positional argument. @@ -351,7 +363,7 @@ type Positional = Value // A Flag represents a command-line flag. type Flag struct { *Value - Group string // Logical grouping when displaying. May also be used by configuration loaders to group options logically. + Group *Group // Logical grouping when displaying. May also be used by configuration loaders to group options logically. Xor string PlaceHolder string Env string @@ -394,6 +406,17 @@ func (f *Flag) FormatPlaceHolder() string { return strings.ToUpper(f.Name) + tail } +// Group holds metadata about a command or flag group used when printing help. +type Group struct { + // Key is the `group` field tag value used to identify this group. + Key string + // Title is displayed above the grouped items. + Title string + // Header is optional and displayed under the Title when non empty. + // It can be used to introduce the group's purpose to the user. + Header string +} + // This is directly from the Go 1.13 source code. func reflectValueIsZero(v reflect.Value) bool { switch v.Kind() { diff --git a/options.go b/options.go index 5bd36c3..fc218d1 100644 --- a/options.go +++ b/options.go @@ -208,6 +208,16 @@ func ConfigureHelp(options HelpOptions) Option { }) } +// Groups associates `group` field tags with their metadata. +// +// It can be used to provide a title or header to a command or flag group. +func Groups(groups []Group) Option { + return OptionFunc(func(k *Kong) error { + k.groups = groups + return nil + }) +} + // UsageOnError configures Kong to display context-sensitive usage if FatalIfErrorf is called with an error. func UsageOnError() Option { return OptionFunc(func(k *Kong) error {