Skip to main content

rdom_parser/
parser.rs

1//! Recursive-descent HTML-ish parser.
2//!
3//! Consumes a template string, emits `Dom<Ext>` tree under a mount
4//! NodeId. Supports:
5//!
6//! - Start tags (`<tag>`), end tags (`</tag>`), self-closing (`<br/>`)
7//! - Void elements (`<br>`, `<hr>`, `<img>`, …) auto-close without `/>`
8//! - Attributes: `name="value"` / `name='value'` / `name=value` / `name`
9//! - Text with entity decoding: `&amp; &lt; &gt; &quot; &apos; &#NNN; &#xHH;`
10//! - Comments: `<!-- … -->` preserved as Comment nodes
11//! - Case-insensitive tag names (tags are normalized to lowercase)
12//!
13//! Out of scope: `<!DOCTYPE>`, CDATA, namespace prefixes, processing
14//! instructions, `<script>` / `<style>` raw-text mode.
15
16use rdom_core::{Dom, NodeId};
17
18use crate::error::{ParseError, Result};
19
20/// HTML5 void tag set — never have children, always self-close.
21/// Mirror of `rdom_core::markup::VOID_TAGS` (we don't depend on that
22/// private constant, so redeclare here).
23const VOID_TAGS: &[&str] = &[
24    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
25    "track", "wbr", "vr",
26];
27
28fn is_void_tag(tag: &str) -> bool {
29    VOID_TAGS.contains(&tag)
30}
31
32/// Parse `template` into a fresh `Dom<Ext>` with a Fragment root. The
33/// returned ids are the top-level children of the fragment.
34pub fn parse<Ext>(template: &str) -> Result<(Dom<Ext>, Vec<NodeId>)>
35where
36    Ext: Default + 'static,
37{
38    let mut dom = Dom::new();
39    let root = dom.root();
40    let ids = parse_into(&mut dom, template, root)?;
41    Ok((dom, ids))
42}
43
44/// Parse `template` and append the parsed tree under `mount`. Returns
45/// the ids of the top-level parsed nodes (direct children appended to
46/// `mount`). Does not alter existing children of `mount`.
47pub fn parse_into<Ext>(dom: &mut Dom<Ext>, template: &str, mount: NodeId) -> Result<Vec<NodeId>>
48where
49    Ext: Default + 'static,
50{
51    let mut p = Parser::new(template);
52    let ids = p.parse_nodes(dom, mount)?;
53    Ok(ids)
54}
55
56// ─── Internal parser ────────────────────────────────────────────────
57
58struct Parser<'a> {
59    src: &'a str,
60    bytes: &'a [u8],
61    pos: usize,
62    line: u32,
63    col: u32,
64}
65
66impl<'a> Parser<'a> {
67    fn new(src: &'a str) -> Self {
68        Self {
69            src,
70            bytes: src.as_bytes(),
71            pos: 0,
72            line: 1,
73            col: 1,
74        }
75    }
76
77    // ── Cursor ────────────────────────────────────────────────────
78
79    fn eof(&self) -> bool {
80        self.pos >= self.bytes.len()
81    }
82
83    fn peek(&self) -> Option<u8> {
84        self.bytes.get(self.pos).copied()
85    }
86
87    fn peek_at(&self, offset: usize) -> Option<u8> {
88        self.bytes.get(self.pos + offset).copied()
89    }
90
91    fn starts_with(&self, needle: &str) -> bool {
92        self.src[self.pos..].starts_with(needle)
93    }
94
95    fn advance(&mut self) -> Option<u8> {
96        let b = self.peek()?;
97        self.pos += 1;
98        if b == b'\n' {
99            self.line += 1;
100            self.col = 1;
101        } else {
102            self.col += 1;
103        }
104        Some(b)
105    }
106
107    fn advance_n(&mut self, n: usize) {
108        for _ in 0..n {
109            if self.advance().is_none() {
110                break;
111            }
112        }
113    }
114
115    fn skip_ws(&mut self) {
116        while let Some(b) = self.peek() {
117            if b.is_ascii_whitespace() {
118                self.advance();
119            } else {
120                break;
121            }
122        }
123    }
124
125    fn err(&self, msg: impl Into<String>) -> ParseError {
126        ParseError::new(msg, self.line, self.col, self.pos)
127    }
128
129    // ── Top-level: parse children of `parent` ─────────────────────
130
131    fn parse_nodes<Ext>(&mut self, dom: &mut Dom<Ext>, parent: NodeId) -> Result<Vec<NodeId>>
132    where
133        Ext: Default + 'static,
134    {
135        let mut out = Vec::new();
136        loop {
137            if self.eof() {
138                break;
139            }
140            if self.starts_with("</") {
141                // Bubble up to the containing element parser.
142                break;
143            }
144            if self.starts_with("<!--") {
145                let id = self.parse_comment(dom, parent)?;
146                out.push(id);
147                continue;
148            }
149            if self.peek() == Some(b'<') {
150                let id = self.parse_element(dom, parent)?;
151                out.push(id);
152                continue;
153            }
154            // Plain text until next '<'.
155            let id = self.parse_text(dom, parent)?;
156            if let Some(id) = id {
157                out.push(id);
158            }
159        }
160        Ok(out)
161    }
162
163    // ── Comment ────────────────────────────────────────────────────
164
165    fn parse_comment<Ext>(&mut self, dom: &mut Dom<Ext>, parent: NodeId) -> Result<NodeId>
166    where
167        Ext: Default + 'static,
168    {
169        // Consume `<!--`.
170        self.advance_n(4);
171        let start = self.pos;
172        loop {
173            if self.eof() {
174                return Err(self
175                    .err("unterminated comment")
176                    .with_hint("missing `-->` closing"));
177            }
178            if self.starts_with("-->") {
179                let data = &self.src[start..self.pos];
180                self.advance_n(3);
181                let id = dom.create_comment(data);
182                dom.append_child(parent, id)
183                    .map_err(|e| self.err(format!("failed to append comment: {:?}", e)))?;
184                return Ok(id);
185            }
186            self.advance();
187        }
188    }
189
190    // ── Text ───────────────────────────────────────────────────────
191
192    /// Consume chars until next `<`, decode entities, emit a Text node.
193    /// Returns `None` when the captured text is empty (no Text node
194    /// created). UTF-8-safe — we slice by byte but the boundaries are
195    /// always on valid char boundaries because we advance byte-at-a-time
196    /// only via `self.advance()` which respects the source encoding.
197    fn parse_text<Ext>(&mut self, dom: &mut Dom<Ext>, parent: NodeId) -> Result<Option<NodeId>>
198    where
199        Ext: Default + 'static,
200    {
201        let mut out = String::new();
202        loop {
203            // Collect consecutive raw (non-'<', non-'&') bytes as a
204            // UTF-8 slice from the source.
205            let slice_start = self.pos;
206            while let Some(b) = self.peek() {
207                if b == b'<' || b == b'&' {
208                    break;
209                }
210                self.advance();
211            }
212            if slice_start < self.pos {
213                out.push_str(&self.src[slice_start..self.pos]);
214            }
215            match self.peek() {
216                None | Some(b'<') => break,
217                Some(b'&') => {
218                    out.push_str(&self.parse_entity()?);
219                }
220                _ => unreachable!(),
221            }
222        }
223        if out.is_empty() {
224            return Ok(None);
225        }
226        let id = dom.create_text_node(&out);
227        dom.append_child(parent, id)
228            .map_err(|e| self.err(format!("failed to append text: {:?}", e)))?;
229        Ok(Some(id))
230    }
231
232    // ── Entity ─────────────────────────────────────────────────────
233
234    fn parse_entity(&mut self) -> Result<String> {
235        // We've seen '&'. Try to match a known entity; fall back to
236        // preserving as-is on malformed input (lenient mode, matches
237        // browser tolerance).
238        debug_assert_eq!(self.peek(), Some(b'&'));
239        let save = self.snapshot();
240
241        self.advance(); // consume '&'
242
243        // Find the end of the entity — next ';' or 16 chars max.
244        let start = self.pos;
245        let mut end = None;
246        for i in 0..16 {
247            match self.peek_at(i) {
248                Some(b';') => {
249                    end = Some(self.pos + i);
250                    break;
251                }
252                Some(b) if b.is_ascii_alphanumeric() || b == b'#' || b == b'x' || b == b'X' => {
253                    continue;
254                }
255                _ => break,
256            }
257        }
258
259        let Some(end) = end else {
260            // No terminator found — restore cursor and emit '&' literally.
261            self.restore(save);
262            self.advance(); // consume the '&'
263            return Ok("&".to_string());
264        };
265
266        let body = &self.src[start..end];
267        let decoded = decode_entity_body(body);
268        if let Some(d) = decoded {
269            // Skip past the ';'.
270            let consume = end - self.pos + 1;
271            self.advance_n(consume);
272            Ok(d)
273        } else {
274            // Unknown entity — leave the '&' literal and continue;
275            // later chars will be consumed as text.
276            self.restore(save);
277            self.advance();
278            Ok("&".to_string())
279        }
280    }
281
282    // ── Element ────────────────────────────────────────────────────
283
284    fn parse_element<Ext>(&mut self, dom: &mut Dom<Ext>, parent: NodeId) -> Result<NodeId>
285    where
286        Ext: Default + 'static,
287    {
288        debug_assert_eq!(self.peek(), Some(b'<'));
289        self.advance(); // '<'
290
291        let tag = self.parse_tag_name()?;
292        let tag_lc = tag.to_ascii_lowercase();
293
294        let element = dom.create_element(&tag_lc);
295
296        // Parse attributes until '>' or '/>'.
297        loop {
298            self.skip_ws();
299            match self.peek() {
300                None => {
301                    return Err(self
302                        .err(format!("unexpected EOF inside <{}>", tag_lc))
303                        .with_hint("missing closing `>`"));
304                }
305                Some(b'>') => {
306                    self.advance();
307                    break;
308                }
309                Some(b'/') => {
310                    // Self-closing.
311                    self.advance();
312                    self.skip_ws();
313                    if self.peek() != Some(b'>') {
314                        return Err(self
315                            .err(format!("expected `>` after `/` in <{}/>", tag_lc))
316                            .with_hint("self-closing syntax is `/>`"));
317                    }
318                    self.advance();
319                    dom.append_child(parent, element)
320                        .map_err(|e| self.err(format!("failed to append <{}>: {:?}", tag_lc, e)))?;
321                    return Ok(element);
322                }
323                Some(_) => {
324                    self.parse_attribute(dom, element)?;
325                }
326            }
327        }
328
329        // Void tag? Done.
330        if is_void_tag(&tag_lc) {
331            dom.append_child(parent, element)
332                .map_err(|e| self.err(format!("failed to append <{}>: {:?}", tag_lc, e)))?;
333            return Ok(element);
334        }
335
336        // Parse children, then expect </tag>.
337        self.parse_nodes(dom, element)?;
338
339        if !self.starts_with("</") {
340            return Err(self
341                .err(format!("missing closing tag for <{}>", tag_lc))
342                .with_hint(format!("add </{}> to close", tag_lc)));
343        }
344        self.advance_n(2); // '</'
345
346        let close_tag = self.parse_tag_name()?;
347        if close_tag.to_ascii_lowercase() != tag_lc {
348            return Err(self
349                .err(format!(
350                    "mismatched closing tag: found </{}>, expected </{}>",
351                    close_tag, tag_lc
352                ))
353                .with_hint("tags must be properly nested"));
354        }
355        self.skip_ws();
356        if self.peek() != Some(b'>') {
357            return Err(self
358                .err(format!("expected `>` in </{}>", tag_lc))
359                .with_hint("no attributes on closing tags"));
360        }
361        self.advance();
362
363        dom.append_child(parent, element)
364            .map_err(|e| self.err(format!("failed to append <{}>: {:?}", tag_lc, e)))?;
365        Ok(element)
366    }
367
368    fn parse_tag_name(&mut self) -> Result<String> {
369        let start = self.pos;
370        while let Some(b) = self.peek() {
371            if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' {
372                self.advance();
373            } else {
374                break;
375            }
376        }
377        if start == self.pos {
378            return Err(self
379                .err("expected tag name")
380                .with_hint("tag names start with a letter"));
381        }
382        Ok(self.src[start..self.pos].to_string())
383    }
384
385    fn parse_attribute<Ext>(&mut self, dom: &mut Dom<Ext>, element: NodeId) -> Result<()>
386    where
387        Ext: Default + 'static,
388    {
389        let name = self.parse_attr_name()?;
390        self.skip_ws();
391
392        let value = if self.peek() == Some(b'=') {
393            self.advance();
394            self.skip_ws();
395            Some(self.parse_attr_value()?)
396        } else {
397            None
398        };
399
400        match value {
401            Some(v) => {
402                // Classes are normalized into the classList; other
403                // attrs go into the attribute map.
404                if name.eq_ignore_ascii_case("class") {
405                    for token in v.split_ascii_whitespace() {
406                        dom.add_class(element, token)
407                            .map_err(|e| self.err(format!("failed to add class: {:?}", e)))?;
408                    }
409                } else {
410                    dom.set_attribute(element, &name, &v)
411                        .map_err(|e| self.err(format!("failed to set attribute: {:?}", e)))?;
412                }
413            }
414            None => {
415                // Boolean attribute.
416                dom.set_attribute(element, &name, "")
417                    .map_err(|e| self.err(format!("failed to set attribute: {:?}", e)))?;
418            }
419        }
420        Ok(())
421    }
422
423    fn parse_attr_name(&mut self) -> Result<String> {
424        let start = self.pos;
425        while let Some(b) = self.peek() {
426            if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':' {
427                self.advance();
428            } else {
429                break;
430            }
431        }
432        if start == self.pos {
433            return Err(self.err("expected attribute name"));
434        }
435        Ok(self.src[start..self.pos].to_string())
436    }
437
438    fn parse_attr_value(&mut self) -> Result<String> {
439        let first = self.peek();
440        match first {
441            Some(b'"') => self.parse_quoted(b'"'),
442            Some(b'\'') => self.parse_quoted(b'\''),
443            Some(_) => self.parse_unquoted(),
444            None => Err(self.err("unexpected EOF in attribute value")),
445        }
446    }
447
448    fn parse_quoted(&mut self, quote: u8) -> Result<String> {
449        self.advance(); // opening quote
450        let mut out = String::new();
451        loop {
452            // Collect consecutive raw bytes up to the next quote or &.
453            let slice_start = self.pos;
454            while let Some(b) = self.peek() {
455                if b == quote || b == b'&' {
456                    break;
457                }
458                self.advance();
459            }
460            if slice_start < self.pos {
461                out.push_str(&self.src[slice_start..self.pos]);
462            }
463            match self.peek() {
464                None => {
465                    return Err(self
466                        .err(format!(
467                            "unterminated attribute value (expected `{}`)",
468                            quote as char
469                        ))
470                        .with_hint("missing closing quote"));
471                }
472                Some(b) if b == quote => {
473                    self.advance();
474                    return Ok(out);
475                }
476                Some(b'&') => {
477                    out.push_str(&self.parse_entity()?);
478                }
479                _ => unreachable!(),
480            }
481        }
482    }
483
484    fn parse_unquoted(&mut self) -> Result<String> {
485        let mut out = String::new();
486        loop {
487            let slice_start = self.pos;
488            while let Some(b) = self.peek() {
489                if b.is_ascii_whitespace() || b == b'>' || b == b'/' || b == b'&' {
490                    break;
491                }
492                self.advance();
493            }
494            if slice_start < self.pos {
495                out.push_str(&self.src[slice_start..self.pos]);
496            }
497            match self.peek() {
498                Some(b'&') => out.push_str(&self.parse_entity()?),
499                _ => break,
500            }
501        }
502        if out.is_empty() {
503            return Err(self
504                .err("empty unquoted attribute value")
505                .with_hint("use \"\" or '' for empty value"));
506        }
507        Ok(out)
508    }
509
510    // ── Snapshots (for entity recovery) ─────────────────────────
511
512    fn snapshot(&self) -> (usize, u32, u32) {
513        (self.pos, self.line, self.col)
514    }
515
516    fn restore(&mut self, (pos, line, col): (usize, u32, u32)) {
517        self.pos = pos;
518        self.line = line;
519        self.col = col;
520    }
521}
522
523// ─── Entity decoding ────────────────────────────────────────────────
524
525/// Decode an entity body (chars between `&` and `;`). Returns `None`
526/// for unrecognized input — caller emits the `&` literal.
527fn decode_entity_body(body: &str) -> Option<String> {
528    match body {
529        "amp" => Some("&".to_string()),
530        "lt" => Some("<".to_string()),
531        "gt" => Some(">".to_string()),
532        "quot" => Some("\"".to_string()),
533        "apos" => Some("'".to_string()),
534        "nbsp" => Some("\u{00A0}".to_string()),
535        _ => {
536            if let Some(rest) = body.strip_prefix('#') {
537                if let Some(hex) = rest.strip_prefix('x').or_else(|| rest.strip_prefix('X')) {
538                    let n = u32::from_str_radix(hex, 16).ok()?;
539                    let c = char::from_u32(n)?;
540                    Some(c.to_string())
541                } else {
542                    let n: u32 = rest.parse().ok()?;
543                    let c = char::from_u32(n)?;
544                    Some(c.to_string())
545                }
546            } else {
547                None
548            }
549        }
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    fn parse_str(s: &str) -> (Dom<()>, Vec<NodeId>) {
558        parse(s).unwrap()
559    }
560
561    // ── Basic elements ───────────────────────────────────────────────
562
563    #[test]
564    fn empty_element() {
565        let (dom, ids) = parse_str("<div></div>");
566        assert_eq!(ids.len(), 1);
567        let n = dom.node(ids[0]);
568        assert_eq!(n.tag_name(), Some("div"));
569        assert_eq!(n.child_nodes().count(), 0);
570    }
571
572    #[test]
573    fn self_closing_element() {
574        let (dom, ids) = parse_str("<br/>");
575        assert_eq!(ids.len(), 1);
576        assert_eq!(dom.node(ids[0]).tag_name(), Some("br"));
577    }
578
579    #[test]
580    fn self_closing_with_space() {
581        let (dom, ids) = parse_str("<br />");
582        assert_eq!(dom.node(ids[0]).tag_name(), Some("br"));
583    }
584
585    #[test]
586    fn void_element_auto_closes() {
587        // `<br>` without `/>` still treated as void.
588        let (dom, ids) = parse_str("<br>");
589        assert_eq!(ids.len(), 1);
590        assert_eq!(dom.node(ids[0]).tag_name(), Some("br"));
591    }
592
593    #[test]
594    fn multiple_void_elements() {
595        let (dom, ids) = parse_str("<br><hr><img>");
596        assert_eq!(ids.len(), 3);
597        assert_eq!(dom.node(ids[0]).tag_name(), Some("br"));
598        assert_eq!(dom.node(ids[1]).tag_name(), Some("hr"));
599        assert_eq!(dom.node(ids[2]).tag_name(), Some("img"));
600    }
601
602    #[test]
603    fn case_insensitive_tag_names() {
604        let (dom, ids) = parse_str("<DIV></div>");
605        assert_eq!(dom.node(ids[0]).tag_name(), Some("div"));
606    }
607
608    // ── Nested elements ──────────────────────────────────────────────
609
610    #[test]
611    fn nested_elements() {
612        let (dom, ids) = parse_str("<div><span></span></div>");
613        let outer = ids[0];
614        assert_eq!(dom.node(outer).child_nodes().count(), 1);
615        let inner = dom.node(outer).first_child().unwrap().id();
616        assert_eq!(dom.node(inner).tag_name(), Some("span"));
617    }
618
619    #[test]
620    fn deeply_nested() {
621        let (dom, ids) = parse_str("<a><b><c><d></d></c></b></a>");
622        let mut cur = ids[0];
623        for tag in &["a", "b", "c", "d"] {
624            assert_eq!(dom.node(cur).tag_name(), Some(*tag));
625            cur = dom.node(cur).first_child().map(|n| n.id()).unwrap_or(cur);
626        }
627    }
628
629    // ── Text content ─────────────────────────────────────────────────
630
631    #[test]
632    fn text_node() {
633        let (dom, ids) = parse_str("<div>hello</div>");
634        let child = dom.node(ids[0]).first_child().unwrap();
635        assert_eq!(child.node_value(), Some("hello"));
636    }
637
638    #[test]
639    fn mixed_content() {
640        let (dom, ids) = parse_str("<div>before <b>mid</b> after</div>");
641        let div = ids[0];
642        let children: Vec<_> = dom.node(div).child_nodes().collect();
643        assert_eq!(children.len(), 3);
644        assert_eq!(children[0].node_value(), Some("before "));
645        assert_eq!(children[1].tag_name(), Some("b"));
646        assert_eq!(children[2].node_value(), Some(" after"));
647    }
648
649    #[test]
650    fn text_at_top_level() {
651        let (dom, ids) = parse_str("hello <span>world</span>");
652        assert_eq!(ids.len(), 2);
653        let root = dom.root();
654        let first = dom.node(root).first_child().unwrap();
655        assert_eq!(first.node_value(), Some("hello "));
656    }
657
658    // ── Attributes ──────────────────────────────────────────────────
659
660    #[test]
661    fn double_quoted_attr() {
662        let (dom, ids) = parse_str(r#"<div id="main"></div>"#);
663        assert_eq!(dom.node(ids[0]).get_attribute("id"), Some("main"));
664    }
665
666    #[test]
667    fn single_quoted_attr() {
668        let (dom, ids) = parse_str("<div id='main'></div>");
669        assert_eq!(dom.node(ids[0]).get_attribute("id"), Some("main"));
670    }
671
672    #[test]
673    fn unquoted_attr() {
674        let (dom, ids) = parse_str("<div id=main></div>");
675        assert_eq!(dom.node(ids[0]).get_attribute("id"), Some("main"));
676    }
677
678    #[test]
679    fn boolean_attr() {
680        let (dom, ids) = parse_str("<input disabled>");
681        assert_eq!(dom.node(ids[0]).get_attribute("disabled"), Some(""));
682        assert!(dom.node(ids[0]).has_attribute("disabled"));
683    }
684
685    #[test]
686    fn multiple_attrs() {
687        let (dom, ids) = parse_str(r#"<div id="x" role="banner" data-n="5"></div>"#);
688        let n = dom.node(ids[0]);
689        assert_eq!(n.get_attribute("id"), Some("x"));
690        assert_eq!(n.get_attribute("role"), Some("banner"));
691        assert_eq!(n.get_attribute("data-n"), Some("5"));
692    }
693
694    #[test]
695    fn class_attr_populates_classlist() {
696        let (dom, ids) = parse_str(r#"<div class="a b c"></div>"#);
697        let n = dom.node(ids[0]);
698        assert!(n.has_class("a"));
699        assert!(n.has_class("b"));
700        assert!(n.has_class("c"));
701    }
702
703    #[test]
704    fn attr_name_case_preserved() {
705        // Unlike tag names, we preserve attribute name case.
706        let (dom, ids) = parse_str(r#"<div dataFoo="bar"></div>"#);
707        assert_eq!(dom.node(ids[0]).get_attribute("dataFoo"), Some("bar"));
708    }
709
710    #[test]
711    fn whitespace_around_attrs() {
712        let (dom, ids) = parse_str("<div  id=main  role=banner  ></div>");
713        assert_eq!(dom.node(ids[0]).get_attribute("id"), Some("main"));
714        assert_eq!(dom.node(ids[0]).get_attribute("role"), Some("banner"));
715    }
716
717    #[test]
718    fn attr_name_with_hyphens_and_colons() {
719        let (dom, ids) = parse_str(r#"<div data-x="1" aria:label="y"></div>"#);
720        assert_eq!(dom.node(ids[0]).get_attribute("data-x"), Some("1"));
721        assert_eq!(dom.node(ids[0]).get_attribute("aria:label"), Some("y"));
722    }
723
724    // ── Entities ─────────────────────────────────────────────────────
725
726    #[test]
727    fn entity_amp() {
728        let (dom, ids) = parse_str("<div>a &amp; b</div>");
729        let child = dom.node(ids[0]).first_child().unwrap();
730        assert_eq!(child.node_value(), Some("a & b"));
731    }
732
733    #[test]
734    fn entity_lt_gt_quot_apos() {
735        let (dom, ids) = parse_str("<div>&lt;tag&gt; &quot;q&quot; &apos;a&apos;</div>");
736        let child = dom.node(ids[0]).first_child().unwrap();
737        assert_eq!(child.node_value(), Some("<tag> \"q\" 'a'"));
738    }
739
740    #[test]
741    fn entity_decimal_numeric() {
742        let (dom, ids) = parse_str("<div>&#65;&#66;</div>");
743        let child = dom.node(ids[0]).first_child().unwrap();
744        assert_eq!(child.node_value(), Some("AB"));
745    }
746
747    #[test]
748    fn entity_hex_numeric() {
749        let (dom, ids) = parse_str("<div>&#x41;&#X42;</div>");
750        let child = dom.node(ids[0]).first_child().unwrap();
751        assert_eq!(child.node_value(), Some("AB"));
752    }
753
754    #[test]
755    fn entity_in_attr_value() {
756        let (dom, ids) = parse_str(r#"<div title="a &amp; b"></div>"#);
757        assert_eq!(dom.node(ids[0]).get_attribute("title"), Some("a & b"));
758    }
759
760    #[test]
761    fn unknown_entity_preserved_as_literal_amp() {
762        // `&unknown;` → '&' literal + "unknown;" as text
763        let (dom, ids) = parse_str("<div>&xyz;</div>");
764        let child = dom.node(ids[0]).first_child().unwrap();
765        // We emit '&' and leave the rest to parse as text.
766        assert_eq!(child.node_value(), Some("&xyz;"));
767    }
768
769    #[test]
770    fn entity_nbsp() {
771        let (dom, ids) = parse_str("<div>a&nbsp;b</div>");
772        let child = dom.node(ids[0]).first_child().unwrap();
773        assert_eq!(child.node_value(), Some("a\u{A0}b"));
774    }
775
776    // ── Comments ─────────────────────────────────────────────────────
777
778    #[test]
779    fn comment_preserved() {
780        let (dom, ids) = parse_str("<!-- hello -->");
781        assert_eq!(ids.len(), 1);
782        let c = dom.node(ids[0]);
783        assert_eq!(c.node_type(), rdom_core::NodeType::Comment);
784        assert_eq!(c.data(), Some(" hello "));
785    }
786
787    #[test]
788    fn comment_inside_element() {
789        let (dom, ids) = parse_str("<div><!-- note -->body</div>");
790        let div = ids[0];
791        let children: Vec<_> = dom.node(div).child_nodes().collect();
792        assert_eq!(children.len(), 2);
793        assert_eq!(children[0].node_type(), rdom_core::NodeType::Comment);
794        assert_eq!(children[1].node_value(), Some("body"));
795    }
796
797    // ── Errors ───────────────────────────────────────────────────────
798
799    #[test]
800    fn error_mismatched_tags() {
801        let err = parse::<()>("<div></span>").unwrap_err();
802        assert!(err.msg.contains("mismatched"));
803    }
804
805    #[test]
806    fn error_missing_close() {
807        let err = parse::<()>("<div>").unwrap_err();
808        assert!(err.msg.contains("missing closing"));
809    }
810
811    #[test]
812    fn error_unterminated_comment() {
813        let err = parse::<()>("<!-- never ends").unwrap_err();
814        assert!(err.msg.contains("unterminated"));
815    }
816
817    #[test]
818    fn error_unterminated_attr_value() {
819        let err = parse::<()>(r#"<div id="abc>"#).unwrap_err();
820        assert!(err.msg.contains("unterminated"));
821    }
822
823    #[test]
824    fn error_position_reported() {
825        let err = parse::<()>("<div>\n<span></p>\n</div>").unwrap_err();
826        // Mismatched </p> is on line 2.
827        assert_eq!(err.line, 2);
828    }
829
830    #[test]
831    fn error_has_hint() {
832        let err = parse::<()>("<div>").unwrap_err();
833        assert!(err.hint.is_some());
834    }
835
836    // ── parse_into API ───────────────────────────────────────────────
837
838    #[test]
839    fn parse_into_appends_to_mount() {
840        let mut dom: Dom<()> = Dom::new();
841        let mount = dom.create_element("body");
842        let root = dom.root();
843        dom.append_child(root, mount).unwrap();
844
845        let ids = parse_into(&mut dom, "<h1>Title</h1><p>Body</p>", mount).unwrap();
846        assert_eq!(ids.len(), 2);
847        assert_eq!(dom.node(mount).child_nodes().count(), 2);
848    }
849
850    // ── Complex templates ────────────────────────────────────────────
851
852    #[test]
853    fn realistic_template() {
854        let t = r#"
855            <div class="card" id="hero">
856              <h1>Welcome</h1>
857              <p>Hello &amp; welcome to <strong>rdom</strong>.</p>
858              <br/>
859              <!-- TODO: add icon -->
860              <button disabled>OK</button>
861            </div>
862        "#;
863        let (dom, ids) = parse::<()>(t).unwrap();
864        // Top-level: the outer div (plus potentially whitespace-only
865        // text around it — we preserve all whitespace).
866        let div_id = ids
867            .iter()
868            .find(|&&id| dom.node(id).tag_name() == Some("div"))
869            .copied()
870            .unwrap();
871        let div = dom.node(div_id);
872        assert!(div.has_class("card"));
873        assert_eq!(div.get_attribute("id"), Some("hero"));
874
875        // Find <h1> inside.
876        let h1 = div
877            .child_nodes()
878            .find(|c| c.tag_name() == Some("h1"))
879            .unwrap();
880        assert_eq!(
881            dom.node(h1.id()).first_child().unwrap().node_value(),
882            Some("Welcome")
883        );
884
885        // The <button disabled> element.
886        let btn = div
887            .child_nodes()
888            .find(|c| c.tag_name() == Some("button"))
889            .unwrap();
890        assert!(dom.node(btn.id()).has_attribute("disabled"));
891    }
892
893    // ── Round-trip ───────────────────────────────────────────────────
894
895    #[test]
896    fn round_trip_simple() {
897        let src = "<div><span>hi</span></div>";
898        let (dom, ids) = parse::<()>(src).unwrap();
899        let out = dom.outer_markup(ids[0]);
900        assert_eq!(out, src);
901    }
902
903    #[test]
904    fn round_trip_with_attrs() {
905        let src = r#"<div data-x="1" id="main"><p></p></div>"#;
906        let (dom, ids) = parse::<()>(src).unwrap();
907        let out = dom.outer_markup(ids[0]);
908        // Attributes sort alphabetically in outer_markup, matching input order.
909        assert_eq!(out, src);
910    }
911
912    #[test]
913    fn round_trip_void_element() {
914        let src = "<hr/>";
915        let (dom, ids) = parse::<()>(src).unwrap();
916        let out = dom.outer_markup(ids[0]);
917        assert_eq!(out, "<hr/>");
918    }
919
920    #[test]
921    fn round_trip_entities_escaped() {
922        let src = "<div>a &amp; b &lt;c&gt;</div>";
923        let (dom, ids) = parse::<()>(src).unwrap();
924        let out = dom.outer_markup(ids[0]);
925        assert_eq!(out, src);
926    }
927
928    // ── Whitespace preservation ──────────────────────────────────────
929
930    #[test]
931    fn whitespace_preserved_in_text() {
932        let (dom, ids) = parse_str("<p>  hello   world  </p>");
933        let child = dom.node(ids[0]).first_child().unwrap();
934        assert_eq!(child.node_value(), Some("  hello   world  "));
935    }
936
937    #[test]
938    fn newlines_preserved() {
939        let (dom, ids) = parse_str("<pre>line1\nline2</pre>");
940        let child = dom.node(ids[0]).first_child().unwrap();
941        assert_eq!(child.node_value(), Some("line1\nline2"));
942    }
943
944    // ── Many children ────────────────────────────────────────────────
945
946    #[test]
947    fn many_children() {
948        let src: String = (0..50).map(|_| "<li>x</li>").collect();
949        let (dom, ids) = parse::<()>(&format!("<ul>{}</ul>", src)).unwrap();
950        let ul = ids[0];
951        assert_eq!(dom.node(ul).child_element_count(), 50);
952    }
953
954    // ── Empty template ───────────────────────────────────────────────
955
956    #[test]
957    fn empty_template() {
958        let (_, ids) = parse_str("");
959        assert!(ids.is_empty());
960    }
961
962    #[test]
963    fn whitespace_only_template() {
964        let (dom, ids) = parse_str("   \n  ");
965        // A single text node containing the whitespace.
966        assert_eq!(ids.len(), 1);
967        let c = dom.node(ids[0]);
968        assert_eq!(c.node_type(), rdom_core::NodeType::Text);
969    }
970
971    // ── Tag name chars ───────────────────────────────────────────────
972
973    #[test]
974    fn hyphenated_tag() {
975        let (dom, ids) = parse_str("<tree-item></tree-item>");
976        assert_eq!(dom.node(ids[0]).tag_name(), Some("tree-item"));
977    }
978
979    #[test]
980    fn underscore_tag() {
981        let (dom, ids) = parse_str("<my_element></my_element>");
982        assert_eq!(dom.node(ids[0]).tag_name(), Some("my_element"));
983    }
984
985    // ── Siblings + lack of whitespace ────────────────────────────────
986
987    #[test]
988    fn adjacent_elements() {
989        let (dom, ids) = parse_str("<a></a><b></b>");
990        assert_eq!(ids.len(), 2);
991        assert_eq!(dom.node(ids[0]).tag_name(), Some("a"));
992        assert_eq!(dom.node(ids[1]).tag_name(), Some("b"));
993    }
994}