Skip to main content

oxc_markdown_parser/block/
lexical.rs

1//! Lexical predicates: grammar facts for formatters.
2//!
3//! A markdown formatter constantly synthesizes new line starts
4//! (joining lines for prose wrap, stripping indent, removing escapes).
5//! Whether the synthesized line would open a block is a grammar fact,
6//! and approximating it downstream is the classic printer failure mode
7//! (Prettier's `/^>|^(?:[*+-]|#{1,6}|\d+[).])$/` misses `:::`, fences, `<`, and `|`.
8//! Each a documented meaning-breaking bug).
9//! This module exposes the parser's own classification instead:
10//! [`line_start`] runs the exact probe table the block engine uses, so the two cannot drift.
11//!
12//! Policy-free: these functions state what the grammar would do with a line,
13//! never what a formatter should do about it.
14
15use crate::options::Constructs;
16use crate::syntax::{self, run_len};
17
18use super::probe::{Start, probe};
19use super::scan;
20
21/// What a line would open at flow position.
22/// One variant per block construct;
23/// the payload-free mirror of the engine's probe result.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum LineStart {
27    Blockquote,
28    /// Only reported with `in_paragraph` (an underline needs a paragraph).
29    SetextUnderline,
30    ThematicBreak,
31    AtxHeading,
32    CodeFence,
33    HtmlBlock,
34    MathFence,
35    Liquid,
36    /// A directive opening fence (`:::name…`): opens a container, interrupts a paragraph.
37    DirectiveOpener,
38    /// A bare `:::` run: closes an open directive, inert otherwise.
39    DirectiveCloser,
40    FootnoteDefinition,
41    ListItem,
42    /// Only reported with `in_paragraph`
43    /// (a delimiter row activates by stealing the paragraph's last line as the header).
44    TableDelimiterRow,
45    /// A `|`-initiated line: a row if a table is open or forming.
46    TableRow,
47}
48
49/// The number of an ordered list marker (`123.` / `123)`), for a printer that renumbers.
50/// CommonMark caps markers at 9 digits, so the value always fits; `None` for a bullet.
51pub fn ordered_number(marker: &str) -> Option<u32> {
52    marker.trim_end_matches(['.', ')']).parse().ok()
53}
54
55/// Classifies what `line` (one line, no line ending) would start as a block,
56/// or `None` for paragraph text.
57/// `in_paragraph` enables the constructs that only exist relative to an open paragraph.
58///
59/// Leading indent is handled here: 4+ columns of indent is indented code (never a block start),
60/// per the default `code_indented` construct.
61pub fn line_start(constructs: &Constructs, line: &str, in_paragraph: bool) -> Option<LineStart> {
62    let indent = run_len(line.as_bytes(), b' ');
63    // 4+ spaces, or a tab anywhere in the first ≤3 columns
64    // (a tab there always advances past column 4): indented code, not a block start.
65    if indent >= 4 || line.as_bytes().get(indent) == Some(&b'\t') {
66        return None;
67    }
68    let tail = &line[indent..];
69    if let Some(start) = probe(constructs, tail, in_paragraph) {
70        return Some(match start {
71            Start::Quote => LineStart::Blockquote,
72            Start::Setext(_) => LineStart::SetextUnderline,
73            Start::ThematicBreak => LineStart::ThematicBreak,
74            Start::Atx { .. } => LineStart::AtxHeading,
75            Start::Fence { .. } => LineStart::CodeFence,
76            Start::Html { .. } => LineStart::HtmlBlock,
77            Start::MathFence { .. } => LineStart::MathFence,
78            Start::Liquid => {
79                // A tag closed on this line interrupts only when nothing follows its closer;
80                // an unclosed one may still close on a later line.
81                let closer = syntax::liquid::open(tail)?;
82                if let Some(after) = syntax::liquid::close(&tail[2..], closer)
83                    && !scan::is_blank(&tail[2 + after..])
84                {
85                    return None;
86                }
87                LineStart::Liquid
88            }
89            Start::Directive { .. } => LineStart::DirectiveOpener,
90            Start::FootnoteDef { .. } => LineStart::FootnoteDefinition,
91            Start::ListItem { .. } => LineStart::ListItem,
92            Start::TableDelimiter(_) => LineStart::TableDelimiterRow,
93        });
94    }
95    // Below the probe table:
96    // line shapes that are inert on their own but significant next to an open construct.
97    // Exactly what a formatter must not create by joining lines.
98    if constructs.container_directive && scan::fence_close(tail, b':', 3) {
99        return Some(LineStart::DirectiveCloser);
100    }
101    if constructs.gfm_table && tail.starts_with('|') {
102        return Some(LineStart::TableRow);
103    }
104    None
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    fn md(line: &str, in_paragraph: bool) -> Option<LineStart> {
112        line_start(&Constructs::markdown(), line, in_paragraph)
113    }
114
115    #[test]
116    fn probe_table_mirrors_engine() {
117        assert_eq!(md("> q", false), Some(LineStart::Blockquote));
118        assert_eq!(md("---", true), Some(LineStart::SetextUnderline));
119        assert_eq!(md("---", false), Some(LineStart::ThematicBreak));
120        assert_eq!(md("# h", false), Some(LineStart::AtxHeading));
121        assert_eq!(md("```rs", false), Some(LineStart::CodeFence));
122        assert_eq!(md("<div>", false), Some(LineStart::HtmlBlock));
123        assert_eq!(md("$$", false), Some(LineStart::MathFence));
124        assert_eq!(md("{% t %}", false), Some(LineStart::Liquid));
125        assert_eq!(md("{% t", false), Some(LineStart::Liquid));
126        assert_eq!(md("{{ t }} text", false), None);
127        assert_eq!(md("[^1]: n", false), Some(LineStart::FootnoteDefinition));
128        assert_eq!(md("- item", false), Some(LineStart::ListItem));
129        assert_eq!(md("plain text", true), None);
130    }
131
132    #[test]
133    fn ordered_numbers() {
134        assert_eq!(ordered_number("007."), Some(7));
135        assert_eq!(ordered_number("999999999)"), Some(999_999_999));
136        assert_eq!(ordered_number("-"), None);
137    }
138
139    #[test]
140    fn covers_prettiers_documented_misses() {
141        // The regex holes behind Blume's directive corruption and #19847.
142        assert_eq!(md(":::note", false), Some(LineStart::DirectiveOpener));
143        assert_eq!(md(":::", false), Some(LineStart::DirectiveCloser));
144        assert_eq!(md(":::)", false), None, "an emoticon is paragraph text");
145        assert_eq!(md("| - | - |", true), Some(LineStart::TableDelimiterRow));
146        assert_eq!(md("| a | b |", false), Some(LineStart::TableRow));
147    }
148
149    #[test]
150    fn indent_is_indented_code() {
151        assert_eq!(md("    # not a heading", false), None);
152        assert_eq!(md("\t- not a list", false), None);
153        assert_eq!(md("  \t- not a list", false), None);
154        assert_eq!(md("   - list", false), Some(LineStart::ListItem));
155    }
156
157    /// The rows below the probe table carry their own construct gates
158    /// (the probe rows are pinned in `probe`'s tests).
159    #[test]
160    fn construct_gating() {
161        let mut c = Constructs::markdown();
162        c.container_directive = false;
163        c.gfm_table = false;
164        assert_eq!(line_start(&c, ":::", false), None);
165        assert_eq!(line_start(&c, "| a |", false), None);
166    }
167}