diff --git a/internal/textutil/almost_equal.go b/internal/textutil/almost_equal.go new file mode 100644 index 00000000..7b8cfd14 --- /dev/null +++ b/internal/textutil/almost_equal.go @@ -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 + } + // check for swap + a, b = tailA, tailB + if len(a) == 0 || len(b) == 0 { + return false + } + 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:] +} diff --git a/internal/textutil/almost_equal_test.go b/internal/textutil/almost_equal_test.go new file mode 100644 index 00000000..694689dc --- /dev/null +++ b/internal/textutil/almost_equal_test.go @@ -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) + } + } +} diff --git a/testscript/testscript.go b/testscript/testscript.go index 00fcbf0f..63ac99a1 100644 --- a/testscript/testscript.go +++ b/testscript/testscript.go @@ -21,6 +21,7 @@ import ( "path/filepath" "regexp" "runtime" + "sort" "strings" "sync/atomic" "syscall" @@ -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" @@ -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