detached 7d5dafe

feat(vimkeys): add pure sequence grammar

Co-authored-by: Cursor <cursoragent@cursor.com>
commit
7d5dafe1aca8fcdb69e5079c0b9556fa472d104a
author
brice · · 2026-07-28 17:31 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
0413b8e
signature
unsigned
6 files · +815 -0
M internal/domain/vimkeys/key_test.go +23 -0
@@ -19,6 +19,8 @@ func TestKeyString(t *testing.T) {
1919 {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},
2020 {name: "special Space", key: Key{Sym: "Space"}, want: "<Space>"},
2121 {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},
22+ {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"},
23+ {name: "ctrl alt", key: Key{Sym: "c", Mods: ModCtrl | ModAlt}, want: "<C-A-c>"},
2224 {name: "ctrl shift", key: Key{Sym: "a", Mods: ModCtrl | ModShift}, want: "<C-S-a>"},
2325 }
2426 for _, tt := range tests {
@@ -49,6 +51,27 @@ func TestSequenceString(t *testing.T) {
4951 }
5052 }
5153
54+func TestSequenceEqual(t *testing.T) {
55+ a := Sequence{{Sym: "j"}, {Sym: "k"}}
56+ b := Sequence{{Sym: "j"}, {Sym: "k"}}
57+ c := Sequence{{Sym: "j"}}
58+ if !a.Equal(b) {
59+ t.Fatal("equal sequences reported unequal")
60+ }
61+ if a.Equal(c) {
62+ t.Fatal("unequal length sequences reported equal")
63+ }
64+ if a.Equal(Sequence{{Sym: "j"}, {Sym: "x"}}) {
65+ t.Fatal("different keys reported equal")
66+ }
67+}
68+
69+func TestKeyStringEmpty(t *testing.T) {
70+ if got := (Key{}).String(); got != "" {
71+ t.Fatalf("empty Key.String() = %q, want empty", got)
72+ }
73+}
74+
5275 func TestErrorSentinels(t *testing.T) {
5376 if ErrEmptyBinding.Error() != "vimkeys: empty binding" {
5477 t.Errorf("ErrEmptyBinding = %q, want %q", ErrEmptyBinding.Error(), "vimkeys: empty binding")
@@ -19,6 +19,8 @@ func TestKeyString(t *testing.T) {
19 {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},19 {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},
20 {name: "special Space", key: Key{Sym: "Space"}, want: "<Space>"},20 {name: "special Space", key: Key{Sym: "Space"}, want: "<Space>"},
21 {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},21 {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},
22+ {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"},
23+ {name: "ctrl alt", key: Key{Sym: "c", Mods: ModCtrl | ModAlt}, want: "<C-A-c>"},
22 {name: "ctrl shift", key: Key{Sym: "a", Mods: ModCtrl | ModShift}, want: "<C-S-a>"},24 {name: "ctrl shift", key: Key{Sym: "a", Mods: ModCtrl | ModShift}, want: "<C-S-a>"},
23 }25 }
24 for _, tt := range tests {26 for _, tt := range tests {
@@ -49,6 +51,27 @@ func TestSequenceString(t *testing.T) {
49 }51 }
50 }52 }
51 53
54+func TestSequenceEqual(t *testing.T) {
55+ a := Sequence{{Sym: "j"}, {Sym: "k"}}
56+ b := Sequence{{Sym: "j"}, {Sym: "k"}}
57+ c := Sequence{{Sym: "j"}}
58+ if !a.Equal(b) {
59+ t.Fatal("equal sequences reported unequal")
60+ }
61+ if a.Equal(c) {
62+ t.Fatal("unequal length sequences reported equal")
63+ }
64+ if a.Equal(Sequence{{Sym: "j"}, {Sym: "x"}}) {
65+ t.Fatal("different keys reported equal")
66+ }
67+}
68+
69+func TestKeyStringEmpty(t *testing.T) {
70+ if got := (Key{}).String(); got != "" {
71+ t.Fatalf("empty Key.String() = %q, want empty", got)
72+ }
73+}
74+
52 func TestErrorSentinels(t *testing.T) {75 func TestErrorSentinels(t *testing.T) {
53 if ErrEmptyBinding.Error() != "vimkeys: empty binding" {76 if ErrEmptyBinding.Error() != "vimkeys: empty binding" {
54 t.Errorf("ErrEmptyBinding = %q, want %q", ErrEmptyBinding.Error(), "vimkeys: empty binding")77 t.Errorf("ErrEmptyBinding = %q, want %q", ErrEmptyBinding.Error(), "vimkeys: empty binding")
A internal/domain/vimkeys/matcher.go +172 -0
@@ -0,0 +1,172 @@
1+package vimkeys
2+
3+import "strconv"
4+
5+// ResultKind classifies matcher feed outcomes.
6+type ResultKind int
7+
8+const (
9+ ResultPending ResultKind = iota
10+ ResultComplete
11+ ResultInvalid
12+)
13+
14+// Result is the outcome of feeding a key into the matcher.
15+type Result struct {
16+ Kind ResultKind
17+ Count int
18+ Action string
19+ Pending string
20+}
21+
22+// Matcher incrementally matches key sequences against a trie.
23+type Matcher struct {
24+ trie *Trie
25+ node *trieNode
26+ pending Sequence
27+ count int
28+ inCount bool
29+ ambiguous bool
30+}
31+
32+// NewMatcher creates a matcher backed by trie.
33+func NewMatcher(trie *Trie) *Matcher {
34+ return &Matcher{trie: trie}
35+}
36+
37+// Reset clears matcher state.
38+func (m *Matcher) Reset() {
39+ m.node = nil
40+ m.pending = nil
41+ m.count = 0
42+ m.inCount = false
43+ m.ambiguous = false
44+}
45+
46+// Pending returns the count prefix and keys matched so far.
47+func (m *Matcher) Pending() string {
48+ return formatPending(m.count, m.pending)
49+}
50+
51+// Ambiguous reports whether the current prefix is both exact and extendable.
52+func (m *Matcher) Ambiguous() bool {
53+ return m.ambiguous
54+}
55+
56+// Feed consumes one key and returns the current match state.
57+func (m *Matcher) Feed(key Key) Result {
58+ if m.node == nil && len(m.pending) == 0 && m.count == 0 && !m.inCount {
59+ m.beginSequence()
60+ }
61+
62+ if m.inCount {
63+ return m.feedCountDigit(key)
64+ }
65+
66+ if len(m.pending) == 0 && isCountStartDigit(key) {
67+ m.inCount = true
68+ return m.feedCountDigit(key)
69+ }
70+
71+ return m.feedSequenceKey(key)
72+}
73+
74+func (m *Matcher) beginSequence() {
75+ m.node = m.trie.root
76+ m.pending = nil
77+ m.count = 0
78+ m.inCount = false
79+ m.ambiguous = false
80+}
81+
82+func (m *Matcher) feedCountDigit(key Key) Result {
83+ if !isCountDigit(key) {
84+ m.inCount = false
85+ m.node = m.trie.root
86+ return m.feedSequenceKey(key)
87+ }
88+
89+ digit := int(key.Sym[0] - '0')
90+ next := m.count*10 + digit
91+ if next > maxCount {
92+ next = maxCount
93+ }
94+ m.count = next
95+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
96+}
97+
98+func (m *Matcher) feedSequenceKey(key Key) Result {
99+ child, ok := m.node.children[key]
100+ if !ok {
101+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
102+ m.Reset()
103+ return result
104+ }
105+
106+ m.pending = append(m.pending, key)
107+ m.node = child
108+
109+ exact := m.node.hasAction
110+ children := len(m.node.children) > 0
111+
112+ switch {
113+ case exact && children:
114+ m.ambiguous = true
115+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
116+ case exact:
117+ result := Result{
118+ Kind: ResultComplete,
119+ Count: normalizedCount(m.count),
120+ Action: m.node.action,
121+ }
122+ m.Reset()
123+ return result
124+ case children:
125+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
126+ default:
127+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
128+ m.Reset()
129+ return result
130+ }
131+}
132+
133+// ResolveAmbiguity completes the current short exact binding.
134+func (m *Matcher) ResolveAmbiguity() (Result, bool) {
135+ if !m.ambiguous || m.node == nil || !m.node.hasAction {
136+ return Result{}, false
137+ }
138+
139+ result := Result{
140+ Kind: ResultComplete,
141+ Count: normalizedCount(m.count),
142+ Action: m.node.action,
143+ }
144+ m.Reset()
145+ return result, true
146+}
147+
148+func isCountStartDigit(key Key) bool {
149+ return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
150+}
151+
152+func isCountDigit(key Key) bool {
153+ return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '0' && key.Sym[0] <= '9'
154+}
155+
156+func normalizedCount(count int) int {
157+ if count <= 0 {
158+ return 0
159+ }
160+ return count
161+}
162+
163+func formatPending(count int, pending Sequence) string {
164+ var out string
165+ if count > 0 {
166+ out = strconv.Itoa(count)
167+ }
168+ if len(pending) > 0 {
169+ out += pending.String()
170+ }
171+ return out
172+}
@@ -0,0 +1,172 @@
1+package vimkeys
2+
3+import "strconv"
4+
5+// ResultKind classifies matcher feed outcomes.
6+type ResultKind int
7+
8+const (
9+ ResultPending ResultKind = iota
10+ ResultComplete
11+ ResultInvalid
12+)
13+
14+// Result is the outcome of feeding a key into the matcher.
15+type Result struct {
16+ Kind ResultKind
17+ Count int
18+ Action string
19+ Pending string
20+}
21+
22+// Matcher incrementally matches key sequences against a trie.
23+type Matcher struct {
24+ trie *Trie
25+ node *trieNode
26+ pending Sequence
27+ count int
28+ inCount bool
29+ ambiguous bool
30+}
31+
32+// NewMatcher creates a matcher backed by trie.
33+func NewMatcher(trie *Trie) *Matcher {
34+ return &Matcher{trie: trie}
35+}
36+
37+// Reset clears matcher state.
38+func (m *Matcher) Reset() {
39+ m.node = nil
40+ m.pending = nil
41+ m.count = 0
42+ m.inCount = false
43+ m.ambiguous = false
44+}
45+
46+// Pending returns the count prefix and keys matched so far.
47+func (m *Matcher) Pending() string {
48+ return formatPending(m.count, m.pending)
49+}
50+
51+// Ambiguous reports whether the current prefix is both exact and extendable.
52+func (m *Matcher) Ambiguous() bool {
53+ return m.ambiguous
54+}
55+
56+// Feed consumes one key and returns the current match state.
57+func (m *Matcher) Feed(key Key) Result {
58+ if m.node == nil && len(m.pending) == 0 && m.count == 0 && !m.inCount {
59+ m.beginSequence()
60+ }
61+
62+ if m.inCount {
63+ return m.feedCountDigit(key)
64+ }
65+
66+ if len(m.pending) == 0 && isCountStartDigit(key) {
67+ m.inCount = true
68+ return m.feedCountDigit(key)
69+ }
70+
71+ return m.feedSequenceKey(key)
72+}
73+
74+func (m *Matcher) beginSequence() {
75+ m.node = m.trie.root
76+ m.pending = nil
77+ m.count = 0
78+ m.inCount = false
79+ m.ambiguous = false
80+}
81+
82+func (m *Matcher) feedCountDigit(key Key) Result {
83+ if !isCountDigit(key) {
84+ m.inCount = false
85+ m.node = m.trie.root
86+ return m.feedSequenceKey(key)
87+ }
88+
89+ digit := int(key.Sym[0] - '0')
90+ next := m.count*10 + digit
91+ if next > maxCount {
92+ next = maxCount
93+ }
94+ m.count = next
95+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
96+}
97+
98+func (m *Matcher) feedSequenceKey(key Key) Result {
99+ child, ok := m.node.children[key]
100+ if !ok {
101+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
102+ m.Reset()
103+ return result
104+ }
105+
106+ m.pending = append(m.pending, key)
107+ m.node = child
108+
109+ exact := m.node.hasAction
110+ children := len(m.node.children) > 0
111+
112+ switch {
113+ case exact && children:
114+ m.ambiguous = true
115+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
116+ case exact:
117+ result := Result{
118+ Kind: ResultComplete,
119+ Count: normalizedCount(m.count),
120+ Action: m.node.action,
121+ }
122+ m.Reset()
123+ return result
124+ case children:
125+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
126+ default:
127+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
128+ m.Reset()
129+ return result
130+ }
131+}
132+
133+// ResolveAmbiguity completes the current short exact binding.
134+func (m *Matcher) ResolveAmbiguity() (Result, bool) {
135+ if !m.ambiguous || m.node == nil || !m.node.hasAction {
136+ return Result{}, false
137+ }
138+
139+ result := Result{
140+ Kind: ResultComplete,
141+ Count: normalizedCount(m.count),
142+ Action: m.node.action,
143+ }
144+ m.Reset()
145+ return result, true
146+}
147+
148+func isCountStartDigit(key Key) bool {
149+ return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
150+}
151+
152+func isCountDigit(key Key) bool {
153+ return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '0' && key.Sym[0] <= '9'
154+}
155+
156+func normalizedCount(count int) int {
157+ if count <= 0 {
158+ return 0
159+ }
160+ return count
161+}
162+
163+func formatPending(count int, pending Sequence) string {
164+ var out string
165+ if count > 0 {
166+ out = strconv.Itoa(count)
167+ }
168+ if len(pending) > 0 {
169+ out += pending.String()
170+ }
171+ return out
172+}
A internal/domain/vimkeys/matcher_test.go +325 -0
@@ -0,0 +1,325 @@
1+package vimkeys
2+
3+import (
4+ "testing"
5+)
6+
7+func testTrie(t *testing.T) *Trie {
8+ t.Helper()
9+ trie := NewTrie()
10+ bindings := []struct {
11+ seq Sequence
12+ action string
13+ }{
14+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
15+ {seq: Sequence{{Sym: "]"}, {Sym: "c"}}, action: "bracket-c"},
16+ {seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
17+ {seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
18+ {seq: Sequence{{Sym: "]"}}, action: "single-bracket"},
19+ {seq: Sequence{{Sym: "j"}}, action: "down"},
20+ {seq: Sequence{{Sym: "0"}}, action: "zero"},
21+ }
22+ for _, binding := range bindings {
23+ if err := trie.Insert(binding.seq, binding.action); err != nil {
24+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
25+ }
26+ }
27+ return trie
28+}
29+
30+func TestMatcher_FeedPrefixesPending(t *testing.T) {
31+ m := NewMatcher(testTrie(t))
32+
33+ cases := []struct {
34+ name string
35+ keys []Key
36+ want ResultKind
37+ pending string
38+ }{
39+ {
40+ name: "]] first bracket",
41+ keys: []Key{{Sym: "]"}},
42+ want: ResultPending,
43+ pending: "]",
44+ },
45+ {
46+ name: "gO prefix g",
47+ keys: []Key{{Sym: "g"}},
48+ want: ResultPending,
49+ pending: "g",
50+ },
51+ {
52+ name: "yah prefix ya",
53+ keys: []Key{{Sym: "y"}, {Sym: "a"}},
54+ want: ResultPending,
55+ pending: "ya",
56+ },
57+ }
58+ for _, tt := range cases {
59+ t.Run(tt.name, func(t *testing.T) {
60+ m.Reset()
61+ var result Result
62+ for _, key := range tt.keys {
63+ result = m.Feed(key)
64+ }
65+ if result.Kind != tt.want {
66+ t.Fatalf("Feed kind = %v, want %v", result.Kind, tt.want)
67+ }
68+ if result.Pending != tt.pending {
69+ t.Fatalf("Pending = %q, want %q", result.Pending, tt.pending)
70+ }
71+ if m.Pending() != tt.pending {
72+ t.Fatalf("Matcher.Pending() = %q, want %q", m.Pending(), tt.pending)
73+ }
74+ if result.Count != 0 {
75+ t.Fatalf("Count = %d, want 0 (absent)", result.Count)
76+ }
77+ })
78+ }
79+}
80+
81+func TestMatcher_FeedComplete(t *testing.T) {
82+ m := NewMatcher(testTrie(t))
83+
84+ cases := []struct {
85+ name string
86+ keys []Key
87+ action string
88+ }{
89+ {name: "]]", keys: []Key{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
90+ {name: "gO", keys: []Key{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
91+ {name: "yah", keys: []Key{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
92+ }
93+ for _, tt := range cases {
94+ t.Run(tt.name, func(t *testing.T) {
95+ m.Reset()
96+ var result Result
97+ for _, key := range tt.keys {
98+ result = m.Feed(key)
99+ }
100+ if result.Kind != ResultComplete {
101+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
102+ }
103+ if result.Action != tt.action {
104+ t.Fatalf("Action = %q, want %q", result.Action, tt.action)
105+ }
106+ if result.Count != 0 {
107+ t.Fatalf("Count = %d, want 0", result.Count)
108+ }
109+ })
110+ }
111+}
112+
113+func TestMatcher_Counts(t *testing.T) {
114+ m := NewMatcher(testTrie(t))
115+
116+ cases := []struct {
117+ name string
118+ keys []Key
119+ count int
120+ action string
121+ }{
122+ {
123+ name: "2]]",
124+ keys: []Key{{Sym: "2"}, {Sym: "]"}, {Sym: "]"}},
125+ count: 2,
126+ action: "double-bracket",
127+ },
128+ {
129+ name: "3j",
130+ keys: []Key{{Sym: "3"}, {Sym: "j"}},
131+ count: 3,
132+ action: "down",
133+ },
134+ {
135+ name: "10j",
136+ keys: []Key{{Sym: "1"}, {Sym: "0"}, {Sym: "j"}},
137+ count: 10,
138+ action: "down",
139+ },
140+ {
141+ name: "03j",
142+ keys: []Key{{Sym: "0"}, {Sym: "3"}, {Sym: "j"}},
143+ count: 3,
144+ action: "down",
145+ },
146+ {
147+ name: "0 binding",
148+ keys: []Key{{Sym: "0"}},
149+ count: 0,
150+ action: "zero",
151+ },
152+ }
153+ for _, tt := range cases {
154+ t.Run(tt.name, func(t *testing.T) {
155+ m.Reset()
156+ var result Result
157+ for _, key := range tt.keys {
158+ result = m.Feed(key)
159+ }
160+ if result.Kind != ResultComplete {
161+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
162+ }
163+ if result.Count != tt.count {
164+ t.Fatalf("Count = %d, want %d", result.Count, tt.count)
165+ }
166+ if result.Action != tt.action {
167+ t.Fatalf("Action = %q, want %q", result.Action, tt.action)
168+ }
169+ })
170+ }
171+}
172+
173+func TestMatcher_CountPendingString(t *testing.T) {
174+ m := NewMatcher(testTrie(t))
175+ m.Reset()
176+ result := m.Feed(Key{Sym: "3"})
177+ if result.Kind != ResultPending {
178+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultPending)
179+ }
180+ if result.Pending != "3" {
181+ t.Fatalf("Pending = %q, want %q", result.Pending, "3")
182+ }
183+ if m.Pending() != "3" {
184+ t.Fatalf("Matcher.Pending() = %q, want %q", m.Pending(), "3")
185+ }
186+}
187+
188+func TestMatcher_CountCap9999(t *testing.T) {
189+ m := NewMatcher(testTrie(t))
190+ digits := make([]Key, 0, 5)
191+ for _, ch := range "10000" {
192+ digits = append(digits, Key{Sym: string(ch)})
193+ }
194+ digits = append(digits, Key{Sym: "j"})
195+
196+ m.Reset()
197+ var result Result
198+ for _, key := range digits {
199+ result = m.Feed(key)
200+ }
201+ if result.Kind != ResultComplete {
202+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
203+ }
204+ if result.Count != 9999 {
205+ t.Fatalf("Count = %d, want 9999", result.Count)
206+ }
207+}
208+
209+func TestMatcher_AutomaticReset(t *testing.T) {
210+ m := NewMatcher(testTrie(t))
211+
212+ m.Reset()
213+ complete := m.Feed(Key{Sym: "j"})
214+ if complete.Kind != ResultComplete {
215+ t.Fatalf("first Feed kind = %v, want %v", complete.Kind, ResultComplete)
216+ }
217+
218+ next := m.Feed(Key{Sym: "g"})
219+ if next.Kind != ResultPending {
220+ t.Fatalf("after complete Feed kind = %v, want %v", next.Kind, ResultPending)
221+ }
222+ if m.Pending() != "g" {
223+ t.Fatalf("Pending after reset = %q, want g", m.Pending())
224+ }
225+
226+ m.Reset()
227+ invalid := m.Feed(Key{Sym: "x"})
228+ if invalid.Kind != ResultInvalid {
229+ t.Fatalf("invalid Feed kind = %v, want %v", invalid.Kind, ResultInvalid)
230+ }
231+
232+ afterInvalid := m.Feed(Key{Sym: "j"})
233+ if afterInvalid.Kind != ResultComplete {
234+ t.Fatalf("after invalid Feed kind = %v, want %v", afterInvalid.Kind, ResultComplete)
235+ }
236+}
237+
238+func TestMatcher_AmbiguousShortLong(t *testing.T) {
239+ m := NewMatcher(testTrie(t))
240+
241+ m.Reset()
242+ result := m.Feed(Key{Sym: "]"})
243+ if result.Kind != ResultPending {
244+ t.Fatalf("Feed ] kind = %v, want %v", result.Kind, ResultPending)
245+ }
246+ if !m.Ambiguous() {
247+ t.Fatal("Ambiguous() = false, want true after ]")
248+ }
249+
250+ short, ok := m.ResolveAmbiguity()
251+ if !ok {
252+ t.Fatal("ResolveAmbiguity() ok = false, want true")
253+ }
254+ if short.Kind != ResultComplete {
255+ t.Fatalf("ResolveAmbiguity() kind = %v, want %v", short.Kind, ResultComplete)
256+ }
257+ if short.Action != "single-bracket" {
258+ t.Fatalf("short action = %q, want %q", short.Action, "single-bracket")
259+ }
260+
261+ m.Reset()
262+ _ = m.Feed(Key{Sym: "]"})
263+ finish := m.Feed(Key{Sym: "]"})
264+ if finish.Kind != ResultComplete {
265+ t.Fatalf("finish ]] kind = %v, want %v", finish.Kind, ResultComplete)
266+ }
267+ if finish.Action != "double-bracket" {
268+ t.Fatalf("long action = %q, want %q", finish.Action, "double-bracket")
269+ }
270+}
271+
272+func TestMatcher_Reset(t *testing.T) {
273+ m := NewMatcher(testTrie(t))
274+ _ = m.Feed(Key{Sym: "y"})
275+ _ = m.Feed(Key{Sym: "a"})
276+ m.Reset()
277+ if m.Pending() != "" {
278+ t.Fatalf("Pending after Reset = %q, want empty", m.Pending())
279+ }
280+ if m.Ambiguous() {
281+ t.Fatal("Ambiguous() after Reset = true, want false")
282+ }
283+}
284+
285+func TestMatcher_InvalidMidSequence(t *testing.T) {
286+ m := NewMatcher(testTrie(t))
287+ m.Reset()
288+ _ = m.Feed(Key{Sym: "y"})
289+ result := m.Feed(Key{Sym: "x"})
290+ if result.Kind != ResultInvalid {
291+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultInvalid)
292+ }
293+}
294+
295+func TestMatcher_ResolveAmbiguityInvalid(t *testing.T) {
296+ m := NewMatcher(testTrie(t))
297+ m.Reset()
298+ _, ok := m.ResolveAmbiguity()
299+ if ok {
300+ t.Fatal("ResolveAmbiguity without prefix ok = true, want false")
301+ }
302+}
303+
304+func TestMatcher_InvalidDeadEnd(t *testing.T) {
305+ trie := NewTrie()
306+ trie.root.children[Key{Sym: "x"}] = newTrieNode()
307+ m := NewMatcher(trie)
308+ result := m.Feed(Key{Sym: "x"})
309+ if result.Kind != ResultInvalid {
310+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultInvalid)
311+ }
312+}
313+
314+func TestMatcher_CountExactCap(t *testing.T) {
315+ m := NewMatcher(testTrie(t))
316+ m.Reset()
317+ var result Result
318+ for _, ch := range "9999" {
319+ result = m.Feed(Key{Sym: string(ch)})
320+ }
321+ result = m.Feed(Key{Sym: "j"})
322+ if result.Count != 9999 {
323+ t.Fatalf("Count = %d, want 9999", result.Count)
324+ }
325+}
@@ -0,0 +1,325 @@
1+package vimkeys
2+
3+import (
4+ "testing"
5+)
6+
7+func testTrie(t *testing.T) *Trie {
8+ t.Helper()
9+ trie := NewTrie()
10+ bindings := []struct {
11+ seq Sequence
12+ action string
13+ }{
14+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
15+ {seq: Sequence{{Sym: "]"}, {Sym: "c"}}, action: "bracket-c"},
16+ {seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
17+ {seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
18+ {seq: Sequence{{Sym: "]"}}, action: "single-bracket"},
19+ {seq: Sequence{{Sym: "j"}}, action: "down"},
20+ {seq: Sequence{{Sym: "0"}}, action: "zero"},
21+ }
22+ for _, binding := range bindings {
23+ if err := trie.Insert(binding.seq, binding.action); err != nil {
24+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
25+ }
26+ }
27+ return trie
28+}
29+
30+func TestMatcher_FeedPrefixesPending(t *testing.T) {
31+ m := NewMatcher(testTrie(t))
32+
33+ cases := []struct {
34+ name string
35+ keys []Key
36+ want ResultKind
37+ pending string
38+ }{
39+ {
40+ name: "]] first bracket",
41+ keys: []Key{{Sym: "]"}},
42+ want: ResultPending,
43+ pending: "]",
44+ },
45+ {
46+ name: "gO prefix g",
47+ keys: []Key{{Sym: "g"}},
48+ want: ResultPending,
49+ pending: "g",
50+ },
51+ {
52+ name: "yah prefix ya",
53+ keys: []Key{{Sym: "y"}, {Sym: "a"}},
54+ want: ResultPending,
55+ pending: "ya",
56+ },
57+ }
58+ for _, tt := range cases {
59+ t.Run(tt.name, func(t *testing.T) {
60+ m.Reset()
61+ var result Result
62+ for _, key := range tt.keys {
63+ result = m.Feed(key)
64+ }
65+ if result.Kind != tt.want {
66+ t.Fatalf("Feed kind = %v, want %v", result.Kind, tt.want)
67+ }
68+ if result.Pending != tt.pending {
69+ t.Fatalf("Pending = %q, want %q", result.Pending, tt.pending)
70+ }
71+ if m.Pending() != tt.pending {
72+ t.Fatalf("Matcher.Pending() = %q, want %q", m.Pending(), tt.pending)
73+ }
74+ if result.Count != 0 {
75+ t.Fatalf("Count = %d, want 0 (absent)", result.Count)
76+ }
77+ })
78+ }
79+}
80+
81+func TestMatcher_FeedComplete(t *testing.T) {
82+ m := NewMatcher(testTrie(t))
83+
84+ cases := []struct {
85+ name string
86+ keys []Key
87+ action string
88+ }{
89+ {name: "]]", keys: []Key{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
90+ {name: "gO", keys: []Key{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
91+ {name: "yah", keys: []Key{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
92+ }
93+ for _, tt := range cases {
94+ t.Run(tt.name, func(t *testing.T) {
95+ m.Reset()
96+ var result Result
97+ for _, key := range tt.keys {
98+ result = m.Feed(key)
99+ }
100+ if result.Kind != ResultComplete {
101+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
102+ }
103+ if result.Action != tt.action {
104+ t.Fatalf("Action = %q, want %q", result.Action, tt.action)
105+ }
106+ if result.Count != 0 {
107+ t.Fatalf("Count = %d, want 0", result.Count)
108+ }
109+ })
110+ }
111+}
112+
113+func TestMatcher_Counts(t *testing.T) {
114+ m := NewMatcher(testTrie(t))
115+
116+ cases := []struct {
117+ name string
118+ keys []Key
119+ count int
120+ action string
121+ }{
122+ {
123+ name: "2]]",
124+ keys: []Key{{Sym: "2"}, {Sym: "]"}, {Sym: "]"}},
125+ count: 2,
126+ action: "double-bracket",
127+ },
128+ {
129+ name: "3j",
130+ keys: []Key{{Sym: "3"}, {Sym: "j"}},
131+ count: 3,
132+ action: "down",
133+ },
134+ {
135+ name: "10j",
136+ keys: []Key{{Sym: "1"}, {Sym: "0"}, {Sym: "j"}},
137+ count: 10,
138+ action: "down",
139+ },
140+ {
141+ name: "03j",
142+ keys: []Key{{Sym: "0"}, {Sym: "3"}, {Sym: "j"}},
143+ count: 3,
144+ action: "down",
145+ },
146+ {
147+ name: "0 binding",
148+ keys: []Key{{Sym: "0"}},
149+ count: 0,
150+ action: "zero",
151+ },
152+ }
153+ for _, tt := range cases {
154+ t.Run(tt.name, func(t *testing.T) {
155+ m.Reset()
156+ var result Result
157+ for _, key := range tt.keys {
158+ result = m.Feed(key)
159+ }
160+ if result.Kind != ResultComplete {
161+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
162+ }
163+ if result.Count != tt.count {
164+ t.Fatalf("Count = %d, want %d", result.Count, tt.count)
165+ }
166+ if result.Action != tt.action {
167+ t.Fatalf("Action = %q, want %q", result.Action, tt.action)
168+ }
169+ })
170+ }
171+}
172+
173+func TestMatcher_CountPendingString(t *testing.T) {
174+ m := NewMatcher(testTrie(t))
175+ m.Reset()
176+ result := m.Feed(Key{Sym: "3"})
177+ if result.Kind != ResultPending {
178+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultPending)
179+ }
180+ if result.Pending != "3" {
181+ t.Fatalf("Pending = %q, want %q", result.Pending, "3")
182+ }
183+ if m.Pending() != "3" {
184+ t.Fatalf("Matcher.Pending() = %q, want %q", m.Pending(), "3")
185+ }
186+}
187+
188+func TestMatcher_CountCap9999(t *testing.T) {
189+ m := NewMatcher(testTrie(t))
190+ digits := make([]Key, 0, 5)
191+ for _, ch := range "10000" {
192+ digits = append(digits, Key{Sym: string(ch)})
193+ }
194+ digits = append(digits, Key{Sym: "j"})
195+
196+ m.Reset()
197+ var result Result
198+ for _, key := range digits {
199+ result = m.Feed(key)
200+ }
201+ if result.Kind != ResultComplete {
202+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
203+ }
204+ if result.Count != 9999 {
205+ t.Fatalf("Count = %d, want 9999", result.Count)
206+ }
207+}
208+
209+func TestMatcher_AutomaticReset(t *testing.T) {
210+ m := NewMatcher(testTrie(t))
211+
212+ m.Reset()
213+ complete := m.Feed(Key{Sym: "j"})
214+ if complete.Kind != ResultComplete {
215+ t.Fatalf("first Feed kind = %v, want %v", complete.Kind, ResultComplete)
216+ }
217+
218+ next := m.Feed(Key{Sym: "g"})
219+ if next.Kind != ResultPending {
220+ t.Fatalf("after complete Feed kind = %v, want %v", next.Kind, ResultPending)
221+ }
222+ if m.Pending() != "g" {
223+ t.Fatalf("Pending after reset = %q, want g", m.Pending())
224+ }
225+
226+ m.Reset()
227+ invalid := m.Feed(Key{Sym: "x"})
228+ if invalid.Kind != ResultInvalid {
229+ t.Fatalf("invalid Feed kind = %v, want %v", invalid.Kind, ResultInvalid)
230+ }
231+
232+ afterInvalid := m.Feed(Key{Sym: "j"})
233+ if afterInvalid.Kind != ResultComplete {
234+ t.Fatalf("after invalid Feed kind = %v, want %v", afterInvalid.Kind, ResultComplete)
235+ }
236+}
237+
238+func TestMatcher_AmbiguousShortLong(t *testing.T) {
239+ m := NewMatcher(testTrie(t))
240+
241+ m.Reset()
242+ result := m.Feed(Key{Sym: "]"})
243+ if result.Kind != ResultPending {
244+ t.Fatalf("Feed ] kind = %v, want %v", result.Kind, ResultPending)
245+ }
246+ if !m.Ambiguous() {
247+ t.Fatal("Ambiguous() = false, want true after ]")
248+ }
249+
250+ short, ok := m.ResolveAmbiguity()
251+ if !ok {
252+ t.Fatal("ResolveAmbiguity() ok = false, want true")
253+ }
254+ if short.Kind != ResultComplete {
255+ t.Fatalf("ResolveAmbiguity() kind = %v, want %v", short.Kind, ResultComplete)
256+ }
257+ if short.Action != "single-bracket" {
258+ t.Fatalf("short action = %q, want %q", short.Action, "single-bracket")
259+ }
260+
261+ m.Reset()
262+ _ = m.Feed(Key{Sym: "]"})
263+ finish := m.Feed(Key{Sym: "]"})
264+ if finish.Kind != ResultComplete {
265+ t.Fatalf("finish ]] kind = %v, want %v", finish.Kind, ResultComplete)
266+ }
267+ if finish.Action != "double-bracket" {
268+ t.Fatalf("long action = %q, want %q", finish.Action, "double-bracket")
269+ }
270+}
271+
272+func TestMatcher_Reset(t *testing.T) {
273+ m := NewMatcher(testTrie(t))
274+ _ = m.Feed(Key{Sym: "y"})
275+ _ = m.Feed(Key{Sym: "a"})
276+ m.Reset()
277+ if m.Pending() != "" {
278+ t.Fatalf("Pending after Reset = %q, want empty", m.Pending())
279+ }
280+ if m.Ambiguous() {
281+ t.Fatal("Ambiguous() after Reset = true, want false")
282+ }
283+}
284+
285+func TestMatcher_InvalidMidSequence(t *testing.T) {
286+ m := NewMatcher(testTrie(t))
287+ m.Reset()
288+ _ = m.Feed(Key{Sym: "y"})
289+ result := m.Feed(Key{Sym: "x"})
290+ if result.Kind != ResultInvalid {
291+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultInvalid)
292+ }
293+}
294+
295+func TestMatcher_ResolveAmbiguityInvalid(t *testing.T) {
296+ m := NewMatcher(testTrie(t))
297+ m.Reset()
298+ _, ok := m.ResolveAmbiguity()
299+ if ok {
300+ t.Fatal("ResolveAmbiguity without prefix ok = true, want false")
301+ }
302+}
303+
304+func TestMatcher_InvalidDeadEnd(t *testing.T) {
305+ trie := NewTrie()
306+ trie.root.children[Key{Sym: "x"}] = newTrieNode()
307+ m := NewMatcher(trie)
308+ result := m.Feed(Key{Sym: "x"})
309+ if result.Kind != ResultInvalid {
310+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultInvalid)
311+ }
312+}
313+
314+func TestMatcher_CountExactCap(t *testing.T) {
315+ m := NewMatcher(testTrie(t))
316+ m.Reset()
317+ var result Result
318+ for _, ch := range "9999" {
319+ result = m.Feed(Key{Sym: string(ch)})
320+ }
321+ result = m.Feed(Key{Sym: "j"})
322+ if result.Count != 9999 {
323+ t.Fatalf("Count = %d, want 9999", result.Count)
324+ }
325+}
M internal/domain/vimkeys/parse_test.go +123 -0
@@ -113,6 +113,129 @@ func TestParseBinding_CanonicalString(t *testing.T) {
113113 }
114114 }
115115
116+func TestParseBinding_LegacyChords(t *testing.T) {
117+ tests := []struct {
118+ input string
119+ want Sequence
120+ }{
121+ {input: "ctrl+d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
122+ {input: "control+j", want: Sequence{{Sym: "j", Mods: ModCtrl}}},
123+ {input: "alt+k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
124+ {input: "ctrl+J", want: Sequence{{Sym: "j", Mods: ModCtrl | ModShift}}},
125+ }
126+ for _, tt := range tests {
127+ t.Run(tt.input, func(t *testing.T) {
128+ got, err := ParseBinding(tt.input)
129+ if err != nil {
130+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
131+ }
132+ if !got.Equal(tt.want) {
133+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
134+ }
135+ })
136+ }
137+}
138+
139+func TestParseBinding_MoreMalformed(t *testing.T) {
140+ tests := []string{
141+ "ctrl+shift+j+k",
142+ "<C->",
143+ "<C--d>",
144+ "<Bad>",
145+ "ctrl+",
146+ }
147+ for _, input := range tests {
148+ t.Run(input, func(t *testing.T) {
149+ _, err := ParseBinding(input)
150+ if err == nil {
151+ t.Fatalf("ParseBinding(%q) expected error", input)
152+ }
153+ if !errors.Is(err, ErrBadBinding) {
154+ t.Fatalf("ParseBinding(%q) error = %v, want %v", input, err, ErrBadBinding)
155+ }
156+ })
157+ }
158+}
159+
160+func TestParseBinding_PlusAlone(t *testing.T) {
161+ got, err := ParseBinding("+")
162+ if err != nil {
163+ t.Fatalf("ParseBinding error = %v", err)
164+ }
165+ want := Sequence{{Sym: "+"}}
166+ if !got.Equal(want) {
167+ t.Fatalf("ParseBinding = %v, want %v", got, want)
168+ }
169+}
170+
171+func TestParseBinding_LooksLikeChordButRaw(t *testing.T) {
172+ got, err := ParseBinding("]c")
173+ if err != nil {
174+ t.Fatalf("ParseBinding error = %v", err)
175+ }
176+ want := Sequence{{Sym: "]"}, {Sym: "c"}}
177+ if !got.Equal(want) {
178+ t.Fatalf("ParseBinding = %v, want %v", got, want)
179+ }
180+}
181+
182+func TestParseBinding_CanonicalInlineModifiers(t *testing.T) {
183+ tests := []struct {
184+ input string
185+ want Sequence
186+ }{
187+ {input: "C-d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
188+ {input: "S-j", want: Sequence{{Sym: "j", Mods: ModShift}}},
189+ {input: "A-k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
190+ }
191+ for _, tt := range tests {
192+ t.Run(tt.input, func(t *testing.T) {
193+ got, err := ParseBinding(tt.input)
194+ if err != nil {
195+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
196+ }
197+ if !got.Equal(tt.want) {
198+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
199+ }
200+ })
201+ }
202+}
203+
204+func TestParseBinding_SpecialNamesInSequence(t *testing.T) {
205+ tests := []struct {
206+ input string
207+ want Sequence
208+ }{
209+ {input: "jEsc", want: Sequence{{Sym: "j"}, {Sym: "Esc"}}},
210+ {input: "Spacej", want: Sequence{{Sym: "Space"}, {Sym: "j"}}},
211+ {input: "CRj", want: Sequence{{Sym: "CR"}, {Sym: "j"}}},
212+ {input: "ltj", want: Sequence{{Sym: "<"}, {Sym: "j"}}},
213+ {input: "Plusj", want: Sequence{{Sym: "+"}, {Sym: "j"}}},
214+ }
215+ for _, tt := range tests {
216+ t.Run(tt.input, func(t *testing.T) {
217+ got, err := ParseBinding(tt.input)
218+ if err != nil {
219+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
220+ }
221+ if !got.Equal(tt.want) {
222+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
223+ }
224+ })
225+ }
226+}
227+
228+func TestParseBinding_PrintableGlyphs(t *testing.T) {
229+ got, err := ParseBinding("!@#")
230+ if err != nil {
231+ t.Fatalf("ParseBinding error = %v", err)
232+ }
233+ want := Sequence{{Sym: "!"}, {Sym: "@"}, {Sym: "#"}}
234+ if !got.Equal(want) {
235+ t.Fatalf("ParseBinding = %v, want %v", got, want)
236+ }
237+}
238+
116239 func TestParseBinding_RoundTrip(t *testing.T) {
117240 inputs := []string{
118241 "j",
@@ -113,6 +113,129 @@ func TestParseBinding_CanonicalString(t *testing.T) {
113 }113 }
114 }114 }
115 115
116+func TestParseBinding_LegacyChords(t *testing.T) {
117+ tests := []struct {
118+ input string
119+ want Sequence
120+ }{
121+ {input: "ctrl+d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
122+ {input: "control+j", want: Sequence{{Sym: "j", Mods: ModCtrl}}},
123+ {input: "alt+k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
124+ {input: "ctrl+J", want: Sequence{{Sym: "j", Mods: ModCtrl | ModShift}}},
125+ }
126+ for _, tt := range tests {
127+ t.Run(tt.input, func(t *testing.T) {
128+ got, err := ParseBinding(tt.input)
129+ if err != nil {
130+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
131+ }
132+ if !got.Equal(tt.want) {
133+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
134+ }
135+ })
136+ }
137+}
138+
139+func TestParseBinding_MoreMalformed(t *testing.T) {
140+ tests := []string{
141+ "ctrl+shift+j+k",
142+ "<C->",
143+ "<C--d>",
144+ "<Bad>",
145+ "ctrl+",
146+ }
147+ for _, input := range tests {
148+ t.Run(input, func(t *testing.T) {
149+ _, err := ParseBinding(input)
150+ if err == nil {
151+ t.Fatalf("ParseBinding(%q) expected error", input)
152+ }
153+ if !errors.Is(err, ErrBadBinding) {
154+ t.Fatalf("ParseBinding(%q) error = %v, want %v", input, err, ErrBadBinding)
155+ }
156+ })
157+ }
158+}
159+
160+func TestParseBinding_PlusAlone(t *testing.T) {
161+ got, err := ParseBinding("+")
162+ if err != nil {
163+ t.Fatalf("ParseBinding error = %v", err)
164+ }
165+ want := Sequence{{Sym: "+"}}
166+ if !got.Equal(want) {
167+ t.Fatalf("ParseBinding = %v, want %v", got, want)
168+ }
169+}
170+
171+func TestParseBinding_LooksLikeChordButRaw(t *testing.T) {
172+ got, err := ParseBinding("]c")
173+ if err != nil {
174+ t.Fatalf("ParseBinding error = %v", err)
175+ }
176+ want := Sequence{{Sym: "]"}, {Sym: "c"}}
177+ if !got.Equal(want) {
178+ t.Fatalf("ParseBinding = %v, want %v", got, want)
179+ }
180+}
181+
182+func TestParseBinding_CanonicalInlineModifiers(t *testing.T) {
183+ tests := []struct {
184+ input string
185+ want Sequence
186+ }{
187+ {input: "C-d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
188+ {input: "S-j", want: Sequence{{Sym: "j", Mods: ModShift}}},
189+ {input: "A-k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
190+ }
191+ for _, tt := range tests {
192+ t.Run(tt.input, func(t *testing.T) {
193+ got, err := ParseBinding(tt.input)
194+ if err != nil {
195+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
196+ }
197+ if !got.Equal(tt.want) {
198+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
199+ }
200+ })
201+ }
202+}
203+
204+func TestParseBinding_SpecialNamesInSequence(t *testing.T) {
205+ tests := []struct {
206+ input string
207+ want Sequence
208+ }{
209+ {input: "jEsc", want: Sequence{{Sym: "j"}, {Sym: "Esc"}}},
210+ {input: "Spacej", want: Sequence{{Sym: "Space"}, {Sym: "j"}}},
211+ {input: "CRj", want: Sequence{{Sym: "CR"}, {Sym: "j"}}},
212+ {input: "ltj", want: Sequence{{Sym: "<"}, {Sym: "j"}}},
213+ {input: "Plusj", want: Sequence{{Sym: "+"}, {Sym: "j"}}},
214+ }
215+ for _, tt := range tests {
216+ t.Run(tt.input, func(t *testing.T) {
217+ got, err := ParseBinding(tt.input)
218+ if err != nil {
219+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
220+ }
221+ if !got.Equal(tt.want) {
222+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
223+ }
224+ })
225+ }
226+}
227+
228+func TestParseBinding_PrintableGlyphs(t *testing.T) {
229+ got, err := ParseBinding("!@#")
230+ if err != nil {
231+ t.Fatalf("ParseBinding error = %v", err)
232+ }
233+ want := Sequence{{Sym: "!"}, {Sym: "@"}, {Sym: "#"}}
234+ if !got.Equal(want) {
235+ t.Fatalf("ParseBinding = %v, want %v", got, want)
236+ }
237+}
238+
116 func TestParseBinding_RoundTrip(t *testing.T) {239 func TestParseBinding_RoundTrip(t *testing.T) {
117 inputs := []string{240 inputs := []string{
118 "j",241 "j",
A internal/domain/vimkeys/trie.go +80 -0
@@ -0,0 +1,80 @@
1+package vimkeys
2+
3+import "errors"
4+
5+// Node describes the trie state at the end of a walked sequence.
6+type Node struct {
7+ Action string
8+ Exact bool
9+ HasChildren bool
10+}
11+
12+// ErrConflict is returned when inserting a duplicate binding.
13+var ErrConflict = errors.New("vimkeys: duplicate binding")
14+
15+const maxCount = 9999
16+
17+type trieNode struct {
18+ children map[Key]*trieNode
19+ action string
20+ hasAction bool
21+}
22+
23+// Trie stores Vim key sequence bindings.
24+type Trie struct {
25+ root *trieNode
26+ depth int
27+}
28+
29+// NewTrie creates an empty binding trie.
30+func NewTrie() *Trie {
31+ return &Trie{root: newTrieNode()}
32+}
33+
34+func newTrieNode() *trieNode {
35+ return &trieNode{children: make(map[Key]*trieNode)}
36+}
37+
38+// Insert adds a binding for seq. Duplicate sequences return ErrConflict.
39+func (t *Trie) Insert(seq Sequence, action string) error {
40+ node := t.root
41+ for _, key := range seq {
42+ child, ok := node.children[key]
43+ if !ok {
44+ child = newTrieNode()
45+ node.children[key] = child
46+ }
47+ node = child
48+ }
49+ if node.hasAction {
50+ return ErrConflict
51+ }
52+ node.hasAction = true
53+ node.action = action
54+ if depth := len(seq); depth > t.depth {
55+ t.depth = depth
56+ }
57+ return nil
58+}
59+
60+// Walk reports the trie node reached by seq and whether the path exists.
61+func (t *Trie) Walk(seq Sequence) (Node, bool) {
62+ node := t.root
63+ for _, key := range seq {
64+ child, ok := node.children[key]
65+ if !ok {
66+ return Node{}, false
67+ }
68+ node = child
69+ }
70+ return Node{
71+ Action: node.action,
72+ Exact: node.hasAction,
73+ HasChildren: len(node.children) > 0,
74+ }, true
75+}
76+
77+// MaxDepth returns the longest inserted sequence length.
78+func (t *Trie) MaxDepth() int {
79+ return t.depth
80+}
@@ -0,0 +1,80 @@
1+package vimkeys
2+
3+import "errors"
4+
5+// Node describes the trie state at the end of a walked sequence.
6+type Node struct {
7+ Action string
8+ Exact bool
9+ HasChildren bool
10+}
11+
12+// ErrConflict is returned when inserting a duplicate binding.
13+var ErrConflict = errors.New("vimkeys: duplicate binding")
14+
15+const maxCount = 9999
16+
17+type trieNode struct {
18+ children map[Key]*trieNode
19+ action string
20+ hasAction bool
21+}
22+
23+// Trie stores Vim key sequence bindings.
24+type Trie struct {
25+ root *trieNode
26+ depth int
27+}
28+
29+// NewTrie creates an empty binding trie.
30+func NewTrie() *Trie {
31+ return &Trie{root: newTrieNode()}
32+}
33+
34+func newTrieNode() *trieNode {
35+ return &trieNode{children: make(map[Key]*trieNode)}
36+}
37+
38+// Insert adds a binding for seq. Duplicate sequences return ErrConflict.
39+func (t *Trie) Insert(seq Sequence, action string) error {
40+ node := t.root
41+ for _, key := range seq {
42+ child, ok := node.children[key]
43+ if !ok {
44+ child = newTrieNode()
45+ node.children[key] = child
46+ }
47+ node = child
48+ }
49+ if node.hasAction {
50+ return ErrConflict
51+ }
52+ node.hasAction = true
53+ node.action = action
54+ if depth := len(seq); depth > t.depth {
55+ t.depth = depth
56+ }
57+ return nil
58+}
59+
60+// Walk reports the trie node reached by seq and whether the path exists.
61+func (t *Trie) Walk(seq Sequence) (Node, bool) {
62+ node := t.root
63+ for _, key := range seq {
64+ child, ok := node.children[key]
65+ if !ok {
66+ return Node{}, false
67+ }
68+ node = child
69+ }
70+ return Node{
71+ Action: node.action,
72+ Exact: node.hasAction,
73+ HasChildren: len(node.children) > 0,
74+ }, true
75+}
76+
77+// MaxDepth returns the longest inserted sequence length.
78+func (t *Trie) MaxDepth() int {
79+ return t.depth
80+}
A internal/domain/vimkeys/trie_test.go +92 -0
@@ -0,0 +1,92 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestTrie_InsertWalkPrefixes(t *testing.T) {
9+ trie := NewTrie()
10+ bindings := []struct {
11+ seq Sequence
12+ action string
13+ }{
14+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
15+ {seq: Sequence{{Sym: "]"}, {Sym: "c"}}, action: "bracket-c"},
16+ {seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
17+ {seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
18+ }
19+ for _, binding := range bindings {
20+ if err := trie.Insert(binding.seq, binding.action); err != nil {
21+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
22+ }
23+ }
24+
25+ prefixCases := []struct {
26+ name string
27+ seq Sequence
28+ wantExact bool
29+ wantChildren bool
30+ wantAction string
31+ }{
32+ {name: "]] prefix ]", seq: Sequence{{Sym: "]"}}, wantChildren: true},
33+ {name: "]] full", seq: Sequence{{Sym: "]"}, {Sym: "]"}}, wantExact: true, wantAction: "double-bracket"},
34+ {name: "]c prefix ]", seq: Sequence{{Sym: "]"}}, wantChildren: true},
35+ {name: "]c full", seq: Sequence{{Sym: "]"}, {Sym: "c"}}, wantExact: true, wantAction: "bracket-c"},
36+ {name: "gO prefix g", seq: Sequence{{Sym: "g"}}, wantChildren: true},
37+ {name: "gO full", seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, wantExact: true, wantAction: "gO"},
38+ {name: "yah prefix y", seq: Sequence{{Sym: "y"}}, wantChildren: true},
39+ {name: "yah prefix ya", seq: Sequence{{Sym: "y"}, {Sym: "a"}}, wantChildren: true},
40+ {name: "yah full", seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, wantExact: true, wantAction: "yah"},
41+ }
42+ for _, tt := range prefixCases {
43+ t.Run(tt.name, func(t *testing.T) {
44+ node, ok := trie.Walk(tt.seq)
45+ if !ok {
46+ t.Fatalf("Walk(%v) ok = false, want true", tt.seq)
47+ }
48+ if node.Exact != tt.wantExact {
49+ t.Errorf("Walk(%v) Exact = %v, want %v", tt.seq, node.Exact, tt.wantExact)
50+ }
51+ if node.HasChildren != tt.wantChildren {
52+ t.Errorf("Walk(%v) HasChildren = %v, want %v", tt.seq, node.HasChildren, tt.wantChildren)
53+ }
54+ if tt.wantExact && node.Action != tt.wantAction {
55+ t.Errorf("Walk(%v) Action = %q, want %q", tt.seq, node.Action, tt.wantAction)
56+ }
57+ })
58+ }
59+}
60+
61+func TestTrie_InsertDuplicateReturnsConflict(t *testing.T) {
62+ trie := NewTrie()
63+ seq := Sequence{{Sym: "j"}}
64+ if err := trie.Insert(seq, "first"); err != nil {
65+ t.Fatalf("first Insert error = %v", err)
66+ }
67+ err := trie.Insert(seq, "second")
68+ if !errors.Is(err, ErrConflict) {
69+ t.Fatalf("duplicate Insert error = %v, want %v", err, ErrConflict)
70+ }
71+}
72+
73+func TestTrie_MaxDepth(t *testing.T) {
74+ trie := NewTrie()
75+ if got := trie.MaxDepth(); got != 0 {
76+ t.Fatalf("empty MaxDepth() = %d, want 0", got)
77+ }
78+ _ = trie.Insert(Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, "yah")
79+ _ = trie.Insert(Sequence{{Sym: "]"}, {Sym: "]"}}, "brackets")
80+ if got := trie.MaxDepth(); got != 3 {
81+ t.Fatalf("MaxDepth() = %d, want 3", got)
82+ }
83+}
84+
85+func TestTrie_WalkMissingPath(t *testing.T) {
86+ trie := NewTrie()
87+ _ = trie.Insert(Sequence{{Sym: "j"}}, "down")
88+ _, ok := trie.Walk(Sequence{{Sym: "k"}})
89+ if ok {
90+ t.Fatal("Walk missing path ok = true, want false")
91+ }
92+}
@@ -0,0 +1,92 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestTrie_InsertWalkPrefixes(t *testing.T) {
9+ trie := NewTrie()
10+ bindings := []struct {
11+ seq Sequence
12+ action string
13+ }{
14+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
15+ {seq: Sequence{{Sym: "]"}, {Sym: "c"}}, action: "bracket-c"},
16+ {seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
17+ {seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
18+ }
19+ for _, binding := range bindings {
20+ if err := trie.Insert(binding.seq, binding.action); err != nil {
21+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
22+ }
23+ }
24+
25+ prefixCases := []struct {
26+ name string
27+ seq Sequence
28+ wantExact bool
29+ wantChildren bool
30+ wantAction string
31+ }{
32+ {name: "]] prefix ]", seq: Sequence{{Sym: "]"}}, wantChildren: true},
33+ {name: "]] full", seq: Sequence{{Sym: "]"}, {Sym: "]"}}, wantExact: true, wantAction: "double-bracket"},
34+ {name: "]c prefix ]", seq: Sequence{{Sym: "]"}}, wantChildren: true},
35+ {name: "]c full", seq: Sequence{{Sym: "]"}, {Sym: "c"}}, wantExact: true, wantAction: "bracket-c"},
36+ {name: "gO prefix g", seq: Sequence{{Sym: "g"}}, wantChildren: true},
37+ {name: "gO full", seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, wantExact: true, wantAction: "gO"},
38+ {name: "yah prefix y", seq: Sequence{{Sym: "y"}}, wantChildren: true},
39+ {name: "yah prefix ya", seq: Sequence{{Sym: "y"}, {Sym: "a"}}, wantChildren: true},
40+ {name: "yah full", seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, wantExact: true, wantAction: "yah"},
41+ }
42+ for _, tt := range prefixCases {
43+ t.Run(tt.name, func(t *testing.T) {
44+ node, ok := trie.Walk(tt.seq)
45+ if !ok {
46+ t.Fatalf("Walk(%v) ok = false, want true", tt.seq)
47+ }
48+ if node.Exact != tt.wantExact {
49+ t.Errorf("Walk(%v) Exact = %v, want %v", tt.seq, node.Exact, tt.wantExact)
50+ }
51+ if node.HasChildren != tt.wantChildren {
52+ t.Errorf("Walk(%v) HasChildren = %v, want %v", tt.seq, node.HasChildren, tt.wantChildren)
53+ }
54+ if tt.wantExact && node.Action != tt.wantAction {
55+ t.Errorf("Walk(%v) Action = %q, want %q", tt.seq, node.Action, tt.wantAction)
56+ }
57+ })
58+ }
59+}
60+
61+func TestTrie_InsertDuplicateReturnsConflict(t *testing.T) {
62+ trie := NewTrie()
63+ seq := Sequence{{Sym: "j"}}
64+ if err := trie.Insert(seq, "first"); err != nil {
65+ t.Fatalf("first Insert error = %v", err)
66+ }
67+ err := trie.Insert(seq, "second")
68+ if !errors.Is(err, ErrConflict) {
69+ t.Fatalf("duplicate Insert error = %v, want %v", err, ErrConflict)
70+ }
71+}
72+
73+func TestTrie_MaxDepth(t *testing.T) {
74+ trie := NewTrie()
75+ if got := trie.MaxDepth(); got != 0 {
76+ t.Fatalf("empty MaxDepth() = %d, want 0", got)
77+ }
78+ _ = trie.Insert(Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, "yah")
79+ _ = trie.Insert(Sequence{{Sym: "]"}, {Sym: "]"}}, "brackets")
80+ if got := trie.MaxDepth(); got != 3 {
81+ t.Fatalf("MaxDepth() = %d, want 3", got)
82+ }
83+}
84+
85+func TestTrie_WalkMissingPath(t *testing.T) {
86+ trie := NewTrie()
87+ _ = trie.Insert(Sequence{{Sym: "j"}}, "down")
88+ _, ok := trie.Walk(Sequence{{Sym: "k"}})
89+ if ok {
90+ t.Fatal("Walk missing path ok = true, want false")
91+ }
92+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close