Skip to main content

strop_core/
buffer.rs

1//! The buffer: a rope, byte-offset positions, edit ops, persistence.
2//! No UI, no modes, no grammar — the thing everything else edits.
3
4mod io;
5pub use io::{SaveReceipt, SaveRequest};
6mod seed;
7pub use seed::BufferSeed;
8mod layout_cache;
9mod mutation;
10use crate::diagnostics::BufferTraceId;
11use crate::history::History;
12use crate::id;
13use crate::range::Range;
14pub use mutation::{
15    Change, ChangeOrigin, EditError, HistoryMove, PreparedReplacements, Replacement, SystemEdit,
16    UserEdit,
17};
18use ropey::Rope;
19
20/// A text buffer. Positions are UTF-8 byte offsets, everywhere (0001 §5.1).
21pub struct Buffer {
22    pub(crate) trace_identity: BufferTraceId,
23    rope: Rope,
24    /// Filesystem identity (0021 §3: Unix filenames aren't UTF-8 — a
25    /// String path makes the filesystem model a UI model). Display via
26    /// to_string_lossy at the edge only.
27    pub path: Option<std::path::PathBuf>,
28    pub dirty: bool,
29    /// Monotonic edit counter; async readers (git gutter) diff lazily.
30    epoch: u64,
31    /// Read-only views (git surfaces): motions/yank work, edits refuse.
32    pub readonly: bool,
33    /// Display name for virtual buffers (statusline shows "[scratch]"
34    /// otherwise): "git log", "commit 1a2b3c", …
35    pub name: Option<String>,
36    /// Undo history (helix-style revision tree). Readonly buffers never
37    /// record (their content is owned by jobs, not the user).
38    history: History,
39    changes: Vec<Change>,
40    /// Disk mtime at load/last save — overwrite protection for `:w`.
41    disk_stamp: Option<std::time::SystemTime>,
42    file_identity: Option<std::path::PathBuf>,
43    line_layouts: layout_cache::LineLayouts,
44}
45
46impl Buffer {
47    pub fn text(&self) -> &Rope {
48        &self.rope
49    }
50    pub fn snapshot(&self) -> Rope {
51        self.rope.clone()
52    }
53
54    /// Diagnostic head excerpt: copies at most [`strop_trace::MAX_EXCERPT_BYTES`]
55    /// instead of materializing the whole buffer, and reports whether the
56    /// text was cut.
57    pub fn text_excerpt(&self) -> (String, bool) {
58        let rope = &self.rope;
59        if rope.len_bytes() <= strop_trace::MAX_EXCERPT_BYTES {
60            return (rope.to_string(), false);
61        }
62        let mut end = strop_trace::MAX_EXCERPT_BYTES;
63        while end > 0 && (rope.byte(end - 1) & 0xC0) == 0x80 {
64            end -= 1;
65        }
66        (
67            rope.get_byte_slice(..end)
68                .map_or_else(String::new, |head| head.to_string()),
69            true,
70        )
71    }
72    pub fn history(&self) -> &History {
73        &self.history
74    }
75    pub fn revision(&self) -> id::BufferRevision {
76        id::BufferRevision::new(self.epoch)
77    }
78    pub fn file_identity(&self) -> Option<&std::path::Path> {
79        self.file_identity.as_deref()
80    }
81
82    pub fn restore_history(
83        &mut self,
84        history: History,
85    ) -> Result<(), crate::history::HistoryError> {
86        history.validate_for(&self.rope)?;
87        self.adopt_history(history);
88        Ok(())
89    }
90
91    pub fn from_text(text: &str) -> Self {
92        Self::from_snapshot(Rope::from_str(text))
93    }
94
95    /// A cheap independent reader over immutable rope structure. No text copy,
96    /// disk identity, or history is inherited from the publishing document.
97    pub fn from_snapshot(rope: Rope) -> Self {
98        Self {
99            trace_identity: BufferTraceId::next(),
100            rope,
101            path: None,
102            dirty: false,
103            epoch: 0,
104            readonly: false,
105            name: None,
106            history: History::default(),
107            changes: Vec::new(),
108            disk_stamp: None,
109            file_identity: None,
110            line_layouts: layout_cache::LineLayouts::default(),
111        }
112    }
113
114    /// Open a file; a missing file is a new empty buffer with that path
115    /// (vim semantics — `:w` creates it). Real I/O errors still error.
116    pub fn open(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
117        let path = path.as_ref();
118        let (rope, disk_stamp) = match std::fs::File::open(path) {
119            Ok(file) => {
120                let stamp = file.metadata()?.modified()?;
121                (Rope::from_reader(file)?, Some(stamp))
122            }
123            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (Rope::new(), None),
124            Err(e) => return Err(e),
125        };
126        Ok(Self {
127            trace_identity: BufferTraceId::next(),
128            rope,
129            path: Some(path.to_path_buf()),
130            dirty: false,
131            epoch: 0,
132            readonly: false,
133            name: None,
134            history: History::default(),
135            changes: Vec::new(),
136            disk_stamp,
137            file_identity: Some(std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())),
138            line_layouts: layout_cache::LineLayouts::default(),
139        })
140    }
141
142    /// Display CELL of an offset within its line (0017/R6): cursor placement
143    /// and overlays need terminal cells, not byte columns — wide chars and
144    /// tabs make the difference. Streams through the containing cluster only:
145    /// no whole-line String, no layout vector, no u16 saturation.
146    pub fn cell_col_with_tab(
147        &self,
148        offset: impl Into<id::ByteOffset>,
149        tab: usize,
150    ) -> id::DisplayColumn {
151        match self.column_from_layout(offset.into().get(), tab, false) {
152            Some(column) => column,
153            None => unreachable!("an unbounded layout projection always completes"),
154        }
155    }
156
157    pub fn len_bytes(&self) -> usize {
158        self.rope.len_bytes()
159    }
160    pub fn len_lines(&self) -> usize {
161        self.rope.len_lines()
162    }
163
164    /// Last *content* line index — a trailing newline's phantom empty
165    /// line doesn't count (vim's G lands on real text).
166    pub fn last_content_line(&self) -> usize {
167        let mut l = self.len_lines().saturating_sub(1);
168        if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
169            l -= 1;
170        }
171        l
172    }
173
174    /// Byte offset of the first char of `line` (0-indexed).
175    pub fn line_start(&self, line: impl Into<id::LineIndex>) -> usize {
176        self.rope
177            .line_to_byte(line.into().get().min(self.len_lines().saturating_sub(1)))
178    }
179
180    /// Byte offset one past the last content char (excludes LF or CRLF).
181    pub fn line_end(&self, line: impl Into<id::LineIndex>) -> usize {
182        let line = line.into().get();
183        let start = self.line_start(line);
184        let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
185        if line + 1 >= self.len_lines() {
186            end = self.len_bytes();
187        }
188        // strip the trailing newline
189        if end > start && self.byte(end - 1) == b'\n' {
190            end -= 1;
191            if end > start && self.byte(end - 1) == b'\r' {
192                end -= 1;
193            }
194        }
195        end
196    }
197
198    pub fn line_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
199        self.rope
200            .byte_to_line(offset.into().get().min(self.len_bytes()))
201    }
202
203    /// Column (in bytes) of `offset` within its line.
204    pub fn col_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
205        let offset = offset.into();
206        offset.get() - self.line_start(self.line_of(offset))
207    }
208    /// Byte at a position. An empty rope reads as NUL: every classifier
209    /// treats NUL as a boundary, and the alternative (a panic) is how
210    /// the second review found this (0015). `byte_at` when absence
211    /// itself matters.
212    pub fn byte(&self, offset: impl Into<id::ByteOffset>) -> u8 {
213        if self.len_bytes() == 0 {
214            return 0;
215        }
216        self.rope
217            .byte(offset.into().get().min(self.len_bytes().saturating_sub(1)))
218    }
219
220    pub fn byte_at(&self, offset: impl Into<id::ByteOffset>) -> Option<u8> {
221        let off = offset.into().get();
222        if off < self.len_bytes() {
223            Some(self.rope.byte(off))
224        } else {
225            None
226        }
227    }
228
229    /// Is `offset` a UTF-8 char boundary? ropey's `try_byte_to_char`
230    /// maps a mid-char byte to its containing char without complaint —
231    /// only the byte↔char roundtrip actually detects boundaries. (The
232    /// pre-0.3.9 clamp trusted it and never clamped anything.)
233    pub fn is_boundary(&self, offset: impl Into<id::ByteOffset>) -> bool {
234        let off = offset.into().get();
235        if off == 0 || off == self.len_bytes() {
236            return true;
237        }
238        if off > self.len_bytes() {
239            return false;
240        }
241        match self.rope.try_byte_to_char(off) {
242            Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == off),
243            Err(_) => false,
244        }
245    }
246
247    /// Clamp a byte offset down to a char boundary (the grapheme policy
248    /// in 0001 §5.9 hardens this further when text goes wide).
249    pub fn clamp_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
250        let mut offset = offset.into().get().min(self.len_bytes());
251        while offset > 0 && !self.is_boundary(offset) {
252            offset -= 1;
253        }
254        offset
255    }
256
257    /// Smallest char boundary >= offset. Byte arithmetic on a cursor
258    /// (`cursor + 1` in x/a/r/~) lands inside a multibyte char; deleting
259    /// or inserting there panics ropey. Round up, never down — a
260    /// deletion that rounds down eats the previous char's tail.
261    pub fn ceil_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
262        let mut offset = offset.into().get().min(self.len_bytes());
263        while offset < self.len_bytes() && !self.is_boundary(offset) {
264            offset += 1;
265        }
266        offset
267    }
268
269    /// Slice as String — for register/paste paths, never for per-frame render.
270    /// Stale ranges clamp (fuzz-driven cascades hand these around).
271    pub fn slice_string(&self, range: Range) -> String {
272        let start = self.clamp_boundary(range.start);
273        let end = self.clamp_boundary(range.end);
274        self.rope.byte_slice(start..end.max(start)).to_string()
275    }
276
277    pub fn line_text(&self, line: impl Into<id::LineIndex>) -> String {
278        let line = line.into().get();
279        let start = self.line_start(line);
280        let end = self.line_end(line);
281        self.rope.byte_slice(start..end).to_string()
282    }
283}
284
285/// Pre-edit and post-edit geometry recorded at the instant text changes.
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub struct InputEdit {
288    pub start_byte: usize,
289    pub old_end_byte: usize,
290    pub new_end_byte: usize,
291    pub start_point: (usize, usize),
292    pub old_end_point: (usize, usize),
293    pub new_end_point: (usize, usize),
294}
295
296impl Buffer {
297    /// (line, col) of a byte offset, as tree-sitter Points.
298    pub fn point_of(&self, offset: usize) -> (usize, usize) {
299        let offset = offset.min(self.len_bytes());
300        (self.line_of(offset), self.col_of(offset))
301    }
302
303    /// The (line, col) extent of a text fragment.
304    fn point_extent(text: &str) -> (usize, usize) {
305        let lines = text.bytes().filter(|b| *b == b'\n').count();
306        let col = if lines == 0 {
307            text.len()
308        } else {
309            text.rsplit('\n').next().map(str::len).unwrap_or(0)
310        };
311        (lines, col)
312    }
313}
314
315#[cfg(test)]
316mod safety_tests {
317    use super::*;
318
319    #[test]
320    fn save_refuses_external_change_unless_forced() {
321        let dir = tempfile::tempdir().unwrap();
322        let f = dir.path().join("f.txt");
323        std::fs::write(&f, "original\n").unwrap();
324        let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
325        b.edit().insert(id::ByteOffset::new(0), "mine ").unwrap();
326        // another process touches the file
327        std::fs::write(&f, "theirs\n").unwrap();
328        std::fs::File::options()
329            .write(true)
330            .open(&f)
331            .unwrap()
332            .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(123))
333            .unwrap();
334        let err = b.prepare_save(None, false).unwrap().execute().unwrap_err();
335        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
336        assert_eq!(std::fs::read_to_string(&f).unwrap(), "theirs\n");
337        let receipt = b.prepare_save(None, true).unwrap().execute().unwrap();
338        assert!(b.accept_save(receipt));
339        assert_eq!(std::fs::read_to_string(&f).unwrap(), "mine original\n");
340        assert!(!b.dirty);
341    }
342
343    #[test]
344    fn save_is_atomic_and_keeps_permissions() {
345        use std::os::unix::fs::PermissionsExt;
346        let dir = tempfile::tempdir().unwrap();
347        let f = dir.path().join("x.sh");
348        std::fs::write(&f, "#!/bin/sh\n").unwrap();
349        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o750)).unwrap();
350        let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
351        let end = b.len_bytes();
352        b.edit()
353            .insert(id::ByteOffset::new(end), "echo hi\n")
354            .unwrap();
355        let receipt = b.prepare_save(None, false).unwrap().execute().unwrap();
356        assert!(b.accept_save(receipt));
357        assert_eq!(std::fs::read_to_string(&f).unwrap(), "#!/bin/sh\necho hi\n");
358        let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
359        assert_eq!(mode, 0o750, "permissions survive the swap");
360        // no temp litter
361        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
362    }
363
364    #[test]
365    fn readonly_refuses_mutation_at_the_boundary() {
366        // 0014: the guard lives in Buffer, not in every caller's memory
367        let mut b = Buffer::from_text("abc\n");
368        b.readonly = true;
369        assert_eq!(b.edit().insert(0, "nope"), Err(EditError::ReadOnly));
370        assert_eq!(
371            b.edit().delete(Range::charwise(0, 2)),
372            Err(EditError::ReadOnly)
373        );
374        assert_eq!(b.rope.to_string(), "abc\n", "untouched");
375        // the owner path still works (job-generated surfaces)
376        b.system_edit().replace_all("gen\n").unwrap();
377        assert_eq!(b.rope.to_string(), "gen\n");
378    }
379    #[test]
380    fn non_utf8_filename_opens_and_roundtrips() {
381        // 0021 §3: the filesystem is not UTF-8 — a weird name must open,
382        // save, and keep its identity
383        use std::os::unix::ffi::OsStrExt;
384        let dir = tempfile::tempdir().unwrap();
385        let weird = dir
386            .path()
387            .join(std::ffi::OsStr::from_bytes(b"weird-\xff.rs"));
388        std::fs::write(&weird, "fn main() {}\n").unwrap();
389        let mut b = Buffer::open(&weird).unwrap();
390        assert_eq!(b.path.as_deref(), Some(weird.as_path()));
391        b.edit().insert(0, "// x\n").unwrap();
392        let receipt = b.prepare_save(None, false).unwrap().execute().unwrap();
393        assert!(b.accept_save(receipt));
394        assert_eq!(
395            std::fs::read_to_string(&weird).unwrap(),
396            "// x\nfn main() {}\n"
397        );
398    }
399}