use std::ops::Range;
#[derive(Debug, Clone)]
pub(crate) struct Attr<'a> {
pub name: &'a str,
pub value: Option<&'a str>,
pub raw: &'a str,
pub raw_range: Range<usize>,
}
#[derive(Debug, Clone)]
pub(crate) struct Attrs<'a> {
input: &'a str,
cursor: usize,
attrs_end: usize,
}
#[derive(Debug)]
pub(crate) struct Tag<'a> {
source: &'a str,
pub name: &'a str,
attrs_start: usize,
attrs_end: usize,
pub close: usize,
pub self_closing: bool,
pub closing: bool,
}
impl<'a> Tag<'a> {
#[inline]
pub(crate) fn attrs(&self) -> Attrs<'a> {
Attrs {
input: self.source,
cursor: self.attrs_start,
attrs_end: self.attrs_end,
}
}
#[inline]
pub(crate) fn attr(&self, name: &str) -> Option<&'a str> {
self.attrs()
.find(|attr| attr.name == name)
.and_then(|attr| attr.value)
}
#[inline]
pub(crate) fn has_attr(&self, name: &str) -> bool {
self.attrs().any(|attr| attr.name == name)
}
}
#[derive(Debug)]
pub(crate) struct Element<'a> {
pub(crate) source: &'a str,
pub(crate) start: usize,
pub(crate) tag: Tag<'a>,
pub(crate) content_start: usize,
pub(crate) content_end: usize,
pub(crate) close_end: usize,
}
impl<'a> Element<'a> {
#[inline]
pub(crate) fn source(&self) -> &'a str {
self.source
}
#[inline]
pub(crate) fn name(&self) -> &'a str {
self.tag.name
}
#[inline]
pub(crate) fn attrs(&self) -> Attrs<'a> {
self.tag.attrs()
}
#[inline]
pub(crate) fn attr(&self, name: &str) -> Option<&'a str> {
self.tag.attr(name)
}
#[inline]
pub(crate) fn has_attr(&self, name: &str) -> bool {
self.tag.has_attr(name)
}
#[inline]
pub(crate) fn opening(&self) -> &'a str {
&self.source[self.start..self.content_start]
}
#[inline]
pub(crate) fn self_closing(&self) -> bool {
self.tag.self_closing
}
#[inline]
pub(crate) fn inner(&self) -> Range<usize> {
self.content_start..self.content_end
}
#[inline]
pub(crate) fn content_end(&self) -> usize {
self.content_end
}
#[inline]
pub(crate) fn close_end(&self) -> usize {
self.close_end
}
#[inline]
pub(crate) fn is_void(&self) -> bool {
is_void_element(self.tag.name)
}
#[cfg(test)]
#[inline]
pub(crate) fn children(&self) -> Walker<'a> {
Walker::new_range(self.source, self.content_start, self.content_end)
}
}
#[derive(Debug)]
pub(crate) enum Event<'a> {
Text(&'a str),
Comment(Range<usize>),
Declaration(Range<usize>),
ClosingTag(Range<usize>),
Element(Element<'a>),
}
#[derive(Debug)]
pub(crate) struct Walker<'a> {
source: &'a str,
cursor: usize,
end: usize,
}
impl<'a> Walker<'a> {
#[cfg(test)]
pub(crate) fn new(source: &'a str) -> Self {
Self::new_range(source, 0, source.len())
}
#[inline]
pub(crate) fn new_range(source: &'a str, start: usize, end: usize) -> Self {
Self {
source,
cursor: start,
end,
}
}
}
impl<'a> Iterator for Walker<'a> {
type Item = Event<'a>;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if self.cursor < self.end {
let start = self.cursor;
let remaining = &self.source[start..self.end];
if remaining.starts_with("<!--") {
let close = find_comment_close(remaining).unwrap_or(remaining.len());
self.cursor += close;
return Some(Event::Comment(start..start + close));
}
if remaining.starts_with("<!") {
let close = find_declaration_close(remaining).unwrap_or(remaining.len());
self.cursor += close;
return Some(Event::Declaration(start..start + close));
}
if remaining.starts_with('<') {
if let Some(tag) = parse_tag(remaining) {
if tag.closing {
self.cursor += tag.close + 1;
return Some(Event::ClosingTag(start..start + tag.close + 1));
}
let content_start = start + tag.close + 1;
let (content_end, close_end) = if tag.self_closing || is_void_element(tag.name)
{
(content_start, content_start)
} else if let Some((close_start, close_end)) =
find_matching_end(remaining, tag.name, tag.close + 1)
{
(start + close_start, start + close_end)
} else {
(self.end, self.end)
};
self.cursor = close_end.max(content_start);
return Some(Event::Element(Element {
source: self.source,
start,
tag,
content_start,
content_end,
close_end,
}));
}
}
let next = remaining.find('<').unwrap_or(remaining.len());
let text_end = if next == 0 {
remaining.chars().next()?.len_utf8()
} else {
next
};
self.cursor += text_end;
return Some(Event::Text(&remaining[..text_end]));
}
None
}
}
#[inline]
pub(crate) fn find_tag_close(input: &str) -> Option<usize> {
let bytes = input.as_bytes();
let mut quote: u8 = 0;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if quote != 0 {
if c == quote {
quote = 0;
}
} else {
match c {
b'>' => return Some(i),
b'"' | b'\'' => quote = c,
_ => {}
}
}
i += 1;
}
None
}
#[inline]
pub(crate) fn parse_tag(input: &str) -> Option<Tag<'_>> {
if !input.starts_with('<') || input.starts_with("<!--") || input.starts_with("<!") {
return None;
}
let close = find_tag_close(input)?;
let bytes = input.as_bytes();
let mut cursor = 1usize;
let closing = bytes.get(cursor) == Some(&b'/');
if closing {
cursor += 1;
}
while cursor < close && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
let name_start = cursor;
while cursor < close
&& !bytes[cursor].is_ascii_whitespace()
&& bytes[cursor] != b'/'
&& bytes[cursor] != b'>'
{
cursor += 1;
}
if name_start == cursor {
return None;
}
let name = &input[name_start..cursor];
let self_closing = !closing && tag_is_self_closing(input, close);
let attrs_end = if self_closing {
input[..close].trim_end().len().saturating_sub(1)
} else {
close
};
let (attrs_start, attrs_end) = if closing {
(cursor, cursor)
} else {
(cursor, attrs_end)
};
Some(Tag {
source: input,
name,
attrs_start,
attrs_end,
close,
self_closing,
closing,
})
}
#[inline]
pub(crate) fn opening_tag_name(input: &str) -> Option<&str> {
let tag = parse_tag(input)?;
(!tag.closing).then_some(tag.name)
}
#[inline]
pub(crate) fn style_element_bounds(input: &str) -> Option<(usize, usize, usize)> {
let tag = parse_tag(input)?;
if tag.closing || !tag.name.eq_ignore_ascii_case("style") {
return None;
}
let content_start = tag.close + 1;
let (close_start, close_end) = find_matching_end(input, tag.name, content_start)?;
Some((content_start, close_start, close_end))
}
#[inline]
pub(crate) fn find_matching_end(
input: &str,
tag_name: &str,
content_start: usize,
) -> Option<(usize, usize)> {
let mut depth = 1usize;
let mut index = content_start;
while index < input.len() {
let relative = input[index..].find('<')?;
index += relative;
if input[index..].starts_with("<!--") {
index += find_comment_close(&input[index..]).unwrap_or(input.len() - index);
continue;
}
let Some(tag) = parse_tag(&input[index..]) else {
index += 1;
continue;
};
if tag.name.eq_ignore_ascii_case(tag_name) {
if tag.closing {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some((index, index + tag.close + 1));
}
} else if !tag.self_closing && !is_void_element(tag.name) {
depth += 1;
}
}
index += tag.close + 1;
}
None
}
#[inline]
pub(crate) fn find_comment_close(input: &str) -> Option<usize> {
input.find("-->").map(|close| close + 3)
}
#[inline]
pub(crate) fn find_declaration_close(input: &str) -> Option<usize> {
find_tag_close(input).map(|close| close + 1)
}
#[inline]
pub(crate) fn is_void_element(tag_name: &str) -> bool {
let bytes = tag_name.as_bytes();
if bytes.is_empty() || bytes.len() > 6 {
return false;
}
let mut buf = [0u8; 6];
let name = &mut buf[..bytes.len()];
name.copy_from_slice(bytes);
name.make_ascii_lowercase();
matches!(
&*name,
b"area"
| b"base"
| b"br"
| b"col"
| b"embed"
| b"hr"
| b"img"
| b"input"
| b"link"
| b"meta"
| b"param"
| b"source"
| b"track"
| b"wbr"
)
}
#[inline]
fn tag_is_self_closing(input: &str, close: usize) -> bool {
input[..close].trim_end().ends_with('/')
}
impl<'a> Iterator for Attrs<'a> {
type Item = Attr<'a>;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
let bytes = self.input.as_bytes();
loop {
while self.cursor < self.attrs_end && bytes[self.cursor].is_ascii_whitespace() {
self.cursor += 1;
}
if self.cursor >= self.attrs_end {
return None;
}
let raw_start = self.cursor;
while self.cursor < self.attrs_end
&& !bytes[self.cursor].is_ascii_whitespace()
&& bytes[self.cursor] != b'='
&& bytes[self.cursor] != b'/'
{
self.cursor += 1;
}
if raw_start == self.cursor {
self.cursor += 1;
continue;
}
let name = &self.input[raw_start..self.cursor];
while self.cursor < self.attrs_end && bytes[self.cursor].is_ascii_whitespace() {
self.cursor += 1;
}
let mut value = None;
if self.cursor < self.attrs_end && bytes[self.cursor] == b'=' {
self.cursor += 1;
while self.cursor < self.attrs_end && bytes[self.cursor].is_ascii_whitespace() {
self.cursor += 1;
}
if self.cursor < self.attrs_end
&& (bytes[self.cursor] == b'"' || bytes[self.cursor] == b'\'')
{
let quote = bytes[self.cursor];
self.cursor += 1;
let value_start = self.cursor;
while self.cursor < self.attrs_end && bytes[self.cursor] != quote {
self.cursor += 1;
}
value = Some(&self.input[value_start..self.cursor.min(self.attrs_end)]);
if self.cursor < self.attrs_end {
self.cursor += 1;
}
} else {
let value_start = self.cursor;
while self.cursor < self.attrs_end && !bytes[self.cursor].is_ascii_whitespace()
{
self.cursor += 1;
}
value = Some(&self.input[value_start..self.cursor]);
}
}
return Some(Attr {
name,
value,
raw: self.input[raw_start..self.cursor].trim(),
raw_range: raw_start..self.cursor,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_tag_close_simple() {
assert_eq!(find_tag_close("<br>"), Some(3));
assert_eq!(find_tag_close("<br/>"), Some(4));
assert_eq!(find_tag_close("<template>"), Some(9));
assert_eq!(find_tag_close("</div>"), Some(5));
assert_eq!(find_tag_close("</style>"), Some(7));
assert_eq!(find_tag_close("</outlet></main>"), Some(8));
assert_eq!(find_tag_close("<outlet /></main>"), Some(9));
assert_eq!(find_tag_close("<div>{{title}}</div>"), Some(4));
assert_eq!(
find_tag_close(r#"<template shadowrootmode="open"><div></div></template>"#),
Some(31)
);
}
#[test]
fn find_tag_close_skips_quoted_gt() {
assert_eq!(find_tag_close(r#"<if condition="a > b">"#), Some(21));
assert_eq!(find_tag_close(r#"<if condition="a >= b">"#), Some(22));
assert_eq!(find_tag_close(r#"<a x='a>b'>"#), Some(10));
}
#[test]
fn find_tag_close_returns_none_for_unterminated_tag() {
assert_eq!(find_tag_close("<div"), None);
assert_eq!(find_tag_close(r#"<a href="unterminated>"#), None);
}
#[test]
fn parse_tag_reads_attributes() {
let tag = parse_tag(r#"<button class="a b" disabled data-x='1'>"#).unwrap();
assert_eq!(tag.name, "button");
assert_eq!(tag.close, 39);
assert!(!tag.self_closing);
let attrs: Vec<_> = tag.attrs().collect();
assert_eq!(attrs.len(), 3);
assert_eq!(attrs[0].name, "class");
assert_eq!(attrs[0].value, Some("a b"));
assert_eq!(attrs[0].raw_range, 8..19);
assert_eq!(attrs[1].name, "disabled");
assert_eq!(attrs[1].value, None);
assert_eq!(attrs[2].name, "data-x");
assert_eq!(attrs[2].value, Some("1"));
}
#[test]
fn walker_yields_dom_like_events() {
let mut walker = Walker::new("<div id=\"a\">hi</div><!--x-->");
let Some(Event::Element(element)) = walker.next() else {
panic!("expected element");
};
assert_eq!(element.name(), "div");
assert_eq!(element.attr("id"), Some("a"));
assert_eq!(element.inner(), 12..14);
let mut children = element.children();
assert!(matches!(children.next(), Some(Event::Text("hi"))));
assert!(children.next().is_none());
assert!(matches!(walker.next(), Some(Event::Comment(range)) if range == (20..28)));
assert!(walker.next().is_none());
}
#[test]
fn walker_preserves_unterminated_tag_as_text() {
let mut walker = Walker::new("before <div");
assert!(matches!(walker.next(), Some(Event::Text("before "))));
assert!(matches!(walker.next(), Some(Event::Text("<"))));
assert!(matches!(walker.next(), Some(Event::Text("div"))));
assert!(walker.next().is_none());
}
#[test]
fn opening_tag_name_reads_tag_name() {
assert_eq!(
opening_tag_name(r#"<template data-x="a>b">"#),
Some("template")
);
assert_eq!(opening_tag_name("<outlet />"), Some("outlet"));
assert_eq!(opening_tag_name("</template>"), None);
}
#[test]
fn style_element_bounds_returns_content_and_closing_tag() {
let html = "<style>.a { color: red; }</style><div></div>";
assert_eq!(style_element_bounds(html), Some((7, 25, 33)));
}
#[test]
fn find_matching_end_handles_nested_same_tag() {
let html = "<div><div>x</div></div><p></p>";
assert_eq!(find_matching_end(html, "div", 5), Some((17, 23)));
}
#[test]
fn find_matching_end_handles_nested_for_inside_if() {
let html = r#"<for each="category in categories">
<if condition="category.hasItems">
<for each="item in category.items">
{{item.title}}
</for>
</if>
</for>"#;
let open = find_tag_close(html).unwrap() + 1;
assert_eq!(find_matching_end(html, "for", open), Some((218, 224)));
}
#[test]
fn is_void_element_matches_case_insensitively() {
for name in [
"br", "BR", "Img", "IMG", "input", "INPUT", "source", "SOURCE",
] {
assert!(is_void_element(name), "{name} should be void");
}
for name in ["div", "style", "Section", "", "sourcex", "embedded"] {
assert!(!is_void_element(name), "{name} should not be void");
}
}
#[test]
fn walker_treats_uppercase_void_element_as_void() {
let mut walker = Walker::new("<BR><span>after</span>");
let Some(Event::Element(br)) = walker.next() else {
panic!("expected void element");
};
assert_eq!(br.name(), "BR");
assert_eq!(br.inner(), 4..4);
let Some(Event::Element(span)) = walker.next() else {
panic!("expected sibling element");
};
assert_eq!(span.name(), "span");
assert!(walker.next().is_none());
}
}