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::line_ending::LineEnding;
8use crate::position::Position;
9use crate::search::SearchQuery;
10use crate::selection::{Selection, Selections};
11use crate::undo::{EditKind, History};
12
13pub struct TextBuffer {
14    rope: Rope,
15    path: Option<PathBuf>,
16    dirty: bool,
17    history: History,
18    /// Nesting depth of `begin_edit_group`. While non-zero, individual edits
19    /// stop taking their own snapshots, so a multi-caret edit is one undo step
20    /// rather than one per cursor.
21    group_depth: usize,
22    /// Detected once at load. Recorded rather than recomputed because editing
23    /// the file must not change the answer — a user deleting the first line
24    /// does not thereby convert the file to LF.
25    line_ending: LineEnding,
26}
27
28impl TextBuffer {
29    // Named to match `Rope::from_str`, not the `FromStr` trait: construction is
30    // infallible, so a `Result`-returning trait impl would be the wrong shape.
31    #[allow(clippy::should_implement_trait)]
32    pub fn from_str(s: &str) -> Self {
33        Self {
34            rope: Rope::from_str(s),
35            path: None,
36            dirty: false,
37            history: History::default(),
38            group_depth: 0,
39            line_ending: LineEnding::detect(s),
40        }
41    }
42
43    /// An empty buffer that will be written to `path` when saved.
44    ///
45    /// A sibling of `from_path` rather than a flag on it, so "read this file"
46    /// keeps meaning exactly that and never quietly invents one.
47    ///
48    /// Not dirty: nothing has been typed. Marking it dirty would make Ctrl+Q
49    /// challenge the user over a file they never edited.
50    pub fn new_at(path: &Path) -> Self {
51        Self {
52            rope: Rope::new(),
53            path: Some(path.to_path_buf()),
54            dirty: false,
55            history: History::default(),
56            group_depth: 0,
57            // Nothing to detect from, and a file TYPE is about to create has no
58            // existing convention to honour.
59            line_ending: LineEnding::default(),
60        }
61    }
62
63    /// Read a file into a buffer.
64    ///
65    /// **CRLF is normalized to LF in the rope** and the original recorded in
66    /// `line_ending`, which `save` writes back. Keeping the `\r` in the rope
67    /// would put it inside every line as a grapheme that `col` arithmetic, word
68    /// motion and search all have to know to skip — and an editor whose whole
69    /// cursor model is "col is a grapheme index" cannot afford one grapheme
70    /// that is secretly punctuation. TermIDE takes the same approach for the
71    /// same reason.
72    pub fn from_path(path: &Path) -> Result<Self> {
73        let text =
74            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
75        let line_ending = LineEnding::detect(&text);
76        let text = match line_ending {
77            LineEnding::Lf => text,
78            LineEnding::Crlf => text.replace("\r\n", "\n"),
79        };
80        Ok(Self {
81            line_ending,
82            rope: Rope::from_str(&text),
83            path: Some(path.to_path_buf()),
84            dirty: false,
85            history: History::default(),
86            group_depth: 0,
87        })
88    }
89
90    pub fn line_count(&self) -> usize {
91        self.rope.len_lines()
92    }
93
94    /// The whole buffer as a `String`.
95    ///
96    /// Allocates the entire text, so it is for whole-file work and never for
97    /// anything on the keystroke path.
98    pub fn text(&self) -> String {
99        self.rope.to_string()
100    }
101
102    /// The whole buffer as `save` would write it, line endings and all.
103    ///
104    /// The rope holds LF only. Comparing `text()` against a CRLF file on disk
105    /// says they differ when they do not, which would make every save of a
106    /// Windows file report itself as an external change.
107    pub fn text_as_saved(&self) -> String {
108        match self.line_ending {
109            LineEnding::Lf => self.text(),
110            LineEnding::Crlf => self.text().replace('\n', "\r\n"),
111        }
112    }
113
114    /// The line terminator this file was loaded with, and the one `save`
115    /// writes back. The rope itself holds LF only.
116    pub fn line_ending(&self) -> LineEnding {
117        self.line_ending
118    }
119
120    pub fn path(&self) -> Option<&Path> {
121        self.path.as_deref()
122    }
123
124    pub fn is_dirty(&self) -> bool {
125        self.dirty
126    }
127
128    /// Call `f` with one line's text, borrowed from the rope when possible.
129    ///
130    /// `RopeSlice::as_str` succeeds whenever the line lives inside a single
131    /// chunk, which is the overwhelmingly common case — ropey chunks are ~1 KB
132    /// and lines of code are not. Only a line straddling a chunk boundary pays
133    /// for a `String`.
134    ///
135    /// This exists because `line_text` returning an owned `String` was correct
136    /// but quadratic in aggregate: three callers looped it over every line in
137    /// the buffer, so one keystroke on a 50k-line file allocated 50k strings.
138    /// A borrowing accessor makes the cheap thing the easy thing to reach for.
139    pub fn with_line_str<T>(&self, line: usize, f: impl FnOnce(&str) -> T) -> T {
140        if line >= self.rope.len_lines() {
141            return f("");
142        }
143        with_slice_str(self.rope.line(line), f)
144    }
145
146    /// Line contents without the trailing newline.
147    ///
148    /// Allocates. Prefer `with_line_str` in anything that runs per line over a
149    /// range of lines.
150    pub fn line_text(&self, line: usize) -> String {
151        self.with_line_str(line, str::to_string)
152    }
153
154    /// Graphemes on a line, without materializing it.
155    pub fn line_grapheme_count(&self, line: usize) -> usize {
156        self.with_line_str(line, |s| s.graphemes(true).count())
157    }
158
159    /// Absolute char offset of a `Position`, clamping out-of-range input.
160    fn char_offset(&self, pos: Position) -> usize {
161        let line = pos.line.min(self.rope.len_lines().saturating_sub(1));
162        let line_start = self.rope.line_to_char(line);
163        let chars_before: usize = self.with_line_str(line, |text| {
164            text.graphemes(true)
165                .take(pos.col)
166                .map(|g| g.chars().count())
167                .sum()
168        });
169        line_start + chars_before
170    }
171
172    pub fn insert_char(&mut self, pos: Position, ch: char) {
173        self.record_snapshot(pos);
174        let offset = self.char_offset(pos);
175        self.rope.insert_char(offset, ch);
176        self.dirty = true;
177    }
178
179    /// Delete the grapheme immediately before `pos` (backspace).
180    pub fn delete_before(&mut self, pos: Position) {
181        let offset = self.char_offset(pos);
182        if offset == 0 {
183            return;
184        }
185        let n = if pos.col == 0 {
186            1 // joining with the previous line: remove the newline
187        } else {
188            self.with_line_str(pos.line, |text| {
189                text.graphemes(true)
190                    .nth(pos.col - 1)
191                    .map_or(1, |g| g.chars().count())
192            })
193        };
194        self.record_snapshot(pos);
195        self.rope.remove(offset - n..offset);
196        self.dirty = true;
197    }
198
199    /// Delete the grapheme at `pos` (forward delete).
200    ///
201    /// At the end of a line this removes the newline, joining the next line up.
202    pub fn delete_after(&mut self, pos: Position) {
203        let offset = self.char_offset(pos);
204        if offset >= self.rope.len_chars() {
205            return;
206        }
207        let n = self.with_line_str(pos.line, |text| {
208            text.graphemes(true)
209                .nth(pos.col)
210                .map_or(1, |g| g.chars().count())
211        });
212        self.record_snapshot(pos);
213        self.rope.remove(offset..offset + n);
214        self.dirty = true;
215    }
216
217    /// The text between two positions.
218    ///
219    /// Ordered by the caller — a selection's `range()` already answers which end
220    /// comes first, so this does not second-guess it.
221    pub fn text_in_range(&self, start: Position, end: Position) -> String {
222        let from = self.char_offset(start);
223        let to = self.char_offset(end);
224        if from >= to {
225            return String::new();
226        }
227        self.rope.slice(from..to).to_string()
228    }
229
230    /// Every match in the buffer, in document order, as selections whose head
231    /// sits at the end of the match — so jumping to one leaves the cursor
232    /// where typing would naturally continue.
233    pub fn find_all(&self, query: &SearchQuery) -> Vec<Selection> {
234        // Split once for the whole buffer, not once per line.
235        let needle: Vec<&str> = query.needle.graphemes(true).collect();
236
237        let mut hits = Vec::new();
238        // `rope.lines()` walks the tree once. Indexing `rope.line(i)` in a loop
239        // instead is a fresh O(log n) descent per line, which measured at 458 ns
240        // of pure overhead per line — 23 ms across 50k lines before a single
241        // byte of the search ran.
242        for (line, slice) in self.rope.lines().enumerate() {
243            with_slice_str(slice, |text| {
244                for (start, end) in crate::search::find_in_line_with(text, &needle, query) {
245                    hits.push(Selection {
246                        anchor: Position { line, col: start },
247                        head: Position { line, col: end },
248                    });
249                }
250            });
251        }
252        hits
253    }
254
255    /// The first match strictly after `after`, wrapping to the top of the
256    /// buffer if there is none below it.
257    ///
258    /// This exists so `Ctrl+D` is not `find_all` with a filter on it. `find_all`
259    /// scans the whole buffer — measured at ~7 ms on 50k lines, against a 16 ms
260    /// keystroke budget — and select-next-occurrence is a key people *hold*, so
261    /// one scan per press is not a cost that can be paid. Stopping at the first
262    /// hit is both the faster thing and the simpler one.
263    ///
264    /// Wrapping is unconditional, and it is load-bearing rather than a
265    /// convenience: coming back round to a match the caller already holds is how
266    /// `Ctrl+D` knows every occurrence is selected and it is time to stop.
267    pub fn find_next(&self, query: &SearchQuery, after: Position) -> Option<Selection> {
268        if query.needle.is_empty() {
269            return None;
270        }
271        let needle: Vec<&str> = query.needle.graphemes(true).collect();
272
273        let line_count = self.rope.len_lines();
274        let first_on_line = |line: usize, min_col: Option<usize>| -> Option<Selection> {
275            self.with_line_str(line, |text| {
276                crate::search::find_in_line_with(text, &needle, query)
277                    .into_iter()
278                    .find(|(start, _)| min_col.is_none_or(|min| *start > min))
279                    .map(|(start, end)| Selection {
280                        anchor: Position { line, col: start },
281                        head: Position { line, col: end },
282                    })
283            })
284        };
285
286        // Forward from the cursor's line to the end...
287        for line in after.line..line_count {
288            let min_col = (line == after.line).then_some(after.col);
289            if let Some(hit) = first_on_line(line, min_col) {
290                return Some(hit);
291            }
292        }
293        // ...then round to the top and back up to it, inclusive, so a lone match
294        // behind the cursor is still found.
295        for line in 0..=after.line.min(line_count.saturating_sub(1)) {
296            if let Some(hit) = first_on_line(line, None) {
297                return Some(hit);
298            }
299        }
300        None
301    }
302
303    /// Replace the text between two positions as a single undo step.
304    ///
305    /// An empty range inserts, so callers can express insertion, deletion and
306    /// replacement as one operation and not branch three ways.
307    pub fn replace_range(&mut self, start: Position, end: Position, text: &str) {
308        let from = self.char_offset(start);
309        let to = self.char_offset(end);
310        if from > to || (from == to && text.is_empty()) {
311            return;
312        }
313        self.record_snapshot(start);
314        if to > from {
315            self.rope.remove(from..to);
316        }
317        if !text.is_empty() {
318            self.rope.insert(from, text);
319        }
320        self.dirty = true;
321    }
322
323    /// Take an undo snapshot unless an edit group is open.
324    ///
325    /// Only the M1-era standalone helpers reach this. They have no selection set
326    /// and no edit kind to offer, so they record as `Other` at a caret placed
327    /// where they are editing — which reproduces their old one-step-per-call
328    /// behavior exactly. M2 Task 12 deletes their last callers.
329    fn record_snapshot(&mut self, at: Position) {
330        if self.group_depth == 0 {
331            let selections = Selections::single(Selection::caret(at));
332            self.history
333                .record(EditKind::Other, self.rope.clone(), &selections);
334        }
335    }
336
337    /// Begin a group of edits that undo together.
338    ///
339    /// One snapshot is taken up front and none during the group, so thirty
340    /// cursors typing one character is one undo step. Without this, undoing a
341    /// thirty-caret edit would take thirty presses and leave the buffer in
342    /// states the user never typed.
343    ///
344    /// Whether that snapshot is actually pushed is `History`'s call: a group
345    /// continuing a run of the same kind folds into the one already there.
346    pub fn begin_edit_group(&mut self, kind: EditKind, selections: &Selections) {
347        if self.group_depth == 0 {
348            self.history.record(kind, self.rope.clone(), selections);
349        }
350        self.group_depth += 1;
351    }
352
353    pub fn end_edit_group(&mut self) {
354        self.group_depth = self.group_depth.saturating_sub(1);
355    }
356
357    /// How many undo steps are currently held.
358    pub fn undo_depth(&self) -> usize {
359        self.history.depth()
360    }
361
362    /// End the current undo run. The next edit starts a new step.
363    pub fn undo_boundary(&mut self) {
364        self.history.boundary();
365    }
366
367    /// Undo one step, returning the selections to restore.
368    ///
369    /// `None` means there was nothing to undo, so the caller leaves its
370    /// selections alone.
371    pub fn undo(&mut self, current: &Selections) -> Option<Selections> {
372        let snapshot = self.history.undo(self.rope.clone(), current)?;
373        self.rope = snapshot.rope;
374        self.dirty = true;
375        Some(snapshot.selections)
376    }
377
378    pub fn redo(&mut self, current: &Selections) -> Option<Selections> {
379        let snapshot = self.history.redo(self.rope.clone(), current)?;
380        self.rope = snapshot.rope;
381        self.dirty = true;
382        Some(snapshot.selections)
383    }
384
385    /// Write the buffer to disk, atomically.
386    ///
387    /// The content goes to a sibling temporary file, is flushed to the device,
388    /// and is then renamed over the target. `rename` replaces the destination
389    /// in one step on both NTFS and POSIX, so an interrupted save leaves the
390    /// previous file intact rather than a truncated one. Writing in place would
391    /// mean a crash between truncate and write costs the user the whole file
392    /// rather than the last edit.
393    pub fn save(&mut self) -> Result<()> {
394        let path = self
395            .path
396            .as_ref()
397            .context("buffer has no path to save to")?
398            .clone();
399
400        // Write through a symlink rather than over it. The rename replaces
401        // whatever is at the path, so saving `~/.bashrc` when it is a link into
402        // a dotfiles repo would replace the link with a regular file and
403        // silently detach it from the repo. ttt resolves the link for the same
404        // reason; nothing else in the surveyed field does.
405        let target = resolve_symlink(&path);
406
407        // Same directory, so the rename never crosses a filesystem boundary —
408        // across devices it would silently become a copy, which is not atomic.
409        let temp = temp_path_beside(&target);
410        write_all_and_sync(&temp, &self.rope, self.line_ending)
411            .with_context(|| format!("writing {}", temp.display()))?;
412
413        // Carry the original's mode onto the temp file *before* the rename, so
414        // the file is never briefly world-readable and an executable script
415        // does not stop being executable because somebody edited it.
416        if let Err(e) = copy_permissions(&target, &temp) {
417            let _ = std::fs::remove_file(&temp);
418            return Err(e).with_context(|| format!("preserving the mode of {}", target.display()));
419        }
420
421        if let Err(e) = std::fs::rename(&temp, &target) {
422            // Leave nothing behind on failure; the original is untouched.
423            let _ = std::fs::remove_file(&temp);
424            return Err(e).with_context(|| format!("replacing {}", target.display()));
425        }
426
427        // A rename is not durable until the directory entry naming it is. Skip
428        // this and a power loss can leave the directory pointing at neither
429        // file — which is the zero-length-file outcome the atomic write exists
430        // to prevent, arriving by the other door. None of ttt, TermIDE or Fresh
431        // does this.
432        sync_parent_dir(&target);
433
434        self.dirty = false;
435        Ok(())
436    }
437
438    /// Point the buffer at another path. Test-only: production code opens a
439    /// new buffer rather than redirecting one.
440    #[doc(hidden)]
441    pub fn set_path_for_test(&mut self, path: PathBuf) {
442        self.path = Some(path);
443    }
444}
445
446/// Call `f` with a line slice's text, borrowed from the rope when possible.
447///
448/// Free-standing rather than a method so callers holding a slice from
449/// `Rope::lines()` can use it without paying for a second lookup by index.
450fn with_slice_str<T>(slice: ropey::RopeSlice, f: impl FnOnce(&str) -> T) -> T {
451    match slice.as_str() {
452        Some(s) => f(trim_line_ending(s)),
453        None => {
454            let owned = slice.to_string();
455            f(trim_line_ending(&owned))
456        }
457    }
458}
459
460/// A line without its terminator. Handles CRLF as one unit rather than as two
461/// separate trims, so a stray `\r` inside a line is left alone.
462fn trim_line_ending(s: &str) -> &str {
463    s.strip_suffix('\n')
464        .map(|s| s.strip_suffix('\r').unwrap_or(s))
465        .unwrap_or(s)
466}
467
468/// A sibling of `path` that will not collide with a real file, or with another
469/// instance of TYPE saving the same file.
470///
471/// The pid is what makes the second guarantee. Two editors saving one path with
472/// a fixed temp name race: one truncates the other's half-written file and
473/// renames whichever won, and the loser's content is gone. A kill mid-save also
474/// leaves the file behind, and a pid-suffixed one is at least attributable.
475fn temp_path_beside(path: &Path) -> PathBuf {
476    let name = path
477        .file_name()
478        .map(|n| n.to_string_lossy().to_string())
479        .unwrap_or_else(|| "buffer".to_string());
480    let parent = path.parent().unwrap_or(Path::new("."));
481    parent.join(format!(".{name}.{}.typ-tmp", std::process::id()))
482}
483
484/// Write the rope out and flush it to the device before returning.
485///
486/// Without the flush, the rename can be durable while the contents are not —
487/// which produces an empty file after a power loss, the exact failure the
488/// atomic write exists to prevent.
489fn write_all_and_sync(path: &Path, rope: &Rope, ending: LineEnding) -> std::io::Result<()> {
490    use std::io::Write;
491
492    let mut file = std::fs::File::create(path)?;
493    for chunk in rope.chunks() {
494        match ending {
495            // The rope holds LF. A chunk boundary cannot split a `\n`, so
496            // converting per chunk is safe without carrying state across them.
497            LineEnding::Lf => file.write_all(chunk.as_bytes())?,
498            LineEnding::Crlf => file.write_all(chunk.replace('\n', "\r\n").as_bytes())?,
499        }
500    }
501    file.flush()?;
502    file.sync_all()?;
503    Ok(())
504}
505
506/// The real file behind a path, if the path is a symlink.
507///
508/// Only follows when the path *is* a link: `canonicalize` on a plain path is a
509/// syscall for nothing, and on Windows it returns a `\\?\` form that is worth
510/// not introducing where it is not needed.
511fn resolve_symlink(path: &Path) -> PathBuf {
512    match std::fs::symlink_metadata(path) {
513        Ok(meta) if meta.file_type().is_symlink() => {
514            std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
515        }
516        _ => path.to_path_buf(),
517    }
518}
519
520/// Give `to` the permissions `from` has, when `from` exists.
521///
522/// A file being created for the first time has nothing to copy, which is not a
523/// failure.
524fn copy_permissions(from: &Path, to: &Path) -> std::io::Result<()> {
525    let Ok(meta) = std::fs::metadata(from) else {
526        return Ok(());
527    };
528    std::fs::set_permissions(to, meta.permissions())
529}
530
531/// fsync the directory holding `path`, so the rename that named the file is
532/// durable and not only the bytes inside it.
533///
534/// Best-effort: opening a directory for this is not portable — Windows has no
535/// equivalent and returns an error — and a save that worked must not be
536/// reported as failed because the extra durability step was unavailable.
537fn sync_parent_dir(path: &Path) {
538    let Some(parent) = path.parent() else { return };
539    let parent = if parent.as_os_str().is_empty() {
540        Path::new(".")
541    } else {
542        parent
543    };
544    if let Ok(dir) = std::fs::File::open(parent) {
545        let _ = dir.sync_all();
546    }
547}