on main help sign in
docs(ui): add comprehensive comments to completion ranking algorithm
09da90awanghuaiyu@qiniu.com
internal/ui/completions/completions.go+91−9
@@ -24,18 +24,31 @@ const (
24 minWidth = 10
24 minWidth = 10
25 maxWidth = 100
25 maxWidth = 100
26
26
27+ // Scoring weights for fuzzy matching.
28+ // Full path fuzzy match contributes most to the score.
27 fullMatchWeight = 1_000
29 fullMatchWeight = 1_000
30+ // Basename fuzzy match contributes less than full path.
28 baseMatchWeight = 300
31 baseMatchWeight = 300
29
32
33+ // Bonus points for exact matches (case-insensitive).
34+ // Path prefix match (e.g., "src/" matches "src/main.go") gets highest bonus.
30− pathPrefixBonus = 5_000
35+ pathPrefixBonus = 5_000
36+ // Path contains match (e.g., "main" matches "src/main.go").
31− pathContainsBonus = 2_000
37+ pathContainsBonus = 2_000
38+ // Additional bonus when path hint is detected (user typed "/" or file extension).
32 pathContainsHintBonus = 2_500
39 pathContainsHintBonus = 2_500
40+ // Basename prefix match (e.g., "main" matches "main.go").
33− basePrefixBonus = 1_500
41+ basePrefixBonus = 1_500
42+ // Basename contains match (e.g., "mai" matches "main.go").
34− baseContainsBonus = 500
43+ baseContainsBonus = 500
44+ // Smaller bonuses when path hint is detected.
35 basePrefixHintBonus = 300
45 basePrefixHintBonus = 300
36 baseContainsHintBonus = 120
46 baseContainsHintBonus = 120
37
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).
38− depthPenaltyDefault = 20
50+ depthPenaltyDefault = 20
51+ // Reduced penalty when user explicitly queries a path (typed "/" or extension).
39 depthPenaltyPathHint = 5
52 depthPenaltyPathHint = 5
40)
53)
41
54
@@ -75,10 +88,11 @@ type Completions struct {
75 focusedStyle lipgloss.Style
88 focusedStyle lipgloss.Style
76 matchStyle lipgloss.Style
89 matchStyle lipgloss.Style
77
90
91+ // Custom ranking state (replaces list.FilterableList's built-in filtering).
78− items []*CompletionItem
92+ items []*CompletionItem // All completion items.
79− filtered []*CompletionItem
93+ filtered []*CompletionItem // Filtered and ranked items based on query.
80− paths []string
94+ paths []string // Pre-computed full paths for matching.
81− bases []string
95+ bases []string // Pre-computed basenames for matching.
82}
96}
83
97
84// New creates a new completions component.
98// New creates a new completions component.
@@ -164,6 +178,7 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
164 c.open = true
178 c.open = true
165 c.query = ""
179 c.query = ""
166 c.items = items
180 c.items = items
181+ // Pre-compute paths and basenames for efficient fuzzy matching.
167 c.paths = make([]string, len(items))
182 c.paths = make([]string, len(items))
168 c.bases = make([]string, len(items))
183 c.bases = make([]string, len(items))
169 for i, item := range items {
184 for i, item := range items {
@@ -171,6 +186,7 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
171 c.paths[i] = path
186 c.paths[i] = path
172 c.bases[i] = pathBase(path)
187 c.bases[i] = pathBase(path)
173 }
188 }
189+ // Perform initial ranking with empty query (returns all items).
174 c.filtered = c.rank(queryContext{
190 c.filtered = c.rank(queryContext{
175 query: c.query,
191 query: c.query,
176 })
192 })
@@ -202,6 +218,7 @@ func (c *Completions) Filter(query string) {
202 }
218 }
203
219
204 c.query = query
220 c.query = query
221+ // Apply custom ranking algorithm instead of list's built-in filtering.
205 c.filtered = c.rank(queryContext{
222 c.filtered = c.rank(queryContext{
206 query: query,
223 query: query,
207 })
224 })
@@ -358,17 +375,32 @@ type rankedItem struct {
358}
375}
359
376
360// rank uses path-first fuzzy ordering with basename as a secondary boost.
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
361func (c *Completions) rank(ctx queryContext) []*CompletionItem {
390func (c *Completions) rank(ctx queryContext) []*CompletionItem {
362 query := strings.TrimSpace(ctx.query)
391 query := strings.TrimSpace(ctx.query)
363 if query == "" {
392 if query == "" {
393+ // Empty query: return all items with no highlights.
364 for _, item := range c.items {
394 for _, item := range c.items {
365 item.SetMatch(fuzzy.Match{})
395 item.SetMatch(fuzzy.Match{})
366 }
396 }
367 return c.items
397 return c.items
368 }
398 }
369
399
400+ // Perform fuzzy matching on both full paths and basenames.
370 fullMatches := matchIndex(query, c.paths)
401 fullMatches := matchIndex(query, c.paths)
371 baseMatches := matchIndex(query, c.bases)
402 baseMatches := matchIndex(query, c.bases)
403+ // Collect unique item indices that matched either full path or basename.
372 allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches))
404 allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches))
373 for idx := range fullMatches {
405 for idx := range fullMatches {
374 allIndexes[idx] = struct{}{}
406 allIndexes[idx] = struct{}{}
@@ -378,6 +410,7 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
378 }
410 }
379
411
380 queryLower := strings.ToLower(query)
412 queryLower := strings.ToLower(query)
413+ // Detect if query looks like a path (contains / or \ or file extension).
381 pathHint := hasPathHint(query)
414 pathHint := hasPathHint(query)
382 ranked := make([]rankedItem, 0, len(allIndexes))
415 ranked := make([]rankedItem, 0, len(allIndexes))
383 for idx := range allIndexes {
416 for idx := range allIndexes {
@@ -388,25 +421,32 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
388 fullMatch, hasFullMatch := fullMatches[idx]
421 fullMatch, hasFullMatch := fullMatches[idx]
389 baseMatch, hasBaseMatch := baseMatches[idx]
422 baseMatch, hasBaseMatch := baseMatches[idx]
390
423
424+ // Check for exact (case-insensitive) prefix/contains matches.
391 pathPrefix := strings.HasPrefix(pathLower, queryLower)
425 pathPrefix := strings.HasPrefix(pathLower, queryLower)
392 pathContains := strings.Contains(pathLower, queryLower)
426 pathContains := strings.Contains(pathLower, queryLower)
393 basePrefix := strings.HasPrefix(baseLower, queryLower)
427 basePrefix := strings.HasPrefix(baseLower, queryLower)
394 baseContains := strings.Contains(baseLower, queryLower)
428 baseContains := strings.Contains(baseLower, queryLower)
395
429
430+ // Calculate score by accumulating weighted components.
396 score := 0
431 score := 0
432+ // Fuzzy match scores (primary signals).
397 if hasFullMatch {
433 if hasFullMatch {
398 score += fullMatch.Score * fullMatchWeight
434 score += fullMatch.Score * fullMatchWeight
399 }
435 }
400 if hasBaseMatch {
436 if hasBaseMatch {
401 score += baseMatch.Score * baseMatchWeight
437 score += baseMatch.Score * baseMatchWeight
402 }
438 }
439+ // Path-level exact match bonuses.
403 if pathPrefix {
440 if pathPrefix {
404 score += pathPrefixBonus
441 score += pathPrefixBonus
405 }
442 }
406 if pathContains {
443 if pathContains {
407 score += pathContainsBonus
444 score += pathContainsBonus
408 }
445 }
446+ // Apply different bonuses based on whether query contains path hints.
409 if pathHint {
447 if pathHint {
448+ // User typed a path-like query (e.g., "src/main.go" or "main.go").
449+ // Prioritize full path matches.
410 if pathContains {
450 if pathContains {
411 score += pathContainsHintBonus
451 score += pathContainsHintBonus
412 }
452 }
@@ -417,6 +457,8 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
417 score += baseContainsHintBonus
457 score += baseContainsHintBonus
418 }
458 }
419 } else {
459 } else {
460+ // User typed a simple query (e.g., "main").
461+ // Prioritize basename matches.
420 if basePrefix {
462 if basePrefix {
421 score += basePrefixBonus
463 score += basePrefixBonus
422 }
464 }
@@ -425,13 +467,17 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
425 }
467 }
426 }
468 }
427
469
470+ // Apply penalties to discourage deeply nested files.
428 depthPenalty := depthPenaltyDefault
471 depthPenalty := depthPenaltyDefault
429 if pathHint {
472 if pathHint {
473+ // Reduce penalty when user explicitly queries a path.
430 depthPenalty = depthPenaltyPathHint
474 depthPenalty = depthPenaltyPathHint
431 }
475 }
432 score -= strings.Count(path, "/") * depthPenalty
476 score -= strings.Count(path, "/") * depthPenalty
477+ // Minor penalty based on path length (favor shorter paths).
433 score -= ansi.StringWidth(path)
478 score -= ansi.StringWidth(path)
434
479
480+ // Choose which match to highlight based on weighted contribution.
435 if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) {
481 if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) {
436 c.items[idx].SetMatch(fullMatch)
482 c.items[idx].SetMatch(fullMatch)
437 } else if hasBaseMatch {
483 } else if hasBaseMatch {
@@ -448,8 +494,10 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
448
494
449 slices.SortStableFunc(ranked, func(a, b rankedItem) int {
495 slices.SortStableFunc(ranked, func(a, b rankedItem) int {
450 if a.score != b.score {
496 if a.score != b.score {
497+ // Higher score first.
451 return b.score - a.score
498 return b.score - a.score
452 }
499 }
500+ // Tie-breaker: sort alphabetically.
453 return strings.Compare(a.item.Text(), b.item.Text())
501 return strings.Compare(a.item.Text(), b.item.Text())
454 })
502 })
455
503
@@ -460,6 +508,7 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
460 return result
508 return result
461}
509}
462
510
511+// matchIndex performs fuzzy matching and returns a map of item index to match result.
463func matchIndex(query string, values []string) map[int]fuzzy.Match {
512func matchIndex(query string, values []string) map[int]fuzzy.Match {
464 source := stringSource(values)
513 source := stringSource(values)
465 matches := fuzzy.FindFrom(query, source)
514 matches := fuzzy.FindFrom(query, source)
@@ -470,6 +519,7 @@ func matchIndex(query string, values []string) map[int]fuzzy.Match {
470 return result
519 return result
471}
520}
472
521
522+// stringSource adapts []string to fuzzy.Source interface.
473type stringSource []string
523type stringSource []string
474
524
475func (s stringSource) Len() int {
525func (s stringSource) Len() int {
@@ -480,6 +530,11 @@ func (s stringSource) String(i int) string {
480 return s[i]
530 return s[i]
481}
531}
482
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/"
483func pathBase(value string) string {
538func pathBase(value string) string {
484 trimmed := strings.TrimRight(value, `/\`)
539 trimmed := strings.TrimRight(value, `/\`)
485 if trimmed == "" {
540 if trimmed == "" {
@@ -492,6 +547,11 @@ func pathBase(value string) string {
492 return trimmed[idx+1:]
547 return trimmed[idx+1:]
493}
548}
494
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)
495func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
555func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
496 base := pathBase(fullPath)
556 base := pathBase(fullPath)
497 if base == "" {
557 if base == "" {
@@ -506,21 +566,43 @@ func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
506 return match
566 return match
507}
567}
508
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)
509func hasPathHint(query string) bool {
585func hasPathHint(query string) bool {
510 if strings.Contains(query, "/") || strings.Contains(query, "\\") {
586 if strings.Contains(query, "/") || strings.Contains(query, "\\") {
511 return true
587 return true
512 }
588 }
513
589
590+ // Check for file extension pattern.
514 lastDot := strings.LastIndex(query, ".")
591 lastDot := strings.LastIndex(query, ".")
515 if lastDot < 0 || lastDot == len(query)-1 {
592 if lastDot < 0 || lastDot == len(query)-1 {
593+ // No dot or dot at end (e.g., "main" or "foo.").
516 return false
594 return false
517 }
595 }
518
596
519 suffix := query[lastDot+1:]
597 suffix := query[lastDot+1:]
520 if len(suffix) > 12 {
598 if len(suffix) > 12 {
599+ // Extension too long (unlikely to be a real extension).
521 return false
600 return false
522 }
601 }
523
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").
524 hasLetter := false
606 hasLetter := false
525 for _, r := range suffix {
607 for _, r := range suffix {
526 if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
608 if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
internal/ui/completions/completions_test.go+19
@@ -8,6 +8,9 @@ import (
8 "github.com/stretchr/testify/require"
8 "github.com/stretchr/testify/require"
9)
9)
10
10
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".
11func TestRankPrefersStrongBasenameMatch(t *testing.T) {
14func TestRankPrefersStrongBasenameMatch(t *testing.T) {
12 t.Parallel()
15 t.Parallel()
13
16
@@ -26,6 +29,8 @@ func TestRankPrefersStrongBasenameMatch(t *testing.T) {
26 require.Equal(t, "user.go", ranked[0].Text())
29 require.Equal(t, "user.go", ranked[0].Text())
27}
30}
28
31
32+// TestRankReturnsOriginalOrderForEmptyQuery verifies that empty queries
33+// return all items in their original order without reordering.
29func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
34func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
30 t.Parallel()
35 t.Parallel()
31
36
@@ -44,6 +49,9 @@ func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
44 require.Equal(t, "a/user.go", ranked[1].Text())
49 require.Equal(t, "a/user.go", ranked[1].Text())
45}
50}
46
51
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.
47func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
55func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
48 t.Parallel()
56 t.Parallel()
49
57
@@ -62,6 +70,9 @@ func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
62 require.Equal(t, "internal/user.go", ranked[0].Text())
70 require.Equal(t, "internal/user.go", ranked[0].Text())
63}
71}
64
72
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".
65func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
76func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
66 t.Parallel()
77 t.Parallel()
67
78
@@ -79,6 +90,9 @@ func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
79 require.Equal(t, "src/user.go", ranked[0].Text())
90 require.Equal(t, "src/user.go", ranked[0].Text())
80}
91}
81
92
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].
82func TestRemapMatchToPath(t *testing.T) {
96func TestRemapMatchToPath(t *testing.T) {
83 t.Parallel()
97 t.Parallel()
84
98
@@ -89,6 +103,11 @@ func TestRemapMatchToPath(t *testing.T) {
89 require.Equal(t, []int{9, 10, 11}, match.MatchedIndexes)
103 require.Equal(t, []int{9, 10, 11}, match.MatchedIndexes)
90}
104}
91
105
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)
92func TestHasPathHint(t *testing.T) {
111func TestHasPathHint(t *testing.T) {
93 t.Parallel()
112 t.Parallel()
94
113