use std::ops::Range;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit {
pub range: Range<usize>,
pub insert: String,
}
impl Edit {
pub fn delta(&self) -> isize {
self.insert.len() as isize - (self.range.end - self.range.start) as isize
}
pub fn fits(&self, text: &str) -> bool {
self.range.start <= self.range.end
&& self.range.end <= text.len()
&& text.is_char_boundary(self.range.start)
&& text.is_char_boundary(self.range.end)
}
pub fn apply(&self, old: &str) -> String {
let mut out =
String::with_capacity(old.len().saturating_sub(self.range.len()) + self.insert.len());
out.push_str(&old[..self.range.start]);
out.push_str(&self.insert);
out.push_str(&old[self.range.end..]);
out
}
}
pub fn apply_edits(old: &str, edits: &[Edit]) -> String {
try_apply_edits(old, edits).expect("apply_edits: edit chain does not fit the text")
}
pub fn try_apply_edits(old: &str, edits: &[Edit]) -> Option<String> {
let mut text = old.to_string();
for e in edits {
if !e.fits(&text) {
return None;
}
text.replace_range(e.range.clone(), &e.insert);
}
Some(text)
}
pub fn diff_edit(old: &str, new: &str) -> Edit {
let ob = old.as_bytes();
let nb = new.as_bytes();
let mut prefix = 0;
let max_prefix = ob.len().min(nb.len());
while prefix < max_prefix && ob[prefix] == nb[prefix] {
prefix += 1;
}
while prefix > 0 && !old.is_char_boundary(prefix) {
prefix -= 1;
}
let mut suffix = 0;
let max_suffix = (ob.len() - prefix).min(nb.len() - prefix);
while suffix < max_suffix && ob[ob.len() - 1 - suffix] == nb[nb.len() - 1 - suffix] {
suffix += 1;
}
while suffix > 0
&& (!old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix))
{
suffix -= 1;
}
Edit {
range: prefix..(old.len() - suffix),
insert: new[prefix..(new.len() - suffix)].to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn edit(range: Range<usize>, insert: &str) -> Edit {
Edit {
range,
insert: insert.to_string(),
}
}
fn assert_recovers(old: &str, new: &str) -> Edit {
let e = diff_edit(old, new);
assert!(e.fits(old), "{e:?} does not fit {old:?}");
assert_eq!(e.apply(old), new, "diff_edit({old:?}, {new:?}) = {e:?}");
e
}
#[test]
fn diff_edit_recovers_a_noop() {
assert_eq!(
assert_recovers("\\section{Hi}\n", "\\section{Hi}\n").insert,
""
);
}
#[test]
fn diff_edit_recovers_an_insertion() {
assert_eq!(assert_recovers("ab\n", "axb\n"), edit(1..1, "x"));
}
#[test]
fn diff_edit_recovers_a_deletion() {
assert_eq!(assert_recovers("axb\n", "ab\n"), edit(1..2, ""));
}
#[test]
fn diff_edit_recovers_a_replacement() {
assert_eq!(
assert_recovers("\\alpha\n", "\\gamma\n"),
edit(1..5, "gamm")
);
}
#[test]
fn diff_edit_collapses_disjoint_edits_into_one_span() {
let e = assert_recovers("a x b y c\n", "a X b Y c\n");
assert_eq!(e, edit(2..7, "X b Y"));
}
#[test]
fn diff_edit_handles_whole_replacement_and_empty_texts() {
assert_recovers("\\begin{a}\n", "\\end{b}\n");
assert_recovers("", "\\section{x}");
assert_recovers("\\section{x}", "");
assert_recovers("", "");
}
#[test]
fn diff_edit_clamps_to_char_boundaries() {
let e = assert_recovers("αβ\n", "αγ\n");
assert!("αβ\n".is_char_boundary(e.range.start));
assert!("αβ\n".is_char_boundary(e.range.end));
}
#[test]
fn diff_edit_clamps_a_shared_suffix_that_splits_a_char() {
assert_recovers("xα\n", "yα\n");
assert_recovers("α\n", "αα\n");
}
#[test]
fn apply_edits_chains_left_to_right() {
let edits = [edit(0..0, "\\a"), edit(2..2, "{b}")];
assert_eq!(apply_edits("\n", &edits), "\\a{b}\n");
}
#[test]
fn try_apply_edits_rejects_an_out_of_bounds_range() {
assert_eq!(try_apply_edits("ab", &[edit(9..9, "x")]), None);
}
#[test]
#[allow(
clippy::reversed_empty_ranges,
reason = "the inverted range is the input under test"
)]
fn try_apply_edits_rejects_an_inverted_range() {
assert_eq!(try_apply_edits("ab", &[edit(2..1, "x")]), None);
}
#[test]
fn try_apply_edits_rejects_an_offset_inside_a_char() {
assert_eq!(try_apply_edits("α", &[edit(1..1, "x")]), None);
}
#[test]
fn try_apply_edits_validates_each_step_against_its_predecessor() {
assert_eq!(
try_apply_edits("abc", &[edit(0..3, ""), edit(1..1, "x")]),
None
);
assert_eq!(
try_apply_edits("abc", &[edit(3..3, "de"), edit(4..5, "X")]).as_deref(),
Some("abcdX"),
);
}
#[test]
fn delta_is_the_shift_applied_to_later_offsets() {
assert_eq!(edit(0..0, "xy").delta(), 2);
assert_eq!(edit(0..2, "").delta(), -2);
assert_eq!(edit(0..2, "ab").delta(), 0);
}
}