Skip to content

Commit

Permalink
Rework help message formatting (#53)
Browse files Browse the repository at this point in the history
* Rework help message formatting

Notable changes:

* the default value of options and args are no longer displayed right
after the names with an equal sign, e.g. `-f, --force=false`. Instead they are displayed
after the description: `(default: 42)`
* only the first short name (if set) and the first long name (if set) of options are
displayed
* do not display the default value for 0-valued options and args, i.e.
 `0`, `false`, `""` and `[]`
* align option long names (by displaying white space if no short name is
specified)
* align the descriptions of options, arguments and commands
* add env word to env vars, e.g. `(env $SUPER_OPTION)`

Fixes #52.
  • Loading branch information
jawher authored Oct 29, 2017
1 parent 1c66e71 commit 0e80ee9
Show file tree
Hide file tree
Showing 9 changed files with 281 additions and 71 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
testdata/*.golden
coverage.out
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ To add a (global) option, call one of the (String[s]|Int[s]|Bool)Opt methods on
recursive := cp.BoolOpt("R recursive", false, "recursively copy the src to dst")
```

* The first argument is a space separated list of names for the option without the dashes
* The first argument is a space separated list of names (short and long) for the option without the dashes, e.g `"f force"`. While you can specify multiple short or long names, e.g. `"f x force force-push"`, only the first short name and the first long name will be displayed in the help messages
* The second parameter is the default value for the option
* The third and last parameter is the option description, as will be shown in the help messages

Expand Down Expand Up @@ -468,6 +468,23 @@ func (d *Durations) Clear() {
}
```

### Hide default value of custom type

If your custom type implements a `IsDefault` method (returning a boolean), the help message generation will make use of it to decide whether or not to display the default value.

```go
type Action string

// Make it implement flag.Value
:
:

// Make it multi-valued
func (a *Action) IsDefault() bool {
return (*a) == "nop"
}
```

## Interceptors

It is possible to define snippets of code to be executed before and after a command or any of its sub commands is executed.
Expand Down Expand Up @@ -610,7 +627,7 @@ x.Spec = "-t | DST"

You can use the `...` postfix operator to mark an element as repeatable:

```go
```go
x.Spec="SRC..."
x.Spec="-e..."
```
Expand All @@ -629,7 +646,7 @@ all that is mutually exclusive to a choice between -x and -y options.
### Option group

This is a shortcut to declare a choice between multiple options:
```go
```go
x.Spec = "-abcd"
```

Expand All @@ -645,13 +662,13 @@ I.e. any combination of the listed options in any order, with at least one optio

Another shortcut:

```go
```go
x.Spec = "[OPTIONS]"
```

This is a special syntax (the square brackets are not for marking an optional item, and the uppercased word is not for an argument).
This is a special syntax (the square brackets are not for marking an optional item, and the uppercased word is not for an argument).
This is equivalent to a repeatable choice between all the available options.
For example, if an app or a command declares 4 options a, b, c and d, `[OPTIONS]` is equivalent to
For example, if an app or a command declares 4 options a, b, c and d, `[OPTIONS]` is equivalent to:

```go
x.Spec = "[-a | -b | -c | -d]..."
Expand Down
85 changes: 61 additions & 24 deletions cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/stretchr/testify/require"

"io/ioutil"
"os"
"testing"
)
Expand Down Expand Up @@ -399,6 +400,8 @@ func TestHelpAndVersionWithOptionsEnd(t *testing.T) {
}
}

var genGolden = flag.Bool("g", false, "Generate golden file(s)")

func TestHelpMessage(t *testing.T) {
var out, err string
defer captureAndRestoreOutput(&out, &err)()
Expand All @@ -407,27 +410,66 @@ func TestHelpMessage(t *testing.T) {
defer exitShouldBeCalledWith(t, 0, &exitCalled)()

app := App("app", "App Desc")
app.Spec = "[-o] ARG"
app.Spec = "[-bdsuikqs] BOOL1 [STR1] INT3..."

app.String(StringOpt{Name: "o opt", Value: "", Desc: "Option"})
app.String(StringArg{Name: "ARG", Value: "", Desc: "Argument"})
// Options
app.Bool(BoolOpt{Name: "b bool1 u uuu", Value: false, EnvVar: "BOOL1", Desc: "Bool Option 1"})
app.Bool(BoolOpt{Name: "bool2", Value: true, EnvVar: " ", Desc: "Bool Option 2"})
app.Bool(BoolOpt{Name: "d", Value: true, EnvVar: "BOOL3", Desc: "Bool Option 3", HideValue: true})

app.String(StringOpt{Name: "s str1", Value: "", EnvVar: "STR1", Desc: "String Option 1"})
app.String(StringOpt{Name: "str2", Value: "a value", Desc: "String Option 2"})
app.String(StringOpt{Name: "u", Value: "another value", EnvVar: "STR3", Desc: "String Option 3", HideValue: true})

app.Int(IntOpt{Name: "i int1", Value: 0, EnvVar: "INT1 ALIAS_INT1"})
app.Int(IntOpt{Name: "int2", Value: 1, EnvVar: "INT2", Desc: "Int Option 2"})
app.Int(IntOpt{Name: "k", Value: 1, EnvVar: "INT3", Desc: "Int Option 3", HideValue: true})

app.Strings(StringsOpt{Name: "x strs1", Value: nil, EnvVar: "STRS1", Desc: "Strings Option 1"})
app.Strings(StringsOpt{Name: "strs2", Value: []string{"value1", "value2"}, EnvVar: "STRS2", Desc: "Strings Option 2"})
app.Strings(StringsOpt{Name: "z", Value: []string{"another value"}, EnvVar: "STRS3", Desc: "Strings Option 3", HideValue: true})

app.Ints(IntsOpt{Name: "q ints1", Value: nil, EnvVar: "INTS1", Desc: "Ints Option 1"})
app.Ints(IntsOpt{Name: "ints2", Value: []int{1, 2, 3}, EnvVar: "INTS2", Desc: "Ints Option 2"})
app.Ints(IntsOpt{Name: "s", Value: []int{1}, EnvVar: "INTS3", Desc: "Ints Option 3", HideValue: true})

// Args
app.Bool(BoolArg{Name: "BOOL1", Value: false, EnvVar: "BOOL1", Desc: "Bool Argument 1"})
app.Bool(BoolArg{Name: "BOOL2", Value: true, Desc: "Bool Argument 2"})
app.Bool(BoolArg{Name: "BOOL3", Value: true, EnvVar: "BOOL3", Desc: "Bool Argument 3", HideValue: true})

app.String(StringArg{Name: "STR1", Value: "", EnvVar: "STR1", Desc: "String Argument 1"})
app.String(StringArg{Name: "STR2", Value: "a value", EnvVar: "STR2", Desc: "String Argument 2"})
app.String(StringArg{Name: "STR3", Value: "another value", EnvVar: "STR3", Desc: "String Argument 3", HideValue: true})

app.Int(IntArg{Name: "INT1", Value: 0, EnvVar: "INT1", Desc: "Int Argument 1"})
app.Int(IntArg{Name: "INT2", Value: 1, EnvVar: "INT2", Desc: "Int Argument 2"})
app.Int(IntArg{Name: "INT3", Value: 1, EnvVar: "INT3", Desc: "Int Argument 3", HideValue: true})

app.Strings(StringsArg{Name: "STRS1", Value: nil, EnvVar: "STRS1", Desc: "Strings Argument 1"})
app.Strings(StringsArg{Name: "STRS2", Value: []string{"value1", "value2"}, EnvVar: "STRS2"})
app.Strings(StringsArg{Name: "STRS3", Value: []string{"another value"}, EnvVar: "STRS3", Desc: "Strings Argument 3", HideValue: true})

app.Ints(IntsArg{Name: "INTS1", Value: nil, EnvVar: "INTS1", Desc: "Ints Argument 1"})
app.Ints(IntsArg{Name: "INTS2", Value: []int{1, 2, 3}, EnvVar: "INTS2", Desc: "Ints Argument 2"})
app.Ints(IntsArg{Name: "INTS3", Value: []int{1}, EnvVar: "INTS3", Desc: "Ints Argument 3", HideValue: true})

app.Action = func() {}
app.Run([]string{"app", "-h"})

help := `
Usage: app [-o] ARG
app.Command("command1", "command1 description", func(cmd *Cmd) {})
app.Command("command2", "command2 description", func(cmd *Cmd) {})
app.Command("command3", "command3 description", func(cmd *Cmd) {})

App Desc
app.Run([]string{"app", "-h"})

Arguments:
ARG="" Argument
if *genGolden {
ioutil.WriteFile("testdata/help-output.txt.golden", []byte(err), 0644)
}

Options:
-o, --opt="" Option
`
expected, e := ioutil.ReadFile("testdata/help-output.txt")
require.NoError(t, e, "Failed to read the expected help output from testdata/help-output.txt")

require.Equal(t, help, err)
require.Equal(t, expected, []byte(err))
}

func TestLongHelpMessage(t *testing.T) {
Expand All @@ -447,19 +489,14 @@ func TestLongHelpMessage(t *testing.T) {
app.Action = func() {}
app.Run([]string{"app", "-h"})

help := `
Usage: app [-o] ARG
Longer App Desc
Arguments:
ARG="" Argument
if *genGolden {
ioutil.WriteFile("testdata/long-help-output.txt.golden", []byte(err), 0644)
}

Options:
-o, --opt="" Option
`
expected, e := ioutil.ReadFile("testdata/long-help-output.txt")
require.NoError(t, e, "Failed to read the expected help output from testdata/long-help-output.txt")

require.Equal(t, help, err)
require.Equal(t, expected, []byte(err))
}

func TestVersionShortcut(t *testing.T) {
Expand Down
98 changes: 64 additions & 34 deletions commands.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cli

import (
"bytes"
"flag"
"fmt"
"strings"
Expand Down Expand Up @@ -335,69 +334,100 @@ func (c *Cmd) printHelp(longDesc bool) {
w := tabwriter.NewWriter(stdErr, 15, 1, 3, ' ', 0)

if len(c.args) > 0 {
fmt.Fprintf(stdErr, "\nArguments:\n")
fmt.Fprint(w, "\t\nArguments:\t\n")

for _, arg := range c.args {
desc := c.formatDescription(arg.desc, arg.envVar)
value := c.formatArgValue(arg)

fmt.Fprintf(w, " %s%s\t%s\n", arg.name, value, desc)
var (
env = formatEnvVarsForHelp(arg.envVar)
value = formatValueForHelp(arg.hideValue, arg.value)
)
fmt.Fprintf(w, " %s\t%s\n", arg.name, joinStrings(arg.desc, env, value))
}
w.Flush()
}

if len(c.options) > 0 {
fmt.Fprintf(stdErr, "\nOptions:\n")
fmt.Fprint(w, "\t\nOptions:\t\n")

for _, opt := range c.options {
desc := c.formatDescription(opt.desc, opt.envVar)
value := c.formatOptValue(opt)
fmt.Fprintf(w, " %s%s\t%s\n", strings.Join(opt.names, ", "), value, desc)
var (
optNames = formatOptNamesForHelp(opt)
env = formatEnvVarsForHelp(opt.envVar)
value = formatValueForHelp(opt.hideValue, opt.value)
)
fmt.Fprintf(w, " %s\t%s\n", optNames, joinStrings(opt.desc, env, value))
}
w.Flush()
}

if len(c.commands) > 0 {
fmt.Fprintf(stdErr, "\nCommands:\n")
fmt.Fprint(w, "\t\nCommands:\t\n")

for _, c := range c.commands {
fmt.Fprintf(w, " %s\t%s\n", strings.Join(c.aliases, ", "), c.desc)
}
w.Flush()
}

if len(c.commands) > 0 {
fmt.Fprintf(stdErr, "\nRun '%s COMMAND --help' for more information on a command.\n", path)
fmt.Fprintf(w, "\t\nRun '%s COMMAND --help' for more information on a command.\n", path)
}

w.Flush()
}

func (c *Cmd) formatArgValue(arg *arg) string {
if arg.hideValue {
return " "
func formatOptNamesForHelp(o *opt) string {
short, long := "", ""

for _, n := range o.names {
if len(n) == 2 && short == "" {
short = n
}

if len(n) > 2 && long == "" {
long = n
}
}

switch {
case short != "" && long != "":
return fmt.Sprintf("%s, %s", short, long)
case short != "":
return fmt.Sprintf("%s", short)
case long != "":
// 2 spaces instead of the short option (-x), one space for the comma (,) and one space for the after comma blank
return fmt.Sprintf(" %s", long)
default:
return ""
}
return "=" + arg.value.String()
}

func (c *Cmd) formatOptValue(opt *opt) string {
if opt.hideValue {
return " "
func formatValueForHelp(hide bool, v flag.Value) string {
if hide {
return ""
}
return "=" + opt.value.String()

if dv, ok := v.(defaultValued); ok {
if dv.IsDefault() {
return ""
}
}

return fmt.Sprintf("(default %s)", v.String())
}

func (c *Cmd) formatDescription(desc, envVar string) string {
var b bytes.Buffer
b.WriteString(desc)
if len(envVar) > 0 {
b.WriteString(" (")
sep := ""
for _, envVal := range strings.Fields(envVar) {
b.WriteString(fmt.Sprintf("%s$%s", sep, envVal))
sep = " "
func formatEnvVarsForHelp(envVars string) string {
if strings.TrimSpace(envVars) == "" {
return ""
}
vars := strings.Fields(envVars)
res := "(env"
sep := " "
for i, v := range vars {
if i > 0 {
sep = ", "
}
b.WriteString(")")
res += fmt.Sprintf("%s$%s", sep, v)
}
return strings.TrimSpace(b.String())
res += ")"
return res
}

func (c *Cmd) parse(args []string, entry, inFlow, outFlow *step) error {
Expand Down
45 changes: 45 additions & 0 deletions testdata/help-output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@

Usage: app [-bdsuikqs] BOOL1 [STR1] INT3... COMMAND [arg...]

App Desc

Arguments:
BOOL1 Bool Argument 1 (env $BOOL1)
BOOL2 Bool Argument 2 (default true)
BOOL3 Bool Argument 3 (env $BOOL3)
STR1 String Argument 1 (env $STR1)
STR2 String Argument 2 (env $STR2) (default "a value")
STR3 String Argument 3 (env $STR3)
INT1 Int Argument 1 (env $INT1) (default 0)
INT2 Int Argument 2 (env $INT2) (default 1)
INT3 Int Argument 3 (env $INT3)
STRS1 Strings Argument 1 (env $STRS1)
STRS2 (env $STRS2) (default ["value1", "value2"])
STRS3 Strings Argument 3 (env $STRS3)
INTS1 Ints Argument 1 (env $INTS1)
INTS2 Ints Argument 2 (env $INTS2) (default [1, 2, 3])
INTS3 Ints Argument 3 (env $INTS3)

Options:
-b, --bool1 Bool Option 1 (env $BOOL1)
--bool2 Bool Option 2 (default true)
-d Bool Option 3 (env $BOOL3)
-s, --str1 String Option 1 (env $STR1)
--str2 String Option 2 (default "a value")
-u String Option 3 (env $STR3)
-i, --int1 (env $INT1, $ALIAS_INT1) (default 0)
--int2 Int Option 2 (env $INT2) (default 1)
-k Int Option 3 (env $INT3)
-x, --strs1 Strings Option 1 (env $STRS1)
--strs2 Strings Option 2 (env $STRS2) (default ["value1", "value2"])
-z Strings Option 3 (env $STRS3)
-q, --ints1 Ints Option 1 (env $INTS1)
--ints2 Ints Option 2 (env $INTS2) (default [1, 2, 3])
-s Ints Option 3 (env $INTS3)

Commands:
command1 command1 description
command2 command2 description
command3 command3 description

Run 'app COMMAND --help' for more information on a command.
10 changes: 10 additions & 0 deletions testdata/long-help-output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

Usage: app [-o] ARG

Longer App Desc

Arguments:
ARG Argument

Options:
-o, --opt Option
Loading

0 comments on commit 0e80ee9

Please sign in to comment.