Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autocomplete a command's args #106

Merged
merged 3 commits into from
Mar 14, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,15 +650,15 @@ func (a *Application) completionOptions(context *ParseContext) []string {
options, flagMatched, valueMatched := target.FlagCompletion(flagName, flagValue)
if valueMatched {
// Value Matched. Show cmdCompletions
return target.CmdCompletion()
return target.CmdCompletion(context)
}

// 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()
return target.CmdCompletion(context)
}

if topFlagMatched {
Expand All @@ -670,10 +670,10 @@ func (a *Application) completionOptions(context *ParseContext) []string {
}
}
return options
} else {
// Perform completion for sub commands.
return target.CmdCompletion()
}

// Perform completion for sub commands and arguments.
return target.CmdCompletion(context)
}

func (a *Application) generateBashCompletion(context *ParseContext) {
Expand Down
58 changes: 55 additions & 3 deletions app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,22 @@ func TestBashCompletionOptions(t *testing.T) {
two.Flag("flag-2", "").String()
two.Flag("flag-3", "").HintOptions("opt4", "opt5", "opt6").String()

three := a.Command("three", "")
three.Flag("flag-4", "").String()
three.Arg("arg-1", "").String()
three.Arg("arg-2", "").HintOptions("arg-2-opt-1", "arg-2-opt-2").String()
three.Arg("arg-3", "").String()
three.Arg("arg-4", "").HintAction(func() []string {
return []string{"arg-4-opt-1", "arg-4-opt-2"}
}).String()

cases := []struct {
Args string
ExpectedOptions []string
}{
{
Args: "--completion-bash",
ExpectedOptions: []string{"help", "one", "two"},
ExpectedOptions: []string{"help", "one", "three", "two"},
},
{
Args: "--completion-bash --",
Expand All @@ -263,7 +272,7 @@ func TestBashCompletionOptions(t *testing.T) {
{
// No options available for flag-0, return to cmd completion
Args: "--completion-bash --flag-0",
ExpectedOptions: []string{"help", "one", "two"},
ExpectedOptions: []string{"help", "one", "three", "two"},
},
{
Args: "--completion-bash --flag-0 --",
Expand All @@ -279,7 +288,7 @@ func TestBashCompletionOptions(t *testing.T) {
},
{
Args: "--completion-bash --flag-1 opt1",
ExpectedOptions: []string{"help", "one", "two"},
ExpectedOptions: []string{"help", "one", "three", "two"},
},
{
Args: "--completion-bash --flag-1 opt1 --",
Expand Down Expand Up @@ -334,6 +343,49 @@ func TestBashCompletionOptions(t *testing.T) {
Args: "--completion-bash two --flag-3 opt4 --",
ExpectedOptions: []string{"--help", "--flag-2", "--flag-3", "--flag-0", "--flag-1"},
},

// Args complete
{
// After a command with an arg with no options, nothing should be
// shown
Args: "--completion-bash three ",
ExpectedOptions: []string(nil),
},
{
// After a command with an arg, explicitly starting a flag should
// complete flags
Args: "--completion-bash three --",
ExpectedOptions: []string{"--flag-0", "--flag-1", "--flag-4", "--help"},
},
{
// After a command with an arg that does have completions, they
// should be shown
Args: "--completion-bash three arg1 ",
ExpectedOptions: []string{"arg-2-opt-1", "arg-2-opt-2"},
},
{
// After a command with an arg that does have completions, but a
// flag is started, flag options should be completed
Args: "--completion-bash three arg1 --",
ExpectedOptions: []string{"--flag-0", "--flag-1", "--flag-4", "--help"},
},
{
// After a command with an arg that has no completions, and isn't first,
// nothing should be shown
Args: "--completion-bash three arg1 arg2 ",
ExpectedOptions: []string(nil),
},
{
// After a command with a different arg that also has completions,
// those different options should be shown
Args: "--completion-bash three arg1 arg2 arg3 ",
ExpectedOptions: []string{"arg-4-opt-1", "arg-4-opt-2"},
},
{
// After a command with all args listed, nothing should complete
Args: "--completion-bash three arg1 arg2 arg3 arg4",
ExpectedOptions: []string(nil),
},
}

for _, c := range cases {
Expand Down
15 changes: 15 additions & 0 deletions args.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func (a *argGroup) init() error {
type ArgClause struct {
actionMixin
parserMixin
completionsMixin
name string
help string
defaultValues []string
Expand Down Expand Up @@ -107,6 +108,20 @@ func (a *ArgClause) PreAction(action Action) *ArgClause {
return a
}

// HintAction registers a HintAction (function) for the arg to provide completions
func (a *ArgClause) HintAction(action HintAction) *ArgClause {
a.addHintAction(action)
return a
}

// HintOptions registers any number of options for the flag to provide completions
func (a *ArgClause) HintOptions(options ...string) *ArgClause {
a.addHintAction(func() []string {
return options
})
return a
}

func (a *ArgClause) init() error {
if a.required && len(a.defaultValues) > 0 {
return fmt.Errorf("required argument '%s' with unusable default value", a.name)
Expand Down
62 changes: 45 additions & 17 deletions cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,38 @@ type cmdMixin struct {
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)
// CmdCompletion returns completion options for arguments, if that's where
// parsing left off, or commands if there aren't any unsatisfied args.
func (c *cmdMixin) CmdCompletion(context *ParseContext) []string {
var options []string

// Count args already satisfied - we won't complete those, and add any
// default commands' alternatives, since they weren't listed explicitly
// and the user may want to explicitly list something else.
argsSatisfied := 0
for _, el := range context.Elements {
switch clause := el.Clause.(type) {
case *ArgClause:
if el.Value != nil && *el.Value != "" {
argsSatisfied++
}
case *CmdClause:
options = append(options, clause.completionAlts...)
default:
}
}

if argsSatisfied < len(c.argGroup.args) {
// Since not all args have been satisfied, show options for the current one
options = append(options, c.argGroup.args[argsSatisfied].resolveCompletions()...)
} else {
// No subcommands
rv = nil
// If all args are satisfied, then go back to completing commands
for _, cmd := range c.cmdGroup.commandOrder {
options = append(options, cmd.name)
}
}
return rv

return options
}

func (c *cmdMixin) FlagCompletion(flagName string, flagValue string) (choices []string, flagMatch bool, optionMatch bool) {
Expand Down Expand Up @@ -86,6 +105,14 @@ func (c *cmdGroup) defaultSubcommand() *CmdClause {
return nil
}

func (c *cmdGroup) cmdNames() []string {
names := make([]string, 0, len(c.commandOrder))
for _, cmd := range c.commandOrder {
names = append(names, cmd.name)
}
return names
}

// GetArg gets a command definition.
//
// This allows existing commands to be modified after definition but before parsing. Useful for
Expand Down Expand Up @@ -158,13 +185,14 @@ type CmdClauseValidator func(*CmdClause) error
// and either subcommands or positional arguments.
type CmdClause struct {
cmdMixin
app *Application
name string
aliases []string
help string
isDefault bool
validator CmdClauseValidator
hidden bool
app *Application
name string
aliases []string
help string
isDefault bool
validator CmdClauseValidator
hidden bool
completionAlts []string
}

func newCommand(app *Application, name, help string) *CmdClause {
Expand Down
75 changes: 73 additions & 2 deletions cmd_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kingpin

import (
"sort"
"strings"

"github.com/alecthomas/assert"
Expand All @@ -21,6 +22,19 @@ func parseAndExecute(app *Application, context *ParseContext) (string, error) {
return app.execute(context, selected)
}

func complete(t *testing.T, app *Application, args ...string) []string {
context, err := app.ParseContext(args)
assert.NoError(t, err)
if err != nil {
return nil
}

completions := app.completionOptions(context)
sort.Strings(completions)

return completions
}

func TestNestedCommands(t *testing.T) {
app := New("app", "")
sub1 := app.Command("sub1", "")
Expand Down Expand Up @@ -271,6 +285,63 @@ func TestCmdCompletion(t *testing.T) {
two.Command("sub1", "")
two.Command("sub2", "")

assert.Equal(t, []string{"one", "two"}, app.CmdCompletion())
assert.Equal(t, []string{"sub1", "sub2"}, two.CmdCompletion())
assert.Equal(t, []string{"help", "one", "two"}, complete(t, app))
assert.Equal(t, []string{"sub1", "sub2"}, complete(t, app, "two"))
}

func TestDefaultCmdCompletion(t *testing.T) {
app := newTestApp()

cmd1 := app.Command("cmd1", "")

cmd1Sub1 := cmd1.Command("cmd1-sub1", "")
cmd1Sub1.Arg("cmd1-sub1-arg1", "").HintOptions("cmd1-arg1").String()

cmd2 := app.Command("cmd2", "").Default()

cmd2.Command("cmd2-sub1", "")

cmd2Sub2 := cmd2.Command("cmd2-sub2", "").Default()

cmd2Sub2Sub1 := cmd2Sub2.Command("cmd2-sub2-sub1", "").Default()
cmd2Sub2Sub1.Arg("cmd2-sub2-sub1-arg1", "").HintOptions("cmd2-sub2-sub1-arg1").String()
cmd2Sub2Sub1.Arg("cmd2-sub2-sub1-arg2", "").HintOptions("cmd2-sub2-sub1-arg2").String()

// Without args, should get:
// - root cmds (incuding implicit "help")
// - thread of default cmds
// - first arg hints for the final default cmd
assert.Equal(t, []string{"cmd1", "cmd2", "cmd2-sub1", "cmd2-sub2", "cmd2-sub2-sub1", "cmd2-sub2-sub1-arg1", "help"}, complete(t, app))

// With a non-default cmd already listed, should get:
// - sub cmds of that arg
assert.Equal(t, []string{"cmd1-sub1"}, complete(t, app, "cmd1"))

// With an explicit default cmd listed, should get:
// - default child-cmds
// - first arg hints for the final default cmd
assert.Equal(t, []string{"cmd2-sub1", "cmd2-sub2", "cmd2-sub2-sub1", "cmd2-sub2-sub1-arg1"}, complete(t, app, "cmd2"))

// Args should be completed when all preceding cmds are explicit, and when
// any of them are implicit (not listed). Check this by trying all possible
// combinations of choosing/excluding the three levels of cmds. This tests
// root-level default, middle default, and end default.
for i := 0; i < 8; i++ {
var cmdline []string

if i&1 != 0 {
cmdline = append(cmdline, "cmd2")
}
if i&2 != 0 {
cmdline = append(cmdline, "cmd2-sub2")
}
if i&4 != 0 {
cmdline = append(cmdline, "cmd2-sub2-sub1")
}

assert.Contains(t, complete(t, app, cmdline...), "cmd2-sub2-sub1-arg1", "with cmdline: %v", cmdline)
}

// With both args of a default sub cmd, should get no completions
assert.Empty(t, complete(t, app, "arg1", "arg2"))
}
4 changes: 4 additions & 0 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ loop:
if flag, err := context.flags.parse(context); err != nil {
if !ignoreDefault {
if cmd := cmds.defaultSubcommand(); cmd != nil {
cmd.completionAlts = cmds.cmdNames()
context.matchedCmd(cmd)
cmds = cmd.cmdGroup
break
Expand All @@ -314,6 +315,7 @@ loop:
if !ok {
if !ignoreDefault {
if cmd = cmds.defaultSubcommand(); cmd != nil {
cmd.completionAlts = cmds.cmdNames()
selectedDefault = true
}
}
Expand All @@ -324,6 +326,7 @@ loop:
if cmd == HelpCommand {
ignoreDefault = true
}
cmd.completionAlts = nil
context.matchedCmd(cmd)
cmds = cmd.cmdGroup
if !selectedDefault {
Expand Down Expand Up @@ -352,6 +355,7 @@ loop:
// Move to innermost default command.
for !ignoreDefault {
if cmd := cmds.defaultSubcommand(); cmd != nil {
cmd.completionAlts = cmds.cmdNames()
context.matchedCmd(cmd)
cmds = cmd.cmdGroup
} else {
Expand Down