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    /// The buffer already holding `path`, if one is open.
350    ///
351    /// DERIVED, deliberately — not a cached `HashMap<PathBuf, BufferId>`.
352    /// An index would be a second source of truth for "which buffer holds
353    /// this file", and [`Buffer::save_as`] is reachable through
354    /// [`get_mut`](Self::get_mut), so that index could silently desync the
355    /// moment anyone renames a buffer. A scan over the open buffers cannot
356    /// desync because there is nothing to keep in sync; an editor holds tens
357    /// of buffers, not millions, so the cost is noise.
358    ///
359    /// Comparison is canonical where the filesystem allows it, so `./foo.rs`,
360    /// `foo.rs` and an absolute path are one file. A path that cannot be
361    /// canonicalised (it does not exist yet — opening a NEW file) falls back
362    /// to a literal comparison, which is the best answer available.
363    #[must_use]
364    pub fn find_by_path(&self, path: impl AsRef<Path>) -> Option<BufferId> {
365        let want = path.as_ref();
366        let want_canon = std::fs::canonicalize(want).ok();
367        self.buffers
368            .iter()
369            .find(|(_, b)| {
370                let Some(have) = b.path.as_deref() else {
371                    return false;
372                };
373                match (&want_canon, std::fs::canonicalize(have).ok()) {
374                    (Some(a), Some(b)) => *a == b,
375                    // Either side unresolvable: fall back to the literal
376                    // path. Never claim a match on weaker evidence than that.
377                    _ => have == want,
378                }
379            })
380            .map(|(id, _)| *id)
381    }
382
383    /// Open `path`, or return the buffer already holding it.
384    ///
385    /// This USED to mint a new id unconditionally, so opening the same file
386    /// twice produced two independent buffers with divergent undo stacks and
387    /// modified flags — edits split between them, and whichever saved last
388    /// won. It was latent only because nothing but the initial CLI argument
389    /// called it; a picker, `files.open`, or a goto-definition makes it
390    /// reachable immediately.
391    ///
392    /// Tier: **only-mitigated**, and honestly so. Duplicate-by-`open` is now
393    /// unreachable, but `BufferSet` does not own path assignment — a caller
394    /// holding two `get_mut` handles can still `save_as` them onto one path.
395    /// Making that unrepresentable means moving path mutation behind this
396    /// type, which belongs with the `madoguchi` dispatch seam (see
397    /// `docs/backlog-plan.md` §V Phase 1), not here.
398    pub fn open(&mut self, path: impl AsRef<Path>) -> Result<BufferId, BufferError> {
399        let path = path.as_ref();
400        if let Some(existing) = self.find_by_path(path) {
401            return Ok(existing);
402        }
403        let id = self.next_id();
404        let buf = Buffer::open(id, path)?;
405        self.buffers.insert(id, buf);
406        Ok(id)
407    }
408
409    pub fn scratch(&mut self, src: &str) -> BufferId {
410        let id = self.next_id();
411        self.buffers.insert(id, Buffer::from_str(id, src));
412        id
413    }
414
415    /// Remove a buffer, returning it.
416    ///
417    /// `None` when no such buffer is open. Deliberately says nothing about
418    /// which buffer becomes active next, or about refusing to close the last
419    /// one: `BufferSet` does not own `active`, and a type that invented a
420    /// policy it cannot enforce would be a second, weaker source of truth.
421    /// The interpreter owns both, and keeps "there is always an active
422    /// buffer" true by opening a scratch when the set empties.
423    ///
424    /// Unsaved changes are NOT checked here either, for the same reason —
425    /// whether a modified buffer may close is policy.
426    pub fn close(&mut self, id: BufferId) -> Option<Buffer> {
427        self.buffers.remove(&id)
428    }
429
430    #[must_use]
431    pub fn get(&self, id: BufferId) -> Option<&Buffer> {
432        self.buffers.get(&id)
433    }
434
435    pub fn get_mut(&mut self, id: BufferId) -> Option<&mut Buffer> {
436        self.buffers.get_mut(&id)
437    }
438
439    #[must_use]
440    pub fn ids(&self) -> Vec<BufferId> {
441        let mut v: Vec<_> = self.buffers.keys().copied().collect();
442        v.sort();
443        v
444    }
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct BufferSummary {
449    pub id: BufferId,
450    pub path: Option<PathBuf>,
451    pub line_count: u32,
452    pub modified: bool,
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use escriba_core::{Edit, Position, Range};
459
460    fn buf(src: &str) -> Buffer {
461        Buffer::from_str(BufferId(1), src)
462    }
463
464    #[test]
465    fn empty_has_one_line() {
466        let b = Buffer::empty(BufferId(1));
467        assert_eq!(b.line_count(), 1);
468    }
469
470    #[test]
471    fn line_counts() {
472        let b = buf("a\nb\nc\n");
473        assert_eq!(b.line_count(), 4); // trailing \n leaves a final empty line
474    }
475
476    #[test]
477    fn position_char_round_trip() {
478        let b = buf("hello\nworld\n");
479        let p = Position::new(1, 3);
480        let c = b.position_to_char(p).unwrap();
481        assert_eq!(b.char_to_position(c), p);
482    }
483
484    #[test]
485    fn insert_then_undo() {
486        let mut b = buf("hello");
487        let e = Edit::insert(Position::new(0, 5), " world");
488        b.apply(&e).unwrap();
489        assert_eq!(b.to_string(), "hello world");
490        b.undo().unwrap();
491        assert_eq!(b.to_string(), "hello");
492    }
493
494    #[test]
495    fn delete_then_redo() {
496        let mut b = buf("hello world");
497        let e = Edit::delete(Range::new(Position::new(0, 5), Position::new(0, 11)));
498        b.apply(&e).unwrap();
499        assert_eq!(b.to_string(), "hello");
500        b.undo().unwrap();
501        assert_eq!(b.to_string(), "hello world");
502        b.redo().unwrap();
503        assert_eq!(b.to_string(), "hello");
504    }
505
506    #[test]
507    fn replace_is_delete_plus_insert() {
508        let mut b = buf("hello world");
509        let e = Edit::replace(
510            Range::new(Position::new(0, 6), Position::new(0, 11)),
511            "tatara",
512        );
513        b.apply(&e).unwrap();
514        assert_eq!(b.to_string(), "hello tatara");
515        b.undo().unwrap();
516        assert_eq!(b.to_string(), "hello world");
517    }
518
519    #[test]
520    fn clamp_constrains_position() {
521        let b = buf("ab\ncd");
522        assert_eq!(b.line_count(), 2);
523        assert_eq!(b.clamp(Position::new(0, 99)), Position::new(0, 2));
524        assert_eq!(b.clamp(Position::new(99, 0)), Position::new(1, 0));
525    }
526
527    #[test]
528    fn slice_returns_text() {
529        let b = buf("hello world");
530        let s = b
531            .slice(Range::new(Position::new(0, 6), Position::new(0, 11)))
532            .unwrap();
533        assert_eq!(s, "world");
534    }
535
536    #[test]
537    fn save_round_trip() {
538        let dir = tempfile::tempdir().unwrap();
539        let path = dir.path().join("demo.txt");
540        let mut b = Buffer::from_str(BufferId(1), "hello\n");
541        b.save_as(&path).unwrap();
542        let b2 = Buffer::open(BufferId(2), &path).unwrap();
543        assert_eq!(b2.to_string(), "hello\n");
544    }
545
546    #[test]
547    fn buffer_set_tracks_ids() {
548        let mut set = BufferSet::new();
549        let a = set.scratch("one");
550        let b = set.scratch("two");
551        assert_ne!(a, b);
552        assert_eq!(set.ids().len(), 2);
553        assert_eq!(set.get(a).unwrap().to_string(), "one");
554    }
555}
556
557#[cfg(test)]
558mod text_rev_tests {
559    use super::*;
560    use escriba_core::{Edit, Position, Range};
561
562    fn buf(src: &str) -> Buffer {
563        Buffer::from_str(BufferId(0), src)
564    }
565
566    #[test]
567    fn a_fresh_buffer_starts_at_revision_zero() {
568        assert_eq!(buf("hello").text_rev(), TextRev(0));
569    }
570
571    #[test]
572    fn an_applied_edit_advances_the_revision() {
573        let mut b = buf("hello");
574        let before = b.text_rev();
575        b.apply(&Edit::insert(Position::new(0, 0), "X".to_string()))
576            .expect("insert applies");
577        assert_ne!(
578            b.text_rev(),
579            before,
580            "a text change must expire old offsets"
581        );
582    }
583
584    #[test]
585    fn each_edit_advances_it_again() {
586        let mut b = buf("hello");
587        let mut seen = vec![b.text_rev()];
588        for _ in 0..3 {
589            b.apply(&Edit::insert(Position::new(0, 0), "X".to_string()))
590                .expect("insert applies");
591            let now = b.text_rev();
592            assert!(!seen.contains(&now), "revisions must not repeat: {now:?}");
593            seen.push(now);
594        }
595    }
596
597    #[test]
598    fn a_rejected_edit_does_not_advance_the_revision() {
599        // The property that makes this usable for staleness: an edit that never
600        // touched the rope must not invalidate offsets that are still correct.
601        let mut b = buf("hello");
602        let before = b.text_rev();
603        let out_of_range = Range {
604            start: Position::new(99, 0),
605            end: Position::new(99, 1),
606        };
607        assert!(
608            b.apply(&Edit::delete(out_of_range)).is_err(),
609            "the edit must fail"
610        );
611        assert_eq!(b.text_rev(), before, "a failed edit changed no text");
612    }
613
614    #[test]
615    fn reading_the_buffer_does_not_advance_the_revision() {
616        // Contrast with `EditGen`, which bumps on every action. If merely
617        // looking moved this counter it would be a refresh generation again.
618        let b = buf("hello");
619        let before = b.text_rev();
620        let _ = b.to_string();
621        let _ = b.text_rev();
622        assert_eq!(b.text_rev(), before);
623    }
624}
625
626#[cfg(test)]
627mod undo_rev_tests {
628    use super::*;
629    use escriba_core::{Edit, Position};
630
631    #[test]
632    fn undo_and_redo_advance_the_revision() {
633        // They mutate the rope, so they expire offsets exactly as `apply`
634        // does. The first fix for this landed inside one match arm only, so
635        // undoing an INSERT — whose reverse is a Delete — still did not bump.
636        let mut b = Buffer::from_str(BufferId(0), "hello");
637        b.apply(&Edit::insert(Position::new(0, 0), "X".to_string()))
638            .expect("insert applies");
639        let after_edit = b.text_rev();
640
641        b.undo().expect("undo applies");
642        assert_ne!(b.text_rev(), after_edit, "undo must advance the revision");
643        let after_undo = b.text_rev();
644
645        b.redo().expect("redo applies");
646        assert_ne!(b.text_rev(), after_undo, "redo must advance it again");
647    }
648
649    #[test]
650    fn a_rejected_undo_does_not_advance_the_revision() {
651        let mut b = Buffer::from_str(BufferId(0), "hello");
652        let before = b.text_rev();
653        assert!(b.undo().is_err(), "nothing to undo");
654        assert_eq!(b.text_rev(), before, "a failed undo changed no text");
655    }
656}