diff --git a/README.md b/README.md index d12cb45..5e67f50 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,15 @@ func main() { ## Reference Documentation -## Sub-commands +### Help + +Second to parsing, providing the user with useful help is probably the most +important thing a command-line parser does. + +Since 1.3.x, Kingpin uses a bunch of heuristics to display help. For example, +`--help` should generally "just work" without much thought from users. + +### Sub-commands Kingpin supports nested sub-commands, with separate flag and positional arguments per sub-command. Note that positional arguments may only occur after diff --git a/app.go b/app.go index 8c9030f..47b298c 100644 --- a/app.go +++ b/app.go @@ -34,7 +34,7 @@ import ( "strings" ) -type Dispatch func() error +type Dispatch func(*ParseContext) error // An Application contains the definitions of flags, arguments and commands // for an application. @@ -43,7 +43,6 @@ type Application struct { *argGroup *cmdGroup initialized bool - commandHelp *string Name string Help string } @@ -53,20 +52,14 @@ func New(name, help string) *Application { a := &Application{ flagGroup: newFlagGroup(), argGroup: newArgGroup(), - cmdGroup: newCmdGroup(), Name: name, Help: help, } - a.Flag("help", "Show help.").Dispatch(a.onFlagHelp).Bool() + a.cmdGroup = newCmdGroup(a) + a.Flag("help", "Show help.").Dispatch(a.onHelp).Bool() return a } -func (a *Application) onFlagHelp() error { - a.Usage(os.Stderr) - os.Exit(0) - return nil -} - // Parse parses command-line arguments. It returns the selected command and an // error. The selected command will be a space separated subcommand, if // subcommands have been configured. @@ -91,7 +84,7 @@ func (a *Application) Parse(args []string) (command string, err error) { // Version adds a --version flag for displaying the application version. func (a *Application) Version(version string) *Application { - a.Flag("version", "Show application version.").Dispatch(func() error { + a.Flag("version", "Show application version.").Dispatch(func(*ParseContext) error { fmt.Println(version) os.Exit(0) return nil @@ -99,6 +92,11 @@ func (a *Application) Version(version string) *Application { return a } +// Command adds a new top-level command. +func (a *Application) Command(name, help string) *CmdClause { + return a.addCommand(name, help) +} + func (a *Application) init() error { if a.initialized { return nil @@ -108,8 +106,8 @@ func (a *Application) init() error { } if len(a.commands) > 0 { - cmd := a.Command("help", "Show help for a command.") - a.commandHelp = cmd.Arg("command", "Command name.").Required().Dispatch(a.onCommandHelp).String() + cmd := a.Command("help", "Show help for a command.").Dispatch(a.onHelp) + cmd.Arg("command", "Command name.").String() // Make "help" command first in order. Also, Go's slice operations are woeful. l := len(a.commandOrder) - 1 a.commandOrder = append(a.commandOrder[l:], a.commandOrder[:l]...) @@ -133,8 +131,30 @@ func (a *Application) init() error { return nil } -func (a *Application) onCommandHelp() error { - a.CommandUsage(os.Stderr, *a.commandHelp) +func (a *Application) onHelp(context *ParseContext) error { + candidates := []string{} + for { + token := context.Peek() + if token.Type == TokenArg { + candidates = append(candidates, token.String()) + context.Next() + } else { + break + } + } + + var cmd *CmdClause + for i := len(candidates); i > 0; i-- { + command := strings.Join(candidates[:i], " ") + cmd = a.findCommand(command) + if cmd != nil { + a.CommandUsage(os.Stderr, command) + break + } + } + if cmd == nil { + a.Usage(os.Stderr) + } os.Exit(0) return nil } @@ -165,6 +185,11 @@ func (a *Application) Errorf(w io.Writer, format string, args ...interface{}) { fmt.Fprintf(w, a.Name+": error: "+format+"\n", args...) } +func (a *Application) Fatalf(w io.Writer, format string, args ...interface{}) { + a.Errorf(w, format, args...) + os.Exit(1) +} + // UsageErrorf prints an error message followed by usage information, then // exits with a non-zero status. func (a *Application) UsageErrorf(w io.Writer, format string, args ...interface{}) { diff --git a/app_test.go b/app_test.go index a487ca4..dc26d24 100644 --- a/app_test.go +++ b/app_test.go @@ -71,7 +71,7 @@ func TestArgsMultipleRequiredThenNonRequired(t *testing.T) { func TestDispatchCallbackIsCalled(t *testing.T) { dispatched := false c := New("test", "") - c.Command("cmd", "").Dispatch(func() error { + c.Command("cmd", "").Dispatch(func(*ParseContext) error { dispatched = true return nil }) diff --git a/args.go b/args.go index 10ab16d..d87c58d 100644 --- a/args.go +++ b/args.go @@ -144,18 +144,17 @@ func (a *ArgClause) init() error { } func (a *ArgClause) parse(context *ParseContext) error { - if token := context.Next(); token.Type == TokenArg { + token := context.Peek() + if token.Type == TokenArg { if err := a.value.Set(token.Value); err != nil { return err } if a.dispatch != nil { - if err := a.dispatch(); err != nil { + if err := a.dispatch(context); err != nil { return err } } - return nil - } else { - context.Return(token) - return nil + context.Next() } + return nil } diff --git a/cmd.go b/cmd.go index c8979d3..023809c 100644 --- a/cmd.go +++ b/cmd.go @@ -1,21 +1,37 @@ package kingpin -import "fmt" +import ( + "fmt" + "os" + "strings" +) type cmdGroup struct { + app *Application + parent *CmdClause commands map[string]*CmdClause commandOrder []*CmdClause } -func newCmdGroup() *cmdGroup { +func newCmdGroup(app *Application) *cmdGroup { return &cmdGroup{ + app: app, commands: make(map[string]*CmdClause), } } -// Command adds a new top-level command to the application. -func (c *cmdGroup) Command(name, help string) *CmdClause { - cmd := newCommand(name, help) +func (c *cmdGroup) flattenedCommands() (out []*CmdClause) { + for _, cmd := range c.commandOrder { + if len(cmd.commands) == 0 { + out = append(out, cmd) + } + out = append(out, cmd.flattenedCommands()...) + } + return +} + +func (c *cmdGroup) addCommand(name, help string) *CmdClause { + cmd := newCommand(c.app, name, help) c.commands[name] = cmd c.commandOrder = append(c.commandOrder, cmd) return cmd @@ -33,7 +49,7 @@ func (c *cmdGroup) init() error { } func (c *cmdGroup) parse(context *ParseContext) (selected []string, _ error) { - token := context.Next() + token := context.Peek() if token.Type != TokenArg { return nil, fmt.Errorf("expected command but got '%s'", token) } @@ -41,6 +57,7 @@ func (c *cmdGroup) parse(context *ParseContext) (selected []string, _ error) { if !ok { return nil, fmt.Errorf("no such command '%s'", token) } + context.Next() context.SelectedCommand = cmd.name selected, err := cmd.parse(context) if err == nil { @@ -59,22 +76,46 @@ type CmdClause struct { *flagGroup *argGroup *cmdGroup + app *Application name string help string dispatch Dispatch } -func newCommand(name, help string) *CmdClause { +func newCommand(app *Application, name, help string) *CmdClause { c := &CmdClause{ flagGroup: newFlagGroup(), argGroup: newArgGroup(), - cmdGroup: newCmdGroup(), + cmdGroup: newCmdGroup(app), + app: app, name: name, help: help, } + c.Flag("help", "Show help on this command.").Hidden().Dispatch(c.onHelp).Bool() return c } +func (c *CmdClause) fullCommand() string { + out := []string{c.name} + for p := c.parent; p != nil; p = p.parent { + out = append([]string{p.name}, out...) + } + return strings.Join(out, " ") +} + +func (c *CmdClause) onHelp(context *ParseContext) error { + c.app.CommandUsage(os.Stderr, c.fullCommand()) + os.Exit(0) + return nil +} + +// Command adds a new sub-command. +func (c *CmdClause) Command(name, help string) *CmdClause { + cmd := c.addCommand(name, help) + cmd.parent = c + return cmd +} + func (c *CmdClause) Dispatch(dispatch Dispatch) *CmdClause { c.dispatch = dispatch return c @@ -107,7 +148,7 @@ func (c *CmdClause) parse(context *ParseContext) (selected []string, _ error) { err = c.argGroup.parse(context) } if err == nil && c.dispatch != nil { - err = c.dispatch() + err = c.dispatch(context) } return selected, err } diff --git a/examples/curl/curl.go b/examples/curl/curl.go index 45361d7..bde6350 100644 --- a/examples/curl/curl.go +++ b/examples/curl/curl.go @@ -14,10 +14,14 @@ import ( var ( timeout = kingpin.Flag("timeout", "Set connection timeout.").Short('t').Default("5s").Duration() - headers = HTTPHeader(kingpin.Flag("headers", "Add HTTP headers to the request.").Short('H').PlaceHolder("HEADER:VALUE")) + headers = HTTPHeader(kingpin.Flag("headers", "Add HTTP headers to the request.").Short('H').PlaceHolder("HEADER=VALUE")) - get = kingpin.Command("get", "GET a resource.") - getURL = get.Arg("url", "URL to GET.").Required().URL() + get = kingpin.Command("get", "GET a resource.") + getFlag = get.Flag("test", "Test flag").Bool() + getURL = get.Command("url", "Retrieve a URL.") + getURLURL = getURL.Arg("url", "URL to GET.").Required().URL() + getFile = get.Command("file", "Retrieve a file.") + getFileFile = getFile.Arg("file", "File to retrieve.").Required().ExistingFile() post = kingpin.Command("post", "POST a resource.") postData = post.Flag("data", "Key-value data to POST").Short('d').PlaceHolder("KEY:VALUE").StringMap() @@ -27,21 +31,21 @@ var ( type HTTPHeaderValue http.Header -func (h *HTTPHeaderValue) Set(value string) error { - parts := strings.SplitN(value, ":", 2) +func (h HTTPHeaderValue) Set(value string) error { + parts := strings.SplitN(value, "=", 2) if len(parts) != 2 { - return fmt.Errorf("expected HEADER:VALUE got '%s'", value) + return fmt.Errorf("expected HEADER=VALUE got '%s'", value) } - (*http.Header)(h).Add(parts[0], parts[1]) + (http.Header)(h).Add(parts[0], parts[1]) return nil } -func (h *HTTPHeaderValue) String() string { +func (h HTTPHeaderValue) String() string { return "" } func HTTPHeader(s kingpin.Settings) (target *http.Header) { - target = new(http.Header) + target = &http.Header{} s.SetValue((*HTTPHeaderValue)(target)) return } @@ -91,8 +95,8 @@ func applyPOST() error { func main() { kingpin.CommandLine.Help = "An example implementation of curl." switch kingpin.Parse() { - case "get": - kingpin.FatalIfError(apply("GET", (*getURL).String()), "GET failed") + case "get url": + kingpin.FatalIfError(apply("GET", (*getURLURL).String()), "GET failed") case "post": kingpin.FatalIfError(applyPOST(), "POST failed") diff --git a/flags.go b/flags.go index 009ad09..746766c 100644 --- a/flags.go +++ b/flags.go @@ -55,7 +55,7 @@ func (f *flagGroup) parse(context *ParseContext, ignoreRequired bool) error { loop: for { - token = context.Next() + token = context.Peek() switch token.Type { case TokenEOL: break loop @@ -87,6 +87,8 @@ loop: delete(required, flag.name) delete(defaults, flag.name) + context.Next() + fb, ok := flag.value.(boolFlag) if ok && fb.IsBoolFlag() { if invert { @@ -98,10 +100,11 @@ loop: if invert { return fmt.Errorf("unknown long flag '%s'", flagToken) } - token = context.Next() + token = context.Peek() if token.Type != TokenArg { return fmt.Errorf("expected argument for flag '%s'", flagToken) } + context.Next() defaultValue = token.Value } @@ -110,13 +113,12 @@ loop: } if flag.dispatch != nil { - if err := flag.dispatch(); err != nil { + if err := flag.dispatch(context); err != nil { return err } } default: - context.Return(token) break loop } } @@ -199,7 +201,7 @@ func (f *FlagClause) init() error { return fmt.Errorf("required flag '--%s' with default value that will never be used", f.name) } if f.value == nil { - return fmt.Errorf("no value defined for --%s", f.name) + return fmt.Errorf("no type defined for --%s (eg. .String())", f.name) } if f.envar != "" { if v := os.Getenv(f.envar); v != "" { diff --git a/global.go b/global.go index a02185d..494c7ee 100644 --- a/global.go +++ b/global.go @@ -38,8 +38,7 @@ func Parse() string { // Fatalf prints an error message to stderr and exits. func Fatalf(format string, args ...interface{}) { - CommandLine.Errorf(os.Stderr, format, args...) - os.Exit(1) + CommandLine.Fatalf(os.Stderr, format, args...) } // FatalIfError prints an error and exits if err is not nil. The error is printed diff --git a/lexer.go b/lexer.go index 49c9850..72608bf 100644 --- a/lexer.go +++ b/lexer.go @@ -54,11 +54,11 @@ func (t Tokens) String() string { return strings.Join(out, " ") } -func (t Tokens) Next() (*Token, Tokens) { +func (t Tokens) Next() Tokens { if len(t) == 0 { - return &TokenEOLMarker, nil + return nil } - return t[0], t[1:] + return t[1:] } func (t Tokens) Return(token *Token) Tokens { diff --git a/lexer_test.go b/lexer_test.go index 096d55d..a57afcc 100644 --- a/lexer_test.go +++ b/lexer_test.go @@ -9,20 +9,28 @@ import ( func TestLexer(t *testing.T) { tokens := Tokenize([]string{"-abc", "foo", "--foo=bar", "--bar", "foo"}).Tokens assert.Equal(t, 8, len(tokens)) - tok, tokens := tokens.Next() + tok := tokens.Peek() assert.Equal(t, &Token{TokenShort, "a"}, tok) - tok, tokens = tokens.Next() + tokens = tokens.Next() + tok = tokens.Peek() assert.Equal(t, &Token{TokenShort, "b"}, tok) - tok, tokens = tokens.Next() + tokens = tokens.Next() + tok = tokens.Peek() assert.Equal(t, &Token{TokenShort, "c"}, tok) - tok, tokens = tokens.Next() + tokens = tokens.Next() + tok = tokens.Peek() assert.Equal(t, &Token{TokenArg, "foo"}, tok) - tok, tokens = tokens.Next() + tokens = tokens.Next() + tok = tokens.Peek() assert.Equal(t, &Token{TokenLong, "foo"}, tok) - tok, tokens = tokens.Next() + tokens = tokens.Next() + tok = tokens.Peek() assert.Equal(t, &Token{TokenArg, "bar"}, tok) - tok, tokens = tokens.Next() + tokens = tokens.Next() + tok = tokens.Peek() assert.Equal(t, &Token{TokenLong, "bar"}, tok) - tok, tokens = tokens.Next() + tokens = tokens.Next() + tok = tokens.Peek() assert.Equal(t, &Token{TokenArg, "foo"}, tok) + tokens = tokens.Next() } diff --git a/parser.go b/parser.go index 42fe44e..0e2545c 100644 --- a/parser.go +++ b/parser.go @@ -5,10 +5,8 @@ type ParseContext struct { SelectedCommand string } -func (p *ParseContext) Next() *Token { - var token *Token - token, p.Tokens = p.Tokens.Next() - return token +func (p *ParseContext) Next() { + p.Tokens = p.Tokens.Next() } func (p *ParseContext) Peek() *Token { diff --git a/usage.go b/usage.go index caf0a8c..8ecc08f 100644 --- a/usage.go +++ b/usage.go @@ -39,17 +39,17 @@ func formatTwoColumns(w io.Writer, indent, padding, width int, rows [][2]string) } } -func (c *Application) Usage(w io.Writer) { - c.writeHelp(guessWidth(w), w) +func (a *Application) Usage(w io.Writer) { + a.writeHelp(guessWidth(w), w) } -func (c *Application) CommandUsage(w io.Writer, command string) { - cmd, ok := c.commands[command] - if !ok { - Fatalf("unknown command '%s'", command) +func (a *Application) CommandUsage(w io.Writer, command string) { + cmd := a.findCommand(command) + if cmd == nil { + a.Fatalf(w, "unknown command '%s'", command) } - s := []string{formatArgsAndFlags(c.Name, c.argGroup, c.flagGroup)} - s = append(s, formatArgsAndFlags(cmd.name, cmd.argGroup, cmd.flagGroup)) + s := []string{formatArgsAndFlags(a.Name, a.argGroup, a.flagGroup, cmd.cmdGroup)} + s = append(s, formatArgsAndFlags(cmd.fullCommand(), cmd.argGroup, cmd.flagGroup, cmd.cmdGroup)) fmt.Fprintf(w, "usage: %s\n", strings.Join(s, " ")) if cmd.help != "" { fmt.Fprintf(w, "\n%s\n", cmd.help) @@ -57,9 +57,24 @@ func (c *Application) CommandUsage(w io.Writer, command string) { cmd.writeHelp(guessWidth(w), w) } -func (c *Application) writeHelp(width int, w io.Writer) { - s := []string{formatArgsAndFlags(c.Name, c.argGroup, c.flagGroup)} - if len(c.commands) > 0 { +func (a *Application) findCommand(command string) *CmdClause { + parts := strings.Split(command, " ") + var cmd *CmdClause + group := a.cmdGroup + for _, part := range parts { + next, ok := group.commands[part] + if !ok { + return nil + } + cmd = next + group = next.cmdGroup + } + return cmd +} + +func (a *Application) writeHelp(width int, w io.Writer) { + s := []string{formatArgsAndFlags(a.Name, a.argGroup, a.flagGroup, a.cmdGroup)} + if len(a.commands) > 0 { s = append(s, "", "[]", "[ ...]") } @@ -73,31 +88,14 @@ func (c *Application) writeHelp(width int, w io.Writer) { for _, l := range lines[1:] { fmt.Fprintf(w, "%*s%s\n", len(prefix), "", l) } - if c.Help != "" { + if a.Help != "" { fmt.Fprintf(w, "\n") - doc.ToText(w, c.Help, "", preIndent, width) - } - - c.flagGroup.writeHelp(width, w) - c.argGroup.writeHelp(width, w) - - if len(c.commands) > 0 { - fmt.Fprintf(w, "\nCommands:\n") - c.helpCommands(width, w) + doc.ToText(w, a.Help, "", preIndent, width) } -} -func (c *Application) helpCommands(width int, w io.Writer) { - for _, cmd := range c.commandOrder { - fmt.Fprintf(w, " %s\n", formatArgsAndFlags(cmd.name, cmd.argGroup, cmd.flagGroup)) - buf := bytes.NewBuffer(nil) - doc.ToText(buf, cmd.help, "", preIndent, width-4) - lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") - for _, line := range lines { - fmt.Fprintf(w, " %s\n", line) - } - fmt.Fprintf(w, "\n") - } + a.flagGroup.writeHelp(width, w) + a.argGroup.writeHelp(width, w) + a.cmdGroup.writeHelp(width, w) } func (f *flagGroup) writeHelp(width int, w io.Writer) { @@ -156,12 +154,31 @@ func (a *argGroup) writeHelp(width int, w io.Writer) { formatTwoColumns(w, 2, 2, width, rows) } -func (c *CmdClause) writeHelp(width int, w io.Writer) { - c.flagGroup.writeHelp(width, w) - c.argGroup.writeHelp(width, w) +func (a *CmdClause) writeHelp(width int, w io.Writer) { + a.flagGroup.writeHelp(width, w) + a.argGroup.writeHelp(width, w) + a.cmdGroup.writeHelp(width, w) +} + +func (c *cmdGroup) writeHelp(width int, w io.Writer) { + if len(c.commands) == 0 { + return + } + fmt.Fprintf(w, "\nCommands:\n") + flattened := c.flattenedCommands() + for _, cmd := range flattened { + fmt.Fprintf(w, " %s\n", formatArgsAndFlags(cmd.fullCommand(), cmd.argGroup, cmd.flagGroup, cmd.cmdGroup)) + buf := bytes.NewBuffer(nil) + doc.ToText(buf, cmd.help, "", preIndent, width-4) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + for _, line := range lines { + fmt.Fprintf(w, " %s\n", line) + } + fmt.Fprintf(w, "\n") + } } -func formatArgsAndFlags(name string, args *argGroup, flags *flagGroup) string { +func formatArgsAndFlags(name string, args *argGroup, flags *flagGroup, commands *cmdGroup) string { s := []string{name} s = append(s, flags.gatherFlagSummary()...) depth := 0