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