Skip to main content

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;