From eb01b767ac648c2c202e8ccae25a601d6bf4f67a Mon Sep 17 00:00:00 2001 From: Yuchen Ying Date: Thu, 4 Jun 2015 14:08:17 -0700 Subject: [PATCH] Add SubcommandChooser The SubcommandChooser can be used to customize the behavior when the default map lookup cannot find the subcommand you want. Example usage: 1. busybox-style subcommand invocation ``` // A symbolic link `ls` to binary `busybox` will invoke `busybox ls` mySubcommandChooser = func(*c cli.CLI) (CommandFactory, error){ subcommand = os.Args[0] if subcommand != c.Name() { if subcommandFunc, ok := c.Commands[subcommand]; ok { return subcommandFunc, nil } } return cli.DefaultSubcommandChooser(c) } c := cli.NewCLI("busybox", "1.0.0") c.Args = os.Args[1:] c.Commands = map[string]cli.CommandFactory{ "ls": lsCommandFactory, "cat": catCommandFactory, } c.SubcommandChooser = mySubcommandChooser ``` 2. default subcommand ``` // `myapp --help` == `myapp default --help` mySubcommandChooser = func(*c cli.CLI) (CommandFactory, error) { if c.Subcommand() == "" { return c.Commands["default"], nil } return cli.DefaultSubcommandChooser(c) } c := cli.NewCLI("myapp", "1.0.0") c.Args = os.Args[1:] c.Commands = map[string]cli.CommandFactory{ "default": defaultCommandFactory, } c.SubcommandChooser = mySubcommandChooser ``` --- cli.go | 50 ++++++++++++++++++++------------ cli_test.go | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++--- command.go | 25 ++++++++++++++++ 3 files changed, 136 insertions(+), 22 deletions(-) diff --git a/cli.go b/cli.go index c3ada92..bfe3b2d 100644 --- a/cli.go +++ b/cli.go @@ -1,6 +1,7 @@ package cli import ( + "fmt" "io" "os" "sync" @@ -26,6 +27,8 @@ type CLI struct { // Version of the CLI. Version string + SubcommandChooser func(*CLI) (CommandFactory, error) + // HelpFunc and HelpWriter are used to output help information, if // requested. // @@ -47,6 +50,28 @@ type CLI struct { isVersion bool } +// DefaultSubcommandChooser is the default SubcommandChooser. It will return +// the proper subcommand CommandFactory, or if that cannot be found and +// IsVersion() return true, return versionCommanFactory. The fallback of +// helpCommandFactory will be returned when there's nothing matched. error will +// be non-nil if a proper subcommand can't be found, so client user can wrap +// this function according to error value. +func DefaultSubcommandChooser(c *CLI) (CommandFactory, error) { + if commandFunc, ok := c.Commands[c.Subcommand()]; ok { + return commandFunc, nil + } else if c.IsVersion() { + versionCommandFactory := func() (Command, error) { + return OutputTextCommand{c.HelpWriter, c.Version}, nil + } + return versionCommandFactory, fmt.Errorf("Failed to find subcommand") + } else { + helpCommandFactory := func() (Command, error) { + return OutputTextCommand{c.HelpWriter, c.HelpFunc(c.Commands)}, nil + } + return helpCommandFactory, fmt.Errorf("Failed to find subcommand") + } +} + // NewClI returns a new CLI instance with sensible defaults. func NewCLI(app, version string) *CLI { return &CLI{ @@ -75,12 +100,6 @@ func (c *CLI) IsVersion() bool { func (c *CLI) Run() (int, error) { c.once.Do(c.init) - // Just show the version and exit if instructed. - if c.IsVersion() && c.Version != "" { - c.HelpWriter.Write([]byte(c.Version + "\n")) - return 1, nil - } - // If there is an invalid flag, then error if len(c.topFlags) > 0 { c.HelpWriter.Write([]byte( @@ -90,25 +109,16 @@ func (c *CLI) Run() (int, error) { return 1, nil } - // Attempt to get the factory function for creating the command - // implementation. If the command is invalid or blank, it is an error. - commandFunc, ok := c.Commands[c.Subcommand()] - if !ok { - c.HelpWriter.Write([]byte(c.HelpFunc(c.Commands) + "\n")) - return 1, nil - } + commandFunc, _ := c.SubcommandChooser(c) command, err := commandFunc() + if err != nil { return 0, err } - - // If we've been instructed to just print the help, then print it if c.IsHelp() { - c.HelpWriter.Write([]byte(command.Help() + "\n")) - return 1, nil + command = OutputTextCommand{c.HelpWriter, command.Help()} } - return command.Run(c.SubcommandArgs()), nil } @@ -140,6 +150,10 @@ func (c *CLI) init() { c.HelpWriter = os.Stderr } + if c.SubcommandChooser == nil { + c.SubcommandChooser = DefaultSubcommandChooser + } + c.processArgs() } diff --git a/cli_test.go b/cli_test.go index d49d8d4..ab513d4 100644 --- a/cli_test.go +++ b/cli_test.go @@ -158,8 +158,81 @@ func TestCLIRun_printHelp(t *testing.T) { continue } - if !strings.Contains(buf.String(), helpText) { - t.Errorf("Args: %#v. Text: %v", testCase, buf.String()) + expect := strings.TrimSpace(buf.String()) + got := strings.TrimSpace(helpText) + + if !strings.Contains(expect, got) { + t.Errorf("Args: %#v, expect: %#v, got %#v", testCase, expect, got) + } + } +} + +func TestCLIRun_printBasicHelpFunc(t *testing.T) { + defaultExpect := `usage: app [--version] [--help] [] + +Available commands are: + bar bar command + foo foo command +` + tests := []struct { + input []string + expect string + }{ + { + []string{}, defaultExpect, + }, + { + []string{"-h"}, defaultExpect, + }, + { + []string{"i-dont-exist"}, defaultExpect, + }, + { + []string{"-bad-flag", "foo"}, + `Invalid flags before the subcommand. If these flags are for +the subcommand, please put them after the subcommand. + +usage: app [--version] [--help] [] + +Available commands are: + bar bar command + foo foo command + +`, + }, + } + + for _, test := range tests { + buf := new(bytes.Buffer) + + cli := &CLI{ + Args: test.input, + Commands: map[string]CommandFactory{ + "foo": func() (Command, error) { + return &MockCommand{SynopsisText: "foo command"}, nil + }, + "bar": func() (Command, error) { + return &MockCommand{SynopsisText: "bar command"}, nil + }, + }, + HelpFunc: BasicHelpFunc("app"), + HelpWriter: buf, + } + + code, err := cli.Run() + if err != nil { + t.Errorf("Args: %#v. Error: %s", test.input, err) + continue + } + + if code != 1 { + t.Errorf("Args: %#v. Code: %d", test.input, code) + continue + } + + if got := buf.String(); got != test.expect { + t.Errorf( + "Args: %#v, expect: %#v, got %#v", test.input, test.expect, got) } } } @@ -195,8 +268,10 @@ func TestCLIRun_printCommandHelp(t *testing.T) { t.Fatalf("bad exit code: %d", exitCode) } - if buf.String() != (command.HelpText + "\n") { - t.Fatalf("bad: %#v", buf.String()) + expect := strings.TrimSpace(command.HelpText) + got := strings.TrimSpace(buf.String()) + if expect != got { + t.Fatalf("Expect %#v, got %#v.", expect, got) } } } diff --git a/command.go b/command.go index b18d3ef..583804d 100644 --- a/command.go +++ b/command.go @@ -1,5 +1,7 @@ package cli +import "io" + // A command is a runnable sub-command of a CLI. type Command interface { // Help should return long-form help text that includes the command-line @@ -21,3 +23,26 @@ type Command interface { // We need a factory because we may need to setup some state on the // struct that implements the command itself. type CommandFactory func() (Command, error) + +// OutputTextCommand implemented Command interface and is used to write text to +// given writer +type OutputTextCommand struct { + writer io.Writer + text string +} + +// Help is part of Command interface +func (c OutputTextCommand) Help() string { + return c.text +} + +// Synopsis is part of Command interface. Return Help() +func (c OutputTextCommand) Synopsis() string { + return c.Help() +} + +// Run is part of Command interface. Args will be ignored. +func (c OutputTextCommand) Run(_ []string) int { + c.writer.Write([]byte(c.Help())) + return 1 +}