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