on main help sign in
feat(ui): improve file completion ranking algorithm
0fcf79bwanghuaiyu@qiniu.com
internal/ui/completions/completions.go+240−15
@@ -5,6 +5,7 @@ import (
5 "slices"
5 "slices"
6 "strings"
6 "strings"
7 "sync"
7 "sync"
8+ "unicode"
8
9
9 "charm.land/bubbles/v2/key"
10 "charm.land/bubbles/v2/key"
10 tea "charm.land/bubbletea/v2"
11 tea "charm.land/bubbletea/v2"
@@ -14,6 +15,7 @@ import (
14 "github.com/charmbracelet/crush/internal/ui/list"
15 "github.com/charmbracelet/crush/internal/ui/list"
15 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/exp/ordered"
17 "github.com/charmbracelet/x/exp/ordered"
18+ "github.com/sahilm/fuzzy"
17)
19)
18
20
19const (
21const (
@@ -21,6 +23,20 @@ const (
21 maxHeight = 10
23 maxHeight = 10
22 minWidth = 10
24 minWidth = 10
23 maxWidth = 100
25 maxWidth = 100
26+
27+ fullMatchWeight = 1_000
28+ baseMatchWeight = 300
29+
30+ pathPrefixBonus = 5_000
31+ pathContainsBonus = 2_000
32+ pathContainsHintBonus = 2_500
33+ basePrefixBonus = 1_500
34+ baseContainsBonus = 500
35+ basePrefixHintBonus = 300
36+ baseContainsHintBonus = 120
37+
38+ depthPenaltyDefault = 20
39+ depthPenaltyPathHint = 5
24)
40)
25
41
26// SelectionMsg is sent when a completion is selected.
42// SelectionMsg is sent when a completion is selected.
@@ -58,6 +74,11 @@ type Completions struct {
58 normalStyle lipgloss.Style
74 normalStyle lipgloss.Style
59 focusedStyle lipgloss.Style
75 focusedStyle lipgloss.Style
60 matchStyle lipgloss.Style
76 matchStyle lipgloss.Style
77+
78+ items []*CompletionItem
79+ filtered []*CompletionItem
80+ paths []string
81+ bases []string
61}
82}
62
83
63// New creates a new completions component.
84// New creates a new completions component.
@@ -87,7 +108,7 @@ func (c *Completions) Query() string {
87
108
88// Size returns the visible size of the popup.
109// Size returns the visible size of the popup.
89func (c *Completions) Size() (width, height int) {
110func (c *Completions) Size() (width, height int) {
90− visible := len(c.list.FilteredItems())
111+ visible := len(c.filtered)
91 return c.width, min(visible, c.height)
112 return c.width, min(visible, c.height)
92}
113}
93
114
@@ -114,7 +135,7 @@ func (c *Completions) Open(depth, limit int) tea.Cmd {
114
135
115// SetItems sets the files and MCP resources and rebuilds the merged list.
136// SetItems sets the files and MCP resources and rebuilds the merged list.
116func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
137func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
117− items := make([]list.FilterableItem, 0, len(files)+len(resources))
138+ items := make([]*CompletionItem, 0, len(files)+len(resources))
118
139
119 // Add files first.
140 // Add files first.
120 for _, file := range files {
141 for _, file := range files {
@@ -142,8 +163,18 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
142
163
143 c.open = true
164 c.open = true
144 c.query = ""
165 c.query = ""
145− c.list.SetItems(items...)
166+ c.items = items
146− c.list.SetFilter("")
167+ c.paths = make([]string, len(items))
168+ c.bases = make([]string, len(items))
169+ for i, item := range items {
170+ path := item.Filter()
171+ c.paths[i] = path
172+ c.bases[i] = pathBase(path)
173+ }
174+ c.filtered = c.rank(queryContext{
175+ query: c.query,
176+ })
177+ c.setVisibleItems(c.filtered)
147 c.list.Focus()
178 c.list.Focus()
148
179
149 c.width = maxWidth
180 c.width = maxWidth
@@ -171,13 +202,16 @@ func (c *Completions) Filter(query string) {
171 }
202 }
172
203
173 c.query = query
204 c.query = query
174− c.list.SetFilter(query)
205+ c.filtered = c.rank(queryContext{
206+ query: query,
207+ })
208+ c.setVisibleItems(c.filtered)
175
209
176 c.updateSize()
210 c.updateSize()
177}
211}
178
212
179func (c *Completions) updateSize() {
213func (c *Completions) updateSize() {
180− items := c.list.FilteredItems()
214+ items := c.filtered
181 start, end := c.list.VisibleItemIndices()
215 start, end := c.list.VisibleItemIndices()
182 width := 0
216 width := 0
183 for i := start; i <= end; i++ {
217 for i := start; i <= end; i++ {
@@ -197,7 +231,7 @@ func (c *Completions) updateSize() {
197
231
198// HasItems returns whether there are visible items.
232// HasItems returns whether there are visible items.
199func (c *Completions) HasItems() bool {
233func (c *Completions) HasItems() bool {
200− return len(c.list.FilteredItems()) > 0
234+ return len(c.filtered) > 0
201}
235}
202
236
203// Update handles key events for the completions.
237// Update handles key events for the completions.
@@ -236,7 +270,7 @@ func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
236
270
237// selectPrev selects the previous item with circular navigation.
271// selectPrev selects the previous item with circular navigation.
238func (c *Completions) selectPrev() {
272func (c *Completions) selectPrev() {
239− items := c.list.FilteredItems()
273+ items := c.filtered
240 if len(items) == 0 {
274 if len(items) == 0 {
241 return
275 return
242 }
276 }
@@ -248,7 +282,7 @@ func (c *Completions) selectPrev() {
248
282
249// selectNext selects the next item with circular navigation.
283// selectNext selects the next item with circular navigation.
250func (c *Completions) selectNext() {
284func (c *Completions) selectNext() {
251− items := c.list.FilteredItems()
285+ items := c.filtered
252 if len(items) == 0 {
286 if len(items) == 0 {
253 return
287 return
254 }
288 }
@@ -260,7 +294,7 @@ func (c *Completions) selectNext() {
260
294
261// selectCurrent returns a command with the currently selected item.
295// selectCurrent returns a command with the currently selected item.
262func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
296func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
263− items := c.list.FilteredItems()
297+ items := c.filtered
264 if len(items) == 0 {
298 if len(items) == 0 {
265 return nil
299 return nil
266 }
300 }
@@ -270,10 +304,7 @@ func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
270 return nil
304 return nil
271 }
305 }
272
306
273− item, ok := items[selected].(*CompletionItem)
307+ item := items[selected]
274− if !ok {
275− return nil
276− }
277
308
278 if !keepOpen {
309 if !keepOpen {
279 c.open = false
310 c.open = false
@@ -301,7 +332,7 @@ func (c *Completions) Render() string {
301 return ""
332 return ""
302 }
333 }
303
334
304− items := c.list.FilteredItems()
335+ items := c.filtered
305 if len(items) == 0 {
336 if len(items) == 0 {
306 return ""
337 return ""
307 }
338 }
@@ -309,6 +340,200 @@ func (c *Completions) Render() string {
309 return c.list.Render()
340 return c.list.Render()
310}
341}
311
342
343+func (c *Completions) setVisibleItems(items []*CompletionItem) {
344+ filterables := make([]list.FilterableItem, 0, len(items))
345+ for _, item := range items {
346+ filterables = append(filterables, item)
347+ }
348+ c.list.SetItems(filterables...)
349+}
350+
351+type queryContext struct {
352+ query string
353+}
354+
355+type rankedItem struct {
356+ item *CompletionItem
357+ score int
358+}
359+
360+// rank uses path-first fuzzy ordering with basename as a secondary boost.
361+func (c *Completions) rank(ctx queryContext) []*CompletionItem {
362+ query := strings.TrimSpace(ctx.query)
363+ if query == "" {
364+ for _, item := range c.items {
365+ item.SetMatch(fuzzy.Match{})
366+ }
367+ return c.items
368+ }
369+
370+ fullMatches := matchIndex(query, c.paths)
371+ baseMatches := matchIndex(query, c.bases)
372+ allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches))
373+ for idx := range fullMatches {
374+ allIndexes[idx] = struct{}{}
375+ }
376+ for idx := range baseMatches {
377+ allIndexes[idx] = struct{}{}
378+ }
379+
380+ queryLower := strings.ToLower(query)
381+ pathHint := hasPathHint(query)
382+ ranked := make([]rankedItem, 0, len(allIndexes))
383+ for idx := range allIndexes {
384+ path := c.paths[idx]
385+ pathLower := strings.ToLower(path)
386+ baseLower := strings.ToLower(c.bases[idx])
387+
388+ fullMatch, hasFullMatch := fullMatches[idx]
389+ baseMatch, hasBaseMatch := baseMatches[idx]
390+
391+ pathPrefix := strings.HasPrefix(pathLower, queryLower)
392+ pathContains := strings.Contains(pathLower, queryLower)
393+ basePrefix := strings.HasPrefix(baseLower, queryLower)
394+ baseContains := strings.Contains(baseLower, queryLower)
395+
396+ score := 0
397+ if hasFullMatch {
398+ score += fullMatch.Score * fullMatchWeight
399+ }
400+ if hasBaseMatch {
401+ score += baseMatch.Score * baseMatchWeight
402+ }
403+ if pathPrefix {
404+ score += pathPrefixBonus
405+ }
406+ if pathContains {
407+ score += pathContainsBonus
408+ }
409+ if pathHint {
410+ if pathContains {
411+ score += pathContainsHintBonus
412+ }
413+ if basePrefix {
414+ score += basePrefixHintBonus
415+ }
416+ if baseContains {
417+ score += baseContainsHintBonus
418+ }
419+ } else {
420+ if basePrefix {
421+ score += basePrefixBonus
422+ }
423+ if baseContains {
424+ score += baseContainsBonus
425+ }
426+ }
427+
428+ depthPenalty := depthPenaltyDefault
429+ if pathHint {
430+ depthPenalty = depthPenaltyPathHint
431+ }
432+ score -= strings.Count(path, "/") * depthPenalty
433+ score -= ansi.StringWidth(path)
434+
435+ if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) {
436+ c.items[idx].SetMatch(fullMatch)
437+ } else if hasBaseMatch {
438+ c.items[idx].SetMatch(remapMatchToPath(baseMatch, path))
439+ } else {
440+ c.items[idx].SetMatch(fuzzy.Match{})
441+ }
442+
443+ ranked = append(ranked, rankedItem{
444+ item: c.items[idx],
445+ score: score,
446+ })
447+ }
448+
449+ slices.SortStableFunc(ranked, func(a, b rankedItem) int {
450+ if a.score != b.score {
451+ return b.score - a.score
452+ }
453+ return strings.Compare(a.item.Text(), b.item.Text())
454+ })
455+
456+ result := make([]*CompletionItem, 0, len(ranked))
457+ for _, item := range ranked {
458+ result = append(result, item.item)
459+ }
460+ return result
461+}
462+
463+func matchIndex(query string, values []string) map[int]fuzzy.Match {
464+ source := stringSource(values)
465+ matches := fuzzy.FindFrom(query, source)
466+ result := make(map[int]fuzzy.Match, len(matches))
467+ for _, match := range matches {
468+ result[match.Index] = match
469+ }
470+ return result
471+}
472+
473+type stringSource []string
474+
475+func (s stringSource) Len() int {
476+ return len(s)
477+}
478+
479+func (s stringSource) String(i int) string {
480+ return s[i]
481+}
482+
483+func pathBase(value string) string {
484+ trimmed := strings.TrimRight(value, `/\`)
485+ if trimmed == "" {
486+ return value
487+ }
488+ idx := strings.LastIndexAny(trimmed, `/\`)
489+ if idx < 0 {
490+ return trimmed
491+ }
492+ return trimmed[idx+1:]
493+}
494+
495+func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
496+ base := pathBase(fullPath)
497+ if base == "" {
498+ return match
499+ }
500+ offset := len(fullPath) - len(base)
501+ remapped := make([]int, 0, len(match.MatchedIndexes))
502+ for _, idx := range match.MatchedIndexes {
503+ remapped = append(remapped, offset+idx)
504+ }
505+ match.MatchedIndexes = remapped
506+ return match
507+}
508+
509+func hasPathHint(query string) bool {
510+ if strings.Contains(query, "/") || strings.Contains(query, "\\") {
511+ return true
512+ }
513+
514+ lastDot := strings.LastIndex(query, ".")
515+ if lastDot < 0 || lastDot == len(query)-1 {
516+ return false
517+ }
518+
519+ suffix := query[lastDot+1:]
520+ if len(suffix) > 12 {
521+ return false
522+ }
523+
524+ hasLetter := false
525+ for _, r := range suffix {
526+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
527+ return false
528+ }
529+ if unicode.IsLetter(r) {
530+ hasLetter = true
531+ }
532+ }
533+
534+ return hasLetter
535+}
536+
312func loadFiles(depth, limit int) []FileCompletionValue {
537func loadFiles(depth, limit int) []FileCompletionValue {
313 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
538 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
314 slices.Sort(files)
539 slices.Sort(files)
internal/ui/completions/completions_test.gonew file+99
@@ -0,0 +1,99 @@
1+package completions
2+
3+import (
4+ "testing"
5+
6+ "charm.land/lipgloss/v2"
7+ "github.com/sahilm/fuzzy"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRankPrefersStrongBasenameMatch(t *testing.T) {
12+ t.Parallel()
13+
14+ c := &Completions{
15+ items: []*CompletionItem{
16+ NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
17+ NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
18+ NewCompletionItem("internal/user_service.go", FileCompletionValue{Path: "internal/user_service.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
19+ },
20+ paths: []string{"internal/ui/chat/search.go", "user.go", "internal/user_service.go"},
21+ bases: []string{"search.go", "user.go", "user_service.go"},
22+ }
23+
24+ ranked := c.rank(queryContext{query: "user"})
25+ require.NotEmpty(t, ranked)
26+ require.Equal(t, "user.go", ranked[0].Text())
27+}
28+
29+func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
30+ t.Parallel()
31+
32+ c := &Completions{
33+ items: []*CompletionItem{
34+ NewCompletionItem("b/user.go", FileCompletionValue{Path: "b/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
35+ NewCompletionItem("a/user.go", FileCompletionValue{Path: "a/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
36+ },
37+ paths: []string{"b/user.go", "a/user.go"},
38+ bases: []string{"user.go", "user.go"},
39+ }
40+
41+ ranked := c.rank(queryContext{query: ""})
42+ require.Len(t, ranked, 2)
43+ require.Equal(t, "b/user.go", ranked[0].Text())
44+ require.Equal(t, "a/user.go", ranked[1].Text())
45+}
46+
47+func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
48+ t.Parallel()
49+
50+ c := &Completions{
51+ items: []*CompletionItem{
52+ NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
53+ NewCompletionItem("internal/user.go", FileCompletionValue{Path: "internal/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
54+ NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
55+ },
56+ paths: []string{"user.go", "internal/user.go", "internal/ui/chat/search.go"},
57+ bases: []string{"user.go", "user.go", "search.go"},
58+ }
59+
60+ ranked := c.rank(queryContext{query: "internal/u"})
61+ require.NotEmpty(t, ranked)
62+ require.Equal(t, "internal/user.go", ranked[0].Text())
63+}
64+
65+func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
66+ t.Parallel()
67+
68+ c := &Completions{
69+ items: []*CompletionItem{
70+ NewCompletionItem("docs/go-guide.md", FileCompletionValue{Path: "docs/go-guide.md"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
71+ NewCompletionItem("src/user.go", FileCompletionValue{Path: "src/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
72+ },
73+ paths: []string{"docs/go-guide.md", "src/user.go"},
74+ bases: []string{"go-guide.md", "user.go"},
75+ }
76+
77+ ranked := c.rank(queryContext{query: ".go"})
78+ require.NotEmpty(t, ranked)
79+ require.Equal(t, "src/user.go", ranked[0].Text())
80+}
81+
82+func TestRemapMatchToPath(t *testing.T) {
83+ t.Parallel()
84+
85+ match := remapMatchToPath(
86+ fuzzy.Match{MatchedIndexes: []int{0, 1, 2}},
87+ "internal/user.go",
88+ )
89+ require.Equal(t, []int{9, 10, 11}, match.MatchedIndexes)
90+}
91+
92+func TestHasPathHint(t *testing.T) {
93+ t.Parallel()
94+
95+ require.True(t, hasPathHint("internal/u"))
96+ require.True(t, hasPathHint("main.go"))
97+ require.False(t, hasPathHint("v0.1"))
98+ require.False(t, hasPathHint("main"))
99+}