Skip to main content

kode_core/
buffer.rs

1use ropey::Rope;
2
3use crate::Position;
4
5/// The text buffer, wrapping a ropey::Rope with position conversion helpers.
6#[derive(Debug, Clone)]
7pub struct Buffer {
8    rope: Rope,
9    version: u64,
10}
11
12impl Buffer {
13    /// Create a buffer from a string.
14    pub fn from_text(text: &str) -> Self {
15        Self {
16            rope: Rope::from_str(text),
17            version: 0,
18        }
19    }
20
21    /// Create an empty buffer.
22    pub fn new() -> Self {
23        Self {
24            rope: Rope::new(),
25            version: 0,
26        }
27    }
28
29    /// The underlying rope.
30    pub fn rope(&self) -> &Rope {
31        &self.rope
32    }
33
34    /// Document version, incremented on every edit.
35    pub fn version(&self) -> u64 {
36        self.version
37    }
38
39    /// Total number of chars in the document.
40    pub fn len_chars(&self) -> usize {
41        self.rope.len_chars()
42    }
43
44    /// Total number of lines (always >= 1).
45    pub fn len_lines(&self) -> usize {
46        self.rope.len_lines()
47    }
48
49    /// True if the document is empty.
50    pub fn is_empty(&self) -> bool {
51        self.rope.len_chars() == 0
52    }
53
54    /// Get the full text as a String.
55    pub fn text(&self) -> String {
56        self.rope.to_string()
57    }
58
59    /// Get text of a specific line (0-indexed), including trailing newline if present.
60    pub fn line(&self, line_idx: usize) -> ropey::RopeSlice<'_> {
61        self.rope.line(line_idx)
62    }
63
64    /// Number of chars in a given line (excluding trailing line-ending chars).
65    /// Handles \n, \r\n, and lone \r.
66    pub fn line_len(&self, line_idx: usize) -> usize {
67        let line = self.rope.line(line_idx);
68        let len = line.len_chars();
69        if len == 0 {
70            return 0;
71        }
72        // Strip \n (covers both \n and \r\n cases via the \r check below)
73        let len = if line.char(len - 1) == '\n' { len - 1 } else { len };
74        // Strip a preceding \r (handles \r\n pairs) or a lone \r as line separator
75        if len > 0 && line.char(len - 1) == '\r' {
76            len - 1
77        } else {
78            len
79        }
80    }
81
82    /// Total number of bytes in the document.
83    pub fn len_bytes(&self) -> usize {
84        self.rope.len_bytes()
85    }
86
87    /// Convert a char offset to a byte offset.
88    pub fn char_to_byte(&self, char_idx: usize) -> usize {
89        let idx = char_idx.min(self.len_chars());
90        self.rope.char_to_byte(idx)
91    }
92
93    /// Convert a byte offset to a char offset.
94    pub fn byte_to_char(&self, byte_idx: usize) -> usize {
95        let idx = byte_idx.min(self.len_bytes());
96        self.rope.byte_to_char(idx)
97    }
98
99    /// Get the char offset of the start of a line.
100    pub fn line_to_char(&self, line_idx: usize) -> usize {
101        let line = line_idx.min(self.len_lines().saturating_sub(1));
102        self.rope.line_to_char(line)
103    }
104
105    /// Convert a Position (line, col) to a char offset in the rope.
106    /// Clamps to valid range.
107    pub fn pos_to_char(&self, pos: Position) -> usize {
108        let line = pos.line.min(self.len_lines().saturating_sub(1));
109        let line_start = self.rope.line_to_char(line);
110        let max_col = self.line_len(line);
111        let col = pos.col.min(max_col);
112        line_start + col
113    }
114
115    /// Convert a char offset to a Position (line, col).
116    pub fn char_to_pos(&self, char_idx: usize) -> Position {
117        let idx = char_idx.min(self.len_chars());
118        let line = self.rope.char_to_line(idx);
119        let line_start = self.rope.line_to_char(line);
120        Position::new(line, idx - line_start)
121    }
122
123    /// Clamp a Position to valid document bounds.
124    pub fn clamp_pos(&self, pos: Position) -> Position {
125        let line = pos.line.min(self.len_lines().saturating_sub(1));
126        let max_col = self.line_len(line);
127        Position::new(line, pos.col.min(max_col))
128    }
129
130    /// Insert text at a char offset. Returns the new version.
131    pub(crate) fn insert(&mut self, char_idx: usize, text: &str) -> u64 {
132        let idx = char_idx.min(self.len_chars());
133        self.rope.insert(idx, text);
134        self.version += 1;
135        self.version
136    }
137
138    /// Delete a range of chars [start, end). Returns the deleted text and new version.
139    pub(crate) fn delete(&mut self, start: usize, end: usize) -> (String, u64) {
140        let s = start.min(self.len_chars());
141        let e = end.min(self.len_chars());
142        if s >= e {
143            return (String::new(), self.version);
144        }
145        let deleted: String = self.rope.slice(s..e).to_string();
146        self.rope.remove(s..e);
147        self.version += 1;
148        (deleted, self.version)
149    }
150
151    /// Replace a range [start, end) with new text. Returns deleted text and new version.
152    pub(crate) fn replace(&mut self, start: usize, end: usize, text: &str) -> (String, u64) {
153        let s = start.min(self.len_chars());
154        let e = end.min(self.len_chars());
155        let deleted: String = if s < e {
156            let d = self.rope.slice(s..e).to_string();
157            self.rope.remove(s..e);
158            d
159        } else {
160            String::new()
161        };
162        self.rope.insert(s, text);
163        self.version += 1;
164        (deleted, self.version)
165    }
166}
167
168impl Default for Buffer {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn empty_buffer() {
180        let buf = Buffer::new();
181        assert!(buf.is_empty());
182        assert_eq!(buf.len_chars(), 0);
183        assert_eq!(buf.len_lines(), 1);
184        assert_eq!(buf.text(), "");
185    }
186
187    #[test]
188    fn from_str_basic() {
189        let buf = Buffer::from_text("hello\nworld");
190        assert_eq!(buf.len_lines(), 2);
191        assert_eq!(buf.line_len(0), 5); // "hello" without \n
192        assert_eq!(buf.line_len(1), 5); // "world"
193        assert_eq!(buf.len_chars(), 11);
194    }
195
196    #[test]
197    fn pos_to_char_and_back() {
198        let buf = Buffer::from_text("abc\ndef\nghi");
199        assert_eq!(buf.pos_to_char(Position::new(0, 0)), 0);
200        assert_eq!(buf.pos_to_char(Position::new(0, 3)), 3);
201        assert_eq!(buf.pos_to_char(Position::new(1, 0)), 4);
202        assert_eq!(buf.pos_to_char(Position::new(2, 2)), 10);
203
204        assert_eq!(buf.char_to_pos(0), Position::new(0, 0));
205        assert_eq!(buf.char_to_pos(4), Position::new(1, 0));
206        assert_eq!(buf.char_to_pos(10), Position::new(2, 2));
207    }
208
209    #[test]
210    fn pos_clamping() {
211        let buf = Buffer::from_text("ab\ncd");
212        // Line past end
213        assert_eq!(buf.clamp_pos(Position::new(99, 0)), Position::new(1, 0));
214        // Col past end of line
215        assert_eq!(buf.clamp_pos(Position::new(0, 99)), Position::new(0, 2));
216    }
217
218    #[test]
219    fn insert_and_version() {
220        let mut buf = Buffer::from_text("hello");
221        assert_eq!(buf.version(), 0);
222        buf.insert(5, " world");
223        assert_eq!(buf.version(), 1);
224        assert_eq!(buf.text(), "hello world");
225    }
226
227    #[test]
228    fn delete_range() {
229        let mut buf = Buffer::from_text("hello world");
230        let (deleted, _) = buf.delete(5, 11);
231        assert_eq!(deleted, " world");
232        assert_eq!(buf.text(), "hello");
233    }
234
235    #[test]
236    fn replace_range() {
237        let mut buf = Buffer::from_text("hello world");
238        let (deleted, _) = buf.replace(6, 11, "rust");
239        assert_eq!(deleted, "world");
240        assert_eq!(buf.text(), "hello rust");
241    }
242
243    #[test]
244    fn unicode_positions() {
245        let buf = Buffer::from_text("café\n日本語");
246        assert_eq!(buf.len_lines(), 2);
247        assert_eq!(buf.line_len(0), 4); // c-a-f-é = 4 chars
248        assert_eq!(buf.line_len(1), 3); // 日本語 = 3 chars
249        assert_eq!(buf.pos_to_char(Position::new(1, 2)), 7); // 5 (café\n) + 2
250    }
251
252    #[test]
253    fn emoji_handling() {
254        let buf = Buffer::from_text("hi 👋🏽 there");
255        // 👋🏽 is 2 chars (wave + skin tone modifier)
256        let text = buf.text();
257        assert_eq!(text, "hi 👋🏽 there");
258    }
259
260    // ── CRLF / lone-CR bugs ───────────────────────────────────────────────
261
262    /// line_len must exclude the \r in a CRLF line, not just the \n.
263    /// Currently returns 4 for "foo\r\n" (includes \r); should return 3.
264    #[test]
265    fn crlf_line_len_excludes_cr() {
266        let buf = Buffer::from_text("foo\r\nbar");
267        assert_eq!(buf.line_len(0), 3, "CRLF: line_len should be 3, not 4");
268    }
269
270    /// With the current bug, pos_to_char(line=0, col=line_len(0)) resolves to
271    /// the '\n' char itself (offset 4), not the position before \r\n (offset 3).
272    #[test]
273    fn crlf_pos_to_char_at_line_end_is_before_cr() {
274        let buf = Buffer::from_text("foo\r\nbar");
275        let end_col = buf.line_len(0);
276        let offset = buf.pos_to_char(Position::new(0, end_col));
277        // Expected: offset 3 (before \r). Current bug: offset 4 (\n itself).
278        assert_eq!(offset, 3, "CRLF: end-of-line offset should be before \\r");
279    }
280
281    /// Lone \r is a line separator in ropey. line_len must not include it.
282    /// Currently returns 4 for "foo\r" (ropey line 0 = "foo\r", len=4, no \n to strip).
283    #[test]
284    fn lone_cr_line_len_excludes_cr() {
285        let buf = Buffer::from_text("foo\rbar");
286        assert_eq!(buf.line_len(0), 3, "lone \\r: line_len should be 3, not 4");
287    }
288
289    /// With the lone-\r bug, pos_to_char at max col jumps to the first char of
290    /// the *next* line because col 4 on line 0 of "foo\rbar" = char offset 4 = 'b'.
291    #[test]
292    fn lone_cr_pos_to_char_stays_on_same_line() {
293        let buf = Buffer::from_text("foo\rbar");
294        let end_col = buf.line_len(0);
295        let offset = buf.pos_to_char(Position::new(0, end_col));
296        // Expected: offset 3 (before \r). Current bug: offset 4 = 'b' (next line!).
297        assert_eq!(offset, 3, "lone \\r: end-of-line offset must not escape into next line");
298    }
299}