1package palette
2
3import (
4 "sort"
5 "strings"
6
7 "github.com/bnema/vev/internal/usecase/fuzzy"
8)
9
10// Match is a fuzzy-matched immutable palette result.
11type Match struct {
12 Result Result
13 Positions []int
14 rank int
15 span int
16 first int
17 order int
18 search string
19}
20
21// Fuzzy searches immutable palette results.
22func Fuzzy(results []Result, query string) []Match {
23 needle := strings.ToLower(query)
24 if query == "" {
25 out := make([]Match, 0, len(results))
26 for i, result := range results {
27 out = append(out, newMatch(result, i))
28 }
29 return out
30 }
31
32 needleRunes := []rune(needle)
33 var out []Match
34 for i, result := range results {
35 if match, ok := score(result, needle, needleRunes, i); ok {
36 out = append(out, match)
37 }
38 }
39 sort.SliceStable(out, func(i, j int) bool {
40 a, b := out[i], out[j]
41 aScore, bScore := fuzzy.Score{Span: a.span, First: a.first}, fuzzy.Score{Span: b.span, First: b.first}
42 if fuzzy.Less(a.rank, aScore, b.rank, bScore) {
43 return true
44 }
45 if fuzzy.Less(b.rank, bScore, a.rank, aScore) {
46 return false
47 }
48 if a.Result.Kind() != b.Result.Kind() {
49 return a.Result.Kind() < b.Result.Kind()
50 }
51 if a.search != b.search {
52 return a.search < b.search
53 }
54 return a.order < b.order
55 })
56 return out
57}
58
59func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) {
60 match := newMatch(result, order)
61
62 identity, label, offset, hasTerms := result.searchTerms()
63 if hasTerms {
64 exactRank := 1
65 if result.Kind() == ResultKindCommand {
66 exactRank = 0
67 }
68 best, matched := scoreField(identity, needle, needleRunes, offset, exactRank, 2, 3)
69 if label != identity {
70 if candidate, ok := scoreField(label, needle, needleRunes, offset, exactRank, 2, 3); ok && (!matched || candidate.betterThan(best)) {
71 best, matched = candidate, true
72 }
73 }
74 if matched {
75 best.apply(&match)
76 return match, true
77 }
78 }
79
80 // Action text remains searchable, but semantic codes and session labels
81 // outrank matches caused only by a rendered action prefix.
82 if fallback, ok := scoreField(match.search, needle, needleRunes, 0, 4, 4, 4); ok {
83 fallback.apply(&match)
84 return match, true
85 }
86 if cmd, ok := result.Command(); ok {
87 // Description matches only rank and never highlight, so Positions
88 // stays empty.
89 if matched, ok := fuzzy.Match(strings.ToLower(cmd.Desc), needle, needleRunes); ok {
90 match.rank, match.span, match.first = 5, matched.Span, matched.First
91 return match, true
92 }
93 }
94 return Match{}, false
95}
96
97type fieldScore struct {
98 positions []int
99 rank int
100 span int
101 first int
102}
103
104func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) {
105 matched, ok := fuzzy.Match(strings.ToLower(text), needle, needleRunes)
106 if !ok {
107 return fieldScore{}, false
108 }
109 scored := fieldScore{positions: matched.Positions, span: matched.Span, first: matched.First}
110 switch matched.Kind {
111 case fuzzy.Exact:
112 scored.rank = exactRank
113 case fuzzy.Prefix:
114 scored.rank = prefixRank
115 default:
116 scored.rank = subsequenceRank
117 }
118 for i := range scored.positions {
119 scored.positions[i] += displayOffset
120 }
121 return scored, true
122}
123
124func (s fieldScore) betterThan(other fieldScore) bool {
125 return fuzzy.Less(s.rank, fuzzy.Score{Span: s.span, First: s.first}, other.rank, fuzzy.Score{Span: other.span, First: other.first})
126}
127
128func (s fieldScore) apply(match *Match) {
129 match.Positions = s.positions
130 match.rank = s.rank
131 match.span = s.span
132 match.first = s.first
133}
134
135func newMatch(result Result, order int) Match {
136 return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}
137}