diff --git a/_examples/shell/main.go b/_examples/shell/main.go index ebefeae..451c3b8 100644 --- a/_examples/shell/main.go +++ b/_examples/shell/main.go @@ -9,8 +9,10 @@ import ( ) var CLI struct { - Help bool `kong:"help='Display help.'"` - Rm struct { + Debug bool `kong:"help='Debug mode.'"` + Output string `kong:"help='File to output to.',placeholder='FILE'"` + + Rm struct { Force bool `kong:"help='Force removal.'"` Recursive bool `kong:"help='Recursively remove files.'"` @@ -23,7 +25,7 @@ var CLI struct { } func main() { - app := kong.Must(&CLI).Hook(&CLI.Help, kong.Help(nil, nil)) + app := kong.Must(&CLI, kong.Description("A shell-like example app.")) cmd, err := app.Parse(os.Args[1:]) app.FatalIfErrorf(err) s, _ := json.Marshal(&CLI) diff --git a/build.go b/build.go index 8b84ce7..ad4a767 100644 --- a/build.go +++ b/build.go @@ -6,7 +6,7 @@ import ( "strings" ) -func build(ast interface{}) (app *Application, err error) { +func build(ast interface{}, extraFlags []*Flag) (app *Application, err error) { defer catch(&err) v := reflect.ValueOf(ast) iv := reflect.Indirect(v) @@ -15,11 +15,16 @@ func build(ast interface{}) (app *Application, err error) { } app = &Application{} - node := buildNode(iv, map[string]bool{}) + seenFlags := map[string]bool{} + for _, flag := range extraFlags { + seenFlags[flag.Name] = true + } + node := buildNode(iv, ApplicationNode, seenFlags) 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.Flags = append(extraFlags, app.Node.Flags...) return app, nil } @@ -27,8 +32,9 @@ func dashedString(s string) string { return strings.Join(camelCase(s), "-") } -func buildNode(v reflect.Value, seenFlags map[string]bool) *Node { +func buildNode(v reflect.Value, typ NodeType, seenFlags map[string]bool) *Node { node := &Node{ + Type: typ, Target: v, } for i := 0; i < v.NumField(); i++ { @@ -47,7 +53,11 @@ func buildNode(v reflect.Value, seenFlags map[string]bool) *Node { // Nested structs are either commands or args. if ft.Type.Kind() == reflect.Struct && (tag.Cmd || tag.Arg) { - buildChild(node, v, ft, fv, tag, name, seenFlags) + typ := CommandNode + if tag.Arg { + typ = ArgumentNode + } + buildChild(node, typ, v, ft, fv, tag, name, seenFlags) } else { buildField(node, v, ft, fv, tag, name, seenFlags) } @@ -60,19 +70,21 @@ func buildNode(v reflect.Value, seenFlags map[string]bool) *Node { // Scan through argument positionals to ensure optional is never before a required. last := true - for _, p := range node.Positional { + for i, p := range node.Positional { if !last && p.Required { fail("argument %q can not be required after an optional", p.Name) } last = p.Required + p.Position = i } return node } -func buildChild(node *Node, v reflect.Value, ft reflect.StructField, fv reflect.Value, tag *Tag, name string, seenFlags map[string]bool) { - child := buildNode(fv, seenFlags) +func buildChild(node *Node, typ NodeType, v reflect.Value, ft reflect.StructField, fv reflect.Value, tag *Tag, name string, seenFlags map[string]bool) { + child := buildNode(fv, typ, seenFlags) + child.Parent = node child.Help = tag.Help // A branching argument. This is a bit hairy, as we let buildNode() do the parsing, then check that @@ -95,14 +107,11 @@ func buildChild(node *Node, v reflect.Value, ft reflect.StructField, fv reflect. v.Type().Name(), ft.Name, child.Name) } - node.Children = append(node.Children, &Branch{Argument: &Argument{ - Node: *child, - Argument: value, - }}) + child.Argument = value } else { child.Name = name - node.Children = append(node.Children, &Branch{Command: child}) } + node.Children = append(node.Children, child) if len(child.Positional) > 0 && len(child.Children) > 0 { fail("can't mix positional arguments and branching arguments on %s.%s", v.Type().Name(), ft.Name) @@ -141,7 +150,7 @@ func buildField(node *Node, v reflect.Value, ft reflect.StructField, fv reflect. node.Flags = append(node.Flags, &Flag{ Value: value, Short: tag.Short, - Placeholder: tag.Placeholder, + PlaceHolder: tag.PlaceHolder, Env: tag.Env, }) } diff --git a/context.go b/context.go index 9b082d5..3ff7f4e 100644 --- a/context.go +++ b/context.go @@ -4,14 +4,17 @@ import ( "fmt" "io" "reflect" + "strconv" "strings" ) -// Trace records the nodes and parsed values from the current command-line. -type Trace struct { +// Path records the nodes and parsed values from the current command-line. +type Path struct { + Parent *Node + // One of these will be non-nil. App *Application - Positional *Value + Positional *Positional Flag *Flag Argument *Argument Command *Command @@ -24,35 +27,92 @@ type Trace struct { } type Context struct { - Trace []*Trace // A trace through parsed nodes. - Error error // Error that occurred during trace, if any. + App *Kong + Path []*Path // A trace through parsed nodes. + Error error // Error that occurred during trace, if any. Stdout io.Writer Stderr io.Writer - node *Node // Current node being parsed. - args []string - app *Application scan *Scanner } +// Trace path of "args" through the gammar tree. +// +// The returned Context will include a Path of all commands, arguments, positionals and flags. +func Trace(k *Kong, args []string) (*Context, error) { + c := &Context{ + App: k, + args: args, + Path: []*Path{ + {App: k.Application, Flags: k.Flags, Value: k.Target}, + }, + } + err := c.reset(&c.App.Node) + if err != nil { + return nil, err + } + c.Error = c.trace(&c.App.Node) + return c, nil +} + +func (c *Context) Validate() error { + for _, path := range c.Path { + if err := checkMissingFlags(path.Flags); err != nil { + return err + } + } + // Check the terminal node. + path := c.Path[len(c.Path)-1] + switch { + case path.App != nil: + if err := checkMissingChildren(&path.App.Node); err != nil { + return err + } + if err := checkMissingPositionals(0, path.App.Positional); err != nil { + return err + } + + case path.Command != nil: + if err := checkMissingChildren(path.Command); err != nil { + return err + } + if err := checkMissingPositionals(0, path.Parent.Positional); err != nil { + return err + } + + case path.Argument != nil: + if err := checkMissingChildren(path.Argument); err != nil { + return err + } + + case path.Positional != nil: + if err := checkMissingPositionals(path.Positional.Position+1, path.Parent.Positional); err != nil { + return err + } + } + return nil +} + // Flags returns the accumulated available flags. -func (p *Context) Flags() (flags []*Flag) { - for _, trace := range p.Trace { +func (c *Context) Flags() (flags []*Flag) { + for _, trace := range c.Path { flags = append(flags, trace.Flags...) } return } // Command returns the full command path. -func (p *Context) Command() (command []string) { - for _, trace := range p.Trace { +func (c *Context) Command() (command []string) { + for _, trace := range c.Path { switch { case trace.Positional != nil: command = append(command, "<"+trace.Positional.Name+">") + case trace.Argument != nil: command = append(command, "<"+trace.Argument.Name+">") + case trace.Command != nil: command = append(command, trace.Command.Name) } @@ -61,8 +121,8 @@ func (p *Context) Command() (command []string) { } // FlagValue returns the set value of a flag, if it was encountered and exists. -func (p *Context) FlagValue(flag *Flag) reflect.Value { - for _, trace := range p.Trace { +func (c *Context) FlagValue(flag *Flag) reflect.Value { + for _, trace := range c.Path { if trace.Flag == flag { return trace.Value } @@ -71,8 +131,8 @@ func (p *Context) FlagValue(flag *Flag) reflect.Value { } // Recursively reset values to defaults (as specified in the grammar) or the zero value. -func (p *Context) reset(node *Node) error { - p.scan = Scan(p.args...) +func (c *Context) reset(node *Node) error { + c.scan = Scan(c.args...) for _, flag := range node.Flags { err := flag.Value.Reset() if err != nil { @@ -87,41 +147,36 @@ func (p *Context) reset(node *Node) error { } for _, branch := range node.Children { if branch.Argument != nil { - arg := branch.Argument.Argument + arg := branch.Argument err := arg.Reset() if err != nil { return err } - err = p.reset(&branch.Argument.Node) - if err != nil { - return err - } - } else { - err := p.reset(branch.Command) - if err != nil { - return err - } + } + err := c.reset(branch) + if err != nil { + return err } } return nil } -func (p *Context) trace(node *Node) (err error) { // nolint: gocyclo +func (c *Context) trace(node *Node) (err error) { // nolint: gocyclo positional := 0 - p.node = node - flags := append(p.Flags(), node.Flags...) - for !p.scan.Peek().IsEOL() { - token := p.scan.Peek() + flags := append(c.Flags(), node.Flags...) + + for !c.scan.Peek().IsEOL() { + token := c.scan.Peek() switch token.Type { case UntypedToken: switch { - // -- indicates end of parsing. All remaining arguments are treated as positional arguments only. + // Indicates end of parsing. All remaining arguments are treated as positional arguments only. case token.Value == "--": - p.scan.Pop() + c.scan.Pop() args := []string{} for { - token = p.scan.Pop() + token = c.scan.Pop() if token.Type == EOLToken { break } @@ -129,46 +184,46 @@ func (p *Context) trace(node *Node) (err error) { // nolint: gocyclo } // Note: tokens must be pushed in reverse order. for i := range args { - p.scan.PushTyped(args[len(args)-1-i], PositionalArgumentToken) + c.scan.PushTyped(args[len(args)-1-i], PositionalArgumentToken) } // Long flag. case strings.HasPrefix(token.Value, "--"): - p.scan.Pop() + c.scan.Pop() // Parse it and push the tokens. parts := strings.SplitN(token.Value[2:], "=", 2) if len(parts) > 1 { - p.scan.PushTyped(parts[1], FlagValueToken) + c.scan.PushTyped(parts[1], FlagValueToken) } - p.scan.PushTyped(parts[0], FlagToken) + c.scan.PushTyped(parts[0], FlagToken) // Short flag. case strings.HasPrefix(token.Value, "-"): - p.scan.Pop() + c.scan.Pop() // Note: tokens must be pushed in reverse order. - p.scan.PushTyped(token.Value[2:], ShortFlagTailToken) - p.scan.PushTyped(token.Value[1:2], ShortFlagToken) + c.scan.PushTyped(token.Value[2:], ShortFlagTailToken) + c.scan.PushTyped(token.Value[1:2], ShortFlagToken) default: - p.scan.Pop() - p.scan.PushTyped(token.Value, PositionalArgumentToken) + c.scan.Pop() + c.scan.PushTyped(token.Value, PositionalArgumentToken) } case ShortFlagTailToken: - p.scan.Pop() + c.scan.Pop() // Note: tokens must be pushed in reverse order. - p.scan.PushTyped(token.Value[1:], ShortFlagTailToken) - p.scan.PushTyped(token.Value[0:1], ShortFlagToken) + c.scan.PushTyped(token.Value[1:], ShortFlagTailToken) + c.scan.PushTyped(token.Value[0:1], ShortFlagToken) case FlagToken: - if err := p.matchFlags(flags, func(f *Flag) bool { + if err := c.matchFlags(flags, func(f *Flag) bool { return f.Name == token.Value }); err != nil { return err } case ShortFlagToken: - if err := p.matchFlags(flags, func(f *Flag) bool { + if err := c.matchFlags(flags, func(f *Flag) bool { return string(f.Name) == token.Value }); err != nil { return err @@ -181,38 +236,45 @@ func (p *Context) trace(node *Node) (err error) { // nolint: gocyclo // Ensure we've consumed all positional arguments. if positional < len(node.Positional) { arg := node.Positional[positional] - value, err := arg.Parse(p.scan) + value, err := arg.Parse(c.scan) if err != nil { return err } - p.Trace = append(p.Trace, &Trace{Positional: arg, Value: value, Flags: node.Flags}) + c.Path = append(c.Path, &Path{ + Parent: node, + Positional: arg, + Value: value, + Flags: node.Flags, + }) positional++ break } // After positional arguments have been consumed, handle commands and branching arguments. for _, branch := range node.Children { - switch { - case branch.Command != nil: - if branch.Command.Name == token.Value { - p.scan.Pop() - p.Trace = append(p.Trace, &Trace{ - Command: branch.Command, + switch branch.Type { + case CommandNode: + if branch.Name == token.Value { + c.scan.Pop() + c.Path = append(c.Path, &Path{ + Parent: node, + Command: branch, + Value: branch.Target, Flags: node.Flags, - Value: branch.Command.Target, }) - return p.trace(branch.Command) + return c.trace(branch) } - case branch.Argument != nil: - arg := branch.Argument.Argument - if value, err := arg.Parse(p.scan); err == nil { - p.Trace = append(p.Trace, &Trace{ - Argument: branch.Argument, + case ArgumentNode: + arg := branch.Argument + if value, err := arg.Parse(c.scan); err == nil { + c.Path = append(c.Path, &Path{ + Parent: node, + Argument: branch, Value: value, Flags: node.Flags, }) - return p.trace(&branch.Argument.Node) + return c.trace(branch) } } } @@ -222,22 +284,13 @@ func (p *Context) trace(node *Node) (err error) { // nolint: gocyclo return fmt.Errorf("unexpected token %s", token) } } - - if err := checkMissingPositionals(positional, node.Positional); err != nil { - return err - } - - if err := checkMissingChildren(node.Children); err != nil { - return err - } - return nil } // Apply traced context to the target grammar. -func (p *Context) Apply() (string, error) { +func (c *Context) Apply() (string, error) { path := []string{} - for _, trace := range p.Trace { + for _, trace := range c.Path { switch { case trace.Argument != nil: path = append(path, "<"+trace.Argument.Name+">") @@ -254,6 +307,24 @@ func (p *Context) Apply() (string, error) { return strings.Join(path, " "), nil } +func (c *Context) matchFlags(flags []*Flag, matcher func(f *Flag) bool) (err error) { + defer catch(&err) + token := c.scan.Peek() + for _, flag := range flags { + // Found a matching flag. + if flag.Name == token.Value { + c.scan.Pop() + value, err := flag.Parse(c.scan) + if err != nil { + return err + } + c.Path = append(c.Path, &Path{Flag: flag, Value: value}) + return nil + } + } + return fmt.Errorf("unknown flag --%s", token.Value) +} + func checkMissingFlags(flags []*Flag) error { missing := []string{} for _, flag := range flags { @@ -269,23 +340,26 @@ func checkMissingFlags(flags []*Flag) error { return fmt.Errorf("missing flags: %s", strings.Join(missing, ", ")) } -func checkMissingChildren(children []*Branch) error { +func checkMissingChildren(node *Node) error { missing := []string{} - for _, child := range children { + for _, child := range node.Children { if child.Argument != nil { - if !child.Argument.Argument.Required { + if !child.Argument.Required { continue } - missing = append(missing, "<"+child.Argument.Name+">") + missing = append(missing, strconv.Quote("<"+child.Argument.Name+">")) } else { - missing = append(missing, child.Command.Name) + missing = append(missing, strconv.Quote(child.Name)) } } if len(missing) == 0 { return nil } - return fmt.Errorf("expected one of %s", strings.Join(missing, ", ")) + if len(missing) == 1 { + return fmt.Errorf("%q should be followed by %s", node.Path(), missing[0]) + } + return fmt.Errorf("%q should be followed by one of %s", node.Path(), strings.Join(missing, ", ")) } // If we're missing any positionals and they're required, return an error. @@ -306,21 +380,3 @@ func checkMissingPositionals(positional int, values []*Value) error { } return fmt.Errorf("missing positional arguments %s", strings.Join(missing, " ")) } - -func (p *Context) matchFlags(flags []*Flag, matcher func(f *Flag) bool) (err error) { - defer catch(&err) - token := p.scan.Peek() - for _, flag := range flags { - // Found a matching flag. - if flag.Name == token.Value { - p.scan.Pop() - value, err := flag.Parse(p.scan) - if err != nil { - return err - } - p.Trace = append(p.Trace, &Trace{Flag: flag, Value: value}) - return nil - } - } - return fmt.Errorf("unknown flag --%s", token.Value) -} diff --git a/guesswidth.go b/guesswidth.go new file mode 100644 index 0000000..46768e6 --- /dev/null +++ b/guesswidth.go @@ -0,0 +1,9 @@ +// +build appengine !linux,!freebsd,!darwin,!dragonfly,!netbsd,!openbsd + +package kong + +import "io" + +func guessWidth(w io.Writer) int { + return 80 +} diff --git a/guesswidth_unix.go b/guesswidth_unix.go new file mode 100644 index 0000000..fbf7e22 --- /dev/null +++ b/guesswidth_unix.go @@ -0,0 +1,38 @@ +// +build !appengine,linux freebsd darwin dragonfly netbsd openbsd + +package kong + +import ( + "io" + "os" + "strconv" + "syscall" + "unsafe" +) + +func guessWidth(w io.Writer) int { + // check if COLUMNS env is set to comply with + // http://pubs.opengroup.org/onlinepubs/009604499/basedefs/xbd_chap08.html + colsStr := os.Getenv("COLUMNS") + if colsStr != "" { + if cols, err := strconv.Atoi(colsStr); err == nil { + return cols + } + } + + if t, ok := w.(*os.File); ok { + fd := t.Fd() + var dimensions [4]uint16 + + if _, _, err := syscall.Syscall6( + syscall.SYS_IOCTL, + uintptr(fd), + uintptr(syscall.TIOCGWINSZ), + uintptr(unsafe.Pointer(&dimensions)), + 0, 0, 0, + ); err == 0 { + return int(dimensions[1]) + } + } + return 80 +} diff --git a/help.go b/help.go index 809481e..940c4f9 100644 --- a/help.go +++ b/help.go @@ -1,36 +1,159 @@ package kong import ( - "text/template" + "bytes" + "fmt" + "go/doc" + "io" + "reflect" + "strings" + + "github.com/aymerick/raymond" ) -const defaultHelp = `{{- with .Application -}} -usage: {{.Name}} +const ( + defaultIndent = 2 + defaultTemplate = ` +{{#with App}} +usage: {{Name}} -{{.Help}} -{{range .Context.Flags}} ---{{.Name}} -{{end}} +{{#wrap}} +{{Help}} +{{/wrap}} -{{- end -}} +Flags: +{{#indent}} +{{formatFlags Flags}} +{{/indent}} + +{{#if Children}} +{{#indent}} +{{#each Children}} +{{Name}} +{{/each}} +{{/indent}} +{{/if}} +{{/with}} ` +) -var defaultHelpTemplate = template.Must(template.New("help").Parse(defaultHelp)) +var defaultHelpTemplate = raymond.MustParse(strings.TrimSpace(defaultTemplate)) + +func init() { + defaultHelpTemplate.RegisterHelpers(map[string]interface{}{ + "indent": func(options *raymond.Options) string { + indent, ok := options.HashProp("depth").(int) + if !ok { + indent = 2 + } + width := options.Data("width").(int) + frame := options.NewDataFrame() + frame.Set("width", width-indent) + indentStr := strings.Repeat(" ", indent) + lines := strings.Split(options.FnData(frame), "\n") + for i, line := range lines { + lines[i] = indentStr + line + } + return strings.Join(lines, "\n") + }, + "formatFlags": func(flags []*Flag, options *raymond.Options) string { + rows := [][2]string{} + haveShort := false + for _, flag := range flags { + if flag.Short != 0 { + haveShort = true + break + } + } + for _, flag := range flags { + if !flag.Hidden { + rows = append(rows, [2]string{formatFlag(haveShort, flag), flag.Help}) + } + } + w := bytes.NewBuffer(nil) + formatTwoColumns(w, 0, 2, options.Data("width").(int), rows) + return w.String() + }, + "wrap": func(options *raymond.Options) string { + w := bytes.NewBuffer(nil) + doc.ToText(w, options.Fn(), "", " ", options.Data("width").(int)) + return w.String() + }, + }) +} // Help returns a Hook that will display help and exit. -func Help(tmpl *template.Template, tmplctx map[string]interface{}) HookFunction { - return func(app *Kong, ctx *Context, trace *Trace) error { +// +// tmpl receives a context with several top-level values, in addition to those passed through tmplctx: +// .Context which is of type *Context and .Path which is of type *Path. +func Help(tmpl *raymond.Template, tmplctx map[string]interface{}) HookFunction { + return func(ctx *Context, path *Path) error { merged := map[string]interface{}{ - "Application": app.Model, + "App": ctx.App, + "Context": ctx, + "Path": path, } for k, v := range tmplctx { merged[k] = v } - err := tmpl.Execute(app.Stdout, merged) + frame := raymond.NewDataFrame() + frame.Set("width", guessWidth(ctx.App.Stdout)) + output, err := tmpl.ExecWith(merged, frame) if err != nil { return err } - app.Exit(0) + io.WriteString(ctx.App.Stdout, output) + ctx.App.Exit(0) return nil } } + +func formatTwoColumns(w io.Writer, indent, padding, width int, rows [][2]string) { + // Find size of first column. + s := 0 + for _, row := range rows { + if c := len(row[0]); c > s && c < 30 { + s = c + } + } + + indentStr := strings.Repeat(" ", indent) + offsetStr := strings.Repeat(" ", s+padding) + + for _, row := range rows { + buf := bytes.NewBuffer(nil) + doc.ToText(buf, row[1], "", strings.Repeat(" ", defaultIndent), width-s-padding-indent) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + fmt.Fprintf(w, "%s%-*s%*s", indentStr, s, row[0], padding, "") + if len(row[0]) >= 30 { + fmt.Fprintf(w, "\n%s%s", indentStr, offsetStr) + } + fmt.Fprintf(w, "%s\n", lines[0]) + for _, line := range lines[1:] { + fmt.Fprintf(w, "%s%s%s\n", indentStr, offsetStr, line) + } + } +} + +// haveShort will be true if there are short flags present at all in the help. Useful for column alignment. +func formatFlag(haveShort bool, flag *Flag) string { + flagString := "" + name := flag.Name + isBool := flag.IsBool() + if flag.Short != 0 { + flagString += fmt.Sprintf("-%c, --%s", flag.Short, name) + } else { + if haveShort { + flagString += fmt.Sprintf(" --%s", name) + } else { + flagString += fmt.Sprintf("--%s", name) + } + } + if !isBool { + flagString += fmt.Sprintf("=%s", flag.FormatPlaceHolder()) + } + if flag.Value.Value.Kind() == reflect.Slice { + flagString += " ..." + } + return flagString +} diff --git a/help_test.go b/help_test.go new file mode 100644 index 0000000..2008855 --- /dev/null +++ b/help_test.go @@ -0,0 +1,31 @@ +package kong + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHelp(t *testing.T) { + var cli struct { + String string `kong:"help='A string flag.'"` + Bool bool `kong:"help='A bool flag.'"` + + One struct { + } `kong:"cmd"` + } + 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 }), + ) + _, err := app.Parse([]string{"--help"}) + require.NoError(t, err) + require.True(t, exited) + fmt.Println(w.String()) +} diff --git a/kong.go b/kong.go index d166bb9..2d7283a 100644 --- a/kong.go +++ b/kong.go @@ -6,10 +6,9 @@ import ( "os" "path/filepath" "reflect" - "text/template" ) -type HookFunction func(app *Kong, ctx *Context, trace *Trace) error +type HookFunction func(ctx *Context, path *Path) error // Error reported by Kong. type Error struct{ msg string } @@ -30,53 +29,54 @@ func Must(ast interface{}, options ...Option) *Kong { // Kong is the main parser type. type Kong struct { - Model *Application + // Grammar model. + *Application + // Termination function (defaults to os.Exit) Exit func(int) Stdout io.Writer Stderr io.Writer - help *template.Template - helpContext map[string]interface{} - helpFuncs template.FuncMap hooks map[reflect.Value]HookFunction noDefaultHelp bool } -// New creates a new Kong parser into ast. -func New(ast interface{}, options ...Option) (*Kong, error) { +// New creates a new Kong parser on grammar. +// +// See the README (https://github.com/alecthomas/kong) for usage instructions. +func New(grammar interface{}, options ...Option) (*Kong, error) { k := &Kong{ - Exit: os.Exit, - Stdout: os.Stdout, - Stderr: os.Stderr, - help: defaultHelpTemplate, - helpContext: map[string]interface{}{}, - helpFuncs: template.FuncMap{}, - hooks: map[reflect.Value]HookFunction{}, + Exit: os.Exit, + Stdout: os.Stdout, + Stderr: os.Stderr, + hooks: map[reflect.Value]HookFunction{}, } - model, err := build(ast) - if err != nil { - return k, err - } - k.Model = model - k.Model.Name = filepath.Base(os.Args[0]) - for _, option := range options { option(k) } - if !k.noDefaultHelp { - k.integrateHelp() + model, err := build(grammar, k.extraFlags()) + if err != nil { + return k, err } + k.Application = model + k.Name = filepath.Base(os.Args[0]) + for _, option := range options { + option(k) + } return k, nil } -func (k *Kong) integrateHelp() { +// Provide additional builtin flags, if any. +func (k *Kong) extraFlags() []*Flag { + if k.noDefaultHelp { + return nil + } helpValue := false - help := &Flag{ + helpFlag := &Flag{ Value: Value{ Name: "help", Help: "Show context-sensitive help.", @@ -85,28 +85,14 @@ func (k *Kong) integrateHelp() { Decoder: kindDecoders[reflect.Bool], }, } - k.Model.Flags = append([]*Flag{help}, k.Model.Flags...) - Hook(&helpValue, Help(defaultHelpTemplate, nil))(k) + hook := Hook(&helpValue, Help(defaultHelpTemplate, nil)) + hook(k) + return []*Flag{helpFlag} } -// Trace parses the command-line, validating and collecting matching grammar nodes. +// Path parses the command-line, validating and collecting matching grammar nodes. func (k *Kong) Trace(args []string) (*Context, error) { - p := &Context{ - app: k.Model, - args: args, - Trace: []*Trace{ - {App: k.Model, Flags: append([]*Flag{}, k.Model.Flags...), Value: k.Model.Target}, - }, - } - err := p.reset(&p.app.Node) - if err != nil { - return nil, err - } - p.Error = p.trace(&p.app.Node) - if err = checkMissingFlags(p.Flags()); err != nil { - return nil, err - } - return p, nil + return Trace(k, args) } // Parse arguments into target. @@ -125,11 +111,14 @@ func (k *Kong) Parse(args []string) (command string, err error) { if ctx.Error != nil { return "", ctx.Error } + if err = ctx.Validate(); err != nil { + return "", err + } return ctx.Apply() } func (k *Kong) applyHooks(ctx *Context) error { - for _, trace := range ctx.Trace { + for _, trace := range ctx.Path { var key reflect.Value switch { case trace.App != nil: @@ -143,13 +132,13 @@ func (k *Kong) applyHooks(ctx *Context) error { case trace.Flag != nil: key = trace.Flag.Value.Value default: - panic("unsupported Trace") + panic("unsupported Path") } if key.IsValid() { key = key.Addr() } if hook := k.hooks[key]; hook != nil { - if err := hook(k, ctx, trace); err != nil { + if err := hook(ctx, trace); err != nil { return err } } @@ -159,12 +148,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.Model.Name+": "+format, args...) + fmt.Fprintf(k.Stdout, k.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.Model.Name+": "+format, args...) + fmt.Fprintf(k.Stderr, k.Name+": "+format, args...) } // FatalIfError terminates with an error message if err != nil. diff --git a/kong_test.go b/kong_test.go index 29acfd6..7fbdbff 100644 --- a/kong_test.go +++ b/kong_test.go @@ -9,9 +9,9 @@ import ( func mustNew(t *testing.T, cli interface{}, options ...Option) *Kong { t.Helper() - options = append(options, ExitFunction(func(int) { + options = append([]Option{ExitFunction(func(int) { t.Fatalf("unexpected exit()") - })) + })}, options...) parser, err := New(cli, options...) require.NoError(t, err) return parser @@ -353,10 +353,10 @@ func TestTraceErrorPartiallySucceeds(t *testing.T) { } `kong:"cmd"` } p := mustNew(t, &cli) - trace, err := p.Trace([]string{"one", "bad"}) + ctx, err := p.Trace([]string{"one", "bad"}) require.NoError(t, err) - require.Error(t, trace.Error) - require.Equal(t, []string{"one"}, trace.Command()) + require.Error(t, ctx.Error) + require.Equal(t, []string{"one"}, ctx.Command()) } func TestHooks(t *testing.T) { @@ -382,9 +382,9 @@ func TestHooks(t *testing.T) { {"Flag", "one --three=three", values{true, "", "three"}}, {"ArgAndFlag", "one two --three=three", values{true, "two", "three"}}, } - setOne := func(app *Kong, ctx *Context, trace *Trace) error { hooked.one = true; return nil } - setTwo := func(app *Kong, ctx *Context, trace *Trace) error { hooked.two = trace.Value.String(); return nil } - setThree := func(app *Kong, ctx *Context, trace *Trace) error { hooked.three = trace.Value.String(); return nil } + setOne := func(ctx *Context, path *Path) error { hooked.one = true; return nil } + setTwo := func(ctx *Context, path *Path) error { hooked.two = path.Value.String(); return nil } + setThree := func(ctx *Context, path *Path) error { hooked.three = path.Value.String(); return nil } p := mustNew(t, &cli, Hook(&cli.One, setOne), Hook(&cli.One.Two, setTwo), @@ -399,6 +399,3 @@ func TestHooks(t *testing.T) { }) } } - -func TestHelp(t *testing.T) { -} diff --git a/model.go b/model.go index 4e70f8d..bd02c78 100644 --- a/model.go +++ b/model.go @@ -1,27 +1,70 @@ package kong -import "reflect" +import ( + "reflect" + "strconv" + "strings" +) type Application struct { Node HelpFlag *Flag } -// A Branch is a command or positional argument that results in a branch in the command tree. -type Branch struct { - Command *Command - Argument *Argument +// Leaves returns the leaf commands/arguments in the command-line grammar. +func (a *Application) Leaves() (out []*Node) { + var walk func(n *Node) + walk = func(n *Node) { + if len(n.Children) == 0 { + out = append(out, n) + } + for _, child := range n.Children { + if child.Type == CommandNode || child.Type == ArgumentNode { + walk(child) + } + } + } + walk(&a.Node) + return } +type Argument = Node + type Command = Node +type NodeType int + +const ( + ApplicationNode NodeType = iota + CommandNode + ArgumentNode +) + type Node struct { + Type NodeType + Parent *Node Name string Help string Flags []*Flag - Positional []*Value - Children []*Branch - Target reflect.Value + Positional []*Positional + Children []*Node + Target reflect.Value // Pointer to the value in the grammar that this Node is associated with. + + Argument *Value // Populated when Type is ArgumentNode. +} + +// 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: + out += " " + n.Name + case ArgumentNode: + out += " " + "<" + n.Name + ">" + } + return strings.TrimSpace(out) } // A Value is either a flag or a variable positional argument. @@ -36,6 +79,11 @@ type Value struct { Required bool Set bool // Used with Required to test if a value has been given. Format string // Formatting directive, if applicable. + Position int // Position (for positional arguments). +} + +func (v *Value) IsBool() bool { + return v.Value.Kind() == reflect.Bool } // Parse tokens into value, parse, and validate, but do not write to the field. @@ -69,14 +117,28 @@ func (v *Value) Reset() error { type Positional = Value -type Argument struct { - Node - Argument *Value -} - type Flag struct { Value - Placeholder string + PlaceHolder string Env string Short rune + Hidden bool +} + +func (f *Flag) FormatPlaceHolder() string { + if f.PlaceHolder != "" { + return f.PlaceHolder + } + if f.Default != "" { + ellipsis := "" + if len(f.Default) > 1 { + ellipsis = "..." + } + + if f.Value.Value.Kind() == reflect.String { + return strconv.Quote(f.Default) + ellipsis + } + return f.Default + ellipsis + } + return strings.ToUpper(f.Name) } diff --git a/model_test.go b/model_test.go new file mode 100644 index 0000000..71f3a31 --- /dev/null +++ b/model_test.go @@ -0,0 +1,27 @@ +package kong + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestModelApplicationCommands(t *testing.T) { + var cli struct { + One struct { + Two struct { + } `kong:"cmd"` + Three struct { + Four struct { + Four string `kong:"arg"` + } `kong:"arg"` + } `kong:"cmd"` + } `kong:"cmd"` + } + p := mustNew(t, &cli) + actual := []string{} + for _, cmd := range p.Leaves() { + actual = append(actual, cmd.Path()) + } + require.Equal(t, []string{"one two", "one three "}, actual) +} diff --git a/options.go b/options.go index e573cea..1075c58 100644 --- a/options.go +++ b/options.go @@ -3,9 +3,12 @@ package kong import ( "io" "reflect" - "text/template" ) +// Options apply optional changes to the Kong application. +// +// Note that Options are applied twice: once just prior to the grammar is constructed and once after. In the +// former case, Kong.Application will be nil. type Option func(k *Kong) // ExitFunction overrides the function used to terminate. This is useful for testing or interactive use. @@ -22,24 +25,20 @@ func NoDefaultHelp() Option { // Name overrides the application name. func Name(name string) Option { - return func(k *Kong) { k.Model.Name = name } + return func(k *Kong) { + if k.Application != nil { + k.Name = name + } + } } // Description sets the application description. func Description(description string) Option { - return func(k *Kong) { k.Model.Help = description } -} - -// HelpTemplate overrides the default help template. -func HelpTemplate(template *template.Template) Option { - return func(k *Kong) { k.help = template } -} - -// HelpContext sets extra context in the help template. -// -// The key "Application" will always be available and is the root of the application model. -func HelpContext(context map[string]interface{}) Option { - return func(k *Kong) { k.helpContext = context } + return func(k *Kong) { + if k.Application != nil { + k.Help = description + } + } } // Writers overrides the default writers. Useful for testing or interactive use. diff --git a/options_test.go b/options_test.go index 61c7de1..08fe5a0 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.Model.Name) - require.Equal(t, "description", p.Model.Help) + require.Equal(t, "name", p.Name) + require.Equal(t, "description", p.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 5431783..ed0d938 100644 --- a/tag.go +++ b/tag.go @@ -17,9 +17,10 @@ type Tag struct { Type string Default string Format string - Placeholder string + PlaceHolder string Env string Short rune + Hidden bool // Storage for all tag keys for arbitrary lookups. items map[string]string @@ -109,10 +110,11 @@ func parseTag(fv reflect.Value, s string) *Tag { t.Type, _ = t.Get("type") t.Env, _ = t.Get("env") t.Short, _ = t.GetRune("short") + t.Hidden = t.Has("hidden") - t.Placeholder, _ = t.Get("placeholder") - if t.Placeholder == "" { - t.Placeholder = strings.ToUpper(dashedString(fv.Type().Name())) + t.PlaceHolder, _ = t.Get("placeholder") + if t.PlaceHolder == "" { + t.PlaceHolder = strings.ToUpper(dashedString(fv.Type().Name())) } return t