Release / Build and publish (push) Successful in 23s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
219 lines
5.3 KiB
Go
219 lines
5.3 KiB
Go
// Package match filters and ranks lists the way fzf (and snacks.nvim's
|
|
// picker, a port of it) does: fzf's own scoring, so a match at the start
|
|
// of a word or in one run of characters beats letters scattered through a
|
|
// name, and fzf's extended search syntax:
|
|
//
|
|
// prod db both terms, in any order
|
|
// db | pw either
|
|
// 'prod exactly "prod", not fuzzy
|
|
// ^kv- -dev$ starts with / ends with
|
|
// !test without "test"
|
|
//
|
|
// ^ and $ anchor to an item's name, the part of it matches count most
|
|
// for (a secret's name rather than the path to it). Lowercase ignores case; an uppercase letter makes that term exact
|
|
// about case (smartcase). Results sort by score, then the shorter text,
|
|
// then their order in the list.
|
|
package match
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/junegunn/fzf/src/algo"
|
|
"github.com/junegunn/fzf/src/util"
|
|
)
|
|
|
|
func init() { algo.Init("default") }
|
|
|
|
// nameBonus is added to a term's score when all its letters are in the
|
|
// item's name rather than in its context (a path, a note), like snacks'
|
|
// filename_bonus: "pass" should find db-password before a secret that
|
|
// only lives in a vault called "passwords".
|
|
const nameBonus = 24
|
|
|
|
type kind int
|
|
|
|
const (
|
|
fuzzy kind = iota
|
|
exact
|
|
prefix
|
|
suffix
|
|
equal
|
|
)
|
|
|
|
type term struct {
|
|
kind kind
|
|
inverse bool
|
|
caseSensitive bool
|
|
text []rune
|
|
}
|
|
|
|
// Query is a parsed search: every group must match (AND), a group
|
|
// matches when any of its terms does (OR).
|
|
type Query struct {
|
|
groups [][]term
|
|
}
|
|
|
|
// Parse reads a search in fzf's extended syntax.
|
|
func Parse(q string) Query {
|
|
var out Query
|
|
or := false
|
|
for _, tok := range strings.Fields(q) {
|
|
if tok == "|" {
|
|
or = true
|
|
continue
|
|
}
|
|
t, ok := parseTerm(tok)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if n := len(out.groups); or && n > 0 {
|
|
out.groups[n-1] = append(out.groups[n-1], t)
|
|
} else {
|
|
out.groups = append(out.groups, []term{t})
|
|
}
|
|
or = false
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseTerm(tok string) (term, bool) {
|
|
t := term{kind: fuzzy}
|
|
if s, ok := strings.CutPrefix(tok, "!"); ok {
|
|
t.inverse, t.kind, tok = true, exact, s
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(tok, "'"):
|
|
t.kind, tok = exact, tok[1:]
|
|
case strings.HasPrefix(tok, "^"):
|
|
t.kind, tok = prefix, tok[1:]
|
|
}
|
|
if s, ok := strings.CutSuffix(tok, "$"); ok && s != "" {
|
|
if t.kind == prefix {
|
|
t.kind = equal
|
|
} else {
|
|
t.kind = suffix
|
|
}
|
|
tok = s
|
|
}
|
|
if tok == "" {
|
|
return term{}, false
|
|
}
|
|
t.caseSensitive = strings.ToLower(tok) != tok
|
|
if !t.caseSensitive {
|
|
tok = strings.ToLower(tok)
|
|
}
|
|
t.text = []rune(tok)
|
|
return t, true
|
|
}
|
|
|
|
// Empty reports whether the query has no terms: everything matches.
|
|
func (q Query) Empty() bool { return len(q.groups) == 0 }
|
|
|
|
// Item is a row to match: Text is everything searched, and runes
|
|
// NameStart up to NameEnd of it are the row's name (the rest is context,
|
|
// like a path or a note). Without a name, nothing gets the name bonus.
|
|
type Item struct {
|
|
Text string
|
|
NameStart, NameEnd int
|
|
}
|
|
|
|
// Hit is a matching item: its index in the list, score, and the rune
|
|
// positions of the matched letters in its Text.
|
|
type Hit struct {
|
|
Index int
|
|
Score int
|
|
Pos []int
|
|
}
|
|
|
|
// Rank returns the items matching q, best first.
|
|
func Rank(q Query, items []Item) []Hit {
|
|
var hits []Hit
|
|
for i, it := range items {
|
|
if h, ok := q.match(it); ok {
|
|
h.Index = i
|
|
hits = append(hits, h)
|
|
}
|
|
}
|
|
slices.SortStableFunc(hits, func(a, b Hit) int {
|
|
if a.Score != b.Score {
|
|
return b.Score - a.Score
|
|
}
|
|
return len(items[a.Index].Text) - len(items[b.Index].Text)
|
|
})
|
|
return hits
|
|
}
|
|
|
|
func (q Query) match(it Item) (Hit, bool) {
|
|
chars := util.ToChars([]byte(it.Text))
|
|
// ^ and $ anchor to the name: "vaults starting with kv-", not to the
|
|
// path before it.
|
|
name := chars
|
|
if it.NameEnd > 0 {
|
|
runes := []rune(it.Text)
|
|
name = util.RunesToChars(runes[it.NameStart:min(it.NameEnd, len(runes))])
|
|
}
|
|
var h Hit
|
|
for _, group := range q.groups {
|
|
best, matched := 0, false
|
|
var bestPos []int
|
|
for _, t := range group {
|
|
in, offset := &chars, 0
|
|
if t.kind == prefix || t.kind == suffix || t.kind == equal {
|
|
in, offset = &name, it.NameStart
|
|
}
|
|
score, pos, ok := t.match(in, offset, it.NameStart, it.NameEnd)
|
|
if ok && (!matched || score > best) {
|
|
best, bestPos, matched = score, pos, true
|
|
}
|
|
}
|
|
if !matched {
|
|
return Hit{}, false
|
|
}
|
|
h.Score += best
|
|
h.Pos = append(h.Pos, bestPos...)
|
|
}
|
|
slices.Sort(h.Pos)
|
|
h.Pos = slices.Compact(h.Pos)
|
|
return h, true
|
|
}
|
|
|
|
// match matches t in chars, whose first rune is at offset in the item's
|
|
// text: positions are in the text.
|
|
func (t term) match(chars *util.Chars, offset, nameStart, nameEnd int) (int, []int, bool) {
|
|
fn := algo.FuzzyMatchV2
|
|
switch t.kind {
|
|
case exact:
|
|
fn = algo.ExactMatchNaive
|
|
case prefix:
|
|
fn = algo.PrefixMatch
|
|
case suffix:
|
|
fn = algo.SuffixMatch
|
|
case equal:
|
|
fn = algo.EqualMatch
|
|
}
|
|
res, pos := fn(t.caseSensitive, false, true, chars, t.text, !t.inverse, nil)
|
|
found := res.Start >= 0
|
|
if t.inverse {
|
|
return 0, nil, !found
|
|
}
|
|
if !found {
|
|
return 0, nil, false
|
|
}
|
|
var p []int
|
|
if pos != nil {
|
|
for _, i := range *pos {
|
|
p = append(p, i+offset)
|
|
}
|
|
} else { // prefix, suffix and equal give a range
|
|
for i := res.Start; i < res.End; i++ {
|
|
p = append(p, i+offset)
|
|
}
|
|
}
|
|
score := res.Score
|
|
if len(p) > 0 && slices.Min(p) >= nameStart && slices.Max(p) < nameEnd {
|
|
score += nameBonus
|
|
}
|
|
return score, p, true
|
|
}
|