accent-proust 0.10.0

A Rust implementation of the Markdoc language: parse, validate, transform, render, and format.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
//! What a tag or a node type is allowed to be.
//!
//! Mirrors upstream's `Schema`, `SchemaAttribute`, `SchemaSlot` and
//! `SchemaMatches` in `src/types.ts`. This is the crate's central extension
//! point: everything a host teaches `accent-proust` about its own content model
//! arrives as one of these.
//!
//! # Hooks are synchronous
//!
//! Upstream types `transform` and `validate` as `MaybePromise`, so a schema may
//! fetch while it transforms. This crate performs no I/O by construction, so an
//! async hook would be a signature with no reachable implementation that
//! coloured every caller above it. Both are ordinary functions here.
//! `DIVERGENCES.md` entry 3 declares it.
//!
//! # Why the hooks are higher-ranked
//!
//! A hook is written once and run against documents of every lifetime, so its
//! type quantifies over the document's:
//!
//! ```text
//! for<'a, 'c> Fn(&'a Node<'a>, &'c Config<'a>) -> Vec<ValidationError<'a>>
//! ```
//!
//! Only the node's lifetime is tied to the result, which is what lets an error
//! quote the span it found. The config reference is free, which is what lets
//! [`validate_tree`](crate::validate::validate_tree) hand a hook a config it
//! owns for the duration of the walk. An ordinary closure satisfies this
//! without annotation.

use std::sync::Arc;

use indexmap::IndexMap;

use crate::ast::{ErrorLevel, Function, Node, NodeType, ValidationError, Value};
use crate::renderable::RenderableTreeNodes;
use crate::validate::Config;
use crate::validate::ValidationType;

/// Turns a node into renderable output.
///
/// Upstream `Schema['transform']`, minus the promise.
pub type TransformHook = Arc<
    dyn for<'a, 'c> Fn(&'a Node<'a>, &'c Config<'a>) -> RenderableTreeNodes + Send + Sync + 'static,
>;

/// Reports problems with a node that the declarative fields cannot express.
///
/// Upstream `Schema['validate']`, minus the promise.
pub type ValidateHook = Arc<
    dyn for<'a, 'c> Fn(&'a Node<'a>, &'c Config<'a>) -> Vec<ValidationError<'a>>
        + Send
        + Sync
        + 'static,
>;

/// Reports problems with one attribute value.
///
/// Upstream `SchemaAttribute['validate']`. The name is passed through because
/// the message usually quotes it -- `The value of 'bar.baz' must be ...` -- and
/// one function is often registered for several attributes.
pub type AttributeValidateHook = Arc<
    dyn for<'a, 'c> Fn(&Value, &'c Config<'a>, &str) -> Vec<ValidationError<'a>>
        + Send
        + Sync
        + 'static,
>;

/// Computes the allowed values for an attribute from the config.
///
/// Upstream spells `matches` as `SchemaMatches | ((config) => SchemaMatches)`.
/// A dynamic list is how a host says "one of the pages that exist", which it
/// cannot know when the schema is written.
pub type MatchesHook =
    Arc<dyn for<'a, 'c> Fn(&'c Config<'a>) -> Option<SchemaMatches> + Send + Sync + 'static>;

/// Turns a resolved call into a value.
///
/// Upstream `ConfigFunction['transform']`. The parameters arrive resolved and
/// keyed exactly as the call site keyed them.
///
/// # Why both sides are `Option`
///
/// [`None`] is JavaScript's `undefined`, and Markdoc's own functions depend on
/// telling it from `null` on both sides. Do not simplify it away.
///
/// On the **parameter** side, a key is present with an undefined value rather
/// than absent: upstream's grammar always writes `parameters[name || index] =
/// value`, and `ast/base.ts::resolve` maps over `Object.entries`, so an
/// argument that did not resolve keeps its key. The arity is load-bearing.
/// `default` is literally `parameters[0] === undefined ? parameters[1] :
/// parameters[0]`, so telling the two apart is its whole job; `equals` and `or`
/// fold over `Object.values`, and dropping an unresolved key would change what
/// they fold over. The conformance corpus pins it: "Conditional with equals and
/// an undefined variable" requires `equals($foo.bar, "test")` with no variables
/// to be false, which it is only if the unresolved argument still counts.
///
/// On the **return** side, `debug()` and `default($unset)` with no fallback
/// return `undefined`, which makes the attribute disappear from the output.
/// [`Value::Null`] would render it instead.
pub type FunctionTransformHook = Arc<
    dyn for<'a, 'c> Fn(&IndexMap<String, Option<Value>>, &'c Config<'a>) -> Option<Value>
        + Send
        + Sync
        + 'static,
