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        // write THROUGH links (0023: replacing a symlink with a regular
146        // file silently breaks the link — vim preserves it)
147        let path = std::fs::canonicalize(&path).unwrap_or(path);
148        let current = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
149        if !force && current.is_some() && current != self.disk_stamp {
150            return Err(std::io::Error::new(
151                std::io::ErrorKind::PermissionDenied,
152                "file changed on disk — :w! to force",
153            ));
154        }
155        write_atomic(std::path::Path::new(&path), &self.rope.to_string())?;
156        self.disk_stamp = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
157        self.dirty = false;
158        Ok(())
159    }
160
161    /// `:w {path}` — persist under a new name and adopt it (the buffer
162    /// becomes that file). The identity changes only after a SUCCESSFUL
163    /// write (0020 §1): an existing target needs `force`, and a failed
164    /// write leaves path, baseline and dirty state untouched.
165    pub fn save_as(&mut self, path: &str, force: bool) -> std::io::Result<()> {
166        let target = std::path::Path::new(path);
167        if !force && target.exists() {
168            return Err(std::io::Error::new(
169                std::io::ErrorKind::PermissionDenied,
170                "file exists — :w! to overwrite",
171            ));
172        }
173        write_atomic(target, &self.rope.to_string())?;
174        // success: adopt the identity
175        self.path = Some(std::path::PathBuf::from(path));
176        self.disk_stamp = std::fs::metadata(target).and_then(|m| m.modified()).ok();
177        self.dirty = false;
178        Ok(())
179    }
180    /// Display CELL of an offset within its line (0017): cursor
181    /// placement and overlays need terminal cells, not byte cols —
182    /// wide chars and tabs make the difference. The LineLayout is the
183    /// single translation seam.
184    pub fn cell_col_of(&self, offset: impl Into<id::ByteOffset>) -> u16 {
185        self.cell_col_with_tab(offset, 8)
186    }
187
188    /// The cell col under a caller's tab stop (0023: the caret and the
189    /// tab glyph must read the same width — render config drives both).
190    pub fn cell_col_with_tab(&self, offset: impl Into<id::ByteOffset>, tab: u16) -> u16 {
191        let offset = offset.into().get();
192        if self.len_bytes() == 0 {
193            return 0;
194        }
195        let line = self.line_of(offset);
196        let (s, e) = (self.line_start(line), self.line_end(line));
197        let text = self.rope.byte_slice(s..e).to_string();
198        let col = offset.saturating_sub(s);
199        let layout = layout::LineLayout::build(text.trim_end_matches('\n'), tab.max(1));
200        layout.cell_at_byte(col.min(layout.len_bytes))
201    }
202
203    pub fn len_bytes(&self) -> usize {
204        self.rope.len_bytes()
205    }
206    pub fn len_lines(&self) -> usize {
207        self.rope.len_lines()
208    }
209
210    /// Last *content* line index — a trailing newline's phantom empty
211    /// line doesn't count (vim's G lands on real text).
212    pub fn last_content_line(&self) -> usize {
213        let mut l = self.len_lines().saturating_sub(1);
214        if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
215            l -= 1;
216        }
217        l
218    }
219
220    /// Byte offset of the first char of `line` (0-indexed).
221    pub fn line_start(&self, line: impl Into<id::LineIndex>) -> usize {
222        self.rope
223            .line_to_byte(line.into().get().min(self.len_lines().saturating_sub(1)))
224    }
225
226    /// Byte offset one past the last content char of `line` (excludes `\n`).
227    pub fn line_end(&self, line: impl Into<id::LineIndex>) -> usize {
228        let line = line.into().get();
229        let start = self.line_start(line);
230        let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
231        if line + 1 >= self.len_lines() {
232            end = self.len_bytes();
233        }
234        // strip the trailing newline
235        if end > start && self.byte(end - 1) == b'\n' {
236            end -= 1;
237        }
238        end
239    }
240
241    pub fn line_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
242        self.rope
243            .byte_to_line(offset.into().get().min(self.len_bytes()))
244    }
245
246    /// Column (in bytes) of `offset` within its line.
247    pub fn col_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
248        let offset = offset.into();
249        offset.get() - self.line_start(self.line_of(offset))
250    }
251    /// Byte at a position. An empty rope reads as NUL: every classifier
252    /// treats NUL as a boundary, and the alternative (a panic) is how
253    /// the second review found this (0015). `byte_at` when absence
254    /// itself matters.
255    pub fn byte(&self, offset: impl Into<id::ByteOffset>) -> u8 {
256        if self.len_bytes() == 0 {
257            return 0;
258        }
259        self.rope
260            .byte(offset.into().get().min(self.len_bytes().saturating_sub(1)))
261    }
262
263    pub fn byte_at(&self, offset: impl Into<id::ByteOffset>) -> Option<u8> {
264        let off = offset.into().get();
265        if off < self.len_bytes() {
266            Some(self.rope.byte(off))
267        } else {
268            None
269        }
270    }
271
272    /// Is `offset` a UTF-8 char boundary? ropey's `try_byte_to_char`
273    /// maps a mid-char byte to its containing char without complaint —
274    /// only the byte↔char roundtrip actually detects boundaries. (The
275    /// pre-0.3.9 clamp trusted it and never clamped anything.)
276    pub fn is_boundary(&self, offset: impl Into<id::ByteOffset>) -> bool {
277        let off = offset.into().get();
278        if off == 0 || off == self.len_bytes() {
279            return true;
280        }
281        if off > self.len_bytes() {
282            return false;
283        }
284        match self.rope.try_byte_to_char(off) {
285            Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == off),
286            Err(_) => false,
287        }
288    }
289
290    /// Clamp a byte offset down to a char boundary (the grapheme policy
291    /// in 0001 §5.9 hardens this further when text goes wide).
292    pub fn clamp_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
293        let mut offset = offset.into().get().min(self.len_bytes());
294        while offset > 0 && !self.is_boundary(offset) {
295            offset -= 1;
296        }
297        offset
298    }
299
300    /// Smallest char boundary >= offset. Byte arithmetic on a cursor
301    /// (`cursor + 1` in x/a/r/~) lands inside a multibyte char; deleting
302    /// or inserting there panics ropey. Round up, never down — a
303    /// deletion that rounds down eats the previous char's tail.
304    pub fn ceil_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
305        let mut offset = offset.into().get().min(self.len_bytes());
306        while offset < self.len_bytes() && !self.is_boundary(offset) {
307            offset += 1;
308        }
309        offset
310    }
311
312    /// Slice as String — for register/paste paths, never for per-frame render.
313    /// Stale ranges clamp (fuzz-driven cascades hand these around).
314    pub fn slice_string(&self, range: Range) -> String {
315        let start = range.start.min(self.len_bytes());
316        let end = range.end.min(self.len_bytes());
317        self.rope.byte_slice(start..end.max(start)).to_string()
318    }
319
320    /// Apply history edits (undo/redo replay — never recorded).
321    pub fn apply_history(&mut self, ops: Vec<Edit>) {
322        self.replaying = true;
323        for op in ops {
324            match op.kind {
325                EditKind::Insert => {
326                    let at = self.clamp_boundary(op.at.min(self.len_bytes()));
327                    self.rope.insert(self.rope.byte_to_char(at), &op.text);
328                }
329                EditKind::Delete => {
330                    // both bounds must land on char boundaries — a stale
331                    // replay against drifted text panics ropey otherwise
332                    let end = self.clamp_boundary((op.at + op.text.len()).min(self.len_bytes()));
333                    let start = self.clamp_boundary(op.at.min(end));
334                    if start < end {
335                        self.rope
336                            .remove(self.rope.byte_to_char(start)..self.rope.byte_to_char(end));
337                    }
338                }
339            }
340        }
341        self.replaying = false;
342        self.dirty = true;
343        self.epoch += 1;
344    }
345
346    /// Replace the whole contents (user-facing path). Refuses on
347    /// readonly buffers — the owning subsystem uses
348    /// `replace_all_system`.
349    pub fn replace_all(&mut self, text: &str) {
350        if self.readonly {
351            return;
352        }
353        self.replace_all_system(text);
354    }
355
356    /// The privileged replace for generated surfaces: their content is
357    /// owned by jobs (git/LSP/shell), refreshed under the user's feet —
358    /// the readonly guard is about *user* edits, not the owner.
359    pub fn replace_all_system(&mut self, text: &str) {
360        self.rope = Rope::from_str(text);
361        self.epoch += 1;
362    }
363
364    /// Returns the deleted text (register payoff). Refuses on readonly
365    /// buffers: the input layer checks first, but the mutation boundary
366    /// enforces — no caller-remembered guard (0014).
367    pub fn delete(&mut self, range: Range) -> String {
368        if self.readonly && !self.replaying {
369            return String::new();
370        }
371        // stale ranges (fuzz-driven cascades, replay drift) clamp, not panic
372        let start = self.clamp_boundary(range.start.min(self.len_bytes()));
373        let end = self.clamp_boundary(range.end.min(self.len_bytes()));
374        if start >= end {
375            return String::new();
376        }
377        let text = self.rope.byte_slice(start..end).to_string();
378        // ropey mutates by CHAR index; our offsets are bytes
379        let cstart = self.rope.byte_to_char(start);
380        let cend = self.rope.byte_to_char(end);
381        self.rope.remove(cstart..cend);
382        self.dirty = true;
383        self.epoch += 1;
384        if !self.replaying && !self.readonly {
385            self.history.record(
386                Edit {
387                    at: range.start,
388                    text: text.clone(),
389                    kind: EditKind::Insert,
390                },
391                Edit {
392                    at: range.start,
393                    text: text.clone(),
394                    kind: EditKind::Delete,
395                },
396            );
397        }
398        text
399    }
400
401    pub fn insert(&mut self, at: impl Into<id::ByteOffset>, text: &str) {
402        if self.readonly && !self.replaying {
403            return;
404        }
405        let at = self.clamp_boundary(at);
406        self.rope.insert(self.rope.byte_to_char(at), text);
407        self.dirty = true;
408        self.epoch += 1;
409        if !self.replaying && !self.readonly {
410            self.history.record(
411                Edit {
412                    at,
413                    text: text.into(),
414                    kind: EditKind::Delete,
415                },
416                Edit {
417                    at,
418                    text: text.into(),
419                    kind: EditKind::Insert,
420                },
421            );
422        }
423    }
424
425    pub fn line_text(&self, line: impl Into<id::LineIndex>) -> String {
426        let line = line.into().get();
427        let start = self.line_start(line);
428        let end = self.line_end(line);
429        self.rope.byte_slice(start..end).to_string()
430    }
431}
432
433/// Same-directory temp + rename, preserving the target's permissions —
434/// the ONE atomic writer (0020 §8: no third copy of this logic).
435fn write_atomic(target: &std::path::Path, contents: &str) -> std::io::Result<()> {
436    let tmp = target.with_file_name(format!(
437        ".strop-tmp-{}-{}",
438        std::process::id(),
439        target.file_name().and_then(|n| n.to_str()).unwrap_or("x")
440    ));
441    std::fs::write(&tmp, contents)?;
442    if let Ok(meta) = std::fs::metadata(target) {
443        // keep the file's permissions across the atomic swap
444        let _ = std::fs::set_permissions(&tmp, meta.permissions());
445    }
446    std::fs::rename(&tmp, target)
447}
448
449/// One edit in tree-sitter's terms (0022 §1): byte range + point
450/// positions, computed from the op itself at commit time — no old text
451/// needed (the point extents derive from the op's own content).
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub struct InputEdit {
454    pub start_byte: usize,
455    pub old_end_byte: usize,
456    pub new_end_byte: usize,
457    pub start_point: (usize, usize),
458    pub old_end_point: (usize, usize),
459    pub new_end_point: (usize, usize),
460}
461
462impl Buffer {
463    /// (line, col) of a byte offset, as tree-sitter Points.
464    pub fn point_of(&self, offset: usize) -> (usize, usize) {
465        let offset = offset.min(self.len_bytes());
466        (self.line_of(offset), self.col_of(offset))
467    }
468
469    /// The (line, col) extent of a text fragment.
470    fn point_extent(text: &str) -> (usize, usize) {
471        let lines = text.bytes().filter(|b| *b == b'\n').count();
472        let col = if lines == 0 {
473            text.len()
474        } else {
475            text.rsplit('\n').next().map(str::len).unwrap_or(0)
476        };
477        (lines, col)
478    }
479
480    /// Bridge one recorded history op to tree-sitter's InputEdit.
481    /// Call against the post-edit buffer (the transaction has landed).
482    pub fn input_edit_of(&self, op: &history::Edit) -> InputEdit {
483        let start_point = self.point_of(op.at);
484        let extent = Self::point_extent(&op.text);
485        match op.kind {
486            history::EditKind::Insert => InputEdit {
487                start_byte: op.at,
488                old_end_byte: op.at,
489                new_end_byte: op.at + op.text.len(),
490                start_point,
491                old_end_point: start_point,
492                // a single-line insert ends at start.column + len — the
493                // extent's col is relative, not absolute (0023 probe)
494                new_end_point: if extent.0 == 0 {
495                    (start_point.0, start_point.1 + extent.1)
496                } else {
497                    (start_point.0 + extent.0, extent.1)
498                },
499            },
500            history::EditKind::Delete => InputEdit {
501                start_byte: op.at,
502                old_end_byte: op.at + op.text.len(),
503                new_end_byte: op.at,
504                start_point,
505                old_end_point: if extent.0 == 0 {
506                    (start_point.0, start_point.1 + extent.1)
507                } else {
508                    (start_point.0 + extent.0, extent.1)
509                },
510                new_end_point: start_point,
511            },
512        }
513    }
514}
515
516#[cfg(test)]
517mod safety_tests {
518    use super::*;
519
520    #[test]
521    fn save_refuses_external_change_unless_forced() {
522        let dir = tempfile::tempdir().unwrap();
523        let f = dir.path().join("f.txt");
524        std::fs::write(&f, "original\n").unwrap();
525        let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
526        b.insert(id::ByteOffset::new(0), "mine ");
527        // another process touches the file
528        std::thread::sleep(std::time::Duration::from_millis(5));
529        std::fs::write(&f, "theirs\n").unwrap();
530        let err = b.save(false).unwrap_err();
531        assert!(err.to_string().contains("changed on disk"));
532        assert_eq!(std::fs::read_to_string(&f).unwrap(), "theirs\n");
533        b.save(true).unwrap(); // :w!
534        assert_eq!(std::fs::read_to_string(&f).unwrap(), "mine original\n");
535        assert!(!b.dirty);
536    }
537
538    #[test]
539    fn save_is_atomic_and_keeps_permissions() {
540        use std::os::unix::fs::PermissionsExt;
541        let dir = tempfile::tempdir().unwrap();
542        let f = dir.path().join("x.sh");
543        std::fs::write(&f, "#!/bin/sh\n").unwrap();
544        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o750)).unwrap();
545        let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
546        b.insert(id::ByteOffset::new(b.len_bytes()), "echo hi\n");
547        b.save(false).unwrap();
548        assert_eq!(std::fs::read_to_string(&f).unwrap(), "#!/bin/sh\necho hi\n");
549        let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
550        assert_eq!(mode, 0o750, "permissions survive the swap");
551        // no temp litter
552        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
553    }
554
555    #[test]
556    fn readonly_refuses_mutation_at_the_boundary() {
557        // 0014: the guard lives in Buffer, not in every caller's memory
558        let mut b = Buffer::from_text("abc\n");
559        b.readonly = true;
560        b.insert(id::ByteOffset::new(0), "nope");
561        let gone = b.delete(Range::charwise(0, 2));
562        assert_eq!(gone, "");
563        assert_eq!(b.rope.to_string(), "abc\n", "untouched");
564        // the owner path still works (job-generated surfaces)
565        b.replace_all_system("gen\n");
566        assert_eq!(b.rope.to_string(), "gen\n");
567    }
568    #[test]
569    fn non_utf8_filename_opens_and_roundtrips() {
570        // 0021 §3: the filesystem is not UTF-8 — a weird name must open,
571        // save, and keep its identity
572        use std::os::unix::ffi::OsStrExt;
573        let dir = tempfile::tempdir().unwrap();
574        let weird = dir
575            .path()
576            .join(std::ffi::OsStr::from_bytes(b"weird-\xff.rs"));
577        std::fs::write(&weird, "fn main() {}\n").unwrap();
578        let mut b = Buffer::open(&weird).unwrap();
579        assert_eq!(b.path.as_deref(), Some(weird.as_path()));
580        b.insert(0, "// x\n");
581        b.save(false).unwrap();
582        assert!(std::fs::read_to_string(&weird).unwrap().starts_with("// x"));
583    }
584}