const BLOCK_TAGS: [&str; 62] = [
"address",
"article",
"aside",
"base",
"basefont",
"blockquote",
"body",
"caption",
"center",
"col",
"colgroup",
"dd",
"details",
"dialog",
"dir",
"div",
"dl",
"dt",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"frame",
"frameset",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"head",
"header",
"hr",
"html",
"iframe",
"legend",
"li",
"link",
"main",
"menu",
"menuitem",
"nav",
"noframes",
"ol",
"optgroup",
"option",
"p",
"param",
"search",
"section",
"summary",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"title",
"tr",
"track",
"ul",
];
const RAW_TAGS: [&str; 4] = ["script", "pre", "style", "textarea"];
pub(crate) fn interrupts_paragraph(line: &str) -> bool {
let text = line.trim_start();
is_bullet_with_content(text) || text.starts_with('>') || starts_html_block(text)
}
fn is_bullet_with_content(text: &str) -> bool {
let mut chars = text.chars();
matches!(chars.next(), Some('-' | '*' | '+'))
&& matches!(chars.next(), Some(' ' | '\t'))
&& chars.any(|ch| !matches!(ch, ' ' | '\t'))
}
fn starts_html_block(text: &str) -> bool {
text.strip_prefix('<')
.is_some_and(|rest| starts_declaration(rest) || starts_tag_block(rest))
}
fn starts_declaration(rest: &str) -> bool {
let is_marked = ["!--", "?", "![CDATA["]
.iter()
.any(|marker| rest.starts_with(marker));
let is_declaration = rest
.strip_prefix('!')
.is_some_and(|name| name.starts_with(|ch: char| ch.is_ascii_alphabetic()));
is_marked || is_declaration
}
fn starts_tag_block(rest: &str) -> bool {
let (is_closing, tag) = match rest.strip_prefix('/') {
Some(tag) => (true, tag),
None => (false, rest),
};
let name_end = tag
.find(|ch: char| !ch.is_ascii_alphanumeric())
.unwrap_or(tag.len());
let name = tag[..name_end].to_ascii_lowercase();
ends_tag_name(&tag[name_end..]) && is_block_name(&name, is_closing)
}
fn ends_tag_name(after: &str) -> bool {
after.is_empty() || after.starts_with([' ', '\t', '>']) || after.starts_with("/>")
}
fn is_block_name(name: &str, is_closing: bool) -> bool {
BLOCK_TAGS.contains(&name) || (!is_closing && RAW_TAGS.contains(&name))
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::interrupts_paragraph;
#[rstest]
#[case::bullet("- item")]
#[case::star("* item")]
#[case::plus_with_tab("+\titem")]
#[case::quote("> quote")]
#[case::comment("<!-- note -->")]
#[case::processing_instruction("<?php")]
#[case::declaration("<!DOCTYPE html>")]
#[case::cdata("<![CDATA[x]]>")]
#[case::raw_tag("<script>")]
#[case::raw_tag_upper("<PRE class=a>")]
#[case::block_tag("<div>")]
#[case::closing_block_tag("</table>")]
#[case::self_closing_block_tag("<hr/>")]
fn a_block_start_interrupts_a_paragraph(#[case] line: &str) {
assert!(interrupts_paragraph(line));
}
#[rstest]
#[case::bare_bullet("-")]
#[case::bullet_without_content("- ")]
#[case::non_breaking_space("-\u{a0}text")]
#[case::no_separator("-text")]
#[case::plain_text("text")]
#[case::inline_tag("<span>x</span>")]
#[case::closing_inline_tag("</em>")]
#[case::longer_name("<divider>")]
#[case::declaration_without_letter("<!1")]
#[case::bare_angle("<")]
fn a_lazy_continuation_does_not_interrupt(#[case] line: &str) {
assert!(!interrupts_paragraph(line));
}
}