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