diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index bb9881d9779d085a76d3d9aafe21c0e1565d3da7..b819e8b974513971e9ddfdc7f04d068b433d685a 100644 --- a/internal/ui/completions/completions.go +++ b/internal/ui/completions/completions.go @@ -2,10 +2,10 @@ package completions import ( "cmp" + "path/filepath" "slices" "strings" "sync" - "unicode" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -15,7 +15,6 @@ import ( "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/ordered" - "github.com/sahilm/fuzzy" ) const ( @@ -24,32 +23,10 @@ const ( minWidth = 10 maxWidth = 100 - // Scoring weights for fuzzy matching. - // Full path fuzzy match contributes most to the score. - fullMatchWeight = 1_000 - // Basename fuzzy match contributes less than full path. - baseMatchWeight = 300 - - // Bonus points for exact matches (case-insensitive). - // Path prefix match (e.g., "src/" matches "src/main.go") gets highest bonus. - pathPrefixBonus = 5_000 - // Path contains match (e.g., "main" matches "src/main.go"). - pathContainsBonus = 2_000 - // Additional bonus when path hint is detected (user typed "/" or file extension). - pathContainsHintBonus = 2_500 - // Basename prefix match (e.g., "main" matches "main.go"). - basePrefixBonus = 1_500 - // Basename contains match (e.g., "mai" matches "main.go"). - baseContainsBonus = 500 - // Smaller bonuses when path hint is detected. - basePrefixHintBonus = 300 - baseContainsHintBonus = 120 - - // Penalties for deeply nested files to favor shallow matches. - // Default penalty per directory level (e.g., "a/b/c" has 2 levels). - depthPenaltyDefault = 20 - // Reduced penalty when user explicitly queries a path (typed "/" or extension). - depthPenaltyPathHint = 5 + tierExactName = iota + tierPrefixName + tierPathSegment + tierFallback ) // SelectionMsg is sent when a completion is selected. @@ -88,11 +65,34 @@ type Completions struct { focusedStyle lipgloss.Style matchStyle lipgloss.Style - // Custom ranking state (replaces list.FilterableList's built-in filtering). - items []*CompletionItem // All completion items. - filtered []*CompletionItem // Filtered and ranked items based on query. - paths []string // Pre-computed full paths for matching. - bases []string // Pre-computed basenames for matching. + allItems []list.FilterableItem + filtered []list.FilterableItem +} + +type namePriorityRule struct { + tier int + match func(pathLower, baseLower, stemLower, queryLower string) bool +} + +var namePriorityRules = []namePriorityRule{ + { + tier: tierExactName, + match: func(_ string, baseLower, stemLower, queryLower string) bool { + return baseLower == queryLower || stemLower == queryLower + }, + }, + { + tier: tierPrefixName, + match: func(_ string, baseLower, _ string, queryLower string) bool { + return strings.HasPrefix(baseLower, queryLower) + }, + }, + { + tier: tierPathSegment, + match: func(pathLower, _ string, _ string, queryLower string) bool { + return hasPathSegment(pathLower, queryLower) + }, + }, } // New creates a new completions component. @@ -149,7 +149,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([]*CompletionItem, 0, len(files)+len(resources)) + items := make([]list.FilterableItem, 0, len(files)+len(resources)) // Add files first. for _, file := range files { @@ -177,20 +177,10 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource c.open = true c.query = "" - c.items = items - // Pre-compute paths and basenames for efficient fuzzy matching. - 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) - } - // Perform initial ranking with empty query (returns all items). - c.filtered = c.rank(queryContext{ - query: c.query, - }) - c.setVisibleItems(c.filtered) + c.allItems = items + c.filtered = append([]list.FilterableItem(nil), items...) + c.list.SetItems(c.filtered...) + c.list.SetFilter("") c.list.Focus() c.width = maxWidth @@ -218,15 +208,65 @@ func (c *Completions) Filter(query string) { } c.query = query - // Apply custom ranking algorithm instead of list's built-in filtering. - c.filtered = c.rank(queryContext{ - query: query, - }) - c.setVisibleItems(c.filtered) + c.applyNamePriorityFilter(query) c.updateSize() } +func (c *Completions) applyNamePriorityFilter(query string) { + if query == "" { + c.filtered = append([]list.FilterableItem(nil), c.allItems...) + c.list.SetItems(c.filtered...) + return + } + + c.list.SetItems(c.allItems...) + c.list.SetFilter(query) + raw := c.list.FilteredItems() + filtered := make([]list.FilterableItem, 0, len(raw)) + for _, item := range raw { + filterable, ok := item.(list.FilterableItem) + if !ok { + continue + } + filtered = append(filtered, filterable) + } + + queryLower := strings.ToLower(strings.TrimSpace(query)) + slices.SortStableFunc(filtered, func(a, b list.FilterableItem) int { + return namePriorityTier(a.Filter(), queryLower) - namePriorityTier(b.Filter(), queryLower) + }) + c.filtered = filtered + c.list.SetItems(c.filtered...) +} + +func namePriorityTier(path, queryLower string) int { + if queryLower == "" { + return tierFallback + } + + pathLower := strings.ToLower(path) + baseLower := strings.ToLower(filepath.Base(strings.ReplaceAll(path, `\`, `/`))) + stemLower := strings.TrimSuffix(baseLower, filepath.Ext(baseLower)) + for _, rule := range namePriorityRules { + if rule.match(pathLower, baseLower, stemLower, queryLower) { + return rule.tier + } + } + return tierFallback +} + +func hasPathSegment(pathLower, queryLower string) bool { + for _, part := range strings.FieldsFunc(pathLower, func(r rune) bool { + return r == '/' || r == '\\' + }) { + if part == queryLower { + return true + } + } + return false +} + func (c *Completions) updateSize() { items := c.filtered start, end := c.list.VisibleItemIndices() @@ -321,7 +361,10 @@ func (c *Completions) selectCurrent(keepOpen bool) tea.Msg { return nil } - item := items[selected] + item, ok := items[selected].(*CompletionItem) + if !ok { + return nil + } if !keepOpen { c.open = false @@ -354,266 +397,7 @@ func (c *Completions) Render() string { return "" } - 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. -// -// Ranking strategy: -// 1. Perform fuzzy matching on both full paths and basenames. -// 2. Apply bonus points for exact prefix/contains matches. -// 3. Adjust bonuses based on whether query contains path hints (/, \, or file extension). -// 4. Apply depth penalty to favor shallower files. -// 5. Sort by score (descending), then alphabetically. -// -// Example scoring for query "main.go": -// - "main.go" (root): high fuzzy + pathPrefix + basePrefix + low depth penalty -// - "src/main.go": high fuzzy + pathContains + basePrefix + moderate depth penalty -// - "test/helper/main.go": high fuzzy + pathContains + basePrefix + high depth penalty -func (c *Completions) rank(ctx queryContext) []*CompletionItem { - query := strings.TrimSpace(ctx.query) - if query == "" { - // Empty query: return all items with no highlights. - for _, item := range c.items { - item.SetMatch(fuzzy.Match{}) - } - return c.items - } - - // Perform fuzzy matching on both full paths and basenames. - fullMatches := matchIndex(query, c.paths) - baseMatches := matchIndex(query, c.bases) - // Collect unique item indices that matched either full path or basename. - 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) - // Detect if query looks like a path (contains / or \ or file extension). - 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] - - // Check for exact (case-insensitive) prefix/contains matches. - pathPrefix := strings.HasPrefix(pathLower, queryLower) - pathContains := strings.Contains(pathLower, queryLower) - basePrefix := strings.HasPrefix(baseLower, queryLower) - baseContains := strings.Contains(baseLower, queryLower) - - // Calculate score by accumulating weighted components. - score := 0 - // Fuzzy match scores (primary signals). - if hasFullMatch { - score += fullMatch.Score * fullMatchWeight - } - if hasBaseMatch { - score += baseMatch.Score * baseMatchWeight - } - // Path-level exact match bonuses. - if pathPrefix { - score += pathPrefixBonus - } - if pathContains { - score += pathContainsBonus - } - // Apply different bonuses based on whether query contains path hints. - if pathHint { - // User typed a path-like query (e.g., "src/main.go" or "main.go"). - // Prioritize full path matches. - if pathContains { - score += pathContainsHintBonus - } - if basePrefix { - score += basePrefixHintBonus - } - if baseContains { - score += baseContainsHintBonus - } - } else { - // User typed a simple query (e.g., "main"). - // Prioritize basename matches. - if basePrefix { - score += basePrefixBonus - } - if baseContains { - score += baseContainsBonus - } - } - - // Apply penalties to discourage deeply nested files. - depthPenalty := depthPenaltyDefault - if pathHint { - // Reduce penalty when user explicitly queries a path. - depthPenalty = depthPenaltyPathHint - } - score -= strings.Count(path, "/") * depthPenalty - // Minor penalty based on path length (favor shorter paths). - score -= ansi.StringWidth(path) - - // Choose which match to highlight based on weighted contribution. - 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 { - // Higher score first. - return b.score - a.score - } - // Tie-breaker: sort alphabetically. - 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 -} - -// matchIndex performs fuzzy matching and returns a map of item index to match 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 -} - -// stringSource adapts []string to fuzzy.Source interface. -type stringSource []string - -func (s stringSource) Len() int { - return len(s) -} - -func (s stringSource) String(i int) string { - return s[i] -} - -// pathBase extracts the basename from a file path (handles both / and \ separators). -// Examples: -// - "src/main.go" → "main.go" -// - "file.txt" → "file.txt" -// - "dir/" → "dir/" -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:] -} - -// remapMatchToPath remaps a basename match's character indices to full path indices. -// Example: -// - baseMatch for "main" in "main.go" with indices [0,1,2,3] -// - fullPath is "src/main.go" (offset 4) -// - Result: [4,5,6,7] (highlights "main" in full path) -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 -} - -// hasPathHint detects if the query looks like a file path query. -// Returns true if: -// - Query contains "/" or "\" (explicit path separator) -// - Query ends with a file extension pattern (e.g., ".go", ".ts") -// -// File extension heuristics: -// - Must have a dot not at start/end (e.g., "main.go" ✓, "v0.1" ✗, ".gitignore" ✓) -// - Extension must be ≤12 chars (e.g., ".go" ✓, ".verylongextension" ✗) -// - Extension must contain at least one letter and only alphanumeric/_/- chars -// -// Examples: -// - "src/main" → true (contains /) -// - "main.go" → true (file extension) -// - ".gitignore" → true (file extension) -// - "v0.1" → false (no letter in suffix) -// - "main" → false (no path hint) -func hasPathHint(query string) bool { - if strings.Contains(query, "/") || strings.Contains(query, "\\") { - return true - } - - // Check for file extension pattern. - lastDot := strings.LastIndex(query, ".") - if lastDot < 0 || lastDot == len(query)-1 { - // No dot or dot at end (e.g., "main" or "foo."). - return false - } - - suffix := query[lastDot+1:] - if len(suffix) > 12 { - // Extension too long (unlikely to be a real extension). - return false - } - - // Validate that suffix looks like a file extension: - // - Contains only alphanumeric, underscore, or hyphen. - // - Contains at least one letter (to exclude version numbers like "v0.1"). - 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 + return c.list.List.Render() } func loadFiles(depth, limit int) []FileCompletionValue { diff --git a/internal/ui/completions/completions_test.go b/internal/ui/completions/completions_test.go index 27faabe3c9833a2efcf13dfa33cdf5f51396f3f4..73b1ea71d8525abe1263443b8b8abd8358838c95 100644 --- a/internal/ui/completions/completions_test.go +++ b/internal/ui/completions/completions_test.go @@ -4,115 +4,106 @@ import ( "testing" "charm.land/lipgloss/v2" - "github.com/sahilm/fuzzy" "github.com/stretchr/testify/require" ) -// TestRankPrefersStrongBasenameMatch verifies that when no path hint is present, -// files with exact basename matches rank higher than partial path matches. -// Query "user" should prefer "user.go" over "internal/user_service.go". -func TestRankPrefersStrongBasenameMatch(t *testing.T) { +func TestFilterPrefersExactBasenameStem(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"}, - } + c := New(lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()) + c.SetItems([]FileCompletionValue{ + {Path: "internal/ui/chat/search.go"}, + {Path: "internal/ui/chat/user.go"}, + }, nil) - ranked := c.rank(queryContext{query: "user"}) - require.NotEmpty(t, ranked) - require.Equal(t, "user.go", ranked[0].Text()) -} - -// TestRankReturnsOriginalOrderForEmptyQuery verifies that empty queries -// return all items in their original order without reordering. -func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) { - t.Parallel() + c.Filter("user") - 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()) + filtered := c.filtered + require.NotEmpty(t, filtered) + first, ok := filtered[0].(*CompletionItem) + require.True(t, ok) + require.Equal(t, "internal/ui/chat/user.go", first.Text()) + require.NotEmpty(t, first.match.MatchedIndexes) } -// TestRankPrefersPathMatchesWhenPathHintPresent verifies that when query -// contains a path separator (/), path-level matches are prioritized. -// Query "internal/u" should rank "internal/user.go" highest. -func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) { +func TestFilterPrefersBasenamePrefix(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"}, - } + c := New(lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()) + c.SetItems([]FileCompletionValue{ + {Path: "internal/ui/chat/mcp.go"}, + {Path: "internal/ui/model/chat.go"}, + }, nil) + + c.Filter("chat.g") - ranked := c.rank(queryContext{query: "internal/u"}) - require.NotEmpty(t, ranked) - require.Equal(t, "internal/user.go", ranked[0].Text()) + filtered := c.filtered + require.NotEmpty(t, filtered) + first, ok := filtered[0].(*CompletionItem) + require.True(t, ok) + require.Equal(t, "internal/ui/model/chat.go", first.Text()) + require.NotEmpty(t, first.match.MatchedIndexes) } -// TestRankDotHintPrefersSuffixPathMatch verifies that file extension queries -// (e.g., ".go") trigger path hint behavior and prioritize extension matches. -// Query ".go" should rank "user.go" higher than "go-guide.md". -func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) { +func TestNamePriorityTier(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()), + tests := []struct { + name string + path string + query string + wantTier int + }{ + { + name: "exact stem", + path: "internal/ui/chat/user.go", + query: "user", + wantTier: tierExactName, + }, + { + name: "basename prefix", + path: "internal/ui/model/chat.go", + query: "chat.g", + wantTier: tierPrefixName, + }, + { + name: "path segment exact", + path: "internal/ui/chat/mcp.go", + query: "chat", + wantTier: tierPathSegment, + }, + { + name: "fallback", + path: "internal/ui/chat/search.go", + query: "user", + wantTier: tierFallback, }, - 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()) + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := namePriorityTier(tt.path, tt.query) + require.Equal(t, tt.wantTier, got) + }) + } } -// TestRemapMatchToPath verifies that basename match indices are correctly -// remapped to full path indices. For "user" matched in "user.go" at [0,1,2], -// when full path is "internal/user.go", indices become [9,10,11]. -func TestRemapMatchToPath(t *testing.T) { +func TestFilterPrefersPathSegmentExact(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) -} + c := New(lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()) + c.SetItems([]FileCompletionValue{ + {Path: "internal/ui/model/xychat.go"}, + {Path: "internal/ui/chat/mcp.go"}, + }, nil) -// TestHasPathHint verifies the heuristics for detecting path-like queries. -// - "internal/u" → true (contains /) -// - "main.go" → true (file extension) -// - "v0.1" → false (no letter in suffix) -// - "main" → false (no path hint) -func TestHasPathHint(t *testing.T) { - t.Parallel() + c.Filter("chat") - require.True(t, hasPathHint("internal/u")) - require.True(t, hasPathHint("main.go")) - require.False(t, hasPathHint("v0.1")) - require.False(t, hasPathHint("main")) + filtered := c.filtered + require.NotEmpty(t, filtered) + first, ok := filtered[0].(*CompletionItem) + require.True(t, ok) + require.Equal(t, "internal/ui/chat/mcp.go", first.Text()) }