>;

/// Reports problems with a call that its parameter declarations cannot express.
///
/// Upstream `ConfigFunction['validate']`.
pub type FunctionValidateHook = Arc<
    dyn for<'a, 'c> Fn(&Function, &'c Config<'a>) -> Vec<ValidationError<'a>>
        + Send
        + Sync
        + 'static,
>;

/// Whether a value reaches the rendered output, and under what name.
///
/// Upstream spells this `render?: boolean | string` on both attributes and
/// slots. Three states, and the third is the one a union of two Rust types
/// would lose: `true` and absent both mean "render under the authored name",
/// `false` means "this is schema input, not output", and a string renames it --
/// which is how a Markdoc attribute becomes a differently spelled HTML one.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum RenderPolicy {
    /// Render under the authored name. Upstream's `true`, and its default.
    #[default]
    Named,
    /// Do not render. Upstream's `false`.
    Hidden,
    /// Render under a different name. Upstream's string form.
    Renamed(String),
}

impl RenderPolicy {
    /// The output name for a value authored as `key`, or [`None`] if it is
    /// hidden.
    #[must_use]
    pub fn output_name<'k>(&'k self, key: &'k str) -> Option<&'k str> {
        match self {
            RenderPolicy::Named => Some(key),
            RenderPolicy::Hidden => None,
            RenderPolicy::Renamed(name) => Some(name.as_str()),
        }
    }
}

/// A closed set of values an attribute may take.
///
/// Upstream's `SchemaMatches = RegExp | string[] | null`, with `null` spelled
/// here as [`Option::None`] on the field.
#[derive(Clone)]
#[non_exhaustive]
pub enum SchemaMatches {
    /// One of these exact strings.
    ///
    /// Compared as upstream compares them, with `Array.includes`: a
    /// non-string attribute value is never a member, so `{% foo bar=1 %}`
    /// against `["1"]` is invalid.
    Values(Vec<String>),
    /// Whatever a host-supplied pattern accepts.
    ///
    /// Upstream takes a JavaScript `RegExp`. This crate has no regular
    /// expression engine and does not want one -- see `DIVERGENCES.md` entry 12
    /// -- so a host supplies the predicate and the spelling the error message
    /// quotes.
    Pattern(Arc<dyn MatchPattern + Send + Sync + 'static>),
    /// Computed from the config each time the attribute is checked.
    ///
    /// Resolved once. If a hook returns another hook, no check runs, which is
    /// what upstream does: after one resolution it tests `Array.isArray` and
    /// `instanceof RegExp`, and a function is neither.
    Dynamic(MatchesHook),
}

/// A host-supplied pattern, standing in for upstream's `RegExp`.
///
/// Two methods because upstream's error message interpolates the pattern
/// itself: `Attribute 'x' must match /^a/. Got 'b' instead.` A predicate alone
/// could decide the check but not write the message.
pub trait MatchPattern {
    /// Whether the value matches.
    ///
    /// The value arrives already coerced to a string the way JavaScript's
    /// `RegExp.test` coerces its argument, so a pattern never has to think
    /// about the value lattice.
    fn is_match(&self, value: &str) -> bool;

