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