Skip to content

Commit

Permalink
Merge pull request #1390 from urfave/saschagrunert-suggestions
Browse files Browse the repository at this point in the history
Add suggestions support (#977)
  • Loading branch information
meatballhat committed May 17, 2022
2 parents 97a222b + f3cf764 commit e770ee9
Show file tree
Hide file tree
Showing 13 changed files with 335 additions and 11 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/cli.yml
Expand Up @@ -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
Expand All @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions app.go
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 6 additions & 0 deletions command.go
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions docs/v2/manual.md
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions go.mod
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions 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=
Expand Down
2 changes: 2 additions & 0 deletions godoc-current.txt
Expand Up @@ -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.
}
Expand Down
21 changes: 16 additions & 5 deletions help.go
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 15 additions & 3 deletions parse.go
Expand Up @@ -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
}

Expand Down Expand Up @@ -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:] {
Expand Down
75 changes: 75 additions & 0 deletions 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)
}

0 comments on commit e770ee9

Please sign in to comment.