lattice_core/buffer.rs
1//! Buffer: a thin, edit-aware wrapper around `ropey::Rope`.
2//!
3//! All edits flow through `apply_edit`, which:
4//! - validates the range against current bounds
5//! - mutates the rope in O(log n)
6//! - returns enough information for the caller to record an inverse for undo
7//! and an `AppliedEdit` for change events
8//!
9//! This crate does not own the actor that serializes edits; that lives in the
10//! dispatcher (later phase). Callers are expected to hold the buffer behind
11//! whatever single-writer discipline they prefer.
12
13use ropey::Rope;
14
15use lattice_protocol::edit::{Edit, EditDelta, EditKind};
16use lattice_protocol::error::ProtocolError;
17use lattice_protocol::position::{Position, Range};
18
19use crate::error::CoreResult;
20
21/// A rope-backed text buffer: the raw text storage under every
22/// [`Document`](crate::Document).
23///
24/// Addressing is by [`Position`] — a **0-based line** plus a **0-based
25/// UTF-8 byte offset within that line** (not a char or column index).
26/// Every mutation goes through [`Buffer::apply_edit`], which returns an
27/// [`AppliedEdit`] carrying both the inverse (for undo) and the
28/// tree-sitter-shaped [`EditDelta`] (for incremental reparse).
29///
30/// `Buffer` knows nothing about versions, undo, dirtiness or cursors — that
31/// bookkeeping is [`Document`](crate::Document)'s. Reach for `Buffer`
32/// directly only when you need text without the document around it.
33///
34/// Cloning is cheap: `ropey::Rope` shares its chunks by `Arc`.
35///
36/// # Examples
37///
38/// ```
39/// use lattice_core::Buffer;
40/// use lattice_core::protocol::edit::Edit;
41/// use lattice_core::protocol::position::{Position, Range};
42///
43/// # fn main() -> lattice_core::CoreResult<()> {
44/// let mut buf = Buffer::from_text("hello\nworld\n");
45///
46/// // Replace "world" (line 1, bytes 0..5) with "there".
47/// let range = Range::new(Position::new(1, 0), Position::new(1, 5));
48/// let applied = buf.apply_edit(&Edit::replace(range, "there"))?;
49///
50/// assert_eq!(buf.as_string(), "hello\nthere\n");
51/// assert_eq!(applied.replaced_text, "world"); // what undo re-inserts
52/// assert_eq!(buf.line(1).as_deref(), Some("there"));
53///
54/// // Two lines as a user counts them; ropey counts the empty tail too.
55/// assert_eq!(buf.content_line_count(), 2);
56/// assert_eq!(buf.rope_line_count(), 3);
57/// # Ok(())
58/// # }
59/// ```
60#[derive(Debug, Clone)]
61pub struct Buffer {
62 rope: Rope,
63}
64
65/// What an `apply_edit` produced. The caller uses this to log change events
66/// and to construct an inverse `Edit` for the undo stack.
67///
68/// `inserted_text` is the text that was placed into `inserted_range`. We
69/// keep it on the struct so subscribers (notably the LSP fan-in, which
70/// needs `range + text` per change) don't have to re-read the buffer
71/// after every applied edit.
72///
73/// `delta` is the tree-sitter-shaped sibling: byte/position deltas
74/// the syntax worker uses to drive incremental reparse via
75/// `Tree::edit` + `Parser::parse(_, Some(&old_tree))`. Constructed
76/// from values already computed during the rope mutation -- no
77/// extra rope reads. See [`EditDelta`] for field semantics.
78#[derive(Debug, Clone)]
79pub struct AppliedEdit {
80 /// The range the edit targeted, in pre-edit coordinates (exactly the
81 /// [`Edit::range`] that was applied).
82 pub original_range: Range,
83 /// Where the inserted text now sits, in post-edit coordinates: starts at
84 /// `original_range.start` and ends after the last inserted byte. Empty
85 /// for a pure delete.
86 pub inserted_range: Range,
87 /// The text the edit removed from `original_range` (empty for a pure
88 /// insert). Replacing `inserted_range` with this text undoes the edit.
89 pub replaced_text: String,
90 /// The text the edit placed at `inserted_range` (empty for a pure
91 /// delete).
92 pub inserted_text: String,
93 /// Byte offsets and positions of the edit in tree-sitter's
94 /// `InputEdit` shape. Byte offsets are absolute (from the start of the
95 /// buffer) and saturate at `u32::MAX`.
96 pub delta: EditDelta,
97}
98
99impl Buffer {
100 /// An empty buffer: zero bytes, one (empty) line.
101 pub fn empty() -> Self {
102 Self { rope: Rope::new() }
103 }
104
105 /// A buffer holding a copy of `text`. No line-ending normalisation is
106 /// done: `\r\n` stays two bytes.
107 pub fn from_text(text: &str) -> Self {
108 Self {
109 rope: Rope::from_str(text),
110 }
111 }
112
113 /// ropey's raw line count: the number of line *starts*, so a buffer
114 /// ending in `\n` reports one extra (empty) line and an empty buffer
115 /// reports 1. Saturates at `u32::MAX`.
116 ///
117 /// This is the right bound for [`Position::line`] validity — the empty
118 /// line after a trailing newline is addressable (it is where an append
119 /// at end-of-file lands). For "how many lines does this file have", use
120 /// [`Self::content_line_count`].
121 ///
122 /// # Examples
123 ///
124 /// ```
125 /// use lattice_core::Buffer;
126 ///
127 /// assert_eq!(Buffer::empty().rope_line_count(), 1);
128 /// assert_eq!(Buffer::from_text("a\nb").rope_line_count(), 2);
129 /// assert_eq!(Buffer::from_text("a\nb\n").rope_line_count(), 3);
130 /// assert_eq!(Buffer::from_text("a\nb\n").content_line_count(), 2);
131 /// ```
132 pub fn rope_line_count(&self) -> u32 {
133 // ropey's `len_lines` counts the trailing implicit empty line for any
134 // rope ending in a newline. For an empty rope it returns 1. We surface
135 // ropey's count directly; callers compose semantics they need.
136 u32::try_from(self.rope.len_lines()).unwrap_or(u32::MAX)
137 }
138
139 /// Lines the document actually *has*, in the sense every editor
140 /// and every user means: `"a\nb\n"` is two lines, and so is
141 /// `"a\nb"`. A trailing newline terminates the last line rather
142 /// than starting a new one.
143 ///
144 /// This is the count for anything that lays out, addresses or
145 /// counts document content — display rows, `G`, `:$`, `%`
146 /// progress. [`Self::rope_line_count`] is ropey's raw count and reports
147 /// one more for any rope ending in `\n`; feeding that to a layout
148 /// or coverage range is what puts a phantom empty row at the end
149 /// of every normal file (CV.2).
150 ///
151 /// Never zero: an empty buffer is one empty line, matching
152 /// [`Self::rope_line_count`] and vim.
153 pub fn content_line_count(&self) -> u32 {
154 let rope_lines = self.rope_line_count();
155 if rope_lines >= 2 && self.line_byte_len(rope_lines - 1) == 0 {
156 rope_lines - 1
157 } else {
158 rope_lines
159 }
160 }
161
162 /// Total length of the buffer in bytes, newlines included.
163 pub fn byte_len(&self) -> u64 {
164 self.rope.len_bytes() as u64
165 }
166
167 /// The whole buffer as one `String`. `O(n)` allocation — never call
168 /// this on a per-frame or per-keystroke path; use [`Self::line`],
169 /// [`Self::slice`] or [`Self::to_rope`] instead.
170 pub fn as_string(&self) -> String {
171 self.rope.to_string()
172 }
173
174 /// Borrow the underlying rope for crate-internal callers that
175 /// need streaming access (chunk iteration, byte slicing) without
176 /// the O(n) `as_string` allocation. Kept `pub(crate)` so the
177 /// rope abstraction stays internal -- if the storage ever
178 /// changes (sled, sumtree, ...) we want a single point of
179 /// adaptation.
180 pub(crate) fn rope(&self) -> &Rope {
181 &self.rope
182 }
183
184 /// Clone the underlying rope. `Rope::clone`
185 /// is `Arc`-share of the underlying chunks (no deep copy), so
186 /// the cost is one refcount bump per chunk. Used by the
187 /// diff subsystem's `BufferTextProvider` impl to hand a
188 /// snapshot rope to `BufferSource`
189 /// from inside the worker's `spawn_blocking` body. Exposes
190 /// only `Rope` (not the internal `Rope` field), so the
191 /// abstraction barrier the `pub(crate) rope()` method
192 /// protects stays in place.
193 ///
194 /// Slice: D.3.a (2026-05-29).
195 pub fn to_rope(&self) -> Rope {
196 self.rope.clone()
197 }
198
199 /// Materialise one logical line (without its trailing `\n`).
200 /// Returns `None` if `line` is past the end. Locating the line
201 /// is `O(log n)`; copying its bytes is `O(line_len)`.
202 ///
203 /// Why this exists: the renderer's hot path needs only the
204 /// visible window of lines per frame. Calling [`Self::as_string`]
205 /// followed by `split('\n').collect::<Vec<_>>()` once per frame
206 /// allocates `O(buffer size)` bytes; a 100MB log blows the §8.2
207 /// frame budget on every paint. Iterating one line at a time
208 /// keeps the per-frame work proportional to the viewport, not
209 /// the document.
210 pub fn line(&self, line: u32) -> Option<String> {
211 if line >= self.rope_line_count() {
212 return None;
213 }
214 let slice = self.rope.line(line as usize);
215 let text = slice.to_string();
216 // ropey includes the trailing newline in line slices; the
217 // renderer wants the line content without it.
218 Some(match text.strip_suffix('\n') {
219 Some(s) => s.to_string(),
220 None => text,
221 })
222 }
223
224 /// Allocation-free [`LineShape`](crate::indent_blocks::LineShape)s from `start` to the end of the
225 /// rope — the indent-guide builder's read path.
226 ///
227 /// Two properties, and the guide layer needs both:
228 ///
229 /// - **Allocation-free.** [`Self::line`] allocates a `String` per
230 /// call, and the guide layer is rebuilt over its whole *covered*
231 /// range on every keystroke — below `WINDOW_CAP_LINES` that is the
232 /// whole document. Streaming the rope slice's chars instead keeps
233 /// the pass allocation-free, and `LineShape::from_chars` stops
234 /// reading each line as soon as the answer cannot change.
235 /// - **Sequential.** Asking for line `i` costs a `O(log n)` B-tree
236 /// descent; asking for `n` lines one index at a time costs `n` of
237 /// them. `Lines` is a cursor that walks chunk by chunk, so the
238 /// whole covered range costs one descent plus a linear walk —
239 /// which is what the builder's forward pass wants. Callers that
240 /// genuinely need one line probe with `line_shapes_from(i).next()`
241 /// and pay the single descent knowingly.
242 ///
243 /// `start` past the end of the rope yields nothing rather than
244 /// panicking, so an out-of-range probe reads as "no such line".
245 pub fn line_shapes_from<'a>(
246 &'a self,
247 start: u32,
248 unit: &'a crate::indent::IndentUnit,
249 ) -> impl Iterator<Item = crate::indent_blocks::LineShape> + 'a {
250 let start = (start as usize).min(self.rope.len_lines());
251 self.rope
252 .lines_at(start)
253 .map(move |line| crate::indent_blocks::LineShape::from_chars(line.chars(), unit))
254 }
255
256 /// Byte length of one line excluding the trailing newline.
257 /// O(log n). Cheap helper for renderers that want to know a
258 /// line's width without materialising its text.
259 pub fn line_byte_len(&self, line: u32) -> u32 {
260 if line >= self.rope_line_count() {
261 return 0;
262 }
263 let slice = self.rope.line(line as usize);
264 let bytes = slice.len_bytes();
265 // Subtract 1 if the slice ends in a newline (ropey
266 // includes trailing `\n` in its line slice). ropey's
267 // `Chars` isn't double-ended so we peek the last byte
268 // directly.
269 let has_trailing_newline = bytes > 0 && slice.byte(bytes - 1) == b'\n';
270 let len = if has_trailing_newline {
271 bytes - 1
272 } else {
273 bytes
274 };
275 u32::try_from(len).unwrap_or(u32::MAX)
276 }
277
278 /// Copy the text in the half-open `range` (`[start, end)`).
279 ///
280 /// Both endpoints are validated like [`Self::position_to_byte`] and then
281 /// rounded *down* to a UTF-8 char boundary, so a mid-codepoint offset
282 /// never panics. A range may span lines; the newlines between are
283 /// included.
284 ///
285 /// # Errors
286 ///
287 /// [`ProtocolError::PositionOutOfBounds`] if either endpoint is out of
288 /// bounds, [`ProtocolError::InvalidRange`] if `end` precedes `start`
289 /// (both wrapped in [`CoreError::Protocol`](crate::CoreError::Protocol)).
290 ///
291 /// # Examples
292 ///
293 /// ```
294 /// use lattice_core::Buffer;
295 /// use lattice_core::protocol::position::{Position, Range};
296 ///
297 /// let buf = Buffer::from_text("héllo\nworld");
298 /// // "é" is two bytes, so "llo" starts at byte 3.
299 /// let r = Range::new(Position::new(0, 3), Position::new(1, 2));
300 /// assert_eq!(buf.slice(r).ok().as_deref(), Some("llo\nwo"));
301 ///
302 /// // Line 5 does not exist.
303 /// let bad = Range::new(Position::new(0, 0), Position::new(5, 0));
304 /// assert!(buf.slice(bad).is_err());
305 /// ```
306 pub fn slice(&self, range: Range) -> CoreResult<String> {
307 let start = snap_to_char_boundary(&self.rope, self.position_to_byte(range.start)?);
308 let end = snap_to_char_boundary(&self.rope, self.position_to_byte(range.end)?);
309 if end < start {
310 return Err(ProtocolError::InvalidRange("end < start").into());
311 }
312 Ok(self.rope.byte_slice(start..end).to_string())
313 }
314
315 /// Apply an edit and return what was applied. The returned
316 /// `AppliedEdit::replaced_text` is exactly what the caller needs to push
317 /// onto the undo stack as the inverse.
318 ///
319 /// The range endpoints are validated and snapped down to UTF-8 char
320 /// boundaries exactly as in [`Self::slice`]. On error the buffer is left
321 /// untouched. Note that [`AppliedEdit::original_range`] and the delta's
322 /// positions echo the edit's range *as given*, before snapping.
323 ///
324 /// # Errors
325 ///
326 /// Same as [`Self::slice`]: an out-of-bounds endpoint or `end < start`.
327 pub fn apply_edit(&mut self, edit: &Edit) -> CoreResult<AppliedEdit> {
328 let start_byte =
329 snap_to_char_boundary(&self.rope, self.position_to_byte(edit.range.start)?);
330 let end_byte = snap_to_char_boundary(&self.rope, self.position_to_byte(edit.range.end)?);
331 if end_byte < start_byte {
332 return Err(ProtocolError::InvalidRange("end < start").into());
333 }
334
335 let replaced_text = self.rope.byte_slice(start_byte..end_byte).to_string();
336
337 match &edit.kind {
338 EditKind::Replace { text } => {
339 if start_byte != end_byte {
340 self.rope.remove(
341 byte_to_char(&self.rope, start_byte)..byte_to_char(&self.rope, end_byte),
342 );
343 }
344 if !text.is_empty() {
345 let char_idx = byte_to_char(&self.rope, start_byte);
346 self.rope.insert(char_idx, text);
347 }
348 }
349 }
350
351 let inserted_end_byte = match &edit.kind {
352 EditKind::Replace { text } => start_byte + text.len(),
353 };
354 let inserted_end = self.byte_to_position(inserted_end_byte)?;
355
356 let inserted_text = match &edit.kind {
357 EditKind::Replace { text } => text.clone(),
358 };
359 // Tree-sitter-shaped delta. Every field is already
360 // computed above; this is a six-cast struct literal --
361 // ~ns regime, no rope reads. The casts to u32 honour
362 // lattice's existing Position-byte cap (Position::byte
363 // is u32, so documents are already capped at 4 GB on
364 // the line-byte axis); a saturating cast keeps wildly
365 // oversized buffers from panicking on cast overflow.
366 let delta = EditDelta {
367 start_byte: u32::try_from(start_byte).unwrap_or(u32::MAX),
368 old_end_byte: u32::try_from(end_byte).unwrap_or(u32::MAX),
369 new_end_byte: u32::try_from(inserted_end_byte).unwrap_or(u32::MAX),
370 start_position: edit.range.start,
371 old_end_position: edit.range.end,
372 new_end_position: inserted_end,
373 };
374 Ok(AppliedEdit {
375 original_range: edit.range,
376 inserted_range: Range::new(edit.range.start, inserted_end),
377 replaced_text,
378 inserted_text,
379 delta,
380 })
381 }
382
383 /// Convert a [`Position`] to an absolute byte offset from the start of
384 /// the buffer.
385 ///
386 /// `pos.line` must be `< rope_line_count()`. `pos.byte` may be anywhere
387 /// up to and **including** the line's full length *with* its trailing
388 /// `\n` — so the offset just past the newline is accepted and equals the
389 /// next line's start. The offset is not snapped to a char boundary.
390 ///
391 /// # Errors
392 ///
393 /// [`ProtocolError::PositionOutOfBounds`] if the line does not exist or
394 /// the byte offset is past the end of the line.
395 ///
396 /// # Examples
397 ///
398 /// ```
399 /// use lattice_core::Buffer;
400 /// use lattice_core::protocol::position::Position;
401 ///
402 /// let buf = Buffer::from_text("ab\ncd");
403 /// assert_eq!(buf.position_to_byte(Position::new(1, 1)).ok(), Some(4));
404 /// assert!(buf.position_to_byte(Position::new(1, 3)).is_err());
405 /// assert_eq!(
406 /// buf.byte_to_position(4).ok(),
407 /// Some(Position::new(1, 1)),
408 /// );
409 /// ```
410 pub fn position_to_byte(&self, pos: Position) -> CoreResult<usize> {
411 let line_count = self.rope_line_count();
412 if pos.line >= line_count {
413 return Err(ProtocolError::PositionOutOfBounds {
414 position: pos,
415 line_count,
416 }
417 .into());
418 }
419 let line_start = self.rope.line_to_byte(pos.line as usize);
420 let line_end = if (pos.line as usize + 1) < self.rope.len_lines() {
421 self.rope.line_to_byte(pos.line as usize + 1)
422 } else {
423 self.rope.len_bytes()
424 };
425 let line_byte_len = line_end - line_start;
426 if pos.byte as usize > line_byte_len {
427 return Err(ProtocolError::PositionOutOfBounds {
428 position: pos,
429 line_count,
430 }
431 .into());
432 }
433 Ok(line_start + pos.byte as usize)
434 }
435
436 /// Convert an absolute byte offset into a [`Position`] (line + byte
437 /// within that line). The inverse of [`Self::position_to_byte`].
438 ///
439 /// # Panics
440 ///
441 /// Despite the `Result` return type this never returns `Err`: a `byte`
442 /// greater than [`Self::byte_len`] panics inside ropey. Callers pass
443 /// offsets derived from the buffer itself.
444 pub fn byte_to_position(&self, byte: usize) -> CoreResult<Position> {
445 let line = self.rope.byte_to_line(byte);
446 let line_start = self.rope.line_to_byte(line);
447 Ok(Position {
448 line: u32::try_from(line).unwrap_or(u32::MAX),
449 byte: u32::try_from(byte - line_start).unwrap_or(u32::MAX),
450 })
451 }
452}
453
454fn byte_to_char(rope: &Rope, byte: usize) -> usize {
455 rope.byte_to_char(byte)
456}
457
458/// Round `byte` DOWN to the start of the UTF-8 scalar it falls within, so it is
459/// always a valid char boundary. A mid-scalar byte offset would panic ropey's
460/// `byte_slice` / `byte_to_char` on the hot path (paramount #1: never panic on
461/// a keystroke). The char motions produce boundary-aligned offsets after the
462/// scalar-step fix; this is the defensive net for any residual byte-off offset
463/// from other motions (word/find/till) or future plugin-contributed motions.
464/// `byte_to_char` is monotonic, so snapping both ends of a range preserves
465/// `start <= end`.
466fn snap_to_char_boundary(rope: &Rope, byte: usize) -> usize {
467 rope.char_to_byte(rope.byte_to_char(byte))
468}
469
470/// Transform a position across an edit (§4.1 of owner-write-caret.md).
471///
472/// Pure, total function: every position has a well-defined result after any
473/// edit. Composes left-to-right across batches.
474///
475/// | Where `p` is relative to `edit.original_range` | Result |
476/// |---|---|
477/// | strictly before `original_range.start` | unchanged |
478/// | at or after `original_range.end` | shifted by `inserted_range.end - original_range.end` |
479/// | strictly inside `original_range` | clamped to `inserted_range.end` |
480/// | at `original_range.start` (non-empty range) | stays at `inserted_range.start` |
481///
482/// # Examples
483///
484/// ```
485/// use lattice_core::Buffer;
486/// use lattice_core::buffer::transform_position;
487/// use lattice_core::protocol::edit::Edit;
488/// use lattice_core::protocol::position::Position;
489///
490/// # fn main() -> lattice_core::CoreResult<()> {
491/// let mut buf = Buffer::from_text("abc def");
492/// // Insert "XY" at byte 1 of line 0.
493/// let applied = buf.apply_edit(&Edit::insert(Position::new(0, 1), "XY"))?;
494///
495/// // A caret after the insert point shifts right by the inserted length…
496/// assert_eq!(transform_position(Position::new(0, 4), &applied), Position::new(0, 6));
497/// // …one before it does not move.
498/// assert_eq!(transform_position(Position::new(0, 0), &applied), Position::new(0, 0));
499/// # Ok(())
500/// # }
501/// ```
502pub fn transform_position(pos: Position, edit: &AppliedEdit) -> Position {
503 let original = edit.original_range;
504 let inserted = edit.inserted_range;
505
506 if pos >= original.end {
507 let d_line = inserted.end.line as i64 - original.end.line as i64;
508 let d_byte = inserted.end.byte as i64 - original.end.byte as i64;
509 let new_line = if d_line >= 0 {
510 pos.line.saturating_add(d_line as u32)
511 } else {
512 pos.line.saturating_sub((-d_line) as u32)
513 };
514 let new_byte = if d_line == 0 {
515 if d_byte >= 0 {
516 pos.byte.saturating_add(d_byte as u32)
517 } else {
518 pos.byte.saturating_sub((-d_byte) as u32)
519 }
520 } else {
521 pos.byte
522 };
523 return Position::new(new_line, new_byte);
524 }
525
526 if pos < original.start {
527 return pos;
528 }
529
530 if pos == original.start {
531 inserted.start
532 } else {
533 inserted.end
534 }
535}
536
537impl Default for Buffer {
538 fn default() -> Self {
539 Self::empty()
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 #![allow(clippy::unwrap_used, clippy::panic)]
546 use super::*;
547 use lattice_protocol::edit::Edit;
548
549 #[test]
550 fn from_str_roundtrips() {
551 let b = Buffer::from_text("hello\nworld\n");
552 assert_eq!(b.as_string(), "hello\nworld\n");
553 }
554
555 #[test]
556 fn line_shapes_stream_matches_the_per_line_projection() {
557 // The streaming read is a pure optimisation of "project each
558 // line in turn", so it must agree with the `&str` projection
559 // line for line — including the tab, the blank, the closer and
560 // the phantom line ropey reports after a trailing newline.
561 let unit = crate::indent::IndentUnit::new(4, true, 4);
562 let text = "fn f() {\n\tif c {\n\n work();\n\t});\n}\n";
563 let b = Buffer::from_text(text);
564
565 let streamed: Vec<_> = b.line_shapes_from(0, &unit).collect();
566 let projected: Vec<_> = text
567 .split('\n')
568 .map(|l| crate::indent_blocks::LineShape::from_line(l, &unit))
569 .collect();
570 assert_eq!(streamed, projected);
571 assert_eq!(streamed.len(), b.rope_line_count() as usize);
572 }
573
574 #[test]
575 fn line_shapes_from_offsets_and_clamps() {
576 let unit = crate::indent::IndentUnit::new(4, true, 4);
577 let b = Buffer::from_text("a\n b\nc");
578
579 // Starting mid-rope yields the tail, so a single-line probe is
580 // `.next()` on a stream started at that line.
581 let tail: Vec<_> = b.line_shapes_from(1, &unit).collect();
582 assert_eq!(tail.len(), 2);
583 assert_eq!(tail[0].columns, 4);
584 assert!(
585 b.line_shapes_from(2, &unit)
586 .next()
587 .is_some_and(|s| s.unindented)
588 );
589
590 // Past the end is empty, not a panic — how an out-of-range
591 // look-back probe reports "no such line".
592 assert!(b.line_shapes_from(3, &unit).next().is_none());
593 assert!(b.line_shapes_from(9_999, &unit).next().is_none());
594 }
595
596 #[test]
597 fn insert_at_origin() {
598 let mut b = Buffer::empty();
599 let applied = b.apply_edit(&Edit::insert(Position::ZERO, "hi")).unwrap();
600 assert_eq!(b.as_string(), "hi");
601 assert_eq!(applied.replaced_text, "");
602 assert_eq!(applied.inserted_range.end, Position::new(0, 2));
603 }
604
605 #[test]
606 fn insert_appends_at_end() {
607 let mut b = Buffer::from_text("ab");
608 b.apply_edit(&Edit::insert(Position::new(0, 2), "cd"))
609 .unwrap();
610 assert_eq!(b.as_string(), "abcd");
611 }
612
613 #[test]
614 fn delete_replaces_with_empty() {
615 let mut b = Buffer::from_text("abcdef");
616 let range = Range::new(Position::new(0, 1), Position::new(0, 4));
617 let applied = b.apply_edit(&Edit::delete(range)).unwrap();
618 assert_eq!(b.as_string(), "aef");
619 assert_eq!(applied.replaced_text, "bcd");
620 }
621
622 #[test]
623 fn replace_returns_replaced_text() {
624 let mut b = Buffer::from_text("hello");
625 let range = Range::new(Position::new(0, 0), Position::new(0, 5));
626 let applied = b.apply_edit(&Edit::replace(range, "world!")).unwrap();
627 assert_eq!(b.as_string(), "world!");
628 assert_eq!(applied.replaced_text, "hello");
629 }
630
631 #[test]
632 fn position_out_of_bounds_is_an_error() {
633 let mut b = Buffer::from_text("abc");
634 let bad = Position::new(5, 0);
635 assert!(b.apply_edit(&Edit::insert(bad, "x")).is_err());
636 }
637
638 #[test]
639 fn slice_extracts_substring() {
640 let b = Buffer::from_text("hello\nworld");
641 let r = Range::new(Position::new(0, 1), Position::new(1, 3));
642 assert_eq!(b.slice(r).unwrap(), "ello\nwor");
643 }
644
645 #[test]
646 fn slice_snaps_mid_scalar_byte_offset_without_panicking() {
647 // Defensive hot-path guard: a range whose end lands mid-UTF-8-scalar
648 // (byte 1 inside `│` U+2502, bytes 0..3) must not panic ropey's
649 // byte_slice. It snaps DOWN to the nearest boundary (byte 0 here), so
650 // the slice is empty rather than a crash.
651 let b = Buffer::from_text("│x");
652 let r = Range::new(Position::new(0, 0), Position::new(0, 1));
653 assert_eq!(b.slice(r).unwrap(), "");
654 }
655
656 #[test]
657 fn apply_edit_snaps_mid_scalar_range_without_panicking() {
658 // Same guard on the edit path: a delete range ending mid-scalar must
659 // not panic; it snaps to a boundary before touching the rope.
660 let mut b = Buffer::from_text("│x");
661 let edit = Edit::delete(Range::new(Position::new(0, 0), Position::new(0, 1)));
662 // No panic; the mid-scalar end snaps down so nothing is removed.
663 assert!(b.apply_edit(&edit).is_ok());
664 assert_eq!(b.as_string(), "│x");
665 }
666
667 #[test]
668 fn line_returns_one_line_without_newline() {
669 let b = Buffer::from_text("hello\nworld\nfoo");
670 assert_eq!(b.line(0), Some("hello".into()));
671 assert_eq!(b.line(1), Some("world".into()));
672 assert_eq!(b.line(2), Some("foo".into()));
673 }
674
675 #[test]
676 fn line_out_of_range_is_none() {
677 let b = Buffer::from_text("a\nb");
678 assert_eq!(b.line(99), None);
679 }
680
681 #[test]
682 fn line_handles_trailing_newline() {
683 // Trailing newline -> ropey reports an extra empty line;
684 // it must materialise as the empty string.
685 let b = Buffer::from_text("a\nb\n");
686 assert_eq!(b.line(0), Some("a".into()));
687 assert_eq!(b.line(1), Some("b".into()));
688 assert_eq!(b.line(2), Some(String::new()));
689 }
690
691 #[test]
692 fn line_byte_len_excludes_newline() {
693 let b = Buffer::from_text("hello\nworld");
694 assert_eq!(b.line_byte_len(0), 5);
695 assert_eq!(b.line_byte_len(1), 5);
696 }
697
698 #[test]
699 fn line_byte_len_for_empty_line_is_zero() {
700 let b = Buffer::from_text("\n");
701 assert_eq!(b.line_byte_len(0), 0);
702 }
703
704 #[test]
705 fn empty_buffer_default_is_equivalent() {
706 let b: Buffer = Default::default();
707 assert_eq!(b.as_string(), "");
708 assert_eq!(b.byte_len(), 0);
709 }
710
711 #[test]
712 fn empty_buffer_has_one_logical_line() {
713 // Ropey's convention: an empty rope still presents one (empty) line.
714 let b = Buffer::empty();
715 assert_eq!(b.rope_line_count(), 1);
716 }
717
718 #[test]
719 fn line_count_counts_trailing_empty_line() {
720 // Ropey's convention: a rope ending with `\n` reports one extra (empty)
721 // trailing line. Documenting current behavior so changes are intentional.
722 let b = Buffer::from_text("a\nb\n");
723 assert_eq!(b.rope_line_count(), 3);
724 }
725
726 /// The content-space peer of the test above: the same rope that
727 /// ropey calls 3 lines is the 2-line file every editor shows.
728 #[test]
729 fn content_line_count_excludes_the_trailing_empty_line() {
730 assert_eq!(Buffer::from_text("a\nb\n").content_line_count(), 2);
731 // No terminating newline — nothing to exclude.
732 assert_eq!(Buffer::from_text("a\nb").content_line_count(), 2);
733 // A genuinely blank final line is content: the file ends with
734 // an empty line, then the terminator.
735 assert_eq!(Buffer::from_text("a\nb\n\n").content_line_count(), 3);
736 // Degenerate shapes stay at vim's one-empty-line floor.
737 assert_eq!(Buffer::empty().content_line_count(), 1);
738 assert_eq!(Buffer::from_text("").content_line_count(), 1);
739 assert_eq!(Buffer::from_text("\n").content_line_count(), 1);
740 }
741
742 #[test]
743 fn byte_len_reports_utf8_byte_count() {
744 let b = Buffer::from_text("café");
745 // café = 5 UTF-8 bytes (c=1, a=1, f=1, é=2)
746 assert_eq!(b.byte_len(), 5);
747 }
748
749 #[test]
750 fn unicode_insert_preserves_byte_count() {
751 let mut b = Buffer::from_text("c");
752 b.apply_edit(&Edit::insert(Position::new(0, 1), "é"))
753 .unwrap();
754 assert_eq!(b.as_string(), "cé");
755 assert_eq!(b.byte_len(), 3);
756 }
757
758 #[test]
759 fn cross_line_replace_works() {
760 let mut b = Buffer::from_text("ab\ncd\nef");
761 let range = Range::new(Position::new(0, 1), Position::new(2, 1));
762 let applied = b.apply_edit(&Edit::replace(range, "X")).expect("replace");
763 assert_eq!(b.as_string(), "aXf");
764 assert_eq!(applied.replaced_text, "b\ncd\ne");
765 assert_eq!(applied.inserted_range.end, Position::new(0, 2));
766 }
767
768 #[test]
769 fn delete_then_insert_via_replace_yields_correct_inserted_range() {
770 let mut b = Buffer::from_text("hello\nworld");
771 let range = Range::new(Position::new(0, 0), Position::new(0, 5));
772 let applied = b
773 .apply_edit(&Edit::replace(range, "BIG\nNEW"))
774 .expect("replace");
775 assert_eq!(b.as_string(), "BIG\nNEW\nworld");
776 assert_eq!(applied.inserted_range.start, Position::new(0, 0));
777 // "BIG\nNEW" -> 7 bytes, line 1 byte 3 (NEW = 3 bytes).
778 assert_eq!(applied.inserted_range.end, Position::new(1, 3));
779 }
780
781 #[test]
782 fn insert_at_end_of_final_line_works() {
783 let mut b = Buffer::from_text("xy");
784 let pos = Position::new(0, 2);
785 let applied = b.apply_edit(&Edit::insert(pos, "z")).expect("insert");
786 assert_eq!(b.as_string(), "xyz");
787 assert_eq!(applied.inserted_range.end, Position::new(0, 3));
788 }
789
790 #[test]
791 fn end_before_start_is_an_error_via_apply() {
792 let mut b = Buffer::from_text("abc");
793 let range = Range::new(Position::new(0, 2), Position::new(0, 1));
794 let err = b.apply_edit(&Edit::delete(range));
795 assert!(err.is_err(), "expected error for inverted range");
796 }
797
798 #[test]
799 fn end_before_start_is_an_error_via_slice() {
800 let b = Buffer::from_text("abc");
801 let range = Range::new(Position::new(0, 2), Position::new(0, 1));
802 assert!(b.slice(range).is_err());
803 }
804
805 #[test]
806 fn slice_at_zero_width_returns_empty_string() {
807 let b = Buffer::from_text("hello");
808 let range = Range::new(Position::new(0, 2), Position::new(0, 2));
809 assert_eq!(b.slice(range).unwrap(), "");
810 }
811
812 #[test]
813 fn applied_edit_round_trip_via_inverse_restores_buffer() {
814 // Apply an edit, then apply the inverse implied by the AppliedEdit, and
815 // verify the buffer returns to its original content. This is the
816 // contract the Document layer relies on for undo.
817 let original = "abcdef";
818 let mut b = Buffer::from_text(original);
819 let range = Range::new(Position::new(0, 1), Position::new(0, 4));
820 let applied = b.apply_edit(&Edit::replace(range, "X")).expect("replace");
821 // Now apply: replace inserted_range with replaced_text.
822 b.apply_edit(&Edit::replace(
823 applied.inserted_range,
824 applied.replaced_text,
825 ))
826 .expect("invert");
827 assert_eq!(b.as_string(), original);
828 }
829
830 // ---- Slice B.1: edit delta plumbing -------------------------
831 //
832 // `AppliedEdit::delta` carries the six tree-sitter-shaped
833 // fields the syntax worker needs to drive incremental
834 // reparse via `Tree::edit`. These tests pin the field
835 // semantics across the four edit shapes (insert, delete,
836 // replace, multi-line) plus invariants that catch field
837 // drift at the construction site.
838
839 #[test]
840 fn delta_shape_for_simple_insert() {
841 // Insert "hello" at the start of "world" -- pure insert,
842 // no bytes deleted. old_end == start; new_end ==
843 // start + len(text).
844 let mut b = Buffer::from_text("world");
845 let applied = b
846 .apply_edit(&Edit::insert(Position::new(0, 0), "hello"))
847 .expect("insert");
848 let d = applied.delta;
849 assert_eq!(d.start_byte, 0);
850 assert_eq!(d.old_end_byte, 0);
851 assert_eq!(d.new_end_byte, 5);
852 assert_eq!(d.start_position, Position::new(0, 0));
853 assert_eq!(d.old_end_position, Position::new(0, 0));
854 assert_eq!(d.new_end_position, Position::new(0, 5));
855 }
856
857 #[test]
858 fn delta_shape_for_pure_delete() {
859 // Delete "bcd" from "abcdef" -- pure delete, no bytes
860 // inserted. new_end == start; old_end captures the
861 // deleted span's end.
862 let mut b = Buffer::from_text("abcdef");
863 let range = Range::new(Position::new(0, 1), Position::new(0, 4));
864 let applied = b.apply_edit(&Edit::delete(range)).expect("delete");
865 let d = applied.delta;
866 assert_eq!(d.start_byte, 1);
867 assert_eq!(d.old_end_byte, 4);
868 assert_eq!(d.new_end_byte, 1);
869 assert_eq!(d.start_position, Position::new(0, 1));
870 assert_eq!(d.old_end_position, Position::new(0, 4));
871 assert_eq!(d.new_end_position, Position::new(0, 1));
872 }
873
874 #[test]
875 fn delta_shape_for_multiline_replace() {
876 // Replace `world\nbar` (spans lines 1-2) with a single
877 // `Y`. The range starts at (1,0) -- line 1's first byte
878 // -- and ends at (2,3), the end of "bar". The result is
879 // `hello\nY\nfoo`. new_end_byte=7 lands at line 1 byte 1
880 // (cursor right after the 'Y', before the trailing \n);
881 // line 0 is unchanged by byte_to_position's mapping.
882 let mut b = Buffer::from_text("hello\nworld\nbar\nfoo");
883 let range = Range::new(Position::new(1, 0), Position::new(2, 3));
884 let applied = b.apply_edit(&Edit::replace(range, "Y")).expect("replace");
885 assert_eq!(b.as_string(), "hello\nY\nfoo");
886 let d = applied.delta;
887 assert_eq!(d.start_byte, 6);
888 assert_eq!(d.old_end_byte, 15); // "hello\n" + "world\nbar" = 6+9
889 assert_eq!(d.new_end_byte, 7); // "hello\n" + "Y"
890 assert_eq!(d.start_position, Position::new(1, 0));
891 assert_eq!(d.old_end_position, Position::new(2, 3));
892 assert_eq!(d.new_end_position, Position::new(1, 1));
893 }
894
895 #[test]
896 fn delta_byte_invariants_hold_for_replace() {
897 // The two key invariants tree-sitter relies on:
898 // new_end_byte - start_byte == inserted_text.len()
899 // old_end_byte - start_byte == replaced_text.len()
900 // Catches any future field drift at the construction
901 // site without naming specific values.
902 let mut b = Buffer::from_text("aaa\nbbb\nccc");
903 let range = Range::new(Position::new(0, 1), Position::new(1, 2));
904 let applied = b.apply_edit(&Edit::replace(range, "XYZ")).expect("replace");
905 let d = applied.delta;
906 assert_eq!(
907 (d.new_end_byte - d.start_byte) as usize,
908 applied.inserted_text.len(),
909 );
910 assert_eq!(
911 (d.old_end_byte - d.start_byte) as usize,
912 applied.replaced_text.len(),
913 );
914 }
915
916 #[test]
917 fn delta_positions_match_inserted_and_original_ranges() {
918 // start_position == original_range.start; old_end_position
919 // == original_range.end; new_end_position ==
920 // inserted_range.end. Pins the AppliedEdit's existing
921 // range fields and the new delta to a consistent shape.
922 let mut b = Buffer::from_text("line0\nline1\nline2");
923 let range = Range::new(Position::new(0, 2), Position::new(1, 3));
924 let applied = b.apply_edit(&Edit::replace(range, "AB")).expect("replace");
925 assert_eq!(applied.delta.start_position, applied.original_range.start);
926 assert_eq!(applied.delta.old_end_position, applied.original_range.end);
927 assert_eq!(applied.delta.new_end_position, applied.inserted_range.end);
928 }
929
930 #[test]
931 fn delta_for_inverse_edit_swaps_old_and_new() {
932 // Apply an insert, then apply its inverse (delete what
933 // was inserted). The inverse's delta has start_byte
934 // unchanged but old_end and new_end swapped relative to
935 // the original. Pins undo/redo correctness without
936 // exercising the Document layer.
937 let mut b = Buffer::from_text("abc");
938 let forward = b
939 .apply_edit(&Edit::insert(Position::new(0, 1), "XY"))
940 .expect("forward");
941 // After forward: "aXYbc". Inverse: delete the "XY" we
942 // just inserted -- range [(0,1), (0,3)).
943 let inverse = b
944 .apply_edit(&Edit::delete(Range::new(
945 Position::new(0, 1),
946 Position::new(0, 3),
947 )))
948 .expect("inverse");
949 // Forward: start=1, old_end=1, new_end=3 (insert 2 bytes).
950 // Inverse: start=1, old_end=3, new_end=1 (delete 2 bytes).
951 assert_eq!(forward.delta.start_byte, 1);
952 assert_eq!(forward.delta.old_end_byte, 1);
953 assert_eq!(forward.delta.new_end_byte, 3);
954 assert_eq!(inverse.delta.start_byte, 1);
955 assert_eq!(inverse.delta.old_end_byte, 3);
956 assert_eq!(inverse.delta.new_end_byte, 1);
957 }
958
959 // ---- owner-write-caret.md §8: transform_position tests ----
960
961 #[test]
962 fn transform_pos_before_is_unchanged() {
963 let edit = AppliedEdit {
964 original_range: Range::new(Position::new(1, 0), Position::new(1, 5)),
965 inserted_range: Range::new(Position::new(1, 0), Position::new(1, 3)),
966 replaced_text: "hello".into(),
967 inserted_text: "hi".into(),
968 // delta is irrelevant for transform
969 delta: EditDelta {
970 start_byte: 0,
971 old_end_byte: 0,
972 new_end_byte: 0,
973 start_position: Position::ZERO,
974 old_end_position: Position::ZERO,
975 new_end_position: Position::ZERO,
976 },
977 };
978 assert_eq!(
979 transform_position(Position::new(0, 10), &edit),
980 Position::new(0, 10)
981 );
982 }
983
984 #[test]
985 fn transform_pos_at_or_after_end_shifts_by_delta() {
986 // Insert at (1, 0) with delta +2 lines +0 byte
987 let edit = AppliedEdit {
988 original_range: Range::new(Position::new(1, 0), Position::new(1, 0)),
989 inserted_range: Range::new(Position::new(1, 0), Position::new(3, 0)),
990 replaced_text: String::new(),
991 inserted_text: "a\nb\nc".into(),
992 delta: EditDelta {
993 start_byte: 0,
994 old_end_byte: 0,
995 new_end_byte: 0,
996 start_position: Position::ZERO,
997 old_end_position: Position::ZERO,
998 new_end_position: Position::ZERO,
999 },
1000 };
1001 // Caret at (5, 3) → shifted by +2 lines → (7, 3)
1002 assert_eq!(
1003 transform_position(Position::new(5, 3), &edit),
1004 Position::new(7, 3)
1005 );
1006 }
1007
1008 #[test]
1009 fn transform_pos_at_or_after_end_shifts_byte_on_same_line() {
1010 let edit = AppliedEdit {
1011 original_range: Range::new(Position::new(0, 2), Position::new(0, 2)),
1012 inserted_range: Range::new(Position::new(0, 2), Position::new(0, 5)),
1013 replaced_text: String::new(),
1014 inserted_text: "abc".into(),
1015 delta: EditDelta {
1016 start_byte: 0,
1017 old_end_byte: 0,
1018 new_end_byte: 0,
1019 start_position: Position::ZERO,
1020 old_end_position: Position::ZERO,
1021 new_end_position: Position::ZERO,
1022 },
1023 };
1024 // Insert "abc" at byte 2. Caret at byte 4 → shifted to byte 7
1025 assert_eq!(
1026 transform_position(Position::new(0, 4), &edit),
1027 Position::new(0, 7)
1028 );
1029 }
1030
1031 #[test]
1032 fn transform_pos_strictly_inside_clamps_to_end() {
1033 let edit = AppliedEdit {
1034 original_range: Range::new(Position::new(0, 1), Position::new(0, 5)),
1035 inserted_range: Range::new(Position::new(0, 1), Position::new(0, 2)),
1036 replaced_text: "ello".into(),
1037 inserted_text: "x".into(),
1038 delta: EditDelta {
1039 start_byte: 0,
1040 old_end_byte: 0,
1041 new_end_byte: 0,
1042 start_position: Position::ZERO,
1043 old_end_position: Position::ZERO,
1044 new_end_position: Position::ZERO,
1045 },
1046 };
1047 // Caret inside "ello" → clamped to end of "x" = (0, 2)
1048 assert_eq!(
1049 transform_position(Position::new(0, 3), &edit),
1050 Position::new(0, 2)
1051 );
1052 }
1053
1054 #[test]
1055 fn transform_pos_at_start_of_non_empty_range_stays() {
1056 let edit = AppliedEdit {
1057 original_range: Range::new(Position::new(0, 1), Position::new(0, 5)),
1058 inserted_range: Range::new(Position::new(0, 1), Position::new(0, 2)),
1059 replaced_text: "ello".into(),
1060 inserted_text: "x".into(),
1061 delta: EditDelta {
1062 start_byte: 0,
1063 old_end_byte: 0,
1064 new_end_byte: 0,
1065 start_position: Position::ZERO,
1066 old_end_position: Position::ZERO,
1067 new_end_position: Position::ZERO,
1068 },
1069 };
1070 // Caret at start of "ello" → stays at start of "x" = (0, 1)
1071 assert_eq!(
1072 transform_position(Position::new(0, 1), &edit),
1073 Position::new(0, 1)
1074 );
1075 }
1076
1077 #[test]
1078 fn transform_pos_insertion_at_cursor_rides_forward() {
1079 // Insert at the caret position (empty range, pos == original.start == original.end)
1080 let edit = AppliedEdit {
1081 original_range: Range::new(Position::new(0, 2), Position::new(0, 2)),
1082 inserted_range: Range::new(Position::new(0, 2), Position::new(0, 5)),
1083 replaced_text: String::new(),
1084 inserted_text: "XYZ".into(),
1085 delta: EditDelta {
1086 start_byte: 0,
1087 old_end_byte: 0,
1088 new_end_byte: 0,
1089 start_position: Position::ZERO,
1090 old_end_position: Position::ZERO,
1091 new_end_position: Position::ZERO,
1092 },
1093 };
1094 // Caret at (0, 2) — the insertion point — rides forward to (0, 5)
1095 assert_eq!(
1096 transform_position(Position::new(0, 2), &edit),
1097 Position::new(0, 5)
1098 );
1099 }
1100
1101 #[test]
1102 fn transform_pos_deletion_spanning_caret_clamps_to_end() {
1103 let edit = AppliedEdit {
1104 original_range: Range::new(Position::new(0, 1), Position::new(0, 10)),
1105 inserted_range: Range::new(Position::new(0, 1), Position::new(0, 1)),
1106 replaced_text: "bcdefghij".into(),
1107 inserted_text: String::new(),
1108 delta: EditDelta {
1109 start_byte: 0,
1110 old_end_byte: 0,
1111 new_end_byte: 0,
1112 start_position: Position::ZERO,
1113 old_end_position: Position::ZERO,
1114 new_end_position: Position::ZERO,
1115 },
1116 };
1117 // Caret at (0, 5) inside deleted range → clamped to (0, 1)
1118 assert_eq!(
1119 transform_position(Position::new(0, 5), &edit),
1120 Position::new(0, 1)
1121 );
1122 }
1123
1124 #[test]
1125 fn transform_pos_batch_applies_left_to_right() {
1126 let edit1 = AppliedEdit {
1127 original_range: Range::new(Position::new(0, 5), Position::new(0, 5)),
1128 inserted_range: Range::new(Position::new(0, 5), Position::new(0, 8)),
1129 replaced_text: String::new(),
1130 inserted_text: "abc".into(),
1131 delta: EditDelta {
1132 start_byte: 0,
1133 old_end_byte: 0,
1134 new_end_byte: 0,
1135 start_position: Position::ZERO,
1136 old_end_position: Position::ZERO,
1137 new_end_position: Position::ZERO,
1138 },
1139 };
1140 let edit2 = AppliedEdit {
1141 original_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
1142 inserted_range: Range::new(Position::new(0, 0), Position::new(0, 2)),
1143 replaced_text: String::new(),
1144 inserted_text: "XY".into(),
1145 delta: EditDelta {
1146 start_byte: 0,
1147 old_end_byte: 0,
1148 new_end_byte: 0,
1149 start_position: Position::ZERO,
1150 old_end_position: Position::ZERO,
1151 new_end_position: Position::ZERO,
1152 },
1153 };
1154 // Caret at (0, 3) before batch.
1155 // After edit1 (insert "abc" at byte 5 → pos unaffected since pos < 5).
1156 // After edit2 (insert "XY" at byte 0 → pos shifts by +2 bytes).
1157 let pos = transform_position(Position::new(0, 3), &edit1);
1158 let pos = transform_position(pos, &edit2);
1159 assert_eq!(pos, Position::new(0, 5));
1160 }
1161}