Skip to main content

tiptap_rusty_parser/
schema.rs

1//! Opt-in schema validation.
2//!
3//! The crate is schema-agnostic by default; nothing here runs unless you call
4//! [`Node::validate`]. A [`Schema`] is an allow-list of node types, marks,
5//! attributes, and child types. Validation collects *all* problems as
6//! [`Violation`]s (each carrying the offending node's index path), so a single
7//! pass reports everything wrong.
8//!
9//! A schema can be built in Rust or loaded from JSON:
10//!
11//! ```
12//! use tiptap_rusty_parser::{Document, Schema, NodeSpec, MarkSpec};
13//!
14//! let schema = Schema::new()
15//!     .node("doc", NodeSpec::new().content(["paragraph"]))
16//!     .node("paragraph", NodeSpec::new().content(["text"]).marks(["bold"]))
17//!     .node("text", NodeSpec::new())
18//!     .mark("bold", MarkSpec::new());
19//!
20//! let doc = Document::from_json_str(
21//!     r#"{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hi"}]}]}"#,
22//! ).unwrap();
23//! assert!(doc.is_valid(&schema));
24//! ```
25
26use crate::content::{ContentExpr, ContentRule, ParseExprError};
27use crate::node::Node;
28use serde::{Deserialize, Serialize};
29use std::collections::{HashMap, HashSet};
30use std::fmt;
31
32/// An allow-list schema: which node/mark types, attributes, and children are
33/// permitted. Build with [`Schema::new`] or load with [`Schema::from_json_str`].
34#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
35pub struct Schema {
36    /// Node type -> its spec. Types absent here are reported as unknown.
37    #[serde(default)]
38    pub nodes: HashMap<String, NodeSpec>,
39    /// Mark type -> its spec. Marks absent here are reported as unknown.
40    #[serde(default)]
41    pub marks: HashMap<String, MarkSpec>,
42}
43
44/// Rules for one node type.
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46pub struct NodeSpec {
47    /// Allowed content: a set of child types (array form) or a content
48    /// expression (string form). `None` = any child allowed.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub content: Option<ContentRule>,
51    /// Groups this node belongs to (space-separated, ProseMirror-style), so
52    /// content expressions can reference it by group name (e.g. `block`).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub group: Option<String>,
55    /// Allowed mark types on this node. `None` = any mark allowed.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub marks: Option<HashSet<String>>,
58    /// Allowed attribute keys. `None` = any attrs allowed.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub attrs: Option<HashSet<String>>,
61    /// Attribute keys that must be present.
62    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
63    pub required_attrs: HashSet<String>,
64}
65
66/// Rules for one mark type.
67#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
68pub struct MarkSpec {
69    /// Allowed attribute keys. `None` = any attrs allowed.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub attrs: Option<HashSet<String>>,
72    /// Attribute keys that must be present.
73    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
74    pub required_attrs: HashSet<String>,
75}
76
77fn into_set<I, S>(items: I) -> HashSet<String>
78where
79    I: IntoIterator<Item = S>,
80    S: Into<String>,
81{
82    items.into_iter().map(Into::into).collect()
83}
84
85impl Schema {
86    /// An empty schema.
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Register (or replace) a node type's spec.
92    pub fn node(mut self, node_type: impl Into<String>, spec: NodeSpec) -> Self {
93        self.nodes.insert(node_type.into(), spec);
94        self
95    }
96
97    /// Register (or replace) a mark type's spec.
98    pub fn mark(mut self, mark_type: impl Into<String>, spec: MarkSpec) -> Self {
99        self.marks.insert(mark_type.into(), spec);
100        self
101    }
102
103    /// Load a schema from its JSON definition.
104    ///
105    /// ```
106    /// use tiptap_rusty_parser::Schema;
107    /// let schema = Schema::from_json_str(r#"{
108    ///   "nodes": { "doc": { "content": ["paragraph"] }, "paragraph": { "content": ["text"] }, "text": {} },
109    ///   "marks": { "link": { "attrs": ["href"], "required_attrs": ["href"] } }
110    /// }"#).unwrap();
111    /// assert!(schema.nodes.contains_key("doc"));
112    /// ```
113    pub fn from_json_str(s: &str) -> crate::Result<Self> {
114        Ok(serde_json::from_str(s)?)
115    }
116}
117
118impl NodeSpec {
119    /// An unrestricted node spec (any attrs/marks/children allowed).
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Restrict allowed child node types (any count/order). For ordering and
125    /// cardinality use [`content_match`](Self::content_match).
126    pub fn content<I, S>(mut self, types: I) -> Self
127    where
128        I: IntoIterator<Item = S>,
129        S: Into<String>,
130    {
131        self.content = Some(ContentRule::Types(into_set(types)));
132        self
133    }
134
135    /// Restrict content with a ProseMirror content expression (e.g.
136    /// `"heading paragraph+"`). Panics on an invalid expression — use
137    /// [`try_content_match`](Self::try_content_match) to handle the error.
138    pub fn content_match(self, expr: &str) -> Self {
139        self.try_content_match(expr)
140            .expect("invalid content expression")
141    }
142
143    /// Fallible [`content_match`](Self::content_match).
144    pub fn try_content_match(mut self, expr: &str) -> Result<Self, ParseExprError> {
145        self.content = Some(ContentRule::Expr(ContentExpr::parse(expr)?));
146        Ok(self)
147    }
148
149    /// Set the groups this node belongs to (space-separated, e.g. `"block"`).
150    pub fn group(mut self, group: impl Into<String>) -> Self {
151        self.group = Some(group.into());
152        self
153    }
154
155    /// The allowed child-type set, if `content` is the array form (not an
156    /// expression). Convenience accessor for the `content` field.
157    pub fn content_types(&self) -> Option<&HashSet<String>> {
158        match &self.content {
159            Some(ContentRule::Types(set)) => Some(set),
160            _ => None,
161        }
162    }
163
164    /// Restrict allowed mark types on this node.
165    pub fn marks<I, S>(mut self, types: I) -> Self
166    where
167        I: IntoIterator<Item = S>,
168        S: Into<String>,
169    {
170        self.marks = Some(into_set(types));
171        self
172    }
173
174    /// Restrict allowed attribute keys.
175    pub fn attrs<I, S>(mut self, keys: I) -> Self
176    where
177        I: IntoIterator<Item = S>,
178        S: Into<String>,
179    {
180        self.attrs = Some(into_set(keys));
181        self
182    }
183
184    /// Set attribute keys that must be present.
185    pub fn required_attrs<I, S>(mut self, keys: I) -> Self
186    where
187        I: IntoIterator<Item = S>,
188        S: Into<String>,
189    {
190        self.required_attrs = into_set(keys);
191        self
192    }
193}
194
195impl MarkSpec {
196    /// An unrestricted mark spec (any attrs allowed).
197    pub fn new() -> Self {
198        Self::default()
199    }
200
201    /// Restrict allowed attribute keys.
202    pub fn attrs<I, S>(mut self, keys: I) -> Self
203    where
204        I: IntoIterator<Item = S>,
205        S: Into<String>,
206    {
207        self.attrs = Some(into_set(keys));
208        self
209    }
210
211    /// Set attribute keys that must be present.
212    pub fn required_attrs<I, S>(mut self, keys: I) -> Self
213    where
214        I: IntoIterator<Item = S>,
215        S: Into<String>,
216    {
217        self.required_attrs = into_set(keys);
218        self
219    }
220}
221
222/// A single schema violation, located by the offending node's index path.
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224pub struct Violation {
225    /// Index path to the node (root = `[]`), as used by [`Node::node_at`].
226    pub path: Vec<usize>,
227    /// What's wrong.
228    pub kind: ViolationKind,
229    /// Index (in the node's `content`) of the offending child: the disallowed
230    /// child for [`ViolationKind::DisallowedChild`], or the first child that
231    /// cannot be matched for [`ViolationKind::InvalidContent`]. `None` for
232    /// other kinds, and for `InvalidContent` when every child matched but the
233    /// expression needs more (e.g. `block+` with no children).
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub child: Option<usize>,
236    /// Index (in the node's `marks`) of the mark the violation is about, for
237    /// [`ViolationKind::UnknownMark`], [`ViolationKind::DisallowedMark`] and
238    /// attribute violations raised on a mark. `None` when it is about the node.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub mark: Option<usize>,
241}
242
243impl Violation {
244    fn at(path: &[usize], kind: ViolationKind) -> Self {
245        Self {
246            path: path.to_vec(),
247            kind,
248            child: None,
249            mark: None,
250        }
251    }
252}
253
254/// The kinds of schema violation.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub enum ViolationKind {
257    /// A node had no `type`.
258    MissingNodeType,
259    /// A node's type is not in the schema.
260    UnknownNodeType(String),
261    /// A child type is not allowed under its parent.
262    DisallowedChild { parent: String, child: String },
263    /// A node's children don't satisfy its content expression.
264    InvalidContent { parent: String, expr: String },
265    /// A mark type is not in the schema.
266    UnknownMark(String),
267    /// A mark is registered but not allowed on this node type.
268    DisallowedMark { node: String, mark: String },
269    /// A required attribute is missing (on a node, or a mark of the node).
270    MissingAttr { key: String },
271    /// An attribute key is not in the allowed set.
272    UnknownAttr { key: String },
273}
274
275impl fmt::Display for ViolationKind {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        match self {
278            ViolationKind::MissingNodeType => write!(f, "node has no type"),
279            ViolationKind::UnknownNodeType(t) => write!(f, "unknown node type `{t}`"),
280            ViolationKind::DisallowedChild { parent, child } => {
281                write!(f, "node type `{child}` not allowed inside `{parent}`")
282            }
283            ViolationKind::InvalidContent { parent, expr } => {
284                write!(
285                    f,
286                    "children of `{parent}` do not match content expression `{expr}`"
287                )
288            }
289            ViolationKind::UnknownMark(m) => write!(f, "unknown mark type `{m}`"),
290            ViolationKind::DisallowedMark { node, mark } => {
291                write!(f, "mark `{mark}` not allowed on `{node}`")
292            }
293            ViolationKind::MissingAttr { key } => write!(f, "missing required attribute `{key}`"),
294            ViolationKind::UnknownAttr { key } => write!(f, "unknown attribute `{key}`"),
295        }
296    }
297}
298
299impl fmt::Display for Violation {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        write!(f, "at {:?}: {}", self.path, self.kind)
302    }
303}
304
305impl Node {
306    /// Validate against `schema`, collecting every [`Violation`]. An empty
307    /// result means the document is valid.
308    ///
309    /// ```
310    /// use tiptap_rusty_parser::{Document, Schema, NodeSpec};
311    /// let schema = Schema::new()
312    ///     .node("doc", NodeSpec::new().content(["paragraph"]))
313    ///     .node("paragraph", NodeSpec::new())
314    ///     .node("heading", NodeSpec::new());
315    /// let doc = Document::from_json_str(
316    ///     r#"{"type":"doc","content":[{"type":"heading"}]}"#,
317    /// ).unwrap();
318    /// let v = doc.validate(&schema);
319    /// assert_eq!(v.len(), 1); // heading is a known type, but not allowed as a child of doc
320    /// ```
321    pub fn validate(&self, schema: &Schema) -> Vec<Violation> {
322        let mut out = Vec::new();
323        let mut path = Vec::new();
324        validate_node(self, schema, &mut path, &mut out);
325        out
326    }
327
328    /// True if the document has no schema violations.
329    ///
330    /// ```
331    /// use tiptap_rusty_parser::{Document, Schema, NodeSpec};
332    /// let schema = Schema::new().node("doc", NodeSpec::new());
333    /// let doc = Document::from_json_str(r#"{"type":"doc"}"#).unwrap();
334    /// assert!(doc.is_valid(&schema));
335    /// ```
336    pub fn is_valid(&self, schema: &Schema) -> bool {
337        self.validate(schema).is_empty()
338    }
339}
340
341fn validate_node(node: &Node, schema: &Schema, path: &mut Vec<usize>, out: &mut Vec<Violation>) {
342    let push = |out: &mut Vec<Violation>, path: &[usize], kind: ViolationKind| {
343        out.push(Violation::at(path, kind));
344    };
345
346    let spec = match &node.node_type {
347        None => {
348            push(out, path, ViolationKind::MissingNodeType);
349            None
350        }
351        Some(t) => match schema.nodes.get(t) {
352            Some(spec) => Some(spec),
353            None => {
354                push(out, path, ViolationKind::UnknownNodeType(t.clone()));
355                None
356            }
357        },
358    };
359
360    if let Some(spec) = spec {
361        // node attrs
362        check_attrs(
363            node.attrs.as_ref(),
364            spec.attrs.as_ref(),
365            &spec.required_attrs,
366            path,
367            None,
368            out,
369        );
370
371        // marks
372        if let Some(marks) = &node.marks {
373            let node_type = node.node_type.as_deref().unwrap_or_default();
374            for (mi, mark) in marks.iter().enumerate() {
375                match schema.marks.get(&mark.mark_type) {
376                    None => out.push(Violation {
377                        mark: Some(mi),
378                        ..Violation::at(path, ViolationKind::UnknownMark(mark.mark_type.clone()))
379                    }),
380                    Some(mark_spec) => {
381                        if let Some(allowed) = &spec.marks {
382                            if !allowed.contains(&mark.mark_type) {
383                                out.push(Violation {
384                                    mark: Some(mi),
385                                    ..Violation::at(
386                                        path,
387                                        ViolationKind::DisallowedMark {
388                                            node: node_type.to_string(),
389                                            mark: mark.mark_type.clone(),
390                                        },
391                                    )
392                                });
393                            }
394                        }
395                        check_attrs(
396                            mark.attrs.as_ref(),
397                            mark_spec.attrs.as_ref(),
398                            &mark_spec.required_attrs,
399                            path,
400                            Some(mi),
401                            out,
402                        );
403                    }
404                }
405            }
406        }
407
408        // content rules
409        if let Some(rule) = &spec.content {
410            let parent = node.node_type.as_deref().unwrap_or_default();
411            match rule {
412                // array form: per-child type membership. A child of an
413                // unknown type is reported once, as `UnknownNodeType`.
414                ContentRule::Types(allowed) => {
415                    if let Some(children) = &node.content {
416                        for (ci, child) in children.iter().enumerate() {
417                            if let Some(ct) = &child.node_type {
418                                if !allowed.contains(ct) && schema.nodes.contains_key(ct) {
419                                    out.push(Violation {
420                                        child: Some(ci),
421                                        ..Violation::at(
422                                            path,
423                                            ViolationKind::DisallowedChild {
424                                                parent: parent.to_string(),
425                                                child: ct.clone(),
426                                            },
427                                        )
428                                    });
429                                }
430                            }
431                        }
432                    }
433                }
434                // expression form: cardinality + ordering over the child sequence
435                ContentRule::Expr(expr) => {
436                    let children = node.content.as_deref().unwrap_or(&[]);
437                    if let Err(child) = expr.check(children, schema) {
438                        out.push(Violation {
439                            child,
440                            ..Violation::at(
441                                path,
442                                ViolationKind::InvalidContent {
443                                    parent: parent.to_string(),
444                                    expr: expr.as_str().to_string(),
445                                },
446                            )
447                        });
448                    }
449                }
450            }
451        }
452    }
453
454    // recurse
455    if let Some(children) = &node.content {
456        for (i, child) in children.iter().enumerate() {
457            path.push(i);
458            validate_node(child, schema, path, out);
459            path.pop();
460        }
461    }
462}
463
464fn check_attrs(
465    attrs: Option<&serde_json::Map<String, serde_json::Value>>,
466    allowed: Option<&HashSet<String>>,
467    required: &HashSet<String>,
468    path: &[usize],
469    mark: Option<usize>,
470    out: &mut Vec<Violation>,
471) {
472    for key in required {
473        let present = attrs.is_some_and(|m| m.contains_key(key));
474        if !present {
475            out.push(Violation {
476                mark,
477                ..Violation::at(path, ViolationKind::MissingAttr { key: key.clone() })
478            });
479        }
480    }
481    if let (Some(allowed), Some(attrs)) = (allowed, attrs) {
482        for key in attrs.keys() {
483            if !allowed.contains(key) {
484                out.push(Violation {
485                    mark,
486                    ..Violation::at(path, ViolationKind::UnknownAttr { key: key.clone() })
487                });
488            }
489        }
490    }
491}