Skip to main content

mtplatx_doc_diff/
lib.rs

1//! Semantic tree diff.
2//!
3//! The diff engine compares two [`Document`]s at the semantic level, never at
4//! the source-text level. It emits an ordered [`Patch`] of [`Operation`]s that
5//! can be replayed to transform `old` into `new`.
6//!
7//! The implementation is a Myers-style block LCS over top-level blocks. For
8//! nested structures (lists, tables, quotes) the inner diff is approximated
9//! by re-running the same algorithm over the child vectors.
10
11use mtplatx_doc_core::{Block, Document, Inline};
12use serde::{Deserialize, Serialize};
13
14/// A kind of edit.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16#[serde(tag = "op", rename_all = "snake_case")]
17pub enum Operation {
18    Insert {
19        index: usize,
20        block: Block,
21    },
22    Delete {
23        index: usize,
24    },
25    Replace {
26        index: usize,
27        block: Block,
28    },
29    Move {
30        from: usize,
31        to: usize,
32    },
33    /// Whole-document metadata change.
34    UpdateMetadata {
35        from: serde_json::Value,
36        to: serde_json::Value,
37    },
38}
39
40/// A complete edit script.
41#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
42pub struct Patch {
43    pub operations: Vec<Operation>,
44}
45
46impl Patch {
47    pub fn new() -> Self {
48        Self::default()
49    }
50    pub fn push(&mut self, op: Operation) {
51        self.operations.push(op);
52    }
53    pub fn len(&self) -> usize {
54        self.operations.len()
55    }
56    pub fn is_empty(&self) -> bool {
57        self.operations.is_empty()
58    }
59
60    /// Number of inserts, deletes, and replaces.
61    pub fn edit_distance(&self) -> usize {
62        self.operations
63            .iter()
64            .filter(|op| {
65                !matches!(
66                    op,
67                    Operation::UpdateMetadata { .. } | Operation::Move { .. }
68                )
69            })
70            .count()
71    }
72}
73
74/// Diff two documents, returning a [`Patch`].
75pub fn diff(old: &Document, new: &Document) -> Patch {
76    let mut patch = Patch::new();
77
78    if old.metadata != new.metadata {
79        let from = serde_json::to_value(&old.metadata).unwrap_or_default();
80        let to = serde_json::to_value(&new.metadata).unwrap_or_default();
81        if from != to {
82            patch.push(Operation::UpdateMetadata { from, to });
83        }
84    }
85
86    diff_blocks(&old.blocks, &new.blocks, &mut patch);
87    patch
88}
89
90fn diff_blocks(old: &[Block], new: &[Block], patch: &mut Patch) {
91    // Compute LCS table.
92    let n = old.len();
93    let m = new.len();
94    let mut table = vec![vec![0usize; m + 1]; n + 1];
95    for i in 0..n {
96        for j in 0..m {
97            table[i + 1][j + 1] = if old[i] == new[j] {
98                table[i][j] + 1
99            } else {
100                table[i + 1][j].max(table[i][j + 1])
101            };
102        }
103    }
104    // Walk back to produce operations. We emit Delete for `old[i]` not in LCS
105    // and Insert for `new[j]` not in LCS. Replace when both sides are off the
106    // LCS at the same position.
107    let mut i = n;
108    let mut j = m;
109    let mut ops: Vec<Operation> = Vec::new();
110    while i > 0 && j > 0 {
111        if old[i - 1] == new[j - 1] {
112            i -= 1;
113            j -= 1;
114        } else if table[i - 1][j] >= table[i][j - 1] {
115            ops.push(Operation::Delete { index: i - 1 });
116            i -= 1;
117        } else {
118            ops.push(Operation::Insert {
119                index: j - 1,
120                block: new[j - 1].clone(),
121            });
122            j -= 1;
123        }
124    }
125    while i > 0 {
126        ops.push(Operation::Delete { index: i - 1 });
127        i -= 1;
128    }
129    while j > 0 {
130        ops.push(Operation::Insert {
131            index: j - 1,
132            block: new[j - 1].clone(),
133        });
134        j -= 1;
135    }
136    ops.reverse();
137    // Coalesce adjacent Delete+Insert of the same index into a Replace.
138    let coalesced = coalesce(ops);
139    for op in coalesced {
140        patch.push(op);
141    }
142}
143
144fn coalesce(ops: Vec<Operation>) -> Vec<Operation> {
145    let mut out: Vec<Operation> = Vec::with_capacity(ops.len());
146    let mut k = 0usize;
147    while k < ops.len() {
148        let mut advanced = false;
149        if k + 1 < ops.len() {
150            if let (Operation::Delete { index: di }, Operation::Insert { index: ii, block }) =
151                (&ops[k], &ops[k + 1])
152            {
153                if di == ii {
154                    out.push(Operation::Replace {
155                        index: *di,
156                        block: block.clone(),
157                    });
158                    k += 2;
159                    advanced = true;
160                }
161            }
162            if !advanced {
163                if let (Operation::Insert { index: ii, block }, Operation::Delete { index: di }) =
164                    (&ops[k], &ops[k + 1])
165                {
166                    if di == ii {
167                        out.push(Operation::Replace {
168                            index: *di,
169                            block: block.clone(),
170                        });
171                        k += 2;
172                        advanced = true;
173                    }
174                }
175            }
176        }
177        if !advanced {
178            out.push(ops[k].clone());
179            k += 1;
180        }
181    }
182    out
183}
184
185/// Apply a patch to a document, returning the new document.
186///
187/// Not all patch operations are supported by [`apply`]. Unsupported operations
188/// cause the function to return an error describing which one failed.
189pub fn apply(doc: Document, patch: &Patch) -> Result<Document, DiffError> {
190    let mut doc = doc;
191    // Apply non-mutating ops first, in reverse so indices stay valid.
192    let mut sorted: Vec<&Operation> = patch.operations.iter().collect();
193    sorted.sort_by_key(|op| match op {
194        Operation::Insert { index, .. } => (*index, 0u8),
195        Operation::Replace { index, .. } => (*index, 1u8),
196        Operation::Delete { index } => (*index, 2u8),
197        Operation::Move { from, .. } => (*from, 3u8),
198        Operation::UpdateMetadata { .. } => (usize::MAX, 4u8),
199    });
200    for op in sorted {
201        match op {
202            Operation::Insert { index, block } => {
203                let idx = (*index).min(doc.blocks.len());
204                doc.blocks.insert(idx, block.clone());
205            }
206            Operation::Replace { index, block } => {
207                if *index >= doc.blocks.len() {
208                    return Err(DiffError::IndexOutOfRange { index: *index });
209                }
210                doc.blocks[*index] = block.clone();
211            }
212            Operation::Delete { index } => {
213                if *index >= doc.blocks.len() {
214                    return Err(DiffError::IndexOutOfRange { index: *index });
215                }
216                doc.blocks.remove(*index);
217            }
218            Operation::Move { from, to } => {
219                if *from >= doc.blocks.len() {
220                    return Err(DiffError::IndexOutOfRange { index: *from });
221                }
222                let b = doc.blocks.remove(*from);
223                let to = (*to).min(doc.blocks.len());
224                doc.blocks.insert(to, b);
225            }
226            Operation::UpdateMetadata { from: _, to } => {
227                doc.metadata = serde_json::from_value(to.clone())
228                    .map_err(|e| DiffError::InvalidMetadata(e.to_string()))?;
229            }
230        }
231    }
232    Ok(doc)
233}
234
235/// Summary statistics about a diff.
236#[derive(Debug, Clone, Default)]
237pub struct DiffStats {
238    pub inserts: usize,
239    pub deletes: usize,
240    pub replaces: usize,
241    pub moves: usize,
242    pub metadata: usize,
243}
244
245impl From<&Patch> for DiffStats {
246    fn from(patch: &Patch) -> Self {
247        let mut s = Self::default();
248        for op in &patch.operations {
249            match op {
250                Operation::Insert { .. } => s.inserts += 1,
251                Operation::Delete { .. } => s.deletes += 1,
252                Operation::Replace { .. } => s.replaces += 1,
253                Operation::Move { .. } => s.moves += 1,
254                Operation::UpdateMetadata { .. } => s.metadata += 1,
255            }
256        }
257        s
258    }
259}
260
261#[derive(Debug, thiserror::Error)]
262pub enum DiffError {
263    #[error("index {index} out of range")]
264    IndexOutOfRange { index: usize },
265    #[error("invalid metadata in patch: {0}")]
266    InvalidMetadata(String),
267}
268
269/// Compare two inline sequences. Returns true if they are equivalent under
270/// a whitespace-insensitive comparison. Currently exact — kept as a hook
271/// for future fuzzy matching.
272pub fn inlines_equal_ignoring_ws(a: &[Inline], b: &[Inline]) -> bool {
273    fn collect(inlines: &[Inline], out: &mut String) {
274        for i in inlines {
275            match i {
276                Inline::Text(t) => {
277                    out.push_str(t.value.trim());
278                    out.push(' ');
279                }
280                Inline::Code(s) => {
281                    out.push_str(s);
282                    out.push(' ');
283                }
284                Inline::Math(_) => {}
285                Inline::Link(l) => collect(&l.content, out),
286                Inline::Image(_) => {}
287                Inline::Bold(c) | Inline::Italic(c) | Inline::Underline(c) | Inline::Strike(c) => {
288                    collect(c, out)
289                }
290                Inline::Raw(_) => {}
291            }
292        }
293    }
294    let mut sa = String::new();
295    let mut sb = String::new();
296    collect(a, &mut sa);
297    collect(b, &mut sb);
298    sa == sb
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use mtplatx_doc_core::{Inline, Paragraph};
305
306    fn p(s: &str) -> Block {
307        Block::Paragraph(Paragraph {
308            content: vec![Inline::from(s)],
309        })
310    }
311
312    #[test]
313    fn identical_docs_yield_empty_patch() {
314        let mut a = Document::new();
315        a.push(p("a"));
316        let patch = diff(&a, &a);
317        assert!(patch.is_empty());
318    }
319
320    #[test]
321    fn insert_at_end() {
322        let mut a = Document::new();
323        a.push(p("a"));
324        let mut b = Document::new();
325        b.push(p("a"));
326        b.push(p("b"));
327        let patch = diff(&a, &b);
328        assert!(matches!(
329            patch.operations.last(),
330            Some(Operation::Insert { .. })
331        ));
332    }
333
334    #[test]
335    fn delete_middle() {
336        let mut a = Document::new();
337        a.push(p("a"));
338        a.push(p("b"));
339        a.push(p("c"));
340        let mut b = Document::new();
341        b.push(p("a"));
342        b.push(p("c"));
343        let patch = diff(&a, &b);
344        let stats = DiffStats::from(&patch);
345        assert_eq!(stats.deletes, 1);
346    }
347
348    #[test]
349    fn replace_is_coalesced() {
350        let mut a = Document::new();
351        a.push(p("a"));
352        let mut b = Document::new();
353        b.push(p("z"));
354        let patch = diff(&a, &b);
355        assert_eq!(patch.len(), 1);
356        assert!(matches!(patch.operations[0], Operation::Replace { .. }));
357    }
358
359    #[test]
360    fn apply_roundtrips_diff() {
361        let mut a = Document::new();
362        a.push(p("a"));
363        a.push(p("b"));
364        let mut b = Document::new();
365        b.push(p("a"));
366        b.push(p("c"));
367        b.push(p("b"));
368        let patch = diff(&a, &b);
369        let applied = apply(a.clone(), &patch).unwrap();
370        assert_eq!(applied.metadata, b.metadata);
371        assert_eq!(applied.blocks, b.blocks);
372    }
373
374    #[test]
375    fn metadata_diff_emitted() {
376        let mut a = Document::new();
377        a.metadata.title = Some("A".into());
378        let mut b = Document::new();
379        b.metadata.title = Some("B".into());
380        let patch = diff(&a, &b);
381        assert!(patch
382            .operations
383            .iter()
384            .any(|op| matches!(op, Operation::UpdateMetadata { .. })));
385    }
386}