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    /// Clamp a byte offset to a char boundary (prototype is ASCII-honest;
165    /// the grapheme policy in 0001 §5.9 hardens this when text goes wide).
166    pub fn clamp_boundary(&self, mut offset: usize) -> usize {
167        offset = offset.min(self.len_bytes());
168        while offset > 0 && self.rope.try_byte_to_char(offset).is_err() {
169            offset -= 1;
170        }
171        offset
172    }
173
174    /// Slice as String — for register/paste paths, never for per-frame render.
175    pub fn slice_string(&self, range: Range) -> String {
176        self.rope.byte_slice(range.start..range.end).to_string()
177    }
178
179    /// Apply history edits (undo/redo replay — never recorded).
180    pub fn apply_history(&mut self, ops: Vec<Edit>) {
181        self.replaying = true;
182        for op in ops {
183            match op.kind {
184                EditKind::Insert => {
185                    let at = self.clamp_boundary(op.at.min(self.len_bytes()));
186                    self.rope.insert(self.rope.byte_to_char(at), &op.text);
187                }
188                EditKind::Delete => {
189                    // both bounds must land on char boundaries — a stale
190                    // replay against drifted text panics ropey otherwise
191                    let end = self.clamp_boundary((op.at + op.text.len()).min(self.len_bytes()));
192                    let start = self.clamp_boundary(op.at.min(end));
193                    if start < end {
194                        self.rope
195                            .remove(self.rope.byte_to_char(start)..self.rope.byte_to_char(end));
196                    }
197                }
198            }
199        }
200        self.replaying = false;
201        self.dirty = true;
202        self.epoch += 1;
203    }
204
205    /// Replace the whole contents (virtual buffers filling from jobs).
206    pub fn replace_all(&mut self, text: &str) {
207        self.rope = Rope::from_str(text);
208        self.epoch += 1;
209    }
210
211    /// Returns the deleted text (register payoff).
212    pub fn delete(&mut self, range: Range) -> String {
213        // stale ranges (fuzz-driven cascades, replay drift) clamp, not panic
214        let start = self.clamp_boundary(range.start.min(self.len_bytes()));
215        let end = self.clamp_boundary(range.end.min(self.len_bytes()));
216        if start >= end {
217            return String::new();
218        }
219        let text = self.rope.byte_slice(start..end).to_string();
220        // ropey mutates by CHAR index; our offsets are bytes
221        let cstart = self.rope.byte_to_char(start);
222        let cend = self.rope.byte_to_char(end);
223        self.rope.remove(cstart..cend);
224        self.dirty = true;
225        self.epoch += 1;
226        if !self.replaying && !self.readonly {
227            self.history.record(
228                Edit {
229                    at: range.start,
230                    text: text.clone(),
231                    kind: EditKind::Insert,
232                },
233                Edit {
234                    at: range.start,
235                    text: text.clone(),
236                    kind: EditKind::Delete,
237                },
238            );
239        }
240        text
241    }
242
243    pub fn insert(&mut self, at: usize, text: &str) {
244        let at = self.clamp_boundary(at);
245        self.rope.insert(self.rope.byte_to_char(at), text);
246        self.dirty = true;
247        self.epoch += 1;
248        if !self.replaying && !self.readonly {
249            self.history.record(
250                Edit {
251                    at,
252                    text: text.into(),
253                    kind: EditKind::Delete,
254                },
255                Edit {
256                    at,
257                    text: text.into(),
258                    kind: EditKind::Insert,
259                },
260            );
261        }
262    }
263
264    pub fn line_text(&self, line: usize) -> String {
265        let start = self.line_start(line);
266        let end = self.line_end(line);
267        self.rope.byte_slice(start..end).to_string()
268    }
269}