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
4use crate::diagnostics::{BufferTraceId, MutationSource};
5use crate::history::{Edit, EditKind, History};
6use crate::range::Range;
7use crate::{id, layout};
8use ropey::Rope;
9
10/// A text buffer. Positions are UTF-8 byte offsets, everywhere (0001 §5.1).
11pub struct Buffer {
12    pub(crate) trace_identity: BufferTraceId,
13    pub rope: Rope,
14    /// Filesystem identity (0021 §3: Unix filenames aren't UTF-8 — a
15    /// String path makes the filesystem model a UI model). Display via
16    /// to_string_lossy at the edge only.
17    pub path: Option<std::path::PathBuf>,
18    pub dirty: bool,
19    /// Monotonic edit counter; async readers (git gutter) diff lazily.
20    pub epoch: u64,
21    /// Read-only views (git surfaces): motions/yank work, edits refuse.
22    pub readonly: bool,
23    /// Display name for virtual buffers (statusline shows "[scratch]"
24    /// otherwise): "git log", "commit 1a2b3c", …
25    pub name: Option<String>,
26    /// Undo history (helix-style revision tree). Readonly buffers never
27    /// record (their content is owned by jobs, not the user).
28    pub history: History,
29    /// Suppresses recording while applying undo/redo ops.
30    pub replaying: bool,
31    /// Disk mtime at load/last save — overwrite protection for `:w`.
32    disk_stamp: Option<std::time::SystemTime>,
33}
34
35impl Buffer {
36    pub fn from_text(text: &str) -> Self {
37        Self {
38            trace_identity: BufferTraceId::next(),
39            rope: Rope::from_str(text),
40            path: None,
41            dirty: false,
42            epoch: 0,
43            readonly: false,
44            name: None,
45            history: History::default(),
46            replaying: false,
47            disk_stamp: None,
48        }
49    }
50
51    /// Open a file; a missing file is a new empty buffer with that path
52    /// (vim semantics — `:w` creates it). Real I/O errors still error.
53    pub fn open(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
54        let path = path.as_ref();
55        let text = match std::fs::read_to_string(path) {
56            Ok(t) => t,
57            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
58            Err(e) => return Err(e),
59        };
60        let disk_stamp = std::fs::metadata(path).and_then(|m| m.modified()).ok();
61        Ok(Self {
62            trace_identity: BufferTraceId::next(),
63            rope: Rope::from_str(&text),
64            path: Some(path.to_path_buf()),
65            dirty: false,
66            epoch: 0,
67            readonly: false,
68            name: None,
69            history: History::default(),
70            replaying: false,
71            disk_stamp,
72        })
73    }
74
75    /// `:w` — atomic (temp + rename in the same dir), refuses to
76    /// overwrite a file another process touched since we loaded it.
77    /// `force` is `:w!`.
78    pub fn save(&mut self, force: bool) -> std::io::Result<()> {
79        let Some(path) = self.path.clone() else {
80            // a pathless buffer has nothing to persist to — "written"
81            // would be a lie (0015)
82            return Err(std::io::Error::new(
83                std::io::ErrorKind::NotFound,
84                "no file name — :w {path} to name it",
85            ));
86        };
87        // write THROUGH links (0023: replacing a symlink with a regular
88        // file silently breaks the link — vim preserves it)
89        let path = std::fs::canonicalize(&path).unwrap_or(path);
90        let current = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
91        if !force && current.is_some() && current != self.disk_stamp {
92            return Err(std::io::Error::new(
93                std::io::ErrorKind::PermissionDenied,
94                "file changed on disk — :w! to force",
95            ));
96        }
97        write_atomic(std::path::Path::new(&path), &self.rope.to_string(), true)?;
98        self.disk_stamp = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
99        self.dirty = false;
100        Ok(())
101    }
102
103    /// `:w {path}` — persist under a new name and adopt it (the buffer
104    /// becomes that file). The identity changes only after a SUCCESSFUL
105    /// write (0020 §1): an existing target needs `force`, and a failed
106    /// write leaves path, baseline and dirty state untouched.
107    pub fn save_as(
108        &mut self,
109        path: impl AsRef<std::path::Path>,
110        force: bool,
111    ) -> std::io::Result<()> {
112        let target = path.as_ref();
113        if !force && target.exists() {
114            return Err(std::io::Error::new(
115                std::io::ErrorKind::PermissionDenied,
116                "file exists — :w! to overwrite",
117            ));
118        }
119        write_atomic(target, &self.rope.to_string(), force)?;
120        // success: adopt the identity
121        self.path = Some(target.to_path_buf());
122        self.disk_stamp = std::fs::metadata(target).and_then(|m| m.modified()).ok();
123        self.dirty = false;
124        Ok(())
125    }
126    /// Display CELL of an offset within its line (0017): cursor
127    /// placement and overlays need terminal cells, not byte cols —
128    /// wide chars and tabs make the difference. The LineLayout is the
129    /// single translation seam.
130    pub fn cell_col_of(&self, offset: impl Into<id::ByteOffset>) -> u16 {
131        self.cell_col_with_tab(offset, 8)
132    }
133
134    /// The cell col under a caller's tab stop (0023: the caret and the
135    /// tab glyph must read the same width — render config drives both).
136    pub fn cell_col_with_tab(&self, offset: impl Into<id::ByteOffset>, tab: u16) -> u16 {
137        let offset = offset.into().get();
138        if self.len_bytes() == 0 {
139            return 0;
140        }
141        let line = self.line_of(offset);
142        let (s, e) = (self.line_start(line), self.line_end(line));
143        let text = self.rope.byte_slice(s..e).to_string();
144        let col = offset.saturating_sub(s);
145        let layout = layout::LineLayout::build(text.trim_end_matches('\n'), tab.max(1));
146        layout.cell_at_byte(col.min(layout.len_bytes))
147    }
148
149    pub fn len_bytes(&self) -> usize {
150        self.rope.len_bytes()
151    }
152    pub fn len_lines(&self) -> usize {
153        self.rope.len_lines()
154    }
155
156    /// Last *content* line index — a trailing newline's phantom empty
157    /// line doesn't count (vim's G lands on real text).
158    pub fn last_content_line(&self) -> usize {
159        let mut l = self.len_lines().saturating_sub(1);
160        if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
161            l -= 1;
162        }
163        l
164    }
165
166    /// Byte offset of the first char of `line` (0-indexed).
167    pub fn line_start(&self, line: impl Into<id::LineIndex>) -> usize {
168        self.rope
169            .line_to_byte(line.into().get().min(self.len_lines().saturating_sub(1)))
170    }
171
172    /// Byte offset one past the last content char (excludes LF or CRLF).
173    pub fn line_end(&self, line: impl Into<id::LineIndex>) -> usize {
174        let line = line.into().get();
175        let start = self.line_start(line);
176        let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
177        if line + 1 >= self.len_lines() {
178            end = self.len_bytes();
179        }
180        // strip the trailing newline
181        if end > start && self.byte(end - 1) == b'\n' {
182            end -= 1;
183            if end > start && self.byte(end - 1) == b'\r' {
184                end -= 1;
185            }
186        }
187        end
188    }
189
190    pub fn line_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
191        self.rope
192            .byte_to_line(offset.into().get().min(self.len_bytes()))
193    }
194
195    /// Column (in bytes) of `offset` within its line.
196    pub fn col_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
197        let offset = offset.into();
198        offset.get() - self.line_start(self.line_of(offset))
199    }
200    /// Byte at a position. An empty rope reads as NUL: every classifier
201    /// treats NUL as a boundary, and the alternative (a panic) is how
202    /// the second review found this (0015). `byte_at` when absence
203    /// itself matters.
204    pub fn byte(&self, offset: impl Into<id::ByteOffset>) -> u8 {
205        if self.len_bytes() == 0 {
206            return 0;
207        }
208        self.rope
209            .byte(offset.into().get().min(self.len_bytes().saturating_sub(1)))
210    }
211
212    pub fn byte_at(&self, offset: impl Into<id::ByteOffset>) -> Option<u8> {
213        let off = offset.into().get();
214        if off < self.len_bytes() {
215            Some(self.rope.byte(off))
216        } else {
217            None
218        }
219    }
220
221    /// Is `offset` a UTF-8 char boundary? ropey's `try_byte_to_char`
222    /// maps a mid-char byte to its containing char without complaint —
223    /// only the byte↔char roundtrip actually detects boundaries. (The
224    /// pre-0.3.9 clamp trusted it and never clamped anything.)
225    pub fn is_boundary(&self, offset: impl Into<id::ByteOffset>) -> bool {
226        let off = offset.into().get();
227        if off == 0 || off == self.len_bytes() {
228            return true;
229        }
230        if off > self.len_bytes() {
231            return false;
232        }
233        match self.rope.try_byte_to_char(off) {
234            Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == off),
235            Err(_) => false,
236        }
237    }
238
239    /// Clamp a byte offset down to a char boundary (the grapheme policy
240    /// in 0001 §5.9 hardens this further when text goes wide).
241    pub fn clamp_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
242        let mut offset = offset.into().get().min(self.len_bytes());
243        while offset > 0 && !self.is_boundary(offset) {
244            offset -= 1;
245        }
246        offset
247    }
248
249    /// Smallest char boundary >= offset. Byte arithmetic on a cursor
250    /// (`cursor + 1` in x/a/r/~) lands inside a multibyte char; deleting
251    /// or inserting there panics ropey. Round up, never down — a
252    /// deletion that rounds down eats the previous char's tail.
253    pub fn ceil_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
254        let mut offset = offset.into().get().min(self.len_bytes());
255        while offset < self.len_bytes() && !self.is_boundary(offset) {
256            offset += 1;
257        }
258        offset
259    }
260
261    /// Slice as String — for register/paste paths, never for per-frame render.
262    /// Stale ranges clamp (fuzz-driven cascades hand these around).
263    pub fn slice_string(&self, range: Range) -> String {
264        let start = range.start.min(self.len_bytes());
265        let end = range.end.min(self.len_bytes());
266        self.rope.byte_slice(start..end.max(start)).to_string()
267    }
268
269    /// Apply history edits (undo/redo replay — never recorded).
270    pub fn apply_history(&mut self, ops: Vec<Edit>) {
271        self.replaying = true;
272        for op in &ops {
273            match op.kind {
274                EditKind::Insert => {
275                    let at = self.clamp_boundary(op.at.min(self.len_bytes()));
276                    self.rope.insert(self.rope.byte_to_char(at), &op.text);
277                }
278                EditKind::Delete => {
279                    // both bounds must land on char boundaries — a stale
280                    // replay against drifted text panics ropey otherwise
281                    let end = self.clamp_boundary((op.at + op.text.len()).min(self.len_bytes()));
282                    let start = self.clamp_boundary(op.at.min(end));
283                    if start < end {
284                        self.rope
285                            .remove(self.rope.byte_to_char(start)..self.rope.byte_to_char(end));
286                    }
287                }
288            }
289        }
290        self.replaying = false;
291        self.dirty = true;
292        self.epoch += 1;
293        self.trace_history(&ops);
294    }
295
296    /// Replace the whole contents (user-facing path). Refuses on
297    /// readonly buffers — the owning subsystem uses
298    /// `replace_all_system`.
299    pub fn replace_all(&mut self, text: &str) {
300        if self.readonly {
301            return;
302        }
303        self.replace_all_system(text);
304    }
305
306    /// The privileged replace for generated surfaces: their content is
307    /// owned by jobs (git/LSP/shell), refreshed under the user's feet —
308    /// the readonly guard is about *user* edits, not the owner.
309    pub fn replace_all_system(&mut self, text: &str) {
310        let removed_bytes = self.len_bytes();
311        self.rope = Rope::from_str(text);
312        self.epoch += 1;
313        self.trace_edit(MutationSource::System, 0, removed_bytes, text);
314    }
315
316    /// Returns the deleted text (register payoff). Refuses on readonly
317    /// buffers: the input layer checks first, but the mutation boundary
318    /// enforces — no caller-remembered guard (0014).
319    pub fn delete(&mut self, range: Range) -> String {
320        if self.readonly && !self.replaying {
321            return String::new();
322        }
323        // stale ranges (fuzz-driven cascades, replay drift) clamp, not panic
324        let start = self.clamp_boundary(range.start.min(self.len_bytes()));
325        let end = self.clamp_boundary(range.end.min(self.len_bytes()));
326        if start >= end {
327            return String::new();
328        }
329        let text = self.rope.byte_slice(start..end).to_string();
330        // ropey mutates by CHAR index; our offsets are bytes
331        let cstart = self.rope.byte_to_char(start);
332        let cend = self.rope.byte_to_char(end);
333        self.rope.remove(cstart..cend);
334        self.dirty = true;
335        self.epoch += 1;
336        self.trace_edit(MutationSource::User, start, end - start, "");
337        if !self.replaying && !self.readonly {
338            self.history.record(
339                Edit {
340                    at: start,
341                    text: text.clone(),
342                    kind: EditKind::Insert,
343                },
344                Edit {
345                    at: start,
346                    text: text.clone(),
347                    kind: EditKind::Delete,
348                },
349            );
350        }
351        text
352    }
353
354    pub fn insert(&mut self, at: impl Into<id::ByteOffset>, text: &str) {
355        if self.readonly && !self.replaying {
356            return;
357        }
358        let at = self.clamp_boundary(at);
359        self.rope.insert(self.rope.byte_to_char(at), text);
360        self.dirty = true;
361        self.epoch += 1;
362        self.trace_edit(MutationSource::User, at, 0, text);
363        if !self.replaying && !self.readonly {
364            self.history.record(
365                Edit {
366                    at,
367                    text: text.into(),
368                    kind: EditKind::Delete,
369                },
370                Edit {
371                    at,
372                    text: text.into(),
373                    kind: EditKind::Insert,
374                },
375            );
376        }
377    }
378
379    pub fn line_text(&self, line: impl Into<id::LineIndex>) -> String {
380        let line = line.into().get();
381        let start = self.line_start(line);
382        let end = self.line_end(line);
383        self.rope.byte_slice(start..end).to_string()
384    }
385}
386
387/// Same-directory temp + rename, preserving the target's permissions —
388/// the ONE atomic writer (0020 §8: no third copy of this logic).
389fn write_atomic(target: &std::path::Path, contents: &str, overwrite: bool) -> std::io::Result<()> {
390    use std::io::Write;
391    let parent = target
392        .parent()
393        .filter(|path| !path.as_os_str().is_empty())
394        .unwrap_or_else(|| std::path::Path::new("."));
395    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
396    match std::fs::metadata(target) {
397        Ok(metadata) => temporary
398            .as_file()
399            .set_permissions(metadata.permissions())?,
400        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
401        Err(error) => return Err(error),
402    }
403    temporary.write_all(contents.as_bytes())?;
404    temporary.as_file().sync_all()?;
405    let result = if overwrite {
406        temporary.persist(target)
407    } else {
408        temporary.persist_noclobber(target)
409    };
410    result.map(|_| ()).map_err(|error| error.error)
411}
412
413/// One edit in tree-sitter's terms (0022 §1): byte range + point
414/// positions, computed from the op itself at commit time — no old text
415/// needed (the point extents derive from the op's own content).
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub struct InputEdit {
418    pub start_byte: usize,
419    pub old_end_byte: usize,
420    pub new_end_byte: usize,
421    pub start_point: (usize, usize),
422    pub old_end_point: (usize, usize),
423    pub new_end_point: (usize, usize),
424}
425
426impl Buffer {
427    /// (line, col) of a byte offset, as tree-sitter Points.
428    pub fn point_of(&self, offset: usize) -> (usize, usize) {
429        let offset = offset.min(self.len_bytes());
430        (self.line_of(offset), self.col_of(offset))
431    }
432
433    /// The (line, col) extent of a text fragment.
434    fn point_extent(text: &str) -> (usize, usize) {
435        let lines = text.bytes().filter(|b| *b == b'\n').count();
436        let col = if lines == 0 {
437            text.len()
438        } else {
439            text.rsplit('\n').next().map(str::len).unwrap_or(0)
440        };
441        (lines, col)
442    }
443
444    /// Bridge one recorded history op to tree-sitter's InputEdit.
445    /// Call against the post-edit buffer (the transaction has landed).
446    pub fn input_edit_of(&self, op: &crate::history::Edit) -> InputEdit {
447        let start_point = self.point_of(op.at);
448        let extent = Self::point_extent(&op.text);
449        match op.kind {
450            EditKind::Insert => InputEdit {
451                start_byte: op.at,
452                old_end_byte: op.at,
453                new_end_byte: op.at + op.text.len(),
454                start_point,
455                old_end_point: start_point,
456                // a single-line insert ends at start.column + len — the
457                // extent's col is relative, not absolute (0023 probe)
458                new_end_point: if extent.0 == 0 {
459                    (start_point.0, start_point.1 + extent.1)
460                } else {
461                    (start_point.0 + extent.0, extent.1)
462                },
463            },
464            EditKind::Delete => InputEdit {
465                start_byte: op.at,
466                old_end_byte: op.at + op.text.len(),
467                new_end_byte: op.at,
468                start_point,
469                old_end_point: if extent.0 == 0 {
470                    (start_point.0, start_point.1 + extent.1)
471                } else {
472                    (start_point.0 + extent.0, extent.1)
473                },
474                new_end_point: start_point,
475            },
476        }
477    }
478}
479
480#[cfg(test)]
481mod safety_tests {
482    use super::*;
483
484    #[test]
485    fn save_refuses_external_change_unless_forced() {
486        let dir = tempfile::tempdir().unwrap();
487        let f = dir.path().join("f.txt");
488        std::fs::write(&f, "original\n").unwrap();
489        let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
490        b.insert(id::ByteOffset::new(0), "mine ");
491        // another process touches the file
492        std::thread::sleep(std::time::Duration::from_millis(5));
493        std::fs::write(&f, "theirs\n").unwrap();
494        let err = b.save(false).unwrap_err();
495        assert!(err.to_string().contains("changed on disk"));
496        assert_eq!(std::fs::read_to_string(&f).unwrap(), "theirs\n");
497        b.save(true).unwrap(); // :w!
498        assert_eq!(std::fs::read_to_string(&f).unwrap(), "mine original\n");
499        assert!(!b.dirty);
500    }
501
502    #[test]
503    fn save_is_atomic_and_keeps_permissions() {
504        use std::os::unix::fs::PermissionsExt;
505        let dir = tempfile::tempdir().unwrap();
506        let f = dir.path().join("x.sh");
507        std::fs::write(&f, "#!/bin/sh\n").unwrap();
508        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o750)).unwrap();
509        let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
510        b.insert(id::ByteOffset::new(b.len_bytes()), "echo hi\n");
511        b.save(false).unwrap();
512        assert_eq!(std::fs::read_to_string(&f).unwrap(), "#!/bin/sh\necho hi\n");
513        let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
514        assert_eq!(mode, 0o750, "permissions survive the swap");
515        // no temp litter
516        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
517    }
518
519    #[test]
520    fn readonly_refuses_mutation_at_the_boundary() {
521        // 0014: the guard lives in Buffer, not in every caller's memory
522        let mut b = Buffer::from_text("abc\n");
523        b.readonly = true;
524        b.insert(id::ByteOffset::new(0), "nope");
525        let gone = b.delete(Range::charwise(0, 2));
526        assert_eq!(gone, "");
527        assert_eq!(b.rope.to_string(), "abc\n", "untouched");
528        // the owner path still works (job-generated surfaces)
529        b.replace_all_system("gen\n");
530        assert_eq!(b.rope.to_string(), "gen\n");
531    }
532    #[test]
533    fn non_utf8_filename_opens_and_roundtrips() {
534        // 0021 §3: the filesystem is not UTF-8 — a weird name must open,
535        // save, and keep its identity
536        use std::os::unix::ffi::OsStrExt;
537        let dir = tempfile::tempdir().unwrap();
538        let weird = dir
539            .path()
540            .join(std::ffi::OsStr::from_bytes(b"weird-\xff.rs"));
541        std::fs::write(&weird, "fn main() {}\n").unwrap();
542        let mut b = Buffer::open(&weird).unwrap();
543        assert_eq!(b.path.as_deref(), Some(weird.as_path()));
544        b.insert(0, "// x\n");
545        b.save(false).unwrap();
546        assert!(std::fs::read_to_string(&weird).unwrap().starts_with("// x"));
547    }
548}