Skip to content

Commit 5150104

Browse files
Meroviusmvdan
authored andcommitted
testscript: suggest misspelled commands
If a command is not found, we go through the list of defined commands and check if any of them are sufficiently close to the one used. "Sufficiently close" is defined by having a Damerau-Levenshtein distance of 1, which feels like it hits the sweet spot between usefulness and ease of implementation. The negation case is still special-cased, as negation is not in the set of defined commands. Fixes #190
1 parent 44c3b86 commit 5150104

File tree

8 files changed

+245
-1
lines changed

8 files changed

+245
-1
lines changed

internal/misspell/misspell.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Package misspell impements utilities for basic spelling correction.
2+
package misspell
3+
4+
import (
5+
"unicode/utf8"
6+
)
7+
8+
// AlmostEqual reports whether a and b have Damerau-Levenshtein distance of at
9+
// most 1. That is, it reports whether a can be transformed into b by adding,
10+
// removing or substituting a single rune, or by swapping two adjacent runes.
11+
// Invalid runes are considered equal.
12+
//
13+
// It runs in O(len(a)+len(b)) time.
14+
func AlmostEqual(a, b string) bool {
15+
for len(a) > 0 && len(b) > 0 {
16+
ra, tailA := shiftRune(a)
17+
rb, tailB := shiftRune(b)
18+
if ra == rb {
19+
a, b = tailA, tailB
20+
continue
21+
}
22+
// check for addition/deletion/substitution
23+
if equalValid(a, tailB) || equalValid(tailA, b) || equalValid(tailA, tailB) {
24+
return true
25+
}
26+
if len(tailA) == 0 || len(tailB) == 0 {
27+
return false
28+
}
29+
// check for swap
30+
a, b = tailA, tailB
31+
Ra, tailA := shiftRune(tailA)
32+
Rb, tailB := shiftRune(tailB)
33+
return ra == Rb && Ra == rb && equalValid(tailA, tailB)
34+
}
35+
if len(a) == 0 {
36+
return len(b) == 0 || singleRune(b)
37+
}
38+
return singleRune(a)
39+
}
40+
41+
// singleRune reports whether s consists of a single UTF-8 codepoint.
42+
func singleRune(s string) bool {
43+
_, n := utf8.DecodeRuneInString(s)
44+
return n == len(s)
45+
}
46+
47+
// shiftRune splits off the first UTF-8 codepoint from s and returns it and the
48+
// rest of the string. It panics if s is empty.
49+
func shiftRune(s string) (rune, string) {
50+
if len(s) == 0 {
51+
panic(s)
52+
}
53+
r, n := utf8.DecodeRuneInString(s)
54+
return r, s[n:]
55+
}
56+
57+
// equalValid reports whether a and b are equal, if invalid code points are considered equal.
58+
func equalValid(a, b string) bool {
59+
var ra, rb rune
60+
for len(a) > 0 && len(b) > 0 {
61+
ra, a = shiftRune(a)
62+
rb, b = shiftRune(b)
63+
if ra != rb {
64+
return false
65+
}
66+
}
67+
return len(a) == 0 && len(b) == 0
68+
}

