├── .gitignore ├── .travis.yml ├── LICENSE ├── run_test.go ├── option.go ├── command.go ├── README.md ├── usage_test.go ├── usage.go ├── parse.go ├── app.go └── parse_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | vendor/ 4 | Gopkg.lock 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.8 5 | 6 | before_install: 7 | - go get 8 | - touch coverage.txt 9 | - pip install --user codecov 10 | 11 | script: 12 | - go test -coverprofile=coverage.txt -covermode=atomic ./... 13 | 14 | after_success: 15 | - codecov 16 | 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /run_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017. Oleg Sklyar & teris.io. All rights reserved. 2 | // See the LICENSE file in the project root for licensing information. 3 | 4 | package cli_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/teris-io/cli" 10 | ) 11 | 12 | func setupRunApp() cli.App { 13 | co := cli.NewCommand("checkout", "Check out a branch or revision"). 14 | WithShortcut("co"). 15 | WithArg(cli.NewArg("branch", "branch to checkout")). 16 | WithArg(cli.NewArg("fallback", "branch to fallback").AsOptional()). 17 | WithOption(cli.NewOption("branch", "Create branch if missing").WithChar('b').WithType(cli.TypeBool)). 18 | WithAction(func(args []string, options map[string]string) int { 19 | if _, ok := options["branch"]; !ok { 20 | return -1 21 | } 22 | if len(args) < 1 || args[0] != "5.5.5" { 23 | return -2 24 | } 25 | if _, ok := options["verbose"]; ok { 26 | return 26 27 | } 28 | if len(args) == 2 && args[1] == "5.1.1" { 29 | return 27 30 | } 31 | return 25 32 | }). 33 | WithCommand(cli.NewCommand("dummy1", "First dummy command")). 34 | WithCommand(cli.NewCommand("dummy2", "Second dummy command")) 35 | 36 | add := cli.NewCommand("add", "add a remote"). 37 | WithOption(cli.NewOption("force", "Force").WithChar('f').WithType(cli.TypeBool)). 38 | WithOption(cli.NewOption("quiet", "Quiet").WithChar('q').WithType(cli.TypeBool)). 39 | WithOption(cli.NewOption("default", "Default")) 40 | 41 | rmt := cli.NewCommand("remote", "operations with remotes").WithCommand(add) 42 | 43 | return cli.New("git tool"). 44 | WithCommand(co). 45 | WithCommand(rmt). 46 | WithOption(cli.NewOption("verbose", "Verbose execution").WithChar('v').WithType(cli.TypeBool)). 47 | WithAction(func(args []string, options map[string]string) int { 48 | return 13 49 | }) 50 | } 51 | 52 | func TestApp_Run_TopLevel_ok(t *testing.T) { 53 | a := setupRunApp() 54 | w := &stringwriter{} 55 | code := a.Run([]string{"./foo"}, w) 56 | assertAppRunOk(t, 13, code) 57 | } 58 | 59 | func TestApp_Run_NestedCommand_ok(t *testing.T) { 60 | a := setupRunApp() 61 | w := &stringwriter{} 62 | code := a.Run([]string{"./foo", "co", "-b", "5.5.5"}, w) 63 | assertAppRunOk(t, 25, code) 64 | } 65 | 66 | func TestApp_Run_NestedCommandWithOptionsFromRoot_ok(t *testing.T) { 67 | a := setupRunApp() 68 | w := &stringwriter{} 69 | code := a.Run([]string{"./foo", "co", "-bv", "5.5.5"}, w) 70 | assertAppRunOk(t, 26, code) 71 | } 72 | 73 | func TestApp_Run_NestedCommandWithOptionalArg_ok(t *testing.T) { 74 | a := setupRunApp() 75 | w := &stringwriter{} 76 | code := a.Run([]string{"./foo", "co", "-b", "5.5.5", "5.1.1"}, w) 77 | assertAppRunOk(t, 27, code) 78 | } 79 | 80 | func assertAppRunOk(t *testing.T, expectedCode, actualCode int) { 81 | if expectedCode != actualCode { 82 | t.Errorf("expected exit code: %s, found: %v", expectedCode, actualCode) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017. Oleg Sklyar & teris.io. All rights reserved. 2 | // See the LICENSE file in the project root for licensing information. 3 | 4 | package cli 5 | 6 | // Option defines an application or command option. 7 | // 8 | // Boolean options do not need a value, as their presence implicitly means `true`. All other option 9 | // types need a value. When options are specified using their char keys their need to be prepended 10 | // by a single dash (-) and can be joined together (-zxv). Hhowever, only boolean options requiring 11 | // no further value may occupy a non-terminal position of a join. A value must follow a char option 12 | // as the next argument, same applies for a non-boolean option at the terminal position of an option 13 | // join, e.g. `-fc 1` means `-f -c 1`, where 1 is the argument for `-c`. 14 | // 15 | // Non-char, complete, options must be prepended with a double dash (--) and their value must be 16 | // provided in the same argument after the equal sign, e.g. --count=1. Similarly here, boolean options 17 | // require no value. Empty values are supported for complete (non-char) string options only by providing 18 | // no value after the equal sign, e.g. `--default=`. Equal signs can be used within the option value, e.g. 19 | // `--default=a=b=c` specifies the `a=b=c` string as a value for `--default`. 20 | // 21 | // Every option must have a `complete` name as these are used as keys to pass options to the action. In case 22 | // only a char option is desired, a complete key with the same single char should be defined. 23 | // 24 | // Options can be used at any position after the command, arbitrarily intermixed with positional arguments. 25 | // In contrast to positional arguments the order of options is not preserved. 26 | type Option interface { 27 | // Key returns the complete key of an option (used with the -- notation), required. 28 | Key() string 29 | // CharKey returns a single-character key of an option (used with the - notation), optional. 30 | CharKey() rune 31 | // Description returns the option description for the usage string. 32 | Description() string 33 | // Type returns the option type (string by default) to be used to decide if a value is required and for 34 | // value validation. 35 | Type() ValueType 36 | 37 | // WithChar sets the char key for the option. 38 | WithChar(char rune) Option 39 | // WithType sets the option value type. 40 | WithType(ft ValueType) Option 41 | } 42 | 43 | // NewOption creates a new option with a given key and description. 44 | func NewOption(key, descr string) Option { 45 | char := rune(0) 46 | if len(key) == 1 { 47 | char = rune(key[0]) 48 | } 49 | return option{key: key, char: char, descr: descr, tp: TypeString} 50 | } 51 | 52 | type option struct { 53 | key string 54 | char rune 55 | descr string 56 | tp ValueType 57 | } 58 | 59 | func (f option) Key() string { 60 | return f.key 61 | } 62 | 63 | func (f option) CharKey() rune { 64 | return f.char 65 | } 66 | 67 | func (f option) Type() ValueType { 68 | return f.tp 69 | } 70 | 71 | func (f option) Description() string { 72 | return f.descr 73 | } 74 | 75 | func (f option) String() string { 76 | return f.descr 77 | } 78 | 79 | func (f option) WithChar(char rune) Option { 80 | f.char = char 81 | return f 82 | } 83 | 84 | func (f option) WithType(tp ValueType) Option { 85 | f.tp = tp 86 | return f 87 | } 88 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017. Oleg Sklyar & teris.io. All rights reserved. 2 | // See the LICENSE file in the project root for licensing information. 3 | 4 | package cli 5 | 6 | // Command defines a named sub-command in a command-tree of an application. A complete path to the terminal 7 | // command e.g. `git remote add` must be defined ahead of any options or positional arguments. These are parsed 8 | // first. 9 | type Command interface { 10 | // Key returns the command name. 11 | Key() string 12 | // Shortcut returns the command shortcut if not empty. 13 | Shortcut() string 14 | // Description returns the command description to be output in the usage. 15 | Description() string 16 | // Args returns required and optional positional arguments for this command. 17 | Args() []Arg 18 | // Options permitted for this command and its sub-commands. 19 | Options() []Option 20 | // Commands returns the set of sub-commands for this command. 21 | Commands() []Command 22 | // Action returns the command action when no further sub-command is specified. 23 | Action() Action 24 | 25 | // WithShortcut adds a (shorter) command alias, e.g. `co` for `checkout`. 26 | WithShortcut(shortcut string) Command 27 | // WithArg adds a positional argument to the command. Specifying last application/command 28 | // argument as optional permits unlimited number of further positional arguments (at least one 29 | // optional argument needs to be specified in the definition for this case). 30 | WithArg(arg Arg) Command 31 | // WithOption adds a permitted option to the command and all sub-commands. 32 | WithOption(opt Option) Command 33 | // WithCommand adds a next-level sub-command to the command. 34 | WithCommand(cmd Command) Command 35 | // WithAction sets the action function for this command. 36 | WithAction(action Action) Command 37 | } 38 | 39 | // NewCommand creates a new command to be added to an application or to another command. 40 | func NewCommand(key, descr string) Command { 41 | return &command{key: key, descr: descr} 42 | } 43 | 44 | type command struct { 45 | key string 46 | descr string 47 | shortcut string 48 | args []Arg 49 | opts []Option 50 | cmds []Command 51 | action Action 52 | } 53 | 54 | func (c *command) Key() string { 55 | return c.key 56 | } 57 | 58 | func (c *command) Description() string { 59 | return c.descr 60 | } 61 | 62 | func (c *command) Shortcut() string { 63 | return c.shortcut 64 | } 65 | 66 | func (c *command) Args() []Arg { 67 | return c.args 68 | } 69 | 70 | func (c *command) Options() []Option { 71 | return c.opts 72 | } 73 | 74 | func (c *command) Commands() []Command { 75 | return c.cmds 76 | } 77 | 78 | func (c *command) Action() Action { 79 | return c.action 80 | } 81 | 82 | func (c *command) WithShortcut(shortcut string) Command { 83 | c.shortcut = shortcut 84 | return c 85 | } 86 | 87 | func (c *command) WithArg(arg Arg) Command { 88 | c.args = append(c.args, arg) 89 | return c 90 | } 91 | 92 | func (c *command) WithOption(opt Option) Command { 93 | c.opts = append(c.opts, opt) 94 | return c 95 | } 96 | 97 | func (c *command) WithCommand(cmd Command) Command { 98 | c.cmds = append(c.cmds, cmd) 99 | return c 100 | } 101 | 102 | func (c *command) WithAction(action Action) Command { 103 | c.action = action 104 | return c 105 | } 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build status][buildimage]][build] [![Coverage][codecovimage]][codecov] [![GoReportCard][cardimage]][card] [![API documentation][docsimage]][docs] 2 | 3 | # Simple and complete API for building command line applications in Go 4 | 5 | Module `cli` provides a simple, fast and complete API for building command line applications in Go. 6 | In contrast to other libraries the emphasis is put on the definition and validation of 7 | positional arguments, handling of options from all levels in a single block as well as 8 | a minimalistic set of dependencies. 9 | 10 | The core of the module is the command, option and argument parsing logic. After a successful parsing the 11 | command action is evaluated passing a slice of (validated) positional arguments and a map of (validated) options. 12 | No more no less. 13 | 14 | ## Definition 15 | 16 | ```go 17 | co := cli.NewCommand("checkout", "checkout a branch or revision"). 18 | WithShortcut("co"). 19 | WithArg(cli.NewArg("revision", "branch or revision to checkout")). 20 | WithOption(cli.NewOption("branch", "Create branch if missing").WithChar('b').WithType(cli.TypeBool)). 21 | WithOption(cli.NewOption("upstream", "Set upstream for the branch").WithChar('u').WithType(cli.TypeBool)). 22 | WithAction(func(args []string, options map[string]string) int { 23 | // do something 24 | return 0 25 | }) 26 | 27 | add := cli.NewCommand("add", "add a remote"). 28 | WithArg(cli.NewArg("remote", "remote to add")) 29 | 30 | rmt := cli.NewCommand("remote", "Work with git remotes"). 31 | WithCommand(add) 32 | 33 | app := cli.New("git tool"). 34 | WithOption(cli.NewOption("verbose", "Verbose execution").WithChar('v').WithType(cli.TypeBool)). 35 | WithCommand(co). 36 | WithCommand(rmt) 37 | // no action attached, just print usage when executed 38 | 39 | os.Exit(app.Run(os.Args, os.Stdout)) 40 | ``` 41 | 42 | ## Execution 43 | 44 | Given the above definition for a git client, e.g. `gitc`, running `gitc` with no arguments or with `-h` will 45 | produce the following output (the exit code will be 1 in the former case, because the action is missing, and 0 in the latter, because help was explicitly requested): 46 | 47 | ``` 48 | gitc [--verbose] 49 | 50 | Description: 51 | git tool 52 | 53 | Options: 54 | -v, --verbose Verbose execution 55 | 56 | Sub-commands: 57 | git checkout checkout a branch or revision 58 | git remote Work with git remotes 59 | ``` 60 | 61 | Running `gitc` with arguments matching e.g. the `checkout` definition, `gitc co -vbu dev` or 62 | `gitc checkout -v --branch -u dev` will execute the command as expected. Running into a parsing error, e.g. 63 | by providing an unknown option `gitc co -f dev`, will output a parsing error and a short usage string: 64 | 65 | ``` 66 | fatal: unknown flag -f 67 | usage: gitc checkout [--verbose] [--branch] [--upstream] 68 | ``` 69 | 70 | 71 | ### License and copyright 72 | 73 | Copyright (c) 2017. Oleg Sklyar and teris.io. MIT license applies. All rights reserved. 74 | 75 | 76 | [build]: https://travis-ci.org/teris-io/cli 77 | [buildimage]: https://travis-ci.org/teris-io/cli.svg?branch=master 78 | 79 | [codecov]: https://codecov.io/github/teris-io/cli?branch=master 80 | [codecovimage]: https://codecov.io/github/teris-io/cli/coverage.svg?branch=master 81 | 82 | [card]: http://goreportcard.com/report/teris-io/cli 83 | [cardimage]: https://goreportcard.com/badge/github.com/teris-io/cli 84 | 85 | [docs]: https://godoc.org/github.com/teris-io/cli 86 | [docsimage]: http://img.shields.io/badge/godoc-reference-blue.svg?style=flat 87 | -------------------------------------------------------------------------------- /usage_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017. Oleg Sklyar & teris.io. All rights reserved. 2 | // See the LICENSE file in the project root for licensing information. 3 | 4 | package cli_test 5 | 6 | import ( 7 | "os" 8 | "testing" 9 | 10 | "github.com/teris-io/cli" 11 | ) 12 | 13 | func setupUsageApp() cli.App { 14 | co := cli.NewCommand("checkout", "Check out a branch or revision"). 15 | WithShortcut("co"). 16 | WithArg(cli.NewArg("revision", "branch or revision to checkout")). 17 | WithArg(cli.NewArg("fallback", "branch to fallback").AsOptional()). 18 | WithOption(cli.NewOption("branch", "create branch if missing").WithChar('b').WithType(cli.TypeBool)). 19 | WithAction(func(args []string, options map[string]string) int { 20 | return 25 21 | }). 22 | WithCommand(cli.NewCommand("sub-cmd1", "First sub-command")). 23 | WithCommand(cli.NewCommand("sub-cmd2", "Second sub-command")) 24 | 25 | rmt := cli.NewCommand("remote", "Work with git remotes") 26 | 27 | return cli.New("git tool"). 28 | WithCommand(co). 29 | WithCommand(rmt). 30 | WithOption(cli.NewOption("verbose", "Verbose execution").WithChar('v').WithType(cli.TypeBool)) 31 | } 32 | 33 | type stringwriter struct { 34 | str string 35 | } 36 | 37 | func (s *stringwriter) Write(p []byte) (n int, err error) { 38 | s.str = s.str + string(p) 39 | return len(p), nil 40 | } 41 | 42 | func TestApp_Usage_NestedCommandHelp_ok(t *testing.T) { 43 | a := setupUsageApp() 44 | w := &stringwriter{} 45 | a.Run([]string{"./foo", "co", "-hb", "5.5.5"}, w) 46 | expected := `foo checkout [--verbose] [--branch] [fallback] 47 | 48 | Description: 49 | Check out a branch or revision 50 | 51 | Arguments: 52 | revision branch or revision to checkout 53 | fallback branch to fallback, optional 54 | 55 | Options: 56 | -v, --verbose Verbose execution 57 | -b, --branch create branch if missing 58 | 59 | Sub-commands: 60 | foo checkout sub-cmd1 First sub-command 61 | foo checkout sub-cmd2 Second sub-command 62 | ` 63 | assertAppUsageOk(t, expected, w.str) 64 | } 65 | 66 | func TestApp_Usage_NestedCommandParsginError_ok(t *testing.T) { 67 | a := setupUsageApp() 68 | w := &stringwriter{} 69 | a.Run([]string{"./foo", "co"}, w) 70 | expected := `fatal: missing required argument revision 71 | usage: foo checkout [--verbose] [--branch] [fallback] 72 | ` 73 | assertAppUsageOk(t, expected, w.str) 74 | } 75 | 76 | func TestApp_Usage_TopWithNoAction(t *testing.T) { 77 | a := setupUsageApp() 78 | w := &stringwriter{} 79 | code := a.Run([]string{"./foo"}, w) 80 | if code != 1 { 81 | t.Errorf("expected exit code 1, found %v", code) 82 | } 83 | expected := `foo [--verbose] 84 | 85 | Description: 86 | git tool 87 | 88 | Options: 89 | -v, --verbose Verbose execution 90 | 91 | Sub-commands: 92 | foo checkout Check out a branch or revision, shortcut: co 93 | foo remote Work with git remotes 94 | ` 95 | assertAppUsageOk(t, expected, w.str) 96 | } 97 | 98 | func TestApp_Usage_README(t *testing.T) { 99 | co := cli.NewCommand("checkout", "checkout a branch or revision"). 100 | WithShortcut("co"). 101 | WithArg(cli.NewArg("revision", "branch or revision to checkout")). 102 | WithOption(cli.NewOption("branch", "Create branch if missing").WithChar('b').WithType(cli.TypeBool)). 103 | WithOption(cli.NewOption("upstream", "Set upstream for the branch").WithChar('u').WithType(cli.TypeBool)). 104 | WithAction(func(args []string, options map[string]string) int { 105 | // do something 106 | return 0 107 | }) 108 | 109 | add := cli.NewCommand("add", "add a remote"). 110 | WithArg(cli.NewArg("remote", "remote to add")) 111 | 112 | rmt := cli.NewCommand("remote", "Work with git remotes"). 113 | WithCommand(add) 114 | 115 | app := cli.New("git tool"). 116 | WithOption(cli.NewOption("verbose", "Verbose execution").WithChar('v').WithType(cli.TypeBool)). 117 | WithCommand(co). 118 | WithCommand(rmt) 119 | // no action attached, just print usage when executed 120 | 121 | app.Run([]string{"./gitc", "co", "-f", "dev"}, os.Stdout) 122 | } 123 | 124 | func assertAppUsageOk(t *testing.T, expectedOutput, actualOutput string) { 125 | if expectedOutput != actualOutput { 126 | t.Errorf("expected output: %v, found: %v", expectedOutput, actualOutput) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /usage.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017. Oleg Sklyar & teris.io. All rights reserved. 2 | // See the LICENSE file in the project root for licensing information. 3 | 4 | package cli 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "io" 10 | "strings" 11 | ) 12 | 13 | type usageline struct { 14 | section string 15 | key string 16 | value string 17 | } 18 | 19 | // Usage prints out the complete usage string. 20 | func Usage(a App, invocation []string, w io.Writer) error { 21 | if len(invocation) < 1 { 22 | return errors.New("invalid invocation path []") 23 | } 24 | 25 | descr := a.Description() 26 | cmds := a.Commands() 27 | args := a.Args() 28 | opts := a.Options() 29 | 30 | if len(invocation) > 1 { 31 | for _, key := range invocation[1:] { 32 | matched := false 33 | for _, cmd := range cmds { 34 | if cmd.Key() == key { 35 | descr = cmd.Description() 36 | cmds = cmd.Commands() 37 | args = cmd.Args() 38 | opts = append(opts, cmd.Options()...) 39 | matched = true 40 | break 41 | } 42 | } 43 | // should never happen if invocation originates from the parser 44 | if !matched { 45 | // ignore errors here as no alternative writer is available 46 | err := fmt.Errorf("fatal: invalid invocation path %v\n", invocation) 47 | fmt.Fprint(w, err.Error()) 48 | return err 49 | } 50 | } 51 | } 52 | 53 | indent := " " 54 | thiscmd := strings.Join(invocation, " ") 55 | fmt.Fprintf(w, "%s%s%s\n\n", thiscmd, optstring(opts), argstring(args)) 56 | fmt.Fprintln(w, "Description:") 57 | fmt.Fprintf(w, "%s%s\n", indent, descr) 58 | 59 | var lines []usageline 60 | maxkey := 0 61 | if len(args) > 0 { 62 | for _, arg := range args { 63 | value := arg.Description() 64 | if arg.Optional() { 65 | value += ", optional" 66 | } 67 | line := usageline{ 68 | section: "Arguments", 69 | key: arg.Key(), 70 | value: value, 71 | } 72 | lines = append(lines, line) 73 | if len(line.key) > maxkey { 74 | maxkey = len(line.key) 75 | } 76 | } 77 | } 78 | 79 | if len(opts) > 0 { 80 | for _, opt := range opts { 81 | charstr := " " 82 | if opt.CharKey() != rune(0) { 83 | charstr = "-" + string(opt.CharKey()) + ", " 84 | } 85 | 86 | line := usageline{ 87 | section: "Options", 88 | key: charstr + "--" + opt.Key(), 89 | value: opt.Description(), 90 | } 91 | lines = append(lines, line) 92 | if len(line.key) > maxkey { 93 | maxkey = len(line.key) 94 | } 95 | } 96 | } 97 | 98 | if len(cmds) > 0 { 99 | for _, cmd := range cmds { 100 | shortstr := "" 101 | if cmd.Shortcut() != "" { 102 | shortstr = ", shortcut: " + cmd.Shortcut() 103 | } 104 | 105 | line := usageline{ 106 | section: "Sub-commands", 107 | key: thiscmd + " " + cmd.Key(), 108 | value: cmd.Description() + shortstr, 109 | } 110 | lines = append(lines, line) 111 | if len(line.key) > maxkey { 112 | maxkey = len(line.key) 113 | } 114 | } 115 | } 116 | 117 | lastsection := "" 118 | for _, line := range lines { 119 | if line.section != lastsection { 120 | fmt.Fprintf(w, "\n%s:\n", line.section) 121 | } 122 | lastsection = line.section 123 | spacer := 3 + maxkey - len(line.key) 124 | fmt.Fprintf(w, "%s%s%s%s\n", indent, line.key, strings.Repeat(" ", spacer), line.value) 125 | } 126 | return nil 127 | } 128 | 129 | func optstring(opts []Option) string { 130 | res := "" 131 | for _, opt := range opts { 132 | res += " [--" + opt.Key() 133 | switch opt.Type() { 134 | case TypeString: 135 | res += "=string" 136 | case TypeInt: 137 | res += "=int" 138 | case TypeNumber: 139 | res += "=number" 140 | } 141 | res += "]" 142 | } 143 | return res 144 | } 145 | 146 | func argstring(args []Arg) string { 147 | res := "" 148 | for _, arg := range args { 149 | if arg.Optional() { 150 | res += " [" + arg.Key() + "]" 151 | } else { 152 | res += " <" + arg.Key() + ">" 153 | } 154 | } 155 | return res 156 | } 157 | 158 | func shortUsage(a App, invocation []string) string { 159 | if len(invocation) < 1 { 160 | return "invalid invocation path []" 161 | } 162 | 163 | cmds := a.Commands() 164 | args := a.Args() 165 | opts := a.Options() 166 | 167 | if len(invocation) > 1 { 168 | for _, key := range invocation[1:] { 169 | matched := false 170 | for _, cmd := range cmds { 171 | if cmd.Key() == key { 172 | cmds = cmd.Commands() 173 | args = cmd.Args() 174 | opts = append(opts, cmd.Options()...) 175 | matched = true 176 | break 177 | } 178 | } 179 | // should never happen if invocation originates from the parser 180 | if !matched { 181 | return fmt.Sprintf("fatal: invalid invocation path %v", invocation) 182 | } 183 | } 184 | } 185 | 186 | return fmt.Sprintf("%s%s%s", strings.Join(invocation, " "), optstring(opts), argstring(args)) 187 | } 188 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017. Oleg Sklyar & teris.io. All rights reserved. 2 | // See the LICENSE file in the project root for licensing information. 3 | 4 | package cli 5 | 6 | import ( 7 | "fmt" 8 | "path" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | const ( 15 | helpKey = "help" 16 | helpChar = 'h' 17 | trueStr = "true" 18 | ) 19 | 20 | // Parse parses the original application arguments into the command invocation path (application -> 21 | // first level command -> second level command etc.), a list of validated positional arguments matching 22 | // the command being invoked (the last one in the invocation path) and a map of validated options 23 | // matching one of the invocation path elements, from the top application down to the command being invoked. 24 | // An error is returned if a command is not found or arguments or options are invalid. In case of an error, 25 | // the invocation path is normally also computed and returned (the content of arguments and options is not 26 | // guaranteed). See `App.parse` 27 | func Parse(a App, appargs []string) (invocation []string, args []string, opts map[string]string, err error) { 28 | _, appname := path.Split(appargs[0]) 29 | // Remove the path and extension of the executable 30 | appname = filepath.Base(appname) 31 | appname = strings.TrimSuffix(appname, filepath.Ext(appname)) 32 | 33 | invocation, argsAndOpts, expArgs, accptOpts := evalCommand(a, appargs[1:]) 34 | invocation = append([]string{appname}, invocation...) 35 | 36 | if args, opts, err = splitArgsAndOpts(argsAndOpts, accptOpts); err == nil { 37 | if _, ok := opts["help"]; !ok { 38 | if err = assertArgs(expArgs, args); err == nil { 39 | err = assertOpts(accptOpts, opts) 40 | } 41 | } 42 | } 43 | return invocation, args, opts, err 44 | } 45 | 46 | func evalCommand(a App, appargs []string) (invocation []string, argsAndOpts []string, expArgs []Arg, accptOpts []Option) { 47 | invocation = []string{} 48 | argsAndOpts = appargs 49 | expArgs = a.Args() 50 | accptOpts = a.Options() 51 | 52 | cmds2check := a.Commands() 53 | for i, arg := range appargs { 54 | matched := false 55 | for _, cmd := range cmds2check { 56 | if cmd.Key() == arg || cmd.Shortcut() == arg { 57 | invocation = append(invocation, cmd.Key()) 58 | argsAndOpts = appargs[i+1:] 59 | expArgs = cmd.Args() 60 | accptOpts = append(accptOpts, cmd.Options()...) 61 | 62 | cmds2check = cmd.Commands() 63 | matched = true 64 | break 65 | } 66 | } 67 | if !matched { 68 | break 69 | } 70 | } 71 | return invocation, argsAndOpts, expArgs, accptOpts 72 | } 73 | 74 | func splitArgsAndOpts(appargs []string, accptOpts []Option) (args []string, opts map[string]string, err error) { 75 | opts = make(map[string]string) 76 | 77 | passthrough := false 78 | danglingOpt := "" 79 | for _, arg := range appargs { 80 | if arg == "--" { 81 | passthrough = true 82 | continue 83 | } 84 | 85 | if danglingOpt != "" { 86 | opts[danglingOpt] = arg 87 | danglingOpt = "" 88 | continue 89 | } 90 | 91 | if !passthrough && strings.HasPrefix(arg, "--") { 92 | arg = arg[2:] 93 | if arg == helpKey { 94 | return nil, map[string]string{helpKey: trueStr}, nil 95 | } 96 | parts := strings.Split(arg, "=") 97 | key := parts[0] 98 | matched := false 99 | for _, accptOpt := range accptOpts { 100 | if accptOpt.Key() == key { 101 | if accptOpt.Type() == TypeBool { 102 | if len(parts) == 1 { 103 | opts[accptOpt.Key()] = trueStr 104 | } else { 105 | return args, opts, fmt.Errorf("boolean options have true assigned implicitly, found value for --%s", key) 106 | } 107 | } else if len(parts) >= 2 { 108 | opts[accptOpt.Key()] = strings.Join(parts[1:], "=") // permit = in values 109 | } else { 110 | return args, opts, fmt.Errorf("missing value for option --%s", key) 111 | } 112 | matched = true 113 | break 114 | } 115 | } 116 | if !matched { 117 | return args, opts, fmt.Errorf("unknown option --%s", key) 118 | } 119 | continue 120 | } 121 | 122 | if !passthrough && strings.HasPrefix(arg, "-") { 123 | arg = arg[1:] 124 | 125 | for i, char := range arg { 126 | if char == helpChar { 127 | return nil, map[string]string{helpKey: trueStr}, nil 128 | } 129 | matched := false 130 | for _, accptOpt := range accptOpts { 131 | if accptOpt.CharKey() == char { 132 | if accptOpt.Type() == TypeBool { 133 | opts[accptOpt.Key()] = trueStr 134 | } else if i == len(arg)-1 { 135 | danglingOpt = accptOpt.Key() 136 | } else { 137 | return args, opts, fmt.Errorf("non-boolean flag -%v in non-terminal position", string(char)) 138 | } 139 | matched = true 140 | break 141 | } 142 | } 143 | if !matched { 144 | return args, opts, fmt.Errorf("unknown flag -%v", string(char)) 145 | } 146 | } 147 | continue 148 | } 149 | 150 | args = append(args, arg) 151 | } 152 | if danglingOpt != "" { 153 | return args, opts, fmt.Errorf("dangling option --%s", danglingOpt) 154 | } 155 | return args, opts, nil 156 | } 157 | 158 | func assertArgs(expected []Arg, actual []string) error { 159 | if len(expected) == 0 || !expected[len(expected)-1].Optional() { 160 | if len(expected) > len(actual) { 161 | return fmt.Errorf("missing required argument %v", expected[len(actual)].Key()) 162 | } else if len(expected) < len(actual) { 163 | return fmt.Errorf("unknown arguments %v", actual[len(expected):]) 164 | } 165 | } 166 | for i, e := range expected { 167 | if len(actual) < i+1 { 168 | if !e.Optional() { 169 | return fmt.Errorf("missing required argument %s", e.Key()) 170 | } 171 | break 172 | } 173 | arg := actual[i] 174 | switch e.Type() { 175 | case TypeBool: 176 | if _, err := strconv.ParseBool(arg); err != nil { 177 | return fmt.Errorf("argument %s must be a boolean value, found %v", e.Key(), arg) 178 | } 179 | case TypeInt: 180 | if _, err := strconv.ParseInt(arg, 10, 64); err != nil { 181 | return fmt.Errorf("argument %s must be an integer value, found %v", e.Key(), arg) 182 | } 183 | case TypeNumber: 184 | if _, err := strconv.ParseFloat(arg, 64); err != nil { 185 | return fmt.Errorf("argument %s must be a number, found %v", e.Key(), arg) 186 | } 187 | } 188 | } 189 | return nil 190 | } 191 | 192 | func assertOpts(permitted []Option, actual map[string]string) error { 193 | for key, value := range actual { 194 | for _, p := range permitted { 195 | if p.Key() == key { 196 | switch p.Type() { 197 | case TypeInt: 198 | if _, err := strconv.ParseInt(value, 10, 64); err != nil { 199 | return fmt.Errorf("option --%s must be given an integer value, found %v", p.Key(), value) 200 | } 201 | case TypeNumber: 202 | if _, err := strconv.ParseFloat(value, 64); err != nil { 203 | return fmt.Errorf("option --%s must must be given a number, found %v", p.Key(), value) 204 | } 205 | } 206 | break 207 | } 208 | } 209 | } 210 | return nil 211 | } 212 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017. Oleg Sklyar & teris.io. All rights reserved. 2 | // See the LICENSE file in the project root for licensing information. 3 | 4 | // Package cli provides a simple, fast and complete API for building command line applications in Go. 5 | // In contrast to other libraries additional emphasis is put on the definition and validation of 6 | // positional arguments and consistent usage outputs combining options from all command levels into 7 | // one block. 8 | package cli 9 | 10 | import ( 11 | "fmt" 12 | "io" 13 | ) 14 | 15 | // Action defines a function type to be executed for an application or a 16 | // command. It takes a slice of validated positional arguments and a map 17 | // of validated options (with all value types encoded as strings) and 18 | // returns a Unix exit code (success: 0). 19 | type Action func(args []string, options map[string]string) int 20 | 21 | // App defines a CLI application parameterizable with sub-commands, arguments and options. 22 | type App interface { 23 | // Description returns the application description to be output in the usage. 24 | Description() string 25 | // Args returns required and optional positional arguments for the top-level application. 26 | Args() []Arg 27 | // Options permitted for the top-level application and all sub-commands. 28 | Options() []Option 29 | // Commands returns the set of first-level sub-commands for the application. 30 | Commands() []Command 31 | // Action returns the application action when no sub-command is specified. 32 | Action() Action 33 | 34 | // WithArg adds a positional argument to the application. Specifying last application/command 35 | // argument as optional permits unlimited number of further positional arguments (at least one 36 | // optional argument needs to be specified in the definition for this case). 37 | WithArg(arg Arg) App 38 | // WithOption adds a permitted option to the application and all sub-commands. 39 | WithOption(opt Option) App 40 | // WithCommand adds a first-level sub-command to the application. 41 | WithCommand(cmd Command) App 42 | // WithAction sets the action function to execute after successful parsing of commands, arguments 43 | // and options to the top-level application. 44 | WithAction(action Action) App 45 | 46 | // Parse parses the original application arguments into the command invocation path (application -> 47 | // first level command -> second level command etc.), a list of validated positional arguments matching 48 | // the command being invoked (the last one in the invocation path) and a map of validated options 49 | // matching one of the invocation path elements, from the top application down to the command being invoked. 50 | // An error is returned if a command is not found or arguments or options are invalid. In case of an error, 51 | // the invocation path is normally also computed and returned (the content of arguments and options is not 52 | // guaranteed). 53 | Parse(appargs []string) (invocation []string, args []string, opts map[string]string, err error) 54 | // Run parses the argument list and runs the command specified with the corresponding options and arguments. 55 | Run(appargs []string, w io.Writer) int 56 | // Usage prints out the full usage help. 57 | Usage(invocation []string, w io.Writer) error 58 | } 59 | 60 | // New creates a new CLI App. 61 | func New(descr string) App { 62 | return &app{descr: descr} 63 | } 64 | 65 | // ValueType defines the type of permitted argument and option values. 66 | type ValueType int 67 | 68 | // ValueType constants for string, boolean, int and number options and arguments. 69 | const ( 70 | TypeString ValueType = iota 71 | TypeBool 72 | TypeInt 73 | TypeNumber 74 | ) 75 | 76 | // Arg defines a positional argument. Arguments are validated for their 77 | // count and their type. If the last defined argument is optional, then 78 | // an unlimited number of arguments can be passed into the call, otherwise 79 | // an exact count of positional arguments is expected. Obviously, optional 80 | // arguments can be omitted. No validation is done for the invalid case of 81 | // specifying an optional positional argument before a required one. 82 | type Arg interface { 83 | // Key defines how the argument will be shown in the usage string. 84 | Key() string 85 | // Description returns the description of the argument usage 86 | Description() string 87 | // Type defines argument type. Default is string, which is not validated, 88 | // other types are validated by simple string parsing into boolean, int and float. 89 | Type() ValueType 90 | // Optional specifies that an argument may be omitted. No non-optional arguments 91 | // should follow an optional one (no validation for this scenario as this is 92 | // the definition time exception, rather than incorrect input at runtime). 93 | Optional() bool 94 | 95 | // WithType sets the argument type. 96 | WithType(at ValueType) Arg 97 | // AsOptional sets the argument as optional. 98 | AsOptional() Arg 99 | } 100 | 101 | // NewArg creates a new positional argument. 102 | func NewArg(key, descr string) Arg { 103 | return arg{key: key, descr: descr} 104 | } 105 | 106 | type app struct { 107 | descr string 108 | args []Arg 109 | opts []Option 110 | cmds []Command 111 | action Action 112 | } 113 | 114 | func (a *app) Description() string { 115 | return a.descr 116 | } 117 | 118 | func (a *app) Args() []Arg { 119 | return a.args 120 | } 121 | 122 | func (a *app) Options() []Option { 123 | return a.opts 124 | } 125 | 126 | func (a *app) Commands() []Command { 127 | return a.cmds 128 | } 129 | 130 | func (a *app) Action() Action { 131 | return a.action 132 | } 133 | 134 | func (a *app) WithArg(arg Arg) App { 135 | a.args = append(a.args, arg) 136 | return a 137 | } 138 | 139 | func (a *app) WithOption(opt Option) App { 140 | a.opts = append(a.opts, opt) 141 | return a 142 | } 143 | 144 | func (a *app) WithCommand(cmd Command) App { 145 | a.cmds = append(a.cmds, cmd) 146 | return a 147 | } 148 | func (a *app) WithAction(action Action) App { 149 | a.action = action 150 | return a 151 | } 152 | 153 | func (a *app) Parse(appargs []string) (invocation []string, args []string, opts map[string]string, err error) { 154 | return Parse(a, appargs) 155 | } 156 | 157 | func (a *app) Run(appargs []string, w io.Writer) int { 158 | invocation, args, opts, err := a.Parse(appargs) 159 | _, help := opts["help"] 160 | code := 1 161 | if err == nil && help { 162 | a.Usage(invocation, w) 163 | code = 0 164 | } else if err != nil { 165 | fmt.Fprintf(w, "fatal: %v\n", err) 166 | fmt.Fprintf(w, "usage: %v\n", shortUsage(a, invocation)) 167 | } else { 168 | action := a.Action() 169 | if len(invocation) > 1 { 170 | cmds := a.Commands() 171 | for i, key := range invocation[1:] { 172 | matched := false 173 | for _, cmd := range cmds { 174 | if cmd.Key() == key { 175 | cmds = cmd.Commands() 176 | matched = true 177 | if i == len(invocation)-2 { 178 | action = cmd.Action() 179 | } 180 | break 181 | } 182 | } 183 | // should never happen if invocation originates from the parser 184 | if !matched { 185 | fmt.Fprintf(w, "fatal: invalid invocation path %v\n", invocation) 186 | fmt.Fprintf(w, "usage: %v\n", shortUsage(a, invocation[:1])) 187 | action = nil 188 | break 189 | } 190 | } 191 | } 192 | if action != nil { 193 | code = action(args, opts) 194 | } else { 195 | a.Usage(invocation, w) 196 | code = 1 197 | } 198 | } 199 | return code 200 | } 201 | 202 | func (a *app) Usage(invocation []string, w io.Writer) error { 203 | return Usage(a, invocation, w) 204 | } 205 | 206 | type arg struct { 207 | key string 208 | descr string 209 | at ValueType 210 | optional bool 211 | } 212 | 213 | func (a arg) Key() string { 214 | return a.key 215 | } 216 | 217 | func (a arg) Description() string { 218 | return a.descr 219 | } 220 | 221 | func (a arg) Type() ValueType { 222 | return a.at 223 | } 224 | 225 | func (a arg) Optional() bool { 226 | return a.optional 227 | } 228 | 229 | func (a arg) WithType(at ValueType) Arg { 230 | a.at = at 231 | return a 232 | } 233 | 234 | func (a arg) AsOptional() Arg { 235 | a.optional = true 236 | return a 237 | } 238 | -------------------------------------------------------------------------------- /parse_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017. Oleg Sklyar & teris.io. All rights reserved. 2 | // See the LICENSE file in the project root for licensing information. 3 | 4 | package cli_test 5 | 6 | import ( 7 | "fmt" 8 | "sort" 9 | "testing" 10 | 11 | "github.com/teris-io/cli" 12 | ) 13 | 14 | func setuParseApp() cli.App { 15 | co := cli.NewCommand("checkout", "checkout a branch or revision"). 16 | WithShortcut("co"). 17 | WithArg(cli.NewArg("branch", "branch to checkout")). 18 | WithOption(cli.NewOption("branch", "Create branch").WithChar('b').WithType(cli.TypeBool)). 19 | WithOption(cli.NewOption("upstream", "Set upstream").WithChar('u').WithType(cli.TypeBool)). 20 | WithOption(cli.NewOption("fallback", "Set upstream").WithChar('f')). 21 | WithOption(cli.NewOption("count", "Count").WithChar('c').WithType(cli.TypeInt)). 22 | WithOption(cli.NewOption("pi", "Set upstream").WithChar('p').WithType(cli.TypeNumber)). 23 | WithOption(cli.NewOption("str", "Count").WithChar('s')) 24 | 25 | add := cli.NewCommand("add", "add a remote"). 26 | WithArg(cli.NewArg("remote", "remote to add")). 27 | WithArg(cli.NewArg("count", "whatever").WithType(cli.TypeInt)). 28 | WithArg(cli.NewArg("pi", "whatever").WithType(cli.TypeNumber)). 29 | WithArg(cli.NewArg("force", "whatever").WithType(cli.TypeBool)). 30 | WithArg(cli.NewArg("optional", "whatever").WithType(cli.TypeBool).AsOptional()). 31 | WithArg(cli.NewArg("passthrough", "passthrough").AsOptional()). 32 | WithOption(cli.NewOption("force", "Force").WithChar('f').WithType(cli.TypeBool)). 33 | WithOption(cli.NewOption("quiet", "Quiet").WithChar('q').WithType(cli.TypeBool)). 34 | WithOption(cli.NewOption("default", "Default")) 35 | 36 | rmt := cli.NewCommand("remote", "operations with remotes").WithCommand(add) 37 | 38 | return cli.New("git tool"). 39 | WithArg(cli.NewArg("arg1", "whatever")). 40 | WithCommand(co). 41 | WithCommand(rmt) 42 | } 43 | 44 | func TestApp_Parse_DropsPathFromAppName_Ok(t *testing.T) { 45 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"~/some/path/git", "checkout", "dev"}) 46 | assertAppParseOk(t, "[git checkout] [dev] map[]", invocation, args, opts, err) 47 | } 48 | 49 | func TestApp_Parse_DropsDotPathFromAppName_Ok(t *testing.T) { 50 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"./git", "checkout", "dev"}) 51 | assertAppParseOk(t, "[git checkout] [dev] map[]", invocation, args, opts, err) 52 | } 53 | 54 | func TestApp_Parse_NoFlags_Ok(t *testing.T) { 55 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "dev"}) 56 | assertAppParseOk(t, "[git checkout] [dev] map[]", invocation, args, opts, err) 57 | } 58 | 59 | func TestApp_Parse_1xCharBoolFlag_Ok(t *testing.T) { 60 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-b", "dev"}) 61 | assertAppParseOk(t, "[git checkout] [dev] map[branch:true]", invocation, args, opts, err) 62 | } 63 | 64 | func TestApp_Parse_2xCharBoolFlags_Ok(t *testing.T) { 65 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-b", "-u", "dev"}) 66 | assertAppParseOk(t, "[git checkout] [dev] map[branch:true upstream:true]", invocation, args, opts, err) 67 | } 68 | 69 | func TestApp_Parse_2xCharBoolFlagsAsOne_Ok(t *testing.T) { 70 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-bu", "dev"}) 71 | assertAppParseOk(t, "[git checkout] [dev] map[branch:true upstream:true]", invocation, args, opts, err) 72 | } 73 | 74 | func TestApp_Parse_MultiCharStringLast_Ok(t *testing.T) { 75 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-buf", "master", "dev"}) 76 | assertAppParseOk(t, "[git checkout] [dev] map[branch:true fallback:master upstream:true]", invocation, args, opts, err) 77 | } 78 | 79 | func TestApp_Parse_MultiCharIntLast_Ok(t *testing.T) { 80 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-buc", "1", "dev"}) 81 | assertAppParseOk(t, "[git checkout] [dev] map[branch:true count:1 upstream:true]", invocation, args, opts, err) 82 | } 83 | 84 | func TestApp_Parse_MultiCharNumberLast_Ok(t *testing.T) { 85 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-bup", "3.14", "dev"}) 86 | assertAppParseOk(t, "[git checkout] [dev] map[branch:true pi:3.14 upstream:true]", invocation, args, opts, err) 87 | } 88 | 89 | func TestApp_Parse_1xBoolFlag_Ok(t *testing.T) { 90 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "--branch", "dev"}) 91 | assertAppParseOk(t, "[git checkout] [dev] map[branch:true]", invocation, args, opts, err) 92 | } 93 | 94 | func TestApp_Parse_2xBoolFlag_Ok(t *testing.T) { 95 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "--branch", "--upstream", "dev"}) 96 | assertAppParseOk(t, "[git checkout] [dev] map[branch:true upstream:true]", invocation, args, opts, err) 97 | } 98 | 99 | func TestApp_Parse_2xBoolAnd1xStringFlag_Ok(t *testing.T) { 100 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "--fallback=master", "--branch", "--upstream", "dev"}) 101 | assertAppParseOk(t, "[git checkout] [dev] map[branch:true fallback:master upstream:true]", invocation, args, opts, err) 102 | } 103 | 104 | func TestApp_Parse_RedundantFlags_Ok(t *testing.T) { 105 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-b", "--branch", "dev"}) 106 | assertAppParseOk(t, "[git checkout] [dev] map[branch:true]", invocation, args, opts, err) 107 | } 108 | 109 | func TestApp_Parse_NestedCommandWithFlags_Ok(t *testing.T) { 110 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "origin", "-f", "1", "3.14", "true"}) 111 | assertAppParseOk(t, "[git remote add] [origin 1 3.14 true] map[force:true]", invocation, args, opts, err) 112 | } 113 | 114 | func TestApp_Parse_OptionalMissing_Ok(t *testing.T) { 115 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "origin", "1", "3.14", "true"}) 116 | assertAppParseOk(t, "[git remote add] [origin 1 3.14 true] map[]", invocation, args, opts, err) 117 | } 118 | 119 | func TestApp_Parse_OptionalPresent_Ok(t *testing.T) { 120 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "origin", "1", "3.14", "true", "true"}) 121 | assertAppParseOk(t, "[git remote add] [origin 1 3.14 true true] map[]", invocation, args, opts, err) 122 | } 123 | 124 | func TestApp_Parse_KeysAnywhereBetweenArgs_Ok(t *testing.T) { 125 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "-f", "origin", "--default=foo", "1", "3.14", "true", "-q"}) 126 | assertAppParseOk(t, "[git remote add] [origin 1 3.14 true] map[default:foo force:true quiet:true]", invocation, args, opts, err) 127 | } 128 | 129 | func TestApp_Parse_AfterDashDash_TakesAsIs_Ok(t *testing.T) { 130 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "origin", "1", "3.14", "true", "false", "--", "-j", "24", "doit"}) 131 | assertAppParseOk(t, "[git remote add] [origin 1 3.14 true false -j 24 doit] map[]", invocation, args, opts, err) 132 | } 133 | 134 | func TestApp_Parse_ExplicitValueForBoolOption_Error(t *testing.T) { 135 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "--force=true", "origin", "1", "3.14", "true"}) 136 | assertAppParseError(t, "[git remote add] [] map[]", 137 | "boolean options have true assigned implicitly, found value for --force", invocation, args, opts, err) 138 | } 139 | 140 | func TestApp_Parse_EqSignInStringOptionValue_Ok(t *testing.T) { 141 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "--default=foo=boo=blah", "origin", "1", "3.14", "true"}) 142 | assertAppParseOk(t, "[git remote add] [origin 1 3.14 true] map[default:foo=boo=blah]", invocation, args, opts, err) 143 | } 144 | 145 | func TestApp_Parse_UnrecognizedCommand_ErrorUnknownFlagForRoot(t *testing.T) { 146 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "foo", "-f", "origin"}) 147 | assertAppParseError(t, "[git] [foo] map[]", "unknown flag -f", invocation, args, opts, err) 148 | } 149 | 150 | func TestApp_Parse_UnrecognizedCommand_ErrorUnknownArgument(t *testing.T) { 151 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "foo", "origin"}) 152 | assertAppParseError(t, "[git] [foo origin] map[]", "unknown arguments [origin]", invocation, args, opts, err) 153 | } 154 | 155 | func TestApp_Parse_BaseApp_ErrorMissingArgument(t *testing.T) { 156 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git"}) 157 | assertAppParseError(t, "[git] [] map[]", "missing required argument arg1", invocation, args, opts, err) 158 | } 159 | 160 | func TestApp_Parse_DanglingOptions_Error(t *testing.T) { 161 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "dev", "-p"}) 162 | assertAppParseError(t, "[git checkout] [dev] map[]", "dangling option --pi", invocation, args, opts, err) 163 | } 164 | 165 | func TestApp_Parse_LastArgOptionalRequiredMissing_Error(t *testing.T) { 166 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "origin", "1"}) 167 | assertAppParseError(t, "[git remote add] [origin 1] map[]", "missing required argument pi", invocation, args, opts, err) 168 | } 169 | 170 | func TestApp_Parse_IncorrectBoolArgType_Error(t *testing.T) { 171 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "origin", "1", "3.14", "foo"}) 172 | assertAppParseError(t, "[git remote add] [origin 1 3.14 foo] map[]", "argument force must be a boolean value, found foo", invocation, args, opts, err) 173 | } 174 | 175 | func TestApp_Parse_IncorrectIntArgType_Error(t *testing.T) { 176 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "origin", "3.14", "3.14", "true"}) 177 | assertAppParseError(t, "[git remote add] [origin 3.14 3.14 true] map[]", "argument count must be an integer value, found 3.14", invocation, args, opts, err) 178 | } 179 | 180 | func TestApp_Parse_IncorrectNumberArgType_Error(t *testing.T) { 181 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "origin", "1", "aaa", "true"}) 182 | assertAppParseError(t, "[git remote add] [origin 1 aaa true] map[]", "argument pi must be a number, found aaa", invocation, args, opts, err) 183 | } 184 | 185 | func TestApp_Parse_IncorrectOptionalArgType_Error(t *testing.T) { 186 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "origin", "1", "3.14", "true", "25"}) 187 | assertAppParseError(t, "[git remote add] [origin 1 3.14 true 25] map[]", "argument optional must be a boolean value, found 25", invocation, args, opts, err) 188 | } 189 | 190 | func TestApp_Parse_NonBooleanFlagInNonTerminalPosition_Error(t *testing.T) { 191 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-bpu", "3.14", "dev"}) 192 | assertAppParseError(t, "[git checkout] [] map[branch:true]", "non-boolean flag -p in non-terminal position", invocation, args, opts, err) 193 | } 194 | 195 | func TestApp_Parse_MissingValueForOption_Error(t *testing.T) { 196 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "--pi", "dev"}) 197 | assertAppParseError(t, "[git checkout] [] map[]", "missing value for option --pi", invocation, args, opts, err) 198 | } 199 | 200 | func TestApp_Parse_NoValueAfterTheEqualSignForStringOption_Ok(t *testing.T) { 201 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "--str=", "dev"}) 202 | assertAppParseOk(t, "[git checkout] [dev] map[str:]", invocation, args, opts, err) 203 | } 204 | 205 | func TestApp_Parse_UnknownOption_Error(t *testing.T) { 206 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "--foo=25", "dev"}) 207 | assertAppParseError(t, "[git checkout] [] map[]", "unknown option --foo", invocation, args, opts, err) 208 | } 209 | 210 | func TestApp_Parse_IncorrectDataForIntOption_Error(t *testing.T) { 211 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-c", "2.25", "dev"}) 212 | assertAppParseError(t, "[git checkout] [dev] map[count:2.25]", "option --count must be given an integer value, found 2.25", invocation, args, opts, err) 213 | } 214 | 215 | func TestApp_Parse_IncorrectDataForNumberOption_Error(t *testing.T) { 216 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-p", "aaa", "dev"}) 217 | assertAppParseError(t, "[git checkout] [dev] map[pi:aaa]", "option --pi must must be given a number, found aaa", invocation, args, opts, err) 218 | } 219 | 220 | func TestApp_Parse_LastArgOptionalPermitsUnlimitedExtraArgs_Error(t *testing.T) { 221 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "remote", "add", "origin", "1", "3.14", "true", "true", "exra1", "extra2"}) 222 | assertAppParseOk(t, "[git remote add] [origin 1 3.14 true true exra1 extra2] map[]", invocation, args, opts, err) 223 | } 224 | 225 | func TestApp_Parse_HelpOptionComesOutWithoutArgOrFlagValidation_Ok(t *testing.T) { 226 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-c", "3.15", "dev", "arg2", "arg3", "--help"}) 227 | assertAppParseOk(t, "[git checkout] [] map[help:true]", invocation, args, opts, err) 228 | } 229 | 230 | func TestApp_Parse_HelpFlagInMultichar_Ok(t *testing.T) { 231 | invocation, args, opts, err := cli.Parse(setuParseApp(), []string{"git", "checkout", "-bhc", "3.15", "dev"}) 232 | assertAppParseOk(t, "[git checkout] [] map[help:true]", invocation, args, opts, err) 233 | } 234 | 235 | func assertAppParseOk(t *testing.T, expected string, invocation []string, args []string, opts map[string]string, err error) { 236 | if err == nil { 237 | optkeys := []string{} 238 | for key := range opts { 239 | optkeys = append(optkeys, key) 240 | } 241 | sort.Strings(optkeys) 242 | for i, key := range optkeys { 243 | optkeys[i] = fmt.Sprintf("%s:%s", key, opts[key]) 244 | } 245 | actual := fmt.Sprintf("%v %v map%v", invocation, args, optkeys) 246 | if actual != expected { 247 | t.Errorf("assertion error: expected '%v', found '%v'", expected, actual) 248 | } 249 | } else { 250 | t.Errorf("no error expected, found '%v'; data %v %v %v", err, invocation, args, opts) 251 | } 252 | } 253 | 254 | func assertAppParseError(t *testing.T, expectedData, expectedError string, invocation []string, args []string, opts map[string]string, err error) { 255 | optkeys := []string{} 256 | for key := range opts { 257 | optkeys = append(optkeys, key) 258 | } 259 | sort.Strings(optkeys) 260 | for i, key := range optkeys { 261 | optkeys[i] = fmt.Sprintf("%s:%s", key, opts[key]) 262 | } 263 | actual := fmt.Sprintf("%v %v map%v", invocation, args, optkeys) 264 | if actual != expectedData { 265 | t.Errorf("assertion error: expectedData '%v', found '%v'", expectedData, actual) 266 | } 267 | if err == nil { 268 | t.Error("an error was expected") 269 | } else if expectedError != err.Error() { 270 | t.Errorf("error mismatch, expected: '%v', found '%v'", expectedError, err.Error()) 271 | } 272 | } 273 | --------------------------------------------------------------------------------