Merge pull request #1390 from urfave/saschagrunert-suggestions

Add suggestions support (#977)
This commit is contained in:
Dan Buch 2022-05-17 08:59:47 -04:00 committed by GitHub
commit e770ee9794
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 335 additions and 11 deletions

View File

@ -37,7 +37,7 @@ jobs:
- name: vet - name: vet
run: go run internal/build/build.go 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 run: go run internal/build/build.go -tags urfave_cli_no_docs test
- name: test - name: test
@ -47,7 +47,7 @@ jobs:
run: go run internal/build/build.go check-binary-size run: go run internal/build/build.go check-binary-size
- name: check-binary-size with tags (informational only) - 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 - name: Upload coverage to Codecov
if: success() && matrix.go == '1.18.x' && matrix.os == 'ubuntu-latest' if: success() && matrix.go == '1.18.x' && matrix.os == 'ubuntu-latest'

View File

@ -63,7 +63,7 @@ You can use the following build tags:
When set, this removes `ToMarkdown` and `ToMan` methods, so your application 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 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 ### GOPATH

12
app.go
View File

@ -94,6 +94,8 @@ type App struct {
// single-character bool arguments into one // single-character bool arguments into one
// i.e. foobar -o -v -> foobar -ov // i.e. foobar -o -v -> foobar -ov
UseShortOptionHandling bool UseShortOptionHandling bool
// Enable suggestions for commands and flags
Suggest bool
didSetup bool didSetup bool
} }
@ -264,6 +266,11 @@ func (a *App) RunContext(ctx context.Context, arguments []string) (err error) {
return err return err
} }
_, _ = fmt.Fprintf(a.Writer, "%s %s\n\n", "Incorrect Usage.", err.Error()) _, _ = 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) _ = ShowAppHelp(cCtx)
return err return err
} }
@ -383,6 +390,11 @@ func (a *App) RunAsSubcommand(ctx *Context) (err error) {
return err return err
} }
_, _ = fmt.Fprintf(a.Writer, "%s %s\n\n", "Incorrect Usage.", err.Error()) _, _ = 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) _ = ShowSubcommandHelp(cCtx)
return err return err
} }

View File

@ -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, "Incorrect Usage:", err.Error())
_, _ = fmt.Fprintln(cCtx.App.Writer) _, _ = 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) _ = ShowCommandHelp(cCtx, c.Name)
return err return err
} }
@ -249,6 +254,7 @@ func (c *Command) startApp(ctx *Context) error {
app.ErrWriter = ctx.App.ErrWriter app.ErrWriter = ctx.App.ErrWriter
app.ExitErrHandler = ctx.App.ExitErrHandler app.ExitErrHandler = ctx.App.ExitErrHandler
app.UseShortOptionHandling = ctx.App.UseShortOptionHandling app.UseShortOptionHandling = ctx.App.UseShortOptionHandling
app.Suggest = ctx.App.Suggest
app.categories = newCommandCategories() app.categories = newCommandCategories()
for _, command := range c.Subcommands { for _, command := range c.Subcommands {

View File

@ -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) 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 ### Full API Example
**Notice**: This is a contrived (functioning) example meant strictly for API **Notice**: This is a contrived (functioning) example meant strictly for API

1
go.mod
View File

@ -4,6 +4,7 @@ go 1.18
require ( require (
github.com/BurntSushi/toml v1.1.0 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 github.com/cpuguy83/go-md2man/v2 v2.0.1
golang.org/x/text v0.3.7 golang.org/x/text v0.3.7
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0

8
go.sum
View File

@ -1,9 +1,17 @@
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 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 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 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= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=

View File

@ -305,6 +305,8 @@ type App struct {
// single-character bool arguments into one // single-character bool arguments into one
// i.e. foobar -o -v -> foobar -ov // i.e. foobar -o -v -> foobar -ov
UseShortOptionHandling bool UseShortOptionHandling bool
// Enable suggestions for commands and flags
Suggest bool
// Has unexported fields. // Has unexported fields.
} }

21
help.go
View File

@ -10,9 +10,14 @@ import (
"unicode/utf8" "unicode/utf8"
) )
const (
helpName = "help"
helpAlias = "h"
)
var helpCommand = &Command{ var helpCommand = &Command{
Name: "help", Name: helpName,
Aliases: []string{"h"}, Aliases: []string{helpAlias},
Usage: "Shows a list of commands or help for one command", Usage: "Shows a list of commands or help for one command",
ArgsUsage: "[command]", ArgsUsage: "[command]",
Action: func(cCtx *Context) error { Action: func(cCtx *Context) error {
@ -27,8 +32,8 @@ var helpCommand = &Command{
} }
var helpSubcommand = &Command{ var helpSubcommand = &Command{
Name: "help", Name: helpName,
Aliases: []string{"h"}, Aliases: []string{helpAlias},
Usage: "Shows a list of commands or help for one command", Usage: "Shows a list of commands or help for one command",
ArgsUsage: "[command]", ArgsUsage: "[command]",
Action: func(cCtx *Context) error { Action: func(cCtx *Context) error {
@ -214,7 +219,13 @@ func ShowCommandHelp(ctx *Context, command string) error {
} }
if ctx.App.CommandNotFound == nil { 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) ctx.App.CommandNotFound(ctx, command)

View File

@ -26,9 +26,8 @@ func parseIter(set *flag.FlagSet, ip iterativeParser, args []string, shellComple
return err return err
} }
errStr := err.Error() trimmed, trimErr := flagFromError(err)
trimmed := strings.TrimPrefix(errStr, "flag provided but not defined: -") if trimErr != nil {
if errStr == trimmed {
return err 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 { func splitShortOptions(set *flag.FlagSet, arg string) []string {
shortFlagsExist := func(s string) bool { shortFlagsExist := func(s string) bool {
for _, c := range s[1:] { for _, c := range s[1:] {

75
suggestions.go Normal file
View File

@ -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)
}

188
suggestions_test.go Normal file
View File

@ -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)
}

View File

@ -305,6 +305,8 @@ type App struct {
// single-character bool arguments into one // single-character bool arguments into one
// i.e. foobar -o -v -> foobar -ov // i.e. foobar -o -v -> foobar -ov
UseShortOptionHandling bool UseShortOptionHandling bool
// Enable suggestions for commands and flags
Suggest bool
// Has unexported fields. // Has unexported fields.
} }