refactor(fuzzy): route palette ordering and description matches through the shared matcher
- commit
- b034df2b5cbd7f7942e7b69749bea8a7729454ce
- author
- brice · · 2026-10-08 09:03 UTC
- committer
- brice · · 2026-10-08 09:31 UTC
- parent
- 1c51966
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +22 -17
M internal/usecase/fuzzy/fuzzy.go +8 -7
| @@ -9,6 +9,7 @@ import "strings" | |||
| 9 | 9 | // Kind is how a field matched the query. | |
| 10 | 10 | type Kind uint8 | |
| 11 | 11 | ||
| 12 | + | // Kinds are ordered best first, so a caller may use the Kind itself as a rank. | |
| 12 | 13 | const ( | |
| 13 | 14 | Exact Kind = iota | |
| 14 | 15 | Prefix | |
| @@ -33,11 +34,11 @@ func Match(text, query string, needle []rune) (Score, bool) { | |||
| 33 | 34 | var score Score | |
| 34 | 35 | switch { | |
| 35 | 36 | case text == query: | |
| 36 | - | score = Score{Kind: Exact, Positions: Range(len(needle))} | |
| 37 | + | score = Score{Kind: Exact, Positions: rangePositions(len(needle))} | |
| 37 | 38 | case strings.HasPrefix(text, query): | |
| 38 | - | score = Score{Kind: Prefix, Positions: Range(len(needle))} | |
| 39 | + | score = Score{Kind: Prefix, Positions: rangePositions(len(needle))} | |
| 39 | 40 | default: | |
| 40 | - | positions, ok := SubsequencePositions([]rune(text), needle) | |
| 41 | + | positions, ok := subsequencePositions([]rune(text), needle) | |
| 41 | 42 | if !ok { | |
| 42 | 43 | return Score{}, false | |
| 43 | 44 | } | |
| @@ -60,8 +61,8 @@ func Less(leftRank int, left Score, rightRank int, right Score) bool { | |||
| 60 | 61 | return left.First < right.First | |
| 61 | 62 | } | |
| 62 | 63 | ||
| 63 | - | // Range returns the positions 0..n-1. | |
| 64 | - | func Range(n int) []int { | |
| 64 | + | // rangePositions returns the positions 0..n-1. | |
| 65 | + | func rangePositions(n int) []int { | |
| 65 | 66 | positions := make([]int, n) | |
| 66 | 67 | for i := range positions { | |
| 67 | 68 | positions[i] = i | |
| @@ -69,9 +70,9 @@ func Range(n int) []int { | |||
| 69 | 70 | return positions | |
| 70 | 71 | } | |
| 71 | 72 | ||
| 72 | - | // SubsequencePositions returns the first in-order positions of needle in | |
| 73 | + | // subsequencePositions returns the first in-order positions of needle in | |
| 73 | 74 | // haystack. | |
| 74 | - | func SubsequencePositions(haystack, needle []rune) ([]int, bool) { | |
| 75 | + | func subsequencePositions(haystack, needle []rune) ([]int, bool) { | |
| 75 | 76 | if len(needle) == 0 { | |
| 76 | 77 | return nil, true | |
| 77 | 78 | } | |
| @@ -9,6 +9,7 @@ import "strings" | |||||
| 9 | // Kind is how a field matched the query. | 9 | // Kind is how a field matched the query. | ||
| 10 | type Kind uint8 | 10 | type Kind uint8 | ||
| 11 | 11 | ||||
| 12 | + | // Kinds are ordered best first, so a caller may use the Kind itself as a rank. | |||
| 12 | const ( | 13 | const ( | ||
| 13 | Exact Kind = iota | 14 | Exact Kind = iota | ||
| 14 | Prefix | 15 | Prefix | ||
| @@ -33,11 +34,11 @@ func Match(text, query string, needle []rune) (Score, bool) { | |||||
| 33 | var score Score | 34 | var score Score | ||
| 34 | switch { | 35 | switch { | ||
| 35 | case text == query: | 36 | case text == query: | ||
| 36 | - | score = Score{Kind: Exact, Positions: Range(len(needle))} | 37 | + | score = Score{Kind: Exact, Positions: rangePositions(len(needle))} |
| 37 | case strings.HasPrefix(text, query): | 38 | case strings.HasPrefix(text, query): | ||
| 38 | - | score = Score{Kind: Prefix, Positions: Range(len(needle))} | 39 | + | score = Score{Kind: Prefix, Positions: rangePositions(len(needle))} |
| 39 | default: | 40 | default: | ||
| 40 | - | positions, ok := SubsequencePositions([]rune(text), needle) | 41 | + | positions, ok := subsequencePositions([]rune(text), needle) |
| 41 | if !ok { | 42 | if !ok { | ||
| 42 | return Score{}, false | 43 | return Score{}, false | ||
| 43 | } | 44 | } | ||
| @@ -60,8 +61,8 @@ func Less(leftRank int, left Score, rightRank int, right Score) bool { | |||||
| 60 | return left.First < right.First | 61 | return left.First < right.First | ||
| 61 | } | 62 | } | ||
| 62 | 63 | ||||
| 63 | - | // Range returns the positions 0..n-1. | 64 | + | // rangePositions returns the positions 0..n-1. |
| 64 | - | func Range(n int) []int { | 65 | + | func rangePositions(n int) []int { |
| 65 | positions := make([]int, n) | 66 | positions := make([]int, n) | ||
| 66 | for i := range positions { | 67 | for i := range positions { | ||
| 67 | positions[i] = i | 68 | positions[i] = i | ||
| @@ -69,9 +70,9 @@ func Range(n int) []int { | |||||
| 69 | return positions | 70 | return positions | ||
| 70 | } | 71 | } | ||
| 71 | 72 | ||||
| 72 | - | // SubsequencePositions returns the first in-order positions of needle in | 73 | + | // subsequencePositions returns the first in-order positions of needle in |
| 73 | // haystack. | 74 | // haystack. | ||
| 74 | - | func SubsequencePositions(haystack, needle []rune) ([]int, bool) { | 75 | + | func subsequencePositions(haystack, needle []rune) ([]int, bool) { |
| 75 | if len(needle) == 0 { | 76 | if len(needle) == 0 { | ||
| 76 | return nil, true | 77 | return nil, true | ||
| 77 | } | 78 | } | ||
M internal/usecase/fuzzy/fuzzy_test.go +8 -0
| @@ -19,6 +19,7 @@ func TestMatch(t *testing.T) { | |||
| 19 | 19 | {name: "prefix", text: "workspace", query: "wor", want: Score{Kind: Prefix, Positions: []int{0, 1, 2}, Span: 3}, ok: true}, | |
| 20 | 20 | {name: "subsequence", text: "a-b-c", query: "bc", want: Score{Kind: Subsequence, Positions: []int{2, 4}, Span: 3, First: 2}, ok: true}, | |
| 21 | 21 | {name: "runes not bytes", text: "été-x", query: "x", want: Score{Kind: Subsequence, Positions: []int{4}, Span: 1, First: 4}, ok: true}, | |
| 22 | + | {name: "multi-byte prefix counts runes", text: "été", query: "ét", want: Score{Kind: Prefix, Positions: []int{0, 1}, Span: 2}, ok: true}, | |
| 22 | 23 | {name: "no match", text: "work", query: "z", ok: false}, | |
| 23 | 24 | {name: "out of order", text: "ab", query: "ba", ok: false}, | |
| 24 | 25 | } | |
| @@ -31,6 +32,13 @@ func TestMatch(t *testing.T) { | |||
| 31 | 32 | } | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 35 | + | // TestKindOrder pins the best-first order callers rely on to use a Kind as a | |
| 36 | + | // rank. | |
| 37 | + | func TestKindOrder(t *testing.T) { | |
| 38 | + | require.Less(t, Exact, Prefix) | |
| 39 | + | require.Less(t, Prefix, Subsequence) | |
| 40 | + | } | |
| 41 | + | ||
| 34 | 42 | func TestLess(t *testing.T) { | |
| 35 | 43 | tests := []struct { | |
| 36 | 44 | name string | |
| @@ -19,6 +19,7 @@ func TestMatch(t *testing.T) { | |||||
| 19 | {name: "prefix", text: "workspace", query: "wor", want: Score{Kind: Prefix, Positions: []int{0, 1, 2}, Span: 3}, ok: true}, | 19 | {name: "prefix", text: "workspace", query: "wor", want: Score{Kind: Prefix, Positions: []int{0, 1, 2}, Span: 3}, ok: true}, | ||
| 20 | {name: "subsequence", text: "a-b-c", query: "bc", want: Score{Kind: Subsequence, Positions: []int{2, 4}, Span: 3, First: 2}, ok: true}, | 20 | {name: "subsequence", text: "a-b-c", query: "bc", want: Score{Kind: Subsequence, Positions: []int{2, 4}, Span: 3, First: 2}, ok: true}, | ||
| 21 | {name: "runes not bytes", text: "été-x", query: "x", want: Score{Kind: Subsequence, Positions: []int{4}, Span: 1, First: 4}, ok: true}, | 21 | {name: "runes not bytes", text: "été-x", query: "x", want: Score{Kind: Subsequence, Positions: []int{4}, Span: 1, First: 4}, ok: true}, | ||
| 22 | + | {name: "multi-byte prefix counts runes", text: "été", query: "ét", want: Score{Kind: Prefix, Positions: []int{0, 1}, Span: 2}, ok: true}, | |||
| 22 | {name: "no match", text: "work", query: "z", ok: false}, | 23 | {name: "no match", text: "work", query: "z", ok: false}, | ||
| 23 | {name: "out of order", text: "ab", query: "ba", ok: false}, | 24 | {name: "out of order", text: "ab", query: "ba", ok: false}, | ||
| 24 | } | 25 | } | ||
| @@ -31,6 +32,13 @@ func TestMatch(t *testing.T) { | |||||
| 31 | } | 32 | } | ||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 35 | + | // TestKindOrder pins the best-first order callers rely on to use a Kind as a | |||
| 36 | + | // rank. | |||
| 37 | + | func TestKindOrder(t *testing.T) { | |||
| 38 | + | require.Less(t, Exact, Prefix) | |||
| 39 | + | require.Less(t, Prefix, Subsequence) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 34 | func TestLess(t *testing.T) { | 42 | func TestLess(t *testing.T) { | ||
| 35 | tests := []struct { | 43 | tests := []struct { | ||
| 36 | name string | 44 | name string | ||
M internal/usecase/palette/fuzzy.go +6 -10
| @@ -38,14 +38,8 @@ func Fuzzy(results []Result, query string) []Match { | |||
| 38 | 38 | } | |
| 39 | 39 | sort.SliceStable(out, func(i, j int) bool { | |
| 40 | 40 | a, b := out[i], out[j] | |
| 41 | - | if a.rank != b.rank { | |
| 42 | - | return a.rank < b.rank | |
| 43 | - | } | |
| 44 | - | if a.span != b.span { | |
| 45 | - | return a.span < b.span | |
| 46 | - | } | |
| 47 | - | if a.first != b.first { | |
| 48 | - | return a.first < b.first | |
| 41 | + | if a.rank != b.rank || a.span != b.span || a.first != b.first { | |
| 42 | + | return fuzzy.Less(a.rank, fuzzy.Score{Span: a.span, First: a.first}, b.rank, fuzzy.Score{Span: b.span, First: b.first}) | |
| 49 | 43 | } | |
| 50 | 44 | if a.Result.Kind() != b.Result.Kind() { | |
| 51 | 45 | return a.Result.Kind() < b.Result.Kind() | |
| @@ -86,8 +80,10 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match, | |||
| 86 | 80 | return match, true | |
| 87 | 81 | } | |
| 88 | 82 | if cmd, ok := result.Command(); ok { | |
| 89 | - | if positions, ok := fuzzy.SubsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { | |
| 90 | - | match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0] | |
| 83 | + | // Description matches only rank and never highlight, so Positions | |
| 84 | + | // stays empty. | |
| 85 | + | if matched, ok := fuzzy.Match(strings.ToLower(cmd.Desc), needle, needleRunes); ok { | |
| 86 | + | match.rank, match.span, match.first = 5, matched.Span, matched.First | |
| 91 | 87 | return match, true | |
| 92 | 88 | } | |
| 93 | 89 | } | |
| @@ -38,14 +38,8 @@ func Fuzzy(results []Result, query string) []Match { | |||||
| 38 | } | 38 | } | ||
| 39 | sort.SliceStable(out, func(i, j int) bool { | 39 | sort.SliceStable(out, func(i, j int) bool { | ||
| 40 | a, b := out[i], out[j] | 40 | a, b := out[i], out[j] | ||
| 41 | - | if a.rank != b.rank { | 41 | + | if a.rank != b.rank || a.span != b.span || a.first != b.first { |
| 42 | - | return a.rank < b.rank | 42 | + | return fuzzy.Less(a.rank, fuzzy.Score{Span: a.span, First: a.first}, b.rank, fuzzy.Score{Span: b.span, First: b.first}) |
| 43 | - | } | |||
| 44 | - | if a.span != b.span { | |||
| 45 | - | return a.span < b.span | |||
| 46 | - | } | |||
| 47 | - | if a.first != b.first { | |||
| 48 | - | return a.first < b.first | |||
| 49 | } | 43 | } | ||
| 50 | if a.Result.Kind() != b.Result.Kind() { | 44 | if a.Result.Kind() != b.Result.Kind() { | ||
| 51 | return a.Result.Kind() < b.Result.Kind() | 45 | return a.Result.Kind() < b.Result.Kind() | ||
| @@ -86,8 +80,10 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match, | |||||
| 86 | return match, true | 80 | return match, true | ||
| 87 | } | 81 | } | ||
| 88 | if cmd, ok := result.Command(); ok { | 82 | if cmd, ok := result.Command(); ok { | ||
| 89 | - | if positions, ok := fuzzy.SubsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { | 83 | + | // Description matches only rank and never highlight, so Positions |
| 90 | - | match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0] | 84 | + | // stays empty. |
| 85 | + | if matched, ok := fuzzy.Match(strings.ToLower(cmd.Desc), needle, needleRunes); ok { | |||
| 86 | + | match.rank, match.span, match.first = 5, matched.Span, matched.First | |||
| 91 | return match, true | 87 | return match, true | ||
| 92 | } | 88 | } | ||
| 93 | } | 89 | } | ||