fix(vim): handle nil sequence trie
- commit
- fc654324b6172ad9307120ed5d585670ee20c010
- author
- brice · · 2026-08-14 03:30 UTC
- parent
- 2bdff8a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +92 -5
M internal/domain/vimkeys/matcher.go +27 -2
| @@ -36,6 +36,9 @@ func NewMatcher(trie *Trie) *Matcher { | |||
| 36 | 36 | ||
| 37 | 37 | // Reset clears matcher state. | |
| 38 | 38 | func (m *Matcher) Reset() { | |
| 39 | + | if m == nil { | |
| 40 | + | return | |
| 41 | + | } | |
| 39 | 42 | m.node = nil | |
| 40 | 43 | m.pending = nil | |
| 41 | 44 | m.count = 0 | |
| @@ -45,16 +48,24 @@ func (m *Matcher) Reset() { | |||
| 45 | 48 | ||
| 46 | 49 | // Pending returns the count prefix and keys matched so far. | |
| 47 | 50 | func (m *Matcher) Pending() string { | |
| 51 | + | if !m.hasTrie() { | |
| 52 | + | return "" | |
| 53 | + | } | |
| 48 | 54 | return formatPending(m.count, m.pending) | |
| 49 | 55 | } | |
| 50 | 56 | ||
| 51 | 57 | // Ambiguous reports whether the current prefix is both exact and extendable. | |
| 52 | 58 | func (m *Matcher) Ambiguous() bool { | |
| 53 | - | return m.ambiguous | |
| 59 | + | return m.hasTrie() && m.ambiguous | |
| 54 | 60 | } | |
| 55 | 61 | ||
| 56 | 62 | // Feed consumes one key and returns the current match state. | |
| 57 | 63 | func (m *Matcher) Feed(key Key) Result { | |
| 64 | + | if !m.hasTrie() { | |
| 65 | + | m.Reset() | |
| 66 | + | return Result{Kind: ResultInvalid} | |
| 67 | + | } | |
| 68 | + | ||
| 58 | 69 | if m.node == nil && len(m.pending) == 0 && m.count == 0 && !m.inCount { | |
| 59 | 70 | m.beginSequence() | |
| 60 | 71 | } | |
| @@ -71,7 +82,15 @@ func (m *Matcher) Feed(key Key) Result { | |||
| 71 | 82 | return m.feedSequenceKey(key) | |
| 72 | 83 | } | |
| 73 | 84 | ||
| 85 | + | func (m *Matcher) hasTrie() bool { | |
| 86 | + | return m != nil && m.trie != nil && m.trie.root != nil | |
| 87 | + | } | |
| 88 | + | ||
| 74 | 89 | func (m *Matcher) beginSequence() { | |
| 90 | + | if !m.hasTrie() { | |
| 91 | + | m.Reset() | |
| 92 | + | return | |
| 93 | + | } | |
| 75 | 94 | m.node = m.trie.root | |
| 76 | 95 | m.pending = nil | |
| 77 | 96 | m.count = 0 | |
| @@ -96,6 +115,12 @@ func (m *Matcher) feedCountDigit(key Key) Result { | |||
| 96 | 115 | } | |
| 97 | 116 | ||
| 98 | 117 | func (m *Matcher) feedSequenceKey(key Key) Result { | |
| 118 | + | if m.node == nil { | |
| 119 | + | result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)} | |
| 120 | + | m.Reset() | |
| 121 | + | return result | |
| 122 | + | } | |
| 123 | + | ||
| 99 | 124 | child, ok := m.node.children[key] | |
| 100 | 125 | if !ok { | |
| 101 | 126 | result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)} | |
| @@ -132,7 +157,7 @@ func (m *Matcher) feedSequenceKey(key Key) Result { | |||
| 132 | 157 | ||
| 133 | 158 | // ResolveAmbiguity completes the current short exact binding. | |
| 134 | 159 | func (m *Matcher) ResolveAmbiguity() (Result, bool) { | |
| 135 | - | if !m.ambiguous || m.node == nil || !m.node.hasAction { | |
| 160 | + | if !m.hasTrie() || !m.ambiguous || m.node == nil || !m.node.hasAction { | |
| 136 | 161 | return Result{}, false | |
| 137 | 162 | } | |
| 138 | 163 | ||
| @@ -36,6 +36,9 @@ func NewMatcher(trie *Trie) *Matcher { | |||||
| 36 | 36 | ||||
| 37 | // Reset clears matcher state. | 37 | // Reset clears matcher state. | ||
| 38 | func (m *Matcher) Reset() { | 38 | func (m *Matcher) Reset() { | ||
| 39 | + | if m == nil { | |||
| 40 | + | return | |||
| 41 | + | } | |||
| 39 | m.node = nil | 42 | m.node = nil | ||
| 40 | m.pending = nil | 43 | m.pending = nil | ||
| 41 | m.count = 0 | 44 | m.count = 0 | ||
| @@ -45,16 +48,24 @@ func (m *Matcher) Reset() { | |||||
| 45 | 48 | ||||
| 46 | // Pending returns the count prefix and keys matched so far. | 49 | // Pending returns the count prefix and keys matched so far. | ||
| 47 | func (m *Matcher) Pending() string { | 50 | func (m *Matcher) Pending() string { | ||
| 51 | + | if !m.hasTrie() { | |||
| 52 | + | return "" | |||
| 53 | + | } | |||
| 48 | return formatPending(m.count, m.pending) | 54 | return formatPending(m.count, m.pending) | ||
| 49 | } | 55 | } | ||
| 50 | 56 | ||||
| 51 | // Ambiguous reports whether the current prefix is both exact and extendable. | 57 | // Ambiguous reports whether the current prefix is both exact and extendable. | ||
| 52 | func (m *Matcher) Ambiguous() bool { | 58 | func (m *Matcher) Ambiguous() bool { | ||
| 53 | - | return m.ambiguous | 59 | + | return m.hasTrie() && m.ambiguous |
| 54 | } | 60 | } | ||
| 55 | 61 | ||||
| 56 | // Feed consumes one key and returns the current match state. | 62 | // Feed consumes one key and returns the current match state. | ||
| 57 | func (m *Matcher) Feed(key Key) Result { | 63 | func (m *Matcher) Feed(key Key) Result { | ||
| 64 | + | if !m.hasTrie() { | |||
| 65 | + | m.Reset() | |||
| 66 | + | return Result{Kind: ResultInvalid} | |||
| 67 | + | } | |||
| 68 | + | ||||
| 58 | if m.node == nil && len(m.pending) == 0 && m.count == 0 && !m.inCount { | 69 | if m.node == nil && len(m.pending) == 0 && m.count == 0 && !m.inCount { | ||
| 59 | m.beginSequence() | 70 | m.beginSequence() | ||
| 60 | } | 71 | } | ||
| @@ -71,7 +82,15 @@ func (m *Matcher) Feed(key Key) Result { | |||||
| 71 | return m.feedSequenceKey(key) | 82 | return m.feedSequenceKey(key) | ||
| 72 | } | 83 | } | ||
| 73 | 84 | ||||
| 85 | + | func (m *Matcher) hasTrie() bool { | |||
| 86 | + | return m != nil && m.trie != nil && m.trie.root != nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 74 | func (m *Matcher) beginSequence() { | 89 | func (m *Matcher) beginSequence() { | ||
| 90 | + | if !m.hasTrie() { | |||
| 91 | + | m.Reset() | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 75 | m.node = m.trie.root | 94 | m.node = m.trie.root | ||
| 76 | m.pending = nil | 95 | m.pending = nil | ||
| 77 | m.count = 0 | 96 | m.count = 0 | ||
| @@ -96,6 +115,12 @@ func (m *Matcher) feedCountDigit(key Key) Result { | |||||
| 96 | } | 115 | } | ||
| 97 | 116 | ||||
| 98 | func (m *Matcher) feedSequenceKey(key Key) Result { | 117 | func (m *Matcher) feedSequenceKey(key Key) Result { | ||
| 118 | + | if m.node == nil { | |||
| 119 | + | result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)} | |||
| 120 | + | m.Reset() | |||
| 121 | + | return result | |||
| 122 | + | } | |||
| 123 | + | ||||
| 99 | child, ok := m.node.children[key] | 124 | child, ok := m.node.children[key] | ||
| 100 | if !ok { | 125 | if !ok { | ||
| 101 | result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)} | 126 | result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)} | ||
| @@ -132,7 +157,7 @@ func (m *Matcher) feedSequenceKey(key Key) Result { | |||||
| 132 | 157 | ||||
| 133 | // ResolveAmbiguity completes the current short exact binding. | 158 | // ResolveAmbiguity completes the current short exact binding. | ||
| 134 | func (m *Matcher) ResolveAmbiguity() (Result, bool) { | 159 | func (m *Matcher) ResolveAmbiguity() (Result, bool) { | ||
| 135 | - | if !m.ambiguous || m.node == nil || !m.node.hasAction { | 160 | + | if !m.hasTrie() || !m.ambiguous || m.node == nil || !m.node.hasAction { |
| 136 | return Result{}, false | 161 | return Result{}, false | ||
| 137 | } | 162 | } | ||
| 138 | 163 | ||||
M internal/domain/vimkeys/matcher_test.go +34 -0
| @@ -335,6 +335,40 @@ func TestMatcher_ResolveAmbiguityInvalid(t *testing.T) { | |||
| 335 | 335 | } | |
| 336 | 336 | } | |
| 337 | 337 | ||
| 338 | + | func TestMatcher_NilTriePendingEmpty(t *testing.T) { | |
| 339 | + | m := NewMatcher(nil) | |
| 340 | + | if got := m.Pending(); got != "" { | |
| 341 | + | t.Fatalf("Pending() = %q, want empty", got) | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func TestMatcher_NilTrieAmbiguousFalse(t *testing.T) { | |
| 346 | + | m := NewMatcher(nil) | |
| 347 | + | if m.Ambiguous() { | |
| 348 | + | t.Fatal("Ambiguous() = true, want false") | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func TestMatcher_NilTrieFeedInvalid(t *testing.T) { | |
| 353 | + | m := NewMatcher(nil) | |
| 354 | + | ||
| 355 | + | for _, key := range []Key{{Sym: "x"}, {Sym: "2"}} { | |
| 356 | + | result := m.Feed(key) | |
| 357 | + | if result.Kind != ResultInvalid { | |
| 358 | + | t.Fatalf("Feed(%v) kind = %v, want %v", key, result.Kind, ResultInvalid) | |
| 359 | + | } | |
| 360 | + | if result.Pending != "" { | |
| 361 | + | t.Fatalf("Feed(%v) pending = %q, want empty", key, result.Pending) | |
| 362 | + | } | |
| 363 | + | if got := m.Pending(); got != "" { | |
| 364 | + | t.Fatalf("after Feed(%v) Pending() = %q, want empty", key, got) | |
| 365 | + | } | |
| 366 | + | if m.Ambiguous() { | |
| 367 | + | t.Fatalf("after Feed(%v) Ambiguous() = true, want false", key) | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | ||
| 338 | 372 | func TestMatcher_InvalidDeadEnd(t *testing.T) { | |
| 339 | 373 | trie := NewTrie() | |
| 340 | 374 | trie.root.children[Key{Sym: "x"}] = newTrieNode() | |
| @@ -335,6 +335,40 @@ func TestMatcher_ResolveAmbiguityInvalid(t *testing.T) { | |||||
| 335 | } | 335 | } | ||
| 336 | } | 336 | } | ||
| 337 | 337 | ||||
| 338 | + | func TestMatcher_NilTriePendingEmpty(t *testing.T) { | |||
| 339 | + | m := NewMatcher(nil) | |||
| 340 | + | if got := m.Pending(); got != "" { | |||
| 341 | + | t.Fatalf("Pending() = %q, want empty", got) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func TestMatcher_NilTrieAmbiguousFalse(t *testing.T) { | |||
| 346 | + | m := NewMatcher(nil) | |||
| 347 | + | if m.Ambiguous() { | |||
| 348 | + | t.Fatal("Ambiguous() = true, want false") | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func TestMatcher_NilTrieFeedInvalid(t *testing.T) { | |||
| 353 | + | m := NewMatcher(nil) | |||
| 354 | + | ||||
| 355 | + | for _, key := range []Key{{Sym: "x"}, {Sym: "2"}} { | |||
| 356 | + | result := m.Feed(key) | |||
| 357 | + | if result.Kind != ResultInvalid { | |||
| 358 | + | t.Fatalf("Feed(%v) kind = %v, want %v", key, result.Kind, ResultInvalid) | |||
| 359 | + | } | |||
| 360 | + | if result.Pending != "" { | |||
| 361 | + | t.Fatalf("Feed(%v) pending = %q, want empty", key, result.Pending) | |||
| 362 | + | } | |||
| 363 | + | if got := m.Pending(); got != "" { | |||
| 364 | + | t.Fatalf("after Feed(%v) Pending() = %q, want empty", key, got) | |||
| 365 | + | } | |||
| 366 | + | if m.Ambiguous() { | |||
| 367 | + | t.Fatalf("after Feed(%v) Ambiguous() = true, want false", key) | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | ||||
| 338 | func TestMatcher_InvalidDeadEnd(t *testing.T) { | 372 | func TestMatcher_InvalidDeadEnd(t *testing.T) { | ||
| 339 | trie := NewTrie() | 373 | trie := NewTrie() | ||
| 340 | trie.root.children[Key{Sym: "x"}] = newTrieNode() | 374 | trie.root.children[Key{Sym: "x"}] = newTrieNode() | ||
M internal/infrastructure/config/validation.go +7 -1
| @@ -313,7 +313,13 @@ func validateModalModeActionsWithKeyCanon( | |||
| 313 | 313 | for _, key := range keys { | |
| 314 | 314 | canonical, err := keyCanon(key) | |
| 315 | 315 | if err != nil { | |
| 316 | - | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s has invalid key binding '%s': %v", modePath, action, key, err)) | |
| 316 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 317 | + | "workspace.%s.actions.%s has invalid key binding '%s': %v", | |
| 318 | + | modePath, | |
| 319 | + | action, | |
| 320 | + | key, | |
| 321 | + | err, | |
| 322 | + | )) | |
| 317 | 323 | continue | |
| 318 | 324 | } | |
| 319 | 325 | if existingAction, exists := seenSequence[canonical]; exists { | |
| @@ -313,7 +313,13 @@ func validateModalModeActionsWithKeyCanon( | |||||
| 313 | for _, key := range keys { | 313 | for _, key := range keys { | ||
| 314 | canonical, err := keyCanon(key) | 314 | canonical, err := keyCanon(key) | ||
| 315 | if err != nil { | 315 | if err != nil { | ||
| 316 | - | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s has invalid key binding '%s': %v", modePath, action, key, err)) | 316 | + | validationErrors = append(validationErrors, fmt.Sprintf( |
| 317 | + | "workspace.%s.actions.%s has invalid key binding '%s': %v", | |||
| 318 | + | modePath, | |||
| 319 | + | action, | |||
| 320 | + | key, | |||
| 321 | + | err, | |||
| 322 | + | )) | |||
| 317 | continue | 323 | continue | ||
| 318 | } | 324 | } | ||
| 319 | if existingAction, exists := seenSequence[canonical]; exists { | 325 | if existingAction, exists := seenSequence[canonical]; exists { | ||
M internal/ui/app_browser_launch_test.go +7 -2
| @@ -1013,11 +1013,16 @@ func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) { | |||
| 1013 | 1013 | toaster, box, label := newTestShellToaster(t) | |
| 1014 | 1014 | box.EXPECT().SetVisible(true).Once() | |
| 1015 | 1015 | label.EXPECT().SetText("will not outlive this test").Once() | |
| 1016 | - | toaster.Show(context.Background(), "will not outlive this test", component.ToastInfo) | |
| 1016 | + | toaster.Show( | |
| 1017 | + | context.Background(), | |
| 1018 | + | "will not outlive this test", | |
| 1019 | + | component.ToastInfo, | |
| 1020 | + | component.WithDuration(component.ToastBriefDurationMs), | |
| 1021 | + | ) | |
| 1017 | 1022 | toaster.CancelAutoDismiss() | |
| 1018 | 1023 | ||
| 1019 | 1024 | mainContext := glib.MainContextDefault() | |
| 1020 | - | deadline := time.Now().Add(component.ToastBriefDurationMs + 600*time.Millisecond) | |
| 1025 | + | deadline := time.Now().Add(component.ToastBriefDurationMs*time.Millisecond + 600*time.Millisecond) | |
| 1021 | 1026 | for time.Now().Before(deadline) { | |
| 1022 | 1027 | for mainContext.Pending() { | |
| 1023 | 1028 | mainContext.Iteration(false) | |
| @@ -1013,11 +1013,16 @@ func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) { | |||||
| 1013 | toaster, box, label := newTestShellToaster(t) | 1013 | toaster, box, label := newTestShellToaster(t) | ||
| 1014 | box.EXPECT().SetVisible(true).Once() | 1014 | box.EXPECT().SetVisible(true).Once() | ||
| 1015 | label.EXPECT().SetText("will not outlive this test").Once() | 1015 | label.EXPECT().SetText("will not outlive this test").Once() | ||
| 1016 | - | toaster.Show(context.Background(), "will not outlive this test", component.ToastInfo) | 1016 | + | toaster.Show( |
| 1017 | + | context.Background(), | |||
| 1018 | + | "will not outlive this test", | |||
| 1019 | + | component.ToastInfo, | |||
| 1020 | + | component.WithDuration(component.ToastBriefDurationMs), | |||
| 1021 | + | ) | |||
| 1017 | toaster.CancelAutoDismiss() | 1022 | toaster.CancelAutoDismiss() | ||
| 1018 | 1023 | ||||
| 1019 | mainContext := glib.MainContextDefault() | 1024 | mainContext := glib.MainContextDefault() | ||
| 1020 | - | deadline := time.Now().Add(component.ToastBriefDurationMs + 600*time.Millisecond) | 1025 | + | deadline := time.Now().Add(component.ToastBriefDurationMs*time.Millisecond + 600*time.Millisecond) |
| 1021 | for time.Now().Before(deadline) { | 1026 | for time.Now().Before(deadline) { | ||
| 1022 | for mainContext.Pending() { | 1027 | for mainContext.Pending() { | ||
| 1023 | mainContext.Iteration(false) | 1028 | mainContext.Iteration(false) | ||
M internal/ui/input/vimkeys_dispatch_test.go +17 -0
| @@ -67,6 +67,23 @@ func captureSequenceActions(h *KeyboardHandler) *[]struct { | |||
| 67 | 67 | return actions | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | + | func TestNewVimModeSequenceState_NilTrie(t *testing.T) { | |
| 71 | + | state := newVimModeSequenceState(nil, 0) | |
| 72 | + | if state.matcher == nil { | |
| 73 | + | t.Fatal("matcher is nil") | |
| 74 | + | } | |
| 75 | + | if got := state.matcher.Pending(); got != "" { | |
| 76 | + | t.Fatalf("Pending() = %q, want empty", got) | |
| 77 | + | } | |
| 78 | + | if state.matcher.Ambiguous() { | |
| 79 | + | t.Fatal("Ambiguous() = true, want false") | |
| 80 | + | } | |
| 81 | + | result := state.matcher.Feed(vimkeys.Key{Sym: "x"}) | |
| 82 | + | if result.Kind != vimkeys.ResultInvalid { | |
| 83 | + | t.Fatalf("Feed kind = %v, want %v", result.Kind, vimkeys.ResultInvalid) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 70 | 87 | func TestTrieOwnsBinding(t *testing.T) { | |
| 71 | 88 | cases := map[string]bool{ | |
| 72 | 89 | "]]": true, | |
| @@ -67,6 +67,23 @@ func captureSequenceActions(h *KeyboardHandler) *[]struct { | |||||
| 67 | return actions | 67 | return actions | ||
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
| 70 | + | func TestNewVimModeSequenceState_NilTrie(t *testing.T) { | |||
| 71 | + | state := newVimModeSequenceState(nil, 0) | |||
| 72 | + | if state.matcher == nil { | |||
| 73 | + | t.Fatal("matcher is nil") | |||
| 74 | + | } | |||
| 75 | + | if got := state.matcher.Pending(); got != "" { | |||
| 76 | + | t.Fatalf("Pending() = %q, want empty", got) | |||
| 77 | + | } | |||
| 78 | + | if state.matcher.Ambiguous() { | |||
| 79 | + | t.Fatal("Ambiguous() = true, want false") | |||
| 80 | + | } | |||
| 81 | + | result := state.matcher.Feed(vimkeys.Key{Sym: "x"}) | |||
| 82 | + | if result.Kind != vimkeys.ResultInvalid { | |||
| 83 | + | t.Fatalf("Feed kind = %v, want %v", result.Kind, vimkeys.ResultInvalid) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 70 | func TestTrieOwnsBinding(t *testing.T) { | 87 | func TestTrieOwnsBinding(t *testing.T) { | ||
| 71 | cases := map[string]bool{ | 88 | cases := map[string]bool{ | ||
| 72 | "]]": true, | 89 | "]]": true, | ||