Skip to main content

strop_core/
lib.rs

1//! strop-core: the buffer. A rope, byte-offset positions, edit ops.
2//! No UI, no modes, no grammar — the thing everything else edits.
3
4pub mod history;
5
6use history::{Edit, EditKind, History};
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    pub path: Option<String>,
13    pub dirty: bool,
14    /// Monotonic edit counter; async readers (git gutter) diff lazily.
15    pub epoch: u64,
16    /// Read-only views (git surfaces): motions/yank work, edits refuse.
17    pub readonly: bool,
18    /// Display name for virtual buffers (statusline shows "[scratch]"
19    /// otherwise): "git log", "commit 1a2b3c", …
20    pub name: Option<String>,
21    /// Undo history (helix-style revision tree). Readonly buffers never
22    /// record (their content is owned by jobs, not the user).
23    pub history: History,
24    /// Suppresses recording while applying undo/redo ops.
25    pub replaying: bool,
26    /// Disk mtime at load/last save — overwrite protection for `:w`.
27    disk_stamp: Option<std::time::SystemTime>,
28}
29
30/// A half-open byte range `[start, end)` plus how vim thinks about it.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Range {
33    pub start: usize,
34    pub end: usize,
35    /// Inclusive (charwise) ranges include the char at `end - 1`'s semantic
36    /// target already — the flag exists for the spec footer and linewise ops.
37    pub linewise: bool,
38}
39
40impl Range {
41    pub fn charwise(start: usize, end: usize) -> Self {
42        debug_assert!(start <= end);
43        Self {
44            start,
45            end,
46            linewise: false,
47        }
48    }
49    pub fn linewise(start: usize, end: usize) -> Self {
50        debug_assert!(start <= end);
51        Self {
52            start,
53            end,
54            linewise: true,
55        }
56    }
57    pub fn len(&self) -> usize {
58        self.end - self.start
59    }
60    pub fn is_empty(&self) -> bool {
61        self.start == self.end
62    }
63}
64
65impl Buffer {
66    pub fn from_text(text: &str) -> Self {
67        Self {
68            rope: Rope::from_str(text),
69            path: None,
70            dirty: false,
71            epoch: 0,
72            readonly: false,
73            name: None,
74            history: History::default(),
75            replaying: false,
76            disk_stamp: None,
77        }
78    }
79
80    /// Open a file; a missing file is a new empty buffer with that path
81    /// (vim semantics — `:w` creates it). Real I/O errors still error.
82    pub fn open(path: &str) -> std::io::Result<Self> {
83        let text = match std::fs::read_to_string(path) {
84            Ok(t) => t,
85            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
86            Err(e) => return Err(e),
87        };
88        let disk_stamp = std::fs::metadata(path).and_then(|m| m.modified()).ok();
89        Ok(Self {
90            rope: Rope::from_str(&text),
91            path: Some(path.to_string()),
92            dirty: false,
93            epoch: 0,
94            readonly: false,
95            name: None,
96            history: History::default(),
97            replaying: false,
98            disk_stamp,
99        })
100    }
101
102    /// `:w` — atomic (temp + rename in the same dir), refuses to
103    /// overwrite a file another process touched since we loaded it.
104    /// `force` is `:w!`.
105    pub fn save(&mut self, force: bool) -> std::io::Result<()> {
106        let Some(path) = self.path.clone() else {
107            return Ok(());
108        };
109        let current = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
110        if !force && current.is_some() && current != self.disk_stamp {
111            return Err(std::io::Error::new(
112                std::io::ErrorKind::PermissionDenied,
113                "file changed on disk — :w! to force",
114            ));
115        }
116        let target = std::path::Path::new(&path);
117        let tmp = target.with_file_name(format!(
118            ".strop-tmp-{}-{}",
119            std::process::id(),
120            target.file_name().and_then(|n| n.to_str()).unwrap_or("x")
121        ));
122        std::fs::write(&tmp, self.rope.to_string())?;
123        if let Ok(meta) = std::fs::metadata(target) {
124            // keep the file's permissions across the atomic swap
125            let _ = std::fs::set_permissions(&tmp, meta.permissions());
126        }
127        std::fs::rename(&tmp, target)?;
128        self.disk_stamp = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
129        self.dirty = false;
130        Ok(())
131    }
132    pub fn len_bytes(&self) -> usize {
133        self.rope.len_bytes()
134    }
135    pub fn len_lines(&self) -> usize {
136        self.rope.len_lines()
137    }
138
139    /// Last *content* line index — a trailing newline's phantom empty
140    /// line doesn't count (vim's G lands on real text).
141    pub fn last_content_line(&self) -> usize {
142        let mut l = self.len_lines().saturating_sub(1);
143        if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
144            l -= 1;
145        }
146        l
147    }
148
149    /// Byte offset of the first char of `line` (0-indexed).
150    pub fn line_start(&self, line: usize) -> usize {
151        self.rope
152            .line_to_byte(line.min(self.len_lines().saturating_sub(1)))
153    }
154
155    /// Byte offset one past the last content char of `line` (excludes `\n`).
156    pub fn line_end(&self, line: usize) -> usize {
157        let start = self.line_start(line);
158        let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
159        if line + 1 >= self.len_lines() {
160            end = self.len_bytes();
161        }
162        // strip the trailing newline
163        if end > start && self.byte(end - 1) == b'\n' {
164            end -= 1;
165        }
166        end
167    }
168
169    pub fn line_of(&self, offset: usize) -> usize {
170        self.rope.byte_to_line(offset.min(self.len_bytes()))
171    }
172
173    /// Column (in bytes) of `offset` within its line.
174    pub fn col_of(&self, offset: usize) -> usize {
175        offset - self.line_start(self.line_of(offset))
176    }
177
178    pub fn byte(&self, offset: usize) -> u8 {
179        self.rope
180            .byte(offset.min(self.len_bytes().saturating_sub(1)))
181    }
182
183    pub fn byte_at(&self, offset: usize) -> Option<u8> {
184        if offset < self.len_bytes() {
185            Some(self.rope.byte(offset))
186        } else {
187            None
188        }
189    }
190
191    /// Is `offset` a UTF-8 char boundary? ropey's `try_byte_to_char`
192    /// maps a mid-char byte to its containing char without complaint —
193    /// only the byte↔char roundtrip actually detects boundaries. (The
194    /// pre-0.3.9 clamp trusted it and never clamped anything.)
195    pub fn is_boundary(&self, offset: usize) -> bool {
196        if offset == 0 || offset == self.len_bytes() {
197            return true;
198        }
199        if offset > self.len_bytes() {
200            return false;
201        }
202        match self.rope.try_byte_to_char(offset) {
203            Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == offset),
204            Err(_) => false,
205        }
206    }
207
208    /// Clamp a byte offset down to a char boundary (the grapheme policy
209    /// in 0001 §5.9 hardens this further when text goes wide).
210    pub fn clamp_boundary(&self, mut offset: usize) -> usize {
211        offset = offset.min(self.len_bytes());
212        while offset > 0 && !self.is_boundary(offset) {
213            offset -= 1;
214        }
215        offset
216    }
217
218    /// Smallest char boundary >= offset. Byte arithmetic on a cursor
219    /// (`cursor + 1` in x/a/r/~) lands inside a multibyte char; deleting
220    /// or inserting there panics ropey. Round up, never down — a
221    /// deletion that rounds down eats the previous char's tail.
222    pub fn ceil_boundary(&self, mut offset: usize) -> usize {
223        offset = offset.min(self.len_bytes());
224        while offset < self.len_bytes() && !self.is_boundary(offset) {
225            offset += 1;
226        }
227        offset
228    }
229
230    /// Slice as String — for register/paste paths, never for per-frame render.
231    /// Stale ranges clamp (fuzz-driven cascades hand these around).
232    pub fn slice_string(&self, range: Range) -> String {
233        let start = range.start.min(self.len_bytes());
234        let end = range.end.min(self.len_bytes());
235        self.rope.byte_slice(start..end.max(start)).to_string()
236    }
237
238    /// Apply history edits (undo/redo replay — never recorded).
239    pub fn apply_history(&mut self, ops: Vec<Edit>) {
240        self.replaying = true;
241        for op in ops {
242            match op.kind {
243                EditKind::Insert => {
244                    let at = self.clamp_boundary(op.at.min(self.len_bytes()));
245                    self.rope.insert(self.rope.byte_to_char(at), &op.text);
246                }
247                EditKind::Delete => {
248                    // both bounds must land on char boundaries — a stale
249                    // replay against drifted text panics ropey otherwise
250                    let end = self.clamp_boundary((op.at + op.text.len()).min(self.len_bytes()));
251                    let start = self.clamp_boundary(op.at.min(end));
252                    if start < end {
253                        self.rope
254                            .remove(self.rope.byte_to_char(start)..self.rope.byte_to_char(end));
255                    }
256                }
257            }
258        }
259        self.replaying = false;
260        self.dirty = true;
261        self.epoch += 1;
262    }
263
264    /// Replace the whole contents (user-facing path). Refuses on
265    /// readonly buffers — the owning subsystem uses
266    /// `replace_all_system`.
267    pub fn replace_all(&mut self, text: &str) {
268        if self.readonly {
269            return;
270        }
271        self.replace_all_system(text);
272    }
273
274    /// The privileged replace for generated surfaces: their content is
275    /// owned by jobs (git/LSP/shell), refreshed under the user's feet —
276    /// the readonly guard is about *user* edits, not the owner.
277    pub fn replace_all_system(&mut self, text: &str) {
278        self.rope = Rope::from_str(text);
279        self.epoch += 1;
280    }
281
282    /// Returns the deleted text (register payoff). Refuses on readonly
283    /// buffers: the input layer checks first, but the mutation boundary
284    /// enforces — no caller-remembered guard (0014).
285    pub fn delete(&mut self, range: Range) -> String {
286        if self.readonly && !self.replaying {
287            return String::new();
288        }
289        // stale ranges (fuzz-driven cascades, replay drift) clamp, not panic
290        let start = self.clamp_boundary(range.start.min(self.len_bytes()));
291        let end = self.clamp_boundary(range.end.min(self.len_bytes()));
292        if start >= end {
293            return String::new();
294        }
295        let text = self.rope.byte_slice(start..end).to_string();
296        // ropey mutates by CHAR index; our offsets are bytes
297        let cstart = self.rope.byte_to_char(start);
298        let cend = self.rope.byte_to_char(end);
299        self.rope.remove(cstart..cend);
300        self.dirty = true;
301        self.epoch += 1;
302        if !self.replaying && !self.readonly {
303            self.history.record(
304                Edit {
305                    at: range.start,
306                    text: text.clone(),
307                    kind: EditKind::Insert,
308                },
309                Edit {
310                    at: range.start,
311                    text: text.clone(),
312                    kind: EditKind::Delete,
313                },
314            );
315        }
316        text
317    }
318
319    pub fn insert(&mut self, at: usize, text: &str) {
320        if self.readonly && !self.replaying {
321            return;
322        }
323        self.rope.insert(self.rope.byte_to_char(at), text);
324        self.dirty = true;
325        self.epoch += 1;
326        if !self.replaying && !self.readonly {
327            self.history.record(
328                Edit {
329                    at,
330                    text: text.into(),
331                    kind: EditKind::Delete,
332                },
333                Edit {
334                    at,
335                    text: text.into(),
336                    kind: EditKind::Insert,
337                },
338            );
339        }
340    }
341
342    pub fn line_text(&self, line: usize) -> String {
343        let start = self.line_start(line);
344        let end = self.line_end(line);
345        self.rope.byte_slice(start..end).to_string()
346    }
347}
348
349#[cfg(test)]
350mod safety_tests {
351    use super::*;
352
353    #[test]
354    fn save_refuses_external_change_unless_forced() {
355        let dir = tempfile::tempdir().unwrap();
356        let f = dir.path().join("f.txt");
357        std::fs::write(&f, "original\n").unwrap();
358        let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
359        b.insert(0, "mine ");
360        // another process touches the file
361        std::thread::sleep(std::time::Duration::from_millis(5));
362        std::fs::write(&f, "theirs\n").unwrap();
363        let err = b.save(false).unwrap_err();
364        assert!(err.to_string().contains("changed on disk"));
365        assert_eq!(std::fs::read_to_string(&f).unwrap(), "theirs\n");
366        b.save(true).unwrap(); // :w!
367        assert_eq!(std::fs::read_to_string(&f).unwrap(), "mine original\n");
368        assert!(!b.dirty);
369    }
370
371    #[test]
372    fn save_is_atomic_and_keeps_permissions() {
373        use std::os::unix::fs::PermissionsExt;
374        let dir = tempfile::tempdir().unwrap();
375        let f = dir.path().join("x.sh");
376        std::fs::write(&f, "#!/bin/sh\n").unwrap();
377        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o750)).unwrap();
378        let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
379        b.insert(b.len_bytes(), "echo hi\n");
380        b.save(false).unwrap();
381        assert_eq!(std::fs::read_to_string(&f).unwrap(), "#!/bin/sh\necho hi\n");
382        let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
383        assert_eq!(mode, 0o750, "permissions survive the swap");
384        // no temp litter
385        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
386    }
387
388    #[test]
389    fn readonly_refuses_mutation_at_the_boundary() {
390        // 0014: the guard lives in Buffer, not in every caller's memory
391        let mut b = Buffer::from_text("abc\n");
392        b.readonly = true;
393        b.insert(0, "nope");
394        let gone = b.delete(Range::charwise(0, 2));
395        assert_eq!(gone, "");
396        assert_eq!(b.rope.to_string(), "abc\n", "untouched");
397        // the owner path still works (job-generated surfaces)
398        b.replace_all_system("gen\n");
399        assert_eq!(b.rope.to_string(), "gen\n");
400    }
401}