oxc_markdown_parser/parser.rs
1//! Parser entry point.
2//!
3//! Two-phase design:
4//! a block phase resolves the container structure and hands each leaf's logical content
5//! (container prefixes (`>`, list indents) already stripped, as segments over the original source)
6//! to an inline phase, together with the reference-definition map collected while blocks were finalized.
7//! Inline spans always point into the original source.
8//!
9//! Construct toggles hook into three fixed decision points of those loops:
10//!
11//! - `<` at flow start / in text: HTML block / inline HTML vs MDX JSX
12//! - `{` in flow / text: literal text (or liquid) vs MDX expression
13//! - 4-space indent at flow start: indented code vs paragraph continuation
14//!
15//! Everything else (GFM, math, wiki-link, liquid, directives) is an ordinary construct
16//! added to the block or inline scan under its flag.
17
18use oxc_allocator::{Allocator, ArenaVec};
19
20use crate::ast::Root;
21use crate::diagnostic::{Diagnostic, DiagnosticKind};
22use crate::options::ParserOptions;
23use crate::pos::Span;
24
25/// The outcome of a parse: an AST plus diagnostics.
26///
27/// Parsing never fails.
28/// CommonMark has no syntax errors,
29/// and MDX-mode errors are reported here while the parse recovers.
30#[derive(Debug)]
31pub struct ParserReturn<'a> {
32 pub root: Root<'a>,
33 pub diagnostics: Vec<Diagnostic>,
34 /// Every logical blank line, in source order:
35 /// the span after the container prefixes (empty for a bare `>` line inside a blockquote),
36 /// up to the line ending.
37 /// Lines inside fenced blocks are content, never listed;
38 /// blank lines inside indented code are listed (they are only content if more code follows),
39 /// consumers compare against node spans.
40 /// Blank runs between siblings carry meaning (HTML verbatim boundaries, loose lists);
41 /// exposing the table the block phase already built saves consumers a container-aware rescan.
42 /// (List tightness itself is already on [`crate::ast::List::tight`],
43 /// computed from the subset that can loosen a list.)
44 pub blanks: Vec<Span>,
45}
46
47pub struct Parser<'a> {
48 allocator: &'a Allocator,
49 source: &'a str,
50 options: ParserOptions,
51}
52
53impl<'a> Parser<'a> {
54 /// Markdown mode with the default constructs.
55 pub fn new(allocator: &'a Allocator, source: &'a str) -> Self {
56 Self::with_options(allocator, source, ParserOptions::default())
57 }
58
59 pub fn with_options(allocator: &'a Allocator, source: &'a str, options: ParserOptions) -> Self {
60 Self { allocator, source, options }
61 }
62
63 /// Parse the source into a [`Root`].
64 ///
65 /// Front matter is not handled here: callers strip it before parsing
66 /// (offsets in the AST are relative to what was passed in).
67 pub fn parse(self) -> ParserReturn<'a> {
68 let mut diagnostics = vec![];
69 if u32::try_from(self.source.len()).is_err() {
70 diagnostics.push(Diagnostic::new(DiagnosticKind::SourceTooLong, Span::empty(0)));
71 return ParserReturn {
72 root: Root { children: ArenaVec::new_in(&self.allocator), span: Span::empty(0) },
73 diagnostics,
74 blanks: vec![],
75 };
76 }
77
78 #[expect(clippy::cast_possible_truncation)]
79 let source_len = self.source.len() as u32;
80 let engine = crate::block::Engine::new(self.source, self.options.constructs);
81 let (irs, blanks, refs) = engine.run();
82 let root = crate::block::build(
83 self.allocator,
84 self.source,
85 &blanks.loosening,
86 irs,
87 Span::new(0, source_len),
88 &self.options.constructs,
89 &refs,
90 );
91 ParserReturn { root, diagnostics, blanks: blanks.all }
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use crate::ast::{Block, HeadingKind};
99
100 /// Every blank line is listed; only some loosen the list:
101 /// a bare `>` line inside a blockquote does not (it still ends an HTML block for consumers).
102 #[test]
103 fn blank_lines_listed_vs_loosening() {
104 for (source, blank_at, tight) in
105 [("- > a\n >\n > b\n", 9, true), ("- a\n\n- b\n", 4, false)]
106 {
107 let allocator = Allocator::default();
108 let ret = Parser::new(&allocator, source).parse();
109 assert_eq!(ret.blanks, vec![Span::empty(blank_at)], "{source:?}");
110 let Some(Block::List(list)) = ret.root.children.first() else { panic!("list") };
111 assert_eq!(list.tight, tight, "{source:?}");
112 }
113 }
114
115 /// A lazy line absorbed into a setext heading stays recorded.
116 #[test]
117 fn setext_heading_keeps_lazy_lines() {
118 let allocator = Allocator::default();
119 let ret = Parser::new(&allocator, "> a\nb\n> ===\n").parse();
120 let Some(Block::Blockquote(quote)) = ret.root.children.first() else { panic!("quote") };
121 let Some(Block::Heading(heading)) = quote.children.first() else { panic!("heading") };
122 let HeadingKind::Setext { lazy_lines, .. } = &heading.kind else { panic!("setext") };
123 assert_eq!(lazy_lines.as_slice(), &[Span::new(4, 5)]);
124 }
125}