Skip to main content

mcd_core/
markdown.rs

1//! Markdown parsing into the MCD canonical block stream.
2
3use std::collections::HashSet;
4
5use comrak::{
6    Arena, Options,
7    nodes::{AstNode, NodeCode, NodeMath, NodeValue, Sourcepos},
8    parse_document,
9};
10
11use crate::{
12    directives::{DirectiveParseOptions, parse_image_directive, parse_table_directive},
13    document::{AnnotationRef, DocumentBlock, McdDocument, SourceSpan},
14    errors::{Diagnostic, McdError, Result},
15};
16
17/// Markdown parsing options.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct MarkdownParseOptions {
20    /// Reject unknown directive fields.
21    pub strict_directives: bool,
22}
23
24impl Default for MarkdownParseOptions {
25    fn default() -> Self {
26        Self {
27            strict_directives: true,
28        }
29    }
30}
31
32/// Parse Markdown into the canonical MCD document stream.
33pub fn parse_markdown(source_path: &str, markdown: &str) -> Result<McdDocument> {
34    parse_markdown_with_options(source_path, markdown, MarkdownParseOptions::default())
35}
36
37/// Parse Markdown into the canonical MCD document stream with explicit options.
38pub fn parse_markdown_with_options(
39    source_path: &str,
40    markdown: &str,
41    options: MarkdownParseOptions,
42) -> Result<McdDocument> {
43    let arena = Arena::new();
44    let mut comrak_options = Options::default();
45    comrak_options.extension.block_directive = true;
46    comrak_options.extension.math_dollars = true;
47    comrak_options.extension.math_code = true;
48    comrak_options.parse.sourcepos_chars = true;
49
50    let root = parse_document(&arena, markdown, &comrak_options);
51    let mut parser = MarkdownBlockParser {
52        source_path,
53        options,
54        next_index: 1,
55        blocks: Vec::new(),
56        placement_refs: HashSet::new(),
57    };
58    parser.visit_container(root)?;
59
60    Ok(McdDocument {
61        source_path: source_path.to_string(),
62        blocks: parser.blocks,
63    })
64}
65
66struct MarkdownBlockParser<'source> {
67    source_path: &'source str,
68    options: MarkdownParseOptions,
69    next_index: usize,
70    blocks: Vec<DocumentBlock>,
71    placement_refs: HashSet<String>,
72}
73
74impl MarkdownBlockParser<'_> {
75    fn visit_container<'arena>(&mut self, node: &'arena AstNode<'arena>) -> Result<()> {
76        for child in node.children() {
77            self.visit_block(child)?;
78        }
79        Ok(())
80    }
81
82    fn visit_block<'arena>(&mut self, node: &'arena AstNode<'arena>) -> Result<()> {
83        let data = node.data();
84        let source = source_span(data.sourcepos);
85        match &data.value {
86            NodeValue::Document => self.visit_container(node),
87            NodeValue::Heading(heading) => {
88                let (text, annotations) = extract_inline_annotation_refs(&collect_plain_text(node));
89                let id = self.next_id("heading");
90                self.blocks.push(DocumentBlock::Heading {
91                    id,
92                    level: heading.level,
93                    text,
94                    source,
95                    annotations,
96                });
97                Ok(())
98            }
99            NodeValue::Paragraph => {
100                if let Some(math) = display_math_text(node) {
101                    let id = self.next_id("math_block");
102                    self.blocks.push(DocumentBlock::MathBlock {
103                        id,
104                        text: math,
105                        source,
106                    });
107                } else {
108                    let (text, annotations) =
109                        extract_inline_annotation_refs(&collect_plain_text(node));
110                    let id = self.next_id("paragraph");
111                    self.blocks.push(DocumentBlock::Paragraph {
112                        id,
113                        text,
114                        source,
115                        annotations,
116                    });
117                }
118                Ok(())
119            }
120            NodeValue::List(_) => {
121                let (text, annotations) = extract_inline_annotation_refs(&collect_plain_text(node));
122                let id = self.next_id("list");
123                self.blocks.push(DocumentBlock::List {
124                    id,
125                    text,
126                    source,
127                    annotations,
128                });
129                Ok(())
130            }
131            NodeValue::CodeBlock(code) => {
132                let info = code.info.trim();
133                let text = code.literal.clone();
134                if info == "math" {
135                    let id = self.next_id("math_block");
136                    self.blocks
137                        .push(DocumentBlock::MathBlock { id, text, source });
138                } else {
139                    let id = self.next_id("code_block");
140                    self.blocks.push(DocumentBlock::CodeBlock {
141                        id,
142                        language: (!info.is_empty()).then(|| info.to_string()),
143                        text,
144                        source,
145                        annotations: Vec::new(),
146                    });
147                }
148                Ok(())
149            }
150            NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => {
151                let (text, annotations) = extract_inline_annotation_refs(&collect_plain_text(node));
152                let id = self.next_id("quote");
153                self.blocks.push(DocumentBlock::Quote {
154                    id,
155                    text,
156                    source,
157                    annotations,
158                });
159                Ok(())
160            }
161            NodeValue::BlockDirective(directive) => {
162                let info = directive.info.trim();
163                match info {
164                    "table" => {
165                        let body = collect_plain_text(node);
166                        let placement = parse_table_directive(
167                            &body,
168                            source,
169                            DirectiveParseOptions {
170                                strict: self.options.strict_directives,
171                            },
172                        )
173                        .map_err(|err| self.with_markdown_source(err, source))?;
174                        self.validate_placement_ref(placement.ref_id.as_deref(), source)?;
175                        let id = self.next_id("table_ref");
176                        self.blocks.push(DocumentBlock::TableRef {
177                            id,
178                            placement,
179                            source,
180                        });
181                        Ok(())
182                    }
183                    "image" => {
184                        let body = collect_plain_text(node);
185                        let placement = parse_image_directive(
186                            &body,
187                            source,
188                            DirectiveParseOptions {
189                                strict: self.options.strict_directives,
190                            },
191                        )
192                        .map_err(|err| self.with_markdown_source(err, source))?;
193                        self.validate_placement_ref(placement.ref_id.as_deref(), source)?;
194                        let id = self.next_id("image_ref");
195                        self.blocks.push(DocumentBlock::ImageRef {
196                            id,
197                            placement,
198                            source,
199                        });
200                        Ok(())
201                    }
202                    "chart" => Err(McdError::from_diagnostic(
203                        self.diagnostic(
204                            "directive.chart.unsupported",
205                            "No separate :::chart directive is supported in MCD 0.1; use :::table with display: chart.",
206                            source,
207                        ),
208                    )),
209                    _ => {
210                        self.visit_container(node)?;
211                        Ok(())
212                    }
213                }
214            }
215            NodeValue::Item(_) => self.visit_container(node),
216            NodeValue::Table(_) => {
217                let id = self.next_id("paragraph");
218                self.blocks.push(DocumentBlock::Paragraph {
219                    id,
220                    text: collect_plain_text(node),
221                    source,
222                    annotations: Vec::new(),
223                });
224                Ok(())
225            }
226            NodeValue::HtmlBlock(html) => {
227                let id = self.next_id("code_block");
228                self.blocks.push(DocumentBlock::CodeBlock {
229                    id,
230                    language: Some("html".to_string()),
231                    text: html.literal.clone(),
232                    source,
233                    annotations: Vec::new(),
234                });
235                Ok(())
236            }
237            NodeValue::ThematicBreak
238            | NodeValue::FrontMatter(_)
239            | NodeValue::FootnoteDefinition(_)
240            | NodeValue::TableRow(_)
241            | NodeValue::TableCell
242            | NodeValue::DescriptionList
243            | NodeValue::DescriptionItem(_)
244            | NodeValue::DescriptionTerm
245            | NodeValue::DescriptionDetails
246            | NodeValue::TaskItem(_)
247            | NodeValue::Alert(_)
248            | NodeValue::Subtext => self.visit_container(node),
249            _ => Ok(()),
250        }
251    }
252
253    fn validate_placement_ref(
254        &mut self,
255        placement_ref: Option<&str>,
256        source: Option<SourceSpan>,
257    ) -> Result<()> {
258        let Some(placement_ref) = placement_ref else {
259            return Ok(());
260        };
261        if self.placement_refs.insert(placement_ref.to_string()) {
262            Ok(())
263        } else {
264            Err(McdError::from_diagnostic(self.diagnostic(
265                "directive.ref.duplicate",
266                format!("Duplicate placement ref '{placement_ref}'."),
267                source,
268            )))
269        }
270    }
271
272    fn next_id(&mut self, kind: &str) -> String {
273        let id = format!("block-{:04}-{kind}", self.next_index);
274        self.next_index += 1;
275        id
276    }
277
278    fn diagnostic(
279        &self,
280        code: impl Into<String>,
281        message: impl Into<String>,
282        source: Option<SourceSpan>,
283    ) -> Diagnostic {
284        let source = source.map(|span| format!("{}:{span}", self.source_path));
285        let mut diagnostic = Diagnostic::error(code, message);
286        if let Some(source) = source {
287            diagnostic = diagnostic.with_source(source);
288        }
289        diagnostic
290    }
291
292    fn with_markdown_source(&self, err: McdError, source: Option<SourceSpan>) -> McdError {
293        let existing = match err.diagnostic() {
294            Some(diagnostic) => diagnostic.clone(),
295            None => return err,
296        };
297        let Some(source) = source else {
298            return err;
299        };
300        let mut diagnostic = existing;
301        diagnostic.source = Some(format!("{}:{source}", self.source_path));
302        McdError::from_diagnostic(diagnostic)
303    }
304}
305
306fn source_span(sourcepos: Sourcepos) -> Option<SourceSpan> {
307    (sourcepos.start.line > 0).then_some(SourceSpan {
308        start_line: sourcepos.start.line,
309        start_column: sourcepos.start.column,
310        end_line: sourcepos.end.line,
311        end_column: sourcepos.end.column,
312    })
313}
314
315fn display_math_text<'arena>(node: &'arena AstNode<'arena>) -> Option<String> {
316    let mut non_break_children = node.children().filter(|child| {
317        !matches!(
318            child.data().value,
319            NodeValue::SoftBreak | NodeValue::LineBreak
320        )
321    });
322    let only = non_break_children.next()?;
323    if non_break_children.next().is_some() {
324        return None;
325    }
326    match &only.data().value {
327        NodeValue::Math(NodeMath {
328            display_math: true,
329            literal,
330            ..
331        }) => Some(literal.clone()),
332        _ => None,
333    }
334}
335
336fn collect_plain_text<'arena>(node: &'arena AstNode<'arena>) -> String {
337    let mut text = String::new();
338    collect_plain_text_inner(node, &mut text);
339    normalize_collected_text(&text)
340}
341
342fn collect_plain_text_inner<'arena>(node: &'arena AstNode<'arena>, text: &mut String) {
343    match &node.data().value {
344        NodeValue::Text(value) => text.push_str(value),
345        NodeValue::Link(link) => {
346            let mut label = String::new();
347            for child in node.children() {
348                collect_plain_text_inner(child, &mut label);
349            }
350            text.push('[');
351            text.push_str(&label);
352            text.push_str("](");
353            text.push_str(&link.url);
354            if !link.title.is_empty() {
355                text.push_str(" \"");
356                text.push_str(&link.title);
357                text.push('"');
358            }
359            text.push(')');
360        }
361        NodeValue::Code(NodeCode { literal, .. }) => text.push_str(literal),
362        NodeValue::HtmlInline(value) => text.push_str(value),
363        NodeValue::Math(NodeMath {
364            display_math,
365            literal,
366            ..
367        }) => {
368            if *display_math {
369                text.push_str("$$");
370                text.push_str(literal);
371                text.push_str("$$");
372            } else {
373                text.push('$');
374                text.push_str(literal);
375                text.push('$');
376            }
377        }
378        NodeValue::SoftBreak | NodeValue::LineBreak => text.push('\n'),
379        NodeValue::CodeBlock(code) => text.push_str(&code.literal),
380        NodeValue::HtmlBlock(html) => text.push_str(&html.literal),
381        _ => {
382            for child in node.children() {
383                collect_plain_text_inner(child, text);
384                if child.data().value.block() && !text.ends_with('\n') {
385                    text.push('\n');
386                }
387            }
388        }
389    }
390}
391
392fn normalize_collected_text(text: &str) -> String {
393    text.lines()
394        .map(str::trim_end)
395        .collect::<Vec<_>>()
396        .join("\n")
397        .trim()
398        .to_string()
399}
400
401fn extract_inline_annotation_refs(text: &str) -> (String, Vec<AnnotationRef>) {
402    let mut cleaned = String::new();
403    let mut annotations = Vec::new();
404    let mut rest = text;
405
406    while let Some(start) = rest.find("[[annotation:") {
407        cleaned.push_str(&rest[..start]);
408        let marker = &rest[start + "[[annotation:".len()..];
409        let Some(end) = marker.find("]]") else {
410            cleaned.push_str(&rest[start..]);
411            return (cleaned, annotations);
412        };
413
414        let id = marker[..end].trim();
415        if id
416            .chars()
417            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
418            && !id.is_empty()
419        {
420            annotations.push(AnnotationRef {
421                id: id.to_owned(),
422                text_offset: Some(cleaned.len()),
423            });
424            rest = &marker[end + 2..];
425        } else {
426            cleaned.push_str(&rest[..start + "[[annotation:".len() + end + 2]);
427            rest = &marker[end + 2..];
428        }
429    }
430
431    cleaned.push_str(rest);
432    (cleaned, annotations)
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::{directives::TableDisplay, document::DocumentBlock};
439
440    #[test]
441    fn emits_blocks_in_source_order_with_directives() {
442        let doc = parse_markdown(
443            "content/main.md",
444            "# Revenue\n\nIntro.\n\n:::table\nref: revenue-chart\ntable: revenue\nview: quarterly-bar-chart\ndisplay: chart\ncaption: Revenue by quarter\n:::\n\n:::image\nref: process-diagram\nasset: process-diagram\nalt: Diagram\n:::\n",
445        )
446        .expect("markdown parses");
447
448        assert_eq!(doc.blocks.len(), 4);
449        assert!(matches!(doc.blocks[0], DocumentBlock::Heading { .. }));
450        assert!(matches!(doc.blocks[1], DocumentBlock::Paragraph { .. }));
451        match &doc.blocks[2] {
452            DocumentBlock::TableRef { placement, .. } => {
453                assert_eq!(placement.ref_id.as_deref(), Some("revenue-chart"));
454                assert_eq!(placement.display, TableDisplay::Chart);
455            }
456            other => panic!("expected table ref, got {other:?}"),
457        }
458        assert!(matches!(doc.blocks[3], DocumentBlock::ImageRef { .. }));
459    }
460
461    #[test]
462    fn duplicate_placement_refs_fail() {
463        let err = parse_markdown(
464            "content/main.md",
465            ":::table\nref: repeated\ntable: revenue\n:::\n\n:::image\nref: repeated\nasset: diagram\n:::\n",
466        )
467        .expect_err("duplicate refs should fail");
468
469        assert_eq!(
470            err.diagnostic().map(|d| d.code.as_str()),
471            Some("directive.ref.duplicate")
472        );
473    }
474
475    #[test]
476    fn chart_directive_is_not_supported() {
477        let err = parse_markdown(
478            "content/main.md",
479            ":::chart\nref: revenue-chart\ntable: revenue\n:::\n",
480        )
481        .expect_err("chart directive should fail");
482
483        assert_eq!(
484            err.diagnostic().map(|d| d.code.as_str()),
485            Some("directive.chart.unsupported")
486        );
487    }
488
489    #[test]
490    fn extracts_inline_annotation_markers_from_text() {
491        let doc = parse_markdown(
492            "content/main.md",
493            "Revenue[[annotation:review-revenue]] increased.\n",
494        )
495        .expect("markdown parses");
496
497        match &doc.blocks[0] {
498            DocumentBlock::Paragraph {
499                text, annotations, ..
500            } => {
501                assert_eq!(text, "Revenue increased.");
502                assert_eq!(annotations[0].id, "review-revenue");
503                assert_eq!(annotations[0].text_offset, Some("Revenue".len()));
504            }
505            other => panic!("expected paragraph, got {other:?}"),
506        }
507    }
508
509    #[test]
510    fn preserves_markdown_link_targets_in_text() {
511        let doc = parse_markdown(
512            "content/main.md",
513            r#"Read [the guide](https://example.com/guide "Guide") now."#,
514        )
515        .expect("markdown parses");
516
517        match &doc.blocks[0] {
518            DocumentBlock::Paragraph { text, .. } => {
519                assert_eq!(
520                    text,
521                    r#"Read [the guide](https://example.com/guide "Guide") now."#
522                );
523            }
524            other => panic!("expected paragraph, got {other:?}"),
525        }
526    }
527
528    #[test]
529    fn preserves_inline_math_delimiters_in_text() {
530        let doc = parse_markdown("content/main.md", "Inline $x^2 + y^2 = z^2$ remains math.")
531            .expect("markdown parses");
532
533        match &doc.blocks[0] {
534            DocumentBlock::Paragraph { text, .. } => {
535                assert_eq!(text, "Inline $x^2 + y^2 = z^2$ remains math.");
536            }
537            other => panic!("expected paragraph, got {other:?}"),
538        }
539    }
540}