@@ -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 {
@@ -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())
}