Repository: alecthomas/kong
Pull request #32 (2019-01-19 18:13:06)

feat: add a command tree help view
Following up #29 and #30, I noticed that the default help looks quite duplicative since only the help text of the last command in chain is viewed. I needed an option to also show the help text of the command nodes in an appropriate way.  This is what I came up with.

It introduces an additional help option that overrides the others. Then it prints a tree view of the nodes, their sub commands, and arguments. It looks like that in the output:

```bash
Command Tree:
  version                     display the app version

  help                        show help for a command
  |- [<command> ...]          the commands for which to show help

  build                       build Docker tags from the given tag vectors
  |- from                     determine the source of the tag vectors
  |  |- stdin                 read the tag vectors from the standard input
  |  |- file                  read the tag vectors from a Dockerfile
  |  |  |- <file>             the Dockerfile containing the vectors as FROM instructions
  |  |- <param>               pass the tag vectors as arguments

  tag                         tag the given source image with the Docker tags from the given tag vectors
  |- <source-tag>             the source tag of the image that should receive the generated tags
  |  |- from                  determine the source of the tag vectors
  |  |  |- stdin              read the tag vectors from the standard input
  |  |  |- file               read the tag vectors from a Dockerfile
  |  |  |  |- <file>          the Dockerfile containing the vectors as FROM instructions
  |  |  |- <param>            pass the tag vectors as arguments
  |  |- to                    set a target repository
  |  |  |- <repository>       the Docker Hub repository of the root tag vector in the format 'organization/repository'
  |  |  |  |- from            determine the source of the tag vectors
  |  |  |  |  |- stdin        read the tag vectors from the standard input
  |  |  |  |  |- file         read the tag vectors from a Dockerfile
  |  |  |  |  |  |- <file>    the Dockerfile containing the vectors as FROM instructions
  |  |  |  |  |- <param>      pass the tag vectors as arguments
```


Patch:
diff --git a/build.go b/build.go
index 49cc767..829e16e 100644
--- a/build.go
+++ b/build.go
@@ -204,6 +204,7 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv
 			PlaceHolder: tag.PlaceHolder,
 			Env:         tag.Env,
 			Group:       tag.Group,
+			Hidden:      tag.Hidden,
 		}
 		value.Flag = flag
 		node.Flags = append(node.Flags, flag)
diff --git a/help.go b/help.go
index d640efb..bc4aeba 100644
--- a/help.go
+++ b/help.go
@@ -37,6 +37,14 @@ type HelpOptions struct {
 
 	// Write help in a more compact, but still fully-specified, form.
 	Compact bool
+
+	// Tree writes command chains in a tree structure instead of listing them separately.
+	Tree bool
+
+	// Indenter modulates the given prefix for the next layer in the tree view.
+	// The following exported templates can be used: kong.SpaceIndenter, kong.LineIndenter, kong.TreeIndenter
+	// The kong.SpaceIndenter will be used by default.
+	Indenter HelpIndenter
 }
 
 // HelpProvider can be implemented by commands/args to provide detailed help.
@@ -45,6 +53,9 @@ type HelpProvider interface {
 	Help() string
 }
 
+// HelpIndenter is used to indent new layers in the help tree.
+type HelpIndenter func(prefix string) string
+
 // HelpPrinter is used to print context-sensitive help.
 type HelpPrinter func(options HelpOptions, ctx *Context) error
 
