Skip to main content

reovim_module_vim/operators/
commands.rs

1//! Operator command wrappers for Epic #415.
2//!
3//! These `CommandHandler` implementations wrap the `Operator` trait,
4//! reading range information from `CommandContext` and delegating to
5//! the appropriate operator.
6//!
7//! # Architecture
8//!
9//! When the runner receives `PopResult::ExecuteCommand`, it executes
10//! the command with the provided arguments. This module provides the
11//! command handlers that are registered with IDs like `vim:delete`
12//! and perform the actual operation.
13
14use {
15    reovim_driver_command::{Command, CommandContext, CommandHandler, CommandResult},
16    reovim_driver_session::{BufferApi, SessionRuntime, TransitionContext, api::ModeApi},
17    reovim_kernel::api::v1::{CommandId, Position},
18};
19
20use {
21    super::{
22        DeleteOperator, LowercaseOperator, Operator, OperatorContext, Range, ToggleCaseOperator,
23        UppercaseOperator, YankOperator,
24    },
25    crate::ids::MODULE,
26};
27
28// =============================================================================
29// Delete Command (vim:delete)
30// =============================================================================
31
32/// Delete operator command - wraps `DeleteOperator` for command execution.
33///
34/// This command is executed by the runner when `PopResult::ExecuteCommand`
35/// is received with command `vim:delete`. It reads the range from the
36/// command context and delegates to `DeleteOperator`.
37#[derive(Debug, Clone, Copy, Default)]
38pub struct DeleteCommand;
39
40impl Command for DeleteCommand {
41    fn id(&self) -> CommandId {
42        CommandId::new(MODULE, "delete")
43    }
44
45    fn description(&self) -> &'static str {
46        "Delete text in range"
47    }
48}
49
50#[cfg_attr(coverage_nightly, coverage(off))]
51impl CommandHandler for DeleteCommand {
52    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
53        execute_operator(&DeleteOperator, runtime, args)
54    }
55}
56
57// =============================================================================
58// Yank Command (vim:yank)
59// =============================================================================
60
61/// Yank operator command - wraps `YankOperator` for command execution.
62///
63/// This command is executed by the runner when `PopResult::ExecuteCommand`
64/// is received with command `vim:yank`. It reads the range from the
65/// command context and delegates to `YankOperator`.
66#[derive(Debug, Clone, Copy, Default)]
67pub struct YankCommand;
68
69impl Command for YankCommand {
70    fn id(&self) -> CommandId {
71        CommandId::new(MODULE, "yank")
72    }
73
74    fn description(&self) -> &'static str {
75        "Yank text in range to register"
76    }
77}
78
79#[cfg_attr(coverage_nightly, coverage(off))]
80impl CommandHandler for YankCommand {
81    #[cfg_attr(coverage_nightly, coverage(off))]
82    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
83        // In Vim, after yank the cursor should be restored to the start of the yanked range.
84        // Get the range start BEFORE executing the yank, so we can restore cursor after.
85        let (start_line, start_col) = args.range_start().unwrap_or((0, 0));
86        let restore_pos = Position::new(start_line, start_col);
87
88        let cur_pos = runtime
89            .windows()
90            .active()
91            .map(|w| Position::new(w.cursor.line, w.cursor.column));
92        tracing::debug!(
93            range_start = ?restore_pos,
94            current_pos = ?cur_pos,
95            "yank command: before execute"
96        );
97
98        let result = execute_operator(&YankOperator, runtime, args);
99
100        // Restore cursor to start of yanked range (Vim behavior)
101        if matches!(result, CommandResult::Success) && args.buffer_id().is_some() {
102            if let Some(window) = runtime.windows_mut().active_mut() {
103                window.cursor = restore_pos.into();
104            }
105
106            let cur_pos = runtime
107                .windows()
108                .active()
109                .map(|w| Position::new(w.cursor.line, w.cursor.column));
110            tracing::debug!(
111                restored_to = ?restore_pos,
112                current_pos = ?cur_pos,
113                "yank command: cursor restored"
114            );
115
116            // #657: Record yank flash state for client-side animation
117            if let Some(buffer_id) = args.buffer_id() {
118                use reovim_driver_session::api::ExtensionApi;
119                let (end_line, end_col) = args.range_end().unwrap_or((0, 0));
120                let state = runtime.ext_mut::<super::yank_flash::YankFlashState>();
121                state.record(
122                    buffer_id,
123                    start_line,
124                    start_col,
125                    end_line,
126                    end_col,
127                    args.is_linewise(),
128                );
129            }
130        }
131
132        result
133    }
134}
135
136// =============================================================================
137// Change Command (vim:change)
138// =============================================================================
139
140/// Change operator command - wraps `ChangeOperator` for command execution.
141///
142/// This command is executed by the runner when `PopResult::ExecuteCommand`
143/// is received with command `vim:change`. It reads the range from the
144/// command context, deletes the text, and transitions to insert mode.
145#[derive(Debug, Clone, Copy, Default)]
146pub struct ChangeCommand;
147
148impl Command for ChangeCommand {
149    fn id(&self) -> CommandId {
150        CommandId::new(MODULE, "change")
151    }
152
153    fn description(&self) -> &'static str {
154        "Change text in range (delete and enter insert)"
155    }
156}
157
158#[cfg_attr(coverage_nightly, coverage(off))]
159impl CommandHandler for ChangeCommand {
160    #[cfg_attr(coverage_nightly, coverage(off))]
161    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
162        use {
163            super::ChangeOperator,
164            crate::modes::VimMode,
165            reovim_driver_undo::{UndoKey, UndoProviderRegistry},
166        };
167
168        // Start undo batching BEFORE the delete - both the delete and subsequent
169        // insert edits should be grouped as a single undo entry
170        if let Some(buffer_id) = args.buffer_id()
171            && let Some(window) = runtime.windows().active()
172            && let Some(undo_registry) = runtime.kernel().services.get::<UndoProviderRegistry>()
173            && let Some(undo_provider) = undo_registry.get(&UndoKey::Buffer)
174        {
175            let pos = Position::new(window.cursor.line, window.cursor.column);
176            undo_provider.begin_batch(buffer_id, pos);
177        }
178
179        // Execute the change operator (delete text)
180        let result = execute_operator(&ChangeOperator, runtime, args);
181
182        // If successful, enter insert mode (change = delete + insert)
183        if matches!(result, CommandResult::Success) {
184            runtime.set_mode(VimMode::INSERT_ID, TransitionContext::new());
185        }
186
187        result
188    }
189}
190
191// =============================================================================
192// Lowercase Command (vim:lowercase)
193// =============================================================================
194
195/// Lowercase operator command - wraps `LowercaseOperator` for command execution.
196#[derive(Debug, Clone, Copy, Default)]
197pub struct LowercaseCommand;
198
199impl Command for LowercaseCommand {
200    fn id(&self) -> CommandId {
201        CommandId::new(MODULE, "lowercase")
202    }
203
204    fn description(&self) -> &'static str {
205        "Lowercase text in range"
206    }
207}
208
209#[cfg_attr(coverage_nightly, coverage(off))]
210impl CommandHandler for LowercaseCommand {
211    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
212        execute_operator(&LowercaseOperator, runtime, args)
213    }
214}
215
216// =============================================================================
217// Uppercase Command (vim:uppercase)
218// =============================================================================
219
220/// Uppercase operator command - wraps `UppercaseOperator` for command execution.
221#[derive(Debug, Clone, Copy, Default)]
222pub struct UppercaseCommand;
223
224impl Command for UppercaseCommand {
225    fn id(&self) -> CommandId {
226        CommandId::new(MODULE, "uppercase")
227    }
228
229    fn description(&self) -> &'static str {
230        "Uppercase text in range"
231    }
232}
233
234#[cfg_attr(coverage_nightly, coverage(off))]
235impl CommandHandler for UppercaseCommand {
236    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
237        execute_operator(&UppercaseOperator, runtime, args)
238    }
239}
240
241// =============================================================================
242// Toggle Case Command (vim:toggle-case-op)
243// =============================================================================
244
245/// Toggle case operator command - wraps `ToggleCaseOperator` for command execution.
246#[derive(Debug, Clone, Copy, Default)]
247pub struct ToggleCaseCommand;
248
249impl Command for ToggleCaseCommand {
250    fn id(&self) -> CommandId {
251        CommandId::new(MODULE, "toggle-case-op")
252    }
253
254    fn description(&self) -> &'static str {
255        "Toggle case of text in range"
256    }
257}
258
259#[cfg_attr(coverage_nightly, coverage(off))]
260impl CommandHandler for ToggleCaseCommand {
261    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
262        execute_operator(&ToggleCaseOperator, runtime, args)
263    }
264}
265
266// =============================================================================
267// Helper Function
268// =============================================================================
269
270/// Execute an operator with range information from `CommandContext`.
271///
272/// This function bridges the `CommandHandler` interface to the `Operator` trait:
273/// 1. Extracts `range_start`, `range_end`, linewise from args
274/// 2. Builds an `OperatorContext` with kernel access
275/// 3. Calls `operator.execute()`
276/// 4. Updates cursor in window for text-modifying operators
277#[cfg_attr(coverage_nightly, coverage(off))]
278fn execute_operator(
279    operator: &dyn Operator,
280    runtime: &mut SessionRuntime<'_>,
281    args: &CommandContext,
282) -> CommandResult {
283    // Get buffer ID
284    let Some(buffer_id) = args.buffer_id() else {
285        return CommandResult::error("No active buffer");
286    };
287
288    // Get range from context (Epic #415 Phase 5)
289    let (start_line, start_col) = args.range_start().unwrap_or((0, 0));
290    let (end_line, end_col) = args.range_end().unwrap_or((0, 0));
291    let linewise = args.is_linewise();
292
293    let start = Position::new(start_line, start_col);
294    let end = Position::new(end_line, end_col);
295
296    // Build range
297    let range = if linewise {
298        Range::linewise(start, end)
299    } else {
300        Range::new(start, end)
301    };
302
303    // Get count and register (#515: convert Option<char> → Register)
304    let count = args.count().unwrap_or(1);
305    let register = super::registers::option_char_to_register(args.register());
306
307    // Get cursor position from window (for undo tracking)
308    let cursor_position = runtime
309        .windows()
310        .active()
311        .map_or_else(Position::origin, |w| Position::new(w.cursor.line, w.cursor.column));
312
313    // Build operator context using runtime's kernel and per-client registers (#515)
314    // Use split-borrow helper to avoid conflicting borrows on runtime
315    let (kernel, registers, clipboard_history) = runtime.kernel_and_registers();
316    let mut op_ctx = OperatorContext {
317        kernel,
318        registers,
319        clipboard_history,
320        buffer_id,
321        register,
322        count,
323        cursor_position,
324        cursor_after: None,
325    };
326
327    // Execute operator
328    match operator.execute(&mut op_ctx, range) {
329        Ok(()) => {
330            // Copy cursor_after before releasing op_ctx borrows (#552).
331            // op_ctx holds split-borrows from runtime.kernel_and_registers();
332            // copying the Copy field lets NLL release those borrows so we can
333            // call runtime.buffer_line_count() and runtime.windows_mut() below.
334            let cursor_after = op_ctx.cursor_after;
335
336            // Update cursor for text-modifying operators (#471, #552)
337            if operator.is_text_modifying() {
338                // Use operator's computed cursor if available,
339                // otherwise clamp range start to valid bounds (#552)
340                let new_cursor = cursor_after.unwrap_or_else(|| {
341                    let line_count = runtime.buffer_line_count(buffer_id).unwrap_or(1);
342                    let clamped_line = start.line.min(line_count.saturating_sub(1));
343                    let line_len = runtime
344                        .buffer_line_len(buffer_id, clamped_line)
345                        .unwrap_or(0);
346                    let clamped_col = if line_len == 0 {
347                        0
348                    } else {
349                        start.column.min(line_len.saturating_sub(1))
350                    };
351                    Position::new(clamped_line, clamped_col)
352                });
353
354                if let Some(window) = runtime.windows_mut().active_mut() {
355                    window.cursor = new_cursor.into();
356                    tracing::debug!(
357                        ?new_cursor,
358                        operator = operator.id(),
359                        "Updated cursor after text-modifying operator"
360                    );
361                }
362            }
363
364            // Record buffer modification for notification pipeline.
365            // The operator writes directly to the kernel buffer via OperatorContext,
366            // bypassing SessionRuntime::delete_range() which normally calls
367            // record_buffer_modified(). We must record it explicitly so that
368            // StateChanges propagates to TUI for display refresh.
369            if operator.is_text_modifying() {
370                runtime.record_buffer_modified(buffer_id);
371            }
372
373            CommandResult::Success
374        }
375        Err(e) => CommandResult::error(&e.to_string()),
376    }
377}
378
379// =============================================================================
380// Command Registration
381// =============================================================================
382
383/// Get all operator command handlers.
384#[must_use]
385pub fn operator_commands() -> Vec<Box<dyn CommandHandler>> {
386    vec![
387        Box::new(DeleteCommand),
388        Box::new(YankCommand),
389        Box::new(ChangeCommand),
390        Box::new(LowercaseCommand),
391        Box::new(UppercaseCommand),
392        Box::new(ToggleCaseCommand),
393    ]
394}
395
396// =============================================================================
397// Tests
398// =============================================================================