From a2ec050947ecfaf6012b5f67ab08964244fb2391 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Thu, 21 Jun 2018 21:50:30 +1000 Subject: [PATCH] Implement a robust `Context.Run()`. This helps when composing large applications from separate command structs. --- README.md | 82 ++++++++- _examples/docker/README.md | 3 + _examples/docker/commands.go | 339 +++++++++++++++++++++++++++++++++++ _examples/docker/main.go | 221 +++++++---------------- context.go | 135 +++++++++++--- global.go | 4 +- help_test.go | 12 +- kong.go | 43 ++--- kong_test.go | 88 ++++++--- mapper_test.go | 22 +-- model.go | 5 + model_test.go | 2 +- options.go | 2 + options_test.go | 8 +- resolver_test.go | 32 ++-- tag_test.go | 8 +- 16 files changed, 727 insertions(+), 279 deletions(-) create mode 100644 _examples/docker/README.md create mode 100644 _examples/docker/commands.go diff --git a/README.md b/README.md index cde4e55..52e2015 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ 1. [Introduction](#introduction) 1. [Help](#help) +1. [Command handling](#command-handling) + 1. [Switch on the command string](#switch-on-the-command-string) + 1. [Attach a `Run(...) error` method to each command](#attach-a-run-error-method-to-each-command) 1. [Flags](#flags) 1. [Commands and sub-commands](#commands-and-sub-commands) 1. [Branching positional arguments](#branching-positional-arguments) @@ -49,16 +52,22 @@ var CLI struct { Force bool `help:"Force removal."` Recursive bool `help:"Recursively remove files."` - Paths []string `arg help:"Paths to remove." type:"path"` + Paths []string `arg name:"path" help:"Paths to remove." type:"path"` } `cmd help:"Remove files."` Ls struct { - Paths []string `arg optional help:"Paths to list." type:"path"` + Paths []string `arg optional name:"path" help:"Paths to list." type:"path"` } `cmd help:"List paths."` } func main() { - kong.Parse(&CLI) + cmd := kong.Parse(&CLI) + switch cmd { + case "rm ": + case "ls": + default: + panic(cmd) + } } ``` @@ -78,10 +87,10 @@ eg. --debug Debug mode. Commands: - rm ... + rm ... Remove files. - ls [ ...] + ls [ ...] List paths. If a command is provided, the help will show full detail on the command including all available flags. @@ -102,6 +111,69 @@ eg. -f, --force Force removal. -r, --recursive Recursively remove files. +## Command handling + +There are two ways to handle commands in Kong. + +### Switch on the command string + +When you call `kong.Parse()` it will return a unique string representation of the command. Each command branch in the hierarchy will be a bare word and each branching argument or required positional argument will be the name surrounded by angle brackets. + +This has the advantage that it is convenient, but the downside that if you modify your CLI structure, the strings may change. This can be fragile. + +### Attach a `Run(...) error` method to each command + +A more robust approach is to break each command out into their own structs: + +1. Attach a `Run(...) error` method to all leaf commands (`Run()` signatures must match). +2. Call `kong.Kong.Parse()` to obtain a `kong.Context`. +3. Call `kong.Context.Run(params...)` to call the selected parsed command. + +eg. + +```go +type RmCmd struct { + Force bool `help:"Force removal."` + Recursive bool `help:"Recursively remove files."` + + Paths []string `arg name:"path" help:"Paths to remove." type:"path"` +} + +func (r *RmCmd) Run(debug bool) error { + fmt.Println("rm", r.Paths) + return nil +} + +type LsCmd struct { + Paths []string `arg optional name:"path" help:"Paths to list." type:"path"` +} + +func (l *LsCmd) Run(debug bool) error { + fmt.Println("ls", l.Paths) + return nil +} + +var cli struct { + Debug bool `help:"Enable debug mode."` + + Rm RmCmd `cmd help:"Remove files."` + Ls LsCmd `cmd help:"List paths."` +} + +func main() { + parser := kong.Must(&cli) + + // Parse and apply the command-line. + ctx, err := parser.Parse(os.Args[1:]) + parser.FatalIfErrorf(err) + + // Call the Run() method of the selected parsed command. + err = ctx.Run(cli.Debug) + parser.FatalIfErrorf(err) +} + +``` + ## Flags Any [mapped](#mapper---customising-how-the-command-line-is-mapped-to-go-values) field in the command structure *not* tagged with `cmd` or `arg` will be a flag. Flags are optional by default. diff --git a/_examples/docker/README.md b/_examples/docker/README.md new file mode 100644 index 0000000..1e6ec57 --- /dev/null +++ b/_examples/docker/README.md @@ -0,0 +1,3 @@ +# Large-scale composed CLI + +This directory illustrates how a large-scale CLI app could be structured. diff --git a/_examples/docker/commands.go b/_examples/docker/commands.go new file mode 100644 index 0000000..fe1d190 --- /dev/null +++ b/_examples/docker/commands.go @@ -0,0 +1,339 @@ +// nolint +package main + +import "fmt" + +type AttachCmd struct { + DetachKeys string `help:"Override the key sequence for detaching a container"` + NoStdin bool `help:"Do not attach STDIN"` + SigProxy bool `help:"Proxy all received signals to the process" default:"true"` + + Container string `arg required help:"Container ID to attach to."` +} + +func (a *AttachCmd) Run(globals *Globals) error { + fmt.Printf("Config: %s\n", globals.Config) + fmt.Printf("Attaching to: %v\n", a.Container) + fmt.Printf("SigProxy: %v\n", a.SigProxy) + return nil +} + +type BuildCmd struct { + Arg string `arg required` +} + +func (cmd *BuildCmd) Run(globals *Globals) error { + return nil +} + +type CommitCmd struct { + Arg string `arg required` +} + +func (cmd *CommitCmd) Run(globals *Globals) error { + return nil +} + +type CpCmd struct { + Arg string `arg required` +} + +func (cmd *CpCmd) Run(globals *Globals) error { + return nil +} + +type CreateCmd struct { + Arg string `arg required` +} + +func (cmd *CreateCmd) Run(globals *Globals) error { + return nil +} + +type DeployCmd struct { + Arg string `arg required` +} + +func (cmd *DeployCmd) Run(globals *Globals) error { + return nil +} + +type DiffCmd struct { + Arg string `arg required` +} + +func (cmd *DiffCmd) Run(globals *Globals) error { + return nil +} + +type EventsCmd struct { + Arg string `arg required` +} + +func (cmd *EventsCmd) Run(globals *Globals) error { + return nil +} + +type ExecCmd struct { + Arg string `arg required` +} + +func (cmd *ExecCmd) Run(globals *Globals) error { + return nil +} + +type ExportCmd struct { + Arg string `arg required` +} + +func (cmd *ExportCmd) Run(globals *Globals) error { + return nil +} + +type HistoryCmd struct { + Arg string `arg required` +} + +func (cmd *HistoryCmd) Run(globals *Globals) error { + return nil +} + +type ImagesCmd struct { + Arg string `arg required` +} + +func (cmd *ImagesCmd) Run(globals *Globals) error { + return nil +} + +type ImportCmd struct { + Arg string `arg required` +} + +func (cmd *ImportCmd) Run(globals *Globals) error { + return nil +} + +type InfoCmd struct { + Arg string `arg required` +} + +func (cmd *InfoCmd) Run(globals *Globals) error { + return nil +} + +type InspectCmd struct { + Arg string `arg required` +} + +func (cmd *InspectCmd) Run(globals *Globals) error { + return nil +} + +type KillCmd struct { + Arg string `arg required` +} + +func (cmd *KillCmd) Run(globals *Globals) error { + return nil +} + +type LoadCmd struct { + Arg string `arg required` +} + +func (cmd *LoadCmd) Run(globals *Globals) error { + return nil +} + +type LoginCmd struct { + Arg string `arg required` +} + +func (cmd *LoginCmd) Run(globals *Globals) error { + return nil +} + +type LogoutCmd struct { + Arg string `arg required` +} + +func (cmd *LogoutCmd) Run(globals *Globals) error { + return nil +} + +type LogsCmd struct { + Arg string `arg required` +} + +func (cmd *LogsCmd) Run(globals *Globals) error { + return nil +} + +type PauseCmd struct { + Arg string `arg required` +} + +func (cmd *PauseCmd) Run(globals *Globals) error { + return nil +} + +type PortCmd struct { + Arg string `arg required` +} + +func (cmd *PortCmd) Run(globals *Globals) error { + return nil +} + +type PsCmd struct { + Arg string `arg required` +} + +func (cmd *PsCmd) Run(globals *Globals) error { + return nil +} + +type PullCmd struct { + Arg string `arg required` +} + +func (cmd *PullCmd) Run(globals *Globals) error { + return nil +} + +type PushCmd struct { + Arg string `arg required` +} + +func (cmd *PushCmd) Run(globals *Globals) error { + return nil +} + +type RenameCmd struct { + Arg string `arg required` +} + +func (cmd *RenameCmd) Run(globals *Globals) error { + return nil +} + +type RestartCmd struct { + Arg string `arg required` +} + +func (cmd *RestartCmd) Run(globals *Globals) error { + return nil +} + +type RmCmd struct { + Arg string `arg required` +} + +func (cmd *RmCmd) Run(globals *Globals) error { + return nil +} + +type RmiCmd struct { + Arg string `arg required` +} + +func (cmd *RmiCmd) Run(globals *Globals) error { + return nil +} + +type RunCmd struct { + Arg string `arg required` +} + +func (cmd *RunCmd) Run(globals *Globals) error { + return nil +} + +type SaveCmd struct { + Arg string `arg required` +} + +func (cmd *SaveCmd) Run(globals *Globals) error { + return nil +} + +type SearchCmd struct { + Arg string `arg required` +} + +func (cmd *SearchCmd) Run(globals *Globals) error { + return nil +} + +type StartCmd struct { + Arg string `arg required` +} + +func (cmd *StartCmd) Run(globals *Globals) error { + return nil +} + +type StatsCmd struct { + Arg string `arg required` +} + +func (cmd *StatsCmd) Run(globals *Globals) error { + return nil +} + +type StopCmd struct { + Arg string `arg required` +} + +func (cmd *StopCmd) Run(globals *Globals) error { + return nil +} + +type TagCmd struct { + Arg string `arg required` +} + +func (cmd *TagCmd) Run(globals *Globals) error { + return nil +} + +type TopCmd struct { + Arg string `arg required` +} + +func (cmd *TopCmd) Run(globals *Globals) error { + return nil +} + +type UnpauseCmd struct { + Arg string `arg required` +} + +func (cmd *UnpauseCmd) Run(globals *Globals) error { + return nil +} + +type UpdateCmd struct { + Arg string `arg required` +} + +func (cmd *UpdateCmd) Run(globals *Globals) error { + return nil +} + +type VersionCmd struct { + Arg string `arg required` +} + +func (cmd *VersionCmd) Run(globals *Globals) error { + return nil +} + +type WaitCmd struct { + Arg string `arg required` +} + +func (cmd *WaitCmd) Run(globals *Globals) error { + return nil +} diff --git a/_examples/docker/main.go b/_examples/docker/main.go index c961e03..98269e5 100644 --- a/_examples/docker/main.go +++ b/_examples/docker/main.go @@ -2,177 +2,84 @@ package main import ( - "fmt" + "os" "github.com/alecthomas/kong" ) -type AttachCmd struct { - DetachKeys string `help:"Override the key sequence for detaching a container"` - NoStdin bool `help:"Do not attach STDIN"` - SigProxy bool `help:"Proxy all received signals to the process" default:"true"` - - Container string `arg required help:"Container ID to attach to."` +type Globals struct { + Config string `help:"Location of client config files" default:"~/.docker" type:"path"` + Debug bool `short:"D" help:"Enable debug mode"` + Host []string `short:"H" help:"Daemon socket(s) to connect to"` + LogLevel string `short:"l" help:"Set the logging level (debug|info|warn|error|fatal)" default:"info"` + TLS bool `help:"Use TLS; implied by --tls-verify"` + TLSCACert string `name:"tls-ca-cert" help:"Trust certs signed only by this CA" default:"~/.docker/ca.pem" type:"path"` + TLSCert string `help:"Path to TLS certificate file" default:"~/.docker/cert.pem" type:"path"` + TLSKey string `help:"Path to TLS key file" default:"~/.docker/key.pem" type:"path"` + TLSVerify bool `help:"Use TLS and verify the remote"` } -func (a *AttachCmd) Run() error { - fmt.Printf("Attaching to: %v\n", a.Container) - fmt.Printf("SigProxy: %v\n", a.SigProxy) - return nil -} +type CLI struct { + Globals + VersionFlag bool `name:"version" help:"Print version information and quit"` -var cli struct { - Config string `help:"Location of client config files" default:"~/.docker" type:"path"` - Debug bool `short:"D" help:"Enable debug mode"` - Host []string `short:"H" help:"Daemon socket(s) to connect to"` - LogLevel string `short:"l" help:"Set the logging level (debug|info|warn|error|fatal)" default:"info"` - TLS bool `help:"Use TLS; implied by --tlsverify"` - TLSCACert string `name:"tls-ca-cert" help:"Trust certs signed only by this CA" default:"~/.docker/ca.pem" type:"path"` - TLSCert string `name:"tls-cert" help:"Path to TLS certificate file" default:"~/.docker/cert.pem" type:"path"` - TLSKey string `help:"Path to TLS key file" default:"~/.docker/key.pem" type:"path"` - TLSVerify bool `help:"Use TLS and verify the remote"` - PrintVersion bool `name:"version" help:"Print version information and quit"` - - Attach AttachCmd `cmd help:"Attach local standard input, output, and error streams to a running container"` - - Build struct { - Arg string `arg required` - } `cmd help:"Build an image from a Dockerfile"` - Commit struct { - Arg string `arg required` - } `cmd help:"Create a new image from a container's changes"` - Cp struct { - Arg string `arg required` - } `cmd help:"Copy files/folders between a container and the local filesystem"` - Create struct { - Arg string `arg required` - } `cmd help:"Create a new container"` - Deploy struct { - Arg string `arg required` - } `cmd help:"Deploy a new stack or update an existing stack"` - Diff struct { - Arg string `arg required` - } `cmd help:"Inspect changes to files or directories on a container's filesystem"` - Events struct { - Arg string `arg required` - } `cmd help:"Get real time events from the server"` - Exec struct { - Arg string `arg required` - } `cmd help:"Run a command in a running container"` - Export struct { - Arg string `arg required` - } `cmd help:"Export a container's filesystem as a tar archive"` - History struct { - Arg string `arg required` - } `cmd help:"Show the history of an image"` - Images struct { - Arg string `arg required` - } `cmd help:"List images"` - Import struct { - Arg string `arg required` - } `cmd help:"Import the contents from a tarball to create a filesystem image"` - Info struct { - Arg string `arg required` - } `cmd help:"Display system-wide information"` - Inspect struct { - Arg string `arg required` - } `cmd help:"Return low-level information on Docker objects"` - Kill struct { - Arg string `arg required` - } `cmd help:"Kill one or more running containers"` - Load struct { - Arg string `arg required` - } `cmd help:"Load an image from a tar archive or STDIN"` - Login struct { - Arg string `arg required` - } `cmd help:"Log in to a Docker registry"` - Logout struct { - Arg string `arg required` - } `cmd help:"Log out from a Docker registry"` - Logs struct { - Arg string `arg required` - } `cmd help:"Fetch the logs of a container"` - Pause struct { - Arg string `arg required` - } `cmd help:"Pause all processes within one or more containers"` - Port struct { - Arg string `arg required` - } `cmd help:"List port mappings or a specific mapping for the container"` - Ps struct { - Arg string `arg required` - } `cmd help:"List containers"` - Pull struct { - Arg string `arg required` - } `cmd help:"Pull an image or a repository from a registry"` - Push struct { - Arg string `arg required` - } `cmd help:"Push an image or a repository to a registry"` - Rename struct { - Arg string `arg required` - } `cmd help:"Rename a container"` - Restart struct { - Arg string `arg required` - } `cmd help:"Restart one or more containers"` - Rm struct { - Arg string `arg required` - } `cmd help:"Remove one or more containers"` - Rmi struct { - Arg string `arg required` - } `cmd help:"Remove one or more images"` - Run struct { - Arg string `arg required` - } `cmd help:"Run a command in a new container"` - Save struct { - Arg string `arg required` - } `cmd help:"Save one or more images to a tar archive (streamed to STDOUT by default)"` - Search struct { - Arg string `arg required` - } `cmd help:"Search the Docker Hub for images"` - Start struct { - Arg string `arg required` - } `cmd help:"Start one or more stopped containers"` - Stats struct { - Arg string `arg required` - } `cmd help:"Display a live stream of container(s) resource usage statistics"` - Stop struct { - Arg string `arg required` - } `cmd help:"Stop one or more running containers"` - Tag struct { - Arg string `arg required` - } `cmd help:"Create a tag TARGET_IMAGE that refers to SOURCE_IMAGE"` - Top struct { - Arg string `arg required` - } `cmd help:"Display the running processes of a container"` - Unpause struct { - Arg string `arg required` - } `cmd help:"Unpause all processes within one or more containers"` - Update struct { - Arg string `arg required` - } `cmd help:"Update configuration of one or more containers"` - Version struct { - Arg string `arg required` - } `cmd help:"Show the Docker version information"` - Wait struct { - Arg string `arg required` - } `cmd help:"Block until one or more containers stop, then print their exit codes"` + Attach AttachCmd `cmd help:"Attach local standard input, output, and error streams to a running container"` + Build BuildCmd `cmd help:"Build an image from a Dockerfile"` + Commit CommitCmd `cmd help:"Create a new image from a container's changes"` + Cp CpCmd `cmd help:"Copy files/folders between a container and the local filesystem"` + Create CreateCmd `cmd help:"Create a new container"` + Deploy DeployCmd `cmd help:"Deploy a new stack or update an existing stack"` + Diff DiffCmd `cmd help:"Inspect changes to files or directories on a container's filesystem"` + Events EventsCmd `cmd help:"Get real time events from the server"` + Exec ExecCmd `cmd help:"Run a command in a running container"` + Export ExportCmd `cmd help:"Export a container's filesystem as a tar archive"` + History HistoryCmd `cmd help:"Show the history of an image"` + Images ImagesCmd `cmd help:"List images"` + Import ImportCmd `cmd help:"Import the contents from a tarball to create a filesystem image"` + Info InfoCmd `cmd help:"Display system-wide information"` + Inspect InspectCmd `cmd help:"Return low-level information on Docker objects"` + Kill KillCmd `cmd help:"Kill one or more running containers"` + Load LoadCmd `cmd help:"Load an image from a tar archive or STDIN"` + Login LoginCmd `cmd help:"Log in to a Docker registry"` + Logout LogoutCmd `cmd help:"Log out from a Docker registry"` + Logs LogsCmd `cmd help:"Fetch the logs of a container"` + Pause PauseCmd `cmd help:"Pause all processes within one or more containers"` + Port PortCmd `cmd help:"List port mappings or a specific mapping for the container"` + Ps PsCmd `cmd help:"List containers"` + Pull PullCmd `cmd help:"Pull an image or a repository from a registry"` + Push PushCmd `cmd help:"Push an image or a repository to a registry"` + Rename RenameCmd `cmd help:"Rename a container"` + Restart RestartCmd `cmd help:"Restart one or more containers"` + Rm RmCmd `cmd help:"Remove one or more containers"` + Rmi RmiCmd `cmd help:"Remove one or more images"` + Run RunCmd `cmd help:"Run a command in a new container"` + Save SaveCmd `cmd help:"Save one or more images to a tar archive (streamed to STDOUT by default)"` + Search SearchCmd `cmd help:"Search the Docker Hub for images"` + Start StartCmd `cmd help:"Start one or more stopped containers"` + Stats StatsCmd `cmd help:"Display a live stream of container(s) resource usage statistics"` + Stop StopCmd `cmd help:"Stop one or more running containers"` + Tag TagCmd `cmd help:"Create a tag TARGET_IMAGE that refers to SOURCE_IMAGE"` + Top TopCmd `cmd help:"Display the running processes of a container"` + Unpause UnpauseCmd `cmd help:"Unpause all processes within one or more containers"` + Update UpdateCmd `cmd help:"Update configuration of one or more containers"` + Version VersionCmd `cmd help:"Show the Docker version information"` + Wait WaitCmd `cmd help:"Block until one or more containers stop, then print their exit codes"` } func main() { - cmd := kong.Parse(&cli, + cli := CLI{} + parser := kong.Must(&cli, kong.Name("docker"), kong.Description("A self-sufficient runtime for containers"), kong.UsageOnError(), kong.HelpOptions(kong.HelpPrinterOptions{ Compact: true, + }), + // + kong.Hook(&cli.VersionFlag, func(ctx *kong.Context, path *kong.Path) error { + ctx.App.Printf("1.0.0").Exit(0) + return nil })) - var err error - switch cmd { - case "attach ": - fmt.Println(cli.Config) - err = cli.Attach.Run() - - default: - panic("unsupported command " + cmd) - } - kong.FatalIfErrorf(err) + err := parser.Run(os.Args[1:], "ofo") + parser.FatalIfErrorf(err) } diff --git a/context.go b/context.go index aa77f36..1109809 100644 --- a/context.go +++ b/context.go @@ -42,15 +42,37 @@ func (p *Path) Node() *Node { // Context contains the current parse context. type Context struct { - App *Kong - Path []*Path // A trace through parsed nodes. - Args []string // Original command-line arguments. - Error error // Error that occurred during trace, if any. + App *Kong + // A trace through parsed nodes. + Path []*Path + // Original command-line arguments. + Args []string + // Error that occurred during trace, if any. + Error error values map[*Value]reflect.Value // Temporary values during tracing. scan *Scanner } +// Trace path of "args" through the gammar tree. +// +// The returned Context will include a Path of all commands, arguments, positionals and flags. +// +// 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, + Args: args, + Path: []*Path{ + {App: k.Model, Flags: k.Model.Flags}, + }, + values: map[*Value]reflect.Value{}, + scan: Scan(args...), + } + c.Error = c.trace(&c.App.Model.Node) + return c, c.traceResolvers() +} + // Value returns the value for a particular path element. func (c *Context) Value(path *Path) reflect.Value { switch { @@ -78,25 +100,6 @@ func (c *Context) Selected() *Node { return selected } -// Trace path of "args" through the gammar tree. -// -// The returned Context will include a Path of all commands, arguments, positionals and flags. -// -// 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, - Args: args, - Path: []*Path{ - {App: k.Model, Flags: k.Model.Flags}, - }, - values: map[*Value]reflect.Value{}, - scan: Scan(args...), - } - c.Error = c.trace(&c.App.Model.Node) - return c, c.traceResolvers() -} - // Empty returns true if there were no arguments provided. func (c *Context) Empty() bool { for _, path := range c.Path { @@ -153,7 +156,8 @@ func (c *Context) Flags() (flags []*Flag) { } // Command returns the full command path. -func (c *Context) Command() (command []string) { +func (c *Context) Command() string { + command := []string{} for _, trace := range c.Path { switch { case trace.Positional != nil: @@ -166,7 +170,7 @@ func (c *Context) Command() (command []string) { command = append(command, trace.Command.Name) } } - return + return strings.Join(command, " ") } // FlagValue returns the set value of a flag, if it was encountered and exists. @@ -432,6 +436,87 @@ func (c *Context) parseFlag(flags []*Flag, match string) (err error) { return fmt.Errorf("unknown flag %s", match) } +// Run executes the corresponding Run(params...) method on the target command selected by the parsed args. +// +// 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) + if err != nil { + return err + } + if expectedRunSignature.NumIn() != len(params) { + return fmt.Errorf("expected %d params but received %d; does not match target Run() signature of %s", + expectedRunSignature.NumIn(), len(params), expectedRunSignature) + } + for i, param := range params { + if reflect.TypeOf(param) != expectedRunSignature.In(i) { + return fmt.Errorf("param %d is of type %s but should be of type %s to match target Run() signature of %s", + i, reflect.TypeOf(param), expectedRunSignature.In(i), expectedRunSignature) + } + } + node := c.Selected() + if node == nil { + return fmt.Errorf("no command selected") + } + method, err := getRunMethod(node.Target) + if err != nil { + return err + } + _, err = c.Apply() + if err != nil { + return err + } + reflectedParams := []reflect.Value{} + for _, param := range params { + reflectedParams = append(reflectedParams, reflect.ValueOf(param)) + } + result := method.Call(reflectedParams) + if result[0].IsNil() { + return nil + } + return result[0].Interface().(error) +} + +// 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() { + method, err := getRunMethod(node.Target) + if err != nil { + return nil, err + } + if signature == nil { + signature = method.Type() + } else if signature != method.Type() { + return nil, fmt.Errorf("Run() methods are not consistent on %s, expected %s but got %s", node.Target.Type(), signature, method.Type()) + } + if signature.NumOut() != 1 || signature.Out(0) != expectedRunReturnSignature { + return nil, fmt.Errorf("Run() method on %s should return (error)", node.Target.Type()) + } + } + for _, child := range node.Children { + if childSignature, err := c.validateRun(child, signature); err != nil { + return nil, err + } else if signature == nil { + signature = childSignature + } + } + return signature, nil +} + +func getRunMethod(value reflect.Value) (reflect.Value, error) { + method := value.MethodByName("Run") + if !method.IsValid() { + if value.CanAddr() { + method = value.Addr().MethodByName("Run") + } + if !method.IsValid() { + return method, fmt.Errorf("no Run() method on %s", value.Type()) + } + } + return method, nil +} + func checkMissingFlags(flags []*Flag) error { missing := []string{} for _, flag := range flags { diff --git a/global.go b/global.go index d90d130..2c4a960 100644 --- a/global.go +++ b/global.go @@ -14,9 +14,9 @@ func Parse(cli interface{}, options ...Option) string { panic(err) } App = parser - cmd, err := parser.Parse(os.Args[1:]) + ctx, err := parser.Parse(os.Args[1:]) parser.FatalIfErrorf(err) - return cmd + return ctx.Command() } // FatalIfErrorf terminates with an error message if err != nil. diff --git a/help_test.go b/help_test.go index 37ee440..0cca946 100644 --- a/help_test.go +++ b/help_test.go @@ -1,10 +1,12 @@ -package kong +package kong_test import ( "bytes" "testing" "github.com/stretchr/testify/require" + + "github.com/alecthomas/kong" ) func TestHelp(t *testing.T) { @@ -37,10 +39,10 @@ func TestHelp(t *testing.T) { w := bytes.NewBuffer(nil) exited := false app := mustNew(t, &cli, - Name("test-app"), - Description("A test app."), - Writers(w, w), - Exit(func(int) { + kong.Name("test-app"), + kong.Description("A test app."), + kong.Writers(w, w), + kong.Exit(func(int) { exited = true panic(true) // Panic to fake "exit". }), diff --git a/kong.go b/kong.go index 5e4e785..eb6bb53 100644 --- a/kong.go +++ b/kong.go @@ -9,6 +9,10 @@ import ( "strings" ) +var ( + expectedRunReturnSignature = reflect.TypeOf((*error)(nil)).Elem() +) + // Error reported by Kong. type Error struct{ msg string } @@ -121,47 +125,32 @@ func (k *Kong) extraFlags() []*Flag { return []*Flag{helpFlag} } -// Help writes help for the given error to the stdout io.Writer associated with this Kong. -// -// "err" should be the error returned by Parse(). -// -// See Help() and Writers() for overriding the help function and stdout, respectively. -func (k *Kong) Help(err error) error { - var ctx *Context - if perr, ok := err.(*ParseError); ok { - ctx = perr.Context - } else { - ctx, err = Trace(k, nil) - if err != nil { - return err - } - } - return k.help(k.helpOptions, ctx) -} - // Parse arguments into target. // -// The returned "command" is a space separated path to the final selected command, if any. Commands appear as -// the command name while positional arguments are the argument name surrounded by "". +// The return Context can be used to further inspect the parsed command-line, to format help, to find the +// selected command, to run command Run() methods, and so on. See Context and README for more information. // // Will return a ParseError if a *semantically* invalid command-line is encountered (as opposed to a syntactically // invalid one, which will report a normal error). -func (k *Kong) Parse(args []string) (command string, err error) { +func (k *Kong) Parse(args []string) (ctx *Context, err error) { defer catch(&err) - ctx, err := Trace(k, args) + ctx, err = Trace(k, args) if err != nil { - return "", err + return nil, err } if err = k.applyHooks(ctx); err != nil { - return "", &ParseError{error: err, Context: ctx} + return nil, &ParseError{error: err, Context: ctx} } if ctx.Error != nil { - return "", &ParseError{error: ctx.Error, Context: ctx} + return nil, &ParseError{error: ctx.Error, Context: ctx} } if err = ctx.Validate(); err != nil { - return "", &ParseError{error: err, Context: ctx} + return nil, &ParseError{error: err, Context: ctx} } - return ctx.Apply() + if _, err = ctx.Apply(); err != nil { + return nil, &ParseError{error: err, Context: ctx} + } + return ctx, nil } func (k *Kong) applyHooks(ctx *Context) error { diff --git a/kong_test.go b/kong_test.go index 1af7cbe..4e49d21 100644 --- a/kong_test.go +++ b/kong_test.go @@ -1,23 +1,26 @@ -package kong +package kong_test import ( "bytes" + "fmt" "strings" "testing" "github.com/stretchr/testify/require" + + "github.com/alecthomas/kong" ) -func mustNew(t *testing.T, cli interface{}, options ...Option) *Kong { +func mustNew(t *testing.T, cli interface{}, options ...kong.Option) *kong.Kong { t.Helper() - options = append([]Option{ - Name("test"), - Exit(func(int) { + options = append([]kong.Option{ + kong.Name("test"), + kong.Exit(func(int) { t.Helper() t.Fatalf("unexpected exit()") }), }, options...) - parser, err := New(cli, options...) + parser, err := kong.New(cli, options...) require.NoError(t, err) return parser } @@ -33,9 +36,9 @@ func TestPositionalArguments(t *testing.T) { } `kong:"cmd"` } p := mustNew(t, &cli) - cmd, err := p.Parse([]string{"user", "create", "10", "Alec", "Thomas"}) + ctx, err := p.Parse([]string{"user", "create", "10", "Alec", "Thomas"}) require.NoError(t, err) - require.Equal(t, "user create ", cmd) + require.Equal(t, "user create ", ctx.Command()) t.Run("Missing", func(t *testing.T) { _, err := p.Parse([]string{"user", "create", "10"}) require.Error(t, err) @@ -69,10 +72,10 @@ func TestBranchingArgument(t *testing.T) { } `kong:"cmd,help='User management.'"` } p := mustNew(t, &cli) - cmd, err := p.Parse([]string{"user", "10", "delete"}) + ctx, err := p.Parse([]string{"user", "10", "delete"}) require.NoError(t, err) require.Equal(t, 10, cli.User.ID.ID) - require.Equal(t, "user delete", cmd) + require.Equal(t, "user delete", ctx.Command()) t.Run("Missing", func(t *testing.T) { _, err = p.Parse([]string{"user"}) require.Error(t, err) @@ -143,7 +146,7 @@ func TestUnsupportedFieldErrors(t *testing.T) { var cli struct { Keys struct{} } - _, err := New(&cli) + _, err := kong.New(&cli) require.Error(t, err) } @@ -154,7 +157,7 @@ func TestMatchingArgField(t *testing.T) { } `kong:"arg"` } - _, err := New(&cli) + _, err := kong.New(&cli) require.Error(t, err) } @@ -164,7 +167,7 @@ func TestCantMixPositionalAndBranches(t *testing.T) { Command struct { } `kong:"cmd"` } - _, err := New(&cli) + _, err := kong.New(&cli) require.Error(t, err) } @@ -220,7 +223,7 @@ func TestInvalidRequiredAfterOptional(t *testing.T) { Name string `kong:"arg"` } - _, err := New(&cli) + _, err := kong.New(&cli) require.Error(t, err) } @@ -288,7 +291,7 @@ func TestCommandMissingTagIsInvalid(t *testing.T) { var cli struct { One struct{} } - _, err := New(&cli) + _, err := kong.New(&cli) require.Error(t, err) } @@ -299,7 +302,7 @@ func TestDuplicateFlag(t *testing.T) { Flag bool } `kong:"cmd"` } - _, err := New(&cli) + _, err := kong.New(&cli) require.Error(t, err) } @@ -312,7 +315,7 @@ func TestDuplicateFlagOnPeerCommandIsOkay(t *testing.T) { Flag bool } `kong:"cmd"` } - _, err := New(&cli) + _, err := kong.New(&cli) require.NoError(t, err) } @@ -324,10 +327,10 @@ func TestTraceErrorPartiallySucceeds(t *testing.T) { } `kong:"cmd"` } p := mustNew(t, &cli) - ctx, err := Trace(p, []string{"one", "bad"}) + ctx, err := kong.Trace(p, []string{"one", "bad"}) require.NoError(t, err) require.Error(t, ctx.Error) - require.Equal(t, []string{"one"}, ctx.Command()) + require.Equal(t, "one", ctx.Command()) } func TestHooks(t *testing.T) { @@ -353,13 +356,13 @@ func TestHooks(t *testing.T) { {"Flag", "one --three=three", values{true, "", "three"}}, {"ArgAndFlag", "one two --three=three", values{true, "two", "three"}}, } - setOne := func(ctx *Context, path *Path) error { hooked.one = true; return nil } - setTwo := func(ctx *Context, path *Path) error { hooked.two = ctx.Value(path).String(); return nil } - setThree := func(ctx *Context, path *Path) error { hooked.three = ctx.Value(path).String(); return nil } + setOne := func(ctx *kong.Context, path *kong.Path) error { hooked.one = true; return nil } + setTwo := func(ctx *kong.Context, path *kong.Path) error { hooked.two = ctx.Value(path).String(); return nil } + setThree := func(ctx *kong.Context, path *kong.Path) error { hooked.three = ctx.Value(path).String(); return nil } p := mustNew(t, &cli, - Hook(&cli.One, setOne), - Hook(&cli.One.Two, setTwo), - Hook(&cli.One.Three, setThree)) + kong.Hook(&cli.One, setOne), + kong.Hook(&cli.One.Two, setTwo), + kong.Hook(&cli.One.Three, setThree)) for _, test := range tests { hooked = values{} @@ -450,7 +453,40 @@ func TestSliceWithDisabledSeparator(t *testing.T) { func TestMultilineMessage(t *testing.T) { w := &bytes.Buffer{} var cli struct{} - p := mustNew(t, &cli, Writers(w, w)) + p := mustNew(t, &cli, kong.Writers(w, w)) p.Printf("hello\nworld") require.Equal(t, "test: hello\n world\n", w.String()) } + +type cmdWithRun struct { + Arg string `arg:""` +} + +func (c *cmdWithRun) Run(key string) error { + c.Arg += key + if key == "ERROR" { + return fmt.Errorf("ERROR") + } + return nil +} + +type grammarWithRun struct { + One cmdWithRun `cmd:""` + Two cmdWithRun `cmd:""` +} + +func TestRun(t *testing.T) { + cli := &grammarWithRun{} + p := mustNew(t, cli) + + ctx, err := p.Parse([]string{"one", "two"}) + require.NoError(t, err) + err = ctx.Run("hello") + require.NoError(t, err) + require.Equal(t, "twohello", cli.One.Arg) + + ctx, err = p.Parse([]string{"two", "three"}) + require.NoError(t, err) + err = ctx.Run("ERROR") + require.Error(t, err) +} diff --git a/mapper_test.go b/mapper_test.go index 2715246..66fc679 100644 --- a/mapper_test.go +++ b/mapper_test.go @@ -1,4 +1,4 @@ -package kong +package kong_test import ( "net/url" @@ -7,13 +7,15 @@ import ( "time" "github.com/stretchr/testify/require" + + "github.com/alecthomas/kong" ) func TestValueMapper(t *testing.T) { var cli struct { Flag string } - k := mustNew(t, &cli, ValueMapper(&cli.Flag, testMooMapper{})) + k := mustNew(t, &cli, kong.ValueMapper(&cli.Flag, testMooMapper{})) _, err := k.Parse(nil) require.NoError(t, err) require.Equal(t, "", cli.Flag) @@ -26,7 +28,7 @@ func TestNamedMapper(t *testing.T) { var cli struct { Flag string `type:"moo"` } - k := mustNew(t, &cli, NamedMapper("moo", testMooMapper{})) + k := mustNew(t, &cli, kong.NamedMapper("moo", testMooMapper{})) _, err := k.Parse(nil) require.NoError(t, err) require.Equal(t, "", cli.Flag) @@ -39,7 +41,7 @@ type testMooMapper struct { text string } -func (t testMooMapper) Decode(ctx *DecodeContext, target reflect.Value) error { +func (t testMooMapper) Decode(ctx *kong.DecodeContext, target reflect.Value) error { if t.text == "" { target.SetString("MOO") } else { @@ -73,14 +75,14 @@ func TestDurationMapper(t *testing.T) { } func TestSplitEscaped(t *testing.T) { - require.Equal(t, []string{"a", "b"}, SplitEscaped("a,b", ',')) - require.Equal(t, []string{"a,b", "c"}, SplitEscaped(`a\,b,c`, ',')) + require.Equal(t, []string{"a", "b"}, kong.SplitEscaped("a,b", ',')) + require.Equal(t, []string{"a,b", "c"}, kong.SplitEscaped(`a\,b,c`, ',')) } func TestJoinEscaped(t *testing.T) { - require.Equal(t, `a,b`, JoinEscaped([]string{"a", "b"}, ',')) - require.Equal(t, `a\,b,c`, JoinEscaped([]string{`a,b`, `c`}, ',')) - require.Equal(t, JoinEscaped(SplitEscaped(`a\,b,c`, ','), ','), `a\,b,c`) + require.Equal(t, `a,b`, kong.JoinEscaped([]string{"a", "b"}, ',')) + require.Equal(t, `a\,b,c`, kong.JoinEscaped([]string{`a,b`, `c`}, ',')) + require.Equal(t, kong.JoinEscaped(kong.SplitEscaped(`a\,b,c`, ','), ','), `a\,b,c`) } func TestMapWithNamedTypes(t *testing.T) { @@ -88,7 +90,7 @@ func TestMapWithNamedTypes(t *testing.T) { TypedValue map[string]string `type:":moo"` TypedKey map[string]string `type:"upper:"` } - k := mustNew(t, &cli, NamedMapper("moo", testMooMapper{}), NamedMapper("upper", testUppercaseMapper{})) + k := mustNew(t, &cli, kong.NamedMapper("moo", testMooMapper{}), kong.NamedMapper("upper", testUppercaseMapper{})) _, err := k.Parse([]string{"--typed-value", "first=5s", "--typed-value", "second=10s"}) require.NoError(t, err) require.Equal(t, map[string]string{"first": "MOO", "second": "MOO"}, cli.TypedValue) diff --git a/model.go b/model.go index 4da7e19..8e75177 100644 --- a/model.go +++ b/model.go @@ -43,6 +43,11 @@ type Node struct { Argument *Value // Populated when Type is ArgumentNode. } +// Leaf returns true if this Node is a leaf node. +func (n *Node) Leaf() bool { + return len(n.Children) == 0 +} + // Find a command/argument/flag by pointer to its field. // // Returns nil if not found. Panics if ptr is not a pointer. diff --git a/model_test.go b/model_test.go index 4d12c7e..9249d25 100644 --- a/model_test.go +++ b/model_test.go @@ -1,4 +1,4 @@ -package kong +package kong_test import ( "testing" diff --git a/options.go b/options.go index d610a66..71d918a 100644 --- a/options.go +++ b/options.go @@ -92,6 +92,8 @@ func Writers(stdout, stderr io.Writer) Option { } // HookFunc is a callback tied to a field of the grammar, called before a value is applied. +// +// "ctx" is the current parse Context, "path" is the Path entry corresponding to the hooked value. type HookFunc func(ctx *Context, path *Path) error // Hook to apply before a command, flag or positional argument is encountered. diff --git a/options_test.go b/options_test.go index ffbc521..79af714 100644 --- a/options_test.go +++ b/options_test.go @@ -1,4 +1,4 @@ -package kong +package kong_test import ( "encoding/json" @@ -7,11 +7,13 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/alecthomas/kong" ) func TestOptions(t *testing.T) { var cli struct{} - p, err := New(&cli, Name("name"), Description("description"), Writers(nil, nil), Exit(nil)) + p, err := kong.New(&cli, kong.Name("name"), kong.Description("description"), kong.Writers(nil, nil), kong.Exit(nil)) require.NoError(t, err) require.Equal(t, "name", p.Model.Name) require.Equal(t, "description", p.Model.Help) @@ -42,7 +44,7 @@ func TestConfigLoading(t *testing.T) { err = json.NewEncoder(second).Encode(&cli) require.NoError(t, err) - p := mustNew(t, &cli, Configuration(JSON, first.Name(), second.Name())) + p := mustNew(t, &cli, kong.Configuration(kong.JSON, first.Name(), second.Name())) _, err = p.Parse(nil) require.NoError(t, err) require.Equal(t, "first", cli.Flag) diff --git a/resolver_test.go b/resolver_test.go index be4d1f6..7e16a8d 100644 --- a/resolver_test.go +++ b/resolver_test.go @@ -1,4 +1,4 @@ -package kong +package kong_test import ( "os" @@ -7,6 +7,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/alecthomas/kong" ) type envMap map[string]string @@ -23,7 +25,7 @@ func tempEnv(env envMap) func() { } } -func newEnvParser(t *testing.T, cli interface{}, env envMap) (*Kong, func()) { +func newEnvParser(t *testing.T, cli interface{}, env envMap) (*kong.Kong, func()) { t.Helper() restoreEnv := tempEnv(env) parser := mustNew(t, cli) @@ -111,10 +113,10 @@ func TestJSONBasic(t *testing.T) { "slice_with_commas": ["a,b", "c"] }` - r, err := JSON(strings.NewReader(json)) + r, err := kong.JSON(strings.NewReader(json)) require.NoError(t, err) - parser := mustNew(t, &cli, Resolver(r)) + parser := mustNew(t, &cli, kong.Resolver(r)) _, err = parser.Parse([]string{}) require.NoError(t, err) require.Equal(t, "🍕", cli.String) @@ -127,14 +129,14 @@ func TestResolvedValueTriggersHooks(t *testing.T) { var cli struct { Int int } - resolver := func(context *Context, parent *Path, flag *Flag) (string, error) { + resolver := func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (string, error) { if flag.Name == "int" { return "1", nil } return "", nil } hooked := 0 - p := mustNew(t, &cli, Resolver(resolver), Hook(&cli.Int, func(ctx *Context, path *Path) error { + p := mustNew(t, &cli, kong.Resolver(resolver), kong.Hook(&cli.Int, func(ctx *kong.Context, path *kong.Path) error { hooked++ return nil })) @@ -152,7 +154,7 @@ func TestResolvedValueTriggersHooks(t *testing.T) { type testUppercaseMapper struct{} -func (testUppercaseMapper) Decode(ctx *DecodeContext, target reflect.Value) error { +func (testUppercaseMapper) Decode(ctx *kong.DecodeContext, target reflect.Value) error { value := ctx.Scan.PopValue("lowercase") target.SetString(strings.ToUpper(value)) return nil @@ -167,7 +169,7 @@ func TestResolversWithMappers(t *testing.T) { defer restoreEnv() parser := mustNew(t, &cli, - NamedMapper("upper", testUppercaseMapper{}), + kong.NamedMapper("upper", testUppercaseMapper{}), ) _, err := parser.Parse([]string{}) require.NoError(t, err) @@ -179,14 +181,14 @@ func TestResolverWithBool(t *testing.T) { Bool bool } - resolver := func(context *Context, parent *Path, flag *Flag) (string, error) { + resolver := func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (string, error) { if flag.Name == "bool" { return "true", nil } return "", nil } - p := mustNew(t, &cli, Resolver(resolver)) + p := mustNew(t, &cli, kong.Resolver(resolver)) _, err := p.Parse(nil) require.NoError(t, err) @@ -198,21 +200,21 @@ func TestLastResolverWins(t *testing.T) { Int []int } - var first ResolverFunc = func(context *Context, parent *Path, flag *Flag) (string, error) { + var first kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (string, error) { if flag.Name == "int" { return "1", nil } return "", nil } - var second ResolverFunc = func(context *Context, parent *Path, flag *Flag) (string, error) { + var second kong.ResolverFunc = func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (string, error) { if flag.Name == "int" { return "2", nil } return "", nil } - p := mustNew(t, &cli, Resolver(first), Resolver(second)) + p := mustNew(t, &cli, kong.Resolver(first), kong.Resolver(second)) _, err := p.Parse(nil) require.NoError(t, err) require.Equal(t, []int{2}, cli.Int) @@ -223,13 +225,13 @@ func TestResolverSatisfiesRequired(t *testing.T) { var cli struct { Int int `required` } - resolver := func(context *Context, parent *Path, flag *Flag) (string, error) { + resolver := func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (string, error) { if flag.Name == "int" { return "1", nil } return "", nil } - _, err := mustNew(t, &cli, Resolver(resolver)).Parse(nil) + _, err := mustNew(t, &cli, kong.Resolver(resolver)).Parse(nil) require.NoError(t, err) require.Equal(t, 1, cli.Int) } diff --git a/tag_test.go b/tag_test.go index 49ce4da..efd3c92 100644 --- a/tag_test.go +++ b/tag_test.go @@ -1,9 +1,11 @@ -package kong +package kong_test import ( "testing" "github.com/stretchr/testify/require" + + "github.com/alecthomas/kong" ) func TestDefaultValueForOptionalArg(t *testing.T) { @@ -42,7 +44,7 @@ func TestBadString(t *testing.T) { var cli struct { Numbers string `kong:"default='yay'n"` } - _, err := New(&cli) + _, err := kong.New(&cli) require.Error(t, err) } @@ -50,7 +52,7 @@ func TestNoQuoteEnd(t *testing.T) { var cli struct { Numbers string `kong:"default='yay"` } - _, err := New(&cli) + _, err := kong.New(&cli) require.Error(t, err) }