Skip to main content

sup_xml_core/html/
mod.rs

1// `#![forbid(unsafe_code)]` is applied per-submodule.  The arena-backed sink
2// ([`sink`]) opts in locally for the type-erased Handle trick — see its
3// module docs for the safety argument.
4#![deny(unsafe_code)]
5
6//! Lenient HTML5 parser.
7//!
8//! Wraps [html5ever](https://github.com/servo/html5ever) — the canonical
9//! Rust HTML5 parser from Servo — driving it into our arena
10//! [`Document`](sup_xml_tree::dom::Document) so the rest of SupXML
11//! (XPath, Selector, serializer) "just works" on the result.
12//!
13//! Gated behind the `html` Cargo feature: users who don't need HTML
14//! parsing pay no compile-time or dep-tree cost.
15//!
16//! # Quick start
17//!
18//! ```no_run
19//! use sup_xml_core::html::parse_html_str;
20//!
21//! let html = r#"<html><body><p>hello <b>world</b></p></body></html>"#;
22//! let doc = parse_html_str(html).unwrap();
23//! assert!(doc.is_html());
24//! ```
25//!
26//! # Design
27//!
28//! - html5ever for spec-compliant HTML5 parsing (~95% of html5lib-tests).
29//! - Output is the arena [`Document`](sup_xml_tree::dom::Document)
30//!   that XPath/Selector/serializer all work on natively.
31//! - DoS-protection limits (`max_element_depth`, `max_text_bytes`) are
32//!   enforced inside our `TreeSink` since html5ever has none built in.
33//! - HTML defaults to lenient (`recovery_mode: true`); inverted from
34//!   `ParseOptions` because HTML is a lenient format by spec.
35//!
36//! Because html5ever implements the WHATWG/HTML5 spec exactly (the same
37//! algorithm browsers use), recovery of malformed input can differ from
38//! libxml2's older ad-hoc HTML parser — e.g. a stray `</body>` is
39//! recovered silently and a stray `</p>` inserts an implied `<p>`, where
40//! libxml2 flags the former and drops the latter.  This is a deliberate
41//! divergence (our output matches browsers); see the `sup-xml-compat`
42//! crate docs, "Behavioral divergences", for the affected lxml tests.
43
44pub mod encoding;
45pub mod events;
46pub mod options;
47mod sink;
48mod stream;
49
50pub use encoding::{decode_html_input, prescan_meta_charset, sniff_html_encoding};
51pub use events::{HtmlAttribute, HtmlAttrs, HtmlAttrsIter, HtmlEvent};
52pub use options::HtmlParseOptions;
53pub use stream::{HtmlBytesReader, HtmlReader, HtmlSaxHandler, HtmlSaxParser};
54
55use html5ever::driver::{parse_document, ParseOpts};
56use html5ever::tendril::TendrilSink;
57use html5ever::tokenizer::TokenizerOpts;
58use html5ever::tree_builder::TreeBuilderOpts;
59
60use crate::error::{ErrorDomain, ErrorLevel, Result, XmlError};
61
62use sup_xml_tree::dom::Document;
63
64/// Parse an HTML document from a UTF-8 string into the arena DOM.
65///
66/// Defaults are tuned for browser-equivalent output and lenient
67/// recovery — see [`HtmlParseOptions`] for the knobs.
68///
69/// Errors: returns `Err` only when an internal limit is exceeded
70/// ([`HtmlParseOptions::max_element_depth`],
71/// [`HtmlParseOptions::max_text_bytes`]) or when
72/// [`HtmlParseOptions::recovery_mode`] is `false` and the parser
73/// encountered a well-formedness violation.  In recovery mode (the
74/// default) parse errors are *recovered* into the resulting tree;
75/// retrieve them via [`parse_html_str_with_recovered`].
76pub fn parse_html_str(input: &str) -> Result<Document> {
77    parse_html_str_opts(input, &HtmlParseOptions::default())
78}
79
80/// Parse an HTML document from raw bytes into the arena DOM.
81pub fn parse_html_bytes(input: &[u8]) -> Result<Document> {
82    parse_html_bytes_opts(input, &HtmlParseOptions::default())
83}
84
85/// Parse an HTML string into the arena DOM with explicit options.
86pub fn parse_html_str_opts(input: &str, opts: &HtmlParseOptions) -> Result<Document> {
87    let (doc, _recovered, fatal) = parse_html_str_inner(input, opts);
88    if let Some(fatal) = fatal {
89        return Err(fatal);
90    }
91    Ok(doc)
92}
93
94/// Parse HTML bytes into the arena DOM with explicit options.
95pub fn parse_html_bytes_opts(
96    input: &[u8],
97    opts: &HtmlParseOptions,
98) -> Result<Document> {
99    let (doc, _recovered, fatal) = parse_html_bytes_inner(input, opts);
100    if let Some(fatal) = fatal {
101        return Err(fatal);
102    }
103    Ok(doc)
104}
105
106/// Variant of [`parse_html_bytes_opts`] that interns element /
107/// attribute names through `dict` instead of an internal one.
108/// Mirrors [`crate::parser::parse_bytes_with_dtd_and_dict`] for
109/// the HTML side — used by the C-ABI layer to share a parser
110/// context's dict with the resulting document.
111///
112/// # Safety
113///
114/// `dict` must be a valid pointer returned by
115/// [`sup_xml_tree::dict::Dict::new_refcounted`] (or otherwise
116/// refcount-managed), with at least one outstanding reference.
117#[cfg(feature = "c-abi")]
118#[allow(unsafe_code)]
119pub unsafe fn parse_html_bytes_opts_with_dict(
120    input: &[u8],
121    opts: &HtmlParseOptions,
122    dict: *mut sup_xml_tree::dict::Dict,
123) -> Result<Document> {
124    let (doc, _recovered, fatal) = unsafe { parse_html_bytes_inner_with_dict(input, opts, dict) };
125    if let Some(fatal) = fatal {
126        return Err(fatal);
127    }
128    Ok(doc)
129}
130
131/// As [`parse_html_bytes_opts_with_dict`] but also adopts a
132/// caller-supplied [`bumpalo::Bump`] arena.  See
133/// [`crate::parser::parse_bytes_with_dtd_dict_arena`] for the
134/// architectural rationale.
135///
136/// # Safety
137///
138/// See [`parse_html_bytes_opts_with_dict`].
139#[cfg(feature = "c-abi")]
140#[allow(unsafe_code)]
141pub unsafe fn parse_html_bytes_opts_with_dict_arena(
142    input: &[u8],
143    opts:  &HtmlParseOptions,
144    dict:  *mut sup_xml_tree::dict::Dict,
145    arena: std::sync::Arc<bumpalo::Bump>,
146) -> Result<Document> {
147    let (doc, _recovered, fatal) =
148        unsafe { parse_html_bytes_inner_with_dict_arena(input, opts, dict, arena) };
149    if let Some(fatal) = fatal {
150        return Err(fatal);
151    }
152    drop_implicit_empty_head(&doc, input);
153    drop_implicit_empty_body(&doc, input);
154    Ok(doc)
155}
156
157/// Like [`parse_html_bytes_opts_with_dict_arena`] but always returns the
158/// (always-produced) HTML document together with the errors html5ever
159/// recovered from.  The C-ABI shim uses this to set `ctxt->wellFormed`
160/// (empty list ⇒ well-formed) so lxml's `recover=False` raises while
161/// `recover=True` keeps the repaired tree.
162///
163/// # Safety
164/// Same contract as [`parse_html_bytes_opts_with_dict_arena`].
165#[cfg(feature = "c-abi")]
166#[allow(unsafe_code)]
167pub unsafe fn parse_html_bytes_recovered_with_dict_arena(
168    input: &[u8],
169    opts:  &HtmlParseOptions,
170    dict:  *mut sup_xml_tree::dict::Dict,
171    arena: std::sync::Arc<bumpalo::Bump>,
172) -> (Document, Vec<XmlError>) {
173    let (doc, mut recovered, fatal) =
174        unsafe { parse_html_bytes_inner_with_dict_arena(input, opts, dict, arena) };
175    if let Some(f) = fatal {
176        recovered.push(f);
177    }
178    drop_implicit_empty_head(&doc, input);
179    drop_implicit_empty_body(&doc, input);
180    (doc, recovered)
181}
182
183/// Whether the HTML source contains an explicit `<head>` start tag —
184/// matched as `<head` followed by a tag terminator, so `<header>` does
185/// not count.  html5ever always synthesizes a `<head>`, but libxml2's
186/// HTML parser materializes one only for an explicit tag or for
187/// head-level content (title, meta, …).
188#[cfg(feature = "c-abi")]
189fn source_has_explicit_head(source: &[u8]) -> bool {
190    let mut i = 0;
191    while i + 5 < source.len() {
192        if source[i] == b'<'
193            && source[i + 1].eq_ignore_ascii_case(&b'h')
194            && source[i + 2].eq_ignore_ascii_case(&b'e')
195            && source[i + 3].eq_ignore_ascii_case(&b'a')
196            && source[i + 4].eq_ignore_ascii_case(&b'd')
197            && (source[i + 5] == b'>'
198                || source[i + 5] == b'/'
199                || source[i + 5].is_ascii_whitespace())
200        {
201            return true;
202        }
203        i += 1;
204    }
205    false
206}
207
208/// Reshape the html5ever tree to libxml2's HTML output: drop the empty
209/// `<head>` html5ever always inserts when the source had neither an
210/// explicit `<head>` tag nor head-level content, so a body-only
211/// document (`<div/>`) becomes `html > body > div` instead of
212/// `html > [head, body] > div`.  c-abi (libxml2-compat) path only — the
213/// native parser keeps the HTML5-spec always-emit-head shape.
214#[cfg(feature = "c-abi")]
215fn drop_implicit_empty_head(doc: &Document, source: &[u8]) {
216    if source_has_explicit_head(source) {
217        return;
218    }
219    let root = doc.root();
220    if root.name() != "html" {
221        return;
222    }
223    if let Some(head) = root.children().find(|c| c.is_element() && c.name() == "head") {
224        if head.children().next().is_none() {
225            // Unlink the empty head from the html root, repairing the
226            // sibling list.  (The c-abi `Document` has no `detach`, but
227            // the link fields are public Cells in every build.)
228            let prev = head.prev_sibling.get();
229            let next = head.next_sibling.get();
230            match prev {
231                Some(p) => p.next_sibling.set(next),
232                None    => root.first_child.set(next),
233            }
234            match next {
235                Some(n) => n.prev_sibling.set(prev),
236                None    => root.last_child.set(prev),
237            }
238            head.parent.set(None);
239            head.prev_sibling.set(None);
240            head.next_sibling.set(None);
241        }
242    }
243}
244
245/// Whether the HTML source contains an explicit `<body>` start tag —
246/// matched as `<body` followed by a tag terminator.  html5ever always
247/// synthesizes a `<body>`; libxml2's HTML parser materializes one only
248/// for an explicit tag or body-level content.
249#[cfg(feature = "c-abi")]
250fn source_has_explicit_body(source: &[u8]) -> bool {
251    let mut i = 0;
252    while i + 5 < source.len() {
253        if source[i] == b'<'
254            && source[i + 1].eq_ignore_ascii_case(&b'b')
255            && source[i + 2].eq_ignore_ascii_case(&b'o')
256            && source[i + 3].eq_ignore_ascii_case(&b'd')
257            && source[i + 4].eq_ignore_ascii_case(&b'y')
258            && (source[i + 5] == b'>'
259                || source[i + 5] == b'/'
260                || source[i + 5].is_ascii_whitespace())
261        {
262            return true;
263        }
264        i += 1;
265    }
266    false
267}
268
269/// Drop the empty `<body>` html5ever always inserts when the source had
270/// neither an explicit `<body>` tag nor body-level content, so
271/// `<html></html>` becomes childless `html` rather than `html > body`,
272/// matching libxml2's HTML output.  c-abi (libxml2-compat) path only.
273#[cfg(feature = "c-abi")]
274fn drop_implicit_empty_body(doc: &Document, source: &[u8]) {
275    if source_has_explicit_body(source) {
276        return;
277    }
278    let root = doc.root();
279    if root.name() != "html" {
280        return;
281    }
282    if let Some(body) = root.children().find(|c| c.is_element() && c.name() == "body") {
283        if body.children().next().is_none() && body.attributes().next().is_none() {
284            let prev = body.prev_sibling.get();
285            let next = body.next_sibling.get();
286            match prev {
287                Some(p) => p.next_sibling.set(next),
288                None    => root.first_child.set(next),
289            }
290            match next {
291                Some(n) => n.prev_sibling.set(prev),
292                None    => root.last_child.set(prev),
293            }
294            body.parent.set(None);
295            body.prev_sibling.set(None);
296            body.next_sibling.set(None);
297        }
298    }
299}
300
301/// Parse an HTML document from a string, returning both the document and the
302/// list of recovered well-formedness errors.  The document is returned even if
303/// errors were recovered; callers can inspect the `Vec<XmlError>` to see what
304/// was repaired.
305///
306/// In strict mode (`recovery_mode: false`), the first error becomes fatal and
307/// is returned via the `Result`.
308pub fn parse_html_str_with_recovered(
309    input: &str,
310    opts: &HtmlParseOptions,
311) -> (Result<Document>, Vec<XmlError>) {
312    let (doc, recovered, fatal) = parse_html_str_inner(input, opts);
313    let result = match fatal {
314        Some(e) => Err(e),
315        None    => Ok(doc),
316    };
317    (result, recovered)
318}
319
320/// Bytes equivalent of [`parse_html_str_with_recovered`].
321pub fn parse_html_bytes_with_recovered(
322    input: &[u8],
323    opts: &HtmlParseOptions,
324) -> (Result<Document>, Vec<XmlError>) {
325    let (doc, recovered, fatal) = parse_html_bytes_inner(input, opts);
326    let result = match fatal {
327        Some(e) => Err(e),
328        None    => Ok(doc),
329    };
330    (result, recovered)
331}
332
333fn parse_html_str_inner(
334    input: &str,
335    opts: &HtmlParseOptions,
336) -> (Document, Vec<XmlError>, Option<XmlError>) {
337    if let Err(e) = crate::license_gate::ensure_licensed() {
338        let b = sup_xml_tree::dom::DocumentBuilder::new();
339        let synth = b.new_element(b.alloc_str("html"));
340        b.set_root(synth);
341        return (b.build(), vec![e.clone()], Some(e));
342    }
343    let sink = sink::BatchSinkArena::new(opts.clone());
344    let parser = parse_document(sink, make_h5_opts(opts));
345    let sink = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parser.one(input))) {
346        Ok(s) => s,
347        Err(_) => {
348            let err = XmlError::new(
349                ErrorDomain::Html,
350                ErrorLevel::Fatal,
351                "internal HTML parser panic — input may be adversarial",
352            );
353            // Build an empty fallback Document.
354            let b = sup_xml_tree::dom::DocumentBuilder::new();
355            let synth = b.new_element(b.alloc_str("html"));
356            b.set_root(synth);
357            return (b.build(), vec![err.clone()], Some(err));
358        }
359    };
360    sink::finalize_arena(sink)
361}
362
363fn parse_html_bytes_inner(
364    input: &[u8],
365    opts: &HtmlParseOptions,
366) -> (Document, Vec<XmlError>, Option<XmlError>) {
367    // WHATWG byte-stream sniffing: caller-supplied → BOM → meta
368    // charset prescan → Windows-1252 fallback.  Transcode to UTF-8
369    // before feeding to html5ever (which expects UTF-8 internally).
370    let (utf8, _detected_encoding) = match encoding::decode_html_input(input, opts) {
371        Ok(out) => out,
372        Err(e) => {
373            let b = sup_xml_tree::dom::DocumentBuilder::new();
374            let synth = b.new_element(b.alloc_str("html"));
375            b.set_root(synth);
376            return (b.build(), vec![e.clone()], Some(e));
377        }
378    };
379    match std::str::from_utf8(&utf8) {
380        Ok(s) => parse_html_str_inner(s, opts),
381        Err(_) => {
382            let lossy = String::from_utf8_lossy(&utf8).into_owned();
383            parse_html_str_inner(&lossy, opts)
384        }
385    }
386}
387
388/// External-dict variant of [`parse_html_bytes_inner`].  The sink
389/// is constructed with [`sink::BatchSinkArena::new_with_dict`] so
390/// element / attribute names are interned through the caller's
391/// dict.  Fatal error fallbacks fall back to a default dict, since
392/// at that point parsing didn't reach the consumer-visible doc
393/// anyway.
394///
395/// # Safety
396///
397/// See [`parse_html_bytes_opts_with_dict`].
398#[cfg(feature = "c-abi")]
399#[allow(unsafe_code)]
400unsafe fn parse_html_bytes_inner_with_dict(
401    input: &[u8],
402    opts: &HtmlParseOptions,
403    dict: *mut sup_xml_tree::dict::Dict,
404) -> (Document, Vec<XmlError>, Option<XmlError>) {
405    if let Err(e) = crate::license_gate::ensure_licensed() {
406        let b = unsafe { sup_xml_tree::dom::DocumentBuilder::new_with_dict(dict) };
407        let synth = b.new_element(b.alloc_str("html"));
408        b.set_root(synth);
409        return (b.build(), vec![e.clone()], Some(e));
410    }
411    let (utf8, _detected_encoding) = match encoding::decode_html_input(input, opts) {
412        Ok(out) => out,
413        Err(e) => {
414            let b = unsafe { sup_xml_tree::dom::DocumentBuilder::new_with_dict(dict) };
415            let synth = b.new_element(b.alloc_str("html"));
416            b.set_root(synth);
417            return (b.build(), vec![e.clone()], Some(e));
418        }
419    };
420    let owned: String = match std::str::from_utf8(&utf8) {
421        Ok(s)  => s.to_string(),
422        Err(_) => String::from_utf8_lossy(&utf8).into_owned(),
423    };
424    let sink = unsafe { sink::BatchSinkArena::new_with_dict(opts.clone(), dict) };
425    let parser = parse_document(sink, make_h5_opts(opts));
426    let sink = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parser.one(owned.as_str()))) {
427        Ok(s) => s,
428        Err(_) => {
429            let err = XmlError::new(
430                ErrorDomain::Html,
431                ErrorLevel::Fatal,
432                "internal HTML parser panic — input may be adversarial",
433            );
434            let b = unsafe { sup_xml_tree::dom::DocumentBuilder::new_with_dict(dict) };
435            let synth = b.new_element(b.alloc_str("html"));
436            b.set_root(synth);
437            return (b.build(), vec![err.clone()], Some(err));
438        }
439    };
440    sink::finalize_arena(sink)
441}
442
443/// Arena variant of [`parse_html_bytes_inner_with_dict`].  Routes
444/// allocations through a caller-supplied shared bump arena.
445#[cfg(feature = "c-abi")]
446#[allow(unsafe_code)]
447unsafe fn parse_html_bytes_inner_with_dict_arena(
448    input: &[u8],
449    opts:  &HtmlParseOptions,
450    dict:  *mut sup_xml_tree::dict::Dict,
451    arena: std::sync::Arc<bumpalo::Bump>,
452) -> (Document, Vec<XmlError>, Option<XmlError>) {
453    if let Err(e) = crate::license_gate::ensure_licensed() {
454        let b = unsafe {
455            sup_xml_tree::dom::DocumentBuilder::new_with_dict_and_arena(
456                dict, std::sync::Arc::clone(&arena),
457            )
458        };
459        let synth = b.new_element(b.alloc_str("html"));
460        b.set_root(synth);
461        return (b.build(), vec![e.clone()], Some(e));
462    }
463    let (utf8, _detected_encoding) = match encoding::decode_html_input(input, opts) {
464        Ok(out) => out,
465        Err(e) => {
466            let b = unsafe {
467                sup_xml_tree::dom::DocumentBuilder::new_with_dict_and_arena(
468                    dict, std::sync::Arc::clone(&arena),
469                )
470            };
471            let synth = b.new_element(b.alloc_str("html"));
472            b.set_root(synth);
473            return (b.build(), vec![e.clone()], Some(e));
474        }
475    };
476    let owned: String = match std::str::from_utf8(&utf8) {
477        Ok(s)  => s.to_string(),
478        Err(_) => String::from_utf8_lossy(&utf8).into_owned(),
479    };
480    let sink = unsafe {
481        sink::BatchSinkArena::new_with_dict_and_arena(opts.clone(), dict, std::sync::Arc::clone(&arena))
482    };
483    let parser = parse_document(sink, make_h5_opts(opts));
484    let sink = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parser.one(owned.as_str()))) {
485        Ok(s) => s,
486        Err(_) => {
487            let err = XmlError::new(
488                ErrorDomain::Html,
489                ErrorLevel::Fatal,
490                "internal HTML parser panic — input may be adversarial",
491            );
492            let b = unsafe {
493                sup_xml_tree::dom::DocumentBuilder::new_with_dict_and_arena(dict, arena)
494            };
495            let synth = b.new_element(b.alloc_str("html"));
496            b.set_root(synth);
497            return (b.build(), vec![err.clone()], Some(err));
498        }
499    };
500    sink::finalize_arena(sink)
501}
502
503// ── shared internals ────────────────────────────────────────────────────────
504
505fn make_h5_opts(opts: &HtmlParseOptions) -> ParseOpts {
506    ParseOpts {
507        tokenizer: TokenizerOpts {
508            discard_bom: opts.discard_bom,
509            ..Default::default()
510        },
511        tree_builder: TreeBuilderOpts {
512            scripting_enabled: opts.scripting_enabled,
513            iframe_srcdoc: opts.iframe_srcdoc,
514            ..Default::default()
515        },
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    //! Arena-DOM tests for the HTML5 parser.
522    use super::*;
523    use sup_xml_tree::dom::NodeKind;
524
525    /// Walk children looking for the first element with `name`.
526    fn find_elem<'a>(
527        n: &'a sup_xml_tree::dom::Node<'a>,
528        name: &str,
529    ) -> Option<&'a sup_xml_tree::dom::Node<'a>> {
530        if n.kind == NodeKind::Element && n.name() == name {
531            return Some(n);
532        }
533        for c in n.children() {
534            if let Some(hit) = find_elem(c, name) {
535                return Some(hit);
536            }
537        }
538        None
539    }
540
541    /// Concatenate text/cdata descendants of a node.
542    fn collect_text<'a>(n: &'a sup_xml_tree::dom::Node<'a>) -> String {
543        let mut s = String::new();
544        fn go<'a>(n: &'a sup_xml_tree::dom::Node<'a>, s: &mut String) {
545            match n.kind {
546                NodeKind::Text | NodeKind::CData => s.push_str(n.content()),
547                NodeKind::Element => {
548                    for c in n.children() { go(c, s); }
549                }
550                _ => {}
551            }
552        }
553        go(n, &mut s);
554        s
555    }
556
557    #[test]
558    fn simple_document_parses() {
559        let html = "<html><body><p>hello</p></body></html>";
560        let doc = parse_html_str(html).unwrap();
561        assert!(doc.is_html());
562        assert_eq!(doc.root().name(),"html");
563    }
564
565    #[test]
566    fn fragment_gets_implicit_html_body() {
567        let doc = parse_html_str("<p>hi</p>").unwrap();
568        assert_eq!(doc.root().name(),"html");
569        // html5ever should have synthesized <head> and <body>.
570        assert!(find_elem(doc.root(), "body").is_some());
571        assert!(find_elem(doc.root(), "p").is_some());
572    }
573
574    #[test]
575    fn tag_soup_recovers() {
576        // Mismatched closes — should not error in recovery mode.
577        let doc = parse_html_str("<div><p>oops</div></p>").unwrap();
578        assert_eq!(doc.root().name(),"html");
579    }
580
581    #[test]
582    fn doctype_is_captured_in_metadata() {
583        let html = "<!DOCTYPE html><html><body></body></html>";
584        let doc = parse_html_str(html).unwrap();
585        let meta = doc.html_metadata.as_ref().unwrap();
586        let dt = meta.doctype.as_ref().unwrap();
587        assert_eq!(dt.name, "html");
588        assert!(dt.public_id.is_empty());
589    }
590
591    #[test]
592    fn quirks_mode_set_for_no_doctype() {
593        let doc = parse_html_str("<html><body></body></html>").unwrap();
594        let meta = doc.html_metadata.as_ref().unwrap();
595        assert!(matches!(meta.quirks_mode, sup_xml_tree::QuirksMode::Quirks));
596    }
597
598    #[test]
599    fn no_quirks_for_html5_doctype() {
600        let doc = parse_html_str("<!DOCTYPE html><html><body></body></html>").unwrap();
601        let meta = doc.html_metadata.as_ref().unwrap();
602        assert!(matches!(meta.quirks_mode, sup_xml_tree::QuirksMode::NoQuirks));
603    }
604
605    #[test]
606    fn entity_decoded() {
607        let doc = parse_html_str("<p>a &amp; b &copy; c</p>").unwrap();
608        let text = collect_text(doc.root());
609        assert!(text.contains('&'), "decoded ampersand: {text}");
610        assert!(text.contains('©'), "decoded named entity: {text}");
611    }
612
613    #[test]
614    fn case_folded_tag_names() {
615        let doc = parse_html_str("<HTML><BODY><P>X</P></BODY></HTML>").unwrap();
616        assert_eq!(doc.root().name(),"html");
617        assert!(find_elem(doc.root(), "body").is_some());
618        assert!(find_elem(doc.root(), "p").is_some());
619    }
620
621    #[test]
622    fn parse_bytes_works() {
623        let doc = parse_html_bytes(b"<p>hello</p>").unwrap();
624        assert!(doc.is_html());
625        assert_eq!(doc.root().name(),"html");
626    }
627
628    #[test]
629    fn strict_mode_returns_err_on_first_error() {
630        let opts = HtmlParseOptions {
631            recovery_mode: false,
632            ..Default::default()
633        };
634        // Mismatched close tag - well-formedness violation.
635        let result = parse_html_str_opts("<p>oops</div>", &opts);
636        assert!(result.is_err(), "strict mode should error on mismatched tags");
637    }
638
639    #[test]
640    fn recovered_errors_surface() {
641        let (doc, recovered) =
642            parse_html_str_with_recovered("<p>oops</div>", &HtmlParseOptions::default());
643        assert!(doc.is_ok());
644        assert!(!recovered.is_empty(), "should have recovered errors");
645    }
646
647    #[test]
648    fn depth_limit_enforced() {
649        let opts = HtmlParseOptions {
650            max_element_depth: 5,
651            ..Default::default()
652        };
653        let html = "<div>".repeat(100) + &"</div>".repeat(100);
654        let result = parse_html_str_opts(&html, &opts);
655        assert!(result.is_err(), "must reject input exceeding depth limit");
656    }
657
658    #[test]
659    fn text_byte_limit_enforced() {
660        let opts = HtmlParseOptions {
661            max_text_bytes: 100,
662            ..Default::default()
663        };
664        let html = format!("<p>{}</p>", "a".repeat(10_000));
665        let result = parse_html_str_opts(&html, &opts);
666        assert!(result.is_err(), "must reject input exceeding text-byte limit");
667    }
668
669    #[test]
670    fn parse_html_bytes_decodes_windows1252_via_meta() {
671        let mut bytes = b"<!DOCTYPE html><html><head><meta charset=\"windows-1252\"></head><body><p>".to_vec();
672        bytes.push(0x97);
673        bytes.extend_from_slice(b"</p></body></html>");
674
675        let doc = parse_html_bytes(&bytes).expect("must parse");
676        assert!(doc.is_html());
677        let p = find_elem(doc.root(), "p").expect("must find <p>");
678        let text = collect_text(p);
679        assert!(
680            text.contains('\u{2014}'),
681            "em dash (U+2014) should be decoded from byte 0x97; got {text:?}"
682        );
683    }
684
685    #[test]
686    fn parse_html_bytes_respects_encoding_override() {
687        // Same Windows-1252 byte 0x97 but no meta tag — without an
688        // override it falls back to Windows-1252 anyway.  With a
689        // wrong override (UTF-8) the byte becomes U+FFFD via lossy
690        // decoding.
691        let mut bytes = b"<html><body><p>".to_vec();
692        bytes.push(0x97);
693        bytes.extend_from_slice(b"</p></body></html>");
694
695        let opts = HtmlParseOptions {
696            encoding_override: Some("UTF-8".into()),
697            ..Default::default()
698        };
699        let doc = parse_html_bytes_opts(&bytes, &opts).expect("must parse");
700        let p = find_elem(doc.root(), "p").expect("must find <p>");
701        let text = collect_text(p);
702        // Either U+FFFD substitution OR lossy decoding swallowed it.
703        assert!(
704            text.contains('\u{FFFD}') || !text.contains('\u{2014}'),
705            "with UTF-8 override, byte 0x97 should not decode as em dash; got {text:?}"
706        );
707    }
708
709    #[test]
710    fn attributes_preserved() {
711        let doc = parse_html_str(r#"<html><body><a href="/x" class="nav">x</a></body></html>"#)
712            .unwrap();
713        let a = find_elem(doc.root(), "a").expect("<a>");
714        let attrs: Vec<(&str, &str)> = a.attributes().map(|x| (x.name(), x.value())).collect();
715        assert!(attrs.iter().any(|(n, v)| *n == "href" && *v == "/x"), "got: {:?}", attrs);
716        assert!(attrs.iter().any(|(n, v)| *n == "class" && *v == "nav"), "got: {:?}", attrs);
717    }
718
719    #[test]
720    fn comments_preserved() {
721        let doc = parse_html_str("<html><body><!-- hello --><p>x</p></body></html>")
722            .unwrap();
723        let body = find_elem(doc.root(), "body").expect("body");
724        let comment = body.children().find(|c| c.kind == NodeKind::Comment);
725        let c = comment.expect("comment");
726        assert_eq!(c.content(), " hello ");
727    }
728
729    #[test]
730    fn deeply_nested_recovers() {
731        // Many levels of nesting under the limit.  html5ever wraps the
732        // run of divs under <html><body>, so dig from body.
733        let html = "<div>".repeat(50) + "x" + &"</div>".repeat(50);
734        let doc = parse_html_str(&html).unwrap();
735        let body = find_elem(doc.root(), "body").expect("body");
736        let mut depth = 0;
737        let mut cur = body;
738        loop {
739            let next = cur.children().find(|c| c.kind == NodeKind::Element && c.name() == "div");
740            match next {
741                Some(d) => { depth += 1; cur = d; }
742                None    => break,
743            }
744        }
745        assert!(depth >= 50, "expected at least 50 nested divs; got {depth}");
746    }
747
748    // ── streaming tests ──────────────────────────────────────────────────────
749
750    use super::events::HtmlAttrs;
751    use super::stream::{HtmlBytesReader, HtmlReader, HtmlSaxHandler, HtmlSaxParser};
752    use super::HtmlEvent;
753
754    /// Drain a reader into an owned event-trace string.  Each event
755    /// is one line; the format is stable enough to compare across
756    /// chunk-size variations.
757    fn drain_pull(reader: &mut HtmlReader<'_>) -> String {
758        let mut out = String::new();
759        loop {
760            let event = reader.next().expect("pull errored unexpectedly");
761            match event {
762                HtmlEvent::StartElement { name, attributes } => {
763                    out.push_str("S:");
764                    out.push_str(name);
765                    let mut attrs: Vec<_> = attributes
766                        .iter()
767                        .map(|a| format!("{}={}", a.name, a.value))
768                        .collect();
769                    attrs.sort();
770                    if !attrs.is_empty() {
771                        out.push('[');
772                        out.push_str(&attrs.join(","));
773                        out.push(']');
774                    }
775                    out.push('\n');
776                }
777                HtmlEvent::EndElement { name } => {
778                    out.push_str("E:");
779                    out.push_str(name);
780                    out.push('\n');
781                }
782                HtmlEvent::Text(t) => {
783                    out.push_str("T:");
784                    out.push_str(t);
785                    out.push('\n');
786                }
787                HtmlEvent::Comment(c) => {
788                    out.push_str("C:");
789                    out.push_str(c);
790                    out.push('\n');
791                }
792                HtmlEvent::Doctype { name, .. } => {
793                    out.push_str("D:");
794                    out.push_str(name);
795                    out.push('\n');
796                }
797                HtmlEvent::Eof => return out,
798            }
799        }
800    }
801
802    #[test]
803    fn streaming_basic_event_sequence() {
804        let mut r = HtmlReader::new("<html><body><p>hi</p></body></html>");
805        let trace = drain_pull(&mut r);
806        assert!(trace.contains("S:html"), "trace:\n{trace}");
807        assert!(trace.contains("S:body"), "trace:\n{trace}");
808        assert!(trace.contains("S:p"), "trace:\n{trace}");
809        assert!(trace.contains("T:hi"), "trace:\n{trace}");
810        assert!(trace.contains("E:p"), "trace:\n{trace}");
811        assert!(trace.contains("E:body"), "trace:\n{trace}");
812        assert!(trace.contains("E:html"), "trace:\n{trace}");
813    }
814
815    #[test]
816    fn streaming_text_coalescing() {
817        // html5ever emits text in chunks; the streaming sink coalesces
818        // adjacent runs into one Text event.
819        let mut r = HtmlReader::new("<p>hello world &amp; goodbye</p>");
820        let trace = drain_pull(&mut r);
821        // Should appear as one Text line, not split per chunk.
822        let text_lines: Vec<&str> = trace.lines().filter(|l| l.starts_with("T:")).collect();
823        assert!(
824            text_lines.iter().any(|l| l.contains("hello world & goodbye")),
825            "expected coalesced text; got lines: {:?}",
826            text_lines
827        );
828    }
829
830    #[test]
831    fn streaming_doctype_event() {
832        let mut r = HtmlReader::new("<!DOCTYPE html><html><body>x</body></html>");
833        let trace = drain_pull(&mut r);
834        assert!(trace.contains("D:html"));
835    }
836
837    #[test]
838    fn streaming_attributes_visible() {
839        let mut r = HtmlReader::new(r#"<a href="/x" class="nav">x</a>"#);
840        let trace = drain_pull(&mut r);
841        assert!(
842            trace.lines().any(|l| l.starts_with("S:a[") && l.contains("href=/x") && l.contains("class=nav")),
843            "expected start tag with attrs; got:\n{}",
844            trace
845        );
846    }
847
848    #[test]
849    fn streaming_chunk_boundary_invariance() {
850        let html = "<html><head><title>t</title></head><body><p>a</p><p>b &amp; c</p></body></html>";
851        let mut a = HtmlReader::new(html);
852        let mut b = HtmlBytesReader::new(html.as_bytes()).expect("bytes reader init");
853        let trace_a = drain_pull(&mut a);
854        let trace_b = drain_pull_bytes(&mut b);
855        assert_eq!(trace_a, trace_b, "str and bytes readers must agree");
856    }
857
858    fn drain_pull_bytes(reader: &mut HtmlBytesReader<'_>) -> String {
859        let mut out = String::new();
860        loop {
861            let event = reader.next().expect("bytes pull errored");
862            match event {
863                HtmlEvent::StartElement { name, attributes } => {
864                    out.push_str("S:");
865                    out.push_str(name);
866                    let mut attrs: Vec<_> = attributes
867                        .iter()
868                        .map(|a| format!("{}={}", a.name, a.value))
869                        .collect();
870                    attrs.sort();
871                    if !attrs.is_empty() {
872                        out.push('[');
873                        out.push_str(&attrs.join(","));
874                        out.push(']');
875                    }
876                    out.push('\n');
877                }
878                HtmlEvent::EndElement { name } => {
879                    out.push_str("E:");
880                    out.push_str(name);
881                    out.push('\n');
882                }
883                HtmlEvent::Text(t) => {
884                    out.push_str("T:");
885                    out.push_str(t);
886                    out.push('\n');
887                }
888                HtmlEvent::Comment(c) => {
889                    out.push_str("C:");
890                    out.push_str(c);
891                    out.push('\n');
892                }
893                HtmlEvent::Doctype { name, .. } => {
894                    out.push_str("D:");
895                    out.push_str(name);
896                    out.push('\n');
897                }
898                HtmlEvent::Eof => return out,
899            }
900        }
901    }
902
903    #[derive(Default)]
904    struct LinkCollector {
905        hrefs: Vec<String>,
906    }
907
908    impl HtmlSaxHandler for LinkCollector {
909        fn start_element(&mut self, name: &str, attrs: HtmlAttrs<'_>) {
910            if name == "a" {
911                if let Some(href) = attrs.get("href") {
912                    self.hrefs.push(href.to_string());
913                }
914            }
915        }
916    }
917
918    #[test]
919    fn sax_parser_collects_attributes() {
920        let html = r#"<a href="/x">x</a><a href="/y">y</a><a>none</a>"#;
921        let mut p = HtmlSaxParser::new(LinkCollector::default());
922        p.feed(html).unwrap();
923        let collector = p.finish().unwrap();
924        assert_eq!(collector.hrefs, vec!["/x", "/y"]);
925    }
926
927    #[test]
928    fn sax_parser_chunked_feed() {
929        // Feed the input one byte at a time — events should still
930        // emit correctly across chunk boundaries.
931        let html = r#"<a href="/x">x</a>"#;
932        let mut p = HtmlSaxParser::new(LinkCollector::default());
933        for ch in html.chars() {
934            let mut tmp = [0u8; 4];
935            let s = ch.encode_utf8(&mut tmp);
936            p.feed(s).unwrap();
937        }
938        let collector = p.finish().unwrap();
939        assert_eq!(collector.hrefs, vec!["/x"]);
940    }
941
942    /// Trace handler — records every event for parity comparison.
943    #[derive(Default)]
944    struct TraceHandler {
945        out: String,
946    }
947
948    impl HtmlSaxHandler for TraceHandler {
949        fn start_element(&mut self, name: &str, attrs: HtmlAttrs<'_>) {
950            self.out.push_str("S:");
951            self.out.push_str(name);
952            let mut a: Vec<_> = attrs
953                .iter()
954                .map(|a| format!("{}={}", a.name, a.value))
955                .collect();
956            a.sort();
957            if !a.is_empty() {
958                self.out.push('[');
959                self.out.push_str(&a.join(","));
960                self.out.push(']');
961            }
962            self.out.push('\n');
963        }
964        fn end_element(&mut self, name: &str) {
965            self.out.push_str("E:");
966            self.out.push_str(name);
967            self.out.push('\n');
968        }
969        fn text(&mut self, content: &str) {
970            self.out.push_str("T:");
971            self.out.push_str(content);
972            self.out.push('\n');
973        }
974        fn comment(&mut self, content: &str) {
975            self.out.push_str("C:");
976            self.out.push_str(content);
977            self.out.push('\n');
978        }
979        fn doctype(&mut self, name: &str, _public_id: &str, _system_id: &str) {
980            self.out.push_str("D:");
981            self.out.push_str(name);
982            self.out.push('\n');
983        }
984    }
985
986    #[test]
987    fn pull_push_parity() {
988        let html = "<!DOCTYPE html><html><body><p>foo &amp; bar</p><!-- comment --><a href=\"/x\">link</a></body></html>";
989
990        let mut pull = HtmlReader::new(html);
991        let pull_trace = drain_pull(&mut pull);
992
993        let mut push = HtmlSaxParser::new(TraceHandler::default());
994        push.feed(html).unwrap();
995        let push_trace = push.finish().unwrap().out;
996
997        assert_eq!(
998            pull_trace, push_trace,
999            "pull and push surfaces must produce identical event traces"
1000        );
1001    }
1002
1003    #[test]
1004    fn streaming_recovered_errors_collected() {
1005        let mut r = HtmlReader::new("<p>oops</div>");
1006        let _ = drain_pull(&mut r);
1007        assert!(
1008            !r.recovered_errors().is_empty(),
1009            "should have recovered at least one parse error"
1010        );
1011    }
1012
1013    #[test]
1014    fn streaming_bytes_reader_decodes_windows1252() {
1015        let mut bytes = b"<!DOCTYPE html><html><head><meta charset=\"windows-1252\"></head><body><p>".to_vec();
1016        bytes.push(0x97);
1017        bytes.extend_from_slice(b"</p></body></html>");
1018
1019        let mut r = HtmlBytesReader::new(&bytes).expect("init");
1020        let mut found_em_dash = false;
1021        loop {
1022            match r.next().expect("pull") {
1023                HtmlEvent::Eof => break,
1024                HtmlEvent::Text(t) if t.contains('\u{2014}') => found_em_dash = true,
1025                _ => {}
1026            }
1027        }
1028        assert!(
1029            found_em_dash,
1030            "streaming bytes reader should decode windows-1252 em dash"
1031        );
1032    }
1033
1034    #[test]
1035    fn streaming_strict_mode_errors() {
1036        let opts = HtmlParseOptions {
1037            recovery_mode: false,
1038            ..Default::default()
1039        };
1040        let mut r = HtmlReader::with_opts("<p>oops</div>", opts);
1041        let mut hit_err = false;
1042        loop {
1043            match r.next() {
1044                Ok(HtmlEvent::Eof) => break,
1045                Ok(_) => {}
1046                Err(_) => {
1047                    hit_err = true;
1048                    break;
1049                }
1050            }
1051        }
1052        assert!(hit_err, "strict mode should surface a parse error");
1053    }
1054
1055    // ── sink coverage: exercise BatchSinkArena code paths ────────────────────
1056
1057    #[test]
1058    fn comments_before_and_after_html_element() {
1059        // Comments at document level — html5ever places them as children
1060        // of the document sentinel.  Exercises:
1061        //   - alloc_comment via create_comment
1062        //   - link_append's `is_document(parent)` branch (doc_children)
1063        //   - last_child's doc_children branch (when appending after a comment)
1064        let html = "<!-- pre --><html><body>x</body></html><!-- post -->";
1065        let doc = parse_html_str(html).unwrap();
1066        // The arena root is the <html> element (sink finalizes by picking
1067        // the first element child).  Sibling comments at doc level get
1068        // dropped — but the sink's link_append path was exercised.
1069        assert_eq!(doc.root().name(), "html");
1070    }
1071
1072    #[test]
1073    fn processing_instruction_in_html_becomes_comment() {
1074        // HTML5 treats `<?foo?>` as a bogus comment.  Our sink's
1075        // `create_pi` maps this to an empty comment.
1076        let doc = parse_html_str("<?xml-stylesheet href='x'?><html><body>x</body></html>")
1077            .unwrap();
1078        // Smoke check — must not panic and must produce <html>.
1079        assert_eq!(doc.root().name(), "html");
1080    }
1081
1082    #[test]
1083    fn max_text_bytes_exceeded_aborts_parse() {
1084        let opts = HtmlParseOptions {
1085            max_text_bytes: 8,
1086            ..Default::default()
1087        };
1088        let (result, _recovered) = parse_html_str_with_recovered(
1089            "<p>this is more than eight bytes of text</p>", &opts,
1090        );
1091        // Aborted parse surfaces the fatal error.
1092        assert!(result.is_err());
1093        if let Err(e) = result {
1094            assert!(e.message.contains("max_text_bytes"), "got {}", e.message);
1095        }
1096    }
1097
1098    #[test]
1099    fn max_element_depth_exceeded_aborts_parse() {
1100        let opts = HtmlParseOptions {
1101            max_element_depth: 4,
1102            ..Default::default()
1103        };
1104        // 10 levels of nested <div> — exceeds depth 4.
1105        let html = "<div>".repeat(10) + "x" + &"</div>".repeat(10);
1106        let (result, _) = parse_html_str_with_recovered(&html, &opts);
1107        assert!(result.is_err());
1108        if let Err(e) = result {
1109            assert!(e.message.contains("max_element_depth"), "got {}", e.message);
1110        }
1111    }
1112
1113    #[test]
1114    fn table_foster_parenting_with_text() {
1115        // Text inside <table> outside a cell triggers HTML5 foster
1116        // parenting — html5ever calls append_before_sibling /
1117        // append_based_on_parent_node on the sink.  Drives the
1118        // insert_text_before / link_before paths.
1119        let html = "<table>raw text<tr><td>cell</td></tr></table>";
1120        let doc = parse_html_str(html).unwrap();
1121        // Smoke: text "raw text" should appear somewhere in the result.
1122        assert_eq!(doc.root().name(), "html");
1123        let body = find_elem(doc.root(), "body").expect("body");
1124        let all_text = collect_text(body);
1125        assert!(all_text.contains("raw text"), "got: {all_text:?}");
1126        assert!(all_text.contains("cell"), "got: {all_text:?}");
1127    }
1128
1129    #[test]
1130    fn duplicate_html_attributes_use_add_attrs_if_missing() {
1131        // Repeated <html> tag with new attributes — html5ever merges
1132        // these by calling add_attrs_if_missing on the existing handle.
1133        let html = r#"<html lang="en"><body></body></html><html dir="ltr">"#;
1134        let doc = parse_html_str(html).unwrap();
1135        let root = doc.root();
1136        let attrs: Vec<(&str, &str)> = root.attributes()
1137            .map(|a| (a.name(), a.value())).collect();
1138        // Both lang= (from first) and dir= (from second) should be present.
1139        assert!(attrs.iter().any(|(n, _)| *n == "lang"), "got {attrs:?}");
1140        assert!(attrs.iter().any(|(n, _)| *n == "dir"),  "got {attrs:?}");
1141    }
1142
1143    #[test]
1144    fn duplicate_html_attribute_does_not_overwrite() {
1145        // add_attrs_if_missing must NOT replace an existing attribute.
1146        let html = r#"<html lang="en"><body></body></html><html lang="fr">"#;
1147        let doc = parse_html_str(html).unwrap();
1148        let lang = doc.root().attributes()
1149            .find(|a| a.name() == "lang").map(|a| a.value().to_owned());
1150        assert_eq!(lang.as_deref(), Some("en"), "first lang should win");
1151    }
1152
1153    #[test]
1154    fn limited_quirks_doctype_sets_metadata() {
1155        // XHTML 1.0 Transitional triggers LimitedQuirks per the HTML5
1156        // spec.  Exercises convert_quirks's LimitedQuirks arm.
1157        let html = r#"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
1158<html><body></body></html>"#;
1159        let doc = parse_html_str(html).unwrap();
1160        let meta = doc.html_metadata.as_ref().expect("metadata");
1161        // Either LimitedQuirks or NoQuirks is acceptable depending on the
1162        // exact spec table; we just verify it's NOT Quirks (which would
1163        // mean the DOCTYPE wasn't recognised at all).
1164        assert!(!matches!(meta.quirks_mode, sup_xml_tree::QuirksMode::Quirks),
1165            "transitional DOCTYPE should not trigger full Quirks mode");
1166    }
1167
1168    #[test]
1169    fn empty_input_synthesizes_html() {
1170        // No <html> element in input — finalize must synthesize one
1171        // so the Document is valid.
1172        let doc = parse_html_str("").unwrap();
1173        assert_eq!(doc.root().name(), "html");
1174    }
1175
1176    #[test]
1177    fn long_run_of_text_chunks_coalesces() {
1178        // html5ever delivers a long text run as many StrTendril chunks
1179        // — sink's PendingText path coalesces them into one node.
1180        let body = "x".repeat(10_000);
1181        let html = format!("<html><body><p>{body}</p></body></html>");
1182        let doc = parse_html_str(&html).unwrap();
1183        let p = find_elem(doc.root(), "p").expect("p");
1184        let text = collect_text(p);
1185        assert_eq!(text.len(), 10_000);
1186    }
1187
1188    #[test]
1189    fn parse_error_is_recorded_in_strict_mode() {
1190        let opts = HtmlParseOptions {
1191            recovery_mode: false,
1192            ..Default::default()
1193        };
1194        // Construct input that html5ever flags as a parse error.  An
1195        // unexpected character after `</` should do it.
1196        let (result, _) = parse_html_str_with_recovered("<p>x</_invalid>", &opts);
1197        // strict mode → first parse error becomes fatal.
1198        assert!(result.is_err());
1199    }
1200
1201    #[test]
1202    fn parse_errors_in_recovery_mode_recorded_not_fatal() {
1203        let opts = HtmlParseOptions { recovery_mode: true, ..Default::default() };
1204        let (result, recovered) = parse_html_str_with_recovered(
1205            "<p>x</_invalid>", &opts,
1206        );
1207        // Recovery mode: result is Ok, errors are recorded.
1208        assert!(result.is_ok());
1209        // At least one recovered error captured.
1210        assert!(!recovered.is_empty() || result.unwrap().root().name() == "html");
1211    }
1212
1213    #[test]
1214    fn quirks_mode_with_old_html_doctype() {
1215        // Plain `<!DOCTYPE html>` triggers NoQuirks.  Test missing DOCTYPE
1216        // separately for Quirks; here just exercise the NoQuirks arm of
1217        // convert_quirks.
1218        let doc = parse_html_str("<!DOCTYPE html><html><body></body></html>").unwrap();
1219        let meta = doc.html_metadata.as_ref().unwrap();
1220        assert!(matches!(meta.quirks_mode, sup_xml_tree::QuirksMode::NoQuirks));
1221    }
1222
1223    #[test]
1224    fn doctype_with_public_and_system_ids() {
1225        // Exercises append_doctype_to_document with non-empty public_id
1226        // AND system_id.
1227        let html = r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"><html><body></body></html>"#;
1228        let doc = parse_html_str(html).unwrap();
1229        let meta = doc.html_metadata.as_ref().unwrap();
1230        let dt = meta.doctype.as_ref().unwrap();
1231        assert_eq!(dt.name, "html");
1232        assert!(dt.public_id.contains("HTML 4.01"));
1233        assert!(dt.system_id.contains("html4"));
1234    }
1235
1236    #[test]
1237    fn template_element_parses() {
1238        // <template> triggers html5ever's get_template_contents query.
1239        let html = "<html><body><template><p>tmpl</p></template></body></html>";
1240        let doc = parse_html_str(html).unwrap();
1241        assert!(find_elem(doc.root(), "template").is_some());
1242    }
1243
1244    #[test]
1245    fn table_foster_parenting_runs_of_text() {
1246        // Multiple text runs inside <table> outside cells — drives the
1247        // insert_text_before code with text-before-text scenarios so the
1248        // merge path runs.
1249        let html = "<table>first<tr><td>cell</td></tr>second</table>";
1250        let doc = parse_html_str(html).unwrap();
1251        let body = find_elem(doc.root(), "body").expect("body");
1252        let all = collect_text(body);
1253        assert!(all.contains("first") && all.contains("second"),
1254            "got: {all:?}");
1255    }
1256
1257    #[test]
1258    fn nested_table_foster_parenting() {
1259        // More complex foster parenting that should drive
1260        // append_based_on_parent_node and link_before with non-first-child.
1261        let html = r#"<table><tr><td>a</td></tr>extra<tr><td>b</td></tr></table>"#;
1262        let doc = parse_html_str(html).unwrap();
1263        let body = find_elem(doc.root(), "body").expect("body");
1264        let text = collect_text(body);
1265        assert!(text.contains("extra"));
1266        assert!(text.contains("a") && text.contains("b"));
1267    }
1268
1269    #[test]
1270    fn svg_with_processing_instruction() {
1271        // HTML5 foreign content (SVG) — try various odd shapes that
1272        // might exercise additional sink paths.
1273        let html = r#"<html><body><svg xmlns="http://www.w3.org/2000/svg"><circle/></svg></body></html>"#;
1274        let doc = parse_html_str(html).unwrap();
1275        assert!(find_elem(doc.root(), "svg").is_some());
1276        assert!(find_elem(doc.root(), "circle").is_some());
1277    }
1278
1279    #[test]
1280    fn text_after_html_close_appended() {
1281        // Trailing text after </html> — html5ever places it inside body
1282        // (HTML5 spec § "after after body").  Hits append paths.
1283        let html = "<html><body>x</body></html>extra-text";
1284        let doc = parse_html_str(html).unwrap();
1285        let body = find_elem(doc.root(), "body").expect("body");
1286        let text = collect_text(body);
1287        assert!(text.contains("x"));
1288    }
1289
1290    #[test]
1291    fn list_with_misnested_tags() {
1292        // Various misnested constructs exercise reparenting / detachment.
1293        let html = "<ul><li>one<li>two<li>three</ul>";
1294        let doc = parse_html_str(html).unwrap();
1295        let body = find_elem(doc.root(), "body").expect("body");
1296        let li_count = body.children().filter_map(|c| {
1297            find_elem(c, "li")
1298        }).count();
1299        assert!(li_count > 0);
1300    }
1301
1302    #[test]
1303    fn text_byte_limit_hit_during_merge() {
1304        // Tiny budget where a single text chunk fits, but a second
1305        // text chunk merging into the same target overflows.  Drives
1306        // the "merge target → text_bytes overflow" abort path.
1307        let opts = HtmlParseOptions {
1308            max_text_bytes: 5,
1309            ..Default::default()
1310        };
1311        // html5ever splits "a<![CDATA-like seam]>" into multiple chunks
1312        // for raw-text elements.  Use a long <script> body where the
1313        // first chunk fits under the budget but a continuation merges
1314        // and overflows.
1315        let html = "<html><body><script>aaa</script><script>bbbbbbbbbb</script></body></html>";
1316        let (result, _) = parse_html_str_with_recovered(html, &opts);
1317        // Either text-merge-abort or single-shot abort — both are
1318        // "max_text_bytes exceeded".
1319        assert!(result.is_err());
1320        if let Err(e) = result {
1321            assert!(e.message.contains("max_text_bytes"), "got {}", e.message);
1322        }
1323    }
1324
1325    #[test]
1326    fn debug_format_on_owned_elem_name_via_strict_error() {
1327        // OwnedElemName's Debug fires when html5ever debug-prints a
1328        // tree-construction step.  In strict mode, parse_error with the
1329        // formatter active will surface it.  This is hard to trigger
1330        // directly from outside; this test just ensures the path doesn't
1331        // panic when many elements are processed.
1332        let html = "<html><body><div><span><a><b><i></i></b></a></span></div></body></html>";
1333        let doc = parse_html_str(html).unwrap();
1334        // Smoke check.
1335        assert_eq!(doc.root().name(), "html");
1336    }
1337}