accent_proust/validate/config.rs
1//! The bag of schemas, variables, functions and partials a document is checked
2//! and transformed against.
3//!
4//! Mirrors upstream's `Config` in `src/types.ts`. Upstream declares it as a
5//! `Partial<>` of five optional records, because in JavaScript "absent" and
6//! "empty" are usefully different for some of them and accidentally different
7//! for the rest. Each field here says which it is, and two of them are
8//! [`Option`] for a reason the validator can be read against:
9//!
10//! - **`variables` is optional.** `config.variables` gates variable checking
11//! entirely (`validator.ts`), so `{}` means "check, and nothing is defined"
12//! while absent means "do not check". Upstream's own test suite fixes both.
13//! - **`ConfigFunction::parameters` is optional**, for the same shape of
14//! reason: absent skips parameter checking, empty rejects every parameter.
15//!
16//! The rest are a schema source and two plain maps. A missing schema and an
17//! empty source produce the same `Undefined tag` error, so there is nothing
18//! for an [`Option`] to distinguish.
19//!
20//! # Where the content comes from
21//!
22//! Nowhere in this crate. A `Config` is assembled by the host -- from a file, a
23//! constant, a plugin manifest -- and handed in. Schemas arrive through
24//! [`SchemaSource`], which is the host's to implement; [`MapSchemaSource`] is
25//! the implementation for a host that assembles them by hand. This module
26//! owns the *shape* only, which is the line that lets `accent-proust` be
27//! published without shipping anybody's schemas.
28
29use std::sync::Arc;
30
31use indexmap::IndexMap;
32
33use crate::ast::{Node, Value};
34use crate::validate::schema::{FunctionTransformHook, FunctionValidateHook, Schema};
35use crate::validate::source::{MapSchemaSource, SchemaKey, SchemaSource};
36use crate::validate::{SchemaAttribute, ValidationType};
37
38/// Variables a document may reference with `$name`.
39///
40/// A nested structure, walked one path segment at a time: `$a.b[0]` descends
41/// through a [`Value::Hash`] and then a [`Value::Array`]. Upstream stores
42/// arbitrary JavaScript here and descends with `hasOwnProperty`; [`Value`] is
43/// the same lattice with a name.
44pub type Variables = IndexMap<String, Value>;
45
46/// Everything the validator and the transformer read.
47///
48/// The lifetime is the source text that [`partials`](Config::partials) and
49/// [`ValidationOptions::parents`] borrow. A config holding neither -- the
50/// ordinary case for a host that registers schemas once and reuses them -- is a
51/// `Config<'static>`, and Rust's variance lets that be passed wherever a
52/// `Config<'a>` is wanted. That is deliberate: a schema registry that could
53/// only validate documents of its own lifetime would have to be rebuilt per
54/// page.
55///
56/// # Why three fields are behind an [`Arc`]
57///
58/// A config is cloned on a hot path and only one field differs between the
59/// original and the copy: `{% partial %}` scopes a partial's body by cloning
60/// the whole config to replace [`variables`](Config::variables). Everything
61/// else -- the schemas, the functions, the parsed partials -- is registered
62/// once and read many times, so copying it per expansion charges the caller for
63/// the site's whole partial corpus on every partial in every page, which
64/// compounds exactly where partials earn their keep.
65///
66/// So those three are shared rather than copied. Reads are unchanged
67/// ([`Arc`] derefs). The two maps have copy-on-write mutators
68/// ([`functions_mut`](Config::functions_mut) and
69/// [`partials_mut`](Config::partials_mut)) for assembly; the schema source
70/// does not, because a trait object cannot be copied on write. A host fills a
71/// [`MapSchemaSource`] and shares it with [`with_schemas`](Config::with_schemas),
72/// so the sharing is explicit rather than clever.
73#[derive(Clone)]
74pub struct Config<'a> {
75 /// Where a schema comes from.
76 ///
77 /// The only mechanism: there is no map beside it and so no precedence rule
78 /// to remember. [`Config::new`] starts it empty and
79 /// [`builtins::config`](crate::builtins::config) at Markdoc's own.
80 pub schemas: Arc<dyn SchemaSource + Send + Sync>,
81 /// Variables a `$name` reference resolves against.
82 ///
83 /// [`None`] switches variable checking off; `Some` of an empty map switches
84 /// it on with nothing defined.
85 pub variables: Option<Variables>,
86 /// Functions a `f()` call resolves against.
87 ///
88 /// Shared: see the note on [`Config`]. Use
89 /// [`functions_mut`](Config::functions_mut) to edit one in place.
90 pub functions: Arc<IndexMap<String, ConfigFunction>>,
91 /// Parsed partial documents, keyed by the name `{% partial file=... %}`
92 /// uses.
93 ///
94 /// Parsed, not raw: this crate performs no I/O, so a host reads the file and
95 /// parses it. That is why the config carries a lifetime.
96 ///
97 /// Shared: see the note on [`Config`], where this field is the one that
98 /// made the sharing worth doing. Use
99 /// [`partials_mut`](Config::partials_mut) to edit the map in place.
100 pub partials: Arc<IndexMap<String, Node<'a>>>,
101 /// Switches and context for the validation pass.
102 pub validation: ValidationOptions<'a>,
103}
104
105impl Default for Config<'_> {
106 /// [`Config::new`]: an empty schema source, no variables, no functions, no
107 /// partials. Written out because a trait object has no default.
108 fn default() -> Self {
109 Config {
110 schemas: Arc::new(MapSchemaSource::new()),
111 variables: None,
112 functions: Arc::default(),
113 partials: Arc::default(),
114 validation: ValidationOptions::default(),
115 }
116 }
117}
118
119impl<'a> Config<'a> {
120 /// An empty config: no schemas, no variables, no functions, no partials.
121 ///
122 /// Every node then reports `node-undefined` or `tag-undefined`, which is the
123 /// correct answer rather than a degenerate one -- upstream's own `validate`
124 /// merges its built-in schemas in before it gets here, and a host that skips
125 /// that step has genuinely defined nothing.
126 #[must_use]
127 pub fn new() -> Config<'a> {
128 Config::default()
129 }
130
131 /// Replace the schema source.
132 ///
133 /// Chainable, for the registering case: fill a [`MapSchemaSource`] and hand
134 /// it over in one expression. The source is shared, not copied, so a host
135 /// with one registry and many configs pays for it once.
136 #[must_use]
137 pub fn with_schemas(mut self, schemas: Arc<dyn SchemaSource + Send + Sync>) -> Config<'a> {
138 self.schemas = schemas;
139 self
140 }
141
142 /// The functions, for in-place edit.
143 ///
144 /// Copy-on-write: the map is copied only if another `Config` is sharing it,
145 /// which is what makes registering once and scoping many times cheap.
146 pub fn functions_mut(&mut self) -> &mut IndexMap<String, ConfigFunction> {
147 Arc::make_mut(&mut self.functions)
148 }
149
150 /// The parsed partials, for in-place edit. Copy-on-write, as
151 /// [`functions_mut`](Config::functions_mut) is.
152 pub fn partials_mut(&mut self) -> &mut IndexMap<String, Node<'a>> {
153 Arc::make_mut(&mut self.partials)
154 }
155
156 /// The schema for a node: its tag's if it has a tag, its type's otherwise.
157 ///
158 /// Upstream's `transformer.findSchema`. It lives on the config rather than
159 /// on [`Node`] because the node is the leaf type and the config is the
160 /// stage above it; upstream's `node.findSchema(config)` is the same call
161 /// with the arrow pointing the other way. The work is
162 /// [`SchemaSource::find`]'s; this only chooses the key.
163 #[must_use]
164 pub fn find_schema(&self, node: &Node<'_>) -> Option<&Schema> {
165 self.schemas.find(SchemaKey::for_node(node))
166 }
167}
168
169/// Switches and context for one validation pass.
170///
171/// Mirrors upstream's `config.validation`. It is part of the config rather than
172/// a separate argument because schema `validate` hooks read it -- most usefully
173/// [`parents`](ValidationOptions::parents), which is how a schema says "a
174/// heading is not allowed inside a `callout`" without walking the tree itself.
175#[derive(Clone, Debug, Default)]
176pub struct ValidationOptions<'a> {
177 /// The ancestors of the node being validated, outermost first.
178 ///
179 /// Set by [`validate_tree`](crate::validate::validate_tree) as it walks, and
180 /// empty for the document node. A hook reading this is reading the path it
181 /// was reached by, not the whole tree.
182 pub parents: Vec<&'a Node<'a>>,
183 /// Whether function calls in attributes are checked against
184 /// [`Config::functions`].
185 ///
186 /// Off by default, as upstream has it: a document may legitimately use
187 /// functions a validating tool does not know about.
188 pub validate_functions: bool,
189 /// A host-defined label for the environment being validated.
190 ///
191 /// This crate never reads it. It exists because upstream schemas do, and
192 /// dropping it would break a ported schema for no gain.
193 pub environment: Option<String>,
194}
195
196/// A function a document may call in an attribute value.
197///
198/// Mirrors upstream's `ConfigFunction`. Both hooks are synchronous; see
199/// `DIVERGENCES.md` entry 3.
200#[derive(Clone, Default)]
201pub struct ConfigFunction {
202 /// What the call returns, used to type-check the attribute it feeds.
203 ///
204 /// Checked only when [`ValidationOptions::validate_functions`] is on.
205 pub returns: Option<ValidationType>,
206 /// The declared parameters, keyed as the call sites key them.
207 ///
208 /// [`None`] means the function declares no parameters and none are checked;
209 /// `Some` of an empty map means every parameter passed is invalid. A
210 /// positional argument is keyed by its decimal index -- see
211 /// [`Function::positional_key`](crate::ast::Function::positional_key) --
212 /// which is why this is a string-keyed map rather than a list.
213 pub parameters: Option<IndexMap<String, SchemaAttribute>>,
214 /// Turns a call into a value at transform time.
215 pub transform: Option<FunctionTransformHook>,
216 /// Reports problems with a call beyond what the parameter declarations
217 /// catch.
218 pub validate: Option<FunctionValidateHook>,
219}
220
221impl std::fmt::Debug for Config<'_> {
222 /// Hooks are function pointers with no useful rendering, so the derived
223 /// `Debug` is unavailable and this one reports what is *there* instead: the
224 /// names registered, which is what you want when a `tag-undefined` error
225 /// disagrees with what you thought you registered.
226 ///
227 /// The schema names come through the source's provided
228 /// [`tag_names`](SchemaSource::tag_names) and
229 /// [`node_types`](SchemaSource::node_types), and print as `None` for a
230 /// source that cannot enumerate -- which is the truth, and different from
231 /// an empty list.
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 f.debug_struct("Config")
234 .field("nodes", &self.schemas.node_types())
235 .field("tags", &self.schemas.tag_names())
236 .field("variables", &self.variables)
237 .field("functions", &self.functions.keys().collect::<Vec<_>>())
238 .field("partials", &self.partials.keys().collect::<Vec<_>>())
239 .field("validation", &self.validation)
240 .finish()
241 }
242}
243
244impl std::fmt::Debug for ConfigFunction {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 f.debug_struct("ConfigFunction")
247 .field("returns", &self.returns)
248 .field("parameters", &self.parameters)
249 .field("transform", &self.transform.is_some())
250 .field("validate", &self.validate.is_some())
251 .finish()
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::ast::{Node, NodeType};
259
260 #[test]
261 fn a_tag_is_looked_up_by_name_and_a_node_by_type() {
262 let mut schemas = MapSchemaSource::new();
263 schemas
264 .insert_tag("callout", Schema::default())
265 .insert_node(NodeType::Heading, Schema::default());
266 let config = Config::new().with_schemas(Arc::new(schemas));
267
268 let mut tag = Node::new(NodeType::Tag);
269 tag.tag = Some("callout".to_string());
270 assert!(config.find_schema(&tag).is_some());
271
272 assert!(config.find_schema(&Node::new(NodeType::Heading)).is_some());
273 assert!(
274 config
275 .find_schema(&Node::new(NodeType::Paragraph))
276 .is_none()
277 );
278
279 // A tag node is never looked up as a node type, even though `tag` is
280 // one. Upstream branches on `node.tag` being set, not on the type.
281 let mut unknown = Node::new(NodeType::Tag);
282 unknown.tag = Some("nope".to_string());
283 assert!(config.find_schema(&unknown).is_none());
284 }
285
286 #[test]
287 fn a_source_that_is_not_a_map_resolves_through_the_config() {
288 // The seam, exercised by something that is not the built-in: a source
289 // with no map, answering from a match arm.
290 struct Aside(Schema);
291 impl SchemaSource for Aside {
292 fn find(&self, key: SchemaKey<'_>) -> Option<&Schema> {
293 match key {
294 SchemaKey::Tag("callout") => Some(&self.0),
295 _ => None,
296 }
297 }
298 }
299
300 let config = Config::new().with_schemas(Arc::new(Aside(Schema::new().render("aside"))));
301
302 let mut callout = Node::new(NodeType::Tag);
303 callout.tag = Some("callout".to_string());
304 assert_eq!(
305 config
306 .find_schema(&callout)
307 .and_then(|s| s.render.as_deref()),
308 Some("aside")
309 );
310 assert!(config.find_schema(&Node::new(NodeType::Heading)).is_none());
311 }
312
313 #[test]
314 fn debug_names_what_a_map_holds_and_admits_what_it_cannot_see() {
315 struct Opaque;
316 impl SchemaSource for Opaque {
317 fn find(&self, _key: SchemaKey<'_>) -> Option<&Schema> {
318 None
319 }
320 }
321
322 let mut schemas = MapSchemaSource::new();
323 schemas.insert_tag("callout", Schema::new());
324 let named = format!("{:?}", Config::new().with_schemas(Arc::new(schemas)));
325 assert!(named.contains(r#"tags: Some(["callout"])"#), "{named}");
326 assert!(named.contains("nodes: Some([])"), "{named}");
327
328 // Not an empty registry: a source that cannot say.
329 let opaque = format!("{:?}", Config::new().with_schemas(Arc::new(Opaque)));
330 assert!(opaque.contains("tags: None"), "{opaque}");
331 assert!(opaque.contains("nodes: None"), "{opaque}");
332 }
333
334 #[test]
335 fn a_config_with_nothing_borrowed_outlives_any_document() {
336 // The point of this test is that it compiles: a `Config<'static>` is
337 // usable against a document parsed from a local string, which is the
338 // shape a host registry has.
339 let config: Config<'static> = Config::new();
340 let source = String::from("# hi\n");
341 let node = Node::new(NodeType::Document);
342 let _ = source;
343 assert!(config.find_schema(&node).is_none());
344 }
345}