on main help sign in
docs(ui): add comprehensive comments to completion ranking algorithm

Add detailed documentation to improve code review experience: - Explain scoring weights and their rationale - Document the ranking strategy and workflow - Clarify path hint detection heuristics - Add examples to helper functions - Document test cases with their intent

commit09da90a2a6b9c7b0a498304f1fb924a3012e750b authorwanghuaiyu@qiniu.com <wanghuaiyu@qiniu.com> dateThu 19 Mar 2026, 19:41 (7 months ago) parent0fcf79b
2 files changed+110−9
internal/ui/completions/completions.go+91−9 internal/ui/completions/completions_test.go+19
internal/ui/completions/completions.go+91−9
@@ -24,18 +24,31 @@ const (
2424 minWidth = 10
2525 maxWidth = 100
2626
27+ // Scoring weights for fuzzy matching.
28+ // Full path fuzzy match contributes most to the score.
2729 fullMatchWeight = 1_000
30+ // Basename fuzzy match contributes less than full path.
2831 baseMatchWeight = 300
2932
30− pathPrefixBonus = 5_000
31− pathContainsBonus = 2_000
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).
3239 pathContainsHintBonus = 2_500
33− basePrefixBonus = 1_500
34− baseContainsBonus = 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.
3545 basePrefixHintBonus = 300
3646 baseContainsHintBonus = 120
3747
38− depthPenaltyDefault = 20
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).
3952 depthPenaltyPathHint = 5
4053)
4154
@@ -75,10 +88,11 @@ type Completions struct {
7588 focusedStyle lipgloss.Style
7689 matchStyle lipgloss.Style
7790
78− items []*CompletionItem
79− filtered []*CompletionItem
80− paths []string
81− bases []string
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.
8296}
8397
8498// New creates a new completions component.
@@ -164,6 +178,7 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
164178 c.open = true
165179 c.query = ""
166180 c.items = items
181+ // Pre-compute paths and basenames for efficient fuzzy matching.
167182 c.paths = make([]string, len(items))
168183 c.bases = make([]string, len(items))
169184 for i, item := range items {
@@ -171,6 +186,7 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
171186 c.paths[i] = path
172187 c.bases[i] = pathBase(path)
173188 }
189+ // Perform initial ranking with empty query (returns all items).
174190 c.filtered = c.rank(queryContext{
175191 query: c.query,
176192 })
@@ -202,6 +218,7 @@ func (c *Completions) Filter(query string) {
202218 }
203219
204220 c.query = query
221+ // Apply custom ranking algorithm instead of list's built-in filtering.
205222 c.filtered = c.rank(queryContext{
206223 query: query,
207224 })
@@ -358,17 +375,32 @@ type rankedItem struct {
358375}
359376
360377// 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
361390func (c *Completions) rank(ctx queryContext) []*CompletionItem {
362391 query := strings.TrimSpace(ctx.query)
363392 if query == "" {
393+ // Empty query: return all items with no highlights.
364394 for _, item := range c.items {
365395 item.SetMatch(fuzzy.Match{})
366396 }
367397 return c.items
368398 }
369399
400+ // Perform fuzzy matching on both full paths and basenames.
370401 fullMatches := matchIndex(query, c.paths)
371402 baseMatches := matchIndex(query, c.bases)
403+ // Collect unique item indices that matched either full path or basename.
372404 allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches))
373405 for idx := range fullMatches {
374406 allIndexes[idx] = struct{}{}
@@ -378,6 +410,7 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
378410 }
379411
380412 queryLower := strings.ToLower(query)
413+ // Detect if query looks like a path (contains / or \ or file extension).
381414 pathHint := hasPathHint(query)
382415 ranked := make([]rankedItem, 0, len(allIndexes))
383416 for idx := range allIndexes {
@@ -388,25 +421,32 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
388421 fullMatch, hasFullMatch := fullMatches[idx]
389422 baseMatch, hasBaseMatch := baseMatches[idx]
390423
424+ // Check for exact (case-insensitive) prefix/contains matches.
391425 pathPrefix := strings.HasPrefix(pathLower, queryLower)
392426 pathContains := strings.Contains(pathLower, queryLower)
393427 basePrefix := strings.HasPrefix(baseLower, queryLower)
394428 baseContains := strings.Contains(baseLower, queryLower)
395429
430+ // Calculate score by accumulating weighted components.
396431 score := 0
432+ // Fuzzy match scores (primary signals).
397433 if hasFullMatch {
398434 score += fullMatch.Score * fullMatchWeight
399435 }
400436 if hasBaseMatch {
401437 score += baseMatch.Score * baseMatchWeight
402438 }
439+ // Path-level exact match bonuses.
403440 if pathPrefix {
404441 score += pathPrefixBonus
405442 }
406443 if pathContains {
407444 score += pathContainsBonus
408445 }
446+ // Apply different bonuses based on whether query contains path hints.
409447 if pathHint {
448+ // User typed a path-like query (e.g., "src/main.go" or "main.go").
449+ // Prioritize full path matches.
410450 if pathContains {
411451 score += pathContainsHintBonus
412452 }
@@ -417,6 +457,8 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
417457 score += baseContainsHintBonus
418458 }
419459 } else {
460+ // User typed a simple query (e.g., "main").
461+ // Prioritize basename matches.
420462 if basePrefix {
421463 score += basePrefixBonus
422464 }
@@ -425,13 +467,17 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
425467 }
426468 }
427469
470+ // Apply penalties to discourage deeply nested files.
428471 depthPenalty := depthPenaltyDefault
429472 if pathHint {
473+ // Reduce penalty when user explicitly queries a path.
430474 depthPenalty = depthPenaltyPathHint
431475 }
432476 score -= strings.Count(path, "/") * depthPenalty
477+ // Minor penalty based on path length (favor shorter paths).
433478 score -= ansi.StringWidth(path)
434479
480+ // Choose which match to highlight based on weighted contribution.
435481 if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) {
436482 c.items[idx].SetMatch(fullMatch)
437483 } else if hasBaseMatch {
@@ -448,8 +494,10 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
448494
449495 slices.SortStableFunc(ranked, func(a, b rankedItem) int {
450496 if a.score != b.score {
497+ // Higher score first.
451498 return b.score - a.score
452499 }
500+ // Tie-breaker: sort alphabetically.
453501 return strings.Compare(a.item.Text(), b.item.Text())
454502 })
455503
@@ -460,6 +508,7 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
460508 return result
461509}
462510
511+// matchIndex performs fuzzy matching and returns a map of item index to match result.
463512func matchIndex(query string, values []string) map[int]fuzzy.Match {
464513 source := stringSource(values)
465514 matches := fuzzy.FindFrom(query, source)
@@ -470,6 +519,7 @@ func matchIndex(query string, values []string) map[int]fuzzy.Match {
470519 return result
471520}
472521
522+// stringSource adapts []string to fuzzy.Source interface.
473523type stringSource []string
474524
475525func (s stringSource) Len() int {
@@ -480,6 +530,11 @@ func (s stringSource) String(i int) string {
480530 return s[i]
481531}
482532
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/"
483538func pathBase(value string) string {
484539 trimmed := strings.TrimRight(value, `/\`)
485540 if trimmed == "" {
@@ -492,6 +547,11 @@ func pathBase(value string) string {
492547 return trimmed[idx+1:]
493548}
494549
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)
495555func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
496556 base := pathBase(fullPath)
497557 if base == "" {
@@ -506,21 +566,43 @@ func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
506566 return match
507567}
508568
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)
509585func hasPathHint(query string) bool {
510586 if strings.Contains(query, "/") || strings.Contains(query, "\\") {
511587 return true
512588 }
513589
590+ // Check for file extension pattern.
514591 lastDot := strings.LastIndex(query, ".")
515592 if lastDot < 0 || lastDot == len(query)-1 {
593+ // No dot or dot at end (e.g., "main" or "foo.").
516594 return false
517595 }
518596
519597 suffix := query[lastDot+1:]
520598 if len(suffix) > 12 {
599+ // Extension too long (unlikely to be a real extension).
521600 return false
522601 }
523602
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").
524606 hasLetter := false
525607 for _, r := range suffix {
526608 if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
internal/ui/completions/completions_test.go+19
@@ -8,6 +8,9 @@ import (
88 "github.com/stretchr/testify/require"
99)
1010
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".
1114func TestRankPrefersStrongBasenameMatch(t *testing.T) {
1215 t.Parallel()
1316
@@ -26,6 +29,8 @@ func TestRankPrefersStrongBasenameMatch(t *testing.T) {
2629 require.Equal(t, "user.go", ranked[0].Text())
2730}
2831
32+// TestRankReturnsOriginalOrderForEmptyQuery verifies that empty queries
33+// return all items in their original order without reordering.
2934func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
3035 t.Parallel()
3136
@@ -44,6 +49,9 @@ func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
4449 require.Equal(t, "a/user.go", ranked[1].Text())
4550}
4651
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.
4755func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
4856 t.Parallel()
4957
@@ -62,6 +70,9 @@ func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
6270 require.Equal(t, "internal/user.go", ranked[0].Text())
6371}
6472
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".
6576func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
6677 t.Parallel()
6778
@@ -79,6 +90,9 @@ func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
7990 require.Equal(t, "src/user.go", ranked[0].Text())
8091}
8192
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].
8296func TestRemapMatchToPath(t *testing.T) {
8397 t.Parallel()
8498
@@ -89,6 +103,11 @@ func TestRemapMatchToPath(t *testing.T) {
89103 require.Equal(t, []int{9, 10, 11}, match.MatchedIndexes)
90104}
91105
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)
92111func TestHasPathHint(t *testing.T) {
93112 t.Parallel()
94113