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;
5
6use history::{Edit, EditKind, History};
7use ropey::Rope;
8
9/// A text buffer. Positions are UTF-8 byte offsets, everywhere (0001 §5.1).
10pub struct Buffer {
11    pub rope: Rope,
12    pub path: Option<String>,
13    pub dirty: bool,
14    /// Monotonic edit counter; async readers (git gutter) diff lazily.
15    pub epoch: u64,
16    /// Read-only views (git surfaces): motions/yank work, edits refuse.
17    pub readonly: bool,
18    /// Display name for virtual buffers (statusline shows "[scratch]"
19    /// otherwise): "git log", "commit 1a2b3c", …
20    pub name: Option<String>,
21    /// Undo history (helix-style revision tree). Readonly buffers never
22    /// record (their content is owned by jobs, not the user).
23    pub history: History,
24    /// Suppresses recording while applying undo/redo ops.
25    pub replaying: bool,
26}
27
28/// A half-open byte range `[start, end)` plus how vim thinks about it.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct Range {
31    pub start: usize,
32    pub end: usize,
33    /// Inclusive (charwise) ranges include the char at `end - 1`'s semantic
34    /// target already — the flag exists for the spec footer and linewise ops.
35    pub linewise: bool,
36}
37
38impl Range {
39    pub fn charwise(start: usize, end: usize) -> Self {
40        debug_assert!(start <= end);
41        Self {
42            start,
43            end,
44            linewise: false,
45        }
46    }
47    pub fn linewise(start: usize, end: usize) -> Self {
48        debug_assert!(start <= end);
49        Self {
50            start,
51            end,
52            linewise: true,
53        }
54    }
55    pub fn len(&self) -> usize {
56        self.end - self.start
57    }
58    pub fn is_empty(&self) -> bool {
59        self.start == self.end
60    }
61}
62
63impl Buffer {
64    pub fn from_text(text: &str) -> Self {
65        Self {
66            rope: Rope::from_str(text),
67            path: None,
68            dirty: false,
69            epoch: 0,
70            readonly: false,
71            name: None,
72            history: History::default(),
73            replaying: false,
74        }
75    }
76
77    /// Open a file; a missing file is a new empty buffer with that path
78    /// (vim semantics — `:w` creates it). Real I/O errors still error.
79    pub fn open(path: &str) -> std::io::Result<Self> {
80        let text = match std::fs::read_to_string(path) {
81            Ok(t) => t,
82            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
83            Err(e) => return Err(e),
84        };
85        Ok(Self {
86            rope: Rope::from_str(&text),
87            path: Some(path.to_string()),
88            dirty: false,
89            epoch: 0,
90            readonly: false,
91            name: None,
92            history: History::default(),
93            replaying: false,
94        })
95    }
96
97    pub fn save(&mut self) -> std::io::Result<()> {
98        if let Some(path) = &self.path {
99            std::fs::write(path, self.rope.to_string())?;
100            self.dirty = false;
101        }
102        Ok(())
103    }
104
105    pub fn len_bytes(&self) -> usize {
106        self.rope.len_bytes()
107    }
108    pub fn len_lines(&self) -> usize {
109        self.rope.len_lines()
110    }
111
112    /// Last *content* line index — a trailing newline's phantom empty
113    /// line doesn't count (vim's G lands on real text).
114    pub fn last_content_line(&self) -> usize {
115        let mut l = self.len_lines().saturating_sub(1);
116        if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
117            l -= 1;
118        }
119        l
120    }
121
122    /// Byte offset of the first char of `line` (0-indexed).
123    pub fn line_start(&self, line: usize) -> usize {
124        self.rope
125            .line_to_byte(line.min(self.len_lines().saturating_sub(1)))
126    }
127
128    /// Byte offset one past the last content char of `line` (excludes `\n`).
129    pub fn line_end(&self, line: usize) -> usize {
130        let start = self.line_start(line);
131        let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
132        if line + 1 >= self.len_lines() {
133            end = self.len_bytes();
134        }
135        // strip the trailing newline
136        if end > start && self.byte(end - 1) == b'\n' {
137            end -= 1;
138        }
139        end
140    }
141
142    pub fn line_of(&self, offset: usize) -> usize {
143        self.rope.byte_to_line(offset.min(self.len_bytes()))
144    }
145
146    /// Column (in bytes) of `offset` within its line.
147    pub fn col_of(&self, offset: usize) -> usize {
148        offset - self.line_start(self.line_of(offset))
149    }
150
151    pub fn byte(&self, offset: usize) -> u8 {
152        self.rope
153            .byte(offset.min(self.len_bytes().saturating_sub(1)))
154    }
155
156    pub fn byte_at(&self, offset: usize) -> Option<u8> {
157        if offset < self.len_bytes() {
158            Some(self.rope.byte(offset))
159        } else {
160            None
161        }
162    }
163
164    /// Is `offset` a UTF-8 char boundary? ropey's `try_byte_to_char`
165    /// maps a mid-char byte to its containing char without complaint —
166    /// only the byte↔char roundtrip actually detects boundaries. (The
167    /// pre-0.3.9 clamp trusted it and never clamped anything.)
168    pub fn is_boundary(&self, offset: usize) -> bool {
169        if offset == 0 || offset == self.len_bytes() {
170            return true;
171        }
172        if offset > self.len_bytes() {
173            return false;
174        }
175        match self.rope.try_byte_to_char(offset) {
176            Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == offset),
177            Err(_) => false,
178        }
179    }
180
181    /// Clamp a byte offset down to a char boundary (the grapheme policy
182    /// in 0001 §5.9 hardens this further when text goes wide).
183    pub fn clamp_boundary(&self, mut offset: usize) -> usize {
184        offset = offset.min(self.len_bytes());
185        while offset > 0 && !self.is_boundary(offset) {
186            offset -= 1;
187        }
188        offset
189    }
190
191    /// Smallest char boundary >= offset. Byte arithmetic on a cursor
192    /// (`cursor + 1` in x/a/r/~) lands inside a multibyte char; deleting
193    /// or inserting there panics ropey. Round up, never down — a
194    /// deletion that rounds down eats the previous char's tail.
195    pub fn ceil_boundary(&self, mut offset: usize) -> usize {
196        offset = offset.min(self.len_bytes());
197        while offset < self.len_bytes() && !self.is_boundary(offset) {
198            offset += 1;
199        }
200        offset
201    }
202
203    /// Slice as String — for register/paste paths, never for per-frame render.
204    /// Stale ranges clamp (fuzz-driven cascades hand these around).
205    pub fn slice_string(&self, range: Range) -> String {
206        let start = range.start.min(self.len_bytes());
207        let end = range.end.min(self.len_bytes());
208        self.rope.byte_slice(start..end.max(start)).to_string()
209    }
210
211    /// Apply history edits (undo/redo replay — never recorded).
212    pub fn apply_history(&mut self, ops: Vec<Edit>) {
213        self.replaying = true;
214        for op in ops {
215            match op.kind {
216                EditKind::Insert => {
217                    let at = self.clamp_boundary(op.at.min(self.len_bytes()));
218                    self.rope.insert(self.rope.byte_to_char(at), &op.text);
219                }
220                EditKind::Delete => {
221                    // both bounds must land on char boundaries — a stale
222                    // replay against drifted text panics ropey otherwise
223                    let end = self.clamp_boundary((op.at + op.text.len()).min(self.len_bytes()));
224                    let start = self.clamp_boundary(op.at.min(end));
225                    if start < end {
226                        self.rope
227                            .remove(self.rope.byte_to_char(start)..self.rope.byte_to_char(end));
228                    }
229                }
230            }
231        }
232        self.replaying = false;
233        self.dirty = true;
234        self.epoch += 1;
235    }
236
237    /// Replace the whole contents (virtual buffers filling from jobs).
238    pub fn replace_all(&mut self, text: &str) {
239        self.rope = Rope::from_str(text);
240        self.epoch += 1;
241    }
242
243    /// Returns the deleted text (register payoff).
244    pub fn delete(&mut self, range: Range) -> String {
245        // stale ranges (fuzz-driven cascades, replay drift) clamp, not panic
246        let start = self.clamp_boundary(range.start.min(self.len_bytes()));
247        let end = self.clamp_boundary(range.end.min(self.len_bytes()));
248        if start >= end {
249            return String::new();
250        }
251        let text = self.rope.byte_slice(start..end).to_string();
252        // ropey mutates by CHAR index; our offsets are bytes
253        let cstart = self.rope.byte_to_char(start);
254        let cend = self.rope.byte_to_char(end);
255        self.rope.remove(cstart..cend);
256        self.dirty = true;
257        self.epoch += 1;
258        if !self.replaying && !self.readonly {
259            self.history.record(
260                Edit {
261                    at: range.start,
262                    text: text.clone(),
263                    kind: EditKind::Insert,
264                },
265                Edit {
266                    at: range.start,
267                    text: text.clone(),
268                    kind: EditKind::Delete,
269                },
270            );
271        }
272        text
273    }
274
275    pub fn insert(&mut self, at: usize, text: &str) {
276        let at = self.clamp_boundary(at);
277        self.rope.insert(self.rope.byte_to_char(at), text);
278        self.dirty = true;
279        self.epoch += 1;
280        if !self.replaying && !self.readonly {
281            self.history.record(
282                Edit {
283                    at,
284                    text: text.into(),
285                    kind: EditKind::Delete,
286                },
287                Edit {
288                    at,
289                    text: text.into(),
290                    kind: EditKind::Insert,
291                },
292            );
293        }
294    }
295
296    pub fn line_text(&self, line: usize) -> String {
297        let start = self.line_start(line);
298        let end = self.line_end(line);
299        self.rope.byte_slice(start..end).to_string()
300    }
301}