Skip to content

Commit

Permalink
Add suggestion on cmd not found
Browse files Browse the repository at this point in the history
  • Loading branch information
cristaloleg committed Oct 28, 2021
1 parent 656e0f2 commit 910d5d7
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 0 deletions.
20 changes: 20 additions & 0 deletions acmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,29 @@ func (r *Runner) run() error {
return c.Do(r.ctx, params)
}
}

if suggestion := r.suggestCommand(cmd); suggestion != "" {
fmt.Fprintf(r.cfg.Output, "%q is not a subcommand, did you mean %q?\n", cmd, suggestion)
}
return fmt.Errorf("no such command %q", cmd)
}

// suggestCommand for not found earlier command.
func (r *Runner) suggestCommand(cmd string) string {
const maxMatchDist = 2
minDist := maxMatchDist + 1
match := ""

for _, c := range r.cmds {
dist := strDistance(cmd, c.Name)
if dist < minDist {
minDist = dist
match = c.Name
}
}
return match
}

var defaultUsage = func(w io.Writer) func(cfg Config, cmds []Command) {
return func(cfg Config, cmds []Command) {
if cfg.AppDescription != "" {
Expand Down
49 changes: 49 additions & 0 deletions acmd_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package acmd

import (
"bytes"
"context"
"testing"
)
Expand Down Expand Up @@ -50,3 +51,51 @@ func TestRunner_init(t *testing.T) {
}
}
}

func TestRunner_suggestCommand(t *testing.T) {
testCases := []struct {
cmds []Command
args []string
want string
}{
{
cmds: []Command{
{Name: "for", Do: nopFunc},
{Name: "foo", Do: nopFunc},
{Name: "bar", Do: nopFunc},
},
args: []string{"fooo"},
want: `"fooo" is not a subcommand, did you mean "foo"?` + "\n",
},
{
cmds: []Command{},
args: []string{"hell"},
want: `"hell" is not a subcommand, did you mean "help"?` + "\n",
},
{
cmds: []Command{},
args: []string{"verZION"},
want: "",
},
{
cmds: []Command{},
args: []string{"verZion"},
want: `"verZion" is not a subcommand, did you mean "version"?` + "\n",
},
}

for _, tc := range testCases {
buf := &bytes.Buffer{}
r := RunnerOf(tc.cmds, Config{
Args: tc.args,
Output: buf,
})
if err := r.Run(); err == nil {
t.Fatal()
}

if got := buf.String(); got != tc.want {
t.Logf("want %q got %q", tc.want, got)
}
}
}
48 changes: 48 additions & 0 deletions levenshtein.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package acmd

// strDistance between 2 strings using Levenshtein distance algorithm.
func strDistance(a, b string) int {
switch {
case a == "":
return len(b)
case b == "":
return len(a)
case a == b:
return 0
}

if len(a) > len(b) {
a, b = b, a
}
lenA, lenB := len(a), len(b)

x := make([]int, lenA+1)
for i := 0; i < len(x); i++ {
x[i] = i
}

for i := 1; i <= lenB; i++ {
prev := i
for j := 1; j <= lenA; j++ {
current := x[j-1] // match
if b[i-1] != a[j-1] {
current = min3(x[j-1]+1, prev+1, x[j]+1)
}
x[j-1], prev = prev, current
}
x[lenA] = prev
}
return x[lenA]
}

func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
}
if b < c {
return b
}
return c
}
30 changes: 30 additions & 0 deletions levenshtein_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package acmd

import "testing"

func Test_strDistance(t *testing.T) {
testCases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "a", 0},
{"", "hello", 5},
{"hello", "", 5},
{"hello", "hello", 0},
{"ab", "aa", 1},
{"ab", "ba", 2},
{"ab", "aaa", 2},
{"bbb", "a", 3},
{"kitten", "sitting", 3},
{"distance", "difference", 5},
{"resume and cafe", "resumes and cafes", 2},
}

for _, tc := range testCases {
dist := strDistance(tc.a, tc.b)
if dist != tc.want {
t.Errorf("for (%q , %q) want %d, got %d", tc.a, tc.b, tc.want, dist)
}
}
}

0 comments on commit 910d5d7

Please sign in to comment.