From 96fa9c43d5f8545260f30583a5ef9615bacb2b5c Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Tue, 5 Jun 2018 10:32:23 +1000 Subject: [PATCH] Improved documentation and help. --- README.md | 202 ++++++++++++++++++++++++++++++++++++---- _examples/shell/main.go | 17 ++-- build.go | 21 ++--- context.go | 19 +++- help.go | 49 +++++++--- help_test.go | 75 +++++++++++---- kong.go | 18 ++-- mapper.go | 100 ++++++++++---------- mapper_test.go | 24 +++++ model.go | 56 +++++++---- model_test.go | 2 +- options.go | 14 +-- options_test.go | 4 +- tag.go | 27 ++++-- tag_test.go | 2 +- 15 files changed, 457 insertions(+), 173 deletions(-) diff --git a/README.md b/README.md index aff7c9c..7fac08f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,34 @@ # Kong is a command-line parser for Go [![CircleCI](https://circleci.com/gh/alecthomas/kong.svg?style=svg&circle-token=477fecac758383bf281453187269b913130f17d2)](https://circleci.com/gh/alecthomas/kong) -It parses a command-line into a struct. eg. + + +1. [Introduction](#introduction) +1. [Help](#help) +1. [Flags](#flags) +1. [Commands and sub-commands](#commands-and-sub-commands) +1. [Supported tags](#supported-tags) +1. [Configuring Kong](#configuring-kong) + 1. [`*Mapper(...)` - customising how the command-line is mapped to Go values](#mapper---customising-how-the-command-line-is-mapped-to-go-values) + 1. [`Help(HelpFunc)` - customising help](#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) + + + + +## Introduction + +Kong aims to support arbitrarily complex command-line structures with as little developer effort as possible. + +To achieve that, command-lines are expressed as Go types, with the structure and tags directing how the command line is mapped onto the struct. + +For example, the following command-line: + +``` +shell rm [-f] [-r] ... +shell ls [ ...] +``` + +Can be represented by the following command-line structure: ```go package main @@ -9,15 +37,15 @@ import "github.com/alecthomas/kong" var CLI struct { Rm struct { - Force bool `kong:"help='Force removal.'"` - Recursive bool `kong:"help='Recursively remove files.'"` + Force bool `help:"Force removal."` + Recursive bool `help:"Recursively remove files."` - Paths []string `kong:"help='Paths to remove.',type='path'"` - } `kong:"help='Remove files.'"` + Paths []string `arg help:"Paths to remove." type:"path"` + } `cmd help:"Remove files."` Ls struct { - Paths []string `kong:"help='Paths to list.',type='path'"` - } `kong:"help='List paths.'"` + Paths []string `arg optional help:"Paths to list." type:"path"` + } `cmd help:"List paths."` } func main() { @@ -25,23 +53,157 @@ func main() { } ``` -## Decoders +## Help -Command-line arguments are mapped to Go values via the Decoder interface: +Help is automatically generated. With no other arguments provided, help will display a full summary of all available commands. + +eg. + +``` +$ shell --help +usage: shell [] + +A shell-like example app. + +Flags: + --help Show context-sensitive help. + --debug Debug mode. + +Commands: + rm [] ... + Remove files. + + ls [] [ ...] + List paths. +``` + +If a command is provided, the help will show full detail on the command including all available flags. + +eg. + +``` +$ shell --help rm +usage: shell rm [] ... + +Remove files. + +Arguments: + ... Paths to remove. + +Flags: + --debug Debug mode. + + -f, --force Force removal. + -r, --recursive Recursively remove files. +``` + +## Flags + +Any field in the command structure *not* tagged with `cmd` or `arg` will be a flag. Flags are optional by default. + +## Commands and sub-commands + +Kong supports arbitrarily nested commands and positional arguments. Nested structs tagged with `cmd` will be treated as commands. + +Arguments can also optionally have children, in order to support commands like the following: + +``` +app rename to +``` + +This is achieved by tagging a nested struct with `arg`, then including a positional argument field inside that struct with the same name. For example: ```go -// A Decoder knows how to decode text into a Go value. -type Decoder interface { - // Decode scan into target. - // - // "ctx" contains context about the value being decoded that may be useful - // to some decoders. - Decode(ctx *DecoderContext, scan *Scanner, target reflect.Value) error +var CLI struct { + Rename struct { + Name struct { + Name string `arg` // <-- NOTE: identical name to enclosing struct field. + To struct { + Name struct { + Name string `arg` + } `arg` + } `cmd` + } `arg` + } `cmd` +} +``` +This looks a little verbose in this contrived example, but typically this will not be the case. + +## Supported tags + +Tags can be in two forms: + +1. Standard Go syntax, eg. `kong:"required,name='foo'"`. +2. Bare tags, eg. `required name:"foo"` + +Both can coexist with standard Tag parsing. + +| Tag | Description | +| -----------------------| ------------------------------------------- | +| `cmd` | If present, struct is a command. | +| `arg` | If present, field is an argument. | +| `type:"X"` | Specify named Mapper to use. | +| `help:"X"` | Help text. | +| `placeholder:"X"` | Placeholder text. | +| `default:"X"` | Default value. | +| `short:"X"` | Short name, if flag. | +| `name:"X"` | Long name, for overriding field name. | +| `required` | If present, flag/arg is required. | +| `optional` | If present, flag/arg is optional. | +| `hidden` | If present, flag is hidden. | +| `format:"X"` | Format for parsing input, if supported. | +| `sep:"X"` | Separator for sequences (defaults to ",") | + +## Configuring Kong + +Each Kong parser can be configured via functional options passed to `New(cli interface{}, options...Option)`. The full set of options can be found in `options.go`. + +### `*Mapper(...)` - customising how the command-line is mapped to Go values + +Command-line arguments are mapped to Go values via the Mapper interface: + +```go +// A Mapper knows how to map command-line input to Go. +type Mapper interface { + // Decode scan into target. + // + // "ctx" contains context about the value being decoded that may be useful + // to some mapperss. + Decode(ctx *MapperContext, scan *Scanner, target reflect.Value) error } ``` -All builtin Go types (as well as a bunch of useful stdlib types like `time.Time`) have decoders registered by default. Decoders for custom types can be added using `kong.RegisterDecoder(decoder)`. Decoders are mapped from fields in three ways: +All builtin Go types (as well as a bunch of useful stdlib types like `time.Time`) have mapperss registered by default. Mappers for custom types can be added using `kong.??Mapper(...)` options. Mappers are applied to fields in four ways: -1. By registering a `kong.NamedDecoder` and using the key `type=''`. -2. By registering a `kong.KindDecoder` with a `reflect.Kind`. -3. By registering a `kong.TypeDecoder` with a `reflect.Type`. +1. `NamedMapper(string, Mapper)` and using the tag key `type:""`. +2. `KindMapper(reflect.Kind, Mapper)`. +3. `TypeMapper(reflect.Type, Mapper)`. +4. `ValueMapper(interface{}, Mapper)`, passing in a pointer to a field of the grammar. + + +### `Help(HelpFunc)` - customising help + +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 + +Hooks are callback functions that are bound to a node in the command-line and executed at parse time, before structural validation and assignment. + +eg. + +```go +app := kong.Must(&CLI, kong.Hook(&CLI.Debug, func(ctx *Context, path *Path) error { + log.SetLevel(DEBUG) + return nil +})) +``` + +Note: it is generally more advisable to use an imperative approach to building command-lines, eg. + +```go +if CLI.Debug { + log.SetLevel(DEBUG) +} +``` + +But under some circumstances, hooks are the right choice. diff --git a/_examples/shell/main.go b/_examples/shell/main.go index 451c3b8..db30871 100644 --- a/_examples/shell/main.go +++ b/_examples/shell/main.go @@ -8,20 +8,21 @@ import ( "github.com/alecthomas/kong" ) +// nolint: govet var CLI struct { - Debug bool `kong:"help='Debug mode.'"` - Output string `kong:"help='File to output to.',placeholder='FILE'"` + Debug bool `help:"Debug mode."` Rm struct { - Force bool `kong:"help='Force removal.'"` - Recursive bool `kong:"help='Recursively remove files.'"` + User string `help:"Run as user." short:"u"` + Force bool `help:"Force removal." short:"f"` + Recursive bool `help:"Recursively remove files." short:"r"` - Paths []string `kong:"arg,help='Paths to remove.',type='path'"` - } `kong:"cmd,help='Remove files.'"` + Paths []string `arg help:"Paths to remove." type:"path"` + } `cmd help:"Remove files."` Ls struct { - Paths []string `kong:"help='Paths to list.',type='path'"` - } `kong:"cmd,help='List paths.'"` + Paths []string `arg optional help:"Paths to list." type:"path"` + } `cmd help:"List paths."` } func main() { diff --git a/build.go b/build.go index 34a5101..e03aa87 100644 --- a/build.go +++ b/build.go @@ -92,8 +92,8 @@ func buildChild(k *Kong, node *Node, typ NodeType, v reflect.Value, ft reflect.S // a positional argument is provided to the child, and move it to the branching argument field. if tag.Arg { if len(child.Positional) == 0 { - fail("positional branch %s.%s must have at least one child positional argument", - v.Type().Name(), ft.Name) + fail("positional branch %s.%s must have at least one child positional argument named %q", + v.Type().Name(), ft.Name, name) } value := child.Positional[0] @@ -122,14 +122,11 @@ func buildChild(k *Kong, node *Node, typ NodeType, v reflect.Value, ft reflect.S func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv reflect.Value, tag *Tag, name string, seenFlags map[string]bool) { mapper := k.registry.ForNamedType(tag.Type, fv) if mapper == nil { - fail("no mapper for %s.%s (of type %s)", v.Type(), ft.Name, ft.Type) + fail("unsupported field type %s.%s (of type %s)", v.Type(), ft.Name, ft.Type) } - flag := !tag.Arg - - value := Value{ + value := &Value{ Name: name, - Flag: flag, Help: tag.Help, Default: tag.Default, Mapper: mapper, @@ -137,22 +134,24 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv Value: fv, // Flags are optional by default, and args are required by default. - Required: (flag && tag.Required) || (tag.Arg && !tag.Optional), + Required: (!tag.Arg && tag.Required) || (tag.Arg && !tag.Optional), Format: tag.Format, } if tag.Arg { - node.Positional = append(node.Positional, &value) + node.Positional = append(node.Positional, value) } else { if seenFlags[value.Name] { fail("duplicate flag --%s", value.Name) } seenFlags[value.Name] = true - node.Flags = append(node.Flags, &Flag{ + flag := &Flag{ Value: value, Short: tag.Short, PlaceHolder: tag.PlaceHolder, Env: tag.Env, - }) + } + value.Flag = flag + node.Flags = append(node.Flags, flag) } } diff --git a/context.go b/context.go index 6ad15c3..b23cef8 100644 --- a/context.go +++ b/context.go @@ -56,14 +56,14 @@ func Trace(k *Kong, args []string) (*Context, error) { App: k, args: args, Path: []*Path{ - {App: k.Application, Flags: k.Flags, Value: k.Target}, + {App: k.Model, Flags: k.Model.Flags, Value: k.Model.Target}, }, } - err := c.reset(&c.App.Node) + err := c.reset(&c.App.Model.Node) if err != nil { return nil, err } - c.Error = c.trace(&c.App.Node) + c.Error = c.trace(&c.App.Model.Node) return c, nil } @@ -93,6 +93,10 @@ func (c *Context) Validate() error { } case path.Argument != nil: + value := path.Argument.Argument + if value.Required && !value.Set { + return fmt.Errorf("%s is required", path.Argument.Summary()) + } if err := checkMissingChildren(path.Argument); err != nil { return err } @@ -341,7 +345,7 @@ func checkMissingFlags(flags []*Flag) error { if !flag.Required || flag.Set { continue } - missing = append(missing, flag.Name) + missing = append(missing, flag.Summary()) } if len(missing) == 0 { return nil @@ -352,12 +356,17 @@ func checkMissingFlags(flags []*Flag) error { func checkMissingChildren(node *Node) error { missing := []string{} + for _, arg := range node.Positional { + if arg.Required && !arg.Set { + missing = append(missing, strconv.Quote(arg.Summary())) + } + } for _, child := range node.Children { if child.Argument != nil { if !child.Argument.Required { continue } - missing = append(missing, strconv.Quote("<"+child.Argument.Name+">")) + missing = append(missing, strconv.Quote(child.Summary())) } else { missing = append(missing, strconv.Quote(child.Name)) } diff --git a/help.go b/help.go index 0d6d937..bc024f4 100644 --- a/help.go +++ b/help.go @@ -13,13 +13,14 @@ const ( defaultIndent = 2 ) +// PrintHelp is the default help printer. func PrintHelp(ctx *Context) error { w := newHelpWriter(guessWidth(ctx.App.Stdout)) selected := ctx.Selected() if selected == nil { - printApp(w, ctx.App.Application) + printApp(w, ctx.App.Model) } else { - printCommand(w, ctx.App.Application, selected) + printCommand(w, ctx.App.Model, selected) } return w.Write(ctx.App.Stdout) } @@ -39,10 +40,15 @@ func printNodeDetail(w *helpWriter, node *Node) { w.Print("") w.Wrap(node.Help) } - if len(node.Flags) > 0 { - w.Printf("") - w.Printf("Flags:") - writeFlags(w.Indent(), node.Flags) + if len(node.Positional) > 0 { + w.Print("") + w.Print("Arguments:") + writePositionals(w.Indent(), node.Positional) + } + if flags := node.AllFlags(); len(flags) > 0 { + w.Print("") + w.Print("Flags:") + writeFlags(w.Indent(), flags) } cmds := node.Leaves() if len(cmds) > 0 { @@ -114,18 +120,33 @@ func (h *helpWriter) Wrap(text string) { } } -func writeFlags(w *helpWriter, flags []*Flag) { +func writePositionals(w *helpWriter, args []*Positional) { + rows := [][2]string{} + for _, arg := range args { + rows = append(rows, [2]string{arg.Summary(), arg.Help}) + } + writeTwoColumns(w, 2, rows) +} + +func writeFlags(w *helpWriter, groups [][]*Flag) { rows := [][2]string{} haveShort := false - for _, flag := range flags { - if flag.Short != 0 { - haveShort = true - break + for _, group := range groups { + for _, flag := range group { + if flag.Short != 0 { + haveShort = true + break + } } } - for _, flag := range flags { - if !flag.Hidden { - rows = append(rows, [2]string{formatFlag(haveShort, flag), flag.Help}) + for i, group := range groups { + if i > 0 { + rows = append(rows, [2]string{"", ""}) + } + for _, flag := range group { + if !flag.Hidden { + rows = append(rows, [2]string{formatFlag(haveShort, flag), flag.Help}) + } } } writeTwoColumns(w, 2, rows) diff --git a/help_test.go b/help_test.go index c359536..c06a9d5 100644 --- a/help_test.go +++ b/help_test.go @@ -9,29 +9,48 @@ import ( func TestHelp(t *testing.T) { var cli struct { - String string `help:"A string flag."` - Bool bool `help:"A bool flag with very long help that wraps a lot and is verbose and is really verbose."` + String string `help:"A string flag."` + Bool bool `help:"A bool flag with very long help that wraps a lot and is verbose and is really verbose."` + Required bool `required help:"A required flag."` One struct { Flag string `help:"Nested flag."` } `cmd help:"A subcommand."` Two struct { - Flag string `help:"Nested flag under two."` + Flag string `help:"Nested flag under two."` + RequiredTwo bool `required` + + Three struct { + RequiredThree bool `required` + Three string `arg` + } `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, Name("test-app"), Description("A test app."), Writers(w, w), - ExitFunction(func(int) { exited = true }), + ExitFunction(func(int) { + exited = true + panic(true) // Panic to fake "exit". + }), ) - _, err := app.Parse([]string{"--help"}) - require.NoError(t, err) - require.True(t, exited) - require.Equal(t, `usage: test-app [] + + t.Run("Full", func(t *testing.T) { + require.Panics(t, 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 [] A test app. @@ -40,25 +59,43 @@ Flags: --string=STRING A string flag. --bool A bool flag with very long help that wraps a lot and is verbose and is really verbose. + --required A required flag. Commands: - one [] + one --required [] A subcommand. - two [] - Another subcommand. + two --required --required-two --required-three [] + Sub-sub-arg. + + two four --required --required-two [] + Sub-sub-command. `, w.String()) + }) - exited = false - w.Truncate(0) - _, err = app.Parse([]string{"one", "--help"}) - require.NoError(t, err) - require.True(t, exited) - require.Equal(t, `usage: test-app one [] + t.Run("Selected", func(t *testing.T) { + exited = false + w.Truncate(0) + require.Panics(t, 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 [] -A subcommand. +Sub-sub-arg. Flags: - --flag=STRING Nested flag. + --string=STRING A string flag. + --bool A bool flag with very long help that wraps a lot and is + verbose and is really verbose. + --required A required flag. + + --flag=STRING Nested flag under two. + --required-two + + --required-three `, w.String()) + }) } diff --git a/kong.go b/kong.go index c16adc3..a8a74df 100644 --- a/kong.go +++ b/kong.go @@ -28,7 +28,7 @@ func Must(ast interface{}, options ...Option) *Kong { // Kong is the main parser type. type Kong struct { // Grammar model. - *Application + Model *Application // Termination function (defaults to os.Exit) Exit func(int) @@ -36,7 +36,7 @@ type Kong struct { Stdout io.Writer Stderr io.Writer - before map[reflect.Value]HookFunction + before map[reflect.Value]HookFunc registry *Registry noDefaultHelp bool help func(*Context) error @@ -50,7 +50,7 @@ func New(grammar interface{}, options ...Option) (*Kong, error) { Exit: os.Exit, Stdout: os.Stdout, Stderr: os.Stderr, - before: map[reflect.Value]HookFunction{}, + before: map[reflect.Value]HookFunc{}, registry: NewRegistry().RegisterDefaults(), help: PrintHelp, } @@ -63,8 +63,8 @@ func New(grammar interface{}, options ...Option) (*Kong, error) { if err != nil { return k, err } - k.Application = model - k.Name = filepath.Base(os.Args[0]) + model.Name = filepath.Base(os.Args[0]) + k.Model = model for _, option := range options { option(k) @@ -80,14 +80,14 @@ func (k *Kong) extraFlags() []*Flag { helpValue := false value := reflect.ValueOf(&helpValue).Elem() helpFlag := &Flag{ - Value: Value{ + Value: &Value{ Name: "help", Help: "Show context-sensitive help.", - Flag: true, Value: value, Mapper: k.registry.ForValue(value), }, } + helpFlag.Flag = helpFlag hook := Hook(&helpValue, func(ctx *Context, path *Path) error { err := PrintHelp(ctx) if err != nil { @@ -158,12 +158,12 @@ func (k *Kong) applyHooks(ctx *Context) error { // Printf writes a message to Kong.Stdout with the application name prefixed. func (k *Kong) Printf(format string, args ...interface{}) { - fmt.Fprintf(k.Stdout, k.Name+": "+format, args...) + fmt.Fprintf(k.Stdout, k.Model.Name+": "+format, args...) } // Errorf writes a message to Kong.Stderr with the application name prefixed. func (k *Kong) Errorf(format string, args ...interface{}) { - fmt.Fprintf(k.Stderr, k.Name+": "+format, args...) + fmt.Fprintf(k.Stderr, k.Model.Name+": "+format, args...) } // FatalIfError terminates with an error message if err != nil. diff --git a/mapper.go b/mapper.go index 5ee10ce..1702030 100644 --- a/mapper.go +++ b/mapper.go @@ -2,6 +2,7 @@ package kong import ( "fmt" + "math/bits" "reflect" "strconv" "strings" @@ -113,16 +114,16 @@ func (d *Registry) RegisterValue(ptr interface{}, mapper Mapper) *Registry { } func (d *Registry) RegisterDefaults() *Registry { - return d.RegisterKind(reflect.Int, MapperFunc(intDecoder)). - RegisterKind(reflect.Int8, MapperFunc(intDecoder)). - RegisterKind(reflect.Int16, MapperFunc(intDecoder)). - RegisterKind(reflect.Int32, MapperFunc(intDecoder)). - RegisterKind(reflect.Int64, MapperFunc(intDecoder)). - RegisterKind(reflect.Uint, MapperFunc(uintDecoder)). - RegisterKind(reflect.Uint8, MapperFunc(uintDecoder)). - RegisterKind(reflect.Uint16, MapperFunc(uintDecoder)). - RegisterKind(reflect.Uint32, MapperFunc(uintDecoder)). - RegisterKind(reflect.Uint64, MapperFunc(uintDecoder)). + return d.RegisterKind(reflect.Int, intDecoder(bits.UintSize)). + RegisterKind(reflect.Int8, intDecoder(8)). + RegisterKind(reflect.Int16, intDecoder(16)). + RegisterKind(reflect.Int32, intDecoder(32)). + RegisterKind(reflect.Int64, intDecoder(64)). + RegisterKind(reflect.Uint, uintDecoder(64)). + RegisterKind(reflect.Uint8, uintDecoder(bits.UintSize)). + RegisterKind(reflect.Uint16, uintDecoder(16)). + RegisterKind(reflect.Uint32, uintDecoder(32)). + RegisterKind(reflect.Uint64, uintDecoder(64)). RegisterKind(reflect.Float32, floatDecoder(32)). RegisterKind(reflect.Float64, floatDecoder(64)). RegisterKind(reflect.String, MapperFunc(func(ctx *DecoderContext, scan *Scanner, target reflect.Value) error { @@ -130,8 +131,8 @@ func (d *Registry) RegisterDefaults() *Registry { return nil })). RegisterKind(reflect.Bool, boolMapper{}). - RegisterType(reflect.TypeOf(time.Time{}), MapperFunc(timeDecoder)). - RegisterType(reflect.TypeOf(time.Duration(0)), MapperFunc(durationDecoder)). + RegisterType(reflect.TypeOf(time.Time{}), timeDecoder()). + RegisterType(reflect.TypeOf(time.Duration(0)), durationDecoder()). RegisterKind(reflect.Slice, sliceDecoder(d)) } @@ -143,46 +144,54 @@ func (boolMapper) Decode(ctx *DecoderContext, scan *Scanner, target reflect.Valu } func (boolMapper) IsBool() bool { return true } -func durationDecoder(ctx *DecoderContext, scan *Scanner, target reflect.Value) error { - d, err := time.ParseDuration(scan.PopValue("duration")) - if err != nil { - return err +func durationDecoder() MapperFunc { + return func(ctx *DecoderContext, scan *Scanner, target reflect.Value) error { + d, err := time.ParseDuration(scan.PopValue("duration")) + if err != nil { + return err + } + target.Set(reflect.ValueOf(d)) + return nil } - target.Set(reflect.ValueOf(d)) - return nil } -func timeDecoder(ctx *DecoderContext, scan *Scanner, target reflect.Value) error { - fmt := time.RFC3339 - if ctx.Value.Format != "" { - fmt = ctx.Value.Format +func timeDecoder() MapperFunc { + return func(ctx *DecoderContext, scan *Scanner, target reflect.Value) error { + fmt := time.RFC3339 + if ctx.Value.Format != "" { + fmt = ctx.Value.Format + } + t, err := time.Parse(fmt, scan.PopValue("time")) + if err != nil { + return err + } + target.Set(reflect.ValueOf(t)) + return nil } - t, err := time.Parse(fmt, scan.PopValue("time")) - if err != nil { - return err - } - target.Set(reflect.ValueOf(t)) - return nil } -func intDecoder(ctx *DecoderContext, scan *Scanner, target reflect.Value) error { - value := scan.PopValue("int") - n, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return fmt.Errorf("invalid int %q", value) +func intDecoder(bits int) MapperFunc { + return func(ctx *DecoderContext, scan *Scanner, target reflect.Value) error { + value := scan.PopValue("int") + n, err := strconv.ParseInt(value, 10, bits) + if err != nil { + return fmt.Errorf("invalid int %q", value) + } + target.SetInt(n) + return nil } - target.SetInt(n) - return nil } -func uintDecoder(ctx *DecoderContext, scan *Scanner, target reflect.Value) error { - value := scan.PopValue("uint") - n, err := strconv.ParseUint(value, 10, 64) - if err != nil { - return fmt.Errorf("invalid uint %q", value) +func uintDecoder(bits int) MapperFunc { + return func(ctx *DecoderContext, scan *Scanner, target reflect.Value) error { + value := scan.PopValue("uint") + n, err := strconv.ParseUint(value, 10, bits) + if err != nil { + return fmt.Errorf("invalid uint %q", value) + } + target.SetUint(n) + return nil } - target.SetUint(n) - return nil } func floatDecoder(bits int) MapperFunc { @@ -200,12 +209,9 @@ func floatDecoder(bits int) MapperFunc { func sliceDecoder(d *Registry) MapperFunc { return func(ctx *DecoderContext, scan *Scanner, target reflect.Value) error { el := target.Type().Elem() - sep, ok := ctx.Value.Tag.Get("sep") - if !ok { - sep = "," - } + sep := ctx.Value.Tag.Sep var childScanner *Scanner - if ctx.Value.Flag { + if ctx.Value.Flag != nil { // If decoding a flag, we need an argument. childScanner = Scan(strings.Split(scan.PopValue("list"), sep)...) } else { diff --git a/mapper_test.go b/mapper_test.go index a9f819a..e635167 100644 --- a/mapper_test.go +++ b/mapper_test.go @@ -3,6 +3,7 @@ package kong import ( "reflect" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -40,3 +41,26 @@ func (testMooMapper) Decode(ctx *DecoderContext, scan *Scanner, target reflect.V return nil } func (testMooMapper) IsBool() bool { return true } + +func TestTimeMapper(t *testing.T) { + var cli struct { + Flag time.Time `format:"2006"` + } + k := mustNew(t, &cli) + _, err := k.Parse([]string{"--flag=2008"}) + require.NoError(t, err) + expected, err := time.Parse("2006", "2008") + require.NoError(t, err) + require.Equal(t, 2008, expected.Year()) + require.Equal(t, expected, cli.Flag) +} + +func TestDurationMapper(t *testing.T) { + var cli struct { + Flag time.Duration + } + k := mustNew(t, &cli) + _, err := k.Parse([]string{"--flag=5s"}) + require.NoError(t, err) + require.Equal(t, time.Second*5, cli.Flag) +} diff --git a/model.go b/model.go index 5f15e12..64fc799 100644 --- a/model.go +++ b/model.go @@ -37,6 +37,16 @@ type Node struct { Argument *Value // Populated when Type is ArgumentNode. } +func (n *Node) AllFlags() (out [][]*Flag) { + if n.Parent != nil { + out = append(out, n.Parent.AllFlags()...) + } + if len(n.Flags) > 0 { + out = append(out, n.Flags) + } + return +} + // Leaves returns the leaf commands/arguments under Node. func (n *Node) Leaves() (out []*Node) { var walk func(n *Node) @@ -70,21 +80,12 @@ func (n *Node) Depth() int { // Summary help string for the node. func (n *Node) Summary() string { summary := n.Path() - if n.Type == ArgumentNode { - summary = "<" + summary + ">" - } if flags := n.FlagSummary(); flags != "" { summary += " " + flags } args := []string{} for _, arg := range n.Positional { - if arg.Required { - argText := "<" + arg.Name + ">" - if arg.IsCumulative() { - argText += " ..." - } - args = append(args, argText) - } + args = append(args, arg.Summary()) } if len(args) != 0 { summary += " " + strings.Join(args, " ") @@ -96,13 +97,11 @@ func (n *Node) Summary() string { func (n *Node) FlagSummary() string { required := []string{} count := 0 - for _, flag := range n.Flags { - count++ - if flag.Required { - if flag.IsBool() { - required = append(required, fmt.Sprintf("--%s", flag.Name)) - } else { - required = append(required, fmt.Sprintf("--%s=%s", flag.Name, flag.FormatPlaceHolder())) + for _, group := range n.AllFlags() { + for _, flag := range group { + count++ + if flag.Required { + required = append(required, flag.Summary()) } } } @@ -128,7 +127,7 @@ func (n *Node) Path() (out string) { // A Value is either a flag or a variable positional argument. type Value struct { - Flag bool // True if flag, false if positional argument. + Flag *Flag Name string Help string Default string @@ -136,11 +135,28 @@ type Value struct { Tag *Tag Value reflect.Value Required bool - Set bool // Used with Required to test if a value has been given. + Set bool // Set to true when this value is set through some mechanism. Format string // Formatting directive, if applicable. Position int // Position (for positional arguments). } +func (v *Value) Summary() string { + if v.Flag != nil { + if v.IsBool() { + return fmt.Sprintf("--%s", v.Name) + } + return fmt.Sprintf("--%s=%s", v.Name, v.Flag.FormatPlaceHolder()) + } + argText := "<" + v.Name + ">" + if v.IsCumulative() { + argText += " ..." + } + if !v.Required { + argText = "[" + argText + "]" + } + return argText +} + func (v *Value) IsCumulative() bool { return v.Value.Kind() == reflect.Slice } @@ -184,7 +200,7 @@ func (v *Value) Reset() error { type Positional = Value type Flag struct { - Value + *Value PlaceHolder string Env string Short rune diff --git a/model_test.go b/model_test.go index 71f3a31..4d12c7e 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.Leaves() { + for _, cmd := range p.Model.Leaves() { actual = append(actual, cmd.Path()) } require.Equal(t, []string{"one two", "one three "}, actual) diff --git a/options.go b/options.go index 99bfe5b..7e2a871 100644 --- a/options.go +++ b/options.go @@ -26,8 +26,8 @@ func NoDefaultHelp() Option { // Name overrides the application name. func Name(name string) Option { return func(k *Kong) { - if k.Application != nil { - k.Name = name + if k.Model != nil { + k.Model.Name = name } } } @@ -55,8 +55,8 @@ func NamedMapper(name string, mapper Mapper) Option { // Description sets the application description. func Description(description string) Option { return func(k *Kong) { - if k.Application != nil { - k.Help = description + if k.Model != nil { + k.Model.Help = description } } } @@ -69,13 +69,13 @@ func Writers(stdout, stderr io.Writer) Option { } } -// HookFunction is a callback tied to a field of the grammar, called before a value is applied. -type HookFunction func(ctx *Context, path *Path) error +// HookFunc is a callback tied to a field of the grammar, called before a value is applied. +type HookFunc func(ctx *Context, path *Path) error // Hook to aply before a command, flag or positional argument is encountered. // // "ptr" is a pointer to a field of the grammar. -func Hook(ptr interface{}, hook HookFunction) Option { +func Hook(ptr interface{}, hook HookFunc) Option { key := reflect.ValueOf(ptr) if key.Kind() != reflect.Ptr { panic("expected a pointer") diff --git a/options_test.go b/options_test.go index 08fe5a0..61c7de1 100644 --- a/options_test.go +++ b/options_test.go @@ -10,8 +10,8 @@ func TestOptions(t *testing.T) { var cli struct{} p, err := New(&cli, Name("name"), Description("description"), Writers(nil, nil), ExitFunction(nil)) require.NoError(t, err) - require.Equal(t, "name", p.Name) - require.Equal(t, "description", p.Help) + require.Equal(t, "name", p.Model.Name) + require.Equal(t, "description", p.Model.Help) require.Nil(t, p.Stdout) require.Nil(t, p.Stderr) require.Nil(t, p.Exit) diff --git a/tag.go b/tag.go index 63646fd..407f924 100644 --- a/tag.go +++ b/tag.go @@ -21,6 +21,7 @@ type Tag struct { Env string Short rune Hidden bool + Sep string // Storage for all tag keys for arbitrary lookups. items map[string]string @@ -109,24 +110,32 @@ func getTagInfo(ft reflect.StructField) (string, tagChars) { func parseTag(fv reflect.Value, ft reflect.StructField) *Tag { s, chars := getTagInfo(ft) t := &Tag{ - items: map[string]string{}, + items: parseTagItems(s, chars), } - if s == "" { - return t - } - - t.items = parseTagItems(s, chars) - t.Cmd = t.Has("cmd") t.Arg = t.Has("arg") - t.Required = t.Has("required") - t.Optional = t.Has("optional") + required := t.Has("required") + optional := t.Has("optional") + if required && optional { + fail("can't specify both required and optional") + } + t.Required = required + t.Optional = optional t.Default, _ = t.Get("default") t.Help, _ = t.Get("help") t.Type, _ = t.Get("type") t.Env, _ = t.Get("env") t.Short, _ = t.GetRune("short") t.Hidden = t.Has("hidden") + t.Format, _ = t.Get("format") + t.Sep, _ = t.Get("sep") + if t.Sep == "" { + if t.Cmd || t.Arg { + t.Sep = " " + } else { + t.Sep = "," + } + } t.PlaceHolder, _ = t.Get("placeholder") if t.PlaceHolder == "" { diff --git a/tag_test.go b/tag_test.go index 8abd904..167aa71 100644 --- a/tag_test.go +++ b/tag_test.go @@ -96,7 +96,7 @@ func TestBareTagsWithJsonTag(t *testing.T) { func TestManySeps(t *testing.T) { var cli struct { - Arg string `arg optional default:"hi"` + Arg string `arg optional default:"hi"` } p := mustNew(t, &cli)