mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-12 10:47:05 +02:00
Work on implementing the fzf algorithm for the choose files kitten
People are more used to that, and it is more optimized for use case of finding files.
This commit is contained in:
374
tools/fzf/algo.go
Normal file
374
tools/fzf/algo.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package fzf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kovidgoyal/kitty/tools/utils"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
/*
|
||||
|
||||
Algorithm
|
||||
---------
|
||||
|
||||
Based on code from fzf (MIT licensed):
|
||||
https://github.com/junegunn/fzf
|
||||
|
||||
FuzzyMatch implements a modified version of Smith-Waterman algorithm to find
|
||||
the optimal solution (highest score) according to the scoring criteria. Unlike
|
||||
the original algorithm, omission or mismatch of a character in the pattern is
|
||||
not allowed.
|
||||
|
||||
Scoring criteria
|
||||
----------------
|
||||
|
||||
- We prefer matches at special positions, such as the start of a word, or
|
||||
uppercase character in camelCase words.
|
||||
|
||||
- That is, we prefer an occurrence of the pattern with more characters
|
||||
matching at special positions, even if the total match length is longer.
|
||||
e.g. "fuzzyfinder" vs. "fuzzy-finder" on "ff"
|
||||
````````````
|
||||
- Also, if the first character in the pattern appears at one of the special
|
||||
positions, the bonus point for the position is multiplied by a constant
|
||||
as it is extremely likely that the first character in the typed pattern
|
||||
has more significance than the rest.
|
||||
e.g. "fo-bar" vs. "foob-r" on "br"
|
||||
``````
|
||||
- But since fzf is still a fuzzy finder, not an acronym finder, we should also
|
||||
consider the total length of the matched substring. This is why we have the
|
||||
gap penalty. The gap penalty increases as the length of the gap (distance
|
||||
between the matching characters) increases, so the effect of the bonus is
|
||||
eventually cancelled at some point.
|
||||
e.g. "fuzzyfinder" vs. "fuzzy-blurry-finder" on "ff"
|
||||
```````````
|
||||
- Consequently, it is crucial to find the right balance between the bonus
|
||||
and the gap penalty. The parameters were chosen that the bonus is cancelled
|
||||
when the gap size increases beyond 8 characters.
|
||||
|
||||
- The bonus mechanism can have the undesirable side effect where consecutive
|
||||
matches are ranked lower than the ones with gaps.
|
||||
e.g. "foobar" vs. "foo-bar" on "foob"
|
||||
```````
|
||||
- To correct this anomaly, we also give extra bonus point to each character
|
||||
in a consecutive matching chunk.
|
||||
e.g. "foobar" vs. "foo-bar" on "foob"
|
||||
``````
|
||||
- The amount of consecutive bonus is primarily determined by the bonus of the
|
||||
first character in the chunk.
|
||||
e.g. "foobar" vs. "out-of-bound" on "oob"
|
||||
````````````
|
||||
*/
|
||||
|
||||
func try_skip(input *Chars, case_sensitive bool, b byte, from int) int {
|
||||
byteArray := input.Bytes()[from:]
|
||||
idx := bytes.IndexByte(byteArray, b)
|
||||
if idx == 0 {
|
||||
// Can't skip any further
|
||||
return from
|
||||
}
|
||||
// We may need to search for the uppercase letter again. We don't have to
|
||||
// consider normalization as we can be sure that this is an ASCII string.
|
||||
if !case_sensitive && b >= 'a' && b <= 'z' {
|
||||
if idx > 0 {
|
||||
byteArray = byteArray[:idx]
|
||||
}
|
||||
uidx := bytes.IndexByte(byteArray, b-32)
|
||||
if uidx >= 0 {
|
||||
idx = uidx
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return -1
|
||||
}
|
||||
return from + idx
|
||||
}
|
||||
|
||||
func ascii_fuzzy_index(input *Chars, pattern []rune, pattern_is_ascii bool, case_sensitive bool) (int, int) {
|
||||
// Can't determine
|
||||
if !input.Is_ASCII() {
|
||||
return 0, input.Length()
|
||||
}
|
||||
// Can't match
|
||||
if !pattern_is_ascii {
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
firstIdx, idx, lastIdx := 0, 0, 0
|
||||
var b byte
|
||||
for pidx := range len(pattern) {
|
||||
b = byte(pattern[pidx])
|
||||
idx = try_skip(input, case_sensitive, 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
|
||||
bu := b
|
||||
if !case_sensitive && b >= 'a' && b <= 'z' {
|
||||
bu = b - 32
|
||||
}
|
||||
scope := input.Bytes()[lastIdx:]
|
||||
for offset := len(scope) - 1; offset > 0; offset-- {
|
||||
if scope[offset] == b || scope[offset] == bu {
|
||||
return firstIdx, lastIdx + offset + 1
|
||||
}
|
||||
}
|
||||
return firstIdx, lastIdx + 1
|
||||
}
|
||||
|
||||
func (m *FuzzyMatcher) charClassOfNonAscii(char rune) charClass {
|
||||
if unicode.IsLower(char) {
|
||||
return charLower
|
||||
} else if unicode.IsUpper(char) {
|
||||
return charUpper
|
||||
} else if unicode.IsNumber(char) {
|
||||
return charNumber
|
||||
} else if unicode.IsLetter(char) {
|
||||
return charLetter
|
||||
} else if unicode.IsSpace(char) {
|
||||
return charWhite
|
||||
} else if strings.ContainsRune(m.delimiterChars, char) {
|
||||
return charDelimiter
|
||||
}
|
||||
return charNonWord
|
||||
}
|
||||
|
||||
// Score the input against pattern. If !m.Case_sensitive pattern must be
|
||||
// lowercased already. pattern must be non-empty. When m.Ignore_accents
|
||||
// accents must already be removed from both pattern and input.
|
||||
func (m *FuzzyMatcher) score_one(input *Chars, pattern []rune, pattern_is_ascii bool, slab *slab) (ans Result) {
|
||||
M := len(pattern)
|
||||
N := input.Length()
|
||||
if M > N {
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1. Optimized search for ASCII string
|
||||
minIdx, maxIdx := ascii_fuzzy_index(input, pattern, pattern_is_ascii, m.Case_sensitive)
|
||||
if minIdx < 0 {
|
||||
return
|
||||
}
|
||||
// fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString())
|
||||
N = maxIdx - minIdx
|
||||
|
||||
slab.reset()
|
||||
|
||||
H0 := slab.alloc16(N)
|
||||
C0 := slab.alloc16(N)
|
||||
// Bonus point for each position
|
||||
B := slab.alloc16(N)
|
||||
// The first occurrence of each character in the pattern
|
||||
F := slab.alloc32(M)
|
||||
// Rune array
|
||||
T := slab.alloc32(N)
|
||||
input.CopyRunes(T, minIdx)
|
||||
|
||||
// Phase 2. Calculate bonus for each point
|
||||
maxScore, maxScorePos := int16(0), 0
|
||||
pidx, lastIdx := 0, 0
|
||||
pchar0, pchar, prevH0, prevClass, inGap := pattern[0], pattern[0], int16(0), m.initialCharClass, false
|
||||
for off, char := range T {
|
||||
var class charClass
|
||||
if char <= unicode.MaxASCII {
|
||||
class = m.asciiCharClasses[char]
|
||||
if !m.Case_sensitive && class == charUpper {
|
||||
char += 32
|
||||
T[off] = char
|
||||
}
|
||||
} else {
|
||||
class = m.charClassOfNonAscii(char)
|
||||
if !m.Case_sensitive && class == charUpper {
|
||||
char = unicode.To(unicode.LowerCase, char)
|
||||
}
|
||||
T[off] = char
|
||||
}
|
||||
|
||||
bonus := m.bonusMatrix[prevClass][class]
|
||||
B[off] = bonus
|
||||
prevClass = class
|
||||
|
||||
if char == pchar {
|
||||
if pidx < M {
|
||||
F[pidx] = int32(off)
|
||||
pidx++
|
||||
pchar = pattern[min(pidx, M-1)]
|
||||
}
|
||||
lastIdx = off
|
||||
}
|
||||
|
||||
if char == pchar0 {
|
||||
score := scoreMatch + bonus*bonusFirstCharMultiplier
|
||||
H0[off] = score
|
||||
C0[off] = 1
|
||||
if M == 1 && (!m.Backwards && score > maxScore || m.Backwards && score >= maxScore) {
|
||||
maxScore, maxScorePos = score, off
|
||||
if !m.Backwards && bonus >= bonusBoundary {
|
||||
break
|
||||
}
|
||||
}
|
||||
inGap = false
|
||||
} else {
|
||||
if inGap {
|
||||
H0[off] = max(prevH0+scoreGapExtension, 0)
|
||||
} else {
|
||||
H0[off] = max(prevH0+scoreGapStart, 0)
|
||||
}
|
||||
C0[off] = 0
|
||||
inGap = true
|
||||
}
|
||||
prevH0 = H0[off]
|
||||
}
|
||||
if pidx != M {
|
||||
return
|
||||
}
|
||||
if M == 1 {
|
||||
if m.Without_positions {
|
||||
return Result{Score: uint(maxScore)}
|
||||
}
|
||||
return Result{Score: uint(maxScore), Positions: []int{minIdx + maxScorePos}}
|
||||
}
|
||||
|
||||
// Phase 3. Fill in score matrix (H)
|
||||
// Unlike the original algorithm, we do not allow omission.
|
||||
f0 := int(F[0])
|
||||
width := lastIdx - f0 + 1
|
||||
H := slab.alloc16(width * M)
|
||||
copy(H, H0[f0:lastIdx+1])
|
||||
|
||||
// Possible length of consecutive chunk at each position.
|
||||
C := slab.alloc16(width * M)
|
||||
copy(C, C0[f0:lastIdx+1])
|
||||
|
||||
Fsub := F[1:]
|
||||
Psub := pattern[1:][:len(Fsub)]
|
||||
for off, f := range Fsub {
|
||||
f := int(f)
|
||||
pchar := Psub[off]
|
||||
pidx := off + 1
|
||||
row := pidx * width
|
||||
inGap := false
|
||||
Tsub := T[f : lastIdx+1]
|
||||
Bsub := B[f:][:len(Tsub)]
|
||||
Csub := C[row+f-f0:][:len(Tsub)]
|
||||
Cdiag := C[row+f-f0-1-width:][:len(Tsub)]
|
||||
Hsub := H[row+f-f0:][:len(Tsub)]
|
||||
Hdiag := H[row+f-f0-1-width:][:len(Tsub)]
|
||||
Hleft := H[row+f-f0-1:][:len(Tsub)]
|
||||
Hleft[0] = 0
|
||||
for off, char := range Tsub {
|
||||
col := off + f
|
||||
var s1, s2, consecutive int16
|
||||
|
||||
if inGap {
|
||||
s2 = Hleft[off] + scoreGapExtension
|
||||
} else {
|
||||
s2 = Hleft[off] + scoreGapStart
|
||||
}
|
||||
|
||||
if pchar == char {
|
||||
s1 = Hdiag[off] + scoreMatch
|
||||
b := Bsub[off]
|
||||
consecutive = Cdiag[off] + 1
|
||||
if consecutive > 1 {
|
||||
fb := B[col-int(consecutive)+1]
|
||||
// Break consecutive chunk
|
||||
if b >= bonusBoundary && b > fb {
|
||||
consecutive = 1
|
||||
} else {
|
||||
b = max(b, max(bonusConsecutive, fb))
|
||||
}
|
||||
}
|
||||
if s1+b < s2 {
|
||||
s1 += Bsub[off]
|
||||
consecutive = 0
|
||||
} else {
|
||||
s1 += b
|
||||
}
|
||||
}
|
||||
Csub[off] = consecutive
|
||||
|
||||
inGap = s1 < s2
|
||||
score := max(max(s1, s2), 0)
|
||||
if pidx == M-1 && (!m.Backwards && score > maxScore || m.Backwards && score >= maxScore) {
|
||||
maxScore, maxScorePos = score, col
|
||||
}
|
||||
Hsub[off] = score
|
||||
}
|
||||
}
|
||||
// Phase 4. (Optional) Backtrace to find character positions
|
||||
var pos []int
|
||||
j := f0
|
||||
if !m.Without_positions {
|
||||
pos = make([]int, 0, M)
|
||||
i := M - 1
|
||||
j = maxScorePos
|
||||
preferMatch := true
|
||||
for {
|
||||
I := i * width
|
||||
j0 := j - f0
|
||||
s := H[I+j0]
|
||||
|
||||
var s1, s2 int16
|
||||
if i > 0 && j >= int(F[i]) {
|
||||
s1 = H[I-width+j0-1]
|
||||
}
|
||||
if j > int(F[i]) {
|
||||
s2 = H[I+j0-1]
|
||||
}
|
||||
|
||||
if s > s1 && (s > s2 || s == s2 && preferMatch) {
|
||||
pos = append(pos, j+minIdx)
|
||||
if i == 0 {
|
||||
break
|
||||
}
|
||||
i--
|
||||
}
|
||||
preferMatch = C[I+j0] > 1 || I+width+j0+1 < len(C) && C[I+width+j0+1] > 0
|
||||
j--
|
||||
}
|
||||
}
|
||||
return Result{Score: uint(maxScore), Positions: pos}
|
||||
}
|
||||
|
||||
func (m *FuzzyMatcher) score(items []string, pattern string, scoring_func func(string, []rune, bool, *slab, func(string) Chars) Result) (ans []Result, err error) {
|
||||
if pattern == "" || len(items) < 1 {
|
||||
return make([]Result, len(items)), nil
|
||||
}
|
||||
as_chars := CharsFromString
|
||||
if m.Ignore_accents {
|
||||
pattern = string(CharsFromStringWithoutAccents(pattern).runes)
|
||||
as_chars = CharsFromStringWithoutAccents
|
||||
}
|
||||
pattern = norm.NFC.String(pattern)
|
||||
if !m.Case_sensitive {
|
||||
pattern = strings.ToLower(pattern)
|
||||
}
|
||||
pat := []rune(pattern)
|
||||
pattern_is_ascii := !slices.ContainsFunc(pat, func(r rune) bool { return r >= utf8.RuneSelf })
|
||||
ans = make([]Result, len(items))
|
||||
err = utils.Run_in_parallel_over_range(0, func(start, end int) error {
|
||||
s := slab{}
|
||||
for i := start; i < end; i++ {
|
||||
ans[i] = scoring_func(items[i], pat, pattern_is_ascii, &s, as_chars)
|
||||
}
|
||||
return nil
|
||||
}, 0, len(items))
|
||||
return
|
||||
|
||||
}
|
||||
90
tools/fzf/algo_test.go
Normal file
90
tools/fzf/algo_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package fzf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func assertMatch(t *testing.T, m *FuzzyMatcher, item string, query string, start, end, score int) {
|
||||
r, err := m.Score([]string{item}, query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r[0].Score != uint(score) {
|
||||
t.Fatalf("Score of %#v in %#v is %d instead of %d", query, item, r[0].Score, score)
|
||||
}
|
||||
if start > -1 && end > -1 {
|
||||
p := r[0].Positions
|
||||
sort.Ints(p)
|
||||
if len(p) < 1 {
|
||||
t.Fatalf("Got no positions for %#v in %#v", query, item)
|
||||
}
|
||||
if p[0] != start {
|
||||
t.Fatalf("First char of %#v in %#v at %d instead of %d", query, item, p[0], start)
|
||||
}
|
||||
if p[len(p)-1]+1 != end {
|
||||
t.Fatalf("Last char of %#v in %#v at %d instead of %d", query, item, p[len(p)-1], end-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFZFAlgo(t *testing.T) {
|
||||
fn := NewFuzzyMatcher(DEFAULT_SCHEME)
|
||||
for _, forward := range []bool{true, false} {
|
||||
fn.Backwards = !forward
|
||||
fn.Case_sensitive = false
|
||||
assertMatch(t, fn, "fooBarbaz1", "oBZ", 2, 9,
|
||||
scoreMatch*3+bonusCamel123+scoreGapStart+scoreGapExtension*3)
|
||||
assertMatch(t, fn, "foo bar baz", "fbb", 0, 9,
|
||||
scoreMatch*3+int(fn.bonusBoundaryWhite)*bonusFirstCharMultiplier+
|
||||
int(fn.bonusBoundaryWhite)*2+2*scoreGapStart+4*scoreGapExtension)
|
||||
assertMatch(t, fn, "/AutomatorDocument.icns", "rdoc", 9, 13,
|
||||
scoreMatch*4+bonusCamel123+bonusConsecutive*2)
|
||||
assertMatch(t, fn, "/man1/zshcompctl.1", "zshc", 6, 10,
|
||||
scoreMatch*4+int(fn.bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(fn.bonusBoundaryDelimiter)*3)
|
||||
assertMatch(t, fn, "/.oh-my-zsh/cache", "zshc", 8, 13,
|
||||
scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(fn.bonusBoundaryDelimiter))
|
||||
assertMatch(t, fn, "ab0123 456", "12356", 3, 10,
|
||||
scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension)
|
||||
assertMatch(t, fn, "abc123 456", "12356", 3, 10,
|
||||
scoreMatch*5+bonusCamel123*bonusFirstCharMultiplier+bonusCamel123*2+bonusConsecutive+scoreGapStart+scoreGapExtension)
|
||||
assertMatch(t, fn, "foo/bar/baz", "fbb", 0, 9,
|
||||
scoreMatch*3+int(fn.bonusBoundaryWhite)*bonusFirstCharMultiplier+
|
||||
int(fn.bonusBoundaryDelimiter)*2+2*scoreGapStart+4*scoreGapExtension)
|
||||
assertMatch(t, fn, "fooBarBaz", "fbb", 0, 7,
|
||||
scoreMatch*3+int(fn.bonusBoundaryWhite)*bonusFirstCharMultiplier+
|
||||
bonusCamel123*2+2*scoreGapStart+2*scoreGapExtension)
|
||||
assertMatch(t, fn, "foo barbaz", "fbb", 0, 8,
|
||||
scoreMatch*3+int(fn.bonusBoundaryWhite)*bonusFirstCharMultiplier+int(fn.bonusBoundaryWhite)+
|
||||
scoreGapStart*2+scoreGapExtension*3)
|
||||
assertMatch(t, fn, "fooBar Baz", "foob", 0, 4,
|
||||
scoreMatch*4+int(fn.bonusBoundaryWhite)*bonusFirstCharMultiplier+int(fn.bonusBoundaryWhite)*3)
|
||||
assertMatch(t, fn, "xFoo-Bar Baz", "foo-b", 1, 6,
|
||||
scoreMatch*5+bonusCamel123*bonusFirstCharMultiplier+bonusCamel123*2+
|
||||
bonusNonWord+bonusBoundary)
|
||||
|
||||
fn.Case_sensitive = true
|
||||
assertMatch(t, fn, "fooBarbaz", "oBz", 2, 9,
|
||||
scoreMatch*3+bonusCamel123+scoreGapStart+scoreGapExtension*3)
|
||||
assertMatch(t, fn, "Foo/Bar/Baz", "FBB", 0, 9,
|
||||
scoreMatch*3+int(fn.bonusBoundaryWhite)*bonusFirstCharMultiplier+int(fn.bonusBoundaryDelimiter)*2+
|
||||
scoreGapStart*2+scoreGapExtension*4)
|
||||
assertMatch(t, fn, "FooBarBaz", "FBB", 0, 7,
|
||||
scoreMatch*3+int(fn.bonusBoundaryWhite)*bonusFirstCharMultiplier+bonusCamel123*2+
|
||||
scoreGapStart*2+scoreGapExtension*2)
|
||||
assertMatch(t, fn, "FooBar Baz", "FooB", 0, 4,
|
||||
scoreMatch*4+int(fn.bonusBoundaryWhite)*bonusFirstCharMultiplier+int(fn.bonusBoundaryWhite)*2+
|
||||
max(bonusCamel123, int(fn.bonusBoundaryWhite)))
|
||||
|
||||
// Consecutive bonus updated
|
||||
assertMatch(t, fn, "foo-bar", "o-ba", 2, 6, scoreMatch*4+bonusBoundary*3)
|
||||
|
||||
// Non-match
|
||||
assertMatch(t, fn, "fooBarbaz", "oBZ", -1, -1, 0)
|
||||
assertMatch(t, fn, "Foo Bar Baz", "fbb", -1, -1, 0)
|
||||
assertMatch(t, fn, "fooBarbaz", "fooBarbazz", -1, -1, 0)
|
||||
}
|
||||
}
|
||||
48
tools/fzf/api.go
Normal file
48
tools/fzf/api.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package fzf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func NewFuzzyMatcher(scheme Scheme) (ans *FuzzyMatcher) {
|
||||
return new_fuzzy_matcher(scheme)
|
||||
}
|
||||
|
||||
// Score the specified items using runtime.NumCPU() go routines. This function
|
||||
// reports a panic in any worker go routine as a regular error.
|
||||
func (m *FuzzyMatcher) Score(items []string, pattern string) (ans []Result, err error) {
|
||||
return m.score(items, pattern, func(item string, pat []rune, pattern_is_ascii bool, slab *slab, as_chars func(string) Chars) Result {
|
||||
c := as_chars(item)
|
||||
return m.score_one(&c, pat, pattern_is_ascii, slab)
|
||||
})
|
||||
}
|
||||
|
||||
// Clear the cache used ScoreWithCache(). Useful if you change some of the
|
||||
// settings used for scoring.
|
||||
func (m *FuzzyMatcher) ClearScoreCache() {
|
||||
m.cache_mutex.Lock()
|
||||
m.cache = make(map[string]Result)
|
||||
m.cache_mutex.Unlock()
|
||||
}
|
||||
|
||||
// Same as Score, except that it uses a cache. Remember to call
|
||||
// ClearScoreCache() if you change any scoring settings on this FuzzyMatcher.
|
||||
func (m *FuzzyMatcher) ScoreWithCache(items []string, pattern string) (ans []Result, err error) {
|
||||
key_prefix := pattern + "\x00"
|
||||
return m.score(items, pattern, func(item string, pat []rune, pattern_is_ascii bool, slab *slab, as_chars func(string) Chars) Result {
|
||||
key := key_prefix + item
|
||||
m.cache_mutex.Lock()
|
||||
res, found := m.cache[key]
|
||||
m.cache_mutex.Unlock()
|
||||
if !found {
|
||||
c := as_chars(item)
|
||||
res = m.score_one(&c, pat, pattern_is_ascii, slab)
|
||||
m.cache_mutex.Lock()
|
||||
m.cache[key] = res
|
||||
m.cache_mutex.Unlock()
|
||||
}
|
||||
return res
|
||||
})
|
||||
}
|
||||
301
tools/fzf/types.go
Normal file
301
tools/fzf/types.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package fzf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
|
||||
"github.com/kovidgoyal/kitty/tools/utils"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type Chars struct {
|
||||
bytes []byte
|
||||
runes []rune
|
||||
}
|
||||
|
||||
const (
|
||||
overflow64 uint64 = 0x8080808080808080
|
||||
overflow32 uint32 = 0x80808080
|
||||
)
|
||||
|
||||
func check_ascii(bytes []byte) (ascii_until int) {
|
||||
i := 0
|
||||
for ; i <= len(bytes)-8; i += 8 {
|
||||
if (overflow64 & *(*uint64)(unsafe.Pointer(&bytes[i]))) > 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for ; i <= len(bytes)-4; i += 4 {
|
||||
if (overflow32 & *(*uint32)(unsafe.Pointer(&bytes[i]))) > 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for ; i < len(bytes); i++ {
|
||||
if bytes[i] >= utf8.RuneSelf {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func CharsFromString(text string) (ans Chars) {
|
||||
ans.bytes = utils.UnsafeStringToBytes(text)
|
||||
ascii_until := check_ascii(ans.bytes)
|
||||
if ascii_until > -1 {
|
||||
runes := []rune(norm.NFC.String(text[ascii_until:]))
|
||||
ans.runes = make([]rune, ascii_until+len(runes))
|
||||
for i := range ascii_until {
|
||||
ans.runes[i] = rune(ans.bytes[i])
|
||||
}
|
||||
copy(ans.runes[ascii_until:], runes)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func CharsFromStringWithoutAccents(text string) (ans Chars) {
|
||||
ans.bytes = utils.UnsafeStringToBytes(text)
|
||||
ascii_until := check_ascii(ans.bytes)
|
||||
if ascii_until > -1 {
|
||||
runes := []rune(norm.NFD.String(text[ascii_until:]))
|
||||
ans.runes = make([]rune, ascii_until, ascii_until+len(runes))
|
||||
for i := range ascii_until {
|
||||
ans.runes[i] = rune(ans.bytes[i])
|
||||
}
|
||||
for _, r := range runes {
|
||||
if !unicode.Is(unicode.Mn, r) {
|
||||
ans.runes = append(ans.runes, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Chars) Bytes() []byte { return c.bytes }
|
||||
func (c *Chars) Is_ASCII() bool { return c.runes == nil }
|
||||
func (c *Chars) Get(i int) rune {
|
||||
if c.runes != nil {
|
||||
return c.runes[i]
|
||||
}
|
||||
return rune(c.bytes[i])
|
||||
}
|
||||
func (c *Chars) Length() int {
|
||||
if c.runes != nil {
|
||||
return len(c.runes)
|
||||
}
|
||||
return len(c.bytes)
|
||||
}
|
||||
|
||||
func (c *Chars) CopyRunes(dest []rune, from int) {
|
||||
if c.runes != nil {
|
||||
copy(dest, c.runes[from:])
|
||||
return
|
||||
}
|
||||
for idx, b := range c.bytes[from:][:len(dest)] {
|
||||
dest[idx] = rune(b)
|
||||
}
|
||||
}
|
||||
|
||||
type charClass int
|
||||
|
||||
const (
|
||||
charWhite charClass = iota
|
||||
charNonWord
|
||||
charDelimiter
|
||||
charLower
|
||||
charUpper
|
||||
charLetter
|
||||
charNumber
|
||||
)
|
||||
|
||||
const (
|
||||
scoreMatch = 16
|
||||
scoreGapStart = -3
|
||||
scoreGapExtension = -1
|
||||
|
||||
// We prefer matches at the beginning of a word, but the bonus should not be
|
||||
// too great to prevent the longer acronym matches from always winning over
|
||||
// shorter fuzzy matches. The bonus point here was specifically chosen that
|
||||
// the bonus is cancelled when the gap between the acronyms grows over
|
||||
// 8 characters, which is approximately the average length of the words found
|
||||
// in web2 dictionary and my file system.
|
||||
bonusBoundary = scoreMatch / 2
|
||||
|
||||
// Although bonus point for non-word characters is non-contextual, we need it
|
||||
// for computing bonus points for consecutive chunks starting with a non-word
|
||||
// character.
|
||||
bonusNonWord = scoreMatch / 2
|
||||
|
||||
// Edge-triggered bonus for matches in camelCase words.
|
||||
// Compared to word-boundary case, they don't accompany single-character gaps
|
||||
// (e.g. FooBar vs. foo-bar), so we deduct bonus point accordingly.
|
||||
bonusCamel123 = bonusBoundary + scoreGapExtension
|
||||
|
||||
// Minimum bonus point given to characters in consecutive chunks.
|
||||
// Note that bonus points for consecutive matches shouldn't have needed if we
|
||||
// used fixed match score as in the original algorithm.
|
||||
bonusConsecutive = -(scoreGapStart + scoreGapExtension)
|
||||
|
||||
// The first character in the typed pattern usually has more significance
|
||||
// than the rest so it's important that it appears at special positions where
|
||||
// bonus points are given, e.g. "to-go" vs. "ongoing" on "og" or on "ogo".
|
||||
// The amount of the extra bonus should be limited so that the gap penalty is
|
||||
// still respected.
|
||||
bonusFirstCharMultiplier = 2
|
||||
)
|
||||
|
||||
const whiteChars = " \t\n\v\f\r\x85\xA0"
|
||||
|
||||
type Result struct {
|
||||
Score uint // A value of zero means did not match
|
||||
Positions []int
|
||||
}
|
||||
|
||||
type FuzzyMatcher struct {
|
||||
Case_sensitive, Ignore_accents, Backwards, Without_positions bool
|
||||
|
||||
// Extra bonus for word boundary after whitespace character or beginning of the string
|
||||
bonusBoundaryWhite int16
|
||||
|
||||
// Extra bonus for word boundary after slash, colon, semi-colon, and comma
|
||||
bonusBoundaryDelimiter int16
|
||||
|
||||
initialCharClass charClass
|
||||
|
||||
// A minor optimization that can give 15%+ performance boost
|
||||
asciiCharClasses [unicode.MaxASCII + 1]charClass
|
||||
|
||||
// A minor optimization that can give yet another 5% performance boost
|
||||
bonusMatrix [charNumber + 1][charNumber + 1]int16
|
||||
|
||||
delimiterChars string
|
||||
|
||||
cache map[string]Result
|
||||
cache_mutex sync.Mutex
|
||||
}
|
||||
|
||||
func (m *FuzzyMatcher) bonusFor(prevClass charClass, class charClass) int16 {
|
||||
if class > charNonWord {
|
||||
switch prevClass {
|
||||
case charWhite:
|
||||
// Word boundary after whitespace
|
||||
return m.bonusBoundaryWhite
|
||||
case charDelimiter:
|
||||
// Word boundary after a delimiter character
|
||||
return m.bonusBoundaryDelimiter
|
||||
case charNonWord:
|
||||
// Word boundary
|
||||
return bonusBoundary
|
||||
}
|
||||
}
|
||||
|
||||
if prevClass == charLower && class == charUpper ||
|
||||
prevClass != charNumber && class == charNumber {
|
||||
// camelCase letter123
|
||||
return bonusCamel123
|
||||
}
|
||||
|
||||
switch class {
|
||||
case charNonWord, charDelimiter:
|
||||
return bonusNonWord
|
||||
case charWhite:
|
||||
return m.bonusBoundaryWhite
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type Scheme string
|
||||
|
||||
const (
|
||||
DEFAULT_SCHEME Scheme = "default"
|
||||
PATH_SCHEME Scheme = "path"
|
||||
HISTORY_SCHEME Scheme = "history"
|
||||
)
|
||||
|
||||
func new_fuzzy_matcher(scheme Scheme) (ans *FuzzyMatcher) {
|
||||
ans = &FuzzyMatcher{
|
||||
bonusBoundaryWhite: bonusBoundary + 2,
|
||||
bonusBoundaryDelimiter: bonusBoundary + 1,
|
||||
delimiterChars: "/,:;|",
|
||||
cache: make(map[string]Result),
|
||||
}
|
||||
switch scheme {
|
||||
case PATH_SCHEME:
|
||||
ans.bonusBoundaryWhite = bonusBoundary
|
||||
ans.initialCharClass = charDelimiter
|
||||
if os.PathSeparator == '/' {
|
||||
ans.delimiterChars = "/"
|
||||
} else {
|
||||
ans.delimiterChars = "/" + string(os.PathSeparator)
|
||||
}
|
||||
case HISTORY_SCHEME:
|
||||
ans.bonusBoundaryWhite = bonusBoundary
|
||||
ans.bonusBoundaryDelimiter = bonusBoundary
|
||||
}
|
||||
for i := 0; i <= unicode.MaxASCII; i++ {
|
||||
char := rune(i)
|
||||
c := charNonWord
|
||||
if char >= 'a' && char <= 'z' {
|
||||
c = charLower
|
||||
} else if char >= 'A' && char <= 'Z' {
|
||||
c = charUpper
|
||||
} else if char >= '0' && char <= '9' {
|
||||
c = charNumber
|
||||
} else if strings.ContainsRune(whiteChars, char) {
|
||||
c = charWhite
|
||||
} else if strings.ContainsRune(ans.delimiterChars, char) {
|
||||
c = charDelimiter
|
||||
}
|
||||
ans.asciiCharClasses[i] = c
|
||||
}
|
||||
for i := 0; i <= int(charNumber); i++ {
|
||||
for j := 0; j <= int(charNumber); j++ {
|
||||
ans.bonusMatrix[i][j] = ans.bonusFor(charClass(i), charClass(j))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type slab struct {
|
||||
i16 []int16
|
||||
i32 []int32
|
||||
i16_used, i32_used int
|
||||
}
|
||||
|
||||
const slab_initial_size = 8192
|
||||
|
||||
func (s *slab) reset() {
|
||||
if s.i16 == nil {
|
||||
s.i16 = make([]int16, slab_initial_size)
|
||||
}
|
||||
if s.i32 == nil {
|
||||
s.i32 = make([]int32, slab_initial_size)
|
||||
}
|
||||
s.i16_used, s.i32_used = 0, 0
|
||||
}
|
||||
|
||||
func (s *slab) alloc16(sz int) []int16 {
|
||||
if sz+s.i16_used < len(s.i16) {
|
||||
s.i16 = make([]int16, max(slab_initial_size, 2*(s.i16_used+sz)))
|
||||
s.i16_used = 0
|
||||
}
|
||||
pos := s.i16_used
|
||||
s.i16_used += sz
|
||||
return s.i16[pos:s.i16_used]
|
||||
}
|
||||
|
||||
func (s *slab) alloc32(sz int) []int32 {
|
||||
if sz+s.i32_used < len(s.i32) {
|
||||
s.i32 = make([]int32, max(slab_initial_size, 2*(s.i32_used+sz)))
|
||||
s.i32_used = 0
|
||||
}
|
||||
pos := s.i32_used
|
||||
s.i32_used += sz
|
||||
return s.i32[pos:s.i32_used]
|
||||
}
|
||||
Reference in New Issue
Block a user