Skip to main content

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(&notation));
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}