From 09da90a2a6b9c7b0a498304f1fb924a3012e750b Mon Sep 17 00:00:00 2001 From: "wanghuaiyu@qiniu.com" Date: Thu, 19 Mar 2026 19:41:36 +0800 Subject: [PATCH] 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 --- internal/ui/completions/completions.go | 100 ++++++++++++++++++-- internal/ui/completions/completions_test.go | 19 ++++ 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index 71f207f5727a730e1e21619482d6bf1b07e6c8ea..bb9881d9779d085a76d3d9aafe21c0e1565d3da7 100644 --- a/internal/ui/completions/completions.go +++ b/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 != '-' { diff --git a/internal/ui/completions/completions_test.go b/internal/ui/completions/completions_test.go index be4b6cb42925aeba0dc67701aa9217d3a867f1e7..27faabe3c9833a2efcf13dfa33cdf5f51396f3f4 100644 --- a/internal/ui/completions/completions_test.go +++ b/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()