Skip to main content

tiptap_rusty_parser/
pos_edit.rs

1//! Position-addressed editing: apply a batch of flat-position [`PosEdit`]s to a
2//! tree and recover an invertible [`Change`] patch.
3//!
4//! Where [`range`](crate::range) edits one block by inline `Position` and
5//! [`block`](crate::block) restructures by index-path, this module addresses
6//! edits by **flat ProseMirror positions** (`from`/`to` integers) — the scheme
7//! the Tiptap AI Toolkit's `tiptapEdit` operations array uses. Each [`PosEdit`]
8//! is resolved (via [`Node::resolve`] / [`Node::pos_to_inline`]) and executed,
9//! and the whole batch is recovered as a [`Change`] list — so it replays and
10//! [`invert`](crate::invert)s like any other patch.
11//!
12//! ```
13//! use tiptap_rusty_parser::{Node, PosContent, PosEdit};
14//!
15//! // doc > paragraph("hello world")
16//! let mut doc = Node::element("doc")
17//!     .with_child(Node::element("paragraph").with_child(Node::text("hello world")));
18//! let original = doc.clone();
19//!
20//! // Replace "world" (scalars 7..12 in flat coords: 1 open token + offset 6..11).
21//! let patch = doc
22//!     .apply_pos_edits(&[PosEdit::Replace {
23//!         from: 7,
24//!         to: 12,
25//!         content: PosContent::Text { text: "there".into(), marks: None },
26//!     }])
27//!     .unwrap();
28//! assert_eq!(doc.text_content(), "hello there");
29//!
30//! // The returned patch inverts to an undo that restores the original.
31//! let undo = original.invert(&patch).unwrap();
32//! let mut back = doc.clone();
33//! back.apply(&undo).unwrap();
34//! assert_eq!(back, original);
35//! ```
36//!
37//! ## v1 scope
38//! - **Same-block** edits work at any nesting depth (the block is resolved from
39//!   the position, e.g. `doc>list>item>paragraph`).
40//! - **Cross-block** delete/replace/mark spans are supported when the two
41//!   endpoints sit in **sibling blocks under a common parent** (the common
42//!   `doc>paragraph` case): the tail of the first block, any whole blocks
43//!   between, and the head of the last block are removed, then the remainder is
44//!   joined (ProseMirror `deleteRange` semantics).
45//! - Spans whose endpoints are at **different depths or under different parents**
46//!   return [`PosEditError::UnsupportedSpan`] (error-first; full cross-structure
47//!   fitting is out of scope for v1).
48//! - A batch must be **disjoint**; edits apply highest-position-first so the
49//!   un-rebased positions stay valid. Overlapping spans return
50//!   [`PosEditError::OverlappingEdits`] (arbitrary-order rebasing needs a
51//!   position map — a later addition).
52
53use crate::block::BlockError;
54use crate::diff::{ApplyError, Change};
55use crate::node::{Mark, Node};
56use crate::normalize::{normalize_children, NormalizeOptions};
57use crate::pos::PosError;
58use crate::range::{ensure_boundary, resolve_range, Position, Range, RangeError};
59use serde::{Deserialize, Serialize};
60use serde_json::{Map, Value};
61use std::fmt;
62
63/// Content carried by an [`PosEdit::Insert`] / [`PosEdit::Replace`].
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(
66    tag = "type",
67    rename_all = "camelCase",
68    rename_all_fields = "camelCase"
69)]
70pub enum PosContent {
71    /// Text (optionally marked) inserted into a block's inline content.
72    Text {
73        /// The text to insert.
74        text: String,
75        /// Marks to carry on the inserted text (`None` = unmarked).
76        #[serde(skip_serializing_if = "Option::is_none", default)]
77        marks: Option<Vec<Mark>>,
78    },
79    /// A run of nodes inserted at the resolved boundary.
80    Nodes {
81        /// The nodes to insert, in order.
82        nodes: Vec<Node>,
83    },
84}
85
86/// A single position-addressed edit. Offsets are **flat ProseMirror positions**.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(
89    tag = "type",
90    rename_all = "camelCase",
91    rename_all_fields = "camelCase"
92)]
93pub enum PosEdit {
94    /// Insert `content` at `pos`.
95    Insert {
96        /// Flat position to insert at.
97        pos: usize,
98        /// What to insert.
99        content: PosContent,
100    },
101    /// Delete the flat range `[from, to)`.
102    Delete {
103        /// Start position (inclusive).
104        from: usize,
105        /// End position (exclusive).
106        to: usize,
107    },
108    /// Replace the flat range `[from, to)` with `content`.
109    Replace {
110        /// Start position (inclusive).
111        from: usize,
112        /// End position (exclusive).
113        to: usize,
114        /// Replacement content.
115        content: PosContent,
116    },
117    /// Add `mark` to text in the flat range `[from, to)`.
118    AddMark {
119        /// Start position (inclusive).
120        from: usize,
121        /// End position (exclusive).
122        to: usize,
123        /// The mark to add.
124        mark: Mark,
125    },
126    /// Remove every mark of `mark_type` from text in `[from, to)`.
127    RemoveMark {
128        /// Start position (inclusive).
129        from: usize,
130        /// End position (exclusive).
131        to: usize,
132        /// The mark type to remove.
133        mark_type: String,
134    },
135    /// Replace the whole attribute map of the block at (or containing) `pos`.
136    SetBlockAttrs {
137        /// A flat position before or inside the target block.
138        pos: usize,
139        /// The new attribute map (empty clears all attrs).
140        attrs: Map<String, Value>,
141    },
142}
143
144impl PosEdit {
145    /// The `[lo, hi)` flat span this edit occupies (point edits have `lo == hi`).
146    fn span(&self) -> (usize, usize) {
147        match self {
148            PosEdit::Insert { pos, .. } | PosEdit::SetBlockAttrs { pos, .. } => (*pos, *pos),
149            PosEdit::Delete { from, to }
150            | PosEdit::Replace { from, to, .. }
151            | PosEdit::AddMark { from, to, .. }
152            | PosEdit::RemoveMark { from, to, .. } => (*from, *to),
153        }
154    }
155}
156
157/// Why a [`Node::apply_pos_edits`] batch could not be applied.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum PosEditError {
160    /// A flat position failed to resolve.
161    Pos(PosError),
162    /// An inline range op failed.
163    Range(RangeError),
164    /// A block-structural op failed.
165    Block(BlockError),
166    /// A recorded change failed to apply.
167    Apply(ApplyError),
168    /// A cross-block span whose endpoints aren't sibling blocks under a common
169    /// parent (different depths/parents) — unsupported in v1.
170    UnsupportedSpan {
171        /// Start position.
172        from: usize,
173        /// End position.
174        to: usize,
175    },
176    /// Two edits in the batch overlap; v1 requires disjoint spans.
177    OverlappingEdits {
178        /// Start of the overlapping edit.
179        from: usize,
180        /// End of the overlapping edit.
181        to: usize,
182    },
183}
184
185impl From<PosError> for PosEditError {
186    fn from(e: PosError) -> Self {
187        PosEditError::Pos(e)
188    }
189}
190impl From<RangeError> for PosEditError {
191    fn from(e: RangeError) -> Self {
192        PosEditError::Range(e)
193    }
194}
195impl From<BlockError> for PosEditError {
196    fn from(e: BlockError) -> Self {
197        PosEditError::Block(e)
198    }
199}
200impl From<ApplyError> for PosEditError {
201    fn from(e: ApplyError) -> Self {
202        PosEditError::Apply(e)
203    }
204}
205
206impl fmt::Display for PosEditError {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        match self {
209            PosEditError::Pos(e) => write!(f, "pos-edit: {e}"),
210            PosEditError::Range(e) => write!(f, "pos-edit: {e}"),
211            PosEditError::Block(e) => write!(f, "pos-edit: {e}"),
212            PosEditError::Apply(e) => write!(f, "pos-edit: {e}"),
213            PosEditError::UnsupportedSpan { from, to } => {
214                write!(f, "pos-edit: unsupported cross-block span [{from},{to})")
215            }
216            PosEditError::OverlappingEdits { from, to } => {
217                write!(f, "pos-edit: overlapping edit at [{from},{to})")
218            }
219        }
220    }
221}
222
223impl std::error::Error for PosEditError {}
224
225impl Node {
226    /// Apply a batch of position-addressed [`PosEdit`]s and return the recovered,
227    /// invertible [`Change`] patch (relative to `self` before the call).
228    ///
229    /// Edits are applied **highest-position-first** so their un-rebased flat
230    /// positions stay valid; the batch must be **disjoint** (overlapping spans
231    /// return [`PosEditError::OverlappingEdits`]). On any error `self` is left
232    /// unchanged (edits run against a working clone, committed only on success).
233    pub fn apply_pos_edits(&mut self, edits: &[PosEdit]) -> Result<Vec<Change>, PosEditError> {
234        // Reject inverted spans up front so they surface as InvertedRange
235        // (consistent with the inline range API) rather than as a spurious
236        // UnsupportedSpan / overlap from the ordering pass below.
237        for e in edits {
238            let (lo, hi) = e.span();
239            if lo > hi {
240                return Err(PosEditError::Range(RangeError::InvertedRange));
241            }
242        }
243
244        // Process highest-position-first; an edit never shifts positions below it.
245        let mut order: Vec<usize> = (0..edits.len()).collect();
246        order.sort_by(|&a, &b| edits[b].span().0.cmp(&edits[a].span().0));
247
248        // Disjointness: each (lower) edit must end at or before the previous
249        // (higher) edit starts.
250        for k in 1..order.len() {
251            let higher = edits[order[k - 1]].span();
252            let lower = edits[order[k]].span();
253            if lower.1 > higher.0 {
254                return Err(PosEditError::OverlappingEdits {
255                    from: lower.0,
256                    to: lower.1,
257                });
258            }
259        }
260
261        let mut work = self.clone();
262        for &i in &order {
263            apply_one(&mut work, &edits[i])?;
264        }
265        let patch = self.diff(&work);
266        *self = work;
267        Ok(patch)
268    }
269}
270
271// ---- internals ----------------------------------------------------------
272
273fn block_mut<'a>(root: &'a mut Node, path: &[usize]) -> Result<&'a mut Node, PosEditError> {
274    root.node_at_mut(path).ok_or_else(|| {
275        PosEditError::Pos(PosError::PathNotFound {
276            path: path.to_vec(),
277        })
278    })
279}
280
281fn apply_one(work: &mut Node, edit: &PosEdit) -> Result<(), PosEditError> {
282    match edit {
283        PosEdit::Insert { pos, content } => insert_at(work, *pos, content),
284        PosEdit::Delete { from, to } => splice(work, *from, *to, None),
285        PosEdit::Replace { from, to, content } => splice(work, *from, *to, Some(content)),
286        PosEdit::AddMark { from, to, mark } => {
287            mark_span(work, *from, *to, &MarkOp::Add(mark.clone()))
288        }
289        PosEdit::RemoveMark {
290            from,
291            to,
292            mark_type,
293        } => mark_span(work, *from, *to, &MarkOp::Remove(mark_type.clone())),
294        PosEdit::SetBlockAttrs { pos, attrs } => set_block_attrs(work, *pos, attrs.clone()),
295    }
296}
297
298fn insert_at(work: &mut Node, pos: usize, content: &PosContent) -> Result<(), PosEditError> {
299    match content {
300        PosContent::Text { text, marks } => {
301            let (block, inline) = work.pos_to_inline(pos)?;
302            block_mut(work, &block)?.insert_text(inline, text, marks.as_deref())?;
303            Ok(())
304        }
305        PosContent::Nodes { nodes } => {
306            let r = work.resolve(pos)?;
307            let (block_path, idx) = match &r.text_offset {
308                // Mid-text: split the text node so nodes land on a boundary.
309                Some(tp) => {
310                    let bp = r.path.clone();
311                    let i = ensure_boundary(
312                        block_mut(work, &bp)?.children_mut(),
313                        Position::new(r.index, tp.offset),
314                    )?;
315                    (bp, i)
316                }
317                None => (r.path.clone(), r.index),
318            };
319            let parent = block_mut(work, &block_path)?;
320            for (k, n) in nodes.iter().enumerate() {
321                parent.insert_child(idx + k, n.clone());
322            }
323            Ok(())
324        }
325    }
326}
327
328fn splice(
329    work: &mut Node,
330    from: usize,
331    to: usize,
332    content: Option<&PosContent>,
333) -> Result<(), PosEditError> {
334    let (fb, fi) = work.pos_to_inline(from)?;
335    let (tb, ti) = work.pos_to_inline(to)?;
336
337    if fb == tb {
338        return splice_same_block(block_mut(work, &fb)?, fi, ti, content);
339    }
340
341    // Cross-block: only sibling blocks under a common parent (v1).
342    let (parent, a, b) =
343        sibling_blocks(&fb, &tb).ok_or(PosEditError::UnsupportedSpan { from, to })?;
344
345    // 1. Trim the first block's tail, then append the replacement content.
346    {
347        let block_a = block_mut(work, &fb)?;
348        let end = Position::new(block_a.children().len(), 0);
349        block_a.delete_range(Range::new(fi, end))?;
350        append_content(block_a, content)?;
351    }
352    // 2. Trim the last block's head.
353    block_mut(work, &tb)?.delete_range(Range::new(Position::new(0, 0), ti))?;
354    // 3. Drop the whole blocks between, then join the last into the first.
355    block_mut(work, &parent)?.children_mut().drain(a + 1..b);
356    work.join_blocks(&parent, a + 1)?;
357    Ok(())
358}
359
360fn splice_same_block(
361    block: &mut Node,
362    from: Position,
363    to: Position,
364    content: Option<&PosContent>,
365) -> Result<(), PosEditError> {
366    match content {
367        None => block.delete_range(Range::new(from, to))?,
368        Some(PosContent::Text { text, marks }) => {
369            block.replace_range(Range::new(from, to), text, marks.as_deref())?
370        }
371        Some(PosContent::Nodes { nodes }) => {
372            let children = block.children_mut();
373            let (s, e) = resolve_range(children, Range::new(from, to))?;
374            children.drain(s..e);
375            for (k, n) in nodes.iter().enumerate() {
376                children.insert(s + k, n.clone());
377            }
378            normalize_children(children, &NormalizeOptions::default());
379        }
380    }
381    Ok(())
382}
383
384/// Append `content` to the end of a block's inline content (used at the seam of
385/// a cross-block replace).
386fn append_content(block: &mut Node, content: Option<&PosContent>) -> Result<(), PosEditError> {
387    match content {
388        None => {}
389        Some(PosContent::Text { text, marks }) => {
390            let at = Position::new(block.children().len(), 0);
391            block.insert_text(at, text, marks.as_deref())?;
392        }
393        Some(PosContent::Nodes { nodes }) => {
394            let at = block.children().len();
395            for (k, n) in nodes.iter().enumerate() {
396                block.insert_child(at + k, n.clone());
397            }
398        }
399    }
400    Ok(())
401}
402
403enum MarkOp {
404    Add(Mark),
405    Remove(String),
406}
407
408fn apply_mark(block: &mut Node, range: Range, op: &MarkOp) -> Result<(), RangeError> {
409    match op {
410        MarkOp::Add(m) => block.add_mark_range(range, m.clone()),
411        MarkOp::Remove(t) => block.remove_mark_range(range, t),
412    }
413}
414
415fn mark_span(work: &mut Node, from: usize, to: usize, op: &MarkOp) -> Result<(), PosEditError> {
416    let (fb, fi) = work.pos_to_inline(from)?;
417    let (tb, ti) = work.pos_to_inline(to)?;
418
419    if fb == tb {
420        apply_mark(block_mut(work, &fb)?, Range::new(fi, ti), op)?;
421        return Ok(());
422    }
423
424    let (parent, a, b) =
425        sibling_blocks(&fb, &tb).ok_or(PosEditError::UnsupportedSpan { from, to })?;
426
427    // First block: from `fi` to its end.
428    {
429        let block_a = block_mut(work, &fb)?;
430        let end = Position::new(block_a.children().len(), 0);
431        apply_mark(block_a, Range::new(fi, end), op)?;
432    }
433    // Whole blocks in between.
434    for k in (a + 1)..b {
435        let mut p = parent.clone();
436        p.push(k);
437        let blk = block_mut(work, &p)?;
438        let end = Position::new(blk.children().len(), 0);
439        apply_mark(blk, Range::new(Position::new(0, 0), end), op)?;
440    }
441    // Last block: start to `ti`.
442    apply_mark(
443        block_mut(work, &tb)?,
444        Range::new(Position::new(0, 0), ti),
445        op,
446    )?;
447    Ok(())
448}
449
450fn set_block_attrs(
451    work: &mut Node,
452    pos: usize,
453    attrs: Map<String, Value>,
454) -> Result<(), PosEditError> {
455    let r = work.resolve(pos)?;
456    // Target the node that *begins* at `pos` (the "position before a node"
457    // convention): descend to the child at the boundary only when it's a real
458    // node (non-text). An inline-text boundary inside a block has
459    // `text_offset == None` too, but `index` points at an inline child — there
460    // we target the containing block instead.
461    let mut target = r.path.clone();
462    if r.text_offset.is_none() {
463        let parent = block_mut(work, &r.path)?;
464        let descend = parent
465            .children()
466            .get(r.index)
467            .is_some_and(|c| c.node_type.as_deref() != Some("text"));
468        if descend {
469            target.push(r.index);
470        }
471    }
472    let node = block_mut(work, &target)?;
473    node.attrs = if attrs.is_empty() { None } else { Some(attrs) };
474    Ok(())
475}
476
477/// If `fb` and `tb` are sibling blocks under a common parent (same depth, same
478/// prefix, `fb` strictly before `tb`), return `(parent_path, a, b)`.
479fn sibling_blocks(fb: &[usize], tb: &[usize]) -> Option<(Vec<usize>, usize, usize)> {
480    let ((&a, fp), (&b, tp)) = (fb.split_last()?, tb.split_last()?);
481    if fp == tp && a < b {
482        Some((fp.to_vec(), a, b))
483    } else {
484        None
485    }
486}