Skip to main content

tramlines_core/
frontmatter.rs

1//! Frontmatter constraints, lifecycle keys and the id template, checked
2//! the same way in every format. The check reports [`Event`]s, which the
3//! checker of a format records with what it knows of where it is.
4
5use crate::{
6    Spanned,
7    grammar::{CoreSchema, FmFormat, FrontmatterConstraint, Only, RuleCommon, States, squash},
8    text::{self, is_slug, is_url},
9};
10
11// sokf:begin host
12/// What a check asks of its host about frontmatter: what lies outside the
13/// document.
14pub trait Host {
15    /// Whether `value` holds the host's scalar format `name`: `None` when
16    /// the host cannot tell, else the failure, which follows the key's
17    /// name in the finding.
18    fn scalar_format(&self, name: &str, value: &str) -> Option<Result<(), String>>;
19
20    /// The frontmatter keys the host owns: never rejected as undeclared.
21    fn reserved_keys(&self) -> &[&str];
22}
23// sokf:end host
24
25// sokf:begin event
26/// One thing the frontmatter check found.
27#[derive(Debug, Clone, PartialEq)]
28pub enum Event {
29    /// A failure of `rule`, at the rule's severity, about the frontmatter
30    /// key `key`.
31    Rule {
32        /// The rule that failed.
33        rule: RuleCommon,
34        /// The key the rule constrains.
35        key: String,
36        /// The line of the value, or of the frontmatter.
37        line: Option<usize>,
38        /// The key of the criterion that the failure breaks.
39        criterion: &'static str,
40        /// What failed.
41        message: String,
42    },
43    /// An error the schema implies with no rule that states it.
44    Error {
45        /// The line.
46        line: Option<usize>,
47        /// The key of the criterion that the failure breaks.
48        criterion: &'static str,
49        /// What failed.
50        message: String,
51    },
52    /// A gated rule matched the value at `line`.
53    Gate {
54        /// The gate.
55        only: Only,
56        /// The line of the value.
57        line: usize,
58        /// The line of the rule in the schema.
59        rule: usize,
60    },
61}
62// sokf:end event
63
64/// The state of one frontmatter check.
65struct Fm<'a> {
66    states: &'a States,
67    host: &'a dyn Host,
68    /// The key whose constraint is being checked.
69    key: String,
70    events: Vec<Event>,
71}
72
73// sokf-implements: CORE-7_AC-12
74impl Fm<'_> {
75    // sokf-implements: CORE-7_AC-7
76    fn emit(
77        &mut self,
78        rule: &RuleCommon,
79        line: Option<usize>,
80        criterion: &'static str,
81        message: impl Into<String>,
82    ) {
83        self.events.push(Event::Rule {
84            rule: rule.clone(),
85            key: self.key.clone(),
86            line,
87            criterion,
88            message: message.into(),
89        });
90    }
91
92    // sokf-implements: CORE-7_AC-8
93    fn error(&mut self, line: Option<usize>, criterion: &'static str, message: impl Into<String>) {
94        self.events.push(Event::Error {
95            line,
96            criterion,
97            message: message.into(),
98        });
99    }
100
101    // sokf-implements: CORE-7_AC-9
102    fn gate(&mut self, rule: &RuleCommon, line: usize) {
103        if rule.only.is_gated() {
104            self.events.push(Event::Gate {
105                only: rule.only.clone(),
106                line,
107                rule: rule.line,
108            });
109        }
110    }
111}
112
113/// Every declared key is present unless optional and holds its shape;
114/// undeclared keys are refused unless `frontmatter-open`; lifecycle keys
115/// hold a state; `id` matches its template.
116// sokf-implements: TL_54
117// sokf-implements: TL_55
118// sokf-implements: TL_56
119// sokf-implements: TL_57
120// sokf-implements: TL_60
121// sokf-implements: TL_61
122// sokf-implements: TL_62
123// sokf-implements: CORE-7_AC-1
124// sokf-implements: CORE-7_AC-3
125// sokf:begin frontmatter-check
126pub fn check(
127    frontmatter: Option<&Spanned>,
128    schema: &CoreSchema,
129    states: &States,
130    host: &dyn Host,
131) -> Vec<Event> {
132    // sokf:end frontmatter-check
133    let mut c = Fm {
134        states,
135        host,
136        key: String::new(),
137        events: Vec::new(),
138    };
139    let Some(frontmatter) = frontmatter.filter(|f| f.as_map().is_some()) else {
140        return c.events;
141    };
142    // sokf-implements: CORE-7_AC-11
143    let first_line = Some(frontmatter.line);
144    for (key, constraint) in &schema.frontmatter {
145        // sokf-implements: CORE-7_AC-10
146        if !constraint.common.only.admits(c.states) {
147            continue;
148        }
149        let value = frontmatter.get(key);
150        // A frontmatter constraint has no `rule-id` and sits in no
151        // section, so it names its key.
152        // sokf-implements: TL_73
153        c.key.clone_from(key);
154        match value {
155            None => {
156                if !constraint.optional {
157                    c.emit(
158                        &constraint.common,
159                        first_line,
160                        "TL_54",
161                        format!("frontmatter key `{key}` is missing"),
162                    );
163                }
164            }
165            Some(v) => {
166                c.gate(&constraint.common, v.line);
167                check_value(&mut c, key, constraint, v);
168            }
169        }
170    }
171    if !schema.frontmatter_open {
172        // sokf-implements: CORE-7_AC-6
173        let reserved = c.host.reserved_keys();
174        let written = frontmatter.as_map().unwrap_or(&[]);
175        for (key, value) in written {
176            let key = &key.value;
177            let declared = schema.frontmatter.iter().any(|(k, _)| k == key)
178                || schema.lifecycle.iter().any(|d| &d.key == key)
179                || reserved.contains(&key.as_str());
180            if !declared {
181                c.error(
182                    Some(value.line),
183                    "TL_55",
184                    format!("frontmatter key `{key}` is not declared"),
185                );
186            }
187        }
188    }
189    for dim in &schema.lifecycle {
190        match frontmatter.get(&dim.key) {
191            None => c.error(
192                first_line,
193                "TL_62",
194                format!("lifecycle key `{}` is missing", dim.key),
195            ),
196            Some(v) => {
197                let text = v.scalar_text().unwrap_or_default();
198                // sokf-implements: TL_108
199                if !dim.states.contains(&text) {
200                    c.error(
201                        Some(v.line),
202                        "TL_108",
203                        format!(
204                            "`{}` must be one of {}; `{text}` is not",
205                            dim.key,
206                            text::code_list(&dim.states, ", ")
207                        ),
208                    );
209                }
210            }
211        }
212    }
213    c.events
214}
215
216fn check_value(c: &mut Fm<'_>, key: &str, constraint: &FrontmatterConstraint, value: &Spanned) {
217    let common = &constraint.common;
218    if constraint.list {
219        let Some(items) = value.as_seq() else {
220            c.emit(
221                common,
222                Some(value.line),
223                "TL_57",
224                format!("`{key}` must be a list"),
225            );
226            return;
227        };
228        if let Some(min) = constraint.min
229            && items.len() < min
230        {
231            // sokf-implements: TL_106
232            c.emit(
233                common,
234                Some(value.line),
235                "TL_106",
236                format!(
237                    "`{key}` needs at least {}; it has {}",
238                    text::count(min, "entry", "entries"),
239                    items.len()
240                ),
241            );
242        }
243        if let Some(max) = constraint.max
244            && items.len() > max
245        {
246            // sokf-implements: TL_107
247            c.emit(
248                common,
249                Some(value.line),
250                "TL_107",
251                format!(
252                    "`{key}` allows at most {}; it has {}",
253                    text::count(max, "entry", "entries"),
254                    items.len()
255                ),
256            );
257        }
258        for item in items {
259            check_scalar(c, key, constraint, item);
260        }
261    } else {
262        if value.as_seq().is_some() {
263            c.emit(
264                common,
265                Some(value.line),
266                "TL_57",
267                format!("`{key}` must be a scalar, not a list"),
268            );
269            return;
270        }
271        check_scalar(c, key, constraint, value);
272    }
273}
274
275fn check_scalar(c: &mut Fm<'_>, key: &str, constraint: &FrontmatterConstraint, value: &Spanned) {
276    let common = &constraint.common;
277    let line = Some(value.line);
278    let Some(text) = value.scalar_text() else {
279        c.emit(common, line, "TL_57", format!("`{key}` must be a scalar"));
280        return;
281    };
282    if let Some(expected) = &constraint.const_
283        && &text != expected
284    {
285        c.emit(
286            common,
287            line,
288            "TL_56",
289            format!("`{key}` must be `{expected}`; it is `{text}`"),
290        );
291    }
292    // The `id` template is also the one a new document is filled with, and fixes the
293    // width of its sequence, so it answers for the `id` key alone.
294    let squashed = squash(&text);
295    if let Some(shape) = &constraint.shape
296        && constraint.id_template.is_none()
297        && !shape.matches(&squashed, |_| None)
298    {
299        c.emit(
300            common,
301            line,
302            "TL_56",
303            format!(
304                "`{key}` `{text}` {}{}",
305                shape.failure(&squashed),
306                constraint
307                    .example
308                    .as_deref()
309                    .map_or_else(String::new, |e| format!("; a value of this kind reads {e}"))
310            ),
311        );
312    }
313    if let Some(allowed) = &constraint.enum_
314        && !allowed.contains(&text)
315    {
316        c.emit(
317            common,
318            line,
319            "TL_56",
320            format!(
321                "`{key}` must be one of {}; `{text}` is not",
322                text::code_list(allowed, ", ")
323            ),
324        );
325    }
326    match &constraint.format {
327        // A format the host owns, the host answers, and a value it refuses
328        // fails its format as any other does.
329        Some(FmFormat::Host(name)) => {
330            // sokf-implements: CORE-7_AC-4
331            // sokf-implements: CORE-7_AC-5
332            if let Some(Err(failure)) = c.host.scalar_format(name, &text) {
333                c.emit(common, line, "TL_56", format!("`{key}` {failure}"));
334            }
335        }
336        Some(FmFormat::Url) if !is_url(&text) => c.emit(
337            common,
338            line,
339            "TL_56",
340            format!("`{key}` must be an absolute URL; `{text}` is not"),
341        ),
342        Some(FmFormat::Slug) if !is_slug(&text) => c.emit(
343            common,
344            line,
345            "TL_56",
346            format!("`{key}` must be a slug; `{text}` is not"),
347        ),
348        Some(FmFormat::Url | FmFormat::Slug) | None => {}
349    }
350    // A fixed id is its `const`, which answers for it above.
351    if let Some(template) = &constraint.id_template
352        && constraint.const_.is_none()
353    {
354        if !template.matches(&text) {
355            c.emit(
356                common,
357                line,
358                "TL_56",
359                format!(
360                    "id `{text}` does not match the template `{}`",
361                    template.source
362                ),
363            );
364        }
365        // sokf-implements: CORE-7_AC-2
366        // sokf-implements: TL_59
367        for (name, held) in template.keys_of(&text) {
368            let Some(state) = c.states.0.get(&name).and_then(|s| s.first()) else {
369                continue;
370            };
371            if &held != state {
372                c.emit(
373                    common,
374                    line,
375                    "TL_59",
376                    format!("id `{text}` holds `{held}` for `{name}`, but `{name}` is `{state}`"),
377                );
378            }
379        }
380    }
381}