commit
edf6e79327
134 files changed
+27239
No files matched your search
@@ -0,0 +1,218 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func names(items []Item, hits []Hit) []string {
|
||||
var out []string
|
||||
for _, h := range hits {
|
||||
out = append(out, items[h.Index].Text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func plain(texts ...string) []Item {
|
||||
var out []Item
|
||||
for _, t := range texts {
|
||||
out = append(out, Item{Text: t, NameEnd: len([]rune(t))})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRanking(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
query string
|
||||
items []string
|
||||
want []string // the order of the hits
|
||||
}{
|
||||
// A word that's there beats letters scattered through a name.
|
||||
{"prod", []string{"p-r-o-d-ucer", "kv-app-prod"}, []string{"kv-app-prod", "p-r-o-d-ucer"}},
|
||||
// At the start of a word beats inside one.
|
||||
{"pass", []string{"bypass-token", "db-password"}, []string{"db-password", "bypass-token"}},
|
||||
// Equal scores: the shorter first.
|
||||
{"db", []string{"db-password-old-backup", "db-password"}, []string{"db-password", "db-password-old-backup"}},
|
||||
// Terms in any order, all of them.
|
||||
{"pass prod", []string{"kv-app-prod/db-password", "kv-app-dev/db-password", "kv-app-prod/api-key"}, []string{"kv-app-prod/db-password"}},
|
||||
// Either.
|
||||
{"pass | key", []string{"db-password", "api-key", "storage-account"}, []string{"db-password", "api-key"}},
|
||||
// Exact, prefix, suffix, without.
|
||||
{"'prod", []string{"p-r-o-d", "kv-prod"}, []string{"kv-prod"}},
|
||||
{"^kv-", []string{"old-kv-app", "kv-app"}, []string{"kv-app"}},
|
||||
{"-dev$", []string{"kv-dev-old", "kv-dev"}, []string{"kv-dev"}},
|
||||
{"kv !dev", []string{"kv-dev", "kv-prod"}, []string{"kv-prod"}},
|
||||
// Smartcase.
|
||||
{"API", []string{"api-key", "API-KEY"}, []string{"API-KEY"}},
|
||||
{"api", []string{"api-key", "API-KEY"}, []string{"api-key", "API-KEY"}},
|
||||
} {
|
||||
items := plain(c.items...)
|
||||
if got := names(items, Rank(Parse(c.query), items)); !slices.Equal(got, c.want) {
|
||||
t.Errorf("%q: %v, want %v", c.query, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameBeatsContext(t *testing.T) {
|
||||
// "secret · note": a hit in the name beats the same hit in the note.
|
||||
items := []Item{
|
||||
{Text: "api-key the password for the api", NameEnd: len("api-key")},
|
||||
{Text: "sub / vault / password", NameStart: len("sub / vault / "), NameEnd: len("sub / vault / password")},
|
||||
}
|
||||
got := names(items, Rank(Parse("password"), items))
|
||||
if got[0] != "sub / vault / password" {
|
||||
t.Errorf("%v: the name should win over a note", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositions(t *testing.T) {
|
||||
items := plain("kv-app-prød")
|
||||
hits := Rank(Parse("prød"), items)
|
||||
if len(hits) != 1 || !slices.Equal(hits[0].Pos, []int{7, 8, 9, 10}) {
|
||||
t.Errorf("%+v: want rune positions 7-10", hits)
|
||||
}
|
||||
hits = Rank(Parse("^kv"), items)
|
||||
if len(hits) != 1 || !slices.Equal(hits[0].Pos, []int{0, 1}) {
|
||||
t.Errorf("prefix: %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnchorsOnTheName(t *testing.T) {
|
||||
path := func(p, name string) Item {
|
||||
return Item{Text: p + name + " · ops", NameStart: len([]rune(p)), NameEnd: len([]rune(p + name))}
|
||||
}
|
||||
items := []Item{
|
||||
path(" platform-prod / ", "kv-app-prod"),
|
||||
path(" platform-prod / kv-app-prod / ", "db-password"),
|
||||
path(" platform-dev / ", "kv-app-dev"),
|
||||
}
|
||||
got := names(items, Rank(Parse("^kv- !dev"), items))
|
||||
if len(got) != 1 || got[0] != items[0].Text {
|
||||
t.Errorf("^kv- !dev: %v, want just the kv-app-prod vault", got)
|
||||
}
|
||||
hits := Rank(Parse("^kv"), items[:1])
|
||||
start := len([]rune(" platform-prod / "))
|
||||
if len(hits) != 1 || !slices.Equal(hits[0].Pos, []int{start, start + 1}) {
|
||||
t.Errorf("^kv positions %v, want %d-%d", hits, start, start+1)
|
||||
}
|
||||
if got := names(items, Rank(Parse("password$"), items)); len(got) != 1 || got[0] != items[1].Text {
|
||||
t.Errorf("password$: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmpty(t *testing.T) {
|
||||
if !Parse(" | ").Empty() || Parse("x").Empty() {
|
||||
t.Error("Empty")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user