Skip to main content

tiptap_rusty_parser/
pos.rs

1//! Flat ProseMirror integer positions over a [`Node`] tree.
2//!
3//! Tiptap/ProseMirror addresses every location in a document with a single
4//! integer ("position"). This module implements that scheme on top of the
5//! crate's index-path model so the two interoperate.
6//!
7//! ## Size rules (ProseMirror `nodeSize`)
8//! - a **text** node has size = its Unicode-scalar length;
9//! - a **leaf** node has size `1`;
10//! - any other node has size `2 + content_size` (an open and a close token);
11//! - the root/`doc` node is **not** wrapped in tokens: valid positions run
12//!   `0..=content_size(root)`, with `0` just inside the root before its first child.
13//!
14//! Whether a node is a *leaf* (size 1) or an *empty container* (size 2, e.g. an
15//! empty paragraph) **cannot be derived from JSON** — it's a schema property. A
16//! [`LeafPolicy`] decides it: the default treats a small built-in set of Tiptap
17//! atoms (`image`, `horizontalRule`, `hardBreak`) as leaves and everything else
18//! as a container. Override it with an explicit type set when your schema differs.
19//!
20//! ```
21//! use tiptap_rusty_parser::Node;
22//! // doc > [ paragraph("hi"), horizontalRule, paragraph("ok") ]
23//! let doc = Node::element("doc").with_children([
24//!     Node::element("paragraph").with_text("hi"),
25//!     Node::element("horizontalRule"),
26//!     Node::element("paragraph").with_text("ok"),
27//! ]);
28//! assert_eq!(doc.pos_before(&[1]).unwrap(), 4);   // the rule sits at pos 4
29//! assert_eq!(doc.pos_in_text(&[0, 0], 1).unwrap(), 2); // after "h" in "hi"
30//! let r = doc.resolve(2).unwrap();
31//! assert_eq!(r.path, vec![0]);                    // inside the first paragraph
32//! assert_eq!(r.text_offset.unwrap().offset, 1);   // 1 scalar into "hi"
33//! ```
34
35use crate::node::Node;
36use crate::range::Position;
37use serde::{Deserialize, Serialize};
38use std::collections::HashSet;
39use std::fmt;
40
41/// Decides which nodes are ProseMirror **leaves** (size 1) vs empty containers
42/// (size 2). Leafness isn't recoverable from JSON, so it's configured here.
43#[derive(Debug, Clone, PartialEq, Eq, Default)]
44pub enum LeafPolicy {
45    /// Built-in Tiptap atoms: `image`, `horizontalRule`, `hardBreak`.
46    #[default]
47    Builtin,
48    /// An explicit set of node-type names to treat as leaves.
49    Types(HashSet<String>),
50}
51
52impl LeafPolicy {
53    /// Build a policy from an explicit list of leaf type names.
54    pub fn from_types<I, S>(types: I) -> Self
55    where
56        I: IntoIterator<Item = S>,
57        S: Into<String>,
58    {
59        LeafPolicy::Types(types.into_iter().map(Into::into).collect())
60    }
61
62    fn is_leaf_type(&self, ty: &str) -> bool {
63        match self {
64            LeafPolicy::Builtin => matches!(ty, "image" | "horizontalRule" | "hardBreak"),
65            LeafPolicy::Types(set) => set.contains(ty),
66        }
67    }
68}
69
70/// A flat position resolved against a [`Node`] tree. All fields are owned
71/// indices (no borrows), so it serializes and crosses FFI cleanly.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct ResolvedPos {
74    /// The original flat position.
75    pub pos: usize,
76    /// Depth of the containing (parent) node; equals `path.len()`.
77    pub depth: usize,
78    /// Index-path from the root to the node containing this position.
79    pub path: Vec<usize>,
80    /// Offset of the position within the parent's content (flat units).
81    pub parent_offset: usize,
82    /// Index of the child at or immediately after the boundary.
83    pub index: usize,
84    /// Set when the position lies strictly inside a text node.
85    pub text_offset: Option<TextPoint>,
86}
87
88impl ResolvedPos {
89    /// Whether the position lies strictly inside a text node.
90    pub fn is_in_text(&self) -> bool {
91        self.text_offset.is_some()
92    }
93
94    /// The parent (containing) node, re-queried against `root`.
95    pub fn parent<'a>(&self, root: &'a Node) -> Option<&'a Node> {
96        root.node_at(&self.path)
97    }
98}
99
100/// The text node and scalar offset a position falls inside.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct TextPoint {
103    /// Index-path to the text node.
104    pub path: Vec<usize>,
105    /// Unicode-scalar offset within that text node.
106    pub offset: usize,
107}
108
109/// A flat ProseMirror range `[from, to]` over a whole document. (Distinct from
110/// the block-scoped [`Range`](crate::Range) used by inline range editing.)
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112pub struct PosRange {
113    /// Start position (inclusive).
114    pub from: usize,
115    /// End position (exclusive).
116    pub to: usize,
117}
118
119impl PosRange {
120    /// Construct a range (`from <= to` expected).
121    pub fn new(from: usize, to: usize) -> Self {
122        Self { from, to }
123    }
124
125    /// A collapsed (empty) range at `at`.
126    pub fn collapsed(at: usize) -> Self {
127        Self { from: at, to: at }
128    }
129
130    /// Whether the range is empty.
131    pub fn is_empty(&self) -> bool {
132        self.to <= self.from
133    }
134}
135
136/// Why a flat-position operation failed.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum PosError {
139    /// `pos` is past the end of the document.
140    OutOfRange {
141        /// The offending position.
142        pos: usize,
143        /// The document's content size (max valid position).
144        size: usize,
145    },
146    /// An index-path didn't resolve to a node.
147    PathNotFound {
148        /// The unresolved path.
149        path: Vec<usize>,
150    },
151    /// A child index in a path is out of range for its parent.
152    OffsetOutOfRange {
153        /// The path being resolved.
154        path: Vec<usize>,
155        /// The offending child index.
156        offset: usize,
157    },
158}
159
160impl fmt::Display for PosError {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        match self {
163            PosError::OutOfRange { pos, size } => {
164                write!(f, "pos: {pos} out of range (document size {size})")
165            }
166            PosError::PathNotFound { path } => write!(f, "pos: no node at path {path:?}"),
167            PosError::OffsetOutOfRange { path, offset } => {
168                write!(f, "pos: child index {offset} out of range at {path:?}")
169            }
170        }
171    }
172}
173
174impl std::error::Error for PosError {}
175
176impl Node {
177    /// ProseMirror node size with the default [`LeafPolicy`].
178    pub fn node_size(&self) -> usize {
179        self.node_size_with(&LeafPolicy::Builtin)
180    }
181
182    /// ProseMirror node size under `policy`.
183    pub fn node_size_with(&self, policy: &LeafPolicy) -> usize {
184        if let Some(t) = &self.text {
185            return t.chars().count();
186        }
187        if self.is_leaf_with(policy) {
188            return 1;
189        }
190        2 + self.content_size_with(policy)
191    }
192
193    /// Sum of child sizes (the count of inner positions) with the default policy.
194    pub fn content_size(&self) -> usize {
195        self.content_size_with(&LeafPolicy::Builtin)
196    }
197
198    /// Sum of child sizes under `policy`.
199    pub fn content_size_with(&self, policy: &LeafPolicy) -> usize {
200        self.children()
201            .iter()
202            .map(|c| c.node_size_with(policy))
203            .sum()
204    }
205
206    /// Whether this node is a ProseMirror leaf under the default policy.
207    pub fn is_leaf(&self) -> bool {
208        self.is_leaf_with(&LeafPolicy::Builtin)
209    }
210
211    /// Whether this node is a ProseMirror leaf under `policy` (non-text, and its
212    /// type is in the leaf set).
213    pub fn is_leaf_with(&self, policy: &LeafPolicy) -> bool {
214        self.text.is_none()
215            && self
216                .node_type
217                .as_deref()
218                .is_some_and(|t| policy.is_leaf_type(t))
219    }
220
221    /// Resolve a flat position with the default [`LeafPolicy`].
222    pub fn resolve(&self, pos: usize) -> Result<ResolvedPos, PosError> {
223        self.resolve_with(pos, &LeafPolicy::Builtin)
224    }
225
226    /// Resolve a flat position into a [`ResolvedPos`] under `policy`.
227    pub fn resolve_with(&self, pos: usize, policy: &LeafPolicy) -> Result<ResolvedPos, PosError> {
228        let total = self.content_size_with(policy);
229        if pos > total {
230            return Err(PosError::OutOfRange { pos, size: total });
231        }
232        let mut path: Vec<usize> = Vec::new();
233        let mut offset = pos;
234        'walk: loop {
235            let node = self.node_at(&path).expect("resolved path stays valid");
236            let children = node.children();
237            let mut i = 0usize;
238            let mut rem = offset;
239            loop {
240                if i == children.len() || rem == 0 {
241                    return Ok(ResolvedPos {
242                        pos,
243                        depth: path.len(),
244                        path,
245                        parent_offset: offset,
246                        index: i,
247                        text_offset: None,
248                    });
249                }
250                let child = &children[i];
251                let cs = child.node_size_with(policy);
252                if rem < cs {
253                    if child.text.is_some() {
254                        let mut tpath = path.clone();
255                        tpath.push(i);
256                        return Ok(ResolvedPos {
257                            pos,
258                            depth: path.len(),
259                            path,
260                            parent_offset: offset,
261                            index: i,
262                            text_offset: Some(TextPoint {
263                                path: tpath,
264                                offset: rem,
265                            }),
266                        });
267                    }
268                    // Descend into a non-text container, crossing its open token.
269                    path.push(i);
270                    offset = rem - 1;
271                    continue 'walk;
272                }
273                rem -= cs;
274                i += 1;
275            }
276        }
277    }
278
279    /// Flat position of the boundary just *before* the node at `path` (default policy).
280    pub fn pos_before(&self, path: &[usize]) -> Result<usize, PosError> {
281        self.pos_before_with(path, &LeafPolicy::Builtin)
282    }
283
284    /// Flat position of the boundary just *before* the node at `path` under `policy`.
285    pub fn pos_before_with(&self, path: &[usize], policy: &LeafPolicy) -> Result<usize, PosError> {
286        let mut acc = 0usize;
287        for depth in 0..path.len() {
288            let parent = self
289                .node_at(&path[..depth])
290                .ok_or_else(|| PosError::PathNotFound {
291                    path: path.to_vec(),
292                })?;
293            let children = parent.children();
294            let i = path[depth];
295            // Every index must address a real child (no node exists at
296            // `children.len()`, nor under a leaf/text node).
297            if i >= children.len() {
298                return Err(PosError::OffsetOutOfRange {
299                    path: path.to_vec(),
300                    offset: i,
301                });
302            }
303            for child in &children[..i] {
304                acc += child.node_size_with(policy);
305            }
306            // Entering a container child (every level except the last) crosses
307            // its open token.
308            if depth + 1 < path.len() {
309                acc += 1;
310            }
311        }
312        Ok(acc)
313    }
314
315    /// Flat position just *after* the node at `path` (default policy).
316    pub fn pos_after(&self, path: &[usize]) -> Result<usize, PosError> {
317        self.pos_after_with(path, &LeafPolicy::Builtin)
318    }
319
320    /// Flat position just *after* the node at `path` under `policy`.
321    pub fn pos_after_with(&self, path: &[usize], policy: &LeafPolicy) -> Result<usize, PosError> {
322        let before = self.pos_before_with(path, policy)?;
323        let node = self.node_at(path).ok_or_else(|| PosError::PathNotFound {
324            path: path.to_vec(),
325        })?;
326        Ok(before + node.node_size_with(policy))
327    }
328
329    /// Flat position at scalar `offset` inside the text node at `text_path`.
330    /// Errors if `text_path` is not a text node or `offset` exceeds its length.
331    pub fn pos_in_text(&self, text_path: &[usize], offset: usize) -> Result<usize, PosError> {
332        let node = self
333            .node_at(text_path)
334            .ok_or_else(|| PosError::PathNotFound {
335                path: text_path.to_vec(),
336            })?;
337        let len = match &node.text {
338            Some(t) => t.chars().count(),
339            None => {
340                return Err(PosError::OffsetOutOfRange {
341                    path: text_path.to_vec(),
342                    offset,
343                })
344            }
345        };
346        if offset > len {
347            return Err(PosError::OffsetOutOfRange {
348                path: text_path.to_vec(),
349                offset,
350            });
351        }
352        Ok(self.pos_before(text_path)? + offset)
353    }
354
355    /// Map a flat position to the `(block path, inline Position)` pair used by
356    /// the inline range-editing API ([`Node::insert_text`] etc.), with the
357    /// default policy. The block path is the resolved container.
358    pub fn pos_to_inline(&self, pos: usize) -> Result<(Vec<usize>, Position), PosError> {
359        let r = self.resolve(pos)?;
360        let inline = match &r.text_offset {
361            Some(tp) => Position::new(r.index, tp.offset),
362            None => Position::new(r.index, 0),
363        };
364        Ok((r.path, inline))
365    }
366
367    /// Inverse of [`Node::pos_to_inline`]: the flat position for a block-local
368    /// inline [`Position`] (default policy).
369    pub fn inline_to_pos(&self, block_path: &[usize], inline: Position) -> Result<usize, PosError> {
370        let policy = LeafPolicy::Builtin;
371        // Just inside the block (past its open token).
372        let mut acc = self.pos_before_with(block_path, &policy)? + 1;
373        let block = self
374            .node_at(block_path)
375            .ok_or_else(|| PosError::PathNotFound {
376                path: block_path.to_vec(),
377            })?;
378        let children = block.children();
379        if inline.child > children.len() {
380            return Err(PosError::OffsetOutOfRange {
381                path: block_path.to_vec(),
382                offset: inline.child,
383            });
384        }
385        // Validate the inline offset against the target child (mirrors the
386        // inline range API: text → within length; non-text/end → offset 0 only).
387        let offset_ok = match children.get(inline.child) {
388            Some(child) => match &child.text {
389                Some(t) => inline.offset <= t.chars().count(),
390                None => inline.offset == 0,
391            },
392            None => inline.offset == 0, // child == len (end boundary)
393        };
394        if !offset_ok {
395            return Err(PosError::OffsetOutOfRange {
396                path: block_path.to_vec(),
397                offset: inline.offset,
398            });
399        }
400        for child in &children[..inline.child] {
401            acc += child.node_size_with(&policy);
402        }
403        Ok(acc + inline.offset)
404    }
405}