diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7ed5e6ec1..45d0972b34 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,10 +45,11 @@ jobs: - if: matrix.go == '1.20.x' && matrix.os == 'ubuntu-latest' run: make v3diff - if: success() && matrix.go == '1.20.x' && matrix.os == 'ubuntu-latest' - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true + verbose: true test-docs: name: test-docs diff --git a/docs/v3/examples/bash-completions.md b/docs/v3/examples/bash-completions.md index 73c7fde707..737401ca63 100644 --- a/docs/v3/examples/bash-completions.md +++ b/docs/v3/examples/bash-completions.md @@ -188,7 +188,7 @@ The default shell completion flag (`--generate-bash-completion`) is defined as ```go package main diff --git a/examples_test.go b/examples_test.go index 1d0ee0d25f..b7050b801d 100644 --- a/examples_test.go +++ b/examples_test.go @@ -269,11 +269,8 @@ func ExampleCommand_Run_shellComplete_bash_withShortFlag() { _ = cmd.Run(context.Background(), os.Args) // Output: // --other - // -o // --xyz - // -x // --help - // -h } func ExampleCommand_Run_shellComplete_bash_withLongFlag() { @@ -376,10 +373,8 @@ func ExampleCommand_Run_shellComplete_bash() { _ = cmd.Run(context.Background(), os.Args) // Output: // describeit - // d // next // help - // h } func ExampleCommand_Run_shellComplete_zsh() { @@ -415,10 +410,8 @@ func ExampleCommand_Run_shellComplete_zsh() { _ = cmd.Run(context.Background(), os.Args) // Output: // describeit:use it to see a description - // d:use it to see a description // next:next example // help:Shows a list of commands or help for one command - // h:Shows a list of commands or help for one command } func ExampleCommand_Run_sliceValues() { diff --git a/go.mod b/go.mod index ccc913baf8..2425a9b7fc 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,7 @@ module github.com/urfave/cli/v3 go 1.18 -require ( - github.com/stretchr/testify v1.8.4 - github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 -) +require github.com/stretchr/testify v1.8.4 require ( github.com/BurntSushi/toml v1.3.2 // indirect diff --git a/go.sum b/go.sum index 39b6c7abd3..1ce67a49f3 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/urfave/cli-altsrc/v3 v3.0.0-alpha2 h1:j4SaBpPB8++L0c0KuTnz/Yus3UQoWJ54hQjhIMW8rCM= github.com/urfave/cli-altsrc/v3 v3.0.0-alpha2/go.mod h1:Q79oyIY/z4jtzIrKEK6MUeWC7/szGr46x4QdOaOAIWc= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/help.go b/help.go index 411d0ca42c..dc970009f0 100644 --- a/help.go +++ b/help.go @@ -160,13 +160,9 @@ func printCommandSuggestions(commands []*Command, writer io.Writer) { continue } if strings.HasSuffix(os.Getenv("SHELL"), "zsh") { - for _, name := range command.Names() { - _, _ = fmt.Fprintf(writer, "%s:%s\n", name, command.Usage) - } + _, _ = fmt.Fprintf(writer, "%s:%s\n", command.Name, command.Usage) } else { - for _, name := range command.Names() { - _, _ = fmt.Fprintf(writer, "%s\n", name) - } + _, _ = fmt.Fprintf(writer, "%s\n", command.Name) } } } @@ -195,23 +191,23 @@ func printFlagSuggestions(lastArg string, flags []Flag, writer io.Writer) { if bflag, ok := flag.(*BoolFlag); ok && bflag.Hidden { continue } - for _, name := range flag.Names() { - name = strings.TrimSpace(name) - // this will get total count utf8 letters in flag name - count := utf8.RuneCountInString(name) - if count > 2 { - count = 2 // reuse this count to generate single - or -- in flag completion - } - // if flag name has more than one utf8 letter and last argument in cli has -- prefix then - // skip flag completion for short flags example -v or -x - if strings.HasPrefix(lastArg, "--") && count == 1 { - continue - } - // match if last argument matches this flag and it is not repeated - if strings.HasPrefix(name, cur) && cur != name && !cliArgContains(name) { - flagCompletion := fmt.Sprintf("%s%s", strings.Repeat("-", count), name) - fmt.Fprintln(writer, flagCompletion) - } + + name := strings.TrimSpace(flag.Names()[0]) + // this will get total count utf8 letters in flag name + count := utf8.RuneCountInString(name) + if count > 2 { + count = 2 // reuse this count to generate single - or -- in flag completion + } + // if flag name has more than one utf8 letter and last argument in cli has -- prefix then + // skip flag completion for short flags example -v or -x + if strings.HasPrefix(lastArg, "--") && count == 1 { + continue + } + // match if last argument matches this flag and it is not repeated + if strings.HasPrefix(name, cur) && cur != name && !cliArgContains(name) { + flagCompletion := fmt.Sprintf("%s%s", strings.Repeat("-", count), name) + fmt.Fprintln(writer, flagCompletion) + } } } diff --git a/suggestions.go b/suggestions.go index 607de09deb..6f29f12213 100644 --- a/suggestions.go +++ b/suggestions.go @@ -1,7 +1,7 @@ package cli import ( - "github.com/xrash/smetrics" + "math" ) const suggestDidYouMeanTemplate = "Did you mean %q?" @@ -16,13 +16,90 @@ type SuggestFlagFunc func(flags []Flag, provided string, hideHelp bool) string type SuggestCommandFunc func(commands []*Command, provided string) string +// jaroDistance is the measure of similarity between two strings. It returns a +// value between 0 and 1, where 1 indicates identical strings and 0 indicates +// completely different strings. +// +// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro.go. +func jaroDistance(a, b string) float64 { + if len(a) == 0 && len(b) == 0 { + return 1 + } + if len(a) == 0 || len(b) == 0 { + return 0 + } + + lenA := float64(len(a)) + lenB := float64(len(b)) + hashA := make([]bool, len(a)) + hashB := make([]bool, len(b)) + maxDistance := int(math.Max(0, math.Floor(math.Max(lenA, lenB)/2.0)-1)) + + var matches float64 + for i := 0; i < len(a); i++ { + start := int(math.Max(0, float64(i-maxDistance))) + end := int(math.Min(lenB-1, float64(i+maxDistance))) + + for j := start; j <= end; j++ { + if hashB[j] { + continue + } + if a[i] == b[j] { + hashA[i] = true + hashB[j] = true + matches++ + break + } + } + } + if matches == 0 { + return 0 + } + + var transpositions float64 + var j int + for i := 0; i < len(a); i++ { + if !hashA[i] { + continue + } + for !hashB[j] { + j++ + } + if a[i] != b[j] { + transpositions++ + } + j++ + } + + transpositions /= 2 + return ((matches / lenA) + (matches / lenB) + ((matches - transpositions) / matches)) / 3.0 +} + +// jaroWinkler is more accurate when strings have a common prefix up to a +// defined maximum length. +// +// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro-winkler.go. func jaroWinkler(a, b string) float64 { - // magic values are from https://github.com/xrash/smetrics/blob/039620a656736e6ad994090895784a7af15e0b80/jaro-winkler.go#L8 const ( boostThreshold = 0.7 prefixSize = 4 ) - return smetrics.JaroWinkler(a, b, boostThreshold, prefixSize) + jaroDist := jaroDistance(a, b) + if jaroDist <= boostThreshold { + return jaroDist + } + + prefix := int(math.Min(float64(len(a)), math.Min(float64(prefixSize), float64(len(b))))) + + var prefixMatch float64 + for i := 0; i < prefix; i++ { + if a[i] == b[i] { + prefixMatch++ + } else { + break + } + } + return jaroDist + 0.1*prefixMatch*(1.0-jaroDist) } func suggestFlag(flags []Flag, provided string, hideHelp bool) string { diff --git a/suggestions_test.go b/suggestions_test.go index 979fbe0cb4..b1e962104e 100644 --- a/suggestions_test.go +++ b/suggestions_test.go @@ -8,6 +8,33 @@ import ( "github.com/stretchr/testify/assert" ) +func TestJaroWinkler(t *testing.T) { + // Given + for _, testCase := range []struct { + a, b string + expected float64 + }{ + {"", "", 1}, + {"a", "", 0}, + {"", "a", 0}, + {"a", "a", 1}, + {"a", "b", 0}, + {"aa", "aa", 1}, + {"aa", "bb", 0}, + {"aaa", "aaa", 1}, + {"aa", "ab", 0.6666666666666666}, + {"aa", "ba", 0.6666666666666666}, + {"ba", "aa", 0.6666666666666666}, + {"ab", "aa", 0.6666666666666666}, + } { + // When + res := jaroWinkler(testCase.a, testCase.b) + + // Then + assert.Equal(t, testCase.expected, res) + } +} + func TestSuggestFlag(t *testing.T) { // Given app := buildExtendedTestCommand() diff --git a/template.go b/template.go index 8981ab447d..c150403a12 100644 --- a/template.go +++ b/template.go @@ -10,6 +10,7 @@ var authorsTemplate = `{{with $length := len .Authors}}{{if ne 1 $length}}S{{end var visibleCommandTemplate = `{{ $cv := offsetCommands .VisibleCommands 5}}{{range .VisibleCommands}} {{$s := join .Names ", "}}{{$s}}{{ $sp := subtract $cv (offset $s 3) }}{{ indent $sp ""}}{{wrap .Usage $cv}}{{end}}` var visibleCommandCategoryTemplate = `{{range .VisibleCategories}}{{if .Name}} + {{.Name}}:{{range .VisibleCommands}} {{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{else}}{{template "visibleCommandTemplate" .}}{{end}}{{end}}` var visibleFlagCategoryTemplate = `{{range .VisibleFlagCategories}}