on main help sign in
feat(ui): prioritize filename-based completion ranking
commitf67fa357932387ab20dada0ea1ac416da021143a authorwanghuaiyu@qiniu.com <wanghuaiyu@qiniu.com> dateWed 25 Mar 2026, 10:37 (7 months ago) parent09da90a
2 files changed+175−400
internal/ui/completions/completions.go+98−314 internal/ui/completions/completions_test.go+77−86
internal/ui/completions/completions.go+98−314
@@ -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 {
internal/ui/completions/completions_test.go+77−86
@@ -4,115 +4,106 @@ import (
44 "testing"
55
66 "charm.land/lipgloss/v2"
7− "github.com/sahilm/fuzzy"
87 "github.com/stretchr/testify/require"
98)
109
11−// TestRankPrefersStrongBasenameMatch verifies that when no path hint is present,
12−// files with exact basename matches rank higher than partial path matches.
13−// Query "user" should prefer "user.go" over "internal/user_service.go".
14−func TestRankPrefersStrongBasenameMatch(t *testing.T) {
10+func TestFilterPrefersExactBasenameStem(t *testing.T) {
1511 t.Parallel()
1612
17− c := &Completions{
18− items: []*CompletionItem{
19− NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
20− NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
21− NewCompletionItem("internal/user_service.go", FileCompletionValue{Path: "internal/user_service.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
22− },
23− paths: []string{"internal/ui/chat/search.go", "user.go", "internal/user_service.go"},
24− bases: []string{"search.go", "user.go", "user_service.go"},
25− }
13+ c := New(lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle())
14+ c.SetItems([]FileCompletionValue{
15+ {Path: "internal/ui/chat/search.go"},
16+ {Path: "internal/ui/chat/user.go"},
17+ }, nil)
2618
27− ranked := c.rank(queryContext{query: "user"})
28− require.NotEmpty(t, ranked)
29− require.Equal(t, "user.go", ranked[0].Text())
30−}
31−
32−// TestRankReturnsOriginalOrderForEmptyQuery verifies that empty queries
33−// return all items in their original order without reordering.
34−func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
35− t.Parallel()
19+ c.Filter("user")
3620
37− c := &Completions{
38− items: []*CompletionItem{
39− NewCompletionItem("b/user.go", FileCompletionValue{Path: "b/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
40− NewCompletionItem("a/user.go", FileCompletionValue{Path: "a/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
41− },
42− paths: []string{"b/user.go", "a/user.go"},
43− bases: []string{"user.go", "user.go"},
44− }
45−
46− ranked := c.rank(queryContext{query: ""})
47− require.Len(t, ranked, 2)
48− require.Equal(t, "b/user.go", ranked[0].Text())
49− require.Equal(t, "a/user.go", ranked[1].Text())
21+ filtered := c.filtered
22+ require.NotEmpty(t, filtered)
23+ first, ok := filtered[0].(*CompletionItem)
24+ require.True(t, ok)
25+ require.Equal(t, "internal/ui/chat/user.go", first.Text())
26+ require.NotEmpty(t, first.match.MatchedIndexes)
5027}
5128
52−// TestRankPrefersPathMatchesWhenPathHintPresent verifies that when query
53−// contains a path separator (/), path-level matches are prioritized.
54−// Query "internal/u" should rank "internal/user.go" highest.
55−func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
29+func TestFilterPrefersBasenamePrefix(t *testing.T) {
5630 t.Parallel()
5731
58− c := &Completions{
59− items: []*CompletionItem{
60− NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
61− NewCompletionItem("internal/user.go", FileCompletionValue{Path: "internal/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
62− NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
63− },
64− paths: []string{"user.go", "internal/user.go", "internal/ui/chat/search.go"},
65− bases: []string{"user.go", "user.go", "search.go"},
66− }
32+ c := New(lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle())
33+ c.SetItems([]FileCompletionValue{
34+ {Path: "internal/ui/chat/mcp.go"},
35+ {Path: "internal/ui/model/chat.go"},
36+ }, nil)
37+
38+ c.Filter("chat.g")
6739
68− ranked := c.rank(queryContext{query: "internal/u"})
69− require.NotEmpty(t, ranked)
70− require.Equal(t, "internal/user.go", ranked[0].Text())
40+ filtered := c.filtered
41+ require.NotEmpty(t, filtered)
42+ first, ok := filtered[0].(*CompletionItem)
43+ require.True(t, ok)
44+ require.Equal(t, "internal/ui/model/chat.go", first.Text())
45+ require.NotEmpty(t, first.match.MatchedIndexes)
7146}
7247
73−// TestRankDotHintPrefersSuffixPathMatch verifies that file extension queries
74−// (e.g., ".go") trigger path hint behavior and prioritize extension matches.
75−// Query ".go" should rank "user.go" higher than "go-guide.md".
76−func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
48+func TestNamePriorityTier(t *testing.T) {
7749 t.Parallel()
7850
79− c := &Completions{
80− items: []*CompletionItem{
81− NewCompletionItem("docs/go-guide.md", FileCompletionValue{Path: "docs/go-guide.md"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
82− NewCompletionItem("src/user.go", FileCompletionValue{Path: "src/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
51+ tests := []struct {
52+ name string
53+ path string
54+ query string
55+ wantTier int
56+ }{
57+ {
58+ name: "exact stem",
59+ path: "internal/ui/chat/user.go",
60+ query: "user",
61+ wantTier: tierExactName,
62+ },
63+ {
64+ name: "basename prefix",
65+ path: "internal/ui/model/chat.go",
66+ query: "chat.g",
67+ wantTier: tierPrefixName,
68+ },
69+ {
70+ name: "path segment exact",
71+ path: "internal/ui/chat/mcp.go",
72+ query: "chat",
73+ wantTier: tierPathSegment,
74+ },
75+ {
76+ name: "fallback",
77+ path: "internal/ui/chat/search.go",
78+ query: "user",
79+ wantTier: tierFallback,
8380 },
84− paths: []string{"docs/go-guide.md", "src/user.go"},
85− bases: []string{"go-guide.md", "user.go"},
8681 }
8782
88− ranked := c.rank(queryContext{query: ".go"})
89− require.NotEmpty(t, ranked)
90− require.Equal(t, "src/user.go", ranked[0].Text())
83+ for _, tt := range tests {
84+ tt := tt
85+ t.Run(tt.name, func(t *testing.T) {
86+ t.Parallel()
87+ got := namePriorityTier(tt.path, tt.query)
88+ require.Equal(t, tt.wantTier, got)
89+ })
90+ }
9191}
9292
93−// TestRemapMatchToPath verifies that basename match indices are correctly
94−// remapped to full path indices. For "user" matched in "user.go" at [0,1,2],
95−// when full path is "internal/user.go", indices become [9,10,11].
96−func TestRemapMatchToPath(t *testing.T) {
93+func TestFilterPrefersPathSegmentExact(t *testing.T) {
9794 t.Parallel()
9895
99− match := remapMatchToPath(
100− fuzzy.Match{MatchedIndexes: []int{0, 1, 2}},
101− "internal/user.go",
102− )
103− require.Equal(t, []int{9, 10, 11}, match.MatchedIndexes)
104−}
96+ c := New(lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle())
97+ c.SetItems([]FileCompletionValue{
98+ {Path: "internal/ui/model/xychat.go"},
99+ {Path: "internal/ui/chat/mcp.go"},
100+ }, nil)
105101
106−// TestHasPathHint verifies the heuristics for detecting path-like queries.
107−// - "internal/u" → true (contains /)
108−// - "main.go" → true (file extension)
109−// - "v0.1" → false (no letter in suffix)
110−// - "main" → false (no path hint)
111−func TestHasPathHint(t *testing.T) {
112− t.Parallel()
102+ c.Filter("chat")
113103
114− require.True(t, hasPathHint("internal/u"))
115− require.True(t, hasPathHint("main.go"))
116− require.False(t, hasPathHint("v0.1"))
117− require.False(t, hasPathHint("main"))
104+ filtered := c.filtered
105+ require.NotEmpty(t, filtered)
106+ first, ok := filtered[0].(*CompletionItem)
107+ require.True(t, ok)
108+ require.Equal(t, "internal/ui/chat/mcp.go", first.Text())
118109}