Skip to main content

escriba_buffer/
buffer.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use escriba_core::{BufferId, Edit, EditKind, Position, Range};
5use ropey::Rope;
6use serde::{Deserialize, Serialize};
7
8use crate::encoding::Encoding;
9use crate::error::BufferError;
10use crate::line_ending::LineEnding;
11use crate::undo::{UndoEntry, UndoTree};
12
13/// A single open text buffer.
14/// A monotonic counter of TEXT changes to one buffer.
15///
16/// Deliberately distinct from `escriba-core`'s `EditGen`, which is a REFRESH
17/// generation: that one bumps on every action, including pure cursor moves,
18/// because its job is telling the renderer when to repaint. Keying staleness
19/// on it would mark every cached offset stale after any keypress — noise, not
20/// staleness.
21///
22/// This one bumps if and only if the rope changed, which is the question an
23/// offset actually needs answered: "is the text I was measured against still
24/// the text?"
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
26pub struct TextRev(pub u64);
27
28impl TextRev {
29    #[must_use]
30    pub const fn next(self) -> Self {
31        Self(self.0.wrapping_add(1))
32    }
33}
34
35#[derive(Debug, Clone)]
36pub struct Buffer {
37    pub id: BufferId,
38    pub path: Option<PathBuf>,
39    pub rope: Rope,
40    pub modified: bool,
41    pub encoding: Encoding,
42    pub line_ending: LineEnding,
43    pub undo: UndoTree,
44    /// Bumped by every successful [`Buffer::apply`]. Private so it can only
45    /// advance through a real mutation — a settable revision is a revision
46    /// that lies.
47    text_rev: TextRev,
48}
49
50impl Buffer {
51    #[must_use]
52    pub fn empty(id: BufferId) -> Self {
53        Self {
54            id,
55            path: None,
56            rope: Rope::new(),
57            modified: false,
58            encoding: Encoding::default(),
59            line_ending: LineEnding::default(),
60            undo: UndoTree::new(),
61            text_rev: TextRev::default(),
62        }
63    }
64
65    /// This buffer's current text revision — the token an offset measured
66    /// against it should carry.
67    #[must_use]
68    pub const fn text_rev(&self) -> TextRev {
69        self.text_rev
70    }
71
72    #[must_use]
73    pub fn from_str(id: BufferId, src: &str) -> Self {
74        let line_ending = LineEnding::detect(src);
75        Self {
76            id,
77            path: None,
78            rope: Rope::from_str(src),
79            modified: false,
80            encoding: Encoding::default(),
81            line_ending,
82            undo: UndoTree::new(),
83            text_rev: TextRev::default(),
84        }
85    }
86
87    pub fn open(id: BufferId, path: impl AsRef<Path>) -> Result<Self, BufferError> {
88        let path = path.as_ref().to_path_buf();
89        let src = if path.exists() {
90            std::fs::read_to_string(&path)?
91        } else {
92            String::new()
93        };
94        let line_ending = LineEnding::detect(&src);
95        Ok(Self {
96            id,
97            path: Some(path),
98            rope: Rope::from_str(&src),
99            modified: false,
100            encoding: Encoding::default(),
101            line_ending,
102            undo: UndoTree::new(),
103            text_rev: TextRev::default(),
104        })
105    }
106
107    pub fn save(&mut self) -> Result<(), BufferError> {
108        let path = self.path.clone().ok_or(BufferError::NoPath)?;
109        let mut src = String::new();
110        for line in self.rope.lines() {
111            src.push_str(&line.to_string());
112        }
113        std::fs::write(&path, src)?;
114        self.modified = false;
115        Ok(())
116    }
117
118    pub fn save_as(&mut self, path: impl AsRef<Path>) -> Result<(), BufferError> {
119        self.path = Some(path.as_ref().to_path_buf());
120        self.save()
121    }
122
123    // ── Queries ────────────────────────────────────────────────────
124
125    #[must_use]
126    pub fn line_count(&self) -> u32 {
127        u32::try_from(self.rope.len_lines()).unwrap_or(u32::MAX)
128    }
129
130    #[must_use]
131    pub fn byte_count(&self) -> usize {
132        self.rope.len_bytes()
133    }
134
135    #[must_use]
136    pub fn char_count(&self) -> usize {
137        self.rope.len_chars()
138    }
139
140    pub fn line(&self, n: u32) -> Option<String> {
141        let n = n as usize;
142        if n >= self.rope.len_lines() {
143            return None;
144        }
145        Some(self.rope.line(n).to_string())
146    }
147
148    pub fn line_len_chars(&self, n: u32) -> u32 {
149        let n = n as usize;
150        if n >= self.rope.len_lines() {
151            return 0;
152        }
153        let line = self.rope.line(n);
154        let mut len = line.len_chars();
155        // Strip trailing newline from char count for cursor-placement purposes.
156        if line.chars().last().is_some_and(|c| c == '\n' || c == '\r') {
157            len = len.saturating_sub(1);
158        }
159        u32::try_from(len).unwrap_or(u32::MAX)
160    }
161
162    /// Clamp a position to valid coordinates inside this buffer.
163    #[must_use]
164    pub fn clamp(&self, pos: Position) -> Position {
165        let line = pos.line.min(self.line_count().saturating_sub(1));
166        let col = pos.column.min(self.line_len_chars(line));
167        Position::new(line, col)
168    }
169
170    pub fn position_to_char(&self, pos: Position) -> Result<usize, BufferError> {
171        let line = pos.line as usize;
172        if line >= self.rope.len_lines() {
173            return Err(BufferError::InvalidPosition {
174                line: pos.line,
175                column: pos.column,
176                total_lines: self.line_count(),
177            });
178        }
179        let line_start = self.rope.line_to_char(line);
180        let line_slice = self.rope.line(line);
181        let max_col = line_slice.len_chars();
182        let col = (pos.column as usize).min(max_col);
183        Ok(line_start + col)
184    }
185
186    #[must_use]
187    pub fn char_to_position(&self, ch: usize) -> Position {
188        let line = self.rope.char_to_line(ch.min(self.rope.len_chars()));
189        let line_start = self.rope.line_to_char(line);
190        let col = ch.saturating_sub(line_start);
191        Position::new(
192            u32::try_from(line).unwrap_or(u32::MAX),
193            u32::try_from(col).unwrap_or(u32::MAX),
194        )
195    }
196
197    pub fn slice(&self, range: Range) -> Result<String, BufferError> {
198        let r = range.normalized();
199        let a = self.position_to_char(r.start)?;
200        let b = self.position_to_char(r.end)?;
201        Ok(self.rope.slice(a..b).to_string())
202    }
203
204    // ── Mutation ───────────────────────────────────────────────────
205
206    pub fn apply(&mut self, edit: &Edit) -> Result<UndoEntry, BufferError> {
207        let range = edit.range.normalized();
208        let start_char = self.position_to_char(range.start)?;
209        let end_char = self.position_to_char(range.end)?;
210        let previous_text = self.rope.slice(start_char..end_char).to_string();
211
212        // The rope is about to change, so every offset measured against the
213        // old text expires here. Bumping AFTER the fallible prelude means a
214        // rejected edit does not invalidate anything.
215        self.text_rev = self.text_rev.next();
216
217        let (inserted_len_chars, reverse_kind) = match &edit.kind {
218            EditKind::Insert { text } => {
219                self.rope.insert(start_char, text);
220                (text.chars().count(), EditKind::Delete)
221            }
222            EditKind::Delete => {
223                self.rope.remove(start_char..end_char);
224                (
225                    0usize,
226                    EditKind::Insert {
227                        text: previous_text.clone(),
228                    },
229                )
230            }
231            EditKind::Replace { text } => {
232                self.rope.remove(start_char..end_char);
233                self.rope.insert(start_char, text);
234                (
235                    text.chars().count(),
236                    EditKind::Replace {
237                        text: previous_text.clone(),
238                    },
239                )
240            }
241        };
242        self.modified = true;
243
244        // Compute the reverse edit's range — where the insertion now sits.
245        let reverse_end_char = start_char + inserted_len_chars;
246        let reverse_range = Range::new(
247            self.char_to_position(start_char),
248            self.char_to_position(reverse_end_char),
249        );
250        let reverse_edit = Edit {
251            range: reverse_range,
252            kind: reverse_kind,
253        };
254        let entry = UndoEntry {
255            applied: edit.clone(),
256            reverse: reverse_edit,
257        };
258        self.undo.push(entry.clone());
259        Ok(entry)
260    }
261
262    pub fn undo(&mut self) -> Result<UndoEntry, BufferError> {
263        let entry = self.undo.pop_undo().ok_or(BufferError::NothingToUndo)?;
264        // Apply reverse edit without pushing a new undo entry.
265        let r = entry.reverse.range.normalized();
266        let a = self.position_to_char(r.start)?;
267        let b = self.position_to_char(r.end)?;
268        // undo/redo change the TEXT, so every offset measured against the old
269        // text expires here too — `apply` bumping alone was not enough: an
270        // ordinal anchored before an undo read as FRESH and displayed a
271        // position in the previous match set.
272        //
273        // BEFORE the match, not inside an arm: the first attempt at this fix
274        // landed in the `Insert` arm only, so undoing an insert (whose reverse
275        // is a Delete) still did not bump. After the fallible prelude, so a
276        // rejected undo invalidates nothing.
277        self.text_rev = self.text_rev.next();
278        match &entry.reverse.kind {
279            EditKind::Insert { text } => {
280                self.rope.insert(a, text);
281            }
282            EditKind::Delete => {
283                self.rope.remove(a..b);
284            }
285            EditKind::Replace { text } => {
286                self.rope.remove(a..b);
287                self.rope.insert(a, text);
288            }
289        }
290        self.modified = true;
291        Ok(entry)
292    }
293
294    pub fn redo(&mut self) -> Result<UndoEntry, BufferError> {
295        let entry = self.undo.pop_redo().ok_or(BufferError::NothingToRedo)?;
296        let r = entry.applied.range.normalized();
297        let a = self.position_to_char(r.start)?;
298        let b = self.position_to_char(r.end)?;
299        // undo/redo change the TEXT, so every offset measured against the old
300        // text expires here too — `apply` bumping alone was not enough: an
301        // ordinal anchored before an undo read as FRESH and displayed a
302        // position in the previous match set.
303        //
304        // BEFORE the match, not inside an arm: the first attempt at this fix
305        // landed in the `Insert` arm only, so undoing an insert (whose reverse
306        // is a Delete) still did not bump. After the fallible prelude, so a
307        // rejected undo invalidates nothing.
308        self.text_rev = self.text_rev.next();
309        match &entry.applied.kind {
310            EditKind::Insert { text } => {
311                self.rope.insert(a, text);
312            }
313            EditKind::Delete => {
314                self.rope.remove(a..b);
315            }
316            EditKind::Replace { text } => {
317                self.rope.remove(a..b);
318                self.rope.insert(a, text);
319            }
320        }
321        self.modified = true;
322        Ok(entry)
323    }
324
325    #[must_use]
326    pub fn to_string(&self) -> String {
327        self.rope.to_string()
328    }
329}
330
331/// A registry of open buffers keyed by id — phase 1 single-threaded view.
332#[derive(Debug, Default, Clone)]
333pub struct BufferSet {
334    buffers: HashMap<BufferId, Buffer>,
335    next_id: u64,
336}
337
338impl BufferSet {
339    #[must_use]
340    pub fn new() -> Self {
341        Self::default()
342    }
343
344    pub fn next_id(&mut self) -> BufferId {
345        self.next_id += 1;
346        BufferId(self.next_id)
347    }
348
349    pub fn open(&mut self, path: impl AsRef<Path>) -> Result<BufferId, BufferError> {
350        let id = self.next_id();
351        let buf = Buffer::open(id, path)?;
352        self.buffers.insert(id, buf);
353        Ok(id)
354    }
355
356    pub fn scratch(&mut self, src: &str) -> BufferId {
357        let id = self.next_id();
358        self.buffers.insert(id, Buffer::from_str(id, src));
359        id
360    }
361
362    #[must_use]
363    pub fn get(&self, id: BufferId) -> Option<&Buffer> {
364        self.buffers.get(&id)
365    }
366
367    pub fn get_mut(&mut self, id: BufferId) -> Option<&mut Buffer> {
368        self.buffers.get_mut(&id)
369    }
370
371    #[must_use]
372    pub fn ids(&self) -> Vec<BufferId> {
373        let mut v: Vec<_> = self.buffers.keys().copied().collect();
374        v.sort();
375        v
376    }
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct BufferSummary {
381    pub id: BufferId,
382    pub path: Option<PathBuf>,
383    pub line_count: u32,
384    pub modified: bool,
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use escriba_core::{Edit, Position, Range};
391
392    fn buf(src: &str) -> Buffer {
393        Buffer::from_str(BufferId(1), src)
394    }
395
396    #[test]
397    fn empty_has_one_line() {
398        let b = Buffer::empty(BufferId(1));
399        assert_eq!(b.line_count(), 1);
400    }
401
402    #[test]
403    fn line_counts() {
404        let b = buf("a\nb\nc\n");
405        assert_eq!(b.line_count(), 4); // trailing \n leaves a final empty line
406    }
407
408    #[test]
409    fn position_char_round_trip() {
410        let b = buf("hello\nworld\n");
411        let p = Position::new(1, 3);
412        let c = b.position_to_char(p).unwrap();
413        assert_eq!(b.char_to_position(c), p);
414    }
415
416    #[test]
417    fn insert_then_undo() {
418        let mut b = buf("hello");
419        let e = Edit::insert(Position::new(0, 5), " world");
420        b.apply(&e).unwrap();
421        assert_eq!(b.to_string(), "hello world");
422        b.undo().unwrap();
423        assert_eq!(b.to_string(), "hello");
424    }
425
426    #[test]
427    fn delete_then_redo() {
428        let mut b = buf("hello world");
429        let e = Edit::delete(Range::new(Position::new(0, 5), Position::new(0, 11)));
430        b.apply(&e).unwrap();
431        assert_eq!(b.to_string(), "hello");
432        b.undo().unwrap();
433        assert_eq!(b.to_string(), "hello world");
434        b.redo().unwrap();
435        assert_eq!(b.to_string(), "hello");
436    }
437
438    #[test]
439    fn replace_is_delete_plus_insert() {
440        let mut b = buf("hello world");
441        let e = Edit::replace(
442            Range::new(Position::new(0, 6), Position::new(0, 11)),
443            "tatara",
444        );
445        b.apply(&e).unwrap();
446        assert_eq!(b.to_string(), "hello tatara");
447        b.undo().unwrap();
448        assert_eq!(b.to_string(), "hello world");
449    }
450
451    #[test]
452    fn clamp_constrains_position() {
453        let b = buf("ab\ncd");
454        assert_eq!(b.line_count(), 2);
455        assert_eq!(b.clamp(Position::new(0, 99)), Position::new(0, 2));
456        assert_eq!(b.clamp(Position::new(99, 0)), Position::new(1, 0));
457    }
458
459    #[test]
460    fn slice_returns_text() {
461        let b = buf("hello world");
462        let s = b
463            .slice(Range::new(Position::new(0, 6), Position::new(0, 11)))
464            .unwrap();
465        assert_eq!(s, "world");
466    }
467
468    #[test]
469    fn save_round_trip() {
470        let dir = tempfile::tempdir().unwrap();
471        let path = dir.path().join("demo.txt");
472        let mut b = Buffer::from_str(BufferId(1), "hello\n");
473        b.save_as(&path).unwrap();
474        let b2 = Buffer::open(BufferId(2), &path).unwrap();
475        assert_eq!(b2.to_string(), "hello\n");
476    }
477
478    #[test]
479    fn buffer_set_tracks_ids() {
480        let mut set = BufferSet::new();
481        let a = set.scratch("one");
482        let b = set.scratch("two");
483        assert_ne!(a, b);
484        assert_eq!(set.ids().len(), 2);
485        assert_eq!(set.get(a).unwrap().to_string(), "one");
486    }
487}
488
489#[cfg(test)]
490mod text_rev_tests {
491    use super::*;
492    use escriba_core::{Edit, Position, Range};
493
494    fn buf(src: &str) -> Buffer {
495        Buffer::from_str(BufferId(0), src)
496    }
497
498    #[test]
499    fn a_fresh_buffer_starts_at_revision_zero() {
500        assert_eq!(buf("hello").text_rev(), TextRev(0));
501    }
502
503    #[test]
504    fn an_applied_edit_advances_the_revision() {
505        let mut b = buf("hello");
506        let before = b.text_rev();
507        b.apply(&Edit::insert(Position::new(0, 0), "X".to_string()))
508            .expect("insert applies");
509        assert_ne!(
510            b.text_rev(),
511            before,
512            "a text change must expire old offsets"
513        );
514    }
515
516    #[test]
517    fn each_edit_advances_it_again() {
518        let mut b = buf("hello");
519        let mut seen = vec![b.text_rev()];
520        for _ in 0..3 {
521            b.apply(&Edit::insert(Position::new(0, 0), "X".to_string()))
522                .expect("insert applies");
523            let now = b.text_rev();
524            assert!(!seen.contains(&now), "revisions must not repeat: {now:?}");
525            seen.push(now);
526        }
527    }
528
529    #[test]
530    fn a_rejected_edit_does_not_advance_the_revision() {
531        // The property that makes this usable for staleness: an edit that never
532        // touched the rope must not invalidate offsets that are still correct.
533        let mut b = buf("hello");
534        let before = b.text_rev();
535        let out_of_range = Range {
536            start: Position::new(99, 0),
537            end: Position::new(99, 1),
538        };
539        assert!(
540            b.apply(&Edit::delete(out_of_range)).is_err(),
541            "the edit must fail"
542        );
543        assert_eq!(b.text_rev(), before, "a failed edit changed no text");
544    }
545
546    #[test]
547    fn reading_the_buffer_does_not_advance_the_revision() {
548        // Contrast with `EditGen`, which bumps on every action. If merely
549        // looking moved this counter it would be a refresh generation again.
550        let b = buf("hello");
551        let before = b.text_rev();
552        let _ = b.to_string();
553        let _ = b.text_rev();
554        assert_eq!(b.text_rev(), before);
555    }
556}
557
558#[cfg(test)]
559mod undo_rev_tests {
560    use super::*;
561    use escriba_core::{Edit, Position};
562
563    #[test]
564    fn undo_and_redo_advance_the_revision() {
565        // They mutate the rope, so they expire offsets exactly as `apply`
566        // does. The first fix for this landed inside one match arm only, so
567        // undoing an INSERT — whose reverse is a Delete — still did not bump.
568        let mut b = Buffer::from_str(BufferId(0), "hello");
569        b.apply(&Edit::insert(Position::new(0, 0), "X".to_string()))
570            .expect("insert applies");
571        let after_edit = b.text_rev();
572
573        b.undo().expect("undo applies");
574        assert_ne!(b.text_rev(), after_edit, "undo must advance the revision");
575        let after_undo = b.text_rev();
576
577        b.redo().expect("redo applies");
578        assert_ne!(b.text_rev(), after_undo, "redo must advance it again");
579    }
580
581    #[test]
582    fn a_rejected_undo_does_not_advance_the_revision() {
583        let mut b = Buffer::from_str(BufferId(0), "hello");
584        let before = b.text_rev();
585        assert!(b.undo().is_err(), "nothing to undo");
586        assert_eq!(b.text_rev(), before, "a failed undo changed no text");
587    }
588}