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 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;