Skip to main content

hjkl_buffer/
buffer.rs

1use std::sync::{Arc, Mutex, MutexGuard};
2
3use crate::content::Buffer;
4use crate::{Position, Viewport};
5
6/// Per-window view onto a [`Buffer`].
7///
8/// `View` is the type the rest of `hjkl-buffer` — and all consumers —
9/// use directly. It owns exactly the state that is local to one editor
10/// window:
11///
12/// - `cursor` — the charwise caret for this window.
13///
14/// All document-level state (text rope, dirty generation, folds) lives on
15/// the inner [`Buffer`] and is accessed via `Arc<Mutex<Buffer>>`.
16/// Two `View` instances that share the same `Arc` share text + folds
17/// but carry independent cursors — the Helix Document+View model.
18///
19/// ## `Send` + `Sync`
20///
21/// `Arc<Mutex<Buffer>>` is `Send + Sync`, so `View` remains `Send`.
22/// The engine trait surface requires `View: Send`; this constraint
23/// drove the choice of `Mutex` over `RefCell`. The mutex is never
24/// contended in normal operation (single-threaded app loop), so the
25/// lock cost is negligible (~5 ns uncontested).
26///
27/// ## Mutex-poisoning policy
28///
29/// Every `content` lock here — and the same for `registers`, `global_marks`,
30/// `change_bank`, `search` and `abbrevs` on `hjkl_engine::Editor` — is taken
31/// with `lock().unwrap()`. That is deliberate, not an oversight: a poisoned
32/// lock means a panic unwound while the buffer state was mid-mutation, so the
33/// rope may be torn between an `Edit` and its cursor fixup. There is no
34/// meaningful recovery from that — continuing would hand the user a document
35/// the editor cannot vouch for, including on the save path — so poisoning is
36/// treated as a fatal, unrecoverable state and the panic is allowed to
37/// propagate.
38///
39/// The `unwrap_or_else(|e| e.into_inner())` recovery shape does appear in the
40/// codebase (e.g. `hjkl-app`'s `trash.rs` test helper, guarding an env-var
41/// lock that carries no data). That is not the policy for buffer state: for
42/// anything reachable from a `View`, poisoned means fatal.
43///
44/// ## 0.8.0 migration notes
45///
46/// The existing constructors ([`View::new`], [`View::from_str`],
47/// [`View::replace_all`], etc.) keep the same external signatures.
48/// Callers that do not need multi-window sharing see no behaviour change.
49/// Use [`View::new_view`] to create a second window onto the same
50/// [`Buffer`].
51///
52/// ## Viewport
53///
54/// The rope invariant — at least one line, never empty — is preserved by
55/// every mutation (ropey's empty rope already reports `len_lines() == 1`).
56/// The viewport itself (top_row, top_col, width, height, wrap, text_width)
57/// lives on the engine `Host` adapter; methods that need it take a
58/// `&Viewport` / `&mut Viewport` parameter so the rope-walking math stays
59/// here while runtime state lives there.
60pub struct View {
61    /// Shared per-document state (text rope, dirty gen, folds).
62    pub(crate) content: Arc<Mutex<Buffer>>,
63    /// Charwise cursor. `col` is bound by the char count of `row` in
64    /// normal mode, one past it in operator-pending / insert.
65    cursor: Position,
66}
67
68impl Default for View {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl View {
75    // ── Constructors ──────────────────────────────────────────────
76
77    /// Construct an empty buffer with one empty row + cursor at `(0, 0)`.
78    pub fn new() -> Self {
79        Self {
80            content: Arc::new(Mutex::new(Buffer::new())),
81            cursor: Position::default(),
82        }
83    }
84
85    /// Build a buffer from a flat string. Splits on `\n`; a trailing
86    /// `\n` produces a trailing empty line (matches every text
87    /// editor's behaviour and keeps `from_text(buf.as_string())` an
88    /// identity round-trip in the common case).
89    #[allow(clippy::should_implement_trait)]
90    pub fn from_str(text: &str) -> Self {
91        Self {
92            content: Arc::new(Mutex::new(Buffer::from_str(text))),
93            cursor: Position::default(),
94        }
95    }
96
97    /// Create a second per-window view onto existing [`Buffer`].
98    ///
99    /// The new `View` shares text + folds with every other view on the
100    /// same `Arc`. Its cursor starts at `(0, 0)` independently. This is
101    /// the primary entry point for split-window features.
102    ///
103    /// ```rust
104    /// # use hjkl_buffer::{View, Buffer, Position};
105    /// # use std::sync::Arc;
106    /// # use std::sync::Mutex;
107    /// let a = View::from_str("hello\nworld");
108    /// let content = a.content_arc();
109    /// let mut b = View::new_view(Arc::clone(&content));
110    ///
111    /// // Cursors are independent.
112    /// let mut a = View::new_view(Arc::clone(&content));
113    /// a.set_cursor(Position::new(1, 0));
114    /// assert_eq!(b.cursor(), Position::new(0, 0));
115    /// ```
116    pub fn new_view(content: Arc<Mutex<Buffer>>) -> Self {
117        Self {
118            content,
119            cursor: Position::default(),
120        }
121    }
122
123    /// Return a clone of the `Arc<Mutex<Buffer>>` so callers can
124    /// create additional views with [`View::new_view`].
125    pub fn content_arc(&self) -> Arc<Mutex<Buffer>> {
126        Arc::clone(&self.content)
127    }
128
129    // ── Read-only accessors (delegate to Buffer) ─────────────────
130
131    pub fn cursor(&self) -> Position {
132        self.cursor
133    }
134
135    /// The last cursor `(row, col)` committed on the shared [`Buffer`] by any
136    /// view (see [`View::set_cursor`]). This is the "last-moved cursor across
137    /// all windows" the cross-session cursor store persists — not this view's
138    /// own live cursor. Best-effort; read at write/close/exit.
139    pub fn last_cursor(&self) -> (usize, usize) {
140        self.content.lock().unwrap().last_cursor
141    }
142
143    pub fn dirty_gen(&self) -> u64 {
144        self.content.lock().unwrap().dirty_gen
145    }
146
147    /// Number of rows in the buffer. Always `>= 1`.
148    pub fn row_count(&self) -> usize {
149        self.content.lock().unwrap().text.len_lines()
150    }
151
152    /// Concatenate the rows into a single `String` joined by `\n`.
153    ///
154    /// Equivalent to `rope.to_string()` — ropey's rope-to-string already
155    /// produces `\n`-joined content matching `split('\n').join("\n")`.
156    pub fn as_string(&self) -> String {
157        self.content.lock().unwrap().text.to_string()
158    }
159
160    // ── Cursor ops ────────────────────────────────────────────────
161
162    /// Set cursor without scrolling. Clamps to valid positions.
163    ///
164    /// The optional sticky column for `j`/`k` motions is **not** reset
165    /// by this call — it survives `set_cursor` intentionally.
166    pub fn set_cursor(&mut self, pos: Position) {
167        let mut c = self.content.lock().unwrap();
168        let n = c.text.len_lines();
169        let last_row = n.saturating_sub(1);
170        let row = pos.row.min(last_row);
171        let line_chars = rope_line_char_count(&c.text, row);
172        let col = pos.col.min(line_chars);
173        // Single choke point for cursor moves: record the last-moved cursor on
174        // the shared `Buffer` so the most-recent move across ALL views onto
175        // this document wins. Cheap — no I/O.
176        c.last_cursor = (row, col);
177        drop(c);
178        self.cursor = Position::new(row, col);
179    }
180
181    /// Bring the cursor into the visible [`Viewport`], scrolling by the
182    /// minimum amount needed.
183    pub fn ensure_cursor_visible(&mut self, viewport: &mut Viewport) {
184        let cursor = self.cursor;
185        let v = *viewport;
186        let wrap_active = !matches!(v.wrap, crate::Wrap::None) && v.text_width > 0;
187        if !wrap_active {
188            viewport.ensure_visible(cursor);
189            return;
190        }
191        if v.height == 0 {
192            return;
193        }
194        // Cursor above the visible region: snap top_row to it.
195        if cursor.row < v.top_row {
196            viewport.top_row = cursor.row;
197            viewport.top_col = 0;
198            return;
199        }
200        let height = v.height as usize;
201        // Compute the cursor's screen row once, then push `top_row` down
202        // incrementally: each dropped row reduces the screen row by its own
203        // visible height. O(distance) instead of recomputing
204        // `cursor_screen_row_from` every step (which was O(distance^2) on a
205        // large soft-wrapped jump).
206        // `screen` was summed only up to the cursor row clamped against the
207        // live rope (the clamp `cursor_screen_row_from` applies); a stale
208        // cursor row — another view shrank the shared Buffer since this
209        // view's cursor was last clamped — must not make the loop subtract
210        // past that point.
211        let cursor_row = {
212            let c = self.content.lock().unwrap();
213            cursor.row.min(c.text.len_lines().saturating_sub(1))
214        };
215        let Some(mut screen) = self.cursor_screen_row_from(viewport, viewport.top_row) else {
216            // Two ways to land here, both repaired by snapping `top_row` to the
217            // cursor's row clamped against the live rope:
218            //   - a concurrent view shrink dropped rows, so this view's stale
219            //     cursor row *and* `top_row` are both past the last line;
220            //   - the cursor's row is hidden by a fold, so the walk from
221            //     `top_row` never reaches it.
222            // The clamp is what does the work in the shrink case: `cursor.row
223            // >= top_row` is already known here, so assigning the raw
224            // `cursor.row` would push `top_row` further past the end rather
225            // than pulling it back into the rope.
226            let last_row = {
227                let c = self.content.lock().unwrap();
228                c.text.len_lines().saturating_sub(1)
229            };
230            viewport.top_row = cursor.row.min(last_row);
231            viewport.top_col = 0;
232            return;
233        };
234        while screen >= height {
235            let c = self.content.lock().unwrap();
236            let mut next = viewport.top_row + 1;
237            while next <= cursor_row && c.folds.iter().any(|f| f.hides(next)) {
238                next += 1;
239            }
240            if next > cursor_row {
241                drop(c);
242                viewport.top_row = cursor_row;
243                break;
244            }
245            // Removing rows [top_row, next) drops their visible heights (hidden
246            // rows contribute 0). After this, `screen` equals
247            // `cursor_screen_row_from(next)`.
248            for r in viewport.top_row..next {
249                if c.folds.iter().any(|f| f.hides(r)) {
250                    continue;
251                }
252                let line = rope_line_str(&c.text, r);
253                screen -= crate::wrap::wrap_segments(&line, v.text_width, v.wrap).len();
254            }
255            drop(c);
256            viewport.top_row = next;
257        }
258        viewport.top_col = 0;
259    }
260
261    /// Cursor's screen row offset (0-based) from `viewport.top_row`.
262    pub fn cursor_screen_row(&self, viewport: &Viewport) -> Option<usize> {
263        if matches!(viewport.wrap, crate::Wrap::None) || viewport.text_width == 0 {
264            return None;
265        }
266        self.cursor_screen_row_from(viewport, viewport.top_row)
267    }
268
269    /// Number of screen rows the doc range `start..=end` occupies.
270    pub fn screen_rows_between(&self, viewport: &Viewport, start: usize, end: usize) -> usize {
271        if start > end {
272            return 0;
273        }
274        let c = self.content.lock().unwrap();
275        let n = c.text.len_lines();
276        let last = n.saturating_sub(1);
277        let end = end.min(last);
278        let v = *viewport;
279        let mut total = 0usize;
280        for r in start..=end {
281            if c.folds.iter().any(|f| f.hides(r)) {
282                continue;
283            }
284            if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
285                total += 1;
286            } else {
287                let line = rope_line_str(&c.text, r);
288                total += crate::wrap::wrap_segments(&line, v.text_width, v.wrap).len();
289            }
290        }
291        total
292    }
293
294    /// Earliest `top_row` such that `screen_rows_between(top, last)`
295    /// is at least `height`.
296    pub fn max_top_for_height(&self, viewport: &Viewport, height: usize) -> usize {
297        if height == 0 {
298            return 0;
299        }
300        let c = self.content.lock().unwrap();
301        let n = c.text.len_lines();
302        let last = n.saturating_sub(1);
303        let mut total = 0usize;
304        let mut row = last;
305        loop {
306            if !c.folds.iter().any(|f| f.hides(row)) {
307                let v = *viewport;
308                total += if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
309                    1
310                } else {
311                    let line = rope_line_str(&c.text, row);
312                    crate::wrap::wrap_segments(&line, v.text_width, v.wrap).len()
313                };
314            }
315            if total >= height {
316                return row;
317            }
318            if row == 0 {
319                return 0;
320            }
321            row -= 1;
322        }
323    }
324
325    /// Clamp `pos` to the buffer's content.
326    pub fn clamp_position(&self, pos: Position) -> Position {
327        let c = self.content.lock().unwrap();
328        let n = c.text.len_lines();
329        let last_row = n.saturating_sub(1);
330        let row = pos.row.min(last_row);
331        let line_chars = rope_line_char_count(&c.text, row);
332        let col = pos.col.min(line_chars);
333        Position::new(row, col)
334    }
335
336    /// Replace the buffer's full text in place. Cursor is clamped to
337    /// the new content.
338    pub fn replace_all(&mut self, text: &str) {
339        let new_cursor = {
340            let mut c = self.content.lock().unwrap();
341            c.text = ropey::Rope::from_str(text);
342            let n = c.text.len_lines();
343            let last_row = n.saturating_sub(1);
344            let row = self.cursor.row.min(last_row);
345            let line_chars = rope_line_char_count(&c.text, row);
346            let col = self.cursor.col.min(line_chars);
347            c.dirty_gen = c.dirty_gen.wrapping_add(1);
348            c.cached_joined = None;
349            c.cached_byte_len = None;
350            Position::new(row, col)
351        };
352        self.cursor = new_cursor;
353    }
354
355    // ── Crate-internal accessors (used by folds.rs) ───────────────
356
357    /// Bump the fold-mutation generation. Crate-internal — every fold
358    /// mutator in [`crate::folds`] calls it (via `folds_changed`) after it
359    /// actually changes the fold set. Deliberately separate from
360    /// [`Self::dirty_gen_bump`]: text edits bump `dirty_gen` on every
361    /// keystroke, and a fold-snapshot cache keyed off that would never hit.
362    pub(crate) fn fold_gen_bump(&mut self) {
363        let mut c = self.content.lock().unwrap();
364        c.fold_gen = c.fold_gen.wrapping_add(1);
365    }
366
367    /// Bump the render-cache generation. Crate-internal.
368    pub(crate) fn dirty_gen_bump(&mut self) {
369        let mut c = self.content.lock().unwrap();
370        c.dirty_gen = c.dirty_gen.wrapping_add(1);
371        c.cached_joined = None;
372        c.cached_byte_len = None;
373    }
374
375    /// Canonical byte length of the document. `Rope::len_bytes()` is O(1)
376    /// and returns the same value as `to_string().len()` (i.e.
377    /// `sum(line_bytes) + (n_lines-1)` separators). Cached against
378    /// `dirty_gen` for API compatibility; the O(1) rope call makes the
379    /// cache essentially free but keeps the invalidation contract identical.
380    pub fn byte_len(&self) -> usize {
381        let mut c = self.content.lock().unwrap();
382        let dg = c.dirty_gen;
383        if let Some((cached_dg, len)) = c.cached_byte_len
384            && cached_dg == dg
385        {
386            return len;
387        }
388        let total = c.text.len_bytes();
389        c.cached_byte_len = Some((dg, total));
390        total
391    }
392
393    /// Return an `Arc<String>` of the full document, cached against
394    /// `dirty_gen`. Multiple per-tick consumers (syntax pipeline, LSP
395    /// notify, git signature, dirty hash) share the same `Arc` for the
396    /// same generation — first caller pays the `rope.to_string()` cost
397    /// (one alloc + one lock), the rest are O(1).
398    ///
399    /// Cache invalidates automatically on every `dirty_gen_bump` and on
400    /// `replace_all`, so callers never need to manage invalidation.
401    pub fn content_joined(&self) -> std::sync::Arc<String> {
402        let mut c = self.content.lock().unwrap();
403        let dg = c.dirty_gen;
404        if let Some((cached_dg, ref s)) = c.cached_joined
405            && cached_dg == dg
406        {
407            return std::sync::Arc::clone(s);
408        }
409        let joined = std::sync::Arc::new(c.text.to_string());
410        c.cached_joined = Some((dg, std::sync::Arc::clone(&joined)));
411        joined
412    }
413
414    /// Borrow the underlying rope. Hot-path consumers (tree-sitter
415    /// streaming parse, byte-range slicing) should use this instead of
416    /// `content_joined()` to avoid materializing the whole document as
417    /// a `String`.
418    ///
419    /// `ropey::Rope::clone` is O(1) — it Arc-clones the root node.
420    /// The clone gives the caller a snapshot they can read without
421    /// holding the content mutex.
422    pub fn rope(&self) -> ropey::Rope {
423        self.content.lock().unwrap().text.clone()
424    }
425
426    /// Shared access to the content guard. Crate-internal.
427    pub(crate) fn content_lock(&self) -> MutexGuard<'_, Buffer> {
428        self.content.lock().unwrap()
429    }
430
431    /// Exclusive access to Buffer. Crate-internal.
432    pub(crate) fn content_lock_mut(&mut self) -> MutexGuard<'_, Buffer> {
433        self.content.lock().unwrap()
434    }
435
436    // ── Screen-row helpers (private) ──────────────────────────────
437
438    fn cursor_screen_row_from(&self, viewport: &Viewport, top: usize) -> Option<usize> {
439        let cursor = self.cursor;
440        if cursor.row < top {
441            return None;
442        }
443        let c = self.content.lock().unwrap();
444        // Clamp against the live rope: another view sharing this Buffer may
445        // have removed rows since this view's cursor was last clamped, and
446        // `rope.line(r)` panics past the last line.
447        let cursor_row = cursor.row.min(c.text.len_lines().saturating_sub(1));
448        if cursor_row < top {
449            return None;
450        }
451        let v = *viewport;
452        let mut screen = 0usize;
453        for r in top..=cursor_row {
454            if c.folds.iter().any(|f| f.hides(r)) {
455                continue;
456            }
457            let line = rope_line_str(&c.text, r);
458            let segs = crate::wrap::wrap_segments(&line, v.text_width, v.wrap);
459            if r == cursor_row {
460                let seg_idx = crate::wrap::segment_for_col(&segs, cursor.col);
461                return Some(screen + seg_idx);
462            }
463            screen += segs.len();
464        }
465        None
466    }
467
468    // ── Per-buffer engine state accessors ─────────────────────────────────
469
470    // ── Undo arena tree accessors (Phase 2a) ─────────────────────────────
471    //
472    // These delegate to the per-document [`crate::UndoTree`]. Their names and
473    // semantics mirror the two-stack API they replace (`undo_stack` /
474    // `redo_stack`), so the engine's undo/redo drivers are untouched apart
475    // from the two `*_step` moves below.
476
477    pub fn undo_stack_is_empty(&self) -> bool {
478        self.content.lock().unwrap().undo.is_at_root()
479    }
480
481    pub fn redo_stack_is_empty(&self) -> bool {
482        !self.content.lock().unwrap().undo.has_redo()
483    }
484
485    pub fn undo_stack_len(&self) -> usize {
486        self.content.lock().unwrap().undo.depth_from_root()
487    }
488
489    /// Commit the pre-edit LIVE state `entry` as an undo boundary: the current
490    /// node takes `entry` and a fresh child becomes current, with the forward
491    /// (redo) branch dropped — the tree form of `undo_stack.push` + `clear_redo`.
492    pub fn push_undo_entry(&self, entry: crate::UndoEntry) {
493        self.content.lock().unwrap().undo.push(entry);
494    }
495
496    /// One undo move. `live` is the current buffer state (rope/cursor/marks) of
497    /// the node being left; returns the parent snapshot to restore, or `None`
498    /// at the root. See [`crate::UndoTree::undo_step`].
499    pub fn undo_step(
500        &self,
501        rope: ropey::Rope,
502        cursor: (usize, usize),
503        marks: crate::MarkSnapshot,
504    ) -> Option<crate::UndoEntry> {
505        self.content
506            .lock()
507            .unwrap()
508            .undo
509            .undo_step(rope, cursor, marks)
510    }
511
512    /// One redo move. Symmetric to [`Self::undo_step`]; returns the child
513    /// snapshot to restore, or `None` when there is no forward branch.
514    pub fn redo_step(
515        &self,
516        rope: ropey::Rope,
517        cursor: (usize, usize),
518        marks: crate::MarkSnapshot,
519    ) -> Option<crate::UndoEntry> {
520        self.content
521            .lock()
522            .unwrap()
523            .undo
524            .redo_step(rope, cursor, marks)
525    }
526
527    /// Discard the most-recent undo boundary without moving the live state
528    /// (`undo_stack.pop()`); `false` at the root. See
529    /// [`crate::UndoTree::pop_committed`].
530    pub fn pop_committed(&self) -> bool {
531        self.content.lock().unwrap().undo.pop_committed()
532    }
533
534    /// One `g-` / `:earlier` step: move to the next-lower-`seq` state tree-wide,
535    /// returning its snapshot to restore, or `None` at the lowest state. `live`
536    /// is the current buffer state, stashed into the node being left. See
537    /// [`crate::UndoTree::seq_earlier_step`].
538    pub fn seq_earlier_step(
539        &self,
540        rope: ropey::Rope,
541        cursor: (usize, usize),
542        marks: crate::MarkSnapshot,
543    ) -> Option<crate::UndoEntry> {
544        self.content
545            .lock()
546            .unwrap()
547            .undo
548            .seq_earlier_step(rope, cursor, marks)
549    }
550
551    /// One `g+` / `:later` step: move to the next-higher-`seq` state tree-wide.
552    /// Symmetric to [`Self::seq_earlier_step`].
553    pub fn seq_later_step(
554        &self,
555        rope: ropey::Rope,
556        cursor: (usize, usize),
557        marks: crate::MarkSnapshot,
558    ) -> Option<crate::UndoEntry> {
559        self.content
560            .lock()
561            .unwrap()
562            .undo
563            .seq_later_step(rope, cursor, marks)
564    }
565
566    /// Timestamp of the next-lower-`seq` state (`:earlier Ns` predicate).
567    pub fn seq_earlier_timestamp(&self) -> Option<std::time::SystemTime> {
568        self.content.lock().unwrap().undo.seq_earlier_timestamp()
569    }
570
571    /// Timestamp of the next-higher-`seq` state (`:later Ns` predicate).
572    pub fn seq_later_timestamp(&self) -> Option<std::time::SystemTime> {
573        self.content.lock().unwrap().undo.seq_later_timestamp()
574    }
575
576    /// Undo-tree leaves for `:undolist`, each `(seq, changes/depth, timestamp,
577    /// is_current)`, sorted by `seq`. See [`crate::UndoTree::leaves`].
578    pub fn undo_leaves(&self) -> Vec<(u64, usize, std::time::SystemTime, bool)> {
579        self.content.lock().unwrap().undo.leaves()
580    }
581
582    pub fn peek_undo_timestamp(&self) -> Option<std::time::SystemTime> {
583        self.content.lock().unwrap().undo.parent_timestamp()
584    }
585
586    pub fn peek_redo_timestamp(&self) -> Option<std::time::SystemTime> {
587        self.content.lock().unwrap().undo.child_timestamp()
588    }
589
590    pub fn clear_undo_redo(&self) {
591        self.content.lock().unwrap().undo.clear_all();
592    }
593
594    // ── Undofile persistence (Phase 3b) ───────────────────────────────────
595
596    /// Project this buffer's undo tree into its serializable form for the
597    /// undofile, plus the current node's `seq`. Syncs the current node to the
598    /// live buffer text first, so the on-disk tree's `current` edge is exact
599    /// even when `current` is a fresh (still-stale) leaf.
600    pub fn undo_to_serializable(&self) -> (crate::SerTree, u64) {
601        let mut c = self.content.lock().unwrap();
602        let rope = c.text.clone();
603        c.undo.sync_current(rope);
604        let seq = c.undo.current_node_seq();
605        (c.undo.to_serializable(), seq)
606    }
607
608    /// Replace this buffer's fresh single-node undo tree with one deserialized
609    /// from an undofile. Must run BEFORE the first user edit and after the
610    /// buffer text is populated (the tree's `current` materializes to the loaded
611    /// content). Returns `false` — leaving the fresh tree untouched — if the
612    /// projection is structurally inconsistent.
613    pub fn install_undo_tree(&self, ser: &crate::SerTree) -> bool {
614        match crate::UndoTree::from_serializable(ser) {
615            Some(tree) => {
616                self.content.lock().unwrap().undo = tree;
617                true
618            }
619            None => false,
620        }
621    }
622
623    /// Install an undo tree recovered from a **swap** file (crash path),
624    /// verifying it against the just-recovered
625    /// buffer text before committing. Unlike [`Self::install_undo_tree`] (the
626    /// undofile / clean-close path, whose caller gates on a content hash), the
627    /// swap tail rides an unsaved buffer, so this re-checks consistency itself.
628    ///
629    /// Returns `false` — leaving the fresh single-node tree seeded from the
630    /// recovered content untouched — when the projection is structurally
631    /// invalid, its current node's `seq` differs from `current_seq`, or its
632    /// current node doesn't materialize to the live buffer text. Must run AFTER
633    /// the recovered content is installed (which resets undo to a single node).
634    /// A trailing-newline difference is normalized away, matching how recovery
635    /// installs `body.strip_suffix('\n')`.
636    pub fn install_recovered_undo_tree(&self, ser: &crate::SerTree, current_seq: u64) -> bool {
637        let Some(mut tree) = crate::UndoTree::from_serializable(ser) else {
638            return false;
639        };
640        if tree.current_node_seq() != current_seq {
641            return false;
642        }
643        let mut c = self.content.lock().unwrap();
644        let materialized = tree.current_content();
645        if materialized.to_string().trim_end_matches('\n')
646            != c.text.to_string().trim_end_matches('\n')
647        {
648            return false;
649        }
650        c.undo = tree;
651        true
652    }
653
654    pub fn clear_redo(&self) {
655        self.content.lock().unwrap().undo.clear_redo();
656    }
657
658    /// Whether an undo group is currently open on this content (depth `> 0`).
659    /// Used by the engine's `push_undo` to decide whether to coalesce.
660    pub fn undo_group_active(&self) -> bool {
661        self.content.lock().unwrap().undo_group_active()
662    }
663
664    /// Arm the open group's single snapshot. See [`Buffer::undo_group_arm`].
665    pub fn undo_group_arm(&self) -> bool {
666        self.content.lock().unwrap().undo_group_arm()
667    }
668
669    pub fn cap_undo(&self, cap: usize) {
670        self.content.lock().unwrap().undo.cap(cap);
671    }
672
673    pub fn content_dirty(&self) -> bool {
674        self.content.lock().unwrap().content_dirty
675    }
676
677    pub fn set_content_dirty(&self, v: bool) {
678        self.content.lock().unwrap().content_dirty = v;
679    }
680
681    pub fn mark_content_dirty(&self) {
682        let mut c = self.content.lock().unwrap();
683        c.content_dirty = true;
684        c.cached_editor_content = None;
685    }
686
687    pub fn take_dirty(&self) -> bool {
688        let mut c = self.content.lock().unwrap();
689        let v = c.content_dirty;
690        c.content_dirty = false;
691        v
692    }
693
694    pub fn cached_editor_content(&self) -> Option<std::sync::Arc<String>> {
695        self.content.lock().unwrap().cached_editor_content.clone()
696    }
697
698    pub fn set_cached_editor_content(&self, arc: std::sync::Arc<String>) {
699        self.content.lock().unwrap().cached_editor_content = Some(arc);
700    }
701
702    pub fn push_fold_op(&self, op: crate::FoldOp) {
703        self.content.lock().unwrap().pending_fold_ops.push(op);
704    }
705
706    pub fn take_fold_ops(&self) -> Vec<crate::FoldOp> {
707        std::mem::take(&mut self.content.lock().unwrap().pending_fold_ops)
708    }
709
710    pub fn extend_change_log(&self, edits: impl IntoIterator<Item = crate::EngineEdit>) {
711        self.content.lock().unwrap().change_log.extend(edits);
712    }
713
714    pub fn take_change_log(&self) -> Vec<crate::EngineEdit> {
715        std::mem::take(&mut self.content.lock().unwrap().change_log)
716    }
717
718    pub fn extend_pending_content_edits(
719        &self,
720        edits: impl IntoIterator<Item = crate::ContentEdit>,
721    ) {
722        self.content
723            .lock()
724            .unwrap()
725            .pending_content_edits
726            .extend(edits);
727    }
728
729    pub fn push_pending_content_edit(&self, edit: crate::ContentEdit) {
730        self.content
731            .lock()
732            .unwrap()
733            .pending_content_edits
734            .push(edit);
735    }
736
737    pub fn take_pending_content_edits(&self) -> Vec<crate::ContentEdit> {
738        std::mem::take(&mut self.content.lock().unwrap().pending_content_edits)
739    }
740
741    pub fn clear_pending_content_edits(&self) {
742        self.content.lock().unwrap().pending_content_edits.clear();
743    }
744
745    pub fn pending_content_reset(&self) -> bool {
746        self.content.lock().unwrap().pending_content_reset
747    }
748
749    pub fn set_pending_content_reset(&self, v: bool) {
750        self.content.lock().unwrap().pending_content_reset = v;
751    }
752
753    pub fn take_pending_content_reset(&self) -> bool {
754        let mut c = self.content.lock().unwrap();
755        let v = c.pending_content_reset;
756        c.pending_content_reset = false;
757        v
758    }
759
760    pub fn mark(&self, c: char) -> Option<(usize, usize)> {
761        self.content_lock().marks.get(&c).copied()
762    }
763    pub fn set_mark(&mut self, c: char, pos: (usize, usize)) {
764        self.content_lock_mut().marks.insert(c, pos);
765    }
766    pub fn clear_mark(&mut self, c: char) {
767        self.content_lock_mut().marks.remove(&c);
768    }
769    pub fn marks_cloned(&self) -> std::collections::BTreeMap<char, (usize, usize)> {
770        self.content_lock().marks.clone()
771    }
772    pub fn set_marks(&mut self, marks: std::collections::BTreeMap<char, (usize, usize)>) {
773        self.content_lock_mut().marks = marks;
774    }
775    /// Drop marks inside `[edit_start, drop_end)` and shift marks at/after
776    /// `shift_threshold` by `delta` rows (clamped to 0). Mirrors the engine's
777    /// edit-coherence pass for the per-buffer mark map (#154).
778    pub fn rebase_marks(
779        &mut self,
780        edit_start: usize,
781        drop_end: usize,
782        shift_threshold: usize,
783        delta: isize,
784    ) {
785        let mut c = self.content_lock_mut();
786        let mut to_drop: Vec<char> = Vec::new();
787        for (ch, (row, _col)) in c.marks.iter_mut() {
788            if (edit_start..drop_end).contains(row) {
789                to_drop.push(*ch);
790            } else if *row >= shift_threshold {
791                *row = ((*row as isize) + delta).max(0) as usize;
792            }
793        }
794        for ch in to_drop {
795            c.marks.remove(&ch);
796        }
797    }
798    pub fn syntax_fold_ranges_cloned(&self) -> Vec<(usize, usize)> {
799        self.content_lock().syntax_fold_ranges.clone()
800    }
801    pub fn set_syntax_fold_ranges(&mut self, ranges: Vec<(usize, usize)>) {
802        self.content_lock_mut().syntax_fold_ranges = ranges;
803    }
804}
805
806// ── Rope line helpers (free functions over &ropey::Rope) ─────────────
807
808/// Largest char-boundary byte index `<= byte_idx` (clamped to rope length).
809///
810/// Byte offsets that arrive from outside the rope — LSP positions clamped to
811/// `len_bytes`, a stale tree-sitter node range — can land in the middle of a
812/// multi-byte char (e.g. a 4-byte emoji whose last byte sits at `N-1` while
813/// the clamp produced `N-3`). `ropey::Rope::byte_slice` panics on a
814/// non-aligned index, so callers must floor first. Uses ropey's own
815/// conversion, which is safe for any byte value `<= len_bytes`:
816/// `byte_to_char` returns the index of the char *containing* a non-boundary
817/// byte. For an already char-aligned, in-bounds index this is the identity.
818pub fn floor_char_boundary(rope: &ropey::Rope, byte_idx: usize) -> usize {
819    let byte_idx = byte_idx.min(rope.len_bytes());
820    rope.char_to_byte(rope.byte_to_char(byte_idx))
821}
822
823/// Absolute byte index where row `row`'s content ends — the first byte of the
824/// separator ropey split on, or `len_bytes()` for the final row.
825///
826/// ropey's default `unicode_lines` feature splits on `\r`, U+000B, U+000C,
827/// U+0085 and U+2028 / U+2029 as well as `\n`, and those are 1–3 bytes wide.
828/// `line_to_byte(row + 1)` points just past the separator, so stepping back a
829/// hard-coded one byte lands *inside* a multi-byte one; flooring to the
830/// enclosing char start snaps to the separator's first byte, which is exactly
831/// the end of this row's content. `\r\n` is unaffected: `\n` begins a char, so
832/// the floor is the identity and a CRLF row keeps its trailing `\r` — the same
833/// rule [`crate::rope_row_range_str`]'s engine-side twin applies.
834fn rope_line_content_end(rope: &ropey::Rope, row: usize) -> usize {
835    if row + 1 >= rope.len_lines() {
836        return rope.len_bytes();
837    }
838    let step_back = rope.line_to_byte(row + 1).saturating_sub(1);
839    floor_char_boundary(rope, step_back)
840}
841
842/// Return logical line `row` as a `String`, stripping the line separator that
843/// ropey includes for non-final lines.
844pub fn rope_line_str(rope: &ropey::Rope, row: usize) -> String {
845    // Preserves the previous contract: ropey panics for `row >= len_lines()`.
846    let start = rope.line_to_byte(row);
847    rope.byte_slice(start..rope_line_content_end(rope, row))
848        .to_string()
849}
850
851/// Byte length of logical line `row` (excluding the line separator).
852///
853/// A `row` at or past `len_lines()` answers 0 rather than panicking: this is
854/// `pub`, so the row can arrive from a caller holding a position that went
855/// stale against a shrunken rope, and `ropey::Rope::line` panics on one.
856pub fn rope_line_bytes(rope: &ropey::Rope, row: usize) -> usize {
857    if row >= rope.len_lines() {
858        return 0;
859    }
860    rope_line_content_end(rope, row).saturating_sub(rope.line_to_byte(row))
861}
862
863/// Char count of logical line `row` (excluding the line separator).
864///
865/// Out of range (`row >= len_lines()`) answers 0, for the same reason as
866/// [`rope_line_bytes`]: a stale row from a `pub` caller must not panic ropey.
867pub fn rope_line_char_count(rope: &ropey::Rope, row: usize) -> usize {
868    if row >= rope.len_lines() {
869        return 0;
870    }
871    let end = rope_line_content_end(rope, row);
872    rope.byte_to_char(end)
873        .saturating_sub(rope.byte_to_char(rope.line_to_byte(row)))
874}
875
876/// Char index from `(row, col)` where `col` is a char index within the line.
877/// Both coordinates are clamped to the rope's bounds so a position that went
878/// stale (e.g. another view shrank the shared `Buffer` between the caller's
879/// clamp and this call) can never panic `line_to_char`.
880pub fn pos_to_char_idx(rope: &ropey::Rope, row: usize, col: usize) -> usize {
881    let row = row.min(rope.len_lines().saturating_sub(1));
882    let line_start = rope.line_to_char(row);
883    let line_char_count = rope_line_char_count(rope, row);
884    line_start + col.min(line_char_count)
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890
891    #[test]
892    fn new_has_one_empty_row() {
893        let b = View::new();
894        assert_eq!(b.row_count(), 1);
895        assert_eq!(rope_line_str(&b.rope(), 0), "");
896        assert_eq!(b.cursor(), Position::default());
897    }
898
899    #[test]
900    fn from_str_splits_on_newline() {
901        let b = View::from_str("foo\nbar\nbaz");
902        assert_eq!(b.row_count(), 3);
903        assert_eq!(rope_line_str(&b.rope(), 0), "foo");
904        assert_eq!(rope_line_str(&b.rope(), 2), "baz");
905    }
906
907    #[test]
908    fn from_str_trailing_newline_keeps_empty_row() {
909        let b = View::from_str("foo\n");
910        assert_eq!(b.row_count(), 2);
911        assert_eq!(rope_line_str(&b.rope(), 1), "");
912    }
913
914    /// ropey's default `unicode_lines` feature splits on more than `\n`, and
915    /// those separators are 1–3 bytes wide. A row helper that strips a literal
916    /// `'\n'` — or subtracts a hard-coded one byte — leaves the separator in
917    /// the row's content, so the row reads one or more chars longer than it is.
918    /// Found by the `handle_key` fuzz target via the curswant invariant.
919    #[test]
920    fn line_helpers_strip_every_line_separator() {
921        // `\r\n` is deliberately absent: ropey makes it one break, and the
922        // established rule (`rope_row_range_str`) keeps the `\r` as content.
923        for sep in ["\r", "\u{0b}", "\u{0c}", "\u{85}", "\u{2028}", "\u{2029}"] {
924            let rope = ropey::Rope::from_str(&format!("ab{sep}cd{sep}ef"));
925            assert_eq!(rope.len_lines(), 3, "separator {sep:?} must split rows");
926            for row in 0..3 {
927                let expect = ["ab", "cd", "ef"][row];
928                assert_eq!(
929                    rope_line_str(&rope, row),
930                    expect,
931                    "rope_line_str kept separator {sep:?} in row {row}"
932                );
933                assert_eq!(
934                    rope_line_bytes(&rope, row),
935                    expect.len(),
936                    "rope_line_bytes counted separator {sep:?} in row {row}"
937                );
938                assert_eq!(
939                    rope_line_char_count(&rope, row),
940                    expect.chars().count(),
941                    "rope_line_char_count counted separator {sep:?} in row {row}"
942                );
943            }
944        }
945    }
946
947    /// The three helpers must agree with each other on every row: they are
948    /// three views of one length, and the curswant invariant compares a column
949    /// derived from one against a clamp derived from another.
950    #[test]
951    fn line_helpers_agree_with_each_other() {
952        let rope = ropey::Rope::from_str("ab\r\ncd\re\u{2028}f\u{85}\u{0c}g\nh");
953        for row in 0..rope.len_lines() {
954            let s = rope_line_str(&rope, row);
955            assert_eq!(
956                rope_line_bytes(&rope, row),
957                s.len(),
958                "bytes differ on {row}"
959            );
960            assert_eq!(
961                rope_line_char_count(&rope, row),
962                s.chars().count(),
963                "char count differs on row {row}"
964            );
965        }
966    }
967
968    #[test]
969    fn from_str_empty_input_keeps_one_row() {
970        let b = View::from_str("");
971        assert_eq!(b.row_count(), 1);
972        assert_eq!(rope_line_str(&b.rope(), 0), "");
973    }
974
975    #[test]
976    fn as_string_round_trips() {
977        let b = View::from_str("a\nb\nc");
978        assert_eq!(b.as_string(), "a\nb\nc");
979    }
980
981    #[test]
982    fn dirty_gen_starts_at_zero() {
983        assert_eq!(View::new().dirty_gen(), 0);
984    }
985
986    /// A minimal single-node [`crate::SerTree`] whose root materializes to
987    /// `base`, tagged with `seq`.
988    fn single_node_tree(base: &str, seq: u64) -> crate::SerTree {
989        crate::SerTree {
990            base: Some(base.to_string()),
991            nodes: vec![crate::SerNode {
992                parent: None,
993                children: Vec::new(),
994                last_child: None,
995                delta: None,
996                cursor: (0, 0),
997                timestamp_unix_ms: 0,
998                marks: crate::MarkSnapshot::default(),
999                seq,
1000            }],
1001            root: 0,
1002            current: 0,
1003            next_seq: seq + 1,
1004        }
1005    }
1006
1007    /// `install_recovered_undo_tree` installs a tree whose current node matches
1008    /// the live buffer content and whose `seq` matches — and rejects (leaves the
1009    /// fresh tree) on a `seq` or content mismatch (the swap consistency guard).
1010    #[test]
1011    fn install_recovered_undo_tree_guards_seq_and_content() {
1012        // Match: content + seq agree → installs.
1013        let v = View::from_str("alpha\nhello");
1014        assert!(v.install_recovered_undo_tree(&single_node_tree("alpha\nhello", 3), 3));
1015
1016        // Seq mismatch → rejected.
1017        let v = View::from_str("alpha\nhello");
1018        assert!(!v.install_recovered_undo_tree(&single_node_tree("alpha\nhello", 3), 4));
1019
1020        // Content mismatch → rejected.
1021        let v = View::from_str("alpha\nhello");
1022        assert!(!v.install_recovered_undo_tree(&single_node_tree("something else", 3), 3));
1023
1024        // A trailing-newline-only difference is normalized away → still installs.
1025        let v = View::from_str("alpha\nhello");
1026        assert!(v.install_recovered_undo_tree(&single_node_tree("alpha\nhello\n", 3), 3));
1027    }
1028
1029    fn vp_wrap(width: u16, height: u16) -> Viewport {
1030        Viewport {
1031            top_row: 0,
1032            top_col: 0,
1033            width,
1034            height,
1035            wrap: crate::Wrap::Char,
1036            text_width: width,
1037            tab_width: 0,
1038        }
1039    }
1040
1041    #[test]
1042    fn ensure_cursor_visible_wrap_scrolls_when_cursor_below_screen() {
1043        let mut b = View::from_str("aaaaaaaaaa\nb\nc");
1044        let mut v = vp_wrap(4, 3);
1045        b.set_cursor(Position::new(2, 0));
1046        b.ensure_cursor_visible(&mut v);
1047        assert_eq!(v.top_row, 1);
1048    }
1049
1050    #[test]
1051    fn ensure_cursor_visible_wrap_no_scroll_when_visible() {
1052        let mut b = View::from_str("aaaaaaaaaa\nb");
1053        let mut v = vp_wrap(4, 4);
1054        b.set_cursor(Position::new(0, 5));
1055        b.ensure_cursor_visible(&mut v);
1056        assert_eq!(v.top_row, 0);
1057    }
1058
1059    #[test]
1060    fn ensure_cursor_visible_wrap_snaps_top_when_cursor_above() {
1061        let mut b = View::from_str("a\nb\nc\nd\ne");
1062        let mut v = vp_wrap(4, 2);
1063        v.top_row = 3;
1064        b.set_cursor(Position::new(1, 0));
1065        b.ensure_cursor_visible(&mut v);
1066        assert_eq!(v.top_row, 1);
1067    }
1068
1069    #[test]
1070    fn screen_rows_between_sums_segments_under_wrap() {
1071        let b = View::from_str("aaaaaaaaa\nb\n");
1072        let v = vp_wrap(4, 0);
1073        assert_eq!(b.screen_rows_between(&v, 0, 0), 3);
1074        assert_eq!(b.screen_rows_between(&v, 0, 1), 4);
1075        assert_eq!(b.screen_rows_between(&v, 0, 2), 5);
1076        assert_eq!(b.screen_rows_between(&v, 1, 2), 2);
1077    }
1078
1079    #[test]
1080    fn screen_rows_between_one_per_doc_row_when_wrap_off() {
1081        let b = View::from_str("aaaaa\nb\nc");
1082        let v = Viewport::default();
1083        assert_eq!(b.screen_rows_between(&v, 0, 2), 3);
1084    }
1085
1086    #[test]
1087    fn max_top_for_height_walks_back_until_height_reached() {
1088        let b = View::from_str("a\nb\nc\nd\neeeeeeee");
1089        let v = vp_wrap(4, 0);
1090        assert_eq!(b.max_top_for_height(&v, 4), 2);
1091        assert_eq!(b.max_top_for_height(&v, 99), 0);
1092    }
1093
1094    #[test]
1095    fn cursor_screen_row_returns_none_when_wrap_off() {
1096        let b = View::from_str("a");
1097        let v = Viewport::default();
1098        assert!(b.cursor_screen_row(&v).is_none());
1099    }
1100
1101    #[test]
1102    fn cursor_screen_row_under_wrap() {
1103        let mut b = View::from_str("aaaaaaaaaa\nb");
1104        let v = vp_wrap(4, 0);
1105        b.set_cursor(Position::new(0, 5));
1106        assert_eq!(b.cursor_screen_row(&v), Some(1));
1107        b.set_cursor(Position::new(1, 0));
1108        assert_eq!(b.cursor_screen_row(&v), Some(3));
1109    }
1110
1111    /// Regression: a view whose cursor went stale after another view shrank
1112    /// the shared Buffer used to panic `rope.line()` inside
1113    /// `cursor_screen_row_from`. The row must clamp to the live rope.
1114    #[test]
1115    fn cursor_screen_row_survives_shrink_from_other_view() {
1116        let a = View::from_str("a\nb\nc\nd\ne");
1117        let arc = a.content_arc();
1118        let mut view_a = View::new_view(Arc::clone(&arc));
1119        let mut view_b = View::new_view(Arc::clone(&arc));
1120        view_b.set_cursor(Position::new(4, 0));
1121        // view_a truncates the document; view_b's cursor row 4 is now stale.
1122        view_a.replace_all("a");
1123        let v = vp_wrap(4, 3);
1124        assert_eq!(view_b.cursor_screen_row(&v), Some(0));
1125        let mut v2 = vp_wrap(4, 3);
1126        view_b.ensure_cursor_visible(&mut v2); // must not panic
1127    }
1128
1129    /// Regression: after another view shrank the shared Buffer, a `top_row`
1130    /// left past the rope's end must be pulled *back* to the clamped cursor
1131    /// row. Assigning the raw stale `cursor.row` would push `top_row` further
1132    /// past the end, leaving the cursor off-screen.
1133    #[test]
1134    fn ensure_cursor_visible_clamps_stale_top_row_after_shrink() {
1135        let a = View::from_str("a\nb\nc\nd\ne");
1136        let arc = a.content_arc();
1137        let mut view_a = View::new_view(Arc::clone(&arc));
1138        let mut view_b = View::new_view(Arc::clone(&arc));
1139        view_b.set_cursor(Position::new(4, 0));
1140        view_a.replace_all("a");
1141        let mut v = vp_wrap(4, 3);
1142        // Stale scroll position: below the cursor's clamped row, above the
1143        // stale cursor row, and past the (now single-line) rope.
1144        v.top_row = 3;
1145        view_b.ensure_cursor_visible(&mut v);
1146        assert_eq!(v.top_row, 0, "top_row must clamp into the live rope");
1147        assert_eq!(v.top_col, 0);
1148    }
1149
1150    /// Regression: a stale cursor ROW past the live rope used to make the
1151    /// scroll loop subtract more wrap segments than `cursor_screen_row_from`
1152    /// had summed — `attempt to subtract with overflow` in debug, a
1153    /// `rope.line()` panic in release. The loop must bound itself by the
1154    /// clamped cursor row, and must not assign the raw stale row to
1155    /// `top_row`.
1156    #[test]
1157    fn ensure_cursor_visible_survives_stale_cursor_row_after_shrink() {
1158        // Row 5 is long enough that `set_cursor` stores col 26 unclamped.
1159        let a = View::from_str(
1160            "aa\nbb\nxxxxxxxxxxxxxxxxxxxxxxxxxxxx\ncc\ndd\nyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
1161        );
1162        let arc = a.content_arc();
1163        let mut view_a = View::new_view(Arc::clone(&arc));
1164        let mut view_b = View::new_view(Arc::clone(&arc));
1165        view_b.set_cursor(Position::new(5, 26));
1166        // view_a truncates the document to 3 rows; view_b's cursor (5, 26)
1167        // is now stale — exactly the concurrent-shrink scenario.
1168        view_a.replace_all("aa\nbb\nxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
1169        let mut v = vp_wrap(4, 6);
1170        view_b.ensure_cursor_visible(&mut v); // must not panic
1171        assert_eq!(
1172            v.top_row, 2,
1173            "top_row must scroll to the clamped cursor row, not past the rope"
1174        );
1175        assert_eq!(v.top_col, 0);
1176    }
1177
1178    /// The other `cursor_screen_row_from` `None` path: the cursor's row is
1179    /// hidden inside a closed fold, so the walk never reaches it. `top_row`
1180    /// still snaps to the cursor row (in-range, so no clamping applies).
1181    #[test]
1182    fn ensure_cursor_visible_snaps_when_cursor_row_folded() {
1183        let mut b = View::from_str("a\nb\nc\nd\ne");
1184        b.set_folds(&[crate::Fold {
1185            start_row: 2,
1186            end_row: 4,
1187            closed: true,
1188            auto_generated: false,
1189        }]);
1190        let mut v = vp_wrap(4, 2);
1191        v.top_row = 1;
1192        b.set_cursor(Position::new(3, 0));
1193        b.ensure_cursor_visible(&mut v);
1194        assert_eq!(v.top_row, 3);
1195    }
1196
1197    #[test]
1198    fn ensure_cursor_visible_falls_back_when_wrap_disabled() {
1199        let mut b = View::from_str("a\nb\nc\nd\ne");
1200        let mut v = Viewport {
1201            top_row: 0,
1202            top_col: 0,
1203            width: 4,
1204            height: 2,
1205            wrap: crate::Wrap::None,
1206            text_width: 4,
1207            tab_width: 0,
1208        };
1209        b.set_cursor(Position::new(4, 0));
1210        b.ensure_cursor_visible(&mut v);
1211        assert_eq!(v.top_row, 3);
1212    }
1213
1214    // ── Per-buffer engine state tests (new in 0.33.0 / Phase B) ──────
1215
1216    /// Undo entries pushed via one `View` view are visible via
1217    /// another view sharing the same `Buffer` — proving that the
1218    /// undo stack lives on `Buffer`, not on the per-window `View`.
1219    #[test]
1220    fn undo_stack_shared_across_views() {
1221        use crate::UndoEntry;
1222        use std::time::SystemTime;
1223
1224        let a = View::from_str("hello");
1225        let arc = a.content_arc();
1226        let view_a = View::new_view(Arc::clone(&arc));
1227        let view_b = View::new_view(Arc::clone(&arc));
1228
1229        assert!(view_a.undo_stack_is_empty());
1230        assert_eq!(view_a.undo_stack_len(), 0);
1231
1232        view_a.push_undo_entry(UndoEntry {
1233            rope: view_a.rope(),
1234            cursor: (0, 0),
1235            timestamp: SystemTime::UNIX_EPOCH,
1236            marks: Default::default(),
1237        });
1238
1239        // Push via view_a is visible via view_b.
1240        assert_eq!(view_b.undo_stack_len(), 1);
1241        assert!(!view_b.undo_stack_is_empty());
1242    }
1243
1244    /// A redo branch created via one view is visible via another — the undo
1245    /// tree lives on the shared `Buffer`, not the per-window `View`. (Phase 2a:
1246    /// re-expressed against the arena-tree API — a redo entry is now a forward
1247    /// child left behind by an undo move, not a `push_redo_entry` onto a Vec.)
1248    #[test]
1249    fn redo_stack_shared_across_views() {
1250        use crate::UndoEntry;
1251        use std::time::SystemTime;
1252
1253        let a = View::from_str("world");
1254        let arc = a.content_arc();
1255        let view_a = View::new_view(Arc::clone(&arc));
1256        let view_b = View::new_view(Arc::clone(&arc));
1257
1258        assert!(view_a.redo_stack_is_empty());
1259
1260        // Commit an undo boundary via view_b, then undo it — that leaves the
1261        // node we left (cursor (0, 2)) as a forward/redo branch on the shared
1262        // Content.
1263        view_b.push_undo_entry(UndoEntry {
1264            rope: view_b.rope(),
1265            cursor: (0, 0),
1266            timestamp: SystemTime::UNIX_EPOCH,
1267            marks: Default::default(),
1268        });
1269        view_b.undo_step(view_b.rope(), (0, 2), Default::default());
1270
1271        // The redo branch is visible + walkable via view_a.
1272        assert!(!view_a.redo_stack_is_empty());
1273        let entry = view_a.redo_step(view_a.rope(), (0, 0), Default::default());
1274        assert!(entry.is_some());
1275        assert_eq!(entry.unwrap().cursor, (0, 2));
1276    }
1277
1278    /// `clear_undo_redo` collapses the shared undo tree to a single node, wiping
1279    /// both directions, and the effect is visible from every view. (Phase 2a:
1280    /// the redo side is now seeded by an undo move rather than a raw
1281    /// `push_redo_entry`.)
1282    #[test]
1283    fn clear_undo_redo_shared_across_views() {
1284        use crate::UndoEntry;
1285        use std::time::SystemTime;
1286
1287        let a = View::from_str("abc");
1288        let arc = a.content_arc();
1289        let view_a = View::new_view(Arc::clone(&arc));
1290        let view_b = View::new_view(Arc::clone(&arc));
1291
1292        // Two boundaries then one undo → undo side AND redo side both populated.
1293        for _ in 0..2 {
1294            view_a.push_undo_entry(UndoEntry {
1295                rope: view_a.rope(),
1296                cursor: (0, 0),
1297                timestamp: SystemTime::UNIX_EPOCH,
1298                marks: Default::default(),
1299            });
1300        }
1301        view_a.undo_step(view_a.rope(), (0, 1), Default::default());
1302        assert!(!view_a.undo_stack_is_empty());
1303        assert!(!view_a.redo_stack_is_empty());
1304
1305        view_b.clear_undo_redo();
1306        assert!(view_a.undo_stack_is_empty());
1307        assert!(view_a.redo_stack_is_empty());
1308    }
1309
1310    /// `content_dirty` flag is shared across views.
1311    #[test]
1312    fn content_dirty_shared_across_views() {
1313        let a = View::from_str("test");
1314        let arc = a.content_arc();
1315        let view_a = View::new_view(Arc::clone(&arc));
1316        let view_b = View::new_view(Arc::clone(&arc));
1317
1318        assert!(!view_a.content_dirty());
1319
1320        view_b.mark_content_dirty();
1321        assert!(view_a.content_dirty());
1322
1323        let taken = view_a.take_dirty();
1324        assert!(taken);
1325        assert!(!view_b.content_dirty());
1326    }
1327
1328    /// `pending_fold_ops` push and take are shared across views.
1329    #[test]
1330    fn pending_fold_ops_shared_across_views() {
1331        let a = View::from_str("a\nb\nc");
1332        let arc = a.content_arc();
1333        let view_a = View::new_view(Arc::clone(&arc));
1334        let view_b = View::new_view(Arc::clone(&arc));
1335
1336        view_a.push_fold_op(crate::FoldOp::Add {
1337            start_row: 0,
1338            end_row: 1,
1339            closed: true,
1340        });
1341
1342        let ops = view_b.take_fold_ops();
1343        assert_eq!(ops.len(), 1);
1344        assert!(matches!(
1345            ops[0],
1346            crate::FoldOp::Add {
1347                start_row: 0,
1348                end_row: 1,
1349                closed: true
1350            }
1351        ));
1352    }
1353
1354    /// `pending_content_reset` flag is shared across views.
1355    #[test]
1356    fn pending_content_reset_shared_across_views() {
1357        let a = View::from_str("x");
1358        let arc = a.content_arc();
1359        let view_a = View::new_view(Arc::clone(&arc));
1360        let view_b = View::new_view(Arc::clone(&arc));
1361
1362        assert!(!view_a.pending_content_reset());
1363        view_b.set_pending_content_reset(true);
1364        assert!(view_a.pending_content_reset());
1365        let taken = view_a.take_pending_content_reset();
1366        assert!(taken);
1367        assert!(!view_b.pending_content_reset());
1368    }
1369
1370    // ── View-split tests (new in 0.8.0) ──────────────────────────
1371
1372    /// Two `View` views sharing one `Buffer` must have independent
1373    /// cursors.
1374    #[test]
1375    fn buffer_views_independent_cursors() {
1376        let a = View::from_str("hello\nworld");
1377        let arc = a.content_arc();
1378        let mut view_a = View::new_view(Arc::clone(&arc));
1379        let mut view_b = View::new_view(Arc::clone(&arc));
1380
1381        view_a.set_cursor(Position::new(1, 3));
1382        // view_b cursor must remain at (0, 0).
1383        assert_eq!(view_b.cursor(), Position::new(0, 0));
1384
1385        view_b.set_cursor(Position::new(0, 2));
1386        // view_a cursor must remain at (1, 3).
1387        assert_eq!(view_a.cursor(), Position::new(1, 3));
1388    }
1389
1390    /// `last_cursor` on the shared `Buffer` reflects the most recent move
1391    /// across two independent `View`s — the "last-moved window wins" contract
1392    /// the cross-session cursor store depends on (docs §6b).
1393    #[test]
1394    fn last_cursor_reflects_most_recent_move_across_views() {
1395        let a = View::from_str("aaaa\nbbbb\ncccc\ndddd");
1396        let arc = a.content_arc();
1397        let mut view_a = View::new_view(Arc::clone(&arc));
1398        let mut view_b = View::new_view(Arc::clone(&arc));
1399
1400        view_a.set_cursor(Position::new(1, 2));
1401        assert_eq!(view_a.last_cursor(), (1, 2));
1402        // Both views see the same shared last_cursor.
1403        assert_eq!(view_b.last_cursor(), (1, 2));
1404
1405        // A later move on view_b wins.
1406        view_b.set_cursor(Position::new(3, 1));
1407        assert_eq!(view_a.last_cursor(), (3, 1));
1408        assert_eq!(view_b.last_cursor(), (3, 1));
1409
1410        // last_cursor stores the CLAMPED landing position, not the request.
1411        view_a.set_cursor(Position::new(99, 99));
1412        assert_eq!(view_a.last_cursor(), (3, 4));
1413    }
1414
1415    /// Cursor-restore clamp contract (docs §6b): a stored row past EOF clamps
1416    /// to the last line, and a col past the line's char count clamps to its
1417    /// length. `clamp_position` is what `build_slot` runs on the stored cursor.
1418    #[test]
1419    fn clamp_position_restore_semantics() {
1420        let b = View::from_str("ab\ncdef\ng");
1421        // Row past the last line (2) → clamped to last line, col to its length.
1422        assert_eq!(b.clamp_position(Position::new(99, 99)), Position::new(2, 1));
1423        // Col past the line's char count → clamped to the char count (4).
1424        assert_eq!(b.clamp_position(Position::new(1, 99)), Position::new(1, 4));
1425        // In-bounds position is unchanged (exact restore on a content match).
1426        assert_eq!(b.clamp_position(Position::new(1, 2)), Position::new(1, 2));
1427    }
1428
1429    /// An edit applied via one view must be visible via the other.
1430    #[test]
1431    fn buffer_views_share_content() {
1432        use crate::edit::Edit;
1433
1434        let a = View::from_str("foo");
1435        let arc = a.content_arc();
1436        let mut view_a = View::new_view(Arc::clone(&arc));
1437        let view_b = View::new_view(Arc::clone(&arc));
1438
1439        view_a.apply_edit(Edit::InsertStr {
1440            at: Position::new(0, 3),
1441            text: "bar".into(),
1442        });
1443
1444        assert_eq!(rope_line_str(&view_a.rope(), 0), "foobar");
1445        assert_eq!(rope_line_str(&view_b.rope(), 0), "foobar");
1446    }
1447
1448    /// The line-measuring helpers are `pub`, so a stale row can reach them from
1449    /// outside; ropey would panic on it. Multi-byte content keeps the two
1450    /// answers distinguishable — a byte count silently used as a char count is
1451    /// the bug the separate helpers exist to prevent.
1452    #[test]
1453    fn line_measures_answer_zero_past_the_last_row() {
1454        let rope = ropey::Rope::from_str("café\nx");
1455        assert_eq!(rope.len_lines(), 2);
1456
1457        // Non-final line: the '\n' is excluded from both measures.
1458        assert_eq!(rope_line_bytes(&rope, 0), 5);
1459        assert_eq!(rope_line_char_count(&rope, 0), 4);
1460
1461        // Last line, no trailing newline: nothing to subtract.
1462        assert_eq!(rope_line_bytes(&rope, 1), 1);
1463        assert_eq!(rope_line_char_count(&rope, 1), 1);
1464
1465        // Out of range: 0, not a panic.
1466        assert_eq!(rope_line_bytes(&rope, 2), 0);
1467        assert_eq!(rope_line_char_count(&rope, 2), 0);
1468        assert_eq!(rope_line_bytes(&rope, usize::MAX), 0);
1469        assert_eq!(rope_line_char_count(&rope, usize::MAX), 0);
1470    }
1471}
1472
1473#[cfg(test)]
1474mod marks_shared_content_tests {
1475    use super::*;
1476
1477    #[test]
1478    fn marks_shared_across_views() {
1479        // Two View views on the same Buffer share marks (#154).
1480        let a = View::from_str("hello\nworld");
1481        let content = a.content_arc();
1482        let mut view_a = View::new_view(std::sync::Arc::clone(&content));
1483        let view_b = View::new_view(std::sync::Arc::clone(&content));
1484
1485        // Set mark 'x' on view_a.
1486        view_a.set_mark('x', (1, 3));
1487
1488        // view_b must see the same mark via shared Buffer.
1489        assert_eq!(view_b.mark('x'), Some((1, 3)));
1490    }
1491
1492    #[test]
1493    fn syntax_fold_ranges_shared_across_views() {
1494        let a = View::from_str("fn foo() {\n  bar();\n}");
1495        let content = a.content_arc();
1496        let mut view_a = View::new_view(std::sync::Arc::clone(&content));
1497        let view_b = View::new_view(std::sync::Arc::clone(&content));
1498
1499        view_a.set_syntax_fold_ranges(vec![(0, 2)]);
1500
1501        assert_eq!(view_b.syntax_fold_ranges_cloned(), vec![(0, 2)]);
1502    }
1503}