From b4087da7eb1bf3822d8413008dbd4c3c6c3c5f2d Mon Sep 17 00:00:00 2001 From: Fabiano Franz Date: Fri, 11 Sep 2015 17:04:58 -0300 Subject: [PATCH] Allows command suggestions along with "unknown command" errors --- README.md | 24 ++++++++++++++++++++++++ cobra.go | 36 ++++++++++++++++++++++++++++++++++++ cobra_test.go | 28 ++++++++++++++++++++++++++++ command.go | 29 ++++++++++++++++++++++++++++- 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b97e021..8869ab96 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,30 @@ func main() { } ``` +## Suggestions when "unknown command" happens + +Cobra will print automatic suggestions when "unknown command" errors happen. This allows Cobra to behavior similarly to the `git` command when a typo happens. For example: + +``` +$ hugo srever +unknown command "srever" for "hugo" + +Did you mean this? + server + +Run 'hugo --help' for usage. +``` + +Suggestions are automatic based on every subcommand registered and use an implementation of Levenshtein distance. Every registered command that matches a minimum distance of 2 (ignoring case) will be displayed as a suggestion. + +If you need to disable suggestions or tweak the string distance in your command, use: + + command.DisableSuggestions = true + +or + + command.SuggestionsMinimumDistance = 1 + ## Generating markdown formatted documentation for your command Cobra can generate a markdown formatted document based on the subcommands, flags, etc. A simple example of how to do this for your command can be found in [Markdown Docs](md_docs.md) diff --git a/cobra.go b/cobra.go index 1d66a70e..a61647bc 100644 --- a/cobra.go +++ b/cobra.go @@ -126,3 +126,39 @@ func tmpl(w io.Writer, text string, data interface{}) error { template.Must(t.Parse(text)) return t.Execute(w, data) } + +// ld compares two strings and returns the levenshtein distance between them +func ld(s, t string, ignoreCase bool) int { + if ignoreCase { + s = strings.ToLower(s) + t = strings.ToLower(t) + } + d := make([][]int, len(s)+1) + for i := range d { + d[i] = make([]int, len(t)+1) + } + for i := range d { + d[i][0] = i + } + for j := range d[0] { + d[0][j] = j + } + for j := 1; j <= len(t); j++ { + for i := 1; i <= len(s); i++ { + if s[i-1] == t[j-1] { + d[i][j] = d[i-1][j-1] + } else { + min := d[i-1][j] + if d[i][j-1] < min { + min = d[i][j-1] + } + if d[i-1][j-1] < min { + min = d[i-1][j-1] + } + d[i][j] = min + 1 + } + } + + } + return d[len(s)][len(t)] +} diff --git a/cobra_test.go b/cobra_test.go index 0a0d1286..3f019837 100644 --- a/cobra_test.go +++ b/cobra_test.go @@ -799,6 +799,34 @@ func TestRootUnknownCommand(t *testing.T) { } } +func TestRootSuggestions(t *testing.T) { + outputWithSuggestions := "Error: unknown command \"%s\" for \"cobra-test\"\n\nDid you mean this?\n\t%s\n\nRun 'cobra-test --help' for usage.\n" + outputWithoutSuggestions := "Error: unknown command \"%s\" for \"cobra-test\"\nRun 'cobra-test --help' for usage.\n" + + cmd := initializeWithRootCmd() + cmd.AddCommand(cmdTimes) + + tests := map[string]string{ + "time": "times", + "tiems": "times", + "timeS": "times", + "rimes": "times", + } + + for typo, suggestion := range tests { + cmd.DisableSuggestions = false + result := simpleTester(cmd, typo) + if expected := fmt.Sprintf(outputWithSuggestions, typo, suggestion); result.Output != expected { + t.Errorf("Unexpected response.\nExpecting to be:\n %q\nGot:\n %q\n", expected, result.Output) + } + cmd.DisableSuggestions = true + result = simpleTester(cmd, typo) + if expected := fmt.Sprintf(outputWithoutSuggestions, typo); result.Output != expected { + t.Errorf("Unexpected response.\nExpecting to be:\n %q\nGot:\n %q\n", expected, result.Output) + } + } +} + func TestFlagsBeforeCommand(t *testing.T) { // short without space x := fullSetupTest("-i10 echo") diff --git a/command.go b/command.go index df5a4554..14fa3a6e 100644 --- a/command.go +++ b/command.go @@ -106,6 +106,11 @@ type Command struct { helpCommand *Command // The help command // The global normalization function that we can use on every pFlag set and children commands globNormFunc func(f *flag.FlagSet, name string) flag.NormalizedName + + // Disable the suggestions based on Levenshtein distance that go along with 'unknown command' messages + DisableSuggestions bool + // If displaying suggestions, allows to set the minimum levenshtein distance to display, must be > 0 + SuggestionsMinimumDistance int } // os.Args[1:] by default, if desired, can be overridden @@ -419,9 +424,31 @@ func (c *Command) Find(args []string) (*Command, []string, error) { if !commandFound.HasSubCommands() { return commandFound, a, nil } + // root command with subcommands, do subcommand checking if commandFound == c && len(argsWOflags) > 0 { - return commandFound, a, fmt.Errorf("unknown command %q for %q", argsWOflags[0], commandFound.CommandPath()) + suggestions := "" + if !c.DisableSuggestions { + if c.SuggestionsMinimumDistance <= 0 { + c.SuggestionsMinimumDistance = 2 + } + similar := []string{} + for _, cmd := range c.commands { + if cmd.IsAvailableCommand() { + levenshtein := ld(argsWOflags[0], cmd.Name(), true) + if levenshtein <= c.SuggestionsMinimumDistance { + similar = append(similar, cmd.Name()) + } + } + } + if len(similar) > 0 { + suggestions += "\n\nDid you mean this?\n" + for _, s := range similar { + suggestions += fmt.Sprintf("\t%v\n", s) + } + } + } + return commandFound, a, fmt.Errorf("unknown command %q for %q%s", argsWOflags[0], commandFound.CommandPath(), suggestions) } return commandFound, a, nil