mirror of
https://github.com/junegunn/fzf
synced 2026-08-08 19:41:43 +00:00
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
This commit is contained in:
+60
-18
@@ -347,15 +347,49 @@ func isAscii(runes []rune) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// runeFuzzyIndex is asciiFuzzyIndex for rune-mode input. Only valid when the
|
// runePrefilterable reports whether scanning the rune array can decide this
|
||||||
// input cannot fold to ASCII, see the caller.
|
// 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) {
|
func runeFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
|
||||||
runes := input.Runes()
|
runes := input.Runes()
|
||||||
firstIdx, idx, lastIdx := 0, 0, 0
|
firstIdx, idx, lastIdx := 0, 0, 0
|
||||||
var b byte
|
var last rune
|
||||||
for pidx := range pattern {
|
for pidx := range pattern {
|
||||||
b = byte(pattern[pidx])
|
last = pattern[pidx]
|
||||||
idx = indexAsciiRune(runes, caseSensitive, b, idx)
|
if last < utf8.RuneSelf {
|
||||||
|
idx = indexAsciiRune(runes, caseSensitive, byte(last), idx)
|
||||||
|
} else {
|
||||||
|
idx = indexRune(runes, last, idx)
|
||||||
|
}
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return -1, -1
|
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
|
// Find the last appearance of the last character of the pattern to limit
|
||||||
// the search scope
|
// the search scope
|
||||||
if lastIdx+1 < len(runes) {
|
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
|
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) {
|
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() {
|
if !input.IsBytes() {
|
||||||
// Case folding or normalization can turn a non-ASCII rune into the
|
if disableRunePrefilter {
|
||||||
// ASCII character we are looking for, which the scan cannot see
|
|
||||||
if disableRunePrefilter || input.MayFoldToAscii() {
|
|
||||||
return 0, input.Length()
|
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)
|
return runeFuzzyIndex(input, pattern, caseSensitive)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not possible
|
||||||
|
if !isAscii(pattern) {
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
firstIdx, idx, lastIdx := 0, 0, 0
|
firstIdx, idx, lastIdx := 0, 0, 0
|
||||||
var b byte
|
var b byte
|
||||||
for pidx := range pattern {
|
for pidx := range pattern {
|
||||||
|
|||||||
@@ -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 {
|
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
||||||
return lastIndexAsciiRuneRef(runes, caseSensitive, b, from)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,3 +35,21 @@ func lastIndexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) i
|
|||||||
}
|
}
|
||||||
return -1
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,69 @@ func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
|||||||
return -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.
|
// lastIndexAsciiRune is indexAsciiRune scanning backwards from the end.
|
||||||
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
|
||||||
view := runeBytes(runes)[from*4:]
|
view := runeBytes(runes)[from*4:]
|
||||||
|
|||||||
@@ -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:
|
// preparePattern mirrors what pattern.go guarantees the algo functions:
|
||||||
// lowercased when case-insensitive, normalized when normalize is on.
|
// lowercased when case-insensitive, normalized when normalize is on.
|
||||||
func preparePattern(pat string, caseSensitive, normalize bool) []rune {
|
func preparePattern(pat string, caseSensitive, normalize bool) []rune {
|
||||||
|
|||||||
Reference in New Issue
Block a user