@@ -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 != '-' {
@@ -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()