Add support for groups in the default HelpPrinter (#135)

This commit is contained in:
Mickaël Menu
2021-02-08 20:58:43 +01:00
committed by GitHub
parent 2479d83cc0
commit b68e1aba63
7 changed files with 326 additions and 29 deletions
+1
View File
@@ -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
+19 -2
View File
@@ -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,
}
}
+108 -24
View File
@@ -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
}
+162 -1
View File
@@ -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())
})
}
+1
View File
@@ -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().
+25 -2
View File
@@ -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() {
+10
View File
@@ -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 {