");
+ assert_eq!(nodes.len(), 1);
+ if let Some(Node::Element(div)) = nodes.first() {
+ assert_eq!(div.tag_name, "div");
+ assert_eq!(div.children.len(), 2);
+ if let Some(Node::Element(span)) = div.children.first() {
+ assert_eq!(span.tag_name, "span");
+ assert_eq!(span.text_content(), Some("Hello".into()));
+ }
+ }
+ }
+
+ #[test]
+ fn test_deeply_nested_fragment() {
+ let nodes = parse_fragment("
Deep
");
+ assert_eq!(nodes.len(), 1);
+ if let Some(Node::Element(div)) = nodes.first() {
+ let ul = div.find_element("ul").unwrap();
+ let li = ul.find_element("li").unwrap();
+ let span = li.find_element("span").unwrap();
+ assert_eq!(span.text_content(), Some("Deep".into()));
+ }
+ }
+
+ #[test]
+ fn test_fragment_void_elements() {
+ let nodes = parse_fragment("
After
");
+ assert_eq!(nodes.len(), 1);
+ if let Some(Node::Element(div)) = nodes.first() {
+ // br is void, should not nest span inside it
+ assert_eq!(div.children.len(), 2);
+ if let Some(Node::Element(br)) = div.children.first() {
+ assert_eq!(br.tag_name, "br");
+ assert!(br.children.is_empty());
+ }
+ }
+ }
+
+ #[test]
+ fn test_fragment_multiple_top_level() {
+ let nodes = parse_fragment("
One
Two
Three
");
+ assert_eq!(nodes.len(), 3);
+ }
+
+ #[test]
+ fn test_many_children_fragment() {
+ use core::fmt::Write;
+ let mut html = String::from("
");
+ for i in 0..1100 {
+ let _ = write!(html, "{i}");
+ }
+ html.push_str("
");
+ let nodes = parse_fragment(&html);
+ assert_eq!(nodes.len(), 1);
+ if let Some(Node::Element(div)) = nodes.first() {
+ assert_eq!(div.children.len(), 1100);
+ }
+ }
+
+ #[test]
+ fn test_unmatched_end_tag() {
+ // Unmatched should not crash or empty the stack
+ let nodes = parse_fragment("
Hello
");
+ assert_eq!(nodes.len(), 1);
+ if let Some(Node::Element(div)) = nodes.first() {
+ assert_eq!(div.tag_name, "div");
+ }
+ }
}
From a721d4948a805418319c892752e5f0c7bb245cce Mon Sep 17 00:00:00 2001
From: Danny Willems
Date: Sun, 8 Feb 2026 09:40:13 -0300
Subject: [PATCH 2/4] fix: emit compile error when for-loop body has multiple
children
Previously, for-loop bodies in html! macro silently dropped all
children except the first element. Now emits a clear compile error:
"for loop body must contain exactly one element".
Closes #50
---
crates/ironhtml-macro/src/lib.rs | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/crates/ironhtml-macro/src/lib.rs b/crates/ironhtml-macro/src/lib.rs
index eb3adfc..487423a 100644
--- a/crates/ironhtml-macro/src/lib.rs
+++ b/crates/ironhtml-macro/src/lib.rs
@@ -313,7 +313,7 @@ struct ForLoop {
impl Parse for ForLoop {
fn parse(input: ParseStream) -> Result {
- input.parse::()?;
+ let for_token: Token![for] = input.parse()?;
let pat = syn::Pat::parse_single(input)?;
input.parse::()?;
input.parse::()?;
@@ -329,6 +329,13 @@ impl Parse for ForLoop {
children.push(content.parse()?);
}
+ if children.len() != 1 || !matches!(children.first(), Some(Node::Element(_))) {
+ return Err(syn::Error::new(
+ for_token.span,
+ "for loop body must contain exactly one element",
+ ));
+ }
+
Ok(Self {
pat,
expr,
From 12d83bb2232c11384efd6d1a3bf3d6c13c33a861 Mon Sep 17 00:00:00 2001
From: Danny Willems
Date: Sun, 8 Feb 2026 09:40:33 -0300
Subject: [PATCH 3/4] fix: remove Rb/Rtc from macro to_pascal_case
These mapped to types that don't exist in ironhtml-elements, causing
confusing compiler errors when used in html! macro. The entries were
also identity mappings (no-ops), since to_pascal_case already produces
the correct output.
Closes #51
---
crates/ironhtml-macro/src/lib.rs | 2 --
1 file changed, 2 deletions(-)
diff --git a/crates/ironhtml-macro/src/lib.rs b/crates/ironhtml-macro/src/lib.rs
index 487423a..29b1b27 100644
--- a/crates/ironhtml-macro/src/lib.rs
+++ b/crates/ironhtml-macro/src/lib.rs
@@ -471,8 +471,6 @@ fn to_pascal_case(s: &str) -> String {
"Em" => "Em".to_string(),
"Rp" => "Rp".to_string(),
"Rt" => "Rt".to_string(),
- "Rb" => "Rb".to_string(),
- "Rtc" => "Rtc".to_string(),
"Wbr" => "Wbr".to_string(),
"Kbd" => "Kbd".to_string(),
"Pre" => "Pre".to_string(),
From 9feedfbc0b90a3a15b0881017bf88df4f404da8d Mon Sep 17 00:00:00 2001
From: Danny Willems
Date: Sun, 8 Feb 2026 09:44:06 -0300
Subject: [PATCH 4/4] test: add comprehensive tree builder tests
28 new tests covering:
- pop_until: skips intermediates, no-match preserves root, closes
correct level with nested same-tag elements
- Fragment nesting: 5 levels deep, text at every level, siblings
with children
- Fragment void elements: multiple voids, voids between text, voids
with attributes, voids nested inside tables
- Fragment comments: top-level, inside elements, between siblings
- Fragment top-level: text-only, mixed text+elements, empty, whitespace
- Malformed input: only end tags, extra end tags, unclosed tags,
interleaved tags, deeply mismatched nesting
- Full document: head elements, implicit body, implicit head+body,
title extraction, round-trip parse
---
crates/ironhtml-parser/src/tree_builder.rs | 349 +++++++++++++++++++++
1 file changed, 349 insertions(+)
diff --git a/crates/ironhtml-parser/src/tree_builder.rs b/crates/ironhtml-parser/src/tree_builder.rs
index 13c723f..56b3bdd 100644
--- a/crates/ironhtml-parser/src/tree_builder.rs
+++ b/crates/ironhtml-parser/src/tree_builder.rs
@@ -553,4 +553,353 @@ mod tests {
assert_eq!(div.tag_name, "div");
}
}
+
+ // ── pop_until tests ──────────────────────────────────────────────
+
+ #[test]
+ fn test_pop_until_skips_intermediate() {
+ // should pop both and , closing at
+ let nodes = parse_fragment("
Text
");
+ assert_eq!(nodes.len(), 1);
+ let div = nodes[0].as_element().unwrap();
+ assert_eq!(div.tag_name, "div");
+ // span was opened, em was opened inside span, then
+ // pops em, span, div
+ let span = div.find_element("span").unwrap();
+ let em = span.find_element("em").unwrap();
+ assert_eq!(em.text_content(), Some("Text".into()));
+ }
+
+ #[test]
+ fn test_pop_until_no_match_preserves_root() {
+ // pops elements but stops at root sentinel
+ let nodes = parse_fragment("
should only close the inner one
+ let nodes = parse_fragment("
Inner
Outer
");
+ assert_eq!(nodes.len(), 1);
+ let outer = nodes[0].as_element().unwrap();
+ assert_eq!(outer.tag_name, "div");
+ assert_eq!(outer.children.len(), 2);
+ // First child: inner div
+ let inner = outer.children[0].as_element().unwrap();
+ assert_eq!(inner.tag_name, "div");
+ assert_eq!(
+ inner.find_element("span").unwrap().text_content(),
+ Some("Inner".into())
+ );
+ // Second child: outer span (after inner div was closed)
+ let outer_span = outer.children[1].as_element().unwrap();
+ assert_eq!(outer_span.tag_name, "span");
+ assert_eq!(outer_span.text_content(), Some("Outer".into()));
+ }
+
+ #[test]
+ fn test_pop_until_multiple_same_tag() {
+ // Three nested s, one closes only the innermost
+ let nodes = parse_fragment("
DeepMidTop
");
+ assert_eq!(nodes.len(), 1);
+ let div = nodes[0].as_element().unwrap();
+ let s1 = div.find_element("span").unwrap();
+ let s2 = s1.find_element("span").unwrap();
+ let s3 = s2.find_element("span").unwrap();
+ assert_eq!(s3.text_content(), Some("Deep".into()));
+ // "Mid" is text after inner span closes, inside middle span
+ assert!(s2.children.len() >= 2);
+ // "Top" is text after middle span closes, inside outer span
+ assert!(s1.children.len() >= 2);
+ }
+
+ // ── fragment nesting tests ───────────────────────────────────────
+
+ #[test]
+ fn test_fragment_five_levels_deep() {
+ let nodes = parse_fragment(
+ "
Title
\
+
",
+ );
+ assert_eq!(nodes.len(), 1);
+ let div = nodes[0].as_element().unwrap();
+ let section = div.find_element("section").unwrap();
+ let article = section.find_element("article").unwrap();
+ let header = article.find_element("header").unwrap();
+ let h1 = header.find_element("h1").unwrap();
+ assert_eq!(h1.text_content(), Some("Title".into()));
+ }
+
+ #[test]
+ fn test_fragment_text_at_every_level() {
+ let nodes = parse_fragment("
ABCDE
");
+ assert_eq!(nodes.len(), 1);
+ let div = nodes[0].as_element().unwrap();
+ // div has: text("A"), span, text("E")
+ assert_eq!(div.children.len(), 3);
+ assert_eq!(div.children[0].as_text().unwrap().data, "A");
+ let span = div.children[1].as_element().unwrap();
+ // span has: text("B"), em, text("D")
+ assert_eq!(span.children.len(), 3);
+ assert_eq!(span.children[0].as_text().unwrap().data, "B");
+ let em = span.children[1].as_element().unwrap();
+ assert_eq!(em.text_content(), Some("C".into()));
+ assert_eq!(span.children[2].as_text().unwrap().data, "D");
+ assert_eq!(div.children[2].as_text().unwrap().data, "E");
+ }
+
+ #[test]
+ fn test_fragment_siblings_with_children() {
+ let nodes = parse_fragment("
One!
Two
Three
");
+ assert_eq!(nodes.len(), 1);
+ let ul = nodes[0].as_element().unwrap();
+ assert_eq!(ul.children.len(), 3);
+ // First li has text + em
+ let li1 = ul.children[0].as_element().unwrap();
+ assert_eq!(li1.children.len(), 2);
+ assert_eq!(li1.children[0].as_text().unwrap().data, "One");
+ assert_eq!(
+ li1.children[1].as_element().unwrap().text_content(),
+ Some("!".into())
+ );
+ // Second and third are simple
+ let li2 = ul.children[1].as_element().unwrap();
+ assert_eq!(li2.text_content(), Some("Two".into()));
+ let li3 = ul.children[2].as_element().unwrap();
+ assert_eq!(li3.text_content(), Some("Three".into()));
+ }
+
+ // ── fragment void element tests ──────────────────────────────────
+
+ #[test]
+ fn test_fragment_multiple_void_elements() {
+ let nodes = parse_fragment("
");
+ assert_eq!(nodes.len(), 1);
+ let div = nodes[0].as_element().unwrap();
+ assert_eq!(div.children.len(), 4);
+ assert_eq!(div.children[0].as_element().unwrap().tag_name, "br");
+ assert_eq!(div.children[1].as_element().unwrap().tag_name, "hr");
+ assert_eq!(div.children[2].as_element().unwrap().tag_name, "img");
+ assert_eq!(div.children[3].as_element().unwrap().tag_name, "input");
+ // None should have children
+ for child in &div.children {
+ assert!(child.as_element().unwrap().children.is_empty());
+ }
+ }
+
+ #[test]
+ fn test_fragment_void_between_text() {
+ let nodes = parse_fragment("
");
+ // No start tags to match, nothing produced
+ assert!(nodes.is_empty());
+ }
+
+ #[test]
+ fn test_malformed_extra_end_tags() {
+ let nodes = parse_fragment("
Hello
");
+ assert_eq!(nodes.len(), 1);
+ let div = nodes[0].as_element().unwrap();
+ assert_eq!(div.text_content(), Some("Hello".into()));
+ }
+
+ #[test]
+ fn test_malformed_unclosed_tags() {
+ // Tags that are never closed
+ let nodes = parse_fragment("
Text");
+ assert_eq!(nodes.len(), 1);
+ let div = nodes[0].as_element().unwrap();
+ let span = div.find_element("span").unwrap();
+ let em = span.find_element("em").unwrap();
+ assert_eq!(em.text_content(), Some("Text".into()));
+ }
+
+ #[test]
+ fn test_malformed_interleaved_tags() {
+ // - interleaved close order
+ let nodes = parse_fragment("TextAfter");
+ // After pops both i and b (pop_until finds b).
+ // "After" goes to root since both are closed.
+ // is unmatched, ignored.
+ assert!(!nodes.is_empty());
+ let b = nodes[0].as_element().unwrap();
+ assert_eq!(b.tag_name, "b");
+ }
+
+ #[test]
+ fn test_malformed_deeply_mismatched() {
+ let nodes = parse_fragment("Text");
+ // pops e, d, c, b, a
+ assert_eq!(nodes.len(), 1);
+ let a = nodes[0].as_element().unwrap();
+ assert_eq!(a.tag_name, "a");
+ assert!(a.find_element("e").is_some());
+ }
+
+ // ── full document tests ──────────────────────────────────────────
+
+ #[test]
+ fn test_document_head_elements() {
+ let doc = parse(
+ r#"
+ Test
+
+
+ "#,
+ );
+ let head = doc.head().unwrap();
+ assert!(head.find_element("title").is_some());
+ assert!(head.find_element("meta").is_some());
+ assert!(head.find_element("link").is_some());
+ }
+
+ #[test]
+ fn test_document_implicit_body() {
+ // No explicit tag, elements go into implicit body
+ let doc = parse("
Content
");
+ let body = doc.body().unwrap();
+ let div = body.find_element("div").unwrap();
+ assert_eq!(div.text_content(), Some("Content".into()));
+ }
+
+ #[test]
+ fn test_document_implicit_head_and_body() {
+ // No head or body, just content
+ let doc = parse("
Content
");
+ assert_eq!(doc.root.tag_name, "html");
+ assert!(doc.head().is_some());
+ assert!(doc.body().is_some());
+ let body = doc.body().unwrap();
+ let div = body.find_element("div").unwrap();
+ assert_eq!(div.text_content(), Some("Content".into()));
+ }
+
+ #[test]
+ fn test_document_title() {
+ let doc = parse(
+ "Hello World\
+ ",
+ );
+ assert_eq!(doc.title(), Some(String::from("Hello World")));
+ }
+
+ #[test]
+ fn test_document_round_trip() {
+ let html = "Test\
+
Hello
";
+ let doc = parse(html);
+ let output = doc.to_html();
+ // Re-parse the output and verify structure
+ let doc2 = parse(&output);
+ assert_eq!(doc2.title(), Some(String::from("Test")));
+ let body = doc2.body().unwrap();
+ let p = body.find_element("p").unwrap();
+ assert_eq!(p.text_content(), Some("Hello".into()));
+ }
}