detached d1365c4

refactor(vimkeys): enforce parser invariants

commit
d1365c44c7954e08ee0d459ea6a213025338d63a
author
brice · · 2026-07-28 17:50 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
e7d570f
signature
unsigned
3 files · +160 -88
M internal/domain/vimkeys/parse.go +127 -85
@@ -6,27 +6,13 @@ import (
66 "unicode/utf8"
77 )
88
9-var specialAliases = map[string]string{
10- "cr": "CR",
11- "return": "CR",
12- "esc": "Esc",
13- "escape": "Esc",
14- "space": "Space",
15- "lt": "<",
16- "plus": "+",
17-}
18-
199 func ParseBinding(s string) (Sequence, error) {
2010 s = strings.TrimSpace(s)
2111 if s == "" {
2212 return nil, ErrEmptyBinding
2313 }
2414
25- if looksLikeChordAttempt(s) {
26- if !isLegacyChord(s) {
27- return nil, ErrBadBinding
28- }
29- key, err := parseLegacyChord(s)
15+ if key, ok, err := tryParseAsLegacyChord(s); ok {
3016 if err != nil {
3117 return nil, err
3218 }
@@ -35,14 +21,45 @@ func ParseBinding(s string) (Sequence, error) {
3521 return parseRawSequence(s)
3622 }
3723
38-func looksLikeChordAttempt(s string) bool {
39- if s == "+" {
24+func legacyChordParts(s string) []string {
25+ parts := strings.Split(s, "+")
26+ tokens := make([]string, 0, len(parts))
27+ for _, part := range parts {
28+ if part = strings.TrimSpace(part); part != "" {
29+ tokens = append(tokens, part)
30+ }
31+ }
32+ return tokens
33+}
34+
35+func isModifierName(part string) bool {
36+ switch strings.ToLower(part) {
37+ case "ctrl", "control", "shift", "alt":
4038 return true
39+ default:
40+ return false
41+ }
42+}
43+
44+func tryParseAsLegacyChord(s string) (Key, bool, error) {
45+ if s == "+" {
46+ return Key{Sym: "+"}, true, nil
47+ }
48+
49+ parts := legacyChordParts(s)
50+ if !legacyChordLooksLikeAttempt(s, parts) {
51+ return Key{}, false, nil
4152 }
53+
54+ key, err := buildLegacyChordKey(s, parts)
55+ return key, true, err
56+}
57+
58+func legacyChordLooksLikeAttempt(s string, parts []string) bool {
4259 if strings.HasSuffix(s, "++") {
4360 return true
4461 }
45- for _, part := range strings.Split(s, "+") {
62+ for _, part := range parts {
4663 if isModifierName(part) {
4764 return true
4865 }
@@ -50,84 +67,77 @@ func looksLikeChordAttempt(s string) bool {
5067 return false
5168 }
5269
53-func isLegacyChord(s string) bool {
54- if s == "+" {
55- return true
56- }
70+func validateLegacyChordLayout(s string, parts []string) error {
5771 if !strings.Contains(s, "+") {
58- return false
72+ return ErrBadBinding
5973 }
6074 if strings.HasSuffix(s, "++") {
61- return true
75+ return nil
6276 }
63- parts := strings.Split(s, "+")
6477 for i, part := range parts {
65- part = strings.TrimSpace(part)
66- if part == "" {
67- continue
68- }
6978 if isModifierName(part) {
7079 continue
7180 }
72- if i == len(parts)-1 {
73- return true
81+ if i != len(parts)-1 {
82+ return ErrBadBinding
7483 }
75- return false
7684 }
77- return false
85+ return nil
7886 }
7987
80-func isModifierName(part string) bool {
88+func modsFromLegacyPart(part string) (Mods, bool) {
8189 switch strings.ToLower(part) {
82- case "ctrl", "control", "shift", "alt":
83- return true
90+ case "ctrl", "control":
91+ return ModCtrl, true
92+ case "shift":
93+ return ModShift, true
94+ case "alt":
95+ return ModAlt, true
8496 default:
85- return false
97+ return 0, false
8698 }
8799 }
88100
89-func parseLegacyChord(s string) (Key, error) {
90- if s == "+" {
91- return Key{Sym: "+"}, nil
101+func collectLegacyChordModsAndKey(s string, parts []string) (Mods, string, error) {
102+ if err := validateLegacyChordLayout(s, parts); err != nil {
103+ return 0, "", err
92104 }
93105
94- parts := strings.Split(s, "+")
95106 var mods Mods
96- var keyPart string
97-
107+ keyPart := ""
98108 for _, part := range parts {
99- part = strings.TrimSpace(part)
100- if part == "" {
109+ if mod, ok := modsFromLegacyPart(part); ok {
110+ mods |= mod
101111 continue
102112 }
103- lower := strings.ToLower(part)
104- switch lower {
105- case "ctrl", "control":
106- mods |= ModCtrl
107- case "shift":
108- mods |= ModShift
109- case "alt":
110- mods |= ModAlt
111- default:
112- if keyPart != "" {
113- return Key{}, ErrBadBinding
114- }
115- keyPart = part
113+ if keyPart != "" {
114+ return 0, "", ErrBadBinding
116115 }
116+ keyPart = part
117117 }
118118
119119 if keyPart == "" && strings.HasSuffix(s, "++") {
120120 keyPart = "+"
121121 }
122122 if keyPart == "" {
123- return Key{}, ErrBadBinding
123+ return 0, "", ErrBadBinding
124124 }
125+ return mods, keyPart, nil
126+}
125127
128+func legacyKeyPartWithShift(keyPart string, mods Mods) (string, Mods) {
126129 if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
127- mods |= ModShift
128- keyPart = strings.ToLower(keyPart)
130+ return strings.ToLower(keyPart), mods | ModShift
129131 }
132+ return keyPart, mods
133+}
130134
135+func buildLegacyChordKey(s string, parts []string) (Key, error) {
136+ mods, keyPart, err := collectLegacyChordModsAndKey(s, parts)
137+ if err != nil {
138+ return Key{}, err
139+ }
140+ keyPart, mods = legacyKeyPartWithShift(keyPart, mods)
131141 sym, err := symFromToken(keyPart, false)
132142 if err != nil {
133143 return Key{}, err
@@ -152,18 +162,11 @@ func parseAngleToken(inner string) (Key, error) {
152162 if modPart == "" {
153163 return Key{}, ErrBadBinding
154164 }
155- for _, ch := range modPart {
156- switch ch {
157- case 'C', 'c':
158- mods |= ModCtrl
159- case 'S', 's':
160- mods |= ModShift
161- case 'A', 'a':
162- mods |= ModAlt
163- default:
164- return Key{}, ErrBadBinding
165- }
165+ partMods, err := modsFromCompactModString(modPart)
166+ if err != nil {
167+ return Key{}, err
166168 }
169+ mods |= partMods
167170 }
168171
169172 sym, err := symFromToken(symPart, true)
@@ -180,7 +183,22 @@ func parseAngleToken(inner string) (Key, error) {
180183 return Key{Sym: sym}, nil
181184 }
182185
183-var sequenceSpecialNames = []string{"Space", "Esc", "CR", "lt", "Plus"}
186+func modsFromCompactModString(modPart string) (Mods, error) {
187+ var mods Mods
188+ for _, ch := range modPart {
189+ switch ch {
190+ case 'C', 'c':
191+ mods |= ModCtrl
192+ case 'S', 's':
193+ mods |= ModShift
194+ case 'A', 'a':
195+ mods |= ModAlt
196+ default:
197+ return 0, ErrBadBinding
198+ }
199+ }
200+ return mods, nil
201+}
184202
185203 func parseRawSequence(s string) (Sequence, error) {
186204 seq := make(Sequence, 0, len(s))
@@ -237,17 +255,24 @@ func parseAngleAt(s string, i int) (Key, int, error) {
237255 }
238256
239257 func tryParseSpecialAt(s string, i int) (Key, int, bool) {
240- for _, name := range sequenceSpecialNames {
241- if !strings.HasPrefix(s[i:], name) {
242- continue
243- }
244- sym, err := symFromAlias(name)
245- if err != nil {
246- continue
247- }
248- return Key{Sym: sym}, len(name), true
258+ if i >= len(s) {
259+ return Key{}, 0, false
260+ }
261+ rest := s[i:]
262+ switch {
263+ case strings.HasPrefix(rest, "Space"):
264+ return Key{Sym: "Space"}, len("Space"), true
265+ case strings.HasPrefix(rest, "Plus"):
266+ return Key{Sym: "+"}, len("Plus"), true
267+ case strings.HasPrefix(rest, "Esc"):
268+ return Key{Sym: "Esc"}, len("Esc"), true
269+ case strings.HasPrefix(rest, "CR"):
270+ return Key{Sym: "CR"}, len("CR"), true
271+ case strings.HasPrefix(rest, "lt"):
272+ return Key{Sym: "<"}, len("lt"), true
273+ default:
274+ return Key{}, 0, false
249275 }
250- return Key{}, 0, false
251276 }
252277
253278 func tryParseCanonicalAt(s string, i int) (Key, int, bool) {
@@ -336,8 +361,25 @@ func shiftedGlyphForRune(r rune) (string, bool) {
336361 }
337362 }
338363
364+func symFromSpecialAlias(token string) (string, bool) {
365+ switch strings.ToLower(token) {
366+ case "cr", "return":
367+ return "CR", true
368+ case "esc", "escape":
369+ return "Esc", true
370+ case "space":
371+ return "Space", true
372+ case "lt":
373+ return "<", true
374+ case "plus":
375+ return "+", true
376+ default:
377+ return "", false
378+ }
379+}
380+
339381 func symFromToken(token string, allowSingleLetter bool) (string, error) {
340- if sym, ok := specialAliases[strings.ToLower(token)]; ok {
382+ if sym, ok := symFromSpecialAlias(token); ok {
341383 return sym, nil
342384 }
343385 if allowSingleLetter && len(token) == 1 {
@@ -356,7 +398,7 @@ func symFromToken(token string, allowSingleLetter bool) (string, error) {
356398 }
357399
358400 func symFromAlias(token string) (string, error) {
359- if sym, ok := specialAliases[strings.ToLower(token)]; ok {
401+ if sym, ok := symFromSpecialAlias(token); ok {
360402 return sym, nil
361403 }
362404 return "", ErrBadBinding
@@ -6,27 +6,13 @@ import (
6 "unicode/utf8"6 "unicode/utf8"
7 )7 )
8 8
9-var specialAliases = map[string]string{
10- "cr": "CR",
11- "return": "CR",
12- "esc": "Esc",
13- "escape": "Esc",
14- "space": "Space",
15- "lt": "<",
16- "plus": "+",
17-}
18-
19 func ParseBinding(s string) (Sequence, error) {9 func ParseBinding(s string) (Sequence, error) {
20 s = strings.TrimSpace(s)10 s = strings.TrimSpace(s)
21 if s == "" {11 if s == "" {
22 return nil, ErrEmptyBinding12 return nil, ErrEmptyBinding
23 }13 }
24 14
25- if looksLikeChordAttempt(s) {15+ if key, ok, err := tryParseAsLegacyChord(s); ok {
26- if !isLegacyChord(s) {
27- return nil, ErrBadBinding
28- }
29- key, err := parseLegacyChord(s)
30 if err != nil {16 if err != nil {
31 return nil, err17 return nil, err
32 }18 }
@@ -35,14 +21,45 @@ func ParseBinding(s string) (Sequence, error) {
35 return parseRawSequence(s)21 return parseRawSequence(s)
36 }22 }
37 23
38-func looksLikeChordAttempt(s string) bool {24+func legacyChordParts(s string) []string {
39- if s == "+" {25+ parts := strings.Split(s, "+")
26+ tokens := make([]string, 0, len(parts))
27+ for _, part := range parts {
28+ if part = strings.TrimSpace(part); part != "" {
29+ tokens = append(tokens, part)
30+ }
31+ }
32+ return tokens
33+}
34+
35+func isModifierName(part string) bool {
36+ switch strings.ToLower(part) {
37+ case "ctrl", "control", "shift", "alt":
40 return true38 return true
39+ default:
40+ return false
41+ }
42+}
43+
44+func tryParseAsLegacyChord(s string) (Key, bool, error) {
45+ if s == "+" {
46+ return Key{Sym: "+"}, true, nil
47+ }
48+
49+ parts := legacyChordParts(s)
50+ if !legacyChordLooksLikeAttempt(s, parts) {
51+ return Key{}, false, nil
41 }52 }
53+
54+ key, err := buildLegacyChordKey(s, parts)
55+ return key, true, err
56+}
57+
58+func legacyChordLooksLikeAttempt(s string, parts []string) bool {
42 if strings.HasSuffix(s, "++") {59 if strings.HasSuffix(s, "++") {
43 return true60 return true
44 }61 }
45- for _, part := range strings.Split(s, "+") {62+ for _, part := range parts {
46 if isModifierName(part) {63 if isModifierName(part) {
47 return true64 return true
48 }65 }
@@ -50,84 +67,77 @@ func looksLikeChordAttempt(s string) bool {
50 return false67 return false
51 }68 }
52 69
53-func isLegacyChord(s string) bool {70+func validateLegacyChordLayout(s string, parts []string) error {
54- if s == "+" {
55- return true
56- }
57 if !strings.Contains(s, "+") {71 if !strings.Contains(s, "+") {
58- return false72+ return ErrBadBinding
59 }73 }
60 if strings.HasSuffix(s, "++") {74 if strings.HasSuffix(s, "++") {
61- return true75+ return nil
62 }76 }
63- parts := strings.Split(s, "+")
64 for i, part := range parts {77 for i, part := range parts {
65- part = strings.TrimSpace(part)
66- if part == "" {
67- continue
68- }
69 if isModifierName(part) {78 if isModifierName(part) {
70 continue79 continue
71 }80 }
72- if i == len(parts)-1 {81+ if i != len(parts)-1 {
73- return true82+ return ErrBadBinding
74 }83 }
75- return false
76 }84 }
77- return false85+ return nil
78 }86 }
79 87
80-func isModifierName(part string) bool {88+func modsFromLegacyPart(part string) (Mods, bool) {
81 switch strings.ToLower(part) {89 switch strings.ToLower(part) {
82- case "ctrl", "control", "shift", "alt":90+ case "ctrl", "control":
83- return true91+ return ModCtrl, true
92+ case "shift":
93+ return ModShift, true
94+ case "alt":
95+ return ModAlt, true
84 default:96 default:
85- return false97+ return 0, false
86 }98 }
87 }99 }
88 100
89-func parseLegacyChord(s string) (Key, error) {101+func collectLegacyChordModsAndKey(s string, parts []string) (Mods, string, error) {
90- if s == "+" {102+ if err := validateLegacyChordLayout(s, parts); err != nil {
91- return Key{Sym: "+"}, nil103+ return 0, "", err
92 }104 }
93 105
94- parts := strings.Split(s, "+")
95 var mods Mods106 var mods Mods
96- var keyPart string107+ keyPart := ""
97-
98 for _, part := range parts {108 for _, part := range parts {
99- part = strings.TrimSpace(part)109+ if mod, ok := modsFromLegacyPart(part); ok {
100- if part == "" {110+ mods |= mod
101 continue111 continue
102 }112 }
103- lower := strings.ToLower(part)113+ if keyPart != "" {
104- switch lower {114+ return 0, "", ErrBadBinding
105- case "ctrl", "control":
106- mods |= ModCtrl
107- case "shift":
108- mods |= ModShift
109- case "alt":
110- mods |= ModAlt
111- default:
112- if keyPart != "" {
113- return Key{}, ErrBadBinding
114- }
115- keyPart = part
116 }115 }
116+ keyPart = part
117 }117 }
118 118
119 if keyPart == "" && strings.HasSuffix(s, "++") {119 if keyPart == "" && strings.HasSuffix(s, "++") {
120 keyPart = "+"120 keyPart = "+"
121 }121 }
122 if keyPart == "" {122 if keyPart == "" {
123- return Key{}, ErrBadBinding123+ return 0, "", ErrBadBinding
124 }124 }
125+ return mods, keyPart, nil
126+}
125 127
128+func legacyKeyPartWithShift(keyPart string, mods Mods) (string, Mods) {
126 if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {129 if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
127- mods |= ModShift130+ return strings.ToLower(keyPart), mods | ModShift
128- keyPart = strings.ToLower(keyPart)
129 }131 }
132+ return keyPart, mods
133+}
130 134
135+func buildLegacyChordKey(s string, parts []string) (Key, error) {
136+ mods, keyPart, err := collectLegacyChordModsAndKey(s, parts)
137+ if err != nil {
138+ return Key{}, err
139+ }
140+ keyPart, mods = legacyKeyPartWithShift(keyPart, mods)
131 sym, err := symFromToken(keyPart, false)141 sym, err := symFromToken(keyPart, false)
132 if err != nil {142 if err != nil {
133 return Key{}, err143 return Key{}, err
@@ -152,18 +162,11 @@ func parseAngleToken(inner string) (Key, error) {
152 if modPart == "" {162 if modPart == "" {
153 return Key{}, ErrBadBinding163 return Key{}, ErrBadBinding
154 }164 }
155- for _, ch := range modPart {165+ partMods, err := modsFromCompactModString(modPart)
156- switch ch {166+ if err != nil {
157- case 'C', 'c':167+ return Key{}, err
158- mods |= ModCtrl
159- case 'S', 's':
160- mods |= ModShift
161- case 'A', 'a':
162- mods |= ModAlt
163- default:
164- return Key{}, ErrBadBinding
165- }
166 }168 }
169+ mods |= partMods
167 }170 }
168 171
169 sym, err := symFromToken(symPart, true)172 sym, err := symFromToken(symPart, true)
@@ -180,7 +183,22 @@ func parseAngleToken(inner string) (Key, error) {
180 return Key{Sym: sym}, nil183 return Key{Sym: sym}, nil
181 }184 }
182 185
183-var sequenceSpecialNames = []string{"Space", "Esc", "CR", "lt", "Plus"}186+func modsFromCompactModString(modPart string) (Mods, error) {
187+ var mods Mods
188+ for _, ch := range modPart {
189+ switch ch {
190+ case 'C', 'c':
191+ mods |= ModCtrl
192+ case 'S', 's':
193+ mods |= ModShift
194+ case 'A', 'a':
195+ mods |= ModAlt
196+ default:
197+ return 0, ErrBadBinding
198+ }
199+ }
200+ return mods, nil
201+}
184 202
185 func parseRawSequence(s string) (Sequence, error) {203 func parseRawSequence(s string) (Sequence, error) {
186 seq := make(Sequence, 0, len(s))204 seq := make(Sequence, 0, len(s))
@@ -237,17 +255,24 @@ func parseAngleAt(s string, i int) (Key, int, error) {
237 }255 }
238 256
239 func tryParseSpecialAt(s string, i int) (Key, int, bool) {257 func tryParseSpecialAt(s string, i int) (Key, int, bool) {
240- for _, name := range sequenceSpecialNames {258+ if i >= len(s) {
241- if !strings.HasPrefix(s[i:], name) {259+ return Key{}, 0, false
242- continue260+ }
243- }261+ rest := s[i:]
244- sym, err := symFromAlias(name)262+ switch {
245- if err != nil {263+ case strings.HasPrefix(rest, "Space"):
246- continue264+ return Key{Sym: "Space"}, len("Space"), true
247- }265+ case strings.HasPrefix(rest, "Plus"):
248- return Key{Sym: sym}, len(name), true266+ return Key{Sym: "+"}, len("Plus"), true
267+ case strings.HasPrefix(rest, "Esc"):
268+ return Key{Sym: "Esc"}, len("Esc"), true
269+ case strings.HasPrefix(rest, "CR"):
270+ return Key{Sym: "CR"}, len("CR"), true
271+ case strings.HasPrefix(rest, "lt"):
272+ return Key{Sym: "<"}, len("lt"), true
273+ default:
274+ return Key{}, 0, false
249 }275 }
250- return Key{}, 0, false
251 }276 }
252 277
253 func tryParseCanonicalAt(s string, i int) (Key, int, bool) {278 func tryParseCanonicalAt(s string, i int) (Key, int, bool) {
@@ -336,8 +361,25 @@ func shiftedGlyphForRune(r rune) (string, bool) {
336 }361 }
337 }362 }
338 363
364+func symFromSpecialAlias(token string) (string, bool) {
365+ switch strings.ToLower(token) {
366+ case "cr", "return":
367+ return "CR", true
368+ case "esc", "escape":
369+ return "Esc", true
370+ case "space":
371+ return "Space", true
372+ case "lt":
373+ return "<", true
374+ case "plus":
375+ return "+", true
376+ default:
377+ return "", false
378+ }
379+}
380+
339 func symFromToken(token string, allowSingleLetter bool) (string, error) {381 func symFromToken(token string, allowSingleLetter bool) (string, error) {
340- if sym, ok := specialAliases[strings.ToLower(token)]; ok {382+ if sym, ok := symFromSpecialAlias(token); ok {
341 return sym, nil383 return sym, nil
342 }384 }
343 if allowSingleLetter && len(token) == 1 {385 if allowSingleLetter && len(token) == 1 {
@@ -356,7 +398,7 @@ func symFromToken(token string, allowSingleLetter bool) (string, error) {
356 }398 }
357 399
358 func symFromAlias(token string) (string, error) {400 func symFromAlias(token string) (string, error) {
359- if sym, ok := specialAliases[strings.ToLower(token)]; ok {401+ if sym, ok := symFromSpecialAlias(token); ok {
360 return sym, nil402 return sym, nil
361 }403 }
362 return "", ErrBadBinding404 return "", ErrBadBinding
M internal/domain/vimkeys/trie.go +4 -0
@@ -36,7 +36,11 @@ func newTrieNode() *trieNode {
3636 }
3737
3838 // Insert adds a binding for seq. Duplicate sequences return ErrConflict.
39+// Nil or empty sequences return ErrEmptyBinding.
3940 func (t *Trie) Insert(seq Sequence, action string) error {
41+ if len(seq) == 0 {
42+ return ErrEmptyBinding
43+ }
4044 node := t.root
4145 for _, key := range seq {
4246 child, ok := node.children[key]
@@ -36,7 +36,11 @@ func newTrieNode() *trieNode {
36 }36 }
37 37
38 // Insert adds a binding for seq. Duplicate sequences return ErrConflict.38 // Insert adds a binding for seq. Duplicate sequences return ErrConflict.
39+// Nil or empty sequences return ErrEmptyBinding.
39 func (t *Trie) Insert(seq Sequence, action string) error {40 func (t *Trie) Insert(seq Sequence, action string) error {
41+ if len(seq) == 0 {
42+ return ErrEmptyBinding
43+ }
40 node := t.root44 node := t.root
41 for _, key := range seq {45 for _, key := range seq {
42 child, ok := node.children[key]46 child, ok := node.children[key]
M internal/domain/vimkeys/trie_test.go +29 -3
@@ -70,13 +70,37 @@ func TestTrie_InsertDuplicateReturnsConflict(t *testing.T) {
7070 }
7171 }
7272
73+func TestTrie_InsertEmptySequenceReturnsErrEmptyBinding(t *testing.T) {
74+ trie := NewTrie()
75+ for _, seq := range []Sequence{nil, {}} {
76+ err := trie.Insert(seq, "root-action")
77+ if !errors.Is(err, ErrEmptyBinding) {
78+ t.Fatalf("Insert(%v) error = %v, want %v", seq, err, ErrEmptyBinding)
79+ }
80+ }
81+ node, ok := trie.Walk(nil)
82+ if !ok {
83+ t.Fatal("Walk(nil) ok = false, want true for empty path")
84+ }
85+ if node.Exact || node.Action != "" {
86+ t.Fatalf("Walk(nil) after rejected empty Insert = %+v, want no root action", node)
87+ }
88+ if trie.MaxDepth() != 0 {
89+ t.Fatalf("MaxDepth() = %d after rejected empty Insert, want 0", trie.MaxDepth())
90+ }
91+}
92+
7393 func TestTrie_MaxDepth(t *testing.T) {
7494 trie := NewTrie()
7595 if got := trie.MaxDepth(); got != 0 {
7696 t.Fatalf("empty MaxDepth() = %d, want 0", got)
7797 }
78- _ = trie.Insert(Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, "yah")
79- _ = trie.Insert(Sequence{{Sym: "]"}, {Sym: "]"}}, "brackets")
98+ if err := trie.Insert(Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, "yah"); err != nil {
99+ t.Fatalf("Insert(yah) error = %v", err)
100+ }
101+ if err := trie.Insert(Sequence{{Sym: "]"}, {Sym: "]"}}, "brackets"); err != nil {
102+ t.Fatalf("Insert(]]) error = %v", err)
103+ }
80104 if got := trie.MaxDepth(); got != 3 {
81105 t.Fatalf("MaxDepth() = %d, want 3", got)
82106 }
@@ -84,7 +108,9 @@ func TestTrie_MaxDepth(t *testing.T) {
84108
85109 func TestTrie_WalkMissingPath(t *testing.T) {
86110 trie := NewTrie()
87- _ = trie.Insert(Sequence{{Sym: "j"}}, "down")
111+ if err := trie.Insert(Sequence{{Sym: "j"}}, "down"); err != nil {
112+ t.Fatalf("Insert(j) error = %v", err)
113+ }
88114 _, ok := trie.Walk(Sequence{{Sym: "k"}})
89115 if ok {
90116 t.Fatal("Walk missing path ok = true, want false")
@@ -70,13 +70,37 @@ func TestTrie_InsertDuplicateReturnsConflict(t *testing.T) {
70 }70 }
71 }71 }
72 72
73+func TestTrie_InsertEmptySequenceReturnsErrEmptyBinding(t *testing.T) {
74+ trie := NewTrie()
75+ for _, seq := range []Sequence{nil, {}} {
76+ err := trie.Insert(seq, "root-action")
77+ if !errors.Is(err, ErrEmptyBinding) {
78+ t.Fatalf("Insert(%v) error = %v, want %v", seq, err, ErrEmptyBinding)
79+ }
80+ }
81+ node, ok := trie.Walk(nil)
82+ if !ok {
83+ t.Fatal("Walk(nil) ok = false, want true for empty path")
84+ }
85+ if node.Exact || node.Action != "" {
86+ t.Fatalf("Walk(nil) after rejected empty Insert = %+v, want no root action", node)
87+ }
88+ if trie.MaxDepth() != 0 {
89+ t.Fatalf("MaxDepth() = %d after rejected empty Insert, want 0", trie.MaxDepth())
90+ }
91+}
92+
73 func TestTrie_MaxDepth(t *testing.T) {93 func TestTrie_MaxDepth(t *testing.T) {
74 trie := NewTrie()94 trie := NewTrie()
75 if got := trie.MaxDepth(); got != 0 {95 if got := trie.MaxDepth(); got != 0 {
76 t.Fatalf("empty MaxDepth() = %d, want 0", got)96 t.Fatalf("empty MaxDepth() = %d, want 0", got)
77 }97 }
78- _ = trie.Insert(Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, "yah")98+ if err := trie.Insert(Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, "yah"); err != nil {
79- _ = trie.Insert(Sequence{{Sym: "]"}, {Sym: "]"}}, "brackets")99+ t.Fatalf("Insert(yah) error = %v", err)
100+ }
101+ if err := trie.Insert(Sequence{{Sym: "]"}, {Sym: "]"}}, "brackets"); err != nil {
102+ t.Fatalf("Insert(]]) error = %v", err)
103+ }
80 if got := trie.MaxDepth(); got != 3 {104 if got := trie.MaxDepth(); got != 3 {
81 t.Fatalf("MaxDepth() = %d, want 3", got)105 t.Fatalf("MaxDepth() = %d, want 3", got)
82 }106 }
@@ -84,7 +108,9 @@ func TestTrie_MaxDepth(t *testing.T) {
84 108
85 func TestTrie_WalkMissingPath(t *testing.T) {109 func TestTrie_WalkMissingPath(t *testing.T) {
86 trie := NewTrie()110 trie := NewTrie()
87- _ = trie.Insert(Sequence{{Sym: "j"}}, "down")111+ if err := trie.Insert(Sequence{{Sym: "j"}}, "down"); err != nil {
112+ t.Fatalf("Insert(j) error = %v", err)
113+ }
88 _, ok := trie.Walk(Sequence{{Sym: "k"}})114 _, ok := trie.Walk(Sequence{{Sym: "k"}})
89 if ok {115 if ok {
90 t.Fatal("Walk missing path ok = true, want false")116 t.Fatal("Walk missing path ok = true, want false")

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