package tui
import (
"sort"
"strings"
)
// Fuzzy matching, for pickers whose list is long enough that scrolling it is
// slower than describing what you want. The rule is subsequence: every
// character typed has to appear, in order, somewhere in the candidate — so
// "dkmd" finds "dark-mode" — and the score decides which of the matches is
// offered first.
//
// The scoring is deliberately small. What people actually type is the start of
// a word or a run of letters from one, so those are what earn points; nothing
// here tries to be a ranking function, only to put the obvious answer on top.
// Scores for the three ways a character can land, and the cap on what skipping
// over characters costs. Consecutive beats a word start beats a stray hit.
const (
fuzzyRun = 10 // directly after the previous match
fuzzyBoundary = 8 // the first character of a word
fuzzyHit = 1 // anywhere else
fuzzyGapMax = 3 // the most one gap can cost
fuzzyLeadMax = 4 // the most a late start can cost
)
// fuzzyMatch reports whether pattern appears in s as a subsequence, and how
// good a match it is — higher is better. Matching is case-insensitive, and an
// empty pattern matches everything, which is what an unfiltered picker wants.
func fuzzyMatch(pattern, s string) (int, bool) {
if pattern == "" {
return 0, true
}
p := []rune(strings.ToLower(pattern))
t := []rune(strings.ToLower(s))
score, pi, prev := 0, 0, -1
for i := 0; i < len(t) && pi < len(p); i++ {
if t[i] != p[pi] {
continue
}
switch {
case i == prev+1 && prev >= 0:
score += fuzzyRun
case i == 0 || isWordBoundary(t[i-1]):
score += fuzzyBoundary
default:
score += fuzzyHit
}
// Skipping over characters to get here is what makes a loose match
// loose, and the penalty is capped so one long gap does not sink a
// candidate that is otherwise a good hit.
if prev >= 0 {
score -= minInt(fuzzyGapMax, i-prev-1)
} else {
// Where the match begins matters too: of two candidates that
// contain "par" as a word, "parser-rewrite" is the one being asked
// for and "old-parser" is the one that happens to contain it.
score -= minInt(fuzzyLeadMax, i)
}
prev, pi = i, pi+1
}
if pi < len(p) {
return 0, false
}
// Of two candidates matched equally well, the shorter one is the one
// meant: "api" should offer "api" before "rapid-prototyping".
return score - len(t)/8, true
}
// isWordBoundary says whether a character separates words, for the purpose of
// deciding that the next one starts a word. Worktree names are slugs and
// branches have slashes in them, so these are the separators that matter.
func isWordBoundary(r rune) bool {
switch r {
case '-', '_', '/', ' ', '.', ':', '@':
return true
}
return false
}
// fuzzyRank narrows a list to what matches pattern, best first. Ties keep the
// order they came in, so a list already sorted the way the picker wants it
// stays that way under an empty or undiscriminating filter.
func fuzzyRank[T any](items []T, pattern string, text func(T) string) []T {
type scored struct {
item T
score int
at int
}
var hits []scored
for i, it := range items {
if score, ok := fuzzyMatch(pattern, text(it)); ok {
hits = append(hits, scored{item: it, score: score, at: i})
}
}
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
return hits[i].at < hits[j].at
})
out := make([]T, 0, len(hits))
for _, h := range hits {
out = append(out, h.item)
}
return out
}