Repository: spf13/cobra
Pull request #149 (2015-09-11 20:44:08)

Allows command suggestions along with "unknown command" errors
Adds *optional* support to suggestions when "unknown command" errors happen. This allows Cobra to mimic the behaviour of `git`, like in:

```
$ git fethc
git: 'fethc' is not a git command. See 'git --help'.

Did you mean this?
	fetch
```

fabianofranz: @eparis some friday-evening awesomeness.  :)
eparis: I sort of feel like "on by default"....  (really cool)

fabianofranz: @eparis Yeah, I kept this off by default so we don't break the current behaviour. But it's going to be on in OpenShift.  :)
eparis: @spf13 I see no reason not to just turn it on and do away with the option altogether...
spf13: I can't think of a reason to not want this either. I think it's just a always on feature. Fewer knobs, more goodness all around. 
apriendeau: I agree. Its a good default way of handling the misspelled. A little more class then just failing or showing help. 
fabianofranz: I'm fine with making this on by default but I'd suggest we keep the knobs
to disable if needed. I can think of some scenarios, like potentially
dangerous administrative commands, with little (or huge) number of
subcommands, where users are expected to know exactly what they are doing
and giving suggestions that may not be completely accurate (since they are
automatic) could be dangerous.

If you are fine with this proposal I'll adjust the PR.
Em 12/09/2015 3:46 PM, "Austin Riendeau" <notifications@github.com>
escreveu:

> I agree. Its a good default way of handling the misspelled. A little more
> class then just failing or showing help.
>
> —
> Reply to this email directly or view it on GitHub
> <https://github.com/spf13/cobra/pull/149#issuecomment-139809209>.
>

fabianofranz: Actually I went ahead and adjusted it already. :) So now we are *on* by default, and have the know to disable it.

If you are good with it this should be good to go.
apriendeau: I think the only suggestion/comments that I have, is that it should skip deprecated and hidden commands.
fabianofranz: @apriendeau good catch, fixed.

Patch:
diff --git a/README.md b/README.md
index 5b97e02..8869ab9 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 97d1627..a61647b 100644
--- a/cobra.go
+++ b/cobra.go
@@ -23,15 +23,13 @@ import (
 	"strconv"
 	"strings"
 	"text/template"
-	"unicode"
 )
 
 var templateFuncs template.FuncMap = template.FuncMap{
-	"trim":           strings.TrimSpace,
-	"trimRightSpace": trimRightSpace,
-	"rpad":           rpad,
-	"gt":             Gt,
-	"eq":             Eq,
+	"trim": strings.TrimSpace,
+	"rpad": rpad,
+	"gt":   Gt,
+	"eq":   Eq,
 }
 
 var initializers []func()
@@ -115,10 +113,6 @@ func Eq(a interface{}, b interface{}) bool {
 	return false
 }
 
-func trimRightSpace(s string) string {
-	return strings.TrimRightFunc(s, unicode.IsSpace)
-}
-
 //rpad adds padding to the right of a string
 func rpad(s string, padding int) string {
 	template := fmt.Sprintf("%%-%ds", padding)
@@ -132,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/command.go b/command.go
index 3ccafcc..14fa3a6 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
@@ -186,9 +191,6 @@ func (c *Command) UsageFunc() (f func(*Command) error) {
 	} else {
 		return func(c *Command) error {
 			err := tmpl(c.Out(), c.UsageTemplate(), c)
-			if err != nil {
-				fmt.Print(err)
-			}
 			return err
 		}
 	}
@@ -251,7 +253,8 @@ func (c *Command) UsageTemplate() string {
 	if c.HasParent() {
 		return c.parent.UsageTemplate()
 	} else {
-		return `Usage:{{if .Runnable}}
+		return `{{ $cmd := . }}
+Usage: {{if .Runnable}}
   {{.UseLine}}{{if .HasFlags}} [flags]{{end}}{{end}}{{if .HasSubCommands}}
   {{ .CommandPath}} [command]{{end}}{{if gt .Aliases 0}}
 
@@ -262,20 +265,20 @@ Aliases:
 Examples:
 {{ .Example }}{{end}}{{ if .HasAvailableSubCommands}}
 
-Available Commands:{{range .Commands}}{{if .IsAvailableCommand}}
+Available Commands: {{range .Commands}}{{if .IsAvailableCommand}}
   {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{ if .HasLocalFlags}}
 
 Flags:
-{{.LocalFlags.FlagUsages | trimRightSpace}}{{end}}{{ if .HasInheritedFlags}}
+{{.LocalFlags.FlagUsages}}{{end}}{{ if .HasInheritedFlags}}
 
 Global Flags:
-{{.InheritedFlags.FlagUsages | trimRightSpace}}{{end}}{{if .HasHelpSubCommands}}
+{{.InheritedFlags.FlagUsages}}{{end}}{{if .HasHelpSubCommands}}
 
-Additional help topics:{{range .Commands}}{{if .IsHelpCommand}}
+Additional help topics: {{range .Commands}}{{if .IsHelpCommand}}
   {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{ if .HasSubCommands }}
 
-Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
-`
+Use "{{.CommandPath}} [command] --help" for more information about a command.
+{{end}}`
 	}
 }
 
@@ -287,9 +290,9 @@ func (c *Command) HelpTemplate() string {
 	if c.HasParent() {
 		return c.parent.HelpTemplate()
 	} else {
-		return `{{with or .Long .Short }}{{. | trim}}
-
-{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}`
+		return `{{with or .Long .Short }}{{. | trim}}{{end}}
+{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}
+`
 	}
 }
 
@@ -421,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
@@ -861,10 +886,6 @@ func (c *Command) IsAvailableCommand() bool {
 		return false
 	}
 
-	if c.HasParent() && c.Parent().helpCommand == c {
-		return false
-	}
-
 	if c.Runnable() || c.HasAvailableSubCommands() {
 		return true
 	}


Test patch:
diff --git a/cobra_test.go b/cobra_test.go
index 0a0d128..3f01983 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")