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