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
4use ropey::Rope;
5
6/// A text buffer. Positions are UTF-8 byte offsets, everywhere (0001 §5.1).
7pub struct Buffer {
8    pub rope: Rope,
9    pub path: Option<String>,
10    pub dirty: bool,
11    /// Monotonic edit counter; async readers (git gutter) diff lazily.
12    pub epoch: u64,
13    /// Read-only views (git surfaces): motions/yank work, edits refuse.
14    pub readonly: bool,
15    /// Display name for virtual buffers (statusline shows "[scratch]"
16    /// otherwise): "git log", "commit 1a2b3c", …
17    pub name: Option<String>,
18}
19
20/// A half-open byte range `[start, end)` plus how vim thinks about it.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Range {
23    pub start: usize,
24    pub end: usize,
25    /// Inclusive (charwise) ranges include the char at `end - 1`'s semantic
26    /// target already — the flag exists for the spec footer and linewise ops.
27    pub linewise: bool,
28}
29
30impl Range {
31    pub fn charwise(start: usize, end: usize) -> Self {
32        debug_assert!(start <= end);
33        Self {
34            start,
35            end,
36            linewise: false,
37        }
38    }
39    pub fn linewise(start: usize, end: usize) -> Self {
40        debug_assert!(start <= end);
41        Self {
42            start,
43            end,
44            linewise: true,
45        }
46    }
47    pub fn len(&self) -> usize {
48        self.end - self.start
49    }
50    pub fn is_empty(&self) -> bool {
51        self.start == self.end
52    }
53}
54
55impl Buffer {
56    pub fn from_text(text: &str) -> Self {
57        Self {
58            rope: Rope::from_str(text),
59            path: None,
60            dirty: false,
61            epoch: 0,
62            readonly: false,
63            name: None,
64        }
65    }
66
67    pub fn open(path: &str) -> std::io::Result<Self> {
68        let text = std::fs::read_to_string(path)?;
69        Ok(Self {
70            rope: Rope::from_str(&text),
71            path: Some(path.to_string()),
72            dirty: false,
73            epoch: 0,
74            readonly: false,
75            name: None,
76        })
77    }
78
79    pub fn save(&mut self) -> std::io::Result<()> {
80        if let Some(path) = &self.path {
81            std::fs::write(path, self.rope.to_string())?;
82            self.dirty = false;
83        }
84        Ok(())
85    }
86
87    pub fn len_bytes(&self) -> usize {
88        self.rope.len_bytes()
89    }
90    pub fn len_lines(&self) -> usize {
91        self.rope.len_lines()
92    }
93
94    /// Last *content* line index — a trailing newline's phantom empty
95    /// line doesn't count (vim's G lands on real text).
96    pub fn last_content_line(&self) -> usize {
97        let mut l = self.len_lines().saturating_sub(1);
98        if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
99            l -= 1;
100        }
101        l
102    }
103
104    /// Byte offset of the first char of `line` (0-indexed).
105    pub fn line_start(&self, line: usize) -> usize {
106        self.rope
107            .line_to_byte(line.min(self.len_lines().saturating_sub(1)))
108    }
109
110    /// Byte offset one past the last content char of `line` (excludes `\n`).
111    pub fn line_end(&self, line: usize) -> usize {
112        let start = self.line_start(line);
113        let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
114        if line + 1 >= self.len_lines() {
115            end = self.len_bytes();
116        }
117        // strip the trailing newline
118        if end > start && self.byte(end - 1) == b'\n' {
119            end -= 1;
120        }
121        end
122    }
123
124    pub fn line_of(&self, offset: usize) -> usize {
125        self.rope.byte_to_line(offset.min(self.len_bytes()))
126    }
127
128    /// Column (in bytes) of `offset` within its line.
129    pub fn col_of(&self, offset: usize) -> usize {
130        offset - self.line_start(self.line_of(offset))
131    }
132
133    pub fn byte(&self, offset: usize) -> u8 {
134        self.rope
135            .byte(offset.min(self.len_bytes().saturating_sub(1)))
136    }
137
138    pub fn byte_at(&self, offset: usize) -> Option<u8> {
139        if offset < self.len_bytes() {
140            Some(self.rope.byte(offset))
141        } else {
142            None
143        }
144    }
145
146    /// Clamp a byte offset to a char boundary (prototype is ASCII-honest;
147    /// the grapheme policy in 0001 §5.9 hardens this when text goes wide).
148    pub fn clamp_boundary(&self, mut offset: usize) -> usize {
149        offset = offset.min(self.len_bytes());
150        while offset > 0 && self.rope.try_byte_to_char(offset).is_err() {
151            offset -= 1;
152        }
153        offset
154    }
155
156    /// Slice as String — for register/paste paths, never for per-frame render.
157    pub fn slice_string(&self, range: Range) -> String {
158        self.rope.byte_slice(range.start..range.end).to_string()
159    }
160
161    /// Replace the whole contents (virtual buffers filling from jobs).
162    pub fn replace_all(&mut self, text: &str) {
163        self.rope = Rope::from_str(text);
164        self.epoch += 1;
165    }
166
167    /// Returns the deleted text (register payoff).
168    pub fn delete(&mut self, range: Range) -> String {
169        let text = self.slice_string(range);
170        self.rope.remove(range.start..range.end);
171        self.dirty = true;
172        self.epoch += 1;
173        text
174    }
175
176    pub fn insert(&mut self, at: usize, text: &str) {
177        self.rope.insert(self.clamp_boundary(at), text);
178        self.dirty = true;
179        self.epoch += 1;
180    }
181
182    pub fn line_text(&self, line: usize) -> String {
183        let start = self.line_start(line);
184        let end = self.line_end(line);
185        self.rope.byte_slice(start..end).to_string()
186    }
187}