@@ -5,6 +5,7 @@ import (
"slices"
"strings"
"sync"
+ "unicode"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
@@ -14,6 +15,7 @@ import (
"github.com/charmbracelet/crush/internal/ui/list"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/ordered"
+ "github.com/sahilm/fuzzy"
)
const (
@@ -21,6 +23,20 @@ const (
maxHeight = 10
minWidth = 10
maxWidth = 100
+
+ fullMatchWeight = 1_000
+ baseMatchWeight = 300
+
+ pathPrefixBonus = 5_000
+ pathContainsBonus = 2_000
+ pathContainsHintBonus = 2_500
+ basePrefixBonus = 1_500
+ baseContainsBonus = 500
+ basePrefixHintBonus = 300
+ baseContainsHintBonus = 120
+
+ depthPenaltyDefault = 20
+ depthPenaltyPathHint = 5
)
// SelectionMsg is sent when a completion is selected.
@@ -58,6 +74,11 @@ type Completions struct {
normalStyle lipgloss.Style
focusedStyle lipgloss.Style
matchStyle lipgloss.Style
+
+ items []*CompletionItem
+ filtered []*CompletionItem
+ paths []string
+ bases []string
}
// New creates a new completions component.
@@ -87,7 +108,7 @@ func (c *Completions) Query() string {
// Size returns the visible size of the popup.
func (c *Completions) Size() (width, height int) {
- visible := len(c.list.FilteredItems())
+ visible := len(c.filtered)
return c.width, min(visible, c.height)
}
@@ -114,7 +135,7 @@ func (c *Completions) Open(depth, limit int) tea.Cmd {
// SetItems sets the files and MCP resources and rebuilds the merged list.
func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
- items := make([]list.FilterableItem, 0, len(files)+len(resources))
+ items := make([]*CompletionItem, 0, len(files)+len(resources))
// Add files first.
for _, file := range files {
@@ -142,8 +163,18 @@ func (c *Completions) SetItems(files []FileCompletionValue, resources []Resource
c.open = true
c.query = ""
- c.list.SetItems(items...)
- c.list.SetFilter("")
+ c.items = items
+ c.paths = make([]string, len(items))
+ c.bases = make([]string, len(items))
+ for i, item := range items {
+ path := item.Filter()
+ c.paths[i] = path
+ c.bases[i] = pathBase(path)
+ }
+ c.filtered = c.rank(queryContext{
+ query: c.query,
+ })
+ c.setVisibleItems(c.filtered)
c.list.Focus()
c.width = maxWidth
@@ -171,13 +202,16 @@ func (c *Completions) Filter(query string) {
}
c.query = query
- c.list.SetFilter(query)
+ c.filtered = c.rank(queryContext{
+ query: query,
+ })
+ c.setVisibleItems(c.filtered)
c.updateSize()
}
func (c *Completions) updateSize() {
- items := c.list.FilteredItems()
+ items := c.filtered
start, end := c.list.VisibleItemIndices()
width := 0
for i := start; i <= end; i++ {
@@ -197,7 +231,7 @@ func (c *Completions) updateSize() {
// HasItems returns whether there are visible items.
func (c *Completions) HasItems() bool {
- return len(c.list.FilteredItems()) > 0
+ return len(c.filtered) > 0
}
// Update handles key events for the completions.
@@ -236,7 +270,7 @@ func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
// selectPrev selects the previous item with circular navigation.
func (c *Completions) selectPrev() {
- items := c.list.FilteredItems()
+ items := c.filtered
if len(items) == 0 {
return
}
@@ -248,7 +282,7 @@ func (c *Completions) selectPrev() {
// selectNext selects the next item with circular navigation.
func (c *Completions) selectNext() {
- items := c.list.FilteredItems()
+ items := c.filtered
if len(items) == 0 {
return
}
@@ -260,7 +294,7 @@ func (c *Completions) selectNext() {
// selectCurrent returns a command with the currently selected item.
func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
- items := c.list.FilteredItems()
+ items := c.filtered
if len(items) == 0 {
return nil
}
@@ -270,10 +304,7 @@ func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
return nil
}
- item, ok := items[selected].(*CompletionItem)
- if !ok {
- return nil
- }
+ item := items[selected]
if !keepOpen {
c.open = false
@@ -301,7 +332,7 @@ func (c *Completions) Render() string {
return ""
}
- items := c.list.FilteredItems()
+ items := c.filtered
if len(items) == 0 {
return ""
}
@@ -309,6 +340,200 @@ func (c *Completions) Render() string {
return c.list.Render()
}
+func (c *Completions) setVisibleItems(items []*CompletionItem) {
+ filterables := make([]list.FilterableItem, 0, len(items))
+ for _, item := range items {
+ filterables = append(filterables, item)
+ }
+ c.list.SetItems(filterables...)
+}
+
+type queryContext struct {
+ query string
+}
+
+type rankedItem struct {
+ item *CompletionItem
+ score int
+}
+
+// rank uses path-first fuzzy ordering with basename as a secondary boost.
+func (c *Completions) rank(ctx queryContext) []*CompletionItem {
+ query := strings.TrimSpace(ctx.query)
+ if query == "" {
+ for _, item := range c.items {
+ item.SetMatch(fuzzy.Match{})
+ }
+ return c.items
+ }
+
+ fullMatches := matchIndex(query, c.paths)
+ baseMatches := matchIndex(query, c.bases)
+ allIndexes := make(map[int]struct{}, len(fullMatches)+len(baseMatches))
+ for idx := range fullMatches {
+ allIndexes[idx] = struct{}{}
+ }
+ for idx := range baseMatches {
+ allIndexes[idx] = struct{}{}
+ }
+
+ queryLower := strings.ToLower(query)
+ pathHint := hasPathHint(query)
+ ranked := make([]rankedItem, 0, len(allIndexes))
+ for idx := range allIndexes {
+ path := c.paths[idx]
+ pathLower := strings.ToLower(path)
+ baseLower := strings.ToLower(c.bases[idx])
+
+ fullMatch, hasFullMatch := fullMatches[idx]
+ baseMatch, hasBaseMatch := baseMatches[idx]
+
+ pathPrefix := strings.HasPrefix(pathLower, queryLower)
+ pathContains := strings.Contains(pathLower, queryLower)
+ basePrefix := strings.HasPrefix(baseLower, queryLower)
+ baseContains := strings.Contains(baseLower, queryLower)
+
+ score := 0
+ if hasFullMatch {
+ score += fullMatch.Score * fullMatchWeight
+ }
+ if hasBaseMatch {
+ score += baseMatch.Score * baseMatchWeight
+ }
+ if pathPrefix {
+ score += pathPrefixBonus
+ }
+ if pathContains {
+ score += pathContainsBonus
+ }
+ if pathHint {
+ if pathContains {
+ score += pathContainsHintBonus
+ }
+ if basePrefix {
+ score += basePrefixHintBonus
+ }
+ if baseContains {
+ score += baseContainsHintBonus
+ }
+ } else {
+ if basePrefix {
+ score += basePrefixBonus
+ }
+ if baseContains {
+ score += baseContainsBonus
+ }
+ }
+
+ depthPenalty := depthPenaltyDefault
+ if pathHint {
+ depthPenalty = depthPenaltyPathHint
+ }
+ score -= strings.Count(path, "/") * depthPenalty
+ score -= ansi.StringWidth(path)
+
+ if hasFullMatch && (!hasBaseMatch || fullMatch.Score*fullMatchWeight >= baseMatch.Score*baseMatchWeight) {
+ c.items[idx].SetMatch(fullMatch)
+ } else if hasBaseMatch {
+ c.items[idx].SetMatch(remapMatchToPath(baseMatch, path))
+ } else {
+ c.items[idx].SetMatch(fuzzy.Match{})
+ }
+
+ ranked = append(ranked, rankedItem{
+ item: c.items[idx],
+ score: score,
+ })
+ }
+
+ slices.SortStableFunc(ranked, func(a, b rankedItem) int {
+ if a.score != b.score {
+ return b.score - a.score
+ }
+ return strings.Compare(a.item.Text(), b.item.Text())
+ })
+
+ result := make([]*CompletionItem, 0, len(ranked))
+ for _, item := range ranked {
+ result = append(result, item.item)
+ }
+ return result
+}
+
+func matchIndex(query string, values []string) map[int]fuzzy.Match {
+ source := stringSource(values)
+ matches := fuzzy.FindFrom(query, source)
+ result := make(map[int]fuzzy.Match, len(matches))
+ for _, match := range matches {
+ result[match.Index] = match
+ }
+ return result
+}
+
+type stringSource []string
+
+func (s stringSource) Len() int {
+ return len(s)
+}
+
+func (s stringSource) String(i int) string {
+ return s[i]
+}
+
+func pathBase(value string) string {
+ trimmed := strings.TrimRight(value, `/\`)
+ if trimmed == "" {
+ return value
+ }
+ idx := strings.LastIndexAny(trimmed, `/\`)
+ if idx < 0 {
+ return trimmed
+ }
+ return trimmed[idx+1:]
+}
+
+func remapMatchToPath(match fuzzy.Match, fullPath string) fuzzy.Match {
+ base := pathBase(fullPath)
+ if base == "" {
+ return match
+ }
+ offset := len(fullPath) - len(base)
+ remapped := make([]int, 0, len(match.MatchedIndexes))
+ for _, idx := range match.MatchedIndexes {
+ remapped = append(remapped, offset+idx)
+ }
+ match.MatchedIndexes = remapped
+ return match
+}
+
+func hasPathHint(query string) bool {
+ if strings.Contains(query, "/") || strings.Contains(query, "\\") {
+ return true
+ }
+
+ lastDot := strings.LastIndex(query, ".")
+ if lastDot < 0 || lastDot == len(query)-1 {
+ return false
+ }
+
+ suffix := query[lastDot+1:]
+ if len(suffix) > 12 {
+ return false
+ }
+
+ hasLetter := false
+ for _, r := range suffix {
+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
+ return false
+ }
+ if unicode.IsLetter(r) {
+ hasLetter = true
+ }
+ }
+
+ return hasLetter
+}
+
func loadFiles(depth, limit int) []FileCompletionValue {
files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
slices.Sort(files)
@@ -0,0 +1,99 @@
+package completions
+
+import (
+ "testing"
+
+ "charm.land/lipgloss/v2"
+ "github.com/sahilm/fuzzy"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRankPrefersStrongBasenameMatch(t *testing.T) {
+ t.Parallel()
+
+ c := &Completions{
+ items: []*CompletionItem{
+ NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+ NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+ NewCompletionItem("internal/user_service.go", FileCompletionValue{Path: "internal/user_service.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+ },
+ paths: []string{"internal/ui/chat/search.go", "user.go", "internal/user_service.go"},
+ bases: []string{"search.go", "user.go", "user_service.go"},
+ }
+
+ ranked := c.rank(queryContext{query: "user"})
+ require.NotEmpty(t, ranked)
+ require.Equal(t, "user.go", ranked[0].Text())
+}
+
+func TestRankReturnsOriginalOrderForEmptyQuery(t *testing.T) {
+ t.Parallel()
+
+ c := &Completions{
+ items: []*CompletionItem{
+ NewCompletionItem("b/user.go", FileCompletionValue{Path: "b/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+ NewCompletionItem("a/user.go", FileCompletionValue{Path: "a/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+ },
+ paths: []string{"b/user.go", "a/user.go"},
+ bases: []string{"user.go", "user.go"},
+ }
+
+ ranked := c.rank(queryContext{query: ""})
+ require.Len(t, ranked, 2)
+ require.Equal(t, "b/user.go", ranked[0].Text())
+ require.Equal(t, "a/user.go", ranked[1].Text())
+}
+
+func TestRankPrefersPathMatchesWhenPathHintPresent(t *testing.T) {
+ t.Parallel()
+
+ c := &Completions{
+ items: []*CompletionItem{
+ NewCompletionItem("user.go", FileCompletionValue{Path: "user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+ NewCompletionItem("internal/user.go", FileCompletionValue{Path: "internal/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+ NewCompletionItem("internal/ui/chat/search.go", FileCompletionValue{Path: "internal/ui/chat/search.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+ },
+ paths: []string{"user.go", "internal/user.go", "internal/ui/chat/search.go"},
+ bases: []string{"user.go", "user.go", "search.go"},
+ }
+
+ ranked := c.rank(queryContext{query: "internal/u"})
+ require.NotEmpty(t, ranked)
+ require.Equal(t, "internal/user.go", ranked[0].Text())
+}
+
+func TestRankDotHintPrefersSuffixPathMatch(t *testing.T) {
+ t.Parallel()
+
+ c := &Completions{
+ items: []*CompletionItem{
+ NewCompletionItem("docs/go-guide.md", FileCompletionValue{Path: "docs/go-guide.md"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+ NewCompletionItem("src/user.go", FileCompletionValue{Path: "src/user.go"}, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()),
+ },
+ paths: []string{"docs/go-guide.md", "src/user.go"},
+ bases: []string{"go-guide.md", "user.go"},
+ }
+
+ ranked := c.rank(queryContext{query: ".go"})
+ require.NotEmpty(t, ranked)
+ require.Equal(t, "src/user.go", ranked[0].Text())
+}
+
+func TestRemapMatchToPath(t *testing.T) {
+ t.Parallel()
+
+ match := remapMatchToPath(
+ fuzzy.Match{MatchedIndexes: []int{0, 1, 2}},
+ "internal/user.go",
+ )
+ require.Equal(t, []int{9, 10, 11}, match.MatchedIndexes)
+}
+
+func TestHasPathHint(t *testing.T) {
+ t.Parallel()
+
+ require.True(t, hasPathHint("internal/u"))
+ require.True(t, hasPathHint("main.go"))
+ require.False(t, hasPathHint("v0.1"))
+ require.False(t, hasPathHint("main"))
+}