diff --git a/CHANGELOG.md b/CHANGELOG.md index 762a1d4cc87..72e122f41f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,15 @@ CHANGELOG 0.74.3 ------ +- Performance optimizations for non-ASCII input + - A line holding any non-ASCII character is kept as a rune array, and the prefilter did not run on those lines, so every item went through the full score matrix + - Queries are up to 16x faster, the gain growing with how much of the line is non-ASCII + - Non-ASCII queries are up to 12x faster + - Reading non-ASCII input is up to 37% faster and uses up to 29% less memory, the gain depending on how early the first non-ASCII character appears in the line + - Accented Latin and fullwidth forms get the faster reading but not the faster queries, because those characters can still match their ASCII counterparts + - ASCII input is unaffected - Fixed an image from a preview command being torn apart when its rows are separated by IND instead of newlines, as `chafa` does under tmux (#4885) +- Fixed `replace-query` corrupting the item text when the query is edited afterwards 0.74.2 ------ diff --git a/Makefile b/Makefile index ab93c7f0035..3ba74813a18 100644 --- a/Makefile +++ b/Makefile @@ -102,7 +102,7 @@ itest: # FUZZTIME (e.g. make fuzz FUZZTIME=5m). FUZZTIME ?= 30s fuzz: - @for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two; do \ + @for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two FuzzRunePrefilter; do \ echo "== $$t =="; \ $(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \ done diff --git a/src/algo/algo.go b/src/algo/algo.go index d1ceec99e54..f4c128b6fed 100644 --- a/src/algo/algo.go +++ b/src/algo/algo.go @@ -303,7 +303,9 @@ func bonusAt(input *util.Chars, idx int) int16 { } func normalizeRune(r rune) rune { - if r < 0x00C0 || r > 0xFF61 { + // Every key of the map folds to ASCII, so a rune the bitmap rejects cannot + // be in it. TestNormalizedKeysAreFlagged pins that. + if !util.MayFoldToAscii(r) { return r } @@ -345,10 +347,90 @@ func isAscii(runes []rune) bool { return true } +// runePrefilterable reports whether scanning the rune array can decide this +// pattern against this item without missing a match. Phase 2 lowercases an +// uppercase text rune and then normalizes it, and the scan sees neither +// transform, so every pattern rune must be unreachable by them. +func runePrefilterable(input *util.Chars, pattern []rune, caseSensitive bool) bool { + if input.MayFoldToAscii() { + // A non-ASCII rune of this item could fold onto an ASCII pattern char + for _, r := range pattern { + if r < utf8.RuneSelf { + return false + } + } + } + if caseSensitive { + // No case transform is applied, and normalization only ever produces + // ASCII, so nothing can reach a non-ASCII pattern rune + return true + } + for _, r := range pattern { + // Another rune must not lowercase onto this one. Being uncased is not + // enough by itself: U+00DF has no simple uppercase yet U+1E9E + // lowercases to it. Excluding the foldable set covers that. + if r >= utf8.RuneSelf && + (unicode.ToUpper(r) != r || unicode.ToLower(r) != r || util.MayFoldToAscii(r)) { + return false + } + } + return true +} + +// runeFuzzyIndex is asciiFuzzyIndex for rune-mode input. Only valid when +// runePrefilterable says so. +func runeFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) { + runes := input.Runes() + firstIdx, idx, lastIdx := 0, 0, 0 + var last rune + for pidx := range pattern { + last = pattern[pidx] + if last < utf8.RuneSelf { + idx = indexAsciiRune(runes, caseSensitive, byte(last), idx) + } else { + idx = indexRune(runes, last, idx) + } + if idx < 0 { + return -1, -1 + } + if pidx == 0 && idx > 0 { + // Step back to find the right bonus point + firstIdx = idx - 1 + } + lastIdx = idx + idx++ + } + + // Find the last appearance of the last character of the pattern to limit + // the search scope + if lastIdx+1 < len(runes) { + var end int + if last < utf8.RuneSelf { + end = lastIndexAsciiRune(runes, caseSensitive, byte(last), lastIdx+1) + } else { + end = lastIndexRune(runes, last, lastIdx+1) + } + if end >= 0 { + return firstIdx, end + 1 + } + } + return firstIdx, lastIdx + 1 +} + func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) { - // Can't determine if !input.IsBytes() { - return 0, input.Length() + if disableRunePrefilter { + return 0, input.Length() + } + // runePrefilterable does not inline, so keep the common case out of + // it: an ASCII pattern against an item that cannot fold to ASCII is + // always scannable, and both of these checks do inline. + if input.MayFoldToAscii() || !isAscii(pattern) { + if !runePrefilterable(input, pattern, caseSensitive) { + return 0, input.Length() + } + } + return runeFuzzyIndex(input, pattern, caseSensitive) } // Not possible @@ -466,8 +548,9 @@ func fuzzyMatchV2Single(caseSensitive bool, forward bool, input *util.Chars, b b // Test hooks: force the general path instead of a fast path, so the two can // be compared for equivalence. var ( - disableSingle bool - disableTwo bool + disableSingle bool + disableTwo bool + disableRunePrefilter bool ) // fuzzyMatchV2Two is a fused fast path for a two-character ASCII pattern on diff --git a/src/algo/runeindex_others.go b/src/algo/runeindex_others.go new file mode 100644 index 00000000000..c69614474be --- /dev/null +++ b/src/algo/runeindex_others.go @@ -0,0 +1,23 @@ +//go:build !386 && !amd64 && !arm64 + +package algo + +// The byte-view scanners in runeindex_x86.go reinterpret a []rune as +// little-endian 4-byte lanes, which is not valid everywhere. Elsewhere the +// reference scanners are the implementation. + +func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int { + return indexAsciiRuneRef(runes, caseSensitive, b, from) +} + +func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int { + return lastIndexAsciiRuneRef(runes, caseSensitive, b, from) +} + +func indexRune(runes []rune, r rune, from int) int { + return indexRuneRef(runes, r, from) +} + +func lastIndexRune(runes []rune, r rune, from int) int { + return lastIndexRuneRef(runes, r, from) +} diff --git a/src/algo/runeindex_ref.go b/src/algo/runeindex_ref.go new file mode 100644 index 00000000000..8bc073990ef --- /dev/null +++ b/src/algo/runeindex_ref.go @@ -0,0 +1,55 @@ +package algo + +// Reference scanners over a []rune, with no representation tricks. +// +// They have two roles. Where reinterpreting a []rune as little-endian bytes is +// not valid, they are the shipped implementation, via runeindex_others.go. +// Everywhere else, the tests feed the same inputs to these and to the byte-view +// scanners in runeindex_x86.go and require identical answers. +// +// They carry no build tag so that both roles hold on every platform. Otherwise +// the portable build would be code that nothing here ever runs. + +func indexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) int { + lower, upper := rune(b), rune(-1) + if !caseSensitive && b >= 'a' && b <= 'z' { + upper = rune(b - 32) + } + for i := from; i < len(runes); i++ { + if runes[i] == lower || runes[i] == upper { + return i + } + } + return -1 +} + +func lastIndexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) int { + lower, upper := rune(b), rune(-1) + if !caseSensitive && b >= 'a' && b <= 'z' { + upper = rune(b - 32) + } + for i := len(runes) - 1; i >= from; i-- { + if runes[i] == lower || runes[i] == upper { + return i + } + } + return -1 +} + +func indexRuneRef(runes []rune, r rune, from int) int { + for i := from; i < len(runes); i++ { + if runes[i] == r { + return i + } + } + return -1 +} + +func lastIndexRuneRef(runes []rune, r rune, from int) int { + for i := len(runes) - 1; i >= from; i-- { + if runes[i] == r { + return i + } + } + return -1 +} diff --git a/src/algo/runeindex_x86.go b/src/algo/runeindex_x86.go new file mode 100644 index 00000000000..aea5c33592a --- /dev/null +++ b/src/algo/runeindex_x86.go @@ -0,0 +1,128 @@ +//go:build 386 || amd64 || arm64 + +package algo + +import ( + "bytes" + "unsafe" +) + +// On these architectures a []rune is a little-endian array of 4-byte lanes, so +// an ASCII rune is the byte itself followed by three zero bytes at a 4-byte +// aligned offset. That lets the SIMD byte scanners run over the rune array +// directly: find the low byte, then confirm alignment and the three zeroes. +// A byte equal to the needle can also appear as the low byte of a multi-byte +// rune (0x0165 has low byte 'e'), which those two checks reject. + +func runeBytes(runes []rune) []byte { + return unsafe.Slice((*byte)(unsafe.Pointer(unsafe.SliceData(runes))), len(runes)*4) +} + +// indexAsciiRune returns the index of the first rune equal to b, or to its +// uppercase form when ignoring case, at or after rune index from. +func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int { + view := runeBytes(runes) + both := !caseSensitive && b >= 'a' && b <= 'z' + for off := from * 4; off < len(view); { + var idx int + if both { + idx = IndexByteTwo(view[off:], b, b-32) + } else { + idx = bytes.IndexByte(view[off:], b) + } + if idx < 0 { + return -1 + } + pos := off + idx + if pos&3 == 0 && view[pos+1]|view[pos+2]|view[pos+3] == 0 { + return pos >> 2 + } + off = pos + 1 + } + return -1 +} + +// runeNeedle picks which of the rune's four bytes to scan for, and returns its +// lane index and value. A zero byte is a useless needle because every ASCII +// rune contributes three of them, so U+AE00 scanned by its low byte would hit +// on almost every character of an ASCII-heavy line. Prefer a byte that cannot +// occur in an ASCII rune at all, then any non-zero byte. +func runeNeedle(r rune) (int, byte) { + var b [4]byte + b[0], b[1], b[2], b[3] = byte(r), byte(r>>8), byte(r>>16), byte(r>>24) + for i, v := range b { + if v >= 0x80 { + return i, v + } + } + for i, v := range b { + if v != 0 { + return i, v + } + } + return 0, 0 +} + +func runeAt(view []byte, start int) rune { + return rune(view[start]) | rune(view[start+1])<<8 | rune(view[start+2])<<16 | rune(view[start+3])<<24 +} + +// indexRune returns the index of the first rune equal to r at or after rune +// index from. Case is not folded, so the caller must have established that no +// other rune can transform into r. +func indexRune(runes []rune, r rune, from int) int { + view := runeBytes(runes) + lane, needle := runeNeedle(r) + for off := from*4 + lane; off < len(view); { + idx := bytes.IndexByte(view[off:], needle) + if idx < 0 { + return -1 + } + pos := off + idx + if start := pos - lane; start&3 == 0 && runeAt(view, start) == r { + return start >> 2 + } + off = pos + 1 + } + return -1 +} + +// lastIndexRune is indexRune scanning backwards from the end. +func lastIndexRune(runes []rune, r rune, from int) int { + view := runeBytes(runes) + lane, needle := runeNeedle(r) + for end := len(view); end > from*4+lane; { + idx := bytes.LastIndexByte(view[from*4+lane:end], needle) + if idx < 0 { + return -1 + } + pos := from*4 + lane + idx + if start := pos - lane; start&3 == 0 && runeAt(view, start) == r { + return start >> 2 + } + end = pos + } + return -1 +} + +// lastIndexAsciiRune is indexAsciiRune scanning backwards from the end. +func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int { + view := runeBytes(runes)[from*4:] + both := !caseSensitive && b >= 'a' && b <= 'z' + for end := len(view); end > 0; { + var idx int + if both { + idx = lastIndexByteTwo(view[:end], b, b-32) + } else { + idx = bytes.LastIndexByte(view[:end], b) + } + if idx < 0 { + return -1 + } + if idx&3 == 0 && view[idx+1]|view[idx+2]|view[idx+3] == 0 { + return from + idx>>2 + } + end = idx + } + return -1 +} diff --git a/src/algo/runeprefilter_test.go b/src/algo/runeprefilter_test.go new file mode 100644 index 00000000000..fa566364c46 --- /dev/null +++ b/src/algo/runeprefilter_test.go @@ -0,0 +1,479 @@ +package algo + +// Correctness tests for the rune-array prefilter (Step C). +// +// The prefilter may narrow the search scope but must never change a Result or +// its positions, and must never reject an item the general path would match. +// Each result is compared against the same code with the prefilter disabled. + +import ( + "math/rand" + "strings" + "testing" + "unicode" + "unicode/utf8" + + "github.com/junegunn/fzf/src/util" +) + +// foldForTest mirrors what Phase 2 does to a non-ASCII text rune: lowercase if +// uppercase, then normalize. +func foldForTest(r rune, normalize bool) rune { + if charClassOfNonAscii(r) == charUpper { + r = unicode.To(unicode.LowerCase, r) + } + if normalize { + r = normalizeRune(r) + } + return r +} + +// The prefilter is only safe on items whose runes cannot become ASCII. This +// pins util.MayFoldToAscii as a superset of the runes that actually can, over +// the whole Unicode range and both normalization modes. If normalize.go or the +// Go unicode tables change, this fails. +func TestMayFoldToAsciiIsSuperset(t *testing.T) { + missed := 0 + for r := rune(utf8.RuneSelf); r <= unicode.MaxRune; r++ { + if r >= 0xD800 && r <= 0xDFFF { + continue + } + for _, normalize := range []bool{true, false} { + if foldForTest(r, normalize) < utf8.RuneSelf && !util.MayFoldToAscii(r) { + if missed++; missed < 10 { + t.Errorf("U+%04X folds to ASCII (normalize=%v) but MayFoldToAscii is false", r, normalize) + } + } + } + } + if missed > 0 { + t.Fatalf("%d runes fold to ASCII without being flagged", missed) + } +} + +// Scripts that must stay unflagged, otherwise the prefilter never engages for +// them and Step C buys nothing. +func TestMayFoldToAsciiExcludesMajorScripts(t *testing.T) { + for _, s := range []struct { + name string + lo, hi rune + }{ + {"Cyrillic", 0x0400, 0x04FF}, {"Greek", 0x0370, 0x03FF}, {"Hebrew", 0x0590, 0x05FF}, + {"Arabic", 0x0600, 0x06FF}, {"Thai", 0x0E00, 0x0E7F}, {"Devanagari", 0x0900, 0x097F}, + {"CJK", 0x4E00, 0x9FFF}, {"Hangul", 0xAC00, 0xD7A3}, {"kana", 0x3040, 0x30FF}, + {"box drawing", 0x2500, 0x257F}, {"emoji", 0x1F300, 0x1FAFF}, + // These sit between the Latin blocks and were swallowed by an earlier, + // wider grouping of foldableRanges. General Punctuation is the costly + // one: curly quotes, en and em dashes and the ellipsis live there. + {"Greek Extended", 0x1F00, 0x1FFF}, {"General Punctuation", 0x2000, 0x206F}, + {"Currency Symbols", 0x20A0, 0x20CF}, {"CJK Symbols", 0x3000, 0x303F}, + } { + for r := s.lo; r <= s.hi; r++ { + if util.MayFoldToAscii(r) { + t.Errorf("%s U+%04X should not be flagged foldable", s.name, r) + break + } + } + } +} + +// The byte-view scan must agree with the shipped reference scanners. The interesting inputs +// are runes whose low byte collides with the needle (U+0165 has low byte 'e') +// and runes sharing a lane offset, which the alignment and zero checks reject. +func TestIndexAsciiRuneMatchesReference(t *testing.T) { + rng := rand.New(rand.NewSource(3)) + alphabet := []rune{'a', 'A', 'e', 'E', '/', '1', 0x0165, 0x00E9, 0x4E00, 0xD55C, + 0x1F389, 0x0065 + 0x100, 0x0041 + 0x100, 0x2F65} + for trial := range 20000 { + n := rng.Intn(24) + runes := make([]rune, n) + for i := range runes { + runes[i] = alphabet[rng.Intn(len(alphabet))] + } + b := []byte{'a', 'e', 'A', 'E', '/', '1'}[rng.Intn(6)] + cs := rng.Intn(2) == 0 + from := 0 + if n > 0 { + from = rng.Intn(n) + } + if got, exp := indexAsciiRune(runes, cs, b, from), indexAsciiRuneRef(runes, cs, b, from); got != exp { + t.Fatalf("trial %d: indexAsciiRune(%U, cs=%v, %q, %d) = %d, expected %d", trial, runes, cs, b, from, got, exp) + } + if got, exp := lastIndexAsciiRune(runes, cs, b, from), lastIndexAsciiRuneRef(runes, cs, b, from); got != exp { + t.Fatalf("trial %d: lastIndexAsciiRune(%U, cs=%v, %q, %d) = %d, expected %d", trial, runes, cs, b, from, got, exp) + } + } +} + +// Differential test: the prefilter must not change any Result or position. +// Corpora deliberately mix scripts that clear the foldable bit (CJK, Hangul, +// Cyrillic, emoji) with scripts that set it (accented Latin, fullwidth), so +// both the engaged and the bypassed path are exercised. +func TestRunePrefilterEquivalence(t *testing.T) { + t.Cleanup(func() { disableRunePrefilter = false }) + rng := rand.New(rand.NewSource(4)) + parts := []string{ + "src", "util", "conf", "a", "e", "E", "A", "/", "_", "1", " ", + "漢字", "한글", "мир", "ελλ", "🎉", "café", "Müller", "naïve", "full", + "Å", "İ", "K", "ǰ", "ff", + } + patterns := []string{"a", "e", "conf", "src/util", "ae", "A", "E", "K", "k", "i", "//", "zz", "s l", + // non-ASCII patterns, the Step G path + "漢", "漢字", "한", "한글", "мир", "м", "ελλ", "🎉", "é", "ß", "Å", "İ", "f", + "漢a", "a漢", "한글/src", "🎉e"} + + slab := util.MakeSlab(100*1024, 2048) + engaged, bypassed := 0, 0 + + for trial := range 30000 { + var sb strings.Builder + for range 1 + rng.Intn(8) { + sb.WriteString(parts[rng.Intn(len(parts))]) + } + chars := util.ToChars([]byte(sb.String())) + if chars.IsBytes() { + continue + } + if chars.MayFoldToAscii() { + bypassed++ + } else { + engaged++ + } + pat := patterns[rng.Intn(len(patterns))] + cs := rng.Intn(2) == 0 + if !cs { + pat = strings.ToLower(pat) + } + pattern := []rune(pat) + norm := rng.Intn(2) == 0 + fwd := rng.Intn(2) == 0 + wp := rng.Intn(2) == 0 + + disableRunePrefilter = true + expR, expP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab) + disableRunePrefilter = false + gotR, gotP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab) + + if gotR != expR || !samePos(gotP, expP) { + t.Fatalf("trial %d: %q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v", + trial, sb.String(), pat, cs, norm, fwd, wp, gotR, gotP, expR, expP) + } + } + disableRunePrefilter = false + t.Logf("prefilter engaged on %d items, bypassed on %d", engaged, bypassed) + if engaged == 0 || bypassed == 0 { + t.Fatalf("corpus did not exercise both paths (engaged=%d bypassed=%d)", engaged, bypassed) + } + + // Equivalence alone would still hold if the prefilter never filtered + // anything, so confirm it both rejects and narrows. + rejected, narrowed := 0, 0 + for range 5000 { + var sb strings.Builder + for range 1 + rng.Intn(8) { + sb.WriteString(parts[rng.Intn(len(parts))]) + } + chars := util.ToChars([]byte(sb.String())) + if chars.IsBytes() || chars.MayFoldToAscii() { + continue + } + pattern := []rune(patterns[rng.Intn(len(patterns))]) + lo, hi := asciiFuzzyIndex(&chars, pattern, false) + switch { + case lo < 0: + rejected++ + case hi-lo < chars.Length(): + narrowed++ + } + } + t.Logf("prefilter rejected %d items, narrowed scope on %d", rejected, narrowed) + if rejected == 0 { + t.Fatal("prefilter never rejected an item, so equivalence proves nothing") + } + if narrowed == 0 { + t.Fatal("prefilter never narrowed the scope") + } +} + +// FuzzyMatchV1 shares asciiFuzzyIndex, so it needs the same guarantee. +func TestRunePrefilterEquivalenceV1(t *testing.T) { + t.Cleanup(func() { disableRunePrefilter = false }) + rng := rand.New(rand.NewSource(5)) + parts := []string{"src", "conf", "a", "e", "/", "漢字", "한글", "мир", "café", "Å", "🎉"} + slab := util.MakeSlab(100*1024, 2048) + for trial := range 20000 { + var sb strings.Builder + for range 1 + rng.Intn(6) { + sb.WriteString(parts[rng.Intn(len(parts))]) + } + chars := util.ToChars([]byte(sb.String())) + if chars.IsBytes() { + continue + } + pattern := []rune([]string{"a", "e", "conf", "src", "ae", "zz"}[rng.Intn(6)]) + cs, norm, fwd, wp := rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0 + + disableRunePrefilter = true + expR, expP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab) + disableRunePrefilter = false + gotR, gotP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab) + + if gotR != expR || !samePos(gotP, expP) { + t.Fatalf("trial %d: %q pattern=%q\n prefilter on: %v %v\n prefilter off: %v %v", + trial, sb.String(), string(pattern), gotR, gotP, expR, expP) + } + } + disableRunePrefilter = false +} + +// normalizeRune skips the map when util.MayFoldToAscii rejects the rune. That +// is only sound if every key of the map is flagged, since a flagged-false rune +// is returned unchanged. +func TestNormalizedKeysAreFlagged(t *testing.T) { + for k := range normalized { + if !util.MayFoldToAscii(k) { + t.Errorf("normalized key U+%04X (%c) is not flagged by MayFoldToAscii", k, k) + } + } +} + +// Guarding normalizeRune must not change what it returns, for any rune. +func TestNormalizeRuneUnchangedByGuard(t *testing.T) { + for r := rune(0); r <= unicode.MaxRune; r++ { + if r >= 0xD800 && r <= 0xDFFF { + continue + } + exp := r + if r >= 0x00C0 && r <= 0xFF61 { + if n := normalized[r]; n > 0 { + exp = n + } + } + if got := normalizeRune(r); got != exp { + t.Fatalf("normalizeRune(U+%04X) = U+%04X, expected U+%04X", r, got, exp) + } + } +} + +// Step G lets non-ASCII pattern runes use the scan, but only when no other +// rune can transform into them. Being uncased is not sufficient: U+00DF has no +// simple uppercase yet U+1E9E lowercases onto it. This pins the guard against +// the full preimage relation over all of Unicode. +func TestRunePrefilterableGuardIsSound(t *testing.T) { + preimage := map[rune][]rune{} + for r := rune(0); r <= unicode.MaxRune; r++ { + if r >= 0xD800 && r <= 0xDFFF { + continue + } + if charClassOfNonAscii(r) == charUpper { + if l := unicode.To(unicode.LowerCase, r); l != r { + preimage[l] = append(preimage[l], r) + } + } + } + + clean := util.ToChars([]byte("漢字")) // rune mode, fold bit clear + admitted, violations := 0, 0 + for r := rune(utf8.RuneSelf); r <= unicode.MaxRune; r++ { + if r >= 0xD800 && r <= 0xDFFF { + continue + } + if !runePrefilterable(&clean, []rune{r}, false) { + continue + } + admitted++ + if extra := preimage[r]; len(extra) > 0 { + violations++ + if violations <= 5 { + t.Errorf("guard admits U+%04X but %U lowercases onto it", r, extra) + } + } + } + t.Logf("guard admits %d non-ASCII pattern runes, unsound for %d", admitted, violations) + + // The scripts this step exists for must be fully admitted. + for _, s := range []struct { + name string + lo, hi rune + }{{"CJK", 0x4E00, 0x9FFF}, {"Hangul", 0xAC00, 0xD7A3}, {"kana", 0x3040, 0x30FF}, + {"Thai", 0x0E00, 0x0E7F}, {"emoji", 0x1F300, 0x1FAFF}} { + for r := s.lo; r <= s.hi; r++ { + if !runePrefilterable(&clean, []rune{r}, false) { + t.Errorf("%s U+%04X should be admitted", s.name, r) + break + } + } + } +} + +// The Step G path must actually engage and reject, otherwise the equivalence +// test above proves nothing about non-ASCII patterns. +func TestNonAsciiPatternPrefilterEngages(t *testing.T) { + rng := rand.New(rand.NewSource(6)) + parts := []string{"漢字", "한글", "src", "conf", "/", "мир", "🎉"} + engaged, rejected, narrowed, bypassed := 0, 0, 0, 0 + for range 5000 { + var sb strings.Builder + for range 1 + rng.Intn(6) { + sb.WriteString(parts[rng.Intn(len(parts))]) + } + chars := util.ToChars([]byte(sb.String())) + if chars.IsBytes() { + continue + } + pattern := []rune([]string{"漢", "漢字", "한글", "мир", "🎉", "é", "ß"}[rng.Intn(7)]) + if !runePrefilterable(&chars, pattern, false) { + bypassed++ + continue + } + engaged++ + lo, hi := asciiFuzzyIndex(&chars, pattern, false) + switch { + case lo < 0: + rejected++ + case hi-lo < chars.Length(): + narrowed++ + } + } + t.Logf("non-ASCII patterns: engaged %d, bypassed %d, rejected %d, narrowed %d", + engaged, bypassed, rejected, narrowed) + if engaged == 0 || rejected == 0 { + t.Fatalf("non-ASCII pattern path not exercised (engaged=%d rejected=%d)", engaged, rejected) + } + if bypassed == 0 { + t.Fatal("cased and foldable patterns should still bypass") + } +} + +// indexRune picks which byte lane to scan, and is checked against the shipped +// reference scanners rather than a copy of them. Zero-low-byte runes (U+AE00) and +// runes whose lanes collide with common ASCII bytes are the cases that break a +// naive low-byte scan, so the alphabet includes both. +func TestIndexRuneMatchesReference(t *testing.T) { + + alphabet := []rune{ + 'a', 'e', 'N', '/', 0x00, + 0xAE00, 0xAC00, 0xD55C, // Hangul, low byte zero for U+AE00 + 0x4E00, 0x6587, 0x9FFF, // CJK, U+4E00 has zero low byte + 0x3040, 0x0E00, // kana, Thai with zero low byte + 0x1F389, 0x1F300, // emoji, 3 significant bytes + 0x0100, 0x0165, 0x00E9, // low byte zero / ASCII-colliding lanes + } + rng := rand.New(rand.NewSource(7)) + for trial := range 30000 { + n := rng.Intn(20) + runes := make([]rune, n) + for i := range runes { + runes[i] = alphabet[rng.Intn(len(alphabet))] + } + r := alphabet[rng.Intn(len(alphabet))] + from := 0 + if n > 0 { + from = rng.Intn(n) + } + if got, exp := indexRune(runes, r, from), indexRuneRef(runes, r, from); got != exp { + t.Fatalf("trial %d: indexRune(%U, U+%04X, %d) = %d, expected %d", trial, runes, r, from, got, exp) + } + if got, exp := lastIndexRune(runes, r, from), lastIndexRuneRef(runes, r, from); got != exp { + t.Fatalf("trial %d: lastIndexRune(%U, U+%04X, %d) = %d, expected %d", trial, runes, r, from, got, exp) + } + } +} + +// preparePattern mirrors what pattern.go guarantees the algo functions: +// lowercased when case-insensitive, normalized when normalize is on. +func preparePattern(pat string, caseSensitive, normalize bool) []rune { + if !caseSensitive { + pat = strings.ToLower(pat) + } + r := []rune(pat) + if normalize { + r = NormalizeRunes(r) + } + return r +} + +// FuzzRunePrefilter drives arbitrary rune-mode input and arbitrary patterns +// through the prefilter and through the same code with it disabled, and +// requires identical Results and positions. The existing fast-path fuzzers +// only generate byte-mode input, so they never reach this path. +func FuzzRunePrefilter(f *testing.F) { + for _, in := range []string{ + "한글/src/util.go", "漢字/conf", "café/binutils", "мир/test", "🎉/a", + "ABC.txt", "Ångström", "ǰ/ß/İ", "a漢b한c", "Āā", + } { + for _, p := range []string{"a", "conf", "漢", "한글", "мир", "ß", "É", "a漢"} { + f.Add(in, p) + } + } + slab := util.MakeSlab(200*1024, 4096) + f.Fuzz(func(t *testing.T, input, pat string) { + if len(input) > 512 || len(pat) == 0 || len(pat) > 32 { + return + } + chars := util.ToChars([]byte(input)) + if chars.IsBytes() { + return // byte mode is the existing fuzzers' territory + } + for _, cs := range []bool{false, true} { + for _, norm := range []bool{false, true} { + p := preparePattern(pat, cs, norm) + if len(p) == 0 { + continue + } + for _, fwd := range []bool{true, false} { + for _, wp := range []bool{false, true} { + for _, fn := range []Algo{FuzzyMatchV2, FuzzyMatchV1, ExactMatchNaive} { + disableRunePrefilter = true + expR, expP := fn(cs, norm, fwd, &chars, p, wp, slab) + disableRunePrefilter = false + gotR, gotP := fn(cs, norm, fwd, &chars, p, wp, slab) + if gotR != expR || !samePos(gotP, expP) { + t.Fatalf("input=%q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v", + input, pat, cs, norm, fwd, wp, gotR, gotP, expR, expP) + } + } + } + } + } + } + }) +} + +// RunesToChars can produce rune-mode Chars holding zero runes, which sends a +// nil pointer through unsafe.SliceData in runeBytes. ToChars cannot produce +// this (an empty input is byte mode), so it needs its own test. +func TestEmptyRuneModeChars(t *testing.T) { + t.Cleanup(func() { disableRunePrefilter = false }) + slab := util.MakeSlab(100*1024, 2048) + for _, runes := range [][]rune{{}, nil, {'a'}, {0x4E00}} { + chars := util.RunesToChars(runes) + if chars.IsBytes() { + continue + } + for _, pat := range []string{"a", "漢", "ab"} { + p := []rune(pat) + for _, fn := range []Algo{FuzzyMatchV2, FuzzyMatchV1, ExactMatchNaive, + PrefixMatch, SuffixMatch, EqualMatch} { + disableRunePrefilter = true + expR, expP := fn(false, true, true, &chars, p, true, slab) + disableRunePrefilter = false + gotR, gotP := fn(false, true, true, &chars, p, true, slab) + if gotR != expR || !samePos(gotP, expP) { + t.Errorf("runes=%U pat=%q: prefilter on %v %v, off %v %v", runes, pat, gotR, gotP, expR, expP) + } + } + } + } +} + +// MayFoldToAscii subtracts before bounds-checking, so a rune below the range +// (including a negative one, which utf8 decoding never produces but callers +// could construct) must not wrap into a false positive. +func TestMayFoldToAsciiOutOfRange(t *testing.T) { + for _, r := range []rune{-1, -0x10000, 0, 'a', 0x7F, 0xBF, 0xFF62, unicode.MaxRune, unicode.MaxRune + 1} { + if util.MayFoldToAscii(r) { + t.Errorf("MayFoldToAscii(%d) = true, expected false", r) + } + } +} diff --git a/src/terminal.go b/src/terminal.go index 00f0b2bb24c..640f5be8aa3 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -7417,7 +7417,9 @@ func (t *Terminal) Loop() error { case actReplaceQuery: current := t.currentItem() if current != nil { - t.input = current.text.ToRunes() + // ToRunes aliases the item text in rune mode, and the + // editing actions below append into t.input in place + t.input = append([]rune{}, current.text.ToRunes()...) t.cx = len(t.input) } case actFatal: diff --git a/src/util/chars.go b/src/util/chars.go index ee46afcf767..e9eb51c52d9 100644 --- a/src/util/chars.go +++ b/src/util/chars.go @@ -3,6 +3,7 @@ package util import ( "bytes" "fmt" + "math/bits" "unicode" "unicode/utf8" "unsafe" @@ -13,9 +14,17 @@ const ( overflow32 uint32 = 0x80808080 ) +const ( + flagInBytes uint8 = 1 << iota + flagMayFold +) + type Chars struct { - slice []byte // or []rune - inBytes bool + slice []byte // or []rune + // Only ever set, never cleared, so a reader racing a Prepend sees either + // the old or the new value and both are safe. trimLength* is kept out + // because TrimLength rewrites it. + flags uint8 trimLengthKnown bool trimLength uint16 @@ -24,6 +33,59 @@ type Chars struct { Index int32 } +// Rune ranges that case folding or normalization can turn into ASCII, derived +// from algo's normalization table and unicode.ToLower, then merged. They are a +// superset of the exact set, which TestMayFoldToAsciiIsSuperset in the algo +// package pins. Grouped tightly on purpose: a wider merge would swallow Greek +// Extended, General Punctuation and the currency and letterlike blocks, and +// every line holding a curly quote or an em dash would then lose the +// prefilter. Cyrillic, Greek, Hebrew, Arabic, Thai, Devanagari, CJK, Hangul, +// kana, emoji, punctuation and box drawing are all outside. +const ( + foldLo = 0x00C0 + foldHi = 0xFF61 +) + +var foldableRanges = [...][2]rune{ + {0x00C0, 0x01B6}, // Latin-1 Supplement, Latin Extended-A and -B + {0x01CD, 0x02AE}, // rest of Latin Extended-B and IPA Extensions + {0x0363, 0x036F}, // combining Latin small letters + {0x1D00, 0x1D22}, // Phonetic Extensions, small capitals + {0x1D62, 0x1D65}, // subscript letters + {0x1E00, 0x1EF9}, // Latin Extended Additional + {0x2071, 0x2071}, // superscript i + {0x2095, 0x209C}, // subscript letters + {0x212A, 0x212B}, // KELVIN SIGN and ANGSTROM SIGN, which fold by case + {0x2183, 0x2184}, // reversed roman numeral one hundred + {0x2C62, 0x2C7F}, // Latin Extended-C + {0xA78D, 0xA78D}, // Latin Extended-D + {0xA7AA, 0xA7B2}, // more Latin Extended-D + {0xA7C5, 0xA7C5}, + {0xFF01, 0xFF61}, // fullwidth ASCII forms, and halfwidth ideographic full stop +} + +// Walking the ranges costs a serial chain of comparisons per rune, which is +// measurable at ingestion, so precompute a bitmap instead. +var foldableBits = func() (bits [(foldHi-foldLo)/8 + 1]byte) { + for _, r := range foldableRanges { + for c := r[0]; c <= r[1]; c++ { + i := c - foldLo + bits[i>>3] |= 1 << (i & 7) + } + } + return +}() + +// MayFoldToAscii reports whether case folding or normalization could turn r +// into an ASCII character. +func MayFoldToAscii(r rune) bool { + i := uint32(r - foldLo) + if i > foldHi-foldLo { + return false + } + return foldableBits[i>>3]&(1<<(i&7)) != 0 +} + func checkAscii(bytes []byte) (bool, int) { i := 0 for ; i <= len(bytes)-8; i += 8 { @@ -44,31 +106,94 @@ func checkAscii(bytes []byte) (bool, int) { return true, 0 } +// countRunes counts the bytes that are not UTF-8 continuation bytes, which is +// the rune count of valid UTF-8. Each invalid byte decodes to its own +// RuneError, so the result can undercount but never overcount, making it safe +// as a capacity hint. +func countRunes(bytes []byte) int { + n, i := 0, 0 + for ; i <= len(bytes)-8; i += 8 { + v := *(*uint64)(unsafe.Pointer(&bytes[i])) + // Continuation byte: bit 7 set, bit 6 clear. In `v << 1` bit 7 of each + // lane holds bit 6 of that same lane. + n += 8 - bits.OnesCount64(v&^(v<<1)&overflow64) + } + for ; i < len(bytes); i++ { + if bytes[i]&0xC0 != 0x80 { + n++ + } + } + return n +} + // ToChars converts byte array into rune array func ToChars(bytes []byte) Chars { inBytes, bytesUntil := checkAscii(bytes) if inBytes { - return Chars{slice: bytes, inBytes: inBytes} + return Chars{slice: bytes, flags: flagInBytes} } - runes := make([]rune, bytesUntil, len(bytes)) + runes := make([]rune, bytesUntil, bytesUntil+countRunes(bytes[bytesUntil:])) for i := range bytesUntil { runes[i] = rune(bytes[i]) } + mayFold := false for i := bytesUntil; i < len(bytes); { + // utf8.DecodeRune has an ASCII path of its own, but it is too complex + // to inline, so a mostly-ASCII line pays one call per byte for it. + // An ASCII rune never sets the fold bit either, so skip both calls. + if b := bytes[i]; b < utf8.RuneSelf { + runes = append(runes, rune(b)) + i++ + continue + } r, sz := utf8.DecodeRune(bytes[i:]) i += sz + mayFold = mayFold || MayFoldToAscii(r) runes = append(runes, r) } - return RunesToChars(runes) + return runesToChars(runes, mayFold) } +// RunesToChars adopts the caller's slice rather than copying it, so the caller +// must not keep mutating it. See Runes for why. func RunesToChars(runes []rune) Chars { - return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), inBytes: false} + mayFold := false + for _, r := range runes { + if MayFoldToAscii(r) { + mayFold = true + break + } + } + return runesToChars(runes, mayFold) +} + +func runesToChars(runes []rune, mayFold bool) Chars { + var flags uint8 + if mayFold { + flags = flagMayFold + } + return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), flags: flags} } func (chars *Chars) IsBytes() bool { - return chars.inBytes + return chars.flags&flagInBytes != 0 +} + +// MayFoldToAscii reports whether the text holds a rune that case folding or +// normalization could turn into an ASCII character. When false, an ASCII +// pattern character can only match the identical ASCII rune, which is what +// lets the prefilter scan the rune array directly. +func (chars *Chars) MayFoldToAscii() bool { + return chars.flags&flagMayFold != 0 +} + +// Runes returns the underlying rune slice, or nil if the text is kept as +// bytes. Read only. The result aliases the text, so writing to it would change +// the text without updating the cached fold bit, and the prefilter would then +// reject items it should match. Copy before mutating. +func (chars *Chars) Runes() []rune { + return chars.optionalRunes() } func (chars *Chars) Bytes() []byte { @@ -105,7 +230,7 @@ func (chars *Chars) NumLines(atMost int) (int, bool) { } func (chars *Chars) optionalRunes() []rune { - if chars.inBytes { + if chars.IsBytes() { return nil } return *(*[]rune)(unsafe.Pointer(&chars.slice)) @@ -127,7 +252,7 @@ func (chars *Chars) Length() int { // String returns the string representation of a Chars object. func (chars *Chars) String() string { - return fmt.Sprintf("Chars{slice: []byte(%q), inBytes: %v, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.inBytes, chars.trimLengthKnown, chars.trimLength, chars.Index) + return fmt.Sprintf("Chars{slice: []byte(%q), inBytes: %v, mayFold: %v, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.IsBytes(), chars.MayFoldToAscii(), chars.trimLengthKnown, chars.trimLength, chars.Index) } // TrimLength returns the length after trimming leading and trailing whitespaces @@ -218,6 +343,8 @@ func (chars *Chars) ToString() string { return unsafe.String(unsafe.SliceData(chars.slice), len(chars.slice)) } +// ToRunes returns the text as runes. In rune mode the result aliases the text +// and must not be mutated, see Runes. In byte mode it is a fresh slice. func (chars *Chars) ToRunes() []rune { if runes := chars.optionalRunes(); runes != nil { return runes @@ -247,6 +374,12 @@ func (chars *Chars) Prepend(prefix string) { } else { chars.slice = append([]byte(prefix), chars.slice...) } + for _, r := range prefix { + if MayFoldToAscii(r) { + chars.flags |= flagMayFold + break + } + } } func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) { diff --git a/src/util/chars_test.go b/src/util/chars_test.go index f27ae45d9ef..5dd1ca7e660 100644 --- a/src/util/chars_test.go +++ b/src/util/chars_test.go @@ -2,19 +2,106 @@ package util import ( "fmt" + "math/rand" + "strings" "testing" + "unicode/utf8" + "unsafe" ) +func TestCountRunes(t *testing.T) { + for _, str := range []string{ + "", "a", "abc", "한글", "🎉🎉", "\tabc한글 ", + strings.Repeat("漢字", 50), strings.Repeat("a", 33) + "é", + } { + if got, exp := countRunes([]byte(str)), utf8.RuneCountInString(str); got != exp { + t.Errorf("countRunes(%q) = %d, expected %d", str, got, exp) + } + } +} + +func TestCountRunesRandom(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + + // Exact on valid UTF-8 + for trial := range 20000 { + var sb strings.Builder + for range rng.Intn(20) { + r := rune(rng.Intn(utf8.MaxRune + 1)) + for r >= 0xD800 && r <= 0xDFFF { + r = rune(rng.Intn(utf8.MaxRune + 1)) + } + sb.WriteRune(r) + } + str := sb.String() + if got, exp := countRunes([]byte(str)), utf8.RuneCountInString(str); got != exp { + t.Fatalf("trial %d: countRunes(%q) = %d, expected %d", trial, str, got, exp) + } + } + + // Never an overcount on arbitrary bytes, so the capacity hint never truncates + for trial := range 20000 { + buf := make([]byte, rng.Intn(40)) + rng.Read(buf) + if got, exp := countRunes(buf), utf8.RuneCount(buf); got > exp { + t.Fatalf("trial %d: countRunes(%x) = %d, overcounts %d", trial, buf, got, exp) + } + } +} + +// ToChars must produce exactly what a []rune conversion produces, including +// one RuneError per invalid byte, and must size the rune slice exactly when +// the input is valid UTF-8. +func TestToCharsIntegrity(t *testing.T) { + rng := rand.New(rand.NewSource(2)) + check := func(buf []byte, exactCap bool) { + chars := ToChars(buf) + exp := []rune(string(buf)) + if chars.Length() != len(exp) { + t.Fatalf("ToChars(%x).Length() = %d, expected %d", buf, chars.Length(), len(exp)) + } + for i, r := range exp { + if chars.Get(i) != r { + t.Fatalf("ToChars(%x).Get(%d) = %q, expected %q", buf, i, chars.Get(i), r) + } + } + if runes := chars.optionalRunes(); runes != nil && exactCap && cap(runes) != len(exp) { + t.Fatalf("ToChars(%x) cap = %d, expected %d", buf, cap(runes), len(exp)) + } + } + + for range 5000 { + var sb strings.Builder + sb.WriteString("ascii") + for range 1 + rng.Intn(10) { + r := rune(0x80 + rng.Intn(utf8.MaxRune-0x80)) + for r >= 0xD800 && r <= 0xDFFF { + r = rune(0x80 + rng.Intn(utf8.MaxRune-0x80)) + } + sb.WriteRune(r) + } + check([]byte(sb.String()), true) + } + + // Invalid UTF-8: still correct, capacity may grow + for range 5000 { + buf := make([]byte, 1+rng.Intn(40)) + rng.Read(buf) + buf[rng.Intn(len(buf))] |= 0x80 // force the rune path + check(buf, false) + } +} + func TestToCharsAscii(t *testing.T) { chars := ToChars([]byte("foobar")) - if !chars.inBytes || chars.ToString() != "foobar" || !chars.inBytes { + if !chars.IsBytes() || chars.ToString() != "foobar" { t.Error() } } func TestCharsLength(t *testing.T) { chars := ToChars([]byte("\tabc한글 ")) - if chars.inBytes || chars.Length() != 8 || chars.TrimLength() != 5 { + if chars.IsBytes() || chars.Length() != 8 || chars.TrimLength() != 5 { t.Error() } } @@ -129,3 +216,70 @@ func TestCharsLinesWrapWord(t *testing.T) { t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0])) } } + +// Chars is one per input line, so its size is load-bearing. It has no spare +// padding, which is why new state goes in the flags byte rather than a field. +// Derive the expectation from the slice header so the invariant holds on +// 32-bit builds too, where the header is 12 bytes and Chars is 20. +func TestCharsSize(t *testing.T) { + var slice []byte + // flags 1 + trimLengthKnown 1 + trimLength 2 + Index 4, no padding + want := unsafe.Sizeof(slice) + 8 + if size := unsafe.Sizeof(Chars{}); size != want { + t.Errorf("unsafe.Sizeof(Chars{}) = %d, expected %d", size, want) + } +} + +func TestMayFoldFlag(t *testing.T) { + for _, c := range []struct { + text string + fold bool + }{ + {"한글/src", false}, {"漢字", false}, {"мир", false}, {"🎉", false}, + {"café", true}, {"Müller", true}, {"Å", true}, {"full", true}, + } { + chars := ToChars([]byte(c.text)) + if chars.MayFoldToAscii() != c.fold { + t.Errorf("ToChars(%q).MayFoldToAscii() = %v, expected %v", c.text, chars.MayFoldToAscii(), c.fold) + } + if runes := RunesToChars([]rune(c.text)); runes.MayFoldToAscii() != c.fold { + t.Errorf("RunesToChars(%q).MayFoldToAscii() = %v, expected %v", c.text, runes.MayFoldToAscii(), c.fold) + } + } + + // Prepend can introduce foldable runes + chars := ToChars([]byte("한글")) + if chars.MayFoldToAscii() { + t.Fatal("baseline should not be foldable") + } + chars.Prepend("é") + if !chars.MayFoldToAscii() { + t.Error("Prepend of a foldable prefix must set the flag") + } +} + +// Runes and ToRunes alias the text in rune mode, so a consumer that mutates +// what they return changes the text without updating the cached fold bit. This +// pins the aliasing so the read-only contract on those methods is not silently +// dropped later. +func TestRuneSlicesAliasTheText(t *testing.T) { + chars := ToChars([]byte("한글abc")) + runes := chars.Runes() + if runes == nil { + t.Fatal("expected rune mode") + } + if &runes[0] != &chars.ToRunes()[0] { + t.Error("Runes and ToRunes should return the same backing array") + } + if chars.MayFoldToAscii() { + t.Fatal("baseline should not be foldable") + } + // Demonstrates why callers must copy: the flag does not follow the text. + runes[0] = 'e' + if chars.MayFoldToAscii() { + t.Error("flag unexpectedly updated") + } + if got := chars.ToString(); got != "e글abc" { + t.Errorf("expected the write to reach the text, got %q", got) + } +} diff --git a/test/test_core.rb b/test/test_core.rb index a8ef16a4523..f32e24b18b8 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -479,6 +479,19 @@ def test_bind_replace_query tmux.until { |lines| assert_equal '> 10', lines[-1] } end + def test_bind_replace_query_does_not_mutate_item + tmux.send_keys "echo '한글abcde' | #{fzf('--bind=ctrl-j:replace-query,ctrl-o:clear-query')}", :Enter + tmux.until { |lines| assert_equal ' 1/1', lines[-2] } + tmux.send_keys 'C-j' + tmux.until { |lines| assert_equal '> 한글abcde', lines[-1] } + # Editing away from the end used to write into the item itself + tmux.send_keys :Left, :BSpace + tmux.until { |lines| assert_equal '> 한글abce', lines[-1] } + tmux.send_keys 'C-o' + tmux.until { |lines| assert_equal '>', lines[-1] } + tmux.until { |lines| assert_equal '> 한글abcde', lines[-3] } + end + def test_select_all_deselect_all_toggle_all tmux.send_keys "seq 100 | #{fzf('--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all --multi')}", :Enter tmux.until { |lines| assert_equal ' 100/100 (0)', lines[-2] } diff --git a/test/test_shell_integration.rb b/test/test_shell_integration.rb index c281cc8910b..6c9cfcc296f 100644 --- a/test/test_shell_integration.rb +++ b/test/test_shell_integration.rb @@ -973,7 +973,7 @@ def test_alt_c_chpwd_hook_once tmux.until { |lines| assert_operator lines.match_count, :>, 0 } tmux.send_keys :Enter tmux.until do |lines| - assert_equal 1, lines.count { |l| l.include?('chpwd hook fired') } + assert_equal(1, lines.count { |l| l.include?('chpwd hook fired') }) end end