@@ -2,10 +2,10 @@ package completions
22
33import (
44 "cmp"
5+ "path/filepath"
56 "slices"
67 "strings"
78 "sync"
8− "unicode"
99
1010 "charm.land/bubbles/v2/key"
1111 tea "charm.land/bubbletea/v2"
@@ -15,7 +15,6 @@ import (
1515 "github.com/charmbracelet/crush/internal/ui/list"
1616 "github.com/charmbracelet/x/ansi"
1717 "github.com/charmbracelet/x/exp/ordered"
18− "github.com/sahilm/fuzzy"
1918)
2019
2120const (
@@ -24,32 +23,10 @@ const (
2423 minWidth = 10
2524 maxWidth = 100
2625
27− // Scoring weights for fuzzy matching.
28− // Full path fuzzy match contributes most to the score.
29− fullMatchWeight = 1_000
30− // Basename fuzzy match contributes less than full path.
31− baseMatchWeight = 300
32−
33− // Bonus points for exact matches (case-insensitive).
34− // Path prefix match (e.g., "src/" matches "src/main.go") gets highest bonus.
35− pathPrefixBonus = 5_000
36− // Path contains match (e.g., "main" matches "src/main.go").
37− pathContainsBonus = 2_000
38− // Additional bonus when path hint is detected (user typed "/" or file extension).
39− pathContainsHintBonus = 2_500
40− // Basename prefix match (e.g., "main" matches "main.go").
41− basePrefixBonus = 1_500
42− // Basename contains match (e.g., "mai" matches "main.go").
43− baseContainsBonus = 500
44− // Smaller bonuses when path hint is detected.
45− basePrefixHintBonus = 300
46− baseContainsHintBonus = 120
47−
48− // Penalties for deeply nested files to favor shallow matches.
49− // Default penalty per directory level (e.g., "a/b/c" has 2 levels).
50− depthPenaltyDefault = 20
51− // Reduced penalty when user explicitly queries a path (typed "/" or extension).
52− depthPenaltyPathHint = 5
26+ tierExactName = iota
27+ tierPrefixName
28+ tierPathSegment
29+ tierFallback
5330)
5431
5532// SelectionMsg is sent when a completion is selected.
@@ -88,11 +65,34 @@ type Completions struct {
8865 focusedStyle lipgloss.Style
8966 matchStyle lipgloss.Style
9067
91− // Custom ranking state (replaces list.FilterableList's built-in filtering).
92− items []*CompletionItem // All completion items.
93− filtered []*CompletionItem // Filtered and ranked items based on query.
94− paths []string // Pre-computed full paths for matching.
95− bases []string // Pre-computed basenames for matching.
68+ allItems []list.FilterableItem
69+ filtered []list.FilterableItem
70+}
71+
72+type namePriorityRule struct {
73+ tier int
74+ match func(pathLower, baseLower, stemLower, queryLower string) bool
75+}
76+
77+var namePriorityRules = []namePriorityRule{
78+ {
79+ tier: tierExactName,
80+ match: func(_ string, baseLower, stemLower, queryLower string) bool {
81+ return baseLower == queryLower || stemLower == queryLower
82+ },
83+ },
84+ {
85+ tier: tierPrefixName,
86+ match: func(_ string, baseLower, _ string, queryLower string) bool {
87+ return strings.HasPrefix(baseLower, queryLower)
88+ },
89+ },
90+ {
91+ tier: tierPathSegment,
92+ match: func(pathLower, _ string, _ string, queryLower string) bool {
93+ return hasPathSegment(pathLower, queryLower)
94+ },
95+ },
9696}
9797
9898// New creates a new completions component.
@@ -149,7 +149,7 @@ func (c *Completions) Open(depth, limit int) tea.Cmd {
149149
150150// SetItems sets the files and MCP resources and rebuilds the merged list.
151151func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
152− items := make([]*CompletionItem, 0, len(files)+len(resources))
152+ items := make([]list.FilterableItem, 0, len(files)+len(resources))
153153
154154 // Add files first.
155155 for _, file := range files {
@@ -177,20 +177,10 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
177177
178178 c.open = true
179179 c.query = ""
180− c.items = items
181− // Pre-compute paths and basenames for efficient fuzzy matching.
182− c.paths = make([]string, len(items))
183− c.bases = make([]string, len(items))
184− for i, item := range items {
185− path := item.Filter()
186− c.paths[i] = path
187− c.bases[i] = pathBase(path)
188− }
189− // Perform initial ranking with empty query (returns all items).
190− c.filtered = c.rank(queryContext{
191− query: c.query,
192− })
193− c.setVisibleItems(c.filtered)
180+ c.allItems = items
181+ c.filtered = append([]list.FilterableItem(nil), items...)
182+ c.list.SetItems(c.filtered...)
183+ c.list.SetFilter("")
194184 c.list.Focus()
195185
196186 c.width = maxWidth
@@ -218,15 +208,65 @@ func (c *Completions) Filter(query string) {
218208 }
219209
220210 c.query = query
221− // Apply custom ranking algorithm instead of list's built-in filtering.
222− c.filtered = c.rank(queryContext{
223− query: query,
224− })
225− c.setVisibleItems(c.filtered)
211+ c.applyNamePriorityFilter(query)
226212
227213 c.updateSize()
228214}
229215
216+func (c *Completions) applyNamePriorityFilter(query string) {
217+ if query == "" {
218+ c.filtered = append([]list.FilterableItem(nil), c.allItems...)
219+ c.list.SetItems(c.filtered...)
220+ return
221+ }
222+
223+ c.list.SetItems(c.allItems...)
224+ c.list.SetFilter(query)
225+ raw := c.list.FilteredItems()
226+ filtered := make([]list.FilterableItem, 0, len(raw))
227+ for _, item := range raw {
228+ filterable, ok := item.(list.FilterableItem)
229+ if !ok {
230+ continue
231+ }
232+ filtered = append(filtered, filterable)
233+ }
234+
235+ queryLower := strings.ToLower(strings.TrimSpace(query))
236+ slices.SortStableFunc(filtered, func(a, b list.FilterableItem) int {
237+ return namePriorityTier(a.Filter(), queryLower) - namePriorityTier(b.Filter(), queryLower)
238+ })
239+ c.filtered = filtered
240+ c.list.SetItems(c.filtered...)
241+}
242+
243+func namePriorityTier(path, queryLower string) int {
244+ if queryLower == "" {
245+ return tierFallback
246+ }
247+
248+ pathLower := strings.ToLower(path)
249+ baseLower := strings.ToLower(filepath.Base(strings.ReplaceAll(path, `\`, `/`)))
250+ stemLower := strings.TrimSuffix(baseLower, filepath.Ext(baseLower))
251+ for _, rule := range namePriorityRules {
252+ if rule.match(pathLower, baseLower, stemLower, queryLower) {
253+ return rule.tier
254+ }
255+ }
256+ return tierFallback
257+}
258+
259+func hasPathSegment(pathLower, queryLower string) bool {
260+ for _, part := range strings.FieldsFunc(pathLower, func(r rune) bool {
261+ return r == '/' || r == '\\'
262+ }) {
263+ if part == queryLower {
264+ return true
265+ }
266+ }
267+ return false
268+}
269+
230270func (c *Completions) updateSize() {
231271 items := c.filtered
232272 start, end := c.list.VisibleItemIndices()
@@ -321,7 +361,10 @@ func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
321361 return nil
322362 }
323363
324− item := items[selected]
364+ item, ok := items[selected].(*CompletionItem)
365+ if !ok {
366+ return nil
367+ }
325368
326369 if !keepOpen {
327370 c.open = false
@@ -354,266 +397,7 @@ func (c *Completions) Render() string {
354397 return ""
355398 }
356399
357− return c.list.Render()
358−}
359−
360−func (c *Completions) setVisibleItems(items []*CompletionItem) {
361− filterables := make([]list.FilterableItem, 0, len(items))
362− for _, item := range items {
363− filterables = append(filterables, item)
364− }
365− c.list.SetItems(filterables...)
366−}
367−
368−type queryContext struct {
369− query string
370−}
371−
372−type rankedItem struct {
373− item *CompletionItem
374− score int
375−}
376−
377−// rank uses path-first fuzzy ordering with basename as a secondary boost.
378−//
379−// Ranking strategy:
380−// 1. Perform fuzzy matching on both full paths and basenames.
381−// 2. Apply bonus points for exact prefix/contains matches.
382−// 3. Adjust bonuses based on whether query contains path hints (/, \, or file extension).
383−// 4. Apply depth penalty to favor shallower files.
384−// 5. Sort by score (descending), then alphabetically.
385−//
386−// Example scoring for query "main.go":
387−// - "main.go" (root): high fuzzy + pathPrefix + basePrefix + low depth penalty
388−// - "src/main.go": high fuzzy + pathContains + basePrefix + moderate depth penalty
389−// - "test/helper/main.go": high fuzzy + pathContains + basePrefix + high depth penalty
390−func (c *Completions) rank(ctx queryContext) []*CompletionItem {
391− query := strings.TrimSpace(ctx.query)
392− if query == "" {
393− // Empty query: return all items with no highlights.
394− for _, item := range c.items {
395− item.SetMatch(fuzzy.Match{})
396− }
397− return c.items
398− }
399−
400− // Perform fuzzy matching on both full paths and basenames.
401− fullMatches := matchIndex(query, c.paths)
402− baseMatches := matchIndex(query, c.bases)
403− // Collect unique item indices that matched either full path or basename.
404− allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches))
405− for idx := range fullMatches {
406− allIndexes[idx] = struct{}{}
407− }
408− for idx := range baseMatches {
409− allIndexes[idx] = struct{}{}
410− }
411−
412− queryLower := strings.ToLower(query)
413− // Detect if query looks like a path (contains / or \ or file extension).
414− pathHint := hasPathHint(query)
415− ranked := make([]rankedItem, 0, len(allIndexes))
416− for idx := range allIndexes {
417− path := c.paths[idx]
418− pathLower := strings.ToLower(path)
419− baseLower := strings.ToLower(c.bases[idx])
420−
421− fullMatch, hasFullMatch := fullMatches[idx]
422− baseMatch, hasBaseMatch := baseMatches[idx]
423−
424− // Check for exact (case-insensitive) prefix/contains matches.
425− pathPrefix := strings.HasPrefix(pathLower, queryLower)
426− pathContains := strings.Contains(pathLower, queryLower)
427− basePrefix := strings.HasPrefix(baseLower, queryLower)
428− baseContains := strings.Contains(baseLower, queryLower)
429−
430− // Calculate score by accumulating weighted components.
431− score := 0
432− // Fuzzy match scores (primary signals).
433− if hasFullMatch {
434− score += fullMatch.Score * fullMatchWeight
435− }
436− if hasBaseMatch {
437− score += baseMatch.Score * baseMatchWeight
438− }
439− // Path-level exact match bonuses.
440− if pathPrefix {
441− score += pathPrefixBonus
442− }
443− if pathContains {
444− score += pathContainsBonus
445− }
446− // Apply different bonuses based on whether query contains path hints.
447− if pathHint {
448− // User typed a path-like query (e.g., "src/main.go" or "main.go").
449− // Prioritize full path matches.
450− if pathContains {
451− score += pathContainsHintBonus
452− }
453− if basePrefix {
454− score += basePrefixHintBonus
455− }
456− if baseContains {
457− score += baseContainsHintBonus
458− }
459− } else {
460− // User typed a simple query (e.g., "main").
461− // Prioritize basename matches.
462− if basePrefix {
463− score += basePrefixBonus
464− }
465− if baseContains {
466− score += baseContainsBonus
467− }
468− }
469−
470− // Apply penalties to discourage deeply nested files.
471− depthPenalty := depthPenaltyDefault
472− if pathHint {
473− // Reduce penalty when user explicitly queries a path.
474− depthPenalty = depthPenaltyPathHint
475− }
476− score -= strings.Count(path, "/") * depthPenalty
477− // Minor penalty based on path length (favor shorter paths).
478− score -= ansi.StringWidth(path)
479−
480− // Choose which match to highlight based on weighted contribution.
481− if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) {
482− c.items[idx].SetMatch(fullMatch)
483− } else if hasBaseMatch {
484− c.items[idx].SetMatch(remapMatchToPath(baseMatch, path))
485− } else {
486− c.items[idx].SetMatch(fuzzy.Match{})
487− }
488−
489− ranked = append(ranked, rankedItem{
490− item: c.items[idx],
491− score: score,
492− })
493− }
494−
495− slices.SortStableFunc(ranked, func(a, b rankedItem) int {
496− if a.score != b.score {
497− // Higher score first.
498− return b.score - a.score
499− }
500− // Tie-breaker: sort alphabetically.
501− return strings.Compare(a.item.Text(), b.item.Text())
502− })
503−
504− result := make([]*CompletionItem, 0, len(ranked))
505− for _, item := range ranked {
506− result = append(result, item.item)
507− }
508− return result
509−}
510−
511−// matchIndex performs fuzzy matching and returns a map of item index to match result.
512−func matchIndex(query string, values []string) map[int]fuzzy.Match {
513− source := stringSource(values)
514− matches := fuzzy.FindFrom(query, source)
515− result := make(map[int]fuzzy.Match, len(matches))
516− for _, match := range matches {
517− result[match.Index] = match
518− }
519− return result
520−}
521−
522−// stringSource adapts []string to fuzzy.Source interface.
523−type stringSource []string
524−
525−func (s stringSource) Len() int {
526− return len(s)
527−}
528−
529−func (s stringSource) String(i int) string {
530− return s[i]
531−}
532−
533−// pathBase extracts the basename from a file path (handles both / and \ separators).
534−// Examples:
535−// - "src/main.go" → "main.go"
536−// - "file.txt" → "file.txt"
537−// - "dir/" → "dir/"
538−func pathBase(value string) string {
539− trimmed := strings.TrimRight(value, `/\`)
540− if trimmed == "" {
541− return value
542− }
543− idx := strings.LastIndexAny(trimmed, `/\`)
544− if idx < 0 {
545− return trimmed
546− }
547− return trimmed[idx+1:]
548−}
549−
550−// remapMatchToPath remaps a basename match's character indices to full path indices.
551−// Example:
552−// - baseMatch for "main" in "main.go" with indices [0,1,2,3]
553−// - fullPath is "src/main.go" (offset 4)
554−// - Result: [4,5,6,7] (highlights "main" in full path)
555−func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
556− base := pathBase(fullPath)
557− if base == "" {
558− return match
559− }
560− offset := len(fullPath) - len(base)
561− remapped := make([]int, 0, len(match.MatchedIndexes))
562− for _, idx := range match.MatchedIndexes {
563− remapped = append(remapped, offset+idx)
564− }
565− match.MatchedIndexes = remapped
566− return match
567−}
568−
569−// hasPathHint detects if the query looks like a file path query.
570−// Returns true if:
571−// - Query contains "/" or "\" (explicit path separator)
572−// - Query ends with a file extension pattern (e.g., ".go", ".ts")
573−//
574−// File extension heuristics:
575−// - Must have a dot not at start/end (e.g., "main.go" ✓, "v0.1" ✗, ".gitignore" ✓)
576−// - Extension must be ≤12 chars (e.g., ".go" ✓, ".verylongextension" ✗)
577−// - Extension must contain at least one letter and only alphanumeric/_/- chars
578−//
579−// Examples:
580−// - "src/main" → true (contains /)
581−// - "main.go" → true (file extension)
582−// - ".gitignore" → true (file extension)
583−// - "v0.1" → false (no letter in suffix)
584−// - "main" → false (no path hint)
585−func hasPathHint(query string) bool {
586− if strings.Contains(query, "/") || strings.Contains(query, "\\") {
587− return true
588− }
589−
590− // Check for file extension pattern.
591− lastDot := strings.LastIndex(query, ".")
592− if lastDot < 0 || lastDot == len(query)-1 {
593− // No dot or dot at end (e.g., "main" or "foo.").
594− return false
595− }
596−
597− suffix := query[lastDot+1:]
598− if len(suffix) > 12 {
599− // Extension too long (unlikely to be a real extension).
600− return false
601− }
602−
603− // Validate that suffix looks like a file extension:
604− // - Contains only alphanumeric, underscore, or hyphen.
605− // - Contains at least one letter (to exclude version numbers like "v0.1").
606− hasLetter := false
607− for _, r := range suffix {
608− if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
609− return false
610− }
611− if unicode.IsLetter(r) {
612− hasLetter = true
613− }
614− }
615−
616− return hasLetter
400+ return c.list.List.Render()
617401}
618402
619403func loadFiles(depth, limit int) []FileCompletionValue {