Skip to content

Commit 55d9a62

Browse files
feat: add better suggests when commands don't match
1 parent 4ceddb8 commit 55d9a62

File tree

2 files changed

+127
-0
lines changed

2 files changed

+127
-0
lines changed

pkg/cmd/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ stl auth login
3030
stl init
3131
stl dev
3232
stl builds create --branch <branch>`,
33+
Suggest: true,
3334
Version: Version,
3435
Flags: []cli.Flag{
3536
&cli.BoolFlag{

pkg/cmd/suggest.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"slices"
7+
"strings"
8+
9+
"github.com/urfave/cli/v3"
10+
)
11+
12+
// This entire file is mostly taken from urfave/cli/v3's source, with the exception of suggestCommand which is
13+
// modified for a nicer error message.
14+
15+
// jaroDistance is the measure of similarity between two strings. It returns a
16+
// value between 0 and 1, where 1 indicates identical strings and 0 indicates
17+
// completely different strings.
18+
//
19+
// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro.go.
20+
func jaroDistance(a, b string) float64 {
21+
if len(a) == 0 && len(b) == 0 {
22+
return 1
23+
}
24+
if len(a) == 0 || len(b) == 0 {
25+
return 0
26+
}
27+
28+
lenA := float64(len(a))
29+
lenB := float64(len(b))
30+
hashA := make([]bool, len(a))
31+
hashB := make([]bool, len(b))
32+
maxDistance := int(math.Max(0, math.Floor(math.Max(lenA, lenB)/2.0)-1))
33+
34+
var matches float64
35+
for i := 0; i < len(a); i++ {
36+
start := int(math.Max(0, float64(i-maxDistance)))
37+
end := int(math.Min(lenB-1, float64(i+maxDistance)))
38+
39+
for j := start; j <= end; j++ {
40+
if hashB[j] {
41+
continue
42+
}
43+
if a[i] == b[j] {
44+
hashA[i] = true
45+
hashB[j] = true
46+
matches++
47+
break
48+
}
49+
}
50+
}
51+
if matches == 0 {
52+
return 0
53+
}
54+
55+
var transpositions float64
56+
var j int
57+
for i := 0; i < len(a); i++ {
58+
if !hashA[i] {
59+
continue
60+
}
61+
for !hashB[j] {
62+
j++
63+
}
64+
if a[i] != b[j] {
65+
transpositions++
66+
}
67+
j++
68+
}
69+
70+
transpositions /= 2
71+
return ((matches / lenA) + (matches / lenB) + ((matches - transpositions) / matches)) / 3.0
72+
}
73+
74+
// jaroWinkler is more accurate when strings have a common prefix up to a
75+
// defined maximum length.
76+
//
77+
// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro-winkler.go.
78+
func jaroWinkler(a, b string) float64 {
79+
const (
80+
boostThreshold = 0.7
81+
prefixSize = 4
82+
)
83+
jaroDist := jaroDistance(a, b)
84+
if jaroDist <= boostThreshold {
85+
return jaroDist
86+
}
87+
88+
prefix := int(math.Min(float64(len(a)), math.Min(float64(prefixSize), float64(len(b)))))
89+
90+
var prefixMatch float64
91+
for i := 0; i < prefix; i++ {
92+
if a[i] == b[i] {
93+
prefixMatch++
94+
} else {
95+
break
96+
}
97+
}
98+
return jaroDist + 0.1*prefixMatch*(1.0-jaroDist)
99+
}
100+
101+
// suggestCommand takes a list of commands and a provided string to suggest a
102+
// command name
103+
func suggestCommand(commands []*cli.Command, provided string) string {
104+
distance := 0.0
105+
var lineage []*cli.Command
106+
for _, command := range commands {
107+
for _, name := range command.Names() {
108+
newDistance := jaroWinkler(name, provided)
109+
if newDistance > distance {
110+
distance = newDistance
111+
lineage = command.Lineage()
112+
}
113+
}
114+
}
115+
116+
var parts []string
117+
for _, command := range lineage {
118+
parts = append(parts, command.Name)
119+
}
120+
slices.Reverse(parts)
121+
return fmt.Sprintf("Did you mean '%s'?", strings.Join(parts, " "))
122+
}
123+
124+
func init() {
125+
cli.SuggestCommand = suggestCommand
126+
}

0 commit comments

Comments
 (0)