Repository: urfave/cli (go)

Issue:
"Did you mean" hint on wrong command or flag
We could tell the user what the closest command or argument match is by calculating the [levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance) between them.

What to do you think?

Pull request:
Add suggestions support (#977)
<!--
  This template provides some ideas of things to include in your PR description.
  To start, try providing a short summary of your changes in the Title above.
  If a section of the PR template does not apply to this PR, then delete that section.
 -->

## What type of PR is this?

- feature

## What this PR does / why we need it:

See #977 

## Which issue(s) this PR fixes:

Closes #895 

## Special notes for your reviewer:

This is intended to carry forward the work in #977 with the addition of build tags to allow opting out of the suggestions dependency at build time as with recently-added work for markdown/man doc dependencies (cc @kolyshkin).

Patch:
diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index 6983404..a7c395a 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -37,7 +37,7 @@ jobs:
       - name: vet
         run: go run internal/build/build.go vet
 
-      - name: test with tags
+      - name: test with urfave_cli_no_docs tag
         run: go run internal/build/build.go -tags urfave_cli_no_docs test
 
       - name: test
@@ -47,7 +47,7 @@ jobs:
         run: go run internal/build/build.go check-binary-size
 
       - name: check-binary-size with tags (informational only)
-        run: go run internal/build/build.go -tags urfave_cli_no_docs check-binary-size || true
+        run: go run internal/build/build.go -tags urfave_cli_no_docs check-binary-size
 
       - name: Upload coverage to Codecov
         if: success() && matrix.go == '1.18.x' && matrix.os == 'ubuntu-latest'
diff --git a/README.md b/README.md
index 6e4d698..beb5963 100644
--- a/README.md
+++ b/README.md
@@ -63,7 +63,7 @@ You can use the following build tags:
 
 When set, this removes `ToMarkdown` and `ToMan` methods, so your application
 won't be able to call those. This reduces the resulting binary size by about
-300-400 KB (measured using Go 1.18.1 on Linux/amd64), due to less dependencies.
+300-400 KB (measured using Go 1.18.1 on Linux/amd64), due to fewer dependencies.
 
 ### GOPATH
 
diff --git a/app.go b/app.go
index 52ef1b0..463437a 100644
--- a/app.go
+++ b/app.go
@@ -94,6 +94,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
 }
@@ -264,6 +266,11 @@ func (a *App) RunContext(ctx context.Context, 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(cCtx)
 		return err
 	}
@@ -383,6 +390,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, cCtx.Command.Name); err == nil {
+				fmt.Fprintf(a.Writer, suggestion)
+			}
+		}
 		_ = ShowSubcommandHelp(cCtx)
 		return err
 	}
diff --git a/command.go b/command.go
index ba0169b..3b9b837 100644
--- a/command.go
+++ b/command.go
@@ -119,6 +119,11 @@ func (c *Command) Run(ctx *Context) (err error) {
 		}
 		_, _ = fmt.Fprintln(cCtx.App.Writer, "Incorrect Usage:", err.Error())
 		_, _ = fmt.Fprintln(cCtx.App.Writer)
+		if ctx.App.Suggest {
+			if suggestion, err := ctx.App.suggestFlagFromError(err, c.Name); err == nil {
+				fmt.Fprintf(cCtx.App.Writer, suggestion)
+			}
+		}
 		_ = ShowCommandHelp(cCtx, c.Name)
 		return err
 	}
@@ -249,6 +254,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/docs/v2/manual.md b/docs/v2/manual.md
index 9c26100..3e0bf95 100644
--- a/docs/v2/manual.md
+++ b/docs/v2/manual.md
@@ -1410,6 +1410,13 @@ In this example the flag could be used like this :
 
 Side note: quotes may be necessary around the date depending on your layout (if you have spaces for instance)
 
+### Suggestions
+
+To enable flag and command suggestions, set `app.Suggest = true`. If the suggest
+feature is enabled, then the help output of the corresponding command will
+provide an appropriate suggestion for the provided flag or subcommand if
+available.
+
 ### Full API Example
 
 **Notice**: This is a contrived (functioning) example meant strictly for API