    /// How the pattern is written in an error message.
    ///
    /// Upstream renders a `RegExp` as `/source/flags`. A host porting a Markdoc
    /// schema should spell it the same way, so that tooling reading the message
    /// sees what it saw before.
    fn display(&self) -> &str;
}

/// One attribute of a tag or node.
///
/// Mirrors upstream's `SchemaAttribute`. Every field is optional there and
/// carries its own default here; the two that are not plain [`Option`] are
/// [`render`](SchemaAttribute::render), which has three states rather than two,
/// and [`required`](SchemaAttribute::required), where absent and `false` mean
/// the same thing.
#[derive(Clone, Default)]
pub struct SchemaAttribute {
    /// The type the value must have.
    ///
    /// [`None`] accepts anything, which is upstream's behaviour for an
    /// attribute declared without a `type`.
    pub attribute_type: Option<ValidationType>,
    /// Whether and how the value reaches the output.
    pub render: RenderPolicy,
    /// The value used when the attribute is absent.
    ///
    /// Applied by the transformer, not the validator: a default does not
    /// satisfy [`required`](SchemaAttribute::required), which is upstream's
    /// behaviour and is the reason the two can be set together without
    /// contradiction.
    pub default: Option<Value>,
    /// Whether the attribute must be present.
    pub required: bool,
    /// A closed set of acceptable values.
    pub matches: Option<SchemaMatches>,
    /// A host check that the declarative fields cannot express.
    pub validate: Option<AttributeValidateHook>,
    /// The level reported for a type or value mismatch on this attribute.
    ///
    /// [`None`] means [`ErrorLevel::Error`]. Lowering it to a warning is how a
    /// host ships a rule it wants surfaced but not enforced yet.
    pub error_level: Option<ErrorLevel>,
    /// Prose for a generated schema reference.
    pub description: Option<String>,
}

/// One named region inside a tag.
///
/// Mirrors upstream's `SchemaSlot`. A slot is content, not a value, so it has
/// no type and no matches -- only whether it is required and whether it renders.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SchemaSlot {
    /// Whether and how the rendered slot reaches the output.
    ///
    /// A rendered slot becomes an *attribute* of the transformed tag holding
    /// the slot's own transformed nodes, which is why
    /// [`Tag::attributes`](crate::renderable::Tag::attributes) is not a map of
    /// scalars.
    pub render: RenderPolicy,
    /// Whether the slot must be filled.
    pub required: bool,
}

/// What a tag or a node type is allowed to be, and what it becomes.
///
/// Mirrors upstream's `Schema`. The declarative fields are checked by
/// [`validator`](crate::validate::validator) in the order upstream checks them,
/// because the order is observable: a document with several problems reports
/// them in one sequence, and a tool diffing that sequence would see a reordering
/// as a change.
#[derive(Clone, Default)]
pub struct Schema {
    /// The element name the transformer emits.
    ///
    /// [`None`] means the node renders as its children with no element around
    /// them, which is how `document` and `inline` disappear from the output.
    pub render: Option<String>,
    /// The node types allowed as children.
    ///
    /// [`None`] allows anything; `Some` of an empty list allows nothing. A
    /// child of type [`NodeType::Error`] is never reported, because the parser
    /// has already said what is wrong with it and a second complaint about its
    /// placement is noise.
    pub children: Option<Vec<NodeType>>,
    /// The declared attributes, in authored order.
    pub attributes: IndexMap<String, SchemaAttribute>,
    /// The declared slots, in authored order.
    pub slots: IndexMap<String, SchemaSlot>,
    /// Whether the tag must be written `{% foo /%}` with no children.
    pub self_closing: bool,
    /// Whether the tag must appear inline or as a block.
    ///
    /// [`None`] allows either, which is upstream's default and is why the
    /// three-state spelling is needed: `Some(false)` means "block only", not
    /// "no opinion".
    pub inline: Option<bool>,
    /// Turns the node into renderable output.
    pub transform: Option<TransformHook>,
    /// Reports problems the declarative fields cannot express.
    pub validate: Option<ValidateHook>,
    /// Prose for a generated schema reference.
    pub description: Option<String>,
}

impl Schema {
    /// A schema that constrains nothing and renders nothing.
    ///
    /// Useful as a base to fill in, and as the honest answer for a tag a host
    /// wants accepted but has nothing to say about.
    #[must_use]
    pub fn new() -> Schema {
        Schema::default()
    }

