Skip to main content

kimun_notes/components/text_editor/
rope_buffer.rs

1//! The **rope buffer**: the editor's buffer over the `ropetext` **edit buffer**.
2//!
3//! The engine owns the text, the cursor, the selection and the history, and
4//! deliberately nothing else (ADR-0041). This is the rest of an editor's buffer —
5//! what kimün adds, not what it adapts:
6//!
7//! - **undo groups**: [`RopeBuffer::edit`] scopes and [`RopeBuffer::continue_group`],
8//!   so a compound action or a typing run is one history entry;
9//! - the edit outcome: what a mutation did, measured — changed, bulk, the damage
10//!   hull in one numbering, the line delta — drained by the component through
11//!   [`RopeBuffer::take_outcome`];
12//! - the goal column a vertical motion aims at;
13//! - the **indent step** ([`RopeBuffer::indent_rows`]): the one place Tab, `>>` and
14//!   a list continuation's dedent move a row by;
15//! - the **find pattern** and its row-wise, wrapping search, which outlive the
16//!   find bar so vim's `n`/`N` can repeat them;
17//! - the yank transport the vim engine's **unnamed register** fills from;
18//! - the `(row, col)` vocabulary every caller speaks: the vim engine, the plain
19//!   key table, the find bar and the component all take `&mut RopeBuffer`.
20//!
21//! It knows text, not markdown. List continuation, **auto-surround** and the
22//! emphasis markers are operations *over* it, in `markdown_edits`, and are
23//! tested against a bare one.
24//!
25//! Nothing here mirrors the text into a second representation. Callers that want
26//! rows ask for them ([`RopeBuffer::rows`]) and pay for them there; the buffer
27//! keeps one copy of the note and no derived copy in step with it.
28//!
29use crate::ropetext::motion::{self, Goal, Words};
30use crate::ropetext::{Change, Column, EditBuffer as Rope, Position, Span, Text};
31
32/// How far one **indent step** moves a row, in spaces — what Tab, `>>` and the
33/// visual `>` add, and what their inverses remove.
34///
35/// Not a tab stop, and deliberately not derived from one. A tab stop is elastic
36/// (a `\t` advances to the next multiple of it, so its width depends on where it
37/// starts) and describes how an existing character *draws*; an indent step is a
38/// fixed amount of text an edit *inserts*, and here it is always spaces. Vim
39/// keeps the two apart as `tabstop` and `shiftwidth`, EditorConfig as
40/// `tab_width` and `indent_size`. That both are 4 today
41/// ([`crate::ropetext::Metrics::DEFAULT_TAB_WIDTH`]) is a coincidence of defaults.
42const DEFAULT_INDENT_WIDTH: std::num::NonZeroU8 = std::num::NonZeroU8::new(4).unwrap();
43
44/// What one call to [`RopeBuffer::edit`] did, measured rather than predicted.
45///
46/// `#[must_use]` on purpose: the caller still applies these (the revision clock
47/// serves both backends and so stays on the component), and forgetting to is
48/// exactly the failure this type exists to prevent. A warning is a check; a
49/// convention is not.
50#[must_use = "an edit's outcome drives the revision bump and the parse-damage signal"]
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct EditOutcome {
53    /// The buffer's text differs from before the edit. A content comparison —
54    /// never a library return value, which can report `false` after mutating.
55    pub changed: bool,
56    /// The change is not confined to the cursor's row, so the incremental
57    /// parser's cursor damage hint would under-report it.
58    pub bulk: bool,
59    /// Which rows the edits changed, in the new text's numbering — the hull when
60    /// several ran before this was drained.
61    ///
62    /// Told by the engine rather than found by comparing the buffer with a copy
63    /// of its previous self, which is what the revision-tagged rope makes
64    /// possible. The **nvim** backend reports lines and not changes, so it
65    /// leaves this `None` and its consumer falls back to a diff.
66    pub damage: Option<std::ops::Range<usize>>,
67    /// Net rows added (or removed, when negative) by the edits behind `damage`.
68    ///
69    /// Travels with the range because the range is only meaningful in a
70    /// numbering, and a consumer that accumulates reports across several drains
71    /// has to bring the older one forward before it can union them.
72    pub line_delta: isize,
73}
74
75/// `range`, renumbered for a change of `delta` lines starting at `at`.
76///
77/// Rows above the change keep their index; the rest move with it. A `delta` of
78/// zero — every edit that stays within its rows, which is most of them — leaves
79/// the range alone.
80///
81/// Shared because damage is accumulated in two places: here, across the
82/// mutations of one group, and in the view, across the drains between two
83/// frames. Both union ranges recorded against different texts, and both are
84/// wrong in the same way without this.
85pub(super) fn shift_rows(
86    range: std::ops::Range<usize>,
87    at: usize,
88    delta: isize,
89) -> std::ops::Range<usize> {
90    let shift = |row: usize| {
91        if row < at {
92            row
93        } else {
94            row.saturating_add_signed(delta)
95        }
96    };
97    shift(range.start)..shift(range.end)
98}
99
100/// Whether a delete fills the register it removed text from.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum Yank {
103    Keep,
104    Discard,
105}
106
107/// One row's share of an indent, addressed against the text before the block
108/// began; the transaction carries it across the rows edited before it.
109#[derive(Debug, Clone, Copy)]
110enum RowEdit {
111    Insert(Position),
112    Delete(Span),
113}
114
115/// A cursor movement, in the vocabulary the editor already speaks.
116///
117/// Deliberately the incumbent's variant set, so the 145 call sites need no
118/// rewriting — but `Jump` takes `usize` rather than `u16`, because clamping a
119/// row to 65535 is a defect the old widget's contract allowed, and this is the
120/// type where it stops being representable.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum CursorMove {
123    Forward,
124    Back,
125    Up,
126    Down,
127    Head,
128    End,
129    Top,
130    Bottom,
131    WordForward,
132    WordBack,
133    WordEnd,
134    /// `W` — a WORD is any run of non-blanks.
135    WordForwardBig,
136    /// `B`.
137    WordBackBig,
138    /// `E`.
139    WordEndBig,
140    /// `ge` / `gE`.
141    WordEndBack {
142        big: bool,
143    },
144    /// `%`. Stays put when there is no bracket ahead on the row, or it is
145    /// unbalanced.
146    MatchingPair,
147    ParagraphForward,
148    ParagraphBack,
149    Jump(usize, usize),
150}
151
152impl CursorMove {
153    /// Whether the movement is vertical, and so keeps the goal column.
154    ///
155    /// `Top` and `Bottom` are row movements but deliberately *not* goal-preserving:
156    /// vim's `gg`/`G` go to a row's first non-blank rather than to a remembered
157    /// column, and making them sticky would be a third behaviour that neither vim
158    /// nor the incumbent has. Change #8 is about `Up`/`Down`.
159    fn is_vertical(self) -> bool {
160        matches!(self, CursorMove::Up | CursorMove::Down)
161    }
162}
163
164/// The open note's text, cursor, selection and history.
165#[derive(Debug)]
166pub struct RopeBuffer {
167    inner: Rope,
168    /// Accumulated since the last drain. The component owns the revision clock
169    /// for as long as the nvim backend has no edit buffer.
170    pending: EditOutcome,
171    /// Nesting depth of [`Self::edit`]. Above zero, a mutation extends the open
172    /// group instead of starting its own.
173    depth: u32,
174    /// Whether the open group has recorded anything yet, so the first mutation
175    /// inside `edit` starts the group and the rest extend it.
176    group_started: bool,
177    /// Set by a backend that has decided the next mutation continues what the
178    /// last one started — a typing run, an insert session. Cleared by using it,
179    /// so continuing is asked for per edit rather than left switched on.
180    continue_group: bool,
181    /// The column a vertical movement is aiming at, which is why walking down
182    /// through a short row and out the other side returns to where it started.
183    goal: Option<Column>,
184    yank: String,
185    search: Option<regex::Regex>,
186    indent_width: std::num::NonZeroU8,
187}
188
189impl Default for RopeBuffer {
190    fn default() -> Self {
191        Self::new(Text::new())
192    }
193}
194
195impl RopeBuffer {
196    pub fn new(text: Text) -> Self {
197        Self {
198            inner: Rope::new(text),
199            pending: EditOutcome::default(),
200            depth: 0,
201            group_started: false,
202            continue_group: false,
203            goal: None,
204            yank: String::new(),
205            search: None,
206            indent_width: DEFAULT_INDENT_WIDTH,
207        }
208    }
209
210    /// Replace the whole buffer, dropping the history with it.
211    pub fn replace(&mut self, text: Text) {
212        self.inner.set_text(text);
213        self.pending = EditOutcome::default();
214        self.goal = None;
215    }
216
217    pub fn text(&self) -> &Text {
218        self.inner.text()
219    }
220
221    /// Pin the **indent step** to a non-default width, so a test can catch a
222    /// reintroduced literal. Nothing configures the step in production yet.
223    #[cfg(test)]
224    pub fn set_indent_width(&mut self, spaces: u8) {
225        self.indent_width =
226            std::num::NonZeroU8::new(spaces).expect("an indent step is at least one space");
227    }
228
229    pub fn snapshot(&self) -> crate::ropetext::Snapshot {
230        self.inner.snapshot()
231    }
232
233    // ── Reads ────────────────────────────────────────────────────────────────
234
235    /// One row's text, or `None` past the end.
236    pub fn row(&self, row: usize) -> Option<std::borrow::Cow<'_, str>> {
237        self.inner.text().line(row)
238    }
239
240    /// How many rows the buffer has. Never zero.
241    pub fn row_count(&self) -> usize {
242        self.inner.text().line_count()
243    }
244
245    /// Every row, materialised.
246    ///
247    /// Not a cache: nothing is maintained between calls, so the cost lands on the
248    /// caller that wants a vector rather than on every edit. That is the whole
249    /// difference from the shim this replaced.
250    pub fn rows(&self) -> Vec<String> {
251        self.inner.text().lines().map(|l| l.to_string()).collect()
252    }
253
254    /// Rows `first..=last`, joined with newlines.
255    pub fn joined_rows(&self, first: usize, last: usize) -> String {
256        (first..=last)
257            .filter_map(|row| self.row(row))
258            .collect::<Vec<_>>()
259            .join("\n")
260    }
261
262    /// Characters in one row.
263    pub fn row_len(&self, row: usize) -> usize {
264        self.inner.text().line_len_chars(row).unwrap_or(0)
265    }
266
267    pub fn cursor(&self) -> (usize, usize) {
268        let cursor = self.inner.cursor();
269        (cursor.row(), cursor.column().get())
270    }
271
272    pub fn is_empty(&self) -> bool {
273        self.inner.text().len_bytes() == 0
274    }
275
276    pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
277        let span = self.inner.selection()?;
278        Some((rc(span.start()), rc(span.end())))
279    }
280
281    pub fn yank_text(&self) -> String {
282        self.yank.clone()
283    }
284
285    pub fn set_yank_text(&mut self, text: impl Into<String>) {
286        self.yank = text.into();
287    }
288
289    pub fn search_pattern(&self) -> Option<&regex::Regex> {
290        self.search.as_ref()
291    }
292
293    pub fn take_outcome(&mut self) -> EditOutcome {
294        std::mem::take(&mut self.pending)
295    }
296
297    // ── Groups ───────────────────────────────────────────────────────────────
298
299    /// Run `f` as one **undo group**.
300    ///
301    /// Every mutation inside lands in a single history entry, however many
302    /// primitives it takes. Nested calls belong to the outermost group, so a
303    /// compound action built from the single-mutation helpers is still one undo.
304    pub fn edit<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
305        if self.depth > 0 {
306            return f(self);
307        }
308        self.depth = 1;
309        self.group_started = false;
310        let out = f(self);
311        self.depth = 0;
312        self.group_started = false;
313        out
314    }
315
316    /// The next mutation joins the previous group instead of starting one.
317    ///
318    /// The policy is the backend's, because only it knows what the user was doing
319    /// — mid-word against after a pause, inside an Insert session against having
320    /// left it. This is the mechanism; `typing_run` and the vim engine are the two
321    /// callers that hold an opinion.
322    pub fn continue_group(&mut self) {
323        self.continue_group = true;
324    }
325
326    /// Apply one primitive as its own group, or as part of an open one.
327    fn mutate(&mut self, f: impl FnOnce(&mut crate::ropetext::Txn<'_>)) -> bool {
328        let extending =
329            (self.depth > 0 && self.group_started) || std::mem::take(&mut self.continue_group);
330        let mut txn = if extending {
331            self.inner.begin_extending()
332        } else {
333            self.inner.begin()
334        };
335        f(&mut txn);
336        let change = txn.commit();
337        if self.depth > 0 {
338            self.group_started = true;
339        }
340        self.record(change)
341    }
342
343    fn record(&mut self, change: Option<Change>) -> bool {
344        let Some(change) = change else {
345            return false;
346        };
347        self.pending.changed = true;
348        self.pending.bulk |= change.is_bulk();
349        self.pending.line_delta += change.line_delta();
350        self.pending.damage = Some(match self.pending.damage.take() {
351            Some(seen) => {
352                // `seen` was recorded against the text as it stood before *this*
353                // change, which may have moved those rows. Hulling the two
354                // directly unions ranges from two different numberings, and the
355                // result is not a superset of either: an edit high in the buffer
356                // followed by one above it that adds a line leaves the first
357                // edit's row below the hull's end, so it is never re-parsed and
358                // renders stale. Bring it into the current numbering first.
359                let seen = shift_rows(seen, change.rows().start, change.line_delta());
360                seen.start.min(change.rows().start)..seen.end.max(change.rows().end)
361            }
362            None => change.rows(),
363        });
364        true
365    }
366
367    // ── Mutations ────────────────────────────────────────────────────────────
368
369    pub fn insert_str(&mut self, s: impl AsRef<str>) -> bool {
370        let text = s.as_ref().to_string();
371        // The anchor goes whether or not it spanned anything, as it does for a
372        // delete: typing is not a selection gesture either.
373        let span = self.inner.selection().filter(|span| !span.is_empty());
374        self.inner.clear_selection();
375        let cursor = self.inner.cursor();
376        self.goal = None;
377        self.mutate(|txn| match span {
378            Some(span) => {
379                txn.replace(span, &text);
380            }
381            None => {
382                txn.insert(cursor, &text);
383            }
384        })
385    }
386
387    pub fn insert_char(&mut self, c: char) {
388        self.insert_str(c.to_string());
389    }
390
391    pub fn insert_newline(&mut self) {
392        self.insert_str("\n");
393    }
394
395    /// Delete `clusters` grapheme clusters forward, a line break counting as one.
396    ///
397    /// Clusters and not scalars, because a delete may not leave half a character
398    /// behind: `forward_by` steps whole clusters, so a caller counting scalars
399    /// over a flag or a ZWJ emoji spends the difference on the text after it.
400    pub fn delete_str(&mut self, clusters: usize) -> bool {
401        if self.take_selection() {
402            return true;
403        }
404        if clusters == 0 {
405            return false;
406        }
407        let from = self.inner.cursor();
408        let to = self.forward_by(from, clusters);
409        self.delete_between(from, to, Yank::Keep)
410    }
411
412    /// Backspace.
413    pub fn delete_char(&mut self) -> bool {
414        if self.take_selection() {
415            return true;
416        }
417        let to = self.inner.cursor();
418        let from = motion::prev_cluster(self.inner.text(), to);
419        // A backspace does not fill the register; only `delete_str`, the word
420        // deletes and `cut` do. Matching the incumbent, which is also vim: `x`
421        // yanks, but a plain backspace in Insert does not.
422        self.delete_between(from, to, Yank::Discard)
423    }
424
425    /// Forward delete.
426    pub fn delete_next_char(&mut self) -> bool {
427        if self.take_selection() {
428            return true;
429        }
430        let from = self.inner.cursor();
431        let to = motion::next_cluster(self.inner.text(), from);
432        self.delete_between(from, to, Yank::Discard)
433    }
434
435    pub fn delete_word(&mut self) -> bool {
436        if self.take_selection() {
437            return true;
438        }
439        let to = self.inner.cursor();
440        let text = self.inner.text();
441        // The incumbent's cascade, which *is* the contract: a word start on this
442        // row, else the row's start, else the line break before it. The last case
443        // is why this cannot simply be a row-local motion.
444        let candidate = motion::word_start_back(text, to, Words::Small);
445        let (from, yank) = if candidate.row() == to.row() && candidate.byte() < to.byte() {
446            (candidate, Yank::Keep)
447        } else if to.column().get() > 0 {
448            (motion::row_start(text, to), Yank::Keep)
449        } else {
450            // Joining rows goes through the incumbent's `delete_newline`, which
451            // does not fill the register. A pasted newline in place of the last
452            // yanked word is a surprising thing to hand back.
453            (motion::prev_cluster(text, to), Yank::Discard)
454        };
455        self.delete_between(from, to, yank)
456    }
457
458    pub fn delete_next_word(&mut self) -> bool {
459        if self.take_selection() {
460            return true;
461        }
462        let from = self.inner.cursor();
463        let text = self.inner.text();
464        // Mirror of `delete_word`: the end of the word at or after the cursor on
465        // this row, else the row's end, else the line break after it. `word_end_
466        // at_or_after` rather than `word_end_forward`, because deleting to the end
467        // of a word must name the word the cursor is *in* — vim's `e` deliberately
468        // looks past it.
469        let candidate = motion::word_end_at_or_after(text, from, Words::Small);
470        let row_end = motion::row_end(text, from);
471        let (to, yank) = match candidate {
472            Some(end) if end.row() == from.row() && end.byte() > from.byte() => (end, Yank::Keep),
473            _ if from.byte() < row_end.byte() => (row_end, Yank::Keep),
474            _ => (motion::next_cluster(text, from), Yank::Discard),
475        };
476        self.delete_between(from, to, yank)
477    }
478
479    pub fn cut(&mut self) -> bool {
480        // Takes the anchor whether or not it spanned anything, like every other
481        // operation that consumes a selection.
482        let span = self.inner.selection().filter(|span| !span.is_empty());
483        self.inner.clear_selection();
484        let Some(span) = span else {
485            return false;
486        };
487        self.yank = self
488            .inner
489            .text()
490            .slice(span)
491            .map(|text| text.to_string())
492            .unwrap_or_default();
493        self.goal = None;
494        self.mutate(|txn| {
495            txn.delete(span);
496        })
497    }
498
499    /// Copying reads: it leaves the selection where it is, and an empty one leaves
500    /// the register alone rather than emptying it.
501    pub fn copy(&mut self) {
502        if let Some(span) = self.inner.selection().filter(|span| !span.is_empty())
503            && let Some(text) = self.inner.text().slice(span)
504        {
505            self.yank = text.to_string();
506        }
507    }
508
509    pub fn paste(&mut self) -> bool {
510        if self.yank.is_empty() {
511            return false;
512        }
513        let text = std::mem::take(&mut self.yank);
514        let changed = self.insert_str(&text);
515        self.yank = text;
516        changed
517    }
518
519    /// Deleting a selection as a *side effect* of typing or of a forward delete
520    /// does not fill the register; an explicit delete does. That asymmetry is the
521    /// incumbent's and vim's both: `d` fills the unnamed register, typing over a
522    /// selection does not.
523    /// Take the selection and delete it, reporting whether anything went.
524    ///
525    /// Taking it is unconditional: an empty selection is not a range, so the
526    /// caller proceeds as though there were none — but the anchor is gone either
527    /// way. Leaving it alive is how an invisible selection outlives the gesture
528    /// that made it — a defect that cost two notes before it was found.
529    fn take_selection(&mut self) -> bool {
530        let span = self.inner.selection().filter(|span| !span.is_empty());
531        self.inner.clear_selection();
532        let Some(span) = span else {
533            return false;
534        };
535        self.delete_between(span.start(), span.end(), Yank::Discard)
536    }
537
538    fn delete_between(&mut self, from: Position, to: Position, yank: Yank) -> bool {
539        let Some(span) = self.inner.text().span(from, to) else {
540            return false;
541        };
542        if span.is_empty() {
543            return false;
544        }
545        if yank == Yank::Keep
546            && let Some(text) = self.inner.text().slice(span)
547        {
548            self.yank = text.to_string();
549        }
550        self.goal = None;
551        self.mutate(|txn| {
552            txn.delete(span);
553        })
554    }
555
556    // ── History ──────────────────────────────────────────────────────────────
557
558    pub fn undo(&mut self) -> bool {
559        let change = self.inner.undo();
560        self.after_history(change)
561    }
562
563    pub fn redo(&mut self) -> bool {
564        let change = self.inner.redo();
565        self.after_history(change)
566    }
567
568    /// The engine restores the selection an entry began with, which the incumbent
569    /// does not. Dropping it keeps undo behaving as it does today: a selection is
570    /// painted, so putting one back is a visible change, and an engine swap is the
571    /// wrong place to make one. The capability stays in the engine for when it is
572    /// asked for on purpose.
573    fn after_history(&mut self, change: Option<Change>) -> bool {
574        if change.is_none() {
575            // Nothing to undo: an operation that did not happen changes nothing,
576            // the selection included.
577            return false;
578        }
579        self.goal = None;
580        self.inner.clear_selection();
581        self.record(change)
582    }
583
584    // ── Cursor and selection ─────────────────────────────────────────────────
585
586    /// Move the cursor, extending a live selection.
587    ///
588    /// Directional movement keeps the anchor deliberately: that is how vim's
589    /// Visual mode extends.
590    pub fn move_cursor(&mut self, movement: CursorMove) {
591        let text = self.inner.text();
592        let from = self.inner.cursor();
593        let goal = self
594            .goal
595            .filter(|_| movement.is_vertical())
596            .unwrap_or_else(|| from.column());
597
598        let to = match movement {
599            CursorMove::Forward => motion::next_cluster(text, from),
600            CursorMove::Back => motion::prev_cluster(text, from),
601            CursorMove::Up => motion::vertical(text, from, -1, Goal::Column(goal)),
602            CursorMove::Down => motion::vertical(text, from, 1, Goal::Column(goal)),
603            CursorMove::Head => motion::row_start(text, from),
604            CursorMove::End => motion::row_end(text, from),
605            // The first and last *row*, keeping the column — not the start and
606            // end of the text, which is a different place on a non-empty row.
607            CursorMove::Top => {
608                let up = -(from.row() as isize);
609                motion::vertical(text, from, up, Goal::Column(goal))
610            }
611            CursorMove::Bottom => {
612                let down = (text.line_count().saturating_sub(1) as isize) - from.row() as isize;
613                motion::vertical(text, from, down, Goal::Column(goal))
614            }
615            CursorMove::WordForward => motion::word_start_forward(text, from, Words::Small),
616            CursorMove::WordBack => motion::word_start_back(text, from, Words::Small),
617            CursorMove::WordForwardBig => motion::word_start_forward(text, from, Words::Big),
618            CursorMove::WordBackBig => motion::word_start_back(text, from, Words::Big),
619            // Inclusive, like `WordEnd`: a cursor landing on a word's end wants the
620            // last cluster, not the place after it.
621            CursorMove::WordEndBig => match motion::word_end_forward(text, from, Words::Big) {
622                Some(end) => motion::prev_cluster(text, end),
623                None => from,
624            },
625            CursorMove::WordEndBack { big } => {
626                let words = if big { Words::Big } else { Words::Small };
627                match motion::word_end_back(text, from, words) {
628                    Some(end) => motion::prev_cluster(text, end),
629                    None => from,
630                }
631            }
632            CursorMove::MatchingPair => motion::matching_bracket(text, from).unwrap_or(from),
633            // The crate's word end is exclusive — just past the last cluster —
634            // because that is what an operator range wants. A *cursor* landing on
635            // a word end wants the last cluster itself, as vim's `e` does. This is
636            // the inclusive-to-half-open conversion CONTEXT names under **span
637            // kind**, and the adapter is where it belongs: the engine holds no view
638            // on which convention a caller uses.
639            CursorMove::WordEnd => match motion::word_end_forward(text, from, Words::Small) {
640                Some(end) => motion::prev_cluster(text, end),
641                // Nothing ahead: the incumbent walks to the end of the text rather
642                // than staying put, and vim's `e` on a trailing blank line does the
643                // same.
644                None => motion::text_end(text),
645            },
646            CursorMove::ParagraphForward => motion::paragraph_forward(text, from),
647            CursorMove::ParagraphBack => motion::paragraph_back(text, from),
648            CursorMove::Jump(row, column) => {
649                match text.position(row, Column::new(column)) {
650                    Some(position) => position,
651                    // Refused, not clamped: a keypress that did nothing is
652                    // recoverable in a way one that edited elsewhere is not.
653                    None => return,
654                }
655            }
656        };
657
658        self.goal = if movement.is_vertical() {
659            Some(goal)
660        } else {
661            None
662        };
663        self.place(to);
664    }
665
666    /// Move the cursor to `(row, col)`, refusing a position the buffer cannot
667    /// address rather than landing somewhere else.
668    pub fn jump_to(&mut self, row: usize, col: usize) -> bool {
669        let Some(to) = self.inner.text().position(row, Column::new(col)) else {
670            return false;
671        };
672        self.goal = None;
673        self.place(to);
674        true
675    }
676
677    /// Move to a position the caller worked out itself — a visual-line motion,
678    /// which needs a layout the buffer does not have.
679    pub fn move_to(&mut self, to: Position) {
680        if self.inner.text().is_stale(to) {
681            return;
682        }
683        self.goal = None;
684        self.place(to);
685    }
686
687    fn place(&mut self, to: Position) {
688        if self.inner.selection().is_some() {
689            self.inner.extend_to(to);
690        } else {
691            self.inner.set_cursor(to);
692        }
693    }
694
695    pub fn start_selection(&mut self) {
696        // Anchors *here*, even when a selection is already live: starting one is
697        // a fresh gesture, not an extension of the last.
698        let cursor = self.inner.cursor();
699        self.inner.clear_selection();
700        self.inner.extend_to(cursor);
701    }
702
703    pub fn cancel_selection(&mut self) {
704        self.inner.clear_selection();
705    }
706
707    pub fn select_all(&mut self) {
708        let span = self.inner.text().full_span();
709        self.inner.select(span);
710    }
711
712    pub fn set_selection(&mut self, start: (usize, usize), end: (usize, usize)) -> bool {
713        let text = self.inner.text();
714        let Some(from) = text.position(start.0, Column::new(start.1)) else {
715            return false;
716        };
717        let Some(to) = text.position(end.0, Column::new(end.1)) else {
718            return false;
719        };
720        let Some(span) = text.span(from, to) else {
721            return false;
722        };
723        self.inner.select(span)
724    }
725
726    /// The text between two `(row, col)` pairs, in either order; `None` when the
727    /// range is empty or names a position the buffer cannot address.
728    pub fn text_between(&self, start: (usize, usize), end: (usize, usize)) -> Option<String> {
729        let span = self
730            .span_between(start, end)
731            .filter(|span| !span.is_empty())?;
732        self.inner.text().slice(span).map(|text| text.into_owned())
733    }
734
735    /// The selected text, or `None` when nothing — or nothing of width — is
736    /// selected.
737    pub fn selection_text(&self) -> Option<String> {
738        let (start, end) = self.selection_range()?;
739        self.text_between(start, end)
740    }
741
742    // ── Indent ───────────────────────────────────────────────────────────────
743
744    /// Indent or dedent `rows` by one **indent step**, as one **undo group** —
745    /// and one transaction.
746    ///
747    /// Indenting puts `indent_width` spaces at the start of every row — always
748    /// spaces, never a tab. Dedenting takes up to one step of leading spaces,
749    /// or the run up to and including a first tab, which counts as a whole
750    /// step: `"  \tfoo"` loses both spaces and the tab and stops there.
751    ///
752    /// The cursor keeps the character it sat on — its column moves by its own
753    /// row's change, so `>>` on the `n` of `one` leaves it on `n`, neovim's
754    /// rule — and a selection is put back the same way, endpoint by endpoint.
755    /// Both fall out of the engine's own mapping across the transaction, so the
756    /// history entry holds the cursor's real before and after, and an undo or
757    /// redo lands where the user was. Rows past the end are ignored. Reports
758    /// whether any text changed; a dedent that finds nothing to remove changes
759    /// nothing and says so.
760    pub fn indent_rows(&mut self, rows: std::ops::RangeInclusive<usize>, dedent: bool) -> bool {
761        let step = self.indent_width.get() as usize;
762        let first = *rows.start();
763        let last = (*rows.end()).min(self.row_count() - 1);
764        if first > last {
765            return false;
766        }
767        // Address every row's edit against the text as it stands; the
768        // transaction carries each later one across the earlier ones.
769        let text = self.inner.text();
770        let mut edits: Vec<RowEdit> = Vec::with_capacity(last - first + 1);
771        let mut deltas: Vec<isize> = Vec::with_capacity(last - first + 1);
772        for row in first..=last {
773            let at = text
774                .position(row, Column::new(0))
775                .expect("row is within the buffer");
776            if dedent {
777                let count = self.leading_step(row);
778                if count > 0
779                    && let Some(span) = text.span(at, self.forward_by(at, count))
780                {
781                    edits.push(RowEdit::Delete(span));
782                }
783                deltas.push(-(count as isize));
784            } else {
785                edits.push(RowEdit::Insert(at));
786                deltas.push(step as isize);
787            }
788        }
789        if edits.is_empty() {
790            return false;
791        }
792        let selection = self.selection_range();
793        let shifted = |(row, col): (usize, usize)| -> (usize, usize) {
794            if row < first || row > last {
795                return (row, col);
796            }
797            (row, col.saturating_add_signed(deltas[row - first]))
798        };
799        let spaces = " ".repeat(step);
800        self.goal = None;
801        self.mutate(|txn| {
802            for edit in &edits {
803                match *edit {
804                    RowEdit::Insert(at) => {
805                        txn.insert(at, &spaces);
806                    }
807                    RowEdit::Delete(span) => {
808                        txn.delete(span);
809                    }
810                }
811            }
812            // An edit drops the anchor; a selection that was live goes back,
813            // each endpoint shifted by its own row's change.
814            if let Some((start, end)) = selection {
815                let (start, end) = (shifted(start), shifted(end));
816                let text = txn.text();
817                if let Some(from) = text.position(start.0, Column::new(start.1))
818                    && let Some(to) = text.position(end.0, Column::new(end.1))
819                    && let Some(span) = text.span(from, to)
820                {
821                    txn.select(span);
822                }
823            }
824        })
825    }
826
827    /// What one dedent removes from the front of `row`: up to a step of spaces,
828    /// or the run up to and including a first tab. Counted in grapheme
829    /// clusters — the unit the delete steps — so a space that carries a
830    /// combining mark ends the run rather than being half-removed.
831    fn leading_step(&self, row: usize) -> usize {
832        use unicode_segmentation::UnicodeSegmentation;
833        let step = self.indent_width.get() as usize;
834        let Some(line) = self.row(row) else {
835            return 0;
836        };
837        let mut count = 0;
838        for cluster in line.graphemes(true).take(step) {
839            match cluster {
840                " " => count += 1,
841                "\t" => return count + 1,
842                _ => break,
843            }
844        }
845        count
846    }
847
848    // ── Search ───────────────────────────────────────────────────────────────
849    //
850    // Not the engine's business: it holds the pattern because vim's
851    // `n`/`N` outlive the find bar, and matches a row at a time because a **find
852    // pattern** can never span a newline.
853
854    pub fn set_search_pattern(&mut self, pattern: &str) -> Result<(), regex::Error> {
855        if pattern.is_empty() {
856            self.search = None;
857            return Ok(());
858        }
859        self.search = Some(regex::Regex::new(pattern)?);
860        Ok(())
861    }
862
863    /// Move the cursor to the next match. Never extends a selection.
864    pub fn search_forward(&mut self, match_cursor: bool) -> bool {
865        self.step_search(false, match_cursor)
866    }
867
868    /// Move the cursor to the previous match. Never extends a selection.
869    pub fn search_back(&mut self, match_cursor: bool) -> bool {
870        self.step_search(true, match_cursor)
871    }
872
873    /// Repeat the persisted pattern (vim `n` / `N`).
874    pub fn search_repeat(&mut self, backward: bool) -> bool {
875        self.step_search(backward, false)
876    }
877
878    fn step_search(&mut self, backward: bool, match_cursor: bool) -> bool {
879        // A search is not a selection gesture, so the anchor goes first — and
880        // here that is one call rather than an invariant to remember, because
881        // `set_cursor` drops it and `extend_to` keeps it.
882        self.cancel_selection();
883        let Some(found) = self.find_match(backward, match_cursor) else {
884            return false;
885        };
886        self.goal = None;
887        self.inner.set_cursor(found);
888        true
889    }
890
891    fn find_match(&self, backward: bool, match_cursor: bool) -> Option<Position> {
892        let pattern = self.search.as_ref()?;
893        let text = self.inner.text();
894        let cursor = self.inner.cursor();
895        let rows = text.line_count();
896
897        // `0..=rows` visits the cursor's row twice: once at the start, and once
898        // more at the end. That last visit IS the wrap, so the hits the first
899        // visit stepped over — the ones behind the cursor — are exactly what it
900        // is for. Filtering them again there is what made search unable to come
901        // back around to them.
902        for step in 0..=rows {
903            let wrapped = step == rows;
904            let row = if backward {
905                (cursor.row() + rows - (step % rows.max(1))) % rows
906            } else {
907                (cursor.row() + step) % rows
908            };
909            let line = text.line(row)?;
910            let mut hits: Vec<usize> = pattern
911                .find_iter(&line)
912                .map(|found| line[..found.start()].chars().count())
913                .collect();
914            if backward {
915                hits.reverse();
916            }
917            for column in hits {
918                let same_row = row == cursor.row();
919                let beyond = if backward {
920                    column < cursor.column().get()
921                } else if match_cursor {
922                    column >= cursor.column().get()
923                } else {
924                    column > cursor.column().get()
925                };
926                if wrapped || !same_row || beyond {
927                    // A match can start inside a grapheme cluster — a regex like
928                    // `.` or a search for a scalar that also appears inside a ZWJ
929                    // sequence. That start is not addressable, so skip the
930                    // candidate; abandoning the whole scan there would report "no
931                    // match" while the bar's own count says otherwise.
932                    if let Some(at) = text.position(row, Column::new(column)) {
933                        return Some(at);
934                    }
935                }
936            }
937        }
938        None
939    }
940
941    /// The span of the match starting exactly at the cursor, if any.
942    pub fn match_at_cursor(&self) -> Option<((usize, usize), (usize, usize))> {
943        let pattern = self.search.as_ref()?;
944        let text = self.inner.text();
945        let cursor = self.inner.cursor();
946        let line = text.line(cursor.row())?;
947        let byte = line
948            .char_indices()
949            .nth(cursor.column().get())
950            .map(|(at, _)| at)
951            .unwrap_or(line.len());
952        let found = pattern.find_at(&line, byte)?;
953        if found.start() != byte {
954            return None;
955        }
956        let chars = line[found.range()].chars().count();
957        Some((rc(cursor), (cursor.row(), cursor.column().get() + chars)))
958    }
959
960    // ── Helpers ──────────────────────────────────────────────────────────────
961
962    /// `chars` scalars forward of `from`, clamped to the end of the text.
963    fn forward_by(&self, from: Position, chars: usize) -> Position {
964        let text = self.inner.text();
965        let mut at = from;
966        for _ in 0..chars {
967            let next = motion::next_cluster(text, at);
968            if next.byte() == at.byte() {
969                break;
970            }
971            at = next;
972        }
973        at
974    }
975
976    /// The span between two `(row, col)` pairs, for callers that still speak in
977    /// them.
978    fn span_between(&self, start: (usize, usize), end: (usize, usize)) -> Option<Span> {
979        let text = self.inner.text();
980        let from = text.position(start.0, Column::new(start.1))?;
981        let to = text.position(end.0, Column::new(end.1))?;
982        text.span(from, to)
983    }
984}
985
986fn rc(position: Position) -> (usize, usize) {
987    (position.row(), position.column().get())
988}
989
990#[cfg(test)]
991mod search_tests {
992    use super::*;
993    use crate::ropetext::Text;
994
995    fn buffer(text: &str, pattern: &str, cursor: (usize, usize)) -> RopeBuffer {
996        let mut buf = RopeBuffer::new(Text::from(text));
997        buf.set_search_pattern(pattern).expect("valid pattern");
998        buf.move_cursor(CursorMove::Jump(cursor.0, cursor.1));
999        buf
1000    }
1001
1002    #[test]
1003    fn a_forward_search_wraps_to_a_match_behind_the_cursor() {
1004        // One row, one match, cursor past it. Before the wrap visit stopped
1005        // re-filtering, this reported no match while the find bar counted one.
1006        let mut buf = buffer("xx foo", "foo", (0, 5));
1007        assert!(buf.search_forward(false), "the match is behind the cursor");
1008        assert_eq!(buf.cursor(), (0, 3));
1009    }
1010
1011    #[test]
1012    fn a_backward_search_wraps_to_a_match_ahead_of_the_cursor() {
1013        let mut buf = buffer("xx foo", "foo", (0, 1));
1014        assert!(buf.search_back(false));
1015        assert_eq!(buf.cursor(), (0, 3));
1016    }
1017
1018    #[test]
1019    fn wrapping_crosses_rows_back_to_the_cursors_own_row() {
1020        let mut buf = buffer("aaa\nxx foo", "foo", (1, 5));
1021        assert!(buf.search_forward(false));
1022        assert_eq!(buf.cursor(), (1, 3));
1023    }
1024
1025    #[test]
1026    fn the_only_match_is_re_offered_rather_than_reported_missing() {
1027        // vim's answer: "search hit BOTTOM, continuing at TOP" lands back on the
1028        // same match. Reporting false would paint "no match" over a match that is
1029        // highlighted on screen.
1030        let mut buf = buffer("xx foo", "foo", (0, 3));
1031        assert!(buf.search_forward(false), "the one match is still a match");
1032        assert_eq!(
1033            buf.cursor(),
1034            (0, 3),
1035            "and the cursor has nowhere else to go"
1036        );
1037    }
1038
1039    #[test]
1040    fn a_match_starting_inside_a_cluster_is_skipped_not_fatal() {
1041        // "\u{1F469}\u{200D}\u{1F4BB}" is one cluster; the laptop scalar sits at
1042        // char column 2, inside it. That start is unaddressable — but the real
1043        // match on row 1 is, and abandoning the scan at the first unaddressable
1044        // candidate is what made the bar say "no match" beside a count of two.
1045        let mut buf = buffer(
1046            "\u{1F469}\u{200D}\u{1F4BB}\nx\u{1F4BB}",
1047            "\u{1F4BB}",
1048            (0, 0),
1049        );
1050        assert!(buf.search_forward(false), "the row 1 match is reachable");
1051        assert_eq!(buf.cursor(), (1, 1));
1052    }
1053}
1054
1055#[cfg(test)]
1056mod cluster_tests {
1057    use super::*;
1058    use crate::ropetext::Text;
1059
1060    #[test]
1061    fn delete_str_spends_its_count_on_clusters() {
1062        // "[[" plus a regional-indicator flag: 4 scalars, 3 clusters. Three is
1063        // what removes exactly `[[` and the flag — a caller counting the four
1064        // scalars would take the space after them too.
1065        let mut buf = RopeBuffer::new(Text::from("[[\u{1F1EA}\u{1F1F8} rest"));
1066        buf.move_cursor(CursorMove::Jump(0, 0));
1067        buf.delete_str(3);
1068        assert_eq!(buf.rows(), &[" rest"]);
1069    }
1070
1071    #[test]
1072    fn inserting_before_a_combining_mark_keeps_the_cursor_addressable() {
1073        // A row starting with a lone combining acute — NFD text pasted from
1074        // macOS. Typing 'a' in front of it makes "a\u{301}", one cluster, and
1075        // the post-edit cursor byte lands inside it.
1076        let mut buf = RopeBuffer::new(Text::from("\u{301}f"));
1077        buf.move_cursor(CursorMove::Jump(0, 0));
1078        buf.insert_char('a');
1079        assert_eq!(buf.rows(), &["a\u{301}f"]);
1080    }
1081}
1082
1083#[cfg(test)]
1084mod damage_tests {
1085    use super::*;
1086    use crate::ropetext::Text;
1087
1088    #[test]
1089    fn damage_from_several_edits_is_in_one_numbering() {
1090        // Two mutations in one group, the second ABOVE the first and changing
1091        // the line count — so the first edit's row moves before the group ends.
1092        let mut buf = RopeBuffer::new(Text::from("r0\nr1\nr2\nr3\nr4"));
1093        buf.edit(|b| {
1094            b.move_cursor(CursorMove::Jump(4, 0));
1095            b.insert_str("X");
1096            b.move_cursor(CursorMove::Jump(0, 0));
1097            b.insert_newline();
1098        });
1099        assert_eq!(buf.rows(), ["", "r0", "r1", "r2", "r3", "Xr4"]);
1100
1101        let damage = buf.take_outcome().damage.expect("the edits were reported");
1102        assert!(
1103            damage.contains(&5),
1104            "the row edited first is row 5 once the group ends, but the damage \
1105             reported was {damage:?} — a range in the older numbering"
1106        );
1107    }
1108}
1109
1110#[cfg(test)]
1111mod read_tests {
1112    use super::*;
1113    use crate::ropetext::Text;
1114
1115    #[test]
1116    fn text_between_slices_by_char_columns_across_rows_in_either_order() {
1117        let buf = RopeBuffer::new(Text::from("héllo🦀\nworld"));
1118        assert_eq!(
1119            buf.text_between((0, 1), (1, 2)).as_deref(),
1120            Some("éllo🦀\nwo")
1121        );
1122        assert_eq!(
1123            buf.text_between((1, 2), (0, 1)).as_deref(),
1124            Some("éllo🦀\nwo")
1125        );
1126    }
1127
1128    #[test]
1129    fn an_empty_or_unaddressable_range_is_none() {
1130        let buf = RopeBuffer::new(Text::from("abc"));
1131        assert_eq!(buf.text_between((0, 1), (0, 1)), None);
1132        assert_eq!(buf.text_between((0, 0), (7, 0)), None);
1133    }
1134
1135    #[test]
1136    fn selection_text_is_the_live_selection_or_none() {
1137        let mut buf = RopeBuffer::new(Text::from("hello world"));
1138        assert_eq!(buf.selection_text(), None);
1139        assert!(buf.set_selection((0, 0), (0, 5)));
1140        assert_eq!(buf.selection_text().as_deref(), Some("hello"));
1141        buf.start_selection();
1142        assert_eq!(
1143            buf.selection_text(),
1144            None,
1145            "a zero-width selection is not text"
1146        );
1147    }
1148}
1149
1150#[cfg(test)]
1151mod indent_tests {
1152    use super::*;
1153    use crate::ropetext::Text;
1154
1155    fn buffer(text: &str) -> RopeBuffer {
1156        RopeBuffer::new(Text::from(text))
1157    }
1158
1159    #[test]
1160    fn indent_inserts_one_step_of_spaces_never_a_tab() {
1161        let mut buf = buffer("foo\nbar");
1162        assert!(buf.indent_rows(0..=1, false));
1163        assert_eq!(buf.rows(), &["    foo", "    bar"]);
1164    }
1165
1166    #[test]
1167    fn indent_follows_indent_width() {
1168        let mut buf = buffer("x");
1169        buf.set_indent_width(2);
1170        assert!(buf.indent_rows(0..=0, false));
1171        assert_eq!(buf.rows(), &["  x"]);
1172    }
1173
1174    #[test]
1175    fn dedent_removes_up_to_one_step_of_spaces() {
1176        let mut buf = buffer("        x\n  y\nz");
1177        assert!(buf.indent_rows(0..=2, true));
1178        assert_eq!(buf.rows(), &["    x", "y", "z"]);
1179    }
1180
1181    #[test]
1182    fn dedent_counts_a_leading_tab_as_a_whole_step() {
1183        let mut buf = buffer("\t\tx\n  \ty");
1184        assert!(buf.indent_rows(0..=1, true));
1185        // One tab is one step; the spaces before a tab go with it, and the
1186        // step ends there.
1187        assert_eq!(buf.rows(), &["\tx", "y"]);
1188    }
1189
1190    #[test]
1191    fn dedent_stops_at_a_space_that_carries_a_combining_mark() {
1192        // "  \u{301}foo": the second space and the acute accent are one cluster.
1193        let mut buf = buffer("  \u{301}foo");
1194        assert!(buf.set_selection((0, 3), (0, 6)));
1195        assert!(buf.indent_rows(0..=0, true));
1196        assert_eq!(buf.rows(), &[" \u{301}foo"]);
1197        assert_eq!(buf.selection_range(), Some(((0, 2), (0, 5))));
1198    }
1199
1200    #[test]
1201    fn dedent_with_nothing_to_remove_reports_no_change() {
1202        let mut buf = buffer("foo");
1203        assert!(!buf.indent_rows(0..=0, true));
1204        assert_eq!(buf.rows(), &["foo"]);
1205        assert!(!buf.take_outcome().changed);
1206    }
1207
1208    #[test]
1209    fn rows_past_the_end_are_ignored() {
1210        let mut buf = buffer("a\nb");
1211        assert!(buf.indent_rows(1..=9, false));
1212        assert_eq!(buf.rows(), &["a", "    b"]);
1213        assert!(!buf.indent_rows(5..=9, false));
1214        assert_eq!(buf.rows(), &["a", "    b"]);
1215    }
1216
1217    #[test]
1218    fn the_cursor_keeps_its_character() {
1219        let mut buf = buffer("one\ntwo");
1220        assert!(buf.jump_to(0, 1)); // on 'n'
1221        buf.indent_rows(0..=1, false);
1222        assert_eq!(buf.cursor(), (0, 1 + 4));
1223        buf.indent_rows(0..=1, true);
1224        assert_eq!(buf.cursor(), (0, 1));
1225    }
1226
1227    #[test]
1228    fn a_cursor_outside_the_rows_does_not_move() {
1229        let mut buf = buffer("a\nb\nc");
1230        assert!(buf.jump_to(2, 1));
1231        buf.indent_rows(0..=1, false);
1232        assert_eq!(buf.cursor(), (2, 1));
1233    }
1234
1235    #[test]
1236    fn a_dedent_never_pushes_the_cursor_below_column_zero() {
1237        let mut buf = buffer("    x");
1238        assert!(buf.jump_to(0, 2));
1239        buf.indent_rows(0..=0, true);
1240        assert_eq!(buf.cursor(), (0, 0));
1241    }
1242
1243    #[test]
1244    fn the_selection_is_put_back_shifted_with_its_rows() {
1245        let mut buf = buffer("hello world\nnext");
1246        assert!(buf.set_selection((0, 6), (1, 2)));
1247        buf.indent_rows(0..=1, false);
1248        assert_eq!(buf.selection_range(), Some(((0, 10), (1, 6))));
1249        assert_eq!(buf.rows()[0].trim_start(), "hello world");
1250    }
1251
1252    #[test]
1253    fn a_dedent_under_a_selection_shifts_each_endpoint_by_its_own_row() {
1254        let mut buf = buffer("    foo\n  bar\nbaz");
1255        assert!(buf.set_selection((0, 4), (2, 3)));
1256        assert!(buf.indent_rows(0..=2, true));
1257        assert_eq!(buf.rows(), &["foo", "bar", "baz"]);
1258        assert_eq!(buf.selection_range(), Some(((0, 0), (2, 3))));
1259    }
1260
1261    #[test]
1262    fn a_block_indent_is_one_undo_group() {
1263        let mut buf = buffer("a\nb\nc");
1264        buf.indent_rows(0..=2, false);
1265        assert!(buf.undo(), "the block is one entry");
1266        assert_eq!(buf.rows(), &["a", "b", "c"]);
1267        assert!(!buf.undo(), "and has nothing left to take back");
1268    }
1269
1270    #[test]
1271    fn undo_and_redo_of_an_indent_land_the_cursor_on_its_own_character() {
1272        let mut buf = buffer("one\ntwo");
1273        assert!(buf.jump_to(0, 1)); // on 'n'
1274        buf.indent_rows(0..=1, false);
1275        assert_eq!(buf.cursor(), (0, 5));
1276        assert!(buf.undo());
1277        assert_eq!(buf.cursor(), (0, 1), "undo returns to where the user was");
1278        assert!(buf.redo());
1279        assert_eq!(
1280            buf.cursor(),
1281            (0, 5),
1282            "redo lands where the indent left them"
1283        );
1284    }
1285
1286    #[test]
1287    fn a_block_indent_is_one_transaction() {
1288        let mut buf = buffer("a\nb\nc");
1289        buf.indent_rows(0..=2, false);
1290        let outcome = buf.take_outcome();
1291        assert!(outcome.changed);
1292        assert!(outcome.bulk, "three rows in one change");
1293        assert!(buf.undo());
1294        assert_eq!(buf.rows(), &["a", "b", "c"]);
1295        assert!(!buf.undo());
1296    }
1297
1298    #[test]
1299    fn a_dedent_does_not_fill_the_yank_transport() {
1300        let mut buf = buffer("    x");
1301        buf.set_yank_text("kept");
1302        buf.indent_rows(0..=0, true);
1303        assert_eq!(buf.yank_text(), "kept");
1304    }
1305
1306    #[test]
1307    fn damage_covers_every_touched_row() {
1308        let mut buf = buffer("a\nb\nc\nd");
1309        buf.indent_rows(1..=2, false);
1310        let outcome = buf.take_outcome();
1311        assert!(outcome.changed);
1312        let damage = outcome.damage.expect("the edits were reported");
1313        assert!(damage.contains(&1) && damage.contains(&2), "{damage:?}");
1314        assert_eq!(outcome.line_delta, 0);
1315    }
1316}