diff --git a/go.mod b/go.mod
index 3455d26..f9111b0 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.18
 
 require (
 	github.com/BurntSushi/toml v1.1.0
+	github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0
 	github.com/cpuguy83/go-md2man/v2 v2.0.1
 	golang.org/x/text v0.3.7
 	gopkg.in/yaml.v2 v2.4.0
diff --git a/go.sum b/go.sum
index 2c20c28..afac06b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,17 @@
 github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
 github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+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/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0 h1:R/qAiUxFT3mNgQaNqJe0IVznjKRNm23ohAIh9lgtlzc=
+github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0/go.mod h1:v3ZDlfVAL1OrkKHbGSFFK60k0/7hruHPDq2XMs9Gu6U=
 github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
 github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
diff --git a/godoc-current.txt b/godoc-current.txt
index 1bddc54..90700eb 100644
--- a/godoc-current.txt
+++ b/godoc-current.txt
@@ -305,6 +305,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
 
 	// Has unexported fields.
 }
diff --git a/help.go b/help.go
index 2f8156f..5103392 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(cCtx *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(cCtx *Context) error {
@@ -214,7 +219,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 7df1729..a2db306 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 0000000..476af4d
--- /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)
+}


Test patch:
diff --git a/suggestions_test.go b/suggestions_test.go
new file mode 100644
index 0000000..4ebe9c0
--- /dev/null
+++ b/suggestions_test.go
@@ -0,0 +1,188 @@
+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", ""},
+		{"s", "-s"},
+	} {
+		// 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 TestSuggestFlagFromErrorWrongError(t *testing.T) {
+	// Given
+	app := testApp()
+
+	// When
+	_, err := app.suggestFlagFromError(errors.New("invalid"), "")
+
+	// Then
+	expect(t, true, err != nil)
+}
+
+func TestSuggestFlagFromErrorWrongCommand(t *testing.T) {
+	// Given
+	app := testApp()
+
+	// When
+	_, err := app.suggestFlagFromError(
+		errors.New(providedButNotDefinedErrMsg+"flag"),
+		"invalid",
+	)
+
+	// Then
+	expect(t, true, err != nil)
+}
+
+func TestSuggestFlagFromErrorNoSuggestion(t *testing.T) {
+	// Given
+	app := testApp()
+
+	// When
+	_, err := app.suggestFlagFromError(
+		errors.New(providedButNotDefinedErrMsg+""),
+		"",
+	)
+
+	// Then
+	expect(t, true, err != nil)
+}
+
+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))
+	}
+}
+
+func ExampleApp_Suggest() {
+	app := &App{
+		Name:                  "greet",
+		Suggest:               true,
+		HideHelp:              true,
+		HideHelpCommand:       true,
+		CustomAppHelpTemplate: "(this space intentionally left blank)\n",
+		Flags: []Flag{
+			&StringFlag{Name: "name", Value: "squirrel", Usage: "a name to say"},
+		},
+		Action: func(cCtx *Context) error {
+			fmt.Printf("Hello %v\n", cCtx.String("name"))
+			return nil
+		},
+	}
+
+	app.Run([]string{"greet", "--nema", "chipmunk"})
+	// Output:
+	// Incorrect Usage. flag provided but not defined: -nema
+	//
+	// Did you mean '--name'?
+	//
+	// (this space intentionally left blank)
+}
+
+func ExampleApp_Suggest_command() {
+	app := &App{
+		Name:                  "greet",
+		Suggest:               true,
+		HideHelp:              true,
+		HideHelpCommand:       true,
+		CustomAppHelpTemplate: "(this space intentionally left blank)\n",
+		Flags: []Flag{
+			&StringFlag{Name: "name", Value: "squirrel", Usage: "a name to say"},
+		},
+		Action: func(cCtx *Context) error {
+			fmt.Printf("Hello %v\n", cCtx.String("name"))
+			return nil
+		},
+		Commands: []*Command{
+			{
+				Name:               "neighbors",
+				CustomHelpTemplate: "(this space intentionally left blank)\n",
+				Flags: []Flag{
+					&BoolFlag{Name: "smiling"},
+				},
+				Action: func(cCtx *Context) error {
+					if cCtx.Bool("smiling") {
+						fmt.Println("😀")
+					}
+					fmt.Println("Hello, neighbors")
+					return nil
+				},
+			},
+		},
+	}
+
+	app.Run([]string{"greet", "neighbors", "--sliming"})
+	// Output:
+	// Incorrect Usage: flag provided but not defined: -sliming
+	//
+	// Did you mean '--smiling'?
+	//
+	// (this space intentionally left blank)
+}
diff --git a/testdata/godoc-v2.x.txt b/testdata/godoc-v2.x.txt
index 1bddc54..90700eb 100644
--- a/testdata/godoc-v2.x.txt
+++ b/testdata/godoc-v2.x.txt
@@ -305,6 +305,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
 
 	// Has unexported fields.
 }