From 2ca6c6ae68542fcd6c07270c4a2373c4cdc063c8 Mon Sep 17 00:00:00 2001 From: Nicholas Whyte Date: Fri, 18 Dec 2015 13:52:35 +1100 Subject: [PATCH] Add Shell completion Support --- README.md | 95 ++++++++++++++++++ app.go | 185 +++++++++++++++++++++++++++++------- app_test.go | 126 ++++++++++++++++++++++++ cmd.go | 82 ++++++++++++++-- cmd_test.go | 102 +++++++++++++++++++- completions.go | 33 +++++++ completions_test.go | 78 +++++++++++++++ examples/completion/main.go | 96 +++++++++++++++++++ flags.go | 29 ++++++ flags_test.go | 110 +++++++++++++++++++++ parser.go | 2 + templates.go | 29 ++++++ 12 files changed, 922 insertions(+), 45 deletions(-) create mode 100644 completions.go create mode 100644 completions_test.go create mode 100644 examples/completion/main.go diff --git a/README.md b/README.md index d2e1358..f9e8115 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - [Default Values](#default-values) - [Place-holders in Help](#place-holders-in-help) - [Consuming all remaining arguments](#consuming-all-remaining-arguments) + - [Bash/ZSH Shell Completion](#bashzsh-shell-completion) - [Supporting -h for help](#supporting--h-for-help) - [Custom help](#custom-help) @@ -508,6 +509,100 @@ And use it like so: ips := IPList(kingpin.Arg("ips", "IP addresses to ping.")) ``` +### Bash/ZSH Shell Completion + +By default, all flags and commands/subcommands generate completions +internally. + +Out of the box, CLI tools using kingpin should be able to take advantage +of completion hinting for flags and commands. By specifying +`--completion-bash` as the first argument, your CLI tool will show +possible subcommands. By ending your argv with `--`, hints for flags +will be shown. + +To allow your end users to take advantage you must package a +`/etc/bash_completion.d` script with your distribution (or the equivalent +for your target platform/shell). An alternative is to instruct your end +user to source a script from their `bash_profile` (or equivalent). + +Fortunately Kingpin makes it easy to generate or source a script for use +with end users shells. `./yourtool --completion-script-bash` and +`./yourtool --completion-script-zsh` will generate these scripts for you. + +**Installation by Package** + +For the best user experience, you should bundle your pre-created +completion script with your CLI tool and install it inside +`/etc/bash_completion.d` (or equivalent). A good suggestion is to add +this as an automated step to your build pipeline, in the implementation +is improved for bug fixed. + +**Installation by `bash_profile`** + +Alternatively, instruct your users to add an additional statement to +their `bash_profile` (or equivalent): + +``` +eval "$(your-cli-tool --completion-script-bash)" +``` + +Or for ZSH + +``` +eval "$(your-cli-tool --completion-script-zsh)" +``` + +#### Additional API +To provide more flexibility, a completion option API has been +exposed for flags to allow user defined completion options, to extend +completions further than just EnumVar/Enum. + + +**Provide Static Options** + +When using an `Enum` or `EnumVar`, users are limited to only the options +given. Maybe we wish to hint possible options to the user, but also +allow them to provide their own custom option. `HintOptions` gives +this functionality to flags. + +``` +app := kingpin.New("completion", "My application with bash completion.") +app.Flag("port", "Provide a port to connect to"). + Required(). + HintOptions("80", "443", "8080"). + IntVar(&c.port) +``` + +**Provide Dynamic Options** +Consider the case that you needed to read a local database or a file to +provide suggestions. You can dynamically generate the options + +``` +func listHosts(args []string) []string { + // Provide a dynamic list of hosts from a hosts file or otherwise + // for bash completion. In this example we simply return static slice. + + // You could use this functionality to reach into a hosts file to provide + // completion for a list of known hosts. + return []string{"sshhost.example", "webhost.example", "ftphost.example"} +} + +app := kingpin.New("completion", "My application with bash completion.") +app.Flag("flag-1", "").HintAction(listHosts).String() +``` + +**EnumVar/Enum** +When using `Enum` or `EnumVar`, any provided options will be automatically +used for bash autocompletion. However, if you wish to provide a subset or +different options, you can use `HintOptions` or `HintAction` which will override +the default completion options for `Enum`/`EnumVar`. + + +**Examples** +You can see an in depth example of the completion API within +`examples/completion/main.go` + + ### Supporting -h for help `kingpin.CommandLine.HelpFlag.Short('h')` diff --git a/app.go b/app.go index 2e2c5a0..9909131 100644 --- a/app.go +++ b/app.go @@ -21,10 +21,7 @@ type ApplicationValidator func(*Application) error // An Application contains the definitions of flags, arguments and commands // for an application. type Application struct { - *flagGroup - *argGroup - *cmdGroup - actionMixin + cmdMixin initialized bool Name string @@ -38,6 +35,7 @@ type Application struct { terminate func(status int) // See Terminate() noInterspersed bool // can flags be interspersed with args (or must they come first) defaultEnvars bool + completion bool // Help flag. Exposed for user customisation. HelpFlag *FlagClause @@ -50,19 +48,23 @@ type Application struct { // New creates a new Kingpin application instance. func New(name, help string) *Application { a := &Application{ - flagGroup: newFlagGroup(), - argGroup: newArgGroup(), Name: name, Help: help, writer: os.Stderr, usageTemplate: DefaultUsageTemplate, terminate: os.Exit, } + a.flagGroup = newFlagGroup() + a.argGroup = newArgGroup() a.cmdGroup = newCmdGroup(a) a.HelpFlag = a.Flag("help", "Show context-sensitive help (also try --help-long and --help-man).") a.HelpFlag.Bool() a.Flag("help-long", "Generate long help.").Hidden().PreAction(a.generateLongHelp).Bool() a.Flag("help-man", "Generate a man page.").Hidden().PreAction(a.generateManPage).Bool() + a.Flag("completion-bash", "Output possible completions for the given args.").Hidden().BoolVar(&a.completion) + a.Flag("completion-script-bash", "Generate completion script for bash.").Hidden().PreAction(a.generateBashCompletionScript).Bool() + a.Flag("completion-script-zsh", "Generate completion script for ZSH.").Hidden().PreAction(a.generateZSHCompletionScript).Bool() + return a } @@ -84,6 +86,24 @@ func (a *Application) generateManPage(c *ParseContext) error { return nil } +func (a *Application) generateBashCompletionScript(c *ParseContext) error { + a.Writer(os.Stdout) + if err := a.UsageForContextWithTemplate(c, 2, BashCompletionTemplate); err != nil { + return err + } + a.terminate(0) + return nil +} + +func (a *Application) generateZSHCompletionScript(c *ParseContext) error { + a.Writer(os.Stdout) + if err := a.UsageForContextWithTemplate(c, 2, ZshCompletionTemplate); err != nil { + return err + } + a.terminate(0) + return nil +} + // DefaultEnvars configures all flags (that do not already have an associated // envar) to use a default environment variable in the form "_". // @@ -145,17 +165,48 @@ func (a *Application) parseContext(ignoreDefault bool, args []string) (*ParseCon // This will populate all flag and argument values, call all callbacks, and so // on. func (a *Application) Parse(args []string) (command string, err error) { - context, err := a.ParseContext(args) - if err != nil { + + context, parseErr := a.ParseContext(args) + selected := []string{} + var setValuesErr error + + if context == nil { + // Since we do not throw error immediately, there could be a case + // where a context returns nil. Protect against that. + return "", parseErr + } + + if err = a.setDefaults(context); err != nil { return "", err } - a.maybeHelp(context) - if !context.EOL() { - return "", fmt.Errorf("unexpected argument '%s'", context.Peek()) + + selected, setValuesErr = a.setValues(context) + + if err = a.applyPreActions(context, !a.completion); err != nil { + return "", err } - command, err = a.execute(context) - if err == ErrCommandNotSpecified { - a.writeUsage(context, nil) + + if a.completion { + a.generateBashCompletion(context) + a.terminate(0) + } else { + if parseErr != nil { + return "", parseErr + } + + a.maybeHelp(context) + if !context.EOL() { + return "", fmt.Errorf("unexpected argument '%s'", context.Peek()) + } + + if setValuesErr != nil { + return "", setValuesErr + } + + command, err = a.execute(context, selected) + if err == ErrCommandNotSpecified { + a.writeUsage(context, nil) + } } return command, err } @@ -325,22 +376,8 @@ func checkDuplicateFlags(current *CmdClause, flagGroups []*flagGroup) error { return nil } -func (a *Application) execute(context *ParseContext) (string, error) { +func (a *Application) execute(context *ParseContext, selected []string) (string, error) { var err error - selected := []string{} - - if err = a.setDefaults(context); err != nil { - return "", err - } - - selected, err = a.setValues(context) - if err != nil { - return "", err - } - - if err = a.applyPreActions(context); err != nil { - return "", err - } if err = a.validateRequired(context); err != nil { return "", err @@ -492,18 +529,21 @@ func (a *Application) applyValidators(context *ParseContext) (err error) { return err } -func (a *Application) applyPreActions(context *ParseContext) error { +func (a *Application) applyPreActions(context *ParseContext, dispatch bool) error { if err := a.actionMixin.applyPreActions(context); err != nil { return err } // Dispatch to actions. - for _, element := range context.Elements { - if applier, ok := element.Clause.(actionApplier); ok { - if err := applier.applyPreActions(context); err != nil { - return err + if dispatch { + for _, element := range context.Elements { + if applier, ok := element.Clause.(actionApplier); ok { + if err := applier.applyPreActions(context); err != nil { + return err + } } } } + return nil } @@ -564,6 +604,83 @@ func (a *Application) FatalIfError(err error, format string, args ...interface{} } } +func (a *Application) completionOptions(context *ParseContext) []string { + args := context.rawArgs + + var ( + currArg string + prevArg string + target cmdMixin + ) + + numArgs := len(args) + if numArgs > 1 { + args = args[1:] + currArg = args[len(args)-1] + } + if numArgs > 2 { + prevArg = args[len(args)-2] + } + + target = a.cmdMixin + if context.SelectedCommand != nil { + // A subcommand was in use. We will use it as the target + target = context.SelectedCommand.cmdMixin + } + + if (currArg != "" && strings.HasPrefix(currArg, "--")) || strings.HasPrefix(prevArg, "--") { + // Perform completion for A flag. The last/current argument started with "-" + var ( + flagName string // The name of a flag if given (could be half complete) + flagValue string // The value assigned to a flag (if given) (could be half complete) + ) + + if strings.HasPrefix(prevArg, "--") && !strings.HasPrefix(currArg, "--") { + // Matches: ./myApp --flag value + // Wont Match: ./myApp --flag -- + flagName = prevArg[2:] // Strip the "--" + flagValue = currArg + } else if strings.HasPrefix(currArg, "--") { + // Matches: ./myApp --flag -- + // Matches: ./myApp --flag somevalue -- + // Matches: ./myApp -- + flagName = currArg[2:] // Strip the "--" + } + + options, flagMatched, valueMatched := target.FlagCompletion(flagName, flagValue) + if valueMatched { + // Value Matched. Show cmdCompletions + return target.CmdCompletion() + } + + // Add top level flags if we're not at the top level and no match was found. + if context.SelectedCommand != nil && flagMatched == false { + topOptions, topFlagMatched, topValueMatched := a.FlagCompletion(flagName, flagValue) + if topValueMatched { + // Value Matched. Back to cmdCompletions + return target.CmdCompletion() + } + + if topFlagMatched { + // Top level had a flag which matched the input. Return it's options. + options = topOptions + } else { + // Add top level flags + options = append(options, topOptions...) + } + } + return options + } else { + // Perform completion for sub commands. + return target.CmdCompletion() + } +} + +func (a *Application) generateBashCompletion(context *ParseContext) { + options := a.completionOptions(context) + fmt.Printf("%s", strings.Join(options, "\n")) +} + func envarTransform(name string) string { return strings.ToUpper(envarTransformRegexp.ReplaceAllString(name, "_")) } diff --git a/app_test.go b/app_test.go index c2d66a1..75860f3 100644 --- a/app_test.go +++ b/app_test.go @@ -5,6 +5,8 @@ import ( "github.com/alecthomas/assert" + "sort" + "strings" "testing" "time" ) @@ -221,3 +223,127 @@ func TestDefaultEnvars(t *testing.T) { assert.Equal(t, "SOME_APP_SOME_FLAG", f0.envar) assert.Equal(t, "", f1.envar) } + +func TestBashCompletionOptionsWithEmptyApp(t *testing.T) { + a := newTestApp() + context, err := a.ParseContext([]string{"--completion-bash"}) + if err != nil { + t.Errorf("Unexpected error whilst parsing context: [%v]", err) + } + args := a.completionOptions(context) + assert.Equal(t, []string(nil), args) +} + +func TestBashCompletionOptions(t *testing.T) { + a := newTestApp() + a.Command("one", "") + a.Flag("flag-0", "").String() + a.Flag("flag-1", "").HintOptions("opt1", "opt2", "opt3").String() + + two := a.Command("two", "") + two.Flag("flag-2", "").String() + two.Flag("flag-3", "").HintOptions("opt4", "opt5", "opt6").String() + + cases := []struct { + Args string + ExpectedOptions []string + }{ + { + Args: "--completion-bash", + ExpectedOptions: []string{"help", "one", "two"}, + }, + { + Args: "--completion-bash --", + ExpectedOptions: []string{"--flag-0", "--flag-1", "--help"}, + }, + { + Args: "--completion-bash --fla", + ExpectedOptions: []string{"--flag-0", "--flag-1", "--help"}, + }, + { + // No options available for flag-0, return to cmd completion + Args: "--completion-bash --flag-0", + ExpectedOptions: []string{"help", "one", "two"}, + }, + { + Args: "--completion-bash --flag-0 --", + ExpectedOptions: []string{"--flag-0", "--flag-1", "--help"}, + }, + { + Args: "--completion-bash --flag-1", + ExpectedOptions: []string{"opt1", "opt2", "opt3"}, + }, + { + Args: "--completion-bash --flag-1 opt", + ExpectedOptions: []string{"opt1", "opt2", "opt3"}, + }, + { + Args: "--completion-bash --flag-1 opt1", + ExpectedOptions: []string{"help", "one", "two"}, + }, + { + Args: "--completion-bash --flag-1 opt1 --", + ExpectedOptions: []string{"--flag-0", "--flag-1", "--help"}, + }, + + // Try Subcommand + { + Args: "--completion-bash two", + ExpectedOptions: []string(nil), + }, + { + Args: "--completion-bash two --", + ExpectedOptions: []string{"--help", "--flag-2", "--flag-3", "--flag-0", "--flag-1"}, + }, + { + Args: "--completion-bash two --flag", + ExpectedOptions: []string{"--help", "--flag-2", "--flag-3", "--flag-0", "--flag-1"}, + }, + { + Args: "--completion-bash two --flag-2", + ExpectedOptions: []string(nil), + }, + { + // Top level flags carry downwards + Args: "--completion-bash two --flag-1", + ExpectedOptions: []string{"opt1", "opt2", "opt3"}, + }, + { + // Top level flags carry downwards + Args: "--completion-bash two --flag-1 opt", + ExpectedOptions: []string{"opt1", "opt2", "opt3"}, + }, + { + // Top level flags carry downwards + Args: "--completion-bash two --flag-1 opt1", + ExpectedOptions: []string(nil), + }, + { + Args: "--completion-bash two --flag-3", + ExpectedOptions: []string{"opt4", "opt5", "opt6"}, + }, + { + Args: "--completion-bash two --flag-3 opt", + ExpectedOptions: []string{"opt4", "opt5", "opt6"}, + }, + { + Args: "--completion-bash two --flag-3 opt4", + ExpectedOptions: []string(nil), + }, + { + Args: "--completion-bash two --flag-3 opt4 --", + ExpectedOptions: []string{"--help", "--flag-2", "--flag-3", "--flag-0", "--flag-1"}, + }, + } + + for _, c := range cases { + context, _ := a.ParseContext(strings.Split(c.Args, " ")) + args := a.completionOptions(context) + + sort.Strings(args) + sort.Strings(c.ExpectedOptions) + + assert.Equal(t, c.ExpectedOptions, args, "Expected != Actual: [%v] != [%v]. \nInput was: [%v]", c.ExpectedOptions, args, c.Args) + } + +} diff --git a/cmd.go b/cmd.go index 9bbc793..b8cbb9b 100644 --- a/cmd.go +++ b/cmd.go @@ -5,6 +5,71 @@ import ( "strings" ) +type cmdMixin struct { + *flagGroup + *argGroup + *cmdGroup + actionMixin +} + +func (c *cmdMixin) CmdCompletion() []string { + rv := []string{} + if len(c.cmdGroup.commandOrder) > 0 { + // This command has subcommands. We should + // show these to the user. + for _, option := range c.cmdGroup.commandOrder { + rv = append(rv, option.name) + } + } else { + // No subcommands + rv = nil + } + return rv +} + +func (c *cmdMixin) FlagCompletion(flagName string, flagValue string) (choices []string, flagMatch bool, optionMatch bool) { + // Check if flagName matches a known flag. + // If it does, show the options for the flag + // Otherwise, show all flags + + options := []string{} + + for _, flag := range c.flagGroup.flagOrder { + // Loop through each flag and determine if a match exists + if flag.name == flagName { + // User typed entire flag. Need to look for flag options. + options = flag.resolveCompletions() + if len(options) == 0 { + // No Options to Choose From, Assume Match. + return options, true, true + } + + // Loop options to find if the user specified value matches + isPrefix := false + matched := false + + for _, opt := range options { + if flagValue == opt { + matched = true + } else if strings.HasPrefix(opt, flagValue) { + isPrefix = true + } + } + + // Matched Flag Directly + // Flag Value Not Prefixed, and Matched Directly + return options, true, !isPrefix && matched + } + + if !flag.hidden { + options = append(options, "--"+flag.name) + } + } + // No Flag directly matched. + return options, false, false + +} + type cmdGroup struct { app *Application parent *CmdClause @@ -92,10 +157,7 @@ type CmdClauseValidator func(*CmdClause) error // A CmdClause is a single top-level command. It encapsulates a set of flags // and either subcommands or positional arguments. type CmdClause struct { - actionMixin - *flagGroup - *argGroup - *cmdGroup + cmdMixin app *Application name string aliases []string @@ -107,13 +169,13 @@ type CmdClause struct { func newCommand(app *Application, name, help string) *CmdClause { c := &CmdClause{ - flagGroup: newFlagGroup(), - argGroup: newArgGroup(), - cmdGroup: newCmdGroup(app), - app: app, - name: name, - help: help, + app: app, + name: name, + help: help, } + c.flagGroup = newFlagGroup() + c.argGroup = newArgGroup() + c.cmdGroup = newCmdGroup(app) return c } diff --git a/cmd_test.go b/cmd_test.go index 55931f9..7d59b8d 100644 --- a/cmd_test.go +++ b/cmd_test.go @@ -12,7 +12,13 @@ func parseAndExecute(app *Application, context *ParseContext) (string, error) { if err := parse(context, app); err != nil { return "", err } - return app.execute(context) + + selected, err := app.setValues(context) + if err != nil { + return "", err + } + + return app.execute(context, selected) } func TestNestedCommands(t *testing.T) { @@ -174,3 +180,97 @@ func TestDuplicateAlias(t *testing.T) { _, err := app.Parse([]string{"one"}) assert.Error(t, err) } + +func TestFlagCompletion(t *testing.T) { + app := newTestApp() + app.Command("one", "") + two := app.Command("two", "") + two.Flag("flag-1", "") + two.Flag("flag-2", "").HintOptions("opt1", "opt2", "opt3") + two.Flag("flag-3", "") + + cases := []struct { + target cmdMixin + flagName string + flagValue string + expectedFlagMatch bool + expectedOptionMatch bool + expectedFlags []string + }{ + { + // Test top level flags + target: app.cmdMixin, + flagName: "", + flagValue: "", + expectedFlagMatch: false, + expectedOptionMatch: false, + expectedFlags: []string{"--help"}, + }, + { + // Test no flag passed + target: two.cmdMixin, + flagName: "", + flagValue: "", + expectedFlagMatch: false, + expectedOptionMatch: false, + expectedFlags: []string{"--flag-1", "--flag-2", "--flag-3"}, + }, + { + // Test an incomplete flag. Should still give all options as if the flag wasn't given at all. + target: two.cmdMixin, + flagName: "flag-", + flagValue: "", + expectedFlagMatch: false, + expectedOptionMatch: false, + expectedFlags: []string{"--flag-1", "--flag-2", "--flag-3"}, + }, + { + // Test with a complete flag. Should show available choices for the flag + // This flag has no options. No options should be produced. + // Should also report an option was matched + target: two.cmdMixin, + flagName: "flag-1", + flagValue: "", + expectedFlagMatch: true, + expectedOptionMatch: true, + expectedFlags: []string(nil), + }, + { + // Test with a complete flag. Should show available choices for the flag + target: two.cmdMixin, + flagName: "flag-2", + flagValue: "", + expectedFlagMatch: true, + expectedOptionMatch: false, + expectedFlags: []string{"opt1", "opt2", "opt3"}, + }, + { + // Test with a complete flag and complete option for that flag. + target: two.cmdMixin, + flagName: "flag-2", + flagValue: "opt1", + expectedFlagMatch: true, + expectedOptionMatch: true, + expectedFlags: []string{"opt1", "opt2", "opt3"}, + }, + } + + for i, c := range cases { + choices, flagMatch, optionMatch := c.target.FlagCompletion(c.flagName, c.flagValue) + assert.Equal(t, c.expectedFlags, choices, "Test case %d: expectedFlags != actual flags", i+1) + assert.Equal(t, c.expectedFlagMatch, flagMatch, "Test case %d: expectedFlagMatch != flagMatch", i+1) + assert.Equal(t, c.expectedOptionMatch, optionMatch, "Test case %d: expectedOptionMatch != optionMatch", i+1) + } + +} + +func TestCmdCompletion(t *testing.T) { + app := newTestApp() + app.Command("one", "") + two := app.Command("two", "") + two.Command("sub1", "") + two.Command("sub2", "") + + assert.Equal(t, []string{"one", "two"}, app.CmdCompletion()) + assert.Equal(t, []string{"sub1", "sub2"}, two.CmdCompletion()) +} diff --git a/completions.go b/completions.go new file mode 100644 index 0000000..6e7b409 --- /dev/null +++ b/completions.go @@ -0,0 +1,33 @@ +package kingpin + +// HintAction is a function type who is expected to return a slice of possible +// command line arguments. +type HintAction func() []string +type completionsMixin struct { + hintActions []HintAction + builtinHintActions []HintAction +} + +func (a *completionsMixin) addHintAction(action HintAction) { + a.hintActions = append(a.hintActions, action) +} + +// Allow adding of HintActions which are added internally, ie, EnumVar +func (a *completionsMixin) addHintActionBuiltin(action HintAction) { + a.builtinHintActions = append(a.builtinHintActions, action) +} + +func (a *completionsMixin) resolveCompletions() []string { + var hints []string + + options := a.builtinHintActions + if len(a.hintActions) > 0 { + // User specified their own hintActions. Use those instead. + options = a.hintActions + } + + for _, hintAction := range options { + hints = append(hints, hintAction()...) + } + return hints +} diff --git a/completions_test.go b/completions_test.go new file mode 100644 index 0000000..74656ea --- /dev/null +++ b/completions_test.go @@ -0,0 +1,78 @@ +package kingpin + +import ( + "testing" + + "github.com/alecthomas/assert" +) + +func TestResolveWithBuiltin(t *testing.T) { + a := completionsMixin{} + + hintAction1 := func() []string { + return []string{"opt1", "opt2"} + } + hintAction2 := func() []string { + return []string{"opt3", "opt4"} + } + + a.builtinHintActions = []HintAction{hintAction1, hintAction2} + + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2", "opt3", "opt4"}, args) +} + +func TestResolveWithUser(t *testing.T) { + a := completionsMixin{} + hintAction1 := func() []string { + return []string{"opt1", "opt2"} + } + hintAction2 := func() []string { + return []string{"opt3", "opt4"} + } + + a.hintActions = []HintAction{hintAction1, hintAction2} + + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2", "opt3", "opt4"}, args) +} + +func TestResolveWithCombination(t *testing.T) { + a := completionsMixin{} + builtin := func() []string { + return []string{"opt1", "opt2"} + } + user := func() []string { + return []string{"opt3", "opt4"} + } + + a.builtinHintActions = []HintAction{builtin} + a.hintActions = []HintAction{user} + + args := a.resolveCompletions() + // User provided args take preference over builtin (enum-defined) args. + assert.Equal(t, []string{"opt3", "opt4"}, args) +} + +func TestAddHintAction(t *testing.T) { + a := completionsMixin{} + hintFunc := func() []string { + return []string{"opt1", "opt2"} + } + a.addHintAction(hintFunc) + + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2"}, args) +} + +func TestAddHintActionBuiltin(t *testing.T) { + a := completionsMixin{} + hintFunc := func() []string { + return []string{"opt1", "opt2"} + } + + a.addHintActionBuiltin(hintFunc) + + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2"}, args) +} diff --git a/examples/completion/main.go b/examples/completion/main.go new file mode 100644 index 0000000..fe17b52 --- /dev/null +++ b/examples/completion/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "os" + + "github.com/alecthomas/kingpin" +) + +func listHosts() []string { + // Provide a dynamic list of hosts from a hosts file or otherwise + // for bash completion. In this example we simply return static slice. + + // You could use this functionality to reach into a hosts file to provide + // completion for a list of known hosts. + return []string{"sshhost.example", "webhost.example", "ftphost.example"} +} + +type NetcatCommand struct { + hostName string + port int + format string +} + +func (n *NetcatCommand) run(c *kingpin.ParseContext) error { + fmt.Printf("Would have run netcat to hostname %v, port %d, and output format %v\n", n.hostName, n.port, n.format) + return nil +} + +func configureNetcatCommand(app *kingpin.Application) { + c := &NetcatCommand{} + nc := app.Command("nc", "Connect to a Host").Action(c.run) + nc.Flag("nop-flag", "Example of a flag with no options").Bool() + + // You can provide hint options using a function to generate them + nc.Flag("host", "Provide a hostname to nc"). + Required(). + HintAction(listHosts). + StringVar(&c.hostName) + + // You can provide hint options statically + nc.Flag("port", "Provide a port to connect to"). + Required(). + HintOptions("80", "443", "8080"). + IntVar(&c.port) + + // Enum/EnumVar options will be turned into completion options automatically + nc.Flag("format", "Define the output format"). + Default("raw"). + EnumVar(&c.format, "raw", "json") + + // You can combine HintOptions with HintAction too + nc.Flag("host-with-multi", "Define a hostname"). + HintAction(listHosts). + HintOptions("myhost.com"). + String() + + // And combine with themselves + nc.Flag("host-with-multi-options", "Define a hostname"). + HintOptions("myhost.com"). + HintOptions("myhost2.com"). + String() + + // If you specify HintOptions/HintActions for Enum/EnumVar, the options + // provided for Enum/EnumVar will be overridden. + nc.Flag("format-with-override-1", "Define a format"). + HintAction(listHosts). + Enum("option1", "option2") + + nc.Flag("format-with-override-2", "Define a format"). + HintOptions("myhost.com", "myhost2.com"). + Enum("option1", "option2") +} + +func addSubCommand(app *kingpin.Application, name string, description string) { + c := app.Command(name, description).Action(func(c *kingpin.ParseContext) error { + fmt.Printf("Would have run command %s.\n", name) + return nil + }) + c.Flag("nop-flag", "Example of a flag with no options").Bool() +} + +func main() { + app := kingpin.New("completion", "My application with bash completion.") + app.Flag("flag-1", "").String() + app.Flag("flag-2", "").HintOptions("opt1", "opt2").String() + + configureNetcatCommand(app) + + // Add some additional top level commands + addSubCommand(app, "ls", "Additional top level command to show command completion") + addSubCommand(app, "ping", "Additional top level command to show command completion") + addSubCommand(app, "nmap", "Additional top level command to show command completion") + + kingpin.MustParse(app.Parse(os.Args[1:])) +} diff --git a/flags.go b/flags.go index e936e6c..ef0e845 100644 --- a/flags.go +++ b/flags.go @@ -159,6 +159,7 @@ func (f *flagGroup) visibleFlags() int { type FlagClause struct { parserMixin actionMixin + completionsMixin name string shorthand byte help string @@ -238,6 +239,34 @@ func (f *FlagClause) PreAction(action Action) *FlagClause { return f } +// HintAction registers a HintAction (function) for the flag to provide completions +func (a *FlagClause) HintAction(action HintAction) *FlagClause { + a.addHintAction(action) + return a +} + +// HintOptions registers any number of options for the flag to provide completions +func (a *FlagClause) HintOptions(options ...string) *FlagClause { + a.addHintAction(func() []string { + return options + }) + return a +} + +func (a *FlagClause) EnumVar(target *string, options ...string) { + a.parserMixin.EnumVar(target, options...) + a.addHintActionBuiltin(func() []string { + return options + }) +} + +func (a *FlagClause) Enum(options ...string) (target *string) { + a.addHintActionBuiltin(func() []string { + return options + }) + return a.parserMixin.Enum(options...) +} + // Default values for this flag. They *must* be parseable by the value of the flag. func (f *FlagClause) Default(values ...string) *FlagClause { f.defaultValues = values diff --git a/flags_test.go b/flags_test.go index 5c33a06..2552da3 100644 --- a/flags_test.go +++ b/flags_test.go @@ -207,3 +207,113 @@ func TestFlagMultipleValuesDefaultEnvarNonRepeatable(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "123\n456", *a) } + +func TestFlagHintAction(t *testing.T) { + c := newTestApp() + + action := func() []string { + return []string{"opt1", "opt2"} + } + + a := c.Flag("foo", "foo").HintAction(action) + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2"}, args) +} + +func TestFlagHintOptions(t *testing.T) { + c := newTestApp() + + a := c.Flag("foo", "foo").HintOptions("opt1", "opt2") + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2"}, args) +} + +func TestFlagEnumVar(t *testing.T) { + c := newTestApp() + var bar string + + a := c.Flag("foo", "foo") + a.Enum("opt1", "opt2") + b := c.Flag("bar", "bar") + b.EnumVar(&bar, "opt3", "opt4") + + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2"}, args) + + args = b.resolveCompletions() + assert.Equal(t, []string{"opt3", "opt4"}, args) +} + +func TestMultiHintOptions(t *testing.T) { + c := newTestApp() + + a := c.Flag("foo", "foo").HintOptions("opt1").HintOptions("opt2") + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2"}, args) +} +func TestMultiHintActions(t *testing.T) { + c := newTestApp() + + a := c.Flag("foo", "foo"). + HintAction(func() []string { + return []string{"opt1"} + }). + HintAction(func() []string { + return []string{"opt2"} + }) + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2"}, args) +} + +func TestCombinationHintActionsOptions(t *testing.T) { + c := newTestApp() + + a := c.Flag("foo", "foo").HintAction(func() []string { + return []string{"opt1"} + }).HintOptions("opt2") + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2"}, args) +} + +func TestCombinationEnumActions(t *testing.T) { + c := newTestApp() + var foo string + + a := c.Flag("foo", "foo"). + HintAction(func() []string { + return []string{"opt1", "opt2"} + }) + a.Enum("opt3", "opt4") + + b := c.Flag("bar", "bar"). + HintAction(func() []string { + return []string{"opt5", "opt6"} + }) + b.EnumVar(&foo, "opt3", "opt4") + + // Provided HintActions should override automatically generated Enum options. + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2"}, args) + + args = b.resolveCompletions() + assert.Equal(t, []string{"opt5", "opt6"}, args) +} + +func TestCombinationEnumOptions(t *testing.T) { + c := newTestApp() + var foo string + + a := c.Flag("foo", "foo").HintOptions("opt1", "opt2") + a.Enum("opt3", "opt4") + + b := c.Flag("bar", "bar").HintOptions("opt5", "opt6") + b.EnumVar(&foo, "opt3", "opt4") + + // Provided HintOptions should override automatically generated Enum options. + args := a.resolveCompletions() + assert.Equal(t, []string{"opt1", "opt2"}, args) + + args = b.resolveCompletions() + assert.Equal(t, []string{"opt5", "opt6"}, args) + +} diff --git a/parser.go b/parser.go index f6ba7ec..9f3f7e5 100644 --- a/parser.go +++ b/parser.go @@ -92,6 +92,7 @@ type ParseContext struct { peek []*Token argi int // Index of current command-line arg we're processing. args []string + rawArgs []string flags *flagGroup arguments *argGroup argumenti int // Cursor into arguments @@ -125,6 +126,7 @@ func tokenize(args []string, ignoreDefault bool) *ParseContext { return &ParseContext{ ignoreDefault: ignoreDefault, args: args, + rawArgs: args, flags: newFlagGroup(), arguments: newArgGroup(), } diff --git a/templates.go b/templates.go index c4f6bc3..97b5c9f 100644 --- a/templates.go +++ b/templates.go @@ -231,3 +231,32 @@ Commands: {{template "FormatCommands" .App}} {{end}}\ ` + +var BashCompletionTemplate = ` +_{{.App.Name}}_bash_autocomplete() { + local cur prev opts base + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + opts=$( ${COMP_WORDS[0]} --completion-bash ${COMP_WORDS[@]:1:$COMP_CWORD} ) + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + return 0 +} +complete -F _{{.App.Name}}_bash_autocomplete {{.App.Name}} + +` + +var ZshCompletionTemplate = ` +#compdef {{.App.Name}} +autoload -U compinit && compinit +autoload -U bashcompinit && bashcompinit + +_{{.App.Name}}_bash_autocomplete() { + local cur prev opts base + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + opts=$( ${COMP_WORDS[0]} --completion-bash ${COMP_WORDS[@]:1:$COMP_CWORD} ) + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + return 0 +} +complete -F _{{.App.Name}}_bash_autocomplete {{.App.Name}} +`