reovim_module_vim/operators/delete.rs
1//! Delete operator.
2//!
3//! Reference: lib/core/src/command/builtin/operator.rs (concept-extraction, not migration)
4
5use {
6 reovim_driver_undo::{UndoKey, UndoProviderRegistry},
7 reovim_kernel::api::v1::{Edit, RegisterContent},
8};
9
10use super::{Operator, OperatorContext, OperatorError, Range, char_col_to_byte, registers};
11
12/// Delete operator - cuts text to register.
13///
14/// Behavior:
15/// - Deletes text in the given range
16/// - Stores deleted text in the unnamed register (or specified register)
17/// - Linewise if the motion was linewise
18///
19/// # Example
20///
21/// ```ignore
22/// let delete = DeleteOperator;
23/// delete.execute(&mut ctx, range)?;
24/// ```
25#[derive(Debug, Clone, Copy)]
26pub struct DeleteOperator;
27
28impl Operator for DeleteOperator {
29 fn id(&self) -> &'static str {
30 "delete"
31 }
32
33 #[allow(clippy::too_many_lines, clippy::option_if_let_else)]
34 #[cfg_attr(coverage_nightly, coverage(off))]
35 fn execute(&self, ctx: &mut OperatorContext<'_>, range: Range) -> Result<(), OperatorError> {
36 // Get the buffer via kernel's buffer manager
37 let buffer_arc = ctx
38 .kernel
39 .buffers
40 .get(ctx.buffer_id)
41 .ok_or(OperatorError::BufferNotFound(ctx.buffer_id))?;
42
43 let mut buffer = buffer_arc.write();
44
45 // Get text before deleting
46 let start = range.start;
47 let end = range.end;
48
49 // Build deleted text from lines
50 // - register_text: for register storage (linewise = "line\n")
51 // - deleted_text: for undo (exact bytes deleted)
52 let mut register_text = String::new();
53 let mut deleted_text = String::new();
54
55 if range.is_linewise {
56 // Linewise deletion: delete entire lines from start.line to end.line (inclusive)
57 // Ignore column values - always delete full lines
58 let line_count = buffer.line_count();
59
60 // Clamp end.line to last valid line to handle counts exceeding buffer
61 let clamped_end = end.line.min(line_count.saturating_sub(1));
62
63 // Build register_text as "line\n" for each line (for paste to work correctly)
64 for line_idx in start.line..=clamped_end {
65 if let Some(line) = buffer.line(line_idx) {
66 register_text.push_str(line);
67 register_text.push('\n');
68 }
69 }
70
71 // For linewise, adjust the actual deletion range to cover full lines.
72 // Three cases based on Vim semantics:
73 //
74 // Case 1: Deleting non-last lines (e.g., dd on line 0 of 3-line buffer)
75 // - Delete from start of first line through the newline
76 // - Range: (start.line, 0) to (clamped_end + 1, 0)
77 // - Removes: "line\n" leaving subsequent lines
78 // - deleted_text: "line\n"
79 //
80 // Case 2: Deleting last line(s) but not all (e.g., dd on line 1 of 2-line buffer)
81 // - Include the PRECEDING newline from previous line
82 // - Range: (start.line - 1, prev_len) to (clamped_end, last_len)
83 // - Removes: "\nline" leaving previous lines intact
84 // - deleted_text: "\nline" (for correct undo)
85 //
86 // Case 3: Deleting the only line (single-line buffer)
87 // - Delete entire content
88 // - Range: (0, 0) to (0, content_len)
89 // - Results in empty buffer
90 // - deleted_text: "line"
91 let delete_start;
92 let delete_end;
93
94 if clamped_end + 1 < line_count {
95 // Case 1: Deleting non-last lines - delete through newline to next line
96 delete_start = reovim_kernel::api::v1::Position::new(start.line, 0);
97 delete_end = reovim_kernel::api::v1::Position::new(clamped_end + 1, 0);
98 // deleted_text matches what we're deleting: "line\n"
99 deleted_text.clone_from(®ister_text);
100 } else if start.line > 0 {
101 // Case 2: Deleting last line(s) but not all
102 // Include the preceding newline (from end of previous line)
103 let prev_line_len = buffer.line(start.line - 1).map_or(0, |l| l.chars().count());
104 delete_start = reovim_kernel::api::v1::Position::new(start.line - 1, prev_line_len);
105 let last_line_char_len = buffer.line(clamped_end).map_or(0, |l| l.chars().count());
106 delete_end = reovim_kernel::api::v1::Position::new(clamped_end, last_line_char_len);
107 // Build deleted_text as "\nline" (preceding newline + content, no trailing newline)
108 // This matches what we're actually deleting for correct undo
109 for line_idx in start.line..=clamped_end {
110 deleted_text.push('\n');
111 if let Some(line) = buffer.line(line_idx) {
112 deleted_text.push_str(line);
113 }
114 }
115 } else {
116 // Case 3: Deleting all lines (start.line == 0 and clamped_end is last line)
117 delete_start = reovim_kernel::api::v1::Position::new(0, 0);
118 let last_line_char_len = buffer.line(clamped_end).map_or(0, |l| l.chars().count());
119 delete_end = reovim_kernel::api::v1::Position::new(clamped_end, last_line_char_len);
120 // deleted_text is just the content (no newlines - single line)
121 if let Some(line) = buffer.line(clamped_end) {
122 deleted_text.push_str(line);
123 }
124 }
125
126 // Track which case for cursor positioning
127 let is_deleting_last_line = (clamped_end + 1 >= line_count) && start.line > 0;
128
129 // Store in register as linewise (handles +/* via ClipboardProvider)
130 let content = RegisterContent::linewise(register_text);
131 registers::store_and_sync(ctx.kernel, ctx.registers, ctx.register, &content);
132 registers::push_to_history(ctx.clipboard_history, &content);
133
134 // Use cursor position from context (passed from caller who has window access)
135 let cursor_before = ctx.cursor_position;
136
137 // Delete entire lines
138 buffer.delete_range(delete_start, delete_end);
139
140 // Cursor positioning after linewise delete follows Vim behavior:
141 //
142 // Case 1 (delete non-last lines): Cursor at column 0 of the line that
143 // takes the place of the deleted lines (i.e., the first remaining line).
144 //
145 // Case 2 (delete last lines but not all): Cursor at the last valid column
146 // of the new last line, since there's no line below to move to.
147 //
148 // Case 3 (delete all lines): Buffer is empty, cursor at (0, 0).
149 let line_count = buffer.line_count();
150 let final_line = start.line.min(line_count.saturating_sub(1));
151 let final_col = if is_deleting_last_line {
152 // Case 2: Cursor at last valid column of the new last line
153 let line_len = buffer.line_len(final_line).unwrap_or(0);
154 if line_len == 0 {
155 0
156 } else {
157 line_len.saturating_sub(1)
158 }
159 } else {
160 // Case 1: Cursor at column 0
161 0
162 };
163 // Cursor position after delete — used for both undo tracking and
164 // communicating desired cursor back to execute_operator (#552)
165 let cursor_after = reovim_kernel::api::v1::Position::new(final_line, final_col);
166 ctx.cursor_after = Some(cursor_after);
167
168 // Record edit for undo
169 if let Some(undo_registry) = ctx.kernel.services.get::<UndoProviderRegistry>()
170 && let Some(undo_provider) = undo_registry.get(&UndoKey::Buffer)
171 {
172 let edit = Edit::Delete {
173 position: delete_start,
174 text: deleted_text.clone(),
175 };
176 undo_provider.record(ctx.buffer_id, vec![edit], cursor_before, cursor_after);
177 }
178 } else {
179 // Characterwise deletion
180 if start.line == end.line {
181 // Single line deletion
182 if let Some(line) = buffer.line(start.line) {
183 let char_len = line.chars().count();
184 let start_col = start.column.min(char_len);
185 let end_col = end.column.min(char_len);
186 if start_col < end_col {
187 let start_byte = char_col_to_byte(line, start_col);
188 let end_byte = char_col_to_byte(line, end_col);
189 deleted_text.push_str(&line[start_byte..end_byte]);
190 }
191 }
192 } else {
193 // Multi-line deletion
194 for line_idx in start.line..=end.line {
195 if let Some(line) = buffer.line(line_idx) {
196 if line_idx == start.line {
197 let char_len = line.chars().count();
198 let start_col = start.column.min(char_len);
199 let start_byte = char_col_to_byte(line, start_col);
200 deleted_text.push_str(&line[start_byte..]);
201 deleted_text.push('\n');
202 } else if line_idx == end.line {
203 let char_len = line.chars().count();
204 let end_col = end.column.min(char_len);
205 let end_byte = char_col_to_byte(line, end_col);
206 deleted_text.push_str(&line[..end_byte]);
207 } else {
208 deleted_text.push_str(line);
209 deleted_text.push('\n');
210 }
211 }
212 }
213 }
214
215 // Store in register as characterwise (handles +/* via ClipboardProvider)
216 let content = RegisterContent::characterwise(deleted_text.clone());
217 registers::store_and_sync(ctx.kernel, ctx.registers, ctx.register, &content);
218 registers::push_to_history(ctx.clipboard_history, &content);
219
220 // Use cursor position from context (passed from caller who has window access)
221 let cursor_before = ctx.cursor_position;
222
223 // Delete the text from buffer
224 buffer.delete_range(start, end);
225
226 // Cursor after characterwise delete is at the start position (#552)
227 let cursor_after = start;
228 ctx.cursor_after = Some(cursor_after);
229
230 // Record edit for undo
231 if let Some(undo_registry) = ctx.kernel.services.get::<UndoProviderRegistry>()
232 && let Some(undo_provider) = undo_registry.get(&UndoKey::Buffer)
233 {
234 let edit = Edit::Delete {
235 position: start,
236 text: deleted_text,
237 };
238 undo_provider.record(ctx.buffer_id, vec![edit], cursor_before, cursor_after);
239 }
240 }
241
242 drop(buffer);
243 Ok(())
244 }
245
246 fn is_linewise(&self) -> bool {
247 false // Default; actual linewise-ness is determined by motion
248 }
249
250 fn is_text_modifying(&self) -> bool {
251 true
252 }
253}