Skip to main content

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}