From 6408010083e5612a7b853c0851e4881bb785f24a Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Mon, 25 Jun 2018 14:45:20 +1000 Subject: [PATCH] Clean up disparity between Context and Kong. Previously, there was a confusing mix of functionality shared between the two wherein you would need to use the Kong type for printing errors, etc. but it did not have access to the context in order to print context-sensitive usage information. This has been fixed. Additionally, there are now fuzzy correction suggestions for flags and commands Also added a server example which shows how Kong can be used for parsing in interactive shells. Run with: $ go run ./_examples/server/*.go Then interact with: $ ssh -p 6740 127.0.0.1 --- README.md | 33 +++--- _examples/docker/main.go | 14 +-- _examples/server/console.go | 49 +++++++++ _examples/server/main.go | 154 ++++++++++++++++++++++++++++ _examples/server/server_rsa_key | 27 +++++ _examples/server/server_rsa_key.pub | 1 + _examples/shell/main.go | 8 +- build.go | 9 +- context.go | 74 ++++++++++--- global.go | 32 +----- guesswidth_unix.go | 3 + help.go | 56 +++++----- help_test.go | 9 +- kong.go | 31 ++++-- kong_test.go | 22 ++++ levenshtein.go | 39 +++++++ model.go | 46 +++++++-- model_test.go | 2 +- options.go | 4 +- tag.go | 6 ++ 20 files changed, 489 insertions(+), 130 deletions(-) create mode 100644 _examples/server/console.go create mode 100644 _examples/server/main.go create mode 100644 _examples/server/server_rsa_key create mode 100644 _examples/server/server_rsa_key.pub create mode 100644 levenshtein.go diff --git a/README.md b/README.md index 3ab481a..1b3a449 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ 1. [`Configuration(loader, paths...)` - load defaults from configuration files](#configurationloader-paths---load-defaults-from-configuration-files) 1. [`Resolver(...)` - support for default values from external sources](#resolver---support-for-default-values-from-external-sources) 1. [`*Mapper(...)` - customising how the command-line is mapped to Go values](#mapper---customising-how-the-command-line-is-mapped-to-go-values) - 1. [`HelpOptions(HelpPrinterOptions)` and `Help(HelpFunc)` - customising help](#helpoptionshelpprinteroptions-and-helphelpfunc---customising-help) + 1. [`ConfigureHelp(HelpOptions)` and `Help(HelpFunc)` - customising help](#configurehelphelpoptions-and-helphelpfunc---customising-help) 1. [`Hook(&field, HookFunc)` - callback hooks to execute when the command-line is parsed](#hookfield-hookfunc---callback-hooks-to-execute-when-the-command-line-is-parsed) 1. [Other options](#other-options) @@ -61,12 +61,12 @@ var CLI struct { } func main() { - cmd := kong.Parse(&CLI) - switch cmd { + ctx := kong.Parse(&CLI) + switch ctx.Command() { case "rm ": case "ls": default: - panic(cmd) + panic(ctx.Command()) } } ``` @@ -142,12 +142,12 @@ var CLI struct { } func main() { - cmd := kong.Parse(&CLI) - switch cmd { + ctx := kong.Parse(&CLI) + switch ctx.Command() { case "rm ": case "ls": default: - panic(cmd) + panic(ctx.Command()) } } ``` @@ -197,15 +197,10 @@ var cli struct { } func main() { - parser := kong.Must(&cli) - - // Parse and apply the command-line. - ctx, err := parser.Parse(os.Args[1:]) - parser.FatalIfErrorf(err) - + ctx := kong.Parse(&cli) // Call the Run() method of the selected parsed command. err = ctx.Run(cli.Debug) - parser.FatalIfErrorf(err) + ctx.FatalIfErrorf(err) } ``` @@ -350,7 +345,7 @@ Both can coexist with standard Tag parsing. | `short:"X"` | Short name, if flag. | | `required` | If present, flag/arg is required. | | `optional` | If present, flag/arg is optional. | -| `hidden` | If present, flag is hidden. | +| `hidden` | If present, command or flag is hidden. | | `format:"X"` | Format for parsing input, if supported. | | `sep:"X"` | Separator for sequences (defaults to ","). May be `none` to disable splitting. | @@ -406,11 +401,11 @@ All builtin Go types (as well as a bunch of useful stdlib types like `time.Time` 3. `TypeMapper(reflect.Type, Mapper)`. 4. `ValueMapper(interface{}, Mapper)`, passing in a pointer to a field of the grammar. -### `HelpOptions(HelpPrinterOptions)` and `Help(HelpFunc)` - customising help +### `ConfigureHelp(HelpOptions)` and `Help(HelpFunc)` - customising help The default help output is usually sufficient, but if not there are two solutions. -1. Use `HelpOptions(HelpPrinterOptions)` to configure how help is formatted (see [HelpPrinterOptions](https://godoc.org/github.com/alecthomas/kong#HelpPrinterOptions) for details). +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. ### `Hook(&field, HookFunc)` - callback hooks to execute when the command-line is parsed @@ -426,7 +421,7 @@ app := kong.Must(&CLI, kong.Hook(&CLI.Debug, func(ctx *Context, path *Path) erro })) ``` -Note: it is generally more advisable to use an imperative approach to building command-lines, eg. +Note: it is generally less verbose to use an imperative approach to building command-lines, eg. ```go if CLI.Debug { @@ -434,7 +429,7 @@ if CLI.Debug { } ``` -But under some circumstances, hooks are the right choice. +But under some circumstances, hooks can be useful. ### Other options diff --git a/_examples/docker/main.go b/_examples/docker/main.go index f472230..e678bf4 100644 --- a/_examples/docker/main.go +++ b/_examples/docker/main.go @@ -2,8 +2,6 @@ package main import ( - "os" - "github.com/alecthomas/kong" ) @@ -68,20 +66,18 @@ type CLI struct { func main() { cli := CLI{} - parser := kong.Must(&cli, + ctx := kong.Parse(&cli, kong.Name("docker"), kong.Description("A self-sufficient runtime for containers"), kong.UsageOnError(), - kong.HelpOptions(kong.HelpPrinterOptions{ + kong.ConfigureHelp(kong.HelpOptions{ Compact: true, }), // kong.Hook(&cli.VersionFlag, func(ctx *kong.Context, path *kong.Path) error { - ctx.App.Printf("1.0.0").Exit(0) + ctx.Printf("1.0.0").Exit(0) return nil })) - ctx, err := parser.Parse(os.Args[1:]) - parser.FatalIfErrorf(err) - err = ctx.Run(&cli.Globals) - parser.FatalIfErrorf(err) + err := ctx.Run(&cli.Globals) + ctx.FatalIfErrorf(err) } diff --git a/_examples/server/console.go b/_examples/server/console.go new file mode 100644 index 0000000..6b7a86c --- /dev/null +++ b/_examples/server/console.go @@ -0,0 +1,49 @@ +// nolint: govet +package main + +import ( + "fmt" + + "github.com/alecthomas/kong" +) + +// Ensure the grammar compiles. +var _ = kong.Must(&grammar{}) + +// Server interface. +type grammar struct { + Help helpCmd `cmd help:"Show help."` + Question helpCmd `cmd hidden name:"?" help:"Show help."` + + Status statusCmd `cmd help:"Show server status."` +} + +type statusCmd struct { + Verbose bool `short:"v" help:"Show verbose status information."` +} + +func (s *statusCmd) Run(ctx *kong.Context) error { + ctx.Printf("OK") + return nil +} + +type helpCmd struct { + Command []string `arg optional help:"Show help on command."` +} + +// Run shows help. +func (h *helpCmd) Run(realCtx *kong.Context) error { + ctx, err := kong.Trace(realCtx.Kong, h.Command) + if err != nil { + return err + } + if ctx.Error != nil { + return ctx.Error + } + err = ctx.PrintUsage(false) + if err != nil { + return err + } + fmt.Fprintln(realCtx.Stdout) + return nil +} diff --git a/_examples/server/main.go b/_examples/server/main.go new file mode 100644 index 0000000..f681a25 --- /dev/null +++ b/_examples/server/main.go @@ -0,0 +1,154 @@ +package main + +import ( + "errors" + "fmt" + "io" + "log" + "os" + "strings" + + "golang.org/x/crypto/ssh/terminal" + + "github.com/chzyer/readline" + "github.com/gliderlabs/ssh" + "github.com/google/shlex" + "github.com/kr/pty" + + "github.com/alecthomas/colour" + "github.com/alecthomas/kong" +) + +type context struct { + kong *kong.Context + rl *readline.Instance +} + +func handle(log *log.Logger, s ssh.Session) error { + log.Printf("New SSH") + sshPty, _, isPty := s.Pty() + if !isPty { + return errors.New("No PTY requested") + } + log.Printf("Using TERM=%s width=%d height=%d", sshPty.Term, sshPty.Window.Width, sshPty.Window.Height) + cpty, tty, err := pty.Open() + if err != nil { + return err + } + defer tty.Close() + state, err := terminal.GetState(int(cpty.Fd())) + if err != nil { + return err + } + defer terminal.Restore(int(cpty.Fd()), state) + + colour.Fprintln(tty, "^BWelcome!^R") + go io.Copy(cpty, s) + go io.Copy(s, cpty) + + parser, err := buildShellParser(tty) + if err != nil { + return err + } + + rl, err := readline.NewEx(&readline.Config{ + Prompt: "> ", + Stderr: tty, + Stdout: tty, + Stdin: tty, + FuncOnWidthChanged: func(f func()) {}, + FuncMakeRaw: func() error { + _, err := terminal.MakeRaw(int(cpty.Fd())) // nolint: govet + return err + }, + FuncExitRaw: func() error { return nil }, + }) + if err != nil { + return err + } + + log.Printf("Loop") + for { + tty.Sync() + + var line string + line, err = rl.Readline() + if err != nil { + if err == io.EOF { + return nil + } + return err + } + var args []string + args, err := shlex.Split(string(line)) + if err != nil { + parser.Errorf("%s", err) + continue + } + var ctx *kong.Context + ctx, err = parser.Parse(args) + if err != nil { + parser.Errorf("%s", err) + if err, ok := err.(*kong.ParseError); ok { + log.Println(err.Error()) + err.Context.PrintUsage(false) + } + continue + } + err = ctx.Run(ctx) + if err != nil { + parser.Errorf("%s", err) + continue + } + } +} + +func buildShellParser(tty *os.File) (*kong.Kong, error) { + parser, err := kong.New(&grammar{}, + kong.Name(""), + kong.Description("Example using Kong for interactive command parsing."), + kong.Writers(tty, tty), + kong.Exit(func(int) {}), + kong.ConfigureHelp(kong.HelpOptions{ + NoAppSummary: true, + }), + kong.NoDefaultHelp(), + ) + return parser, err +} + +func handlerWithError(handle func(log *log.Logger, s ssh.Session) error) ssh.Handler { + return func(s ssh.Session) { + prefix := fmt.Sprintf("%s->%s ", s.LocalAddr(), s.RemoteAddr()) + l := log.New(os.Stdout, prefix, log.LstdFlags) + err := handle(l, s) + if err != nil { + log.Printf("error: %s", err) + s.Exit(1) + } else { + log.Printf("Bye") + s.Exit(0) + } + } +} + +var cli struct { + HostKey string `type:"existingfile" help:"SSH host key to use." default:"./_examples/server/server_rsa_key"` + Bind string `help:"Bind address for server." default:"127.0.0.1:6740"` +} + +func main() { + ctx := kong.Parse(&cli, + kong.Name("server"), + kong.Description("A network server using Kong for interacting with clients.")) + + ssh.Handle(handlerWithError(handle)) + log.Printf("SSH listening on: %s", cli.Bind) + log.Printf("Using host key: %s", cli.HostKey) + log.Println() + parts := strings.Split(cli.Bind, ":") + log.Printf("Connect with: ssh -p %s %s", parts[1], parts[0]) + log.Println() + err := ssh.ListenAndServe(cli.Bind, nil, ssh.HostKeyFile(cli.HostKey)) + ctx.FatalIfErrorf(err) +} diff --git a/_examples/server/server_rsa_key b/_examples/server/server_rsa_key new file mode 100644 index 0000000..008f812 --- /dev/null +++ b/_examples/server/server_rsa_key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAxK1QbPQYibc0VFZdc6GHPka5oTeGnXsMZiyX4JbHUJl1oNDB +Xg9NATbft+q/6ZDjyVEQhq8xgLvYFkL8qBLt/6UAaOub0RtmPqQwmxoNLWuXFFwn +YlBKApQ4gf58/jOcGPpdEwfjkjwLb536Bni25XMU4cYrdIvQIhtMaK+Zqja/3MAC +V6ZRZZCd8hABJqaZu+3mRElnF1d7gfMvA/hhaq7Y5VYr8rUrBHHimrT/GEP6aCbf +Npo43SfRnUDu2+EAK7vA9cM8fg/O/mvNR1/9jzOWyr8ZDLD6R6iaZCQ2anEPcqim +MCOtibSeVOms07Zcn/TSsgmfGwa8rQkpXVJA5wIDAQABAoIBAQCTUccGdajTrzkx +WyfQ71NgoJV3XyIkYAEfn5N8FTTi+LAVb4kILanemP3mw55RE8isCV65pA0OgqYP +tsmOE+/WKAAwlxs1/LIPhekqpM7uEMMv6v9NMxrc562UIc36kyn/w7loAebCqNtg +FhMsOcu1/wfLPidau0eB5LTNTYtq5RuSKxoindvatk+Zmk0KjoA+f25MlwCEHQNr +ygpopclyTHVln2t3t0o97/a7dHa9+HlmVO4GxWvTTiqtcFErTGWtTUW8aeZFS83r +p+JZNxReSJ2MlM9bm15wJ0L86GTeYZQiaNuC1XETbFvX+9Ffkl+7EtsdYDLV1N6r +/eOP2f0hAoGBAOKVDHmnru7SVmbH5BI8HW5sd6IVztZM3+BKzy6AaPc4/FgG6MOr +bJyFbmN8S/gVi4OYOJXgfaeKcycYJFTjXUSnNRQ9eT0MseD9SxzEXV7RGtnvudiu +pbRmtBRtf3e4beaN9X4SfWk4+Frw7B8UsPXwV/09s7AW279cES565IkfAoGBAN42 +TQSC/jQmJBpGSnqWfqQtKPTSKFoZ/JQbxoy9QckAMqVSFwBBgwQYr4MbI7WyjPRE +s43kpf+Sq/++fc3hyk5XAWBKscK0KLs0HBRZyLybQYI+f4/x2giVzKeRRNVa9nQa +VdIU8i+eO2AUzG690q89HGkRBsfXekjq5kXC9Cc5AoGAUY0b5F16FPMXrf6cFAQX +A7t+g5Qd0fvxSCUk1LPbE8Aq8vPpqyN0ABH2XVBLd4spn7+V/jvCfh7Su2txCCyd +USxtak+F53c+PqBr/HqgsJPKek5SMa8KbRfaENAoZMq4o5bMmQfGo6yhlvnHwpgL +6TkMMlWW6vYPOZzFglkxEDkCgYApT78Rz6ii2VRs7hR6pe/1Zc/vdAK8fYhPoLpQ +//5y9+5yfch467UH1e8LWMhSx1cdMoiPIKsb0JDZgviwhgGufs5qsHhL0mKgKxft +UKPZLKQJKsVcZYI7hl396Sv63mZjP2IlJG/CGpC/VB6NmAzLN3lIrzmrfYvmcoVN +AumRQQKBgB4Uznek3nq5ZQf93+ucvXpf0bLRqq1va7jYmRosyiCN52grbclj5uMq +vxr1uoqmgtCfqdgUbm0s+kVK6D4bPkz4HQOSXImXhLs8/KdixYfPLSarxYvTxZKg +mMF1XqcdRwSv3RZYtUbbF7dYQYsC1/ZKXvtPldeoDmTZ+U7b2qbE +-----END RSA PRIVATE KEY----- diff --git a/_examples/server/server_rsa_key.pub b/_examples/server/server_rsa_key.pub new file mode 100644 index 0000000..aba46a1 --- /dev/null +++ b/_examples/server/server_rsa_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDErVBs9BiJtzRUVl1zoYc+RrmhN4adewxmLJfglsdQmXWg0MFeD00BNt+36r/pkOPJURCGrzGAu9gWQvyoEu3/pQBo65vRG2Y+pDCbGg0ta5cUXCdiUEoClDiB/nz+M5wY+l0TB+OSPAtvnfoGeLblcxThxit0i9AiG0xor5mqNr/cwAJXplFlkJ3yEAEmppm77eZESWcXV3uB8y8D+GFqrtjlVivytSsEceKatP8YQ/poJt82mjjdJ9GdQO7b4QAru8D1wzx+D87+a81HX/2PM5bKvxkMsPpHqJpkJDZqcQ9yqKYwI62JtJ5U6azTtlyf9NKyCZ8bBrytCSldUkDn diff --git a/_examples/shell/main.go b/_examples/shell/main.go index 9aa3652..6a26f01 100644 --- a/_examples/shell/main.go +++ b/_examples/shell/main.go @@ -23,13 +23,15 @@ var cli struct { } func main() { - cmd := kong.Parse(&cli, kong.Description("A shell-like example app."), + ctx := kong.Parse(&cli, + kong.Name("shell"), + kong.Description("A shell-like example app."), kong.UsageOnError(), - kong.HelpOptions(kong.HelpPrinterOptions{ + kong.ConfigureHelp(kong.HelpOptions{ Compact: true, Summary: true, })) - switch cmd { + switch ctx.Command() { case "rm ": fmt.Println(cli.Rm.Paths, cli.Rm.Force, cli.Rm.Recursive) diff --git a/build.go b/build.go index 41229fd..3bea4ae 100644 --- a/build.go +++ b/build.go @@ -24,7 +24,7 @@ func build(k *Kong, ast interface{}) (app *Application, err error) { if len(node.Positional) > 0 && len(node.Children) > 0 { return nil, fmt.Errorf("can't mix positional arguments and branching arguments on %T", ast) } - app.Node = *node + app.Node = node app.Node.Flags = append(extraFlags, app.Node.Flags...) return app, nil } @@ -63,13 +63,13 @@ func buildNode(k *Kong, v reflect.Value, typ NodeType, seenFlags map[string]bool ft := field.field fv := field.value - name := ft.Tag.Get("name") + tag := parseTag(fv, ft) + + name := tag.Name if name == "" { name = strings.ToLower(dashedString(ft.Name)) } - tag := parseTag(fv, ft) - // Nested structs are either commands or args. if ft.Type.Kind() == reflect.Struct && (tag.Cmd || tag.Arg) { typ := CommandNode @@ -105,6 +105,7 @@ func buildChild(k *Kong, node *Node, typ NodeType, v reflect.Value, ft reflect.S child := buildNode(k, fv, typ, seenFlags) child.Parent = node child.Help = tag.Help + child.Hidden = tag.Hidden // A branching argument. This is a bit hairy, as we let buildNode() do the parsing, then check that // a positional argument is provided to the child, and move it to the branching argument field. diff --git a/context.go b/context.go index 1109809..8d60d98 100644 --- a/context.go +++ b/context.go @@ -29,7 +29,7 @@ type Path struct { func (p *Path) Node() *Node { switch { case p.App != nil: - return &p.App.Node + return p.App.Node case p.Argument != nil: return p.Argument @@ -42,7 +42,7 @@ func (p *Path) Node() *Node { // Context contains the current parse context. type Context struct { - App *Kong + *Kong // A trace through parsed nodes. Path []*Path // Original command-line arguments. @@ -61,7 +61,7 @@ type Context struct { // Note that this will not modify the target grammar. Call Apply() to do so. func Trace(k *Kong, args []string) (*Context, error) { c := &Context{ - App: k, + Kong: k, Args: args, Path: []*Path{ {App: k.Model, Flags: k.Model.Flags}, @@ -69,7 +69,7 @@ func Trace(k *Kong, args []string) (*Context, error) { values: map[*Value]reflect.Value{}, scan: Scan(args...), } - c.Error = c.trace(&c.App.Model.Node) + c.Error = c.trace(c.Model.Node) return c, c.traceResolvers() } @@ -120,7 +120,7 @@ func (c *Context) Validate() error { // Check the terminal node. node := c.Selected() if node == nil { - node = &c.App.Model.Node + node = c.Model.Node } // Find deepest positional argument so we can check if all required positionals have been provided. @@ -216,7 +216,10 @@ func (c *Context) reset(node *Node) error { func (c *Context) trace(node *Node) (err error) { // nolint: gocyclo positional := 0 - flags := append(c.Flags(), node.Flags...) + flags := []*Flag{} + for _, group := range node.AllFlags(false) { + flags = append(flags, group...) + } for !c.scan.Peek().IsEOL() { token := c.scan.Peek() @@ -285,6 +288,8 @@ func (c *Context) trace(node *Node) (err error) { // nolint: gocyclo return fmt.Errorf("unexpected flag argument %q", token.Value) case PositionalArgumentToken: + candidates := []string{} + // Ensure we've consumed all positional arguments. if positional < len(node.Positional) { arg := node.Positional[positional] @@ -302,6 +307,9 @@ func (c *Context) trace(node *Node) (err error) { // nolint: gocyclo // After positional arguments have been consumed, check commands next... for _, branch := range node.Children { + if branch.Type == CommandNode { + candidates = append(candidates, branch.Name) + } if branch.Type == CommandNode && branch.Name == token.Value { c.scan.Pop() c.Path = append(c.Path, &Path{ @@ -327,8 +335,8 @@ func (c *Context) trace(node *Node) (err error) { // nolint: gocyclo } } } - return fmt.Errorf("unexpected positional argument %s", token) + return findPotentialCandidates(token.Value, candidates, "unexpected argument %s", token) default: return fmt.Errorf("unexpected token %s", token) } @@ -336,9 +344,28 @@ func (c *Context) trace(node *Node) (err error) { // nolint: gocyclo return nil } +func findPotentialCandidates(needle string, haystack []string, format string, args ...interface{}) error { + if len(haystack) == 0 { + return fmt.Errorf(format, args...) + } + closestCandidates := []string{} + for _, candidate := range haystack { + if strings.HasPrefix(candidate, needle) || levenshtein(candidate, needle) <= 2 { + closestCandidates = append(closestCandidates, fmt.Sprintf("%q", candidate)) + } + } + prefix := fmt.Sprintf(format, args...) + if len(closestCandidates) == 1 { + return fmt.Errorf("%s, did you mean %s?", prefix, closestCandidates[0]) + } else if len(closestCandidates) > 1 { + return fmt.Errorf("%s, did you mean one of %s?", prefix, strings.Join(closestCandidates, ", ")) + } + return fmt.Errorf("%s", prefix) +} + // Walk through flags from existing nodes in the path. func (c *Context) traceResolvers() error { - if len(c.App.resolvers) == 0 { + if len(c.resolvers) == 0 { return nil } @@ -349,7 +376,7 @@ func (c *Context) traceResolvers() error { if _, ok := c.values[flag.Value]; ok { continue } - for _, resolver := range c.App.resolvers { + for _, resolver := range c.resolvers { s, err := resolver(c, path, flag) if err != nil { return err @@ -386,7 +413,7 @@ func (c *Context) getValue(value *Value) reflect.Value { // Apply traced context to the target grammar. func (c *Context) Apply() (string, error) { - err := c.reset(&c.App.Model.Node) + err := c.reset(c.Model.Node) if err != nil { return "", err } @@ -420,8 +447,15 @@ func (c *Context) Apply() (string, error) { func (c *Context) parseFlag(flags []*Flag, match string) (err error) { defer catch(&err) + candidates := []string{} for _, flag := range flags { - if "-"+string(flag.Short) != match && "--"+flag.Name != match { + long := "--" + flag.Name + short := "-" + string(flag.Short) + candidates = append(candidates, long) + if flag.Short != 0 { + candidates = append(candidates, short) + } + if short != match && long != match { continue } // Found a matching flag. @@ -433,7 +467,7 @@ func (c *Context) parseFlag(flags []*Flag, match string) (err error) { c.Path = append(c.Path, &Path{Flag: flag}) return nil } - return fmt.Errorf("unknown flag %s", match) + return findPotentialCandidates(match, candidates, "unknown flag %s", match) } // Run executes the corresponding Run(params...) method on the target command selected by the parsed args. @@ -441,7 +475,7 @@ func (c *Context) parseFlag(flags []*Flag, match string) (err error) { // The target Run() method must exist and have the type signature "Run(params...) error". func (c *Context) Run(params ...interface{}) (err error) { defer catch(&err) - expectedRunSignature, err := c.validateRun(&c.App.Model.Node, nil) + expectedRunSignature, err := c.validateRun(c.Model.Node, nil) if err != nil { return err } @@ -478,6 +512,16 @@ func (c *Context) Run(params ...interface{}) (err error) { return result[0].Interface().(error) } +// PrintUsage to Kong's stdout. +// +// If summary is true, a summarised version of the help will be output. +func (c *Context) PrintUsage(summary bool) error { + options := c.helpOptions + options.Summary = summary + _ = c.help(options, c) + return nil +} + // Validate that all commands have Run() methods and that their signatures are the same. func (c *Context) validateRun(node *Node, signature reflect.Type) (reflect.Type, error) { if node.Leaf() { @@ -554,12 +598,12 @@ func checkMissingChildren(node *Node) error { } if len(missing) == 1 { - return fmt.Errorf("%q should be followed by %s", node.Path(), missing[0]) + return fmt.Errorf("expected %s", missing[0]) } if len(missing) > 5 { missing = append(missing[:5], "...") } - return fmt.Errorf("%q should be followed by one of %s", node.Path(), strings.Join(missing, ", ")) + return fmt.Errorf("expected one of %s", strings.Join(missing, ", ")) } // If we're missing any positionals and they're required, return an error. diff --git a/global.go b/global.go index 2c4a960..d4b3cb5 100644 --- a/global.go +++ b/global.go @@ -4,41 +4,13 @@ import ( "os" ) -// App is the default global instance. It is populated by Parse(). -var App *Kong - // Parse constructs a new parser and parses the default command-line. -func Parse(cli interface{}, options ...Option) string { +func Parse(cli interface{}, options ...Option) *Context { parser, err := New(cli, options...) if err != nil { panic(err) } - App = parser ctx, err := parser.Parse(os.Args[1:]) parser.FatalIfErrorf(err) - return ctx.Command() -} - -// FatalIfErrorf terminates with an error message if err != nil. -func FatalIfErrorf(err error, args ...interface{}) { - if App == nil { - panic("call kong.Parse() before using kong.FatalIfErrorf()") - } - App.FatalIfErrorf(err, args...) -} - -// Errorf writes a message to Kong.Stderr with the application name prefixed. -func Errorf(format string, args ...interface{}) { - if App == nil { - panic("call kong.Parse() before using kong.Errorf()") - } - App.Errorf(format, args...) -} - -// Printf writes a message to Kong.Stdout with the application name prefixed. -func Printf(format string, args ...interface{}) { - if App == nil { - panic("call kong.Parse() before using kong.Printf()") - } - App.Printf(format, args...) + return ctx } diff --git a/guesswidth_unix.go b/guesswidth_unix.go index b549ed4..41d54e6 100644 --- a/guesswidth_unix.go +++ b/guesswidth_unix.go @@ -31,6 +31,9 @@ func guessWidth(w io.Writer) int { uintptr(unsafe.Pointer(&dimensions)), // nolint: gas 0, 0, 0, ); err == 0 { + if dimensions[1] == 0 { + return 80 + } return int(dimensions[1]) } } diff --git a/help.go b/help.go index cf81059..99e6033 100644 --- a/help.go +++ b/help.go @@ -13,38 +13,43 @@ const ( defaultColumnPadding = 4 ) -// HelpPrinterOptions for HelpPrinters. -type HelpPrinterOptions struct { +// HelpOptions for HelpPrinters. +type HelpOptions struct { + // Don't print top-level usage summary. + NoAppSummary bool + // Write a one-line summary of the context. Summary bool - // Write help in a more compact form, but still fully-specified. + // Write help in a more compact, but still fully-specified, form. Compact bool } // HelpPrinter is used to print context-sensitive help. -type HelpPrinter func(options HelpPrinterOptions, ctx *Context) error +type HelpPrinter func(options HelpOptions, ctx *Context) error // DefaultHelpPrinter is the default HelpPrinter. -func DefaultHelpPrinter(options HelpPrinterOptions, ctx *Context) error { +func DefaultHelpPrinter(options HelpOptions, ctx *Context) error { if ctx.Empty() { options.Summary = false } w := newHelpWriter(ctx, options) selected := ctx.Selected() if selected == nil { - printApp(w, ctx.App.Model) + printApp(w, ctx.Model) } else { - printCommand(w, ctx.App.Model, selected) + printCommand(w, ctx.Model, selected) } - return w.Write(ctx.App.Stdout) + return w.Write(ctx.Stdout) } func printApp(w *helpWriter, app *Application) { - w.Printf("Usage: %s", app.Summary()) - printNodeDetail(w, &app.Node) - cmds := app.Leaves() - if len(cmds) > 0 { + if !w.NoAppSummary { + w.Printf("Usage: %s%s", app.Name, app.Summary()) + } + printNodeDetail(w, app.Node) + cmds := app.Leaves(true) + if len(cmds) > 0 && app.HelpFlag != nil { w.Print("") if w.Summary { w.Printf(`Run "%s --help" for more information.`, app.Name) @@ -55,11 +60,12 @@ func printApp(w *helpWriter, app *Application) { } func printCommand(w *helpWriter, app *Application, cmd *Command) { - w.Printf("Usage: %s %s", app.Name, cmd.Summary()) + if !w.NoAppSummary { + w.Printf("Usage: %s %s", app.Name, cmd.Summary()) + } printNodeDetail(w, cmd) - if w.Summary { - w.Print("") - w.Printf(`Run "%s %s --help" for more information.`, app.Name, cmd.Path()) + if w.Summary && app.HelpFlag != nil { + w.Printf(`Run "%s %s --help" for more information.`, app.Name, cmd.FullPath()) } } @@ -76,12 +82,12 @@ func printNodeDetail(w *helpWriter, node *Node) { w.Print("Arguments:") writePositionals(w.Indent(), node.Positional) } - if flags := node.AllFlags(); len(flags) > 0 { + if flags := node.AllFlags(true); len(flags) > 0 { w.Print("") w.Print("Flags:") writeFlags(w.Indent(), flags) } - cmds := node.Leaves() + cmds := node.Leaves(true) if len(cmds) > 0 { w.Print("") w.Print("Commands:") @@ -114,16 +120,16 @@ type helpWriter struct { indent string width int lines *[]string - HelpPrinterOptions + HelpOptions } -func newHelpWriter(ctx *Context, options HelpPrinterOptions) *helpWriter { +func newHelpWriter(ctx *Context, options HelpOptions) *helpWriter { lines := []string{} w := &helpWriter{ - indent: "", - width: guessWidth(ctx.App.Stdout), - lines: &lines, - HelpPrinterOptions: options, + indent: "", + width: guessWidth(ctx.Stdout), + lines: &lines, + HelpOptions: options, } return w } @@ -137,7 +143,7 @@ func (h *helpWriter) Print(text string) { } func (h *helpWriter) Indent() *helpWriter { - return &helpWriter{indent: h.indent + " ", lines: h.lines, width: h.width - 2, HelpPrinterOptions: h.HelpPrinterOptions} + return &helpWriter{indent: h.indent + " ", lines: h.lines, width: h.width - 2, HelpOptions: h.HelpOptions} } func (h *helpWriter) String() string { diff --git a/help_test.go b/help_test.go index 0cca946..8b5bca9 100644 --- a/help_test.go +++ b/help_test.go @@ -49,13 +49,13 @@ func TestHelp(t *testing.T) { ) t.Run("Full", func(t *testing.T) { - require.Panics(t, func() { + require.PanicsWithValue(t, true, func() { _, err := app.Parse([]string{"--help"}) require.NoError(t, err) }) require.True(t, exited) t.Log(w.String()) - require.Equal(t, `Usage: test-app --required + require.Equal(t, `Usage: test-app --required A test app. @@ -85,17 +85,18 @@ Run "test-app --help" for more information on a command. t.Run("Selected", func(t *testing.T) { exited = false w.Truncate(0) - require.Panics(t, func() { + require.PanicsWithValue(t, true, func() { _, err := app.Parse([]string{"two", "hello", "--help"}) require.NoError(t, err) }) require.True(t, exited) t.Log(w.String()) - require.Equal(t, `Usage: test-app two --required --required-two --required-three + require.Equal(t, `Usage: test-app two --required --required-two --required-three Sub-sub-arg. Flags: + --help Show context-sensitive help. --string=STRING A string flag. --bool A bool flag with very long help that wraps a lot and is verbose and is really verbose. diff --git a/kong.go b/kong.go index eb6bb53..9d2c5ca 100644 --- a/kong.go +++ b/kong.go @@ -42,13 +42,15 @@ type Kong struct { Stdout io.Writer Stderr io.Writer - before map[reflect.Value]HookFunc - resolvers []ResolverFunc - registry *Registry + before map[reflect.Value]HookFunc + resolvers []ResolverFunc + registry *Registry + noDefaultHelp bool usageOnError bool help HelpPrinter - helpOptions HelpPrinterOptions + helpOptions HelpOptions + helpFlag *Flag // Set temporarily by Options. These are applied after build(). postBuildOptions []Option @@ -83,6 +85,7 @@ func New(grammar interface{}, options ...Option) (*Kong, error) { } model.Name = filepath.Base(os.Args[0]) k.Model = model + k.Model.HelpFlag = k.helpFlag for _, option := range k.postBuildOptions { if err := option(k); err != nil { @@ -121,6 +124,7 @@ func (k *Kong) extraFlags() []*Flag { k.Exit(1) return nil }) + k.helpFlag = helpFlag _ = hook(k) return []*Flag{helpFlag} } @@ -182,8 +186,15 @@ func (k *Kong) applyHooks(ctx *Context) error { return nil } -func formatMultilineMessage(w io.Writer, leader string, format string, args ...interface{}) { +func formatMultilineMessage(w io.Writer, leaders []string, format string, args ...interface{}) { lines := strings.Split(fmt.Sprintf(format, args...), "\n") + leader := "" + for _, l := range leaders { + if l == "" { + continue + } + leader += l + ": " + } fmt.Fprintf(w, "%s%s\n", leader, lines[0]) for _, line := range lines[1:] { fmt.Fprintf(w, "%*s%s\n", len(leader), " ", line) @@ -192,16 +203,22 @@ func formatMultilineMessage(w io.Writer, leader string, format string, args ...i // Printf writes a message to Kong.Stdout with the application name prefixed. func (k *Kong) Printf(format string, args ...interface{}) *Kong { - formatMultilineMessage(k.Stdout, k.Model.Name+": ", format, args...) + formatMultilineMessage(k.Stdout, []string{k.Model.Name}, format, args...) return k } // Errorf writes a message to Kong.Stderr with the application name prefixed. func (k *Kong) Errorf(format string, args ...interface{}) *Kong { - formatMultilineMessage(k.Stderr, k.Model.Name+": error: ", format, args...) + formatMultilineMessage(k.Stderr, []string{k.Model.Name, "error"}, format, args...) return k } +// Fatalf writes a message to Kong.Stderr with the application name prefixed then exits with a non-zero status. +func (k *Kong) Fatalf(format string, args ...interface{}) { + k.Errorf(format, args...) + k.Exit(1) +} + // FatalIfErrorf terminates with an error message if err != nil. func (k *Kong) FatalIfErrorf(err error, args ...interface{}) { if err == nil { diff --git a/kong_test.go b/kong_test.go index 4e49d21..596833b 100644 --- a/kong_test.go +++ b/kong_test.go @@ -207,6 +207,28 @@ func TestOptionalArg(t *testing.T) { require.NoError(t, err) } +func TestOptionalArgWithDefault(t *testing.T) { + var cli struct { + Arg string `kong:"arg,optional,default='moo'"` + } + + parser := mustNew(t, &cli) + _, err := parser.Parse([]string{}) + require.NoError(t, err) + require.Equal(t, "moo", cli.Arg) +} + +func TestArgWithDefaultIsOptional(t *testing.T) { + var cli struct { + Arg string `kong:"arg,default='moo'"` + } + + parser := mustNew(t, &cli) + _, err := parser.Parse([]string{}) + require.NoError(t, err) + require.Equal(t, "moo", cli.Arg) +} + func TestRequiredArg(t *testing.T) { var cli struct { Arg string `kong:"arg"` diff --git a/levenshtein.go b/levenshtein.go new file mode 100644 index 0000000..1816f30 --- /dev/null +++ b/levenshtein.go @@ -0,0 +1,39 @@ +package kong + +import "unicode/utf8" + +// https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Go +// License: https://creativecommons.org/licenses/by-sa/3.0/ +func levenshtein(a, b string) int { + f := make([]int, utf8.RuneCountInString(b)+1) + + for j := range f { + f[j] = j + } + + for _, ca := range a { + j := 1 + fj1 := f[0] // fj1 is the value of f[j - 1] in last iteration + f[0]++ + for _, cb := range b { + mn := min(f[j]+1, f[j-1]+1) // delete & insert + if cb != ca { + mn = min(mn, fj1+1) // change + } else { + mn = min(mn, fj1) // matched + } + + fj1, f[j] = f[j], mn // save f[j] to fj1(j is about to increase), update f[j] to mn + j++ + } + } + + return f[len(f)-1] +} + +func min(a, b int) int { + if a <= b { + return a + } + return b +} diff --git a/model.go b/model.go index 8e75177..40c7030 100644 --- a/model.go +++ b/model.go @@ -9,7 +9,8 @@ import ( // Application is the root of the Kong model. type Application struct { - Node + *Node + // Help flag, if the NoDefaultHelp() option is not specified. HelpFlag *Flag } @@ -35,6 +36,7 @@ type Node struct { Parent *Node Name string Help string + Hidden bool Flags []*Flag Positional []*Positional Children []*Node @@ -72,20 +74,33 @@ func (n *Node) findNode(key reflect.Value) *Node { } // AllFlags returns flags from all ancestor branches encountered. -func (n *Node) AllFlags() (out [][]*Flag) { +// +// If "hide" is true hidden flags will be omitted. +func (n *Node) AllFlags(hide bool) (out [][]*Flag) { if n.Parent != nil { - out = append(out, n.Parent.AllFlags()...) + out = append(out, n.Parent.AllFlags(hide)...) } - if len(n.Flags) > 0 { - out = append(out, n.Flags) + group := []*Flag{} + for _, flag := range n.Flags { + if !hide || !flag.Hidden { + group = append(group, flag) + } + } + if len(group) > 0 { + out = append(out, group) } return } // Leaves returns the leaf commands/arguments under Node. -func (n *Node) Leaves() (out []*Node) { +// +// If "hidden" is true hidden leaves will be omitted. +func (n *Node) Leaves(hide bool) (out []*Node) { var walk func(n *Node) walk = func(n *Node) { + if hide && n.Hidden { + return + } if len(n.Children) == 0 && n.Type != ApplicationNode { out = append(out, n) } @@ -112,10 +127,10 @@ func (n *Node) Depth() int { return depth } -// Summary help string for the node. +// Summary help string for the node (not including application name). func (n *Node) Summary() string { summary := n.Path() - if flags := n.FlagSummary(); flags != "" { + if flags := n.FlagSummary(true); flags != "" { summary += " " + flags } args := []string{} @@ -131,10 +146,10 @@ func (n *Node) Summary() string { } // FlagSummary for the node. -func (n *Node) FlagSummary() string { +func (n *Node) FlagSummary(hide bool) string { required := []string{} count := 0 - for _, group := range n.AllFlags() { + for _, group := range n.AllFlags(hide) { for _, flag := range group { count++ if flag.Required { @@ -145,13 +160,22 @@ func (n *Node) FlagSummary() string { return strings.Join(required, " ") } +// FullPath is like Path() but includes the Application root node. +func (n *Node) FullPath() string { + root := n + for root.Parent != nil { + root = root.Parent + } + return strings.TrimSpace(root.Name + " " + n.Path()) +} + // Path through ancestors to this Node. func (n *Node) Path() (out string) { if n.Parent != nil { out += " " + n.Parent.Path() } switch n.Type { - case ApplicationNode, CommandNode: + case CommandNode: out += " " + n.Name case ArgumentNode: out += " " + "<" + n.Name + ">" diff --git a/model_test.go b/model_test.go index 9249d25..b128aaf 100644 --- a/model_test.go +++ b/model_test.go @@ -20,7 +20,7 @@ func TestModelApplicationCommands(t *testing.T) { } p := mustNew(t, &cli) actual := []string{} - for _, cmd := range p.Model.Leaves() { + for _, cmd := range p.Model.Leaves(false) { actual = append(actual, cmd.Path()) } require.Equal(t, []string{"one two", "one three "}, actual) diff --git a/options.go b/options.go index 71d918a..ef78f82 100644 --- a/options.go +++ b/options.go @@ -121,8 +121,8 @@ func Help(help HelpPrinter) Option { } } -// HelpOptions sets the HelpPrinterOptions to use for printing help. -func HelpOptions(options HelpPrinterOptions) Option { +// ConfigureHelp sets the HelpOptions to use for printing help. +func ConfigureHelp(options HelpOptions) Option { return func(k *Kong) error { k.helpOptions = options return nil diff --git a/tag.go b/tag.go index d07fd2f..2904d44 100644 --- a/tag.go +++ b/tag.go @@ -14,6 +14,7 @@ type Tag struct { Arg bool Required bool Optional bool + Name string Help string Type string Default string @@ -125,6 +126,11 @@ func parseTag(fv reflect.Value, ft reflect.StructField) *Tag { t.Required = required t.Optional = optional t.Default = t.Get("default") + // Arguments with defaults are always optional. + if t.Arg && t.Default != "" { + t.Optional = true + } + t.Name = t.Get("name") t.Help = t.Get("help") t.Type = t.Get("type") t.Env = t.Get("env")