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}