accent_proust/validate/source.rs
1//! Where a schema comes from: [`SchemaSource`], the [`SchemaKey`] it is asked
2//! with, and [`MapSchemaSource`], the two maps a host fills by hand.
3//!
4//! Private module; all three are re-exported from [`crate::validate`].
5
6use indexmap::IndexMap;
7
8use crate::ast::{Node, NodeType};
9use crate::validate::schema::Schema;
10
11/// What a schema lookup is keyed by: a tag by its name, a node by its type.
12///
13/// Upstream's `findSchema` branches on whether `node.tag` is set, and so does
14/// [`for_node`](SchemaKey::for_node). A tag node is never looked up by its
15/// type, even though `tag` is one: the tag's name is the key, and a source
16/// that defines no schema for that name has defined nothing for the node.
17///
18/// Exhaustive, unlike the other public enums in this crate. Those grow with
19/// Markdoc; this one has the two variants a node can be looked up by, and no
20/// third is foreseen. An implementation of [`SchemaSource`] matches both, so
21/// that if a third way ever did appear it would fail to compile in every
22/// implementation rather than silently answer `None` -- a schema that never
23/// applies is the failure hardest to see from outside.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum SchemaKey<'a> {
26 /// A tag, by the name written in `{% name %}`.
27 Tag(&'a str),
28 /// A built-in node, by type.
29 Node(NodeType),
30}
31
32impl<'a> SchemaKey<'a> {
33 /// The key a node is looked up by.
34 #[must_use]
35 pub fn for_node(node: &'a Node<'_>) -> SchemaKey<'a> {
36 match &node.tag {
37 Some(tag) => SchemaKey::Tag(tag.as_str()),
38 None => SchemaKey::Node(node.node_type),
39 }
40 }
41}
42
43/// Where a schema comes from.
44///
45/// The validator and the transformer ask one question of a configuration:
46/// what is the schema for this node? This trait is that question, and
47/// [`Config::schemas`](crate::validate::Config::schemas) holds whatever
48/// answers it. [`MapSchemaSource`] is the answer a host assembles by hand;
49/// a host with schemas somewhere else -- computed from a component registry,
50/// populated from a sandboxed guest -- implements the trait and hands the
51/// config that instead. The crate never learns which.
52///
53/// # Three properties, each deliberate
54///
55/// **It is object-safe.** `Config` holds an
56/// `Arc<dyn SchemaSource + Send + Sync>`, so no generic parameter reaches
57/// `Config` or anything holding one. The bound is the field's, and an
58/// implementation meets it by being thread-safe and owning what it borrows
59/// from: `Send + Sync + 'static`. A source over `Rc` state, or one borrowing
60/// a registry, compiles as an implementation and is refused at
61/// [`with_schemas`](crate::validate::Config::with_schemas) -- own the
62/// registry, or share it behind an `Arc`.
63///
64/// **It is synchronous**, for the reason `DIVERGENCES.md` entry 3 gives for
65/// schema hooks: the crate performs no I/O, so an async signature would have
66/// no reachable implementation and would colour every caller above it.
67///
68/// **It returns a borrow.** A source that loads a schema from disk on first
69/// request cannot implement this, because there is nothing to borrow from
70/// after the call returns. The alternative -- `Arc<Schema>` by value -- costs
71/// a refcount bump on every lookup in the hot path of both the validator and
72/// the transformer, to serve a case no host in this repository has. A host
73/// that wants laziness populates its source before handing it over.
74///
75/// # Implementing it
76///
77/// [`find`](SchemaSource::find) is the whole contract, and [`SchemaKey`] is
78/// exhaustive, so an implementation matches its two variants and the compiler
79/// holds it to both. The two provided methods serve diagnostics only.
80/// Override them if the source can enumerate what it holds; leave them if it
81/// cannot, and `Config`'s `Debug` output says so rather than claiming an
82/// empty registry.
83///
84/// # Examples
85///
86/// A source that answers from a match arm, with no map at all:
87///
88/// ```
89/// use std::sync::Arc;
90///
91/// use accent_proust::builtins;
92/// use accent_proust::validate::{Schema, SchemaKey, SchemaSource};
93///
94/// struct OneTag(Schema);
95///
96/// impl SchemaSource for OneTag {
97/// fn find(&self, key: SchemaKey<'_>) -> Option<&Schema> {
98/// match key {
99/// SchemaKey::Tag("callout") => Some(&self.0),
100/// SchemaKey::Tag(_) | SchemaKey::Node(_) => None,
101/// }
102/// }
103/// }
104///
105/// let config = builtins::config_with(Arc::new(OneTag(Schema::new().render("aside"))));
106/// assert!(config.schemas.find(SchemaKey::Tag("callout")).is_some());
107/// assert!(config.schemas.find(SchemaKey::Tag("if")).is_none());
108/// ```
109pub trait SchemaSource {
110 /// The schema for `key`, or `None` when this source does not define one.
111 ///
112 /// `None` is not an error. The validator reports `tag-undefined` or
113 /// `node-undefined` for it, which is the answer a host wants -- upstream's
114 /// own `validate` does the same for a name its config lacks.
115 fn find(&self, key: SchemaKey<'_>) -> Option<&Schema>;
116
117 /// The tag names this source defines, when it can say.
118 ///
119 /// Diagnostics only. `None` means "cannot enumerate", which is the honest
120 /// answer for a source that computes schemas on demand, and is why this
121 /// is an option rather than an empty list.
122 fn tag_names(&self) -> Option<Vec<&str>> {
123 None
124 }
125
126 /// The node types this source defines, when it can say.
127 ///
128 /// Diagnostics only, as [`tag_names`](SchemaSource::tag_names) is.
129 fn node_types(&self) -> Option<Vec<NodeType>> {
130 None
131 }
132}
133
134/// The schemas a host registered, as two maps.
135///
136/// What `Config` used to hold directly, behind the trait instead: schemas for
137/// built-in node types keyed by type, and schemas for tags keyed by name.
138/// [`builtin`](MapSchemaSource::builtin) starts both at Markdoc's own, which
139/// is where a host that adds a tag or replaces a node schema starts from;
140/// [`new`](MapSchemaSource::new) starts both empty, for a host that supplies
141/// every schema itself.
142///
143/// The maps are reachable directly, because they are the whole content and
144/// hiding them would only add a method per operation. Fill one, then hand it
145/// to [`builtins::config_with`](crate::builtins::config_with), which builds
146/// the built-in schemas exactly once:
147///
148/// ```
149/// use std::sync::Arc;
150///
151/// use accent_proust::builtins;
152/// use accent_proust::validate::{MapSchemaSource, Schema, SchemaKey};
153///
154/// let mut schemas = MapSchemaSource::builtin();
155/// schemas.insert_tag("callout", Schema::new().render("aside"));
156///
157/// let config = builtins::config_with(Arc::new(schemas));
158/// assert!(config.schemas.find(SchemaKey::Tag("callout")).is_some());
159/// assert!(config.schemas.find(SchemaKey::Tag("if")).is_some());
160/// ```
161#[derive(Clone, Default)]
162pub struct MapSchemaSource {
163 nodes: IndexMap<NodeType, Schema>,
164 tags: IndexMap<String, Schema>,
165}
166
167impl MapSchemaSource {
168 /// Nothing registered. Every lookup misses, which is what
169 /// [`Config::new`](crate::validate::Config::new) has always meant.
170 #[must_use]
171 pub fn new() -> MapSchemaSource {
172 MapSchemaSource::default()
173 }
174
175 /// Markdoc's own nodes and tags: what
176 /// [`builtins::config`](crate::builtins::config) starts from.
177 #[must_use]
178 pub fn builtin() -> MapSchemaSource {
179 MapSchemaSource {
180 nodes: crate::validate::nodes::builtin(),
181 tags: crate::tags::builtin(),
182 }
183 }
184
185 /// Register a tag schema, replacing any under the same name.
186 pub fn insert_tag(&mut self, name: impl Into<String>, schema: Schema) -> &mut MapSchemaSource {
187 self.tags.insert(name.into(), schema);
188 self
189 }
190
191 /// Register a node schema, replacing any for the same type.
192 pub fn insert_node(&mut self, node_type: NodeType, schema: Schema) -> &mut MapSchemaSource {
193 self.nodes.insert(node_type, schema);
194 self
195 }
196
197 /// The tag schemas, by name.
198 #[must_use]
199 pub fn tags(&self) -> &IndexMap<String, Schema> {
200 &self.tags
201 }
202
203 /// The tag schemas, for in-place edit.
204 pub fn tags_mut(&mut self) -> &mut IndexMap<String, Schema> {
205 &mut self.tags
206 }
207
208 /// The node schemas, by type.
209 #[must_use]
210 pub fn nodes(&self) -> &IndexMap<NodeType, Schema> {
211 &self.nodes
212 }
213
214 /// The node schemas, for in-place edit.
215 pub fn nodes_mut(&mut self) -> &mut IndexMap<NodeType, Schema> {
216 &mut self.nodes
217 }
218}
219
220impl SchemaSource for MapSchemaSource {
221 fn find(&self, key: SchemaKey<'_>) -> Option<&Schema> {
222 match key {
223 SchemaKey::Tag(name) => self.tags.get(name),
224 SchemaKey::Node(node_type) => self.nodes.get(&node_type),
225 }
226 }
227
228 fn tag_names(&self) -> Option<Vec<&str>> {
229 Some(self.tags.keys().map(String::as_str).collect())
230 }
231
232 fn node_types(&self) -> Option<Vec<NodeType>> {
233 Some(self.nodes.keys().copied().collect())
234 }
235}
236
237impl std::fmt::Debug for MapSchemaSource {
238 /// The names registered, through the same two methods `Config`'s `Debug`
239 /// reads, so the two renderings of one source cannot drift. A `Schema`
240 /// carries hooks, which have no useful rendering, so the derived form is
241 /// unavailable; the keys are what a reader chasing a `tag-undefined`
242 /// wants anyway.
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.debug_struct("MapSchemaSource")
245 .field("nodes", &self.node_types())
246 .field("tags", &self.tag_names())
247 .finish()
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn a_tag_node_is_keyed_by_name_and_a_plain_node_by_type() {
257 let mut tag = Node::new(NodeType::Tag);
258 tag.tag = Some("callout".to_string());
259 assert_eq!(SchemaKey::for_node(&tag), SchemaKey::Tag("callout"));
260 assert_eq!(
261 SchemaKey::for_node(&Node::new(NodeType::Heading)),
262 SchemaKey::Node(NodeType::Heading)
263 );
264 }
265
266 #[test]
267 fn the_map_source_answers_from_its_maps() {
268 let mut schemas = MapSchemaSource::new();
269 schemas
270 .insert_tag("callout", Schema::new())
271 .insert_node(NodeType::Heading, Schema::new());
272
273 assert!(schemas.find(SchemaKey::Tag("callout")).is_some());
274 assert!(schemas.find(SchemaKey::Node(NodeType::Heading)).is_some());
275 assert!(schemas.find(SchemaKey::Tag("nope")).is_none());
276 assert!(schemas.find(SchemaKey::Node(NodeType::Paragraph)).is_none());
277 // A tag node is never looked up by its type.
278 assert!(schemas.find(SchemaKey::Node(NodeType::Tag)).is_none());
279 }
280
281 #[test]
282 fn the_map_source_can_enumerate_and_the_default_cannot() {
283 struct Opaque;
284 impl SchemaSource for Opaque {
285 fn find(&self, _key: SchemaKey<'_>) -> Option<&Schema> {
286 None
287 }
288 }
289
290 let mut schemas = MapSchemaSource::new();
291 schemas
292 .insert_tag("b", Schema::new())
293 .insert_tag("a", Schema::new());
294 // Authored order, not sorted: the same promise every map here makes.
295 assert_eq!(schemas.tag_names(), Some(vec!["b", "a"]));
296 assert_eq!(schemas.node_types(), Some(vec![]));
297
298 assert_eq!(Opaque.tag_names(), None);
299 assert_eq!(Opaque.node_types(), None);
300 }
301
302 #[test]
303 fn debug_reads_the_same_names_the_trait_reports() {
304 let mut schemas = MapSchemaSource::new();
305 schemas.insert_tag("callout", Schema::new());
306 let debug = format!("{schemas:?}");
307 assert!(debug.contains(r#"tags: Some(["callout"])"#), "{debug}");
308 assert!(debug.contains("nodes: Some([])"), "{debug}");
309 }
310
311 #[test]
312 fn builtin_is_markdocs_vocabulary() {
313 let schemas = MapSchemaSource::builtin();
314 assert!(schemas.find(SchemaKey::Tag("if")).is_some());
315 assert!(schemas.find(SchemaKey::Tag("partial")).is_some());
316 assert!(schemas.find(SchemaKey::Node(NodeType::Heading)).is_some());
317 assert!(schemas.find(SchemaKey::Tag("callout")).is_none());
318 }
319}