use crate::dynamic::content_expr::{self, ContentExpr};
use crate::dynamic::types::{
DynTypeStore, DynamicMark, DynamicMarkType, DynamicMarkTypeData, DynamicNode, DynamicNodeType,
DynamicNodeTypeData, DYN_TYPES,
};
use crate::model::{Fragment, MarkSet, Node, NodeType};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug)]
pub enum DynamicSchemaError {
ContentExpr(content_expr::ContentExprError),
UnknownNodeType(String),
InvalidSpec(String),
}
impl std::fmt::Display for DynamicSchemaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ContentExpr(e) => write!(f, "Content expression error: {}", e),
Self::UnknownNodeType(name) => write!(f, "Unknown node type: {}", name),
Self::InvalidSpec(msg) => write!(f, "Invalid spec: {}", msg),
}
}
}
impl std::error::Error for DynamicSchemaError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaSpec {
pub nodes: IndexMap<String, NodeSpec>,
#[serde(default)]
pub marks: IndexMap<String, MarkSpec>,
#[serde(default = "default_top_node", alias = "topNode")]
pub top_node: String,
}
fn default_top_node() -> String {
"doc".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeSpec {
#[serde(default)]
pub content: String,
#[serde(default)]
pub group: String,
#[serde(default)]
pub marks: Option<String>,
#[serde(default)]
pub attrs: Option<HashMap<String, AttributeSpec>>,
#[serde(default)]
pub inline: bool,
#[serde(default)]
pub atom: bool,
#[serde(default)]
pub defining: bool,
#[serde(default)]
pub defining_as_context: bool,
#[serde(default)]
pub defining_for_content: bool,
#[serde(default)]
pub isolating: bool,
#[serde(default)]
pub code: bool,
#[serde(default)]
pub draggable: bool,
#[serde(default = "default_true")]
pub selectable: bool,
#[serde(default)]
pub whitespace: Option<String>,
}
fn default_true() -> bool {
true
}
fn split_space_separated_names(value: &str) -> Vec<String> {
value
.split_whitespace()
.map(|name| name.to_string())
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AttributeSpec {
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(default)]
pub validate: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarkSpec {
#[serde(default)]
pub attrs: Option<HashMap<String, AttributeSpec>>,
#[serde(default = "default_true")]
pub inclusive: bool,
#[serde(default)]
pub excludes: Option<String>,
#[serde(default)]
pub group: String,
#[serde(default = "default_true")]
pub spanning: bool,
}
pub struct DynamicSchema {
pub node_types: Vec<DynamicNodeTypeData>,
pub node_type_map: HashMap<String, usize>,
pub mark_types: Vec<DynamicMarkTypeData>,
pub mark_type_map: HashMap<String, usize>,
pub node_groups: HashMap<String, Vec<usize>>,
pub top_node: String,
#[allow(dead_code)]
content_exprs: Vec<ContentExpr>,
store: Box<DynTypeStore>,
}
impl Default for DynamicSchema {
fn default() -> Self {
Self::from_json(&serde_json::json!({
"nodes": {
"doc": { "content": "text*" },
"text": {}
}
}))
.expect("Default schema should be valid")
}
}
impl DynamicSchema {
pub fn from_json(json: &serde_json::Value) -> Result<Self, DynamicSchemaError> {
let spec: SchemaSpec = serde_json::from_value(json.clone())
.map_err(|e| DynamicSchemaError::InvalidSpec(e.to_string()))?;
Self::from_spec(spec)
}
pub fn from_spec(spec: SchemaSpec) -> Result<Self, DynamicSchemaError> {
for name in spec.nodes.keys() {
if spec.marks.contains_key(name) {
return Err(DynamicSchemaError::InvalidSpec(format!(
"schema item name `{}` is used for both a node and a mark",
name
)));
}
}
if !spec.nodes.contains_key(&spec.top_node) {
return Err(DynamicSchemaError::UnknownNodeType(spec.top_node.clone()));
}
let mut node_types_data = Vec::new();
let mut node_type_map = HashMap::new();
let mut node_groups: HashMap<String, Vec<usize>> = HashMap::new();
let mut content_exprs = Vec::new();
let mut groups: HashMap<String, Vec<String>> = HashMap::new();
for (name, node_spec) in &spec.nodes {
if !node_spec.group.is_empty() {
for g in node_spec.group.split(' ') {
groups.entry(g.to_string()).or_default().push(name.clone());
}
}
}
let node_type_names: std::collections::HashSet<String> =
spec.nodes.keys().cloned().collect();
for (name, node_spec) in &spec.nodes {
let idx = node_types_data.len();
let content_expr = if node_spec.content.is_empty() {
ContentExpr::empty()
} else {
content_expr::parse_content_expr(&node_spec.content, &groups, &node_type_names)
.map_err(DynamicSchemaError::ContentExpr)?
};
content_exprs.push(content_expr);
let content_expr_idx = content_exprs.len() - 1;
let has_inline_content = !node_spec.content.is_empty()
&& (node_spec.content.contains("text") || node_spec.content.contains("inline"));
let is_textblock =
has_inline_content && (node_spec.inline || (name != "doc" && name != "blockquote"));
let allowed_marks = match node_spec.marks.as_deref() {
Some("_") => None,
Some("") => Some(Vec::new()),
Some(marks) => Some(split_space_separated_names(marks)),
None => {
if has_inline_content {
None
} else {
Some(Vec::new())
}
}
};
let attrs = node_spec
.attrs
.as_ref()
.map(|a| {
a.iter()
.map(|(k, v)| {
(
k.clone(),
v.default.clone().unwrap_or(serde_json::Value::Null),
)
})
.collect()
})
.unwrap_or_default();
let attr_validators: HashMap<String, String> = node_spec
.attrs
.as_ref()
.map(|a| {
a.iter()
.filter_map(|(k, v)| {
v.validate.as_ref().map(|val| (k.clone(), val.clone()))
})
.collect()
})
.unwrap_or_default();
let groups_list: Vec<String> = node_spec
.group
.split(' ')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
for g in &groups_list {
node_groups.entry(g.clone()).or_default().push(idx);
}
node_type_map.insert(name.clone(), idx);
node_types_data.push(DynamicNodeTypeData {
name: name.clone(),
inline: node_spec.inline || name == "text",
atom: node_spec.atom,
isolating: node_spec.isolating,
defining: node_spec.defining,
defining_as_context: node_spec.defining_as_context,
defining_for_content: node_spec.defining_for_content,
has_required_attrs: node_spec
.attrs
.as_ref()
.is_some_and(|a| a.values().any(|v| v.default.is_none())),
textblock: is_textblock,
has_inline_content,
content_expr_idx,
groups: groups_list,
attrs,
attr_validators,
allowed_marks,
whitespace: node_spec.whitespace.clone(),
});
}
let mut mark_types_data = Vec::new();
let mut mark_type_map = HashMap::new();
let mut mark_groups: HashMap<String, Vec<usize>> = HashMap::new();
for (name, mark_spec) in &spec.marks {
let idx = mark_types_data.len();
let attrs = mark_spec
.attrs
.as_ref()
.map(|a| {
a.iter()
.map(|(k, v)| {
(
k.clone(),
v.default.clone().unwrap_or(serde_json::Value::Null),
)
})
.collect()
})
.unwrap_or_default();
let attr_validators: HashMap<String, String> = mark_spec
.attrs
.as_ref()
.map(|a| {
a.iter()
.filter_map(|(k, v)| {
v.validate.as_ref().map(|val| (k.clone(), val.clone()))
})
.collect()
})
.unwrap_or_default();
let groups_list: Vec<String> = mark_spec
.group
.split(' ')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
for group in &groups_list {
mark_groups.entry(group.clone()).or_default().push(idx);
}
mark_type_map.insert(name.clone(), idx);
mark_types_data.push(DynamicMarkTypeData {
name: name.clone(),
attrs,
attr_validators,
inclusive: mark_spec.inclusive,
excludes: Vec::new(),
excluded: Vec::new(),
groups: groups_list,
});
}
let num_mark_types = mark_types_data.len();
let all_mark_names: Vec<String> = mark_types_data.iter().map(|d| d.name.clone()).collect();
for data in mark_types_data.iter_mut() {
let raw_excludes = match spec
.marks
.get(&data.name)
.and_then(|s| s.excludes.as_deref())
{
Some("") => Vec::new(),
Some("_") => {
data.excludes = all_mark_names.clone();
data.excluded = (0..num_mark_types).collect();
continue;
}
Some(excludes) => split_space_separated_names(excludes),
None => vec![data.name.clone()],
};
data.excludes = raw_excludes.clone();
let mut seen = std::collections::HashSet::new();
for name in &raw_excludes {
if let Some(&target_idx) = mark_type_map.get(name) {
if seen.insert(target_idx) {
data.excluded.push(target_idx);
}
} else if let Some(group_marks) = mark_groups.get(name) {
for &target_idx in group_marks {
if seen.insert(target_idx) {
data.excluded.push(target_idx);
}
}
}
}
}
let store = Box::new(DynTypeStore {
node_types: node_types_data.clone(),
mark_types: mark_types_data.clone(),
content_exprs: content_exprs.clone(),
});
Ok(DynamicSchema {
node_types: node_types_data,
node_type_map,
mark_types: mark_types_data,
mark_type_map,
node_groups,
top_node: spec.top_node,
content_exprs,
store,
})
}
pub fn with_types<R>(&self, f: impl FnOnce() -> R) -> R {
let store_ref: &DynTypeStore = &self.store;
let store_static: &'static DynTypeStore = unsafe { std::mem::transmute(store_ref) };
let already_set = DYN_TYPES.with(|cell| {
let already = cell.borrow().is_some();
if !already {
cell.borrow_mut().replace(store_static);
}
already
});
let result = f();
if !already_set {
DYN_TYPES.with(|cell| {
cell.borrow_mut().take();
});
}
result
}
pub fn node_type(&self, name: &str) -> Option<DynamicNodeType> {
self.node_type_map
.get(name)
.map(|&idx| DynamicNodeType { idx })
}
pub fn mark_type(&self, name: &str) -> Option<DynamicMarkType> {
self.mark_type_map
.get(name)
.map(|&idx| DynamicMarkType { idx })
}
pub fn node_from_json(
&self,
json: &serde_json::Value,
) -> Result<DynamicNode, DynamicSchemaError> {
self.with_types(|| {
serde_json::from_value::<DynamicNode>(json.clone())
.map_err(|e| DynamicSchemaError::InvalidSpec(e.to_string()))
})
}
pub fn mark_from_json(
&self,
json: &serde_json::Value,
) -> Result<DynamicMark, DynamicSchemaError> {
self.with_types(|| {
serde_json::from_value::<DynamicMark>(json.clone())
.map_err(|e| DynamicSchemaError::InvalidSpec(e.to_string()))
})
}
pub fn text(&self, text: &str) -> DynamicNode {
self.with_types(|| DynamicNode::text(text))
}
pub fn node(
&self,
type_name: &str,
attrs: serde_json::Value,
content: Fragment<crate::dynamic::types::Dyn>,
marks: MarkSet<crate::dynamic::types::Dyn>,
) -> Result<DynamicNode, DynamicSchemaError> {
let idx = self
.node_type_map
.get(type_name)
.copied()
.ok_or_else(|| DynamicSchemaError::UnknownNodeType(type_name.to_string()))?;
self.with_types(|| {
let nt = DynamicNodeType { idx };
Ok(nt.create(attrs, Some(&content), Some(&marks)))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{ContentMatch, Mark, NodeType};
fn basic_spec_json() -> serde_json::Value {
serde_json::json!({
"nodes": {
"doc": { "content": "block+" },
"paragraph": { "content": "inline*", "group": "block" },
"heading": {
"attrs": { "level": { "default": 1 } },
"content": "inline*",
"group": "block",
"defining": true
},
"text": { "group": "inline" },
"image": {
"inline": true,
"attrs": { "src": {}, "alt": { "default": null }, "title": { "default": null } },
"group": "inline",
"atom": true
},
"horizontal_rule": { "group": "block" },
"hard_break": { "inline": true, "group": "inline" }
},
"marks": {
"strong": {},
"em": {},
"link": { "attrs": { "href": {}, "title": { "default": null } }, "inclusive": false }
}
})
}
#[test]
fn test_schema_from_json() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
assert!(schema.node_type("doc").is_some());
assert!(schema.node_type("paragraph").is_some());
assert!(schema.node_type("heading").is_some());
assert!(schema.node_type("text").is_some());
assert!(schema.node_type("nonexistent").is_none());
assert_eq!(schema.node_types.len(), 7);
assert_eq!(schema.mark_types.len(), 3);
let heading = &schema.node_types[schema.node_type_map["heading"]];
assert!(heading.attrs.contains_key("level"));
assert_eq!(heading.attrs["level"], serde_json::json!(1));
}
fn schema_error(json: serde_json::Value) -> DynamicSchemaError {
match DynamicSchema::from_json(&json) {
Ok(_) => panic!("schema should fail"),
Err(err) => err,
}
}
#[test]
fn test_schema_accepts_camel_case_top_node() {
let schema = DynamicSchema::from_json(&serde_json::json!({
"topNode": "root",
"nodes": {
"root": { "content": "paragraph+" },
"paragraph": { "content": "text*", "group": "block" },
"text": { "group": "inline" }
},
"marks": {}
}))
.unwrap();
assert_eq!(schema.top_node, "root");
assert!(schema.node_type("root").is_some());
}
#[test]
fn test_schema_rejects_missing_top_node() {
let err = schema_error(serde_json::json!({
"topNode": "root",
"nodes": {
"doc": { "content": "paragraph+" },
"paragraph": { "content": "text*", "group": "block" },
"text": { "group": "inline" }
},
"marks": {}
}));
assert!(matches!(
err,
DynamicSchemaError::UnknownNodeType(name) if name == "root"
));
}
#[test]
fn test_schema_rejects_node_mark_name_collisions() {
let err = schema_error(serde_json::json!({
"nodes": {
"doc": { "content": "text*" },
"text": { "group": "inline" }
},
"marks": {
"text": {}
}
}));
assert!(matches!(
err,
DynamicSchemaError::InvalidSpec(message)
if message.contains("both a node and a mark") && message.contains("text")
));
}
#[test]
fn test_mark_spec_defaults_and_marks_sentinels() {
let schema = DynamicSchema::from_json(&serde_json::json!({
"nodes": {
"doc": { "content": "block+" },
"paragraph": { "content": "text*", "group": "block" },
"code_block": { "content": "text*", "marks": "", "group": "block" },
"all_marks_block": { "content": "text*", "marks": "_", "group": "block" },
"text": { "group": "inline" }
},
"marks": {
"strong": {},
"em": { "excludes": "" },
"link": { "excludes": "strong em" },
"comment": { "excludes": "_" }
}
}))
.unwrap();
let strong_data = &schema.mark_types[schema.mark_type_map["strong"]];
let em_data = &schema.mark_types[schema.mark_type_map["em"]];
let link_data = &schema.mark_types[schema.mark_type_map["link"]];
let comment_data = &schema.mark_types[schema.mark_type_map["comment"]];
assert_eq!(strong_data.excludes, vec!["strong".to_string()]);
assert!(em_data.excludes.is_empty());
assert_eq!(
link_data.excludes,
vec!["strong".to_string(), "em".to_string()]
);
assert_eq!(
comment_data.excludes,
vec![
"strong".to_string(),
"em".to_string(),
"link".to_string(),
"comment".to_string()
]
);
schema.with_types(|| {
let strong = schema.mark_type("strong").unwrap();
let paragraph = schema.node_type("paragraph").unwrap();
let code_block = schema.node_type("code_block").unwrap();
let all_marks_block = schema.node_type("all_marks_block").unwrap();
assert!(paragraph.allows_mark_type(strong));
assert!(!code_block.allows_mark_type(strong));
assert!(all_marks_block.allows_mark_type(strong));
});
}
#[test]
fn test_node_from_json() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
let doc_json = serde_json::json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{ "type": "text", "text": "Hello world" }]
}]
});
let doc = schema.node_from_json(&doc_json).unwrap();
assert_eq!(doc.r#type().idx, schema.node_type_map["doc"]);
assert_eq!(doc.child_count(), 1);
let para = doc.child(0).unwrap();
assert_eq!(para.r#type().idx, schema.node_type_map["paragraph"]);
assert_eq!(para.child(0).unwrap().text_content(), "Hello world");
}
#[test]
fn test_mark_from_json_uses_schema_scope() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
let mark = schema
.mark_from_json(&serde_json::json!({
"type": "link",
"attrs": { "href": "https://example.com" }
}))
.unwrap();
assert_eq!(mark.type_name, "link");
assert_eq!(
mark.attrs.get("href").and_then(|value| value.as_str()),
Some("https://example.com")
);
schema.with_types(|| {
assert_eq!(mark.r#type().idx, schema.mark_type_map["link"]);
});
}
#[test]
fn test_mark_from_json_rejects_unknown_mark_types() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
let err = schema
.mark_from_json(&serde_json::json!({ "type": "missing" }))
.unwrap_err();
assert!(matches!(
err,
DynamicSchemaError::InvalidSpec(message)
if message.contains("Unknown mark type") && message.contains("missing")
));
}
#[test]
fn test_node_from_json_rejects_unknown_nested_mark_types() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
let err = schema
.node_from_json(&serde_json::json!({
"type": "paragraph",
"content": [{
"type": "text",
"text": "Hello",
"marks": [{ "type": "missing" }]
}]
}))
.unwrap_err();
assert!(matches!(
err,
DynamicSchemaError::InvalidSpec(message)
if message.contains("Unknown mark type") && message.contains("missing")
));
}
#[test]
fn test_content_matching() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
schema.with_types(|| {
let doc_type = schema.node_type("doc").unwrap();
let cm = doc_type.content_match();
let para_type = schema.node_type("paragraph").unwrap();
assert!(cm.match_type(para_type).is_some());
assert!(!cm.valid_end());
});
}
#[test]
fn test_dynamic_node_type_name_and_atom_flags() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
schema.with_types(|| {
let paragraph = schema.node_type("paragraph").unwrap();
let horizontal_rule = schema.node_type("horizontal_rule").unwrap();
let image = schema.node_type("image").unwrap();
assert_eq!(paragraph.name(), "paragraph");
assert_eq!(horizontal_rule.name(), "horizontal_rule");
assert!(!paragraph.is_atom());
assert!(horizontal_rule.is_atom());
assert!(image.is_atom());
});
}
#[test]
fn test_dynamic_text_nodes_are_inline_leaves_without_schema_scope() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
let text = schema.text("hello");
assert!(text.is_text());
assert!(text.is_leaf());
assert!(text.is_inline());
assert!(!text.is_block());
}
#[test]
fn test_round_trip() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
let doc_json = serde_json::json!({
"type": "doc",
"content": [
{ "type": "heading", "attrs": { "level": 2 }, "content": [
{ "type": "text", "text": "Title" }
]},
{ "type": "paragraph", "content": [
{ "type": "text", "text": "Hello ", "marks": [{"type": "em"}] },
{ "type": "text", "text": "world", "marks": [{"type": "strong"}] }
]}
]
});
let doc = schema.with_types(|| schema.node_from_json(&doc_json).unwrap());
assert_eq!(doc.child_count(), 2);
assert_eq!(doc.child(0).unwrap().attrs["level"], 2);
assert_eq!(
doc.child(0).unwrap().child(0).unwrap().text_content(),
"Title"
);
let serialized = serde_json::to_value(&doc).unwrap();
let doc2 = schema.with_types(|| schema.node_from_json(&serialized).unwrap());
assert_eq!(doc, doc2);
}
#[test]
fn test_node_size() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
schema.with_types(|| {
let doc = schema.node_from_json(&serde_json::json!({
"type": "doc",
"content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "hi" }] }]
})).unwrap();
assert_eq!(doc.node_size(), 6);
assert_eq!(doc.content_size(), 4);
});
}
#[test]
fn test_text_between() {
let schema = DynamicSchema::from_json(&basic_spec_json()).unwrap();
schema.with_types(|| {
let doc = schema
.node_from_json(&serde_json::json!({
"type": "doc",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "hello" }] },
{ "type": "paragraph", "content": [{ "type": "text", "text": "world" }] }
]
}))
.unwrap();
assert_eq!(doc.text_content(), "helloworld");
assert_eq!(doc.child(0).unwrap().text_content(), "hello");
assert_eq!(doc.child(1).unwrap().text_content(), "world");
});
}
}
#[cfg(test)]
mod test_validate {
use super::*;
#[test]
fn test_attr_validate_deser() {
let json = r#"{"nodes":{"doc":{"content":"image"},"image":{"inline":true,"attrs":{"src":{"validate":"string"},"alt":{"default":null}},"group":"inline"}},"marks":{}}"#;
let spec: SchemaSpec = serde_json::from_str(json).unwrap();
let img = spec.nodes.get("image").unwrap();
let src = img.attrs.as_ref().unwrap().get("src").unwrap();
assert_eq!(src.validate, Some("string".to_string()));
}
}
#[cfg(test)]
mod test_check_attrs {
use super::*;
#[test]
fn test_check_attrs_boolean_for_string() {
let json = r#"{"nodes":{"doc":{"content":"image"},"image":{"inline":true,"attrs":{"src":{"validate":"string"},"alt":{"default":null}},"group":"inline"}},"marks":{}}"#;
let schema = DynamicSchema::from_json(&serde_json::from_str(json).unwrap()).unwrap();
let img_type = schema.node_type_map.get("image").copied().unwrap();
let node_type = DynamicNodeType { idx: img_type };
let result = schema.with_types(|| node_type.check_attrs(&serde_json::json!({"src": true})));
assert!(result.is_err(), "expected error but got {:?}", result);
let err = result.unwrap_err();
assert!(
err.contains("Expected value of type"),
"unexpected error: {}",
err
);
}
}
#[cfg(test)]
mod test_node_check_validate {
use super::*;
use crate::dynamic::types::DynamicNodeType;
use crate::model::Node;
#[test]
fn test_node_check_with_validate() {
let json = r#"{"nodes":{"doc":{"content":"image"},"image":{"inline":true,"attrs":{"src":{"validate":"string"},"alt":{"default":null},"title":{"default":null}},"group":"inline"}},"marks":{}}"#;
let schema = DynamicSchema::from_json(&serde_json::from_str(json).unwrap()).unwrap();
let img_type = schema.node_type_map.get("image").copied().unwrap();
let node_type = DynamicNodeType { idx: img_type };
let node =
schema.with_types(|| node_type.create(serde_json::json!({"src": true}), None, None));
let result = schema.with_types(|| node.check());
println!("check result: {:?}", result);
assert!(result.is_err());
}
}