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_deref(),
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_deref(),
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}