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//! The checked entry points are [`TextRange::try_new`] and
7//! [`DocumentChange::try_apply`]. `try_apply` validates the edit against the
8//! actual document (bounds, UTF-8 character boundaries, and — when supplied —
9//! the expected `old_text`), then returns an [`AppliedChange`] whose
10//! [`inverse`](AppliedChange::inverse) is derived from the document itself,
11//! so undo evidence is never caller-authored fiction.
12//!
13//! The panicking [`TextRange::new`] / [`DocumentChange::apply`] entry points
14//! are kept for compatibility but deprecated; they delegate to the checked
15//! implementations so there is exactly one validation path.
16
17/// Error constructing a [`TextRange`].
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19pub enum RangeError {
20    /// The range's start offset is greater than its end offset.
21    #[error("invalid text range: start {start} > end {end}")]
22    StartAfterEnd {
23        /// The offending start offset.
24        start: usize,
25        /// The offending end offset.
26        end: usize,
27    },
28}
29
30/// Error applying a [`DocumentChange`] to a source string.
31#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32pub enum EditError {
33    /// The change's range is not a valid range (start > end).
34    #[error(transparent)]
35    Range(#[from] RangeError),
36    /// The change's range extends past the end of the source.
37    #[error("range end {end} is out of bounds for source of length {len}")]
38    OutOfBounds {
39        /// The offending (exclusive) end offset.
40        end: usize,
41        /// The source length in bytes.
42        len: usize,
43    },
44    /// A range endpoint does not sit on a UTF-8 character boundary.
45    #[error("byte offset {offset} is not on a UTF-8 character boundary")]
46    NotCharBoundary {
47        /// The offending byte offset.
48        offset: usize,
49    },
50    /// The change's `old_text` does not match what the source actually
51    /// contains at the range — the edit was built against a stale version
52    /// of the document. Precondition failure, not a panic.
53    #[error("stale edit: expected {expected:?} at range, found {found:?}")]
54    StaleEdit {
55        /// What the change claimed the range contained.
56        expected: String,
57        /// What the source actually contains at the range.
58        found: String,
59    },
60}
61
62/// A byte range within a document's source string.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct TextRange {
65    /// Inclusive start byte offset.
66    pub start: usize,
67    /// Exclusive end byte offset.
68    pub end: usize,
69}
70
71impl TextRange {
72    /// Creates a new `TextRange`, validating that `start <= end`.
73    ///
74    /// Bounds and UTF-8 boundary checks require the source string, so they
75    /// happen in [`DocumentChange::try_apply`], not here.
76    pub fn try_new(start: usize, end: usize) -> Result<Self, RangeError> {
77        if start > end {
78            return Err(RangeError::StartAfterEnd { start, end });
79        }
80        Ok(Self { start, end })
81    }
82
83    /// Creates a new `TextRange`.
84    #[deprecated(note = "panics on invalid input (start > end); use try_new")]
85    pub fn new(start: usize, end: usize) -> Self {
86        Self::try_new(start, end).expect("TextRange::new called with start > end; use try_new")
87    }
88
89    /// Returns the number of bytes covered by this range.
90    pub fn len(&self) -> usize {
91        self.end.saturating_sub(self.start)
92    }
93
94    /// Returns `true` if the range covers zero bytes.
95    pub fn is_empty(&self) -> bool {
96        self.len() == 0
97    }
98}
99
100/// A single text edit: replace `range` in the source with `new_text`.
101///
102/// `old_text` optionally records what the caller believes the range
103/// currently contains. When present, [`try_apply`](Self::try_apply)
104/// verifies it against the source and rejects the edit as
105/// [`EditError::StaleEdit`] on mismatch. Callers should not author undo
106/// evidence themselves — apply the change with `try_apply` and use the
107/// derived [`AppliedChange::inverse`] instead.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct DocumentChange {
110    /// The byte range that is replaced.
111    pub range: TextRange,
112    /// The text that replaces `range`.
113    pub new_text: String,
114    /// Optional precondition: the text the caller expects at `range`.
115    /// Verified by [`try_apply`](Self::try_apply) when present.
116    pub old_text: Option<String>,
117}
118
119impl DocumentChange {
120    /// Creates a change that replaces `range` with `new_text`, with no
121    /// `old_text` precondition. The true removed text is derived from the
122    /// source by [`try_apply`](Self::try_apply).
123    pub fn replace(range: TextRange, new_text: impl Into<String>) -> Self {
124        Self {
125            range,
126            new_text: new_text.into(),
127            old_text: None,
128        }
129    }
130
131    /// Creates a change that replaces `range` with `new_text`, asserting
132    /// that the range currently contains `old_text`.
133    /// [`try_apply`](Self::try_apply) rejects the edit as
134    /// [`EditError::StaleEdit`] if the assertion does not hold.
135    pub fn replace_expecting(
136        range: TextRange,
137        new_text: impl Into<String>,
138        old_text: impl Into<String>,
139    ) -> Self {
140        Self {
141            range,
142            new_text: new_text.into(),
143            old_text: Some(old_text.into()),
144        }
145    }
146
147    /// Creates a new `DocumentChange` from caller-authored parts.
148    #[deprecated(note = "trusts caller-authored old_text (unverified undo evidence); \
149                use replace/replace_expecting + try_apply")]
150    pub fn new(range: TextRange, new_text: impl Into<String>, old_text: impl Into<String>) -> Self {
151        Self::replace_expecting(range, new_text, old_text)
152    }
153
154    /// Applies this change to `source`, validating it first.
155    ///
156    /// Checks, in order:
157    /// 1. the range is well-formed (`start <= end`) — [`EditError::Range`];
158    /// 2. the range lies within `source` — [`EditError::OutOfBounds`];
159    /// 3. both endpoints sit on UTF-8 character boundaries —
160    ///    [`EditError::NotCharBoundary`];
161    /// 4. if `old_text` is present, it matches the source at the range —
162    ///    [`EditError::StaleEdit`].
163    ///
164    /// On success, returns an [`AppliedChange`] carrying the modified
165    /// document and an inverse derived from the *actual* removed text, so
166    /// the inverse is truthful even if the caller supplied no `old_text`.
167    ///
168    /// ```
169    /// use scrybe_core::change::{DocumentChange, TextRange};
170    ///
171    /// let range = TextRange::try_new(7, 12).unwrap();
172    /// let change = DocumentChange::replace(range, "Rust");
173    /// let applied = change.try_apply("Hello, world!").unwrap();
174    /// assert_eq!(applied.new_text, "Hello, Rust!");
175    /// // The inverse is derived from the document, not authored by us.
176    /// let undone = applied.inverse.try_apply(&applied.new_text).unwrap();
177    /// assert_eq!(undone.new_text, "Hello, world!");
178    /// ```
179    pub fn try_apply(&self, source: &str) -> Result<AppliedChange, EditError> {
180        let TextRange { start, end } = self.range;
181        // Re-validate even though try_new checks this: the fields are public,
182        // so a range may not have gone through try_new.
183        TextRange::try_new(start, end)?;
184        if end > source.len() {
185            return Err(EditError::OutOfBounds {
186                end,
187                len: source.len(),
188            });
189        }
190        for offset in [start, end] {
191            if !source.is_char_boundary(offset) {
192                return Err(EditError::NotCharBoundary { offset });
193            }
194        }
195
196        let removed = &source[start..end];
197        if let Some(expected) = &self.old_text {
198            if expected != removed {
199                return Err(EditError::StaleEdit {
200                    expected: expected.clone(),
201                    found: removed.to_string(),
202                });
203            }
204        }
205
206        let mut new_text =
207            String::with_capacity(source.len() - self.range.len() + self.new_text.len());
208        new_text.push_str(&source[..start]);
209        new_text.push_str(&self.new_text);
210        new_text.push_str(&source[end..]);
211
212        let inverse = Self {
213            range: TextRange {
214                start,
215                end: start + self.new_text.len(),
216            },
217            new_text: removed.to_string(),
218            old_text: Some(self.new_text.clone()),
219        };
220
221        Ok(AppliedChange { new_text, inverse })
222    }
223
224    /// Applies this change to `source`, returning the modified string.
225    ///
226    /// Panics if the change is invalid for `source`: range out of bounds or
227    /// off a character boundary, or (unlike the historical behavior) a
228    /// present `old_text` that does not match the source.
229    #[deprecated(note = "panics on invalid input; use try_apply")]
230    pub fn apply(&self, source: &str) -> String {
231        self.try_apply(source)
232            .expect("DocumentChange::apply called with an invalid change; use try_apply")
233            .new_text
234    }
235
236    /// Builds the inverse from this change's own (possibly caller-authored,
237    /// possibly absent) `old_text`, without consulting the document. A
238    /// missing `old_text` is treated as empty.
239    ///
240    /// This is exact for changes that came out of
241    /// [`try_apply`](Self::try_apply) (their `old_text` is derived and
242    /// verified); for anything else it is only as truthful as the caller.
243    fn inverse_unverified(&self) -> Self {
244        Self {
245            range: TextRange {
246                start: self.range.start,
247                end: self.range.start + self.new_text.len(),
248            },
249            new_text: self.old_text.clone().unwrap_or_default(),
250            old_text: Some(self.new_text.clone()),
251        }
252    }
253
254    /// Returns the inverse of this change (suitable for undoing).
255    ///
256    /// The inverse replaces the region `[start .. start + new_text.len()]`
257    /// (i.e. the bytes written by `apply`) back with `old_text`.
258    #[deprecated(note = "built from caller-authored old_text, which may not match the \
259                document; use the derived AppliedChange::inverse from try_apply")]
260    pub fn inverse(&self) -> Self {
261        self.inverse_unverified()
262    }
263}
264
265/// The result of successfully applying a [`DocumentChange`] to a source
266/// string via [`DocumentChange::try_apply`].
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct AppliedChange {
269    /// The full document text after the change.
270    pub new_text: String,
271    /// A change that, applied to [`new_text`](Self::new_text), restores the
272    /// original document exactly. Its `old_text` and replacement text are
273    /// derived from the source by the library, never caller-authored.
274    pub inverse: DocumentChange,
275}
276
277/// Undo/redo history for a document.
278///
279/// Changes are pushed as they are applied — prefer
280/// [`push_applied`](Self::push_applied) with the result of
281/// [`DocumentChange::try_apply`], so the recorded undo evidence is derived
282/// from the document rather than caller-authored. [`undo`](Self::undo)
283/// returns the inverse of the most-recent change; [`redo`](Self::redo)
284/// re-applies a change that was undone.
285#[derive(Debug, Default)]
286pub struct DocumentHistory {
287    past: Vec<DocumentChange>,
288    future: Vec<DocumentChange>,
289}
290
291impl DocumentHistory {
292    /// Creates an empty history.
293    pub fn new() -> Self {
294        Self::default()
295    }
296
297    /// Records a verified change. Any future (redo) stack is cleared.
298    ///
299    /// The forward change is reconstructed from the applied change's
300    /// derived inverse, so both undo and redo evidence come from the
301    /// document itself.
302    pub fn push_applied(&mut self, applied: &AppliedChange) {
303        // The inverse of the derived inverse is the forward change with its
304        // old_text filled in from the actual document — exact, because both
305        // sides of the derived inverse are fully specified.
306        self.push(applied.inverse.inverse_unverified());
307    }
308
309    /// Records a new change. Any future (redo) stack is cleared.
310    ///
311    /// The change's `old_text` is trusted as-is; prefer
312    /// [`push_applied`](Self::push_applied), which records evidence derived
313    /// from the document.
314    pub fn push(&mut self, change: DocumentChange) {
315        self.past.push(change);
316        self.future.clear();
317    }
318
319    /// Removes the most-recent change from the undo stack and returns a
320    /// reference to it, or `None` if the stack is empty.
321    ///
322    /// The inverse of the returned change is placed on the redo stack.
323    pub fn undo(&mut self) -> Option<&DocumentChange> {
324        let change = self.past.pop()?;
325        self.future.push(change.inverse_unverified());
326        // Return a reference into the redo stack (the last item is the one
327        // that the caller should apply to the document).
328        self.future.last()
329    }
330
331    /// Re-applies the most-recently-undone change and returns a reference
332    /// to it, or `None` if the redo stack is empty.
333    pub fn redo(&mut self) -> Option<&DocumentChange> {
334        let change = self.future.pop()?;
335        // The redo change is the inverse-of-the-inverse, i.e. the original.
336        // Push it back onto the undo stack.
337        self.past.push(change.inverse_unverified());
338        self.past.last()
339    }
340
341    /// Returns `true` if there is at least one change to undo.
342    pub fn can_undo(&self) -> bool {
343        !self.past.is_empty()
344    }
345
346    /// Returns `true` if there is at least one change to redo.
347    pub fn can_redo(&self) -> bool {
348        !self.future.is_empty()
349    }
350}
351
352// ---------------------------------------------------------------------------
353// Tests
354// ---------------------------------------------------------------------------
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn range(start: usize, end: usize) -> TextRange {
361        TextRange::try_new(start, end).expect("test range must be well-formed")
362    }
363
364    // -- TextRange -----------------------------------------------------------
365
366    #[test]
367    fn test_try_new_accepts_ordered_and_empty_ranges() {
368        assert_eq!(TextRange::try_new(2, 7), Ok(TextRange { start: 2, end: 7 }));
369        assert_eq!(TextRange::try_new(3, 3), Ok(TextRange { start: 3, end: 3 }));
370        assert_eq!(TextRange::try_new(0, 0), Ok(TextRange { start: 0, end: 0 }));
371    }
372
373    #[test]
374    fn test_try_new_rejects_reversed_range() {
375        assert_eq!(
376            TextRange::try_new(7, 2),
377            Err(RangeError::StartAfterEnd { start: 7, end: 2 })
378        );
379    }
380
381    #[test]
382    fn test_range_error_display() {
383        let err = RangeError::StartAfterEnd { start: 7, end: 2 };
384        assert_eq!(err.to_string(), "invalid text range: start 7 > end 2");
385    }
386
387    #[test]
388    fn test_text_range_len() {
389        assert_eq!(range(2, 7).len(), 5);
390        assert_eq!(range(3, 3).len(), 0);
391    }
392
393    #[test]
394    fn test_text_range_is_empty() {
395        assert!(range(5, 5).is_empty());
396        assert!(!range(5, 6).is_empty());
397    }
398
399    // -- DocumentChange::try_apply — success paths ---------------------------
400
401    #[test]
402    fn test_try_apply_replaces_range() {
403        let change = DocumentChange::replace_expecting(range(7, 12), "Rust", "world");
404        let applied = change.try_apply("Hello, world!").expect("valid edit");
405        assert_eq!(applied.new_text, "Hello, Rust!");
406    }
407
408    #[test]
409    fn test_try_apply_empty_insertion_at_start() {
410        let change = DocumentChange::replace(range(0, 0), "Hello ");
411        let applied = change.try_apply("world").expect("valid edit");
412        assert_eq!(applied.new_text, "Hello world");
413    }
414
415    #[test]
416    fn test_try_apply_empty_insertion_at_end() {
417        let source = "Hello";
418        let change = DocumentChange::replace(range(source.len(), source.len()), "!");
419        let applied = change.try_apply(source).expect("valid edit");
420        assert_eq!(applied.new_text, "Hello!");
421    }
422
423    #[test]
424    fn test_try_apply_empty_insertion_into_empty_source() {
425        let change = DocumentChange::replace(range(0, 0), "seed");
426        let applied = change.try_apply("").expect("valid edit");
427        assert_eq!(applied.new_text, "seed");
428    }
429
430    #[test]
431    fn test_try_apply_delete_whole_source() {
432        let source = "Hello, world!";
433        let change = DocumentChange::replace(range(0, source.len()), "");
434        let applied = change.try_apply(source).expect("valid edit");
435        assert_eq!(applied.new_text, "");
436        // The derived inverse restores everything.
437        assert_eq!(applied.inverse.new_text, source);
438    }
439
440    #[test]
441    fn test_try_apply_no_op_change() {
442        let change = DocumentChange::replace(range(3, 3), "");
443        let applied = change.try_apply("abcdef").expect("valid edit");
444        assert_eq!(applied.new_text, "abcdef");
445    }
446
447    #[test]
448    fn test_try_apply_multibyte_content_replacement() {
449        // "héllo 🦀" — é is 2 bytes (1..3), 🦀 is 4 bytes (7..11).
450        let source = "héllo 🦀";
451        let change = DocumentChange::replace(range(7, 11), "🐍");
452        let applied = change.try_apply(source).expect("valid edit");
453        assert_eq!(applied.new_text, "héllo 🐍");
454        assert_eq!(applied.inverse.new_text, "🦀");
455    }
456
457    // -- DocumentChange::try_apply — rejected edits --------------------------
458
459    #[test]
460    fn test_try_apply_rejects_reversed_range() {
461        // Public fields let a caller bypass try_new; try_apply re-validates.
462        let change = DocumentChange {
463            range: TextRange { start: 5, end: 2 },
464            new_text: "x".to_string(),
465            old_text: None,
466        };
467        assert_eq!(
468            change.try_apply("Hello, world!"),
469            Err(EditError::Range(RangeError::StartAfterEnd {
470                start: 5,
471                end: 2
472            }))
473        );
474    }
475
476    #[test]
477    fn test_try_apply_rejects_out_of_bounds_end() {
478        let change = DocumentChange::replace(range(0, 99), "x");
479        assert_eq!(
480            change.try_apply("short"),
481            Err(EditError::OutOfBounds { end: 99, len: 5 })
482        );
483    }
484
485    #[test]
486    fn test_try_apply_rejects_out_of_bounds_empty_insertion() {
487        let change = DocumentChange::replace(range(6, 6), "x");
488        assert_eq!(
489            change.try_apply("short"),
490            Err(EditError::OutOfBounds { end: 6, len: 5 })
491        );
492    }
493
494    #[test]
495    fn test_try_apply_rejects_split_two_byte_char() {
496        // é occupies bytes 1..3 of "café" → offset 4 splits it.
497        let source = "café";
498        assert_eq!(source.len(), 5);
499        let change = DocumentChange::replace(range(0, 4), "");
500        assert_eq!(
501            change.try_apply(source),
502            Err(EditError::NotCharBoundary { offset: 4 })
503        );
504    }
505
506    #[test]
507    fn test_try_apply_rejects_split_emoji() {
508        // 🦀 occupies bytes 0..4 → offsets 1..=3 are interior.
509        let change = DocumentChange::replace(range(0, 2), "");
510        assert_eq!(
511            change.try_apply("🦀"),
512            Err(EditError::NotCharBoundary { offset: 2 })
513        );
514    }
515
516    #[test]
517    fn test_try_apply_rejects_split_start_offset() {
518        let change = DocumentChange::replace(range(1, 4), "");
519        assert_eq!(
520            change.try_apply("🦀!"),
521            Err(EditError::NotCharBoundary { offset: 1 })
522        );
523    }
524
525    #[test]
526    fn test_try_apply_rejects_split_combining_mark() {
527        // "e\u{0301}" — 'e' at byte 0, U+0301 occupies bytes 1..3.
528        // Offset 2 lands inside the combining mark's UTF-8 encoding.
529        let source = "e\u{0301}";
530        let change = DocumentChange::replace(range(0, 2), "");
531        assert_eq!(
532            change.try_apply(source),
533            Err(EditError::NotCharBoundary { offset: 2 })
534        );
535    }
536
537    #[test]
538    fn test_try_apply_allows_char_boundary_between_base_and_combining_mark() {
539        // Byte 1 is a *character* boundary (between 'e' and U+0301) even
540        // though it is inside a grapheme cluster. The API validates char
541        // boundaries, not grapheme clusters — documented behavior.
542        let source = "e\u{0301}";
543        let change = DocumentChange::replace(range(1, 1), "x");
544        let applied = change.try_apply(source).expect("char boundary is valid");
545        assert_eq!(applied.new_text, "ex\u{0301}");
546    }
547
548    #[test]
549    fn test_try_apply_rejects_stale_old_text() {
550        let change = DocumentChange::replace_expecting(range(7, 12), "Rust", "world");
551        // The document moved on: the range now holds "there".
552        assert_eq!(
553            change.try_apply("Hello, there!"),
554            Err(EditError::StaleEdit {
555                expected: "world".to_string(),
556                found: "there".to_string(),
557            })
558        );
559    }
560
561    #[test]
562    fn test_try_apply_without_old_text_skips_stale_check() {
563        let change = DocumentChange::replace(range(7, 12), "Rust");
564        let applied = change.try_apply("Hello, there!").expect("no precondition");
565        assert_eq!(applied.new_text, "Hello, Rust!");
566        // ...but the derived inverse still records the truth.
567        assert_eq!(applied.inverse.new_text, "there");
568    }
569
570    #[test]
571    fn test_edit_error_display() {
572        let stale = EditError::StaleEdit {
573            expected: "a".to_string(),
574            found: "b".to_string(),
575        };
576        assert_eq!(
577            stale.to_string(),
578            "stale edit: expected \"a\" at range, found \"b\""
579        );
580        let oob = EditError::OutOfBounds { end: 9, len: 3 };
581        assert_eq!(
582            oob.to_string(),
583            "range end 9 is out of bounds for source of length 3"
584        );
585        let boundary = EditError::NotCharBoundary { offset: 2 };
586        assert_eq!(
587            boundary.to_string(),
588            "byte offset 2 is not on a UTF-8 character boundary"
589        );
590        // Range errors pass through transparently.
591        let range_err = EditError::Range(RangeError::StartAfterEnd { start: 3, end: 1 });
592        assert_eq!(range_err.to_string(), "invalid text range: start 3 > end 1");
593    }
594
595    // -- Derived inverses round-trip -----------------------------------------
596
597    #[test]
598    fn test_apply_then_inverse_restores_original() {
599        let source = "Hello, world!";
600        let change = DocumentChange::replace(range(7, 12), "Rust");
601        let applied = change.try_apply(source).expect("valid edit");
602        assert_eq!(applied.new_text, "Hello, Rust!");
603
604        let restored = applied
605            .inverse
606            .try_apply(&applied.new_text)
607            .expect("derived inverse is valid");
608        assert_eq!(restored.new_text, source);
609    }
610
611    #[test]
612    fn test_inverse_of_insertion_deletes() {
613        let source = "ab";
614        let change = DocumentChange::replace(range(1, 1), "XYZ");
615        let applied = change.try_apply(source).expect("valid edit");
616        assert_eq!(applied.new_text, "aXYZb");
617
618        let restored = applied
619            .inverse
620            .try_apply(&applied.new_text)
621            .expect("derived inverse is valid");
622        assert_eq!(restored.new_text, source);
623    }
624
625    #[test]
626    fn test_inverse_of_deletion_reinserts_exact_bytes() {
627        let source = "héllo 🦀 wörld";
628        let change = DocumentChange::replace(range(6, 11), "");
629        let applied = change.try_apply(source).expect("valid edit");
630        assert_eq!(applied.new_text, "héllo wörld");
631
632        let restored = applied
633            .inverse
634            .try_apply(&applied.new_text)
635            .expect("derived inverse is valid");
636        assert_eq!(restored.new_text, source);
637        // Byte-exact restoration.
638        assert_eq!(restored.new_text.as_bytes(), source.as_bytes());
639    }
640
641    #[test]
642    fn test_inverse_round_trip_multibyte_replacement() {
643        let source = "🦀🐍🦀";
644        let change = DocumentChange::replace(range(4, 8), "e\u{0301}");
645        let applied = change.try_apply(source).expect("valid edit");
646        assert_eq!(applied.new_text, "🦀e\u{0301}🦀");
647
648        let restored = applied
649            .inverse
650            .try_apply(&applied.new_text)
651            .expect("derived inverse is valid");
652        assert_eq!(restored.new_text.as_bytes(), source.as_bytes());
653    }
654
655    #[test]
656    fn test_derived_inverse_carries_verified_evidence() {
657        let change = DocumentChange::replace(range(0, 5), "Howdy");
658        let applied = change.try_apply("Hello, world!").expect("valid edit");
659        // The inverse's precondition is what the forward change wrote...
660        assert_eq!(applied.inverse.old_text.as_deref(), Some("Howdy"));
661        // ...and its replacement is what the document really contained.
662        assert_eq!(applied.inverse.new_text, "Hello");
663        assert_eq!(applied.inverse.range, TextRange { start: 0, end: 5 });
664    }
665
666    // -- DocumentHistory -----------------------------------------------------
667
668    #[test]
669    fn test_history_can_undo_after_push_applied() {
670        let mut h = DocumentHistory::new();
671        assert!(!h.can_undo());
672        let applied = DocumentChange::replace(range(0, 0), "x")
673            .try_apply("")
674            .expect("valid edit");
675        h.push_applied(&applied);
676        assert!(h.can_undo());
677    }
678
679    #[test]
680    fn test_history_undo_returns_verified_inverse() {
681        let source = "Hello, world!";
682        let applied = DocumentChange::replace(range(7, 12), "Rust")
683            .try_apply(source)
684            .expect("valid edit");
685
686        let mut h = DocumentHistory::new();
687        h.push_applied(&applied);
688
689        let undo_change = h.undo().expect("should have undo");
690        // The undo carries evidence derived from the document.
691        assert_eq!(undo_change.new_text, "world");
692        assert_eq!(undo_change.old_text.as_deref(), Some("Rust"));
693        let restored = undo_change
694            .try_apply(&applied.new_text)
695            .expect("undo is valid against the edited document");
696        assert_eq!(restored.new_text, source);
697    }
698
699    #[test]
700    fn test_history_undo_enables_redo() {
701        let applied = DocumentChange::replace(range(0, 1), "b")
702            .try_apply("a")
703            .expect("valid edit");
704        let mut h = DocumentHistory::new();
705        h.push_applied(&applied);
706        assert!(!h.can_redo());
707        h.undo();
708        assert!(h.can_redo());
709        assert!(!h.can_undo());
710    }
711
712    #[test]
713    fn test_history_redo_re_applies() {
714        let source = "a";
715        let applied = DocumentChange::replace(range(0, 1), "b")
716            .try_apply(source)
717            .expect("valid edit"); // "b"
718
719        let mut h = DocumentHistory::new();
720        h.push_applied(&applied);
721
722        let undo = h.undo().expect("undo").clone();
723        let after_undo = undo.try_apply(&applied.new_text).expect("valid undo");
724        assert_eq!(after_undo.new_text, "a");
725
726        let redo = h.redo().expect("redo").clone();
727        let after_redo = redo.try_apply(&after_undo.new_text).expect("valid redo");
728        assert_eq!(after_redo.new_text, "b");
729    }
730
731    #[test]
732    fn test_push_clears_redo_stack() {
733        let applied = DocumentChange::replace(range(0, 1), "b")
734            .try_apply("a")
735            .expect("valid edit");
736        let mut h = DocumentHistory::new();
737        h.push_applied(&applied);
738        h.undo();
739        assert!(h.can_redo());
740
741        // A new push should wipe the redo stack.
742        let applied2 = DocumentChange::replace(range(0, 1), "c")
743            .try_apply("a")
744            .expect("valid edit");
745        h.push_applied(&applied2);
746        assert!(!h.can_redo());
747    }
748
749    #[test]
750    fn test_multi_step_undo_redo() {
751        let mut source = String::from("a");
752        let mut h = DocumentHistory::new();
753
754        let a1 = DocumentChange::replace(range(1, 1), "b")
755            .try_apply(&source)
756            .expect("valid edit");
757        h.push_applied(&a1);
758        source = a1.new_text; // "ab"
759
760        let a2 = DocumentChange::replace(range(2, 2), "c")
761            .try_apply(&source)
762            .expect("valid edit");
763        h.push_applied(&a2);
764        source = a2.new_text; // "abc"
765
766        // Undo c2
767        let u2 = h.undo().expect("undo c2").clone();
768        source = u2.try_apply(&source).expect("valid undo").new_text;
769        assert_eq!(source, "ab");
770
771        // Undo c1
772        let u1 = h.undo().expect("undo c1").clone();
773        source = u1.try_apply(&source).expect("valid undo").new_text;
774        assert_eq!(source, "a");
775
776        // Redo c1
777        let r1 = h.redo().expect("redo c1").clone();
778        source = r1.try_apply(&source).expect("valid redo").new_text;
779        assert_eq!(source, "ab");
780
781        // Redo c2
782        let r2 = h.redo().expect("redo c2").clone();
783        source = r2.try_apply(&source).expect("valid redo").new_text;
784        assert_eq!(source, "abc");
785    }
786
787    // -- Deprecated compatibility shims --------------------------------------
788    //
789    // These tests exercise the deprecated panicking APIs on purpose, so the
790    // allow(deprecated) is scoped to this module only.
791    mod deprecated_compat {
792        #![allow(deprecated)]
793
794        use super::super::*;
795
796        #[test]
797        fn test_deprecated_apply_still_works_on_valid_input() {
798            let change = DocumentChange::new(TextRange::new(7, 12), "Rust", "world");
799            assert_eq!(change.apply("Hello, world!"), "Hello, Rust!");
800        }
801
802        #[test]
803        #[should_panic(expected = "invalid change")]
804        fn test_deprecated_apply_still_panics_on_out_of_bounds() {
805            let change = DocumentChange::new(TextRange::new(0, 99), "x", "y");
806            change.apply("short");
807        }
808
809        #[test]
810        #[should_panic(expected = "start > end")]
811        fn test_deprecated_text_range_new_panics_on_reversed_range() {
812            TextRange::new(7, 2);
813        }
814
815        #[test]
816        fn test_deprecated_inverse_round_trips_when_old_text_accurate() {
817            let source = "Hello, world!";
818            let change = DocumentChange::new(TextRange::new(7, 12), "Rust", "world");
819            let modified = change.apply(source);
820            let restored = change.inverse().apply(&modified);
821            assert_eq!(restored, source);
822        }
823
824        #[test]
825        fn test_deprecated_new_populates_old_text_precondition() {
826            let change = DocumentChange::new(TextRange::new(0, 5), "new", "old_t");
827            assert_eq!(change.old_text.as_deref(), Some("old_t"));
828        }
829    }
830}