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