Skip to main content

scrybe_core/
change.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Shawn Hartsock and contributors
3
4//! Change tracking — byte-range edits and undo/redo history for documents.
5
6/// A byte range within a document's source string.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct TextRange {
9    /// Inclusive start byte offset.
10    pub start: usize,
11    /// Exclusive end byte offset.
12    pub end: usize,
13}
14
15impl TextRange {
16    /// Creates a new `TextRange`.
17    pub fn new(start: usize, end: usize) -> Self {
18        Self { start, end }
19    }
20
21    /// Returns the number of bytes covered by this range.
22    pub fn len(&self) -> usize {
23        self.end.saturating_sub(self.start)
24    }
25
26    /// Returns `true` if the range covers zero bytes.
27    pub fn is_empty(&self) -> bool {
28        self.len() == 0
29    }
30}
31
32/// A single text edit: replace `range` in the source with `new_text`.
33///
34/// `old_text` records the bytes that were replaced so the change can be
35/// inverted (undone).
36#[derive(Debug, Clone)]
37pub struct DocumentChange {
38    /// The byte range that is replaced.
39    pub range: TextRange,
40    /// The text that replaces `range`.
41    pub new_text: String,
42    /// The original text at `range` (used for undo).
43    pub old_text: String,
44}
45
46impl DocumentChange {
47    /// Creates a new `DocumentChange`.
48    pub fn new(range: TextRange, new_text: impl Into<String>, old_text: impl Into<String>) -> Self {
49        Self {
50            range,
51            new_text: new_text.into(),
52            old_text: old_text.into(),
53        }
54    }
55
56    /// Applies this change to `source`, returning the modified string.
57    ///
58    /// Panics if `range` refers to a byte offset outside of `source` or
59    /// does not sit on a character boundary.
60    pub fn apply(&self, source: &str) -> String {
61        let mut result =
62            String::with_capacity(source.len() - self.range.len() + self.new_text.len());
63        result.push_str(&source[..self.range.start]);
64        result.push_str(&self.new_text);
65        result.push_str(&source[self.range.end..]);
66        result
67    }
68
69    /// Returns the inverse of this change (suitable for undoing).
70    ///
71    /// The inverse replaces the region `[start .. start + new_text.len()]`
72    /// (i.e. the bytes written by `apply`) back with `old_text`.
73    pub fn inverse(&self) -> Self {
74        Self {
75            range: TextRange::new(self.range.start, self.range.start + self.new_text.len()),
76            new_text: self.old_text.clone(),
77            old_text: self.new_text.clone(),
78        }
79    }
80}
81
82/// Undo/redo history for a document.
83///
84/// Changes are pushed as they are applied. [`undo`](Self::undo) returns
85/// the inverse of the most-recent change; [`redo`](Self::redo) re-applies
86/// a change that was undone.
87#[derive(Debug, Default)]
88pub struct DocumentHistory {
89    past: Vec<DocumentChange>,
90    future: Vec<DocumentChange>,
91}
92
93impl DocumentHistory {
94    /// Creates an empty history.
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Records a new change. Any future (redo) stack is cleared.
100    pub fn push(&mut self, change: DocumentChange) {
101        self.past.push(change);
102        self.future.clear();
103    }
104
105    /// Removes the most-recent change from the undo stack and returns a
106    /// reference to it, or `None` if the stack is empty.
107    ///
108    /// The inverse of the returned change is placed on the redo stack.
109    pub fn undo(&mut self) -> Option<&DocumentChange> {
110        let change = self.past.pop()?;
111        self.future.push(change.inverse());
112        // Return a reference into the redo stack (the last item is the one
113        // that the caller should apply to the document).
114        self.future.last()
115    }
116
117    /// Re-applies the most-recently-undone change and returns a reference
118    /// to it, or `None` if the redo stack is empty.
119    pub fn redo(&mut self) -> Option<&DocumentChange> {
120        let change = self.future.pop()?;
121        // The redo change is the inverse-of-the-inverse, i.e. the original.
122        // Push it back onto the undo stack.
123        self.past.push(change.inverse());
124        self.past.last()
125    }
126
127    /// Returns `true` if there is at least one change to undo.
128    pub fn can_undo(&self) -> bool {
129        !self.past.is_empty()
130    }
131
132    /// Returns `true` if there is at least one change to redo.
133    pub fn can_redo(&self) -> bool {
134        !self.future.is_empty()
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Tests
140// ---------------------------------------------------------------------------
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    // -- TextRange -----------------------------------------------------------
147
148    #[test]
149    fn test_text_range_len() {
150        assert_eq!(TextRange::new(2, 7).len(), 5);
151        assert_eq!(TextRange::new(3, 3).len(), 0);
152    }
153
154    #[test]
155    fn test_text_range_is_empty() {
156        assert!(TextRange::new(5, 5).is_empty());
157        assert!(!TextRange::new(5, 6).is_empty());
158    }
159
160    // -- DocumentChange::apply ----------------------------------------------
161
162    #[test]
163    fn test_apply_replaces_range() {
164        let source = "Hello, world!";
165        let change = DocumentChange::new(TextRange::new(7, 12), "Rust", "world");
166        assert_eq!(change.apply(source), "Hello, Rust!");
167    }
168
169    #[test]
170    fn test_apply_insert_at_start() {
171        let source = "world";
172        let change = DocumentChange::new(TextRange::new(0, 0), "Hello ", "");
173        assert_eq!(change.apply(source), "Hello world");
174    }
175
176    #[test]
177    fn test_apply_delete_range() {
178        let source = "Hello, world!";
179        let change = DocumentChange::new(TextRange::new(5, 12), "", ", world");
180        assert_eq!(change.apply(source), "Hello!");
181    }
182
183    // -- DocumentChange::inverse --------------------------------------------
184
185    #[test]
186    fn test_inverse_undoes_change() {
187        let source = "Hello, world!";
188        let change = DocumentChange::new(TextRange::new(7, 12), "Rust", "world");
189        let modified = change.apply(source);
190        assert_eq!(modified, "Hello, Rust!");
191
192        let undo = change.inverse();
193        let restored = undo.apply(&modified);
194        assert_eq!(restored, source);
195    }
196
197    #[test]
198    fn test_inverse_of_inverse_is_original() {
199        let c = DocumentChange::new(TextRange::new(0, 5), "new", "old_t");
200        let inv = c.inverse();
201        let inv_inv = inv.inverse();
202        assert_eq!(inv_inv.range.start, c.range.start);
203        assert_eq!(inv_inv.new_text, c.new_text);
204        assert_eq!(inv_inv.old_text, c.old_text);
205    }
206
207    // -- DocumentHistory -----------------------------------------------------
208
209    #[test]
210    fn test_history_can_undo_after_push() {
211        let mut h = DocumentHistory::new();
212        assert!(!h.can_undo());
213        h.push(DocumentChange::new(TextRange::new(0, 0), "x", ""));
214        assert!(h.can_undo());
215    }
216
217    #[test]
218    fn test_history_undo_returns_inverse() {
219        let source = "Hello, world!";
220        let change = DocumentChange::new(TextRange::new(7, 12), "Rust", "world");
221        let modified = change.apply(source);
222
223        let mut h = DocumentHistory::new();
224        h.push(change);
225
226        let undo_change = h.undo().expect("should have undo");
227        let restored = undo_change.apply(&modified);
228        assert_eq!(restored, source);
229    }
230
231    #[test]
232    fn test_history_undo_enables_redo() {
233        let mut h = DocumentHistory::new();
234        h.push(DocumentChange::new(TextRange::new(0, 1), "b", "a"));
235        assert!(!h.can_redo());
236        h.undo();
237        assert!(h.can_redo());
238        assert!(!h.can_undo());
239    }
240
241    #[test]
242    fn test_history_redo_re_applies() {
243        let source = "a";
244        let change = DocumentChange::new(TextRange::new(0, 1), "b", "a");
245        let modified = change.apply(source); // "b"
246
247        let mut h = DocumentHistory::new();
248        h.push(change);
249
250        let undo = h.undo().expect("undo").clone();
251        let after_undo = undo.apply(&modified); // back to "a"
252        assert_eq!(after_undo, "a");
253
254        let redo = h.redo().expect("redo").clone();
255        let after_redo = redo.apply(&after_undo);
256        assert_eq!(after_redo, "b");
257    }
258
259    #[test]
260    fn test_push_clears_redo_stack() {
261        let mut h = DocumentHistory::new();
262        h.push(DocumentChange::new(TextRange::new(0, 1), "b", "a"));
263        h.undo();
264        assert!(h.can_redo());
265
266        // A new push should wipe the redo stack.
267        h.push(DocumentChange::new(TextRange::new(0, 1), "c", "a"));
268        assert!(!h.can_redo());
269    }
270
271    #[test]
272    fn test_multi_step_undo_redo() {
273        let mut source = String::from("a");
274        let mut h = DocumentHistory::new();
275
276        let c1 = DocumentChange::new(TextRange::new(1, 1), "b", "");
277        source = c1.apply(&source); // "ab"
278        h.push(c1);
279
280        let c2 = DocumentChange::new(TextRange::new(2, 2), "c", "");
281        source = c2.apply(&source); // "abc"
282        h.push(c2);
283
284        // Undo c2
285        let u2 = h.undo().expect("undo c2").clone();
286        source = u2.apply(&source); // "ab"
287        assert_eq!(source, "ab");
288
289        // Undo c1
290        let u1 = h.undo().expect("undo c1").clone();
291        source = u1.apply(&source); // "a"
292        assert_eq!(source, "a");
293
294        // Redo c1
295        let r1 = h.redo().expect("redo c1").clone();
296        source = r1.apply(&source); // "ab"
297        assert_eq!(source, "ab");
298
299        // Redo c2
300        let r2 = h.redo().expect("redo c2").clone();
301        source = r2.apply(&source); // "abc"
302        assert_eq!(source, "abc");
303    }
304}