#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RangeError {
#[error("invalid text range: start {start} > end {end}")]
StartAfterEnd {
start: usize,
end: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum EditError {
#[error(transparent)]
Range(#[from] RangeError),
#[error("range end {end} is out of bounds for source of length {len}")]
OutOfBounds {
end: usize,
len: usize,
},
#[error("byte offset {offset} is not on a UTF-8 character boundary")]
NotCharBoundary {
offset: usize,
},
#[error("stale edit: expected {expected:?} at range, found {found:?}")]
StaleEdit {
expected: String,
found: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextRange {
pub start: usize,
pub end: usize,
}
impl TextRange {
pub fn try_new(start: usize, end: usize) -> Result<Self, RangeError> {
if start > end {
return Err(RangeError::StartAfterEnd { start, end });
}
Ok(Self { start, end })
}
#[deprecated(note = "panics on invalid input (start > end); use try_new")]
pub fn new(start: usize, end: usize) -> Self {
Self::try_new(start, end).expect("TextRange::new called with start > end; use try_new")
}
pub fn len(&self) -> usize {
self.end.saturating_sub(self.start)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentChange {
pub range: TextRange,
pub new_text: String,
pub old_text: Option<String>,
}
impl DocumentChange {
pub fn replace(range: TextRange, new_text: impl Into<String>) -> Self {
Self {
range,
new_text: new_text.into(),
old_text: None,
}
}
pub fn replace_expecting(
range: TextRange,
new_text: impl Into<String>,
old_text: impl Into<String>,
) -> Self {
Self {
range,
new_text: new_text.into(),
old_text: Some(old_text.into()),
}
}
#[deprecated(note = "trusts caller-authored old_text (unverified undo evidence); \
use replace/replace_expecting + try_apply")]
pub fn new(range: TextRange, new_text: impl Into<String>, old_text: impl Into<String>) -> Self {
Self::replace_expecting(range, new_text, old_text)
}
pub fn try_apply(&self, source: &str) -> Result<AppliedChange, EditError> {
let TextRange { start, end } = self.range;
TextRange::try_new(start, end)?;
if end > source.len() {
return Err(EditError::OutOfBounds {
end,
len: source.len(),
});
}
for offset in [start, end] {
if !source.is_char_boundary(offset) {
return Err(EditError::NotCharBoundary { offset });
}
}
let removed = &source[start..end];
if let Some(expected) = &self.old_text {
if expected != removed {
return Err(EditError::StaleEdit {
expected: expected.clone(),
found: removed.to_string(),
});
}
}
let mut new_text =
String::with_capacity(source.len() - self.range.len() + self.new_text.len());
new_text.push_str(&source[..start]);
new_text.push_str(&self.new_text);
new_text.push_str(&source[end..]);
let inverse = Self {
range: TextRange {
start,
end: start + self.new_text.len(),
},
new_text: removed.to_string(),
old_text: Some(self.new_text.clone()),
};
Ok(AppliedChange { new_text, inverse })
}
#[deprecated(note = "panics on invalid input; use try_apply")]
pub fn apply(&self, source: &str) -> String {
self.try_apply(source)
.expect("DocumentChange::apply called with an invalid change; use try_apply")
.new_text
}
fn inverse_unverified(&self) -> Self {
Self {
range: TextRange {
start: self.range.start,
end: self.range.start + self.new_text.len(),
},
new_text: self.old_text.clone().unwrap_or_default(),
old_text: Some(self.new_text.clone()),
}
}
#[deprecated(note = "built from caller-authored old_text, which may not match the \
document; use the derived AppliedChange::inverse from try_apply")]
pub fn inverse(&self) -> Self {
self.inverse_unverified()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedChange {
pub new_text: String,
pub inverse: DocumentChange,
}
#[derive(Debug, Default)]
pub struct DocumentHistory {
past: Vec<DocumentChange>,
future: Vec<DocumentChange>,
}
impl DocumentHistory {
pub fn new() -> Self {
Self::default()
}
pub fn push_applied(&mut self, applied: &AppliedChange) {
self.push(applied.inverse.inverse_unverified());
}
pub fn push(&mut self, change: DocumentChange) {
self.past.push(change);
self.future.clear();
}
pub fn undo(&mut self) -> Option<&DocumentChange> {
let change = self.past.pop()?;
self.future.push(change.inverse_unverified());
self.future.last()
}
pub fn redo(&mut self) -> Option<&DocumentChange> {
let change = self.future.pop()?;
self.past.push(change.inverse_unverified());
self.past.last()
}
pub fn can_undo(&self) -> bool {
!self.past.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.future.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn range(start: usize, end: usize) -> TextRange {
TextRange::try_new(start, end).expect("test range must be well-formed")
}
#[test]
fn test_try_new_accepts_ordered_and_empty_ranges() {
assert_eq!(TextRange::try_new(2, 7), Ok(TextRange { start: 2, end: 7 }));
assert_eq!(TextRange::try_new(3, 3), Ok(TextRange { start: 3, end: 3 }));
assert_eq!(TextRange::try_new(0, 0), Ok(TextRange { start: 0, end: 0 }));
}
#[test]
fn test_try_new_rejects_reversed_range() {
assert_eq!(
TextRange::try_new(7, 2),
Err(RangeError::StartAfterEnd { start: 7, end: 2 })
);
}
#[test]
fn test_range_error_display() {
let err = RangeError::StartAfterEnd { start: 7, end: 2 };
assert_eq!(err.to_string(), "invalid text range: start 7 > end 2");
}
#[test]
fn test_text_range_len() {
assert_eq!(range(2, 7).len(), 5);
assert_eq!(range(3, 3).len(), 0);
}
#[test]
fn test_text_range_is_empty() {
assert!(range(5, 5).is_empty());
assert!(!range(5, 6).is_empty());
}
#[test]
fn test_try_apply_replaces_range() {
let change = DocumentChange::replace_expecting(range(7, 12), "Rust", "world");
let applied = change.try_apply("Hello, world!").expect("valid edit");
assert_eq!(applied.new_text, "Hello, Rust!");
}
#[test]
fn test_try_apply_empty_insertion_at_start() {
let change = DocumentChange::replace(range(0, 0), "Hello ");
let applied = change.try_apply("world").expect("valid edit");
assert_eq!(applied.new_text, "Hello world");
}
#[test]
fn test_try_apply_empty_insertion_at_end() {
let source = "Hello";
let change = DocumentChange::replace(range(source.len(), source.len()), "!");
let applied = change.try_apply(source).expect("valid edit");
assert_eq!(applied.new_text, "Hello!");
}
#[test]
fn test_try_apply_empty_insertion_into_empty_source() {
let change = DocumentChange::replace(range(0, 0), "seed");
let applied = change.try_apply("").expect("valid edit");
assert_eq!(applied.new_text, "seed");
}
#[test]
fn test_try_apply_delete_whole_source() {
let source = "Hello, world!";
let change = DocumentChange::replace(range(0, source.len()), "");
let applied = change.try_apply(source).expect("valid edit");
assert_eq!(applied.new_text, "");
assert_eq!(applied.inverse.new_text, source);
}
#[test]
fn test_try_apply_no_op_change() {
let change = DocumentChange::replace(range(3, 3), "");
let applied = change.try_apply("abcdef").expect("valid edit");
assert_eq!(applied.new_text, "abcdef");
}
#[test]
fn test_try_apply_multibyte_content_replacement() {
let source = "héllo 🦀";
let change = DocumentChange::replace(range(7, 11), "🐍");
let applied = change.try_apply(source).expect("valid edit");
assert_eq!(applied.new_text, "héllo 🐍");
assert_eq!(applied.inverse.new_text, "🦀");
}
#[test]
fn test_try_apply_rejects_reversed_range() {
let change = DocumentChange {
range: TextRange { start: 5, end: 2 },
new_text: "x".to_string(),
old_text: None,
};
assert_eq!(
change.try_apply("Hello, world!"),
Err(EditError::Range(RangeError::StartAfterEnd {
start: 5,
end: 2
}))
);
}
#[test]
fn test_try_apply_rejects_out_of_bounds_end() {
let change = DocumentChange::replace(range(0, 99), "x");
assert_eq!(
change.try_apply("short"),
Err(EditError::OutOfBounds { end: 99, len: 5 })
);
}
#[test]
fn test_try_apply_rejects_out_of_bounds_empty_insertion() {
let change = DocumentChange::replace(range(6, 6), "x");
assert_eq!(
change.try_apply("short"),
Err(EditError::OutOfBounds { end: 6, len: 5 })
);
}
#[test]
fn test_try_apply_rejects_split_two_byte_char() {
let source = "café";
assert_eq!(source.len(), 5);
let change = DocumentChange::replace(range(0, 4), "");
assert_eq!(
change.try_apply(source),
Err(EditError::NotCharBoundary { offset: 4 })
);
}
#[test]
fn test_try_apply_rejects_split_emoji() {
let change = DocumentChange::replace(range(0, 2), "");
assert_eq!(
change.try_apply("🦀"),
Err(EditError::NotCharBoundary { offset: 2 })
);
}
#[test]
fn test_try_apply_rejects_split_start_offset() {
let change = DocumentChange::replace(range(1, 4), "");
assert_eq!(
change.try_apply("🦀!"),
Err(EditError::NotCharBoundary { offset: 1 })
);
}
#[test]
fn test_try_apply_rejects_split_combining_mark() {
let source = "e\u{0301}";
let change = DocumentChange::replace(range(0, 2), "");
assert_eq!(
change.try_apply(source),
Err(EditError::NotCharBoundary { offset: 2 })
);
}
#[test]
fn test_try_apply_allows_char_boundary_between_base_and_combining_mark() {
let source = "e\u{0301}";
let change = DocumentChange::replace(range(1, 1), "x");
let applied = change.try_apply(source).expect("char boundary is valid");
assert_eq!(applied.new_text, "ex\u{0301}");
}
#[test]
fn test_try_apply_rejects_stale_old_text() {
let change = DocumentChange::replace_expecting(range(7, 12), "Rust", "world");
assert_eq!(
change.try_apply("Hello, there!"),
Err(EditError::StaleEdit {
expected: "world".to_string(),
found: "there".to_string(),
})
);
}
#[test]
fn test_try_apply_without_old_text_skips_stale_check() {
let change = DocumentChange::replace(range(7, 12), "Rust");
let applied = change.try_apply("Hello, there!").expect("no precondition");
assert_eq!(applied.new_text, "Hello, Rust!");
assert_eq!(applied.inverse.new_text, "there");
}
#[test]
fn test_edit_error_display() {
let stale = EditError::StaleEdit {
expected: "a".to_string(),
found: "b".to_string(),
};
assert_eq!(
stale.to_string(),
"stale edit: expected \"a\" at range, found \"b\""
);
let oob = EditError::OutOfBounds { end: 9, len: 3 };
assert_eq!(
oob.to_string(),
"range end 9 is out of bounds for source of length 3"
);
let boundary = EditError::NotCharBoundary { offset: 2 };
assert_eq!(
boundary.to_string(),
"byte offset 2 is not on a UTF-8 character boundary"
);
let range_err = EditError::Range(RangeError::StartAfterEnd { start: 3, end: 1 });
assert_eq!(range_err.to_string(), "invalid text range: start 3 > end 1");
}
#[test]
fn test_apply_then_inverse_restores_original() {
let source = "Hello, world!";
let change = DocumentChange::replace(range(7, 12), "Rust");
let applied = change.try_apply(source).expect("valid edit");
assert_eq!(applied.new_text, "Hello, Rust!");
let restored = applied
.inverse
.try_apply(&applied.new_text)
.expect("derived inverse is valid");
assert_eq!(restored.new_text, source);
}
#[test]
fn test_inverse_of_insertion_deletes() {
let source = "ab";
let change = DocumentChange::replace(range(1, 1), "XYZ");
let applied = change.try_apply(source).expect("valid edit");
assert_eq!(applied.new_text, "aXYZb");
let restored = applied
.inverse
.try_apply(&applied.new_text)
.expect("derived inverse is valid");
assert_eq!(restored.new_text, source);
}
#[test]
fn test_inverse_of_deletion_reinserts_exact_bytes() {
let source = "héllo 🦀 wörld";
let change = DocumentChange::replace(range(6, 11), "");
let applied = change.try_apply(source).expect("valid edit");
assert_eq!(applied.new_text, "héllo wörld");
let restored = applied
.inverse
.try_apply(&applied.new_text)
.expect("derived inverse is valid");
assert_eq!(restored.new_text, source);
assert_eq!(restored.new_text.as_bytes(), source.as_bytes());
}
#[test]
fn test_inverse_round_trip_multibyte_replacement() {
let source = "🦀🐍🦀";
let change = DocumentChange::replace(range(4, 8), "e\u{0301}");
let applied = change.try_apply(source).expect("valid edit");
assert_eq!(applied.new_text, "🦀e\u{0301}🦀");
let restored = applied
.inverse
.try_apply(&applied.new_text)
.expect("derived inverse is valid");
assert_eq!(restored.new_text.as_bytes(), source.as_bytes());
}
#[test]
fn test_derived_inverse_carries_verified_evidence() {
let change = DocumentChange::replace(range(0, 5), "Howdy");
let applied = change.try_apply("Hello, world!").expect("valid edit");
assert_eq!(applied.inverse.old_text.as_deref(), Some("Howdy"));
assert_eq!(applied.inverse.new_text, "Hello");
assert_eq!(applied.inverse.range, TextRange { start: 0, end: 5 });
}
#[test]
fn test_history_can_undo_after_push_applied() {
let mut h = DocumentHistory::new();
assert!(!h.can_undo());
let applied = DocumentChange::replace(range(0, 0), "x")
.try_apply("")
.expect("valid edit");
h.push_applied(&applied);
assert!(h.can_undo());
}
#[test]
fn test_history_undo_returns_verified_inverse() {
let source = "Hello, world!";
let applied = DocumentChange::replace(range(7, 12), "Rust")
.try_apply(source)
.expect("valid edit");
let mut h = DocumentHistory::new();
h.push_applied(&applied);
let undo_change = h.undo().expect("should have undo");
assert_eq!(undo_change.new_text, "world");
assert_eq!(undo_change.old_text.as_deref(), Some("Rust"));
let restored = undo_change
.try_apply(&applied.new_text)
.expect("undo is valid against the edited document");
assert_eq!(restored.new_text, source);
}
#[test]
fn test_history_undo_enables_redo() {
let applied = DocumentChange::replace(range(0, 1), "b")
.try_apply("a")
.expect("valid edit");
let mut h = DocumentHistory::new();
h.push_applied(&applied);
assert!(!h.can_redo());
h.undo();
assert!(h.can_redo());
assert!(!h.can_undo());
}
#[test]
fn test_history_redo_re_applies() {
let source = "a";
let applied = DocumentChange::replace(range(0, 1), "b")
.try_apply(source)
.expect("valid edit");
let mut h = DocumentHistory::new();
h.push_applied(&applied);
let undo = h.undo().expect("undo").clone();
let after_undo = undo.try_apply(&applied.new_text).expect("valid undo");
assert_eq!(after_undo.new_text, "a");
let redo = h.redo().expect("redo").clone();
let after_redo = redo.try_apply(&after_undo.new_text).expect("valid redo");
assert_eq!(after_redo.new_text, "b");
}
#[test]
fn test_push_clears_redo_stack() {
let applied = DocumentChange::replace(range(0, 1), "b")
.try_apply("a")
.expect("valid edit");
let mut h = DocumentHistory::new();
h.push_applied(&applied);
h.undo();
assert!(h.can_redo());
let applied2 = DocumentChange::replace(range(0, 1), "c")
.try_apply("a")
.expect("valid edit");
h.push_applied(&applied2);
assert!(!h.can_redo());
}
#[test]
fn test_multi_step_undo_redo() {
let mut source = String::from("a");
let mut h = DocumentHistory::new();
let a1 = DocumentChange::replace(range(1, 1), "b")
.try_apply(&source)
.expect("valid edit");
h.push_applied(&a1);
source = a1.new_text;
let a2 = DocumentChange::replace(range(2, 2), "c")
.try_apply(&source)
.expect("valid edit");
h.push_applied(&a2);
source = a2.new_text;
let u2 = h.undo().expect("undo c2").clone();
source = u2.try_apply(&source).expect("valid undo").new_text;
assert_eq!(source, "ab");
let u1 = h.undo().expect("undo c1").clone();
source = u1.try_apply(&source).expect("valid undo").new_text;
assert_eq!(source, "a");
let r1 = h.redo().expect("redo c1").clone();
source = r1.try_apply(&source).expect("valid redo").new_text;
assert_eq!(source, "ab");
let r2 = h.redo().expect("redo c2").clone();
source = r2.try_apply(&source).expect("valid redo").new_text;
assert_eq!(source, "abc");
}
mod deprecated_compat {
#![allow(deprecated)]
use super::super::*;
#[test]
fn test_deprecated_apply_still_works_on_valid_input() {
let change = DocumentChange::new(TextRange::new(7, 12), "Rust", "world");
assert_eq!(change.apply("Hello, world!"), "Hello, Rust!");
}
#[test]
#[should_panic(expected = "invalid change")]
fn test_deprecated_apply_still_panics_on_out_of_bounds() {
let change = DocumentChange::new(TextRange::new(0, 99), "x", "y");
change.apply("short");
}
#[test]
#[should_panic(expected = "start > end")]
fn test_deprecated_text_range_new_panics_on_reversed_range() {
TextRange::new(7, 2);
}
#[test]
fn test_deprecated_inverse_round_trips_when_old_text_accurate() {
let source = "Hello, world!";
let change = DocumentChange::new(TextRange::new(7, 12), "Rust", "world");
let modified = change.apply(source);
let restored = change.inverse().apply(&modified);
assert_eq!(restored, source);
}
#[test]
fn test_deprecated_new_populates_old_text_precondition() {
let change = DocumentChange::new(TextRange::new(0, 5), "new", "old_t");
assert_eq!(change.old_text.as_deref(), Some("old_t"));
}
}
}