1use 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 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 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#[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
72enum 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
93fn 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
146fn 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 NodeValue::HtmlInline(html) => out.push_str(html),
162 NodeValue::SoftBreak | NodeValue::LineBreak => out.push(' '),
163 _ => collect_text(c, out),
164 }
165 }
166}
167
168fn 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
193fn 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
333fn 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
396fn 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
410fn 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 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
496fn 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}