Skip to content

Commit

Permalink
testscript: suggest misspelled commands
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Merovius committed Feb 3, 2023
1 parent e3815af commit 964bbe9
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 1 deletion.
51 changes: 51 additions & 0 deletions internal/textutil/almost_equal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package textutil

import "unicode/utf8"

// AlmostEqual reports whether a and b have Damerau-Levenshtein distance of at
// most 1. That is, it reports whether a can be transformed into b by adding,
// removing or substituting a single rune, or by swapping two adjacent runes.
//
// It runs in O(len(a)+len(b)) time.
func AlmostEqual(a, b string) bool {
for len(a) > 0 && len(b) > 0 {
ra, tailA := shiftRune(a)
rb, tailB := shiftRune(b)
if ra == rb {
a, b = tailA, tailB
continue
}
// check for addition/deletion/substitution
if a == tailB || tailA == b || tailA == tailB {
return true
}
if len(tailA) == 0 || len(tailB) == 0 {
return false
}
// check for swap
a, b = tailA, tailB
Ra, tailA := shiftRune(tailA)
Rb, tailB := shiftRune(tailB)
return ra == Rb && Ra == rb && tailA == tailB
}
if len(a) == 0 {
return len(b) == 0 || singleRune(b)
}
return singleRune(a)
}

// singleRune reports whether s consists of a single UTF-8 codepoint.
func singleRune(s string) bool {
_, n := utf8.DecodeRuneInString(s)
return n == len(s)
}

// shiftRune splits off the first UTF-8 codepoint from s and returns it and the
// rest of the string. It panics if s is empty.
func shiftRune(s string) (rune, string) {
if len(s) == 0 {
panic(s)
}
r, n := utf8.DecodeRuneInString(s)
return r, s[n:]
}
38 changes: 38 additions & 0 deletions internal/textutil/almost_equal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package textutil

import "testing"

func FuzzAlmostEqual(f *testing.F) {
f.Fuzz(func(t *testing.T, a, b string) {
AlmostEqual(a, b)
})
}

func TestAlmostEqual(t *testing.T) {
t.Parallel()

tcs := []struct {
inA string
inB string
want bool
}{
{"", "", true},
{"", "a", true},
{"a", "a", true},
{"a", "b", true},
{"hello", "hell", true},
{"hello", "jello", true},
{"hello", "helol", true},
{"hello", "jelol", false},
}
for _, tc := range tcs {
got := AlmostEqual(tc.inA, tc.inB)
if got != tc.want {
t.Errorf("AlmostEqual(%q, %q) = %v, want %v", tc.inA, tc.inB, got, tc.want)
}
// two tests for the price of one \o/
if got != AlmostEqual(tc.inB, tc.inA) {
t.Errorf("AlmostEqual(%q, %q) == %v != AlmostEqual(%q, %q)", tc.inA, tc.inB, got, tc.inB, tc.inA)
}
}
}
40 changes: 39 additions & 1 deletion testscript/testscript.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sync/atomic"
"syscall"
Expand All @@ -29,6 +30,7 @@ import (

"github.com/rogpeppe/go-internal/imports"
"github.com/rogpeppe/go-internal/internal/os/execpath"
"github.com/rogpeppe/go-internal/internal/textutil"
"github.com/rogpeppe/go-internal/par"
"github.com/rogpeppe/go-internal/testenv"
"github.com/rogpeppe/go-internal/txtar"
Expand Down Expand Up @@ -661,12 +663,48 @@ func (ts *TestScript) runLine(line string) (runOK bool) {
cmd = ts.params.Cmds[args[0]]
}
if cmd == nil {
ts.Fatalf("unknown command %q", args[0])
// try to find spelling corrections. We arbitrarily limit the number of
// corrections, to not be too noisy.
switch c := ts.cmdSuggestions(args[0]); len(c) {
case 1:
ts.Fatalf("unknown command %q (did you mean %q?)", args[0], c[0])
case 2, 3, 4:
ts.Fatalf("unknown command %q (did you mean one of %q?)", args[0], c)
default:
ts.Fatalf("unknown command %q", args[0])
}
}
cmd(ts, neg, args[1:])
return true
}

func (ts *TestScript) cmdSuggestions(name string) []string {
var candidates []string
check := func(s string) {
if textutil.AlmostEqual(name, s) || name == "! "+s {
candidates = append(candidates, s)
}
}
for c := range scriptCmds {
check(c)
}
for c := range ts.params.Cmds {
check(c)
}
if len(candidates) == 0 {
return nil
}
// deduplicate candidates
sort.Strings(candidates)
out := candidates[:1]
for _, c := range candidates[1:] {
if out[len(out)-1] == c {
out = append(out, c)
}
}
return out
}

func (ts *TestScript) applyScriptUpdates() {
if len(ts.scriptUpdates) == 0 {
return
Expand Down

0 comments on commit 964bbe9

Please sign in to comment.