From c5c733d14c6c51f5edb80a14cafe4a5252b39805 Mon Sep 17 00:00:00 2001 From: Branden J Brown Date: Fri, 23 Jan 2026 15:23:36 -0500 Subject: [PATCH] use fzf for autocomplete --- autocomplete/autocomplete.go | 140 +++++++++--------------------- autocomplete/autocomplete_test.go | 8 +- go.mod | 5 +- go.sum | 7 ++ main.go | 7 -- skill.go | 4 +- 6 files changed, 59 insertions(+), 112 deletions(-) diff --git a/autocomplete/autocomplete.go b/autocomplete/autocomplete.go index a341934..f58b6af 100644 --- a/autocomplete/autocomplete.go +++ b/autocomplete/autocomplete.go @@ -1,122 +1,60 @@ package autocomplete import ( + "bytes" + "cmp" "slices" - "strings" - "unicode" - "unicode/utf8" + "sync" + + "github.com/junegunn/fzf/src/algo" + "github.com/junegunn/fzf/src/util" ) // Set is an autocomplete set. -type Set struct { - mask []uint64 - keys [][]string - vals [][]string -} - -var ( - test [2]uint64 - idxs [256]byte -) - -const careset = " 0123456789abcdefghijklmnopqrstuvwxyz.!'" - -// Certify the size of careset at compile time. -// It must be no larger than 64. -var _ [0]struct{} = [len(careset) - 40]struct{}{} - -func init() { - // Construct the test and idxs maps. - for i, c := range []byte(careset) { - if c < 64 { - test[0] |= 1 << c - } else { - test[1] |= 1 << (c - 64) - } - idxs[c] = byte(i) - } -} - -func normalize(s string) string { - s = strings.Map(func(c rune) rune { - c = unicode.ToLower(c) - // TODO(zeph): map latin letters with diacritics to their not-diacritic - // characters; e.g. 2* pasta's unique Corazón ☆ Ardiente should have - // ó mapped to o. for now, we just special-case it. - if c == 'ó' { - c = 'o' - } - return c - }, s) - return s -} - -func filter(s string) (r uint64) { - for _, c := range s { - if c > 0x7f { - // Skip non-ASCII characters. - continue - } - if test[byte(c)/64]>>(byte(c)%64)&1 == 0 { - // Not in the care set. - continue - } - r |= 1 << idxs[byte(c)] - } - return r +type Set[V any] struct { + keys []util.Chars + vals []V } // Add associates a value with a key in the autocomplete set. // The behavior is undefined if the key already has a value. -func (s *Set) Add(key, val string) { - key = normalize(key) - m := filter(key) - i, ok := slices.BinarySearch(s.mask, m) - if !ok { - s.mask = slices.Insert(s.mask, i, m) - s.keys = slices.Insert(s.keys, i, nil) - s.vals = slices.Insert(s.vals, i, nil) +func (s *Set[V]) Add(key string, val V) { + k := util.ToChars([]byte(key)) + i, ok := slices.BinarySearchFunc(s.keys, k, func(a, b util.Chars) int { + return bytes.Compare(a.Bytes(), b.Bytes()) + }) + if ok { + s.vals[i] = val + return } - j, _ := slices.BinarySearch(s.keys[i], key) - s.keys[i] = slices.Insert(s.keys[i], j, key) - s.vals[i] = slices.Insert(s.vals[i], j, val) + s.keys = slices.Insert(s.keys, i, k) + s.vals = slices.Insert(s.vals, i, val) } // Find appends to r all values in the set with keys that key matches. -func (s *Set) Find(r []string, key string) []string { - key = normalize(key) - m := filter(key) - for i, v := range s.mask { - if m&v != m { +func (s *Set[V]) Find(r []V, key string) []V { + initFzf() + var ( + p = []rune(key) + + got []V + t []algo.Result + slab util.Slab + ) + for i := range s.keys { + res, _ := algo.FuzzyMatchV2(false, true, true, &s.keys[i], p, false, &slab) + if res.Score <= 0 { continue } - for j, k := range s.keys[i] { - if inorder(key, k) { - r = append(r, s.vals[i][j]) - } + j, _ := slices.BinarySearchFunc(t, res, func(a, b algo.Result) int { return -cmp.Compare(a.Score, b.Score) }) + // Insert after all other matches with the same score for stability. + for j < len(t) && t[j].Score == res.Score { + j++ } + t = slices.Insert(t, j, res) + got = slices.Insert(got, j, s.vals[i]) } - return r + return append(r, got...) } -// inorder checks whether each character in a appears in the same relative order in b. -func inorder(a, b string) bool { - for _, c := range a { - k := strings.IndexRune(b, c) - if k < 0 { - return false - } - _, l := utf8.DecodeRuneInString(b[k:]) - b = b[k+l:] - } - return true -} - -// Metrics gets the number of buckets in the autocomplete set and the length -// of the longest bucket. -func (s *Set) Metrics() (buckets, longest int) { - for _, b := range s.keys { - longest = max(longest, len(b)) - } - return len(s.keys), longest -} +var initFzf = sync.OnceFunc(func() { algo.Init("default") }) diff --git a/autocomplete/autocomplete_test.go b/autocomplete/autocomplete_test.go index c4a4756..664824c 100644 --- a/autocomplete/autocomplete_test.go +++ b/autocomplete/autocomplete_test.go @@ -40,6 +40,12 @@ func TestAutocomplete(t *testing.T) { search: "o", want: these("bocchi", "ryo"), }, + { + name: "unrelated", + add: these("bocchi", "ryo", "nijika", "kita"), + search: "x", + want: nil, + }, { name: "map", add: these("Corazón ☆ Ardiente"), @@ -49,7 +55,7 @@ func TestAutocomplete(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - var set autocomplete.Set + var set autocomplete.Set[string] for _, s := range c.add { set.Add(s, s) } diff --git a/go.mod b/go.mod index d567b45..458373c 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module git.sunturtle.xyz/zephyr/horsebot -go 1.25.6 +go 1.25.5 require ( git.sunturtle.xyz/zephyr/horse v0.0.0-20260123040553-4bfb06b6826f github.com/disgoorg/disgo v0.19.0-rc.15 + github.com/junegunn/fzf v0.67.0 ) require ( @@ -13,6 +14,8 @@ require ( github.com/disgoorg/snowflake/v2 v2.0.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/klauspost/compress v1.18.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/sys v0.39.0 // indirect diff --git a/go.sum b/go.sum index 97ff242..645104c 100644 --- a/go.sum +++ b/go.sum @@ -12,16 +12,23 @@ github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzg github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/junegunn/fzf v0.67.0 h1:naiOdIkV5/ZCfHgKQIV/f5YDWowl95G6yyOQqW8FeSo= +github.com/junegunn/fzf v0.67.0/go.mod h1:xlXX2/rmsccKQUnr9QOXPDi5DyV9cM0UjKy/huScBeE= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxEmTbaqt1hkJ/t6skqEGYiMag343ucI= github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index 896f700..fb09d00 100644 --- a/main.go +++ b/main.go @@ -109,13 +109,6 @@ func main() { stop() } slog.Info("ready") - go func() { - // Preload autocomplete in a separate goroutine. - slog.Info("begin computing skill autocomplete") - set := skillGlobalAuto() - buckets, longest := set.Metrics() - slog.Info("done computing skill autocomplete", slog.Int("buckets", buckets), slog.Int("longest", longest)) - }() <-ctx.Done() stop() diff --git a/skill.go b/skill.go index 90723e1..95ef371 100644 --- a/skill.go +++ b/skill.go @@ -118,8 +118,8 @@ func isDebuff(s horse.Skill) bool { return false } -var skillGlobalAuto = sync.OnceValue(func() *autocomplete.Set { - var set autocomplete.Set +var skillGlobalAuto = sync.OnceValue(func() *autocomplete.Set[string] { + var set autocomplete.Set[string] for _, id := range global.OrderedSkills { s := global.AllSkills[id] set.Add(s.Name, s.Name)