Skip to main content

scrive_core/
buffer.rs

1//! The document text store: a rope.
2//!
3//! Storage is a `Rope` — an augmented `SumTree<Chunk>`, the one
4//! tree every position query rides. The **LF-only line model** lives in the
5//! chunk summary (a `'\n'` count), so line/point math is a summary fold with no
6//! second index to drift; a test pins that only `'\n'` breaks a line. An edit is
7//! `O(edit + log chunks)` and coordinate lookups are `O(log chunks)` cursor
8//! descents — no operation is bandwidth-bound in the document length. There is no
9//! load-size policy: the only bound is the `u32` offset space (~4 GiB), checked at
10//! load and on the edit path. (The rope is oracle-tested byte-for-byte against an
11//! independent reference model.)
12//!
13//! **The read API is backing-agnostic:** every read hands out [`Cow`] slices or
14//! single chars —
15//! [`Buffer::slice`], [`Buffer::line`], [`Buffer::char_at`],
16//! [`Buffer::char_before`] — so no consumer outside this module assumes the
17//! text is one contiguous allocation. A `Cow` is `Borrowed` when the requested
18//! range lies inside one rope chunk (always, for documents small enough to be
19//! a single leaf) and `Owned` when it crosses chunks. [`Buffer::text`] is the
20//! one whole-text read: **cold-path only** (find seeding, whole-document
21//! scans, serialization, tests) — it is a genuine O(len) materialization on
22//! any document large enough to span chunks, and per-frame/per-keystroke code
23//! must not call it.
24//!
25//! Internal text is **LF-only** and the trailing terminator, if any, is an
26//! empty final line — never a flag. The original EOL flavor is remembered only
27//! to re-expand at [`Buffer::serialize`].
28
29use std::borrow::Cow;
30use std::ops::Range;
31use std::sync::atomic::{AtomicU64, Ordering};
32
33use crate::coords::{snap_char_boundary, Bias, Point};
34use crate::rope::Rope;
35
36/// Fixed window size for the buffer's backing-agnostic literal scanners
37/// ([`crate::find`]'s `scan_buffer` and the document's `scan_from`, both via
38/// [`Buffer::scan_resume`]) — big enough that the per-window match scan
39/// dominates the per-window bookkeeping, small enough that a near or capped
40/// match stops after touching only a prefix of the rope, never materializing
41/// the whole document. One knob so the two scanners cannot drift.
42pub(crate) const SCAN_WINDOW: u32 = 64 * 1024;
43
44/// Why a [`Buffer::new`] load was refused.
45///
46/// There is **no policy size limit**: the only refusal is representational —
47/// byte offsets are `u32`, so a document must fit the u32 offset space. Hosts
48/// wanting a policy cap (e.g. "don't open logs in the editor") enforce it before
49/// loading.
50#[non_exhaustive]
51#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
52pub enum LoadError {
53    /// The input doesn't fit the `u32` offset space (~4 GiB).
54    #[error("document of {len} bytes exceeds the u32 offset space (4 GiB)")]
55    TooLarge {
56        /// The rejected input's byte length.
57        len: usize,
58    },
59}
60
61/// The load-size guard: `Err` iff `len` can't be addressed by `u32` offsets.
62/// Pure and length-only, so it is unit-testable without allocating 4 GiB.
63fn check_load_len(len: usize) -> Result<(), LoadError> {
64    if len >= u32::MAX as usize {
65        return Err(LoadError::TooLarge { len });
66    }
67    Ok(())
68}
69
70/// Monotonic per-document transaction counter: `0` at load, `+1` per committed
71/// transaction *including undo and redo*. Never reused within a document's
72/// lifetime, never runs backwards. The universal cache-key component and async
73/// correlation stamp.
74#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
75pub struct Revision(pub u64);
76
77/// Process-unique document identity, minted fresh at each load. Async replies
78/// stamped `(DocId, Revision)` can never collide across reloads.
79#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
80pub struct DocId(u64);
81
82static NEXT_DOC_ID: AtomicU64 = AtomicU64::new(1);
83
84/// The line-ending flavor detected at load; consulted only by
85/// [`Buffer::serialize`]. Internal text is always LF.
86#[derive(Copy, Clone, PartialEq, Eq, Debug)]
87pub enum EolFlavor {
88    /// Unix `\n`.
89    Lf,
90    /// Windows `\r\n`.
91    CrLf,
92}
93
94/// An immutable, `Send + Sync` copy of the document for background consumers
95/// (e.g. a debounced compile thread).
96///
97/// Creation is **O(1)** — a rope clone shares structure. The consumer pays for
98/// what it reads: [`Snapshot::text`]
99/// is an O(len) materialization that now runs on the *consumer's* thread, not
100/// the UI thread; the ranged reads mirror [`Buffer`]'s backing-agnostic API.
101#[derive(Clone, Debug)]
102pub struct Snapshot {
103    text: Rope,
104    doc_id: DocId,
105    revision: Revision,
106}
107
108impl Snapshot {
109    /// Total byte length of the snapshot.
110    #[must_use]
111    pub fn len(&self) -> u32 {
112        self.text.len()
113    }
114
115    /// Whether the snapshot is empty (a single empty line — never truly zero).
116    #[must_use]
117    pub fn is_empty(&self) -> bool {
118        self.text.is_empty()
119    }
120
121    /// The whole snapshot text, LF-only. `Borrowed` for single-chunk (small)
122    /// documents; an O(len) materialization otherwise — run it on the
123    /// consumer's thread.
124    #[must_use]
125    pub fn text(&self) -> Cow<'_, str> {
126        self.text.slice(0..self.text.len())
127    }
128
129    /// The text in `range` (byte offsets on char boundaries). Clamps to the
130    /// snapshot end.
131    #[must_use]
132    pub fn slice(&self, range: Range<u32>) -> Cow<'_, str> {
133        self.text.slice(range)
134    }
135
136    /// The text of one row, excluding its trailing `\n`. Out-of-range rows
137    /// return `""`.
138    #[must_use]
139    pub fn line(&self, row: u32) -> Cow<'_, str> {
140        self.text.line(row)
141    }
142
143    /// Number of lines. Always ≥ 1; a trailing `\n` yields a final empty line.
144    #[must_use]
145    pub fn line_count(&self) -> u32 {
146        self.text.line_count()
147    }
148
149    /// The document this snapshot came from.
150    #[must_use]
151    pub fn doc_id(&self) -> DocId {
152        self.doc_id
153    }
154
155    /// The revision this snapshot froze.
156    #[must_use]
157    pub fn revision(&self) -> Revision {
158        self.revision
159    }
160}
161
162/// The single owner of document text.
163///
164/// Invariants, upheld inside every mutation so no observer sees them disagree:
165/// 1. `text` is LF-only (no `\r`). The trailing terminator, if any, is an empty
166///    final line in `text`, not a flag. Line boundaries are the rope's own
167///    bookkeeping (LF-only by feature selection) — there is no second index to
168///    drift.
169/// 2. `revision` bumps once per committed transaction (via `Buffer::bump_revision`,
170///    called by the transaction engine).
171#[derive(Clone, Debug)]
172pub struct Buffer {
173    text: Rope,
174    revision: Revision,
175    doc_id: DocId,
176    eol_flavor: EolFlavor,
177}
178
179impl Buffer {
180    /// Load `input` as a new document.
181    ///
182    /// Refuses only input past the `u32` offset space (see [`LoadError`] — no
183    /// policy limit); normalizes `\r\n | \r → \n`, remembering the detected
184    /// [`EolFlavor`]; mints a fresh [`DocId`] at revision `0`.
185    pub fn new(input: &str) -> Result<Self, LoadError> {
186        check_load_len(input.len())?;
187        let eol_flavor = if input.contains("\r\n") {
188            EolFlavor::CrLf
189        } else {
190            EolFlavor::Lf
191        };
192        // Normalize to LF only. Avoid the intermediate String when the input
193        // is already clean (normalization only shrinks, so the length check
194        // above covers both paths).
195        let text = if input.bytes().any(|b| b == b'\r') {
196            Rope::from_str(&input.replace("\r\n", "\n").replace('\r', "\n"))
197        } else {
198            Rope::from_str(input)
199        };
200        Ok(Self {
201            text,
202            revision: Revision(0),
203            doc_id: DocId(NEXT_DOC_ID.fetch_add(1, Ordering::Relaxed)),
204            eol_flavor,
205        })
206    }
207
208    /// Total byte length of the document.
209    #[must_use]
210    pub fn len(&self) -> u32 {
211        self.text.len()
212    }
213
214    /// Whether the document is empty (a single empty line — never truly zero).
215    #[must_use]
216    pub fn is_empty(&self) -> bool {
217        self.text.is_empty()
218    }
219
220    /// The whole document text, LF-only, as one slice.
221    ///
222    /// **Cold-path read:** `Borrowed` only while the
223    /// document is a single rope chunk; an O(len) materialization otherwise.
224    /// For whole-document scans (find seeding, select-all-occurrences),
225    /// serialization, and tests — per-frame and per-keystroke code reads via
226    /// [`Buffer::slice`] / [`Buffer::line`] / [`Buffer::char_at`] /
227    /// [`Buffer::char_before`].
228    #[must_use]
229    pub fn text(&self) -> Cow<'_, str> {
230        self.text.slice(0..self.text.len())
231    }
232
233    /// The text in `range` (byte offsets; must lie on char boundaries, like
234    /// `str` slicing). Clamps to the document end. The workhorse ranged read —
235    /// `Borrowed` while the range sits in one rope chunk, `Owned` across
236    /// chunks.
237    #[must_use]
238    pub fn slice(&self, range: Range<u32>) -> Cow<'_, str> {
239        self.text.slice(range)
240    }
241
242    /// The text of one row, excluding its trailing `\n`. Out-of-range rows
243    /// return `""`.
244    #[must_use]
245    pub fn line(&self, row: u32) -> Cow<'_, str> {
246        self.text.line(row)
247    }
248
249    /// The char whose first byte is at `offset` — `None` at/past the end.
250    /// (`offset` must be a char boundary.) The forward one-char read every
251    /// boundary scan uses instead of slicing the whole text. `O(log chunks)`.
252    #[must_use]
253    pub fn char_at(&self, offset: u32) -> Option<char> {
254        let off = offset.min(self.len());
255        // chunk_at returns the chunk *containing* `off` (the right-hand chunk at
256        // an exact boundary), so the tail slice is non-empty except at the
257        // document end.
258        let (chunk, chunk_start) = self.text.chunk_at(off);
259        chunk[(off - chunk_start) as usize..].chars().next()
260    }
261
262    /// The char ending at `offset` — `None` at 0. (`offset` must be a char
263    /// boundary.) The backward twin of [`Buffer::char_at`].
264    #[must_use]
265    pub fn char_before(&self, offset: u32) -> Option<char> {
266        let off = offset.min(self.len());
267        if off == 0 {
268            return None;
269        }
270        // The char ending at `off` starts at most 4 bytes back; chunk
271        // boundaries are char boundaries, so the chunk holding byte `off - 1`
272        // holds the whole char.
273        let (chunk, chunk_start) = self.text.chunk_at(off - 1);
274        chunk[..(off - chunk_start) as usize].chars().next_back()
275    }
276
277    /// Number of lines. Always ≥ 1 (an empty document is one empty line); a
278    /// trailing `\n` yields a final empty line, so `line_count("a\nb\n") == 3`.
279    #[must_use]
280    pub fn line_count(&self) -> u32 {
281        self.text.line_count()
282    }
283
284    /// Byte length of one row's text (excludes `\n`). Out-of-range → 0.
285    /// Allocation-free (range arithmetic, no line materialization).
286    #[must_use]
287    pub fn line_len(&self, row: u32) -> u32 {
288        self.text.line_len(row)
289    }
290
291    /// The current revision.
292    #[must_use]
293    pub fn revision(&self) -> Revision {
294        self.revision
295    }
296
297    /// This document's identity.
298    #[must_use]
299    pub fn doc_id(&self) -> DocId {
300        self.doc_id
301    }
302
303    /// The EOL flavor detected at load.
304    #[must_use]
305    pub fn eol_flavor(&self) -> EolFlavor {
306        self.eol_flavor
307    }
308
309    /// Convert a byte offset to a [`Point`]. Offsets past the end clamp to the
310    /// end of the document. `O(log chunks)`.
311    #[must_use]
312    pub fn offset_to_point(&self, offset: u32) -> Point {
313        crate::perf::charge(1); // complexity gate: one position query
314        self.text.byte_to_point(offset)
315    }
316
317    /// Convert a [`Point`] to a byte offset, clamping the row to the last line
318    /// and the column to that line's length. `O(log chunks)`.
319    #[must_use]
320    pub fn point_to_offset(&self, point: Point) -> u32 {
321        crate::perf::charge(1); // complexity gate: one position query
322        self.text.point_to_offset(point)
323    }
324
325    /// Clamp a byte offset into `[0, len]` and snap it to a char boundary
326    /// (direction from `bias`). Idempotent.
327    #[must_use]
328    pub fn clip_offset(&self, offset: u32, bias: Bias) -> u32 {
329        let off = offset.min(self.len());
330        // A non-boundary offset sits strictly inside one char, and chunk
331        // boundaries are char boundaries — so the snap never leaves the chunk
332        // containing `off`.
333        let (chunk, chunk_start) = self.text.chunk_at(off);
334        chunk_start + snap_char_boundary(chunk, off - chunk_start, bias)
335    }
336
337    /// Where a windowed literal scan resumes after examining the window
338    /// `[pos, win_end)` for a `k`-byte needle (`k ≥ 1`). The **one** owner of
339    /// the seam-scan resume rule, shared by [`crate::find`]'s `scan_buffer` and
340    /// the document's `scan_from` so the subtle overlap arithmetic lives in a
341    /// single place: resume past the last in-window match end (`last_match_end`)
342    /// when the window found one; otherwise at the last char boundary at or
343    /// before `win_end − (k−1)` — no match can START later and still end inside
344    /// this window — clamped forward past `pos` so a multi-byte char can never
345    /// stall the scan.
346    #[must_use]
347    pub(crate) fn scan_resume(&self, pos: u32, win_end: u32, k: u32, last_match_end: Option<u32>) -> u32 {
348        match last_match_end {
349            Some(end) => end,
350            None => {
351                let safe = self.clip_offset((u64::from(win_end) - u64::from(k - 1)) as u32, Bias::Left);
352                safe.max(self.clip_offset(pos + 1, Bias::Right))
353            }
354        }
355    }
356
357    /// Clip a [`Point`] to a valid position: clamp the row/col, then snap the
358    /// resulting offset to a char boundary.
359    #[must_use]
360    pub fn clip_point(&self, point: Point, bias: Bias) -> Point {
361        self.offset_to_point(self.clip_offset(self.point_to_offset(point), bias))
362    }
363
364    /// An immutable copy for background consumers. **O(1)** — the
365    /// rope clone shares structure; nothing is materialized until the
366    /// consumer reads.
367    #[must_use]
368    pub fn snapshot(&self) -> Snapshot {
369        Snapshot { text: self.text.clone(), doc_id: self.doc_id, revision: self.revision }
370    }
371
372    /// Serialize for saving: verbatim EOL-expansion of the stored LF text into
373    /// `flavor`. The empty final line reproduces a trailing terminator by
374    /// construction; a no-trailing-newline file round-trips without one.
375    /// Cold-path O(len), like any save.
376    #[must_use]
377    pub fn serialize(&self, flavor: EolFlavor) -> String {
378        match flavor {
379            EolFlavor::Lf => {
380                let mut out = String::with_capacity(self.text.len() as usize);
381                self.text.for_each_chunk(|chunk| out.push_str(chunk));
382                out
383            }
384            EolFlavor::CrLf => {
385                // One byte per '\n' on top of len; chunk boundaries are char
386                // boundaries, so a '\n' never splits across chunks.
387                let extra = self.text.line_count() as usize - 1;
388                let mut out = String::with_capacity(self.text.len() as usize + extra);
389                for chunk in self.text.chunks() {
390                    let mut rest = chunk;
391                    while let Some(i) = rest.find('\n') {
392                        out.push_str(&rest[..i]);
393                        out.push_str("\r\n");
394                        rest = &rest[i + 1..];
395                    }
396                    out.push_str(rest);
397                }
398                out
399            }
400        }
401    }
402
403    /// Replace `range` (byte offsets in the current text) with `new_text`.
404    /// `O(edit + log chunks)` — the rope's bookkeeping *is* the line index,
405    /// so there is no second structure to splice.
406    ///
407    /// `pub(crate)`: reachable only through the transaction engine,
408    /// which owns validation, revision bumping, and inverse derivation.
409    /// `new_text` must already be LF-only (the transaction boundary normalizes).
410    pub(crate) fn splice(&mut self, range: Range<u32>, new_text: &str) {
411        debug_assert!(!new_text.as_bytes().contains(&b'\r'), "splice text must be LF-only");
412        let (start, end) = (range.start, range.end);
413        debug_assert!(start <= end && end <= self.len(), "splice range out of bounds");
414
415        debug_assert!(self.text.is_char_boundary(start), "splice start is not a char boundary");
416        debug_assert!(self.text.is_char_boundary(end), "splice end is not a char boundary");
417        self.text.replace(start..end, new_text);
418    }
419
420    /// Apply MANY disjoint edits (`(range, replacement)`, sorted ascending, disjoint,
421    /// in-bounds, LF-only) in ONE rope pass — the batched twin of N [`Self::splice`]s
422    /// (the document-scale multi-caret path). The transaction engine owns the
423    /// validation and revision bump, exactly as for `splice`.
424    pub(crate) fn edit_many(&mut self, edits: &[(Range<u32>, &str)]) {
425        debug_assert!(
426            edits.windows(2).all(|w| w[0].0.end <= w[1].0.start),
427            "edit_many wants sorted, disjoint ranges"
428        );
429        debug_assert!(
430            edits.iter().all(|(r, t)| {
431                r.end <= self.len()
432                    && self.text.is_char_boundary(r.start)
433                    && self.text.is_char_boundary(r.end)
434                    && !t.as_bytes().contains(&b'\r')
435            }),
436            "edit_many ranges must be in-bounds char boundaries and text LF-only"
437        );
438        // The common single-caret case takes the direct rope splice — no batch
439        // machinery for one edit; the batch pays off only with many carets.
440        match edits {
441            [] => {}
442            [(range, text)] => self.splice(range.clone(), text),
443            _ => self.text.edit_many(edits),
444        }
445    }
446
447    /// Bump the revision. Called once per committed transaction by the engine;
448    /// never call it from a raw splice.
449    pub(crate) fn bump_revision(&mut self) {
450        self.revision.0 += 1;
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn buf(s: &str) -> Buffer {
459        Buffer::new(s).unwrap()
460    }
461
462    #[test]
463    fn empty_document_is_one_empty_line() {
464        let b = buf("");
465        assert_eq!(b.line_count(), 1);
466        assert_eq!(b.line(0), "");
467        assert_eq!(b.len(), 0);
468        assert!(b.is_empty());
469    }
470
471    #[test]
472    fn trailing_newline_is_an_empty_final_line() {
473        // "foo\nbar\n" → ["foo","bar",""], count 3.
474        let b = buf("foo\nbar\n");
475        assert_eq!(b.line_count(), 3);
476        assert_eq!(b.line(0), "foo");
477        assert_eq!(b.line(1), "bar");
478        assert_eq!(b.line(2), "");
479        // No trailing newline → 2 lines, caret rests at end of "bar".
480        let b2 = buf("foo\nbar");
481        assert_eq!(b2.line_count(), 2);
482        assert_eq!(b2.line(1), "bar");
483    }
484
485    #[test]
486    fn crlf_normalized_on_load_flavor_remembered() {
487        let b = buf("a\r\nb\r\n");
488        assert_eq!(b.text(), "a\nb\n"); // LF-only internally
489        assert_eq!(b.eol_flavor(), EolFlavor::CrLf);
490        assert!(!b.text().contains('\r'));
491        assert_eq!(buf("a\nb").eol_flavor(), EolFlavor::Lf);
492    }
493
494    #[test]
495    fn serialize_round_trips_each_flavor() {
496        // CRLF file → load (LF internal) → serialize(CrLf) == original.
497        let original = "a\r\nb\r\n";
498        let b = buf(original);
499        assert_eq!(b.serialize(EolFlavor::CrLf), original);
500        assert_eq!(b.serialize(EolFlavor::Lf), "a\nb\n");
501        // No-trailing-newline LF file round-trips without one.
502        let b2 = buf("x\ny");
503        assert_eq!(b2.serialize(EolFlavor::Lf), "x\ny");
504    }
505
506    #[test]
507    fn only_the_u32_offset_space_is_refused() {
508        // No policy limit: a 2 MB document loads fine…
509        assert!(Buffer::new(&"a".repeat(2 * 1_048_576)).is_ok());
510        // …and the representational guard is tested pure (no 4 GiB alloc).
511        assert!(check_load_len(u32::MAX as usize - 1).is_ok());
512        assert!(matches!(
513            check_load_len(u32::MAX as usize),
514            Err(LoadError::TooLarge { len }) if len == u32::MAX as usize
515        ));
516        assert!(check_load_len(u32::MAX as usize + 1).is_err());
517    }
518
519    #[test]
520    fn bump_revision_increments() {
521        // Exercised for real by the transaction engine; pinned here so the
522        // counter's contract stands on its own.
523        let mut b = buf("x");
524        assert_eq!(b.revision(), Revision(0));
525        b.bump_revision();
526        assert_eq!(b.revision(), Revision(1));
527    }
528
529    #[test]
530    fn offset_point_round_trip() {
531        let b = buf("foo\nbar\nbaz");
532        for off in 0..=b.len() {
533            let p = b.offset_to_point(off);
534            assert_eq!(b.point_to_offset(p), off, "offset {off}");
535        }
536        assert_eq!(b.offset_to_point(0), Point::new(0, 0));
537        assert_eq!(b.offset_to_point(4), Point::new(1, 0)); // start of "bar"
538        assert_eq!(b.offset_to_point(6), Point::new(1, 2)); // 'r' in "bar"
539    }
540
541    #[test]
542    fn point_to_offset_clamps_row_and_col() {
543        let b = buf("ab\ncd");
544        assert_eq!(b.point_to_offset(Point::new(0, 99)), 2); // col clamps to line end
545        assert_eq!(b.point_to_offset(Point::new(9, 0)), 3); // row clamps to last line start
546    }
547
548    #[test]
549    fn splice_insert_updates_index_and_text() {
550        let mut b = buf("ac");
551        b.splice(1..1, "b"); // insert 'b' between a and c
552        assert_eq!(b.text(), "abc");
553        assert_eq!(b.line_count(), 1);
554
555        let mut b = buf("line1\nline2");
556        b.splice(5..5, "X\nY"); // insert a newline mid-document
557        assert_eq!(b.text(), "line1X\nY\nline2");
558        assert_eq!(b.line_count(), 3);
559        assert_eq!(b.line(0), "line1X");
560        assert_eq!(b.line(1), "Y");
561        assert_eq!(b.line(2), "line2");
562    }
563
564    #[test]
565    fn splice_delete_across_lines_merges() {
566        let mut b = buf("foo\nbar\nbaz");
567        b.splice(2..9, ""); // delete "o\nbar\nb" → "fo" + "az"... check
568        assert_eq!(b.text(), "foaz");
569        assert_eq!(b.line_count(), 1);
570    }
571
572    #[test]
573    fn splice_replace_shifts_following_line_starts() {
574        let mut b = buf("a\nb\nc");
575        // Replace "a" (0..1) with "AAAA": every following line start shifts +3.
576        b.splice(0..1, "AAAA");
577        assert_eq!(b.text(), "AAAA\nb\nc");
578        assert_eq!(b.line(0), "AAAA");
579        assert_eq!(b.line(1), "b");
580        assert_eq!(b.line(2), "c");
581    }
582
583    // ------------------------------------------------------------------
584    // Cross-chunk correctness: these pin the reads and edits that must behave
585    // identically no matter how the rope splits text into chunks. The line
586    // model in particular counts only '\n' — `only_lf_is_a_line_break` guards
587    // that other Unicode line separators never break a line.
588    // ------------------------------------------------------------------
589
590    /// The editor's line model counts only '\n'. Other Unicode line separators
591    /// — U+000B/U+000C/U+0085/U+2028/U+2029 and CR — must not break a line, so
592    /// display rows map one-to-one to LF-delimited text lines.
593    #[test]
594    fn only_lf_is_a_line_break() {
595        let b = buf("a\u{000B}b\u{000C}c\u{0085}d\u{2028}e\u{2029}f\ng");
596        assert_eq!(b.line_count(), 2, "only \\n may break lines");
597        assert_eq!(b.line(1), "g");
598        assert_eq!(b.offset_to_point(b.len()).row, 1);
599    }
600
601    /// A document big enough to span many rope chunks must answer every read
602    /// identically to a reference `String` model (multibyte chars included,
603    /// positioned to land on chunk seams somewhere in the run).
604    #[test]
605    fn chunk_crossing_reads_match_a_string_model() {
606        // ~64 KB of varied lines — far past one leaf chunk.
607        let mut model = String::new();
608        for i in 0..2000 {
609            match i % 4 {
610                0 => model.push_str(&format!("line {i}: the quick brown fox\n")),
611                1 => model.push_str(&format!("Zeile {i}: äöü ßẞ €42 →←\n")),
612                2 => model.push_str(&format!("行 {i}: 日本語のテキスト 🦀🚀\n")),
613                _ => model.push_str(&format!("l{i}\n")),
614            }
615        }
616        model.push_str("no trailing newline");
617        let b = buf(&model);
618
619        assert_eq!(b.len() as usize, model.len());
620        assert_eq!(b.text(), model.as_str());
621
622        // Reference line starts: [0] plus one entry after every '\n'.
623        let mut starts = vec![0usize];
624        starts.extend(memchr::memchr_iter(b'\n', model.as_bytes()).map(|i| i + 1));
625        assert_eq!(b.line_count() as usize, starts.len());
626        for (row, &start) in starts.iter().enumerate() {
627            let end = starts.get(row + 1).map_or(model.len(), |&next| next - 1);
628            assert_eq!(b.line(row as u32), &model[start..end], "line {row}");
629            assert_eq!(b.line_len(row as u32) as usize, end - start, "line_len {row}");
630        }
631
632        // Sampled offsets (snapped to boundaries) — chars, points, slices, clips.
633        let mut off = 0usize;
634        while off <= model.len() {
635            let o = off as u32;
636            assert_eq!(b.char_at(o), model[off..].chars().next(), "char_at {off}");
637            assert_eq!(b.char_before(o), model[..off].chars().next_back(), "char_before {off}");
638            let p = b.offset_to_point(o);
639            assert_eq!(b.point_to_offset(p), o, "point round-trip {off}");
640            assert_eq!(starts[p.row as usize] + p.col as usize, off, "offset_to_point {off}");
641            assert_eq!(b.clip_offset(o, Bias::Left), o, "boundary clips are identity");
642            let slice_end = (off + 97).min(model.len());
643            let slice_end = (slice_end..=model.len())
644                .find(|&e| model.is_char_boundary(e))
645                .unwrap_or(model.len());
646            assert_eq!(b.slice(o..slice_end as u32), &model[off..slice_end], "slice at {off}");
647            // Next char boundary ≥ off + 61 (prime stride to wander chunk seams).
648            off = (off + 61..=model.len())
649                .find(|&e| model.is_char_boundary(e))
650                .unwrap_or(model.len() + 1);
651        }
652
653        // Non-boundary clips snap like str-based snapping did.
654        for (i, _) in model.char_indices().take(500) {
655            for probe in i + 1..(i + 4).min(model.len()) {
656                if !model.is_char_boundary(probe) {
657                    let left = (0..=probe).rev().find(|&x| model.is_char_boundary(x)).unwrap();
658                    let right = (probe..=model.len()).find(|&x| model.is_char_boundary(x)).unwrap();
659                    assert_eq!(b.clip_offset(probe as u32, Bias::Left) as usize, left);
660                    assert_eq!(b.clip_offset(probe as u32, Bias::Right) as usize, right);
661                }
662            }
663        }
664
665        // Every chunk seam, structurally: the sampling strides above only
666        // graze seams by numeric coincidence, and the seam is exactly where
667        // char_at/char_before/clip pick their chunk. (The test module may
668        // read the private rope to enumerate seams.)
669        let seams: Vec<usize> = {
670            let mut acc = 0usize;
671            b.text
672                .chunks()
673                .map(|c| {
674                    acc += c.len();
675                    acc
676                })
677                .collect()
678        };
679        assert!(seams.len() > 1, "corpus must span multiple chunks");
680        for &seam in &seams[..seams.len() - 1] {
681            for probe in [seam - 1, seam, seam + 1] {
682                let o = probe as u32;
683                if model.is_char_boundary(probe) {
684                    assert_eq!(b.char_at(o), model[probe..].chars().next(), "char_at seam {probe}");
685                    assert_eq!(
686                        b.char_before(o),
687                        model[..probe].chars().next_back(),
688                        "char_before seam {probe}"
689                    );
690                    assert_eq!(b.clip_offset(o, Bias::Left), o, "seam clip identity {probe}");
691                    let end = ((probe + 5).min(model.len())..=model.len())
692                        .find(|&e| model.is_char_boundary(e))
693                        .unwrap();
694                    assert_eq!(b.slice(o..end as u32), &model[probe..end], "slice seam {probe}");
695                } else {
696                    let left = (0..=probe).rev().find(|&x| model.is_char_boundary(x)).unwrap();
697                    let right = (probe..=model.len()).find(|&x| model.is_char_boundary(x)).unwrap();
698                    assert_eq!(b.clip_offset(o, Bias::Left) as usize, left, "seam {probe}");
699                    assert_eq!(b.clip_offset(o, Bias::Right) as usize, right, "seam {probe}");
700                }
701            }
702        }
703    }
704
705    /// Seeded random splice walk: the rope and a `String` model must agree on
706    /// the full text and line count after every edit (multibyte inserts
707    /// included). Seeded with a multi-chunk corpus — and pinned to STAY
708    /// multi-chunk — so the walk exercises the rope's cross-leaf edit path, not
709    /// just single-chunk splices (which the other splice tests already cover).
710    #[test]
711    fn splice_random_walk_matches_string_model() {
712        let mut model = String::new();
713        for i in 0..400 {
714            model.push_str(&format!("seed line {i}: ää🦀 with some text\n"));
715        }
716        let mut b = buf(&model);
717        let mut state = 0x2545F49_u64; // deterministic LCG
718        let mut rand = move |bound: usize| {
719            state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
720            ((state >> 33) as usize) % bound.max(1)
721        };
722        let inserts =
723            ["x", "hello", "\n", "ab\ncd", "äöü", "🦀", "fn main() {}\n", "", "日本語", "\n\n\n"];
724        let mut multi_chunk_steps = 0;
725        for step in 0..600 {
726            // Random boundary-snapped range in the current text.
727            let mut s = rand(model.len() + 1);
728            while !model.is_char_boundary(s) {
729                s -= 1;
730            }
731            let mut e = (s + rand(24)).min(model.len());
732            while !model.is_char_boundary(e) {
733                e -= 1;
734            }
735            let e = e.max(s);
736            let ins = inserts[rand(inserts.len())];
737            model.replace_range(s..e, ins);
738            b.splice(s as u32..e as u32, ins);
739            assert_eq!(b.text(), model.as_str(), "text diverged at step {step}");
740            assert_eq!(
741                b.line_count() as usize,
742                memchr::memchr_iter(b'\n', model.as_bytes()).count() + 1,
743                "line count diverged at step {step}"
744            );
745            if b.text.chunks().nth(1).is_some() {
746                multi_chunk_steps += 1;
747            }
748        }
749        // The guard that keeps this test honest: if a future edit shrinks the
750        // corpus (or deletes outpace inserts), the walk silently stops testing
751        // the cross-leaf path — fail instead.
752        assert!(
753            multi_chunk_steps >= 550,
754            "walk must stay multi-chunk (got {multi_chunk_steps}/600 steps)"
755        );
756    }
757
758    /// `slice` clamps rather than panicking: inverted or past-end ranges yield
759    /// empty or the available tail, never a panic, so callers can pass loosely
760    /// computed ranges safely. Mirrored on `Snapshot`, which shares the helper.
761    #[test]
762    #[allow(clippy::reversed_empty_ranges)] // inverted ranges ARE the subject
763    fn slice_clamps_inverted_and_past_end_ranges() {
764        let b = buf("hello world");
765        assert_eq!(b.slice(5..2), "");
766        assert_eq!(b.slice(100..3), "");
767        assert_eq!(b.slice(3..100), "lo world");
768        assert_eq!(b.slice(100..200), "");
769        assert_eq!(b.line(99), "");
770        let snap = b.snapshot();
771        assert_eq!(snap.slice(5..2), "");
772        assert_eq!(snap.slice(3..100), "lo world");
773        assert_eq!(snap.line(99), "");
774    }
775
776    /// A snapshot is isolated from later edits and carries the stamp it froze.
777    /// (The O(1)-creation claim is wall-clock — pinned in `benches/perf.rs`.)
778    #[test]
779    fn snapshot_is_isolated_from_later_edits() {
780        let mut b = buf("alpha\nbeta\n");
781        b.bump_revision();
782        let snap = b.snapshot();
783        b.splice(0..5, "OMEGA");
784        assert_eq!(snap.text(), "alpha\nbeta\n");
785        assert_eq!(snap.line(0), "alpha");
786        assert_eq!(snap.slice(6..10), "beta");
787        assert_eq!(snap.line_count(), 3);
788        assert_eq!(snap.len(), 11);
789        assert!(!snap.is_empty());
790        assert_eq!(snap.revision(), Revision(1));
791        assert_eq!(snap.doc_id(), b.doc_id());
792        assert_eq!(b.text(), "OMEGA\nbeta\n", "the buffer moved on");
793    }
794
795    /// Small (single-chunk) documents keep zero-copy reads — the common case
796    /// returns a `Borrowed` `Cow` with no allocation.
797    #[test]
798    fn small_document_reads_are_borrowed() {
799        let b = buf("let x = 1;\nlet y = 2;\n");
800        assert!(matches!(b.text(), Cow::Borrowed(_)));
801        assert!(matches!(b.slice(4..9), Cow::Borrowed(_)));
802        assert!(matches!(b.line(1), Cow::Borrowed(_)));
803    }
804}