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    "base",
21    "basefont",
22    "blockquote",
23    "body",
24    "canvas",
25    "caption",
26    "center",
27    "col",
28    "colgroup",
29    "dd",
30    "details",
31    "dialog",
32    "dir",
33    "div",
34    "dl",
35    "dt",
36    "embed",
37    "fieldset",
38    "figcaption",
39    "figure",
40    "footer",
41    "form",
42    "frame",
43    "frameset",
44    "h1",
45    "h2",
46    "h3",
47    "h4",
48    "h5",
49    "h6",
50    "head",
51    "header",
52    "hr",
53    "html",
54    "iframe",
55    "legend",
56    "li",
57    "link",
58    "main",
59    "menu",
60    "menuitem",
61    "nav",
62    "noframes",
63    "noscript",
64    "object",
65    "ol",
66    "optgroup",
67    "option",
68    "p",
69    "param",
70    "picture",
71    "pre",
72    "script",
73    "search",
74    "section",
75    "source",
76    "style",
77    "summary",
78    "svg",
79    "table",
80    "tbody",
81    "td",
82    "template",
83    "textarea",
84    "tfoot",
85    "th",
86    "thead",
87    "title",
88    "tr",
89    "track",
90    "ul",
91    "video",
92];
93
94/// The elements of [`BLOCK_ELEMENTS`] that CommonMark does not name in its
95/// type-1 or type-6 start conditions. A spec parser opens a block on one only
96/// through start condition 7, which parsers disagree on where a block may
97/// start: after a table row, markdown-rs ends the table there and
98/// pulldown-cmark reads the tag as the next row.
99pub const NON_SPEC_BLOCK_ELEMENTS: &[&str] = &[
100    "audio", "canvas", "embed", "noscript", "object", "picture", "source", "svg", "template", "video",
101];
102
103/// If `trimmed` (a line with leading whitespace already stripped) opens an
104/// HTML block per rumdl's parser, return the lowercased tag name and whether
105/// it is a closing tag. Returns `None` for text, autolinks, and inline-level
106/// tags (`<span>`, `<b>`, ...), which cannot interrupt a paragraph.
107///
108/// The tag name has to end the way CommonMark's start conditions require:
109/// whitespace, the end of the line, `>` or `/>` may follow it, so `<div.class>`
110/// or `<p,` is text that happens to begin with a block element's name.
111pub fn parse_html_block_start(trimmed: &str) -> Option<(String, bool)> {
112    let after_bracket = trimmed.strip_prefix('<')?;
113    if after_bracket.is_empty() {
114        return None;
115    }
116    let is_closing = after_bracket.starts_with('/');
117    let tag_start = if is_closing { &after_bracket[1..] } else { after_bracket };
118
119    let tag_name = tag_start
120        .chars()
121        .take_while(|c| c.is_ascii_alphabetic() || *c == '-' || c.is_ascii_digit())
122        .collect::<String>()
123        .to_lowercase();
124
125    let rest = &tag_start[tag_name.len()..];
126    let terminated =
127        rest.is_empty() || rest.starts_with(|c: char| c.is_ascii_whitespace() || c == '>') || rest.starts_with("/>");
128
129    if terminated && !tag_name.is_empty() && BLOCK_ELEMENTS.contains(&tag_name.as_str()) {
130        Some((tag_name, is_closing))
131    } else {
132        None
133    }
134}
135
136/// Whether `trimmed` (a line with leading whitespace already stripped) opens
137/// an HTML block that no tag name identifies: a comment, a processing
138/// instruction, a declaration or a CDATA section (CommonMark start conditions
139/// 2 to 5). Each interrupts a paragraph like a block-level tag does, and none
140/// of them is a tag `parse_html_block_start` can name, so a caller asking
141/// "does this line start a block?" needs both.
142pub fn opens_untagged_html_block(trimmed: &str) -> bool {
143    let Some(after_bracket) = trimmed.strip_prefix('<') else {
144        return false;
145    };
146    after_bracket.starts_with("!--")
147        || after_bracket.starts_with('?')
148        || after_bracket.starts_with("![CDATA[")
149        || after_bracket
150            .strip_prefix('!')
151            .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_alphabetic()))
152}
153
154/// The byte length of the complete HTML open tag or closing tag `text` starts
155/// with, as CommonMark defines one: `<name`, attributes each preceded by
156/// whitespace (with an unquoted, single-quoted or double-quoted value), then
157/// optional whitespace and `>` or `/>`; or `</name`, optional whitespace and
158/// `>`. Only spaces and tabs count as whitespace, since the caller holds a
159/// single line.
160pub fn complete_tag_len(text: &str) -> Option<usize> {
161    let bytes = text.as_bytes();
162    let mut i = 1;
163    if bytes.first() != Some(&b'<') {
164        return None;
165    }
166    let closing = bytes.get(1) == Some(&b'/');
167    if closing {
168        i += 1;
169    }
170    if !bytes.get(i).is_some_and(u8::is_ascii_alphabetic) {
171        return None;
172    }
173    while bytes.get(i).is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'-') {
174        i += 1;
175    }
176    let is_space = |b: Option<&u8>| matches!(b, Some(b' ' | b'\t'));
177    let skip_space = |mut i: usize| {
178        while is_space(bytes.get(i)) {
179            i += 1;
180        }
181        i
182    };
183    if closing {
184        i = skip_space(i);
185        return (bytes.get(i) == Some(&b'>')).then_some(i + 1);
186    }
187    loop {
188        let after_space = skip_space(i);
189        match bytes.get(after_space) {
190            Some(b'>') => return Some(after_space + 1),
191            Some(b'/') => return (bytes.get(after_space + 1) == Some(&b'>')).then_some(after_space + 2),
192            _ => {}
193        }
194        // Another attribute, which needs whitespace before it.
195        if after_space == i {
196            return None;
197        }
198        i = after_space;
199        if !bytes
200            .get(i)
201            .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_' || *b == b':')
202        {
203            return None;
204        }
205        while bytes
206            .get(i)
207            .is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'-'))
208        {
209            i += 1;
210        }
211        let before_value = skip_space(i);
212        if bytes.get(before_value) != Some(&b'=') {
213            continue;
214        }
215        i = skip_space(before_value + 1);
216        match bytes.get(i) {
217            Some(&quote @ (b'"' | b'\'')) => {
218                let close = bytes[i + 1..].iter().position(|&b| b == quote)?;
219                i += close + 2;
220            }
221            Some(_) => {
222                let start = i;
223                while bytes
224                    .get(i)
225                    .is_some_and(|b| !b.is_ascii_whitespace() && !matches!(b, b'"' | b'\'' | b'=' | b'<' | b'>' | b'`'))
226                {
227                    i += 1;
228                }
229                if i == start {
230                    return None;
231                }
232            }
233            None => return None,
234        }
235    }
236}
237
238/// Whether `trimmed` (a line with leading whitespace already stripped) opens
239/// an HTML block through CommonMark start condition 7: a complete open or
240/// closing tag of any element but the type-1 raw ones, followed by nothing but
241/// whitespace. Unlike every other HTML block such a line cannot interrupt a
242/// paragraph, so it matters only where a paragraph starts, and whether a line
243/// qualifies depends on all of its text, not on how it begins.
244pub fn opens_tag_line_html_block(trimmed: &str) -> bool {
245    let Some(len) = complete_tag_len(trimmed) else {
246        return false;
247    };
248    let name_start = if trimmed.starts_with("</") { 2 } else { 1 };
249    let name: String = trimmed[name_start..]
250        .chars()
251        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
252        .collect::<String>()
253        .to_ascii_lowercase();
254    !TYPE_1_BLOCK_ELEMENTS.contains(&name.as_str()) && trimmed[len..].trim_matches([' ', '\t']).is_empty()
255}
256
257#[cfg(test)]
258mod tests {
259    use super::{complete_tag_len, opens_tag_line_html_block, opens_untagged_html_block, parse_html_block_start};
260
261    #[test]
262    fn untagged_html_block_openers_are_the_spec_start_conditions() {
263        for (line, expected) in [
264            ("<!-- note -->", true),
265            ("<!--", true),
266            ("<?php echo 1; ?>", true),
267            ("<!DOCTYPE html>", true),
268            ("<![CDATA[x]]>", true),
269            ("<!>", false),
270            ("<!1>", false),
271            ("<![cdata[x]]>", false),
272            ("<div>", false),
273            ("text <!-- note -->", false),
274            ("", false),
275        ] {
276            assert_eq!(opens_untagged_html_block(line), expected, "{line:?}");
277        }
278    }
279
280    #[test]
281    fn a_block_tag_name_needs_a_terminator() {
282        for (line, expected) in [
283            ("<div>", Some(("div".to_string(), false))),
284            ("<div class=\"x\">", Some(("div".to_string(), false))),
285            ("<div", Some(("div".to_string(), false))),
286            ("<div/>", Some(("div".to_string(), false))),
287            ("<DIV\tid=x>", Some(("div".to_string(), false))),
288            ("</div>", Some(("div".to_string(), true))),
289            ("</div", Some(("div".to_string(), true))),
290            ("<pre>", Some(("pre".to_string(), false))),
291            ("<h1>", Some(("h1".to_string(), false))),
292            ("<div.class>", None),
293            ("<p,", None),
294            ("<div/x>", None),
295            ("<div=1>", None),
296            ("<span>", None),
297            ("<div-custom>", None),
298            ("<h1foo>", None),
299            ("<", None),
300            ("</", None),
301            ("text <div>", None),
302        ] {
303            assert_eq!(parse_html_block_start(line), expected, "{line:?}");
304        }
305    }
306
307    #[test]
308    fn a_complete_tag_is_measured_to_its_closing_bracket() {
309        for (text, expected) in [
310            ("<img />", Some(7)),
311            ("<img/>", Some(6)),
312            ("<a href=\"x y\">text", Some(14)),
313            ("<a href='x'>", Some(12)),
314            ("<a href=x>", Some(10)),
315            ("<a href = x title>", Some(18)),
316            ("<a\thidden>", Some(10)),
317            ("</span >", Some(8)),
318            ("<custom-el data-x=\"1\">", Some(22)),
319            ("<a href=\"x>", None),
320            ("<a href=>", None),
321            ("<a href=\"x\"title=\"y\">", None),
322            ("</span x>", None),
323            ("<1a>", None),
324            ("<a", None),
325            ("< a>", None),
326            ("text", None),
327        ] {
328            assert_eq!(complete_tag_len(text), expected, "{text:?}");
329        }
330    }
331
332    #[test]
333    fn a_complete_tag_alone_on_the_line_opens_a_type_seven_block() {
334        for (line, expected) in [
335            ("<img alt=\"a b c\" src=\"x\" />", true),
336            ("<span>  ", true),
337            ("</span>", true),
338            ("<div>", true),
339            ("<img src=\"x\" /> and text", false),
340            ("<img alt=\"a b", false),
341            ("<pre>", false),
342            ("<SCRIPT>", false),
343            ("</textarea>", false),
344            ("<http://example.com>", false),
345            ("text <span>", false),
346        ] {
347            assert_eq!(opens_tag_line_html_block(line), expected, "{line:?}");
348        }
349    }
350}