use crate::model::{Mark, MarkKind, Content};
use serde::{Deserialize, Serialize};
use similar::{ChangeTag, TextDiff};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Delta {
pub ops: Vec<Op>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Op {
Retain(usize),
Insert(String),
Delete(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Assoc {
Before,
After,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BaseLengthMismatch {
pub expected: usize,
pub actual: usize,
}
impl Delta {
pub fn expected_base_len(&self) -> usize {
self.ops
.iter()
.map(|op| match op {
Op::Retain(n) | Op::Delete(n) => *n,
Op::Insert(_) => 0,
})
.sum()
}
pub fn apply(&self, base: &str) -> String {
let chars: Vec<char> = base.chars().collect();
let mut out = String::new();
let mut i = 0usize;
for op in &self.ops {
match op {
Op::Retain(n) => {
out.extend(&chars[i..i + n]);
i += n;
}
Op::Delete(n) => i += n,
Op::Insert(s) => out.push_str(s),
}
}
out.extend(&chars[i..]);
out
}
pub fn try_apply(&self, base: &str) -> Result<String, BaseLengthMismatch> {
let expected = self.expected_base_len();
let actual = base.chars().count();
if expected > actual {
return Err(BaseLengthMismatch { expected, actual });
}
Ok(self.apply(base))
}
pub fn map_pos(&self, pos: usize, assoc: Assoc) -> usize {
let mut old = 0usize;
let mut new = 0usize;
for op in &self.ops {
match op {
Op::Retain(n) => {
if pos < old + n {
return new + (pos - old);
}
old += n;
new += n;
}
Op::Delete(n) => {
if pos < old + n {
return new;
}
old += n;
}
Op::Insert(s) => {
let len = s.chars().count();
if pos == old {
match assoc {
Assoc::Before => return new,
Assoc::After => new += len, }
} else {
new += len;
}
}
}
}
new + pos.saturating_sub(old)
}
fn is_deleted(&self, pos: usize) -> bool {
let mut old = 0usize;
for op in &self.ops {
match op {
Op::Retain(n) => old += n,
Op::Delete(n) => {
if pos > old && pos < old + n {
return true;
}
old += n;
}
Op::Insert(_) => {}
}
}
false
}
fn inserted_spans(&self) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut new = 0usize;
for op in &self.ops {
match op {
Op::Retain(n) => new += n,
Op::Insert(s) => {
let len = s.chars().count();
if len > 0 {
spans.push((new, new + len));
}
new += len;
}
Op::Delete(_) => {}
}
}
spans
}
}
const MIN_MOVE: usize = 4;
const CHAR_DIFF_LIMIT: usize = 5_000;
pub fn diff(base: &str, new: &str) -> Delta {
let multiline = base.contains('\n') || new.contains('\n');
if !multiline
&& (base.chars().count() > CHAR_DIFF_LIMIT || new.chars().count() > CHAR_DIFF_LIMIT)
{
return coarse_replace(base, new);
}
let text_diff = if multiline {
TextDiff::from_lines(base, new)
} else {
TextDiff::from_chars(base, new)
};
let mut ops = Vec::new();
for change in text_diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => push_retain(&mut ops, change.value().chars().count()),
ChangeTag::Delete => push_delete(&mut ops, change.value().chars().count()),
ChangeTag::Insert => push_insert(&mut ops, change.value()),
}
}
Delta { ops }
}
fn coarse_replace(base: &str, new: &str) -> Delta {
let base_chars: Vec<char> = base.chars().collect();
let new_chars: Vec<char> = new.chars().collect();
let max_common = base_chars.len().min(new_chars.len());
let mut prefix = 0;
while prefix < max_common && base_chars[prefix] == new_chars[prefix] {
prefix += 1;
}
let mut suffix = 0;
while suffix < max_common - prefix
&& base_chars[base_chars.len() - 1 - suffix] == new_chars[new_chars.len() - 1 - suffix]
{
suffix += 1;
}
let mut ops = Vec::new();
push_retain(&mut ops, prefix);
push_delete(&mut ops, base_chars.len() - prefix - suffix);
let inserted: String = new_chars[prefix..new_chars.len() - suffix].iter().collect();
push_insert(&mut ops, &inserted);
push_retain(&mut ops, suffix);
Delta { ops }
}
fn push_retain(ops: &mut Vec<Op>, n: usize) {
if n == 0 {
return;
}
if let Some(Op::Retain(last)) = ops.last_mut() {
*last += n;
} else {
ops.push(Op::Retain(n));
}
}
fn push_delete(ops: &mut Vec<Op>, n: usize) {
if n == 0 {
return;
}
if let Some(Op::Delete(last)) = ops.last_mut() {
*last += n;
} else {
ops.push(Op::Delete(n));
}
}
fn push_insert(ops: &mut Vec<Op>, s: &str) {
if s.is_empty() {
return;
}
if let Some(Op::Insert(last)) = ops.last_mut() {
last.push_str(s);
} else {
ops.push(Op::Insert(s.to_owned()));
}
}
pub fn diff_import(
base: &Content,
new_markdown: &str,
) -> Result<(Content, Delta), crate::import::ImportError> {
let mut new_rt = crate::import::from_markdown(new_markdown)?;
let delta = diff(&base.text, &new_rt.text);
let base_chars: Vec<char> = base.text.chars().collect();
let new_chars: Vec<char> = new_rt.text.chars().collect();
let inserted = delta.inserted_spans();
for m in &base.marks {
let MarkKind::Anchor { .. } = &m.kind else {
continue;
};
if let Some((ns, ne)) = rebase_anchor(&delta, &base_chars, &new_chars, &inserted, m) {
new_rt.marks.push(Mark {
start: ns,
end: ne,
kind: m.kind.clone(),
});
}
}
new_rt.normalize();
Ok((new_rt, delta))
}
fn rebase_anchor(
delta: &Delta,
base_chars: &[char],
new_chars: &[char],
inserted: &[(usize, usize)],
m: &Mark,
) -> Option<(usize, usize)> {
if m.start == m.end {
if !delta.is_deleted(m.start) {
let p = delta.map_pos(m.start, Assoc::Before);
return Some((p, p));
}
return relocate_point(base_chars, new_chars, inserted, m.start);
}
let ns = delta.map_pos(m.start, Assoc::After);
let ne = delta.map_pos(m.end, Assoc::Before);
if ns < ne {
return Some((ns, ne)); }
relocate_span(base_chars, new_chars, inserted, m.start, m.end)
}
fn relocate_span(
base_chars: &[char],
new_chars: &[char],
inserted: &[(usize, usize)],
start: usize,
end: usize,
) -> Option<(usize, usize)> {
if end > base_chars.len() {
return None;
}
let needle = &base_chars[start..end];
find_in_spans(new_chars, needle, inserted).map(|pos| (pos, pos + needle.len()))
}
fn relocate_point(
base_chars: &[char],
new_chars: &[char],
inserted: &[(usize, usize)],
pos: usize,
) -> Option<(usize, usize)> {
const K: usize = 24;
let l0 = pos.saturating_sub(K);
let left = &base_chars[l0..pos];
if let Some(p) = find_in_spans(new_chars, left, inserted) {
return Some((p + left.len(), p + left.len()));
}
let r1 = (pos + K).min(base_chars.len());
let right = &base_chars[pos..r1];
if let Some(p) = find_in_spans(new_chars, right, inserted) {
return Some((p, p));
}
None
}
fn find_in_spans(hay: &[char], needle: &[char], spans: &[(usize, usize)]) -> Option<usize> {
if needle.len() < MIN_MOVE || needle.len() > hay.len() {
return None;
}
(0..=hay.len() - needle.len()).find(|&i| {
&hay[i..i + needle.len()] == needle
&& spans.iter().any(|&(s, e)| i < e && i + needle.len() > s)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::from_markdown;
use crate::model::MarkKind;
#[test]
fn diff_apply_round_trips() {
let d = diff("the quick brown fox", "the slow brown fox");
assert_eq!(d.apply("the quick brown fox"), "the slow brown fox");
}
#[test]
fn map_pos_insertion() {
let d = diff("abcdef", "abcXYdef");
assert_eq!(d.apply("abcdef"), "abcXYdef");
assert_eq!(d.map_pos(2, Assoc::After), 2);
assert_eq!(d.map_pos(4, Assoc::Before), 6);
}
#[test]
fn try_apply_accepts_short_delta_with_implicit_trailing_retain() {
let short = Delta {
ops: vec![Op::Insert("NEW ".into())],
};
assert_eq!(short.expected_base_len(), 0);
assert_eq!(short.try_apply("hello").unwrap(), "NEW hello");
let partial = Delta {
ops: vec![Op::Retain(1), Op::Insert("X".into())],
};
assert_eq!(partial.try_apply("hello").unwrap(), "hXello");
}
#[test]
fn try_apply_rejects_over_long_delta() {
let over = Delta {
ops: vec![Op::Retain(9)],
};
assert_eq!(
over.try_apply("hello"),
Err(BaseLengthMismatch {
expected: 9,
actual: 5,
})
);
let over_del = Delta {
ops: vec![Op::Delete(9)],
};
assert!(over_del.try_apply("hello").is_err());
}
#[test]
#[should_panic]
fn apply_panics_on_over_long_delta() {
let over = Delta {
ops: vec![Op::Retain(9)],
};
let _ = over.apply("hello");
}
#[test]
fn anchor_rehomed_on_block_move() {
let mut base = from_markdown("first para here\n\nsecond para here").unwrap();
base.marks.push(Mark {
start: 0,
end: 15,
kind: MarkKind::Anchor { id: "c1".into() },
});
base.normalize();
let (new_rt, _) = diff_import(&base, "second para here\n\nfirst para here").unwrap();
let anchor = new_rt
.marks
.iter()
.find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
.expect("anchor re-homed onto moved block");
assert_eq!(
new_rt.text[byte(&new_rt.text, anchor.start)..byte(&new_rt.text, anchor.end)]
.to_string(),
"first para here"
);
}
#[test]
fn anchor_dropped_when_text_deleted() {
let mut base = from_markdown("keep this and drop that").unwrap();
base.marks.push(Mark {
start: 14,
end: 23,
kind: MarkKind::Anchor { id: "c1".into() },
});
base.normalize();
let (new_rt, _) = diff_import(&base, "keep this").unwrap();
assert!(
!new_rt
.marks
.iter()
.any(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1")),
"anchor on deleted text detaches (accepted residual)"
);
}
#[test]
fn anchor_not_rehomed_onto_unrelated_survivor() {
let mut base = from_markdown("target one to drop\n\nkeep the target two").unwrap();
base.marks.push(Mark {
start: 0,
end: 6, kind: MarkKind::Anchor { id: "c1".into() },
});
base.normalize();
let (new_rt, _) = diff_import(&base, "keep the target two").unwrap();
assert!(
!new_rt
.marks
.iter()
.any(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1")),
"anchor wrongly re-homed onto surviving unrelated text"
);
}
#[test]
fn map_pos_after_moves_past_boundary_insertion() {
let d = diff("abcdef", "abcXYdef");
assert_eq!(d.map_pos(3, Assoc::After), 5);
assert_eq!(d.map_pos(3, Assoc::Before), 3);
}
#[test]
fn point_anchor_at_deletion_left_edge_survives() {
let d = diff("abcdef", "abef"); assert!(!d.is_deleted(2), "left edge of deletion survives");
assert!(d.is_deleted(3), "interior of deletion is deleted");
}
#[test]
fn disjoint_edits_are_separate_ops() {
let d = diff("aaaMIDDLEbbb", "AAAMIDDLEZZZ");
assert_eq!(d.apply("aaaMIDDLEbbb"), "AAAMIDDLEZZZ");
let retained: usize = d
.ops
.iter()
.filter_map(|op| match op {
Op::Retain(n) => Some(*n),
_ => None,
})
.sum();
assert!(
retained >= 6,
"unchanged middle span retained ({retained} USV): {ops:?}",
ops = d.ops
);
assert!(
!matches!(d.ops.as_slice(), [Op::Delete(_), Op::Insert(_)]),
"coarse single replace: {ops:?}",
ops = d.ops
);
}
#[test]
fn anchor_survives_between_disjoint_edits() {
let mut base = from_markdown("aaaMIDDLEbbb").unwrap();
base.marks.push(Mark {
start: 3,
end: 9,
kind: MarkKind::Anchor { id: "c1".into() },
});
base.normalize();
let (new_rt, _) = diff_import(&base, "AAAMIDDLEZZZ").unwrap();
let anchor = new_rt
.marks
.iter()
.find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
.expect("anchor between disjoint edits survives without move detector");
assert_eq!(
new_rt.text[byte(&new_rt.text, anchor.start)..byte(&new_rt.text, anchor.end)]
.to_string(),
"MIDDLE"
);
}
fn byte(s: &str, char_idx: usize) -> usize {
crate::usv::char_to_byte(s, char_idx)
}
fn filler(n: usize, offset: u8) -> String {
(0..n)
.map(|i| char::from(b'a' + ((i as u8).wrapping_mul(7).wrapping_add(offset)) % 26))
.collect()
}
#[test]
fn large_single_line_diff_stays_fast() {
let base = format!("PREFIX-{}-BASE-SUFFIX", filler(25_000, 0));
let new = format!("PREFIX-{}-NEW-SUFFIX", filler(25_000, 13));
let start = std::time::Instant::now();
let d = diff(&base, &new);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(2),
"large single-line diff took {elapsed:?}, expected well under the 2s budget"
);
assert_eq!(d.apply(&base), new);
}
#[test]
fn large_single_line_diff_retains_common_prefix_and_suffix() {
let base = format!("shared-prefix-{}-shared-suffix", filler(20_000, 0));
let new = format!("shared-prefix-{}-shared-suffix", filler(20_000, 5));
let d = diff(&base, &new);
assert_eq!(d.apply(&base), new);
let Some(Op::Retain(prefix_len)) = d.ops.first() else {
panic!("expected a leading Retain for the shared prefix: {:?}", d.ops);
};
assert!(
*prefix_len >= "shared-prefix-".len(),
"shared prefix should be retained, got Retain({prefix_len})"
);
let Some(Op::Retain(suffix_len)) = d.ops.last() else {
panic!("expected a trailing Retain for the shared suffix: {:?}", d.ops);
};
assert!(
*suffix_len >= "shared-suffix".len(),
"shared suffix should be retained, got Retain({suffix_len})"
);
}
#[test]
fn diff_import_large_single_line_rewrite_keeps_prefix_anchor() {
let base_text = format!("hello target world-{}-end", filler(30_000, 0));
let mut base = from_markdown(&base_text).unwrap();
base.marks.push(Mark {
start: 6,
end: 12, kind: MarkKind::Anchor { id: "c1".into() },
});
base.normalize();
let new_markdown = format!("hello target world-{}-end", filler(30_000, 11));
let start = std::time::Instant::now();
let (new_rt, _delta) = diff_import(&base, &new_markdown).unwrap();
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(2),
"diff_import took {elapsed:?}, expected well under the 2s budget"
);
let anchor = new_rt
.marks
.iter()
.find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
.expect("anchor in shared prefix survives the coarse fallback diff");
assert_eq!(
new_rt.text[byte(&new_rt.text, anchor.start)..byte(&new_rt.text, anchor.end)]
.to_string(),
"target"
);
}
}