Skip to main content

reovim_module_vim/visual/
operators.rs

1//! Visual mode operator commands.
2//!
3//! Provides commands that operate on the visual selection:
4//! - `d` - Delete selection
5//! - `y` - Yank (copy) selection
6//! - `c` - Change selection (delete + insert mode)
7//! - `>` - Indent selection
8//! - `<` - Dedent selection
9
10use {
11    reovim_driver_command::{Command, CommandContext, CommandHandler, CommandResult},
12    reovim_driver_session::{
13        BufferApi, SessionRuntime, TransitionContext,
14        api::{ChangeTracker, ModeApi, RegisterContent, Selection, SelectionMode},
15    },
16    reovim_kernel::api::v1::{CommandId, Position},
17};
18
19use crate::{ids, modes::VimMode};
20
21/// Calculate the expanded range for a selection.
22///
23/// This converts an API Selection into start/end positions suitable for
24/// text extraction and deletion, taking selection mode into account:
25/// - Character mode: End is already exclusive, use as-is
26/// - Line mode: Expand to full lines including trailing newline
27/// - Block mode: End is already exclusive, use as-is
28///
29/// Phase 8 (#465): Selection.end is EXCLUSIVE (like Rust ranges).
30/// The selection (0,0) to (0,5) means columns 0..5 = "hello" (5 chars).
31///
32/// Returns `(start, end, is_linewise)`.
33fn expand_selection_range(
34    selection: &Selection,
35    end_line_len: Option<usize>,
36    total_lines: usize,
37) -> (Position, Position, bool) {
38    let start = selection.start;
39    let end = selection.end;
40
41    match selection.mode {
42        SelectionMode::Line => {
43            // Expand to full lines, including the trailing newline
44            let start = Position::new(start.line, 0);
45            // For line mode, end.line is already the exclusive end line
46            // For non-last lines, extend to start of end line (includes previous line's newline)
47            // For last line, end at line length
48            let end_line_len = end_line_len.unwrap_or(0);
49            let end = if end.line < total_lines {
50                Position::new(end.line, 0)
51            } else {
52                // End is past buffer, cap at last line's length
53                Position::new(end.line - 1, end_line_len)
54            };
55            (start, end, true)
56        }
57        // Character and Block modes: End is already exclusive - use as-is
58        SelectionMode::Character | SelectionMode::Block => (start, end, false),
59    }
60}
61
62/// Delete selection (d in visual mode).
63///
64/// Deletes the selected text and stores it in the register.
65/// Returns to Normal mode after execution.
66#[derive(Debug, Clone, Copy, Default)]
67pub struct DeleteSelection;
68
69impl Command for DeleteSelection {
70    fn id(&self) -> CommandId {
71        ids::DELETE_SELECTION
72    }
73
74    fn description(&self) -> &'static str {
75        "Delete visual selection"
76    }
77}
78
79impl CommandHandler for DeleteSelection {
80    #[cfg_attr(coverage_nightly, coverage(off))]
81    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
82        let Some(buffer_id) = args.buffer_id() else {
83            return CommandResult::error("No active buffer");
84        };
85
86        // Get selection from active window
87        let Some(selection) = runtime.windows().active().and_then(|w| w.selection.clone()) else {
88            return CommandResult::Success; // No selection - no-op
89        };
90
91        // Get line info for expanding selection
92        let end_line_len = runtime.buffer_line_len(buffer_id, selection.end.line);
93        let total_lines = runtime.buffer_line_count(buffer_id).unwrap_or(1);
94
95        // Expand selection to deletion range
96        let (start, end, is_linewise) =
97            expand_selection_range(&selection, end_line_len, total_lines);
98        let cursor_pos = start;
99
100        // Extract text for register with clipboard sync (#515)
101        if let Some(text) = runtime.buffer_text_range(buffer_id, start, end) {
102            let content = if is_linewise {
103                RegisterContent::linewise(&text)
104            } else {
105                RegisterContent::characterwise(&text)
106            };
107            runtime.store_register_with_sync(args.register(), content);
108        }
109
110        // Delete the range
111        runtime.delete_range(buffer_id, start, end);
112
113        // Clear selection and set cursor
114        if let Some(window) = runtime.windows_mut().active_mut() {
115            window.selection = None;
116            window.cursor = cursor_pos.into();
117        }
118
119        // #474: Notify other clients that selection was cleared
120        runtime.record_selection_change(buffer_id);
121
122        // Mode transition to Normal
123        runtime.set_mode(VimMode::NORMAL_ID, TransitionContext::new());
124
125        CommandResult::Success
126    }
127}
128
129/// Yank selection (y in visual mode).
130///
131/// Copies the selected text to the register without deleting it.
132/// Returns to Normal mode after execution.
133#[derive(Debug, Clone, Copy, Default)]
134pub struct YankSelection;
135
136impl Command for YankSelection {
137    fn id(&self) -> CommandId {
138        ids::YANK_SELECTION
139    }
140
141    fn description(&self) -> &'static str {
142        "Yank visual selection"
143    }
144}
145
146impl CommandHandler for YankSelection {
147    #[cfg_attr(coverage_nightly, coverage(off))]
148    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
149        let Some(buffer_id) = args.buffer_id() else {
150            return CommandResult::error("No active buffer");
151        };
152
153        // Get selection from active window
154        let Some(selection) = runtime.windows().active().and_then(|w| w.selection.clone()) else {
155            return CommandResult::Success; // No selection - no-op
156        };
157
158        // Get line info for expanding selection
159        let end_line_len = runtime.buffer_line_len(buffer_id, selection.end.line);
160        let total_lines = runtime.buffer_line_count(buffer_id).unwrap_or(1);
161
162        // Expand selection to yank range
163        let (start, end, is_linewise) =
164            expand_selection_range(&selection, end_line_len, total_lines);
165
166        // Extract text for register with clipboard sync (#515)
167        if let Some(text) = runtime.buffer_text_range(buffer_id, start, end) {
168            let content = if is_linewise {
169                RegisterContent::linewise(&text)
170            } else {
171                RegisterContent::characterwise(&text)
172            };
173            runtime.store_register_with_sync(args.register(), content);
174        }
175
176        // Clear selection (yank doesn't delete text or move cursor)
177        if let Some(window) = runtime.windows_mut().active_mut() {
178            window.selection = None;
179        }
180
181        // #474: Notify other clients that selection was cleared
182        runtime.record_selection_change(buffer_id);
183
184        // Mode transition to Normal
185        runtime.set_mode(VimMode::NORMAL_ID, TransitionContext::new());
186
187        CommandResult::Success
188    }
189}
190
191/// Change selection (c in visual mode).
192///
193/// Deletes the selected text and enters Insert mode.
194#[derive(Debug, Clone, Copy, Default)]
195pub struct ChangeSelection;
196
197impl Command for ChangeSelection {
198    fn id(&self) -> CommandId {
199        ids::CHANGE_SELECTION
200    }
201
202    fn description(&self) -> &'static str {
203        "Change visual selection (delete and enter insert mode)"
204    }
205}
206
207impl CommandHandler for ChangeSelection {
208    #[cfg_attr(coverage_nightly, coverage(off))]
209    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
210        let Some(buffer_id) = args.buffer_id() else {
211            return CommandResult::error("No active buffer");
212        };
213
214        // Get selection from active window
215        let Some(selection) = runtime.windows().active().and_then(|w| w.selection.clone()) else {
216            return CommandResult::Success; // No selection - no-op
217        };
218
219        // Get line info for expanding selection
220        let end_line_len = runtime.buffer_line_len(buffer_id, selection.end.line);
221        let total_lines = runtime.buffer_line_count(buffer_id).unwrap_or(1);
222
223        // Expand selection to deletion range
224        let (start, end, is_linewise) =
225            expand_selection_range(&selection, end_line_len, total_lines);
226        let cursor_pos = start;
227
228        // Extract text for register with clipboard sync (#515)
229        if let Some(text) = runtime.buffer_text_range(buffer_id, start, end) {
230            let content = if is_linewise {
231                RegisterContent::linewise(&text)
232            } else {
233                RegisterContent::characterwise(&text)
234            };
235            runtime.store_register_with_sync(args.register(), content);
236        }
237
238        // Delete the range
239        runtime.delete_range(buffer_id, start, end);
240
241        // Clear selection and set cursor
242        if let Some(window) = runtime.windows_mut().active_mut() {
243            window.selection = None;
244            window.cursor = cursor_pos.into();
245        }
246
247        // #474: Notify other clients that selection was cleared
248        runtime.record_selection_change(buffer_id);
249
250        // Mode transition to Insert (change = delete + insert mode)
251        runtime.set_mode(VimMode::INSERT_ID, TransitionContext::new());
252
253        CommandResult::Success
254    }
255}
256
257/// Indent selection (> in visual mode).
258///
259/// Increases indentation of selected lines.
260/// Returns to Normal mode after execution.
261#[derive(Debug, Clone, Copy, Default)]
262pub struct IndentSelection;
263
264impl Command for IndentSelection {
265    fn id(&self) -> CommandId {
266        ids::INDENT_SELECTION
267    }
268
269    fn description(&self) -> &'static str {
270        "Indent visual selection"
271    }
272}
273
274impl CommandHandler for IndentSelection {
275    #[cfg_attr(coverage_nightly, coverage(off))]
276    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
277        let Some(buffer_id) = args.buffer_id() else {
278            return CommandResult::error("No active buffer");
279        };
280
281        // Get selection from active window
282        let Some(selection) = runtime.windows().active().and_then(|w| w.selection.clone()) else {
283            return CommandResult::Success; // No selection - no-op
284        };
285
286        // Get line range from normalized selection
287        // Phase 8 (#465): Selection.end is EXCLUSIVE (like Rust ranges)
288        let start_line = selection.start.line;
289        let end_line = selection.end.line; // exclusive
290
291        // Indent each line (add tab/spaces at start)
292        // Using 4 spaces as default indent
293        let indent = "    ";
294        for line_idx in start_line..end_line {
295            runtime.insert_text(buffer_id, Position::new(line_idx, 0), indent);
296        }
297
298        // Clear selection
299        if let Some(window) = runtime.windows_mut().active_mut() {
300            window.selection = None;
301        }
302
303        // #474: Notify other clients that selection was cleared
304        runtime.record_selection_change(buffer_id);
305
306        runtime.set_mode(VimMode::NORMAL_ID, TransitionContext::new());
307
308        CommandResult::Success
309    }
310}
311
312/// Dedent selection (< in visual mode).
313///
314/// Decreases indentation of selected lines.
315/// Returns to Normal mode after execution.
316#[derive(Debug, Clone, Copy, Default)]
317pub struct DedentSelection;
318
319impl Command for DedentSelection {
320    fn id(&self) -> CommandId {
321        ids::DEDENT_SELECTION
322    }
323
324    fn description(&self) -> &'static str {
325        "Dedent visual selection"
326    }
327}
328
329impl CommandHandler for DedentSelection {
330    #[cfg_attr(coverage_nightly, coverage(off))]
331    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
332        let Some(buffer_id) = args.buffer_id() else {
333            return CommandResult::error("No active buffer");
334        };
335
336        // Get selection from active window
337        let Some(selection) = runtime.windows().active().and_then(|w| w.selection.clone()) else {
338            return CommandResult::Success; // No selection - no-op
339        };
340
341        // Get line range from normalized selection
342        // Phase 8 (#465): Selection.end is EXCLUSIVE (like Rust ranges)
343        let start_line = selection.start.line;
344        let end_line = selection.end.line; // exclusive
345
346        // Dedent each line (remove leading whitespace, up to 4 chars or one tab)
347        for line_idx in start_line..end_line {
348            if let Some(line) = runtime.buffer_line(buffer_id, line_idx) {
349                let mut chars_to_remove = 0;
350                for (i, c) in line.chars().enumerate() {
351                    if c == '\t' {
352                        chars_to_remove = i + 1;
353                        break;
354                    } else if c == ' ' && i < 4 {
355                        chars_to_remove = i + 1;
356                    } else {
357                        break;
358                    }
359                }
360                if chars_to_remove > 0 {
361                    let start = Position::new(line_idx, 0);
362                    let end = Position::new(line_idx, chars_to_remove);
363                    runtime.delete_range(buffer_id, start, end);
364                }
365            }
366        }
367
368        // Clear selection
369        if let Some(window) = runtime.windows_mut().active_mut() {
370            window.selection = None;
371        }
372
373        // #474: Notify other clients that selection was cleared
374        runtime.record_selection_change(buffer_id);
375
376        runtime.set_mode(VimMode::NORMAL_ID, TransitionContext::new());
377
378        CommandResult::Success
379    }
380}
381
382// =============================================================================
383// Case Operators in Visual Mode (#666)
384// =============================================================================
385
386/// Helper to apply a case transformation to the visual selection.
387#[cfg_attr(coverage_nightly, coverage(off))]
388fn execute_case_selection(
389    runtime: &mut SessionRuntime<'_>,
390    args: &CommandContext,
391    transform: fn(&str) -> String,
392) -> CommandResult {
393    let Some(buffer_id) = args.buffer_id() else {
394        return CommandResult::error("No active buffer");
395    };
396
397    let Some(selection) = runtime.windows().active().and_then(|w| w.selection.clone()) else {
398        return CommandResult::Success;
399    };
400
401    let end_line_len = runtime.buffer_line_len(buffer_id, selection.end.line);
402    let total_lines = runtime.buffer_line_count(buffer_id).unwrap_or(1);
403    let (start, end, _is_linewise) = expand_selection_range(&selection, end_line_len, total_lines);
404
405    // Read, transform, replace
406    if let Some(text) = runtime.buffer_text_range(buffer_id, start, end) {
407        let transformed = transform(&text);
408        if transformed != text {
409            runtime.delete_range(buffer_id, start, end);
410            runtime.insert_text(buffer_id, start, &transformed);
411        }
412    }
413
414    // Clear selection and set cursor to start of range
415    if let Some(window) = runtime.windows_mut().active_mut() {
416        window.selection = None;
417        window.cursor = start.into();
418    }
419
420    runtime.record_selection_change(buffer_id);
421    runtime.set_mode(VimMode::NORMAL_ID, TransitionContext::new());
422
423    CommandResult::Success
424}
425
426/// Toggle case of selection (~ in visual mode).
427#[derive(Debug, Clone, Copy, Default)]
428pub struct ToggleCaseSelection;
429
430impl Command for ToggleCaseSelection {
431    fn id(&self) -> CommandId {
432        ids::TOGGLE_CASE_SELECTION
433    }
434
435    fn description(&self) -> &'static str {
436        "Toggle case of visual selection"
437    }
438}
439
440// Needs visual mode + buffer state — tested by integration tests.
441#[cfg_attr(coverage_nightly, coverage(off))]
442impl CommandHandler for ToggleCaseSelection {
443    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
444        execute_case_selection(runtime, args, |s| {
445            s.chars()
446                .map(|c| {
447                    if c.is_uppercase() {
448                        c.to_lowercase().next().unwrap_or(c)
449                    } else if c.is_lowercase() {
450                        c.to_uppercase().next().unwrap_or(c)
451                    } else {
452                        c
453                    }
454                })
455                .collect()
456        })
457    }
458}
459
460/// Lowercase selection (u in visual mode).
461#[derive(Debug, Clone, Copy, Default)]
462pub struct LowercaseSelection;
463
464impl Command for LowercaseSelection {
465    fn id(&self) -> CommandId {
466        ids::LOWERCASE_SELECTION
467    }
468
469    fn description(&self) -> &'static str {
470        "Lowercase visual selection"
471    }
472}
473
474#[cfg_attr(coverage_nightly, coverage(off))]
475impl CommandHandler for LowercaseSelection {
476    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
477        execute_case_selection(runtime, args, str::to_lowercase)
478    }
479}
480
481/// Uppercase selection (U in visual mode).
482#[derive(Debug, Clone, Copy, Default)]
483pub struct UppercaseSelection;
484
485impl Command for UppercaseSelection {
486    fn id(&self) -> CommandId {
487        ids::UPPERCASE_SELECTION
488    }
489
490    fn description(&self) -> &'static str {
491        "Uppercase visual selection"
492    }
493}
494
495#[cfg_attr(coverage_nightly, coverage(off))]
496impl CommandHandler for UppercaseSelection {
497    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
498        execute_case_selection(runtime, args, str::to_uppercase)
499    }
500}
501
502#[cfg(test)]
503#[allow(clippy::significant_drop_tightening, clippy::uninlined_format_args)]
504#[path = "tests/operators.rs"]
505mod tests;