@@ -115,19 +126,32 @@ func printNodeDetail(w *helpWriter, node *Node, hide bool) {
 	cmds := node.Leaves(hide)
 	if len(cmds) > 0 {
 		w.Print("")
-		w.Print("Commands:")
-		iw := w.Indent()
-		if w.Compact {
-			rows := [][2]string{}
-			for _, cmd := range cmds {
-				rows = append(rows, [2]string{cmd.Path(), cmd.Help})
+		if w.Tree {
+			w.Print("Command Tree:")
+			iw := w.Indent()
+			var rows [][2]string
+			for i, cmd := range node.Children {
+				rows = append(rows, w.commandTree(cmd, "")...)
+				if i != len(node.Children)-1 {
+					rows = append(rows, [2]string{"", ""})
+				}
 			}
 			writeTwoColumns(iw, defaultColumnPadding, rows)
 		} else {
-			for i, cmd := range cmds {
-				printCommandSummary(iw, cmd)
-				if i != len(cmds)-1 {
-					iw.Print("")
+			w.Print("Commands:")
+			iw := w.Indent()
+			if w.Compact {
+				rows := [][2]string{}
+				for _, cmd := range cmds {
+					rows = append(rows, [2]string{cmd.Path(), cmd.Help})
+				}
+				writeTwoColumns(iw, defaultColumnPadding, rows)
+			} else {
+				for i, cmd := range cmds {
+					printCommandSummary(iw, cmd)
+					if i != len(cmds)-1 {
+						iw.Print("")
+					}
 				}
 			}
 		}
@@ -276,3 +300,50 @@ func formatFlag(haveShort bool, flag *Flag) string {
 	}
 	return flagString
 }
+
+// commandTree creates a tree with the given node name as root and its children's arguments and sub commands as leaves.
+func (w *HelpOptions) commandTree(node *Node, prefix string) (rows [][2]string) {
+	var nodeName string
+	switch node.Type {
+	default:
+		nodeName += prefix + node.Name
+	case ArgumentNode:
+		nodeName += prefix + "<" + node.Name + ">"
+	}
+	rows = append(rows, [2]string{nodeName, node.Help})
+	if w.Indenter == nil {
+		prefix = SpaceIndenter(prefix)
+	} else {
+		prefix = w.Indenter(prefix)
+	}
+	for _, arg := range node.Positional {
+		rows = append(rows, [2]string{prefix + arg.Summary(), arg.Help})
+	}
+	for _, subCmd := range node.Children {
+		rows = append(rows, w.commandTree(subCmd, prefix)...)
+	}
+	return
+}
+
+// SpaceIndenter adds a space indent to the given prefix.
+func SpaceIndenter(prefix string) string {
+	return prefix + strings.Repeat(" ", defaultIndent)
+}
+
+// SpaceIndenter adds line points to every new indent.
+func LineIndenter(prefix string) string {
+	if prefix == "" {
+		return "- "
+	} else {
+		return strings.Repeat(" ", defaultIndent) + prefix
+	}
+}
+
+// TreeIndenter adds line points to every new indent and vertical lines to every layer.
+func TreeIndenter(prefix string) string {
+	if prefix == "" {
+		return "|- "
+	} else {
+		return "|" + strings.Repeat(" ", defaultIndent) + prefix
+	}
+}


Test patch:
diff --git a/help_test.go b/help_test.go
index 91068c5..a985bba 100644
--- a/help_test.go
+++ b/help_test.go
@@ -124,3 +124,98 @@ Flags:
 		require.Equal(t, expected, w.String())
 	})
 }
+
+func TestHelpTree(t *testing.T) {
+	// nolint: govet
+	var cli struct {
+		One struct {
+			Thing struct {
+				Arg string `arg help:"argument"`
+			} `cmd help:"subcommand thing"`
+			Other struct {
+				Other string `arg help:"other arg"`
+			} `arg help:"subcommand other"`
+		} `cmd help:"subcommand one"`
+
+		Two struct {
+			Three threeArg `arg help:"Sub-sub-arg."`
+
+			Four struct {
+			} `cmd help:"Sub-sub-command."`
+		} `cmd help:"Another subcommand."`
+	}
+
+	w := bytes.NewBuffer(nil)
+	exited := false
+	app := mustNew(t, &cli,
+		kong.Name("test-app"),
+		kong.Description("A test app."),
+		kong.Writers(w, w),
+		kong.ConfigureHelp(kong.HelpOptions{
+			Tree:     true,
+			Indenter: kong.LineIndenter,
+		}),
+		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.NoError(t, err)
+		})
+		require.True(t, exited)
+		expected := `Usage: test-app <command>
+
+A test app.
+
+Flags:
+  --help    Show context-sensitive help.
+
+Command Tree:
+  one          subcommand one
+  - thing      subcommand thing
+    - <arg>    argument
+  - <other>    subcommand other
+
+  two          Another subcommand.
+  - <three>    Sub-sub-arg.
+  - four       Sub-sub-command.
+
+Run "test-app <command> --help" for more information on a command.
+`
+		if expected != w.String() {
+			t.Errorf("help command returned:\n%v\n\nwant:\n%v", w.String(), 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{"one", "--help"})
+			require.NoError(t, err)
+		})
+		require.True(t, exited)
+		expected := `Usage: test-app one <command>
+
+subcommand one
+
+Flags:
+  --help    Show context-sensitive help.
+
+Command Tree:
+  thing      subcommand thing
+  - <arg>    argument
+
+  <other>    subcommand other
+`
+		if expected != w.String() {
+			t.Errorf("help command returned:\n%v\n\nwant:\n%v", w.String(), expected)
+		}
+		require.Equal(t, expected, w.String())
+	})
+}