docs(ui): add comprehensive comments to completion ranking algorithm

wanghuaiyu@qiniu.com created

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

Change summary

internal/ui/completions/completions.go      | 100 ++++++++++++++++++++--
internal/ui/completions/completions_test.go |  19 ++++
2 files changed, 110 insertions(+), 9 deletions(-)

Detailed changes

internal/ui/completions/completions.go 🔗

@@ -24,18 +24,31 @@ 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
 
-	pathPrefixBonus       = 5_000
-	pathContainsBonus     = 2_000
+	// 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
-	basePrefixBonus       = 1_500
-	baseContainsBonus     = 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
 
-	depthPenaltyDefault  = 20
+	// 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
 )
 
@@ -75,10 +88,11 @@ type Completions struct {
 	focusedStyle lipgloss.Style
 	matchStyle   lipgloss.Style
 
-	items    []*CompletionItem
-	filtered []*CompletionItem
-	paths    []string
-	bases    []string
+	// 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.
 }
 
 // New creates a new completions component.
@@ -164,6 +178,7 @@ 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 {
@@ -171,6 +186,7 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
 		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,
 	})
@@ -202,6 +218,7 @@ 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,
 	})
@@ -358,17 +375,32 @@ type rankedItem struct {
 }
 
 // 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{}{}
@@ -378,6 +410,7 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
 	}
 
 	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 {
@@ -388,25 +421,32 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
 		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
 			}
@@ -417,6 +457,8 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
 				score += baseContainsHintBonus
 			}
 		} else {
+			// User typed a simple query (e.g., "main").
+			// Prioritize basename matches.
 			if basePrefix {
 				score += basePrefixBonus
 			}
@@ -425,13 +467,17 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
 			}
 		}
 
+		// 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 {
@@ -448,8 +494,10 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
 
 	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())
 	})
 
@@ -460,6 +508,7 @@ func (c *Completions) rank(ctx queryContext) []*CompletionItem {
 	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)
@@ -470,6 +519,7 @@ func matchIndex(query string, values []string) map[int]fuzzy.Match {
 	return result
 }
 
+// stringSource adapts []string to fuzzy.Source interface.
 type stringSource []string
 
 func (s stringSource) Len() int {
@@ -480,6 +530,11 @@ 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 == "" {
@@ -492,6 +547,11 @@ func pathBase(value string) string {
 	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 == "" {
@@ -506,21 +566,43 @@ func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
 	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 != '-' {

internal/ui/completions/completions_test.go 🔗

@@ -8,6 +8,9 @@ import (
 	"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) {
 	t.Parallel()
 
@@ -26,6 +29,8 @@ func TestRankPrefersStrongBasenameMatch(t *testing.T) {
 	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()
 
@@ -44,6 +49,9 @@ func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
 	require.Equal(t, "a/user.go", ranked[1].Text())
 }
 
+// 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) {
 	t.Parallel()
 
@@ -62,6 +70,9 @@ func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
 	require.Equal(t, "internal/user.go", ranked[0].Text())
 }
 
+// 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) {
 	t.Parallel()
 
@@ -79,6 +90,9 @@ func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
 	require.Equal(t, "src/user.go", ranked[0].Text())
 }
 
+// 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) {
 	t.Parallel()
 
@@ -89,6 +103,11 @@ func TestRemapMatchToPath(t *testing.T) {
 	require.Equal(t, []int{9, 10, 11}, match.MatchedIndexes)
 }
 
+// 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()