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: the common named references
10//!   (`&amp; &lt; &copy; &mdash; …`) plus `&#NNN;` / `&#xHH;`; U+0000,
11//!   surrogates and out-of-range code points decode to U+FFFD
12//! - A `<` not followed by an ASCII letter, `/`, `!`, or `?` is text
13//!   (HTML §13.2.5.6 tag-open state), so `a < b` needs no escaping
14//! - `<style>` / `<script>` bodies are RAWTEXT (no tags, no entities)
15//!   and `<textarea>` / `<title>` bodies are RCDATA (entities only),
16//!   each ending at its own case-insensitive end tag (HTML §13.2.5.3–6)
17//! - Comments: `<!-- … -->` preserved as Comment nodes
18//! - `<!DOCTYPE …>` is consumed and produces no node; any other `<!…>`
19//!   or `<?…>` is a bogus comment kept as a Comment node (§13.2.5.41)
20//! - A newline right after `<textarea>` is dropped (§13.2.6.4.7)
21//! - Case-insensitive tag names (tags are normalized to lowercase)
22//!
23//! Out of scope: CDATA, namespace prefixes, processing instructions,
24//! tree-construction error recovery (a mismatched or missing end tag is
25//! a hard error — this is a template parser, not a browser).
26
27use rdom_core::{Dom, NodeId, is_void_element};
28
29use crate::entities::{LONGEST_LEGACY_NAME, LONGEST_NAME, NAMED_REFERENCES};
30
31use crate::error::{ParseError, Result};
32
33/// Where a character reference sits. HTML §13.2.5.73 leaves a legacy
34/// no-semicolon reference literal inside an attribute value when `=` or
35/// an alphanumeric follows it (`?a=1&copy=2`).
36#[derive(Clone, Copy, PartialEq, Eq)]
37enum RefContext {
38    Text,
39    Attribute,
40}
41
42/// Parse `template` into a fresh `Dom<Ext>` with a Fragment root. The
43/// returned ids are the top-level children of the fragment.
44pub fn parse<Ext>(template: &str) -> Result<(Dom<Ext>, Vec<NodeId>)>
45where
46    Ext: Default + 'static,
47{
48    let mut dom = Dom::new();
49    let root = dom.root();
50    let ids = parse_into(&mut dom, template, root)?;
51    Ok((dom, ids))
52}
53
54/// Parse `template` and append the parsed tree under `mount`. Returns
55/// the ids of the top-level parsed nodes (direct children appended to
56/// `mount`). Does not alter existing children of `mount`.
57pub fn parse_into<Ext>(dom: &mut Dom<Ext>, template: &str, mount: NodeId) -> Result<Vec<NodeId>>
58where
59    Ext: Default + 'static,
60{
61    let mut p = Parser::new(template);
62    let ids = p.parse_nodes(dom, mount)?;
63    if !p.eof() {
64        // `parse_nodes` stops at `</…`; at the top level nothing is open,
65        // so a stray end tag is an error rather than silent truncation.
66        return Err(p
67            .err("unexpected closing tag at top level")
68            .with_hint("nothing is open here — remove the end tag or open its element"));
69    }
70    Ok(ids)
71}
72
73// ─── Internal parser ────────────────────────────────────────────────
74
75struct Parser<'a> {
76    src: &'a str,
77    bytes: &'a [u8],
78    pos: usize,
79    line: u32,
80    col: u32,
81}
82
83impl<'a> Parser<'a> {
84    fn new(src: &'a str) -> Self {
85        Self {
86            src,
87            bytes: src.as_bytes(),
88            pos: 0,
89            line: 1,
90            col: 1,
91        }
92    }
93
94    // ── Cursor ────────────────────────────────────────────────────
95
96    fn eof(&self) -> bool {
97        self.pos >= self.bytes.len()
98    }
99
100    fn peek(&self) -> Option<u8> {
101        self.bytes.get(self.pos).copied()
102    }
103
104    fn peek_at(&self, offset: usize) -> Option<u8> {
105        self.bytes.get(self.pos + offset).copied()
106    }
107
108    fn starts_with(&self, needle: &str) -> bool {
109        self.src[self.pos..].starts_with(needle)
110    }
111
112    fn advance(&mut self) -> Option<u8> {
113        let b = self.peek()?;
114        self.pos += 1;
115        if b == b'\n' {
116            self.line += 1;
117            self.col = 1;
118        } else {
119            self.col += 1;
120        }
121        Some(b)
122    }
123
124    fn advance_n(&mut self, n: usize) {
125        for _ in 0..n {
126            if self.advance().is_none() {
127                break;
128            }
129        }
130    }
131
132    fn skip_ws(&mut self) {
133        while let Some(b) = self.peek() {
134            if b.is_ascii_whitespace() {
135                self.advance();
136            } else {
137                break;
138            }
139        }
140    }
141
142    fn err(&self, msg: impl Into<String>) -> ParseError {
143        ParseError::new(msg, self.line, self.col, self.pos)
144    }
145
146    // ── Top-level: parse children of `parent` ─────────────────────
147
148    fn parse_nodes<Ext>(&mut self, dom: &mut Dom<Ext>, parent: NodeId) -> Result<Vec<NodeId>>
149    where
150        Ext: Default + 'static,
151    {
152        let mut out = Vec::new();
153        loop {
154            if self.eof() {
155                break;
156            }
157            if self.starts_with("</") {
158                // Bubble up to the containing element parser.
159                break;
160            }
161            if self.starts_with("<!--") {
162                let id = self.parse_comment(dom, parent)?;
163                out.push(id);
164                continue;
165            }
166            if self.starts_with("<!") {
167                if self.src[self.pos + 2..]
168                    .get(..7)
169                    .is_some_and(|k| k.eq_ignore_ascii_case("DOCTYPE"))
170                {
171                    // `<!DOCTYPE html>`: consumed, no node (rdom has no
172                    // DocumentType node — see DIVERGENCES §HTML parsing).
173                    self.skip_declaration();
174                } else {
175                    // Any other `<!…>` is a bogus comment (§13.2.5.42).
176                    let id = self.parse_bogus_comment(dom, parent)?;
177                    out.push(id);
178                }
179                continue;
180            }
181            if self.starts_with("<?") {
182                // `<?…>` is a bogus comment (§13.2.5.6 "?" branch).
183                let id = self.parse_bogus_comment(dom, parent)?;
184                out.push(id);
185                continue;
186            }
187            if self.peek() == Some(b'<') && self.peek_at(1).is_some_and(|b| b.is_ascii_alphabetic())
188            {
189                let id = self.parse_element(dom, parent)?;
190                out.push(id);
191                continue;
192            }
193            // Plain text until the next tag open. A `<` not followed by
194            // an ASCII letter, `/`, `!`, or `?` is text (§13.2.5.6).
195            let id = self.parse_text(dom, parent)?;
196            if let Some(id) = id {
197                out.push(id);
198            }
199        }
200        Ok(out)
201    }
202
203    // ── Comment ────────────────────────────────────────────────────
204
205    fn parse_comment<Ext>(&mut self, dom: &mut Dom<Ext>, parent: NodeId) -> Result<NodeId>
206    where
207        Ext: Default + 'static,
208    {
209        // Consume `<!--`.
210        self.advance_n(4);
211        let start = self.pos;
212        loop {
213            if self.eof() {
214                return Err(self
215                    .err("unterminated comment")
216                    .with_hint("missing `-->` closing"));
217            }
218            if self.starts_with("-->") {
219                let data = &self.src[start..self.pos];
220                self.advance_n(3);
221                let id = dom.create_comment(data);
222                dom.append_child(parent, id)
223                    .map_err(|e| self.err(format!("failed to append comment: {:?}", e)))?;
224                return Ok(id);
225            }
226            self.advance();
227        }
228    }
229
230    // ── Text ───────────────────────────────────────────────────────
231
232    /// Consume chars until next `<`, decode entities, emit a Text node.
233    /// Returns `None` when the captured text is empty (no Text node
234    /// created). UTF-8-safe — we slice by byte but the boundaries are
235    /// always on valid char boundaries because we advance byte-at-a-time
236    /// only via `self.advance()` which respects the source encoding.
237    fn parse_text<Ext>(&mut self, dom: &mut Dom<Ext>, parent: NodeId) -> Result<Option<NodeId>>
238    where
239        Ext: Default + 'static,
240    {
241        let mut out = String::new();
242        loop {
243            // Collect consecutive raw (non-'<', non-'&') bytes as a
244            // UTF-8 slice from the source.
245            let slice_start = self.pos;
246            while let Some(b) = self.peek() {
247                if b == b'&' || (b == b'<' && self.at_tag_open()) {
248                    break;
249                }
250                self.advance();
251            }
252            if slice_start < self.pos {
253                out.push_str(&self.src[slice_start..self.pos]);
254            }
255            match self.peek() {
256                None | Some(b'<') => break,
257                Some(b'&') => {
258                    out.push_str(&self.parse_entity(RefContext::Text)?);
259                }
260                _ => unreachable!(),
261            }
262        }
263        if out.is_empty() {
264            return Ok(None);
265        }
266        let id = dom.create_text_node(&out);
267        dom.append_child(parent, id)
268            .map_err(|e| self.err(format!("failed to append text: {:?}", e)))?;
269        Ok(Some(id))
270    }
271
272    /// Is the `<` at the cursor a real tag open (HTML §13.2.5.6)? Only
273    /// when followed by an ASCII letter, `/`, `!`, or `?`.
274    fn at_tag_open(&self) -> bool {
275        self.peek() == Some(b'<')
276            && self
277                .peek_at(1)
278                .is_some_and(|b| b.is_ascii_alphabetic() || matches!(b, b'/' | b'!' | b'?'))
279    }
280
281    /// HTML §13.2.5.41 bogus comment state: everything from just after
282    /// the `<` up to the next `>` becomes a Comment node's data
283    /// (`<?xml version="1.0"?>` → `?xml version="1.0"?`).
284    fn parse_bogus_comment<Ext>(&mut self, dom: &mut Dom<Ext>, parent: NodeId) -> Result<NodeId>
285    where
286        Ext: Default + 'static,
287    {
288        self.advance(); // '<'
289        let start = self.pos;
290        while let Some(b) = self.peek() {
291            if b == b'>' {
292                break;
293            }
294            self.advance();
295        }
296        let data = self.src[start..self.pos].to_string();
297        if self.peek() == Some(b'>') {
298            self.advance();
299        }
300        let id = dom.create_comment(&data);
301        dom.append_child(parent, id)
302            .map_err(|e| self.err(format!("failed to append comment: {:?}", e)))?;
303        Ok(id)
304    }
305
306    /// Consume a `<!DOCTYPE …>` declaration through its closing `>`, or
307    /// to EOF.
308    fn skip_declaration(&mut self) {
309        while let Some(b) = self.advance() {
310            if b == b'>' {
311                return;
312            }
313        }
314    }
315
316    /// Byte offset of the `</tag` (ASCII-case-insensitive, followed by
317    /// whitespace, `/`, or `>`) that ends a RAWTEXT / RCDATA element,
318    /// searching from the cursor. `None` at EOF.
319    fn find_end_tag(&self, tag_lc: &str) -> Option<usize> {
320        let hay = &self.bytes[self.pos..];
321        let needle_len = 2 + tag_lc.len();
322        let mut i = 0;
323        while i + needle_len <= hay.len() {
324            if hay[i] == b'<' && hay[i + 1] == b'/' {
325                let name = &hay[i + 2..i + needle_len];
326                if name.eq_ignore_ascii_case(tag_lc.as_bytes()) {
327                    let after = hay.get(i + needle_len).copied();
328                    if after.is_none_or(|b| b.is_ascii_whitespace() || b == b'>' || b == b'/') {
329                        return Some(self.pos + i);
330                    }
331                }
332            }
333            i += 1;
334        }
335        None
336    }
337
338    /// Parse the body of a RAWTEXT (`<style>`, `<script>`) or RCDATA
339    /// (`<textarea>`, `<title>`) element as a single text node, then
340    /// consume its end tag. RCDATA decodes character references; RAWTEXT
341    /// takes the bytes verbatim (HTML §13.2.5.3–6).
342    fn parse_special_text<Ext>(
343        &mut self,
344        dom: &mut Dom<Ext>,
345        element: NodeId,
346        tag_lc: &str,
347        decode_entities: bool,
348    ) -> Result<()>
349    where
350        Ext: Default + 'static,
351    {
352        let Some(end) = self.find_end_tag(tag_lc) else {
353            return Err(self
354                .err(format!("missing closing tag for <{}>", tag_lc))
355                .with_hint(format!("add </{}> to close", tag_lc)));
356        };
357        let mut raw = &self.src[self.pos..end];
358        // HTML §13.2.6.4.7: a newline immediately after `<textarea>` is
359        // ignored (the same rule HTML applies to `<pre>` / `<listing>`).
360        if tag_lc == "textarea" {
361            raw = raw
362                .strip_prefix("\r\n")
363                .or_else(|| raw.strip_prefix('\n'))
364                .unwrap_or(raw);
365        }
366        let text = if decode_entities {
367            decode_character_references(raw)
368        } else {
369            raw.to_string()
370        };
371        if !text.is_empty() {
372            let id = dom.create_text_node(&text);
373            dom.append_child(element, id)
374                .map_err(|e| self.err(format!("failed to append text: {:?}", e)))?;
375        }
376        // Walk (not jump) so line / column stay right for later errors.
377        self.advance_n(end - self.pos);
378        self.advance_n(2 + tag_lc.len()); // `</tag`
379        self.skip_ws();
380        if self.peek() != Some(b'>') {
381            return Err(self
382                .err(format!("expected `>` in </{}>", tag_lc))
383                .with_hint("no attributes on closing tags"));
384        }
385        self.advance();
386        Ok(())
387    }
388
389    // ── Entity ─────────────────────────────────────────────────────
390
391    fn parse_entity(&mut self, context: RefContext) -> Result<String> {
392        // We've seen '&'. One scanner for the text path, the RCDATA path
393        // and attribute values (`scan_reference`), so all agree on what a
394        // reference looks like; unknown or malformed input keeps the `&`
395        // literal (as browsers flush the raw characters).
396        debug_assert_eq!(self.peek(), Some(b'&'));
397        match scan_reference(&self.src[self.pos + 1..], context) {
398            Some((decoded, consumed)) => {
399                self.advance_n(1 + consumed);
400                Ok(decoded)
401            }
402            None => {
403                self.advance(); // consume the '&'
404                Ok("&".to_string())
405            }
406        }
407    }
408
409    // ── Element ────────────────────────────────────────────────────
410
411    fn parse_element<Ext>(&mut self, dom: &mut Dom<Ext>, parent: NodeId) -> Result<NodeId>
412    where
413        Ext: Default + 'static,
414    {
415        debug_assert_eq!(self.peek(), Some(b'<'));
416        self.advance(); // '<'
417
418        let tag = self.parse_tag_name()?;
419        let tag_lc = tag.to_ascii_lowercase();
420
421        let element = dom.create_element(&tag_lc);
422
423        // Parse attributes until '>' or '/>'.
424        loop {
425            self.skip_ws();
426            match self.peek() {
427                None => {
428                    return Err(self
429                        .err(format!("unexpected EOF inside <{}>", tag_lc))
430                        .with_hint("missing closing `>`"));
431                }
432                Some(b'>') => {
433                    self.advance();
434                    break;
435                }
436                Some(b'/') => {
437                    // Self-closing.
438                    self.advance();
439                    self.skip_ws();
440                    if self.peek() != Some(b'>') {
441                        return Err(self
442                            .err(format!("expected `>` after `/` in <{}/>", tag_lc))
443                            .with_hint("self-closing syntax is `/>`"));
444                    }
445                    self.advance();
446                    dom.append_child(parent, element)
447                        .map_err(|e| self.err(format!("failed to append <{}>: {:?}", tag_lc, e)))?;
448                    return Ok(element);
449                }
450                Some(_) => {
451                    self.parse_attribute(dom, element)?;
452                }
453            }
454        }
455
456        // Void tag? Done.
457        if is_void_element(&tag_lc) {
458            dom.append_child(parent, element)
459                .map_err(|e| self.err(format!("failed to append <{}>: {:?}", tag_lc, e)))?;
460            return Ok(element);
461        }
462
463        // RAWTEXT / RCDATA elements take their body as one text node
464        // up to their own end tag.
465        match tag_lc.as_str() {
466            "style" | "script" => {
467                self.parse_special_text(dom, element, &tag_lc, false)?;
468                dom.append_child(parent, element)
469                    .map_err(|e| self.err(format!("failed to append <{}>: {:?}", tag_lc, e)))?;
470                return Ok(element);
471            }
472            "textarea" | "title" => {
473                self.parse_special_text(dom, element, &tag_lc, true)?;
474                dom.append_child(parent, element)
475                    .map_err(|e| self.err(format!("failed to append <{}>: {:?}", tag_lc, e)))?;
476                return Ok(element);
477            }
478            _ => {}
479        }
480
481        // Parse children, then expect </tag>.
482        self.parse_nodes(dom, element)?;
483
484        if !self.starts_with("</") {
485            return Err(self
486                .err(format!("missing closing tag for <{}>", tag_lc))
487                .with_hint(format!("add </{}> to close", tag_lc)));
488        }
489        self.advance_n(2); // '</'
490
491        let close_tag = self.parse_tag_name()?;
492        if close_tag.to_ascii_lowercase() != tag_lc {
493            return Err(self
494                .err(format!(
495                    "mismatched closing tag: found </{}>, expected </{}>",
496                    close_tag, tag_lc
497                ))
498                .with_hint("tags must be properly nested"));
499        }
500        self.skip_ws();
501        if self.peek() != Some(b'>') {
502            return Err(self
503                .err(format!("expected `>` in </{}>", tag_lc))
504                .with_hint("no attributes on closing tags"));
505        }
506        self.advance();
507
508        dom.append_child(parent, element)
509            .map_err(|e| self.err(format!("failed to append <{}>: {:?}", tag_lc, e)))?;
510        Ok(element)
511    }
512
513    fn parse_tag_name(&mut self) -> Result<String> {
514        let start = self.pos;
515        while let Some(b) = self.peek() {
516            if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' {
517                self.advance();
518            } else {
519                break;
520            }
521        }
522        if start == self.pos {
523            return Err(self
524                .err("expected tag name")
525                .with_hint("tag names start with a letter"));
526        }
527        Ok(self.src[start..self.pos].to_string())
528    }
529
530    fn parse_attribute<Ext>(&mut self, dom: &mut Dom<Ext>, element: NodeId) -> Result<()>
531    where
532        Ext: Default + 'static,
533    {
534        let name = self.parse_attr_name()?;
535        self.skip_ws();
536
537        let value = if self.peek() == Some(b'=') {
538            self.advance();
539            self.skip_ws();
540            Some(self.parse_attr_value()?)
541        } else {
542            None
543        };
544
545        match value {
546            Some(v) => {
547                // Classes are normalized into the classList; other
548                // attrs go into the attribute map.
549                if name.eq_ignore_ascii_case("class") {
550                    for token in v.split_ascii_whitespace() {
551                        dom.add_class(element, token)
552                            .map_err(|e| self.err(format!("failed to add class: {:?}", e)))?;
553                    }
554                } else {
555                    dom.set_attribute(element, &name, &v)
556                        .map_err(|e| self.err(format!("failed to set attribute: {:?}", e)))?;
557                }
558            }
559            None => {
560                // Boolean attribute.
561                dom.set_attribute(element, &name, "")
562                    .map_err(|e| self.err(format!("failed to set attribute: {:?}", e)))?;
563            }
564        }
565        Ok(())
566    }
567
568    fn parse_attr_name(&mut self) -> Result<String> {
569        let start = self.pos;
570        while let Some(b) = self.peek() {
571            if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':' {
572                self.advance();
573            } else {
574                break;
575            }
576        }
577        if start == self.pos {
578            return Err(self.err("expected attribute name"));
579        }
580        Ok(self.src[start..self.pos].to_string())
581    }
582
583    fn parse_attr_value(&mut self) -> Result<String> {
584        let first = self.peek();
585        match first {
586            Some(b'"') => self.parse_quoted(b'"'),
587            Some(b'\'') => self.parse_quoted(b'\''),
588            Some(_) => self.parse_unquoted(),
589            None => Err(self.err("unexpected EOF in attribute value")),
590        }
591    }
592
593    fn parse_quoted(&mut self, quote: u8) -> Result<String> {
594        self.advance(); // opening quote
595        let mut out = String::new();
596        loop {
597            // Collect consecutive raw bytes up to the next quote or &.
598            let slice_start = self.pos;
599            while let Some(b) = self.peek() {
600                if b == quote || b == b'&' {
601                    break;
602                }
603                self.advance();
604            }
605            if slice_start < self.pos {
606                out.push_str(&self.src[slice_start..self.pos]);
607            }
608            match self.peek() {
609                None => {
610                    return Err(self
611                        .err(format!(
612                            "unterminated attribute value (expected `{}`)",
613                            quote as char
614                        ))
615                        .with_hint("missing closing quote"));
616                }
617                Some(b) if b == quote => {
618                    self.advance();
619                    return Ok(out);
620                }
621                Some(b'&') => {
622                    out.push_str(&self.parse_entity(RefContext::Attribute)?);
623                }
624                _ => unreachable!(),
625            }
626        }
627    }
628
629    fn parse_unquoted(&mut self) -> Result<String> {
630        let mut out = String::new();
631        loop {
632            let slice_start = self.pos;
633            while let Some(b) = self.peek() {
634                if b.is_ascii_whitespace() || b == b'>' || b == b'/' || b == b'&' {
635                    break;
636                }
637                self.advance();
638            }
639            if slice_start < self.pos {
640                out.push_str(&self.src[slice_start..self.pos]);
641            }
642            match self.peek() {
643                Some(b'&') => out.push_str(&self.parse_entity(RefContext::Attribute)?),
644                _ => break,
645            }
646        }
647        if out.is_empty() {
648            return Err(self
649                .err("empty unquoted attribute value")
650                .with_hint("use \"\" or '' for empty value"));
651        }
652        Ok(out)
653    }
654}
655
656// ─── Entity decoding ────────────────────────────────────────────────
657
658/// HTML §13.2.5.80: the C1 control range 0x80–0x9F is remapped to the
659/// Windows-1252 code points (`&#146;` is `’`, `&#150;` is `–`); the five
660/// unmapped positions (0x81, 0x8D, 0x8F, 0x90, 0x9D) pass through.
661const C1_REMAP: [(u32, char); 27] = [
662    (0x80, '\u{20AC}'),
663    (0x82, '\u{201A}'),
664    (0x83, '\u{0192}'),
665    (0x84, '\u{201E}'),
666    (0x85, '\u{2026}'),
667    (0x86, '\u{2020}'),
668    (0x87, '\u{2021}'),
669    (0x88, '\u{02C6}'),
670    (0x89, '\u{2030}'),
671    (0x8A, '\u{0160}'),
672    (0x8B, '\u{2039}'),
673    (0x8C, '\u{0152}'),
674    (0x8E, '\u{017D}'),
675    (0x91, '\u{2018}'),
676    (0x92, '\u{2019}'),
677    (0x93, '\u{201C}'),
678    (0x94, '\u{201D}'),
679    (0x95, '\u{2022}'),
680    (0x96, '\u{2013}'),
681    (0x97, '\u{2014}'),
682    (0x98, '\u{02DC}'),
683    (0x99, '\u{2122}'),
684    (0x9A, '\u{0161}'),
685    (0x9B, '\u{203A}'),
686    (0x9C, '\u{0153}'),
687    (0x9E, '\u{017E}'),
688    (0x9F, '\u{0178}'),
689];
690
691/// Decode the digits of a numeric reference per HTML §13.2.5.80: U+0000,
692/// surrogates and values above U+10FFFF (including anything that
693/// overflows `u32`) yield U+FFFD; the C1 range is remapped.
694fn decode_numeric(digits: &str, radix: u32) -> char {
695    let n = u32::from_str_radix(digits, radix).unwrap_or(u32::MAX);
696    match n {
697        0 | 0xD800..=0xDFFF => '\u{FFFD}',
698        0x80..=0x9F => C1_REMAP
699            .iter()
700            .find(|(from, _)| *from == n)
701            .map_or_else(|| char::from_u32(n).unwrap_or('\u{FFFD}'), |(_, to)| *to),
702        _ => char::from_u32(n).unwrap_or('\u{FFFD}'),
703    }
704}
705
706/// Look `name` (without `&`, with or without `;`) up in the WHATWG table.
707fn named_reference(name: &str) -> Option<&'static str> {
708    NAMED_REFERENCES
709        .binary_search_by(|(n, _)| (*n).cmp(name))
710        .ok()
711        .map(|i| NAMED_REFERENCES[i].1)
712}
713
714/// Scan a character reference whose `&` has just been consumed
715/// (`after_amp` starts right after it). Returns the decoded text and the
716/// number of bytes to consume, or `None` when the caller should keep the
717/// `&` literal. HTML §13.2.5.72–80:
718///
719/// - numeric: `#` + digits (or `#x` + hex digits), an optional `;`;
720/// - named: the *longest* table prefix wins, so `&notit;` is `¬it;`
721///   (the legacy `not`) and `&notin;` is `∉`;
722/// - a legacy (no-`;`) match inside an attribute value is left literal
723///   when the next character is `=` or alphanumeric, so query strings
724///   like `?a=1&copy=2` survive.
725fn scan_reference(after_amp: &str, context: RefContext) -> Option<(String, usize)> {
726    let bytes = after_amp.as_bytes();
727    if bytes.first() == Some(&b'#') {
728        // Decimal / hexadecimal character reference states consume digits
729        // of their radix only; whatever follows (`;` or not) is left for
730        // the caller (`&#65abc;` → `A` + `abc;`).
731        let (start, radix): (usize, u32) = match bytes.get(1) {
732            Some(b'x' | b'X') => (2, 16),
733            _ => (1, 10),
734        };
735        let mut n = start;
736        while n < bytes.len() && (bytes[n] as char).is_digit(radix) {
737            n += 1;
738        }
739        if n == start {
740            return None;
741        }
742        let c = decode_numeric(&after_amp[start..n], radix);
743        let consumed = if bytes.get(n) == Some(&b';') {
744            n + 1
745        } else {
746            n
747        };
748        return Some((c.to_string(), consumed));
749    }
750    let mut n = 0;
751    while n < bytes.len() && n < LONGEST_NAME && bytes[n].is_ascii_alphanumeric() {
752        n += 1;
753    }
754    if n == 0 {
755        return None;
756    }
757    if bytes.get(n) == Some(&b';')
758        && let Some(v) = named_reference(&after_amp[..=n])
759    {
760        return Some((v.to_string(), n + 1));
761    }
762    // Only legacy names can match without `;`, and they are short.
763    for len in (1..=n.min(LONGEST_LEGACY_NAME)).rev() {
764        let Some(v) = named_reference(&after_amp[..len]) else {
765            continue;
766        };
767        if context == RefContext::Attribute
768            && let Some(&next) = bytes.get(len)
769            && (next == b'=' || next.is_ascii_alphanumeric())
770        {
771            return None;
772        }
773        return Some((v.to_string(), len));
774    }
775    None
776}
777
778/// Decode every character reference in `text` (RCDATA bodies) with the
779/// same scanner the text path uses.
780fn decode_character_references(text: &str) -> String {
781    let mut out = String::with_capacity(text.len());
782    let mut rest = text;
783    while let Some(amp) = rest.find('&') {
784        out.push_str(&rest[..amp]);
785        let after = &rest[amp + 1..];
786        match scan_reference(after, RefContext::Text) {
787            Some((decoded, consumed)) => {
788                out.push_str(&decoded);
789                rest = &after[consumed..];
790            }
791            None => {
792                out.push('&');
793                rest = after;
794            }
795        }
796    }
797    out.push_str(rest);
798    out
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    fn parse_str(s: &str) -> (Dom<()>, Vec<NodeId>) {
806        parse(s).unwrap()
807    }
808
809    /// `named_reference` binary-searches the generated table, so it must
810    /// stay byte-sorted and free of duplicates, and it must be the whole
811    /// WHATWG list (2 231 entries, 106 legacy names without `;`).
812    /// Numeric edge cases from HTML §13.2.5.75–80: no digits → literal;
813    /// a missing `;` still decodes; the scan stops at the first non-digit.
814    #[test]
815    fn numeric_reference_scanner_edges() {
816        let text = |s: &str| scan_reference(s, RefContext::Text);
817        assert_eq!(text("#;x"), None);
818        assert_eq!(text("#x;"), None);
819        assert_eq!(text("#"), None);
820        assert_eq!(text("#65 rest"), Some(("A".to_string(), 3)));
821        assert_eq!(text("#65;"), Some(("A".to_string(), 4)));
822        assert_eq!(text("#65abc;"), Some(("A".to_string(), 3)));
823        assert_eq!(text("#x41g"), Some(("A".to_string(), 4)));
824        assert_eq!(text("#X41;"), Some(("A".to_string(), 5)));
825        assert_eq!(text("#150;"), Some(("\u{2013}".to_string(), 5)));
826        assert_eq!(text("#129;"), Some(("\u{81}".to_string(), 5)));
827    }
828
829    /// Named edge cases: longest prefix, case sensitivity, the attribute
830    /// caveat only for legacy names, and the legacy-length bound.
831    #[test]
832    fn named_reference_scanner_edges() {
833        let text = |s: &str| scan_reference(s, RefContext::Text);
834        let attr = |s: &str| scan_reference(s, RefContext::Attribute);
835        assert_eq!(text("notit;"), Some(("\u{AC}".to_string(), 3)));
836        assert_eq!(text("notin;"), Some(("\u{2209}".to_string(), 6)));
837        assert_eq!(text("Amp;"), None, "names are case-sensitive");
838        assert_eq!(text("AMP;"), Some(("&".to_string(), 4)));
839        assert_eq!(attr("copy=2"), None);
840        assert_eq!(attr("copyx"), None);
841        assert_eq!(attr("copy 2"), Some(("\u{A9}".to_string(), 4)));
842        assert_eq!(attr("copy;=2"), Some(("\u{A9}".to_string(), 5)));
843        assert_eq!(text("ThisIsNotAReferenceAtAllButLong;"), None);
844    }
845
846    #[test]
847    fn named_reference_table_is_the_full_sorted_whatwg_list() {
848        assert_eq!(NAMED_REFERENCES.len(), 2231);
849        let legacy = NAMED_REFERENCES
850            .iter()
851            .filter(|(n, _)| !n.ends_with(';'))
852            .count();
853        assert_eq!(legacy, 106);
854        for w in NAMED_REFERENCES.windows(2) {
855            assert!(
856                w[0].0 < w[1].0,
857                "{:?} must sort before {:?}",
858                w[0].0,
859                w[1].0
860            );
861        }
862        assert!(
863            NAMED_REFERENCES
864                .iter()
865                .all(|(n, _)| n.len() <= LONGEST_NAME)
866        );
867    }
868
869    // ── Basic elements ───────────────────────────────────────────────
870
871    #[test]
872    fn empty_element() {
873        let (dom, ids) = parse_str("<div></div>");
874        assert_eq!(ids.len(), 1);
875        let n = dom.node(ids[0]);
876        assert_eq!(n.tag_name(), Some("div"));
877        assert_eq!(n.child_nodes().count(), 0);
878    }
879
880    #[test]
881    fn self_closing_element() {
882        let (dom, ids) = parse_str("<br/>");
883        assert_eq!(ids.len(), 1);
884        assert_eq!(dom.node(ids[0]).tag_name(), Some("br"));
885    }
886
887    #[test]
888    fn self_closing_with_space() {
889        let (dom, ids) = parse_str("<br />");
890        assert_eq!(dom.node(ids[0]).tag_name(), Some("br"));
891    }
892
893    #[test]
894    fn void_element_auto_closes() {
895        // `<br>` without `/>` still treated as void.
896        let (dom, ids) = parse_str("<br>");
897        assert_eq!(ids.len(), 1);
898        assert_eq!(dom.node(ids[0]).tag_name(), Some("br"));
899    }
900
901    #[test]
902    fn multiple_void_elements() {
903        let (dom, ids) = parse_str("<br><hr><img>");
904        assert_eq!(ids.len(), 3);
905        assert_eq!(dom.node(ids[0]).tag_name(), Some("br"));
906        assert_eq!(dom.node(ids[1]).tag_name(), Some("hr"));
907        assert_eq!(dom.node(ids[2]).tag_name(), Some("img"));
908    }
909
910    #[test]
911    fn case_insensitive_tag_names() {
912        let (dom, ids) = parse_str("<DIV></div>");
913        assert_eq!(dom.node(ids[0]).tag_name(), Some("div"));
914    }
915
916    // ── Nested elements ──────────────────────────────────────────────
917
918    #[test]
919    fn nested_elements() {
920        let (dom, ids) = parse_str("<div><span></span></div>");
921        let outer = ids[0];
922        assert_eq!(dom.node(outer).child_nodes().count(), 1);
923        let inner = dom.node(outer).first_child().unwrap().id();
924        assert_eq!(dom.node(inner).tag_name(), Some("span"));
925    }
926
927    #[test]
928    fn deeply_nested() {
929        let (dom, ids) = parse_str("<a><b><c><d></d></c></b></a>");
930        let mut cur = ids[0];
931        for tag in &["a", "b", "c", "d"] {
932            assert_eq!(dom.node(cur).tag_name(), Some(*tag));
933            cur = dom.node(cur).first_child().map(|n| n.id()).unwrap_or(cur);
934        }
935    }
936
937    // ── Text content ─────────────────────────────────────────────────
938
939    #[test]
940    fn text_node() {
941        let (dom, ids) = parse_str("<div>hello</div>");
942        let child = dom.node(ids[0]).first_child().unwrap();
943        assert_eq!(child.node_value(), Some("hello"));
944    }
945
946    #[test]
947    fn mixed_content() {
948        let (dom, ids) = parse_str("<div>before <b>mid</b> after</div>");
949        let div = ids[0];
950        let children: Vec<_> = dom.node(div).child_nodes().collect();
951        assert_eq!(children.len(), 3);
952        assert_eq!(children[0].node_value(), Some("before "));
953        assert_eq!(children[1].tag_name(), Some("b"));
954        assert_eq!(children[2].node_value(), Some(" after"));
955    }
956
957    #[test]
958    fn text_at_top_level() {
959        let (dom, ids) = parse_str("hello <span>world</span>");
960        assert_eq!(ids.len(), 2);
961        let root = dom.root();
962        let first = dom.node(root).first_child().unwrap();
963        assert_eq!(first.node_value(), Some("hello "));
964    }
965
966    // ── Attributes ──────────────────────────────────────────────────
967
968    #[test]
969    fn double_quoted_attr() {
970        let (dom, ids) = parse_str(r#"<div id="main"></div>"#);
971        assert_eq!(dom.node(ids[0]).get_attribute("id"), Some("main"));
972    }
973
974    #[test]
975    fn single_quoted_attr() {
976        let (dom, ids) = parse_str("<div id='main'></div>");
977        assert_eq!(dom.node(ids[0]).get_attribute("id"), Some("main"));
978    }
979
980    #[test]
981    fn unquoted_attr() {
982        let (dom, ids) = parse_str("<div id=main></div>");
983        assert_eq!(dom.node(ids[0]).get_attribute("id"), Some("main"));
984    }
985
986    #[test]
987    fn boolean_attr() {
988        let (dom, ids) = parse_str("<input disabled>");
989        assert_eq!(dom.node(ids[0]).get_attribute("disabled"), Some(""));
990        assert!(dom.node(ids[0]).has_attribute("disabled"));
991    }
992
993    #[test]
994    fn multiple_attrs() {
995        let (dom, ids) = parse_str(r#"<div id="x" role="banner" data-n="5"></div>"#);
996        let n = dom.node(ids[0]);
997        assert_eq!(n.get_attribute("id"), Some("x"));
998        assert_eq!(n.get_attribute("role"), Some("banner"));
999        assert_eq!(n.get_attribute("data-n"), Some("5"));
1000    }
1001
1002    #[test]
1003    fn class_attr_populates_classlist() {
1004        let (dom, ids) = parse_str(r#"<div class="a b c"></div>"#);
1005        let n = dom.node(ids[0]);
1006        assert!(n.has_class("a"));
1007        assert!(n.has_class("b"));
1008        assert!(n.has_class("c"));
1009    }
1010
1011    #[test]
1012    fn attr_name_case_preserved() {
1013        // Unlike tag names, we preserve attribute name case.
1014        let (dom, ids) = parse_str(r#"<div dataFoo="bar"></div>"#);
1015        assert_eq!(dom.node(ids[0]).get_attribute("dataFoo"), Some("bar"));
1016    }
1017
1018    #[test]
1019    fn whitespace_around_attrs() {
1020        let (dom, ids) = parse_str("<div  id=main  role=banner  ></div>");
1021        assert_eq!(dom.node(ids[0]).get_attribute("id"), Some("main"));
1022        assert_eq!(dom.node(ids[0]).get_attribute("role"), Some("banner"));
1023    }
1024
1025    #[test]
1026    fn attr_name_with_hyphens_and_colons() {
1027        let (dom, ids) = parse_str(r#"<div data-x="1" aria:label="y"></div>"#);
1028        assert_eq!(dom.node(ids[0]).get_attribute("data-x"), Some("1"));
1029        assert_eq!(dom.node(ids[0]).get_attribute("aria:label"), Some("y"));
1030    }
1031
1032    // ── Entities ─────────────────────────────────────────────────────
1033
1034    #[test]
1035    fn entity_amp() {
1036        let (dom, ids) = parse_str("<div>a &amp; b</div>");
1037        let child = dom.node(ids[0]).first_child().unwrap();
1038        assert_eq!(child.node_value(), Some("a & b"));
1039    }
1040
1041    #[test]
1042    fn entity_lt_gt_quot_apos() {
1043        let (dom, ids) = parse_str("<div>&lt;tag&gt; &quot;q&quot; &apos;a&apos;</div>");
1044        let child = dom.node(ids[0]).first_child().unwrap();
1045        assert_eq!(child.node_value(), Some("<tag> \"q\" 'a'"));
1046    }
1047
1048    #[test]
1049    fn entity_decimal_numeric() {
1050        let (dom, ids) = parse_str("<div>&#65;&#66;</div>");
1051        let child = dom.node(ids[0]).first_child().unwrap();
1052        assert_eq!(child.node_value(), Some("AB"));
1053    }
1054
1055    #[test]
1056    fn entity_hex_numeric() {
1057        let (dom, ids) = parse_str("<div>&#x41;&#X42;</div>");
1058        let child = dom.node(ids[0]).first_child().unwrap();
1059        assert_eq!(child.node_value(), Some("AB"));
1060    }
1061
1062    #[test]
1063    fn entity_in_attr_value() {
1064        let (dom, ids) = parse_str(r#"<div title="a &amp; b"></div>"#);
1065        assert_eq!(dom.node(ids[0]).get_attribute("title"), Some("a & b"));
1066    }
1067
1068    #[test]
1069    fn unknown_entity_preserved_as_literal_amp() {
1070        // `&unknown;` → '&' literal + "unknown;" as text
1071        let (dom, ids) = parse_str("<div>&xyz;</div>");
1072        let child = dom.node(ids[0]).first_child().unwrap();
1073        // We emit '&' and leave the rest to parse as text.
1074        assert_eq!(child.node_value(), Some("&xyz;"));
1075    }
1076
1077    #[test]
1078    fn entity_nbsp() {
1079        let (dom, ids) = parse_str("<div>a&nbsp;b</div>");
1080        let child = dom.node(ids[0]).first_child().unwrap();
1081        assert_eq!(child.node_value(), Some("a\u{A0}b"));
1082    }
1083
1084    // ── Comments ─────────────────────────────────────────────────────
1085
1086    #[test]
1087    fn comment_preserved() {
1088        let (dom, ids) = parse_str("<!-- hello -->");
1089        assert_eq!(ids.len(), 1);
1090        let c = dom.node(ids[0]);
1091        assert_eq!(c.node_type(), rdom_core::NodeType::Comment);
1092        assert_eq!(c.data(), Some(" hello "));
1093    }
1094
1095    #[test]
1096    fn comment_inside_element() {
1097        let (dom, ids) = parse_str("<div><!-- note -->body</div>");
1098        let div = ids[0];
1099        let children: Vec<_> = dom.node(div).child_nodes().collect();
1100        assert_eq!(children.len(), 2);
1101        assert_eq!(children[0].node_type(), rdom_core::NodeType::Comment);
1102        assert_eq!(children[1].node_value(), Some("body"));
1103    }
1104
1105    // ── Errors ───────────────────────────────────────────────────────
1106
1107    #[test]
1108    fn error_mismatched_tags() {
1109        let err = parse::<()>("<div></span>").unwrap_err();
1110        assert!(err.msg.contains("mismatched"));
1111    }
1112
1113    #[test]
1114    fn error_missing_close() {
1115        let err = parse::<()>("<div>").unwrap_err();
1116        assert!(err.msg.contains("missing closing"));
1117    }
1118
1119    #[test]
1120    fn error_unterminated_comment() {
1121        let err = parse::<()>("<!-- never ends").unwrap_err();
1122        assert!(err.msg.contains("unterminated"));
1123    }
1124
1125    #[test]
1126    fn error_unterminated_attr_value() {
1127        let err = parse::<()>(r#"<div id="abc>"#).unwrap_err();
1128        assert!(err.msg.contains("unterminated"));
1129    }
1130
1131    #[test]
1132    fn error_position_reported() {
1133        let err = parse::<()>("<div>\n<span></p>\n</div>").unwrap_err();
1134        // Mismatched </p> is on line 2.
1135        assert_eq!(err.line, 2);
1136    }
1137
1138    #[test]
1139    fn error_has_hint() {
1140        let err = parse::<()>("<div>").unwrap_err();
1141        assert!(err.hint.is_some());
1142    }
1143
1144    // ── parse_into API ───────────────────────────────────────────────
1145
1146    #[test]
1147    fn parse_into_appends_to_mount() {
1148        let mut dom: Dom<()> = Dom::new();
1149        let mount = dom.create_element("body");
1150        let root = dom.root();
1151        dom.append_child(root, mount).unwrap();
1152
1153        let ids = parse_into(&mut dom, "<h1>Title</h1><p>Body</p>", mount).unwrap();
1154        assert_eq!(ids.len(), 2);
1155        assert_eq!(dom.node(mount).child_nodes().count(), 2);
1156    }
1157
1158    // ── Complex templates ────────────────────────────────────────────
1159
1160    #[test]
1161    fn realistic_template() {
1162        let t = r#"
1163            <div class="card" id="hero">
1164              <h1>Welcome</h1>
1165              <p>Hello &amp; welcome to <strong>rdom</strong>.</p>
1166              <br/>
1167              <!-- TODO: add icon -->
1168              <button disabled>OK</button>
1169            </div>
1170        "#;
1171        let (dom, ids) = parse::<()>(t).unwrap();
1172        // Top-level: the outer div (plus potentially whitespace-only
1173        // text around it — we preserve all whitespace).
1174        let div_id = ids
1175            .iter()
1176            .find(|&&id| dom.node(id).tag_name() == Some("div"))
1177            .copied()
1178            .unwrap();
1179        let div = dom.node(div_id);
1180        assert!(div.has_class("card"));
1181        assert_eq!(div.get_attribute("id"), Some("hero"));
1182
1183        // Find <h1> inside.
1184        let h1 = div
1185            .child_nodes()
1186            .find(|c| c.tag_name() == Some("h1"))
1187            .unwrap();
1188        assert_eq!(
1189            dom.node(h1.id()).first_child().unwrap().node_value(),
1190            Some("Welcome")
1191        );
1192
1193        // The <button disabled> element.
1194        let btn = div
1195            .child_nodes()
1196            .find(|c| c.tag_name() == Some("button"))
1197            .unwrap();
1198        assert!(dom.node(btn.id()).has_attribute("disabled"));
1199    }
1200
1201    // ── Round-trip ───────────────────────────────────────────────────
1202
1203    #[test]
1204    fn round_trip_simple() {
1205        let src = "<div><span>hi</span></div>";
1206        let (dom, ids) = parse::<()>(src).unwrap();
1207        let out = dom.outer_markup(ids[0]);
1208        assert_eq!(out, src);
1209    }
1210
1211    #[test]
1212    fn round_trip_with_attrs() {
1213        let src = r#"<div data-x="1" id="main"><p></p></div>"#;
1214        let (dom, ids) = parse::<()>(src).unwrap();
1215        let out = dom.outer_markup(ids[0]);
1216        // Attributes sort alphabetically in outer_markup, matching input order.
1217        assert_eq!(out, src);
1218    }
1219
1220    #[test]
1221    fn round_trip_void_element() {
1222        let src = "<hr/>";
1223        let (dom, ids) = parse::<()>(src).unwrap();
1224        let out = dom.outer_markup(ids[0]);
1225        assert_eq!(out, "<hr/>");
1226    }
1227
1228    #[test]
1229    fn round_trip_entities_escaped() {
1230        let src = "<div>a &amp; b &lt;c&gt;</div>";
1231        let (dom, ids) = parse::<()>(src).unwrap();
1232        let out = dom.outer_markup(ids[0]);
1233        assert_eq!(out, src);
1234    }
1235
1236    // ── Whitespace preservation ──────────────────────────────────────
1237
1238    #[test]
1239    fn whitespace_preserved_in_text() {
1240        let (dom, ids) = parse_str("<p>  hello   world  </p>");
1241        let child = dom.node(ids[0]).first_child().unwrap();
1242        assert_eq!(child.node_value(), Some("  hello   world  "));
1243    }
1244
1245    #[test]
1246    fn newlines_preserved() {
1247        let (dom, ids) = parse_str("<pre>line1\nline2</pre>");
1248        let child = dom.node(ids[0]).first_child().unwrap();
1249        assert_eq!(child.node_value(), Some("line1\nline2"));
1250    }
1251
1252    // ── Many children ────────────────────────────────────────────────
1253
1254    #[test]
1255    fn many_children() {
1256        let src: String = (0..50).map(|_| "<li>x</li>").collect();
1257        let (dom, ids) = parse::<()>(&format!("<ul>{}</ul>", src)).unwrap();
1258        let ul = ids[0];
1259        assert_eq!(dom.node(ul).child_element_count(), 50);
1260    }
1261
1262    // ── Empty template ───────────────────────────────────────────────
1263
1264    #[test]
1265    fn empty_template() {
1266        let (_, ids) = parse_str("");
1267        assert!(ids.is_empty());
1268    }
1269
1270    #[test]
1271    fn whitespace_only_template() {
1272        let (dom, ids) = parse_str("   \n  ");
1273        // A single text node containing the whitespace.
1274        assert_eq!(ids.len(), 1);
1275        let c = dom.node(ids[0]);
1276        assert_eq!(c.node_type(), rdom_core::NodeType::Text);
1277    }
1278
1279    // ── Tag name chars ───────────────────────────────────────────────
1280
1281    #[test]
1282    fn hyphenated_tag() {
1283        let (dom, ids) = parse_str("<tree-item></tree-item>");
1284        assert_eq!(dom.node(ids[0]).tag_name(), Some("tree-item"));
1285    }
1286
1287    #[test]
1288    fn underscore_tag() {
1289        let (dom, ids) = parse_str("<my_element></my_element>");
1290        assert_eq!(dom.node(ids[0]).tag_name(), Some("my_element"));
1291    }
1292
1293    // ── Siblings + lack of whitespace ────────────────────────────────
1294
1295    #[test]
1296    fn adjacent_elements() {
1297        let (dom, ids) = parse_str("<a></a><b></b>");
1298        assert_eq!(ids.len(), 2);
1299        assert_eq!(dom.node(ids[0]).tag_name(), Some("a"));
1300        assert_eq!(dom.node(ids[1]).tag_name(), Some("b"));
1301    }
1302}