Skip to main content

tiptap_rusty_parser/
diff.rs

1//! Structural diff: compute a path-addressed [`Change`] list between two
2//! [`Node`] trees, and [`apply`] it to reproduce the target.
3//!
4//! ```
5//! use tiptap_rusty_parser::Document;
6//!
7//! let a = Document::from_json_str(
8//!     r#"{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hi"}]}]}"#,
9//! ).unwrap();
10//! let b = Document::from_json_str(
11//!     r#"{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bye"}]}]}"#,
12//! ).unwrap();
13//!
14//! let changes = a.diff(&b);          // -> Vec<Change>
15//! let mut c = a.clone();
16//! c.apply(&changes).unwrap();        // reproduce `b`
17//! assert_eq!(c, b);
18//! ```
19//!
20//! ## Path convention
21//! Node-local changes ([`Change::SetAttr`], `RemoveAttr`, `SetText`, `SetMarks`,
22//! `SetExtra`, `RemoveExtra`, `Replace`) address the **target node** by its
23//! index path. Child-list changes ([`Change::Insert`], [`Change::Remove`])
24//! address the **parent** node, with `index` selecting the child — mirroring
25//! [`Node::insert_child`] / [`Node::remove_child`].
26//!
27//! ## Apply contract
28//! [`apply`] executes changes strictly in order; child `index` values are
29//! interpreted against the *live* (already-partially-mutated) list. A list
30//! produced by [`diff`] always reproduces the target exactly:
31//! `apply(&mut a, &diff(a, b))` yields `b`.
32//!
33//! Empty-vs-absent container shapes (e.g. `"content":[]` vs no `content`) are
34//! preserved: when the field/child ops can't express the difference, the node
35//! is replaced wholesale so the round-trip stays exact.
36//!
37//! ## Move detection
38//! A child relocated within a list is emitted as a single [`Change::Move`]
39//! (no subtree clone) instead of a remove+insert: after LCS alignment, leftover
40//! deletions and insertions that are *equal by value* are paired as moves, and
41//! their live indices are derived by simulating the op stream — so the result
42//! reproduces the target exactly regardless of how moves interleave with plain
43//! inserts/removes. Pairing is greedy/FIFO, so duplicate-equal children give a
44//! correct (if not always minimal) result. [`invert`] needs no special handling:
45//! it re-diffs the reverse direction, re-deriving the inverse moves.
46//!
47//! ## v1 limitations
48//! - Matching is LCS-by-equality; modifies are paired positionally within the
49//!   gaps between matched anchors (still correct, just not always minimal).
50
51use crate::node::{Mark, Node};
52use crate::text_diff::{self, DiffGranularity, DiffOptions};
53use serde::{Deserialize, Serialize};
54use serde_json::{Map, Value};
55use std::collections::{HashMap, HashSet};
56use std::fmt;
57
58/// A single structural change between two [`Node`] trees.
59///
60/// Serializes as a tagged object, e.g. `{"op":"setText","path":[0,0],"text":"hi"}`.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(tag = "op", rename_all = "camelCase", rename_all_fields = "camelCase")]
63pub enum Change {
64    /// Set (insert or overwrite) attribute `key` on the node at `path`.
65    SetAttr {
66        /// Index path of the target node.
67        path: Vec<usize>,
68        /// Attribute key.
69        key: String,
70        /// New attribute value.
71        value: Value,
72    },
73    /// Remove attribute `key` from the node at `path`.
74    RemoveAttr {
75        /// Index path of the target node.
76        path: Vec<usize>,
77        /// Attribute key.
78        key: String,
79    },
80    /// Set the text payload of the node at `path` (`None` clears it).
81    SetText {
82        /// Index path of the target node.
83        path: Vec<usize>,
84        /// New text payload, or `None` to clear.
85        text: Option<String>,
86    },
87    /// Replace the whole mark list of the node at `path` (`None` clears it).
88    SetMarks {
89        /// Index path of the target node.
90        path: Vec<usize>,
91        /// New mark list, or `None` to clear.
92        marks: Option<Vec<Mark>>,
93    },
94    /// Set (insert or overwrite) unknown top-level field `key` on the node at `path`.
95    SetExtra {
96        /// Index path of the target node.
97        path: Vec<usize>,
98        /// Field key.
99        key: String,
100        /// New field value.
101        value: Value,
102    },
103    /// Remove unknown top-level field `key` from the node at `path`.
104    RemoveExtra {
105        /// Index path of the target node.
106        path: Vec<usize>,
107        /// Field key.
108        key: String,
109    },
110    /// Insert `node` as a child of the node at `path` (the parent), at `index`.
111    Insert {
112        /// Index path of the **parent** node.
113        path: Vec<usize>,
114        /// Child position to insert at.
115        index: usize,
116        /// The node to insert.
117        node: Node,
118    },
119    /// Remove the child at `index` of the node at `path` (the parent).
120    Remove {
121        /// Index path of the **parent** node.
122        path: Vec<usize>,
123        /// Child position to remove.
124        index: usize,
125    },
126    /// Replace the node at `path` wholesale (used when its `type` changes).
127    Replace {
128        /// Index path of the target node (empty = root).
129        path: Vec<usize>,
130        /// The replacement node.
131        node: Node,
132    },
133    /// Relocate a child within the same parent's list, without cloning its
134    /// subtree. `from`/`to` are interpreted against the *live* list: the child
135    /// at `from` is removed first, then re-inserted so it lands at index `to`
136    /// in the post-removal list — observationally `Remove{from}` + `Insert{to}`,
137    /// so it composes with the same live-index cursor model as the other ops.
138    Move {
139        /// Index path of the **parent** node.
140        path: Vec<usize>,
141        /// Live index of the child to move (before this op).
142        from: usize,
143        /// Target index in the list after the child is removed.
144        to: usize,
145    },
146    /// Splice a text node's payload: replace the scalar range
147    /// `[from, from+len_del)` with `insert`. Offsets count Unicode scalars.
148    /// Emitted by [`Node::diff_with`](crate::Node::diff_with) in inline/smart
149    /// granularity instead of a whole-string [`SetText`](Change::SetText).
150    ///
151    /// Appended last (rather than grouped next to [`SetText`](Change::SetText))
152    /// so existing variants keep their discriminants — only the new-variant
153    /// break remains, not a discriminant shift for downstream `as` casts.
154    SpliceText {
155        /// Index path of the target text node.
156        path: Vec<usize>,
157        /// Scalar offset where the splice starts.
158        from: usize,
159        /// Number of scalars removed.
160        len_del: usize,
161        /// Text inserted at `from`.
162        insert: String,
163    },
164}
165
166/// Error from [`apply`] when a change can't be located (no node at the path, or
167/// a child index out of range). Lists produced by [`diff`] never fail.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct ApplyError {
170    /// The path that could not be resolved.
171    pub path: Vec<usize>,
172}
173
174impl fmt::Display for ApplyError {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        write!(f, "apply: no node at path {:?}", self.path)
177    }
178}
179
180impl std::error::Error for ApplyError {}
181
182impl Node {
183    /// Structural diff from `self` to `other`: a [`Change`] list that, when
184    /// [`applied`](apply) to a clone of `self`, reproduces `other`.
185    pub fn diff(&self, other: &Node) -> Vec<Change> {
186        self.diff_with(other, &DiffOptions::default())
187    }
188
189    /// Structural diff with [`DiffOptions`] — e.g. inline/smart text granularity
190    /// (character-level [`Change::SpliceText`] instead of whole [`Change::SetText`]).
191    pub fn diff_with(&self, other: &Node, opts: &DiffOptions) -> Vec<Change> {
192        let mut out = Vec::new();
193        let mut path = Vec::new();
194        diff_node(self, other, &mut path, &mut out, opts);
195        out
196    }
197
198    /// Apply `changes` to `self` in order. See [`apply`].
199    pub fn apply(&mut self, changes: &[Change]) -> std::result::Result<(), ApplyError> {
200        apply(self, changes)
201    }
202
203    /// Invert `changes` relative to `self` (the pre-image). See [`invert`].
204    pub fn invert(&self, changes: &[Change]) -> std::result::Result<Vec<Change>, ApplyError> {
205        invert(self, changes)
206    }
207}
208
209/// Structural diff between two nodes. Free-function form of [`Node::diff`].
210pub fn diff(a: &Node, b: &Node) -> Vec<Change> {
211    a.diff(b)
212}
213
214/// Apply a [`Change`] list to `root` in order, mutating it in place.
215///
216/// Applying `diff(a, b)` to a clone of `a` reproduces `b` exactly. Returns
217/// [`ApplyError`] only for externally-authored lists whose paths/indices don't
218/// resolve.
219pub fn apply(root: &mut Node, changes: &[Change]) -> std::result::Result<(), ApplyError> {
220    for change in changes {
221        apply_one(root, change)?;
222    }
223    Ok(())
224}
225
226/// Invert a change list: produce the reverse changes that, applied to the
227/// result of `apply(base, changes)`, restore `base` — the basis for undo.
228///
229/// Computed as `diff(apply(base, changes), base)`: replay the forward changes,
230/// then diff back to `base`. This reuses the diff round-trip guarantee, so it
231/// handles every value/shape edge exactly — subject to the same non-minimality
232/// caveats as [`diff`] (e.g. no move detection). Errors only if `changes`
233/// itself doesn't apply to `base`.
234///
235/// ```
236/// use tiptap_rusty_parser::Document;
237/// let a = Document::from_json_str(r#"{"type":"doc","content":[{"type":"paragraph"}]}"#).unwrap();
238/// let b = Document::from_json_str(r#"{"type":"doc","content":[{"type":"heading"}]}"#).unwrap();
239/// let forward = a.diff(&b);
240/// let undo = a.invert(&forward).unwrap();
241/// let mut c = b.clone();
242/// c.apply(&undo).unwrap();
243/// assert_eq!(c, a);
244/// ```
245pub fn invert(base: &Node, changes: &[Change]) -> std::result::Result<Vec<Change>, ApplyError> {
246    let mut result = base.clone();
247    apply(&mut result, changes)?;
248    Ok(result.diff(base))
249}
250
251// ---- diff internals -----------------------------------------------------
252
253fn diff_node(a: &Node, b: &Node, path: &mut Vec<usize>, out: &mut Vec<Change>, opts: &DiffOptions) {
254    if a == b {
255        return; // prune identical subtrees (the main perf lever)
256    }
257    if a.node_type != b.node_type
258        || empty_shape_mismatch(
259            a.attrs.as_ref().map(Map::is_empty),
260            b.attrs.as_ref().map(Map::is_empty),
261        )
262        || empty_shape_mismatch(
263            a.content.as_ref().map(Vec::is_empty),
264            b.content.as_ref().map(Vec::is_empty),
265        )
266    {
267        // Type change, or an empty-vs-None container shape the field/child ops
268        // can't express (e.g. `"content":[]` -> absent) -> wholesale replace.
269        out.push(Change::Replace {
270            path: path.clone(),
271            node: b.clone(),
272        });
273        return;
274    }
275    diff_attrs(a.attrs.as_ref(), b.attrs.as_ref(), path, out);
276    if a.text != b.text {
277        diff_text_field(a.text.as_deref(), b.text.as_deref(), path, out, opts);
278    }
279    if a.marks != b.marks {
280        out.push(Change::SetMarks {
281            path: path.clone(),
282            marks: b.marks.clone(),
283        });
284    }
285    diff_extra(&a.extra, &b.extra, path, out);
286    diff_children(a.children(), b.children(), path, out, opts);
287}
288
289/// Emit the change(s) for a differing text payload, honoring the granularity.
290fn diff_text_field(
291    a: Option<&str>,
292    b: Option<&str>,
293    path: &mut [usize],
294    out: &mut Vec<Change>,
295    opts: &DiffOptions,
296) {
297    // Character-level splices only make sense when both sides are present text.
298    if let (Some(at), Some(bt)) = (a, b) {
299        match opts.text {
300            DiffGranularity::Inline => {
301                let segs = text_diff::diff_text(at, bt);
302                text_diff::splices_from_segments(path, &segs, out);
303                return;
304            }
305            DiffGranularity::Smart { replace_threshold } => {
306                let segs = text_diff::diff_text(at, bt);
307                let total = at.chars().count() + bt.chars().count();
308                let changed = text_diff::changed_scalars(&segs);
309                if total == 0 || (changed as f32 / total as f32) <= replace_threshold {
310                    text_diff::splices_from_segments(path, &segs, out);
311                    return;
312                }
313            }
314            DiffGranularity::Block => {}
315        }
316    }
317    out.push(Change::SetText {
318        path: path.to_vec(),
319        text: b.map(str::to_owned),
320    });
321}
322
323/// Whether an `Option<container>` shape difference (where the arg is
324/// `Some(is_empty)` / `None`) can't be reconciled by the key/child ops, which
325/// normalize emptied containers to `None`. A present-but-empty container
326/// (`Some(true)`, e.g. parsed from `[]`/`{}`) needs an exact match on the other
327/// side; otherwise the node must be replaced wholesale to round-trip exactly.
328fn empty_shape_mismatch(a_is_empty: Option<bool>, b_is_empty: Option<bool>) -> bool {
329    match b_is_empty {
330        Some(true) => a_is_empty != Some(true),
331        None => a_is_empty == Some(true),
332        Some(false) => false,
333    }
334}
335
336fn diff_attrs(
337    a: Option<&Map<String, Value>>,
338    b: Option<&Map<String, Value>>,
339    path: &mut [usize],
340    out: &mut Vec<Change>,
341) {
342    let empty = Map::new();
343    let am = a.unwrap_or(&empty);
344    let bm = b.unwrap_or(&empty);
345    for (k, v) in bm {
346        if am.get(k) != Some(v) {
347            out.push(Change::SetAttr {
348                path: path.to_vec(),
349                key: k.clone(),
350                value: v.clone(),
351            });
352        }
353    }
354    for k in am.keys() {
355        if !bm.contains_key(k) {
356            out.push(Change::RemoveAttr {
357                path: path.to_vec(),
358                key: k.clone(),
359            });
360        }
361    }
362}
363
364fn diff_extra(
365    am: &Map<String, Value>,
366    bm: &Map<String, Value>,
367    path: &mut [usize],
368    out: &mut Vec<Change>,
369) {
370    for (k, v) in bm {
371        if am.get(k) != Some(v) {
372            out.push(Change::SetExtra {
373                path: path.to_vec(),
374                key: k.clone(),
375                value: v.clone(),
376            });
377        }
378    }
379    for k in am.keys() {
380        if !bm.contains_key(k) {
381            out.push(Change::RemoveExtra {
382                path: path.to_vec(),
383                key: k.clone(),
384            });
385        }
386    }
387}
388
389/// One LCS-alignment step over the (trimmed) middle child slices.
390enum Step {
391    Match,
392    Del(usize),
393    Ins(usize),
394}
395
396/// A live-list element identity used by the move simulation: either an original
397/// `a`-child (reused/relocated/modified in place) or a freshly inserted node.
398#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
399enum Key {
400    Orig(usize),
401    New(usize),
402}
403
404/// Positional pairing of a leftover deletion with a leftover insertion in the
405/// same gap: a same-type modify (recurse) or a different-type wholesale replace.
406enum Role {
407    Modify(usize),
408    Replace(usize),
409}
410
411fn diff_children(
412    a: &[Node],
413    b: &[Node],
414    path: &mut Vec<usize>,
415    out: &mut Vec<Change>,
416    opts: &DiffOptions,
417) {
418    // Trim common prefix/suffix (cheap; shrinks the LCS DP and handles the
419    // common append/prepend cases in linear time).
420    let mut start = 0;
421    while start < a.len() && start < b.len() && a[start] == b[start] {
422        start += 1;
423    }
424    let mut ea = a.len();
425    let mut eb = b.len();
426    while ea > start && eb > start && a[ea - 1] == b[eb - 1] {
427        ea -= 1;
428        eb -= 1;
429    }
430
431    let am = &a[start..ea];
432    let bm = &b[start..eb];
433    if am.is_empty() && bm.is_empty() {
434        return;
435    }
436
437    let steps = lcs_align(am, bm);
438
439    // Reconstruct the alignment: which bm index each am index matched (kept in
440    // place), plus the per-gap unmatched deletions/insertions between anchors.
441    let mut gaps: Vec<(Vec<usize>, Vec<usize>)> = Vec::new();
442    let mut cur_dels: Vec<usize> = Vec::new();
443    let mut cur_inss: Vec<usize> = Vec::new();
444    let mut bm_match_src: HashMap<usize, usize> = HashMap::new(); // bm_j -> am_i
445    let (mut ai, mut bj) = (0usize, 0usize);
446    for step in &steps {
447        match step {
448            Step::Match => {
449                gaps.push((std::mem::take(&mut cur_dels), std::mem::take(&mut cur_inss)));
450                bm_match_src.insert(bj, ai);
451                ai += 1;
452                bj += 1;
453            }
454            Step::Del(i) => {
455                cur_dels.push(*i);
456                ai += 1;
457            }
458            Step::Ins(j) => {
459                cur_inss.push(*j);
460                bj += 1;
461            }
462        }
463    }
464    gaps.push((cur_dels, cur_inss));
465
466    // Move detection: pair value-equal nodes that LCS split into a deletion and
467    // an insertion. Greedy/FIFO over equal values -> stable on duplicates.
468    let all_dels: Vec<usize> = gaps.iter().flat_map(|(d, _)| d.iter().copied()).collect();
469    let mut del_used = vec![false; all_dels.len()];
470    let mut bm_move_src: HashMap<usize, usize> = HashMap::new(); // bm_j -> am_d
471    let mut am_moved: HashSet<usize> = HashSet::new();
472    for (_, inss) in &gaps {
473        for &j in inss {
474            if let Some(slot) = all_dels
475                .iter()
476                .enumerate()
477                .find(|(slot, &d)| !del_used[*slot] && am[d] == bm[j])
478                .map(|(slot, _)| slot)
479            {
480                del_used[slot] = true;
481                bm_move_src.insert(j, all_dels[slot]);
482                am_moved.insert(all_dels[slot]);
483            }
484        }
485    }
486
487    // Per-gap modify/replace pairing on the remaining (non-moved) leftovers.
488    let mut bm_role: HashMap<usize, Role> = HashMap::new();
489    let mut removed_am: Vec<usize> = Vec::new();
490    for (dels, inss) in &gaps {
491        let rem_dels: Vec<usize> = dels
492            .iter()
493            .copied()
494            .filter(|d| !am_moved.contains(d))
495            .collect();
496        let rem_inss: Vec<usize> = inss
497            .iter()
498            .copied()
499            .filter(|j| !bm_move_src.contains_key(j))
500            .collect();
501        let pairs = rem_dels.len().min(rem_inss.len());
502        for k in 0..pairs {
503            let (d, j) = (rem_dels[k], rem_inss[k]);
504            if am[d].node_type == bm[j].node_type {
505                bm_role.insert(j, Role::Modify(d)); // same type -> recurse in place
506            } else {
507                bm_role.insert(j, Role::Replace(d)); // type change -> wholesale
508            }
509        }
510        removed_am.extend_from_slice(&rem_dels[pairs..]);
511    }
512
513    // Target key sequence for the middle (what the live list must become), plus
514    // a key -> target-index lookup used to place relocated nodes minimally.
515    let mut target: Vec<Key> = Vec::with_capacity(bm.len());
516    for j in 0..bm.len() {
517        let key = if let Some(&i) = bm_match_src.get(&j) {
518            Key::Orig(i)
519        } else if let Some(&d) = bm_move_src.get(&j) {
520            Key::Orig(d)
521        } else {
522            match bm_role.get(&j) {
523                Some(Role::Modify(d) | Role::Replace(d)) => Key::Orig(*d),
524                None => Key::New(j),
525            }
526        };
527        target.push(key);
528    }
529    let target_pos: HashMap<Key, usize> = target.iter().enumerate().map(|(i, &k)| (k, i)).collect();
530    let is_moved = |k: Key| matches!(k, Key::Orig(x) if am_moved.contains(&x));
531
532    // Simulate the live list and emit ops whose indices are valid by
533    // construction. Absolute index = start + position in the middle.
534    let mut live: Vec<Key> = (0..am.len()).map(Key::Orig).collect();
535
536    // 1. Plain removes (indices computed against the shrinking live list).
537    for &d in &removed_am {
538        let idx = live.iter().position(|&k| k == Key::Orig(d)).unwrap();
539        out.push(Change::Remove {
540            path: path.clone(),
541            index: start + idx,
542        });
543        live.remove(idx);
544    }
545
546    // 2. Settle each target position left to right, maintaining the invariant
547    //    `live[..p] == target[..p]`. New nodes are inserted; otherwise we
548    //    relocate exactly one genuinely-moved node (the displaced occupant, or
549    //    the wanted node if it's the one that moved) to its sorted destination —
550    //    one Move per moved node, never disturbing the matched skeleton.
551    let mut p = 0;
552    while p < target.len() {
553        let want = target[p];
554        if live.get(p).copied() == Some(want) {
555            p += 1;
556            continue;
557        }
558        if let Key::New(j) = want {
559            out.push(Change::Insert {
560                path: path.clone(),
561                index: start + p,
562                node: bm[j].clone(),
563            });
564            live.insert(p, want);
565            p += 1;
566            continue;
567        }
568        // `want` is an original node not yet at `p`. Move the displaced occupant
569        // if it is the relocated one, else pull `want` (itself relocated) here.
570        let cur = live[p];
571        let mover = if is_moved(cur) { cur } else { want };
572        let from = live.iter().position(|&k| k == mover).unwrap();
573        let tmover = target_pos[&mover];
574        // Destination = number of currently-present elements that precede it in
575        // the target order (excluding the mover itself).
576        let dest = live
577            .iter()
578            .filter(|&&k| k != mover && target_pos[&k] < tmover)
579            .count();
580        out.push(Change::Move {
581            path: path.clone(),
582            from: start + from,
583            to: start + dest,
584        });
585        let k = live.remove(from);
586        live.insert(dest, k);
587        // Re-check `p` (the move may or may not have settled this slot).
588    }
589    debug_assert_eq!(live, target, "diff move simulation diverged from target");
590
591    // 3. Value fixes for modify/replace pairs, at their final positions (the
592    //    structural layout is now settled, so index = start + bm index).
593    for (j, target_node) in bm.iter().enumerate() {
594        match bm_role.get(&j) {
595            Some(Role::Modify(d)) => {
596                path.push(start + j);
597                diff_node(&am[*d], target_node, path, out, opts);
598                path.pop();
599            }
600            Some(Role::Replace(_)) => {
601                let mut child = path.clone();
602                child.push(start + j);
603                out.push(Change::Replace {
604                    path: child,
605                    node: target_node.clone(),
606                });
607            }
608            None => {}
609        }
610    }
611}
612
613/// Longest-common-subsequence alignment of two child slices, by node equality.
614fn lcs_align(a: &[Node], b: &[Node]) -> Vec<Step> {
615    let (m, n) = (a.len(), b.len());
616    if m == 0 {
617        return (0..n).map(Step::Ins).collect();
618    }
619    if n == 0 {
620        return (0..m).map(Step::Del).collect();
621    }
622    // dp[i][j] = LCS length of a[i..] vs b[j..].
623    let mut dp = vec![vec![0u32; n + 1]; m + 1];
624    for i in (0..m).rev() {
625        for j in (0..n).rev() {
626            dp[i][j] = if a[i] == b[j] {
627                dp[i + 1][j + 1] + 1
628            } else {
629                dp[i + 1][j].max(dp[i][j + 1])
630            };
631        }
632    }
633    let mut steps = Vec::with_capacity(m.max(n));
634    let (mut i, mut j) = (0usize, 0usize);
635    while i < m && j < n {
636        if a[i] == b[j] {
637            steps.push(Step::Match);
638            i += 1;
639            j += 1;
640        } else if dp[i + 1][j] >= dp[i][j + 1] {
641            steps.push(Step::Del(i));
642            i += 1;
643        } else {
644            steps.push(Step::Ins(j));
645            j += 1;
646        }
647    }
648    while i < m {
649        steps.push(Step::Del(i));
650        i += 1;
651    }
652    while j < n {
653        steps.push(Step::Ins(j));
654        j += 1;
655    }
656    steps
657}
658
659// ---- apply internals ----------------------------------------------------
660
661fn node_at_mut<'a>(
662    root: &'a mut Node,
663    path: &[usize],
664) -> std::result::Result<&'a mut Node, ApplyError> {
665    root.node_at_mut(path).ok_or_else(|| ApplyError {
666        path: path.to_vec(),
667    })
668}
669
670fn apply_one(root: &mut Node, change: &Change) -> std::result::Result<(), ApplyError> {
671    match change {
672        Change::SetAttr { path, key, value } => {
673            node_at_mut(root, path)?.set_attr(key.clone(), value.clone());
674        }
675        Change::RemoveAttr { path, key } => {
676            node_at_mut(root, path)?.remove_attr(key);
677        }
678        Change::SetText { path, text } => {
679            node_at_mut(root, path)?.text = text.clone();
680        }
681        Change::SpliceText {
682            path,
683            from,
684            len_del,
685            insert,
686        } => {
687            let node = node_at_mut(root, path)?;
688            // Validate against the current text *without* mutating, so an
689            // out-of-range or overflowing splice leaves the node untouched.
690            // Scalar offsets translate to byte offsets (scalar-safe); a scalar
691            // index equal to the length maps to the byte length (end splice).
692            let (start, end) = {
693                let cur = node.text.as_deref().unwrap_or("");
694                let scalars = cur.chars().count();
695                let end_scalar = match from.checked_add(*len_del) {
696                    Some(e) if *from <= scalars && e <= scalars => e,
697                    _ => return Err(ApplyError { path: path.clone() }),
698                };
699                let start = cur.char_indices().nth(*from).map_or(cur.len(), |(b, _)| b);
700                let end = cur
701                    .char_indices()
702                    .nth(end_scalar)
703                    .map_or(cur.len(), |(b, _)| b);
704                (start, end)
705            };
706            // A pure no-op (nothing removed, nothing inserted) must not
707            // materialize a `None` text field into `Some("")` — the model
708            // preserves missing-vs-empty for faithful round-tripping.
709            if start != end || !insert.is_empty() {
710                node.text
711                    .get_or_insert_with(String::new)
712                    .replace_range(start..end, insert);
713            }
714        }
715        Change::SetMarks { path, marks } => {
716            node_at_mut(root, path)?.marks = marks.clone();
717        }
718        Change::SetExtra { path, key, value } => {
719            node_at_mut(root, path)?
720                .extra
721                .insert(key.clone(), value.clone());
722        }
723        Change::RemoveExtra { path, key } => {
724            node_at_mut(root, path)?.extra.remove(key);
725        }
726        Change::Insert { path, index, node } => {
727            let parent = node_at_mut(root, path)?;
728            if *index > parent.child_count() {
729                let mut p = path.clone();
730                p.push(*index);
731                return Err(ApplyError { path: p });
732            }
733            parent.insert_child(*index, node.clone());
734        }
735        Change::Remove { path, index } => {
736            if node_at_mut(root, path)?.remove_child(*index).is_none() {
737                let mut p = path.clone();
738                p.push(*index);
739                return Err(ApplyError { path: p });
740            }
741        }
742        Change::Replace { path, node } => {
743            if path.is_empty() {
744                *root = node.clone();
745            } else {
746                let (parent_path, last) = path.split_at(path.len() - 1);
747                let parent = node_at_mut(root, parent_path)?;
748                if parent.replace_child(last[0], node.clone()).is_none() {
749                    return Err(ApplyError { path: path.clone() });
750                }
751            }
752        }
753        Change::Move { path, from, to } => {
754            let parent = node_at_mut(root, path)?;
755            let len = parent.child_count();
756            // `from` indexes the live list; `to` indexes the list after removal,
757            // so its valid range is 0..=len-1.
758            if *from >= len || *to >= len {
759                let mut p = path.clone();
760                p.push(if *from >= len { *from } else { *to });
761                return Err(ApplyError { path: p });
762            }
763            let node = parent.remove_child(*from).expect("from bounds-checked");
764            parent.insert_child(*to, node);
765        }
766    }
767    Ok(())
768}