From f18f0b70b52aeec36a80fff54e7a4ed485c1a392 Mon Sep 17 00:00:00 2001 From: Sascha Grunert Date: Fri, 29 Nov 2019 14:09:46 +0100 Subject: [PATCH] Add suggestions support The new option `app.Suggest` enables command and flag suggestions via the jaro-winkler distance algorithm. Flags are scoped to their appropriate commands whereas command suggestions are scoped to the current command level. Signed-off-by: Sascha Grunert --- app.go | 12 +++++++ command.go | 6 ++++ go.mod | 1 + go.sum | 2 ++ help.go | 23 +++++++++---- parse.go | 18 ++++++++-- suggestions.go | 75 ++++++++++++++++++++++++++++++++++++++++ suggestions_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 suggestions.go create mode 100644 suggestions_test.go diff --git a/app.go b/app.go index a62aa237f1..5b3729d4e8 100644 --- a/app.go +++ b/app.go @@ -88,6 +88,8 @@ type App struct { // single-character bool arguments into one // i.e. foobar -o -v -> foobar -ov UseShortOptionHandling bool + // Enable suggestions for commands and flags + Suggest bool didSetup bool } @@ -243,6 +245,11 @@ func (a *App) Run(arguments []string) (err error) { return err } _, _ = fmt.Fprintf(a.Writer, "%s %s\n\n", "Incorrect Usage.", err.Error()) + if a.Suggest { + if suggestion, err := a.suggestFlagFromError(err, ""); err == nil { + fmt.Fprintf(a.Writer, suggestion) + } + } _ = ShowAppHelp(context) return err } @@ -374,6 +381,11 @@ func (a *App) RunAsSubcommand(ctx *Context) (err error) { return err } _, _ = fmt.Fprintf(a.Writer, "%s %s\n\n", "Incorrect Usage.", err.Error()) + if a.Suggest { + if suggestion, err := a.suggestFlagFromError(err, context.Command.Name); err == nil { + fmt.Fprintf(a.Writer, suggestion) + } + } _ = ShowSubcommandHelp(context) return err } diff --git a/command.go b/command.go index db6c8024ba..5300ee8a9d 100644 --- a/command.go +++ b/command.go @@ -116,6 +116,11 @@ func (c *Command) Run(ctx *Context) (err error) { } _, _ = fmt.Fprintln(context.App.Writer, "Incorrect Usage:", err.Error()) _, _ = fmt.Fprintln(context.App.Writer) + if ctx.App.Suggest { + if suggestion, err := ctx.App.suggestFlagFromError(err, c.Name); err == nil { + fmt.Fprintf(context.App.Writer, suggestion) + } + } _ = ShowCommandHelp(context, c.Name) return err } @@ -244,6 +249,7 @@ func (c *Command) startApp(ctx *Context) error { app.ErrWriter = ctx.App.ErrWriter app.ExitErrHandler = ctx.App.ExitErrHandler app.UseShortOptionHandling = ctx.App.UseShortOptionHandling + app.Suggest = ctx.App.Suggest app.categories = newCommandCategories() for _, command := range c.Subcommands { diff --git a/go.mod b/go.mod index c38d41c14b..1c280a6f17 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.11 require ( github.com/BurntSushi/toml v0.3.1 + github.com/antzucaro/matchr v0.0.0-20180616170659-cbc221335f3c github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d gopkg.in/yaml.v2 v2.2.2 ) diff --git a/go.sum b/go.sum index ef121ff5db..6165e94c45 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/antzucaro/matchr v0.0.0-20180616170659-cbc221335f3c h1:CucViv7orgFBMkehuFFdkCVF5ERovbkRRyhvaYaHu/k= +github.com/antzucaro/matchr v0.0.0-20180616170659-cbc221335f3c/go.mod h1:bV/CkX4+ANGDaBwbHkt9kK287al/i9BsB18PRBvyqYo= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/help.go b/help.go index aa5947d6e9..9162661cdc 100644 --- a/help.go +++ b/help.go @@ -10,9 +10,14 @@ import ( "unicode/utf8" ) +const ( + helpName = "help" + helpAlias = "h" +) + var helpCommand = &Command{ - Name: "help", - Aliases: []string{"h"}, + Name: helpName, + Aliases: []string{helpAlias}, Usage: "Shows a list of commands or help for one command", ArgsUsage: "[command]", Action: func(c *Context) error { @@ -27,8 +32,8 @@ var helpCommand = &Command{ } var helpSubcommand = &Command{ - Name: "help", - Aliases: []string{"h"}, + Name: helpName, + Aliases: []string{helpAlias}, Usage: "Shows a list of commands or help for one command", ArgsUsage: "[command]", Action: func(c *Context) error { @@ -138,7 +143,7 @@ func printFlagSuggestions(lastArg string, flags []Flag, writer io.Writer) { if bflag, ok := flag.(*BoolFlag); ok && bflag.Hidden { continue } - for _, name := range flag.Names(){ + for _, name := range flag.Names() { name = strings.TrimSpace(name) // this will get total count utf8 letters in flag name count := utf8.RuneCountInString(name) @@ -207,7 +212,13 @@ func ShowCommandHelp(ctx *Context, command string) error { } if ctx.App.CommandNotFound == nil { - return Exit(fmt.Sprintf("No help topic for '%v'", command), 3) + errMsg := fmt.Sprintf("No help topic for '%v'", command) + if ctx.App.Suggest { + if suggestion := suggestCommand(ctx.App.Commands, command); suggestion != "" { + errMsg += ". " + suggestion + } + } + return Exit(errMsg, 3) } ctx.App.CommandNotFound(ctx, command) diff --git a/parse.go b/parse.go index 7df17296a4..a2db306e12 100644 --- a/parse.go +++ b/parse.go @@ -26,9 +26,8 @@ func parseIter(set *flag.FlagSet, ip iterativeParser, args []string, shellComple return err } - errStr := err.Error() - trimmed := strings.TrimPrefix(errStr, "flag provided but not defined: -") - if errStr == trimmed { + trimmed, trimErr := flagFromError(err) + if trimErr != nil { return err } @@ -67,6 +66,19 @@ func parseIter(set *flag.FlagSet, ip iterativeParser, args []string, shellComple } } +const providedButNotDefinedErrMsg = "flag provided but not defined: -" + +// flagFromError tries to parse a provided flag from an error message. If the +// parsing fials, it returns the input error and an empty string +func flagFromError(err error) (string, error) { + errStr := err.Error() + trimmed := strings.TrimPrefix(errStr, providedButNotDefinedErrMsg) + if errStr == trimmed { + return "", err + } + return trimmed, nil +} + func splitShortOptions(set *flag.FlagSet, arg string) []string { shortFlagsExist := func(s string) bool { for _, c := range s[1:] { diff --git a/suggestions.go b/suggestions.go new file mode 100644 index 0000000000..476af4de50 --- /dev/null +++ b/suggestions.go @@ -0,0 +1,75 @@ +package cli + +import ( + "fmt" + + "github.com/antzucaro/matchr" +) + +const didYouMeanTemplate = "Did you mean '%s'?" + +func (a *App) suggestFlagFromError(err error, command string) (string, error) { + flag, parseErr := flagFromError(err) + if parseErr != nil { + return "", err + } + + flags := a.Flags + if command != "" { + cmd := a.Command(command) + if cmd == nil { + return "", err + } + flags = cmd.Flags + } + + suggestion := a.suggestFlag(flags, flag) + if len(suggestion) == 0 { + return "", err + } + + return fmt.Sprintf(didYouMeanTemplate+"\n\n", suggestion), nil +} + +func (a *App) suggestFlag(flags []Flag, provided string) (suggestion string) { + distance := 0.0 + + for _, flag := range flags { + flagNames := flag.Names() + if !a.HideHelp { + flagNames = append(flagNames, HelpFlag.Names()...) + } + for _, name := range flagNames { + newDistance := matchr.JaroWinkler(name, provided, true) + if newDistance > distance { + distance = newDistance + suggestion = name + } + } + } + + if len(suggestion) == 1 { + suggestion = "-" + suggestion + } else if len(suggestion) > 1 { + suggestion = "--" + suggestion + } + + return suggestion +} + +// suggestCommand takes a list of commands and a provided string to suggest a +// command name +func suggestCommand(commands []*Command, provided string) (suggestion string) { + distance := 0.0 + for _, command := range commands { + for _, name := range append(command.Names(), helpName, helpAlias) { + newDistance := matchr.JaroWinkler(name, provided, true) + if newDistance > distance { + distance = newDistance + suggestion = name + } + } + } + + return fmt.Sprintf(didYouMeanTemplate, suggestion) +} diff --git a/suggestions_test.go b/suggestions_test.go new file mode 100644 index 0000000000..69401a5c8b --- /dev/null +++ b/suggestions_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "errors" + "fmt" + "testing" +) + +func TestSuggestFlag(t *testing.T) { + // Given + app := testApp() + + for _, testCase := range []struct { + provided, expected string + }{ + {"", ""}, + {"a", "--another-flag"}, + {"hlp", "--help"}, + {"k", ""}, + {"soccer", "--socket"}, + } { + // When + res := app.suggestFlag(app.Flags, testCase.provided) + + // Then + expect(t, res, testCase.expected) + } +} + +func TestSuggestFlagHideHelp(t *testing.T) { + // Given + app := testApp() + app.HideHelp = true + + // When + res := app.suggestFlag(app.Flags, "hlp") + + // Then + expect(t, res, "--fl") +} + +func TestSuggestFlagFromError(t *testing.T) { + // Given + app := testApp() + + for _, testCase := range []struct { + command, provided, expected string + }{ + {"", "hel", "--help"}, + {"", "soccer", "--socket"}, + {"config", "anot", "--another-flag"}, + } { + // When + res, _ := app.suggestFlagFromError( + errors.New(providedButNotDefinedErrMsg+testCase.provided), + testCase.command, + ) + + // Then + expect(t, res, fmt.Sprintf(didYouMeanTemplate+"\n\n", testCase.expected)) + } +} + +func TestSuggestCommand(t *testing.T) { + // Given + app := testApp() + + for _, testCase := range []struct { + provided, expected string + }{ + {"", ""}, + {"conf", "config"}, + {"i", "i"}, + {"information", "info"}, + {"not-existing", "info"}, + } { + // When + res := suggestCommand(app.Commands, testCase.provided) + + // Then + expect(t, res, fmt.Sprintf(didYouMeanTemplate, testCase.expected)) + } +}