hjkl_buffer/edit.rs
1//! Edit operations on [`crate::View`].
2//!
3//! Every mutation goes through [`View::apply_edit`] and returns
4//! the inverse `Edit` so the host can build an undo stack without
5//! snapshotting the whole buffer. Cursor follows edits the way vim
6//! does: insertions land the cursor at the end of the inserted
7//! text; deletions clamp the cursor to the deletion start.
8
9use crate::buffer::{pos_to_char_idx, rope_line_char_count};
10use crate::{Position, View};
11
12/// Granularity of a delete; preserved through undo so a linewise
13/// delete doesn't come back as a charwise one.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum MotionKind {
16 /// Charwise — `[start, end)` byte range, possibly wrapping rows.
17 Char,
18 /// Linewise — whole rows from `start.row..=end.row`. Endpoint
19 /// columns are ignored.
20 Line,
21 /// Blockwise — rectangle `[start.row..=end.row] × [min_col..=max_col]`.
22 Block,
23}
24
25/// One unit of buffer mutation. Constructed by the caller (vim
26/// engine, ex command, …) and handed to [`View::apply_edit`].
27///
28/// ## Invariants
29///
30/// All `Position` arguments must satisfy the bounds documented on
31/// [`Position`] before the edit is applied. Out-of-bounds positions
32/// are clamped by [`View::clamp_position`] inside
33/// [`View::apply_edit`]; if the clamped form changes the edit's
34/// meaning the result is implementation-defined.
35///
36/// See [`View::apply_edit`] for post-conditions that hold after
37/// every variant.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Edit {
40 /// Insert one char at `at`. Cursor lands one position past it.
41 ///
42 /// `at` must be a valid [`Position`]. `ch` must be a single Unicode
43 /// scalar. Multi-grapheme content must use [`Edit::InsertStr`].
44 InsertChar { at: Position, ch: char },
45 /// Insert `text` (possibly multi-line) at `at`. Cursor lands at
46 /// the end of the inserted content.
47 ///
48 /// `at` must be a valid [`Position`]. `text` may contain `\n` — the
49 /// buffer splits on newline. CR (`\r`) is preserved as-is; the host
50 /// is responsible for CRLF normalization before insert.
51 InsertStr { at: Position, text: String },
52 /// Delete `[start, end)` with the given kind.
53 ///
54 /// `start <= end` in document order. [`MotionKind`] controls whether
55 /// trailing newlines are consumed:
56 ///
57 /// - [`MotionKind::Char`][]: byte-precise; preserves enclosing newlines.
58 /// - [`MotionKind::Line`][]: whole rows from `start.row..=end.row`;
59 /// endpoint columns are ignored.
60 /// - [`MotionKind::Block`][]: rectangle
61 /// `[start.row..=end.row] × [min_col..=max_col]`.
62 DeleteRange {
63 start: Position,
64 end: Position,
65 kind: MotionKind,
66 },
67 /// `J` (`with_space = true`) / `gJ` (`false`) — fold `count` rows
68 /// after `row` into `row`.
69 ///
70 /// `row + count - 1` must be a valid row. `count >= 1`.
71 JoinLines {
72 row: usize,
73 count: usize,
74 with_space: bool,
75 },
76 /// Inverse of `JoinLines`. Splits `row` back at each char column
77 /// in `cols`.
78 ///
79 /// `inserted_spaces[i]` records whether the join that produced
80 /// `cols[i]` ACTUALLY inserted a space there — NOT the caller's
81 /// `with_space` intent passed to `JoinLines`, which is uniform for
82 /// the whole (possibly multi-join) batch while the per-join outcome
83 /// is not: `do_join_lines` skips the space whenever either side of
84 /// that specific join is empty. A single bool here (matching the
85 /// original, uniform `with_space` intent) can't tell those joins
86 /// apart from ones that DID insert a space, so `do_split_lines` would
87 /// misidentify — and delete — an unrelated, legitimately-present
88 /// space character that happens to sit at that col (audit-r2 fix 6).
89 /// Parallel to `cols`.
90 SplitLines {
91 row: usize,
92 cols: Vec<usize>,
93 inserted_spaces: Vec<bool>,
94 },
95 /// Replace `[start, end)` with `with` (charwise, may span rows).
96 ///
97 /// Same constraints as [`Edit::DeleteRange`] with
98 /// [`MotionKind::Char`] for the deleted range, plus the insert
99 /// constraints from [`Edit::InsertStr`] for `with`.
100 Replace {
101 start: Position,
102 end: Position,
103 with: String,
104 },
105 /// Insert one chunk per row, each at `(at.row + i, at.col)`.
106 /// Inverse of a blockwise delete; preserves the rectangle even
107 /// when rows are ragged shorter than `at.col`.
108 InsertBlock { at: Position, chunks: Vec<String> },
109 /// Inverse of [`Edit::InsertBlock`]. Removes `widths[i]` chars
110 /// starting at `(at.row + i, at.col)`, plus `pads[i]` more chars
111 /// immediately BEFORE `at.col` on that row. Carrying widths instead
112 /// of recomputing means a ragged-row block delete round-trips
113 /// exactly.
114 ///
115 /// `pads` exists because `do_insert_block` space-pads a row that's
116 /// shorter than `at.col` before splicing the chunk in, so that the
117 /// chunk lands at the intended column; without recording that pad
118 /// width here too, this inverse would remove the chunk but leave the
119 /// padding behind (audit-r2 fix 6). `pads[i]` is always `0` for a row
120 /// that didn't need padding. `DeleteBlockChunks` only ever appears as
121 /// `InsertBlock`'s inverse (never constructed by a "forward" edit —
122 /// see `do_delete_range`'s `MotionKind::Block` arm, which builds its
123 /// own inverse `InsertBlock` directly via `rope_cut_chars`), so this
124 /// field has no bearing on any other call site's semantics.
125 DeleteBlockChunks {
126 at: Position,
127 widths: Vec<usize>,
128 pads: Vec<usize>,
129 },
130}
131
132impl View {
133 /// Apply `edit` and return the inverse. Pushing the inverse back
134 /// through `apply_edit` restores the previous state, making it the
135 /// single hook for undo-stack integration.
136 ///
137 /// `apply_edit` is the **only** way to mutate buffer text.
138 ///
139 /// ## Post-conditions
140 ///
141 /// After any [`Edit`] variant:
142 ///
143 /// - [`View::dirty_gen`] is incremented exactly once.
144 /// - The cursor is repositioned to a sensible place for the edit kind
145 /// (insert lands past the inserted content; delete lands at the
146 /// start). Callers that need to override the new cursor must call
147 /// [`View::set_cursor`] immediately after.
148 /// - All [`Position`] values the caller held from before the edit may
149 /// be invalid. Re-derive from row / col deltas; do not cache.
150 pub fn apply_edit(&mut self, edit: Edit) -> Edit {
151 match edit {
152 Edit::InsertChar { at, ch } => {
153 // Encode in place — a per-keystroke `String` allocation for a
154 // single char is pure overhead on the insert-mode hot path.
155 let mut buf = [0u8; 4];
156 self.do_insert_str(at, ch.encode_utf8(&mut buf))
157 }
158 Edit::InsertStr { at, text } => self.do_insert_str(at, &text),
159 Edit::DeleteRange { start, end, kind } => self.do_delete_range(start, end, kind),
160 Edit::JoinLines {
161 row,
162 count,
163 with_space,
164 } => self.do_join_lines(row, count, with_space),
165 Edit::SplitLines {
166 row,
167 cols,
168 inserted_spaces,
169 } => self.do_split_lines(row, &cols, &inserted_spaces),
170 Edit::Replace { start, end, with } => self.do_replace(start, end, &with),
171 Edit::InsertBlock { at, chunks } => self.do_insert_block(at, chunks),
172 Edit::DeleteBlockChunks { at, widths, pads } => {
173 self.do_delete_block_chunks(at, &widths, &pads)
174 }
175 }
176 }
177
178 fn do_insert_block(&mut self, at: Position, chunks: Vec<String>) -> Edit {
179 let mut widths: Vec<usize> = Vec::with_capacity(chunks.len());
180 let mut pads: Vec<usize> = Vec::with_capacity(chunks.len());
181 for (i, chunk) in chunks.into_iter().enumerate() {
182 let row = at.row + i;
183 // Pad short rows with spaces so the column position exists
184 // before splicing — same semantics as the old Vec<String> impl.
185 // Recorded in `pads` so the returned DeleteBlockChunks inverse
186 // can remove this padding too, not just the chunk (audit-r2
187 // fix 6): otherwise undoing an InsertBlock that padded a
188 // ragged row leaves the padding behind.
189 let mut pad = 0usize;
190 {
191 let mut c = self.content.lock().unwrap();
192 let n = c.text.len_lines();
193 if row < n {
194 let lc = rope_line_char_count(&c.text, row);
195 if lc < at.col {
196 pad = at.col - lc;
197 let insert_char_idx = pos_to_char_idx(&c.text, row, lc);
198 c.text.insert(insert_char_idx, &" ".repeat(pad));
199 }
200 }
201 }
202 pads.push(pad);
203 widths.push(chunk.chars().count());
204 // Insert chunk at (row, at.col).
205 {
206 let mut c = self.content.lock().unwrap();
207 let n = c.text.len_lines();
208 if row < n {
209 let char_idx = pos_to_char_idx(&c.text, row, at.col);
210 c.text.insert(char_idx, &chunk);
211 }
212 }
213 }
214 self.dirty_gen_bump();
215 self.set_cursor(at);
216 Edit::DeleteBlockChunks { at, widths, pads }
217 }
218
219 fn do_delete_block_chunks(&mut self, at: Position, widths: &[usize], pads: &[usize]) -> Edit {
220 let mut chunks: Vec<String> = Vec::with_capacity(widths.len());
221 for (i, &w) in widths.iter().enumerate() {
222 let pad = pads.get(i).copied().unwrap_or(0);
223 let row = at.row + i;
224 let removed = {
225 let mut c = self.content.lock().unwrap();
226 let n = c.text.len_lines();
227 if row >= n {
228 String::new()
229 } else {
230 let lc = rope_line_char_count(&c.text, row);
231 // Remove the pad (immediately before at.col) together
232 // with the chunk (at.col..at.col+w) in one contiguous
233 // span — do_insert_block always places them adjacently.
234 let col_start = at.col.saturating_sub(pad).min(lc);
235 let col_end = (at.col + w).min(lc);
236 if col_start >= col_end {
237 String::new()
238 } else {
239 let char_start = pos_to_char_idx(&c.text, row, col_start);
240 let char_end = pos_to_char_idx(&c.text, row, col_end);
241 let removed_span: String = c.text.slice(char_start..char_end).to_string();
242 c.text.remove(char_start..char_end);
243 // Discard the pad portion from the returned chunk —
244 // it's regenerated automatically by do_insert_block
245 // if this inverse is itself later undone (redo).
246 let pad_end = at.col.min(lc);
247 let pad_len = pad_end.saturating_sub(col_start);
248 removed_span.chars().skip(pad_len).collect()
249 }
250 }
251 };
252 chunks.push(removed);
253 }
254 self.dirty_gen_bump();
255 self.set_cursor(at);
256 Edit::InsertBlock { at, chunks }
257 }
258
259 fn do_insert_str(&mut self, at: Position, text: &str) -> Edit {
260 let normalised = self.clamp_position(at);
261 let inserted_chars = text.chars().count();
262 let inserted_lines = text.split('\n').count();
263 let end = if inserted_lines > 1 {
264 let last_chars = text.rsplit('\n').next().unwrap_or("").chars().count();
265 Position::new(normalised.row + inserted_lines - 1, last_chars)
266 } else {
267 Position::new(normalised.row, normalised.col + inserted_chars)
268 };
269 {
270 let mut c = self.content.lock().unwrap();
271 let char_idx = pos_to_char_idx(&c.text, normalised.row, normalised.col);
272 c.text.insert(char_idx, text);
273 }
274 self.dirty_gen_bump();
275 self.set_cursor(end);
276 Edit::DeleteRange {
277 start: normalised,
278 end,
279 kind: MotionKind::Char,
280 }
281 }
282
283 fn do_delete_range(&mut self, start: Position, end: Position, kind: MotionKind) -> Edit {
284 let (start, end) = order(start, end);
285 match kind {
286 MotionKind::Char => {
287 let removed = {
288 let mut c = self.content.lock().unwrap();
289 rope_cut_chars(&mut c.text, start, end)
290 };
291 self.dirty_gen_bump();
292 self.set_cursor(start);
293 Edit::InsertStr {
294 at: start,
295 text: removed,
296 }
297 }
298 MotionKind::Line => {
299 let (_removed_text, inverse_at, inverse_text, new_cursor) = {
300 let mut c = self.content.lock().unwrap();
301 let n = c.text.len_lines();
302 // Clamp BOTH endpoints. An unclamped `lo` past the last
303 // row underflows the `hi - lo + 1` capacity below and
304 // panics `line_to_char(lo)`.
305 let lo = start.row.min(n.saturating_sub(1));
306 let hi = end.row.min(n.saturating_sub(1));
307
308 // Collect the removed rows as a joined string (needed for inverse).
309 let mut removed_lines: Vec<String> = Vec::with_capacity(hi - lo + 1);
310 for r in lo..=hi {
311 removed_lines.push(rope_line_str_locked(&c.text, r));
312 }
313
314 // Compute char range to remove.
315 // When hi is not the last row, we take [line_to_char(lo), line_to_char(hi+1)).
316 // When hi IS the last row and lo>0, we also remove the '\n' that ends
317 // row lo-1 so we don't leave a trailing newline orphan.
318 // When removing everything (lo==0, hi==last), take [0, len_chars()).
319 let (remove_start, remove_end) = if hi + 1 < n {
320 // Normal case: rows lo..=hi followed by more rows.
321 // char range = [line_to_char(lo), line_to_char(hi+1))
322 (c.text.line_to_char(lo), c.text.line_to_char(hi + 1))
323 } else if lo > 0 {
324 // hi is the last row AND there are rows before lo.
325 // Remove the '\n' that ended row lo-1 as well.
326 (c.text.line_to_char(lo) - 1, c.text.len_chars())
327 } else {
328 // Removing everything (lo==0, hi==last).
329 (0, c.text.len_chars())
330 };
331
332 // Capture the exact removed span BEFORE removing. The
333 // inverse must re-insert byte-for-byte what was cut, and
334 // ropey's unicode line splitting separates on `\r`,
335 // `\r\n`, U+000B, U+000C, U+0085, U+2028 and U+2029 as
336 // well as `\n` — a join rebuilt with `'\n'` would rewrite
337 // those separators and undo would restore a different
338 // buffer. (`removed_joined` below keeps the `\n`-joined
339 // register form, which vim-style registers expect.)
340 let removed_span: String = if remove_start == remove_end {
341 String::new()
342 } else {
343 c.text.slice(remove_start..remove_end).to_string()
344 };
345 c.text.remove(remove_start..remove_end);
346 // ropey guarantees len_lines() >= 1 (empty rope = 1 line).
347
348 let n2 = c.text.len_lines();
349 let target_row = lo.min(n2.saturating_sub(1));
350 let removed_joined = if remove_start == remove_end {
351 // True no-op: zero chars removed (linewise delete on
352 // an already-empty buffer — the only shape where the
353 // range collapses). Joining the lone empty row and
354 // appending the '\n' terminator would fabricate "\n"
355 // out of nothing; callers then record that phantom
356 // text into the unnamed register, where vim leaves
357 // registers untouched (#280 follow-up).
358 String::new()
359 } else {
360 let mut s = removed_lines.join("\n");
361 // Add trailing '\n' so the register form reads like
362 // vim's `dd` register (lines joined with '\n').
363 s.push('\n');
364 s
365 };
366 // The inverse text is the exact removed span, so its
367 // shape follows the three `(remove_start, remove_end)`
368 // branches above:
369 //
370 // - `lo > 0 && hi + 1 == n` (last-row delete with rows
371 // above): the removed span starts with the separator
372 // that ended row lo-1 (its trailing `\n` for a CRLF
373 // row — the `\r` stays in the surviving row), so the
374 // inverse is a leading-separator insert at the end of
375 // the new last row (row lo-1). The trailing-separator
376 // form targeted at (lo, 0) would reference a row that
377 // no longer exists.
378 // - `lo == 0 && hi + 1 == n` (whole buffer): the removed
379 // span is the entire rope; re-inserting it at (0, 0)
380 // restores it exactly, separators included.
381 // - `hi + 1 < n` (rows survive below): the surviving rows
382 // must be pushed back down, so inserting the span at
383 // (lo, 0) is exact.
384 let (inverse_at, inverse_text) = if lo > 0 && hi + 1 == n {
385 let last_row_chars = c.text.line(lo - 1).len_chars();
386 (Position::new(lo - 1, last_row_chars), removed_span)
387 } else if hi + 1 == n {
388 (Position::new(0, 0), removed_span)
389 } else {
390 (Position::new(lo, 0), removed_span)
391 };
392 (
393 removed_joined,
394 inverse_at,
395 inverse_text,
396 Position::new(target_row, 0),
397 )
398 };
399 self.dirty_gen_bump();
400 self.set_cursor(new_cursor);
401 Edit::InsertStr {
402 at: inverse_at,
403 text: inverse_text,
404 }
405 }
406 MotionKind::Block => {
407 let (left, right) = (start.col.min(end.col), start.col.max(end.col));
408 let mut chunks: Vec<String> = Vec::with_capacity(end.row - start.row + 1);
409 for row in start.row..=end.row {
410 let removed = {
411 let mut c = self.content.lock().unwrap();
412 let n = c.text.len_lines();
413 if row >= n {
414 String::new()
415 } else {
416 let row_start_pos = Position::new(row, left);
417 let row_end_pos = Position::new(row, right + 1);
418 rope_cut_chars(&mut c.text, row_start_pos, row_end_pos)
419 }
420 };
421 chunks.push(removed);
422 }
423 self.dirty_gen_bump();
424 self.set_cursor(Position::new(start.row, left));
425 Edit::InsertBlock {
426 at: Position::new(start.row, left),
427 chunks,
428 }
429 }
430 }
431 }
432
433 fn do_join_lines(&mut self, row: usize, count: usize, with_space: bool) -> Edit {
434 let count = count.max(1);
435 let (actual_row, split_cols, inserted_spaces) = {
436 let mut c = self.content.lock().unwrap();
437 let n = c.text.len_lines();
438 let row = row.min(n.saturating_sub(1));
439 let mut split_cols: Vec<usize> = Vec::with_capacity(count);
440 // Per-join outcome (did THIS join actually insert a space),
441 // NOT the uniform `with_space` intent — see the field doc on
442 // `Edit::SplitLines::inserted_spaces` (audit-r2 fix 6).
443 let mut inserted_spaces: Vec<bool> = Vec::with_capacity(count);
444
445 for _ in 0..count {
446 let n2 = c.text.len_lines();
447 if row + 1 >= n2 {
448 break;
449 }
450 // Current length of row (in chars, sans '\n').
451 let join_col = rope_line_char_count(&c.text, row);
452 split_cols.push(join_col);
453
454 // The '\n' that ends row is at char index line_to_char(row) + join_col.
455 let newline_char = c.text.line_to_char(row) + join_col;
456 // Remove the '\n'.
457 c.text.remove(newline_char..newline_char + 1);
458
459 // Now row and (what was row+1) are merged. Insert space if needed.
460 let mut this_inserted_space = false;
461 if with_space {
462 // After removing '\n', the join_col chars of original row are
463 // followed immediately by the next row's content.
464 // Insert space only if both sides are non-empty.
465 let merged_len = rope_line_char_count(&c.text, row);
466 let prefix_empty = join_col == 0;
467 let suffix_empty = join_col >= merged_len;
468 if !prefix_empty && !suffix_empty {
469 // Insert space at newline_char (now the join point).
470 c.text.insert_char(newline_char, ' ');
471 this_inserted_space = true;
472 // Adjust future split_cols: the space shifts subsequent
473 // join points by 1, but split_cols[i] is the char count
474 // of the original row *before* this join, which doesn't
475 // need adjustment — the SplitLines inverse uses it to
476 // split the joined line at the right position.
477 }
478 }
479 inserted_spaces.push(this_inserted_space);
480 }
481 (row, split_cols, inserted_spaces)
482 };
483 self.dirty_gen_bump();
484 self.set_cursor(Position::new(actual_row, 0));
485 Edit::SplitLines {
486 row: actual_row,
487 cols: split_cols,
488 inserted_spaces,
489 }
490 }
491
492 fn do_split_lines(&mut self, row: usize, cols: &[usize], inserted_spaces: &[bool]) -> Edit {
493 let actual_row = {
494 let mut c = self.content.lock().unwrap();
495 let n = c.text.len_lines();
496 let row = row.min(n.saturating_sub(1));
497
498 // Split right-to-left so each col still indexes into the
499 // original char positions on the surviving prefix.
500 for (idx, &col) in cols.iter().enumerate().rev() {
501 let mut split_col = col;
502 // Per-col: did the ORIGINAL join at this position actually
503 // insert a space? (Not a uniform flag — see the
504 // `Edit::SplitLines` field doc, audit-r2 fix 6.)
505 if inserted_spaces.get(idx).copied().unwrap_or(false) {
506 // The original join inserted a space at `col`, so the
507 // current content has a space at position `col` which
508 // we need to remove before inserting the '\n'.
509 let lc = rope_line_char_count(&c.text, row);
510 if split_col < lc {
511 let space_char_idx = c.text.line_to_char(row) + split_col;
512 // Check if char at split_col is a space.
513 let ch = c.text.char(space_char_idx);
514 if ch == ' ' {
515 c.text.remove(space_char_idx..space_char_idx + 1);
516 }
517 }
518 // split_col stays the same — the '\n' goes at the same
519 // position (we removed the space, so col is still correct).
520 } else {
521 let lc = rope_line_char_count(&c.text, row);
522 split_col = split_col.min(lc);
523 }
524
525 // Insert '\n' at (row, split_col).
526 let char_idx = c.text.line_to_char(row) + split_col;
527 c.text.insert_char(char_idx, '\n');
528 }
529
530 row
531 };
532 self.dirty_gen_bump();
533 self.set_cursor(Position::new(actual_row, 0));
534 Edit::JoinLines {
535 row: actual_row,
536 count: cols.len(),
537 // Reconstructing a single with_space intent for redo: true iff
538 // ANY col in this batch actually inserted a space. When none
539 // did, redoing with with_space=false reproduces the identical
540 // result anyway (do_join_lines would skip every space here
541 // too), so this is a safe, behavior-preserving collapse.
542 with_space: inserted_spaces.iter().any(|&b| b),
543 }
544 }
545
546 fn do_replace(&mut self, start: Position, end: Position, with: &str) -> Edit {
547 let (start, end) = order(start, end);
548 let removed = {
549 let mut c = self.content.lock().unwrap();
550 rope_cut_chars(&mut c.text, start, end)
551 };
552 let normalised = self.clamp_position(start);
553 let inserted_chars = with.chars().count();
554 let inserted_lines = with.split('\n').count();
555 let new_end = if inserted_lines > 1 {
556 let last_chars = with.rsplit('\n').next().unwrap_or("").chars().count();
557 Position::new(normalised.row + inserted_lines - 1, last_chars)
558 } else {
559 Position::new(normalised.row, normalised.col + inserted_chars)
560 };
561 {
562 let mut c = self.content.lock().unwrap();
563 let char_idx = pos_to_char_idx(&c.text, normalised.row, normalised.col);
564 c.text.insert(char_idx, with);
565 }
566 self.dirty_gen_bump();
567 self.set_cursor(new_end);
568 Edit::Replace {
569 start: normalised,
570 end: new_end,
571 with: removed,
572 }
573 }
574}
575
576// ── Internals — char surgery (free functions over &mut ropey::Rope) ──
577
578/// Get logical line `row` as a `String`, stripping the FULL line separator.
579/// ropey's `line()` includes the separator, and under the default
580/// `unicode_lines` splitting that is `\n`, `\r\n`, `\r`, U+000B, U+000C,
581/// U+0085, U+2028 and U+2029 — stripping only a trailing `'\n'` left the
582/// others embedded in the content, and the linewise-delete inverse then
583/// carried them plus a pushed `'\n'`: a phantom extra line break.
584/// Identical to `rope_line_str` but takes a lock guard's rope by ref
585/// (avoids re-importing the pub(crate) helper from buffer.rs inside this module).
586fn rope_line_str_locked(rope: &ropey::Rope, row: usize) -> String {
587 let start = rope.line_to_byte(row);
588 rope.byte_slice(start..rope_line_content_end_locked(rope, row))
589 .to_string()
590}
591
592/// Absolute byte index where row `row`'s content ends — the first byte of the
593/// separator ropey split on, or `len_bytes()` for the final row. Mirrors
594/// `buffer::rope_line_content_end` exactly: the separators are 1–3 bytes wide
595/// and `line_to_byte(row + 1)` points just past one, so stepping back a single
596/// byte lands *inside* a multi-byte separator; flooring to the enclosing char
597/// start snaps to the separator's first byte, which is the end of this row's
598/// content. `\r\n` is unaffected — `\n` begins a char, the floor is the
599/// identity, and a CRLF row keeps its trailing `\r` (the same rule
600/// `rope_line_str` applies).
601fn rope_line_content_end_locked(rope: &ropey::Rope, row: usize) -> usize {
602 if row + 1 >= rope.len_lines() {
603 return rope.len_bytes();
604 }
605 let step_back = rope.line_to_byte(row + 1).saturating_sub(1);
606 crate::buffer::floor_char_boundary(rope, step_back)
607}
608
609/// Remove `[start, end)` (charwise) from the rope and return the
610/// removed text as a `String` (with `\n` between rows).
611///
612/// `start` and `end` carry `(row, col)` where `col` is a char index
613/// within the line. The function converts them to absolute char indices,
614/// removes the range, and returns the removed text.
615fn rope_cut_chars(rope: &mut ropey::Rope, start: Position, end: Position) -> String {
616 let (start, end) = order(start, end);
617 let n = rope.len_lines();
618
619 // Clamp to rope bounds.
620 let start_row = start.row.min(n.saturating_sub(1));
621 let start_col = {
622 let lc = crate::buffer::rope_line_char_count(rope, start_row);
623 start.col.min(lc)
624 };
625 let end_row = end.row.min(n.saturating_sub(1));
626 let end_col = {
627 let lc = crate::buffer::rope_line_char_count(rope, end_row);
628 end.col.min(lc)
629 };
630
631 let char_start = rope.line_to_char(start_row) + start_col;
632 let char_end = rope.line_to_char(end_row) + end_col;
633
634 if char_start >= char_end {
635 return String::new();
636 }
637
638 let removed: String = rope.slice(char_start..char_end).to_string();
639 rope.remove(char_start..char_end);
640 removed
641}
642
643fn order(a: Position, b: Position) -> (Position, Position) {
644 if a <= b { (a, b) } else { (b, a) }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use crate::buffer::rope_line_str;
651
652 fn round_trip_check(initial: &str, edit: Edit) {
653 let mut b = View::from_str(initial);
654 let snapshot_before = b.as_string();
655 let inverse = b.apply_edit(edit);
656 b.apply_edit(inverse);
657 assert_eq!(b.as_string(), snapshot_before);
658 }
659
660 #[test]
661 fn insert_char_round_trip() {
662 round_trip_check(
663 "abc",
664 Edit::InsertChar {
665 at: Position::new(0, 1),
666 ch: 'X',
667 },
668 );
669 }
670
671 #[test]
672 fn insert_str_multiline_round_trip() {
673 round_trip_check(
674 "abc\ndef",
675 Edit::InsertStr {
676 at: Position::new(0, 2),
677 text: "X\nY\nZ".into(),
678 },
679 );
680 }
681
682 #[test]
683 fn delete_charwise_single_row_round_trip() {
684 round_trip_check(
685 "alpha bravo charlie",
686 Edit::DeleteRange {
687 start: Position::new(0, 6),
688 end: Position::new(0, 11),
689 kind: MotionKind::Char,
690 },
691 );
692 }
693
694 #[test]
695 fn delete_charwise_multi_row_round_trip() {
696 round_trip_check(
697 "row0\nrow1\nrow2",
698 Edit::DeleteRange {
699 start: Position::new(0, 2),
700 end: Position::new(2, 2),
701 kind: MotionKind::Char,
702 },
703 );
704 }
705
706 #[test]
707 fn delete_linewise_round_trip() {
708 round_trip_check(
709 "a\nb\nc\nd",
710 Edit::DeleteRange {
711 start: Position::new(1, 0),
712 end: Position::new(2, 0),
713 kind: MotionKind::Line,
714 },
715 );
716 }
717
718 #[test]
719 fn delete_blockwise_round_trip() {
720 round_trip_check(
721 "abcdef\nghijkl\nmnopqr",
722 Edit::DeleteRange {
723 start: Position::new(0, 1),
724 end: Position::new(2, 3),
725 kind: MotionKind::Block,
726 },
727 );
728 }
729
730 #[test]
731 fn join_lines_with_space_round_trip() {
732 round_trip_check(
733 "first\nsecond\nthird",
734 Edit::JoinLines {
735 row: 0,
736 count: 2,
737 with_space: true,
738 },
739 );
740 }
741
742 #[test]
743 fn join_lines_no_space_round_trip() {
744 round_trip_check(
745 "first\nsecond",
746 Edit::JoinLines {
747 row: 0,
748 count: 1,
749 with_space: false,
750 },
751 );
752 }
753
754 #[test]
755 fn replace_round_trip() {
756 round_trip_check(
757 "foo bar baz",
758 Edit::Replace {
759 start: Position::new(0, 4),
760 end: Position::new(0, 7),
761 with: "QUUX".into(),
762 },
763 );
764 }
765
766 // ── Block-op / split-lines round trips (audit-r2 fix 6) ──────────────────
767 //
768 // These inverses are dead today — nothing currently chains
769 // apply(edit) -> apply(inverse) for InsertBlock/DeleteBlockChunks or a
770 // JoinLines/SplitLines pair with mixed per-join outcomes — but the
771 // contract (`apply_edit` returns an inverse that restores the pre-edit
772 // text exactly) must hold the day something does.
773
774 #[test]
775 fn insert_block_round_trip_uniform_rows() {
776 round_trip_check(
777 "ab\ncd\nef",
778 Edit::InsertBlock {
779 at: Position::new(0, 1),
780 chunks: vec!["X".into(), "Y".into(), "Z".into()],
781 },
782 );
783 }
784
785 /// `do_insert_block` space-pads a row shorter than `at.col` before
786 /// splicing the chunk in. Round-tripping must remove that padding too,
787 /// not just the chunk — pre-fix, `DeleteBlockChunks`'s inverse only
788 /// carried the chunk width, leaving the padding behind.
789 #[test]
790 fn insert_block_round_trip_pads_short_row() {
791 // Row 1 ("x") is only 1 char; at.col=3 needs 2 chars of padding
792 // before the "Q" chunk lands.
793 round_trip_check(
794 "abcd\nx\nefgh",
795 Edit::InsertBlock {
796 at: Position::new(0, 3),
797 chunks: vec!["P".into(), "Q".into(), "R".into()],
798 },
799 );
800 }
801
802 /// Same as above but EVERY row needs padding, and by different amounts.
803 #[test]
804 fn insert_block_round_trip_ragged_pads_vary_per_row() {
805 round_trip_check(
806 "\na\nab\nabc",
807 Edit::InsertBlock {
808 at: Position::new(0, 3),
809 chunks: vec!["W".into(), "X".into(), "Y".into(), "Z".into()],
810 },
811 );
812 }
813
814 #[test]
815 fn delete_block_chunks_round_trip() {
816 // Constructed directly (DeleteBlockChunks only ever appears in
817 // practice as InsertBlock's returned inverse — see the variant's
818 // doc comment) to round-trip the OTHER direction: does re-inserting
819 // (InsertBlock) restore what DeleteBlockChunks removed?
820 round_trip_check(
821 "abcdef\nghijkl",
822 Edit::DeleteBlockChunks {
823 at: Position::new(0, 1),
824 widths: vec![2, 2],
825 pads: vec![0, 0],
826 },
827 );
828 }
829
830 /// Regression for the exact scenario fix 6 describes: a join with an
831 /// EMPTY prefix (row 0 is blank) skips inserting a space, but the
832 /// pulled-up row legitimately STARTS with its own, unrelated space.
833 /// Pre-fix, `SplitLines`'s single uniform `inserted_space` flag
834 /// couldn't tell "this join skipped the space" from "this join
835 /// inserted one", so splitting back mistook the legitimate leading
836 /// space for the (never-inserted) join space and ate it.
837 #[test]
838 fn join_then_split_empty_prefix_preserves_legitimate_leading_space() {
839 round_trip_check(
840 "\n bar",
841 Edit::JoinLines {
842 row: 0,
843 count: 1,
844 with_space: true,
845 },
846 );
847 }
848
849 /// Same failure mode from the empty-SUFFIX side: the row being joined
850 /// INTO legitimately ends with a space of its own, and the incoming
851 /// (pulled-up) row is empty, so the join skips inserting one.
852 #[test]
853 fn join_then_split_empty_suffix_preserves_legitimate_trailing_space() {
854 round_trip_check(
855 "foo \n",
856 Edit::JoinLines {
857 row: 0,
858 count: 1,
859 with_space: true,
860 },
861 );
862 }
863
864 /// count > 1 with an empty middle line mixes a skipped-space join and a
865 /// real one in the SAME batch — the scenario `content_edit_shape_tests`
866 /// (hjkl-engine) exercises for byte-exactness; here we check the
867 /// simpler round-trip-restores-original-text property instead.
868 #[test]
869 fn join_then_split_multi_count_mixed_spaces_round_trip() {
870 round_trip_check(
871 "foo\n\nbar",
872 Edit::JoinLines {
873 row: 0,
874 count: 2,
875 with_space: true,
876 },
877 );
878 }
879
880 /// Regression: a linewise delete whose START row lies past the last
881 /// buffer row used to underflow `hi - lo + 1` (capacity math) and panic
882 /// `line_to_char(lo)`. Both endpoints must clamp to the last row.
883 #[test]
884 fn delete_linewise_start_past_end_is_clamped() {
885 let mut b = View::from_str("a\nb\nc");
886 b.apply_edit(Edit::DeleteRange {
887 start: Position::new(10, 0),
888 end: Position::new(20, 0),
889 kind: MotionKind::Line,
890 });
891 // Clamps to the last row and removes it.
892 assert_eq!(b.as_string(), "a\nb");
893 }
894
895 #[test]
896 fn delete_clearing_buffer_keeps_one_empty_row() {
897 let mut b = View::from_str("only");
898 b.apply_edit(Edit::DeleteRange {
899 start: Position::new(0, 0),
900 end: Position::new(0, 0),
901 kind: MotionKind::Line,
902 });
903 assert_eq!(b.row_count(), 1);
904 assert_eq!(rope_line_str(&b.rope(), 0), "");
905 }
906
907 /// Regression (#280 follow-up): a linewise delete on an ALREADY-EMPTY
908 /// buffer removes zero chars, so the inverse must carry empty text.
909 /// The old code fabricated "\n" (joined the lone empty row and appended
910 /// a terminator), which callers then recorded into the unnamed register
911 /// — vim leaves registers untouched on a true no-op delete.
912 #[test]
913 fn noop_linewise_delete_on_empty_buffer_has_empty_inverse() {
914 let mut b = View::from_str("");
915 let inv = b.apply_edit(Edit::DeleteRange {
916 start: Position::new(0, 0),
917 end: Position::new(0, 0),
918 kind: MotionKind::Line,
919 });
920 match inv {
921 Edit::InsertStr { text, .. } => {
922 assert_eq!(text, "", "no-op delete must not fabricate \"\\n\"");
923 }
924 other => panic!("expected InsertStr inverse, got {other:?}"),
925 }
926 assert_eq!(b.row_count(), 1);
927 assert_eq!(rope_line_str(&b.rope(), 0), "");
928 }
929
930 /// Regression (non-`\n` line separators): ropey's unicode line splitting
931 /// treats `\r`, U+000B, U+000C, U+0085, U+2028 and U+2029 as line
932 /// separators too, and the linewise-delete inverse used to rebuild the
933 /// removed text from per-row strings joined with `'\n'` — fabricating a
934 /// phantom extra line break after a U+2028 separator (and rewriting `\r`
935 /// as `\n`). The inverse must be the exact removed span so undo restores
936 /// the buffer byte-for-byte.
937 #[test]
938 fn delete_linewise_inverse_is_exact_removed_span_for_non_nl_separators() {
939 for (initial, removed_span) in [
940 ("a\u{2028}b", "a\u{2028}"),
941 ("a\rb", "a\r"),
942 ("a\r\nb", "a\r\n"),
943 ] {
944 let mut b = View::from_str(initial);
945 let inv = b.apply_edit(Edit::DeleteRange {
946 start: Position::new(0, 0),
947 end: Position::new(0, 0),
948 kind: MotionKind::Line,
949 });
950 match &inv {
951 Edit::InsertStr { text, .. } => assert_eq!(
952 text.as_str(),
953 removed_span,
954 "inverse must be the exact removed span for {initial:?}"
955 ),
956 other => panic!("expected InsertStr inverse, got {other:?}"),
957 }
958 b.apply_edit(inv);
959 assert_eq!(b.as_string(), initial, "undo must restore {initial:?}");
960 }
961 }
962
963 /// Same regression from the whole-buffer side: `dd`ing every row must
964 /// restore a U+2028-separated buffer exactly — the old inverse joined the
965 /// rows with `'\n'`, rewriting the separator.
966 #[test]
967 fn delete_linewise_whole_buffer_restores_unicode_separator() {
968 let initial = "a\u{2028}b\u{2028}c";
969 let mut b = View::from_str(initial);
970 let inv = b.apply_edit(Edit::DeleteRange {
971 start: Position::new(0, 0),
972 end: Position::new(2, 0),
973 kind: MotionKind::Line,
974 });
975 match &inv {
976 Edit::InsertStr { text, .. } => assert_eq!(text.as_str(), initial),
977 other => panic!("expected InsertStr inverse, got {other:?}"),
978 }
979 b.apply_edit(inv);
980 assert_eq!(b.as_string(), initial);
981 }
982
983 #[test]
984 fn insert_char_lands_cursor_after() {
985 let mut b = View::from_str("abc");
986 b.set_cursor(Position::new(0, 1));
987 b.apply_edit(Edit::InsertChar {
988 at: Position::new(0, 1),
989 ch: 'X',
990 });
991 assert_eq!(b.cursor(), Position::new(0, 2));
992 assert_eq!(rope_line_str(&b.rope(), 0), "aXbc");
993 }
994
995 #[test]
996 fn block_delete_on_ragged_rows_handles_short_lines() {
997 // Row 1 is shorter than the block right edge — only the
998 // chars that exist get removed.
999 let mut b = View::from_str("longline\nhi\nthird row");
1000 let inv = b.apply_edit(Edit::DeleteRange {
1001 start: Position::new(0, 2),
1002 end: Position::new(2, 5),
1003 kind: MotionKind::Block,
1004 });
1005 b.apply_edit(inv);
1006 assert_eq!(b.as_string(), "longline\nhi\nthird row");
1007 }
1008
1009 #[test]
1010 fn dirty_gen_bumps_per_edit() {
1011 let mut b = View::from_str("abc");
1012 let g0 = b.dirty_gen();
1013 b.apply_edit(Edit::InsertChar {
1014 at: Position::new(0, 0),
1015 ch: 'X',
1016 });
1017 assert_eq!(b.dirty_gen(), g0 + 1);
1018 b.apply_edit(Edit::DeleteRange {
1019 start: Position::new(0, 0),
1020 end: Position::new(0, 1),
1021 kind: MotionKind::Char,
1022 });
1023 assert_eq!(b.dirty_gen(), g0 + 2);
1024 }
1025
1026 /// Regression: a 60 k-row multi-line `InsertStr` into a 60 k-row buffer
1027 /// used to call `Vec::insert(insert_at + i, …)` per row → O(N²) memmove.
1028 /// With ropey, InsertStr is O(log N + edit_size) — this test confirms it
1029 /// stays comfortably under the 200 ms budget.
1030 #[test]
1031 // miri interprets rather than executes, so a 60 k-row splice takes orders
1032 // of magnitude longer than the 200 ms budget and would both stall the
1033 // weekly miri job and fail an assertion that says nothing about UB.
1034 #[cfg_attr(miri, ignore = "wall-clock budget is meaningless under miri")]
1035 fn splice_at_60k_paste_at_row_zero_is_under_200ms() {
1036 // View with 60 k rows of empty content.
1037 let initial = "\n".repeat(60_000);
1038 let mut b = View::from_str(&initial);
1039 // Multi-line payload: 60 k "x" lines glued by \n.
1040 let payload = vec!["x"; 60_000].join("\n");
1041 let t = std::time::Instant::now();
1042 b.apply_edit(Edit::InsertStr {
1043 at: Position::new(0, 0),
1044 text: payload,
1045 });
1046 let elapsed = t.elapsed();
1047 assert!(
1048 elapsed.as_millis() < 200,
1049 "60k-row InsertStr took {elapsed:?}; budget 200 ms"
1050 );
1051 }
1052}