Skip to main content

kimun_notes/components/text_editor/
rope_buffer.rs

1//! The **edit buffer** over `ropetext`, presenting the surface the rest of the
2//! editor already calls.
3//!
4//! This exists so the engine swap does not have to happen everywhere at once.
5//! It keeps every method name and `(row, col)` signature the `TextArea`-backed
6//! buffer had, so `vim.rs`, `find_bar.rs` and the component compiled against it
7//! unchanged. A differential proptest held it to the incumbent's behaviour
8//! operation by operation until the migration landed; it found seven real
9//! defects, three of them kimün's own, and was removed with the dependency it
10//! needed.
11//!
12//! Nothing here mirrors the text into a second representation. Callers that want
13//! rows ask for them ([`RopeBuffer::rows`]) and pay for them there; the buffer
14//! keeps one copy of the note and no derived copy in step with it.
15//!
16use crate::ropetext::motion::{self, Goal, Words};
17use crate::ropetext::{Change, Column, EditBuffer as Rope, Position, Span, Text};
18
19/// How far one indent step moves a line, in spaces, when `hard_tab_indent` is
20/// off — what Tab, `>>` and the visual `>` add, and what their inverses remove.
21///
22/// Not a tab stop, and deliberately not derived from one. A tab stop is elastic
23/// (a `\t` advances to the next multiple of it, so its width depends on where it
24/// starts) and describes how an existing character *draws*; an indent step is a
25/// fixed amount of text an edit *inserts*. Vim keeps the two apart as `tabstop`
26/// and `shiftwidth`, EditorConfig as `tab_width` and `indent_size`, and
27/// `hard_tab_indent` is exactly the setting under which they must differ: insert
28/// one literal `\t`, still draw it [`crate::ropetext::Metrics::DEFAULT_TAB_WIDTH`]
29/// cells wide. That both are 4 today is a coincidence of defaults.
30const DEFAULT_INDENT_WIDTH: u8 = 4;
31
32/// What one call to [`RopeBuffer::edit`] did, measured rather than predicted.
33///
34/// `#[must_use]` on purpose: the caller still applies these (the revision clock
35/// serves both backends and so stays on the component), and forgetting to is
36/// exactly the failure this type exists to prevent. A warning is a check; a
37/// convention is not.
38#[must_use = "an edit's outcome drives the revision bump and the parse-damage signal"]
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct EditOutcome {
41    /// The buffer's text differs from before the edit. A content comparison —
42    /// never a library return value, which can report `false` after mutating.
43    pub changed: bool,
44    /// The change is not confined to the cursor's row, so the incremental
45    /// parser's cursor damage hint would under-report it.
46    pub bulk: bool,
47    /// Which rows the edits changed, in the new text's numbering — the hull when
48    /// several ran before this was drained.
49    ///
50    /// Told by the engine rather than found by comparing the buffer with a copy
51    /// of its previous self, which is what the revision-tagged rope makes
52    /// possible. The **nvim** backend reports lines and not changes, so it
53    /// leaves this `None` and its consumer falls back to a diff.
54    pub damage: Option<std::ops::Range<usize>>,
55    /// Net rows added (or removed, when negative) by the edits behind `damage`.
56    ///
57    /// Travels with the range because the range is only meaningful in a
58    /// numbering, and a consumer that accumulates reports across several drains
59    /// has to bring the older one forward before it can union them.
60    pub line_delta: isize,
61}
62
63/// `range`, renumbered for a change of `delta` lines starting at `at`.
64///
65/// Rows above the change keep their index; the rest move with it. A `delta` of
66/// zero — every edit that stays within its rows, which is most of them — leaves
67/// the range alone.
68///
69/// Shared because damage is accumulated in two places: here, across the
70/// mutations of one group, and in the view, across the drains between two
71/// frames. Both union ranges recorded against different texts, and both are
72/// wrong in the same way without this.
73pub(super) fn shift_rows(
74    range: std::ops::Range<usize>,
75    at: usize,
76    delta: isize,
77) -> std::ops::Range<usize> {
78    let shift = |row: usize| {
79        if row < at {
80            row
81        } else {
82            row.saturating_add_signed(delta)
83        }
84    };
85    shift(range.start)..shift(range.end)
86}
87
88/// Whether a delete fills the register it removed text from.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90enum Yank {
91    Keep,
92    Discard,
93}
94
95/// A cursor movement, in the vocabulary the editor already speaks.
96///
97/// Deliberately the incumbent's variant set, so the 145 call sites need no
98/// rewriting — but `Jump` takes `usize` rather than `u16`, because clamping a
99/// row to 65535 is a defect the old widget's contract allowed, and this is the
100/// type where it stops being representable.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CursorMove {
103    Forward,
104    Back,
105    Up,
106    Down,
107    Head,
108    End,
109    Top,
110    Bottom,
111    WordForward,
112    WordBack,
113    WordEnd,
114    /// `W` — a WORD is any run of non-blanks.
115    WordForwardBig,
116    /// `B`.
117    WordBackBig,
118    /// `E`.
119    WordEndBig,
120    /// `ge` / `gE`.
121    WordEndBack {
122        big: bool,
123    },
124    /// `%`. Stays put when there is no bracket ahead on the row, or it is
125    /// unbalanced.
126    MatchingPair,
127    ParagraphForward,
128    ParagraphBack,
129    Jump(usize, usize),
130}
131
132impl CursorMove {
133    /// Whether the movement is vertical, and so keeps the goal column.
134    ///
135    /// `Top` and `Bottom` are row movements but deliberately *not* goal-preserving:
136    /// vim's `gg`/`G` go to a row's first non-blank rather than to a remembered
137    /// column, and making them sticky would be a third behaviour that neither vim
138    /// nor the incumbent has. Change #8 is about `Up`/`Down`.
139    fn is_vertical(self) -> bool {
140        matches!(self, CursorMove::Up | CursorMove::Down)
141    }
142}
143
144/// The open note's text, cursor, selection and history.
145#[derive(Debug)]
146pub struct RopeBuffer {
147    inner: Rope,
148    /// Accumulated since the last drain. The component owns the revision clock
149    /// for as long as the nvim backend has no edit buffer.
150    pending: EditOutcome,
151    /// Nesting depth of [`Self::edit`]. Above zero, a mutation extends the open
152    /// group instead of starting its own.
153    depth: u32,
154    /// Whether the open group has recorded anything yet, so the first mutation
155    /// inside `edit` starts the group and the rest extend it.
156    group_started: bool,
157    /// Set by a backend that has decided the next mutation continues what the
158    /// last one started — a typing run, an insert session. Cleared by using it,
159    /// so continuing is asked for per edit rather than left switched on.
160    continue_group: bool,
161    /// The column a vertical movement is aiming at, which is why walking down
162    /// through a short row and out the other side returns to where it started.
163    goal: Option<Column>,
164    yank: String,
165    search: Option<regex::Regex>,
166    indent_width: u8,
167    hard_tab_indent: bool,
168}
169
170impl Default for RopeBuffer {
171    fn default() -> Self {
172        Self::new(Text::new())
173    }
174}
175
176impl RopeBuffer {
177    pub fn new(text: Text) -> Self {
178        Self {
179            inner: Rope::new(text),
180            pending: EditOutcome::default(),
181            depth: 0,
182            group_started: false,
183            continue_group: false,
184            goal: None,
185            yank: String::new(),
186            search: None,
187            indent_width: DEFAULT_INDENT_WIDTH,
188            hard_tab_indent: false,
189        }
190    }
191
192    /// Replace the whole buffer, dropping the history with it.
193    pub fn replace(&mut self, text: Text) {
194        self.inner.set_text(text);
195        self.pending = EditOutcome::default();
196        self.goal = None;
197    }
198
199    pub fn text(&self) -> &Text {
200        self.inner.text()
201    }
202
203    /// Spaces one indent step inserts. Both backends read this, so `>>` and Tab
204    /// move a line by the same amount.
205    pub fn indent_width(&self) -> u8 {
206        self.indent_width
207    }
208
209    pub fn set_indent_width(&mut self, spaces: u8) {
210        self.indent_width = spaces;
211    }
212
213    pub fn hard_tab_indent(&self) -> bool {
214        self.hard_tab_indent
215    }
216
217    pub fn set_hard_tab_indent(&mut self, hard: bool) {
218        self.hard_tab_indent = hard;
219    }
220
221    pub fn snapshot(&self) -> crate::ropetext::Snapshot {
222        self.inner.snapshot()
223    }
224
225    // ── Reads ────────────────────────────────────────────────────────────────
226
227    /// One row's text, or `None` past the end.
228    pub fn row(&self, row: usize) -> Option<std::borrow::Cow<'_, str>> {
229        self.inner.text().line(row)
230    }
231
232    /// How many rows the buffer has. Never zero.
233    pub fn row_count(&self) -> usize {
234        self.inner.text().line_count()
235    }
236
237    /// Every row, materialised.
238    ///
239    /// Not a cache: nothing is maintained between calls, so the cost lands on the
240    /// caller that wants a vector rather than on every edit. That is the whole
241    /// difference from the shim this replaced.
242    pub fn rows(&self) -> Vec<String> {
243        self.inner.text().lines().map(|l| l.to_string()).collect()
244    }
245
246    /// Rows `first..=last`, joined with newlines.
247    pub fn joined_rows(&self, first: usize, last: usize) -> String {
248        (first..=last)
249            .filter_map(|row| self.row(row))
250            .collect::<Vec<_>>()
251            .join("\n")
252    }
253
254    /// Characters in one row.
255    pub fn row_len(&self, row: usize) -> usize {
256        self.inner.text().line_len_chars(row).unwrap_or(0)
257    }
258
259    pub fn cursor(&self) -> (usize, usize) {
260        let cursor = self.inner.cursor();
261        (cursor.row(), cursor.column().get())
262    }
263
264    pub fn is_empty(&self) -> bool {
265        self.inner.text().len_bytes() == 0
266    }
267
268    pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
269        let span = self.inner.selection()?;
270        Some((rc(span.start()), rc(span.end())))
271    }
272
273    pub fn yank_text(&self) -> String {
274        self.yank.clone()
275    }
276
277    pub fn set_yank_text(&mut self, text: impl Into<String>) {
278        self.yank = text.into();
279    }
280
281    pub fn search_pattern(&self) -> Option<&regex::Regex> {
282        self.search.as_ref()
283    }
284
285    pub fn take_outcome(&mut self) -> EditOutcome {
286        std::mem::take(&mut self.pending)
287    }
288
289    // ── Groups ───────────────────────────────────────────────────────────────
290
291    /// Run `f` as one **undo group**.
292    ///
293    /// Every mutation inside lands in a single history entry, however many
294    /// primitives it takes. Nested calls belong to the outermost group, so a
295    /// compound action built from the single-mutation helpers is still one undo.
296    pub fn edit<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
297        if self.depth > 0 {
298            return f(self);
299        }
300        self.depth = 1;
301        self.group_started = false;
302        let out = f(self);
303        self.depth = 0;
304        self.group_started = false;
305        out
306    }
307
308    /// The next mutation joins the previous group instead of starting one.
309    ///
310    /// The policy is the backend's, because only it knows what the user was doing
311    /// — mid-word against after a pause, inside an Insert session against having
312    /// left it. This is the mechanism; `typing_run` and the vim engine are the two
313    /// callers that hold an opinion.
314    pub fn continue_group(&mut self) {
315        self.continue_group = true;
316    }
317
318    /// Apply one primitive as its own group, or as part of an open one.
319    fn mutate(&mut self, f: impl FnOnce(&mut crate::ropetext::Txn<'_>)) -> bool {
320        let extending =
321            (self.depth > 0 && self.group_started) || std::mem::take(&mut self.continue_group);
322        let mut txn = if extending {
323            self.inner.begin_extending()
324        } else {
325            self.inner.begin()
326        };
327        f(&mut txn);
328        let change = txn.commit();
329        if self.depth > 0 {
330            self.group_started = true;
331        }
332        self.record(change)
333    }
334
335    fn record(&mut self, change: Option<Change>) -> bool {
336        let Some(change) = change else {
337            return false;
338        };
339        self.pending.changed = true;
340        self.pending.bulk |= change.is_bulk();
341        self.pending.line_delta += change.line_delta();
342        self.pending.damage = Some(match self.pending.damage.take() {
343            Some(seen) => {
344                // `seen` was recorded against the text as it stood before *this*
345                // change, which may have moved those rows. Hulling the two
346                // directly unions ranges from two different numberings, and the
347                // result is not a superset of either: an edit high in the buffer
348                // followed by one above it that adds a line leaves the first
349                // edit's row below the hull's end, so it is never re-parsed and
350                // renders stale. Bring it into the current numbering first.
351                let seen = shift_rows(seen, change.rows().start, change.line_delta());
352                seen.start.min(change.rows().start)..seen.end.max(change.rows().end)
353            }
354            None => change.rows(),
355        });
356        true
357    }
358
359    // ── Mutations ────────────────────────────────────────────────────────────
360
361    pub fn insert_str(&mut self, s: impl AsRef<str>) -> bool {
362        let text = s.as_ref().to_string();
363        // The anchor goes whether or not it spanned anything, as it does for a
364        // delete: typing is not a selection gesture either.
365        let span = self.inner.selection().filter(|span| !span.is_empty());
366        self.inner.clear_selection();
367        let cursor = self.inner.cursor();
368        self.goal = None;
369        self.mutate(|txn| match span {
370            Some(span) => {
371                txn.replace(span, &text);
372            }
373            None => {
374                txn.insert(cursor, &text);
375            }
376        })
377    }
378
379    pub fn insert_char(&mut self, c: char) {
380        self.insert_str(c.to_string());
381    }
382
383    pub fn insert_newline(&mut self) {
384        self.insert_str("\n");
385    }
386
387    /// Delete `clusters` grapheme clusters forward, a line break counting as one.
388    ///
389    /// Clusters and not scalars, because a delete may not leave half a character
390    /// behind: `forward_by` steps whole clusters, so a caller counting scalars
391    /// over a flag or a ZWJ emoji spends the difference on the text after it.
392    pub fn delete_str(&mut self, clusters: usize) -> bool {
393        if self.take_selection() {
394            return true;
395        }
396        if clusters == 0 {
397            return false;
398        }
399        let from = self.inner.cursor();
400        let to = self.forward_by(from, clusters);
401        self.delete_between(from, to, Yank::Keep)
402    }
403
404    /// Backspace.
405    pub fn delete_char(&mut self) -> bool {
406        if self.take_selection() {
407            return true;
408        }
409        let to = self.inner.cursor();
410        let from = motion::prev_cluster(self.inner.text(), to);
411        // A backspace does not fill the register; only `delete_str`, the word
412        // deletes and `cut` do. Matching the incumbent, which is also vim: `x`
413        // yanks, but a plain backspace in Insert does not.
414        self.delete_between(from, to, Yank::Discard)
415    }
416
417    /// Forward delete.
418    pub fn delete_next_char(&mut self) -> bool {
419        if self.take_selection() {
420            return true;
421        }
422        let from = self.inner.cursor();
423        let to = motion::next_cluster(self.inner.text(), from);
424        self.delete_between(from, to, Yank::Discard)
425    }
426
427    pub fn delete_word(&mut self) -> bool {
428        if self.take_selection() {
429            return true;
430        }
431        let to = self.inner.cursor();
432        let text = self.inner.text();
433        // The incumbent's cascade, which *is* the contract: a word start on this
434        // row, else the row's start, else the line break before it. The last case
435        // is why this cannot simply be a row-local motion.
436        let candidate = motion::word_start_back(text, to, Words::Small);
437        let (from, yank) = if candidate.row() == to.row() && candidate.byte() < to.byte() {
438            (candidate, Yank::Keep)
439        } else if to.column().get() > 0 {
440            (motion::row_start(text, to), Yank::Keep)
441        } else {
442            // Joining rows goes through the incumbent's `delete_newline`, which
443            // does not fill the register. A pasted newline in place of the last
444            // yanked word is a surprising thing to hand back.
445            (motion::prev_cluster(text, to), Yank::Discard)
446        };
447        self.delete_between(from, to, yank)
448    }
449
450    pub fn delete_next_word(&mut self) -> bool {
451        if self.take_selection() {
452            return true;
453        }
454        let from = self.inner.cursor();
455        let text = self.inner.text();
456        // Mirror of `delete_word`: the end of the word at or after the cursor on
457        // this row, else the row's end, else the line break after it. `word_end_
458        // at_or_after` rather than `word_end_forward`, because deleting to the end
459        // of a word must name the word the cursor is *in* — vim's `e` deliberately
460        // looks past it.
461        let candidate = motion::word_end_at_or_after(text, from, Words::Small);
462        let row_end = motion::row_end(text, from);
463        let (to, yank) = match candidate {
464            Some(end) if end.row() == from.row() && end.byte() > from.byte() => (end, Yank::Keep),
465            _ if from.byte() < row_end.byte() => (row_end, Yank::Keep),
466            _ => (motion::next_cluster(text, from), Yank::Discard),
467        };
468        self.delete_between(from, to, yank)
469    }
470
471    pub fn cut(&mut self) -> bool {
472        // Takes the anchor whether or not it spanned anything, like every other
473        // operation that consumes a selection.
474        let span = self.inner.selection().filter(|span| !span.is_empty());
475        self.inner.clear_selection();
476        let Some(span) = span else {
477            return false;
478        };
479        self.yank = self
480            .inner
481            .text()
482            .slice(span)
483            .map(|text| text.to_string())
484            .unwrap_or_default();
485        self.goal = None;
486        self.mutate(|txn| {
487            txn.delete(span);
488        })
489    }
490
491    /// Copying reads: it leaves the selection where it is, and an empty one leaves
492    /// the register alone rather than emptying it.
493    pub fn copy(&mut self) {
494        if let Some(span) = self.inner.selection().filter(|span| !span.is_empty())
495            && let Some(text) = self.inner.text().slice(span)
496        {
497            self.yank = text.to_string();
498        }
499    }
500
501    pub fn paste(&mut self) -> bool {
502        if self.yank.is_empty() {
503            return false;
504        }
505        let text = std::mem::take(&mut self.yank);
506        let changed = self.insert_str(&text);
507        self.yank = text;
508        changed
509    }
510
511    /// Deleting a selection as a *side effect* of typing or of a forward delete
512    /// does not fill the register; an explicit delete does. That asymmetry is the
513    /// incumbent's and vim's both: `d` fills the unnamed register, typing over a
514    /// selection does not.
515    /// Take the selection and delete it, reporting whether anything went.
516    ///
517    /// Taking it is unconditional: an empty selection is not a range, so the
518    /// caller proceeds as though there were none — but the anchor is gone either
519    /// way. Leaving it alive is how an invisible selection outlives the gesture
520    /// that made it — a defect that cost two notes before it was found.
521    fn take_selection(&mut self) -> bool {
522        let span = self.inner.selection().filter(|span| !span.is_empty());
523        self.inner.clear_selection();
524        let Some(span) = span else {
525            return false;
526        };
527        self.delete_between(span.start(), span.end(), Yank::Discard)
528    }
529
530    fn delete_between(&mut self, from: Position, to: Position, yank: Yank) -> bool {
531        let Some(span) = self.inner.text().span(from, to) else {
532            return false;
533        };
534        if span.is_empty() {
535            return false;
536        }
537        if yank == Yank::Keep
538            && let Some(text) = self.inner.text().slice(span)
539        {
540            self.yank = text.to_string();
541        }
542        self.goal = None;
543        self.mutate(|txn| {
544            txn.delete(span);
545        })
546    }
547
548    // ── History ──────────────────────────────────────────────────────────────
549
550    pub fn undo(&mut self) -> bool {
551        let change = self.inner.undo();
552        self.after_history(change)
553    }
554
555    pub fn redo(&mut self) -> bool {
556        let change = self.inner.redo();
557        self.after_history(change)
558    }
559
560    /// The engine restores the selection an entry began with, which the incumbent
561    /// does not. Dropping it keeps undo behaving as it does today: a selection is
562    /// painted, so putting one back is a visible change, and an engine swap is the
563    /// wrong place to make one. The capability stays in the engine for when it is
564    /// asked for on purpose.
565    fn after_history(&mut self, change: Option<Change>) -> bool {
566        if change.is_none() {
567            // Nothing to undo: an operation that did not happen changes nothing,
568            // the selection included.
569            return false;
570        }
571        self.goal = None;
572        self.inner.clear_selection();
573        self.record(change)
574    }
575
576    // ── Cursor and selection ─────────────────────────────────────────────────
577
578    /// Move the cursor, extending a live selection.
579    ///
580    /// Directional movement keeps the anchor deliberately: that is how vim's
581    /// Visual mode extends.
582    pub fn move_cursor(&mut self, movement: CursorMove) {
583        let text = self.inner.text();
584        let from = self.inner.cursor();
585        let goal = self
586            .goal
587            .filter(|_| movement.is_vertical())
588            .unwrap_or_else(|| from.column());
589
590        let to = match movement {
591            CursorMove::Forward => motion::next_cluster(text, from),
592            CursorMove::Back => motion::prev_cluster(text, from),
593            CursorMove::Up => motion::vertical(text, from, -1, Goal::Column(goal)),
594            CursorMove::Down => motion::vertical(text, from, 1, Goal::Column(goal)),
595            CursorMove::Head => motion::row_start(text, from),
596            CursorMove::End => motion::row_end(text, from),
597            // The first and last *row*, keeping the column — not the start and
598            // end of the text, which is a different place on a non-empty row.
599            CursorMove::Top => {
600                let up = -(from.row() as isize);
601                motion::vertical(text, from, up, Goal::Column(goal))
602            }
603            CursorMove::Bottom => {
604                let down = (text.line_count().saturating_sub(1) as isize) - from.row() as isize;
605                motion::vertical(text, from, down, Goal::Column(goal))
606            }
607            CursorMove::WordForward => motion::word_start_forward(text, from, Words::Small),
608            CursorMove::WordBack => motion::word_start_back(text, from, Words::Small),
609            CursorMove::WordForwardBig => motion::word_start_forward(text, from, Words::Big),
610            CursorMove::WordBackBig => motion::word_start_back(text, from, Words::Big),
611            // Inclusive, like `WordEnd`: a cursor landing on a word's end wants the
612            // last cluster, not the place after it.
613            CursorMove::WordEndBig => match motion::word_end_forward(text, from, Words::Big) {
614                Some(end) => motion::prev_cluster(text, end),
615                None => from,
616            },
617            CursorMove::WordEndBack { big } => {
618                let words = if big { Words::Big } else { Words::Small };
619                match motion::word_end_back(text, from, words) {
620                    Some(end) => motion::prev_cluster(text, end),
621                    None => from,
622                }
623            }
624            CursorMove::MatchingPair => motion::matching_bracket(text, from).unwrap_or(from),
625            // The crate's word end is exclusive — just past the last cluster —
626            // because that is what an operator range wants. A *cursor* landing on
627            // a word end wants the last cluster itself, as vim's `e` does. This is
628            // the inclusive-to-half-open conversion CONTEXT names under **span
629            // kind**, and the adapter is where it belongs: the engine holds no view
630            // on which convention a caller uses.
631            CursorMove::WordEnd => match motion::word_end_forward(text, from, Words::Small) {
632                Some(end) => motion::prev_cluster(text, end),
633                // Nothing ahead: the incumbent walks to the end of the text rather
634                // than staying put, and vim's `e` on a trailing blank line does the
635                // same.
636                None => motion::text_end(text),
637            },
638            CursorMove::ParagraphForward => motion::paragraph_forward(text, from),
639            CursorMove::ParagraphBack => motion::paragraph_back(text, from),
640            CursorMove::Jump(row, column) => {
641                match text.position(row, Column::new(column)) {
642                    Some(position) => position,
643                    // Refused, not clamped: a keypress that did nothing is
644                    // recoverable in a way one that edited elsewhere is not.
645                    None => return,
646                }
647            }
648        };
649
650        self.goal = if movement.is_vertical() {
651            Some(goal)
652        } else {
653            None
654        };
655        self.place(to);
656    }
657
658    /// Move the cursor to `(row, col)`, refusing a position the buffer cannot
659    /// address rather than landing somewhere else.
660    pub fn jump_to(&mut self, row: usize, col: usize) -> bool {
661        let Some(to) = self.inner.text().position(row, Column::new(col)) else {
662            return false;
663        };
664        self.goal = None;
665        self.place(to);
666        true
667    }
668
669    /// Move to a position the caller worked out itself — a visual-line motion,
670    /// which needs a layout the buffer does not have.
671    pub fn move_to(&mut self, to: Position) {
672        if self.inner.text().is_stale(to) {
673            return;
674        }
675        self.goal = None;
676        self.place(to);
677    }
678
679    fn place(&mut self, to: Position) {
680        if self.inner.selection().is_some() {
681            self.inner.extend_to(to);
682        } else {
683            self.inner.set_cursor(to);
684        }
685    }
686
687    pub fn start_selection(&mut self) {
688        // Anchors *here*, even when a selection is already live: starting one is
689        // a fresh gesture, not an extension of the last.
690        let cursor = self.inner.cursor();
691        self.inner.clear_selection();
692        self.inner.extend_to(cursor);
693    }
694
695    pub fn cancel_selection(&mut self) {
696        self.inner.clear_selection();
697    }
698
699    pub fn select_all(&mut self) {
700        let span = self.inner.text().full_span();
701        self.inner.select(span);
702    }
703
704    pub fn set_selection(&mut self, start: (usize, usize), end: (usize, usize)) -> bool {
705        let text = self.inner.text();
706        let Some(from) = text.position(start.0, Column::new(start.1)) else {
707            return false;
708        };
709        let Some(to) = text.position(end.0, Column::new(end.1)) else {
710            return false;
711        };
712        let Some(span) = text.span(from, to) else {
713            return false;
714        };
715        self.inner.select(span);
716        true
717    }
718
719    // ── Search ───────────────────────────────────────────────────────────────
720    //
721    // Not the engine's business: it holds the pattern because vim's
722    // `n`/`N` outlive the find bar, and matches a row at a time because a **find
723    // pattern** can never span a newline.
724
725    pub fn set_search_pattern(&mut self, pattern: &str) -> Result<(), regex::Error> {
726        if pattern.is_empty() {
727            self.search = None;
728            return Ok(());
729        }
730        self.search = Some(regex::Regex::new(pattern)?);
731        Ok(())
732    }
733
734    /// Move the cursor to the next match. Never extends a selection.
735    pub fn search_forward(&mut self, match_cursor: bool) -> bool {
736        self.step_search(false, match_cursor)
737    }
738
739    /// Move the cursor to the previous match. Never extends a selection.
740    pub fn search_back(&mut self, match_cursor: bool) -> bool {
741        self.step_search(true, match_cursor)
742    }
743
744    /// Repeat the persisted pattern (vim `n` / `N`).
745    pub fn search_repeat(&mut self, backward: bool) -> bool {
746        self.step_search(backward, false)
747    }
748
749    fn step_search(&mut self, backward: bool, match_cursor: bool) -> bool {
750        // A search is not a selection gesture, so the anchor goes first — and
751        // here that is one call rather than an invariant to remember, because
752        // `set_cursor` drops it and `extend_to` keeps it.
753        self.cancel_selection();
754        let Some(found) = self.find_match(backward, match_cursor) else {
755            return false;
756        };
757        self.goal = None;
758        self.inner.set_cursor(found);
759        true
760    }
761
762    fn find_match(&self, backward: bool, match_cursor: bool) -> Option<Position> {
763        let pattern = self.search.as_ref()?;
764        let text = self.inner.text();
765        let cursor = self.inner.cursor();
766        let rows = text.line_count();
767
768        // `0..=rows` visits the cursor's row twice: once at the start, and once
769        // more at the end. That last visit IS the wrap, so the hits the first
770        // visit stepped over — the ones behind the cursor — are exactly what it
771        // is for. Filtering them again there is what made search unable to come
772        // back around to them.
773        for step in 0..=rows {
774            let wrapped = step == rows;
775            let row = if backward {
776                (cursor.row() + rows - (step % rows.max(1))) % rows
777            } else {
778                (cursor.row() + step) % rows
779            };
780            let line = text.line(row)?;
781            let mut hits: Vec<usize> = pattern
782                .find_iter(&line)
783                .map(|found| line[..found.start()].chars().count())
784                .collect();
785            if backward {
786                hits.reverse();
787            }
788            for column in hits {
789                let same_row = row == cursor.row();
790                let beyond = if backward {
791                    column < cursor.column().get()
792                } else if match_cursor {
793                    column >= cursor.column().get()
794                } else {
795                    column > cursor.column().get()
796                };
797                if wrapped || !same_row || beyond {
798                    // A match can start inside a grapheme cluster — a regex like
799                    // `.` or a search for a scalar that also appears inside a ZWJ
800                    // sequence. That start is not addressable, so skip the
801                    // candidate; abandoning the whole scan there would report "no
802                    // match" while the bar's own count says otherwise.
803                    if let Some(at) = text.position(row, Column::new(column)) {
804                        return Some(at);
805                    }
806                }
807            }
808        }
809        None
810    }
811
812    /// The span of the match starting exactly at the cursor, if any.
813    pub fn match_at_cursor(&self) -> Option<((usize, usize), (usize, usize))> {
814        let pattern = self.search.as_ref()?;
815        let text = self.inner.text();
816        let cursor = self.inner.cursor();
817        let line = text.line(cursor.row())?;
818        let byte = line
819            .char_indices()
820            .nth(cursor.column().get())
821            .map(|(at, _)| at)
822            .unwrap_or(line.len());
823        let found = pattern.find_at(&line, byte)?;
824        if found.start() != byte {
825            return None;
826        }
827        let chars = line[found.range()].chars().count();
828        Some((rc(cursor), (cursor.row(), cursor.column().get() + chars)))
829    }
830
831    // ── Helpers ──────────────────────────────────────────────────────────────
832
833    /// `chars` scalars forward of `from`, clamped to the end of the text.
834    fn forward_by(&self, from: Position, chars: usize) -> Position {
835        let text = self.inner.text();
836        let mut at = from;
837        for _ in 0..chars {
838            let next = motion::next_cluster(text, at);
839            if next.byte() == at.byte() {
840                break;
841            }
842            at = next;
843        }
844        at
845    }
846
847    /// The span between two `(row, col)` pairs, for callers that still speak in
848    /// them.
849    pub fn span_between(&self, start: (usize, usize), end: (usize, usize)) -> Option<Span> {
850        let text = self.inner.text();
851        let from = text.position(start.0, Column::new(start.1))?;
852        let to = text.position(end.0, Column::new(end.1))?;
853        text.span(from, to)
854    }
855}
856
857fn rc(position: Position) -> (usize, usize) {
858    (position.row(), position.column().get())
859}
860
861#[cfg(test)]
862mod search_tests {
863    use super::*;
864    use crate::ropetext::Text;
865
866    fn buffer(text: &str, pattern: &str, cursor: (usize, usize)) -> RopeBuffer {
867        let mut buf = RopeBuffer::new(Text::from(text));
868        buf.set_search_pattern(pattern).expect("valid pattern");
869        buf.move_cursor(CursorMove::Jump(cursor.0, cursor.1));
870        buf
871    }
872
873    #[test]
874    fn a_forward_search_wraps_to_a_match_behind_the_cursor() {
875        // One row, one match, cursor past it. Before the wrap visit stopped
876        // re-filtering, this reported no match while the find bar counted one.
877        let mut buf = buffer("xx foo", "foo", (0, 5));
878        assert!(buf.search_forward(false), "the match is behind the cursor");
879        assert_eq!(buf.cursor(), (0, 3));
880    }
881
882    #[test]
883    fn a_backward_search_wraps_to_a_match_ahead_of_the_cursor() {
884        let mut buf = buffer("xx foo", "foo", (0, 1));
885        assert!(buf.search_back(false));
886        assert_eq!(buf.cursor(), (0, 3));
887    }
888
889    #[test]
890    fn wrapping_crosses_rows_back_to_the_cursors_own_row() {
891        let mut buf = buffer("aaa\nxx foo", "foo", (1, 5));
892        assert!(buf.search_forward(false));
893        assert_eq!(buf.cursor(), (1, 3));
894    }
895
896    #[test]
897    fn the_only_match_is_re_offered_rather_than_reported_missing() {
898        // vim's answer: "search hit BOTTOM, continuing at TOP" lands back on the
899        // same match. Reporting false would paint "no match" over a match that is
900        // highlighted on screen.
901        let mut buf = buffer("xx foo", "foo", (0, 3));
902        assert!(buf.search_forward(false), "the one match is still a match");
903        assert_eq!(
904            buf.cursor(),
905            (0, 3),
906            "and the cursor has nowhere else to go"
907        );
908    }
909
910    #[test]
911    fn a_match_starting_inside_a_cluster_is_skipped_not_fatal() {
912        // "\u{1F469}\u{200D}\u{1F4BB}" is one cluster; the laptop scalar sits at
913        // char column 2, inside it. That start is unaddressable — but the real
914        // match on row 1 is, and abandoning the scan at the first unaddressable
915        // candidate is what made the bar say "no match" beside a count of two.
916        let mut buf = buffer(
917            "\u{1F469}\u{200D}\u{1F4BB}\nx\u{1F4BB}",
918            "\u{1F4BB}",
919            (0, 0),
920        );
921        assert!(buf.search_forward(false), "the row 1 match is reachable");
922        assert_eq!(buf.cursor(), (1, 1));
923    }
924}
925
926#[cfg(test)]
927mod cluster_tests {
928    use super::*;
929    use crate::ropetext::Text;
930
931    #[test]
932    fn delete_str_spends_its_count_on_clusters() {
933        // "[[" plus a regional-indicator flag: 4 scalars, 3 clusters. Three is
934        // what removes exactly `[[` and the flag — a caller counting the four
935        // scalars would take the space after them too.
936        let mut buf = RopeBuffer::new(Text::from("[[\u{1F1EA}\u{1F1F8} rest"));
937        buf.move_cursor(CursorMove::Jump(0, 0));
938        buf.delete_str(3);
939        assert_eq!(buf.rows(), &[" rest"]);
940    }
941
942    #[test]
943    fn inserting_before_a_combining_mark_keeps_the_cursor_addressable() {
944        // A row starting with a lone combining acute — NFD text pasted from
945        // macOS. Typing 'a' in front of it makes "a\u{301}", one cluster, and
946        // the post-edit cursor byte lands inside it.
947        let mut buf = RopeBuffer::new(Text::from("\u{301}f"));
948        buf.move_cursor(CursorMove::Jump(0, 0));
949        buf.insert_char('a');
950        assert_eq!(buf.rows(), &["a\u{301}f"]);
951    }
952}
953
954#[cfg(test)]
955mod damage_tests {
956    use super::*;
957    use crate::ropetext::Text;
958
959    #[test]
960    fn damage_from_several_edits_is_in_one_numbering() {
961        // Two mutations in one group, the second ABOVE the first and changing
962        // the line count — so the first edit's row moves before the group ends.
963        let mut buf = RopeBuffer::new(Text::from("r0\nr1\nr2\nr3\nr4"));
964        buf.edit(|b| {
965            b.move_cursor(CursorMove::Jump(4, 0));
966            b.insert_str("X");
967            b.move_cursor(CursorMove::Jump(0, 0));
968            b.insert_newline();
969        });
970        assert_eq!(buf.rows(), ["", "r0", "r1", "r2", "r3", "Xr4"]);
971
972        let damage = buf.take_outcome().damage.expect("the edits were reported");
973        assert!(
974            damage.contains(&5),
975            "the row edited first is row 5 once the group ends, but the damage \
976             reported was {damage:?} — a range in the older numbering"
977        );
978    }
979}