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;
17
18use super::cursor::Cursor;
19use super::probe::{Start, probe};
20use super::scan;
21
22/// What a line would open at flow position.
23/// One variant per block construct;
24/// the payload-free mirror of the engine's probe result.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum LineStart {
28    Blockquote,
29    /// Only reported with `in_paragraph` (an underline needs a paragraph).
30    SetextUnderline,
31    ThematicBreak,
32    AtxHeading,
33    CodeFence,
34    HtmlBlock,
35    MathFence,
36    Liquid,
37    /// A directive opening fence (`:::name…`): opens a container, interrupts a paragraph.
38    DirectiveOpener,
39    /// A bare `:::` run: closes an open directive, inert otherwise.
40    DirectiveCloser,
41    FootnoteDefinition,
42    ListItem,
43    /// Only reported with `in_paragraph`
44    /// (a delimiter row activates by stealing the paragraph's last line as the header).
45    TableDelimiterRow,
46    /// A `|`-initiated line: a row if a table is open or forming.
47    TableRow,
48}
49
50/// The number of an ordered list marker (`123.` / `123)`), for a printer that renumbers.
51/// CommonMark caps markers at 9 digits, so the value always fits; `None` for a bullet.
52pub fn ordered_number(marker: &str) -> Option<u32> {
53    marker.trim_end_matches(['.', ')']).parse().ok()
54}
55
56/// Classifies what `line` (one line, no line ending) would start as a block,
57/// or `None` for paragraph text.
58/// `in_paragraph` enables the constructs that only exist relative to an open paragraph.
59///
60/// Leading indent is handled here: 4+ columns of indent is indented code (never a block start),
61/// per the default `code_indented` construct.
62pub fn line_start(constructs: &Constructs, line: &str, in_paragraph: bool) -> Option<LineStart> {
63    let tail = flow_tail(line)?;
64    if let Some(start) = probe(constructs, tail, in_paragraph) {
65        return Some(match start {
66            Start::Quote => LineStart::Blockquote,
67            Start::Setext(_) => LineStart::SetextUnderline,
68            Start::ThematicBreak => LineStart::ThematicBreak,
69            Start::Atx { .. } => LineStart::AtxHeading,
70            Start::Fence { .. } => LineStart::CodeFence,
71            Start::Html { .. } => LineStart::HtmlBlock,
72            Start::MathFence { .. } => LineStart::MathFence,
73            Start::Liquid => {
74                // A tag closed on this line interrupts only when nothing follows its closer;
75                // an unclosed one may still close on a later line.
76                let closer = syntax::liquid::open(tail)?;
77                if let Some(after) = syntax::liquid::close(&tail[2..], closer)
78                    && !scan::is_blank(&tail[2 + after..])
79                {
80                    return None;
81                }
82                LineStart::Liquid
83            }
84            Start::Directive { .. } => LineStart::DirectiveOpener,
85            Start::FootnoteDef { .. } => LineStart::FootnoteDefinition,
86            Start::ListItem { .. } => LineStart::ListItem,
87            Start::TableDelimiter(_) => LineStart::TableDelimiterRow,
88        });
89    }
90    // Below the probe table:
91    // line shapes that are inert on their own but significant next to an open construct.
92    // Exactly what a formatter must not create by joining lines.
93    if constructs.container_directive && scan::fence_close(tail, b':', 3) {
94        return Some(LineStart::DirectiveCloser);
95    }
96    if constructs.gfm_table && tail.starts_with('|') {
97        return Some(LineStart::TableRow);
98    }
99    None
100}
101
102/// Whether delimiter row `row` opens a table under `header`, the paragraph line right above it.
103///
104/// [`line_start`] reports any delimiter-shaped line as [`LineStart::TableDelimiterRow`];
105/// the row steals `header` only when their cell counts match, otherwise both stay paragraph text
106/// (the engine's `try_start_table`, which also rules out a lazy or deep header).
107pub fn table_delimiter_activates(constructs: &Constructs, header: &str, row: &str) -> bool {
108    flow_tail(header).is_some()
109        && matches!(
110            flow_tail(row).and_then(|tail| probe(constructs, tail, true)),
111            Some(Start::TableDelimiter(align)) if scan::table_row_cells(header).len() == align.len()
112        )
113}
114
115/// `line` from where a flow construct could start, `None` when it is indented-code-deep.
116fn flow_tail(line: &str) -> Option<&str> {
117    Cursor::new(line, 0).flow_start().map(|cursor| cursor.tail())
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn md(line: &str, in_paragraph: bool) -> Option<LineStart> {
125        line_start(&Constructs::markdown(), line, in_paragraph)
126    }
127
128    #[test]
129    fn probe_table_mirrors_engine() {
130        assert_eq!(md("> q", false), Some(LineStart::Blockquote));
131        assert_eq!(md("---", true), Some(LineStart::SetextUnderline));
132        assert_eq!(md("---", false), Some(LineStart::ThematicBreak));
133        assert_eq!(md("# h", false), Some(LineStart::AtxHeading));
134        assert_eq!(md("```rs", false), Some(LineStart::CodeFence));
135        assert_eq!(md("<div>", false), Some(LineStart::HtmlBlock));
136        assert_eq!(md("$$", false), Some(LineStart::MathFence));
137        assert_eq!(md("{% t %}", false), Some(LineStart::Liquid));
138        assert_eq!(md("{% t", false), Some(LineStart::Liquid));
139        assert_eq!(md("{{ t }} text", false), None);
140        assert_eq!(md("[^1]: n", false), Some(LineStart::FootnoteDefinition));
141        assert_eq!(md("- item", false), Some(LineStart::ListItem));
142        assert_eq!(md("plain text", true), None);
143    }
144
145    #[test]
146    fn ordered_numbers() {
147        assert_eq!(ordered_number("007."), Some(7));
148        assert_eq!(ordered_number("999999999)"), Some(999_999_999));
149        assert_eq!(ordered_number("-"), None);
150    }
151
152    #[test]
153    fn covers_prettiers_documented_misses() {
154        // The regex holes behind Blume's directive corruption and #19847.
155        assert_eq!(md(":::note", false), Some(LineStart::DirectiveOpener));
156        assert_eq!(md(":::", false), Some(LineStart::DirectiveCloser));
157        assert_eq!(md(":::)", false), None, "an emoticon is paragraph text");
158        assert_eq!(md("| - | - |", true), Some(LineStart::TableDelimiterRow));
159        assert_eq!(md("| a | b |", false), Some(LineStart::TableRow));
160    }
161
162    #[test]
163    fn table_delimiter_activates_on_matching_cells() {
164        let md = Constructs::markdown();
165        assert!(table_delimiter_activates(&md, "| a | b |", "| - | - |"));
166        assert!(table_delimiter_activates(&md, "a | b", "--- | ---"));
167        assert!(
168            !table_delimiter_activates(&md, "| a | b |", "| - | - | - |"),
169            "a cell count mismatch"
170        );
171        assert!(!table_delimiter_activates(&md, "| a | b |", "| a | b |"), "not a delimiter row");
172        assert!(
173            !table_delimiter_activates(&md, "    | a | b |", "| - | - |"),
174            "an indented header"
175        );
176    }
177
178    #[test]
179    fn indent_is_indented_code() {
180        assert_eq!(md("    # not a heading", false), None);
181        assert_eq!(md("\t- not a list", false), None);
182        assert_eq!(md("  \t- not a list", false), None);
183        assert_eq!(md("   - list", false), Some(LineStart::ListItem));
184    }
185
186    /// The rows below the probe table carry their own construct gates
187    /// (the probe rows are pinned in `probe`'s tests).
188    #[test]
189    fn construct_gating() {
190        let mut c = Constructs::markdown();
191        c.container_directive = false;
192        c.gfm_table = false;
193        assert_eq!(line_start(&c, ":::", false), None);
194        assert_eq!(line_start(&c, "| a |", false), None);
195    }
196}