Skip to main content

opys_engine/mdprism/
validate.rs

1//! Validation: does a markdown document conform to the schema's body?
2//!
3//! The document is parsed with comrak, lowered into a simplified block tree
4//! (headings reconstructed into nested sections), then matched against the
5//! schema body. Frontmatter validation is a later phase; the frontmatter block
6//! is stripped before parsing.
7
8use super::error::Problem;
9use super::schema::*;
10use comrak::nodes::{AstNode, ListType, NodeValue};
11use comrak::{parse_document, Arena, Options};
12use regex::Regex;
13use serde_json::{Map, Value};
14
15impl Schema {
16    /// Validate a markdown document's **body** against the schema. An empty
17    /// result means it conforms. (Frontmatter typing: future phase.)
18    pub fn validate(&self, markdown: &str) -> Vec<Problem> {
19        let body = strip_frontmatter(markdown);
20        let arena = Arena::new();
21        let root = parse_document(&arena, body, &Options::default());
22        let doc = build_blocks(root.children());
23
24        let mut problems = Vec::new();
25        let mut path = Vec::new();
26        match_nodes(&self.body, &doc, self.opts, &mut path, &mut problems);
27        problems
28    }
29
30    /// Parse a **conforming** document into the typed data object, keyed by the
31    /// schema's capture aliases. Returns the validation problems if it does not
32    /// conform. (Frontmatter capture: future phase — body only for now.)
33    pub fn extract(&self, markdown: &str) -> Result<Value, Vec<Problem>> {
34        let problems = self.validate(markdown);
35        if !problems.is_empty() {
36            return Err(problems);
37        }
38        let body = strip_frontmatter(markdown);
39        let arena = Arena::new();
40        let root = parse_document(&arena, body, &Options::default());
41        let doc = build_blocks(root.children());
42
43        let mut obj = Map::new();
44        extract_into(&self.body, &doc, &mut obj);
45        Ok(Value::Object(obj))
46    }
47}
48
49// ---- a simplified document block tree ------------------------------------
50
51#[derive(Debug, Clone)]
52enum DocBlock {
53    Section {
54        level: u8,
55        title: String,
56        children: Vec<DocBlock>,
57    },
58    List {
59        ordered: bool,
60        items: Vec<DocItem>,
61    },
62    Para(String),
63}
64
65#[derive(Debug, Clone)]
66struct DocItem {
67    text: String,
68    checked: Option<bool>,
69    children: Vec<DocBlock>,
70}
71
72/// One flat block before heading-nesting is reconstructed.
73enum Flat {
74    Heading(u8, String),
75    Block(DocBlock),
76}
77
78fn build_blocks<'a>(nodes: impl Iterator<Item = &'a AstNode<'a>>) -> Vec<DocBlock> {
79    let flats: Vec<Flat> = nodes.filter_map(flat_of).collect();
80    let mut pos = 0;
81    nest(&flats, &mut pos, 0)
82}
83
84fn flat_of<'a>(node: &'a AstNode<'a>) -> Option<Flat> {
85    match &node.data.borrow().value {
86        NodeValue::Heading(h) => Some(Flat::Heading(h.level, text_of(node))),
87        NodeValue::List(nl) => Some(Flat::Block(doc_list(node, nl.list_type))),
88        NodeValue::Paragraph => Some(Flat::Block(DocBlock::Para(text_of(node)))),
89        _ => None,
90    }
91}
92
93/// Reconstruct heading nesting: a heading owns following blocks and deeper
94/// headings until a heading of the same or higher rank.
95fn nest(flats: &[Flat], pos: &mut usize, parent_level: usize) -> Vec<DocBlock> {
96    let mut out = Vec::new();
97    while *pos < flats.len() {
98        match &flats[*pos] {
99            Flat::Heading(level, title) => {
100                if (*level as usize) <= parent_level {
101                    break;
102                }
103                let (level, title) = (*level, title.clone());
104                *pos += 1;
105                let children = nest(flats, pos, level as usize);
106                out.push(DocBlock::Section {
107                    level,
108                    title,
109                    children,
110                });
111            }
112            Flat::Block(b) => {
113                out.push(b.clone());
114                *pos += 1;
115            }
116        }
117    }
118    out
119}
120
121fn doc_list<'a>(list: &'a AstNode<'a>, list_type: ListType) -> DocBlock {
122    let mut items = Vec::new();
123    for item in list.children() {
124        let mut text = String::new();
125        let mut child_nodes = Vec::new();
126        for ch in item.children() {
127            match &ch.data.borrow().value {
128                NodeValue::Paragraph if text.is_empty() => text = text_of(ch),
129                NodeValue::List(_) => child_nodes.push(ch),
130                _ => {}
131            }
132        }
133        let (checked, text) = strip_checkbox(&text);
134        items.push(DocItem {
135            text,
136            checked,
137            children: build_blocks(child_nodes.into_iter()),
138        });
139    }
140    DocBlock::List {
141        ordered: matches!(list_type, ListType::Ordered),
142        items,
143    }
144}
145
146/// Concatenate the inline text of a node (headings, paragraphs, item lines).
147fn text_of<'a>(node: &'a AstNode<'a>) -> String {
148    let mut s = String::new();
149    collect_text(node, &mut s);
150    s.trim().to_string()
151}
152
153fn collect_text<'a>(node: &'a AstNode<'a>, out: &mut String) {
154    for c in node.children() {
155        match &c.data.borrow().value {
156            NodeValue::Text(t) => out.push_str(t),
157            NodeValue::Code(code) => out.push_str(&code.literal),
158            // Comrak parses HTML-like text (`<uuid>`, `<ctrl><shift>`) as raw
159            // inline HTML; its literal is the source text, which we must keep or
160            // the round-trip silently drops anything in angle brackets.
161            NodeValue::HtmlInline(html) => out.push_str(html),
162            NodeValue::SoftBreak | NodeValue::LineBreak => out.push(' '),
163            _ => collect_text(c, out),
164        }
165    }
166}
167
168/// Pull a leading `[ ]` / `[x]` checkbox off an item's text (no GFM extension).
169fn strip_checkbox(text: &str) -> (Option<bool>, String) {
170    let t = text.trim_start();
171    if let Some(rest) = t.strip_prefix("[ ] ") {
172        (Some(false), rest.to_string())
173    } else if let Some(rest) = t.strip_prefix("[x] ").or_else(|| t.strip_prefix("[X] ")) {
174        (Some(true), rest.to_string())
175    } else {
176        (None, text.to_string())
177    }
178}
179
180fn strip_frontmatter(md: &str) -> &str {
181    let Some(rest) = md.strip_prefix("---\n") else {
182        return md;
183    };
184    match rest.find("\n---") {
185        Some(end) => {
186            let after = &rest[end + 4..];
187            after.strip_prefix('\n').unwrap_or(after)
188        }
189        None => md,
190    }
191}
192
193// ---- the matcher ----------------------------------------------------------
194
195fn match_nodes(
196    schema: &[Node],
197    doc: &[DocBlock],
198    opts: SchemaOpts,
199    path: &mut Vec<String>,
200    problems: &mut Vec<Problem>,
201) {
202    let mut cursor = 0usize;
203    for node in schema {
204        let start = if opts.ordered { cursor } else { 0 };
205        match node {
206            Node::Heading {
207                level,
208                title,
209                head,
210                children,
211            } => {
212                let idxs: Vec<usize> = (start..doc.len())
213                    .filter(|&i| is_section(&doc[i], *level, title))
214                    .collect();
215                if let Some(msg) = card_problem(head.card, idxs.len(), "section") {
216                    problems.push(problem(path, alias(node), msg));
217                }
218                for &i in &idxs {
219                    if let DocBlock::Section { children: sc, .. } = &doc[i] {
220                        path.push(alias(node));
221                        match_nodes(children, sc, opts, path, problems);
222                        path.pop();
223                    }
224                }
225                if opts.ordered {
226                    if let Some(&last) = idxs.last() {
227                        cursor = last + 1;
228                    }
229                }
230            }
231            Node::List {
232                style,
233                item,
234                head,
235                children,
236            } => {
237                let found = (start..doc.len()).find(|&i| is_list(&doc[i], *style));
238                let count = match found {
239                    Some(i) => {
240                        if let DocBlock::List { items, .. } = &doc[i] {
241                            items.len()
242                        } else {
243                            0
244                        }
245                    }
246                    None => 0,
247                };
248                if let Some(msg) = card_problem(head.card, count, "item") {
249                    problems.push(problem(path, alias(node), msg));
250                }
251                if let Some(i) = found {
252                    if let DocBlock::List { items, .. } = &doc[i] {
253                        for it in items {
254                            if let Some(m) = item {
255                                if !matches_text(m, &it.text) {
256                                    problems.push(problem(
257                                        path,
258                                        alias(node),
259                                        format!("item does not match {}: {}", describe(m), it.text),
260                                    ));
261                                }
262                            }
263                            if !children.is_empty() {
264                                path.push(alias(node));
265                                match_nodes(children, &it.children, opts, path, problems);
266                                path.pop();
267                            }
268                        }
269                    }
270                    if opts.ordered {
271                        cursor = i + 1;
272                    }
273                }
274            }
275            Node::Prose { text, head } => {
276                let found = (start..doc.len()).find(|&i| matches!(doc[i], DocBlock::Para(_)));
277                if let Some(i) = found {
278                    if let DocBlock::Para(t) = &doc[i] {
279                        if let Some(m) = text {
280                            if !matches_text(m, t) {
281                                problems.push(problem(
282                                    path,
283                                    alias(node),
284                                    format!("paragraph does not match {}", describe(m)),
285                                ));
286                            }
287                        }
288                    }
289                    if opts.ordered {
290                        cursor = i + 1;
291                    }
292                } else if presence_required(head.card) {
293                    problems.push(problem(
294                        path,
295                        alias(node),
296                        "missing required paragraph".into(),
297                    ));
298                }
299            }
300        }
301    }
302}
303
304fn is_section(b: &DocBlock, level: u8, title: &Match) -> bool {
305    matches!(b, DocBlock::Section { level: l, title: t, .. } if *l == level && matches_text(title, t))
306}
307
308fn is_list(b: &DocBlock, style: ListStyle) -> bool {
309    let DocBlock::List { ordered, items } = b else {
310        return false;
311    };
312    match style {
313        ListStyle::Ordered => *ordered,
314        ListStyle::Bullet => !*ordered,
315        ListStyle::Checklist => !*ordered && items.iter().any(|i| i.checked.is_some()),
316    }
317}
318
319fn matches_text(m: &Match, text: &str) -> bool {
320    match m {
321        Match::Literal(l) => text.trim_start().starts_with(l),
322        Match::Regex(p) => Regex::new(p).map(|re| re.is_match(text)).unwrap_or(false),
323    }
324}
325
326fn describe(m: &Match) -> String {
327    match m {
328        Match::Literal(l) => format!("\"{l}\""),
329        Match::Regex(p) => format!("/{p}/"),
330    }
331}
332
333/// `Some(message)` when `count` violates the cardinality. `unit` is "section"
334/// or "item" for the message.
335fn card_problem(card: Card, count: usize, unit: &str) -> Option<String> {
336    let ok = match card {
337        Card::Required | Card::Plus => count >= 1,
338        Card::Optional => count <= 1,
339        Card::Star => true,
340        Card::Range(min, max) => {
341            count >= min as usize && max.map(|m| count <= m as usize).unwrap_or(true)
342        }
343    };
344    if ok {
345        return None;
346    }
347    Some(match card {
348        Card::Required | Card::Plus => format!("expected at least one {unit}, found {count}"),
349        Card::Optional => format!("expected at most one {unit}, found {count}"),
350        Card::Range(min, Some(max)) => format!("expected {min}..{max} {unit}(s), found {count}"),
351        Card::Range(min, None) => format!("expected at least {min} {unit}(s), found {count}"),
352        Card::Star => unreachable!(),
353    })
354}
355
356fn presence_required(card: Card) -> bool {
357    matches!(card, Card::Required | Card::Plus | Card::Range(1.., _))
358}
359
360fn alias(node: &Node) -> String {
361    let head = match node {
362        Node::Heading { head, title, .. } => {
363            if let Some(n) = &head.name {
364                return n.clone();
365            }
366            if let Match::Literal(t) = title {
367                return slug(t);
368            }
369            head
370        }
371        Node::List { head, .. } | Node::Prose { head, .. } => head,
372    };
373    head.name.clone().unwrap_or_else(|| "block".to_string())
374}
375
376fn slug(s: &str) -> String {
377    s.chars()
378        .map(|c| {
379            if c.is_ascii_alphanumeric() {
380                c.to_ascii_lowercase()
381            } else {
382                '_'
383            }
384        })
385        .collect::<String>()
386        .trim_matches('_')
387        .to_string()
388}
389
390fn problem(path: &[String], alias: String, message: String) -> Problem {
391    let mut p = path.to_vec();
392    p.push(alias);
393    Problem { path: p, message }
394}
395
396// ---- extraction (capture aliases → JSON) ---------------------------------
397
398/// The alias a node captures under, or `None` if it isn't captured (an unnamed
399/// list/prose; a regex-titled heading without `@name`).
400fn capture_alias(node: &Node) -> Option<String> {
401    match node {
402        Node::Heading { head, title, .. } => head.name.clone().or_else(|| match title {
403            Match::Literal(t) => Some(slug(t)),
404            Match::Regex(_) => None,
405        }),
406        Node::List { head, .. } | Node::Prose { head, .. } => head.name.clone(),
407    }
408}
409
410/// A heading/range cardinality that yields an array rather than a single value.
411fn repeated(card: Card) -> bool {
412    matches!(card, Card::Plus | Card::Star | Card::Range(..))
413}
414
415fn extract_into(schema: &[Node], doc: &[DocBlock], obj: &mut Map<String, Value>) {
416    for node in schema {
417        let Some(key) = capture_alias(node) else {
418            continue;
419        };
420        let value = match node {
421            Node::Heading {
422                level,
423                title,
424                head,
425                children,
426            } => {
427                let secs: Vec<&DocBlock> = doc
428                    .iter()
429                    .filter(|b| is_section(b, *level, title))
430                    .collect();
431                let make = |sec: &DocBlock| {
432                    let mut m = Map::new();
433                    if let DocBlock::Section {
434                        children: sc,
435                        title: t,
436                        ..
437                    } = sec
438                    {
439                        if matches!(title, Match::Regex(_)) {
440                            m.insert("title".into(), Value::String(t.clone()));
441                        }
442                        extract_into(children, sc, &mut m);
443                    }
444                    Value::Object(m)
445                };
446                if repeated(head.card) {
447                    Value::Array(secs.iter().map(|s| make(s)).collect())
448                } else {
449                    secs.first().map(|s| make(s)).unwrap_or(Value::Null)
450                }
451            }
452            Node::List {
453                style,
454                item,
455                head,
456                children,
457            } => {
458                let items: &[DocItem] = match doc.iter().find(|b| is_list(b, *style)) {
459                    Some(DocBlock::List { items, .. }) => items,
460                    _ => &[],
461                };
462                let single = matches!(head.card, Card::Required | Card::Optional);
463                if single && children.is_empty() {
464                    // A single labeled bullet captures the text after its label.
465                    items
466                        .first()
467                        .map(|it| Value::String(after_label(item, &it.text)))
468                        .unwrap_or(Value::Null)
469                } else {
470                    Value::Array(
471                        items
472                            .iter()
473                            .map(|it| {
474                                if children.is_empty() {
475                                    Value::String(it.text.clone())
476                                } else {
477                                    let mut m = Map::new();
478                                    m.insert("text".into(), Value::String(it.text.clone()));
479                                    extract_into(children, &it.children, &mut m);
480                                    Value::Object(m)
481                                }
482                            })
483                            .collect(),
484                    )
485                }
486            }
487            Node::Prose { .. } => match doc.iter().find(|b| matches!(b, DocBlock::Para(_))) {
488                Some(DocBlock::Para(t)) => Value::String(t.clone()),
489                _ => Value::Null,
490            },
491        };
492        obj.insert(key, value);
493    }
494}
495
496/// The text after a literal label (`Docs: foo` with label `Docs:` → `foo`);
497/// otherwise the whole text.
498fn after_label(item: &Option<Match>, text: &str) -> String {
499    if let Some(Match::Literal(l)) = item {
500        if let Some(rest) = text.trim_start().strip_prefix(l.as_str()) {
501            return rest.trim().to_string();
502        }
503    }
504    text.to_string()
505}