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