From 0b7f9746614a165c1ed9f54b85f5ed4e98745316 Mon Sep 17 00:00:00 2001 From: Heewa Barfchin Date: Sun, 14 Feb 2016 03:55:08 -0500 Subject: [PATCH 1/3] Add completions to Args --- app.go | 6 +++--- app_test.go | 36 +++++++++++++++++++++++++++++++++--- args.go | 15 +++++++++++++++ cmd.go | 21 +++++++++++---------- 4 files changed, 62 insertions(+), 16 deletions(-) diff --git a/app.go b/app.go index 9909131..f31ecd5 100644 --- a/app.go +++ b/app.go @@ -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() } func (a *Application) generateBashCompletion(context *ParseContext) { diff --git a/app_test.go b/app_test.go index 75860f3..44b43da 100644 --- a/app_test.go +++ b/app_test.go @@ -244,13 +244,21 @@ 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", "").HintAction(func() []string { + return []string{"arg-3-opt-1", "arg-3-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 --", @@ -263,7 +271,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 --", @@ -279,7 +287,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 --", @@ -334,6 +342,28 @@ 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 arg options + Args: "--completion-bash three ", + ExpectedOptions: []string{"arg-2-opt-1", "arg-2-opt-2", "arg-3-opt-1", "arg-3-opt-2"}, + }, + { + // But not after flag start + Args: "--completion-bash three --", + ExpectedOptions: []string{"--flag-0", "--flag-1", "--flag-4", "--help"}, + }, + { + // Completes args after one already listed + Args: "--completion-bash three firstArg ", + ExpectedOptions: []string{"arg-2-opt-1", "arg-2-opt-2", "arg-3-opt-1", "arg-3-opt-2"}, + }, + { + // Completes args after a flag + Args: "--completion-bash three firstArg --flag-4 ", + ExpectedOptions: []string{"arg-2-opt-1", "arg-2-opt-2", "arg-3-opt-1", "arg-3-opt-2"}, + }, } for _, c := range cases { diff --git a/args.go b/args.go index 9dd0c0e..3e25890 100644 --- a/args.go +++ b/args.go @@ -64,6 +64,7 @@ func (a *argGroup) init() error { type ArgClause struct { actionMixin parserMixin + completionsMixin name string help string defaultValues []string @@ -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) diff --git a/cmd.go b/cmd.go index b8cbb9b..9fa0df8 100644 --- a/cmd.go +++ b/cmd.go @@ -13,17 +13,18 @@ type cmdMixin struct { } 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 + var rv []string + + // If this command has subcommands, we should show these to the user. + for _, option := range c.cmdGroup.commandOrder { + rv = append(rv, option.name) } + + // Add any completions from args as well + for _, arg := range c.argGroup.args { + rv = append(rv, arg.resolveCompletions()...) + } + return rv } From 043e8ecc0a30069a29593607fa166f1654469628 Mon Sep 17 00:00:00 2001 From: Heewa Barfchin Date: Thu, 18 Feb 2016 01:19:36 -0500 Subject: [PATCH 2/3] Complete specifically the current arg based on pos --- app.go | 6 +++--- app_test.go | 44 +++++++++++++++++++++++++++++++++----------- cmd.go | 27 +++++++++++++++++---------- cmd_test.go | 6 ++++-- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/app.go b/app.go index f31ecd5..0408bea 100644 --- a/app.go +++ b/app.go @@ -650,7 +650,7 @@ 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. @@ -658,7 +658,7 @@ func (a *Application) completionOptions(context *ParseContext) []string { topOptions, topFlagMatched, topValueMatched := a.FlagCompletion(flagName, flagValue) if topValueMatched { // Value Matched. Back to cmdCompletions - return target.CmdCompletion() + return target.CmdCompletion(context) } if topFlagMatched { @@ -673,7 +673,7 @@ func (a *Application) completionOptions(context *ParseContext) []string { } // Perform completion for sub commands and arguments. - return target.CmdCompletion() + return target.CmdCompletion(context) } func (a *Application) generateBashCompletion(context *ParseContext) { diff --git a/app_test.go b/app_test.go index 44b43da..999af36 100644 --- a/app_test.go +++ b/app_test.go @@ -248,8 +248,9 @@ func TestBashCompletionOptions(t *testing.T) { 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", "").HintAction(func() []string { - return []string{"arg-3-opt-1", "arg-3-opt-2"} + 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 { @@ -345,24 +346,45 @@ func TestBashCompletionOptions(t *testing.T) { // Args complete { - // After a command with arg options + // After a command with an arg with no options, nothing should be + // shown Args: "--completion-bash three ", - ExpectedOptions: []string{"arg-2-opt-1", "arg-2-opt-2", "arg-3-opt-1", "arg-3-opt-2"}, + ExpectedOptions: []string(nil), }, { - // But not after flag start + // 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"}, }, { - // Completes args after one already listed - Args: "--completion-bash three firstArg ", - ExpectedOptions: []string{"arg-2-opt-1", "arg-2-opt-2", "arg-3-opt-1", "arg-3-opt-2"}, + // 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), }, { - // Completes args after a flag - Args: "--completion-bash three firstArg --flag-4 ", - ExpectedOptions: []string{"arg-2-opt-1", "arg-2-opt-2", "arg-3-opt-1", "arg-3-opt-2"}, + // 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), }, } diff --git a/cmd.go b/cmd.go index 9fa0df8..9e74ec0 100644 --- a/cmd.go +++ b/cmd.go @@ -12,20 +12,27 @@ type cmdMixin struct { actionMixin } -func (c *cmdMixin) CmdCompletion() []string { - var rv []string - - // If 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 { + // Count args already satisfied - we won't complete those + argsSatisfied := 0 + for _, parsed := range context.Elements { + if _, ok := parsed.Clause.(*ArgClause); ok && parsed.Value != nil && *parsed.Value != "" { + argsSatisfied++ + } } - // Add any completions from args as well - for _, arg := range c.argGroup.args { - rv = append(rv, arg.resolveCompletions()...) + if argsSatisfied < len(c.argGroup.args) { + return c.argGroup.args[argsSatisfied].resolveCompletions() } - return rv + // All args have been listed already, so complete (sub-)commands + var options []string + for _, cmd := range c.cmdGroup.commandOrder { + options = append(options, cmd.name) + } + return options } func (c *cmdMixin) FlagCompletion(flagName string, flagValue string) (choices []string, flagMatch bool, optionMatch bool) { diff --git a/cmd_test.go b/cmd_test.go index 7d59b8d..d0de5ee 100644 --- a/cmd_test.go +++ b/cmd_test.go @@ -271,6 +271,8 @@ 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()) + context, _ := app.ParseContext([]string{}) + + assert.Equal(t, []string{"help", "one", "two"}, app.CmdCompletion(context)) + assert.Equal(t, []string{"sub1", "sub2"}, two.CmdCompletion(context)) } From d5ac1f27d71dbbb826414278c24aa51d92b814d4 Mon Sep 17 00:00:00 2001 From: Heewa Barfchin Date: Wed, 24 Feb 2016 19:49:10 -0500 Subject: [PATCH 3/3] Handle completion when there are default cmds --- cmd.go | 54 ++++++++++++++++++++++++++------------ cmd_test.go | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++--- parser.go | 4 +++ 3 files changed, 113 insertions(+), 20 deletions(-) diff --git a/cmd.go b/cmd.go index 9e74ec0..e30831b 100644 --- a/cmd.go +++ b/cmd.go @@ -15,23 +15,34 @@ type cmdMixin struct { // 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 { - // Count args already satisfied - we won't complete those + 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 _, parsed := range context.Elements { - if _, ok := parsed.Clause.(*ArgClause); ok && parsed.Value != nil && *parsed.Value != "" { - argsSatisfied++ + 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) { - return c.argGroup.args[argsSatisfied].resolveCompletions() + // Since not all args have been satisfied, show options for the current one + options = append(options, c.argGroup.args[argsSatisfied].resolveCompletions()...) + } else { + // If all args are satisfied, then go back to completing commands + for _, cmd := range c.cmdGroup.commandOrder { + options = append(options, cmd.name) + } } - // All args have been listed already, so complete (sub-)commands - var options []string - for _, cmd := range c.cmdGroup.commandOrder { - options = append(options, cmd.name) - } return options } @@ -94,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 @@ -166,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 { diff --git a/cmd_test.go b/cmd_test.go index d0de5ee..e1b69e3 100644 --- a/cmd_test.go +++ b/cmd_test.go @@ -1,6 +1,7 @@ package kingpin import ( + "sort" "strings" "github.com/alecthomas/assert" @@ -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", "") @@ -271,8 +285,63 @@ func TestCmdCompletion(t *testing.T) { two.Command("sub1", "") two.Command("sub2", "") - context, _ := app.ParseContext([]string{}) + 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) + } - assert.Equal(t, []string{"help", "one", "two"}, app.CmdCompletion(context)) - assert.Equal(t, []string{"sub1", "sub2"}, two.CmdCompletion(context)) + // With both args of a default sub cmd, should get no completions + assert.Empty(t, complete(t, app, "arg1", "arg2")) } diff --git a/parser.go b/parser.go index 9f3f7e5..f9af98f 100644 --- a/parser.go +++ b/parser.go @@ -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 @@ -314,6 +315,7 @@ loop: if !ok { if !ignoreDefault { if cmd = cmds.defaultSubcommand(); cmd != nil { + cmd.completionAlts = cmds.cmdNames() selectedDefault = true } } @@ -324,6 +326,7 @@ loop: if cmd == HelpCommand { ignoreDefault = true } + cmd.completionAlts = nil context.matchedCmd(cmd) cmds = cmd.cmdGroup if !selectedDefault { @@ -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 {