Skip to main content

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                    c.text.remove(remove_start..remove_end);
333                    // ropey guarantees len_lines() >= 1 (empty rope = 1 line).
334
335                    let n2 = c.text.len_lines();
336                    let target_row = lo.min(n2.saturating_sub(1));
337                    let removed_joined = if remove_start == remove_end {
338                        // True no-op: zero chars removed (linewise delete on
339                        // an already-empty buffer — the only shape where the
340                        // range collapses). Joining the lone empty row and
341                        // appending the '\n' terminator would fabricate "\n"
342                        // out of nothing; callers then record that phantom
343                        // text into the unnamed register, where vim leaves
344                        // registers untouched (#280 follow-up).
345                        String::new()
346                    } else {
347                        let mut s = removed_lines.join("\n");
348                        // Add trailing '\n' so the inverse InsertStr re-inserts
349                        // correctly (pushes surviving rows down).
350                        s.push('\n');
351                        s
352                    };
353                    // The inverse text must be *exactly* the removed span, so
354                    // its shape follows the three `(remove_start, remove_end)`
355                    // branches above:
356                    //
357                    // - `lo > 0 && hi + 1 == n` (last-row delete with rows
358                    //   above): the removed span includes the '\n' that ended
359                    //   row lo-1, so the inverse inserts a *leading*-separator
360                    //   form at the end of the new last row (row lo-1). The
361                    //   trailing-separator form targeted at (lo, 0) would
362                    //   reference a row that no longer exists.
363                    // - `lo == 0 && hi + 1 == n` (whole buffer): the removed
364                    //   span is the entire rope, which the plain join already
365                    //   reproduces — a rope without a trailing newline yields
366                    //   ["a","b","c"] -> "a\nb\nc", one with a trailing newline
367                    //   yields ["a","b","c",""] -> "a\nb\nc\n". Appending a '\n'
368                    //   (the `removed_joined` register form) would restore an
369                    //   extra trailing row.
370                    // - `hi + 1 < n` (rows survive below): the surviving rows
371                    //   must be pushed back down, so the trailing-separator
372                    //   form at (lo, 0) is exact.
373                    let (inverse_at, inverse_text) = if lo > 0 && hi + 1 == n {
374                        let last_row_chars = c.text.line(lo - 1).len_chars();
375                        (
376                            Position::new(lo - 1, last_row_chars),
377                            "\n".to_string() + &removed_lines.join("\n"),
378                        )
379                    } else if hi + 1 == n {
380                        (Position::new(0, 0), removed_lines.join("\n"))
381                    } else {
382                        (Position::new(lo, 0), removed_joined.clone())
383                    };
384                    (
385                        removed_joined,
386                        inverse_at,
387                        inverse_text,
388                        Position::new(target_row, 0),
389                    )
390                };
391                self.dirty_gen_bump();
392                self.set_cursor(new_cursor);
393                Edit::InsertStr {
394                    at: inverse_at,
395                    text: inverse_text,
396                }
397            }
398            MotionKind::Block => {
399                let (left, right) = (start.col.min(end.col), start.col.max(end.col));
400                let mut chunks: Vec<String> = Vec::with_capacity(end.row - start.row + 1);
401                for row in start.row..=end.row {
402                    let removed = {
403                        let mut c = self.content.lock().unwrap();
404                        let n = c.text.len_lines();
405                        if row >= n {
406                            String::new()
407                        } else {
408                            let row_start_pos = Position::new(row, left);
409                            let row_end_pos = Position::new(row, right + 1);
410                            rope_cut_chars(&mut c.text, row_start_pos, row_end_pos)
411                        }
412                    };
413                    chunks.push(removed);
414                }
415                self.dirty_gen_bump();
416                self.set_cursor(Position::new(start.row, left));
417                Edit::InsertBlock {
418                    at: Position::new(start.row, left),
419                    chunks,
420                }
421            }
422        }
423    }
424
425    fn do_join_lines(&mut self, row: usize, count: usize, with_space: bool) -> Edit {
426        let count = count.max(1);
427        let (actual_row, split_cols, inserted_spaces) = {
428            let mut c = self.content.lock().unwrap();
429            let n = c.text.len_lines();
430            let row = row.min(n.saturating_sub(1));
431            let mut split_cols: Vec<usize> = Vec::with_capacity(count);
432            // Per-join outcome (did THIS join actually insert a space),
433            // NOT the uniform `with_space` intent — see the field doc on
434            // `Edit::SplitLines::inserted_spaces` (audit-r2 fix 6).
435            let mut inserted_spaces: Vec<bool> = Vec::with_capacity(count);
436
437            for _ in 0..count {
438                let n2 = c.text.len_lines();
439                if row + 1 >= n2 {
440                    break;
441                }
442                // Current length of row (in chars, sans '\n').
443                let join_col = rope_line_char_count(&c.text, row);
444                split_cols.push(join_col);
445
446                // The '\n' that ends row is at char index line_to_char(row) + join_col.
447                let newline_char = c.text.line_to_char(row) + join_col;
448                // Remove the '\n'.
449                c.text.remove(newline_char..newline_char + 1);
450
451                // Now row and (what was row+1) are merged. Insert space if needed.
452                let mut this_inserted_space = false;
453                if with_space {
454                    // After removing '\n', the join_col chars of original row are
455                    // followed immediately by the next row's content.
456                    // Insert space only if both sides are non-empty.
457                    let merged_len = rope_line_char_count(&c.text, row);
458                    let prefix_empty = join_col == 0;
459                    let suffix_empty = join_col >= merged_len;
460                    if !prefix_empty && !suffix_empty {
461                        // Insert space at newline_char (now the join point).
462                        c.text.insert_char(newline_char, ' ');
463                        this_inserted_space = true;
464                        // Adjust future split_cols: the space shifts subsequent
465                        // join points by 1, but split_cols[i] is the char count
466                        // of the original row *before* this join, which doesn't
467                        // need adjustment — the SplitLines inverse uses it to
468                        // split the joined line at the right position.
469                    }
470                }
471                inserted_spaces.push(this_inserted_space);
472            }
473            (row, split_cols, inserted_spaces)
474        };
475        self.dirty_gen_bump();
476        self.set_cursor(Position::new(actual_row, 0));
477        Edit::SplitLines {
478            row: actual_row,
479            cols: split_cols,
480            inserted_spaces,
481        }
482    }
483
484    fn do_split_lines(&mut self, row: usize, cols: &[usize], inserted_spaces: &[bool]) -> Edit {
485        let actual_row = {
486            let mut c = self.content.lock().unwrap();
487            let n = c.text.len_lines();
488            let row = row.min(n.saturating_sub(1));
489
490            // Split right-to-left so each col still indexes into the
491            // original char positions on the surviving prefix.
492            for (idx, &col) in cols.iter().enumerate().rev() {
493                let mut split_col = col;
494                // Per-col: did the ORIGINAL join at this position actually
495                // insert a space? (Not a uniform flag — see the
496                // `Edit::SplitLines` field doc, audit-r2 fix 6.)
497                if inserted_spaces.get(idx).copied().unwrap_or(false) {
498                    // The original join inserted a space at `col`, so the
499                    // current content has a space at position `col` which
500                    // we need to remove before inserting the '\n'.
501                    let lc = rope_line_char_count(&c.text, row);
502                    if split_col < lc {
503                        let space_char_idx = c.text.line_to_char(row) + split_col;
504                        // Check if char at split_col is a space.
505                        let ch = c.text.char(space_char_idx);
506                        if ch == ' ' {
507                            c.text.remove(space_char_idx..space_char_idx + 1);
508                        }
509                    }
510                    // split_col stays the same — the '\n' goes at the same
511                    // position (we removed the space, so col is still correct).
512                } else {
513                    let lc = rope_line_char_count(&c.text, row);
514                    split_col = split_col.min(lc);
515                }
516
517                // Insert '\n' at (row, split_col).
518                let char_idx = c.text.line_to_char(row) + split_col;
519                c.text.insert_char(char_idx, '\n');
520            }
521
522            row
523        };
524        self.dirty_gen_bump();
525        self.set_cursor(Position::new(actual_row, 0));
526        Edit::JoinLines {
527            row: actual_row,
528            count: cols.len(),
529            // Reconstructing a single with_space intent for redo: true iff
530            // ANY col in this batch actually inserted a space. When none
531            // did, redoing with with_space=false reproduces the identical
532            // result anyway (do_join_lines would skip every space here
533            // too), so this is a safe, behavior-preserving collapse.
534            with_space: inserted_spaces.iter().any(|&b| b),
535        }
536    }
537
538    fn do_replace(&mut self, start: Position, end: Position, with: &str) -> Edit {
539        let (start, end) = order(start, end);
540        let removed = {
541            let mut c = self.content.lock().unwrap();
542            rope_cut_chars(&mut c.text, start, end)
543        };
544        let normalised = self.clamp_position(start);
545        let inserted_chars = with.chars().count();
546        let inserted_lines = with.split('\n').count();
547        let new_end = if inserted_lines > 1 {
548            let last_chars = with.rsplit('\n').next().unwrap_or("").chars().count();
549            Position::new(normalised.row + inserted_lines - 1, last_chars)
550        } else {
551            Position::new(normalised.row, normalised.col + inserted_chars)
552        };
553        {
554            let mut c = self.content.lock().unwrap();
555            let char_idx = pos_to_char_idx(&c.text, normalised.row, normalised.col);
556            c.text.insert(char_idx, with);
557        }
558        self.dirty_gen_bump();
559        self.set_cursor(new_end);
560        Edit::Replace {
561            start: normalised,
562            end: new_end,
563            with: removed,
564        }
565    }
566}
567
568// ── Internals — char surgery (free functions over &mut ropey::Rope) ──
569
570/// Get logical line `row` as a `String`, stripping trailing `\n`.
571/// Identical to `rope_line_str` but takes a lock guard's rope by ref
572/// (avoids re-importing the pub(crate) helper from buffer.rs inside this module).
573fn rope_line_str_locked(rope: &ropey::Rope, row: usize) -> String {
574    let slice = rope.line(row);
575    let s = slice.to_string();
576    if s.ends_with('\n') {
577        s[..s.len() - 1].to_string()
578    } else {
579        s
580    }
581}
582
583/// Remove `[start, end)` (charwise) from the rope and return the
584/// removed text as a `String` (with `\n` between rows).
585///
586/// `start` and `end` carry `(row, col)` where `col` is a char index
587/// within the line. The function converts them to absolute char indices,
588/// removes the range, and returns the removed text.
589fn rope_cut_chars(rope: &mut ropey::Rope, start: Position, end: Position) -> String {
590    let (start, end) = order(start, end);
591    let n = rope.len_lines();
592
593    // Clamp to rope bounds.
594    let start_row = start.row.min(n.saturating_sub(1));
595    let start_col = {
596        let lc = crate::buffer::rope_line_char_count(rope, start_row);
597        start.col.min(lc)
598    };
599    let end_row = end.row.min(n.saturating_sub(1));
600    let end_col = {
601        let lc = crate::buffer::rope_line_char_count(rope, end_row);
602        end.col.min(lc)
603    };
604
605    let char_start = rope.line_to_char(start_row) + start_col;
606    let char_end = rope.line_to_char(end_row) + end_col;
607
608    if char_start >= char_end {
609        return String::new();
610    }
611
612    let removed: String = rope.slice(char_start..char_end).to_string();
613    rope.remove(char_start..char_end);
614    removed
615}
616
617fn order(a: Position, b: Position) -> (Position, Position) {
618    if a <= b { (a, b) } else { (b, a) }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use crate::buffer::rope_line_str;
625
626    fn round_trip_check(initial: &str, edit: Edit) {
627        let mut b = View::from_str(initial);
628        let snapshot_before = b.as_string();
629        let inverse = b.apply_edit(edit);
630        b.apply_edit(inverse);
631        assert_eq!(b.as_string(), snapshot_before);
632    }
633
634    #[test]
635    fn insert_char_round_trip() {
636        round_trip_check(
637            "abc",
638            Edit::InsertChar {
639                at: Position::new(0, 1),
640                ch: 'X',
641            },
642        );
643    }
644
645    #[test]
646    fn insert_str_multiline_round_trip() {
647        round_trip_check(
648            "abc\ndef",
649            Edit::InsertStr {
650                at: Position::new(0, 2),
651                text: "X\nY\nZ".into(),
652            },
653        );
654    }
655
656    #[test]
657    fn delete_charwise_single_row_round_trip() {
658        round_trip_check(
659            "alpha bravo charlie",
660            Edit::DeleteRange {
661                start: Position::new(0, 6),
662                end: Position::new(0, 11),
663                kind: MotionKind::Char,
664            },
665        );
666    }
667
668    #[test]
669    fn delete_charwise_multi_row_round_trip() {
670        round_trip_check(
671            "row0\nrow1\nrow2",
672            Edit::DeleteRange {
673                start: Position::new(0, 2),
674                end: Position::new(2, 2),
675                kind: MotionKind::Char,
676            },
677        );
678    }
679
680    #[test]
681    fn delete_linewise_round_trip() {
682        round_trip_check(
683            "a\nb\nc\nd",
684            Edit::DeleteRange {
685                start: Position::new(1, 0),
686                end: Position::new(2, 0),
687                kind: MotionKind::Line,
688            },
689        );
690    }
691
692    #[test]
693    fn delete_blockwise_round_trip() {
694        round_trip_check(
695            "abcdef\nghijkl\nmnopqr",
696            Edit::DeleteRange {
697                start: Position::new(0, 1),
698                end: Position::new(2, 3),
699                kind: MotionKind::Block,
700            },
701        );
702    }
703
704    #[test]
705    fn join_lines_with_space_round_trip() {
706        round_trip_check(
707            "first\nsecond\nthird",
708            Edit::JoinLines {
709                row: 0,
710                count: 2,
711                with_space: true,
712            },
713        );
714    }
715
716    #[test]
717    fn join_lines_no_space_round_trip() {
718        round_trip_check(
719            "first\nsecond",
720            Edit::JoinLines {
721                row: 0,
722                count: 1,
723                with_space: false,
724            },
725        );
726    }
727
728    #[test]
729    fn replace_round_trip() {
730        round_trip_check(
731            "foo bar baz",
732            Edit::Replace {
733                start: Position::new(0, 4),
734                end: Position::new(0, 7),
735                with: "QUUX".into(),
736            },
737        );
738    }
739
740    // ── Block-op / split-lines round trips (audit-r2 fix 6) ──────────────────
741    //
742    // These inverses are dead today — nothing currently chains
743    // apply(edit) -> apply(inverse) for InsertBlock/DeleteBlockChunks or a
744    // JoinLines/SplitLines pair with mixed per-join outcomes — but the
745    // contract (`apply_edit` returns an inverse that restores the pre-edit
746    // text exactly) must hold the day something does.
747
748    #[test]
749    fn insert_block_round_trip_uniform_rows() {
750        round_trip_check(
751            "ab\ncd\nef",
752            Edit::InsertBlock {
753                at: Position::new(0, 1),
754                chunks: vec!["X".into(), "Y".into(), "Z".into()],
755            },
756        );
757    }
758
759    /// `do_insert_block` space-pads a row shorter than `at.col` before
760    /// splicing the chunk in. Round-tripping must remove that padding too,
761    /// not just the chunk — pre-fix, `DeleteBlockChunks`'s inverse only
762    /// carried the chunk width, leaving the padding behind.
763    #[test]
764    fn insert_block_round_trip_pads_short_row() {
765        // Row 1 ("x") is only 1 char; at.col=3 needs 2 chars of padding
766        // before the "Q" chunk lands.
767        round_trip_check(
768            "abcd\nx\nefgh",
769            Edit::InsertBlock {
770                at: Position::new(0, 3),
771                chunks: vec!["P".into(), "Q".into(), "R".into()],
772            },
773        );
774    }
775
776    /// Same as above but EVERY row needs padding, and by different amounts.
777    #[test]
778    fn insert_block_round_trip_ragged_pads_vary_per_row() {
779        round_trip_check(
780            "\na\nab\nabc",
781            Edit::InsertBlock {
782                at: Position::new(0, 3),
783                chunks: vec!["W".into(), "X".into(), "Y".into(), "Z".into()],
784            },
785        );
786    }
787
788    #[test]
789    fn delete_block_chunks_round_trip() {
790        // Constructed directly (DeleteBlockChunks only ever appears in
791        // practice as InsertBlock's returned inverse — see the variant's
792        // doc comment) to round-trip the OTHER direction: does re-inserting
793        // (InsertBlock) restore what DeleteBlockChunks removed?
794        round_trip_check(
795            "abcdef\nghijkl",
796            Edit::DeleteBlockChunks {
797                at: Position::new(0, 1),
798                widths: vec![2, 2],
799                pads: vec![0, 0],
800            },
801        );
802    }
803
804    /// Regression for the exact scenario fix 6 describes: a join with an
805    /// EMPTY prefix (row 0 is blank) skips inserting a space, but the
806    /// pulled-up row legitimately STARTS with its own, unrelated space.
807    /// Pre-fix, `SplitLines`'s single uniform `inserted_space` flag
808    /// couldn't tell "this join skipped the space" from "this join
809    /// inserted one", so splitting back mistook the legitimate leading
810    /// space for the (never-inserted) join space and ate it.
811    #[test]
812    fn join_then_split_empty_prefix_preserves_legitimate_leading_space() {
813        round_trip_check(
814            "\n bar",
815            Edit::JoinLines {
816                row: 0,
817                count: 1,
818                with_space: true,
819            },
820        );
821    }
822
823    /// Same failure mode from the empty-SUFFIX side: the row being joined
824    /// INTO legitimately ends with a space of its own, and the incoming
825    /// (pulled-up) row is empty, so the join skips inserting one.
826    #[test]
827    fn join_then_split_empty_suffix_preserves_legitimate_trailing_space() {
828        round_trip_check(
829            "foo \n",
830            Edit::JoinLines {
831                row: 0,
832                count: 1,
833                with_space: true,
834            },
835        );
836    }
837
838    /// count > 1 with an empty middle line mixes a skipped-space join and a
839    /// real one in the SAME batch — the scenario `content_edit_shape_tests`
840    /// (hjkl-engine) exercises for byte-exactness; here we check the
841    /// simpler round-trip-restores-original-text property instead.
842    #[test]
843    fn join_then_split_multi_count_mixed_spaces_round_trip() {
844        round_trip_check(
845            "foo\n\nbar",
846            Edit::JoinLines {
847                row: 0,
848                count: 2,
849                with_space: true,
850            },
851        );
852    }
853
854    /// Regression: a linewise delete whose START row lies past the last
855    /// buffer row used to underflow `hi - lo + 1` (capacity math) and panic
856    /// `line_to_char(lo)`. Both endpoints must clamp to the last row.
857    #[test]
858    fn delete_linewise_start_past_end_is_clamped() {
859        let mut b = View::from_str("a\nb\nc");
860        b.apply_edit(Edit::DeleteRange {
861            start: Position::new(10, 0),
862            end: Position::new(20, 0),
863            kind: MotionKind::Line,
864        });
865        // Clamps to the last row and removes it.
866        assert_eq!(b.as_string(), "a\nb");
867    }
868
869    #[test]
870    fn delete_clearing_buffer_keeps_one_empty_row() {
871        let mut b = View::from_str("only");
872        b.apply_edit(Edit::DeleteRange {
873            start: Position::new(0, 0),
874            end: Position::new(0, 0),
875            kind: MotionKind::Line,
876        });
877        assert_eq!(b.row_count(), 1);
878        assert_eq!(rope_line_str(&b.rope(), 0), "");
879    }
880
881    /// Regression (#280 follow-up): a linewise delete on an ALREADY-EMPTY
882    /// buffer removes zero chars, so the inverse must carry empty text.
883    /// The old code fabricated "\n" (joined the lone empty row and appended
884    /// a terminator), which callers then recorded into the unnamed register
885    /// — vim leaves registers untouched on a true no-op delete.
886    #[test]
887    fn noop_linewise_delete_on_empty_buffer_has_empty_inverse() {
888        let mut b = View::from_str("");
889        let inv = b.apply_edit(Edit::DeleteRange {
890            start: Position::new(0, 0),
891            end: Position::new(0, 0),
892            kind: MotionKind::Line,
893        });
894        match inv {
895            Edit::InsertStr { text, .. } => {
896                assert_eq!(text, "", "no-op delete must not fabricate \"\\n\"");
897            }
898            other => panic!("expected InsertStr inverse, got {other:?}"),
899        }
900        assert_eq!(b.row_count(), 1);
901        assert_eq!(rope_line_str(&b.rope(), 0), "");
902    }
903
904    #[test]
905    fn insert_char_lands_cursor_after() {
906        let mut b = View::from_str("abc");
907        b.set_cursor(Position::new(0, 1));
908        b.apply_edit(Edit::InsertChar {
909            at: Position::new(0, 1),
910            ch: 'X',
911        });
912        assert_eq!(b.cursor(), Position::new(0, 2));
913        assert_eq!(rope_line_str(&b.rope(), 0), "aXbc");
914    }
915
916    #[test]
917    fn block_delete_on_ragged_rows_handles_short_lines() {
918        // Row 1 is shorter than the block right edge — only the
919        // chars that exist get removed.
920        let mut b = View::from_str("longline\nhi\nthird row");
921        let inv = b.apply_edit(Edit::DeleteRange {
922            start: Position::new(0, 2),
923            end: Position::new(2, 5),
924            kind: MotionKind::Block,
925        });
926        b.apply_edit(inv);
927        assert_eq!(b.as_string(), "longline\nhi\nthird row");
928    }
929
930    #[test]
931    fn dirty_gen_bumps_per_edit() {
932        let mut b = View::from_str("abc");
933        let g0 = b.dirty_gen();
934        b.apply_edit(Edit::InsertChar {
935            at: Position::new(0, 0),
936            ch: 'X',
937        });
938        assert_eq!(b.dirty_gen(), g0 + 1);
939        b.apply_edit(Edit::DeleteRange {
940            start: Position::new(0, 0),
941            end: Position::new(0, 1),
942            kind: MotionKind::Char,
943        });
944        assert_eq!(b.dirty_gen(), g0 + 2);
945    }
946
947    /// Regression: a 60 k-row multi-line `InsertStr` into a 60 k-row buffer
948    /// used to call `Vec::insert(insert_at + i, …)` per row → O(N²) memmove.
949    /// With ropey, InsertStr is O(log N + edit_size) — this test confirms it
950    /// stays comfortably under the 200 ms budget.
951    #[test]
952    // miri interprets rather than executes, so a 60 k-row splice takes orders
953    // of magnitude longer than the 200 ms budget and would both stall the
954    // weekly miri job and fail an assertion that says nothing about UB.
955    #[cfg_attr(miri, ignore = "wall-clock budget is meaningless under miri")]
956    fn splice_at_60k_paste_at_row_zero_is_under_200ms() {
957        // View with 60 k rows of empty content.
958        let initial = "\n".repeat(60_000);
959        let mut b = View::from_str(&initial);
960        // Multi-line payload: 60 k "x" lines glued by \n.
961        let payload = vec!["x"; 60_000].join("\n");
962        let t = std::time::Instant::now();
963        b.apply_edit(Edit::InsertStr {
964            at: Position::new(0, 0),
965            text: payload,
966        });
967        let elapsed = t.elapsed();
968        assert!(
969            elapsed.as_millis() < 200,
970            "60k-row InsertStr took {elapsed:?}; budget 200 ms"
971        );
972    }
973}