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 lines and provides efficient operations for insertion,
5//! deletion, and navigation.
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};
20
21/// A text buffer with line-based storage.
22///
23/// The buffer stores text as a vector of lines, where each line is a String
24/// without the trailing newline character. This provides efficient line-based
25/// access while maintaining a simple implementation.
26///
27/// # Invariants
28///
29/// - An empty buffer has zero lines (not one empty line)
30/// - Lines do not contain newline characters
31/// - Positions are clamped to valid ranges on access
32///
33/// # Cursor Isolation (#471)
34///
35/// Buffer does NOT have a cursor field. Cursor is per-client state in Window.
36/// All edit operations take explicit positions instead of using an internal cursor.
37///
38/// # Example
39///
40/// ```
41/// use reovim_kernel::api::v1::*;
42///
43/// let mut buf = Buffer::from_string("Hello\nWorld");
44/// assert_eq!(buf.line_count(), 2);
45/// assert_eq!(buf.line(0), Some("Hello"));
46///
47/// buf.insert_at(Position::new(0, 5), "!");
48/// assert_eq!(buf.line(0), Some("Hello!"));
49/// ```
50#[derive(Debug, Clone)]
51pub struct Buffer {
52 /// Unique identifier for this buffer.
53 id: BufferId,
54 /// Text content stored as lines.
55 lines: Vec<String>,
56 /// Whether the buffer has unsaved modifications.
57 modified: bool,
58 /// File path associated with this buffer.
59 file_path: Option<String>,
60 // NOTE: Cursor removed in #471 (per-client cursor isolation).
61 // Cursor now lives in Window (per-client state), not Buffer.
62 // See: server/lib/drivers/session/src/types.rs - Window.cursor
63 //
64 // NOTE: Selection removed in Phase 8 (#465).
65 // Selection now lives in Window (per-window state), not Buffer.
66 // See: server/lib/drivers/session/src/types.rs - Window.selection
67}
68
69impl Buffer {
70 /// Create a new empty buffer.
71 #[must_use]
72 pub fn new() -> Self {
73 Self {
74 id: BufferId::new(),
75 lines: Vec::new(),
76 modified: false,
77 file_path: None,
78 }
79 }
80
81 /// Create a buffer with a specific ID.
82 ///
83 /// This is primarily useful for testing.
84 #[must_use]
85 pub const fn with_id(id: BufferId) -> Self {
86 Self {
87 id,
88 lines: Vec::new(),
89 modified: false,
90 file_path: None,
91 }
92 }
93
94 /// Create a buffer from a string.
95 ///
96 /// The string is split by newlines into lines.
97 /// An empty string results in a buffer with zero lines.
98 #[must_use]
99 pub fn from_string(content: &str) -> Self {
100 let lines = if content.is_empty() {
101 Vec::new()
102 } else {
103 content.lines().map(String::from).collect()
104 };
105 Self {
106 id: BufferId::new(),
107 lines,
108 modified: false,
109 file_path: None,
110 }
111 }
112
113 // === Accessors ===
114
115 /// Get the buffer ID.
116 #[must_use]
117 pub const fn id(&self) -> BufferId {
118 self.id
119 }
120
121 // NOTE: cursor(), cursor_mut(), position(), set_position() removed in #471.
122 // Cursor is per-client state in Window, not Buffer.
123 // Use SessionRuntime::cursor_position(buffer_id) via BufferApi trait.
124
125 /// Check if the buffer has unsaved modifications.
126 #[must_use]
127 pub const fn is_modified(&self) -> bool {
128 self.modified
129 }
130
131 /// Mark the buffer as modified or unmodified.
132 pub const fn set_modified(&mut self, modified: bool) {
133 self.modified = modified;
134 }
135
136 // NOTE: Selection methods removed in Phase 8 (#465).
137 // Selection now lives in Window (per-window state), not Buffer.
138 // Use SessionRuntime::selection(buffer_id) via BufferApi trait.
139
140 // === File Path ===
141
142 /// Get the file path associated with this buffer.
143 #[must_use]
144 pub fn file_path(&self) -> Option<&str> {
145 self.file_path.as_deref()
146 }
147
148 /// Set the file path for this buffer.
149 pub fn set_file_path(&mut self, path: Option<String>) {
150 self.file_path = path;
151 }
152
153 // === Line Hashing ===
154
155 /// Compute hash of a line for cache validation.
156 ///
157 /// Uses `DefaultHasher` for speed over cryptographic strength.
158 /// Returns `None` if line index is out of bounds.
159 #[must_use]
160 pub fn line_hash(&self, line_idx: usize) -> Option<u64> {
161 use std::collections::hash_map::DefaultHasher;
162
163 self.line(line_idx).map(|line| {
164 let mut hasher = DefaultHasher::new();
165 line.hash(&mut hasher);
166 hasher.finish()
167 })
168 }
169
170 /// Get all line hashes (for saturator requests).
171 #[must_use]
172 pub fn line_hashes(&self) -> Vec<u64> {
173 (0..self.line_count())
174 .filter_map(|idx| self.line_hash(idx))
175 .collect()
176 }
177
178 // === Line Access ===
179
180 /// Get the number of lines in the buffer.
181 #[must_use]
182 pub const fn line_count(&self) -> usize {
183 self.lines.len()
184 }
185
186 /// Check if the buffer is empty (has no lines).
187 #[must_use]
188 pub const fn is_empty(&self) -> bool {
189 self.lines.is_empty()
190 }
191
192 /// Get a specific line by index.
193 ///
194 /// Returns `None` if the index is out of bounds.
195 #[must_use]
196 pub fn line(&self, index: usize) -> Option<&str> {
197 self.lines.get(index).map(String::as_str)
198 }
199
200 /// Get the length of a specific line in characters.
201 ///
202 /// Returns `None` if the index is out of bounds.
203 #[must_use]
204 pub fn line_len(&self, index: usize) -> Option<usize> {
205 self.lines.get(index).map(|l| l.chars().count())
206 }
207
208 /// Get all lines as a slice.
209 #[must_use]
210 pub fn lines(&self) -> &[String] {
211 &self.lines
212 }
213
214 /// Get the full content as a string (lines joined with newlines).
215 #[must_use]
216 pub fn content(&self) -> String {
217 self.lines.join("\n")
218 }
219
220 /// Set the full content from a string.
221 ///
222 /// This replaces all existing content and resets the cursor.
223 pub fn set_content(&mut self, content: &str) {
224 self.lines = if content.is_empty() {
225 Vec::new()
226 } else {
227 content.lines().map(String::from).collect()
228 };
229 self.modified = true;
230 }
231
232 // === Edit Operations ===
233 //
234 // NOTE: insert() and delete() convenience methods removed in #471.
235 // These methods relied on internal cursor which is now per-client in Window.
236 // Use insert_at(pos, text) and delete_at(pos, count) with explicit positions.
237
238 /// Insert text at a specific position.
239 ///
240 /// This is a pure text operation - cursor management is the caller's responsibility.
241 pub fn insert_at(&mut self, pos: Position, text: &str) {
242 if text.is_empty() {
243 return;
244 }
245
246 // Ensure we have at least one line
247 if self.lines.is_empty() {
248 self.lines.push(String::new());
249 }
250
251 let pos = self.clamp_position(pos);
252 let line_idx = pos.line;
253 let col = pos.column;
254
255 // Get the current line and split at insertion point
256 let current_line = &self.lines[line_idx];
257 let byte_offset = char_to_byte_offset(current_line, col);
258 let (before, after) = current_line.split_at(byte_offset);
259 let before = before.to_string();
260 let after = after.to_string();
261
262 // Handle single-line vs multi-line insertion
263 let insert_lines: Vec<&str> = text.split('\n').collect();
264
265 if insert_lines.len() == 1 {
266 // Single line: just insert in place
267 self.lines[line_idx] = format!("{before}{text}{after}");
268 } else {
269 // Multi-line: split and insert
270 // First line gets before + first insert part
271 let first_insert = insert_lines[0];
272 self.lines[line_idx] = format!("{before}{first_insert}");
273
274 // Last line gets last insert part + after
275 let last_insert = insert_lines[insert_lines.len() - 1];
276 let last_line = format!("{last_insert}{after}");
277
278 // Insert middle lines and last line
279 let insert_pos = line_idx + 1;
280 self.lines.splice(
281 insert_pos..insert_pos,
282 insert_lines[1..insert_lines.len() - 1]
283 .iter()
284 .map(|s| (*s).to_string())
285 .chain(std::iter::once(last_line)),
286 );
287 }
288
289 self.modified = true;
290 }
291
292 /// Delete text at a specific position.
293 ///
294 /// Returns the deleted text.
295 /// This is a pure text operation - cursor management is the caller's responsibility.
296 #[cfg_attr(coverage_nightly, coverage(off))]
297 pub fn delete_at(&mut self, pos: Position, count: usize) -> String {
298 if count == 0 || self.lines.is_empty() {
299 return String::new();
300 }
301
302 let pos = self.clamp_position(pos);
303 let mut deleted = String::new();
304 let mut remaining = count;
305 let current_line = pos.line;
306 let current_col = pos.column;
307
308 while remaining > 0 && current_line < self.lines.len() {
309 let line = &self.lines[current_line];
310 let chars: Vec<char> = line.chars().collect();
311 let chars_in_line = chars.len();
312
313 if current_col >= chars_in_line {
314 // At end of line, delete the newline (merge with next line)
315 if current_line + 1 < self.lines.len() {
316 deleted.push('\n');
317 let next_line = self.lines.remove(current_line + 1);
318 self.lines[current_line].push_str(&next_line);
319 remaining -= 1;
320 } else {
321 // Nothing more to delete
322 break;
323 }
324 } else {
325 // Delete characters in current line
326 let chars_to_delete = remaining.min(chars_in_line - current_col);
327 let delete_chars: String = chars[current_col..current_col + chars_to_delete]
328 .iter()
329 .collect();
330 deleted.push_str(&delete_chars);
331
332 // Rebuild the line without deleted chars
333 let new_line: String = chars[..current_col]
334 .iter()
335 .chain(chars[current_col + chars_to_delete..].iter())
336 .collect();
337 self.lines[current_line] = new_line;
338
339 remaining -= chars_to_delete;
340 }
341 }
342
343 if !deleted.is_empty() {
344 self.modified = true;
345 }
346
347 deleted
348 }
349
350 /// Delete a range of text.
351 ///
352 /// Returns the deleted text.
353 pub fn delete_range(&mut self, start: Position, end: Position) -> String {
354 let (start, end) = if start <= end {
355 (start, end)
356 } else {
357 (end, start)
358 };
359
360 let start = self.clamp_position(start);
361 let end = self.clamp_position(end);
362
363 // Calculate character count between positions
364 let count = self.char_count_between(start, end);
365 self.delete_at(start, count)
366 }
367
368 // === Position Conversion ===
369
370 /// Convert a position to a byte offset in the full content.
371 ///
372 /// This is useful for tree-sitter and other byte-based APIs.
373 #[must_use]
374 #[cfg_attr(coverage_nightly, coverage(off))]
375 pub fn position_to_byte(&self, pos: Position) -> usize {
376 let pos = self.clamp_position(pos);
377 let mut offset = 0;
378
379 for (i, line) in self.lines.iter().enumerate() {
380 if i < pos.line {
381 offset += line.len() + 1; // +1 for newline
382 } else if i == pos.line {
383 // Add bytes up to column
384 offset += char_to_byte_offset(line, pos.column);
385 break;
386 }
387 }
388
389 offset
390 }
391
392 /// Convert a byte offset to a position.
393 #[must_use]
394 pub fn byte_to_position(&self, byte_offset: usize) -> Position {
395 let mut remaining = byte_offset;
396
397 for (line_idx, line) in self.lines.iter().enumerate() {
398 let line_bytes = line.len();
399 let line_total = line_bytes + 1; // +1 for newline
400
401 if remaining <= line_bytes {
402 // Position is within this line (includes newline boundary: since
403 // line_total = line_bytes + 1, there's no integer between line_bytes
404 // and line_total, so <= catches both content and newline positions)
405 let col = byte_to_char_offset(line, remaining);
406 return Position::new(line_idx, col);
407 }
408
409 remaining -= line_total;
410 }
411
412 // Past end of buffer
413 let last_line = self.lines.len().saturating_sub(1);
414 let last_col = self.lines.last().map_or(0, |l| l.chars().count());
415 Position::new(last_line, last_col)
416 }
417
418 // === Helper Methods ===
419
420 /// Clamp a position to valid buffer coordinates.
421 #[must_use]
422 fn clamp_position(&self, pos: Position) -> Position {
423 if self.lines.is_empty() {
424 return Position::origin();
425 }
426
427 let line = pos.line.min(self.lines.len() - 1);
428 let max_col = self.lines[line].chars().count();
429 let column = pos.column.min(max_col);
430
431 Position::new(line, column)
432 }
433
434 /// Count characters between two positions.
435 #[cfg_attr(coverage_nightly, coverage(off))]
436 fn char_count_between(&self, start: Position, end: Position) -> usize {
437 if start >= end {
438 return 0;
439 }
440
441 if start.line == end.line {
442 return end.column.saturating_sub(start.column);
443 }
444
445 let mut count = 0;
446
447 // Characters from start to end of first line + newline
448 if let Some(first_line) = self.lines.get(start.line) {
449 count += first_line.chars().count() - start.column + 1; // +1 for newline
450 }
451
452 // Full lines in between
453 for line_idx in start.line + 1..end.line {
454 if let Some(line) = self.lines.get(line_idx) {
455 count += line.chars().count() + 1; // +1 for newline
456 }
457 }
458
459 // Characters in last line
460 count += end.column;
461
462 count
463 }
464}
465
466impl Default for Buffer {
467 fn default() -> Self {
468 Self::new()
469 }
470}
471
472// === Helper Functions ===
473
474/// Convert a character offset to a byte offset within a line.
475fn char_to_byte_offset(line: &str, char_offset: usize) -> usize {
476 line.char_indices()
477 .nth(char_offset)
478 .map_or(line.len(), |(byte_idx, _)| byte_idx)
479}
480
481/// Convert a byte offset to a character offset within a line.
482fn byte_to_char_offset(line: &str, byte_offset: usize) -> usize {
483 line.char_indices()
484 .take_while(|(byte_idx, _)| *byte_idx < byte_offset)
485 .count()
486}
487
488#[cfg(test)]
489#[path = "tests/buffer.rs"]
490mod tests;