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    /// Open a file; a missing file is a new empty buffer with that path
68    /// (vim semantics — `:w` creates it). Real I/O errors still error.
69    pub fn open(path: &str) -> std::io::Result<Self> {
70        let text = match std::fs::read_to_string(path) {
71            Ok(t) => t,
72            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
73            Err(e) => return Err(e),
74        };
75        Ok(Self {
76            rope: Rope::from_str(&text),
77            path: Some(path.to_string()),
78            dirty: false,
79            epoch: 0,
80            readonly: false,
81            name: None,
82        })
83    }
84
85    pub fn save(&mut self) -> std::io::Result<()> {
86        if let Some(path) = &self.path {
87            std::fs::write(path, self.rope.to_string())?;
88            self.dirty = false;
89        }
90        Ok(())
91    }
92
93    pub fn len_bytes(&self) -> usize {
94        self.rope.len_bytes()
95    }
96    pub fn len_lines(&self) -> usize {
97        self.rope.len_lines()
98    }
99
100    /// Last *content* line index — a trailing newline's phantom empty
101    /// line doesn't count (vim's G lands on real text).
102    pub fn last_content_line(&self) -> usize {
103        let mut l = self.len_lines().saturating_sub(1);
104        if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
105            l -= 1;
106        }
107        l
108    }
109
110    /// Byte offset of the first char of `line` (0-indexed).
111    pub fn line_start(&self, line: usize) -> usize {
112        self.rope
113            .line_to_byte(line.min(self.len_lines().saturating_sub(1)))
114    }
115
116    /// Byte offset one past the last content char of `line` (excludes `\n`).
117    pub fn line_end(&self, line: usize) -> usize {
118        let start = self.line_start(line);
119        let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
120        if line + 1 >= self.len_lines() {
121            end = self.len_bytes();
122        }
123        // strip the trailing newline
124        if end > start && self.byte(end - 1) == b'\n' {
125            end -= 1;
126        }
127        end
128    }
129
130    pub fn line_of(&self, offset: usize) -> usize {
131        self.rope.byte_to_line(offset.min(self.len_bytes()))
132    }
133
134    /// Column (in bytes) of `offset` within its line.
135    pub fn col_of(&self, offset: usize) -> usize {
136        offset - self.line_start(self.line_of(offset))
137    }
138
139    pub fn byte(&self, offset: usize) -> u8 {
140        self.rope
141            .byte(offset.min(self.len_bytes().saturating_sub(1)))
142    }
143
144    pub fn byte_at(&self, offset: usize) -> Option<u8> {
145        if offset < self.len_bytes() {
146            Some(self.rope.byte(offset))
147        } else {
148            None
149        }
150    }
151
152    /// Clamp a byte offset to a char boundary (prototype is ASCII-honest;
153    /// the grapheme policy in 0001 §5.9 hardens this when text goes wide).
154    pub fn clamp_boundary(&self, mut offset: usize) -> usize {
155        offset = offset.min(self.len_bytes());
156        while offset > 0 && self.rope.try_byte_to_char(offset).is_err() {
157            offset -= 1;
158        }
159        offset
160    }
161
162    /// Slice as String — for register/paste paths, never for per-frame render.
163    pub fn slice_string(&self, range: Range) -> String {
164        self.rope.byte_slice(range.start..range.end).to_string()
165    }
166
167    /// Replace the whole contents (virtual buffers filling from jobs).
168    pub fn replace_all(&mut self, text: &str) {
169        self.rope = Rope::from_str(text);
170        self.epoch += 1;
171    }
172
173    /// Returns the deleted text (register payoff).
174    pub fn delete(&mut self, range: Range) -> String {
175        let text = self.slice_string(range);
176        self.rope.remove(range.start..range.end);
177        self.dirty = true;
178        self.epoch += 1;
179        text
180    }
181
182    pub fn insert(&mut self, at: usize, text: &str) {
183        self.rope.insert(self.clamp_boundary(at), text);
184        self.dirty = true;
185        self.epoch += 1;
186    }
187
188    pub fn line_text(&self, line: usize) -> String {
189        let start = self.line_start(line);
190        let end = self.line_end(line);
191        self.rope.byte_slice(start..end).to_string()
192    }
193}