use crate::engine::ast::{Inline, TextRange};
use crate::warn::{Warning, WarningCollector};
use super::scan::{Step, Token};
const ALLOWED: &[&str] = &[
"abbr",
"b",
"bdi",
"bdo",
"big",
"blockquote",
"br",
"caption",
"center",
"cite",
"code",
"data",
"dd",
"del",
"dfn",
"div",
"dl",
"dt",
"em",
"font",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"ins",
"kbd",
"li",
"mark",
"ol",
"p",
"pre",
"q",
"rb",
"rp",
"rt",
"rtc",
"ruby",
"s",
"samp",
"small",
"span",
"strike",
"strong",
"sub",
"sup",
"table",
"td",
"th",
"time",
"tr",
"tt",
"u",
"ul",
"var",
"wbr",
];
pub(super) fn scan_tag(
flat: &str,
pos: usize,
end: usize,
warnings: &mut WarningCollector,
) -> Step {
let rest = &flat[pos..end];
let closing = rest[1..].starts_with('/');
let name_at = 1 + usize::from(closing);
let name_len = rest[name_at..]
.bytes()
.take_while(u8::is_ascii_alphanumeric)
.count();
if name_len == 0 || !rest.as_bytes()[name_at].is_ascii_alphabetic() {
return Step::Miss;
}
let name_end = name_at + name_len;
match rest.as_bytes().get(name_end) {
Some(b) if b.is_ascii_whitespace() || *b == b'/' || *b == b'>' => {}
_ => return Step::Miss,
}
let mut in_quote: Option<char> = None;
let mut gt = None;
for (i, c) in rest[name_end..].char_indices() {
match in_quote {
Some(q) if c == q => in_quote = None,
Some(_) => {}
None if c == '"' || c == '\'' => in_quote = Some(c),
None if c == '>' => {
gt = Some(name_end + i);
break;
}
None => {}
}
}
let Some(gt) = gt else {
return Step::Miss;
};
let trimmed_inner = rest[name_end..gt].trim_end();
let (self_closing, attr_body) = match trimmed_inner.strip_suffix('/') {
Some(without) => (true, without),
None => (false, trimmed_inner),
};
let attr_trim = attr_body.trim();
let attributes = if attr_trim.is_empty() {
None
} else {
let lead = attr_body.len() - attr_body.trim_start().len();
let start = pos + name_end + lead;
Some(TextRange::new(
start as u32,
(start + attr_trim.len()) as u32,
))
};
let name = rest[name_at..name_end].to_ascii_lowercase();
let next = pos + gt + 1;
if ALLOWED.binary_search(&name.as_str()).is_ok() {
Step::Emit(
Token::Node(Inline::HtmlTag {
name,
closing,
self_closing,
attributes,
}),
next,
)
} else {
warnings.emit(Warning::DisallowedHtmlTag { name });
Step::Text(next)
}
}
#[cfg(test)]
mod tests {
use super::super::testutil::{check, dump, parse};
use super::ALLOWED;
use crate::warn::Warning;
#[test]
fn allowlist_is_sorted_for_binary_search() {
assert!(ALLOWED.is_sorted());
assert_eq!(ALLOWED.len(), 59);
}
#[test]
fn open_and_close_tags() {
check("<b>x</b>", "Html(b) Text(x) Html(b/c)");
}
#[test]
fn self_closing_tag() {
check("<br/>", "Html(br/s)");
check("<br />", "Html(br/s)");
}
#[test]
fn attributes_keep_their_raw_range() {
check("<span class=\"a\">", "Html(span class=\"a\")");
check("<span class=\"a\" />", "Html(span/s class=\"a\")");
}
#[test]
fn quoted_attribute_may_contain_gt() {
check("<span title=\"a>b\">", "Html(span title=\"a>b\")");
check("<span title='a>b'>", "Html(span title='a>b')");
}
#[test]
fn unterminated_quote_leaves_the_bracket_literal() {
check("<span class=\"a>", "Text(<span class=\"a>)");
}
#[test]
fn tag_names_are_lowercased() {
check("<SPAN>", "Html(span)");
check("</DIV>", "Html(div/c)");
}
#[test]
fn disallowed_tag_warns_and_stays_literal() {
let src = "<script>alert(1)</script>";
let (nodes, warnings) = parse(src);
assert_eq!(dump(src, &nodes), "Text(<script>alert(1)</script>)");
assert_eq!(
warnings,
vec![Warning::DisallowedHtmlTag {
name: "script".to_owned()
}]
);
}
#[test]
fn unknown_tag_warns_too() {
let src = "<iframe src=\"x\">";
let (nodes, warnings) = parse(src);
assert_eq!(dump(src, &nodes), "Text(<iframe src=\"x\">)");
assert_eq!(
warnings,
vec![Warning::DisallowedHtmlTag {
name: "iframe".to_owned()
}]
);
}
#[test]
fn non_tag_brackets_are_literal() {
check("< notag", "Text(< notag)");
check("<3>", "Text(<3>)");
check("a<", "Text(a<)");
check("</", "Text(</)");
}
#[test]
fn unclosed_tag_is_literal() {
check("<b", "Text(<b)");
check("<b class=x", "Text(<b class=x)");
}
#[test]
fn name_must_be_followed_by_whitespace_slash_or_gt() {
check("<b]]>", "Text(<b]]>)");
check("<b=x>", "Text(<b=x>)");
check("a<b, c>", "Text(a<b, c>)");
}
}