Skip to main content

reovim_module_vim/resolvers/
operator_common.rs

1//! Shared logic for operator mode resolvers (delete, yank, change).
2//!
3//! This module contains common patterns extracted from the operator resolution flow:
4//!
5#![allow(clippy::missing_const_for_fn)] // Many methods could be const but aren't for future flexibility
6#![allow(clippy::doc_markdown)] // Allow type names without backticks in doc comments for readability
7//! - Escape handling (cancel operator)
8//! - Count digit accumulation
9//! - Linewise motion detection
10//! - Building `PopResult::ExecuteCommand` with operator arguments
11//! - Key sequence management
12
13use std::collections::HashMap;
14
15use {
16    reovim_driver_command_types::ArgValue,
17    reovim_driver_input::{
18        KeyCode, KeyEvent, KeyLookupState, KeySequence, ModeTransition, Modifiers, PopResult,
19    },
20    reovim_kernel::api::v1::{CommandId, ModeId, Position},
21};
22
23use crate::{ids::OperatorId, modes::VimMode};
24
25// =============================================================================
26// Operator Type
27// =============================================================================
28
29/// The type of operator being executed.
30///
31/// This enum identifies which operator mode we're in, enabling the resolver
32/// to know what command to execute when the motion completes.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum OperatorType {
35    /// Delete operator (d)
36    Delete,
37    /// Yank operator (y)
38    Yank,
39    /// Change operator (c)
40    Change,
41    /// Lowercase operator (gu)
42    Lowercase,
43    /// Uppercase operator (gU)
44    Uppercase,
45    /// Toggle case operator (g~)
46    ToggleCase,
47}
48
49impl OperatorType {
50    /// Get the mode ID for this operator type.
51    #[must_use]
52    pub const fn mode_id(&self) -> ModeId {
53        match self {
54            Self::Delete => VimMode::DELETE_ID,
55            Self::Yank => VimMode::YANK_ID,
56            Self::Change => VimMode::CHANGE_ID,
57            Self::Lowercase => VimMode::LOWERCASE_ID,
58            Self::Uppercase => VimMode::UPPERCASE_ID,
59            Self::ToggleCase => VimMode::TOGGLE_CASE_ID,
60        }
61    }
62
63    /// Get the operator ID for this operator type.
64    #[allow(clippy::redundant_clone)] // Const values require clone for owned return
65    #[must_use]
66    pub fn operator_id(&self) -> OperatorId {
67        match self {
68            Self::Delete => crate::ids::DELETE.clone(),
69            Self::Yank => crate::ids::YANK.clone(),
70            Self::Change => crate::ids::CHANGE.clone(),
71            Self::Lowercase => crate::ids::LOWERCASE.clone(),
72            Self::Uppercase => crate::ids::UPPERCASE.clone(),
73            Self::ToggleCase => crate::ids::TOGGLE_CASE_OP.clone(),
74        }
75    }
76
77    /// Get the key that triggers linewise operation (doubled operator).
78    ///
79    /// For case operators, this is the second key after the `g` prefix:
80    /// - `guu` → lowercase current line (line key is `u`)
81    /// - `gUU` → uppercase current line (line key is `U`)
82    /// - `g~~` → toggle case current line (line key is `~`)
83    #[must_use]
84    pub const fn line_key(&self) -> char {
85        match self {
86            Self::Delete => 'd',
87            Self::Yank => 'y',
88            Self::Change => 'c',
89            Self::Lowercase => 'u',
90            Self::Uppercase => 'U',
91            Self::ToggleCase => '~',
92        }
93    }
94
95    /// Get the display name for this operator.
96    #[must_use]
97    #[cfg_attr(coverage_nightly, coverage(off))]
98    pub const fn display_name(&self) -> &'static str {
99        match self {
100            Self::Delete => "DELETE",
101            Self::Yank => "YANK",
102            Self::Change => "CHANGE",
103            Self::Lowercase => "LOWERCASE",
104            Self::Uppercase => "UPPERCASE",
105            Self::ToggleCase => "TOGGLE-CASE",
106        }
107    }
108}
109
110// =============================================================================
111// Common Helper Functions
112// =============================================================================
113
114/// Check if key is escape (cancels operator).
115#[must_use]
116pub fn is_escape(key: &KeyEvent) -> bool {
117    key.code == KeyCode::Escape
118        || (key.code == KeyCode::Char('[') && key.modifiers.contains(Modifiers::CTRL))
119}
120
121/// Check if a key is a count digit.
122///
123/// - First digit must be 1-9 (0 is a motion in Vim)
124/// - Subsequent digits can be 0-9
125#[must_use]
126pub fn is_count_digit(key: &KeyEvent, has_count: bool) -> bool {
127    if key.modifiers != Modifiers::NONE {
128        return false;
129    }
130
131    match key.code {
132        KeyCode::Char('1'..='9') => true,
133        KeyCode::Char('0') => has_count, // 0 is only count if we already have a count
134        _ => false,
135    }
136}
137
138/// Accumulate a count digit into the existing count.
139///
140/// Returns the new count value.
141///
142/// # Panics
143///
144/// Panics if the key is '0'-'9' but `to_digit(10)` fails (should never happen).
145#[must_use]
146pub fn accumulate_count(key: &KeyEvent, current: Option<usize>) -> Option<usize> {
147    if let KeyCode::Char(c @ '0'..='9') = key.code {
148        let digit = c.to_digit(10).expect("valid digit") as usize;
149        Some(current.unwrap_or(0) * 10 + digit)
150    } else {
151        current
152    }
153}
154
155/// Check if key is the operator's line key (for dd, yy, cc).
156#[must_use]
157pub fn is_line_operator_key(key: &KeyEvent, operator: OperatorType) -> bool {
158    key.modifiers == Modifiers::NONE && key.code == KeyCode::Char(operator.line_key())
159}
160
161/// Determine if a motion command is linewise.
162///
163/// This is vim policy knowledge - the resolver knows which motions
164/// are linewise vs characterwise based on the command name.
165///
166/// # Linewise Motions
167/// - j, k (line up/down) - `cursor-down`, `cursor-up`
168/// - gg, G (document start/end) - `document-start`, `document-end`
169/// - H, M, L (screen positions) - `screen-top`, `screen-middle`, `screen-bottom`
170/// - {, } (paragraph motions) - `paragraph-forward`, `paragraph-backward`
171/// - +, - (line motions) - `next-line`, `prev-line`
172/// - whole-line (for dd, yy, cc)
173///
174/// # Characterwise Motions (default)
175/// - w, b, e, W, B, E (word motions)
176/// - h, l (character motions)
177/// - 0, $, ^ (line position motions)
178/// - f, F, t, T (find-char motions)
179#[must_use]
180pub fn is_linewise_motion(cmd: &CommandId) -> bool {
181    let name = cmd.name();
182    matches!(
183        name,
184        // j, k - cursor up/down (editor module)
185        "cursor-down"
186            | "cursor-up"
187            // gg, G - document start/end (motions module)
188            | "document-start"
189            | "document-end"
190            // H, M, L - screen positions
191            | "screen-top"
192            | "screen-middle"
193            | "screen-bottom"
194            // {, } - paragraph motions
195            | "paragraph-forward"
196            | "paragraph-backward"
197            // +, - - next/prev line
198            | "next-line"
199            | "prev-line"
200            // Special: whole-line for dd, yy, cc
201            | "whole-line"
202    )
203}
204
205/// Determine if a motion is inclusive (cursor lands ON last char).
206///
207/// This is vim policy knowledge - classifies motions by their end position semantics.
208///
209/// # Inclusive Motions
210/// - $: line-end (cursor on last char)
211/// - e, E: word-end (cursor on last char of word)
212/// - f, F: find-char (cursor on found char)
213/// - t, T: till-char (cursor before/after found char)
214/// - G: document-end (cursor on last line)
215/// - %: match-bracket (cursor on matching bracket)
216///
217/// # Exclusive Motions (default)
218/// - w, W, b, B: word motions (cursor at start of next/prev word)
219/// - h, l: character motions (cursor moves by 1)
220/// - 0, ^: line-start motions (cursor at start)
221///
222/// For characterwise inclusive motions, the Range.end (which is exclusive)
223/// needs to be adjusted by +1 to include the character under the cursor.
224#[must_use]
225pub fn is_inclusive_motion(cmd: &CommandId) -> bool {
226    let name = cmd.name();
227    matches!(
228        name,
229        "line-end"           // $
230            | "word-end"     // e
231            | "word-end-big" // E
232            | "find-char"    // f
233            | "find-char-back" // F
234            | "till-char"    // t
235            | "till-char-back" // T
236            | "match-bracket" // %
237    )
238}
239
240/// Determine if a motion is word-forward (w, W).
241///
242/// This is used for the `cw` special case in Vim: `cw` behaves like `ce`
243/// (change to end of word, not to start of next word).
244/// See `:help cw` for Vim documentation.
245///
246/// Word-forward motions move cursor to the START of the next word,
247/// but when used with the change operator, they should only change
248/// to the END of the current word (excluding trailing whitespace).
249#[must_use]
250pub fn is_word_forward_motion(cmd: &CommandId) -> bool {
251    let name = cmd.name();
252    matches!(
253        name,
254        "word-forward"      // w
255            | "word-forward-big" // W
256    )
257}
258
259/// Build a `PopResult::ExecuteCommand` with operator arguments.
260///
261/// This constructs the complete command context for executing an operator
262/// with the given range. The runner just executes - no vim knowledge needed.
263#[must_use]
264pub fn build_operator_execute(
265    operator: OperatorType,
266    start: Position,
267    end: Position,
268    linewise: bool,
269    count: Option<usize>,
270    register: Option<char>,
271) -> PopResult {
272    let operator_id = operator.operator_id();
273    let command =
274        CommandId::from_owned(operator_id.module().clone(), operator_id.name().to_owned());
275
276    let mut args = HashMap::new();
277
278    // Set linewise flag
279    args.insert("linewise".to_string(), ArgValue::Bool(linewise));
280
281    // Set range positions
282    args.insert("range_start".to_string(), ArgValue::Position(start.line, start.column));
283    args.insert("range_end".to_string(), ArgValue::Position(end.line, end.column));
284
285    // Set count (defaults to 1)
286    args.insert("count".to_string(), ArgValue::Count(count.unwrap_or(1)));
287
288    // Set register if specified
289    if let Some(reg) = register {
290        args.insert("register".to_string(), ArgValue::Register(reg));
291    }
292
293    PopResult::ExecuteCommand { command, args }
294}
295
296/// Build a cancellation mode transition.
297#[must_use]
298pub fn build_cancelled() -> ModeTransition {
299    ModeTransition::Pop {
300        result: Some(PopResult::Cancelled),
301    }
302}
303
304/// Apply Vim policy to determine the next action based on keymap lookup.
305///
306/// This is reusable across all operator resolvers since they share the same policy:
307/// - `ExactOnly` or `ExactWithLonger` → Execute the motion
308/// - `PrefixOnly` → Wait for more keys
309/// - `NotFound` → Cancel the operator
310#[must_use]
311pub fn apply_keymap_policy(lookup: &KeyLookupState) -> KeymapAction {
312    match lookup {
313        KeyLookupState::ExactOnly(cmd) | KeyLookupState::ExactWithLonger { exact: cmd } => {
314            KeymapAction::Execute(cmd.clone())
315        }
316        KeyLookupState::PrefixOnly => KeymapAction::Pending,
317        KeyLookupState::NotFound => KeymapAction::Cancel,
318    }
319}
320
321/// Result of applying keymap policy.
322#[derive(Debug, Clone)]
323pub enum KeymapAction {
324    /// Execute the found command (motion/text-object).
325    Execute(CommandId),
326    /// Wait for more keys (prefix match).
327    Pending,
328    /// Cancel the operator (no match).
329    Cancel,
330}
331
332// =============================================================================
333// Operator State
334// =============================================================================
335
336/// State owned by an operator resolver.
337///
338/// Unlike `VimSessionState.pending_operator`, this state is owned by the resolver
339/// itself. This makes testing easier and eliminates runtime lookup of operator type.
340///
341/// # State Lifecycle
342///
343/// When a mode is pushed (e.g., user presses 'd' in normal mode):
344/// 1. `initialized` is false
345/// 2. First `resolve_with_session` call reads `pending_count`/`pending_register` from VimSessionState
346/// 3. Sets `initialized = true`
347/// 4. Subsequent keys use the cached state
348/// 5. When mode is popped or cancelled, `reset()` clears the state
349#[derive(Debug, Clone)]
350pub struct OperatorState {
351    /// The type of operator (delete, yank, change).
352    pub operator: OperatorType,
353    /// Start position captured when entering operator mode.
354    pub start_position: Option<Position>,
355    /// Count applied before the operator (e.g., `2d` in `2dw`).
356    pub operator_count: Option<usize>,
357    /// Count applied to the motion (e.g., `3` in `d3w`).
358    pub motion_count: Option<usize>,
359    /// Target register for the operation.
360    pub register: Option<char>,
361    /// Accumulated key sequence for multi-key motions.
362    pub pending_keys: KeySequence,
363    /// Whether the state has been initialized with context from normal mode.
364    ///
365    /// On first key press in the mode, the resolver reads `pending_count` and
366    /// `pending_register` from VimSessionState and sets this to true.
367    pub initialized: bool,
368}
369
370impl OperatorState {
371    /// Create a new operator state.
372    ///
373    /// The state starts uninitialized. On first key press, the resolver
374    /// will read `pending_count` and `pending_register` from VimSessionState.
375    #[must_use]
376    pub fn new(operator: OperatorType) -> Self {
377        Self {
378            operator,
379            start_position: None,
380            operator_count: None,
381            motion_count: None,
382            register: None,
383            pending_keys: KeySequence::new(),
384            initialized: false,
385        }
386    }
387
388    /// Create operator state with initial count and register.
389    ///
390    /// Used in tests to pre-initialize state without going through VimSessionState.
391    #[must_use]
392    pub fn with_context(
393        operator: OperatorType,
394        count: Option<usize>,
395        register: Option<char>,
396    ) -> Self {
397        Self {
398            operator,
399            start_position: None,
400            operator_count: count,
401            motion_count: None,
402            register,
403            pending_keys: KeySequence::new(),
404            initialized: true, // Pre-initialized for testing
405        }
406    }
407
408    /// Set the start position.
409    pub fn set_start_position(&mut self, pos: Position) {
410        self.start_position = Some(pos);
411    }
412
413    /// Get the effective count (operator_count * motion_count, defaulting to 1).
414    #[must_use]
415    pub fn effective_count(&self) -> usize {
416        let op = self.operator_count.unwrap_or(1);
417        let motion = self.motion_count.unwrap_or(1);
418        op * motion
419    }
420
421    /// Get count only if explicitly specified (not defaulted).
422    ///
423    /// Returns `None` if no count was given, `Some(n)` if count was given.
424    /// This preserves the distinction between "no count" and "count=1" for
425    /// motions like G/gg where these have different semantics:
426    /// - `G` (no count) → last line
427    /// - `1G` (count=1) → line 1
428    #[must_use]
429    pub fn explicit_count(&self) -> Option<usize> {
430        match (self.operator_count, self.motion_count) {
431            (None, None) => None,
432            (Some(op), None) => Some(op),
433            (None, Some(motion)) => Some(motion),
434            (Some(op), Some(motion)) => Some(op * motion),
435        }
436    }
437
438    /// Check if we have a motion count.
439    #[must_use]
440    pub fn has_motion_count(&self) -> bool {
441        self.motion_count.is_some()
442    }
443
444    /// Accumulate a motion count digit.
445    pub fn accumulate_motion_count(&mut self, key: &KeyEvent) {
446        self.motion_count = accumulate_count(key, self.motion_count);
447    }
448
449    /// Take the motion count, clearing it.
450    pub fn take_motion_count(&mut self) -> Option<usize> {
451        self.motion_count.take()
452    }
453
454    /// Add a key to pending sequence.
455    pub fn push_key(&mut self, key: KeyEvent) {
456        self.pending_keys.push(key);
457    }
458
459    /// Get a clone of pending keys.
460    #[must_use]
461    pub fn keys(&self) -> KeySequence {
462        self.pending_keys.clone()
463    }
464
465    /// Clear pending keys.
466    pub fn clear_keys(&mut self) {
467        self.pending_keys.clear();
468    }
469
470    /// Reset all state for mode re-entry.
471    ///
472    /// Called when the mode is exited (cancelled or completed). Clears all
473    /// state including count and register so they can be re-read from
474    /// VimSessionState on the next mode entry.
475    pub fn reset(&mut self) {
476        self.start_position = None;
477        self.operator_count = None;
478        self.motion_count = None;
479        self.register = None;
480        self.pending_keys.clear();
481        self.initialized = false;
482    }
483}