Repository: alecthomas/kong (go)

Issue:
Are logical groups still supported?
Hi,

I've been trying to use the `group:"X"` fields tag, expecting to see the flags/commands grouped in the help output. Unfortunately it doesn't seem to be working. 

After a quick look at the code, it seems that the groups are not used anymore: https://github.com/alecthomas/kong/blob/2479d83cc0307560579a16089433cbed7a6e5c77/help.go#L206-L226

Is this feature deprecated?

Thanks, I appreciate the effort you put in crafting this beautiful library!

Pull request:
Add support for groups in the default HelpPrinter
Fix #134

@alecthomas It is still lacking support for grouped flags, I wanted to check with you that this PR is welcome and on the right path first.

Here's what's implemented so far:

* Ungrouped commands are displayed first, under a "Command:" group.
* Each group is printed in order of appearance.
* Tree mode ignores group for now.
   * This part is not so easy to implement, maybe that's a reasonable limitation.

I think it could be useful to add some group metadata as well. In this case, the `group` tag would be a key referencing a declared Group metadata. We could store:

* The group title, in case it's long and to be able to modify it in only one spot.
  * Git headings are quite long, e.g. `start a working area (see also: git help tutorial)`
* A short help message displayed under the group title.
    ```
    Filtering:
    Options used to filter the items in the list.

        --name   Filter by name
        --color   Filter by color
    ```

I'm not sure what would be the best place for such metadata. Maybe an `Option`?

Patch:
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)
+		key := ""
+		if group := node.ClosestGroup(); group != nil {
+			key = group.Key
+			if _, ok := nodesByGroup[key]; !ok {
+				groups = append(groups, group)
+			}
+		}
+		nodesByGroup[key] = append(nodesByGroup[key], node)
 	}
+
 	out := []helpCommandGroup{}
-	for name, nodes := range groups {
-		if name == "" {
-			name = "Commands"
-		}
-		out = append(out, helpCommandGroup{Name: name, Commands: nodes})
+	// 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/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 {


Test patch:
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 <command>
+
+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 <arg>
+    subcommand thing
+
+  one <other>
+    subcommand other
+
+  three
+    Another subcommand grouped in A.
+
+Group B
+  one <stuff>
+    subcommand stuff
+
+  four
+    Another subcommand grouped in B.
+
+Run "test-app <command> --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())
+	})
+}