1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct MarkdownParseOptions {
20 pub strict_directives: bool,
22}
23
24impl Default for MarkdownParseOptions {
25 fn default() -> Self {
26 Self {
27 strict_directives: true,
28 }
29 }
30}
31
32pub fn parse_markdown(source_path: &str, markdown: &str) -> Result<McdDocument> {
34 parse_markdown_with_options(source_path, markdown, MarkdownParseOptions::default())
35}
36
37pub 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}