feat(ui): improve file completion ranking algorithm

wanghuaiyu@qiniu.com created

Implement custom ranking algorithm for file completions to provide
better matching results based on user input patterns.

Changes:
- Add intelligent path hint detection to distinguish between filename
  and filepath queries
- Implement weighted scoring system that balances fuzzy matching with
  prefix/contains bonuses
- Support both full path and basename matching with appropriate weights
- Add depth penalty adjustment based on query type
- Select match highlights based on weighted contribution
- Add comprehensive test coverage for ranking logic

Change summary

internal/ui/completions/completions.go      | 255 +++++++++++++++++++++-
internal/ui/completions/completions_test.go |  99 ++++++++
2 files changed, 339 insertions(+), 15 deletions(-)

Detailed changes

internal/ui/completions/completions.go 🔗

@@ -5,6 +5,7 @@ import (
 	"slices"
 	"strings"
 	"sync"
+	"unicode"
 
 	"charm.land/bubbles/v2/key"
 	tea "charm.land/bubbletea/v2"
@@ -14,6 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/ordered"
+	"github.com/sahilm/fuzzy"
 )
 
 const (
@@ -21,6 +23,20 @@ const (
 	maxHeight = 10
 	minWidth  = 10
 	maxWidth  = 100
+
+	fullMatchWeight = 1_000
+	baseMatchWeight = 300
+
+	pathPrefixBonus       = 5_000
+	pathContainsBonus     = 2_000
+	pathContainsHintBonus = 2_500
+	basePrefixBonus       = 1_500
+	baseContainsBonus     = 500
+	basePrefixHintBonus   = 300
+	baseContainsHintBonus = 120
+
+	depthPenaltyDefault  = 20
+	depthPenaltyPathHint = 5
 )
 
 // SelectionMsg is sent when a completion is selected.
@@ -58,6 +74,11 @@ type Completions struct {
 	normalStyle  lipgloss.Style
 	focusedStyle lipgloss.Style
 	matchStyle   lipgloss.Style
+
+	items    []*CompletionItem
+	filtered []*CompletionItem
+	paths    []string
+	bases    []string
 }
 
 // New creates a new completions component.
@@ -87,7 +108,7 @@ func (c *Completions) Query() string {
 
 // Size returns the visible size of the popup.
 func (c *Completions) Size() (width, height int) {
-	visible := len(c.list.FilteredItems())
+	visible := len(c.filtered)
 	return c.width, min(visible, c.height)
 }
 
@@ -114,7 +135,7 @@ func (c *Completions) Open(depth, limit int) tea.Cmd {
 
 // SetItems sets the files and MCP resources and rebuilds the merged list.
 func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
-	items := make([]list.FilterableItem, 0, len(files)+len(resources))
+	items := make([]*CompletionItem, 0, len(files)+len(resources))
 
 	// Add files first.
 	for _, file := range files {
@@ -142,8 +163,18 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
 
 	c.open = true
 	c.query = ""
-	c.list.SetItems(items...)
-	c.list.SetFilter("")
+	c.items = items
+	c.paths = make([]string, len(items))
+	c.bases = make([]string, len(items))
+	for i, item := range items {
+		path := item.Filter()
+		c.paths[i] = path
+		c.bases[i] = pathBase(path)
+	}
+	c.filtered = c.rank(queryContext{
+		query: c.query,
+	})
+	c.setVisibleItems(c.filtered)
 	c.list.Focus()
 
 	c.width = maxWidth
@@ -171,13 +202,16 @@ func (c *Completions) Filter(query string) {
 	}
 
 	c.query = query
-	c.list.SetFilter(query)
+	c.filtered = c.rank(queryContext{
+		query: query,
+	})
+	c.setVisibleItems(c.filtered)
 
 	c.updateSize()
 }
 
 func (c *Completions) updateSize() {
-	items := c.list.FilteredItems()
+	items := c.filtered
 	start, end := c.list.VisibleItemIndices()
 	width := 0
 	for i := start; i <= end; i++ {
@@ -197,7 +231,7 @@ func (c *Completions) updateSize() {
 
 // HasItems returns whether there are visible items.
 func (c *Completions) HasItems() bool {
-	return len(c.list.FilteredItems()) > 0
+	return len(c.filtered) > 0
 }
 
 // Update handles key events for the completions.
@@ -236,7 +270,7 @@ func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
 
 // selectPrev selects the previous item with circular navigation.
 func (c *Completions) selectPrev() {
-	items := c.list.FilteredItems()
+	items := c.filtered
 	if len(items) == 0 {
 		return
 	}
@@ -248,7 +282,7 @@ func (c *Completions) selectPrev() {
 
 // selectNext selects the next item with circular navigation.
 func (c *Completions) selectNext() {
-	items := c.list.FilteredItems()
+	items := c.filtered
 	if len(items) == 0 {
 		return
 	}
@@ -260,7 +294,7 @@ func (c *Completions) selectNext() {
 
 // selectCurrent returns a command with the currently selected item.
 func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
-	items := c.list.FilteredItems()
+	items := c.filtered
 	if len(items) == 0 {
 		return nil
 	}
@@ -270,10 +304,7 @@ func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
 		return nil
 	}
 
-	item, ok := items[selected].(*CompletionItem)
-	if !ok {
-		return nil
-	}
+	item := items[selected]
 
 	if !keepOpen {
 		c.open = false
@@ -301,7 +332,7 @@ func (c *Completions) Render() string {
 		return ""
 	}
 
-	items := c.list.FilteredItems()
+	items := c.filtered
 	if len(items) == 0 {
 		return ""
 	}
@@ -309,6 +340,200 @@ func (c *Completions) Render() string {
 	return c.list.Render()
 }
 
+func (c *Completions) setVisibleItems(items []*CompletionItem) {
+	filterables := make([]list.FilterableItem, 0, len(items))
+	for _, item := range items {
+		filterables = append(filterables, item)
+	}
+	c.list.SetItems(filterables...)
+}
+
+type queryContext struct {
+	query string
+}
+
+type rankedItem struct {
+	item  *CompletionItem
+	score int
+}
+
+// rank uses path-first fuzzy ordering with basename as a secondary boost.
+func (c *Completions) rank(ctx queryContext) []*CompletionItem {
+	query := strings.TrimSpace(ctx.query)
+	if query == "" {
+		for _, item := range c.items {
+			item.SetMatch(fuzzy.Match{})
+		}
+		return c.items
+	}
+
+	fullMatches := matchIndex(query, c.paths)
+	baseMatches := matchIndex(query, c.bases)
+	allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches))
+	for idx := range fullMatches {
+		allIndexes[idx] = struct{}{}
+	}
+	for idx := range baseMatches {
+		allIndexes[idx] = struct{}{}
+	}
+
+	queryLower := strings.ToLower(query)
+	pathHint := hasPathHint(query)
+	ranked := make([]rankedItem, 0, len(allIndexes))
+	for idx := range allIndexes {
+		path := c.paths[idx]
+		pathLower := strings.ToLower(path)
+		baseLower := strings.ToLower(c.bases[idx])
+
+		fullMatch, hasFullMatch := fullMatches[idx]
+		baseMatch, hasBaseMatch := baseMatches[idx]
+
+		pathPrefix := strings.HasPrefix(pathLower, queryLower)
+		pathContains := strings.Contains(pathLower, queryLower)
+		basePrefix := strings.HasPrefix(baseLower, queryLower)
+		baseContains := strings.Contains(baseLower, queryLower)
+
+		score := 0
+		if hasFullMatch {
+			score += fullMatch.Score * fullMatchWeight
+		}
+		if hasBaseMatch {
+			score += baseMatch.Score * baseMatchWeight
+		}
+		if pathPrefix {
+			score += pathPrefixBonus
+		}
+		if pathContains {
+			score += pathContainsBonus
+		}
+		if pathHint {
+			if pathContains {
+				score += pathContainsHintBonus
+			}
+			if basePrefix {
+				score += basePrefixHintBonus
+			}
+			if baseContains {
+				score += baseContainsHintBonus
+			}
+		} else {
+			if basePrefix {
+				score += basePrefixBonus
+			}
+			if baseContains {
+				score += baseContainsBonus
+			}
+		}
+
+		depthPenalty := depthPenaltyDefault
+		if pathHint {
+			depthPenalty = depthPenaltyPathHint
+		}
+		score -= strings.Count(path, "/") * depthPenalty
+		score -= ansi.StringWidth(path)
+
+		if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) {
+			c.items[idx].SetMatch(fullMatch)
+		} else if hasBaseMatch {
+			c.items[idx].SetMatch(remapMatchToPath(baseMatch, path))
+		} else {
+			c.items[idx].SetMatch(fuzzy.Match{})
+		}
+
+		ranked = append(ranked, rankedItem{
+			item:  c.items[idx],
+			score: score,
+		})
+	}
+
+	slices.SortStableFunc(ranked, func(a, b rankedItem) int {
+		if a.score != b.score {
+			return b.score - a.score
+		}
+		return strings.Compare(a.item.Text(), b.item.Text())
+	})
+
+	result := make([]*CompletionItem, 0, len(ranked))
+	for _, item := range ranked {
+		result = append(result, item.item)
+	}
+	return result
+}
+
+func matchIndex(query string, values []string) map[int]fuzzy.Match {
+	source := stringSource(values)
+	matches := fuzzy.FindFrom(query, source)
+	result := make(map[int]fuzzy.Match, len(matches))
+	for _, match := range matches {
+		result[match.Index] = match
+	}
+	return result
+}
+
+type stringSource []string
+
+func (s stringSource) Len() int {
+	return len(s)
+}
+
+func (s stringSource) String(i int) string {
+	return s[i]
+}
+
+func pathBase(value string) string {
+	trimmed := strings.TrimRight(value, `/\`)
+	if trimmed == "" {
+		return value
+	}
+	idx := strings.LastIndexAny(trimmed, `/\`)
+	if idx < 0 {
+		return trimmed
+	}
+	return trimmed[idx+1:]
+}
+
+func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
+	base := pathBase(fullPath)
+	if base == "" {
+		return match
+	}
+	offset := len(fullPath) - len(base)
+	remapped := make([]int, 0, len(match.MatchedIndexes))
+	for _, idx := range match.MatchedIndexes {
+		remapped = append(remapped, offset+idx)
+	}
+	match.MatchedIndexes = remapped
+	return match
+}
+
+func hasPathHint(query string) bool {
+	if strings.Contains(query, "/") || strings.Contains(query, "\\") {
+		return true
+	}
+
+	lastDot := strings.LastIndex(query, ".")
+	if lastDot < 0 || lastDot == len(query)-1 {
+		return false
+	}
+
+	suffix := query[lastDot+1:]
+	if len(suffix) > 12 {
+		return false
+	}
+
+	hasLetter := false
+	for _, r := range suffix {
+		if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
+			return false
+		}
+		if unicode.IsLetter(r) {
+			hasLetter = true
+		}
+	}
+
+	return hasLetter
+}
+
 func loadFiles(depth, limit int) []FileCompletionValue {
 	files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
 	slices.Sort(files)

internal/ui/completions/completions_test.go 🔗

@@ -0,0 +1,99 @@
+package completions
+
+import (
+	"testing"
+
+	"charm.land/lipgloss/v2"
+	"github.com/sahilm/fuzzy"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRankPrefersStrongBasenameMatch(t *testing.T) {
+	t.Parallel()
+
+	c := &Completions{
+		items: []*CompletionItem{
+			NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+			NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+			NewCompletionItem("internal/user_service.go", FileCompletionValue{Path: "internal/user_service.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+		},
+		paths: []string{"internal/ui/chat/search.go", "user.go", "internal/user_service.go"},
+		bases: []string{"search.go", "user.go", "user_service.go"},
+	}
+
+	ranked := c.rank(queryContext{query: "user"})
+	require.NotEmpty(t, ranked)
+	require.Equal(t, "user.go", ranked[0].Text())
+}
+
+func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
+	t.Parallel()
+
+	c := &Completions{
+		items: []*CompletionItem{
+			NewCompletionItem("b/user.go", FileCompletionValue{Path: "b/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+			NewCompletionItem("a/user.go", FileCompletionValue{Path: "a/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+		},
+		paths: []string{"b/user.go", "a/user.go"},
+		bases: []string{"user.go", "user.go"},
+	}
+
+	ranked := c.rank(queryContext{query: ""})
+	require.Len(t, ranked, 2)
+	require.Equal(t, "b/user.go", ranked[0].Text())
+	require.Equal(t, "a/user.go", ranked[1].Text())
+}
+
+func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
+	t.Parallel()
+
+	c := &Completions{
+		items: []*CompletionItem{
+			NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+			NewCompletionItem("internal/user.go", FileCompletionValue{Path: "internal/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+			NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+		},
+		paths: []string{"user.go", "internal/user.go", "internal/ui/chat/search.go"},
+		bases: []string{"user.go", "user.go", "search.go"},
+	}
+
+	ranked := c.rank(queryContext{query: "internal/u"})
+	require.NotEmpty(t, ranked)
+	require.Equal(t, "internal/user.go", ranked[0].Text())
+}
+
+func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
+	t.Parallel()
+
+	c := &Completions{
+		items: []*CompletionItem{
+			NewCompletionItem("docs/go-guide.md", FileCompletionValue{Path: "docs/go-guide.md"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+			NewCompletionItem("src/user.go", FileCompletionValue{Path: "src/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+		},
+		paths: []string{"docs/go-guide.md", "src/user.go"},
+		bases: []string{"go-guide.md", "user.go"},
+	}
+
+	ranked := c.rank(queryContext{query: ".go"})
+	require.NotEmpty(t, ranked)
+	require.Equal(t, "src/user.go", ranked[0].Text())
+}
+
+func TestRemapMatchToPath(t *testing.T) {
+	t.Parallel()
+
+	match := remapMatchToPath(
+		fuzzy.Match{MatchedIndexes: []int{0, 1, 2}},
+		"internal/user.go",
+	)
+	require.Equal(t, []int{9, 10, 11}, match.MatchedIndexes)
+}
+
+func TestHasPathHint(t *testing.T) {
+	t.Parallel()
+
+	require.True(t, hasPathHint("internal/u"))
+	require.True(t, hasPathHint("main.go"))
+	require.False(t, hasPathHint("v0.1"))
+	require.False(t, hasPathHint("main"))
+}