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 file's majority EOL flavor (see
27//! [`EolFlavor`]) is remembered only 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///
87/// Detection is a **majority vote** over the file's terminators, matching VS
88/// Code: `CrLf` iff CRLF terminators strictly outnumber LF ones, so a **tie
89/// goes to `Lf`** — including the no-terminator case (an empty or one-line
90/// file). A lone `\r` is normalized to `\n` but casts no vote. Saving in the
91/// detected flavor therefore round-trips a uniformly terminated file
92/// byte-for-byte and rewrites only the minority lines of a mixed one.
93#[derive(Copy, Clone, PartialEq, Eq, Debug)]
94pub enum EolFlavor {
95    /// Unix `\n`.
96    Lf,
97    /// Windows `\r\n`.
98    CrLf,
99}
100
101impl EolFlavor {
102    /// The one owner of the vote rule (see the type docs): strict CRLF
103    /// majority, else `Lf`.
104    fn majority(lf: usize, crlf: usize) -> EolFlavor {
105        if crlf > lf {
106            EolFlavor::CrLf
107        } else {
108            EolFlavor::Lf
109        }
110    }
111}
112
113/// Normalize `\r\n | \r → \n` in one pass over `input`'s CRs, starting at
114/// `first_cr` (the first one; the caller already found it), and report what it
115/// rewrote: `(normalized, crlf, lone_cr)`. The counts feed the [`EolFlavor`]
116/// vote (the caller derives the LF side from them), gathered here so
117/// detection costs no pass of its own.
118/// Slicing at CR/LF positions is sound: both are ASCII, so every cut is a char
119/// boundary.
120fn normalize_eol(input: &str, first_cr: usize) -> (String, usize, usize) {
121    let bytes = input.as_bytes();
122    let mut out = String::with_capacity(input.len());
123    let (mut crlf, mut lone_cr) = (0, 0);
124    let mut copied = 0; // input[..copied] is already in `out` (normalized)
125    for cr in memchr::memchr_iter(b'\r', &bytes[first_cr..]).map(|i| first_cr + i) {
126        out.push_str(&input[copied..cr]);
127        out.push('\n');
128        // `\r\r\n` is a lone CR then a CRLF: the pair test looks only at the
129        // next byte, and a skipped '\n' is never a CR the iterator could yield.
130        if bytes.get(cr + 1) == Some(&b'\n') {
131            crlf += 1;
132            copied = cr + 2;
133        } else {
134            lone_cr += 1;
135            copied = cr + 1;
136        }
137    }
138    out.push_str(&input[copied..]);
139    (out, crlf, lone_cr)
140}
141
142/// An immutable, `Send + Sync` copy of the document for background consumers
143/// (e.g. a debounced compile thread).
144///
145/// Creation is **O(1)** — a rope clone shares structure. The consumer pays for
146/// what it reads: [`Snapshot::text`]
147/// is an O(len) materialization that now runs on the *consumer's* thread, not
148/// the UI thread; the ranged reads mirror [`Buffer`]'s backing-agnostic API.
149#[derive(Clone, Debug)]
150pub struct Snapshot {
151    text: Rope,
152    doc_id: DocId,
153    revision: Revision,
154}
155
156impl Snapshot {
157    /// Total byte length of the snapshot.
158    #[must_use]
159    pub fn len(&self) -> u32 {
160        self.text.len()
161    }
162
163    /// Whether the snapshot is empty (a single empty line — never truly zero).
164    #[must_use]
165    pub fn is_empty(&self) -> bool {
166        self.text.is_empty()
167    }
168
169    /// The whole snapshot text, LF-only. `Borrowed` for single-chunk (small)
170    /// documents; an O(len) materialization otherwise — run it on the
171    /// consumer's thread.
172    #[must_use]
173    pub fn text(&self) -> Cow<'_, str> {
174        self.text.slice(0..self.text.len())
175    }
176
177    /// The text in `range` (byte offsets on char boundaries). Clamps to the
178    /// snapshot end.
179    ///
180    /// # Panics
181    ///
182    /// If either end of the (clamped) range splits a multibyte char — the same
183    /// kept precondition as [`Buffer::slice`]. A snapshot is the buffer's text
184    /// at one revision, so offsets clipped by that buffer's
185    /// [`Buffer::clip_offset`] at that revision are safe here.
186    #[must_use]
187    pub fn slice(&self, range: Range<u32>) -> Cow<'_, str> {
188        self.text.slice(range)
189    }
190
191    /// The text of one row, excluding its trailing `\n`. Out-of-range rows
192    /// return `""`.
193    #[must_use]
194    pub fn line(&self, row: u32) -> Cow<'_, str> {
195        self.text.line(row)
196    }
197
198    /// Number of lines. Always ≥ 1; a trailing `\n` yields a final empty line.
199    #[must_use]
200    pub fn line_count(&self) -> u32 {
201        self.text.line_count()
202    }
203
204    /// The document this snapshot came from.
205    #[must_use]
206    pub fn doc_id(&self) -> DocId {
207        self.doc_id
208    }
209
210    /// The revision this snapshot froze.
211    #[must_use]
212    pub fn revision(&self) -> Revision {
213        self.revision
214    }
215}
216
217/// The single owner of document text.
218///
219/// Invariants, upheld inside every mutation so no observer sees them disagree:
220/// 1. `text` is LF-only (no `\r`). The trailing terminator, if any, is an empty
221///    final line in `text`, not a flag. Line boundaries are the rope's own
222///    bookkeeping (LF-only by feature selection) — there is no second index to
223///    drift.
224/// 2. `revision` bumps once per committed transaction (via `Buffer::bump_revision`,
225///    called by the transaction engine).
226#[derive(Clone, Debug)]
227pub struct Buffer {
228    text: Rope,
229    revision: Revision,
230    doc_id: DocId,
231    eol_flavor: EolFlavor,
232}
233
234impl Buffer {
235    /// Load `input` as a new document.
236    ///
237    /// Refuses only input past the `u32` offset space (see [`LoadError`] — no
238    /// policy limit); normalizes `\r\n | \r → \n`, remembering the majority
239    /// [`EolFlavor`] (tie → `Lf`); mints a fresh [`DocId`] at revision `0`.
240    pub fn new(input: &str) -> Result<Self, LoadError> {
241        check_load_len(input.len())?;
242        // Detection rides passes load already makes — never a counting pass of
243        // its own. A CR-free input (the common case, settled by one memchr) is
244        // already LF-only: it has no CRLF terminators, so the majority is LF
245        // whatever its newline count, and the input is chunked without a copy.
246        // Otherwise the one normalizing pass counts the CRLFs and lone CRs it
247        // rewrites, and the LF count falls out of the rope's O(1) newline
248        // total: every terminator became exactly one '\n'. (Normalization only
249        // shrinks, so the length check above covers both paths.)
250        let (text, eol_flavor) = match memchr::memchr(b'\r', input.as_bytes()) {
251            None => (Rope::from_str(input), EolFlavor::Lf),
252            Some(first_cr) => {
253                let (normalized, crlf, lone_cr) = normalize_eol(input, first_cr);
254                let text = Rope::from_str(&normalized);
255                let lf = (text.line_count() - 1) as usize - crlf - lone_cr;
256                (text, EolFlavor::majority(lf, crlf))
257            }
258        };
259        Ok(Self {
260            text,
261            revision: Revision(0),
262            doc_id: DocId(NEXT_DOC_ID.fetch_add(1, Ordering::Relaxed)),
263            eol_flavor,
264        })
265    }
266
267    /// Total byte length of the document.
268    #[must_use]
269    pub fn len(&self) -> u32 {
270        self.text.len()
271    }
272
273    /// Whether the document is empty (a single empty line — never truly zero).
274    #[must_use]
275    pub fn is_empty(&self) -> bool {
276        self.text.is_empty()
277    }
278
279    /// The whole document text, LF-only, as one slice.
280    ///
281    /// **Cold-path read:** `Borrowed` only while the
282    /// document is a single rope chunk; an O(len) materialization otherwise.
283    /// For whole-document scans (find seeding, select-all-occurrences),
284    /// serialization, and tests — per-frame and per-keystroke code reads via
285    /// [`Buffer::slice`] / [`Buffer::line`] / [`Buffer::char_at`] /
286    /// [`Buffer::char_before`].
287    #[must_use]
288    pub fn text(&self) -> Cow<'_, str> {
289        self.text.slice(0..self.text.len())
290    }
291
292    /// The text in `range` (byte offsets; must lie on char boundaries, like
293    /// `str` slicing). Clamps to the document end. The workhorse ranged read —
294    /// `Borrowed` while the range sits in one rope chunk, `Owned` across
295    /// chunks.
296    ///
297    /// # Panics
298    ///
299    /// If either end of the (clamped) range splits a multibyte char. The
300    /// precondition is kept rather than clipped: this low-level read sits
301    /// under every scan, where a silently snapped end would turn a caller's
302    /// off-by-a-byte bug into a wrong read. Snap host offsets first with
303    /// [`Buffer::clip_offset`] (the editing APIs on
304    /// [`Document`](crate::Document) already do).
305    #[must_use]
306    pub fn slice(&self, range: Range<u32>) -> Cow<'_, str> {
307        self.text.slice(range)
308    }
309
310    /// The text of one row, excluding its trailing `\n`. Out-of-range rows
311    /// return `""`.
312    #[must_use]
313    pub fn line(&self, row: u32) -> Cow<'_, str> {
314        self.text.line(row)
315    }
316
317    /// The char whose first byte is at `offset` — `None` at/past the end.
318    /// (`offset` must be a char boundary.) The forward one-char read every
319    /// boundary scan uses instead of slicing the whole text. `O(log chunks)`.
320    ///
321    /// # Panics
322    ///
323    /// If `offset` is inside the document but splits a multibyte char — kept,
324    /// like [`Buffer::slice`]'s, so a boundary-scan bug cannot read a char
325    /// that is not there; snap host offsets with [`Buffer::clip_offset`].
326    #[must_use]
327    pub fn char_at(&self, offset: u32) -> Option<char> {
328        let off = offset.min(self.len());
329        // chunk_at returns the chunk *containing* `off` (the right-hand chunk at
330        // an exact boundary), so the tail slice is non-empty except at the
331        // document end.
332        let (chunk, chunk_start) = self.text.chunk_at(off);
333        chunk[(off - chunk_start) as usize..].chars().next()
334    }
335
336    /// The char ending at `offset` — `None` at 0. (`offset` must be a char
337    /// boundary.) The backward twin of [`Buffer::char_at`].
338    ///
339    /// # Panics
340    ///
341    /// If `offset` is inside the document but splits a multibyte char — the
342    /// same kept precondition as [`Buffer::char_at`].
343    #[must_use]
344    pub fn char_before(&self, offset: u32) -> Option<char> {
345        let off = offset.min(self.len());
346        if off == 0 {
347            return None;
348        }
349        // The char ending at `off` starts at most 4 bytes back; chunk
350        // boundaries are char boundaries, so the chunk holding byte `off - 1`
351        // holds the whole char.
352        let (chunk, chunk_start) = self.text.chunk_at(off - 1);
353        chunk[..(off - chunk_start) as usize].chars().next_back()
354    }
355
356    /// Number of lines. Always ≥ 1 (an empty document is one empty line); a
357    /// trailing `\n` yields a final empty line, so `line_count("a\nb\n") == 3`.
358    #[must_use]
359    pub fn line_count(&self) -> u32 {
360        self.text.line_count()
361    }
362
363    /// Byte length of one row's text (excludes `\n`). Out-of-range → 0.
364    /// Allocation-free (range arithmetic, no line materialization).
365    #[must_use]
366    pub fn line_len(&self, row: u32) -> u32 {
367        self.text.line_len(row)
368    }
369
370    /// The current revision.
371    #[must_use]
372    pub fn revision(&self) -> Revision {
373        self.revision
374    }
375
376    /// This document's identity.
377    #[must_use]
378    pub fn doc_id(&self) -> DocId {
379        self.doc_id
380    }
381
382    /// The EOL flavor detected at load: the file's majority terminator, tie →
383    /// `Lf` (see [`EolFlavor`]). Pass it to [`Buffer::serialize`] to save in
384    /// the file's own convention.
385    #[must_use]
386    pub fn eol_flavor(&self) -> EolFlavor {
387        self.eol_flavor
388    }
389
390    /// Convert a byte offset to a [`Point`]. Offsets past the end clamp to the
391    /// end of the document. `O(log chunks)`.
392    #[must_use]
393    pub fn offset_to_point(&self, offset: u32) -> Point {
394        crate::perf::charge(1); // complexity gate: one position query
395        self.text.byte_to_point(offset)
396    }
397
398    /// Convert a [`Point`] to a byte offset, clamping the row to the last line
399    /// and the column to that line's length, then snapping a column that falls
400    /// inside a multibyte char [`Bias::Left`] to that char's start — so the
401    /// result is always a char boundary, whatever arithmetic (UTF-16 / LSP
402    /// columns, a goal column carried across lines) produced `point`. For the
403    /// other snap direction use [`Buffer::clip_point`]. `O(log chunks)`.
404    #[must_use]
405    pub fn point_to_offset(&self, point: Point) -> u32 {
406        self.point_to_offset_snapped(point, Bias::Left)
407    }
408
409    /// [`Buffer::point_to_offset`] with the snap direction chosen — the one
410    /// owner of the clamp-then-snap, shared with [`Buffer::clip_point`].
411    fn point_to_offset_snapped(&self, point: Point, bias: Bias) -> u32 {
412        crate::perf::charge(1); // complexity gate: one position query
413        // The rope clamps row and col but counts the col in raw bytes; the
414        // snap is one more chunk descent, and a boundary (col 0, the line
415        // end, any ASCII column) comes back unchanged.
416        self.clip_offset(self.text.point_to_offset(point), bias)
417    }
418
419    /// Clamp a byte offset into `[0, len]` and snap it to a char boundary
420    /// (direction from `bias`). Idempotent.
421    #[must_use]
422    pub fn clip_offset(&self, offset: u32, bias: Bias) -> u32 {
423        let off = offset.min(self.len());
424        // A non-boundary offset sits strictly inside one char, and chunk
425        // boundaries are char boundaries — so the snap never leaves the chunk
426        // containing `off`.
427        let (chunk, chunk_start) = self.text.chunk_at(off);
428        chunk_start + snap_char_boundary(chunk, off - chunk_start, bias)
429    }
430
431    /// Clip a host-supplied range to valid offsets — THE rule every API that
432    /// stores a caller's range (selections, diagnostics, the find scope) shares,
433    /// so they cannot disagree about what a mid-char or past-end range means.
434    ///
435    /// Both ends clamp into `[0, len]`; an inverted range collapses to an empty
436    /// one at its (clamped) end, as [`Buffer::slice`] reads it. A non-empty
437    /// range then snaps **outward** (start [`Bias::Left`], end [`Bias::Right`]),
438    /// so it never drops part of a char it covers — the transaction engine's
439    /// rule for edit ranges. An empty one snaps both ends `Left`, so a caret or
440    /// zero-width span stays empty, at the start of the char it split, instead
441    /// of growing over that char.
442    #[must_use]
443    pub(crate) fn clip_range(&self, range: Range<u32>) -> Range<u32> {
444        let end = range.end.min(self.len());
445        let start = range.start.min(end);
446        if start == end {
447            let at = self.clip_offset(start, Bias::Left);
448            return at..at;
449        }
450        self.clip_offset(start, Bias::Left)..self.clip_offset(end, Bias::Right)
451    }
452
453    /// Where a windowed literal scan resumes after examining the window
454    /// `[pos, win_end)` for a `k`-byte needle (`k ≥ 1`). The **one** owner of
455    /// the seam-scan resume rule, shared by [`crate::find`]'s `scan_buffer` and
456    /// the document's `scan_from` so the subtle overlap arithmetic lives in a
457    /// single place: resume past the last in-window match end (`last_match_end`)
458    /// when the window found one; otherwise at the last char boundary at or
459    /// before `win_end − (k−1)` — no match can START later and still end inside
460    /// this window — clamped forward past `pos` so a multi-byte char can never
461    /// stall the scan.
462    #[must_use]
463    pub(crate) fn scan_resume(&self, pos: u32, win_end: u32, k: u32, last_match_end: Option<u32>) -> u32 {
464        match last_match_end {
465            Some(end) => end,
466            None => {
467                let safe = self.clip_offset((u64::from(win_end) - u64::from(k - 1)) as u32, Bias::Left);
468                safe.max(self.clip_offset(pos + 1, Bias::Right))
469            }
470        }
471    }
472
473    /// Clip a [`Point`] to a valid position: clamp the row/col, then snap the
474    /// resulting offset to a char boundary (direction from `bias`).
475    #[must_use]
476    pub fn clip_point(&self, point: Point, bias: Bias) -> Point {
477        // Not via `point_to_offset`: it has already snapped `Left`, which
478        // would swallow a `Right` bias.
479        self.offset_to_point(self.point_to_offset_snapped(point, bias))
480    }
481
482    /// An immutable copy for background consumers. **O(1)** — the
483    /// rope clone shares structure; nothing is materialized until the
484    /// consumer reads.
485    #[must_use]
486    pub fn snapshot(&self) -> Snapshot {
487        Snapshot { text: self.text.clone(), doc_id: self.doc_id, revision: self.revision }
488    }
489
490    /// Serialize for saving: verbatim EOL-expansion of the stored LF text into
491    /// `flavor`. The empty final line reproduces a trailing terminator by
492    /// construction; a no-trailing-newline file round-trips without one.
493    /// Cold-path O(len), like any save.
494    #[must_use]
495    pub fn serialize(&self, flavor: EolFlavor) -> String {
496        match flavor {
497            EolFlavor::Lf => {
498                let mut out = String::with_capacity(self.text.len() as usize);
499                self.text.for_each_chunk(|chunk| out.push_str(chunk));
500                out
501            }
502            EolFlavor::CrLf => {
503                // One byte per '\n' on top of len; chunk boundaries are char
504                // boundaries, so a '\n' never splits across chunks.
505                let extra = self.text.line_count() as usize - 1;
506                let mut out = String::with_capacity(self.text.len() as usize + extra);
507                for chunk in self.text.chunks() {
508                    let mut rest = chunk;
509                    while let Some(i) = rest.find('\n') {
510                        out.push_str(&rest[..i]);
511                        out.push_str("\r\n");
512                        rest = &rest[i + 1..];
513                    }
514                    out.push_str(rest);
515                }
516                out
517            }
518        }
519    }
520
521    /// Replace `range` (byte offsets in the current text) with `new_text`.
522    /// `O(edit + log chunks)` — the rope's bookkeeping *is* the line index,
523    /// so there is no second structure to splice.
524    ///
525    /// `pub(crate)`: reachable only through the transaction engine,
526    /// which owns validation, revision bumping, and inverse derivation.
527    /// `new_text` must already be LF-only (the transaction boundary normalizes).
528    pub(crate) fn splice(&mut self, range: Range<u32>, new_text: &str) {
529        debug_assert!(!new_text.as_bytes().contains(&b'\r'), "splice text must be LF-only");
530        let (start, end) = (range.start, range.end);
531        debug_assert!(start <= end && end <= self.len(), "splice range out of bounds");
532
533        debug_assert!(self.text.is_char_boundary(start), "splice start is not a char boundary");
534        debug_assert!(self.text.is_char_boundary(end), "splice end is not a char boundary");
535        self.text.replace(start..end, new_text);
536    }
537
538    /// Apply MANY disjoint edits (`(range, replacement)`, sorted ascending, disjoint,
539    /// in-bounds, LF-only) in ONE rope pass — the batched twin of N [`Self::splice`]s
540    /// (the document-scale multi-caret path). The transaction engine owns the
541    /// validation and revision bump, exactly as for `splice`.
542    pub(crate) fn edit_many(&mut self, edits: &[(Range<u32>, &str)]) {
543        debug_assert!(
544            edits.windows(2).all(|w| w[0].0.end <= w[1].0.start),
545            "edit_many wants sorted, disjoint ranges"
546        );
547        debug_assert!(
548            edits.iter().all(|(r, t)| {
549                r.end <= self.len()
550                    && self.text.is_char_boundary(r.start)
551                    && self.text.is_char_boundary(r.end)
552                    && !t.as_bytes().contains(&b'\r')
553            }),
554            "edit_many ranges must be in-bounds char boundaries and text LF-only"
555        );
556        // The common single-caret case takes the direct rope splice — no batch
557        // machinery for one edit; the batch pays off only with many carets.
558        match edits {
559            [] => {}
560            [(range, text)] => self.splice(range.clone(), text),
561            _ => self.text.edit_many(edits),
562        }
563    }
564
565    /// Bump the revision. Called once per committed transaction by the engine;
566    /// never call it from a raw splice.
567    pub(crate) fn bump_revision(&mut self) {
568        self.revision.0 += 1;
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    fn buf(s: &str) -> Buffer {
577        Buffer::new(s).unwrap()
578    }
579
580    #[test]
581    fn empty_document_is_one_empty_line() {
582        let b = buf("");
583        assert_eq!(b.line_count(), 1);
584        assert_eq!(b.line(0), "");
585        assert_eq!(b.len(), 0);
586        assert!(b.is_empty());
587    }
588
589    #[test]
590    fn trailing_newline_is_an_empty_final_line() {
591        // "foo\nbar\n" → ["foo","bar",""], count 3.
592        let b = buf("foo\nbar\n");
593        assert_eq!(b.line_count(), 3);
594        assert_eq!(b.line(0), "foo");
595        assert_eq!(b.line(1), "bar");
596        assert_eq!(b.line(2), "");
597        // No trailing newline → 2 lines, caret rests at end of "bar".
598        let b2 = buf("foo\nbar");
599        assert_eq!(b2.line_count(), 2);
600        assert_eq!(b2.line(1), "bar");
601    }
602
603    #[test]
604    fn crlf_normalized_on_load_flavor_remembered() {
605        let b = buf("a\r\nb\r\n");
606        assert_eq!(b.text(), "a\nb\n"); // LF-only internally
607        assert_eq!(b.eol_flavor(), EolFlavor::CrLf);
608        assert!(!b.text().contains('\r'));
609        assert_eq!(buf("a\nb").eol_flavor(), EolFlavor::Lf);
610    }
611
612    #[test]
613    fn serialize_round_trips_each_flavor() {
614        // CRLF file → load (LF internal) → serialize(CrLf) == original.
615        let original = "a\r\nb\r\n";
616        let b = buf(original);
617        assert_eq!(b.serialize(EolFlavor::CrLf), original);
618        assert_eq!(b.serialize(EolFlavor::Lf), "a\nb\n");
619        // No-trailing-newline LF file round-trips without one.
620        let b2 = buf("x\ny");
621        assert_eq!(b2.serialize(EolFlavor::Lf), "x\ny");
622    }
623
624    /// Regression: one stray CRLF in a mostly-LF file used to flip the whole
625    /// file to CRLF, so an untouched save rewrote every line. The majority (3
626    /// LF vs 1 CRLF) wins, and saving in it diffs only the stray line.
627    #[test]
628    fn one_stray_crlf_does_not_flip_a_mostly_lf_file() {
629        let b = buf("a\nb\nc\nd\r\n");
630        assert_eq!(b.eol_flavor(), EolFlavor::Lf);
631        assert_eq!(b.serialize(b.eol_flavor()), "a\nb\nc\nd\n");
632    }
633
634    /// The mirror case: a stray LF in a CRLF file is the minority, so the
635    /// file stays CRLF and only the stray line changes on save.
636    #[test]
637    fn one_stray_lf_does_not_flip_a_mostly_crlf_file() {
638        let b = buf("a\r\nb\r\nc\nd\r\n");
639        assert_eq!(b.eol_flavor(), EolFlavor::CrLf);
640        assert_eq!(b.serialize(b.eol_flavor()), "a\r\nb\r\nc\r\nd\r\n");
641    }
642
643    /// The documented tie rule: equal counts go to LF, and so does a file
644    /// with no terminators at all (0 vs 0).
645    #[test]
646    fn an_eol_tie_goes_to_lf() {
647        assert_eq!(buf("a\r\nb\n").eol_flavor(), EolFlavor::Lf);
648        assert_eq!(buf("a\nb\r\nc\r\nd\n").eol_flavor(), EolFlavor::Lf);
649        assert_eq!(buf("").eol_flavor(), EolFlavor::Lf);
650        assert_eq!(buf("one line").eol_flavor(), EolFlavor::Lf);
651    }
652
653    /// A lone CR is normalized to '\n' but casts no vote — in particular not
654    /// an LF one, though it becomes one '\n' in the rope's newline total that
655    /// the LF count is derived from. 2 CRLF vs 0 LF (3 lone CR) is CRLF.
656    #[test]
657    fn lone_cr_casts_no_eol_vote() {
658        let b = buf("a\r\nb\r\nc\rd\re\rf");
659        assert_eq!(b.text(), "a\nb\nc\nd\ne\nf");
660        assert_eq!(b.eol_flavor(), EolFlavor::CrLf);
661        // Only lone CRs: no vote either way, so the 0-0 tie rule applies.
662        assert_eq!(buf("a\rb\r").eol_flavor(), EolFlavor::Lf);
663    }
664
665    /// Seeded oracle over random terminator mixes — uniform LF, uniform CRLF,
666    /// random shares (near-ties included), and lone CRs incl. the `\r\r\n`
667    /// straddle — with multibyte bodies and multi-chunk sizes. Load must
668    /// normalize exactly like the retained two-`replace` chain, pick the
669    /// flavor an independent per-terminator count gives (the LF count is
670    /// derived from the rope total, so this pins that derivation), and
671    /// round-trip a uniformly terminated input byte-for-byte.
672    #[test]
673    fn eol_detection_matches_a_counting_oracle() {
674        let mut state = 0x9E37_79B9_7F4A_7C15_u64; // deterministic LCG
675        let mut rand = move |bound: usize| {
676            state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
677            ((state >> 33) as usize) % bound.max(1)
678        };
679        let bodies = ["", "x", "fn main() {}", "äöü ß", "🦀 rope", "\t  indented", "日本語"];
680        // Outcomes among MIXED inputs (both kinds present) — where the bug was.
681        let (mut mixed_lf, mut mixed_crlf, mut round_trips, mut lone_cases, mut multi_chunk) =
682            (0, 0, 0, 0, 0);
683        for case in 0..400 {
684            let mix = case % 4; // 0 all-LF, 1 all-CRLF, 2 random share, 3 + lone CRs
685            let crlf_pct = match mix {
686                0 => 0,
687                1 => 100,
688                _ => rand(101),
689            };
690            let lines = if case % 16 < 2 { 2000 + rand(500) } else { rand(40) };
691            let mut input = String::new();
692            for _ in 0..lines {
693                input.push_str(bodies[rand(bodies.len())]);
694                if mix == 3 && rand(6) == 0 {
695                    input.push('\r'); // a lone CR, or the straddle `\r\r\n`
696                }
697                input.push_str(if rand(100) < crlf_pct { "\r\n" } else { "\n" });
698            }
699            if rand(2) == 0 {
700                input.push_str("no trailing terminator");
701            }
702
703            // Independent count: a piece of `split_inclusive('\n')` ends in
704            // exactly one terminator; a lone CR never ends a piece.
705            let (mut lf, mut crlf) = (0, 0);
706            for piece in input.split_inclusive('\n') {
707                if piece.ends_with("\r\n") {
708                    crlf += 1;
709                } else if piece.ends_with('\n') {
710                    lf += 1;
711                }
712            }
713            let has_lone_cr = input.matches('\r').count() > crlf;
714            let want = if crlf > lf { EolFlavor::CrLf } else { EolFlavor::Lf };
715
716            let b = buf(&input);
717            assert_eq!(
718                b.text(),
719                input.replace("\r\n", "\n").replace('\r', "\n"),
720                "normalization diverged in case {case}"
721            );
722            assert_eq!(b.eol_flavor(), want, "case {case}: {lf} LF vs {crlf} CRLF");
723            if !has_lone_cr && (lf == 0 || crlf == 0) {
724                assert_eq!(b.serialize(b.eol_flavor()), input, "uniform case {case} must round-trip");
725                round_trips += 1;
726            }
727            if lf > 0 && crlf > 0 {
728                match want {
729                    EolFlavor::Lf => mixed_lf += 1,
730                    EolFlavor::CrLf => mixed_crlf += 1,
731                }
732            }
733            lone_cases += usize::from(has_lone_cr);
734            multi_chunk += usize::from(b.text.chunks().nth(1).is_some());
735        }
736        // Keep the oracle honest: every regime it claims must actually occur.
737        assert!(mixed_lf >= 50 && mixed_crlf >= 50, "mixed: {mixed_lf} LF / {mixed_crlf} CRLF wins");
738        assert!(round_trips >= 150, "only {round_trips} uniform round-trips");
739        assert!(lone_cases >= 50, "only {lone_cases} lone-CR cases");
740        assert!(multi_chunk >= 40, "only {multi_chunk} multi-chunk cases");
741    }
742
743    #[test]
744    fn only_the_u32_offset_space_is_refused() {
745        // No policy limit: a 2 MB document loads fine…
746        assert!(Buffer::new(&"a".repeat(2 * 1_048_576)).is_ok());
747        // …and the representational guard is tested pure (no 4 GiB alloc).
748        assert!(check_load_len(u32::MAX as usize - 1).is_ok());
749        assert!(matches!(
750            check_load_len(u32::MAX as usize),
751            Err(LoadError::TooLarge { len }) if len == u32::MAX as usize
752        ));
753        assert!(check_load_len(u32::MAX as usize + 1).is_err());
754    }
755
756    #[test]
757    fn bump_revision_increments() {
758        // Exercised for real by the transaction engine; pinned here so the
759        // counter's contract stands on its own.
760        let mut b = buf("x");
761        assert_eq!(b.revision(), Revision(0));
762        b.bump_revision();
763        assert_eq!(b.revision(), Revision(1));
764    }
765
766    #[test]
767    fn offset_point_round_trip() {
768        let b = buf("foo\nbar\nbaz");
769        for off in 0..=b.len() {
770            let p = b.offset_to_point(off);
771            assert_eq!(b.point_to_offset(p), off, "offset {off}");
772        }
773        assert_eq!(b.offset_to_point(0), Point::new(0, 0));
774        assert_eq!(b.offset_to_point(4), Point::new(1, 0)); // start of "bar"
775        assert_eq!(b.offset_to_point(6), Point::new(1, 2)); // 'r' in "bar"
776    }
777
778    #[test]
779    fn point_to_offset_clamps_row_and_col() {
780        let b = buf("ab\ncd");
781        assert_eq!(b.point_to_offset(Point::new(0, 99)), 2); // col clamps to line end
782        assert_eq!(b.point_to_offset(Point::new(9, 0)), 3); // row clamps to last line start
783    }
784
785    /// Regression (fails-first): a column inside a multibyte char (UTF-16 /
786    /// LSP column math) came back as a mid-char offset, which the next
787    /// `char_at`/`slice` panicked on. Every column now lands on a char
788    /// boundary — the one at or before it — checked exhaustively against a
789    /// `String` model over every (row, col) in and past each line, multi-chunk
790    /// so seams are crossed.
791    #[test]
792    fn point_to_offset_snaps_every_column_to_a_char_boundary() {
793        let lines = ["aé🦀b", "", "日本語", "🦀🦀"];
794        let mut model = String::new();
795        for i in 0..40 {
796            if i > 0 {
797                model.push('\n');
798            }
799            model.push_str(lines[i % lines.len()]);
800        }
801        let b = buf(&model);
802        assert!(b.text.chunks().nth(1).is_some(), "corpus must span multiple chunks");
803        let mut start = 0usize;
804        for (row, line) in model.split('\n').enumerate() {
805            for col in 0..=line.len() + 2 {
806                let want = (0..=col.min(line.len())).rev().find(|&c| line.is_char_boundary(c)).unwrap();
807                let got = b.point_to_offset(Point::new(row as u32, col as u32));
808                assert_eq!(got as usize, start + want, "({row}, {col})");
809                assert_eq!(b.char_at(got), model[got as usize..].chars().next(), "readable at ({row}, {col})");
810            }
811            start += line.len() + 1;
812        }
813        // `clip_point` keeps its caller's bias: Right still snaps forward.
814        assert_eq!(b.clip_point(Point::new(0, 2), Bias::Right), Point::new(0, 3));
815        assert_eq!(b.clip_point(Point::new(0, 2), Bias::Left), Point::new(0, 1));
816    }
817
818    #[test]
819    fn splice_insert_updates_index_and_text() {
820        let mut b = buf("ac");
821        b.splice(1..1, "b"); // insert 'b' between a and c
822        assert_eq!(b.text(), "abc");
823        assert_eq!(b.line_count(), 1);
824
825        let mut b = buf("line1\nline2");
826        b.splice(5..5, "X\nY"); // insert a newline mid-document
827        assert_eq!(b.text(), "line1X\nY\nline2");
828        assert_eq!(b.line_count(), 3);
829        assert_eq!(b.line(0), "line1X");
830        assert_eq!(b.line(1), "Y");
831        assert_eq!(b.line(2), "line2");
832    }
833
834    #[test]
835    fn splice_delete_across_lines_merges() {
836        let mut b = buf("foo\nbar\nbaz");
837        b.splice(2..9, ""); // delete "o\nbar\nb" → "fo" + "az"... check
838        assert_eq!(b.text(), "foaz");
839        assert_eq!(b.line_count(), 1);
840    }
841
842    #[test]
843    fn splice_replace_shifts_following_line_starts() {
844        let mut b = buf("a\nb\nc");
845        // Replace "a" (0..1) with "AAAA": every following line start shifts +3.
846        b.splice(0..1, "AAAA");
847        assert_eq!(b.text(), "AAAA\nb\nc");
848        assert_eq!(b.line(0), "AAAA");
849        assert_eq!(b.line(1), "b");
850        assert_eq!(b.line(2), "c");
851    }
852
853    // ------------------------------------------------------------------
854    // Cross-chunk correctness: these pin the reads and edits that must behave
855    // identically no matter how the rope splits text into chunks. The line
856    // model in particular counts only '\n' — `only_lf_is_a_line_break` guards
857    // that other Unicode line separators never break a line.
858    // ------------------------------------------------------------------
859
860    /// The editor's line model counts only '\n'. Other Unicode line separators
861    /// — U+000B/U+000C/U+0085/U+2028/U+2029 and CR — must not break a line, so
862    /// display rows map one-to-one to LF-delimited text lines.
863    #[test]
864    fn only_lf_is_a_line_break() {
865        let b = buf("a\u{000B}b\u{000C}c\u{0085}d\u{2028}e\u{2029}f\ng");
866        assert_eq!(b.line_count(), 2, "only \\n may break lines");
867        assert_eq!(b.line(1), "g");
868        assert_eq!(b.offset_to_point(b.len()).row, 1);
869    }
870
871    /// A document big enough to span many rope chunks must answer every read
872    /// identically to a reference `String` model (multibyte chars included,
873    /// positioned to land on chunk seams somewhere in the run).
874    #[test]
875    fn chunk_crossing_reads_match_a_string_model() {
876        // ~64 KB of varied lines — far past one leaf chunk.
877        let mut model = String::new();
878        for i in 0..2000 {
879            match i % 4 {
880                0 => model.push_str(&format!("line {i}: the quick brown fox\n")),
881                1 => model.push_str(&format!("Zeile {i}: äöü ßẞ €42 →←\n")),
882                2 => model.push_str(&format!("行 {i}: 日本語のテキスト 🦀🚀\n")),
883                _ => model.push_str(&format!("l{i}\n")),
884            }
885        }
886        model.push_str("no trailing newline");
887        let b = buf(&model);
888
889        assert_eq!(b.len() as usize, model.len());
890        assert_eq!(b.text(), model.as_str());
891
892        // Reference line starts: [0] plus one entry after every '\n'.
893        let mut starts = vec![0usize];
894        starts.extend(memchr::memchr_iter(b'\n', model.as_bytes()).map(|i| i + 1));
895        assert_eq!(b.line_count() as usize, starts.len());
896        for (row, &start) in starts.iter().enumerate() {
897            let end = starts.get(row + 1).map_or(model.len(), |&next| next - 1);
898            assert_eq!(b.line(row as u32), &model[start..end], "line {row}");
899            assert_eq!(b.line_len(row as u32) as usize, end - start, "line_len {row}");
900        }
901
902        // Sampled offsets (snapped to boundaries) — chars, points, slices, clips.
903        let mut off = 0usize;
904        while off <= model.len() {
905            let o = off as u32;
906            assert_eq!(b.char_at(o), model[off..].chars().next(), "char_at {off}");
907            assert_eq!(b.char_before(o), model[..off].chars().next_back(), "char_before {off}");
908            let p = b.offset_to_point(o);
909            assert_eq!(b.point_to_offset(p), o, "point round-trip {off}");
910            assert_eq!(starts[p.row as usize] + p.col as usize, off, "offset_to_point {off}");
911            assert_eq!(b.clip_offset(o, Bias::Left), o, "boundary clips are identity");
912            let slice_end = (off + 97).min(model.len());
913            let slice_end = (slice_end..=model.len())
914                .find(|&e| model.is_char_boundary(e))
915                .unwrap_or(model.len());
916            assert_eq!(b.slice(o..slice_end as u32), &model[off..slice_end], "slice at {off}");
917            // Next char boundary ≥ off + 61 (prime stride to wander chunk seams).
918            off = (off + 61..=model.len())
919                .find(|&e| model.is_char_boundary(e))
920                .unwrap_or(model.len() + 1);
921        }
922
923        // Non-boundary clips snap like str-based snapping did.
924        for (i, _) in model.char_indices().take(500) {
925            for probe in i + 1..(i + 4).min(model.len()) {
926                if !model.is_char_boundary(probe) {
927                    let left = (0..=probe).rev().find(|&x| model.is_char_boundary(x)).unwrap();
928                    let right = (probe..=model.len()).find(|&x| model.is_char_boundary(x)).unwrap();
929                    assert_eq!(b.clip_offset(probe as u32, Bias::Left) as usize, left);
930                    assert_eq!(b.clip_offset(probe as u32, Bias::Right) as usize, right);
931                }
932            }
933        }
934
935        // Every chunk seam, structurally: the sampling strides above only
936        // graze seams by numeric coincidence, and the seam is exactly where
937        // char_at/char_before/clip pick their chunk. (The test module may
938        // read the private rope to enumerate seams.)
939        let seams: Vec<usize> = {
940            let mut acc = 0usize;
941            b.text
942                .chunks()
943                .map(|c| {
944                    acc += c.len();
945                    acc
946                })
947                .collect()
948        };
949        assert!(seams.len() > 1, "corpus must span multiple chunks");
950        for &seam in &seams[..seams.len() - 1] {
951            for probe in [seam - 1, seam, seam + 1] {
952                let o = probe as u32;
953                if model.is_char_boundary(probe) {
954                    assert_eq!(b.char_at(o), model[probe..].chars().next(), "char_at seam {probe}");
955                    assert_eq!(
956                        b.char_before(o),
957                        model[..probe].chars().next_back(),
958                        "char_before seam {probe}"
959                    );
960                    assert_eq!(b.clip_offset(o, Bias::Left), o, "seam clip identity {probe}");
961                    let end = ((probe + 5).min(model.len())..=model.len())
962                        .find(|&e| model.is_char_boundary(e))
963                        .unwrap();
964                    assert_eq!(b.slice(o..end as u32), &model[probe..end], "slice seam {probe}");
965                } else {
966                    let left = (0..=probe).rev().find(|&x| model.is_char_boundary(x)).unwrap();
967                    let right = (probe..=model.len()).find(|&x| model.is_char_boundary(x)).unwrap();
968                    assert_eq!(b.clip_offset(o, Bias::Left) as usize, left, "seam {probe}");
969                    assert_eq!(b.clip_offset(o, Bias::Right) as usize, right, "seam {probe}");
970                }
971            }
972        }
973    }
974
975    /// Seeded random splice walk: the rope and a `String` model must agree on
976    /// the full text and line count after every edit (multibyte inserts
977    /// included). Seeded with a multi-chunk corpus — and pinned to STAY
978    /// multi-chunk — so the walk exercises the rope's cross-leaf edit path, not
979    /// just single-chunk splices (which the other splice tests already cover).
980    #[test]
981    fn splice_random_walk_matches_string_model() {
982        let mut model = String::new();
983        for i in 0..400 {
984            model.push_str(&format!("seed line {i}: ää🦀 with some text\n"));
985        }
986        let mut b = buf(&model);
987        let mut state = 0x2545F49_u64; // deterministic LCG
988        let mut rand = move |bound: usize| {
989            state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
990            ((state >> 33) as usize) % bound.max(1)
991        };
992        let inserts =
993            ["x", "hello", "\n", "ab\ncd", "äöü", "🦀", "fn main() {}\n", "", "日本語", "\n\n\n"];
994        let mut multi_chunk_steps = 0;
995        for step in 0..600 {
996            // Random boundary-snapped range in the current text.
997            let mut s = rand(model.len() + 1);
998            while !model.is_char_boundary(s) {
999                s -= 1;
1000            }
1001            let mut e = (s + rand(24)).min(model.len());
1002            while !model.is_char_boundary(e) {
1003                e -= 1;
1004            }
1005            let e = e.max(s);
1006            let ins = inserts[rand(inserts.len())];
1007            model.replace_range(s..e, ins);
1008            b.splice(s as u32..e as u32, ins);
1009            assert_eq!(b.text(), model.as_str(), "text diverged at step {step}");
1010            assert_eq!(
1011                b.line_count() as usize,
1012                memchr::memchr_iter(b'\n', model.as_bytes()).count() + 1,
1013                "line count diverged at step {step}"
1014            );
1015            if b.text.chunks().nth(1).is_some() {
1016                multi_chunk_steps += 1;
1017            }
1018        }
1019        // The guard that keeps this test honest: if a future edit shrinks the
1020        // corpus (or deletes outpace inserts), the walk silently stops testing
1021        // the cross-leaf path — fail instead.
1022        assert!(
1023            multi_chunk_steps >= 550,
1024            "walk must stay multi-chunk (got {multi_chunk_steps}/600 steps)"
1025        );
1026    }
1027
1028    /// `slice` clamps rather than panicking: inverted or past-end ranges yield
1029    /// empty or the available tail, never a panic, so callers can pass loosely
1030    /// computed ranges safely. Mirrored on `Snapshot`, which shares the helper.
1031    #[test]
1032    #[allow(clippy::reversed_empty_ranges)] // inverted ranges ARE the subject
1033    fn slice_clamps_inverted_and_past_end_ranges() {
1034        let b = buf("hello world");
1035        assert_eq!(b.slice(5..2), "");
1036        assert_eq!(b.slice(100..3), "");
1037        assert_eq!(b.slice(3..100), "lo world");
1038        assert_eq!(b.slice(100..200), "");
1039        assert_eq!(b.line(99), "");
1040        let snap = b.snapshot();
1041        assert_eq!(snap.slice(5..2), "");
1042        assert_eq!(snap.slice(3..100), "lo world");
1043        assert_eq!(snap.line(99), "");
1044    }
1045
1046    /// A snapshot is isolated from later edits and carries the stamp it froze.
1047    /// (The O(1)-creation claim is wall-clock — pinned in `benches/perf.rs`.)
1048    #[test]
1049    fn snapshot_is_isolated_from_later_edits() {
1050        let mut b = buf("alpha\nbeta\n");
1051        b.bump_revision();
1052        let snap = b.snapshot();
1053        b.splice(0..5, "OMEGA");
1054        assert_eq!(snap.text(), "alpha\nbeta\n");
1055        assert_eq!(snap.line(0), "alpha");
1056        assert_eq!(snap.slice(6..10), "beta");
1057        assert_eq!(snap.line_count(), 3);
1058        assert_eq!(snap.len(), 11);
1059        assert!(!snap.is_empty());
1060        assert_eq!(snap.revision(), Revision(1));
1061        assert_eq!(snap.doc_id(), b.doc_id());
1062        assert_eq!(b.text(), "OMEGA\nbeta\n", "the buffer moved on");
1063    }
1064
1065    /// Small (single-chunk) documents keep zero-copy reads — the common case
1066    /// returns a `Borrowed` `Cow` with no allocation.
1067    #[test]
1068    fn small_document_reads_are_borrowed() {
1069        let b = buf("let x = 1;\nlet y = 2;\n");
1070        assert!(matches!(b.text(), Cow::Borrowed(_)));
1071        assert!(matches!(b.slice(4..9), Cow::Borrowed(_)));
1072        assert!(matches!(b.line(1), Cow::Borrowed(_)));
1073    }
1074}