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}