reovim_kernel/mm/buffer.rs
1//! Buffer data structure for text storage.
2//!
3//! The buffer is the core abstraction for text editing. It stores
4//! text as a rope data structure providing O(log n) insert/delete,
5//! O(1) clone via structural sharing, and O(log n) position conversion.
6//!
7//! # Cursor Isolation (#471)
8//!
9//! **Buffer does NOT track cursor position.** Cursor is per-client UI state
10//! that lives in `Window`, not in the shared kernel Buffer. This follows the
11//! mechanism vs policy principle:
12//! - **Mechanism**: Buffer provides text storage operations
13//! - **Policy**: Window/Session decides cursor position (per-client)
14//!
15//! Use `SessionRuntime::cursor_position(buffer_id)` to get cursor from Window.
16
17use std::hash::{Hash, Hasher};
18
19use super::{BufferId, Position, rope::Rope};
20
21/// A text buffer with rope-based storage.
22///
23/// The buffer stores text as a rope — a balanced B-tree of text chunks.
24/// This provides O(log n) insert/delete, O(1) clone via structural sharing
25/// (`Arc<Node>`), and O(log n) position conversion.
26///
27/// # Invariants
28///
29/// - An empty buffer has zero lines (not one empty line)
30/// - Positions are clamped to valid ranges on access
31///
32/// # Cursor Isolation (#471)
33///
34/// Buffer does NOT have a cursor field. Cursor is per-client state in Window.
35/// All edit operations take explicit positions instead of using an internal cursor.
36///
37/// # Example
38///
39/// ```
40/// use reovim_kernel::api::v1::*;
41///
42/// let mut buf = Buffer::from_string("Hello\nWorld");
43/// assert_eq!(buf.line_count(), 2);
44/// assert_eq!(buf.line(0), Some("Hello"));
45///
46/// buf.insert_at(Position::new(0, 5), "!");
47/// assert_eq!(buf.line(0), Some("Hello!"));
48/// ```
49#[derive(Debug, Clone)]
50pub struct Buffer {
51 /// Unique identifier for this buffer.
52 id: BufferId,
53 /// Text content stored as a rope.
54 text: Rope,
55 /// Whether the buffer has unsaved modifications.
56 modified: bool,
57 /// File path associated with this buffer.
58 file_path: Option<String>,
59 // NOTE: Cursor removed in #471 (per-client cursor isolation).
60 // Cursor now lives in Window (per-client state), not Buffer.
61 // See: server/lib/drivers/session/src/types.rs - Window.cursor
62 //
63 // NOTE: Selection removed in Phase 8 (#465).
64 // Selection now lives in Window (per-window state), not Buffer.
65 // See: server/lib/drivers/session/src/types.rs - Window.selection
66}
67
68impl Buffer {
69 /// Create a new empty buffer.
70 #[must_use]
71 pub fn new() -> Self {
72 Self {
73 id: BufferId::new(),
74 text: Rope::new(),
75 modified: false,
76 file_path: None,
77 }
78 }
79
80 /// Create a buffer with a specific ID.
81 ///
82 /// This is primarily useful for testing.
83 #[must_use]
84 pub fn with_id(id: BufferId) -> Self {
85 Self {
86 id,
87 text: Rope::new(),
88 modified: false,
89 file_path: None,
90 }
91 }
92
93 /// Create a buffer from a string.
94 ///
95 /// The string is split by newlines into lines.
96 /// An empty string results in a buffer with zero lines.
97 #[must_use]
98 pub fn from_string(content: &str) -> Self {
99 Self {
100 id: BufferId::new(),
101 text: normalize_to_rope(content),
102 modified: false,
103 file_path: None,
104 }
105 }
106
107 // === Accessors ===
108
109 /// Get the buffer ID.
110 #[must_use]
111 pub const fn id(&self) -> BufferId {
112 self.id
113 }
114
115 // NOTE: cursor(), cursor_mut(), position(), set_position() removed in #471.
116 // Cursor is per-client state in Window, not Buffer.
117 // Use SessionRuntime::cursor_position(buffer_id) via BufferApi trait.
118
119 /// Check if the buffer has unsaved modifications.
120 #[must_use]
121 pub const fn is_modified(&self) -> bool {
122 self.modified
123 }
124
125 /// Mark the buffer as modified or unmodified.
126 pub const fn set_modified(&mut self, modified: bool) {
127 self.modified = modified;
128 }
129
130 // NOTE: Selection methods removed in Phase 8 (#465).
131 // Selection now lives in Window (per-window state), not Buffer.
132 // Use SessionRuntime::selection(buffer_id) via BufferApi trait.
133
134 // === File Path ===
135
136 /// Get the file path associated with this buffer.
137 #[must_use]
138 pub fn file_path(&self) -> Option<&str> {
139 self.file_path.as_deref()
140 }
141
142 /// Set the file path for this buffer.
143 pub fn set_file_path(&mut self, path: Option<String>) {
144 self.file_path = path;
145 }
146
147 // === Line Hashing ===
148
149 /// Compute hash of a line for cache validation.
150 ///
151 /// Uses `DefaultHasher` for speed over cryptographic strength.
152 /// Returns `None` if line index is out of bounds.
153 #[must_use]
154 pub fn line_hash(&self, line_idx: usize) -> Option<u64> {
155 use std::collections::hash_map::DefaultHasher;
156
157 self.line(line_idx).map(|line| {
158 let mut hasher = DefaultHasher::new();
159 line.hash(&mut hasher);
160 hasher.finish()
161 })
162 }
163
164 /// Get all line hashes (for saturator requests).
165 #[must_use]
166 pub fn line_hashes(&self) -> Vec<u64> {
167 (0..self.line_count())
168 .filter_map(|idx| self.line_hash(idx))
169 .collect()
170 }
171
172 // === Line Access ===
173
174 /// Get the number of lines in the buffer.
175 #[must_use]
176 pub fn line_count(&self) -> usize {
177 self.text.line_count()
178 }
179
180 /// Check if the buffer is empty (has no lines).
181 #[must_use]
182 pub fn is_empty(&self) -> bool {
183 self.text.is_empty()
184 }
185
186 /// Get a specific line by index.
187 ///
188 /// Returns `None` if the index is out of bounds.
189 #[must_use]
190 pub fn line(&self, index: usize) -> Option<&str> {
191 self.text.line(index)
192 }
193
194 /// Get the length of a specific line in characters.
195 ///
196 /// Returns `None` if the index is out of bounds.
197 #[must_use]
198 pub fn line_len(&self, index: usize) -> Option<usize> {
199 self.text.line_len(index)
200 }
201
202 // NOTE: lines() -> &[String] removed in #711.
203 // With rope storage, there is no contiguous &[String] to return.
204 // Use line(idx) for individual access or content() for full text.
205
206 /// Clone the internal rope (O(1) via `Arc` sharing).
207 ///
208 /// Used by snapshot types for efficient state capture.
209 #[must_use]
210 pub(crate) fn clone_rope(&self) -> Rope {
211 self.text.clone()
212 }
213
214 /// Replace the internal rope directly (O(1) via `Arc` sharing).
215 ///
216 /// Used by snapshot restore for efficient state restoration.
217 pub(crate) fn set_rope(&mut self, rope: Rope) {
218 self.text = rope;
219 self.modified = true;
220 }
221
222 /// Get the full content as a string (lines joined with newlines).
223 #[must_use]
224 pub fn content(&self) -> String {
225 self.text.content()
226 }
227
228 /// Set the full content from a string.
229 ///
230 /// This replaces all existing content.
231 pub fn set_content(&mut self, content: &str) {
232 self.text = normalize_to_rope(content);
233 self.modified = true;
234 }
235
236 // === Edit Operations ===
237 //
238 // NOTE: insert() and delete() convenience methods removed in #471.
239 // These methods relied on internal cursor which is now per-client in Window.
240 // Use insert_at(pos, text) and delete_at(pos, count) with explicit positions.
241
242 /// Insert text at a specific position.
243 ///
244 /// This is a pure text operation - cursor management is the caller's responsibility.
245 pub fn insert_at(&mut self, pos: Position, text: &str) {
246 if text.is_empty() {
247 return;
248 }
249
250 if self.text.is_empty() {
251 self.text = Rope::from_str(text);
252 self.modified = true;
253 return;
254 }
255
256 let pos = self.clamp_position(pos);
257 let byte_offset = self.text.position_to_byte(pos.line, pos.column);
258 self.text = self.text.insert(byte_offset, text);
259 self.modified = true;
260 }
261
262 /// Delete text at a specific position.
263 ///
264 /// Returns the deleted text.
265 /// This is a pure text operation - cursor management is the caller's responsibility.
266 pub fn delete_at(&mut self, pos: Position, count: usize) -> String {
267 if count == 0 || self.text.is_empty() {
268 return String::new();
269 }
270
271 let pos = self.clamp_position(pos);
272 let byte_start = self.text.position_to_byte(pos.line, pos.column);
273 let char_start = self.text.byte_to_char(byte_start);
274 let char_end = (char_start + count).min(self.text.char_len());
275 let byte_end = self.text.char_to_byte(char_end);
276
277 if byte_start >= byte_end {
278 return String::new();
279 }
280
281 let deleted = extract_byte_range(&self.text, byte_start, byte_end);
282 self.text = self.text.remove(byte_start..byte_end);
283 // byte_start < byte_end (guard above), so deleted is always non-empty.
284 self.modified = true;
285
286 deleted
287 }
288
289 /// Delete a range of text.
290 ///
291 /// Returns the deleted text.
292 pub fn delete_range(&mut self, start: Position, end: Position) -> String {
293 let (start, end) = if start <= end {
294 (start, end)
295 } else {
296 (end, start)
297 };
298
299 let start = self.clamp_position(start);
300 let end = self.clamp_position(end);
301
302 let byte_start = self.text.position_to_byte(start.line, start.column);
303 let byte_end = self.text.position_to_byte(end.line, end.column);
304
305 if byte_start >= byte_end {
306 return String::new();
307 }
308
309 let deleted = extract_byte_range(&self.text, byte_start, byte_end);
310 self.text = self.text.remove(byte_start..byte_end);
311 // byte_start < byte_end (guard above), so deleted is always non-empty.
312 self.modified = true;
313
314 deleted
315 }
316
317 // === Position Conversion ===
318
319 /// Convert a position to a byte offset in the full content.
320 ///
321 /// This is useful for tree-sitter and other byte-based APIs.
322 #[must_use]
323 pub fn position_to_byte(&self, pos: Position) -> usize {
324 if self.text.is_empty() {
325 return 0;
326 }
327 let pos = self.clamp_position(pos);
328 self.text.position_to_byte(pos.line, pos.column)
329 }
330
331 /// Convert a byte offset to a position.
332 #[must_use]
333 pub fn byte_to_position(&self, byte_offset: usize) -> Position {
334 if self.text.is_empty() {
335 return Position::new(0, 0);
336 }
337 let (line, col) = self.text.byte_to_position(byte_offset);
338 Position::new(line, col)
339 }
340
341 // === Helper Methods ===
342
343 /// Clamp a position to valid buffer coordinates.
344 #[must_use]
345 fn clamp_position(&self, pos: Position) -> Position {
346 if self.text.is_empty() {
347 return Position::origin();
348 }
349
350 let line = pos.line.min(self.text.line_count() - 1);
351 let max_col = self.text.line_len(line).unwrap_or(0);
352 let column = pos.column.min(max_col);
353
354 Position::new(line, column)
355 }
356}
357
358impl Default for Buffer {
359 fn default() -> Self {
360 Self::new()
361 }
362}
363
364// === Helper Functions ===
365
366/// Normalize content string into a rope.
367///
368/// Uses `str::lines()` to split and rejoin, which strips the optional
369/// final newline — matching the old `Vec<String>` buffer behavior where
370/// `content()` was `lines.join("\n")`.
371fn normalize_to_rope(content: &str) -> Rope {
372 if content.is_empty() {
373 return Rope::new();
374 }
375 let joined: String = content.lines().collect::<Vec<_>>().join("\n");
376 if joined.is_empty() {
377 Rope::new()
378 } else {
379 Rope::from_str(&joined)
380 }
381}
382
383/// Extract text in a byte range from a rope by iterating over chunks.
384fn extract_byte_range(text: &Rope, start: usize, end: usize) -> String {
385 let mut result = String::with_capacity(end - start);
386 let mut pos = 0;
387 for chunk in text.chunks() {
388 let chunk_end = pos + chunk.len();
389 if chunk_end <= start {
390 pos = chunk_end;
391 continue;
392 }
393 if pos >= end {
394 break;
395 }
396 let s = start.saturating_sub(pos);
397 let e = (end - pos).min(chunk.len());
398 result.push_str(&chunk[s..e]);
399 pos = chunk_end;
400 }
401 result
402}
403
404#[cfg(test)]
405#[path = "tests/buffer.rs"]
406mod tests;