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}