reovim_module_vim/resolvers/normal.rs
1// Methods made `pub` for test access from `resolvers::tests::normal`.
2// This module is private, so `pub` is effectively crate-internal.
3#![allow(clippy::missing_panics_doc, clippy::must_use_candidate)]
4
5//! Vim normal mode key resolver.
6//!
7//! Handles normal mode key interpretation including:
8//! - Count prefix accumulation (1-9, then 0)
9//! - Register prefix (") handling
10//! - Command key lookup via keymap registry
11//! - Operator entry transitions
12//! - Macro recording (q) and playback (@) - Epic #465 Phase 8D
13
14#![allow(clippy::unused_self)] // Methods may need self for future extensibility
15
16use std::sync::RwLock;
17
18use {
19 reovim_driver_input::{
20 ArgValue, ExtensionMap, KeyCode, KeyEvent, KeyLookupState, KeySequence, ModeKeyResolver,
21 ModeState, ModeTransition, Modifiers, ResolveContext, ResolveInput, ResolveResult,
22 TransitionContext,
23 },
24 reovim_kernel::api::v1::ModeId,
25 reovim_module_editor as editor,
26};
27
28use crate::{
29 ids,
30 macros::notation_to_keys,
31 modes::VimMode,
32 session_state::{PendingCharOp, VimSessionState},
33};
34
35use reovim_module_cmdline::CmdlineState;
36
37/// Coordinator command dispatched for find-char motions (#563).
38/// Defined locally to reference the motions module's command without
39/// compile-time dependency.
40const DISPATCH_FIND_CHAR: reovim_kernel::api::v1::CommandId =
41 reovim_kernel::api::v1::CommandId::new(
42 reovim_kernel::api::v1::ModuleId::new("motions"),
43 "dispatch-find-char",
44 );
45
46/// Pending macro operation.
47///
48/// Tracks what macro-related action we're waiting for after pressing `q` or `@`.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum PendingMacroOp {
51 /// Waiting for register character after `q` (to start recording).
52 StartRecording,
53 /// Waiting for register character after `@` (to play macro).
54 PlayMacro,
55}
56
57/// Vim normal mode key resolver.
58///
59/// In normal mode:
60/// - Digits 1-9 (and 0 after other digits) accumulate as count prefix
61/// - `"` followed by a character selects a register
62/// - Other keys are looked up in the keymap
63/// - Operators (d, y, c) trigger transition to operator-pending mode
64/// - `q` starts/stops macro recording (Epic #465 Phase 8D)
65/// - `@` plays macro from register (Epic #465 Phase 8D)
66///
67/// # State Management
68///
69/// The resolver owns its state (counts, pending register) rather than
70/// storing it externally. This enables:
71/// - Unit testing without full runner
72/// - Different editing styles with different state needs
73/// - Clean hot-reload (replace resolver, state resets)
74///
75/// # Example
76///
77/// ```ignore
78/// let resolver = VimNormalResolver::new();
79///
80/// // Process '3' - accumulates count
81/// let result = resolver.resolve(&key_3, &mut state);
82/// assert!(matches!(result, ResolveResult::Pending));
83///
84/// // Process 'j' - executes cursor-down with count=3
85/// let result = resolver.resolve(&key_j, &mut state);
86/// assert!(matches!(result, ResolveResult::Execute(..)));
87/// ```
88pub struct VimNormalResolver {
89 /// Mode ID for normal mode.
90 mode_id: ModeId,
91
92 /// Accumulated count prefix.
93 ///
94 /// - `None`: No count yet
95 /// - `Some(n)`: Count is n
96 ///
97 /// Reset after command execution or escape.
98 pending_count: RwLock<Option<usize>>,
99
100 /// Pending register selection.
101 ///
102 /// - `None`: No register prefix
103 /// - `Some('"')`: Waiting for register character (sentinel)
104 /// - `Some('a'..'z')`: Register selected
105 ///
106 /// Reset after command execution or escape.
107 pub pending_register: RwLock<Option<char>>,
108
109 /// Accumulated key sequence for multi-key commands.
110 pending_keys: RwLock<KeySequence>,
111
112 /// Pending macro operation (Epic #465 Phase 8D).
113 ///
114 /// - `None`: No pending macro operation
115 /// - `Some(StartRecording)`: Waiting for register after `q`
116 /// - `Some(PlayMacro)`: Waiting for register after `@`
117 pending_macro: RwLock<Option<PendingMacroOp>>,
118}
119
120impl VimNormalResolver {
121 /// Create a new normal mode resolver.
122 #[must_use]
123 pub const fn new() -> Self {
124 Self {
125 mode_id: VimMode::NORMAL_ID,
126 pending_count: RwLock::new(None),
127 pending_register: RwLock::new(None),
128 pending_keys: RwLock::new(KeySequence::new()),
129 pending_macro: RwLock::new(None),
130 }
131 }
132
133 /// Get the accumulated count, if any.
134 ///
135 /// # Panics
136 ///
137 /// Panics if the internal lock is poisoned.
138 #[must_use]
139 pub fn pending_count(&self) -> Option<usize> {
140 *self.pending_count.read().expect("lock poisoned")
141 }
142
143 /// Get the pending register, if any.
144 ///
145 /// # Panics
146 ///
147 /// Panics if the internal lock is poisoned.
148 #[must_use]
149 pub fn pending_register(&self) -> Option<char> {
150 *self.pending_register.read().expect("lock poisoned")
151 }
152
153 /// Check if waiting for register character.
154 #[must_use]
155 pub fn is_waiting_for_register(&self) -> bool {
156 self.pending_register() == Some('"')
157 }
158
159 /// Check if a key is a count digit.
160 ///
161 /// - First digit must be 1-9 (not 0, since 0 is a motion)
162 /// - Subsequent digits can be 0-9
163 pub fn is_count_digit(&self, key: &KeyEvent) -> bool {
164 // Only plain digits (no modifiers) are count digits
165 if key.modifiers != Modifiers::NONE {
166 return false;
167 }
168
169 match key.code {
170 KeyCode::Char('1'..='9') => true,
171 KeyCode::Char('0') => {
172 // 0 is only a count digit if we already have a count
173 self.pending_count().is_some()
174 }
175 _ => false,
176 }
177 }
178
179 /// Accumulate a count digit.
180 #[cfg_attr(coverage_nightly, coverage(off))]
181 pub fn accumulate_count(&self, key: &KeyEvent) {
182 if let KeyCode::Char(c @ '0'..='9') = key.code {
183 let digit = c.to_digit(10).expect("valid digit") as usize;
184 let mut guard = self.pending_count.write().expect("lock poisoned");
185 *guard = Some(guard.unwrap_or(0) * 10 + digit);
186 }
187 }
188
189 /// Check if a key is the register prefix (`"`).
190 pub fn is_register_prefix(key: &KeyEvent) -> bool {
191 key.modifiers == Modifiers::NONE && key.code == KeyCode::Char('"')
192 }
193
194 /// Handle register character after `"` prefix.
195 pub fn handle_register_char(&self, key: &KeyEvent) -> ResolveResult {
196 if let KeyCode::Char(c) = key.code {
197 // Valid register characters: a-z, A-Z, 0-9, and special registers
198 if c.is_ascii_alphanumeric() || "+-*/.%#:".contains(c) {
199 *self.pending_register.write().expect("lock poisoned") = Some(c);
200 // Don't execute yet - wait for command
201 return ResolveResult::Pending;
202 }
203 }
204
205 // Invalid register character - cancel and pass key through
206 *self.pending_register.write().expect("lock poisoned") = None;
207 ResolveResult::NotHandled
208 }
209
210 /// Take the accumulated count, clearing it.
211 pub fn take_count(&self) -> Option<usize> {
212 self.pending_count.write().expect("lock poisoned").take()
213 }
214
215 /// Take the pending register, clearing it.
216 pub fn take_register(&self) -> Option<char> {
217 let reg = self.pending_register.write().expect("lock poisoned").take();
218 // Don't return the sentinel
219 reg.filter(|&r| r != '"')
220 }
221
222 /// Clear pending keys.
223 fn clear_pending_keys(&self) {
224 self.pending_keys.write().expect("lock poisoned").clear();
225 }
226
227 /// Add a key to pending sequence.
228 pub fn push_pending_key(&self, key: KeyEvent) {
229 self.pending_keys.write().expect("lock poisoned").push(key);
230 }
231
232 /// Get a clone of pending keys for lookup.
233 pub fn get_pending_keys(&self) -> KeySequence {
234 self.pending_keys.read().expect("lock poisoned").clone()
235 }
236
237 /// Clear all internal state (for use from &self via interior mutability).
238 pub fn clear_state(&self) {
239 *self.pending_count.write().expect("lock poisoned") = None;
240 *self.pending_register.write().expect("lock poisoned") = None;
241 self.pending_keys.write().expect("lock poisoned").clear();
242 *self.pending_macro.write().expect("lock poisoned") = None;
243 }
244
245 // ========================================================================
246 // Macro Recording/Playback Helpers (Epic #465 Phase 8D)
247 // ========================================================================
248
249 /// Check if we're waiting for a macro operation register.
250 pub fn pending_macro_op(&self) -> Option<PendingMacroOp> {
251 *self.pending_macro.read().expect("lock poisoned")
252 }
253
254 /// Set pending macro operation.
255 pub fn set_pending_macro(&self, op: PendingMacroOp) {
256 *self.pending_macro.write().expect("lock poisoned") = Some(op);
257 }
258
259 /// Clear pending macro operation.
260 pub fn clear_pending_macro(&self) {
261 *self.pending_macro.write().expect("lock poisoned") = None;
262 }
263
264 /// Check if a key is the macro record key (`q` without modifiers).
265 pub fn is_macro_record_key(key: &KeyEvent) -> bool {
266 key.modifiers == Modifiers::NONE && key.code == KeyCode::Char('q')
267 }
268
269 /// Check if a key is the macro play key (`@` without modifiers).
270 pub fn is_macro_play_key(key: &KeyEvent) -> bool {
271 key.modifiers == Modifiers::NONE && key.code == KeyCode::Char('@')
272 }
273
274 /// Handle `q` key for macro recording.
275 ///
276 /// - If currently recording: stop recording, store to register
277 /// - If not recording: set pending macro state to wait for register
278 #[cfg_attr(coverage_nightly, coverage(off))]
279 fn handle_macro_record_key(
280 &self,
281 vim: &mut VimSessionState,
282 input: &ResolveInput<'_>,
283 ) -> ResolveResult {
284 if vim.is_recording() {
285 // Stop recording - store keys to register
286 if let Some((register, keys)) = vim.stop_recording() {
287 // Convert keys to notation string and store in register
288 let notation = crate::macros::keys_to_notation(&keys);
289 tracing::debug!(
290 register = %register,
291 key_count = keys.len(),
292 notation = %notation,
293 "Stopped macro recording"
294 );
295
296 // Store in register via input's register access
297 // Note: We store as text - macros are just key notation strings
298 if let Some(registers) = input.registers {
299 use reovim_kernel::api::v1::RegisterContent;
300 registers
301 .write()
302 .set_named(register, RegisterContent::characterwise(¬ation));
303 }
304 }
305 ResolveResult::Completed
306 } else {
307 // Start recording - wait for register character
308 self.set_pending_macro(PendingMacroOp::StartRecording);
309 ResolveResult::Pending
310 }
311 }
312
313 /// Handle register character after `q` (start recording).
314 #[cfg_attr(coverage_nightly, coverage(off))]
315 fn handle_macro_record_register(
316 &self,
317 key: &KeyEvent,
318 vim: &mut VimSessionState,
319 ) -> ResolveResult {
320 self.clear_pending_macro();
321
322 if let KeyCode::Char(c) = key.code
323 && c.is_ascii_lowercase()
324 && vim.start_recording(c)
325 {
326 tracing::debug!(register = %c, "Started macro recording");
327 return ResolveResult::Completed;
328 }
329
330 // Invalid register - cancel
331 tracing::debug!(?key.code, "Invalid macro register");
332 ResolveResult::NotHandled
333 }
334
335 /// Handle `@` key for macro playback.
336 #[cfg_attr(coverage_nightly, coverage(off))]
337 fn handle_macro_play_key(&self, vim: &VimSessionState) -> ResolveResult {
338 // Check if we can enter playback (depth limit)
339 if vim.is_macro_depth_exceeded() {
340 tracing::warn!(depth = vim.macro_playback_depth, "Macro playback depth exceeded");
341 return ResolveResult::NotHandled;
342 }
343
344 // Wait for register character
345 self.set_pending_macro(PendingMacroOp::PlayMacro);
346 ResolveResult::Pending
347 }
348
349 /// Handle register character after `@` (play macro).
350 #[cfg_attr(coverage_nightly, coverage(off))]
351 fn handle_macro_play_register(
352 &self,
353 key: &KeyEvent,
354 vim: &mut VimSessionState,
355 input: &ResolveInput<'_>,
356 ) -> ResolveResult {
357 self.clear_pending_macro();
358
359 let register = if key.code == KeyCode::Char('@') {
360 // @@ - repeat last macro
361 vim.last_macro_register
362 } else if let KeyCode::Char(c) = key.code {
363 if c.is_ascii_lowercase() {
364 Some(c)
365 } else {
366 None
367 }
368 } else {
369 None
370 };
371
372 let Some(register) = register else {
373 tracing::debug!(?key.code, "Invalid macro playback register");
374 return ResolveResult::NotHandled;
375 };
376
377 // Get macro content from register
378 let Some(registers) = input.registers else {
379 tracing::warn!("No register access for macro playback");
380 return ResolveResult::NotHandled;
381 };
382
383 let content = {
384 let guard = registers.read();
385 guard.get_by_name(Some(register)).cloned()
386 };
387
388 let Some(content) = content else {
389 tracing::debug!(register = %register, "Macro register is empty");
390 return ResolveResult::NotHandled;
391 };
392
393 // Parse the notation string to keys
394 let Some(keys) = notation_to_keys(&content.text) else {
395 tracing::warn!(
396 register = %register,
397 content = %content.text,
398 "Failed to parse macro content"
399 );
400 return ResolveResult::NotHandled;
401 };
402
403 if keys.is_empty() {
404 return ResolveResult::Completed;
405 }
406
407 // Update last macro register for @@ support
408 vim.last_macro_register = Some(register);
409
410 // Get count for playback
411 let count = vim.pending_count.take().unwrap_or(1);
412
413 // Enter playback
414 if !vim.enter_macro_playback() {
415 return ResolveResult::NotHandled;
416 }
417
418 // Build full key sequence (count repetitions)
419 let mut all_keys = Vec::with_capacity(keys.len() * count);
420 for _ in 0..count {
421 all_keys.extend(keys.iter().copied());
422 }
423
424 tracing::debug!(
425 register = %register,
426 count,
427 key_count = all_keys.len(),
428 "Playing macro"
429 );
430
431 // Return keys to be injected
432 // The runner will inject these and call exit_macro_playback when done
433 ResolveResult::InjectKeys {
434 keys: all_keys,
435 exit_macro_playback: true,
436 }
437 }
438
439 /// Build resolve context with count and register.
440 pub fn build_context(&self, keys: KeySequence) -> ResolveContext {
441 let mut ctx = ResolveContext::new().keys(keys);
442
443 if let Some(count) = self.take_count() {
444 ctx = ctx.count(count);
445 }
446
447 if let Some(reg) = self.take_register() {
448 ctx = ctx.register(reg);
449 }
450
451 ctx
452 }
453
454 // ========================================================================
455 // Extension-based helpers (Epic #385 - use VimSessionState)
456 // ========================================================================
457
458 /// Check if a key is a count digit (extension-based version).
459 #[cfg_attr(coverage_nightly, coverage(off))]
460 pub fn is_count_digit_ext(&self, key: &KeyEvent, vim: &VimSessionState) -> bool {
461 if key.modifiers != Modifiers::NONE {
462 return false;
463 }
464
465 match key.code {
466 KeyCode::Char('1'..='9') => true,
467 KeyCode::Char('0') => vim.pending_count.is_some(),
468 _ => false,
469 }
470 }
471
472 /// Accumulate a count digit (extension-based version).
473 #[cfg_attr(coverage_nightly, coverage(off))]
474 pub fn accumulate_count_ext(&self, key: &KeyEvent, vim: &mut VimSessionState) {
475 if let KeyCode::Char(c @ '0'..='9') = key.code {
476 let digit = c.to_digit(10).expect("valid digit") as usize;
477 vim.pending_count = Some(vim.pending_count.unwrap_or(0) * 10 + digit);
478 }
479 }
480
481 /// Handle register character after `"` prefix (extension-based version).
482 #[cfg_attr(coverage_nightly, coverage(off))]
483 pub fn handle_register_char_ext(
484 &self,
485 key: &KeyEvent,
486 vim: &mut VimSessionState,
487 ) -> ResolveResult {
488 if let KeyCode::Char(c) = key.code {
489 // Valid register characters: a-z, A-Z, 0-9, and special registers
490 if c.is_ascii_alphanumeric() || "+-*/.%#:".contains(c) {
491 vim.pending_register = Some(c);
492 return ResolveResult::Pending;
493 }
494 }
495
496 // Invalid register character - cancel and pass key through
497 vim.pending_register = None;
498 ResolveResult::NotHandled
499 }
500
501 /// Build resolve context with count and register (extension-based version).
502 pub fn build_context_ext(
503 &self,
504 keys: KeySequence,
505 vim: &mut VimSessionState,
506 ) -> ResolveContext {
507 let mut ctx = ResolveContext::new().keys(keys);
508
509 if let Some(count) = vim.pending_count.take() {
510 ctx = ctx.count(count);
511 }
512
513 // Don't return the sentinel
514 if let Some(reg) = vim.pending_register.take()
515 && reg != '"'
516 {
517 ctx = ctx.register(reg);
518 }
519
520 ctx
521 }
522
523 /// Classify a command as a find-char operation, if applicable.
524 ///
525 /// Returns the corresponding `PendingCharOp` if the command is one of the
526 /// find-char commands (f, F, t, T), otherwise returns `None`.
527 ///
528 /// This enables the resolver to intercept these commands and handle the
529 /// character wait internally, rather than relying on the runner.
530 pub fn classify_find_char_command(
531 cmd: &reovim_kernel::api::v1::CommandId,
532 ) -> Option<PendingCharOp> {
533 // Check if this is a motions module command
534 if cmd.module().as_str() != "motions" {
535 return None;
536 }
537
538 // Match by command name within the motions module
539 match cmd.name() {
540 "find-char-forward" => Some(PendingCharOp::FindForward),
541 "find-char-backward" => Some(PendingCharOp::FindBackward),
542 "till-char-forward" => Some(PendingCharOp::TillForward),
543 "till-char-backward" => Some(PendingCharOp::TillBackward),
544 _ => None,
545 }
546 }
547
548 /// Classify a command as a mark operation, if applicable (#654).
549 ///
550 /// Returns the corresponding `PendingCharOp` if the command is one of the
551 /// mark commands (m, ', `` ` ``), otherwise returns `None`.
552 #[cfg_attr(coverage_nightly, coverage(off))]
553 pub fn classify_mark_command(cmd: &reovim_kernel::api::v1::CommandId) -> Option<PendingCharOp> {
554 if *cmd == editor::ids::SET_MARK {
555 Some(PendingCharOp::SetMark)
556 } else if *cmd == editor::ids::GOTO_MARK_LINE {
557 Some(PendingCharOp::GotoMarkLine)
558 } else if *cmd == editor::ids::GOTO_MARK_EXACT {
559 Some(PendingCharOp::GotoMarkExact)
560 } else {
561 None
562 }
563 }
564
565 /// Check if a command is an insert entry command (#577).
566 ///
567 /// These commands start a change that should be recorded for dot repeat.
568 /// The recording starts here and continues through insert mode until
569 /// `ExitToNormal` finishes it.
570 #[cfg_attr(coverage_nightly, coverage(off))]
571 pub fn is_insert_entry_command(cmd: &reovim_kernel::api::v1::CommandId) -> bool {
572 *cmd == ids::ENTER_INSERT
573 || *cmd == ids::ENTER_INSERT_AFTER
574 || *cmd == ids::ENTER_INSERT_EOL
575 || *cmd == ids::ENTER_INSERT_BOL
576 || *cmd == ids::OPEN_LINE_BELOW
577 || *cmd == ids::OPEN_LINE_ABOVE
578 }
579
580 /// Classify an operator entry command and return the target mode.
581 ///
582 /// Returns the `ModeId` for the dedicated operator mode (DELETE, YANK, CHANGE)
583 /// if the command is an operator entry command, otherwise returns `None`.
584 ///
585 /// # Epic #415 - Dedicated Operator Modes
586 ///
587 /// Instead of routing all operators to a generic `operator-pending` mode,
588 /// we now push to dedicated modes where:
589 /// - The MODE itself carries operator semantics (no runtime lookup needed)
590 /// - Each resolver is focused (~300 lines) and easier to debug
591 /// - Statusline shows "DELETE" instead of "OP-PENDING"
592 ///
593 /// The enter-*-operator commands in the editor module are "shell" commands
594 /// (they do nothing). The real work is done by:
595 /// 1. This resolver: intercepts and pushes to the specific operator mode
596 /// 2. Dedicated operator resolver (delete/yank/change): captures motion, returns range
597 /// 3. Runner: executes the operator on the range
598 ///
599 /// # Compile-time Safety
600 ///
601 /// This function uses hard-typed comparisons against the editor module's
602 /// `CommandId` constants. If those constants are renamed or removed, this
603 /// code will fail to compile rather than silently break at runtime.
604 #[cfg_attr(coverage_nightly, coverage(off))]
605 pub fn classify_operator_mode(cmd: &reovim_kernel::api::v1::CommandId) -> Option<ModeId> {
606 // Hard-typed check using editor module constants (compile-time safe)
607 // Returns the dedicated operator mode (not generic operator-pending)
608 if *cmd == editor::ids::ENTER_DELETE_OPERATOR {
609 Some(VimMode::DELETE_ID)
610 } else if *cmd == editor::ids::ENTER_YANK_OPERATOR {
611 Some(VimMode::YANK_ID)
612 } else if *cmd == editor::ids::ENTER_CHANGE_OPERATOR {
613 Some(VimMode::CHANGE_ID)
614 } else if *cmd == editor::ids::ENTER_LOWERCASE_OPERATOR {
615 Some(VimMode::LOWERCASE_ID)
616 } else if *cmd == editor::ids::ENTER_UPPERCASE_OPERATOR {
617 Some(VimMode::UPPERCASE_ID)
618 } else if *cmd == editor::ids::ENTER_TOGGLE_CASE_OPERATOR {
619 Some(VimMode::TOGGLE_CASE_ID)
620 } else {
621 None
622 }
623 }
624}
625
626impl Default for VimNormalResolver {
627 fn default() -> Self {
628 Self::new()
629 }
630}
631
632impl ModeKeyResolver for VimNormalResolver {
633 /// Vim-style key resolution with keymap access.
634 ///
635 /// This method queries the keymap and applies Vim policy to determine
636 /// whether to execute immediately or wait for more keys.
637 ///
638 /// # Vim Policy
639 ///
640 /// | Lookup State | Vim Behavior |
641 /// |--------------|--------------|
642 /// | `ExactWithLonger` | `Pending` - wait for more keys (d might become dd) |
643 /// | `ExactOnly` | `Execute` - run the command immediately |
644 /// | `PrefixOnly` | `Pending` - wait for more keys |
645 /// | `NotFound` | `NotHandled` - delegate to fallback handler |
646 #[cfg_attr(coverage_nightly, coverage(off))]
647 fn resolve_with_keymap(
648 &self,
649 key: &KeyEvent,
650 _state: &mut ModeState,
651 input: &ResolveInput<'_>,
652 ) -> ResolveResult {
653 // Handle escape - reset state and return NotHandled
654 if key.code == KeyCode::Escape {
655 self.clear_state();
656 return ResolveResult::NotHandled;
657 }
658
659 // Check for register prefix waiting for character
660 if self.is_waiting_for_register() {
661 return self.handle_register_char(key);
662 }
663
664 // Check for register prefix start
665 if Self::is_register_prefix(key) {
666 *self.pending_register.write().expect("lock poisoned") = Some('"'); // Sentinel
667 return ResolveResult::Pending;
668 }
669
670 // Check for count digit
671 if self.is_count_digit(key) {
672 self.accumulate_count(key);
673 return ResolveResult::Pending;
674 }
675
676 // Add to pending keys for lookup
677 self.push_pending_key(*key);
678 let keys = self.get_pending_keys();
679
680 // Query keymap for facts about what bindings exist
681 let lookup_state = input.keymap.query(input.mode, &keys);
682
683 // Apply Vim policy (inline match - no separate trait needed)
684 match lookup_state {
685 KeyLookupState::ExactWithLonger { .. } => {
686 // Wait for longer sequence (d might become dd)
687 // Keep pending_keys for next lookup
688 ResolveResult::Pending
689 }
690 KeyLookupState::ExactOnly(cmd) => {
691 // Execute with context containing count and register
692 let ctx = self.build_context(keys);
693 self.clear_pending_keys();
694 ResolveResult::Execute(cmd, ctx)
695 }
696 KeyLookupState::PrefixOnly => {
697 // Wait for more keys (g waiting for gg, etc.)
698 // Keep pending_keys for next lookup
699 ResolveResult::Pending
700 }
701 KeyLookupState::NotFound => {
702 // No binding found - clear keys and let runner handle
703 self.clear_pending_keys();
704 ResolveResult::NotHandled
705 }
706 }
707 }
708
709 /// Vim-style key resolution with keymap AND session extensions access.
710 ///
711 /// This is the new architecture (Epic #385) that uses `VimSessionState` from
712 /// extensions instead of the runner's `AppState`. The resolver owns the vim
713 /// policy, the runner is pure mechanism.
714 ///
715 /// # State Management
716 ///
717 /// | State | Source | Notes |
718 /// |-------|--------|-------|
719 /// | `pending_count` | `VimSessionState` | From extensions |
720 /// | `pending_register` | `VimSessionState` | From extensions |
721 /// | `pending_keys` | Internal `RwLock` | Multi-key sequence |
722 ///
723 /// Note: For backward compatibility, we still use internal `pending_keys`.
724 /// Once all resolvers are migrated, these can move to `VimSessionState` too.
725 #[allow(clippy::too_many_lines)]
726 #[cfg_attr(coverage_nightly, coverage(off))]
727 fn resolve_with_extensions(
728 &self,
729 key: &KeyEvent,
730 _state: &mut ModeState,
731 input: &ResolveInput<'_>,
732 _shared_extensions: &mut ExtensionMap,
733 client_extensions: &mut ExtensionMap,
734 ) -> ResolveResult {
735 // Clear any pending cmdline message on next keypress (#558)
736 if let Some(cmdline) = client_extensions.get_mut::<CmdlineState>() {
737 cmdline.clear_message();
738 }
739
740 // Get vim session state from client extensions (per-client state)
741 let vim = client_extensions.get_or_insert::<VimSessionState>();
742
743 // Handle escape - reset state and return NotHandled
744 if key.code == KeyCode::Escape {
745 vim.clear_pending();
746 self.clear_pending_keys();
747 return ResolveResult::NotHandled;
748 }
749
750 // =====================================================================
751 // Epic #385 - Handle pending find-char operation
752 // =====================================================================
753 // If pending_char is set, the next character completes the find motion.
754 // We create an Execute result with EXECUTE_FIND_CHAR and the char in metadata.
755 // Note: take() consumes pending_char regardless of whether the key is a char,
756 // which correctly cancels the operation if a non-char key is pressed.
757 if let Some(pending_op) = vim.pending_char.take()
758 && let KeyCode::Char(c) = key.code
759 {
760 let mut ctx = self.build_context_ext(KeySequence::new(), vim);
761 self.clear_pending_keys();
762
763 if pending_op.is_motion() {
764 // Find-char motion (f/F/t/T)
765 ctx.metadata
766 .insert("find_char".to_string(), ArgValue::Char(c));
767 let direction = if pending_op.is_forward() {
768 "forward"
769 } else {
770 "backward"
771 };
772 ctx.metadata
773 .insert("find_direction".to_string(), ArgValue::String(direction.to_string()));
774 let inclusive = pending_op.is_find();
775 ctx.metadata
776 .insert("find_inclusive".to_string(), ArgValue::Bool(inclusive));
777 return ResolveResult::Execute(DISPATCH_FIND_CHAR, ctx);
778 }
779
780 // Replace operation (r) — dispatch to editor::REPLACE_CHAR
781 if matches!(pending_op, PendingCharOp::Replace) {
782 ctx.metadata
783 .insert("replace_char".to_string(), ArgValue::Char(c));
784 return ResolveResult::Execute(editor::ids::REPLACE_CHAR, ctx);
785 }
786
787 // #654 - Mark operations (m, ', `)
788 let cmd_id = match pending_op {
789 PendingCharOp::SetMark => editor::ids::SET_MARK,
790 PendingCharOp::GotoMarkLine => editor::ids::GOTO_MARK_LINE,
791 PendingCharOp::GotoMarkExact => editor::ids::GOTO_MARK_EXACT,
792 _ => unreachable!("All pending ops handled above"),
793 };
794 ctx.metadata
795 .insert("mark_char".to_string(), ArgValue::Char(c));
796 return ResolveResult::Execute(cmd_id, ctx);
797 }
798
799 // =====================================================================
800 // Epic #465 Phase 8D - Macro Recording/Playback
801 // =====================================================================
802
803 // Handle pending macro operations first (waiting for register after q or @)
804 if let Some(pending_op) = self.pending_macro_op() {
805 match pending_op {
806 PendingMacroOp::StartRecording => {
807 // Record key if we're recording (except q that stops)
808 // Note: We're about to potentially start recording, so don't record this key
809 return self.handle_macro_record_register(key, vim);
810 }
811 PendingMacroOp::PlayMacro => {
812 return self.handle_macro_play_register(key, vim, input);
813 }
814 }
815 }
816
817 // Check for macro record key (q)
818 if Self::is_macro_record_key(key) {
819 return self.handle_macro_record_key(vim, input);
820 }
821
822 // Check for macro play key (@)
823 if Self::is_macro_play_key(key) {
824 return self.handle_macro_play_key(vim);
825 }
826
827 // Record key if we're recording (before normal processing)
828 // The key will be recorded regardless of what it does
829 if vim.is_recording() {
830 vim.record_key(*key);
831 }
832
833 // Check for register prefix waiting for character
834 if vim.pending_register == Some('"') {
835 return self.handle_register_char_ext(key, vim);
836 }
837
838 // Check for register prefix start
839 if Self::is_register_prefix(key) {
840 vim.pending_register = Some('"'); // Sentinel
841 return ResolveResult::Pending;
842 }
843
844 // Check for count digit
845 if self.is_count_digit_ext(key, vim) {
846 self.accumulate_count_ext(key, vim);
847 return ResolveResult::Pending;
848 }
849
850 // Add to pending keys for lookup
851 self.push_pending_key(*key);
852 let keys = self.get_pending_keys();
853
854 // Query keymap for facts about what bindings exist
855 let lookup_state = input.keymap.query(input.mode, &keys);
856
857 // Apply Vim policy
858 match lookup_state {
859 KeyLookupState::ExactWithLonger { exact: cmd } => {
860 // Epic #415: Operators push to dedicated modes (DELETE, YANK, CHANGE)
861 // Even though dd exists, we don't wait - the dedicated mode handles dd
862 // via the is_line_operator check when the second 'd' is pressed.
863 //
864 // Key insight: we DON'T take pending_count/pending_register here!
865 // The dedicated resolver reads them on its first key press.
866 // This simplifies the flow and eliminates vim.pending_operator.
867 if let Some(target_mode) = Self::classify_operator_mode(&cmd) {
868 self.clear_pending_keys();
869 // #577: Start recording keys for dot repeat
870 vim.start_repeat_recording();
871 vim.record_repeat_key(*key);
872 return ResolveResult::ModeTransition(ModeTransition::Push {
873 mode: target_mode,
874 context: TransitionContext::new(),
875 });
876 }
877
878 // Not an operator - wait for longer sequence
879 ResolveResult::Pending
880 }
881 KeyLookupState::ExactOnly(cmd) => {
882 // #577 - Intercept dot repeat and replay recorded keys
883 if cmd == ids::DOT_REPEAT
884 && let Some(ref lc) = vim.last_change
885 && !lc.keys.is_empty()
886 {
887 let replay_keys = lc.keys.clone();
888 self.clear_pending_keys();
889 return ResolveResult::InjectKeys {
890 keys: replay_keys,
891 exit_macro_playback: false,
892 };
893 }
894
895 // Epic #385 - Intercept find-char commands
896 // Instead of executing commands that return WaitingForChar,
897 // set pending_char in VimSessionState and return Pending.
898 if let Some(pending_op) = Self::classify_find_char_command(&cmd) {
899 vim.pending_char = Some(pending_op);
900 self.clear_pending_keys();
901 return ResolveResult::Pending;
902 }
903
904 // #554 - Intercept replace-char-start (r)
905 // Like find-char, this sets pending_char and waits for the next char.
906 if cmd == editor::ids::REPLACE_CHAR_START {
907 vim.pending_char = Some(PendingCharOp::Replace);
908 self.clear_pending_keys();
909 return ResolveResult::Pending;
910 }
911
912 // #654 - Intercept mark commands (m, ', `)
913 if let Some(mark_op) = Self::classify_mark_command(&cmd) {
914 vim.pending_char = Some(mark_op);
915 self.clear_pending_keys();
916 return ResolveResult::Pending;
917 }
918
919 // Epic #415 - Push to dedicated operator modes (DELETE, YANK, CHANGE)
920 // Instead of executing enter-*-operator commands (which do nothing),
921 // push to the specific operator mode. The resolver reads pending_count
922 // and pending_register from VimSessionState on its first key press.
923 if let Some(target_mode) = Self::classify_operator_mode(&cmd) {
924 self.clear_pending_keys();
925 // #577: Start recording keys for dot repeat
926 vim.start_repeat_recording();
927 vim.record_repeat_key(*key);
928 return ResolveResult::ModeTransition(ModeTransition::Push {
929 mode: target_mode,
930 context: TransitionContext::new(),
931 });
932 }
933
934 // #577: Start recording on insert entry commands
935 if Self::is_insert_entry_command(&cmd) {
936 vim.start_repeat_recording();
937 vim.record_repeat_key(*key);
938 }
939
940 // Execute with context containing count and register
941 let ctx = self.build_context_ext(keys, vim);
942 self.clear_pending_keys();
943 ResolveResult::Execute(cmd, ctx)
944 }
945 KeyLookupState::PrefixOnly => {
946 // Wait for more keys
947 ResolveResult::Pending
948 }
949 KeyLookupState::NotFound => {
950 // No binding found - clear keys and let runner handle
951 self.clear_pending_keys();
952 ResolveResult::NotHandled
953 }
954 }
955 }
956
957 fn mode_id(&self) -> &ModeId {
958 &self.mode_id
959 }
960
961 fn inherits_from(&self) -> Option<&ModeId> {
962 None
963 }
964
965 #[cfg_attr(coverage_nightly, coverage(off))]
966 fn pending_keys(&self) -> KeySequence {
967 self.get_pending_keys()
968 }
969
970 fn reset(&mut self) {
971 *self.pending_count.write().expect("lock poisoned") = None;
972 *self.pending_register.write().expect("lock poisoned") = None;
973 self.pending_keys.write().expect("lock poisoned").clear();
974 *self.pending_macro.write().expect("lock poisoned") = None;
975 }
976}
977
978#[cfg(test)]
979impl VimNormalResolver {
980 /// Get a clone of pending keys (for testing).
981 pub fn pending_keys(&self) -> KeySequence {
982 self.get_pending_keys()
983 }
984}