internal/misspell/misspell_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package misspell
2+
3+
import (
4+
"math"
5+
"testing"
6+
)
7+
8+
func TestAlmostEqual(t *testing.T) {
9+
t.Parallel()
10+
11+
tcs := []struct {
12+
inA string
13+
inB string
14+
want bool
15+
}{
16+
{"", "", true},
17+
{"", "a", true},
18+
{"a", "a", true},
19+
{"a", "b", true},
20+
{"hello", "hell", true},
21+
{"hello", "jello", true},
22+
{"hello", "helol", true},
23+
{"hello", "jelol", false},
24+
}
25+
for _, tc := range tcs {
26+
got := AlmostEqual(tc.inA, tc.inB)
27+
if got != tc.want {
28+
t.Errorf("AlmostEqual(%q, %q) = %v, want %v", tc.inA, tc.inB, got, tc.want)
29+
}
30+
// two tests for the price of one \o/
31+
if got != AlmostEqual(tc.inB, tc.inA) {
32+
t.Errorf("AlmostEqual(%q, %q) == %v != AlmostEqual(%q, %q)", tc.inA, tc.inB, got, tc.inB, tc.inA)
33+
}
34+
}
35+
}
36+
37+
func FuzzAlmostEqual(f *testing.F) {
38+
f.Add("", "")
39+
f.Add("", "a")
40+
f.Add("a", "a")
41+
f.Add("a", "b")
42+
f.Add("hello", "hell")
43+
f.Add("hello", "jello")
44+
f.Add("hello", "helol")
45+
f.Add("hello", "jelol")
46+
f.Fuzz(func(t *testing.T, a, b string) {
47+
if len(a) > 10 || len(b) > 10 {
48+
// longer strings won't add coverage, but take longer to check
49+
return
50+
}
51+
d := editDistance([]rune(a), []rune(b))
52+
got := AlmostEqual(a, b)
53+
if want := d <= 1; got != want {
54+
t.Errorf("AlmostEqual(%q, %q) = %v, editDistance(%q, %q) = %d", a, b, got, a, b, d)
55+
}
56+
if got != AlmostEqual(b, a) {
57+
t.Errorf("AlmostEqual(%q, %q) == %v != AlmostEqual(%q, %q)", a, b, got, b, a)
58+
}
59+
})
60+
}
61+
62+
// editDistance returns the Damerau-Levenshtein distance between a and b. It is
63+
// inefficient, but by keeping almost verbatim to the recursive definition from
64+
// Wikipedia, hopefully "obviously correct" and thus suitable for the fuzzing
65+
// test of AlmostEqual.
66+
func editDistance(a, b []rune) int {
67+
i, j := len(a), len(b)
68+
m := math.MaxInt
69+
if i == 0 && j == 0 {
70+
return 0
71+
}
72+
if i > 0 {
73+
m = min(m, editDistance(a[1:], b)+1)
74+
}
75+
if j > 0 {
76+
m = min(m, editDistance(a, b[1:])+1)
77+
}
78+
if i > 0 && j > 0 {
79+
d := editDistance(a[1:], b[1:])
80+
if a[0] != b[0] {
81+
d += 1
82+
}
83+
m = min(m, d)
84+
}
85+
if i > 1 && j > 1 && a[0] == b[1] && a[1] == b[0] {
86+
d := editDistance(a[2:], b[2:])
87+
if a[0] != b[0] {
88+
d += 1
89+
}
90+
m = min(m, d)
91+
}
92+
return m
93+
}
94+
95+
func min(a, b int) int {
96+
if a < b {
97+
return a
98+
}
99+
return b
100+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
go test fuzz v1
2+
string("")
3+
string("00")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
go test fuzz v1
2+
string("\x980")
3+
string("0\xb70")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
go test fuzz v1
2+
string("OOOOOOOO000")
3+
string("0000000000000")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
go test fuzz v1
2+
string("OOOOOOOO000")
3+
string("0000000000\x1000")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Check that unknown commands output a useful error message
2+
3+
! testscript notfound
4+
stdout 'unknown command "notexist"'
5+
6+
! testscript negation
7+
stdout 'unknown command "!exists" \(did you mean "! exists"\?\)'
8+
9+
! testscript misspelled
10+
stdout 'unknown command "exits" \(did you mean "exists"\?\)'
11+
12+
-- notfound/script.txt --
13+
notexist
14+
-- negation/script.txt --
15+
!exists file
16+
-- misspelled/script.txt --
17+
exits file

testscript/testscript.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ import (
2222
"path/filepath"
2323
"regexp"
2424
"runtime"
25+
"sort"
2526
"strings"
2627
"sync/atomic"
2728
"syscall"
2829
"testing"
2930
"time"
3031

3132
"github.com/rogpeppe/go-internal/imports"
33+
"github.com/rogpeppe/go-internal/internal/misspell"
3234
"github.com/rogpeppe/go-internal/internal/os/execpath"
3335
"github.com/rogpeppe/go-internal/par"
3436
"github.com/rogpeppe/go-internal/testenv"
@@ -669,7 +671,16 @@ func (ts *TestScript) runLine(line string) (runOK bool) {
669671
cmd = ts.params.Cmds[args[0]]
670672
}
671673
if cmd == nil {
672-
ts.Fatalf("unknown command %q", args[0])
674+
// try to find spelling corrections. We arbitrarily limit the number of
675+
// corrections, to not be too noisy.
676+
switch c := ts.cmdSuggestions(args[0]); len(c) {
677+
case 1:
678+
ts.Fatalf("unknown command %q (did you mean %q?)", args[0], c[0])
679+
case 2, 3, 4:
680+
ts.Fatalf("unknown command %q (did you mean one of %q?)", args[0], c)
681+
default:
682+
ts.Fatalf("unknown command %q", args[0])
683+
}
673684
}
674685
ts.callBuiltinCmd(args[0], func() {
675686
cmd(ts, neg, args[1:])
@@ -694,6 +705,42 @@ func (ts *TestScript) callBuiltinCmd(cmd string, runCmd func()) {
694705
runCmd()
695706
}
696707

708+
func (ts *TestScript) cmdSuggestions(name string) []string {
709+
// special case: spell-correct `!cmd` to `! cmd`
710+
if strings.HasPrefix(name, "!") {
711+
if _, ok := scriptCmds[name[1:]]; ok {
712+
return []string{"! " + name[1:]}
713+
}
714+
if _, ok := ts.params.Cmds[name[1:]]; ok {
715+
return []string{"! " + name[1:]}
716+
}
717+
}
718+
var candidates []string
719+
for c := range scriptCmds {
720+
if misspell.AlmostEqual(name, c) {
721+
candidates = append(candidates, c)
722+
}
723+
}
724+
for c := range ts.params.Cmds {
725+
if misspell.AlmostEqual(name, c) {
726+
candidates = append(candidates, c)
727+
}
728+
}
729+
if len(candidates) == 0 {
730+
return nil
731+
}
732+
// deduplicate candidates
733+
// TODO: Use slices.Compact (and maybe slices.Sort) once we can use Go 1.21
734+
sort.Strings(candidates)
735+
out := candidates[:1]
736+
for _, c := range candidates[1:] {
737+
if out[len(out)-1] == c {
738+
out = append(out, c)
739+
}
740+
}
741+
return out
742+
}
743+
697744
func (ts *TestScript) applyScriptUpdates() {
698745
if len(ts.scriptUpdates) == 0 {
699746
return

0 commit comments

Comments
 (0)