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
86impl PosContent {
87    /// Flat (ProseMirror) size of this content once inserted — text scalars, or
88    /// the summed `node_size` of the nodes (default [`LeafPolicy`](crate::LeafPolicy)).
89    pub(crate) fn flat_len(&self) -> usize {
90        match self {
91            PosContent::Text { text, .. } => text.chars().count(),
92            PosContent::Nodes { nodes } => nodes.iter().map(Node::node_size).sum(),
93        }
94    }
95}
96
97/// A single position-addressed edit. Offsets are **flat ProseMirror positions**.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99#[serde(
100    tag = "type",
101    rename_all = "camelCase",
102    rename_all_fields = "camelCase"
103)]
104pub enum PosEdit {
105    /// Insert `content` at `pos`.
106    Insert {
107        /// Flat position to insert at.
108        pos: usize,
109        /// What to insert.
110        content: PosContent,
111    },
112    /// Delete the flat range `[from, to)`.
113    Delete {
114        /// Start position (inclusive).
115        from: usize,
116        /// End position (exclusive).
117        to: usize,
118    },
119    /// Replace the flat range `[from, to)` with `content`.
120    Replace {
121        /// Start position (inclusive).
122        from: usize,
123        /// End position (exclusive).
124        to: usize,
125        /// Replacement content.
126        content: PosContent,
127    },
128    /// Add `mark` to text in the flat range `[from, to)`.
129    AddMark {
130        /// Start position (inclusive).
131        from: usize,
132        /// End position (exclusive).
133        to: usize,
134        /// The mark to add.
135        mark: Mark,
136    },
137    /// Remove every mark of `mark_type` from text in `[from, to)`.
138    RemoveMark {
139        /// Start position (inclusive).
140        from: usize,
141        /// End position (exclusive).
142        to: usize,
143        /// The mark type to remove.
144        mark_type: String,
145    },
146    /// Replace the whole attribute map of the block at (or containing) `pos`.
147    SetBlockAttrs {
148        /// A flat position before or inside the target block.
149        pos: usize,
150        /// The new attribute map (empty clears all attrs).
151        attrs: Map<String, Value>,
152    },
153}
154
155impl PosEdit {
156    /// The `[lo, hi)` flat span this edit occupies (point edits have `lo == hi`).
157    fn span(&self) -> (usize, usize) {
158        match self {
159            PosEdit::Insert { pos, .. } | PosEdit::SetBlockAttrs { pos, .. } => (*pos, *pos),
160            PosEdit::Delete { from, to }
161            | PosEdit::Replace { from, to, .. }
162            | PosEdit::AddMark { from, to, .. }
163            | PosEdit::RemoveMark { from, to, .. } => (*from, *to),
164        }
165    }
166}
167
168/// Why a [`Node::apply_pos_edits`] batch could not be applied.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum PosEditError {
171    /// A flat position failed to resolve.
172    Pos(PosError),
173    /// An inline range op failed.
174    Range(RangeError),
175    /// A block-structural op failed.
176    Block(BlockError),
177    /// A recorded change failed to apply.
178    Apply(ApplyError),
179    /// A cross-block span whose endpoints aren't sibling blocks under a common
180    /// parent (different depths/parents) — unsupported in v1.
181    UnsupportedSpan {
182        /// Start position.
183        from: usize,
184        /// End position.
185        to: usize,
186    },
187    /// Two edits in the batch overlap; v1 requires disjoint spans.
188    OverlappingEdits {
189        /// Start of the overlapping edit.
190        from: usize,
191        /// End of the overlapping edit.
192        to: usize,
193    },
194}
195
196impl From<PosError> for PosEditError {
197    fn from(e: PosError) -> Self {
198        PosEditError::Pos(e)
199    }
200}
201impl From<RangeError> for PosEditError {
202    fn from(e: RangeError) -> Self {
203        PosEditError::Range(e)
204    }
205}
206impl From<BlockError> for PosEditError {
207    fn from(e: BlockError) -> Self {
208        PosEditError::Block(e)
209    }
210}
211impl From<ApplyError> for PosEditError {
212    fn from(e: ApplyError) -> Self {
213        PosEditError::Apply(e)
214    }
215}
216
217impl fmt::Display for PosEditError {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        match self {
220            PosEditError::Pos(e) => write!(f, "pos-edit: {e}"),
221            PosEditError::Range(e) => write!(f, "pos-edit: {e}"),
222            PosEditError::Block(e) => write!(f, "pos-edit: {e}"),
223            PosEditError::Apply(e) => write!(f, "pos-edit: {e}"),
224            PosEditError::UnsupportedSpan { from, to } => {
225                write!(f, "pos-edit: unsupported cross-block span [{from},{to})")
226            }
227            PosEditError::OverlappingEdits { from, to } => {
228                write!(f, "pos-edit: overlapping edit at [{from},{to})")
229            }
230        }
231    }
232}
233
234impl std::error::Error for PosEditError {}
235
236impl Node {
237    /// Apply a batch of position-addressed [`PosEdit`]s and return the recovered,
238    /// invertible [`Change`] patch (relative to `self` before the call).
239    ///
240    /// Edits are applied **highest-position-first** so their un-rebased flat
241    /// positions stay valid; the batch must be **disjoint** (overlapping spans
242    /// return [`PosEditError::OverlappingEdits`]). On any error `self` is left
243    /// unchanged (edits run against a working clone, committed only on success).
244    pub fn apply_pos_edits(&mut self, edits: &[PosEdit]) -> Result<Vec<Change>, PosEditError> {
245        // Reject inverted spans up front so they surface as InvertedRange
246        // (consistent with the inline range API) rather than as a spurious
247        // UnsupportedSpan / overlap from the ordering pass below.
248        for e in edits {
249            let (lo, hi) = e.span();
250            if lo > hi {
251                return Err(PosEditError::Range(RangeError::InvertedRange));
252            }
253        }
254
255        // Process highest-position-first; an edit never shifts positions below it.
256        let mut order: Vec<usize> = (0..edits.len()).collect();
257        order.sort_by(|&a, &b| edits[b].span().0.cmp(&edits[a].span().0));
258
259        // Disjointness: each (lower) edit must end at or before the previous
260        // (higher) edit starts.
261        for k in 1..order.len() {
262            let higher = edits[order[k - 1]].span();
263            let lower = edits[order[k]].span();
264            if lower.1 > higher.0 {
265                return Err(PosEditError::OverlappingEdits {
266                    from: lower.0,
267                    to: lower.1,
268                });
269            }
270        }
271
272        let mut work = self.clone();
273        for &i in &order {
274            apply_one(&mut work, &edits[i])?;
275        }
276        let patch = self.diff(&work);
277        *self = work;
278        Ok(patch)
279    }
280
281    /// Like [`apply_pos_edits`](Node::apply_pos_edits), but also returns a
282    /// [`PosMap`](crate::PosMap) carrying pre-edit flat positions through the
283    /// batch — so a selection, cursor, or decoration range can be re-anchored in
284    /// the edited document.
285    pub fn apply_pos_edits_mapped(
286        &mut self,
287        edits: &[PosEdit],
288    ) -> Result<(Vec<Change>, crate::PosMap), PosEditError> {
289        let patch = self.apply_pos_edits(edits)?;
290        Ok((patch, crate::PosMap::from_pos_edits(edits)))
291    }
292}
293
294// ---- internals ----------------------------------------------------------
295
296fn block_mut<'a>(root: &'a mut Node, path: &[usize]) -> Result<&'a mut Node, PosEditError> {
297    root.node_at_mut(path).ok_or_else(|| {
298        PosEditError::Pos(PosError::PathNotFound {
299            path: path.to_vec(),
300        })
301    })
302}
303
304fn apply_one(work: &mut Node, edit: &PosEdit) -> Result<(), PosEditError> {
305    match edit {
306        PosEdit::Insert { pos, content } => insert_at(work, *pos, content),
307        PosEdit::Delete { from, to } => splice(work, *from, *to, None),
308        PosEdit::Replace { from, to, content } => splice(work, *from, *to, Some(content)),
309        PosEdit::AddMark { from, to, mark } => {
310            mark_span(work, *from, *to, &MarkOp::Add(mark.clone()))
311        }
312        PosEdit::RemoveMark {
313            from,
314            to,
315            mark_type,
316        } => mark_span(work, *from, *to, &MarkOp::Remove(mark_type.clone())),
317        PosEdit::SetBlockAttrs { pos, attrs } => set_block_attrs(work, *pos, attrs.clone()),
318    }
319}
320
321fn insert_at(work: &mut Node, pos: usize, content: &PosContent) -> Result<(), PosEditError> {
322    match content {
323        PosContent::Text { text, marks } => {
324            let (block, inline) = work.pos_to_inline(pos)?;
325            block_mut(work, &block)?.insert_text(inline, text, marks.as_deref())?;
326            Ok(())
327        }
328        PosContent::Nodes { nodes } => {
329            let r = work.resolve(pos)?;
330            let (block_path, idx) = match &r.text_offset {
331                // Mid-text: split the text node so nodes land on a boundary.
332                Some(tp) => {
333                    let bp = r.path.clone();
334                    let i = ensure_boundary(
335                        block_mut(work, &bp)?.children_mut(),
336                        Position::new(r.index, tp.offset),
337                    )?;
338                    (bp, i)
339                }
340                None => (r.path.clone(), r.index),
341            };
342            let parent = block_mut(work, &block_path)?;
343            for (k, n) in nodes.iter().enumerate() {
344                parent.insert_child(idx + k, n.clone());
345            }
346            Ok(())
347        }
348    }
349}
350
351fn splice(
352    work: &mut Node,
353    from: usize,
354    to: usize,
355    content: Option<&PosContent>,
356) -> Result<(), PosEditError> {
357    let (fb, fi) = work.pos_to_inline(from)?;
358    let (tb, ti) = work.pos_to_inline(to)?;
359
360    if fb == tb {
361        return splice_same_block(block_mut(work, &fb)?, fi, ti, content);
362    }
363
364    // Cross-block: only sibling blocks under a common parent (v1).
365    let (parent, a, b) =
366        sibling_blocks(&fb, &tb).ok_or(PosEditError::UnsupportedSpan { from, to })?;
367
368    // 1. Trim the first block's tail, then append the replacement content.
369    {
370        let block_a = block_mut(work, &fb)?;
371        let end = Position::new(block_a.children().len(), 0);
372        block_a.delete_range(Range::new(fi, end))?;
373        append_content(block_a, content)?;
374    }
375    // 2. Trim the last block's head.
376    block_mut(work, &tb)?.delete_range(Range::new(Position::new(0, 0), ti))?;
377    // 3. Drop the whole blocks between, then join the last into the first.
378    block_mut(work, &parent)?.children_mut().drain(a + 1..b);
379    work.join_blocks(&parent, a + 1)?;
380    Ok(())
381}
382
383fn splice_same_block(
384    block: &mut Node,
385    from: Position,
386    to: Position,
387    content: Option<&PosContent>,
388) -> Result<(), PosEditError> {
389    match content {
390        None => block.delete_range(Range::new(from, to))?,
391        Some(PosContent::Text { text, marks }) => {
392            block.replace_range(Range::new(from, to), text, marks.as_deref())?
393        }
394        Some(PosContent::Nodes { nodes }) => {
395            let children = block.children_mut();
396            let (s, e) = resolve_range(children, Range::new(from, to))?;
397            children.drain(s..e);
398            for (k, n) in nodes.iter().enumerate() {
399                children.insert(s + k, n.clone());
400            }
401            normalize_children(children, &NormalizeOptions::default());
402        }
403    }
404    Ok(())
405}
406
407/// Append `content` to the end of a block's inline content (used at the seam of
408/// a cross-block replace).
409fn append_content(block: &mut Node, content: Option<&PosContent>) -> Result<(), PosEditError> {
410    match content {
411        None => {}
412        Some(PosContent::Text { text, marks }) => {
413            let at = Position::new(block.children().len(), 0);
414            block.insert_text(at, text, marks.as_deref())?;
415        }
416        Some(PosContent::Nodes { nodes }) => {
417            let at = block.children().len();
418            for (k, n) in nodes.iter().enumerate() {
419                block.insert_child(at + k, n.clone());
420            }
421        }
422    }
423    Ok(())
424}
425
426enum MarkOp {
427    Add(Mark),
428    Remove(String),
429}
430
431fn apply_mark(block: &mut Node, range: Range, op: &MarkOp) -> Result<(), RangeError> {
432    match op {
433        MarkOp::Add(m) => block.add_mark_range(range, m.clone()),
434        MarkOp::Remove(t) => block.remove_mark_range(range, t),
435    }
436}
437
438fn mark_span(work: &mut Node, from: usize, to: usize, op: &MarkOp) -> Result<(), PosEditError> {
439    let (fb, fi) = work.pos_to_inline(from)?;
440    let (tb, ti) = work.pos_to_inline(to)?;
441
442    if fb == tb {
443        apply_mark(block_mut(work, &fb)?, Range::new(fi, ti), op)?;
444        return Ok(());
445    }
446
447    let (parent, a, b) =
448        sibling_blocks(&fb, &tb).ok_or(PosEditError::UnsupportedSpan { from, to })?;
449
450    // First block: from `fi` to its end.
451    {
452        let block_a = block_mut(work, &fb)?;
453        let end = Position::new(block_a.children().len(), 0);
454        apply_mark(block_a, Range::new(fi, end), op)?;
455    }
456    // Whole blocks in between.
457    for k in (a + 1)..b {
458        let mut p = parent.clone();
459        p.push(k);
460        let blk = block_mut(work, &p)?;
461        let end = Position::new(blk.children().len(), 0);
462        apply_mark(blk, Range::new(Position::new(0, 0), end), op)?;
463    }
464    // Last block: start to `ti`.
465    apply_mark(
466        block_mut(work, &tb)?,
467        Range::new(Position::new(0, 0), ti),
468        op,
469    )?;
470    Ok(())
471}
472
473fn set_block_attrs(
474    work: &mut Node,
475    pos: usize,
476    attrs: Map<String, Value>,
477) -> Result<(), PosEditError> {
478    let r = work.resolve(pos)?;
479    // Target the node that *begins* at `pos` (the "position before a node"
480    // convention): descend to the child at the boundary only when it's a real
481    // node (non-text). An inline-text boundary inside a block has
482    // `text_offset == None` too, but `index` points at an inline child — there
483    // we target the containing block instead.
484    let mut target = r.path.clone();
485    if r.text_offset.is_none() {
486        let parent = block_mut(work, &r.path)?;
487        let descend = parent
488            .children()
489            .get(r.index)
490            .is_some_and(|c| c.node_type.as_deref() != Some("text"));
491        if descend {
492            target.push(r.index);
493        }
494    }
495    let node = block_mut(work, &target)?;
496    node.attrs = if attrs.is_empty() { None } else { Some(attrs) };
497    Ok(())
498}
499
500/// If `fb` and `tb` are sibling blocks under a common parent (same depth, same
501/// prefix, `fb` strictly before `tb`), return `(parent_path, a, b)`.
502fn sibling_blocks(fb: &[usize], tb: &[usize]) -> Option<(Vec<usize>, usize, usize)> {
503    let ((&a, fp), (&b, tp)) = (fb.split_last()?, tb.split_last()?);
504    if fp == tp && a < b {
505        Some((fp.to_vec(), a, b))
506    } else {
507        None
508    }
509}