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::{SavePlan, 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/// Why a buffer refuses edits (0056 AR14): the typed owner `:explain`
21/// renders, recorded at the site that actually imposed the policy — never
22/// a generic hint. `None` alongside `readonly` is reserved for tests that
23/// poke the mutation guard directly.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ReadonlyReason {
27    /// The filesystem reports the file not writable.
28    Filesystem,
29    /// `:set ro` or `:view`.
30    Command,
31    /// A remote snapshot without write authority (`:remote edit` grants it).
32    RemoteAuthority,
33    /// Container bytes have no local write path (0037 DC1b).
34    Container,
35    /// A git memory surface — content derived from history.
36    GitSurface,
37    /// Transient named output (help, `:!`, the undo browser, `:explain`).
38    Output,
39    /// A directory listing without an editable filename draft.
40    DirectoryListing,
41    /// A directory operation is applying or reloading.
42    DirectoryOperation,
43    /// A stale collection whose projection failed.
44    CollectionProjection,
45    /// The recovery checkpoint surface.
46    RecoveryCheckpoint,
47    /// Navigation landed outside the workspace root.
48    OutsideWorkspace,
49}
50
51/// A text buffer. Positions are UTF-8 byte offsets, everywhere (0001 §5.1).
52pub struct Buffer {
53    pub(crate) trace_identity: BufferTraceId,
54    rope: Rope,
55    /// Filesystem identity (0021 §3: Unix filenames aren't UTF-8 — a
56    /// String path makes the filesystem model a UI model). Display via
57    /// to_string_lossy at the edge only.
58    pub path: Option<std::path::PathBuf>,
59    pub dirty: bool,
60    /// Monotonic edit counter; async readers (git gutter) diff lazily.
61    epoch: u64,
62    /// Read-only views (git surfaces): motions/yank work, edits refuse.
63    pub readonly: bool,
64    /// The typed owner of the readonly policy (0056 AR14).
65    pub readonly_reason: Option<ReadonlyReason>,
66    /// Display name for virtual buffers (statusline shows "[scratch]"
67    /// otherwise): "git log", "commit 1a2b3c", …
68    pub name: Option<String>,
69    /// Undo history (helix-style revision tree). Readonly buffers never
70    /// record (their content is owned by jobs, not the user).
71    history: History,
72    changes: Vec<Change>,
73    /// Disk mtime at load/last save — overwrite protection for `:w`.
74    disk_stamp: Option<std::time::SystemTime>,
75    file_identity: Option<std::path::PathBuf>,
76    line_layouts: layout_cache::LineLayouts,
77}
78
79impl Buffer {
80    /// Impose readonly policy with its typed reason. Re-imposing while a
81    /// reason stands keeps the original owner — a later generic open must
82    /// not erase a more specific source.
83    pub fn set_readonly(&mut self, reason: ReadonlyReason) {
84        if !self.readonly {
85            self.readonly_reason = Some(reason);
86        }
87        self.readonly = true;
88    }
89
90    /// Explicit write authority (`:set noro`, a granted `:remote edit`, a
91    /// successful collection projection) clears the flag and its reason.
92    pub fn clear_readonly(&mut self) {
93        self.readonly = false;
94        self.readonly_reason = None;
95    }
96
97    pub fn text(&self) -> &Rope {
98        &self.rope
99    }
100    pub fn snapshot(&self) -> Rope {
101        self.rope.clone()
102    }
103
104    /// Diagnostic head excerpt: copies at most [`strop_trace::MAX_EXCERPT_BYTES`]
105    /// instead of materializing the whole buffer, and reports whether the
106    /// text was cut.
107    pub fn text_excerpt(&self) -> (String, bool) {
108        let rope = &self.rope;
109        if rope.len_bytes() <= strop_trace::MAX_EXCERPT_BYTES {
110            return (rope.to_string(), false);
111        }
112        let mut end = strop_trace::MAX_EXCERPT_BYTES;
113        while end > 0 && (rope.byte(end - 1) & 0xC0) == 0x80 {
114            end -= 1;
115        }
116        (
117            rope.get_byte_slice(..end)
118                .map_or_else(String::new, |head| head.to_string()),
119            true,
120        )
121    }
122    pub fn history(&self) -> &History {
123        &self.history
124    }
125    pub fn revision(&self) -> id::BufferRevision {
126        id::BufferRevision::new(self.epoch)
127    }
128    pub fn file_identity(&self) -> Option<&std::path::Path> {
129        self.file_identity.as_deref()
130    }
131
132    /// The observed on-disk mtime baseline (0021 §3): reload guards and
133    /// the save-time external-change check compare against it.
134    pub fn disk_stamp(&self) -> Option<std::time::SystemTime> {
135        self.disk_stamp
136    }
137    pub fn restore_history(
138        &mut self,
139        history: History,
140    ) -> Result<(), crate::history::HistoryError> {
141        history.validate_for(&self.rope)?;
142        self.adopt_history(history);
143        Ok(())
144    }
145
146    pub fn from_text(text: &str) -> Self {
147        Self::from_snapshot(Rope::from_str(text))
148    }
149
150    /// A cheap independent reader over immutable rope structure. No text copy,
151    /// disk identity, or history is inherited from the publishing document.
152    pub fn from_snapshot(rope: Rope) -> Self {
153        Self {
154            trace_identity: BufferTraceId::next(),
155            rope,
156            path: None,
157            dirty: false,
158            readonly_reason: None,
159            epoch: 0,
160            readonly: false,
161            name: None,
162            history: History::default(),
163            changes: Vec::new(),
164            disk_stamp: None,
165            file_identity: None,
166            line_layouts: layout_cache::LineLayouts::default(),
167        }
168    }
169
170    /// Open a file; a missing file is a new empty buffer with that path
171    /// (vim semantics — `:w` creates it). Real I/O errors still error.
172    /// A file whose permissions report not writable opens readonly with
173    /// that typed reason (0056 AR14) — `:set noro` / `:w!` stay explicit.
174    pub fn open(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
175        let path = path.as_ref();
176        let (rope, disk_stamp, writable) = match std::fs::File::open(path) {
177            Ok(file) => {
178                let metadata = file.metadata()?;
179                let stamp = metadata.modified()?;
180                (
181                    Rope::from_reader(file)?,
182                    Some(stamp),
183                    !metadata.permissions().readonly(),
184                )
185            }
186            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (Rope::new(), None, true),
187            Err(e) => return Err(e),
188        };
189        let mut buffer = Self {
190            trace_identity: BufferTraceId::next(),
191            rope,
192            path: Some(path.to_path_buf()),
193            dirty: false,
194            epoch: 0,
195            readonly: false,
196            readonly_reason: None,
197            name: None,
198            history: History::default(),
199            changes: Vec::new(),
200            disk_stamp,
201            file_identity: Some(std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())),
202            line_layouts: layout_cache::LineLayouts::default(),
203        };
204        if !writable {
205            buffer.set_readonly(ReadonlyReason::Filesystem);
206        }
207        Ok(buffer)
208    }
209
210    /// Adopt content an admitted reader (0058 WK04: the local worker)
211    /// returned for `path`, together with the observation evidence it
212    /// reported. Every field here is evidence from that read: the rope is
213    /// the payload, `disk_stamp` the observed mtime, `canonical` the
214    /// namespace's identity path and `writable` the observed permission
215    /// fact — nothing is probed or guessed in-process. Missing files use
216    /// `disk_stamp: None` with an empty rope (vim new-file semantics,
217    /// matching [`Buffer::open`]); their callers pass `writable: true`
218    /// since the absent file carries no permissions.
219    pub fn from_read(
220        path: std::path::PathBuf,
221        rope: Rope,
222        disk_stamp: Option<std::time::SystemTime>,
223        canonical: std::path::PathBuf,
224        writable: bool,
225    ) -> Self {
226        let mut buffer = Self {
227            trace_identity: BufferTraceId::next(),
228            rope,
229            path: Some(path),
230            dirty: false,
231            epoch: 0,
232            readonly: false,
233            readonly_reason: None,
234            name: None,
235            history: History::default(),
236            changes: Vec::new(),
237            disk_stamp,
238            file_identity: Some(canonical),
239            line_layouts: layout_cache::LineLayouts::default(),
240        };
241        if !writable {
242            buffer.set_readonly(ReadonlyReason::Filesystem);
243        }
244        buffer
245    }
246
247    /// Display CELL of an offset within its line (0017/R6): cursor placement
248    /// and overlays need terminal cells, not byte columns — wide chars and
249    /// tabs make the difference. Streams through the containing cluster only:
250    /// no whole-line String, no layout vector, no u16 saturation.
251    pub fn cell_col_with_tab(
252        &self,
253        offset: impl Into<id::ByteOffset>,
254        tab: usize,
255    ) -> id::DisplayColumn {
256        match self.column_from_layout(offset.into().get(), tab, false) {
257            Some(column) => column,
258            None => unreachable!("an unbounded layout projection always completes"),
259        }
260    }
261
262    pub fn len_bytes(&self) -> usize {
263        self.rope.len_bytes()
264    }
265    pub fn len_lines(&self) -> usize {
266        self.rope.len_lines()
267    }
268
269    /// Last *content* line index — a trailing newline's phantom empty
270    /// line doesn't count (vim's G lands on real text).
271    pub fn last_content_line(&self) -> usize {
272        let mut l = self.len_lines().saturating_sub(1);
273        if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
274            l -= 1;
275        }
276        l
277    }
278
279    /// Byte offset of the first char of `line` (0-indexed).
280    pub fn line_start(&self, line: impl Into<id::LineIndex>) -> usize {
281        self.rope
282            .line_to_byte(line.into().get().min(self.len_lines().saturating_sub(1)))
283    }
284
285    /// Byte offset one past the last content char (excludes LF or CRLF).
286    pub fn line_end(&self, line: impl Into<id::LineIndex>) -> usize {
287        let line = line.into().get();
288        let start = self.line_start(line);
289        let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
290        if line + 1 >= self.len_lines() {
291            end = self.len_bytes();
292        }
293        // strip the trailing newline
294        if end > start && self.byte(end - 1) == b'\n' {
295            end -= 1;
296            if end > start && self.byte(end - 1) == b'\r' {
297                end -= 1;
298            }
299        }
300        end
301    }
302
303    pub fn line_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
304        self.rope
305            .byte_to_line(offset.into().get().min(self.len_bytes()))
306    }
307
308    /// Column (in bytes) of `offset` within its line.
309    pub fn col_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
310        let offset = offset.into();
311        offset.get() - self.line_start(self.line_of(offset))
312    }
313    /// Byte at a position. An empty rope reads as NUL: every classifier
314    /// treats NUL as a boundary, and the alternative (a panic) is how
315    /// the second review found this (0015). `byte_at` when absence
316    /// itself matters.
317    pub fn byte(&self, offset: impl Into<id::ByteOffset>) -> u8 {
318        if self.len_bytes() == 0 {
319            return 0;
320        }
321        self.rope
322            .byte(offset.into().get().min(self.len_bytes().saturating_sub(1)))
323    }
324
325    pub fn byte_at(&self, offset: impl Into<id::ByteOffset>) -> Option<u8> {
326        let off = offset.into().get();
327        if off < self.len_bytes() {
328            Some(self.rope.byte(off))
329        } else {
330            None
331        }
332    }
333
334    /// Is `offset` a UTF-8 char boundary? ropey's `try_byte_to_char`
335    /// maps a mid-char byte to its containing char without complaint —
336    /// only the byte↔char roundtrip actually detects boundaries. (The
337    /// pre-0.3.9 clamp trusted it and never clamped anything.)
338    pub fn is_boundary(&self, offset: impl Into<id::ByteOffset>) -> bool {
339        let off = offset.into().get();
340        if off == 0 || off == self.len_bytes() {
341            return true;
342        }
343        if off > self.len_bytes() {
344            return false;
345        }
346        match self.rope.try_byte_to_char(off) {
347            Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == off),
348            Err(_) => false,
349        }
350    }
351
352    /// Clamp a byte offset down to a char boundary (the grapheme policy
353    /// in 0001 §5.9 hardens this further when text goes wide).
354    pub fn clamp_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
355        let mut offset = offset.into().get().min(self.len_bytes());
356        while offset > 0 && !self.is_boundary(offset) {
357            offset -= 1;
358        }
359        offset
360    }
361
362    /// Smallest char boundary >= offset. Byte arithmetic on a cursor
363    /// (`cursor + 1` in x/a/r/~) lands inside a multibyte char; deleting
364    /// or inserting there panics ropey. Round up, never down — a
365    /// deletion that rounds down eats the previous char's tail.
366    pub fn ceil_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
367        let mut offset = offset.into().get().min(self.len_bytes());
368        while offset < self.len_bytes() && !self.is_boundary(offset) {
369            offset += 1;
370        }
371        offset
372    }
373
374    /// Slice as String — for register/paste paths, never for per-frame render.
375    /// Stale ranges clamp (fuzz-driven cascades hand these around).
376    pub fn slice_string(&self, range: Range) -> String {
377        let start = self.clamp_boundary(range.start);
378        let end = self.clamp_boundary(range.end);
379        self.rope.byte_slice(start..end.max(start)).to_string()
380    }
381
382    pub fn line_text(&self, line: impl Into<id::LineIndex>) -> String {
383        let line = line.into().get();
384        let start = self.line_start(line);
385        let end = self.line_end(line);
386        self.rope.byte_slice(start..end).to_string()
387    }
388}
389
390/// Pre-edit and post-edit geometry recorded at the instant text changes.
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub struct InputEdit {
393    pub start_byte: usize,
394    pub old_end_byte: usize,
395    pub new_end_byte: usize,
396    pub start_point: (usize, usize),
397    pub old_end_point: (usize, usize),
398    pub new_end_point: (usize, usize),
399}
400
401impl Buffer {
402    /// (line, col) of a byte offset, as tree-sitter Points.
403    pub fn point_of(&self, offset: usize) -> (usize, usize) {
404        let offset = offset.min(self.len_bytes());
405        (self.line_of(offset), self.col_of(offset))
406    }
407
408    /// The (line, col) extent of a text fragment.
409    fn point_extent(text: &str) -> (usize, usize) {
410        let lines = text.bytes().filter(|b| *b == b'\n').count();
411        let col = if lines == 0 {
412            text.len()
413        } else {
414            text.rsplit('\n').next().map(str::len).unwrap_or(0)
415        };
416        (lines, col)
417    }
418}
419
420#[cfg(test)]
421mod safety_tests {
422    use super::*;
423
424    #[test]
425    fn save_refuses_external_change_unless_forced() {
426        let dir = tempfile::tempdir().unwrap();
427        let f = dir.path().join("f.txt");
428        std::fs::write(&f, "original\n").unwrap();
429        let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
430        b.edit().insert(id::ByteOffset::new(0), "mine ").unwrap();
431        // another process touches the file
432        std::fs::write(&f, "theirs\n").unwrap();
433        std::fs::File::options()
434            .write(true)
435            .open(&f)
436            .unwrap()
437            .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(123))
438            .unwrap();
439        let err = b.prepare_save(None, false).unwrap().execute().unwrap_err();
440        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
441        assert_eq!(std::fs::read_to_string(&f).unwrap(), "theirs\n");
442        let receipt = b.prepare_save(None, true).unwrap().execute().unwrap();
443        assert!(b.accept_save(receipt));
444        assert_eq!(std::fs::read_to_string(&f).unwrap(), "mine original\n");
445        assert!(!b.dirty);
446    }
447
448    #[test]
449    fn save_is_atomic_and_keeps_permissions() {
450        use std::os::unix::fs::PermissionsExt;
451        let dir = tempfile::tempdir().unwrap();
452        let f = dir.path().join("x.sh");
453        std::fs::write(&f, "#!/bin/sh\n").unwrap();
454        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o750)).unwrap();
455        let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
456        let end = b.len_bytes();
457        b.edit()
458            .insert(id::ByteOffset::new(end), "echo hi\n")
459            .unwrap();
460        let receipt = b.prepare_save(None, false).unwrap().execute().unwrap();
461        assert!(b.accept_save(receipt));
462        assert_eq!(std::fs::read_to_string(&f).unwrap(), "#!/bin/sh\necho hi\n");
463        let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
464        assert_eq!(mode, 0o750, "permissions survive the swap");
465        // no temp litter
466        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
467    }
468
469    #[test]
470    fn readonly_refuses_mutation_at_the_boundary() {
471        // 0014: the guard lives in Buffer, not in every caller's memory
472        let mut b = Buffer::from_text("abc\n");
473        b.readonly = true;
474        assert_eq!(b.edit().insert(0, "nope"), Err(EditError::ReadOnly));
475        assert_eq!(
476            b.edit().delete(Range::charwise(0, 2)),
477            Err(EditError::ReadOnly)
478        );
479        assert_eq!(b.rope.to_string(), "abc\n", "untouched");
480        // the owner path still works (job-generated surfaces)
481        b.system_edit().replace_all("gen\n").unwrap();
482        assert_eq!(b.rope.to_string(), "gen\n");
483    }
484    #[test]
485    fn non_utf8_filename_opens_and_roundtrips() {
486        // 0021 §3: the filesystem is not UTF-8 — a weird name must open,
487        // save, and keep its identity
488        use std::os::unix::ffi::OsStrExt;
489        let dir = tempfile::tempdir().unwrap();
490        let weird = dir
491            .path()
492            .join(std::ffi::OsStr::from_bytes(b"weird-\xff.rs"));
493        std::fs::write(&weird, "fn main() {}\n").unwrap();
494        let mut b = Buffer::open(&weird).unwrap();
495        assert_eq!(b.path.as_deref(), Some(weird.as_path()));
496        b.edit().insert(0, "// x\n").unwrap();
497        let receipt = b.prepare_save(None, false).unwrap().execute().unwrap();
498        assert!(b.accept_save(receipt));
499        assert_eq!(
500            std::fs::read_to_string(&weird).unwrap(),
501            "// x\nfn main() {}\n"
502        );
503    }
504}