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}