refactor(usecase): share one fuzzy matcher between picker and palette
internal/usecase/fuzzy owns exact, prefix and subsequence matching and the span/first tie-break. The session picker and command palette keep their own rank tables on top of it.
- commit
- 8e56f1629b5eadc9cf8ec49f8cc056347d1de8c3
- author
- brice · · 2026-10-08 05:06 UTC
- parent
- e15d552
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +164 -97
M docs/architecture.md +1 -0
| @@ -25,6 +25,7 @@ Every result keeps its request ID for strict correlation. | |||
| 25 | 25 | envelope, no type byte). | |
| 26 | 26 | - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations. | |
| 27 | 27 | - `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer. | |
| 28 | + | - `internal/usecase/fuzzy`: the exact/prefix/subsequence matcher and tie-break order shared by the session picker and the command palette; each caller maps match kinds to its own ranks. | |
| 28 | 29 | - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition. | |
| 29 | 30 | - `internal/adapters/sessionwire`: translates between typed session | |
| 30 | 31 | connections and raw wire transports: directional `oneof` envelope | |
| @@ -25,6 +25,7 @@ Every result keeps its request ID for strict correlation. | |||||
| 25 | envelope, no type byte). | 25 | envelope, no type byte). | ||
| 26 | - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations. | 26 | - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations. | ||
| 27 | - `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer. | 27 | - `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer. | ||
| 28 | + | - `internal/usecase/fuzzy`: the exact/prefix/subsequence matcher and tie-break order shared by the session picker and the command palette; each caller maps match kinds to its own ranks. | |||
| 28 | - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition. | 29 | - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition. | ||
| 29 | - `internal/adapters/sessionwire`: translates between typed session | 30 | - `internal/adapters/sessionwire`: translates between typed session | ||
| 30 | connections and raw wire transports: directional `oneof` envelope | 31 | connections and raw wire transports: directional `oneof` envelope | ||
A internal/usecase/fuzzy/fuzzy.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | // Package fuzzy scores one already case-folded text against a folded query. | |
| 2 | + | // | |
| 3 | + | // A field matches exactly, by prefix, or as an in-order rune subsequence. | |
| 4 | + | // Callers map each Kind to their own rank and order matches with Less. | |
| 5 | + | package fuzzy | |
| 6 | + | ||
| 7 | + | import "strings" | |
| 8 | + | ||
| 9 | + | // Kind is how a field matched the query. | |
| 10 | + | type Kind uint8 | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | Exact Kind = iota | |
| 14 | + | Prefix | |
| 15 | + | Subsequence | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Score is one field match. Positions are rune indexes into the text. | |
| 19 | + | type Score struct { | |
| 20 | + | Kind Kind | |
| 21 | + | Positions []int | |
| 22 | + | // Span is the rune distance from the first to the last matched position. | |
| 23 | + | Span int | |
| 24 | + | First int | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Match scores folded text against query, whose runes are needle. An empty | |
| 28 | + | // needle matches as Exact with no positions. | |
| 29 | + | func Match(text, query string, needle []rune) (Score, bool) { | |
| 30 | + | if len(needle) == 0 { | |
| 31 | + | return Score{Kind: Exact}, true | |
| 32 | + | } | |
| 33 | + | var score Score | |
| 34 | + | switch { | |
| 35 | + | case text == query: | |
| 36 | + | score = Score{Kind: Exact, Positions: Range(len(needle))} | |
| 37 | + | case strings.HasPrefix(text, query): | |
| 38 | + | score = Score{Kind: Prefix, Positions: Range(len(needle))} | |
| 39 | + | default: | |
| 40 | + | positions, ok := SubsequencePositions([]rune(text), needle) | |
| 41 | + | if !ok { | |
| 42 | + | return Score{}, false | |
| 43 | + | } | |
| 44 | + | score = Score{Kind: Subsequence, Positions: positions} | |
| 45 | + | } | |
| 46 | + | score.Span = score.Positions[len(score.Positions)-1] - score.Positions[0] + 1 | |
| 47 | + | score.First = score.Positions[0] | |
| 48 | + | return score, true | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Less orders two scores whose ranks the caller derived from their Kind: | |
| 52 | + | // lower rank, then tighter span, then earlier first position. | |
| 53 | + | func Less(leftRank int, left Score, rightRank int, right Score) bool { | |
| 54 | + | if leftRank != rightRank { | |
| 55 | + | return leftRank < rightRank | |
| 56 | + | } | |
| 57 | + | if left.Span != right.Span { | |
| 58 | + | return left.Span < right.Span | |
| 59 | + | } | |
| 60 | + | return left.First < right.First | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Range returns the positions 0..n-1. | |
| 64 | + | func Range(n int) []int { | |
| 65 | + | positions := make([]int, n) | |
| 66 | + | for i := range positions { | |
| 67 | + | positions[i] = i | |
| 68 | + | } | |
| 69 | + | return positions | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // SubsequencePositions returns the first in-order positions of needle in | |
| 73 | + | // haystack. | |
| 74 | + | func SubsequencePositions(haystack, needle []rune) ([]int, bool) { | |
| 75 | + | if len(needle) == 0 { | |
| 76 | + | return nil, true | |
| 77 | + | } | |
| 78 | + | positions := make([]int, 0, len(needle)) | |
| 79 | + | next := 0 | |
| 80 | + | for idx, r := range haystack { | |
| 81 | + | if r != needle[next] { | |
| 82 | + | continue | |
| 83 | + | } | |
| 84 | + | positions = append(positions, idx) | |
| 85 | + | next++ | |
| 86 | + | if next == len(needle) { | |
| 87 | + | return positions, true | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | return nil, false | |
| 91 | + | } | |
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | // Package fuzzy scores one already case-folded text against a folded query. | |||
| 2 | + | // | |||
| 3 | + | // A field matches exactly, by prefix, or as an in-order rune subsequence. | |||
| 4 | + | // Callers map each Kind to their own rank and order matches with Less. | |||
| 5 | + | package fuzzy | |||
| 6 | + | ||||
| 7 | + | import "strings" | |||
| 8 | + | ||||
| 9 | + | // Kind is how a field matched the query. | |||
| 10 | + | type Kind uint8 | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | Exact Kind = iota | |||
| 14 | + | Prefix | |||
| 15 | + | Subsequence | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // Score is one field match. Positions are rune indexes into the text. | |||
| 19 | + | type Score struct { | |||
| 20 | + | Kind Kind | |||
| 21 | + | Positions []int | |||
| 22 | + | // Span is the rune distance from the first to the last matched position. | |||
| 23 | + | Span int | |||
| 24 | + | First int | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // Match scores folded text against query, whose runes are needle. An empty | |||
| 28 | + | // needle matches as Exact with no positions. | |||
| 29 | + | func Match(text, query string, needle []rune) (Score, bool) { | |||
| 30 | + | if len(needle) == 0 { | |||
| 31 | + | return Score{Kind: Exact}, true | |||
| 32 | + | } | |||
| 33 | + | var score Score | |||
| 34 | + | switch { | |||
| 35 | + | case text == query: | |||
| 36 | + | score = Score{Kind: Exact, Positions: Range(len(needle))} | |||
| 37 | + | case strings.HasPrefix(text, query): | |||
| 38 | + | score = Score{Kind: Prefix, Positions: Range(len(needle))} | |||
| 39 | + | default: | |||
| 40 | + | positions, ok := SubsequencePositions([]rune(text), needle) | |||
| 41 | + | if !ok { | |||
| 42 | + | return Score{}, false | |||
| 43 | + | } | |||
| 44 | + | score = Score{Kind: Subsequence, Positions: positions} | |||
| 45 | + | } | |||
| 46 | + | score.Span = score.Positions[len(score.Positions)-1] - score.Positions[0] + 1 | |||
| 47 | + | score.First = score.Positions[0] | |||
| 48 | + | return score, true | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Less orders two scores whose ranks the caller derived from their Kind: | |||
| 52 | + | // lower rank, then tighter span, then earlier first position. | |||
| 53 | + | func Less(leftRank int, left Score, rightRank int, right Score) bool { | |||
| 54 | + | if leftRank != rightRank { | |||
| 55 | + | return leftRank < rightRank | |||
| 56 | + | } | |||
| 57 | + | if left.Span != right.Span { | |||
| 58 | + | return left.Span < right.Span | |||
| 59 | + | } | |||
| 60 | + | return left.First < right.First | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Range returns the positions 0..n-1. | |||
| 64 | + | func Range(n int) []int { | |||
| 65 | + | positions := make([]int, n) | |||
| 66 | + | for i := range positions { | |||
| 67 | + | positions[i] = i | |||
| 68 | + | } | |||
| 69 | + | return positions | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // SubsequencePositions returns the first in-order positions of needle in | |||
| 73 | + | // haystack. | |||
| 74 | + | func SubsequencePositions(haystack, needle []rune) ([]int, bool) { | |||
| 75 | + | if len(needle) == 0 { | |||
| 76 | + | return nil, true | |||
| 77 | + | } | |||
| 78 | + | positions := make([]int, 0, len(needle)) | |||
| 79 | + | next := 0 | |||
| 80 | + | for idx, r := range haystack { | |||
| 81 | + | if r != needle[next] { | |||
| 82 | + | continue | |||
| 83 | + | } | |||
| 84 | + | positions = append(positions, idx) | |||
| 85 | + | next++ | |||
| 86 | + | if next == len(needle) { | |||
| 87 | + | return positions, true | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | return nil, false | |||
| 91 | + | } | |||
A internal/usecase/fuzzy/fuzzy_test.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | package fuzzy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestMatch(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | text string | |
| 13 | + | query string | |
| 14 | + | want Score | |
| 15 | + | ok bool | |
| 16 | + | }{ | |
| 17 | + | {name: "empty query", text: "work", query: "", want: Score{Kind: Exact}, ok: true}, | |
| 18 | + | {name: "exact", text: "work", query: "work", want: Score{Kind: Exact, Positions: []int{0, 1, 2, 3}, Span: 4}, 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}, | |
| 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: "no match", text: "work", query: "z", ok: false}, | |
| 23 | + | {name: "out of order", text: "ab", query: "ba", ok: false}, | |
| 24 | + | } | |
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | got, ok := Match(tt.text, tt.query, []rune(tt.query)) | |
| 28 | + | require.Equal(t, tt.ok, ok) | |
| 29 | + | require.Equal(t, tt.want, got) | |
| 30 | + | }) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestLess(t *testing.T) { | |
| 35 | + | tests := []struct { | |
| 36 | + | name string | |
| 37 | + | leftRank int | |
| 38 | + | left Score | |
| 39 | + | rightRank int | |
| 40 | + | right Score | |
| 41 | + | wantLess bool | |
| 42 | + | wantGreater bool | |
| 43 | + | }{ | |
| 44 | + | {name: "rank wins", leftRank: 0, left: Score{Span: 9}, rightRank: 1, right: Score{Span: 1}, wantLess: true}, | |
| 45 | + | {name: "span breaks rank tie", left: Score{Span: 2, First: 5}, right: Score{Span: 3}, wantLess: true}, | |
| 46 | + | {name: "first breaks span tie", left: Score{Span: 2, First: 1}, right: Score{Span: 2, First: 3}, wantLess: true}, | |
| 47 | + | {name: "equal", left: Score{Span: 2}, right: Score{Span: 2}}, | |
| 48 | + | } | |
| 49 | + | for _, tt := range tests { | |
| 50 | + | t.Run(tt.name, func(t *testing.T) { | |
| 51 | + | require.Equal(t, tt.wantLess, Less(tt.leftRank, tt.left, tt.rightRank, tt.right)) | |
| 52 | + | require.Equal(t, tt.wantGreater, Less(tt.rightRank, tt.right, tt.leftRank, tt.left)) | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | package fuzzy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestMatch(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | text string | |||
| 13 | + | query string | |||
| 14 | + | want Score | |||
| 15 | + | ok bool | |||
| 16 | + | }{ | |||
| 17 | + | {name: "empty query", text: "work", query: "", want: Score{Kind: Exact}, ok: true}, | |||
| 18 | + | {name: "exact", text: "work", query: "work", want: Score{Kind: Exact, Positions: []int{0, 1, 2, 3}, Span: 4}, 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}, | |||
| 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: "no match", text: "work", query: "z", ok: false}, | |||
| 23 | + | {name: "out of order", text: "ab", query: "ba", ok: false}, | |||
| 24 | + | } | |||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | got, ok := Match(tt.text, tt.query, []rune(tt.query)) | |||
| 28 | + | require.Equal(t, tt.ok, ok) | |||
| 29 | + | require.Equal(t, tt.want, got) | |||
| 30 | + | }) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestLess(t *testing.T) { | |||
| 35 | + | tests := []struct { | |||
| 36 | + | name string | |||
| 37 | + | leftRank int | |||
| 38 | + | left Score | |||
| 39 | + | rightRank int | |||
| 40 | + | right Score | |||
| 41 | + | wantLess bool | |||
| 42 | + | wantGreater bool | |||
| 43 | + | }{ | |||
| 44 | + | {name: "rank wins", leftRank: 0, left: Score{Span: 9}, rightRank: 1, right: Score{Span: 1}, wantLess: true}, | |||
| 45 | + | {name: "span breaks rank tie", left: Score{Span: 2, First: 5}, right: Score{Span: 3}, wantLess: true}, | |||
| 46 | + | {name: "first breaks span tie", left: Score{Span: 2, First: 1}, right: Score{Span: 2, First: 3}, wantLess: true}, | |||
| 47 | + | {name: "equal", left: Score{Span: 2}, right: Score{Span: 2}}, | |||
| 48 | + | } | |||
| 49 | + | for _, tt := range tests { | |||
| 50 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 51 | + | require.Equal(t, tt.wantLess, Less(tt.leftRank, tt.left, tt.rightRank, tt.right)) | |||
| 52 | + | require.Equal(t, tt.wantGreater, Less(tt.rightRank, tt.right, tt.leftRank, tt.left)) | |||
| 53 | + | }) | |||
| 54 | + | } | |||
| 55 | + | } | |||
M internal/usecase/palette/fuzzy.go +12 -49
| @@ -3,7 +3,8 @@ package palette | |||
| 3 | 3 | import ( | |
| 4 | 4 | "sort" | |
| 5 | 5 | "strings" | |
| 6 | - | "unicode/utf8" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/usecase/fuzzy" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // Match is a fuzzy-matched immutable palette result. | |
| @@ -85,7 +86,7 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match, | |||
| 85 | 86 | return match, true | |
| 86 | 87 | } | |
| 87 | 88 | if cmd, ok := result.Command(); ok { | |
| 88 | - | if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { | |
| 89 | + | if positions, ok := fuzzy.SubsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { | |
| 89 | 90 | match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0] | |
| 90 | 91 | return match, true | |
| 91 | 92 | } | |
| @@ -101,25 +102,19 @@ type fieldScore struct { | |||
| 101 | 102 | } | |
| 102 | 103 | ||
| 103 | 104 | func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) { | |
| 104 | - | text = strings.ToLower(text) | |
| 105 | - | var scored fieldScore | |
| 106 | - | switch { | |
| 107 | - | case text == needle: | |
| 108 | - | scored.positions = rangePositions(utf8.RuneCountInString(text)) | |
| 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: | |
| 109 | 112 | scored.rank = exactRank | |
| 110 | - | case strings.HasPrefix(text, needle): | |
| 111 | - | scored.positions = rangePositions(utf8.RuneCountInString(needle)) | |
| 113 | + | case fuzzy.Prefix: | |
| 112 | 114 | scored.rank = prefixRank | |
| 113 | 115 | default: | |
| 114 | - | positions, ok := subsequencePositions([]rune(text), needleRunes) | |
| 115 | - | if !ok { | |
| 116 | - | return fieldScore{}, false | |
| 117 | - | } | |
| 118 | - | scored.positions = positions | |
| 119 | 116 | scored.rank = subsequenceRank | |
| 120 | 117 | } | |
| 121 | - | scored.span = scored.positions[len(scored.positions)-1] - scored.positions[0] + 1 | |
| 122 | - | scored.first = scored.positions[0] | |
| 123 | 118 | for i := range scored.positions { | |
| 124 | 119 | scored.positions[i] += displayOffset | |
| 125 | 120 | } | |
| @@ -127,13 +122,7 @@ func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRan | |||
| 127 | 122 | } | |
| 128 | 123 | ||
| 129 | 124 | func (s fieldScore) betterThan(other fieldScore) bool { | |
| 130 | - | if s.rank != other.rank { | |
| 131 | - | return s.rank < other.rank | |
| 132 | - | } | |
| 133 | - | if s.span != other.span { | |
| 134 | - | return s.span < other.span | |
| 135 | - | } | |
| 136 | - | return s.first < other.first | |
| 125 | + | return fuzzy.Less(s.rank, fuzzy.Score{Span: s.span, First: s.first}, other.rank, fuzzy.Score{Span: other.span, First: other.first}) | |
| 137 | 126 | } | |
| 138 | 127 | ||
| 139 | 128 | func (s fieldScore) apply(match *Match) { | |
| @@ -146,29 +135,3 @@ func (s fieldScore) apply(match *Match) { | |||
| 146 | 135 | func newMatch(result Result, order int) Match { | |
| 147 | 136 | return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())} | |
| 148 | 137 | } | |
| 149 | - | ||
| 150 | - | func rangePositions(n int) []int { | |
| 151 | - | positions := make([]int, n) | |
| 152 | - | for i := range positions { | |
| 153 | - | positions[i] = i | |
| 154 | - | } | |
| 155 | - | return positions | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | func subsequencePositions(haystack, needle []rune) ([]int, bool) { | |
| 159 | - | if len(needle) == 0 { | |
| 160 | - | return nil, true | |
| 161 | - | } | |
| 162 | - | positions := make([]int, 0, len(needle)) | |
| 163 | - | j := 0 | |
| 164 | - | for i, r := range haystack { | |
| 165 | - | if r == needle[j] { | |
| 166 | - | positions = append(positions, i) | |
| 167 | - | j++ | |
| 168 | - | if j == len(needle) { | |
| 169 | - | return positions, true | |
| 170 | - | } | |
| 171 | - | } | |
| 172 | - | } | |
| 173 | - | return nil, false | |
| 174 | - | } | |
| @@ -3,7 +3,8 @@ package palette | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "sort" | 4 | "sort" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | - | "unicode/utf8" | 6 | + | |
| 7 | + | "github.com/bnema/vev/internal/usecase/fuzzy" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | // Match is a fuzzy-matched immutable palette result. | 10 | // Match is a fuzzy-matched immutable palette result. | ||
| @@ -85,7 +86,7 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match, | |||||
| 85 | return match, true | 86 | return match, true | ||
| 86 | } | 87 | } | ||
| 87 | if cmd, ok := result.Command(); ok { | 88 | if cmd, ok := result.Command(); ok { | ||
| 88 | - | if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { | 89 | + | if positions, ok := fuzzy.SubsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { |
| 89 | match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0] | 90 | match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0] | ||
| 90 | return match, true | 91 | return match, true | ||
| 91 | } | 92 | } | ||
| @@ -101,25 +102,19 @@ type fieldScore struct { | |||||
| 101 | } | 102 | } | ||
| 102 | 103 | ||||
| 103 | func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) { | 104 | func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) { | ||
| 104 | - | text = strings.ToLower(text) | 105 | + | matched, ok := fuzzy.Match(strings.ToLower(text), needle, needleRunes) |
| 105 | - | var scored fieldScore | 106 | + | if !ok { |
| 106 | - | switch { | 107 | + | return fieldScore{}, false |
| 107 | - | case text == needle: | 108 | + | } |
| 108 | - | scored.positions = rangePositions(utf8.RuneCountInString(text)) | 109 | + | scored := fieldScore{positions: matched.Positions, span: matched.Span, first: matched.First} |
| 110 | + | switch matched.Kind { | |||
| 111 | + | case fuzzy.Exact: | |||
| 109 | scored.rank = exactRank | 112 | scored.rank = exactRank | ||
| 110 | - | case strings.HasPrefix(text, needle): | 113 | + | case fuzzy.Prefix: |
| 111 | - | scored.positions = rangePositions(utf8.RuneCountInString(needle)) | |||
| 112 | scored.rank = prefixRank | 114 | scored.rank = prefixRank | ||
| 113 | default: | 115 | default: | ||
| 114 | - | positions, ok := subsequencePositions([]rune(text), needleRunes) | |||
| 115 | - | if !ok { | |||
| 116 | - | return fieldScore{}, false | |||
| 117 | - | } | |||
| 118 | - | scored.positions = positions | |||
| 119 | scored.rank = subsequenceRank | 116 | scored.rank = subsequenceRank | ||
| 120 | } | 117 | } | ||
| 121 | - | scored.span = scored.positions[len(scored.positions)-1] - scored.positions[0] + 1 | |||
| 122 | - | scored.first = scored.positions[0] | |||
| 123 | for i := range scored.positions { | 118 | for i := range scored.positions { | ||
| 124 | scored.positions[i] += displayOffset | 119 | scored.positions[i] += displayOffset | ||
| 125 | } | 120 | } | ||
| @@ -127,13 +122,7 @@ func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRan | |||||
| 127 | } | 122 | } | ||
| 128 | 123 | ||||
| 129 | func (s fieldScore) betterThan(other fieldScore) bool { | 124 | func (s fieldScore) betterThan(other fieldScore) bool { | ||
| 130 | - | if s.rank != other.rank { | 125 | + | return fuzzy.Less(s.rank, fuzzy.Score{Span: s.span, First: s.first}, other.rank, fuzzy.Score{Span: other.span, First: other.first}) |
| 131 | - | return s.rank < other.rank | |||
| 132 | - | } | |||
| 133 | - | if s.span != other.span { | |||
| 134 | - | return s.span < other.span | |||
| 135 | - | } | |||
| 136 | - | return s.first < other.first | |||
| 137 | } | 126 | } | ||
| 138 | 127 | ||||
| 139 | func (s fieldScore) apply(match *Match) { | 128 | func (s fieldScore) apply(match *Match) { | ||
| @@ -146,29 +135,3 @@ func (s fieldScore) apply(match *Match) { | |||||
| 146 | func newMatch(result Result, order int) Match { | 135 | func newMatch(result Result, order int) Match { | ||
| 147 | return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())} | 136 | return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())} | ||
| 148 | } | 137 | } | ||
| 149 | - | ||||
| 150 | - | func rangePositions(n int) []int { | |||
| 151 | - | positions := make([]int, n) | |||
| 152 | - | for i := range positions { | |||
| 153 | - | positions[i] = i | |||
| 154 | - | } | |||
| 155 | - | return positions | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | func subsequencePositions(haystack, needle []rune) ([]int, bool) { | |||
| 159 | - | if len(needle) == 0 { | |||
| 160 | - | return nil, true | |||
| 161 | - | } | |||
| 162 | - | positions := make([]int, 0, len(needle)) | |||
| 163 | - | j := 0 | |||
| 164 | - | for i, r := range haystack { | |||
| 165 | - | if r == needle[j] { | |||
| 166 | - | positions = append(positions, i) | |||
| 167 | - | j++ | |||
| 168 | - | if j == len(needle) { | |||
| 169 | - | return positions, true | |||
| 170 | - | } | |||
| 171 | - | } | |||
| 172 | - | } | |||
| 173 | - | return nil, false | |||
| 174 | - | } | |||
M internal/usecase/picker/search.go +5 -48
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "unicode/utf8" | |
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/fuzzy" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | const MaxSearchQueryRunes = 256 | |
| @@ -282,60 +283,16 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) { | |||
| 282 | 283 | } | |
| 283 | 284 | ||
| 284 | 285 | func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) { | |
| 285 | - | if len(needle) == 0 { | |
| 286 | - | return fieldMatch{field: field}, true | |
| 287 | - | } | |
| 288 | - | if text == query { | |
| 289 | - | positions := rangeRunePositions(len(needle)) | |
| 290 | - | return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true | |
| 291 | - | } | |
| 292 | - | if strings.HasPrefix(text, query) { | |
| 293 | - | positions := rangeRunePositions(len(needle)) | |
| 294 | - | return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true | |
| 295 | - | } | |
| 296 | - | positions, ok := subsequenceRunePositions([]rune(text), needle) | |
| 286 | + | matched, ok := fuzzy.Match(text, query, needle) | |
| 297 | 287 | if !ok { | |
| 298 | 288 | return fieldMatch{}, false | |
| 299 | 289 | } | |
| 300 | - | return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true | |
| 290 | + | return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true | |
| 301 | 291 | } | |
| 302 | 292 | ||
| 303 | 293 | func lessFieldMatch(left, right fieldMatch) bool { | |
| 304 | - | if left.rank != right.rank { | |
| 305 | - | return left.rank < right.rank | |
| 306 | - | } | |
| 307 | - | if left.span != right.span { | |
| 308 | - | return left.span < right.span | |
| 309 | - | } | |
| 310 | - | if left.first != right.first { | |
| 311 | - | return left.first < right.first | |
| 294 | + | if left.rank != right.rank || left.span != right.span || left.first != right.first { | |
| 295 | + | return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first}) | |
| 312 | 296 | } | |
| 313 | 297 | return left.field < right.field | |
| 314 | 298 | } | |
| 315 | - | ||
| 316 | - | func rangeRunePositions(length int) []int { | |
| 317 | - | positions := make([]int, length) | |
| 318 | - | for i := range positions { | |
| 319 | - | positions[i] = i | |
| 320 | - | } | |
| 321 | - | return positions | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | func subsequenceRunePositions(haystack, needle []rune) ([]int, bool) { | |
| 325 | - | if len(needle) == 0 { | |
| 326 | - | return nil, true | |
| 327 | - | } | |
| 328 | - | positions := make([]int, 0, len(needle)) | |
| 329 | - | next := 0 | |
| 330 | - | for idx, r := range haystack { | |
| 331 | - | if r != needle[next] { | |
| 332 | - | continue | |
| 333 | - | } | |
| 334 | - | positions = append(positions, idx) | |
| 335 | - | next++ | |
| 336 | - | if next == len(needle) { | |
| 337 | - | return positions, true | |
| 338 | - | } | |
| 339 | - | } | |
| 340 | - | return nil, false | |
| 341 | - | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "unicode/utf8" | 6 | "unicode/utf8" | ||
| 7 | 7 | ||||
| 8 | renderer "github.com/bnema/vev-vt" | 8 | renderer "github.com/bnema/vev-vt" | ||
| 9 | + | "github.com/bnema/vev/internal/usecase/fuzzy" | |||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | const MaxSearchQueryRunes = 256 | 12 | const MaxSearchQueryRunes = 256 | ||
| @@ -282,60 +283,16 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) { | |||||
| 282 | } | 283 | } | ||
| 283 | 284 | ||||
| 284 | func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) { | 285 | func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) { | ||
| 285 | - | if len(needle) == 0 { | 286 | + | matched, ok := fuzzy.Match(text, query, needle) |
| 286 | - | return fieldMatch{field: field}, true | |||
| 287 | - | } | |||
| 288 | - | if text == query { | |||
| 289 | - | positions := rangeRunePositions(len(needle)) | |||
| 290 | - | return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true | |||
| 291 | - | } | |||
| 292 | - | if strings.HasPrefix(text, query) { | |||
| 293 | - | positions := rangeRunePositions(len(needle)) | |||
| 294 | - | return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true | |||
| 295 | - | } | |||
| 296 | - | positions, ok := subsequenceRunePositions([]rune(text), needle) | |||
| 297 | if !ok { | 287 | if !ok { | ||
| 298 | return fieldMatch{}, false | 288 | return fieldMatch{}, false | ||
| 299 | } | 289 | } | ||
| 300 | - | return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true | 290 | + | return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true |
| 301 | } | 291 | } | ||
| 302 | 292 | ||||
| 303 | func lessFieldMatch(left, right fieldMatch) bool { | 293 | func lessFieldMatch(left, right fieldMatch) bool { | ||
| 304 | - | if left.rank != right.rank { | 294 | + | if left.rank != right.rank || left.span != right.span || left.first != right.first { |
| 305 | - | return left.rank < right.rank | 295 | + | return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first}) |
| 306 | - | } | |||
| 307 | - | if left.span != right.span { | |||
| 308 | - | return left.span < right.span | |||
| 309 | - | } | |||
| 310 | - | if left.first != right.first { | |||
| 311 | - | return left.first < right.first | |||
| 312 | } | 296 | } | ||
| 313 | return left.field < right.field | 297 | return left.field < right.field | ||
| 314 | } | 298 | } | ||
| 315 | - | ||||
| 316 | - | func rangeRunePositions(length int) []int { | |||
| 317 | - | positions := make([]int, length) | |||
| 318 | - | for i := range positions { | |||
| 319 | - | positions[i] = i | |||
| 320 | - | } | |||
| 321 | - | return positions | |||
| 322 | - | } | |||
| 323 | - | ||||
| 324 | - | func subsequenceRunePositions(haystack, needle []rune) ([]int, bool) { | |||
| 325 | - | if len(needle) == 0 { | |||
| 326 | - | return nil, true | |||
| 327 | - | } | |||
| 328 | - | positions := make([]int, 0, len(needle)) | |||
| 329 | - | next := 0 | |||
| 330 | - | for idx, r := range haystack { | |||
| 331 | - | if r != needle[next] { | |||
| 332 | - | continue | |||
| 333 | - | } | |||
| 334 | - | positions = append(positions, idx) | |||
| 335 | - | next++ | |||
| 336 | - | if next == len(needle) { | |||
| 337 | - | return positions, true | |||
| 338 | - | } | |||
| 339 | - | } | |||
| 340 | - | return nil, false | |||
| 341 | - | } | |||