Skip to main content

twrite_core/
buffer.rs

1use std::ops::Range;
2use std::path::Path;
3
4use ropey::Rope;
5
6use crate::{
7    coordinates::Point,
8    error::{EditorError, Result},
9    history::{Edit, History, Transaction},
10};
11
12/// A text buffer that manages document contents, cursor position,
13/// and undo/redo history.
14///
15/// `EditorBuffer` stores its text in a [`Rope`], making insertion,
16/// deletion, and line-based operations efficient for an editor.
17///
18/// Cursor positions are represented internally as byte offsets.
19///
20/// # Examples
21///
22/// ```
23/// use twrite_core::EditorBuffer;
24///
25/// let buffer = EditorBuffer::new("Hello, world!");
26///
27/// assert_eq!(buffer.len_bytes(), 13);
28/// assert_eq!(buffer.len_lines(), 1);
29/// assert_eq!(buffer.cursor_offset(), 0);
30/// ```
31#[derive(Debug)]
32pub struct EditorBuffer {
33    text: Rope,
34    cursor: usize,
35    history: History,
36    version: usize,
37}
38
39impl EditorBuffer {
40    /// Creates a new editor buffer containing `initial_text`.
41    ///
42    /// The cursor is initially positioned at byte offset `0`, and the
43    /// undo/redo history starts empty.
44    pub fn new(initial_text: &str) -> Self {
45        Self {
46            text: Rope::from_str(initial_text),
47            cursor: 0,
48            history: History::default(),
49            version: 0,
50        }
51    }
52
53    /// Returns the monotonic document version, incremented on every text modification.
54    pub fn version(&self) -> usize {
55        self.version
56    }
57
58    /// Returns a reference to the underlying text.
59    ///
60    /// The returned [`Rope`] can be used to inspect the document without
61    /// copying its contents.
62    pub fn text(&self) -> &Rope {
63        &self.text
64    }
65
66    /// Returns the current cursor position as a byte offset.
67    ///
68    /// The cursor is always maintained at a valid UTF-8 character boundary.
69    pub fn cursor_offset(&self) -> usize {
70        self.cursor
71    }
72
73    /// Returns the total number of bytes in the document.
74    pub fn len_bytes(&self) -> usize {
75        self.text.len_bytes()
76    }
77
78    /// Returns the number of lines in the document.
79    pub fn len_lines(&self) -> usize {
80        self.text.len_lines()
81    }
82
83    /// Returns the contents of a line as a [`String`].
84    ///
85    /// Returns an empty string if `line_idx` is outside the document.
86    pub fn line_to_string(&self, line_idx: usize) -> String {
87        if line_idx >= self.text.len_lines() {
88            return String::new();
89        }
90
91        self.text.line(line_idx).to_string()
92    }
93
94    /// Converts a byte offset into a [`Point`].
95    ///
96    /// The returned point contains a zero-based row and a byte-based
97    /// column. If `offset` is beyond the end of the document, it is
98    /// clamped to the document's end.
99    pub fn offset_to_point(&self, offset: usize) -> Point {
100        let clamped = offset.min(self.text.len_bytes());
101        let row = self.text.byte_to_line(clamped);
102        let line_start_byte = self.text.line_to_byte(row);
103        let column = clamped - line_start_byte;
104
105        Point::new(row, column)
106    }
107
108    /// Converts a [`Point`] into a byte offset.
109    ///
110    /// If the row is outside the document, the returned offset points to
111    /// the end of the document. If the column exceeds the length of the
112    /// line, it is clamped to the end of that line.
113    pub fn point_to_offset(&self, point: Point) -> usize {
114        if point.row >= self.text.len_lines() {
115            return self.text.len_bytes();
116        }
117        let line_start_byte = self.text.line_to_byte(point.row);
118        let line_len = self.text.line(point.row).len_bytes();
119        let col = point.column.min(line_len);
120        line_start_byte + col
121    }
122
123    /// Returns the current cursor position as a [`Point`].
124    pub fn cursor_point(&self) -> Point {
125        self.offset_to_point(self.cursor)
126    }
127
128    /// Sets the cursor to the given byte offset.
129    ///
130    /// The offset is clamped to the document's bounds.
131    ///
132    /// The resulting cursor position is kept on a valid UTF-8 character
133    /// boundary.
134    pub fn set_cursor_offset(&mut self, offset: usize) {
135        let offset = offset.min(self.text.len_bytes());
136        self.cursor = self.text.char_to_byte(self.text.byte_to_char(offset));
137    }
138
139    /// Sets the cursor to the given document position.
140    ///
141    /// The row and column are clamped to the document's bounds.
142    pub fn set_cursor_point(&mut self, point: Point) {
143        self.cursor = self.point_to_offset(point);
144    }
145
146    /// Moves the cursor one character to the right.
147    ///
148    /// Does nothing if the cursor is already at the end of the document.
149    pub fn move_cursor_right(&mut self) {
150        if self.cursor < self.text.len_bytes() {
151            let char_idx = self.text.byte_to_char(self.cursor);
152            let next_char = (char_idx + 1).min(self.text.len_chars());
153            self.cursor = self.text.char_to_byte(next_char);
154        }
155    }
156
157    /// Moves the cursor one line upward.
158    ///
159    /// The column is preserved when possible. If the target line is shorter,
160    /// the cursor is placed at the end of that line.
161    pub fn move_cursor_up(&mut self) {
162        let point = self.cursor_point();
163        if point.row > 0 {
164            self.set_cursor_point(Point::new(point.row - 1, point.column));
165        }
166    }
167
168    /// Moves the cursor one line downward.
169    ///
170    /// The column is preserved when possible. If the target line is shorter,
171    /// the cursor is placed at the end of that line.
172    pub fn move_cursor_down(&mut self) {
173        let point = self.cursor_point();
174        if point.row + 1 < self.text.len_lines() {
175            self.set_cursor_point(Point::new(point.row + 1, point.column));
176        }
177    }
178
179    /// Moves the cursor one character to the left.
180    ///
181    /// Does nothing if the cursor is already at the beginning of the
182    /// document.
183    pub fn move_cursor_left(&mut self) {
184        if self.cursor > 0 {
185            let char_idx = self.text.byte_to_char(self.cursor);
186            self.cursor = self.text.char_to_byte(char_idx - 1);
187        }
188    }
189
190    /// Returns the byte offset of the previous word start relative to current cursor.
191    pub fn prev_word_offset(&self) -> usize {
192        crate::movement::find_prev_word_start(&self.text, self.cursor)
193    }
194
195    /// Returns the byte offset of the next word end relative to current cursor.
196    pub fn next_word_offset(&self) -> usize {
197        crate::movement::find_next_word_end(&self.text, self.cursor)
198    }
199
200    /// Returns the byte offset of the start of the current line.
201    pub fn line_start_offset(&self) -> usize {
202        crate::movement::find_line_start(&self.text, self.cursor)
203    }
204
205    /// Returns the byte offset of the end of the current line (excluding trailing newline).
206    pub fn line_end_offset(&self) -> usize {
207        crate::movement::find_line_end(&self.text, self.cursor)
208    }
209
210    /// Returns the byte range of the word, punctuation token, or whitespace run containing `offset`.
211    pub fn word_range_at(&self, offset: usize) -> Range<usize> {
212        crate::movement::find_word_range_at(&self.text, offset)
213    }
214
215    /// Returns the byte range of the full line containing `offset`, including
216    /// any trailing line terminator.
217    pub fn line_range_at(&self, offset: usize) -> Range<usize> {
218        crate::movement::find_line_range_at(&self.text, offset)
219    }
220
221    /// Moves the cursor to the start of the previous word.
222    pub fn move_cursor_prev_word(&mut self) {
223        self.cursor = self.prev_word_offset();
224    }
225
226    /// Moves the cursor to the end of the next word.
227    pub fn move_cursor_next_word(&mut self) {
228        self.cursor = self.next_word_offset();
229    }
230
231    /// Moves the cursor to the beginning of the current line.
232    pub fn move_cursor_line_start(&mut self) {
233        self.cursor = self.line_start_offset();
234    }
235
236    /// Moves the cursor to the end of the current line.
237    pub fn move_cursor_line_end(&mut self) {
238        self.cursor = self.line_end_offset();
239    }
240
241    /// Deletes the text from the previous word boundary up to the cursor.
242    ///
243    /// Returns `true` if text was deleted, or `false` if the cursor was already at the beginning.
244    pub fn delete_prev_word(&mut self) -> bool {
245        let target = self.prev_word_offset();
246        if target < self.cursor {
247            self.delete_range(target..self.cursor);
248            true
249        } else {
250            false
251        }
252    }
253
254    /// Deletes the text from the cursor up to the next word boundary.
255    ///
256    /// Returns `true` if text was deleted, or `false` if the cursor was already at the end.
257    pub fn delete_next_word(&mut self) -> bool {
258        let target = self.next_word_offset();
259        if self.cursor < target {
260            self.delete_range(self.cursor..target);
261            true
262        } else {
263            false
264        }
265    }
266
267    /// Inserts `text` at the current cursor position.
268    ///
269    /// The inserted text becomes a single undoable transaction, and the
270    /// cursor is moved to the end of the inserted text.
271    ///
272    /// Inserting new text after undoing clears the redo history.
273    pub fn insert(&mut self, text: &str) {
274        let previous_cursor = self.cursor;
275        let char_idx = self.text.byte_to_char(self.cursor);
276        self.text.insert(char_idx, text);
277        self.cursor += text.len();
278
279        let tx = Transaction {
280            edits: vec![Edit {
281                bytes_range: previous_cursor..previous_cursor,
282                inserted_text: text.to_string(),
283                deleted_text: String::new(),
284            }],
285            previous_cursor,
286            resulting_cursor: self.cursor,
287        };
288
289        self.history.undo_stack.push(tx);
290        self.history.redo_stack.clear();
291        self.version += 1;
292    }
293
294    /// Deletes the character immediately before the cursor.
295    ///
296    /// If the cursor is at the beginning of the document, this method does
297    /// nothing.
298    ///
299    /// The deleted character is recorded as an undoable transaction and
300    /// the cursor moves to the beginning of the deleted character.
301    ///
302    /// Inserting new text after undoing clears the redo history.
303    pub fn backspace(&mut self) {
304        if self.cursor == 0 {
305            return;
306        }
307
308        let char_idx = self.text.byte_to_char(self.cursor);
309        let previous_char_byte = self.text.char_to_byte(char_idx - 1);
310        let range_to_delete = previous_char_byte..self.cursor;
311        let deleted_text = self.text.byte_slice(range_to_delete.clone()).to_string();
312
313        let previous_cursor = self.cursor;
314        self.text.remove((char_idx - 1)..char_idx);
315        self.cursor = previous_char_byte;
316
317        let tx = Transaction {
318            edits: vec![Edit {
319                bytes_range: range_to_delete,
320                inserted_text: String::new(),
321                deleted_text,
322            }],
323            previous_cursor,
324            resulting_cursor: self.cursor,
325        };
326
327        self.history.undo_stack.push(tx);
328        self.history.redo_stack.clear();
329        self.version += 1;
330    }
331
332    /// Deletes the character at the current cursor position.
333    ///
334    /// If the cursor is at the end of the document, this method does nothing.
335    /// The cursor remains at the same byte offset after the deletion.
336    ///
337    /// The deleted text is recorded as a transaction so the operation can be
338    /// undone and redone.
339    pub fn delete(&mut self) {
340        if self.cursor >= self.text.len_bytes() {
341            return;
342        }
343
344        let char_idx = self.text.byte_to_char(self.cursor);
345        let next_char = char_idx + 1;
346
347        let end = self.text.char_to_byte(next_char);
348        let byte_range = self.cursor..end;
349        let deleted_text = self.text.byte_slice(byte_range.clone()).to_string();
350
351        self.text.remove(char_idx..next_char);
352
353        let tx = Transaction {
354            edits: vec![Edit {
355                bytes_range: byte_range,
356                inserted_text: String::new(),
357                deleted_text,
358            }],
359            previous_cursor: self.cursor,
360            resulting_cursor: self.cursor,
361        };
362
363        self.history.undo_stack.push(tx);
364        self.history.redo_stack.clear();
365        self.version += 1;
366    }
367
368    /// Deletes the text within `range`.
369    ///
370    /// The deletion is recorded as an undoable transaction and the cursor
371    /// is set to the start of `range`.
372    pub fn delete_range(&mut self, range: Range<usize>) {
373        let start = range.start.min(self.text.len_bytes());
374        let end = range.end.min(self.text.len_bytes());
375        if start >= end {
376            return;
377        }
378
379        let start_char = self.text.byte_to_char(start);
380        let end_char = self.text.byte_to_char(end);
381        let deleted_text = self.text.byte_slice(start..end).to_string();
382        let previous_cursor = self.cursor;
383
384        self.text.remove(start_char..end_char);
385        self.cursor = start;
386
387        let tx = Transaction {
388            edits: vec![Edit {
389                bytes_range: start..end,
390                inserted_text: String::new(),
391                deleted_text,
392            }],
393            previous_cursor,
394            resulting_cursor: self.cursor,
395        };
396
397        self.history.undo_stack.push(tx);
398        self.history.redo_stack.clear();
399        self.version += 1;
400    }
401
402    /// Replaces the text within `range` with `text`.
403    ///
404    /// If `range` is empty, this is equivalent to [`Self::insert`].
405    pub fn replace_range(&mut self, range: Range<usize>, text: &str) {
406        let start = range.start.min(self.text.len_bytes());
407        let end = range.end.min(self.text.len_bytes());
408        if start == end {
409            self.cursor = start;
410            self.insert(text);
411            return;
412        }
413
414        let start_char = self.text.byte_to_char(start);
415        let end_char = self.text.byte_to_char(end);
416        let deleted_text = self.text.byte_slice(start..end).to_string();
417        let previous_cursor = self.cursor;
418
419        self.text.remove(start_char..end_char);
420        self.text.insert(start_char, text);
421        self.cursor = start + text.len();
422
423        let tx = Transaction {
424            edits: vec![Edit {
425                bytes_range: start..end,
426                inserted_text: text.to_string(),
427                deleted_text,
428            }],
429            previous_cursor,
430            resulting_cursor: self.cursor,
431        };
432
433        self.history.undo_stack.push(tx);
434        self.history.redo_stack.clear();
435        self.version += 1;
436    }
437
438    /// Applies multiple non-overlapping replacements as a single undoable transaction.
439    ///
440    /// `replacements` holds `(range, replacement_text)` pairs. They are applied
441    /// back-to-front so earlier byte offsets stay valid, recorded as one
442    /// [`Transaction`](crate::history::Transaction), and undone/redone together.
443    /// Returns the number of replacements applied. Overlapping, empty, or
444    /// out-of-bounds ranges are skipped. A no-op leaves the version untouched.
445    pub fn replace_many(&mut self, replacements: Vec<(Range<usize>, String)>) -> usize {
446        let len = self.text.len_bytes();
447        let mut valid: Vec<(usize, usize, String)> = Vec::with_capacity(replacements.len());
448        for (range, text) in replacements {
449            if range.start >= range.end || range.end > len {
450                continue;
451            }
452            if !self.is_char_boundary(range.start) || !self.is_char_boundary(range.end) {
453                continue;
454            }
455            valid.push((range.start, range.end, text));
456        }
457        if valid.is_empty() {
458            return 0;
459        }
460        valid.sort_by_key(|(start, _, _)| *start);
461        // Matches from a single scan never overlap, but callers may pass
462        // arbitrary ranges: keep the first of any overlapping pair.
463        let mut dedup: Vec<(usize, usize, String)> = Vec::with_capacity(valid.len());
464        for (start, end, text) in valid {
465            if let Some((_, last_end, _)) = dedup.last()
466                && start < *last_end
467            {
468                continue;
469            }
470            dedup.push((start, end, text));
471        }
472        if dedup.is_empty() {
473            return 0;
474        }
475
476        let previous_cursor = self.cursor;
477        // Apply back-to-front so earlier byte offsets stay valid, then store
478        // the edits ascending; undo/redo both walk descending (see below).
479        let mut edits: Vec<Edit> = Vec::with_capacity(dedup.len());
480        for (start, end, text) in dedup.iter().rev() {
481            let deleted_text = self.text.byte_slice(*start..*end).to_string();
482            let start_char = self.text.byte_to_char(*start);
483            let end_char = self.text.byte_to_char(*end);
484            self.text.remove(start_char..end_char);
485            self.text.insert(start_char, text);
486            edits.push(Edit {
487                bytes_range: *start..*end,
488                inserted_text: text.clone(),
489                deleted_text,
490            });
491        }
492        edits.reverse();
493
494        // Cursor tracks the end of the last replacement: earlier edits shift
495        // it by the sum of their length deltas.
496        let mut shift: i64 = 0;
497        for edit in &edits[..edits.len() - 1] {
498            shift += edit.inserted_text.len() as i64
499                - (edit.bytes_range.end - edit.bytes_range.start) as i64;
500        }
501        let last = &edits[edits.len() - 1];
502        let new_cursor = (last.bytes_range.start as i64 + shift + last.inserted_text.len() as i64)
503            .max(0) as usize;
504        self.cursor = new_cursor.min(self.text.len_bytes());
505
506        let tx = Transaction {
507            edits,
508            previous_cursor,
509            resulting_cursor: self.cursor,
510        };
511        let applied = tx.edits.len();
512
513        self.history.undo_stack.push(tx);
514        self.history.redo_stack.clear();
515        self.version += 1;
516        applied
517    }
518
519    /// Moves a 0-based range of lines `start_row..=end_row` up by one line, swapping
520    /// with the line above (`start_row - 1`).
521    ///
522    /// Preserves the cursor's column position within the moved lines, clamped to line length.
523    /// Preserves document line terminator style and avoids creating extra trailing newlines at EOF.
524    /// Returns `false` if `start_row == 0` or if the bounds are invalid.
525    pub fn move_lines_up(&mut self, start_row: usize, end_row: usize) -> bool {
526        let total_lines = self.text.len_lines();
527        if start_row == 0 || start_row > end_row || end_row >= total_lines {
528            return false;
529        }
530
531        let target_row = start_row - 1;
532        let target_line_start = self.text.line_to_byte(target_row);
533        let block_line_start = self.text.line_to_byte(start_row);
534        let span_end = if end_row + 1 < total_lines {
535            self.text.line_to_byte(end_row + 1)
536        } else {
537            self.text.len_bytes()
538        };
539
540        let target_line = self
541            .text
542            .byte_slice(target_line_start..block_line_start)
543            .to_string();
544        let block = self.text.byte_slice(block_line_start..span_end).to_string();
545
546        let target_newline = if target_line.ends_with("\r\n") {
547            "\r\n"
548        } else {
549            "\n"
550        };
551
552        let swapped_text = if !block.ends_with('\n') {
553            let target_trimmed = &target_line[..target_line.len() - target_newline.len()];
554            format!("{block}{target_newline}{target_trimmed}")
555        } else {
556            format!("{block}{target_line}")
557        };
558
559        let cursor_point = self.cursor_point();
560        let new_point = if cursor_point.row >= start_row && cursor_point.row <= end_row {
561            Point::new(cursor_point.row - 1, cursor_point.column)
562        } else if cursor_point.row == target_row {
563            Point::new(end_row, cursor_point.column)
564        } else {
565            cursor_point
566        };
567
568        let start_char = self.text.byte_to_char(target_line_start);
569        let end_char = self.text.byte_to_char(span_end);
570        let deleted_text = self
571            .text
572            .byte_slice(target_line_start..span_end)
573            .to_string();
574        let previous_cursor = self.cursor;
575
576        self.text.remove(start_char..end_char);
577        self.text.insert(start_char, &swapped_text);
578        self.cursor = self.point_to_offset(new_point);
579
580        let tx = Transaction {
581            edits: vec![Edit {
582                bytes_range: target_line_start..span_end,
583                inserted_text: swapped_text,
584                deleted_text,
585            }],
586            previous_cursor,
587            resulting_cursor: self.cursor,
588        };
589
590        self.history.undo_stack.push(tx);
591        self.history.redo_stack.clear();
592        self.version += 1;
593        true
594    }
595
596    /// Moves a 0-based range of lines `start_row..=end_row` down by one line, swapping
597    /// with the line below (`end_row + 1`).
598    ///
599    /// Preserves the cursor's column position within the moved lines, clamped to line length.
600    /// Preserves document line terminator style and avoids creating extra trailing newlines at EOF.
601    /// Returns `false` if `end_row + 1 >= self.len_lines()` or if the bounds are invalid.
602    pub fn move_lines_down(&mut self, start_row: usize, end_row: usize) -> bool {
603        let total_lines = self.text.len_lines();
604        if start_row > end_row || end_row + 1 >= total_lines {
605            return false;
606        }
607
608        let target_row = end_row + 1;
609        let block_line_start = self.text.line_to_byte(start_row);
610        let target_line_start = self.text.line_to_byte(target_row);
611        let span_end = if target_row + 1 < total_lines {
612            self.text.line_to_byte(target_row + 1)
613        } else {
614            self.text.len_bytes()
615        };
616
617        let block = self
618            .text
619            .byte_slice(block_line_start..target_line_start)
620            .to_string();
621        let target_line = self
622            .text
623            .byte_slice(target_line_start..span_end)
624            .to_string();
625
626        let block_newline = if block.ends_with("\r\n") {
627            "\r\n"
628        } else {
629            "\n"
630        };
631
632        let swapped_text = if !target_line.ends_with('\n') {
633            let block_trimmed = &block[..block.len() - block_newline.len()];
634            format!("{target_line}{block_newline}{block_trimmed}")
635        } else {
636            format!("{target_line}{block}")
637        };
638
639        let cursor_point = self.cursor_point();
640        let new_point = if cursor_point.row >= start_row && cursor_point.row <= end_row {
641            Point::new(cursor_point.row + 1, cursor_point.column)
642        } else if cursor_point.row == target_row {
643            Point::new(start_row, cursor_point.column)
644        } else {
645            cursor_point
646        };
647
648        let start_char = self.text.byte_to_char(block_line_start);
649        let end_char = self.text.byte_to_char(span_end);
650        let deleted_text = self.text.byte_slice(block_line_start..span_end).to_string();
651        let previous_cursor = self.cursor;
652
653        self.text.remove(start_char..end_char);
654        self.text.insert(start_char, &swapped_text);
655        self.cursor = self.point_to_offset(new_point);
656
657        let tx = Transaction {
658            edits: vec![Edit {
659                bytes_range: block_line_start..span_end,
660                inserted_text: swapped_text,
661                deleted_text,
662            }],
663            previous_cursor,
664            resulting_cursor: self.cursor,
665        };
666
667        self.history.undo_stack.push(tx);
668        self.history.redo_stack.clear();
669        self.version += 1;
670        true
671    }
672
673    /// Undoes the most recent transaction.
674    ///
675    /// If there is no transaction to undo, this method does nothing.
676    /// The undone transaction is moved to the redo stack.
677    pub fn undo(&mut self) {
678        if let Some(tx) = self.history.undo_stack.pop() {
679            // Stored ranges are original-document coordinates. Undone
680            // descending, each edit's inserted text sits at its stored start
681            // plus the length deltas of all still-applied earlier edits.
682            let mut prefix = Vec::with_capacity(tx.edits.len() + 1);
683            prefix.push(0i64);
684            for edit in &tx.edits {
685                let delta = edit.inserted_text.len() as i64
686                    - (edit.bytes_range.end - edit.bytes_range.start) as i64;
687                prefix.push(prefix.last().copied().unwrap_or(0) + delta);
688            }
689            for (index, edit) in tx.edits.iter().enumerate().rev() {
690                let start = (edit.bytes_range.start as i64 + prefix[index]).max(0) as usize;
691                let end = start + edit.inserted_text.len();
692
693                if end > start {
694                    let start_char = self.text.byte_to_char(start);
695                    let end_char = self.text.byte_to_char(end);
696                    self.text.remove(start_char..end_char);
697                }
698                if !edit.deleted_text.is_empty() {
699                    let start_char = self.text.byte_to_char(start);
700                    self.text.insert(start_char, &edit.deleted_text);
701                }
702            }
703            self.cursor = tx.previous_cursor;
704            self.history.redo_stack.push(tx);
705            self.version += 1;
706        }
707    }
708
709    /// Returns whether an undo transaction is available.
710    pub fn can_undo(&self) -> bool {
711        !self.history.undo_stack.is_empty()
712    }
713
714    /// Returns whether a redo transaction is available.
715    pub fn can_redo(&self) -> bool {
716        !self.history.redo_stack.is_empty()
717    }
718
719    /// Redoes the most recently undone transaction.
720    ///
721    /// If there is no transaction to redo, this method does nothing.
722    /// The redone transaction is moved back to the undo stack.
723    pub fn redo(&mut self) {
724        if let Some(tx) = self.history.redo_stack.pop() {
725            // Descending (like `undo`): higher offsets are re-applied first so
726            // earlier stored ranges stay valid for multi-edit transactions.
727            for edit in tx.edits.iter().rev() {
728                let start = edit.bytes_range.start;
729                let end = start + edit.deleted_text.len();
730
731                if end > start {
732                    let start_char = self.text.byte_to_char(start);
733                    let end_char = self.text.byte_to_char(end);
734                    self.text.remove(start_char..end_char);
735                }
736                if !edit.inserted_text.is_empty() {
737                    let start_char = self.text.byte_to_char(start);
738                    self.text.insert(start_char, &edit.inserted_text);
739                }
740            }
741            self.cursor = tx.resulting_cursor;
742            self.history.undo_stack.push(tx);
743            self.version += 1;
744        }
745    }
746
747    /// Checks whether `offset` falls on a valid UTF-8 character boundary.
748    pub fn is_char_boundary(&self, offset: usize) -> bool {
749        if offset > self.text.len_bytes() {
750            return false;
751        }
752        let char_idx = self.text.byte_to_char(offset);
753        self.text.char_to_byte(char_idx) == offset
754    }
755
756    /// Validates that `offset` is within bounds and lies on a UTF-8 character boundary.
757    pub fn validate_offset(&self, offset: usize) -> Result<()> {
758        let len = self.text.len_bytes();
759        if offset > len {
760            return Err(EditorError::OutOfBounds { offset, len });
761        }
762        if !self.is_char_boundary(offset) {
763            return Err(EditorError::InvalidCharBoundary { offset });
764        }
765        Ok(())
766    }
767
768    /// Validates that `range` is well-formed, within bounds, and on UTF-8 character boundaries.
769    pub fn validate_range(&self, range: &Range<usize>) -> Result<()> {
770        let len = self.text.len_bytes();
771        if range.start > range.end || range.end > len {
772            return Err(EditorError::InvalidRange {
773                range: range.clone(),
774                len,
775            });
776        }
777        if !self.is_char_boundary(range.start) {
778            return Err(EditorError::InvalidCharBoundary {
779                offset: range.start,
780            });
781        }
782        if !self.is_char_boundary(range.end) {
783            return Err(EditorError::InvalidCharBoundary { offset: range.end });
784        }
785        Ok(())
786    }
787
788    /// Attempts to read the text of the given `row`, returning an error if out of bounds.
789    pub fn try_line_to_string(&self, row: usize) -> Result<String> {
790        let total_lines = self.text.len_lines();
791        if row >= total_lines {
792            return Err(EditorError::InvalidRow { row, total_lines });
793        }
794        Ok(self.text.line(row).to_string())
795    }
796
797    /// Attempts to replace the text within `range`, validating bounds and UTF-8 boundaries.
798    pub fn try_replace_range(&mut self, range: Range<usize>, text: &str) -> Result<()> {
799        self.validate_range(&range)?;
800        self.replace_range(range, text);
801        Ok(())
802    }
803
804    /// Attempts to delete the text within `range`, validating bounds and UTF-8 boundaries.
805    pub fn try_delete_range(&mut self, range: Range<usize>) -> Result<()> {
806        self.validate_range(&range)?;
807        self.delete_range(range);
808        Ok(())
809    }
810
811    /// Loads document text directly from a file path.
812    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
813        let content = std::fs::read_to_string(path)?;
814        Ok(Self::new(&content))
815    }
816
817    /// Saves the current buffer contents to a file path.
818    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
819        std::fs::write(path, self.text.to_string())?;
820        Ok(())
821    }
822}