Skip to main content

hjkl_buffer/
edit.rs

1//! Edit operations on [`crate::Buffer`].
2//!
3//! Every mutation goes through [`Buffer::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, Position};
10
11/// Granularity of a delete; preserved through undo so a linewise
12/// delete doesn't come back as a charwise one.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum MotionKind {
15    /// Charwise — `[start, end)` byte range, possibly wrapping rows.
16    Char,
17    /// Linewise — whole rows from `start.row..=end.row`. Endpoint
18    /// columns are ignored.
19    Line,
20    /// Blockwise — rectangle `[start.row..=end.row] × [min_col..=max_col]`.
21    Block,
22}
23
24/// One unit of buffer mutation. Constructed by the caller (vim
25/// engine, ex command, …) and handed to [`Buffer::apply_edit`].
26///
27/// ## Invariants
28///
29/// All `Position` arguments must satisfy the bounds documented on
30/// [`Position`] before the edit is applied. Out-of-bounds positions
31/// are clamped by [`Buffer::clamp_position`] inside
32/// [`Buffer::apply_edit`]; if the clamped form changes the edit's
33/// meaning the result is implementation-defined.
34///
35/// See [`Buffer::apply_edit`] for post-conditions that hold after
36/// every variant.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum Edit {
39    /// Insert one char at `at`. Cursor lands one position past it.
40    ///
41    /// `at` must be a valid [`Position`]. `ch` must be a single Unicode
42    /// scalar. Multi-grapheme content must use [`Edit::InsertStr`].
43    InsertChar { at: Position, ch: char },
44    /// Insert `text` (possibly multi-line) at `at`. Cursor lands at
45    /// the end of the inserted content.
46    ///
47    /// `at` must be a valid [`Position`]. `text` may contain `\n` — the
48    /// buffer splits on newline. CR (`\r`) is preserved as-is; the host
49    /// is responsible for CRLF normalization before insert.
50    InsertStr { at: Position, text: String },
51    /// Delete `[start, end)` with the given kind.
52    ///
53    /// `start <= end` in document order. [`MotionKind`] controls whether
54    /// trailing newlines are consumed:
55    ///
56    /// - [`MotionKind::Char`][]: byte-precise; preserves enclosing newlines.
57    /// - [`MotionKind::Line`][]: whole rows from `start.row..=end.row`;
58    ///   endpoint columns are ignored.
59    /// - [`MotionKind::Block`][]: rectangle
60    ///   `[start.row..=end.row] × [min_col..=max_col]`.
61    DeleteRange {
62        start: Position,
63        end: Position,
64        kind: MotionKind,
65    },
66    /// `J` (`with_space = true`) / `gJ` (`false`) — fold `count` rows
67    /// after `row` into `row`.
68    ///
69    /// `row + count - 1` must be a valid row. `count >= 1`.
70    JoinLines {
71        row: usize,
72        count: usize,
73        with_space: bool,
74    },
75    /// Inverse of `JoinLines`. Splits `row` back at each char column
76    /// in `cols`. `inserted_space` matches the original join so the
77    /// inverse can drop the space before splitting.
78    SplitLines {
79        row: usize,
80        cols: Vec<usize>,
81        inserted_space: bool,
82    },
83    /// Replace `[start, end)` with `with` (charwise, may span rows).
84    ///
85    /// Same constraints as [`Edit::DeleteRange`] with
86    /// [`MotionKind::Char`] for the deleted range, plus the insert
87    /// constraints from [`Edit::InsertStr`] for `with`.
88    Replace {
89        start: Position,
90        end: Position,
91        with: String,
92    },
93    /// Insert one chunk per row, each at `(at.row + i, at.col)`.
94    /// Inverse of a blockwise delete; preserves the rectangle even
95    /// when rows are ragged shorter than `at.col`.
96    InsertBlock { at: Position, chunks: Vec<String> },
97    /// Inverse of [`Edit::InsertBlock`]. Removes `widths[i]` chars
98    /// starting at `(at.row + i, at.col)`. Carrying widths instead
99    /// of recomputing means a ragged-row block delete round-trips
100    /// exactly.
101    DeleteBlockChunks { at: Position, widths: Vec<usize> },
102}
103
104impl Buffer {
105    /// Apply `edit` and return the inverse. Pushing the inverse back
106    /// through `apply_edit` restores the previous state, making it the
107    /// single hook for undo-stack integration.
108    ///
109    /// `apply_edit` is the **only** way to mutate buffer text.
110    ///
111    /// ## Post-conditions
112    ///
113    /// After any [`Edit`] variant:
114    ///
115    /// - [`Buffer::dirty_gen`] is incremented exactly once.
116    /// - The cursor is repositioned to a sensible place for the edit kind
117    ///   (insert lands past the inserted content; delete lands at the
118    ///   start). Callers that need to override the new cursor must call
119    ///   [`Buffer::set_cursor`] immediately after.
120    /// - All [`Position`] values the caller held from before the edit may
121    ///   be invalid. Re-derive from row / col deltas; do not cache.
122    pub fn apply_edit(&mut self, edit: Edit) -> Edit {
123        match edit {
124            Edit::InsertChar { at, ch } => self.do_insert_str(at, ch.to_string()),
125            Edit::InsertStr { at, text } => self.do_insert_str(at, text),
126            Edit::DeleteRange { start, end, kind } => self.do_delete_range(start, end, kind),
127            Edit::JoinLines {
128                row,
129                count,
130                with_space,
131            } => self.do_join_lines(row, count, with_space),
132            Edit::SplitLines {
133                row,
134                cols,
135                inserted_space,
136            } => self.do_split_lines(row, cols, inserted_space),
137            Edit::Replace { start, end, with } => self.do_replace(start, end, with),
138            Edit::InsertBlock { at, chunks } => self.do_insert_block(at, chunks),
139            Edit::DeleteBlockChunks { at, widths } => self.do_delete_block_chunks(at, widths),
140        }
141    }
142
143    fn do_insert_block(&mut self, at: Position, chunks: Vec<String>) -> Edit {
144        let mut widths: Vec<usize> = Vec::with_capacity(chunks.len());
145        for (i, chunk) in chunks.into_iter().enumerate() {
146            let row = at.row + i;
147            // Pad short rows with spaces so the column position
148            // exists before splicing.
149            let line_chars = self.lines_mut()[row].chars().count();
150            if line_chars < at.col {
151                let pad = at.col - line_chars;
152                self.lines_mut()[row].push_str(&" ".repeat(pad));
153            }
154            widths.push(chunk.chars().count());
155            self.splice_at(Position::new(row, at.col), &chunk);
156        }
157        self.dirty_gen_bump();
158        self.set_cursor(at);
159        Edit::DeleteBlockChunks { at, widths }
160    }
161
162    fn do_delete_block_chunks(&mut self, at: Position, widths: Vec<usize>) -> Edit {
163        let mut chunks: Vec<String> = Vec::with_capacity(widths.len());
164        for (i, w) in widths.into_iter().enumerate() {
165            let row = at.row + i;
166            let removed =
167                self.cut_chars(Position::new(row, at.col), Position::new(row, at.col + w));
168            chunks.push(removed);
169        }
170        self.dirty_gen_bump();
171        self.set_cursor(at);
172        Edit::InsertBlock { at, chunks }
173    }
174
175    fn do_insert_str(&mut self, at: Position, text: String) -> Edit {
176        let normalised = self.clamp_position(at);
177        let inserted_chars = text.chars().count();
178        let inserted_lines = text.split('\n').count();
179        let end = if inserted_lines > 1 {
180            let last_chars = text.rsplit('\n').next().unwrap_or("").chars().count();
181            Position::new(normalised.row + inserted_lines - 1, last_chars)
182        } else {
183            Position::new(normalised.row, normalised.col + inserted_chars)
184        };
185        self.splice_at(normalised, &text);
186        self.dirty_gen_bump();
187        self.set_cursor(end);
188        Edit::DeleteRange {
189            start: normalised,
190            end,
191            kind: MotionKind::Char,
192        }
193    }
194
195    fn do_delete_range(&mut self, start: Position, end: Position, kind: MotionKind) -> Edit {
196        let (start, end) = order(start, end);
197        match kind {
198            MotionKind::Char => {
199                let removed = self.cut_chars(start, end);
200                self.dirty_gen_bump();
201                self.set_cursor(start);
202                Edit::InsertStr {
203                    at: start,
204                    text: removed,
205                }
206            }
207            MotionKind::Line => {
208                let lo = start.row;
209                let hi = end.row.min(self.row_count().saturating_sub(1));
210                let removed_lines: Vec<String> = self.lines_mut().drain(lo..=hi).collect();
211                if self.lines_mut().is_empty() {
212                    self.lines_mut().push(String::new());
213                }
214                self.dirty_gen_bump();
215                let target_row = lo.min(self.row_count().saturating_sub(1));
216                self.set_cursor(Position::new(target_row, 0));
217                let mut text = removed_lines.join("\n");
218                // Trailing `\n` so the inverse insert pushes the
219                // surviving row(s) down rather than concatenating
220                // onto whatever currently sits at `lo`.
221                text.push('\n');
222                Edit::InsertStr {
223                    at: Position::new(lo, 0),
224                    text,
225                }
226            }
227            MotionKind::Block => {
228                let (left, right) = (start.col.min(end.col), start.col.max(end.col));
229                let mut chunks: Vec<String> = Vec::with_capacity(end.row - start.row + 1);
230                for row in start.row..=end.row {
231                    let row_left = Position::new(row, left);
232                    let row_right = Position::new(row, right + 1);
233                    let removed = self.cut_chars(row_left, row_right);
234                    chunks.push(removed);
235                }
236                self.dirty_gen_bump();
237                self.set_cursor(Position::new(start.row, left));
238                // Inverse paired with [`Edit::InsertBlock`]: each
239                // chunk lands back at its original column on its
240                // row, preserving ragged-row content exactly.
241                Edit::InsertBlock {
242                    at: Position::new(start.row, left),
243                    chunks,
244                }
245            }
246        }
247    }
248
249    fn do_join_lines(&mut self, row: usize, count: usize, with_space: bool) -> Edit {
250        let count = count.max(1);
251        let row = row.min(self.row_count().saturating_sub(1));
252        let mut split_cols: Vec<usize> = Vec::with_capacity(count);
253        let mut joined = std::mem::take(&mut self.lines_mut()[row]);
254        for _ in 0..count {
255            if row + 1 >= self.row_count() {
256                break;
257            }
258            let next = self.lines_mut().remove(row + 1);
259            let join_col = joined.chars().count();
260            split_cols.push(join_col);
261            if with_space && !joined.is_empty() && !next.is_empty() {
262                joined.push(' ');
263            }
264            joined.push_str(&next);
265        }
266        self.lines_mut()[row] = joined;
267        self.dirty_gen_bump();
268        self.set_cursor(Position::new(row, 0));
269        Edit::SplitLines {
270            row,
271            cols: split_cols,
272            inserted_space: with_space,
273        }
274    }
275
276    fn do_split_lines(&mut self, row: usize, cols: Vec<usize>, inserted_space: bool) -> Edit {
277        let row = row.min(self.row_count().saturating_sub(1));
278        let mut working = std::mem::take(&mut self.lines_mut()[row]);
279        // Split right-to-left so each `cols[i]` still indexes into
280        // the original char positions on the surviving prefix.
281        let mut tails: Vec<String> = Vec::with_capacity(cols.len());
282        for &c in cols.iter().rev() {
283            let byte = Position::new(0, c).byte_offset(&working);
284            let mut tail = working.split_off(byte);
285            if inserted_space && tail.starts_with(' ') {
286                tail.remove(0);
287            }
288            tails.push(tail);
289        }
290        // Re-insert head + tails in document order.
291        self.lines_mut()[row] = working;
292        for (i, tail) in tails.into_iter().rev().enumerate() {
293            self.lines_mut().insert(row + 1 + i, tail);
294        }
295        self.dirty_gen_bump();
296        self.set_cursor(Position::new(row, 0));
297        Edit::JoinLines {
298            row,
299            count: cols.len(),
300            with_space: inserted_space,
301        }
302    }
303
304    fn do_replace(&mut self, start: Position, end: Position, with: String) -> Edit {
305        let (start, end) = order(start, end);
306        let removed = self.cut_chars(start, end);
307        let normalised = self.clamp_position(start);
308        let inserted_chars = with.chars().count();
309        let inserted_lines = with.split('\n').count();
310        let new_end = if inserted_lines > 1 {
311            let last_chars = with.rsplit('\n').next().unwrap_or("").chars().count();
312            Position::new(normalised.row + inserted_lines - 1, last_chars)
313        } else {
314            Position::new(normalised.row, normalised.col + inserted_chars)
315        };
316        self.splice_at(normalised, &with);
317        self.dirty_gen_bump();
318        self.set_cursor(new_end);
319        Edit::Replace {
320            start: normalised,
321            end: new_end,
322            with: removed,
323        }
324    }
325}
326
327// ── Internals — char surgery ───────────────────────────────────
328
329impl Buffer {
330    /// Splice multi-line `text` at `at`. The first piece appends to
331    /// the prefix of the row; intermediate pieces become new rows;
332    /// the last piece prepends to the suffix.
333    fn splice_at(&mut self, at: Position, text: &str) {
334        let pieces: Vec<&str> = text.split('\n').collect();
335        let row = at.row;
336        let line = &mut self.lines_mut()[row];
337        let byte = at.byte_offset(line);
338        let suffix = line.split_off(byte);
339        if pieces.len() == 1 {
340            line.push_str(pieces[0]);
341            line.push_str(&suffix);
342            return;
343        }
344        line.push_str(pieces[0]);
345        let mut new_rows: Vec<String> = pieces[1..pieces.len() - 1]
346            .iter()
347            .map(|s| (*s).to_string())
348            .collect();
349        let mut last = pieces.last().copied().unwrap_or("").to_string();
350        last.push_str(&suffix);
351        new_rows.push(last);
352        let insert_at = row + 1;
353        for (i, l) in new_rows.into_iter().enumerate() {
354            self.lines_mut().insert(insert_at + i, l);
355        }
356    }
357
358    /// Remove `[start, end)` (charwise) and return what was removed
359    /// with `\n` between rows.
360    fn cut_chars(&mut self, start: Position, end: Position) -> String {
361        let (start, end) = order(start, end);
362        if start.row == end.row {
363            let line = &mut self.lines_mut()[start.row];
364            let lo = start.byte_offset(line).min(line.len());
365            let hi = end.byte_offset(line).min(line.len());
366            return line.drain(lo..hi).collect();
367        }
368        let mut out = String::new();
369        // Suffix of start row.
370        {
371            let line = &mut self.lines_mut()[start.row];
372            let byte = start.byte_offset(line).min(line.len());
373            let suffix: String = line.drain(byte..).collect();
374            out.push_str(&suffix);
375        }
376        out.push('\n');
377        // Drain rows strictly between start.row and end.row.
378        let mid_lo = start.row + 1;
379        let mid_hi = end.row.saturating_sub(1);
380        if mid_hi >= mid_lo {
381            let drained: Vec<String> = self.lines_mut().drain(mid_lo..=mid_hi).collect();
382            for l in drained {
383                out.push_str(&l);
384                out.push('\n');
385            }
386        }
387        // Prefix of (now-shifted) end row.
388        let end_line_idx = start.row + 1;
389        {
390            let line = &mut self.lines_mut()[end_line_idx];
391            let byte = end.byte_offset(line).min(line.len());
392            let prefix: String = line.drain(..byte).collect();
393            out.push_str(&prefix);
394        }
395        // Glue start row + remainder of end row.
396        let merged = self.lines_mut().remove(end_line_idx);
397        self.lines_mut()[start.row].push_str(&merged);
398        out
399    }
400}
401
402fn order(a: Position, b: Position) -> (Position, Position) {
403    if a <= b { (a, b) } else { (b, a) }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn round_trip_check(initial: &str, edit: Edit) {
411        let mut b = Buffer::from_str(initial);
412        let snapshot_before = b.as_string();
413        let inverse = b.apply_edit(edit);
414        b.apply_edit(inverse);
415        assert_eq!(b.as_string(), snapshot_before);
416    }
417
418    #[test]
419    fn insert_char_round_trip() {
420        round_trip_check(
421            "abc",
422            Edit::InsertChar {
423                at: Position::new(0, 1),
424                ch: 'X',
425            },
426        );
427    }
428
429    #[test]
430    fn insert_str_multiline_round_trip() {
431        round_trip_check(
432            "abc\ndef",
433            Edit::InsertStr {
434                at: Position::new(0, 2),
435                text: "X\nY\nZ".into(),
436            },
437        );
438    }
439
440    #[test]
441    fn delete_charwise_single_row_round_trip() {
442        round_trip_check(
443            "alpha bravo charlie",
444            Edit::DeleteRange {
445                start: Position::new(0, 6),
446                end: Position::new(0, 11),
447                kind: MotionKind::Char,
448            },
449        );
450    }
451
452    #[test]
453    fn delete_charwise_multi_row_round_trip() {
454        round_trip_check(
455            "row0\nrow1\nrow2",
456            Edit::DeleteRange {
457                start: Position::new(0, 2),
458                end: Position::new(2, 2),
459                kind: MotionKind::Char,
460            },
461        );
462    }
463
464    #[test]
465    fn delete_linewise_round_trip() {
466        round_trip_check(
467            "a\nb\nc\nd",
468            Edit::DeleteRange {
469                start: Position::new(1, 0),
470                end: Position::new(2, 0),
471                kind: MotionKind::Line,
472            },
473        );
474    }
475
476    #[test]
477    fn delete_blockwise_round_trip() {
478        round_trip_check(
479            "abcdef\nghijkl\nmnopqr",
480            Edit::DeleteRange {
481                start: Position::new(0, 1),
482                end: Position::new(2, 3),
483                kind: MotionKind::Block,
484            },
485        );
486    }
487
488    #[test]
489    fn join_lines_with_space_round_trip() {
490        round_trip_check(
491            "first\nsecond\nthird",
492            Edit::JoinLines {
493                row: 0,
494                count: 2,
495                with_space: true,
496            },
497        );
498    }
499
500    #[test]
501    fn join_lines_no_space_round_trip() {
502        round_trip_check(
503            "first\nsecond",
504            Edit::JoinLines {
505                row: 0,
506                count: 1,
507                with_space: false,
508            },
509        );
510    }
511
512    #[test]
513    fn replace_round_trip() {
514        round_trip_check(
515            "foo bar baz",
516            Edit::Replace {
517                start: Position::new(0, 4),
518                end: Position::new(0, 7),
519                with: "QUUX".into(),
520            },
521        );
522    }
523
524    #[test]
525    fn delete_clearing_buffer_keeps_one_empty_row() {
526        let mut b = Buffer::from_str("only");
527        b.apply_edit(Edit::DeleteRange {
528            start: Position::new(0, 0),
529            end: Position::new(0, 0),
530            kind: MotionKind::Line,
531        });
532        assert_eq!(b.row_count(), 1);
533        assert_eq!(b.line(0), Some(""));
534    }
535
536    #[test]
537    fn insert_char_lands_cursor_after() {
538        let mut b = Buffer::from_str("abc");
539        b.set_cursor(Position::new(0, 1));
540        b.apply_edit(Edit::InsertChar {
541            at: Position::new(0, 1),
542            ch: 'X',
543        });
544        assert_eq!(b.cursor(), Position::new(0, 2));
545        assert_eq!(b.line(0), Some("aXbc"));
546    }
547
548    #[test]
549    fn block_delete_on_ragged_rows_handles_short_lines() {
550        // Row 1 is shorter than the block right edge — only the
551        // chars that exist get removed.
552        let mut b = Buffer::from_str("longline\nhi\nthird row");
553        let inv = b.apply_edit(Edit::DeleteRange {
554            start: Position::new(0, 2),
555            end: Position::new(2, 5),
556            kind: MotionKind::Block,
557        });
558        b.apply_edit(inv);
559        assert_eq!(b.as_string(), "longline\nhi\nthird row");
560    }
561
562    #[test]
563    fn dirty_gen_bumps_per_edit() {
564        let mut b = Buffer::from_str("abc");
565        let g0 = b.dirty_gen();
566        b.apply_edit(Edit::InsertChar {
567            at: Position::new(0, 0),
568            ch: 'X',
569        });
570        assert_eq!(b.dirty_gen(), g0 + 1);
571        b.apply_edit(Edit::DeleteRange {
572            start: Position::new(0, 0),
573            end: Position::new(0, 1),
574            kind: MotionKind::Char,
575        });
576        assert_eq!(b.dirty_gen(), g0 + 2);
577    }
578}