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, needs_pad) = {
150                let c = self.content.lock().unwrap();
151                let lc = c.lines[row].chars().count();
152                (lc, lc < at.col)
153            };
154            if needs_pad {
155                let pad = at.col - line_chars;
156                self.content.lock().unwrap().lines[row].push_str(&" ".repeat(pad));
157            }
158            widths.push(chunk.chars().count());
159            splice_at(
160                &mut self.content.lock().unwrap().lines,
161                Position::new(row, at.col),
162                &chunk,
163            );
164        }
165        self.dirty_gen_bump();
166        self.set_cursor(at);
167        Edit::DeleteBlockChunks { at, widths }
168    }
169
170    fn do_delete_block_chunks(&mut self, at: Position, widths: Vec<usize>) -> Edit {
171        let mut chunks: Vec<String> = Vec::with_capacity(widths.len());
172        for (i, w) in widths.into_iter().enumerate() {
173            let row = at.row + i;
174            let removed = cut_chars(
175                &mut self.content.lock().unwrap().lines,
176                Position::new(row, at.col),
177                Position::new(row, at.col + w),
178            );
179            chunks.push(removed);
180        }
181        self.dirty_gen_bump();
182        self.set_cursor(at);
183        Edit::InsertBlock { at, chunks }
184    }
185
186    fn do_insert_str(&mut self, at: Position, text: String) -> Edit {
187        let normalised = self.clamp_position(at);
188        let inserted_chars = text.chars().count();
189        let inserted_lines = text.split('\n').count();
190        let end = if inserted_lines > 1 {
191            let last_chars = text.rsplit('\n').next().unwrap_or("").chars().count();
192            Position::new(normalised.row + inserted_lines - 1, last_chars)
193        } else {
194            Position::new(normalised.row, normalised.col + inserted_chars)
195        };
196        splice_at(&mut self.content.lock().unwrap().lines, normalised, &text);
197        self.dirty_gen_bump();
198        self.set_cursor(end);
199        Edit::DeleteRange {
200            start: normalised,
201            end,
202            kind: MotionKind::Char,
203        }
204    }
205
206    fn do_delete_range(&mut self, start: Position, end: Position, kind: MotionKind) -> Edit {
207        let (start, end) = order(start, end);
208        match kind {
209            MotionKind::Char => {
210                let removed = cut_chars(&mut self.content.lock().unwrap().lines, start, end);
211                self.dirty_gen_bump();
212                self.set_cursor(start);
213                Edit::InsertStr {
214                    at: start,
215                    text: removed,
216                }
217            }
218            MotionKind::Line => {
219                let lo = start.row;
220                let (removed_lines, new_cursor) = {
221                    let mut c = self.content.lock().unwrap();
222                    let hi = end.row.min(c.lines.len().saturating_sub(1));
223                    let removed: Vec<String> = c.lines.drain(lo..=hi).collect();
224                    if c.lines.is_empty() {
225                        c.lines.push(String::new());
226                    }
227                    let target_row = lo.min(c.lines.len().saturating_sub(1));
228                    (removed, Position::new(target_row, 0))
229                };
230                self.dirty_gen_bump();
231                self.set_cursor(new_cursor);
232                let mut text = removed_lines.join("\n");
233                // Trailing `\n` so the inverse insert pushes the
234                // surviving row(s) down rather than concatenating
235                // onto whatever currently sits at `lo`.
236                text.push('\n');
237                Edit::InsertStr {
238                    at: Position::new(lo, 0),
239                    text,
240                }
241            }
242            MotionKind::Block => {
243                let (left, right) = (start.col.min(end.col), start.col.max(end.col));
244                let mut chunks: Vec<String> = Vec::with_capacity(end.row - start.row + 1);
245                for row in start.row..=end.row {
246                    let row_left = Position::new(row, left);
247                    let row_right = Position::new(row, right + 1);
248                    let removed =
249                        cut_chars(&mut self.content.lock().unwrap().lines, row_left, row_right);
250                    chunks.push(removed);
251                }
252                self.dirty_gen_bump();
253                self.set_cursor(Position::new(start.row, left));
254                // Inverse paired with [`Edit::InsertBlock`]: each
255                // chunk lands back at its original column on its
256                // row, preserving ragged-row content exactly.
257                Edit::InsertBlock {
258                    at: Position::new(start.row, left),
259                    chunks,
260                }
261            }
262        }
263    }
264
265    fn do_join_lines(&mut self, row: usize, count: usize, with_space: bool) -> Edit {
266        let count = count.max(1);
267        let (row, split_cols) = {
268            let mut c = self.content.lock().unwrap();
269            let row = row.min(c.lines.len().saturating_sub(1));
270            let mut split_cols: Vec<usize> = Vec::with_capacity(count);
271            let mut joined = std::mem::take(&mut c.lines[row]);
272            for _ in 0..count {
273                if row + 1 >= c.lines.len() {
274                    break;
275                }
276                let next = c.lines.remove(row + 1);
277                let join_col = joined.chars().count();
278                split_cols.push(join_col);
279                if with_space && !joined.is_empty() && !next.is_empty() {
280                    joined.push(' ');
281                }
282                joined.push_str(&next);
283            }
284            c.lines[row] = joined;
285            (row, split_cols)
286        };
287        self.dirty_gen_bump();
288        self.set_cursor(Position::new(row, 0));
289        Edit::SplitLines {
290            row,
291            cols: split_cols,
292            inserted_space: with_space,
293        }
294    }
295
296    fn do_split_lines(&mut self, row: usize, cols: Vec<usize>, inserted_space: bool) -> Edit {
297        let row = {
298            let mut c = self.content.lock().unwrap();
299            let row = row.min(c.lines.len().saturating_sub(1));
300            let mut working = std::mem::take(&mut c.lines[row]);
301            // Split right-to-left so each `cols[i]` still indexes into
302            // the original char positions on the surviving prefix.
303            let mut tails: Vec<String> = Vec::with_capacity(cols.len());
304            for &col in cols.iter().rev() {
305                let byte = Position::new(0, col).byte_offset(&working);
306                let mut tail = working.split_off(byte);
307                if inserted_space && tail.starts_with(' ') {
308                    tail.remove(0);
309                }
310                tails.push(tail);
311            }
312            // Re-insert head + tails in document order.
313            c.lines[row] = working;
314            for (i, tail) in tails.into_iter().rev().enumerate() {
315                c.lines.insert(row + 1 + i, tail);
316            }
317            row
318        };
319        self.dirty_gen_bump();
320        self.set_cursor(Position::new(row, 0));
321        Edit::JoinLines {
322            row,
323            count: cols.len(),
324            with_space: inserted_space,
325        }
326    }
327
328    fn do_replace(&mut self, start: Position, end: Position, with: String) -> Edit {
329        let (start, end) = order(start, end);
330        let removed = cut_chars(&mut self.content.lock().unwrap().lines, start, end);
331        let normalised = self.clamp_position(start);
332        let inserted_chars = with.chars().count();
333        let inserted_lines = with.split('\n').count();
334        let new_end = if inserted_lines > 1 {
335            let last_chars = with.rsplit('\n').next().unwrap_or("").chars().count();
336            Position::new(normalised.row + inserted_lines - 1, last_chars)
337        } else {
338            Position::new(normalised.row, normalised.col + inserted_chars)
339        };
340        splice_at(&mut self.content.lock().unwrap().lines, normalised, &with);
341        self.dirty_gen_bump();
342        self.set_cursor(new_end);
343        Edit::Replace {
344            start: normalised,
345            end: new_end,
346            with: removed,
347        }
348    }
349}
350
351// ── Internals — char surgery (free functions over &mut Vec<String>) ──
352
353/// Splice multi-line `text` at `at`. The first piece appends to
354/// the prefix of the row; intermediate pieces become new rows;
355/// the last piece prepends to the suffix.
356fn splice_at(lines: &mut Vec<String>, at: Position, text: &str) {
357    let pieces: Vec<&str> = text.split('\n').collect();
358    let row = at.row;
359    let byte = at.byte_offset(&lines[row]);
360    let suffix = lines[row].split_off(byte);
361    if pieces.len() == 1 {
362        lines[row].push_str(pieces[0]);
363        lines[row].push_str(&suffix);
364        return;
365    }
366    lines[row].push_str(pieces[0]);
367    let mut new_rows: Vec<String> = pieces[1..pieces.len() - 1]
368        .iter()
369        .map(|s| (*s).to_string())
370        .collect();
371    let mut last = pieces.last().copied().unwrap_or("").to_string();
372    last.push_str(&suffix);
373    new_rows.push(last);
374    let insert_at = row + 1;
375    for (i, l) in new_rows.into_iter().enumerate() {
376        lines.insert(insert_at + i, l);
377    }
378}
379
380/// Remove `[start, end)` (charwise) and return what was removed
381/// with `\n` between rows.
382fn cut_chars(lines: &mut Vec<String>, start: Position, end: Position) -> String {
383    let (start, end) = order(start, end);
384    if start.row == end.row {
385        let line = &mut lines[start.row];
386        let lo = start.byte_offset(line).min(line.len());
387        let hi = end.byte_offset(line).min(line.len());
388        return line.drain(lo..hi).collect();
389    }
390    let mut out = String::new();
391    // Suffix of start row.
392    {
393        let line = &mut lines[start.row];
394        let byte = start.byte_offset(line).min(line.len());
395        let suffix: String = line.drain(byte..).collect();
396        out.push_str(&suffix);
397    }
398    out.push('\n');
399    // Drain rows strictly between start.row and end.row.
400    let mid_lo = start.row + 1;
401    let mid_hi = end.row.saturating_sub(1);
402    if mid_hi >= mid_lo {
403        let drained: Vec<String> = lines.drain(mid_lo..=mid_hi).collect();
404        for l in drained {
405            out.push_str(&l);
406            out.push('\n');
407        }
408    }
409    // Prefix of (now-shifted) end row.
410    let end_line_idx = start.row + 1;
411    {
412        let line = &mut lines[end_line_idx];
413        let byte = end.byte_offset(line).min(line.len());
414        let prefix: String = line.drain(..byte).collect();
415        out.push_str(&prefix);
416    }
417    // Glue start row + remainder of end row.
418    let merged = lines.remove(end_line_idx);
419    lines[start.row].push_str(&merged);
420    out
421}
422
423fn order(a: Position, b: Position) -> (Position, Position) {
424    if a <= b { (a, b) } else { (b, a) }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    fn round_trip_check(initial: &str, edit: Edit) {
432        let mut b = Buffer::from_str(initial);
433        let snapshot_before = b.as_string();
434        let inverse = b.apply_edit(edit);
435        b.apply_edit(inverse);
436        assert_eq!(b.as_string(), snapshot_before);
437    }
438
439    #[test]
440    fn insert_char_round_trip() {
441        round_trip_check(
442            "abc",
443            Edit::InsertChar {
444                at: Position::new(0, 1),
445                ch: 'X',
446            },
447        );
448    }
449
450    #[test]
451    fn insert_str_multiline_round_trip() {
452        round_trip_check(
453            "abc\ndef",
454            Edit::InsertStr {
455                at: Position::new(0, 2),
456                text: "X\nY\nZ".into(),
457            },
458        );
459    }
460
461    #[test]
462    fn delete_charwise_single_row_round_trip() {
463        round_trip_check(
464            "alpha bravo charlie",
465            Edit::DeleteRange {
466                start: Position::new(0, 6),
467                end: Position::new(0, 11),
468                kind: MotionKind::Char,
469            },
470        );
471    }
472
473    #[test]
474    fn delete_charwise_multi_row_round_trip() {
475        round_trip_check(
476            "row0\nrow1\nrow2",
477            Edit::DeleteRange {
478                start: Position::new(0, 2),
479                end: Position::new(2, 2),
480                kind: MotionKind::Char,
481            },
482        );
483    }
484
485    #[test]
486    fn delete_linewise_round_trip() {
487        round_trip_check(
488            "a\nb\nc\nd",
489            Edit::DeleteRange {
490                start: Position::new(1, 0),
491                end: Position::new(2, 0),
492                kind: MotionKind::Line,
493            },
494        );
495    }
496
497    #[test]
498    fn delete_blockwise_round_trip() {
499        round_trip_check(
500            "abcdef\nghijkl\nmnopqr",
501            Edit::DeleteRange {
502                start: Position::new(0, 1),
503                end: Position::new(2, 3),
504                kind: MotionKind::Block,
505            },
506        );
507    }
508
509    #[test]
510    fn join_lines_with_space_round_trip() {
511        round_trip_check(
512            "first\nsecond\nthird",
513            Edit::JoinLines {
514                row: 0,
515                count: 2,
516                with_space: true,
517            },
518        );
519    }
520
521    #[test]
522    fn join_lines_no_space_round_trip() {
523        round_trip_check(
524            "first\nsecond",
525            Edit::JoinLines {
526                row: 0,
527                count: 1,
528                with_space: false,
529            },
530        );
531    }
532
533    #[test]
534    fn replace_round_trip() {
535        round_trip_check(
536            "foo bar baz",
537            Edit::Replace {
538                start: Position::new(0, 4),
539                end: Position::new(0, 7),
540                with: "QUUX".into(),
541            },
542        );
543    }
544
545    #[test]
546    fn delete_clearing_buffer_keeps_one_empty_row() {
547        let mut b = Buffer::from_str("only");
548        b.apply_edit(Edit::DeleteRange {
549            start: Position::new(0, 0),
550            end: Position::new(0, 0),
551            kind: MotionKind::Line,
552        });
553        assert_eq!(b.row_count(), 1);
554        assert_eq!(b.line(0), Some(""));
555    }
556
557    #[test]
558    fn insert_char_lands_cursor_after() {
559        let mut b = Buffer::from_str("abc");
560        b.set_cursor(Position::new(0, 1));
561        b.apply_edit(Edit::InsertChar {
562            at: Position::new(0, 1),
563            ch: 'X',
564        });
565        assert_eq!(b.cursor(), Position::new(0, 2));
566        assert_eq!(b.line(0), Some("aXbc"));
567    }
568
569    #[test]
570    fn block_delete_on_ragged_rows_handles_short_lines() {
571        // Row 1 is shorter than the block right edge — only the
572        // chars that exist get removed.
573        let mut b = Buffer::from_str("longline\nhi\nthird row");
574        let inv = b.apply_edit(Edit::DeleteRange {
575            start: Position::new(0, 2),
576            end: Position::new(2, 5),
577            kind: MotionKind::Block,
578        });
579        b.apply_edit(inv);
580        assert_eq!(b.as_string(), "longline\nhi\nthird row");
581    }
582
583    #[test]
584    fn dirty_gen_bumps_per_edit() {
585        let mut b = Buffer::from_str("abc");
586        let g0 = b.dirty_gen();
587        b.apply_edit(Edit::InsertChar {
588            at: Position::new(0, 0),
589            ch: 'X',
590        });
591        assert_eq!(b.dirty_gen(), g0 + 1);
592        b.apply_edit(Edit::DeleteRange {
593            start: Position::new(0, 0),
594            end: Position::new(0, 1),
595            kind: MotionKind::Char,
596        });
597        assert_eq!(b.dirty_gen(), g0 + 2);
598    }
599}