Skip to main content

tiptap_rusty_parser/
change_ops.rs

1//! Utilities over [`Change`] lists: compose, compact, and position mapping.
2//!
3//! These complement [`diff`](crate::diff)/[`apply`](crate::apply)/[`invert`](crate::invert):
4//! - [`compose`] concatenates two change lists into one apply-equivalent patch.
5//! - [`compact`] coalesces redundant node-local ops (safely — structural ops act
6//!   as barriers, since [`apply`] interprets child indices against the live tree).
7//! - [`map_path`] carries an index-path through a change list (the basis for
8//!   mapping a selection or decoration across an edit); [`unmap_path`] and
9//!   [`rebase_change`] go the other way.
10//!
11//! Operational-transform style *rebasing* of concurrent edits is intentionally
12//! out of scope: it needs a bidirectional position map with insertion tie-breaks
13//! and stable node identity that the value/live-index [`Change`] model doesn't
14//! provide.
15
16use crate::diff::Change;
17use std::collections::HashMap;
18
19/// A change list apply-equivalent to running `a` then `b`.
20///
21/// Because [`apply`](crate::apply) folds changes in order against the live tree,
22/// `b`'s paths/indices already assume `a` has run — so plain concatenation is
23/// the semantic identity. `compose` returns that concatenation in [`compact`]ed
24/// form (redundant node-local ops coalesced).
25///
26/// ```
27/// use tiptap_rusty_parser::{apply, compose, Node};
28/// let base = Node::element("doc").with_child(Node::text("x"));
29/// let a = vec![tiptap_rusty_parser::Change::SetText { path: vec![0], text: Some("y".into()) }];
30/// let b = vec![tiptap_rusty_parser::Change::SetText { path: vec![0], text: Some("z".into()) }];
31/// let composed = compose(&a, &b);
32/// assert_eq!(composed.len(), 1); // the two SetTexts coalesced to the last
33/// let mut t = base.clone();
34/// apply(&mut t, &composed).unwrap();
35/// assert_eq!(t.text_content(), "z");
36/// ```
37pub fn compose(a: &[Change], b: &[Change]) -> Vec<Change> {
38    let mut all = Vec::with_capacity(a.len() + b.len());
39    all.extend_from_slice(a);
40    all.extend_from_slice(b);
41    compact(&all)
42}
43
44/// Identifies a node-local *field* write, so repeated writes to the same field
45/// can be coalesced (last-wins). Structural ops have no key.
46#[derive(PartialEq, Eq, Hash)]
47enum FieldKey {
48    Text(Vec<usize>),
49    Marks(Vec<usize>),
50    Attr(Vec<usize>, String),
51    Extra(Vec<usize>, String),
52}
53
54fn field_key(change: &Change) -> Option<FieldKey> {
55    match change {
56        Change::SetText { path, .. } => Some(FieldKey::Text(path.clone())),
57        Change::SetMarks { path, .. } => Some(FieldKey::Marks(path.clone())),
58        Change::SetAttr { path, key, .. } | Change::RemoveAttr { path, key } => {
59            Some(FieldKey::Attr(path.clone(), key.clone()))
60        }
61        Change::SetExtra { path, key, .. } | Change::RemoveExtra { path, key } => {
62            Some(FieldKey::Extra(path.clone(), key.clone()))
63        }
64        // Structural: Insert / Remove / Replace / Move.
65        _ => None,
66    }
67}
68
69/// Coalesce redundant changes while preserving the [`apply`](crate::apply)
70/// result. Only **safe** reductions are performed:
71///
72/// - repeated writes to the same node field (`SetText`/`SetMarks`, or
73///   `SetAttr`/`RemoveAttr` / `SetExtra`/`RemoveExtra` on the same key) collapse
74///   to the **last** one (each write fully determines that field's final value);
75/// - an `Insert` immediately followed by a `Remove` of that same just-inserted
76///   child cancels out.
77///
78/// Structural ops (`Insert`/`Remove`/`Move`/`Replace`) act as **barriers**:
79/// because [`apply`] resolves indices against the live tree, field ops are never
80/// coalesced across them. `compact(c)` is never longer than `c`.
81///
82/// Precondition: this preserves the `apply` result for change lists that apply
83/// **cleanly** to their intended base (as produced by [`diff`](crate::diff) /
84/// [`Transform`](crate::Transform)). For a deliberately invalid list it can
85/// differ — e.g. an out-of-bounds `Insert` immediately followed by its matching
86/// `Remove` cancels here, whereas applying the original would error; validity
87/// can't be checked from a change list alone.
88pub fn compact(changes: &[Change]) -> Vec<Change> {
89    let mut out: Vec<Change> = Vec::with_capacity(changes.len());
90    // (field key) -> index in `out` of the last kept write, within the current
91    // barrier segment. Cleared whenever a structural op is emitted.
92    let mut last: HashMap<FieldKey, usize> = HashMap::new();
93
94    for change in changes {
95        match field_key(change) {
96            Some(key) => {
97                if let Some(&idx) = last.get(&key) {
98                    out[idx] = change.clone(); // last-wins, kept in place (segment-local)
99                } else {
100                    last.insert(key, out.len());
101                    out.push(change.clone());
102                }
103            }
104            None => {
105                // Adjacent Insert+Remove of the same child cancels out.
106                if let Change::Remove { path, index } = change {
107                    if let Some(Change::Insert {
108                        path: ip,
109                        index: ii,
110                        ..
111                    }) = out.last()
112                    {
113                        if ip == path && ii == index {
114                            out.pop();
115                            last.clear();
116                            continue;
117                        }
118                    }
119                }
120                out.push(change.clone());
121                last.clear(); // structural barrier
122            }
123        }
124    }
125    out
126}
127
128/// If `prefix` is a strict ancestor level of `q`, return that level's depth
129/// (so the structural op at parent `prefix` acts on `q[depth]`).
130fn affected_level(q: &[usize], prefix: &[usize]) -> Option<usize> {
131    if prefix.len() < q.len() && q[..prefix.len()] == *prefix {
132        Some(prefix.len())
133    } else {
134        None
135    }
136}
137
138/// Carry an index-path through a change list: where does the node originally at
139/// `path` end up after applying `changes`? Returns `None` if it was removed or
140/// replaced away.
141///
142/// Field changes never move nodes. Structural changes shift the index components
143/// of `path` at the level they act on (an `Insert` at or before the index shifts
144/// it right; a `Remove`/`Move` shifts accordingly; removing or replacing a node
145/// on the path drops it). This mirrors [`apply`](crate::apply)'s live-index
146/// semantics, so the mapped path addresses the same node in the applied tree.
147///
148/// ```
149/// use tiptap_rusty_parser::{map_path, Change};
150/// // A sibling inserted before index 1 shifts it to 2.
151/// let changes = vec![Change::Insert { path: vec![], index: 0, node: Default::default() }];
152/// assert_eq!(map_path(&[1, 0], &changes), Some(vec![2, 0]));
153/// // Removing the node on the path drops it.
154/// let removed = vec![Change::Remove { path: vec![], index: 1 }];
155/// assert_eq!(map_path(&[1, 0], &removed), None);
156/// ```
157pub fn map_path(path: &[usize], changes: &[Change]) -> Option<Vec<usize>> {
158    let mut q = path.to_vec();
159    for change in changes {
160        match change {
161            Change::Insert { path: p, index, .. } => {
162                if let Some(d) = affected_level(&q, p) {
163                    if *index <= q[d] {
164                        q[d] += 1;
165                    }
166                }
167            }
168            Change::Remove { path: p, index } => {
169                if let Some(d) = affected_level(&q, p) {
170                    if *index < q[d] {
171                        q[d] -= 1;
172                    } else if *index == q[d] {
173                        return None; // the node on the path was removed
174                    }
175                }
176            }
177            Change::Move { path: p, from, to } if from != to => {
178                if let Some(d) = affected_level(&q, p) {
179                    let k = q[d];
180                    if *from == k {
181                        // The node on the path is the one being moved.
182                        q[d] = *to;
183                    } else {
184                        // remove(from) then insert(to), in post-removal coords.
185                        let after_remove = if *from < k { k - 1 } else { k };
186                        q[d] = if *to <= after_remove {
187                            after_remove + 1
188                        } else {
189                            after_remove
190                        };
191                    }
192                }
193            }
194            // Replacing a node at or above `q` discards `q`'s subtree.
195            Change::Replace { path: p, .. } if p.len() <= q.len() && q[..p.len()] == *p => {
196                return None;
197            }
198            // Field ops (and replaces elsewhere) never relocate a node.
199            _ => {}
200        }
201    }
202    Some(q)
203}
204
205/// Inverse of [`map_path`]: given a path in the tree *after* `changes` were
206/// applied, where was that node before them? Returns `None` if the node did
207/// not exist before (it was inserted, or lives inside a replaced subtree).
208///
209/// Lets a UI relate a [`Change`] taken from the middle of a [`diff`](crate::diff)
210/// list (whose paths are live, partially-mutated indices) back to the original
211/// tree: `unmap_path(live, &changes[..i])`.
212///
213/// ```
214/// use tiptap_rusty_parser::{map_path, unmap_path, Change};
215/// let changes = vec![Change::Insert { path: vec![], index: 0, node: Default::default() }];
216/// assert_eq!(unmap_path(&[2, 0], &changes), Some(vec![1, 0]));
217/// assert_eq!(unmap_path(&[0], &changes), None); // the inserted node itself
218/// let removed = vec![Change::Remove { path: vec![], index: 1 }];
219/// assert_eq!(unmap_path(&[1], &removed), Some(vec![2]));
220/// ```
221pub fn unmap_path(path: &[usize], changes: &[Change]) -> Option<Vec<usize>> {
222    unmap(path, changes, false)
223}
224
225/// Reverse walk shared by [`unmap_path`] and [`rebase_change`]. With `slot`,
226/// the last component of `path` is an insertion slot (`0..=len`) rather than a
227/// node: an earlier insert at the same slot leaves it in place instead of
228/// dropping it.
229fn unmap(path: &[usize], changes: &[Change], slot: bool) -> Option<Vec<usize>> {
230    unmap_text(path, changes, slot, None).map(|(p, _)| p)
231}
232
233/// Scalar span `(from, len_del)` inside a text node.
234type TextSpan = (usize, usize);
235
236/// [`unmap`] that also carries a scalar span `(from, len_del)` of the text node
237/// at `path` back through earlier splices of that node. `None` when the span
238/// touches text an earlier change inserted or the node's text was replaced.
239fn unmap_text(
240    path: &[usize],
241    changes: &[Change],
242    slot: bool,
243    text: Option<TextSpan>,
244) -> Option<(Vec<usize>, Option<TextSpan>)> {
245    let mut q = path.to_vec();
246    let mut text = text;
247    let last = q.len().wrapping_sub(1);
248    for change in changes.iter().rev() {
249        match change {
250            Change::Insert { path: p, index, .. } => {
251                if let Some(d) = affected_level(&q, p) {
252                    if *index == q[d] && !(slot && d == last) {
253                        return None; // the node on the path was inserted
254                    } else if *index < q[d] {
255                        q[d] -= 1;
256                    }
257                }
258            }
259            Change::Remove { path: p, index } => {
260                if let Some(d) = affected_level(&q, p) {
261                    if *index <= q[d] {
262                        q[d] += 1;
263                    }
264                }
265            }
266            Change::Move { path: p, from, to } if from != to => {
267                if let Some(d) = affected_level(&q, p) {
268                    let k = q[d];
269                    if *to == k && !(slot && d == last) {
270                        q[d] = *from;
271                    } else {
272                        // undo insert(to), then undo remove(from)
273                        let after_remove = if *to < k { k - 1 } else { k };
274                        q[d] = if *from <= after_remove {
275                            after_remove + 1
276                        } else {
277                            after_remove
278                        };
279                    }
280                }
281            }
282            // A node strictly inside a replaced subtree did not exist before.
283            Change::Replace { path: p, .. } if p.len() < q.len() && q[..p.len()] == *p => {
284                return None;
285            }
286            Change::SpliceText {
287                path: p,
288                from: pf,
289                len_del: pdel,
290                insert,
291            } if text.is_some() && *p == q => {
292                let (from, len_del) = text.unwrap();
293                let ins = insert.chars().count();
294                if from >= pf + ins {
295                    text = Some((from - ins + pdel, len_del));
296                } else if from + len_del > *pf {
297                    return None; // overlaps text the earlier splice inserted
298                }
299            }
300            Change::SetText { path: p, .. } | Change::Replace { path: p, .. }
301                if text.is_some() && *p == q =>
302            {
303                return None;
304            }
305            _ => {}
306        }
307    }
308    Some((q, text))
309}
310
311/// Re-express `change`, taken from the middle of a change list right after
312/// `prior`, against the tree *before* `prior` ran, so it can be applied on its
313/// own (cherry-picked) to that base. Returns `None` when its target did not
314/// exist in the base (it lives inside a node that `prior` inserted or replaced).
315///
316/// Node addresses go through [`unmap_path`]; insertion slots (`Insert::index`,
317/// `Move::to`) are carried as slots, so a sibling inserted earlier at the same
318/// position does not drop them.
319///
320/// ```
321/// use tiptap_rusty_parser::{rebase_change, Change};
322/// let prior = vec![Change::Remove { path: vec![], index: 0 }];
323/// let later = Change::SetText { path: vec![1, 0], text: Some("x".into()) };
324/// assert_eq!(
325///     rebase_change(&later, &prior),
326///     Some(Change::SetText { path: vec![2, 0], text: Some("x".into()) })
327/// );
328/// ```
329pub fn rebase_change(change: &Change, prior: &[Change]) -> Option<Change> {
330    fn child(parent: &[usize], i: usize) -> Vec<usize> {
331        let mut p = parent.to_vec();
332        p.push(i);
333        p
334    }
335    fn split(mut p: Vec<usize>) -> (Vec<usize>, usize) {
336        let i = p.pop().unwrap_or(0);
337        (p, i)
338    }
339    let mut out = change.clone();
340    match &mut out {
341        Change::Insert { path, index, .. } => {
342            (*path, *index) = split(unmap(&child(path, *index), prior, true)?);
343        }
344        Change::Remove { path, index } => {
345            (*path, *index) = split(unmap(&child(path, *index), prior, false)?);
346        }
347        Change::Move { path, from, to } => {
348            // `to` is a slot in the post-removal list; lift it into the
349            // pre-removal list, rebase, then drop the removed child again.
350            let slot = if *to >= *from { *to + 1 } else { *to };
351            let (p, f) = split(unmap(&child(path, *from), prior, false)?);
352            let (_, s) = split(unmap(&child(path, slot), prior, true)?);
353            *to = if s > f { s - 1 } else { s };
354            *path = p;
355            *from = f;
356        }
357        Change::SetAttr { path, .. }
358        | Change::RemoveAttr { path, .. }
359        | Change::SetText { path, .. }
360        | Change::SetMarks { path, .. }
361        | Change::SetExtra { path, .. }
362        | Change::RemoveExtra { path, .. }
363        | Change::Replace { path, .. } => {
364            *path = unmap(path, prior, false)?;
365        }
366        Change::SpliceText {
367            path,
368            from,
369            len_del,
370            ..
371        } => {
372            let (p, span) = unmap_text(path, prior, false, Some((*from, *len_del)))?;
373            *path = p;
374            (*from, *len_del) = span?;
375        }
376    }
377    Some(out)
378}