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}