Skip to main content

rumdl_lib/utils/
html_block.rs

1//! Shared HTML block-start classification.
2//!
3//! A line whose first tag names one of these elements starts an HTML block in
4//! rumdl's parser (`lint_context::heading_detection::detect_html_blocks`).
5//! Reflow consults the same predicate so a wrapped line can never introduce a
6//! construct the parser would classify differently: the two must agree, and
7//! sharing the list keeps them from drifting apart.
8
9/// Type-1 tags per CommonMark: blank lines inside these blocks do not
10/// terminate them — only a matching end tag (or EOF) does.
11pub const TYPE_1_BLOCK_ELEMENTS: &[&str] = &["pre", "script", "style", "textarea"];
12
13/// HTML elements whose tags open an HTML block at line start (CommonMark
14/// type-1 and type-6 conditions, as recognized by rumdl's parser).
15pub const BLOCK_ELEMENTS: &[&str] = &[
16    "address",
17    "article",
18    "aside",
19    "audio",
20    "blockquote",
21    "canvas",
22    "details",
23    "dialog",
24    "dd",
25    "div",
26    "dl",
27    "dt",
28    "embed",
29    "fieldset",
30    "figcaption",
31    "figure",
32    "footer",
33    "form",
34    "h1",
35    "h2",
36    "h3",
37    "h4",
38    "h5",
39    "h6",
40    "header",
41    "hr",
42    "iframe",
43    "li",
44    "main",
45    "menu",
46    "nav",
47    "noscript",
48    "object",
49    "ol",
50    "p",
51    "picture",
52    "pre",
53    "script",
54    "search",
55    "section",
56    "source",
57    "style",
58    "summary",
59    "svg",
60    "table",
61    "tbody",
62    "td",
63    "template",
64    "textarea",
65    "tfoot",
66    "th",
67    "thead",
68    "tr",
69    "track",
70    "ul",
71    "video",
72];
73
74/// If `trimmed` (a line with leading whitespace already stripped) opens an
75/// HTML block per rumdl's parser, return the lowercased tag name and whether
76/// it is a closing tag. Returns `None` for text, autolinks, and inline-level
77/// tags (`<span>`, `<b>`, ...), which cannot interrupt a paragraph.
78///
79/// The tag name has to end the way CommonMark's start conditions require:
80/// whitespace, the end of the line, `>` or `/>` may follow it, so `<div.class>`
81/// or `<p,` is text that happens to begin with a block element's name.
82pub fn parse_html_block_start(trimmed: &str) -> Option<(String, bool)> {
83    let after_bracket = trimmed.strip_prefix('<')?;
84    if after_bracket.is_empty() {
85        return None;
86    }
87    let is_closing = after_bracket.starts_with('/');
88    let tag_start = if is_closing { &after_bracket[1..] } else { after_bracket };
89
90    let tag_name = tag_start
91        .chars()
92        .take_while(|c| c.is_ascii_alphabetic() || *c == '-' || c.is_ascii_digit())
93        .collect::<String>()
94        .to_lowercase();
95
96    let rest = &tag_start[tag_name.len()..];
97    let terminated =
98        rest.is_empty() || rest.starts_with(|c: char| c.is_ascii_whitespace() || c == '>') || rest.starts_with("/>");
99
100    if terminated && !tag_name.is_empty() && BLOCK_ELEMENTS.contains(&tag_name.as_str()) {
101        Some((tag_name, is_closing))
102    } else {
103        None
104    }
105}
106
107/// Whether `trimmed` (a line with leading whitespace already stripped) opens
108/// an HTML block that no tag name identifies: a comment, a processing
109/// instruction, a declaration or a CDATA section (CommonMark start conditions
110/// 2 to 5). Each interrupts a paragraph like a block-level tag does, and none
111/// of them is a tag `parse_html_block_start` can name, so a caller asking
112/// "does this line start a block?" needs both.
113pub fn opens_untagged_html_block(trimmed: &str) -> bool {
114    let Some(after_bracket) = trimmed.strip_prefix('<') else {
115        return false;
116    };
117    after_bracket.starts_with("!--")
118        || after_bracket.starts_with('?')
119        || after_bracket.starts_with("![CDATA[")
120        || after_bracket
121            .strip_prefix('!')
122            .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_alphabetic()))
123}
124
125#[cfg(test)]
126mod tests {
127    use super::{opens_untagged_html_block, parse_html_block_start};
128
129    #[test]
130    fn untagged_html_block_openers_are_the_spec_start_conditions() {
131        for (line, expected) in [
132            ("<!-- note -->", true),
133            ("<!--", true),
134            ("<?php echo 1; ?>", true),
135            ("<!DOCTYPE html>", true),
136            ("<![CDATA[x]]>", true),
137            ("<!>", false),
138            ("<!1>", false),
139            ("<![cdata[x]]>", false),
140            ("<div>", false),
141            ("text <!-- note -->", false),
142            ("", false),
143        ] {
144            assert_eq!(opens_untagged_html_block(line), expected, "{line:?}");
145        }
146    }
147
148    #[test]
149    fn a_block_tag_name_needs_a_terminator() {
150        for (line, expected) in [
151            ("<div>", Some(("div".to_string(), false))),
152            ("<div class=\"x\">", Some(("div".to_string(), false))),
153            ("<div", Some(("div".to_string(), false))),
154            ("<div/>", Some(("div".to_string(), false))),
155            ("<DIV\tid=x>", Some(("div".to_string(), false))),
156            ("</div>", Some(("div".to_string(), true))),
157            ("</div", Some(("div".to_string(), true))),
158            ("<pre>", Some(("pre".to_string(), false))),
159            ("<h1>", Some(("h1".to_string(), false))),
160            ("<div.class>", None),
161            ("<p,", None),
162            ("<div/x>", None),
163            ("<div=1>", None),
164            ("<span>", None),
165            ("<div-custom>", None),
166            ("<h1foo>", None),
167            ("<", None),
168            ("</", None),
169            ("text <div>", None),
170        ] {
171            assert_eq!(parse_html_block_start(line), expected, "{line:?}");
172        }
173    }
174}