Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add suggestions support #977

Merged
merged 2 commits into from May 17, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 12 additions & 0 deletions app.go
Expand Up @@ -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
}
Expand Down Expand Up @@ -250,6 +252,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(context)
return err
}
Expand Down Expand Up @@ -381,6 +388,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
}
Expand Down
6 changes: 6 additions & 0 deletions command.go
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions docs/v2/manual.md
Expand Up @@ -34,6 +34,7 @@ cli v2 manual
* [Version Flag](#version-flag)
+ [Customization](#customization-2)
* [Timestamp Flag](#timestamp-flag)
* [Suggestions](#suggestions)
* [Full API Example](#full-api-example)

<!-- tocstop -->
Expand Down Expand Up @@ -1426,6 +1427,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.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
)
2 changes: 2 additions & 0 deletions 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=
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(c *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(c *Context) error {
Expand Down Expand Up @@ -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)
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: -"
asahasrabuddhe marked this conversation as resolved.
Show resolved Hide resolved

// 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
}
saschagrunert marked this conversation as resolved.
Show resolved Hide resolved

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()...)
}
saschagrunert marked this conversation as resolved.
Show resolved Hide resolved
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nice to have) I'm not sure about this suggestion, but: would it be a good idea to consider suggesting sub commands too? I'm imagining an example CLI that has exactly these 3 commands

docker info
docker info containers
docker info images

and I input this

docker containers

I would be expecting to get a suggestion like

did you mean: docker info containers?

whereas I believe this code with give me

did you mean: docker info?

is that a correct assumption?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is correct that it would only suggest docker info in that case, because can only suggest one command with the current implementation. I'm also not sure if we should add such a traversal suggestion to the users. It might be helpful but it also might result in confusing them. 😁

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^ fair!

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)
}
saschagrunert marked this conversation as resolved.
Show resolved Hide resolved
122 changes: 122 additions & 0 deletions suggestions_test.go
@@ -0,0 +1,122 @@
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))
}
}