From c28a9781864a87ac89ea9cfc0928a76a00fc29a8 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 8 Aug 2026 11:07:59 +0900 Subject: [PATCH 1/8] Avoid over-allocation in ToChars - Capacity was byte length, over-allocating by bytes-per-rune (2-4x) - Count non-continuation bytes with SWAR before allocating - Invalid bytes undercount, never overcount, so append covers the gap - Query performance unchanged, this is a memory fix - The gain tracks bytes-per-rune, the cost tracks how much of the line follows the first non-ASCII byte, so the two move independently Measured on 1.4M-line corpora: - Every line CJK: RSS 362MB -> 255MB, ingestion -5% - Mostly-ASCII paths behind a Hangul prefix: RSS 556MB -> 533MB, ingestion +3.5%, the counting pass covering the whole line - The same paths with the Hangul at the end: RSS 563MB -> 535MB, ingestion +0.9%, the counting pass covering six bytes --- src/util/chars.go | 23 ++++++++++- src/util/chars_test.go | 86 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/util/chars.go b/src/util/chars.go index ee46afcf767..a2ddca77727 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" @@ -44,6 +45,26 @@ 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) @@ -51,7 +72,7 @@ func ToChars(bytes []byte) Chars { return Chars{slice: bytes, inBytes: inBytes} } - runes := make([]rune, bytesUntil, len(bytes)) + runes := make([]rune, bytesUntil, bytesUntil+countRunes(bytes[bytesUntil:])) for i := range bytesUntil { runes[i] = rune(bytes[i]) } diff --git a/src/util/chars_test.go b/src/util/chars_test.go index f27ae45d9ef..e7ac58aca03 100644 --- a/src/util/chars_test.go +++ b/src/util/chars_test.go @@ -2,9 +2,95 @@ package util import ( "fmt" + "math/rand" + "strings" "testing" + "unicode/utf8" ) +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 { From 7761f3f32dbd26f46bfb8b281e87bf631428bbd5 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 8 Aug 2026 11:08:01 +0900 Subject: [PATCH 2/8] Skip rune decoding for ASCII bytes in ToChars utf8.DecodeRune already fast-paths ASCII, but it is too complex to inline (cost 201 against a budget of 80), so a mostly-ASCII line pays one call per byte just to be told the byte is ASCII. Only the run after the first non-ASCII byte reaches the decode loop, so the gain depends on where that byte falls. - 70-rune ASCII line: 145ns -> 42ns in the decode loop - Ingestion of 1.4M mostly-ASCII paths behind a Hangul prefix, where the loop covers the whole line: 377ms -> 233ms - The same paths with the Hangul at the end, where it covers six bytes: 207ms -> 196ms - Break-even sits at ~100% non-ASCII runes: still 1.01x at 95%. Only a line holding no ASCII byte at all loses, by ~0.14ns per rune, ~5% of the loop --- src/util/chars.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/util/chars.go b/src/util/chars.go index a2ddca77727..88cb5ce5e1a 100644 --- a/src/util/chars.go +++ b/src/util/chars.go @@ -77,6 +77,13 @@ func ToChars(bytes []byte) Chars { runes[i] = rune(bytes[i]) } 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. + if b := bytes[i]; b < utf8.RuneSelf { + runes = append(runes, rune(b)) + i++ + continue + } r, sz := utf8.DecodeRune(bytes[i:]) i += sz runes = append(runes, r) From 89939f19c833a51a37ae482af878bfb83eb7755a Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 8 Aug 2026 19:10:28 +0900 Subject: [PATCH 3/8] Prefilter rune-mode input asciiFuzzyIndex gave up on non-ASCII lines, so every item ran the full score matrix. A []rune is a fixed 4-byte stride, so the SIMD byte scanners can run over it directly: find the low byte, then confirm 4-byte alignment and three zero bytes. Case folding and normalization can turn a non-ASCII rune into the ASCII char being searched, which the scan cannot see. ToChars now flags lines holding such a rune and those keep the old path. Normalization is Latin-only, so Hangul, CJK, Cyrillic, Greek, Hebrew, Arabic, Thai, kana and emoji never set the flag. Chars had no spare padding, so inBytes moves into a flags byte. Measured on 1.4M-line corpora: - Mostly-ASCII paths behind a Hangul prefix: 'conf' 1.8x, 'binutils' 2.9x, 'ltversion' 4.0x, no-match 8.4x - Every line CJK: 17x on both matching and non-matching queries - ASCII input unchanged, non-ASCII patterns not covered yet --- Makefile | 2 +- src/algo/algo.go | 53 +++++- src/algo/runeindex_others.go | 15 ++ src/algo/runeindex_ref.go | 37 ++++ src/algo/runeindex_x86.go | 65 +++++++ src/algo/runeprefilter_test.go | 325 +++++++++++++++++++++++++++++++++ src/util/chars.go | 116 +++++++++++- src/util/chars_test.go | 46 ++++- 8 files changed, 641 insertions(+), 18 deletions(-) create mode 100644 src/algo/runeindex_others.go create mode 100644 src/algo/runeindex_ref.go create mode 100644 src/algo/runeindex_x86.go create mode 100644 src/algo/runeprefilter_test.go 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..45433c03b37 100644 --- a/src/algo/algo.go +++ b/src/algo/algo.go @@ -345,15 +345,53 @@ func isAscii(runes []rune) bool { return true } +// runeFuzzyIndex is asciiFuzzyIndex for rune-mode input. Only valid when the +// input cannot fold to ASCII, see the caller. +func runeFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) { + runes := input.Runes() + firstIdx, idx, lastIdx := 0, 0, 0 + var b byte + for pidx := range pattern { + b = byte(pattern[pidx]) + idx = indexAsciiRune(runes, caseSensitive, b, 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) { + if end := lastIndexAsciiRune(runes, caseSensitive, b, lastIdx+1); 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() { + if !isAscii(pattern) { + // An ASCII string cannot contain a non-ASCII character + if input.IsBytes() { + return -1, -1 + } + // Rune input with a non-ASCII pattern is not filtered yet return 0, input.Length() } - // Not possible - if !isAscii(pattern) { - return -1, -1 + if !input.IsBytes() { + // Case folding or normalization can turn a non-ASCII rune into the + // ASCII character we are looking for, which the scan cannot see + if disableRunePrefilter || input.MayFoldToAscii() { + return 0, input.Length() + } + return runeFuzzyIndex(input, pattern, caseSensitive) } firstIdx, idx, lastIdx := 0, 0, 0 @@ -466,8 +504,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..4a0b73d93e3 --- /dev/null +++ b/src/algo/runeindex_others.go @@ -0,0 +1,15 @@ +//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) +} diff --git a/src/algo/runeindex_ref.go b/src/algo/runeindex_ref.go new file mode 100644 index 00000000000..53bcd03846c --- /dev/null +++ b/src/algo/runeindex_ref.go @@ -0,0 +1,37 @@ +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 +} diff --git a/src/algo/runeindex_x86.go b/src/algo/runeindex_x86.go new file mode 100644 index 00000000000..56d31db3cb4 --- /dev/null +++ b/src/algo/runeindex_x86.go @@ -0,0 +1,65 @@ +//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 +} + +// 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..8277eb4089f --- /dev/null +++ b/src/algo/runeprefilter_test.go @@ -0,0 +1,325 @@ +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 +} + +// 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/util/chars.go b/src/util/chars.go index 88cb5ce5e1a..a2ddbd01917 100644 --- a/src/util/chars.go +++ b/src/util/chars.go @@ -14,9 +14,19 @@ const ( overflow32 uint32 = 0x80808080 ) +const ( + flagInBytes uint8 = 1 << iota + flagMayFold +) + type Chars struct { - slice []byte // or []rune - inBytes bool + slice []byte // or []rune + // Written while the item is built and never after it reaches a matcher, + // so nothing reads these bits concurrently with a write. Prepend touches + // them, but only on the transient tokens inside transformItem, before + // item.text exists. trimLength* is kept out because TrimLength writes it + // lazily, long after that point. + flags uint8 trimLengthKnown bool trimLength uint16 @@ -25,6 +35,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 { @@ -69,16 +132,18 @@ func countRunes(bytes []byte) int { 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, 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++ @@ -86,17 +151,46 @@ func ToChars(bytes []byte) Chars { } r, sz := utf8.DecodeRune(bytes[i:]) i += sz + mayFold = mayFold || MayFoldToAscii(r) runes = append(runes, r) } - return RunesToChars(runes) + return runesToChars(runes, mayFold) } 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. +func (chars *Chars) Runes() []rune { + return chars.optionalRunes() } func (chars *Chars) Bytes() []byte { @@ -133,7 +227,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)) @@ -155,7 +249,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 @@ -275,6 +369,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 e7ac58aca03..c31f194e0df 100644 --- a/src/util/chars_test.go +++ b/src/util/chars_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" "unicode/utf8" + "unsafe" ) func TestCountRunes(t *testing.T) { @@ -93,14 +94,14 @@ func TestToCharsIntegrity(t *testing.T) { 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() } } @@ -215,3 +216,44 @@ 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") + } +} From be50293f66f90ec26db931feeb586d28950ec633 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 8 Aug 2026 19:10:37 +0900 Subject: [PATCH 4/8] Skip the normalization map for runes that cannot normalize normalizeRune guarded with 0x00C0..0xFF61, which does not exclude Hangul, CJK or Cyrillic, so every rune of those scripts hashed into the map only to miss. Every key of the map folds to ASCII, so the bitmap added for the rune prefilter rejects them without a lookup. - Non-ASCII queries 1.21x where every line is CJK, 1.05x on mostly-ASCII paths behind a Hangul prefix - ASCII queries unchanged, the prefilter already skips Phase 2 for them - Normalization share of query time for a non-ASCII query: 17.2% -> 0% --- src/algo/algo.go | 4 +++- src/algo/runeprefilter_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/algo/algo.go b/src/algo/algo.go index 45433c03b37..c3a4ca7d271 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 } diff --git a/src/algo/runeprefilter_test.go b/src/algo/runeprefilter_test.go index 8277eb4089f..59c2d283b3d 100644 --- a/src/algo/runeprefilter_test.go +++ b/src/algo/runeprefilter_test.go @@ -226,6 +226,35 @@ func TestRunePrefilterEquivalenceV1(t *testing.T) { 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) + } + } +} + // 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 { From 76db14199d2d531c65bf0e94bf0fd3b789c84503 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 8 Aug 2026 19:10:45 +0900 Subject: [PATCH 5/8] Prefilter rune-mode input for non-ASCII patterns The scan only ran for ASCII patterns, so searching CJK text with a CJK query still built the full score matrix. Scan for one byte of the pattern rune and verify all four. Which byte matters. Every ASCII rune contributes three zero bytes, so U+AE00 scanned by its zero low byte hits on nearly every character of an ASCII-heavy line. Pick a byte that cannot occur in an ASCII rune, else any non-zero one. A non-ASCII pattern rune is safe only when no other rune lowercases onto it. Uncased is not sufficient: U+00DF has no simple uppercase, yet U+1E9E lowercases to it, so the foldable set is excluded too. Measured on 1.4M-line corpora, with a non-ASCII query: - Every line CJK: 5.1x to 10.2x - Mostly-ASCII paths behind a Hangul prefix: 5.6x to 6.0x, and 1.2x where every line matches so nothing can be rejected - ASCII queries unchanged, kept off the non-inlinable guard --- src/algo/algo.go | 78 +++++++++++++++----- src/algo/runeindex_others.go | 8 +++ src/algo/runeindex_ref.go | 18 +++++ src/algo/runeindex_x86.go | 63 +++++++++++++++++ src/algo/runeprefilter_test.go | 125 +++++++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 18 deletions(-) diff --git a/src/algo/algo.go b/src/algo/algo.go index c3a4ca7d271..f4c128b6fed 100644 --- a/src/algo/algo.go +++ b/src/algo/algo.go @@ -347,15 +347,49 @@ func isAscii(runes []rune) bool { return true } -// runeFuzzyIndex is asciiFuzzyIndex for rune-mode input. Only valid when the -// input cannot fold to ASCII, see the caller. +// 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 b byte + var last rune for pidx := range pattern { - b = byte(pattern[pidx]) - idx = indexAsciiRune(runes, caseSensitive, b, idx) + 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 } @@ -370,7 +404,13 @@ func runeFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, // Find the last appearance of the last character of the pattern to limit // the search scope if lastIdx+1 < len(runes) { - if end := lastIndexAsciiRune(runes, caseSensitive, b, lastIdx+1); end >= 0 { + 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 } } @@ -378,24 +418,26 @@ func runeFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, } func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) { - if !isAscii(pattern) { - // An ASCII string cannot contain a non-ASCII character - if input.IsBytes() { - return -1, -1 - } - // Rune input with a non-ASCII pattern is not filtered yet - return 0, input.Length() - } - if !input.IsBytes() { - // Case folding or normalization can turn a non-ASCII rune into the - // ASCII character we are looking for, which the scan cannot see - if disableRunePrefilter || input.MayFoldToAscii() { + 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 + if !isAscii(pattern) { + return -1, -1 + } + firstIdx, idx, lastIdx := 0, 0, 0 var b byte for pidx := range pattern { diff --git a/src/algo/runeindex_others.go b/src/algo/runeindex_others.go index 4a0b73d93e3..c69614474be 100644 --- a/src/algo/runeindex_others.go +++ b/src/algo/runeindex_others.go @@ -13,3 +13,11 @@ func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int { 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 index 53bcd03846c..8bc073990ef 100644 --- a/src/algo/runeindex_ref.go +++ b/src/algo/runeindex_ref.go @@ -35,3 +35,21 @@ func lastIndexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) 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 index 56d31db3cb4..aea5c33592a 100644 --- a/src/algo/runeindex_x86.go +++ b/src/algo/runeindex_x86.go @@ -42,6 +42,69 @@ func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int { 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:] diff --git a/src/algo/runeprefilter_test.go b/src/algo/runeprefilter_test.go index 59c2d283b3d..fa566364c46 100644 --- a/src/algo/runeprefilter_test.go +++ b/src/algo/runeprefilter_test.go @@ -255,6 +255,131 @@ func TestNormalizeRuneUnchangedByGuard(t *testing.T) { } } +// 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 { From 01c44a7d5b202f612ac6852c388ce3bba12ad739 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 8 Aug 2026 19:10:51 +0900 Subject: [PATCH 6/8] Copy the item text in replace-query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToRunes aliases the rune array, and the editing actions append into t.input in place when the cursor is not at the end, so the keystrokes edit the item. Non-ASCII items only, ASCII gets a fresh slice. printf '한글abcde\n' | fzf --bind 'ctrl-y:replace-query' ctrl-y, Left, BSpace, ctrl-u -> 한글abcee Runes and ToRunes are now documented read-only. A stale fold bit was the other symptom, letting the prefilter reject an item the general path matches. --- src/terminal.go | 4 +++- src/util/chars.go | 17 +++++++++++------ src/util/chars_test.go | 26 ++++++++++++++++++++++++++ test/test_core.rb | 13 +++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) 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 a2ddbd01917..e9eb51c52d9 100644 --- a/src/util/chars.go +++ b/src/util/chars.go @@ -21,11 +21,9 @@ const ( type Chars struct { slice []byte // or []rune - // Written while the item is built and never after it reaches a matcher, - // so nothing reads these bits concurrently with a write. Prepend touches - // them, but only on the transient tokens inside transformItem, before - // item.text exists. trimLength* is kept out because TrimLength writes it - // lazily, long after that point. + // 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 @@ -157,6 +155,8 @@ func ToChars(bytes []byte) Chars { 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 { mayFold := false for _, r := range runes { @@ -188,7 +188,10 @@ 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. +// 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() } @@ -340,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 diff --git a/src/util/chars_test.go b/src/util/chars_test.go index c31f194e0df..5dd1ca7e660 100644 --- a/src/util/chars_test.go +++ b/src/util/chars_test.go @@ -257,3 +257,29 @@ func TestMayFoldFlag(t *testing.T) { 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] } From caf425365bc482cbee9e26a4eb53654381597536 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 8 Aug 2026 19:10:52 +0900 Subject: [PATCH 7/8] Update CHANGELOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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 ------ From f5512cbf3ee36a79e7531cb06f432a633fc60948 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 8 Aug 2026 19:10:52 +0900 Subject: [PATCH 8/8] Parenthesize an assert_equal argument in the zsh chpwd test Lint/AmbiguousBlockAssociation. rubocop -a rewrote this on every 'make lint' run, leaving the tree dirty. --- test/test_shell_integration.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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