Skip to main content

typ_buffer/
buffer.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use ropey::Rope;
5use unicode_segmentation::UnicodeSegmentation;
6
7use crate::position::Position;
8use crate::search::SearchQuery;
9use crate::selection::{Selection, Selections};
10use crate::undo::{EditKind, History};
11
12pub struct TextBuffer {
13    rope: Rope,
14    path: Option<PathBuf>,
15    dirty: bool,
16    history: History,
17    /// Nesting depth of `begin_edit_group`. While non-zero, individual edits
18    /// stop taking their own snapshots, so a multi-caret edit is one undo step
19    /// rather than one per cursor.
20    group_depth: usize,
21}
22
23impl TextBuffer {
24    // Named to match `Rope::from_str`, not the `FromStr` trait: construction is
25    // infallible, so a `Result`-returning trait impl would be the wrong shape.
26    #[allow(clippy::should_implement_trait)]
27    pub fn from_str(s: &str) -> Self {
28        Self {
29            rope: Rope::from_str(s),
30            path: None,
31            dirty: false,
32            history: History::default(),
33            group_depth: 0,
34        }
35    }
36
37    pub fn from_path(path: &Path) -> Result<Self> {
38        let text =
39            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
40        Ok(Self {
41            rope: Rope::from_str(&text),
42            path: Some(path.to_path_buf()),
43            dirty: false,
44            history: History::default(),
45            group_depth: 0,
46        })
47    }
48
49    pub fn line_count(&self) -> usize {
50        self.rope.len_lines()
51    }
52
53    pub fn path(&self) -> Option<&Path> {
54        self.path.as_deref()
55    }
56
57    pub fn is_dirty(&self) -> bool {
58        self.dirty
59    }
60
61    /// Call `f` with one line's text, borrowed from the rope when possible.
62    ///
63    /// `RopeSlice::as_str` succeeds whenever the line lives inside a single
64    /// chunk, which is the overwhelmingly common case — ropey chunks are ~1 KB
65    /// and lines of code are not. Only a line straddling a chunk boundary pays
66    /// for a `String`.
67    ///
68    /// This exists because `line_text` returning an owned `String` was correct
69    /// but quadratic in aggregate: three callers looped it over every line in
70    /// the buffer, so one keystroke on a 50k-line file allocated 50k strings.
71    /// A borrowing accessor makes the cheap thing the easy thing to reach for.
72    pub fn with_line_str<T>(&self, line: usize, f: impl FnOnce(&str) -> T) -> T {
73        if line >= self.rope.len_lines() {
74            return f("");
75        }
76        with_slice_str(self.rope.line(line), f)
77    }
78
79    /// Line contents without the trailing newline.
80    ///
81    /// Allocates. Prefer `with_line_str` in anything that runs per line over a
82    /// range of lines.
83    pub fn line_text(&self, line: usize) -> String {
84        self.with_line_str(line, str::to_string)
85    }
86
87    /// Graphemes on a line, without materializing it.
88    pub fn line_grapheme_count(&self, line: usize) -> usize {
89        self.with_line_str(line, |s| s.graphemes(true).count())
90    }
91
92    /// Absolute char offset of a `Position`, clamping out-of-range input.
93    fn char_offset(&self, pos: Position) -> usize {
94        let line = pos.line.min(self.rope.len_lines().saturating_sub(1));
95        let line_start = self.rope.line_to_char(line);
96        let chars_before: usize = self.with_line_str(line, |text| {
97            text.graphemes(true)
98                .take(pos.col)
99                .map(|g| g.chars().count())
100                .sum()
101        });
102        line_start + chars_before
103    }
104
105    pub fn insert_char(&mut self, pos: Position, ch: char) {
106        self.record_snapshot(pos);
107        let offset = self.char_offset(pos);
108        self.rope.insert_char(offset, ch);
109        self.dirty = true;
110    }
111
112    /// Delete the grapheme immediately before `pos` (backspace).
113    pub fn delete_before(&mut self, pos: Position) {
114        let offset = self.char_offset(pos);
115        if offset == 0 {
116            return;
117        }
118        let n = if pos.col == 0 {
119            1 // joining with the previous line: remove the newline
120        } else {
121            self.with_line_str(pos.line, |text| {
122                text.graphemes(true)
123                    .nth(pos.col - 1)
124                    .map_or(1, |g| g.chars().count())
125            })
126        };
127        self.record_snapshot(pos);
128        self.rope.remove(offset - n..offset);
129        self.dirty = true;
130    }
131
132    /// Delete the grapheme at `pos` (forward delete).
133    ///
134    /// At the end of a line this removes the newline, joining the next line up.
135    pub fn delete_after(&mut self, pos: Position) {
136        let offset = self.char_offset(pos);
137        if offset >= self.rope.len_chars() {
138            return;
139        }
140        let n = self.with_line_str(pos.line, |text| {
141            text.graphemes(true)
142                .nth(pos.col)
143                .map_or(1, |g| g.chars().count())
144        });
145        self.record_snapshot(pos);
146        self.rope.remove(offset..offset + n);
147        self.dirty = true;
148    }
149
150    /// Every match in the buffer, in document order, as selections whose head
151    /// sits at the end of the match — so jumping to one leaves the cursor
152    /// where typing would naturally continue.
153    pub fn find_all(&self, query: &SearchQuery) -> Vec<Selection> {
154        // Split once for the whole buffer, not once per line.
155        let needle: Vec<&str> = query.needle.graphemes(true).collect();
156
157        let mut hits = Vec::new();
158        // `rope.lines()` walks the tree once. Indexing `rope.line(i)` in a loop
159        // instead is a fresh O(log n) descent per line, which measured at 458 ns
160        // of pure overhead per line — 23 ms across 50k lines before a single
161        // byte of the search ran.
162        for (line, slice) in self.rope.lines().enumerate() {
163            with_slice_str(slice, |text| {
164                for (start, end) in crate::search::find_in_line_with(text, &needle, query) {
165                    hits.push(Selection {
166                        anchor: Position { line, col: start },
167                        head: Position { line, col: end },
168                    });
169                }
170            });
171        }
172        hits
173    }
174
175    /// Replace the text between two positions as a single undo step.
176    ///
177    /// An empty range inserts, so callers can express insertion, deletion and
178    /// replacement as one operation and not branch three ways.
179    pub fn replace_range(&mut self, start: Position, end: Position, text: &str) {
180        let from = self.char_offset(start);
181        let to = self.char_offset(end);
182        if from > to || (from == to && text.is_empty()) {
183            return;
184        }
185        self.record_snapshot(start);
186        if to > from {
187            self.rope.remove(from..to);
188        }
189        if !text.is_empty() {
190            self.rope.insert(from, text);
191        }
192        self.dirty = true;
193    }
194
195    /// Take an undo snapshot unless an edit group is open.
196    ///
197    /// Only the M1-era standalone helpers reach this. They have no selection set
198    /// and no edit kind to offer, so they record as `Other` at a caret placed
199    /// where they are editing — which reproduces their old one-step-per-call
200    /// behavior exactly. M2 Task 12 deletes their last callers.
201    fn record_snapshot(&mut self, at: Position) {
202        if self.group_depth == 0 {
203            let selections = Selections::single(Selection::caret(at));
204            self.history
205                .record(EditKind::Other, self.rope.clone(), &selections);
206        }
207    }
208
209    /// Begin a group of edits that undo together.
210    ///
211    /// One snapshot is taken up front and none during the group, so thirty
212    /// cursors typing one character is one undo step. Without this, undoing a
213    /// thirty-caret edit would take thirty presses and leave the buffer in
214    /// states the user never typed.
215    ///
216    /// Whether that snapshot is actually pushed is `History`'s call: a group
217    /// continuing a run of the same kind folds into the one already there.
218    pub fn begin_edit_group(&mut self, kind: EditKind, selections: &Selections) {
219        if self.group_depth == 0 {
220            self.history.record(kind, self.rope.clone(), selections);
221        }
222        self.group_depth += 1;
223    }
224
225    pub fn end_edit_group(&mut self) {
226        self.group_depth = self.group_depth.saturating_sub(1);
227    }
228
229    /// End the current undo run. The next edit starts a new step.
230    pub fn undo_boundary(&mut self) {
231        self.history.boundary();
232    }
233
234    /// Undo one step, returning the selections to restore.
235    ///
236    /// `None` means there was nothing to undo, so the caller leaves its
237    /// selections alone.
238    pub fn undo(&mut self, current: &Selections) -> Option<Selections> {
239        let snapshot = self.history.undo(self.rope.clone(), current)?;
240        self.rope = snapshot.rope;
241        self.dirty = true;
242        Some(snapshot.selections)
243    }
244
245    pub fn redo(&mut self, current: &Selections) -> Option<Selections> {
246        let snapshot = self.history.redo(self.rope.clone(), current)?;
247        self.rope = snapshot.rope;
248        self.dirty = true;
249        Some(snapshot.selections)
250    }
251
252    /// Write the buffer to disk, atomically.
253    ///
254    /// The content goes to a sibling temporary file, is flushed to the device,
255    /// and is then renamed over the target. `rename` replaces the destination
256    /// in one step on both NTFS and POSIX, so an interrupted save leaves the
257    /// previous file intact rather than a truncated one. Writing in place would
258    /// mean a crash between truncate and write costs the user the whole file
259    /// rather than the last edit.
260    pub fn save(&mut self) -> Result<()> {
261        let path = self
262            .path
263            .as_ref()
264            .context("buffer has no path to save to")?
265            .clone();
266
267        // Same directory, so the rename never crosses a filesystem boundary —
268        // across devices it would silently become a copy, which is not atomic.
269        let temp = temp_path_beside(&path);
270        write_all_and_sync(&temp, &self.rope)
271            .with_context(|| format!("writing {}", temp.display()))?;
272
273        if let Err(e) = std::fs::rename(&temp, &path) {
274            // Leave nothing behind on failure; the original is untouched.
275            let _ = std::fs::remove_file(&temp);
276            return Err(e).with_context(|| format!("replacing {}", path.display()));
277        }
278
279        self.dirty = false;
280        Ok(())
281    }
282
283    /// Point the buffer at another path. Test-only: production code opens a
284    /// new buffer rather than redirecting one.
285    #[doc(hidden)]
286    pub fn set_path_for_test(&mut self, path: PathBuf) {
287        self.path = Some(path);
288    }
289}
290
291/// Call `f` with a line slice's text, borrowed from the rope when possible.
292///
293/// Free-standing rather than a method so callers holding a slice from
294/// `Rope::lines()` can use it without paying for a second lookup by index.
295fn with_slice_str<T>(slice: ropey::RopeSlice, f: impl FnOnce(&str) -> T) -> T {
296    match slice.as_str() {
297        Some(s) => f(trim_line_ending(s)),
298        None => {
299            let owned = slice.to_string();
300            f(trim_line_ending(&owned))
301        }
302    }
303}
304
305/// A line without its terminator. Handles CRLF as one unit rather than as two
306/// separate trims, so a stray `\r` inside a line is left alone.
307fn trim_line_ending(s: &str) -> &str {
308    s.strip_suffix('\n')
309        .map(|s| s.strip_suffix('\r').unwrap_or(s))
310        .unwrap_or(s)
311}
312
313/// A sibling of `path` that will not collide with a real file.
314fn temp_path_beside(path: &Path) -> PathBuf {
315    let name = path
316        .file_name()
317        .map(|n| n.to_string_lossy().to_string())
318        .unwrap_or_else(|| "buffer".to_string());
319    let parent = path.parent().unwrap_or(Path::new("."));
320    parent.join(format!(".{name}.typ-tmp"))
321}
322
323/// Write the rope out and flush it to the device before returning.
324///
325/// Without the flush, the rename can be durable while the contents are not —
326/// which produces an empty file after a power loss, the exact failure the
327/// atomic write exists to prevent.
328fn write_all_and_sync(path: &Path, rope: &Rope) -> std::io::Result<()> {
329    use std::io::Write;
330
331    let mut file = std::fs::File::create(path)?;
332    for chunk in rope.chunks() {
333        file.write_all(chunk.as_bytes())?;
334    }
335    file.flush()?;
336    file.sync_all()?;
337    Ok(())
338}