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`. `inserted_space` matches the original join so the
78    /// inverse can drop the space before splitting.
79    SplitLines {
80        row: usize,
81        cols: Vec<usize>,
82        inserted_space: bool,
83    },
84    /// Replace `[start, end)` with `with` (charwise, may span rows).
85    ///
86    /// Same constraints as [`Edit::DeleteRange`] with
87    /// [`MotionKind::Char`] for the deleted range, plus the insert
88    /// constraints from [`Edit::InsertStr`] for `with`.
89    Replace {
90        start: Position,
91        end: Position,
92        with: String,
93    },
94    /// Insert one chunk per row, each at `(at.row + i, at.col)`.
95    /// Inverse of a blockwise delete; preserves the rectangle even
96    /// when rows are ragged shorter than `at.col`.
97    InsertBlock { at: Position, chunks: Vec<String> },
98    /// Inverse of [`Edit::InsertBlock`]. Removes `widths[i]` chars
99    /// starting at `(at.row + i, at.col)`. Carrying widths instead
100    /// of recomputing means a ragged-row block delete round-trips
101    /// exactly.
102    DeleteBlockChunks { at: Position, widths: Vec<usize> },
103}
104
105impl View {
106    /// Apply `edit` and return the inverse. Pushing the inverse back
107    /// through `apply_edit` restores the previous state, making it the
108    /// single hook for undo-stack integration.
109    ///
110    /// `apply_edit` is the **only** way to mutate buffer text.
111    ///
112    /// ## Post-conditions
113    ///
114    /// After any [`Edit`] variant:
115    ///
116    /// - [`View::dirty_gen`] is incremented exactly once.
117    /// - The cursor is repositioned to a sensible place for the edit kind
118    ///   (insert lands past the inserted content; delete lands at the
119    ///   start). Callers that need to override the new cursor must call
120    ///   [`View::set_cursor`] immediately after.
121    /// - All [`Position`] values the caller held from before the edit may
122    ///   be invalid. Re-derive from row / col deltas; do not cache.
123    pub fn apply_edit(&mut self, edit: Edit) -> Edit {
124        match edit {
125            Edit::InsertChar { at, ch } => self.do_insert_str(at, ch.to_string()),
126            Edit::InsertStr { at, text } => self.do_insert_str(at, text),
127            Edit::DeleteRange { start, end, kind } => self.do_delete_range(start, end, kind),
128            Edit::JoinLines {
129                row,
130                count,
131                with_space,
132            } => self.do_join_lines(row, count, with_space),
133            Edit::SplitLines {
134                row,
135                cols,
136                inserted_space,
137            } => self.do_split_lines(row, cols, inserted_space),
138            Edit::Replace { start, end, with } => self.do_replace(start, end, with),
139            Edit::InsertBlock { at, chunks } => self.do_insert_block(at, chunks),
140            Edit::DeleteBlockChunks { at, widths } => self.do_delete_block_chunks(at, widths),
141        }
142    }
143
144    fn do_insert_block(&mut self, at: Position, chunks: Vec<String>) -> Edit {
145        let mut widths: Vec<usize> = Vec::with_capacity(chunks.len());
146        for (i, chunk) in chunks.into_iter().enumerate() {
147            let row = at.row + i;
148            // Pad short rows with spaces so the column position exists
149            // before splicing — same semantics as the old Vec<String> impl.
150            {
151                let mut c = self.content.lock().unwrap();
152                let n = c.text.len_lines();
153                if row < n {
154                    let lc = rope_line_char_count(&c.text, row);
155                    if lc < at.col {
156                        let pad = at.col - lc;
157                        let insert_char_idx = pos_to_char_idx(&c.text, row, lc);
158                        c.text.insert(insert_char_idx, &" ".repeat(pad));
159                    }
160                }
161            }
162            widths.push(chunk.chars().count());
163            // Insert chunk at (row, at.col).
164            {
165                let mut c = self.content.lock().unwrap();
166                let n = c.text.len_lines();
167                if row < n {
168                    let char_idx = pos_to_char_idx(&c.text, row, at.col);
169                    c.text.insert(char_idx, &chunk);
170                }
171            }
172        }
173        self.dirty_gen_bump();
174        self.set_cursor(at);
175        Edit::DeleteBlockChunks { at, widths }
176    }
177
178    fn do_delete_block_chunks(&mut self, at: Position, widths: Vec<usize>) -> Edit {
179        let mut chunks: Vec<String> = Vec::with_capacity(widths.len());
180        for (i, w) in widths.into_iter().enumerate() {
181            let row = at.row + i;
182            let removed = {
183                let mut c = self.content.lock().unwrap();
184                let n = c.text.len_lines();
185                if row >= n {
186                    String::new()
187                } else {
188                    let lc = rope_line_char_count(&c.text, row);
189                    let col_start = at.col.min(lc);
190                    let col_end = (at.col + w).min(lc);
191                    if col_start >= col_end {
192                        String::new()
193                    } else {
194                        let char_start = pos_to_char_idx(&c.text, row, col_start);
195                        let char_end = pos_to_char_idx(&c.text, row, col_end);
196                        let removed: String = c.text.slice(char_start..char_end).to_string();
197                        c.text.remove(char_start..char_end);
198                        removed
199                    }
200                }
201            };
202            chunks.push(removed);
203        }
204        self.dirty_gen_bump();
205        self.set_cursor(at);
206        Edit::InsertBlock { at, chunks }
207    }
208
209    fn do_insert_str(&mut self, at: Position, text: String) -> Edit {
210        let normalised = self.clamp_position(at);
211        let inserted_chars = text.chars().count();
212        let inserted_lines = text.split('\n').count();
213        let end = if inserted_lines > 1 {
214            let last_chars = text.rsplit('\n').next().unwrap_or("").chars().count();
215            Position::new(normalised.row + inserted_lines - 1, last_chars)
216        } else {
217            Position::new(normalised.row, normalised.col + inserted_chars)
218        };
219        {
220            let mut c = self.content.lock().unwrap();
221            let char_idx = pos_to_char_idx(&c.text, normalised.row, normalised.col);
222            c.text.insert(char_idx, &text);
223        }
224        self.dirty_gen_bump();
225        self.set_cursor(end);
226        Edit::DeleteRange {
227            start: normalised,
228            end,
229            kind: MotionKind::Char,
230        }
231    }
232
233    fn do_delete_range(&mut self, start: Position, end: Position, kind: MotionKind) -> Edit {
234        let (start, end) = order(start, end);
235        match kind {
236            MotionKind::Char => {
237                let removed = {
238                    let mut c = self.content.lock().unwrap();
239                    rope_cut_chars(&mut c.text, start, end)
240                };
241                self.dirty_gen_bump();
242                self.set_cursor(start);
243                Edit::InsertStr {
244                    at: start,
245                    text: removed,
246                }
247            }
248            MotionKind::Line => {
249                let (removed_text, new_cursor, lo) = {
250                    let mut c = self.content.lock().unwrap();
251                    let n = c.text.len_lines();
252                    // Clamp BOTH endpoints. An unclamped `lo` past the last
253                    // row underflows the `hi - lo + 1` capacity below and
254                    // panics `line_to_char(lo)`.
255                    let lo = start.row.min(n.saturating_sub(1));
256                    let hi = end.row.min(n.saturating_sub(1));
257
258                    // Collect the removed rows as a joined string (needed for inverse).
259                    let mut removed_lines: Vec<String> = Vec::with_capacity(hi - lo + 1);
260                    for r in lo..=hi {
261                        removed_lines.push(rope_line_str_locked(&c.text, r));
262                    }
263
264                    // Compute char range to remove.
265                    // When hi is not the last row, we take [line_to_char(lo), line_to_char(hi+1)).
266                    // When hi IS the last row and lo>0, we also remove the '\n' that ends
267                    // row lo-1 so we don't leave a trailing newline orphan.
268                    // When removing everything (lo==0, hi==last), take [0, len_chars()).
269                    let (remove_start, remove_end) = if hi + 1 < n {
270                        // Normal case: rows lo..=hi followed by more rows.
271                        // char range = [line_to_char(lo), line_to_char(hi+1))
272                        (c.text.line_to_char(lo), c.text.line_to_char(hi + 1))
273                    } else if lo > 0 {
274                        // hi is the last row AND there are rows before lo.
275                        // Remove the '\n' that ended row lo-1 as well.
276                        (c.text.line_to_char(lo) - 1, c.text.len_chars())
277                    } else {
278                        // Removing everything (lo==0, hi==last).
279                        (0, c.text.len_chars())
280                    };
281
282                    c.text.remove(remove_start..remove_end);
283                    // ropey guarantees len_lines() >= 1 (empty rope = 1 line).
284
285                    let n2 = c.text.len_lines();
286                    let target_row = lo.min(n2.saturating_sub(1));
287                    let removed_joined = {
288                        let mut s = removed_lines.join("\n");
289                        // Add trailing '\n' so the inverse InsertStr re-inserts
290                        // correctly (pushes surviving rows down).
291                        s.push('\n');
292                        s
293                    };
294                    (removed_joined, Position::new(target_row, 0), lo)
295                };
296                self.dirty_gen_bump();
297                self.set_cursor(new_cursor);
298                Edit::InsertStr {
299                    at: Position::new(lo, 0),
300                    text: removed_text,
301                }
302            }
303            MotionKind::Block => {
304                let (left, right) = (start.col.min(end.col), start.col.max(end.col));
305                let mut chunks: Vec<String> = Vec::with_capacity(end.row - start.row + 1);
306                for row in start.row..=end.row {
307                    let removed = {
308                        let mut c = self.content.lock().unwrap();
309                        let n = c.text.len_lines();
310                        if row >= n {
311                            String::new()
312                        } else {
313                            let row_start_pos = Position::new(row, left);
314                            let row_end_pos = Position::new(row, right + 1);
315                            rope_cut_chars(&mut c.text, row_start_pos, row_end_pos)
316                        }
317                    };
318                    chunks.push(removed);
319                }
320                self.dirty_gen_bump();
321                self.set_cursor(Position::new(start.row, left));
322                Edit::InsertBlock {
323                    at: Position::new(start.row, left),
324                    chunks,
325                }
326            }
327        }
328    }
329
330    fn do_join_lines(&mut self, row: usize, count: usize, with_space: bool) -> Edit {
331        let count = count.max(1);
332        let (actual_row, split_cols) = {
333            let mut c = self.content.lock().unwrap();
334            let n = c.text.len_lines();
335            let row = row.min(n.saturating_sub(1));
336            let mut split_cols: Vec<usize> = Vec::with_capacity(count);
337
338            for _ in 0..count {
339                let n2 = c.text.len_lines();
340                if row + 1 >= n2 {
341                    break;
342                }
343                // Current length of row (in chars, sans '\n').
344                let join_col = rope_line_char_count(&c.text, row);
345                split_cols.push(join_col);
346
347                // The '\n' that ends row is at char index line_to_char(row) + join_col.
348                let newline_char = c.text.line_to_char(row) + join_col;
349                // Remove the '\n'.
350                c.text.remove(newline_char..newline_char + 1);
351
352                // Now row and (what was row+1) are merged. Insert space if needed.
353                if with_space {
354                    // After removing '\n', the join_col chars of original row are
355                    // followed immediately by the next row's content.
356                    // Insert space only if both sides are non-empty.
357                    let n3 = c.text.len_lines();
358                    let merged_len = rope_line_char_count(&c.text, row);
359                    let prefix_empty = join_col == 0;
360                    let suffix_empty = join_col >= merged_len;
361                    if !prefix_empty && !suffix_empty {
362                        // Insert space at newline_char (now the join point).
363                        c.text.insert_char(newline_char, ' ');
364                        // Adjust future split_cols: the space shifts subsequent
365                        // join points by 1, but split_cols[i] is the char count
366                        // of the original row *before* this join, which doesn't
367                        // need adjustment — the SplitLines inverse uses it to
368                        // split the joined line at the right position.
369                    }
370                    let _ = n3;
371                }
372            }
373            (row, split_cols)
374        };
375        self.dirty_gen_bump();
376        self.set_cursor(Position::new(actual_row, 0));
377        Edit::SplitLines {
378            row: actual_row,
379            cols: split_cols,
380            inserted_space: with_space,
381        }
382    }
383
384    fn do_split_lines(&mut self, row: usize, cols: Vec<usize>, inserted_space: bool) -> Edit {
385        let actual_row = {
386            let mut c = self.content.lock().unwrap();
387            let n = c.text.len_lines();
388            let row = row.min(n.saturating_sub(1));
389
390            // Split right-to-left so each col still indexes into the
391            // original char positions on the surviving prefix.
392            for &col in cols.iter().rev() {
393                let mut split_col = col;
394                if inserted_space {
395                    // The original join inserted a space at `col`, so the
396                    // current content has a space at position `col` which
397                    // we need to remove before inserting the '\n'.
398                    let lc = rope_line_char_count(&c.text, row);
399                    if split_col < lc {
400                        let space_char_idx = c.text.line_to_char(row) + split_col;
401                        // Check if char at split_col is a space.
402                        let ch = c.text.char(space_char_idx);
403                        if ch == ' ' {
404                            c.text.remove(space_char_idx..space_char_idx + 1);
405                        }
406                    }
407                    // split_col stays the same — the '\n' goes at the same
408                    // position (we removed the space, so col is still correct).
409                } else {
410                    let lc = rope_line_char_count(&c.text, row);
411                    split_col = split_col.min(lc);
412                }
413
414                // Insert '\n' at (row, split_col).
415                let char_idx = c.text.line_to_char(row) + split_col;
416                c.text.insert_char(char_idx, '\n');
417            }
418
419            row
420        };
421        self.dirty_gen_bump();
422        self.set_cursor(Position::new(actual_row, 0));
423        Edit::JoinLines {
424            row: actual_row,
425            count: cols.len(),
426            with_space: inserted_space,
427        }
428    }
429
430    fn do_replace(&mut self, start: Position, end: Position, with: String) -> Edit {
431        let (start, end) = order(start, end);
432        let removed = {
433            let mut c = self.content.lock().unwrap();
434            rope_cut_chars(&mut c.text, start, end)
435        };
436        let normalised = self.clamp_position(start);
437        let inserted_chars = with.chars().count();
438        let inserted_lines = with.split('\n').count();
439        let new_end = if inserted_lines > 1 {
440            let last_chars = with.rsplit('\n').next().unwrap_or("").chars().count();
441            Position::new(normalised.row + inserted_lines - 1, last_chars)
442        } else {
443            Position::new(normalised.row, normalised.col + inserted_chars)
444        };
445        {
446            let mut c = self.content.lock().unwrap();
447            let char_idx = pos_to_char_idx(&c.text, normalised.row, normalised.col);
448            c.text.insert(char_idx, &with);
449        }
450        self.dirty_gen_bump();
451        self.set_cursor(new_end);
452        Edit::Replace {
453            start: normalised,
454            end: new_end,
455            with: removed,
456        }
457    }
458}
459
460// ── Internals — char surgery (free functions over &mut ropey::Rope) ──
461
462/// Get logical line `row` as a `String`, stripping trailing `\n`.
463/// Identical to `rope_line_str` but takes a lock guard's rope by ref
464/// (avoids re-importing the pub(crate) helper from buffer.rs inside this module).
465fn rope_line_str_locked(rope: &ropey::Rope, row: usize) -> String {
466    let slice = rope.line(row);
467    let s = slice.to_string();
468    if s.ends_with('\n') {
469        s[..s.len() - 1].to_string()
470    } else {
471        s
472    }
473}
474
475/// Remove `[start, end)` (charwise) from the rope and return the
476/// removed text as a `String` (with `\n` between rows).
477///
478/// `start` and `end` carry `(row, col)` where `col` is a char index
479/// within the line. The function converts them to absolute char indices,
480/// removes the range, and returns the removed text.
481fn rope_cut_chars(rope: &mut ropey::Rope, start: Position, end: Position) -> String {
482    let (start, end) = order(start, end);
483    let n = rope.len_lines();
484
485    // Clamp to rope bounds.
486    let start_row = start.row.min(n.saturating_sub(1));
487    let start_col = {
488        let lc = crate::buffer::rope_line_char_count(rope, start_row);
489        start.col.min(lc)
490    };
491    let end_row = end.row.min(n.saturating_sub(1));
492    let end_col = {
493        let lc = crate::buffer::rope_line_char_count(rope, end_row);
494        end.col.min(lc)
495    };
496
497    let char_start = rope.line_to_char(start_row) + start_col;
498    let char_end = rope.line_to_char(end_row) + end_col;
499
500    if char_start >= char_end {
501        return String::new();
502    }
503
504    let removed: String = rope.slice(char_start..char_end).to_string();
505    rope.remove(char_start..char_end);
506    removed
507}
508
509fn order(a: Position, b: Position) -> (Position, Position) {
510    if a <= b { (a, b) } else { (b, a) }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::buffer::rope_line_str;
517
518    fn round_trip_check(initial: &str, edit: Edit) {
519        let mut b = View::from_str(initial);
520        let snapshot_before = b.as_string();
521        let inverse = b.apply_edit(edit);
522        b.apply_edit(inverse);
523        assert_eq!(b.as_string(), snapshot_before);
524    }
525
526    #[test]
527    fn insert_char_round_trip() {
528        round_trip_check(
529            "abc",
530            Edit::InsertChar {
531                at: Position::new(0, 1),
532                ch: 'X',
533            },
534        );
535    }
536
537    #[test]
538    fn insert_str_multiline_round_trip() {
539        round_trip_check(
540            "abc\ndef",
541            Edit::InsertStr {
542                at: Position::new(0, 2),
543                text: "X\nY\nZ".into(),
544            },
545        );
546    }
547
548    #[test]
549    fn delete_charwise_single_row_round_trip() {
550        round_trip_check(
551            "alpha bravo charlie",
552            Edit::DeleteRange {
553                start: Position::new(0, 6),
554                end: Position::new(0, 11),
555                kind: MotionKind::Char,
556            },
557        );
558    }
559
560    #[test]
561    fn delete_charwise_multi_row_round_trip() {
562        round_trip_check(
563            "row0\nrow1\nrow2",
564            Edit::DeleteRange {
565                start: Position::new(0, 2),
566                end: Position::new(2, 2),
567                kind: MotionKind::Char,
568            },
569        );
570    }
571
572    #[test]
573    fn delete_linewise_round_trip() {
574        round_trip_check(
575            "a\nb\nc\nd",
576            Edit::DeleteRange {
577                start: Position::new(1, 0),
578                end: Position::new(2, 0),
579                kind: MotionKind::Line,
580            },
581        );
582    }
583
584    #[test]
585    fn delete_blockwise_round_trip() {
586        round_trip_check(
587            "abcdef\nghijkl\nmnopqr",
588            Edit::DeleteRange {
589                start: Position::new(0, 1),
590                end: Position::new(2, 3),
591                kind: MotionKind::Block,
592            },
593        );
594    }
595
596    #[test]
597    fn join_lines_with_space_round_trip() {
598        round_trip_check(
599            "first\nsecond\nthird",
600            Edit::JoinLines {
601                row: 0,
602                count: 2,
603                with_space: true,
604            },
605        );
606    }
607
608    #[test]
609    fn join_lines_no_space_round_trip() {
610        round_trip_check(
611            "first\nsecond",
612            Edit::JoinLines {
613                row: 0,
614                count: 1,
615                with_space: false,
616            },
617        );
618    }
619
620    #[test]
621    fn replace_round_trip() {
622        round_trip_check(
623            "foo bar baz",
624            Edit::Replace {
625                start: Position::new(0, 4),
626                end: Position::new(0, 7),
627                with: "QUUX".into(),
628            },
629        );
630    }
631
632    /// Regression: a linewise delete whose START row lies past the last
633    /// buffer row used to underflow `hi - lo + 1` (capacity math) and panic
634    /// `line_to_char(lo)`. Both endpoints must clamp to the last row.
635    #[test]
636    fn delete_linewise_start_past_end_is_clamped() {
637        let mut b = View::from_str("a\nb\nc");
638        b.apply_edit(Edit::DeleteRange {
639            start: Position::new(10, 0),
640            end: Position::new(20, 0),
641            kind: MotionKind::Line,
642        });
643        // Clamps to the last row and removes it.
644        assert_eq!(b.as_string(), "a\nb");
645    }
646
647    #[test]
648    fn delete_clearing_buffer_keeps_one_empty_row() {
649        let mut b = View::from_str("only");
650        b.apply_edit(Edit::DeleteRange {
651            start: Position::new(0, 0),
652            end: Position::new(0, 0),
653            kind: MotionKind::Line,
654        });
655        assert_eq!(b.row_count(), 1);
656        assert_eq!(rope_line_str(&b.rope(), 0), "");
657    }
658
659    #[test]
660    fn insert_char_lands_cursor_after() {
661        let mut b = View::from_str("abc");
662        b.set_cursor(Position::new(0, 1));
663        b.apply_edit(Edit::InsertChar {
664            at: Position::new(0, 1),
665            ch: 'X',
666        });
667        assert_eq!(b.cursor(), Position::new(0, 2));
668        assert_eq!(rope_line_str(&b.rope(), 0), "aXbc");
669    }
670
671    #[test]
672    fn block_delete_on_ragged_rows_handles_short_lines() {
673        // Row 1 is shorter than the block right edge — only the
674        // chars that exist get removed.
675        let mut b = View::from_str("longline\nhi\nthird row");
676        let inv = b.apply_edit(Edit::DeleteRange {
677            start: Position::new(0, 2),
678            end: Position::new(2, 5),
679            kind: MotionKind::Block,
680        });
681        b.apply_edit(inv);
682        assert_eq!(b.as_string(), "longline\nhi\nthird row");
683    }
684
685    #[test]
686    fn dirty_gen_bumps_per_edit() {
687        let mut b = View::from_str("abc");
688        let g0 = b.dirty_gen();
689        b.apply_edit(Edit::InsertChar {
690            at: Position::new(0, 0),
691            ch: 'X',
692        });
693        assert_eq!(b.dirty_gen(), g0 + 1);
694        b.apply_edit(Edit::DeleteRange {
695            start: Position::new(0, 0),
696            end: Position::new(0, 1),
697            kind: MotionKind::Char,
698        });
699        assert_eq!(b.dirty_gen(), g0 + 2);
700    }
701
702    /// Regression: a 60 k-row multi-line `InsertStr` into a 60 k-row buffer
703    /// used to call `Vec::insert(insert_at + i, …)` per row → O(N²) memmove.
704    /// With ropey, InsertStr is O(log N + edit_size) — this test confirms it
705    /// stays comfortably under the 200 ms budget.
706    #[test]
707    fn splice_at_60k_paste_at_row_zero_is_under_200ms() {
708        // View with 60 k rows of empty content.
709        let initial = "\n".repeat(60_000);
710        let mut b = View::from_str(&initial);
711        // Multi-line payload: 60 k "x" lines glued by \n.
712        let payload = vec!["x"; 60_000].join("\n");
713        let t = std::time::Instant::now();
714        b.apply_edit(Edit::InsertStr {
715            at: Position::new(0, 0),
716            text: payload,
717        });
718        let elapsed = t.elapsed();
719        assert!(
720            elapsed.as_millis() < 200,
721            "60k-row InsertStr took {elapsed:?}; budget 200 ms"
722        );
723    }
724}