Skip to main content

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}