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