diff --git a/go.mod b/go.mod index e8538532a..34bf6dba7 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b golang.org/x/image v0.27.0 golang.org/x/sys v0.33.0 + golang.org/x/text v0.25.0 howett.net/plist v1.0.1 ) diff --git a/go.sum b/go.sum index a92963846..e4c987375 100644 --- a/go.sum +++ b/go.sum @@ -28,10 +28,6 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/kovidgoyal/dbus v0.0.0-20250518162316-472d38549895 h1:CQ+iKunklzE89I7C/AtXXltUhokfDNwL4rlst4AFQ9Q= -github.com/kovidgoyal/dbus v0.0.0-20250518162316-472d38549895/go.mod h1:RbNG3Q1g6GUy1/WzWVx+S24m7VKyvl57vV+cr2hpt50= -github.com/kovidgoyal/dbus v0.0.0-20250518163929-5e5bfb2bd2f2 h1:txptp4rHsZ8LPdmJSbak3fU08C+SmVqKy+FRBbQhl84= -github.com/kovidgoyal/dbus v0.0.0-20250518163929-5e5bfb2bd2f2/go.mod h1:RbNG3Q1g6GUy1/WzWVx+S24m7VKyvl57vV+cr2hpt50= github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 h1:rMY/hWfcVzBm6BLX6YLA+gLJEpuXBed/VP6YEkXt8R4= github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1/go.mod h1:RbNG3Q1g6GUy1/WzWVx+S24m7VKyvl57vV+cr2hpt50= github.com/kovidgoyal/imaging v1.6.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk= @@ -79,6 +75,8 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/tools/fzf/algo.go b/tools/fzf/algo.go new file mode 100644 index 000000000..ca3c29917 --- /dev/null +++ b/tools/fzf/algo.go @@ -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 + +} diff --git a/tools/fzf/algo_test.go b/tools/fzf/algo_test.go new file mode 100644 index 000000000..a5a5a2f7a --- /dev/null +++ b/tools/fzf/algo_test.go @@ -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) + } +} diff --git a/tools/fzf/api.go b/tools/fzf/api.go new file mode 100644 index 000000000..3b02cce12 --- /dev/null +++ b/tools/fzf/api.go @@ -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 + }) +} diff --git a/tools/fzf/types.go b/tools/fzf/types.go new file mode 100644 index 000000000..c9bae90f0 --- /dev/null +++ b/tools/fzf/types.go @@ -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] +}