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}