    /// The same schema, rendering as `element`.
    #[must_use]
    pub fn render(mut self, element: impl Into<String>) -> Schema {
        self.render = Some(element.into());
        self
    }

    /// The same schema with one more attribute.
    #[must_use]
    pub fn attribute(mut self, name: impl Into<String>, attribute: SchemaAttribute) -> Schema {
        self.attributes.insert(name.into(), attribute);
        self
    }

    /// The same schema with one more slot.
    #[must_use]
    pub fn slot(mut self, name: impl Into<String>, slot: SchemaSlot) -> Schema {
        self.slots.insert(name.into(), slot);
        self
    }
}

impl std::fmt::Debug for Schema {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Schema")
            .field("render", &self.render)
            .field("children", &self.children)
            .field("attributes", &self.attributes)
            .field("slots", &self.slots)
            .field("self_closing", &self.self_closing)
            .field("inline", &self.inline)
            .field("transform", &self.transform.is_some())
            .field("validate", &self.validate.is_some())
            .field("description", &self.description)
            .finish()
    }
}

impl std::fmt::Debug for SchemaAttribute {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SchemaAttribute")
            .field("attribute_type", &self.attribute_type)
            .field("render", &self.render)
            .field("default", &self.default)
            .field("required", &self.required)
            .field("matches", &self.matches)
            .field("validate", &self.validate.is_some())
            .field("error_level", &self.error_level)
            .field("description", &self.description)
            .finish()
    }
}

impl std::fmt::Debug for SchemaMatches {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SchemaMatches::Values(values) => f.debug_tuple("Values").field(values).finish(),
            SchemaMatches::Pattern(pattern) => {
                f.debug_tuple("Pattern").field(&pattern.display()).finish()
            }
            SchemaMatches::Dynamic(_) => f.write_str("Dynamic(..)"),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_render_policy_has_three_states() {
        assert_eq!(RenderPolicy::default().output_name("id"), Some("id"));
        assert_eq!(RenderPolicy::Hidden.output_name("id"), None);
        assert_eq!(
            RenderPolicy::Renamed("data-id".to_string()).output_name("id"),
            Some("data-id")
        );
    }

    #[test]
    fn a_hook_is_an_ordinary_closure() {
        // The point of this test is that it compiles without a lifetime
        // annotation on the closure: the higher-ranked hook types are only
        // worth having if a host can write one by hand.
        let schema = Schema {
            validate: Some(Arc::new(|node: &Node<'_>, _config: &Config<'_>| {
                vec![ValidationError::new(
                    "example",
                    ErrorLevel::Warning,
                    format!("saw {}", node.name()),
                )]
            })),
            ..Schema::new()
        };
        let node = Node::new(NodeType::Paragraph);
        let config = Config::new();
        let hook = schema.validate.expect("just set");
        assert_eq!(hook(&node, &config).first().map(|e| e.id), Some("example"));
    }

    #[test]
    fn an_error_may_quote_the_span_it_found() {
        // The other half of the signature's job: a hook's error borrows the
        // document, so it can carry a location rather than only a message.
        let source = String::from("# heading\n");
        let lines = crate::ast::Lines::new(&source);
        let mut node = Node::new(NodeType::Heading);
        node.location = Some(lines.locate(0..9, None));

        let hook: ValidateHook = Arc::new(|node: &Node<'_>, _config: &Config<'_>| {
            let mut error = ValidationError::new("example", ErrorLevel::Error, "no");
            error.location = node.location;
            vec![error]
        });
        let config = Config::new();
        let errors = hook(&node, &config);
        assert_eq!(
            errors.first().and_then(|e| e.location).map(|l| l.text),
            Some("# heading")
        );
    }

    #[test]
    fn a_schema_builds_by_parts() {
        let schema = Schema::new()
            .render("aside")
            .attribute(
                "type",
                SchemaAttribute {
                    required: true,
                    ..SchemaAttribute::default()
                },
            )
            .slot("footer", SchemaSlot::default());
        assert_eq!(schema.render.as_deref(), Some("aside"));
        assert!(schema.attributes["type"].required);
        assert!(schema.slots.contains_key("footer"));
    }
}