Skip to main content

libxml_rs/xml/html/
mod.rs

1//! HTML parser and serializer (§29, §85 Phase 4).
2//!
3//! The libxml2 historical HTML parser — a tag-recovery parser, NOT a WHATWG
4//! HTML5 parser. Preserves version-specific historical behavior.
5//!
6//! Implements:
7//! - Tag-recovery parsing (auto-close, implicit open, case-insensitive)
8//! - HTML element info table with flags matching libxml2
9//! - HTML entity resolution
10//! - Auto-creation of html/head/body when missing
11//! - HTML-specific serialization (no self-closing void tags, no namespace decls)
12//! - Minimized and unquoted attribute support
13//!
14//! # Upstream contract
15//!
16//! Mirrors upstream `HTMLparser.c`, `HTMLtree.c` and `HTMLdocument.c`
17//! (`SRC-LIBXML2-2.15.0-HTMLPARSER-C` et al., parity target libxml2 2.15.3
18//! oracle): the tag-recovery HTML parser, the htmlElementInfo table,
19//! htmlDocDump serialization and the htmlDefaultSAXHandler /
20//! htmlDefaultSAXLocator data globals (R-000135 exports them
21//! byte-identical; htmlInitAutoClose / htmlElementAllowedHere are the
22//! R-000138 no-op set).
23//!
24//! # Conceptual behavior
25//!
26//! Implements libxml2 historical HTML parsing: case-insensitive tag
27//! recovery with auto-close and implicit-open rules driven by the
28//! htmlElementInfo flags table (HTML_EMPTY/HTML_NO_END/HTML_HEAD/...),
29//! HTML entity resolution, auto-creation of html/head/body, minimized and
30//! unquoted attributes, and HTML-specific serialization (no self-closing
31//! void tags, no namespace declarations). This is deliberately NOT a
32//! WHATWG HTML5 parser (WHATWG-HTML).
33//!
34//! # Ownership & safety invariants
35//!
36//! The parser creates a document the caller owns (freed with
37//! `xmlFreeDoc`, same as XML); elements/attributes are owned by the tree.
38//! Serialization borrows the tree and writes through the output buffer.
39//! Push-mode input buffers are owned by the parser context (CVE-2015-8242
40//! fixed a push-mode buffer overread upstream, SEC-0008).
41//!
42//! # Historical quirks & epochs
43//!
44//! E-007: the `--html` dump became a single line in the 2.15.0 epoch —
45//! six `xmlOutputBufferWriteString(buf, "\n")` calls were removed from
46//! HTMLtree.c (commits 0d81d6f8, 46f05ea4); the crate matches the 2.15+
47//! single-line epoch. R-000118 locked the HTML output method for XSLT.
48//! The tag-recovery rules themselves go back to the 1.x/2.0 HTML era and
49//! stay version-faithful.
50//!
51//! # Deliberate oddities
52//!
53//! The htmlElementInfo table ordering and flag values reproduce upstream
54//! exactly (R-000135 DATA-GLOBALS-001 fingerprints the default handler
55//! slots); case-folding and the auto-close stack follow HTMLparser.c
56//! rather than the WHATWG spec — a deliberate historical fidelity choice.
57//!
58//! # Proving courts
59//!
60//! HTML-* courts (SEC-0008), the CLI `--html` differential cases
61//! (html-dump epoch case in SEMANTIC_EPOCHS.md) and DATA-GLOBALS-001
62//! compare output byte-identical against the oracle; cargo test runs the
63//! HTML unit suites.
64//!
65//! # Tempting simplifications that would break parity
66//!
67//! Do not switch to a WHATWG HTML5 parser: downstream consumers depend on
68//! libxml2 tag-recovery quirks. Do not re-add newlines to the dump (E-007
69//! epoch) and do not reorder or re-flag the element table — both are
70//! observable through the serializer and the exported data globals.
71//!
72//! # Safety
73//!
74//! - Raw `_xmlDoc`, `_xmlNode`, `_xmlAttr` and `_xmlBuffer` pointers are
75//!   allocated by the crate allocator (or the tree helpers) and must stay
76//!   valid for the duration of each call; owned documents are freed with
77//!   `tree::free_doc` and buffers with the matching `xmlFreeImpl` routine.
78//! - `HtmlParserCtxt.input` must be non-NULL and readable for `input_len`
79//!   bytes whenever `input_pos` is below `input_len`; every read is
80//!   bounds-checked against `input_len` before dereferencing.
81//! - Node trees walked through `parent`, `children`, `next` and `properties`
82//!   links must be well-formed: links are NULL-terminated, the parent chain
83//!   terminates (no cycles), and every node in a chain is a valid `_xmlNode`.
84//! - `name`, `content`, `version` and `encoding` fields are NULL or valid
85//!   NUL-terminated `xmlChar` strings.
86
87use core::ffi::c_void;
88use core::mem::size_of;
89use core::ptr;
90use core::slice;
91use std::os::raw::{c_char, c_int};
92
93use crate::abi::allocator::{xmlFreeImpl, xmlMallocZero};
94use crate::abi::structs::*;
95use crate::abi::types::xmlDocProperties::XML_DOC_WELLFORMED;
96use crate::abi::types::xmlElementType::*;
97use crate::abi::types::*;
98use crate::xml::io;
99use crate::xml::string::*;
100use crate::xml::tree;
101
102// ═══════════════════════════════════════════════════════════════════════════════
103// HTML Element Info
104// ═══════════════════════════════════════════════════════════════════════════════
105
106/// HTML element flag constants matching libxml2 HTMLparser.h.
107// HTML parser option bits (HTMLparser.h) as stored in `HtmlParserCtxt.options`
108// (the same merged option space as the XML_PARSE_* bits).
109const HTML_PARSE_NODEFDTD: c_int = 1 << 2;
110const HTML_PARSE_NOIMPLIED: c_int = 1 << 13;
111
112const HTML_INLINE: u32 = 0x1;
113const HTML_BLOCK: u32 = 0x2;
114const HTML_EMPTY: u32 = 0x4;
115#[allow(dead_code)]
116const HTML_DEPRECATED: u32 = 0x8;
117const HTML_OL: u32 = 0x10;
118const HTML_DL: u32 = 0x20;
119#[allow(dead_code)]
120const HTML_COMPACT: u32 = 0x40;
121const HTML_HEAD: u32 = 0x80;
122const HTML_BODY: u32 = 0x100;
123#[allow(dead_code)]
124const HTML_HEADSTRUCK: u32 = 0x200;
125const HTML_VALID: u32 = 0x400;
126const HTML_NO_END: u32 = 0x800; // end tag optional
127#[allow(dead_code)]
128const HTML_IMPLIED: u32 = 0x1000; // implied/auto-created
129
130/// Information about an HTML element.
131#[derive(Clone, Copy)]
132struct HtmlElementInfo {
133    name: &'static str,
134    flags: u32,
135}
136
137/// Lookup table of HTML elements and their properties.
138/// This matches libxml2's `htmlElementInfo` table.
139const HTML_ELEMENTS: &[HtmlElementInfo] = &[
140    // Void / empty elements
141    HtmlElementInfo {
142        name: "br",
143        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
144    },
145    HtmlElementInfo {
146        name: "hr",
147        flags: HTML_BLOCK | HTML_EMPTY | HTML_VALID,
148    },
149    HtmlElementInfo {
150        name: "img",
151        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
152    },
153    HtmlElementInfo {
154        name: "input",
155        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
156    },
157    HtmlElementInfo {
158        name: "meta",
159        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
160    },
161    HtmlElementInfo {
162        name: "link",
163        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
164    },
165    HtmlElementInfo {
166        name: "base",
167        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
168    },
169    HtmlElementInfo {
170        name: "area",
171        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
172    },
173    HtmlElementInfo {
174        name: "col",
175        flags: HTML_BLOCK | HTML_EMPTY | HTML_VALID,
176    },
177    HtmlElementInfo {
178        name: "embed",
179        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
180    },
181    HtmlElementInfo {
182        name: "param",
183        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
184    },
185    HtmlElementInfo {
186        name: "source",
187        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
188    },
189    HtmlElementInfo {
190        name: "track",
191        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
192    },
193    HtmlElementInfo {
194        name: "wbr",
195        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
196    },
197    // Block elements
198    HtmlElementInfo {
199        name: "html",
200        flags: HTML_BLOCK | HTML_VALID,
201    },
202    HtmlElementInfo {
203        name: "head",
204        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
205    },
206    HtmlElementInfo {
207        name: "body",
208        flags: HTML_BODY | HTML_BLOCK | HTML_VALID,
209    },
210    HtmlElementInfo {
211        name: "div",
212        flags: HTML_BLOCK | HTML_VALID,
213    },
214    HtmlElementInfo {
215        name: "p",
216        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
217    },
218    HtmlElementInfo {
219        name: "h1",
220        flags: HTML_BLOCK | HTML_VALID,
221    },
222    HtmlElementInfo {
223        name: "h2",
224        flags: HTML_BLOCK | HTML_VALID,
225    },
226    HtmlElementInfo {
227        name: "h3",
228        flags: HTML_BLOCK | HTML_VALID,
229    },
230    HtmlElementInfo {
231        name: "h4",
232        flags: HTML_BLOCK | HTML_VALID,
233    },
234    HtmlElementInfo {
235        name: "h5",
236        flags: HTML_BLOCK | HTML_VALID,
237    },
238    HtmlElementInfo {
239        name: "h6",
240        flags: HTML_BLOCK | HTML_VALID,
241    },
242    HtmlElementInfo {
243        name: "ul",
244        flags: HTML_BLOCK | HTML_VALID | HTML_OL,
245    },
246    HtmlElementInfo {
247        name: "ol",
248        flags: HTML_BLOCK | HTML_VALID | HTML_OL,
249    },
250    HtmlElementInfo {
251        name: "li",
252        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
253    },
254    HtmlElementInfo {
255        name: "dl",
256        flags: HTML_BLOCK | HTML_VALID | HTML_DL,
257    },
258    HtmlElementInfo {
259        name: "dt",
260        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
261    },
262    HtmlElementInfo {
263        name: "dd",
264        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
265    },
266    HtmlElementInfo {
267        name: "table",
268        flags: HTML_BLOCK | HTML_VALID,
269    },
270    HtmlElementInfo {
271        name: "tr",
272        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
273    },
274    HtmlElementInfo {
275        name: "td",
276        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
277    },
278    HtmlElementInfo {
279        name: "th",
280        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
281    },
282    HtmlElementInfo {
283        name: "thead",
284        flags: HTML_BLOCK | HTML_VALID,
285    },
286    HtmlElementInfo {
287        name: "tbody",
288        flags: HTML_BLOCK | HTML_VALID,
289    },
290    HtmlElementInfo {
291        name: "tfoot",
292        flags: HTML_BLOCK | HTML_VALID,
293    },
294    HtmlElementInfo {
295        name: "colgroup",
296        flags: HTML_BLOCK | HTML_VALID,
297    },
298    HtmlElementInfo {
299        name: "caption",
300        flags: HTML_BLOCK | HTML_VALID,
301    },
302    HtmlElementInfo {
303        name: "form",
304        flags: HTML_BLOCK | HTML_VALID,
305    },
306    HtmlElementInfo {
307        name: "fieldset",
308        flags: HTML_BLOCK | HTML_VALID,
309    },
310    HtmlElementInfo {
311        name: "legend",
312        flags: HTML_BLOCK | HTML_VALID,
313    },
314    HtmlElementInfo {
315        name: "pre",
316        flags: HTML_BLOCK | HTML_VALID,
317    },
318    HtmlElementInfo {
319        name: "blockquote",
320        flags: HTML_BLOCK | HTML_VALID,
321    },
322    HtmlElementInfo {
323        name: "address",
324        flags: HTML_BLOCK | HTML_VALID,
325    },
326    HtmlElementInfo {
327        name: "center",
328        flags: HTML_BLOCK | HTML_VALID,
329    },
330    HtmlElementInfo {
331        name: "dir",
332        flags: HTML_BLOCK | HTML_VALID,
333    },
334    HtmlElementInfo {
335        name: "menu",
336        flags: HTML_BLOCK | HTML_VALID,
337    },
338    HtmlElementInfo {
339        name: "noscript",
340        flags: HTML_BLOCK | HTML_VALID,
341    },
342    HtmlElementInfo {
343        name: "frameset",
344        flags: HTML_BLOCK | HTML_VALID,
345    },
346    HtmlElementInfo {
347        name: "frame",
348        flags: HTML_BLOCK | HTML_EMPTY | HTML_VALID,
349    },
350    HtmlElementInfo {
351        name: "iframe",
352        flags: HTML_BLOCK | HTML_VALID,
353    },
354    HtmlElementInfo {
355        name: "noframes",
356        flags: HTML_BLOCK | HTML_VALID,
357    },
358    // Inline elements
359    HtmlElementInfo {
360        name: "a",
361        flags: HTML_INLINE | HTML_VALID,
362    },
363    HtmlElementInfo {
364        name: "abbr",
365        flags: HTML_INLINE | HTML_VALID,
366    },
367    HtmlElementInfo {
368        name: "acronym",
369        flags: HTML_INLINE | HTML_VALID,
370    },
371    HtmlElementInfo {
372        name: "b",
373        flags: HTML_INLINE | HTML_VALID,
374    },
375    HtmlElementInfo {
376        name: "basefont",
377        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
378    },
379    HtmlElementInfo {
380        name: "bdo",
381        flags: HTML_INLINE | HTML_VALID,
382    },
383    HtmlElementInfo {
384        name: "big",
385        flags: HTML_INLINE | HTML_VALID,
386    },
387    HtmlElementInfo {
388        name: "cite",
389        flags: HTML_INLINE | HTML_VALID,
390    },
391    HtmlElementInfo {
392        name: "code",
393        flags: HTML_INLINE | HTML_VALID,
394    },
395    HtmlElementInfo {
396        name: "dfn",
397        flags: HTML_INLINE | HTML_VALID,
398    },
399    HtmlElementInfo {
400        name: "em",
401        flags: HTML_INLINE | HTML_VALID,
402    },
403    HtmlElementInfo {
404        name: "font",
405        flags: HTML_INLINE | HTML_VALID,
406    },
407    HtmlElementInfo {
408        name: "i",
409        flags: HTML_INLINE | HTML_VALID,
410    },
411    HtmlElementInfo {
412        name: "kbd",
413        flags: HTML_INLINE | HTML_VALID,
414    },
415    HtmlElementInfo {
416        name: "label",
417        flags: HTML_INLINE | HTML_VALID,
418    },
419    HtmlElementInfo {
420        name: "map",
421        flags: HTML_INLINE | HTML_VALID,
422    },
423    HtmlElementInfo {
424        name: "nobr",
425        flags: HTML_INLINE | HTML_VALID,
426    },
427    HtmlElementInfo {
428        name: "object",
429        flags: HTML_INLINE | HTML_VALID,
430    },
431    HtmlElementInfo {
432        name: "q",
433        flags: HTML_INLINE | HTML_VALID,
434    },
435    HtmlElementInfo {
436        name: "rb",
437        flags: HTML_INLINE | HTML_VALID,
438    },
439    HtmlElementInfo {
440        name: "rbc",
441        flags: HTML_INLINE | HTML_VALID,
442    },
443    HtmlElementInfo {
444        name: "rp",
445        flags: HTML_INLINE | HTML_VALID,
446    },
447    HtmlElementInfo {
448        name: "rt",
449        flags: HTML_INLINE | HTML_VALID,
450    },
451    HtmlElementInfo {
452        name: "rtc",
453        flags: HTML_INLINE | HTML_VALID,
454    },
455    HtmlElementInfo {
456        name: "ruby",
457        flags: HTML_INLINE | HTML_VALID,
458    },
459    HtmlElementInfo {
460        name: "s",
461        flags: HTML_INLINE | HTML_VALID,
462    },
463    HtmlElementInfo {
464        name: "samp",
465        flags: HTML_INLINE | HTML_VALID,
466    },
467    HtmlElementInfo {
468        name: "select",
469        flags: HTML_INLINE | HTML_VALID,
470    },
471    HtmlElementInfo {
472        name: "small",
473        flags: HTML_INLINE | HTML_VALID,
474    },
475    HtmlElementInfo {
476        name: "span",
477        flags: HTML_INLINE | HTML_VALID,
478    },
479    HtmlElementInfo {
480        name: "strike",
481        flags: HTML_INLINE | HTML_VALID,
482    },
483    HtmlElementInfo {
484        name: "strong",
485        flags: HTML_INLINE | HTML_VALID,
486    },
487    HtmlElementInfo {
488        name: "sub",
489        flags: HTML_INLINE | HTML_VALID,
490    },
491    HtmlElementInfo {
492        name: "sup",
493        flags: HTML_INLINE | HTML_VALID,
494    },
495    HtmlElementInfo {
496        name: "textarea",
497        flags: HTML_INLINE | HTML_VALID,
498    },
499    HtmlElementInfo {
500        name: "tt",
501        flags: HTML_INLINE | HTML_VALID,
502    },
503    HtmlElementInfo {
504        name: "u",
505        flags: HTML_INLINE | HTML_VALID,
506    },
507    HtmlElementInfo {
508        name: "var",
509        flags: HTML_INLINE | HTML_VALID,
510    },
511    // Heading block elements (also block)
512    HtmlElementInfo {
513        name: "header",
514        flags: HTML_BLOCK | HTML_VALID,
515    },
516    HtmlElementInfo {
517        name: "footer",
518        flags: HTML_BLOCK | HTML_VALID,
519    },
520    HtmlElementInfo {
521        name: "nav",
522        flags: HTML_BLOCK | HTML_VALID,
523    },
524    HtmlElementInfo {
525        name: "article",
526        flags: HTML_BLOCK | HTML_VALID,
527    },
528    HtmlElementInfo {
529        name: "section",
530        flags: HTML_BLOCK | HTML_VALID,
531    },
532    HtmlElementInfo {
533        name: "aside",
534        flags: HTML_BLOCK | HTML_VALID,
535    },
536    HtmlElementInfo {
537        name: "main",
538        flags: HTML_BLOCK | HTML_VALID,
539    },
540    HtmlElementInfo {
541        name: "figure",
542        flags: HTML_BLOCK | HTML_VALID,
543    },
544    HtmlElementInfo {
545        name: "figcaption",
546        flags: HTML_BLOCK | HTML_VALID,
547    },
548    HtmlElementInfo {
549        name: "details",
550        flags: HTML_BLOCK | HTML_VALID,
551    },
552    HtmlElementInfo {
553        name: "summary",
554        flags: HTML_BLOCK | HTML_VALID,
555    },
556    // Script and style
557    HtmlElementInfo {
558        name: "script",
559        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
560    },
561    HtmlElementInfo {
562        name: "style",
563        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
564    },
565    HtmlElementInfo {
566        name: "title",
567        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
568    },
569];
570
571/// Case-insensitive lookup of an HTML element by name.
572/// Returns `None` if the element is not in the table (treated as unknown).
573fn html_tag_lookup(name: &str) -> Option<&'static HtmlElementInfo> {
574    // Allocation-free, case-insensitive classification (Phase 16.5.8): the
575    // previous implementation lowercased into a Vec<u8>, re-validated UTF-8,
576    // then linearly scanned the static table. HTML tag matching is
577    // ASCII-case-insensitive, so eq_ignore_ascii_case avoids the temporary.
578    HTML_ELEMENTS
579        .iter()
580        .find(|info| info.name.eq_ignore_ascii_case(name))
581}
582
583// ═══════════════════════════════════════════════════════════════════════════════
584// HTML Entity Handling
585// ═══════════════════════════════════════════════════════════════════════════════
586
587/// HTML named entities table. Maps entity names to their UTF-8 character(s).
588/// This is a subset of the full HTML entity list, matching what libxml2 supports.
589const HTML_ENTITIES: &[(&str, &str)] = &[
590    ("nbsp", "\u{00a0}"),
591    ("lt", "<"),
592    ("gt", ">"),
593    ("amp", "&"),
594    ("quot", "\""),
595    ("apos", "'"),
596    ("copy", "\u{00a9}"),
597    ("reg", "\u{00ae}"),
598    ("amp", "&"),
599    ("iexcl", "\u{00a1}"),
600    ("cent", "\u{00a2}"),
601    ("pound", "\u{00a3}"),
602    ("curren", "\u{00a4}"),
603    ("yen", "\u{00a5}"),
604    ("brvbar", "\u{00a6}"),
605    ("sect", "\u{00a7}"),
606    ("uml", "\u{00a8}"),
607    ("ordf", "\u{00aa}"),
608    ("laquo", "\u{00ab}"),
609    ("not", "\u{00ac}"),
610    ("shy", "\u{00ad}"),
611    ("macr", "\u{00ae}"),
612    ("deg", "\u{00b0}"),
613    ("plusmn", "\u{00b1}"),
614    ("sup2", "\u{00b2}"),
615    ("sup3", "\u{00b3}"),
616    ("acute", "\u{00b4}"),
617    ("micro", "\u{00b5}"),
618    ("para", "\u{00b6}"),
619    ("middot", "\u{00b7}"),
620    ("cedil", "\u{00b8}"),
621    ("sup1", "\u{00b9}"),
622    ("ordm", "\u{00ba}"),
623    ("raquo", "\u{00bb}"),
624    ("frac14", "\u{00bc}"),
625    ("frac12", "\u{00bd}"),
626    ("frac34", "\u{00be}"),
627    ("iquest", "\u{00bf}"),
628    ("times", "\u{00d7}"),
629    ("divide", "\u{00f7}"),
630    ("ETH", "\u{00d0}"),
631    ("eth", "\u{00f0}"),
632    ("THORN", "\u{00de}"),
633    ("thorn", "\u{00fe}"),
634    ("AElig", "\u{00c6}"),
635    ("aelig", "\u{00e6}"),
636    ("OElig", "\u{0152}"),
637    ("oelig", "\u{0153}"),
638    ("Scaron", "\u{0160}"),
639    ("scaron", "\u{0161}"),
640    ("Yuml", "\u{0178}"),
641    ("circ", "\u{02c6}"),
642    ("tilde", "\u{02dc}"),
643    ("ensp", "\u{2002}"),
644    ("emsp", "\u{2003}"),
645    ("thinsp", "\u{2009}"),
646    ("zwnj", "\u{200c}"),
647    ("zwj", "\u{200d}"),
648    ("lrm", "\u{200e}"),
649    ("rlm", "\u{200f}"),
650    ("ndash", "\u{2013}"),
651    ("mdash", "\u{2014}"),
652    ("lsquo", "\u{2018}"),
653    ("rsquo", "\u{2019}"),
654    ("sbquo", "\u{201a}"),
655    ("ldquo", "\u{201c}"),
656    ("rdquo", "\u{201d}"),
657    ("bdquo", "\u{201e}"),
658    ("dagger", "\u{2020}"),
659    ("Dagger", "\u{2021}"),
660    ("bull", "\u{2022}"),
661    ("hellip", "\u{2026}"),
662    ("permil", "\u{2030}"),
663    ("prime", "\u{2032}"),
664    ("Prime", "\u{2033}"),
665    ("lsaquo", "\u{2039}"),
666    ("rsaquo", "\u{203a}"),
667    ("oline", "\u{203e}"),
668    ("euro", "\u{20ac}"),
669    ("trade", "\u{2122}"),
670    ("larr", "\u{2190}"),
671    ("uarr", "\u{2191}"),
672    ("rarr", "\u{2192}"),
673    ("darr", "\u{2193}"),
674    ("harr", "\u{2194}"),
675    ("crarr", "\u{21b5}"),
676    ("lceil", "\u{2308}"),
677    ("rceil", "\u{2309}"),
678    ("lfloor", "\u{230a}"),
679    ("rfloor", "\u{230b}"),
680    ("loz", "\u{25ca}"),
681    ("spades", "\u{2660}"),
682    ("clubs", "\u{2663}"),
683    ("hearts", "\u{2665}"),
684    ("diams", "\u{2666}"),
685    ("Alpha", "\u{0391}"),
686    ("Beta", "\u{0392}"),
687    ("Gamma", "\u{0393}"),
688    ("Delta", "\u{0394}"),
689    ("Epsilon", "\u{0395}"),
690    ("Zeta", "\u{0396}"),
691    ("Eta", "\u{0397}"),
692    ("Theta", "\u{0398}"),
693    ("Iota", "\u{0399}"),
694    ("Kappa", "\u{039a}"),
695    ("Lambda", "\u{039b}"),
696    ("Mu", "\u{039c}"),
697    ("Nu", "\u{039d}"),
698    ("Xi", "\u{039e}"),
699    ("Omicron", "\u{039f}"),
700    ("Pi", "\u{03a0}"),
701    ("Rho", "\u{03a1}"),
702    ("Sigma", "\u{03a3}"),
703    ("Tau", "\u{03a4}"),
704    ("Upsilon", "\u{03a5}"),
705    ("Phi", "\u{03a6}"),
706    ("Chi", "\u{03a7}"),
707    ("Psi", "\u{03a8}"),
708    ("Omega", "\u{03a9}"),
709    ("alpha", "\u{03b1}"),
710    ("beta", "\u{03b2}"),
711    ("gamma", "\u{03b3}"),
712    ("delta", "\u{03b4}"),
713    ("epsilon", "\u{03b5}"),
714    ("zeta", "\u{03b6}"),
715    ("eta", "\u{03b7}"),
716    ("theta", "\u{03b8}"),
717    ("iota", "\u{03b9}"),
718    ("kappa", "\u{03ba}"),
719    ("lambda", "\u{03bb}"),
720    ("mu", "\u{03bc}"),
721    ("nu", "\u{03bd}"),
722    ("xi", "\u{03be}"),
723    ("omicron", "\u{03bf}"),
724    ("pi", "\u{03c0}"),
725    ("rho", "\u{03c1}"),
726    ("sigmaf", "\u{03c2}"),
727    ("sigma", "\u{03c3}"),
728    ("tau", "\u{03c4}"),
729    ("upsilon", "\u{03c5}"),
730    ("phi", "\u{03c6}"),
731    ("chi", "\u{03c7}"),
732    ("psi", "\u{03c8}"),
733    ("omega", "\u{03c9}"),
734    ("thetasym", "\u{03d1}"),
735    ("upsih", "\u{03d2}"),
736    ("piv", "\u{03d6}"),
737];
738
739/// Look up an HTML entity by name (without the leading '&').
740/// Returns the replacement string, or None if unknown.
741fn html_entity_lookup(name: &str) -> Option<&'static str> {
742    HTML_ENTITIES
743        .iter()
744        .find(|(n, _)| *n == name)
745        .map(|(_, v)| *v)
746}
747
748// ═══════════════════════════════════════════════════════════════════════════════
749// Parser Context
750// ═══════════════════════════════════════════════════════════════════════════════
751
752/// Internal HTML parser context.
753struct HtmlParserCtxt {
754    /// The document being built
755    doc: *mut _xmlDoc,
756    /// Current insertion point (current parent node)
757    current: *mut _xmlNode,
758    /// The html element (auto-created or parsed)
759    html: *mut _xmlNode,
760    /// The head element (auto-created or parsed)
761    head: *mut _xmlNode,
762    /// The body element (auto-created or parsed)
763    body: *mut _xmlNode,
764    /// Whether we're inside <head>
765    in_head: bool,
766    /// Whether we're inside <body>
767    in_body: bool,
768    /// Whether html has been seen/created
769    html_created: bool,
770    /// Whether head has been seen/created
771    head_created: bool,
772    /// Whether body has been seen/created
773    body_created: bool,
774    /// Whether we've seen body content (moves from head to body)
775    seen_body_content: bool,
776    /// Input buffer (the HTML source)
777    input: *mut u8,
778    /// Current position in input
779    input_pos: usize,
780    /// Total input length
781    input_len: usize,
782    /// Line number tracking
783    line: c_int,
784    /// Error flag
785    #[allow(dead_code)]
786    err: bool,
787    /// Filename (for file parsing)
788    filename: *mut c_char,
789    /// Encoding
790    encoding: *mut c_char,
791    /// Parse options (XML_PARSE_NOBLANKS etc., forwarded from the host
792    /// htmlParserCtxt by the exports).
793    options: c_int,
794}
795
796impl HtmlParserCtxt {
797    const fn new() -> Self {
798        HtmlParserCtxt {
799            doc: ptr::null_mut(),
800            current: ptr::null_mut(),
801            html: ptr::null_mut(),
802            head: ptr::null_mut(),
803            body: ptr::null_mut(),
804            in_head: false,
805            in_body: false,
806            html_created: false,
807            head_created: false,
808            body_created: false,
809            seen_body_content: false,
810            input: ptr::null_mut(),
811            input_pos: 0,
812            input_len: 0,
813            line: 1,
814            err: false,
815            filename: ptr::null_mut(),
816            encoding: ptr::null_mut(),
817            options: 0,
818        }
819    }
820
821    /// Peek at the next byte without consuming it.
822    ///
823    /// # Safety
824    ///
825    /// - `self.input` must be non-NULL and point to a valid allocation of at
826    ///   least `self.input_len` bytes; the read at `self.input_pos` is
827    ///   bounds-checked against `self.input_len` before dereferencing.
828    fn peek(&self) -> Option<u8> {
829        if self.input_pos < self.input_len {
830            unsafe { Some(*self.input.add(self.input_pos)) }
831        } else {
832            None
833        }
834    }
835
836    /// Peek ahead `n` bytes.
837    ///
838    /// # Safety
839    ///
840    /// - `self.input` must be non-NULL and point to a valid allocation of at
841    ///   least `self.input_len` bytes; the read at `self.input_pos + offset`
842    ///   is bounds-checked against `self.input_len` before dereferencing, so
843    ///   `offset` must not be large enough to overflow `usize` when added to
844    ///   `self.input_pos`.
845    fn peek_at(&self, offset: usize) -> Option<u8> {
846        let pos = self.input_pos + offset;
847        if pos < self.input_len {
848            unsafe { Some(*self.input.add(pos)) }
849        } else {
850            None
851        }
852    }
853
854    /// Consume and return the next byte.
855    ///
856    /// # Safety
857    ///
858    /// - `self.input` must be non-NULL and point to a valid allocation of at
859    ///   least `self.input_len` bytes; the read at `self.input_pos` is
860    ///   bounds-checked against `self.input_len` before dereferencing.
861    fn next(&mut self) -> Option<u8> {
862        if self.input_pos < self.input_len {
863            let ch = unsafe { *self.input.add(self.input_pos) };
864            self.input_pos += 1;
865            if ch == b'\n' {
866                self.line += 1;
867            }
868            Some(ch)
869        } else {
870            None
871        }
872    }
873
874    /// Skip bytes while the predicate returns true.
875    fn skip_while<F: Fn(u8) -> bool>(&mut self, f: F) {
876        while let Some(ch) = self.peek() {
877            if f(ch) {
878                self.next();
879            } else {
880                break;
881            }
882        }
883    }
884
885    /// Skip ASCII whitespace.
886    fn skip_whitespace(&mut self) {
887        self.skip_while(|ch| ch == b' ' || ch == b'\t' || ch == b'\n' || ch == b'\r');
888    }
889
890    /// Check if we've reached end of input.
891    const fn is_eof(&self) -> bool {
892        self.input_pos >= self.input_len
893    }
894
895    /// Read a sequence of bytes while the predicate returns true.
896    ///
897    /// # Safety
898    ///
899    /// - `self.input` must be a valid pointer to `self.input_len` readable
900    ///   bytes for the duration of the call; reads are bounds-checked against
901    ///   `self.input_pos`/`self.input_len` before each access, and the
902    ///   returned slice is copied out before `self` can mutate.
903    fn read_while<F: Fn(u8) -> bool>(&mut self, f: F) -> Vec<u8> {
904        let start = self.input_pos;
905        while let Some(ch) = self.peek() {
906            if f(ch) {
907                self.next();
908            } else {
909                break;
910            }
911        }
912        unsafe { slice::from_raw_parts(self.input.add(start), self.input_pos - start).to_vec() }
913    }
914}
915
916// ═══════════════════════════════════════════════════════════════════════════════
917// Auto-close Logic
918// ═══════════════════════════════════════════════════════════════════════════════
919
920/// Check if a tag name is a "heading" element (h1-h6).
921fn is_heading(name: &str) -> bool {
922    matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
923}
924
925/// Get the parent element of a node, walking up to find the nearest element.
926/// Returns the current node's parent if it's an element, or walks up.
927///
928/// # Safety
929///
930/// - `node` must be NULL or a valid pointer to an `_xmlNode`; every node
931///   reached through the `parent` link must also be a valid `_xmlNode`, and
932///   the parent chain must terminate in a NULL pointer (no cycles).
933#[allow(dead_code)]
934unsafe fn get_parent_element(node: *mut _xmlNode) -> *mut _xmlNode {
935    if node.is_null() {
936        return ptr::null_mut();
937    }
938    let mut n = node;
939    loop {
940        let parent = unsafe { (*n).parent };
941        if parent.is_null() {
942            return ptr::null_mut();
943        }
944        let ptype = unsafe { (*parent).type_ };
945        if ptype == XML_ELEMENT_NODE as c_int
946            || ptype == XML_HTML_DOCUMENT_NODE as c_int
947            || ptype == XML_DOCUMENT_NODE as c_int
948        {
949            return parent;
950        }
951        n = parent;
952    }
953}
954
955/// Auto-close elements that should be closed before opening a new tag.
956/// Returns the new current insertion point.
957///
958/// # Safety
959///
960/// - `ctxt` must be a valid `HtmlParserCtxt` whose `current` field is NULL or
961///   points into a well-formed node tree; every node reached through the
962///   `parent` link must be a valid `_xmlNode` and the chain must terminate in
963///   a NULL pointer (no cycles).
964/// - For every element node visited, `name` must be NULL or a valid
965///   NUL-terminated `xmlChar` string readable by `xmlstr_to_bytes`.
966unsafe fn auto_close_element(ctxt: &mut HtmlParserCtxt, tag_name: &str) {
967    let tag_lower: Vec<u8> = tag_name.bytes().map(|b| b.to_ascii_lowercase()).collect();
968    let tag_lower_str = match core::str::from_utf8(&tag_lower) {
969        Ok(s) => s,
970        Err(_) => return,
971    };
972
973    let info = html_tag_lookup(tag_lower_str);
974
975    let mut current = ctxt.current;
976
977    // Rule 1: <p> auto-closes before another <p>, and before block elements
978    if tag_lower_str == "p" || info.is_some_and(|i| i.flags & HTML_BLOCK != 0) {
979        // Close any open <p> elements
980        let mut cur2 = current;
981        while !cur2.is_null() {
982            let ctype = unsafe { (*cur2).type_ };
983            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
984                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
985                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
986                if name_str.eq_ignore_ascii_case("p") {
987                    // Close this <p> by moving current up past it
988                    current = unsafe { (*cur2).parent };
989                    break;
990                }
991            }
992            cur2 = unsafe { (*cur2).parent };
993        }
994    }
995
996    // Rule 2: Headings (h1-h6) auto-close other headings
997    if is_heading(tag_lower_str) {
998        let mut cur2 = current;
999        while !cur2.is_null() {
1000            let ctype = unsafe { (*cur2).type_ };
1001            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1002                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1003                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1004                if is_heading(name_str) {
1005                    current = unsafe { (*cur2).parent };
1006                    break;
1007                }
1008            }
1009            cur2 = unsafe { (*cur2).parent };
1010        }
1011    }
1012
1013    // Rule 3: <li> auto-closes another <li>
1014    if tag_lower_str == "li" {
1015        let mut cur2 = current;
1016        while !cur2.is_null() {
1017            let ctype = unsafe { (*cur2).type_ };
1018            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1019                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1020                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1021                if name_str.eq_ignore_ascii_case("li") {
1022                    current = unsafe { (*cur2).parent };
1023                    break;
1024                }
1025            }
1026            cur2 = unsafe { (*cur2).parent };
1027        }
1028    }
1029
1030    // Rule 4: <dt>/<dd> auto-close another <dt>/<dd>
1031    if tag_lower_str == "dt" || tag_lower_str == "dd" {
1032        let mut cur2 = current;
1033        while !cur2.is_null() {
1034            let ctype = unsafe { (*cur2).type_ };
1035            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1036                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1037                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1038                if name_str == "dt" || name_str == "dd" {
1039                    current = unsafe { (*cur2).parent };
1040                    break;
1041                }
1042            }
1043            cur2 = unsafe { (*cur2).parent };
1044        }
1045    }
1046
1047    // Rule 5: <tr> auto-closes the open row — the <tr> itself and any open
1048    // <td>/<th> cell (which is a child of that row); a new <td>/<th> only
1049    // auto-closes an open <td>/<th> (the previous cell) and stays inside the
1050    // open <tr> (upstream htmlAutoClose). The walk must NOT stop at a cell:
1051    // it has to keep climbing to the enclosing <tr> and close the whole row
1052    // (otherwise the new <tr> would nest inside the old one — corpus
1053    // html-table).
1054    if tag_lower_str == "tr" {
1055        let mut cur2 = current;
1056        while !cur2.is_null() {
1057            let ctype = unsafe { (*cur2).type_ };
1058            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1059                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1060                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1061                if name_str == "tr" {
1062                    current = unsafe { (*cur2).parent };
1063                    break;
1064                }
1065            }
1066            cur2 = unsafe { (*cur2).parent };
1067        }
1068    } else if tag_lower_str == "td" || tag_lower_str == "th" {
1069        let mut cur2 = current;
1070        while !cur2.is_null() {
1071            let ctype = unsafe { (*cur2).type_ };
1072            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1073                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1074                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1075                if name_str == "td" || name_str == "th" {
1076                    current = unsafe { (*cur2).parent };
1077                    break;
1078                }
1079            }
1080            cur2 = unsafe { (*cur2).parent };
1081        }
1082    }
1083
1084    // Rule 6: <thead>, <tbody>, <tfoot> auto-close each other
1085    if matches!(tag_lower_str, "thead" | "tbody" | "tfoot") {
1086        let mut cur2 = current;
1087        while !cur2.is_null() {
1088            let ctype = unsafe { (*cur2).type_ };
1089            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1090                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1091                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1092                if name_str == "thead" || name_str == "tbody" || name_str == "tfoot" {
1093                    current = unsafe { (*cur2).parent };
1094                    break;
1095                }
1096            }
1097            cur2 = unsafe { (*cur2).parent };
1098        }
1099    }
1100
1101    // Rule 7: <colgroup> auto-closes another <colgroup>
1102    if tag_lower_str == "colgroup" {
1103        let mut cur2 = current;
1104        while !cur2.is_null() {
1105            let ctype = unsafe { (*cur2).type_ };
1106            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1107                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1108                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1109                if name_str == "colgroup" {
1110                    current = unsafe { (*cur2).parent };
1111                    break;
1112                }
1113            }
1114            cur2 = unsafe { (*cur2).parent };
1115        }
1116    }
1117
1118    // Rule 8: <caption> auto-closes another <caption>
1119    if tag_lower_str == "caption" {
1120        let mut cur2 = current;
1121        while !cur2.is_null() {
1122            let ctype = unsafe { (*cur2).type_ };
1123            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1124                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1125                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1126                if name_str == "caption" {
1127                    current = unsafe { (*cur2).parent };
1128                    break;
1129                }
1130            }
1131            cur2 = unsafe { (*cur2).parent };
1132        }
1133    }
1134
1135    // Rule 9: <form> auto-closes another <form> (in libxml2 behavior)
1136    if tag_lower_str == "form" {
1137        let mut cur2 = current;
1138        while !cur2.is_null() {
1139            let ctype = unsafe { (*cur2).type_ };
1140            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1141                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1142                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1143                if name_str.eq_ignore_ascii_case("form") {
1144                    current = unsafe { (*cur2).parent };
1145                    break;
1146                }
1147            }
1148            cur2 = unsafe { (*cur2).parent };
1149        }
1150    }
1151
1152    ctxt.current = current;
1153}
1154
1155// ═══════════════════════════════════════════════════════════════════════════════
1156// Implicit Element Creation
1157// ═══════════════════════════════════════════════════════════════════════════════
1158
1159/// Ensure the html element exists, creating it implicitly if needed.
1160unsafe fn ensure_html(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1161    // UPSTREAM-PARITY (HTML_PARSE_NOIMPLIED): never auto-create the html
1162    // skeleton when the caller suppressed implied elements (bug76285).
1163    if ctxt.options & HTML_PARSE_NOIMPLIED != 0 {
1164        return ptr::null_mut();
1165    }
1166    if !ctxt.html.is_null() {
1167        return ctxt.html;
1168    }
1169
1170    let html_node = tree::new_node(ptr::null_mut(), b"html\0" as *const u8 as *const xmlChar);
1171    if html_node.is_null() {
1172        return ptr::null_mut();
1173    }
1174    {
1175        // Set HTML_IMPLIED flag concept - mark as auto-created
1176        // (We track this via a separate flag rather than modifying the node structure)
1177    }
1178    ctxt.html = html_node;
1179    ctxt.html_created = true;
1180
1181    // Add to document
1182    tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1183    ctxt.current = html_node;
1184
1185    html_node
1186}
1187
1188/// Ensure the head element exists, creating it implicitly if needed.
1189unsafe fn ensure_head(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1190    if ctxt.options & HTML_PARSE_NOIMPLIED != 0 {
1191        return ptr::null_mut();
1192    }
1193    if !ctxt.head.is_null() {
1194        return ctxt.head;
1195    }
1196
1197    // Ensure html exists first
1198    ensure_html(ctxt);
1199
1200    let head_node = tree::new_node(ptr::null_mut(), b"head\0" as *const u8 as *const xmlChar);
1201    if head_node.is_null() {
1202        return ptr::null_mut();
1203    }
1204    ctxt.head = head_node;
1205    ctxt.head_created = true;
1206
1207    // Add as child of html
1208    tree::add_child(ctxt.html, head_node);
1209    ctxt.current = head_node;
1210    ctxt.in_head = true;
1211
1212    head_node
1213}
1214
1215/// Ensure the body element exists, creating it implicitly if needed.
1216unsafe fn ensure_body(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1217    if ctxt.options & HTML_PARSE_NOIMPLIED != 0 {
1218        return ptr::null_mut();
1219    }
1220    if !ctxt.body.is_null() {
1221        return ctxt.body;
1222    }
1223
1224    // Ensure html exists first
1225    ensure_html(ctxt);
1226
1227    let body_node = tree::new_node(ptr::null_mut(), b"body\0" as *const u8 as *const xmlChar);
1228    if body_node.is_null() {
1229        return ptr::null_mut();
1230    }
1231    ctxt.body = body_node;
1232    ctxt.body_created = true;
1233
1234    // Add as child of html
1235    tree::add_child(ctxt.html, body_node);
1236    ctxt.current = body_node;
1237    ctxt.in_body = true;
1238
1239    body_node
1240}
1241
1242/// Transition from head to body when body content is encountered.
1243#[allow(dead_code)]
1244unsafe fn transition_to_body(ctxt: &mut HtmlParserCtxt) {
1245    if ctxt.in_head && !ctxt.seen_body_content {
1246        ctxt.seen_body_content = true;
1247        ctxt.in_head = false;
1248        ensure_body(ctxt);
1249    }
1250}
1251
1252// ═══════════════════════════════════════════════════════════════════════════════
1253// Tokenizer
1254// ═══════════════════════════════════════════════════════════════════════════════
1255
1256/// Result of parsing an attribute.
1257struct HtmlAttr {
1258    name: Vec<u8>,
1259    value: Vec<u8>,
1260    #[allow(dead_code)]
1261    quoted: bool,
1262}
1263
1264/// Parse an attribute name.
1265fn parse_attr_name(ctxt: &mut HtmlParserCtxt) -> Vec<u8> {
1266    let mut name = Vec::new();
1267    while let Some(ch) = ctxt.peek() {
1268        if ch == b'='
1269            || ch == b'>'
1270            || ch == b'/'
1271            || ch == b' '
1272            || ch == b'\t'
1273            || ch == b'\n'
1274            || ch == b'\r'
1275        {
1276            break;
1277        }
1278        name.push(ch);
1279        ctxt.next();
1280    }
1281    name
1282}
1283
1284/// Parse an attribute value (may be quoted or unquoted).
1285fn parse_attr_value(ctxt: &mut HtmlParserCtxt) -> (Vec<u8>, bool) {
1286    ctxt.skip_whitespace();
1287
1288    let quote = match ctxt.peek() {
1289        Some(b'"') => {
1290            ctxt.next(); // consume opening quote
1291            b'"'
1292        }
1293        Some(b'\'') => {
1294            ctxt.next(); // consume opening quote
1295            b'\''
1296        }
1297        _ => {
1298            // Unquoted value
1299            let value = ctxt.read_while(|ch| {
1300                ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1301            });
1302            return (value, false);
1303        }
1304    };
1305
1306    // Quoted value
1307    let mut value = Vec::new();
1308    loop {
1309        match ctxt.next() {
1310            Some(ch) if ch == quote => break,
1311            Some(ch) => value.push(ch),
1312            None => break,
1313        }
1314    }
1315    (value, true)
1316}
1317
1318/// Parse attributes until we hit '>' or end of tag.
1319fn parse_attributes(ctxt: &mut HtmlParserCtxt) -> Vec<HtmlAttr> {
1320    let mut attrs = Vec::new();
1321
1322    loop {
1323        ctxt.skip_whitespace();
1324
1325        match ctxt.peek() {
1326            Some(b'>') | None => break,
1327            Some(b'/')
1328                // Could be self-closing tag like <br/>
1329                if ctxt.peek_at(1) == Some(b'>') => {
1330                    break;
1331                }
1332                // Otherwise it's part of a minimized attribute or path
1333            _ => {}
1334        }
1335
1336        let name = parse_attr_name(ctxt);
1337        if name.is_empty() {
1338            break;
1339        }
1340
1341        // Check for '='
1342        ctxt.skip_whitespace();
1343        if ctxt.peek() == Some(b'=') {
1344            ctxt.next(); // consume '='
1345            let (value, quoted) = parse_attr_value(ctxt);
1346            attrs.push(HtmlAttr {
1347                name,
1348                value,
1349                quoted,
1350            });
1351        } else {
1352            // Minimized attribute (e.g., <option selected>)
1353            attrs.push(HtmlAttr {
1354                name,
1355                value: Vec::new(),
1356                quoted: false,
1357            });
1358        }
1359    }
1360
1361    attrs
1362}
1363
1364// ═══════════════════════════════════════════════════════════════════════════════
1365// Tree Builder
1366// ═══════════════════════════════════════════════════════════════════════════════
1367
1368/// Process a resolved HTML entity reference and return the replacement bytes.
1369fn resolve_entity(name: &str) -> Vec<u8> {
1370    if let Some(replacement) = html_entity_lookup(name) {
1371        replacement.as_bytes().to_vec()
1372    } else {
1373        // Unknown entity: leave as-is (pass through as text)
1374        let mut result = Vec::new();
1375        result.push(b'&');
1376        result.extend_from_slice(name.as_bytes());
1377        result.push(b';');
1378        result
1379    }
1380}
1381
1382/// Handle a numeric character reference (decimal or hex).
1383fn resolve_numeric_entity(value: &str, is_hex: bool) -> Vec<u8> {
1384    let codepoint = if is_hex {
1385        u32::from_str_radix(value, 16).unwrap_or(0xFFFD)
1386    } else {
1387        value.parse::<u32>().unwrap_or(0xFFFD)
1388    };
1389
1390    if codepoint == 0 {
1391        return Vec::new();
1392    }
1393
1394    // Convert codepoint to UTF-8
1395    match char::from_u32(codepoint) {
1396        Some(c) => {
1397            let mut buf = [0u8; 4];
1398            let s = c.encode_utf8(&mut buf);
1399            s.as_bytes().to_vec()
1400        }
1401        None => vec![0xEF, 0xBF, 0xBD], // replacement character
1402    }
1403}
1404
1405/// Parse an entity reference starting at current position (which points to '&').
1406/// Returns the replacement text and advances position.
1407fn parse_entity(ctxt: &mut HtmlParserCtxt) -> Vec<u8> {
1408    // We should be at '&'
1409    match ctxt.peek() {
1410        Some(b'&') => {
1411            ctxt.next(); // consume '&'
1412        }
1413        _ => return vec![b'&'],
1414    }
1415
1416    // Check for numeric entities
1417    if ctxt.peek() == Some(b'#') {
1418        ctxt.next(); // consume '#'
1419        let is_hex = ctxt.peek() == Some(b'x') || ctxt.peek() == Some(b'X');
1420        if is_hex {
1421            ctxt.next(); // consume 'x' or 'X'
1422        }
1423
1424        let digits = ctxt.read_while(|ch| {
1425            if is_hex {
1426                ch.is_ascii_hexdigit()
1427            } else {
1428                ch.is_ascii_digit()
1429            }
1430        });
1431
1432        let digits_str = core::str::from_utf8(&digits).unwrap_or("");
1433        if digits_str.is_empty() {
1434            let mut result = vec![b'&', b'#'];
1435            if is_hex {
1436                result.push(b'x');
1437            }
1438            return result;
1439        }
1440
1441        // Expect semicolon
1442        if ctxt.peek() == Some(b';') {
1443            ctxt.next();
1444        }
1445
1446        return resolve_numeric_entity(digits_str, is_hex);
1447    }
1448
1449    // Named entity
1450    let name = ctxt.read_while(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-');
1451    let name_str = core::str::from_utf8(&name).unwrap_or("");
1452
1453    // Expect semicolon
1454    if ctxt.peek() == Some(b';') {
1455        ctxt.next();
1456    }
1457
1458    resolve_entity(name_str)
1459}
1460
1461/// Handle text content in the tree builder.
1462///
1463/// # Safety
1464///
1465/// - `ctxt` must be a valid `HtmlParserCtxt` with a non-NULL `doc` pointing
1466///   to a valid `_xmlDoc`.
1467/// - The `head`, `body`, `html` and `current` fields, when non-NULL, must be
1468///   valid `_xmlNode` pointers owned by that document tree.
1469/// - Nodes returned by `tree::new_text` and `tree::new_node` are NULL-checked
1470///   before their fields are written, and `tree::add_child` requires a valid
1471///   non-NULL parent node.
1472
1473/// Create an element node from a heap-free byte-slice name.
1474///
1475/// `tree::new_node` DUPLICATES its name argument, so passing a freshly
1476/// `bytes_to_xmlstr`-allocated copy leaks it (ASan fuzz: ~5 bytes per element
1477/// on every HTML parse). This helper frees the temporary copy.
1478///
1479/// # Safety
1480///
1481/// - `name` is a valid byte slice for the call.
1482unsafe fn new_element_node(ns: *mut _xmlNs, name: &[u8]) -> *mut _xmlNode {
1483    let name_c = bytes_to_xmlstr(name);
1484    let node = tree::new_node(ns, name_c);
1485    if !name_c.is_null() {
1486        xmlFreeImpl(name_c as *mut c_void);
1487    }
1488    node
1489}
1490
1491/// Create a text node whose content is a byte slice.
1492///
1493/// `tree::new_text(NULL)` pre-allocates a 1-byte empty content placeholder;
1494/// assigning over it leaks that byte (ASan fuzz: 1 byte per text node). This
1495/// helper replaces the placeholder with the real (NUL-terminated) content.
1496///
1497/// # Safety
1498///
1499/// - `content` is a valid byte slice for the call.
1500unsafe fn new_text_node(content: &[u8]) -> *mut _xmlNode {
1501    let node = tree::new_text(ptr::null_mut());
1502    if node.is_null() {
1503        return ptr::null_mut();
1504    }
1505    let content_c = bytes_to_xmlstr(content);
1506    unsafe {
1507        if !(*node).content.is_null() {
1508            xmlFreeImpl((*node).content as *mut c_void);
1509        }
1510        // NULL only when bytes_to_xmlstr hit OOM.
1511        (*node).content = content_c;
1512    }
1513    node
1514}
1515
1516unsafe fn handle_text(ctxt: &mut HtmlParserCtxt, text: &[u8]) {
1517    if text.is_empty() {
1518        return;
1519    }
1520
1521    // Determine current insertion point
1522    let parent = if ctxt.in_head {
1523        ctxt.head
1524    } else if ctxt.in_body || ctxt.body_created {
1525        ctxt.body
1526    } else if ctxt.html_created {
1527        ctxt.html
1528    } else {
1529        ctxt.doc as *mut _xmlNode
1530    };
1531
1532    let insertion_point = if ctxt.current.is_null() {
1533        parent
1534    } else {
1535        ctxt.current
1536    };
1537
1538    if insertion_point.is_null() {
1539        // Fall back to document
1540        let text_node = new_text_node(text);
1541        if !text_node.is_null() {
1542            tree::add_child(ctxt.doc as *mut _xmlNode, text_node);
1543        }
1544        return;
1545    }
1546
1547    // UPSTREAM-PARITY: whitespace-only text at the document level (before or
1548    // after the root element) is discarded; non-whitespace stray text is
1549    // wrapped in a new `html` element (htmlParseCharData behavior), with
1550    // leading blank characters skipped.
1551    if insertion_point == ctxt.doc as *mut _xmlNode {
1552        if text.iter().all(|b| b.is_ascii_whitespace()) {
1553            return;
1554        }
1555        let content = trim_ascii_start(text);
1556        let html_node = new_element_node(ptr::null_mut(), b"html");
1557        if !html_node.is_null() {
1558            let text_node = new_text_node(content);
1559            if !text_node.is_null() {
1560                tree::add_child(html_node, text_node);
1561            }
1562            tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1563            ctxt.html = html_node;
1564            ctxt.html_created = true;
1565            ctxt.current = html_node;
1566        }
1567        return;
1568    }
1569
1570    let text_node = new_text_node(text);
1571    if text_node.is_null() {
1572        return;
1573    }
1574
1575    tree::add_child(insertion_point, text_node);
1576}
1577
1578/// Attach parsed HTML attributes to a freshly created element node.
1579///
1580/// # Safety
1581///
1582/// - `node` must be a valid element node; `attrs` a live slice of parsed
1583///   attributes.
1584unsafe fn attach_attrs(node: *mut _xmlNode, attrs: &[HtmlAttr]) {
1585    for attr in attrs {
1586        let name_c = bytes_to_xmlstr(&attr.name);
1587        let val_c = bytes_to_xmlstr(&attr.value);
1588        if !name_c.is_null() {
1589            tree::set_prop(node, name_c, val_c);
1590            xmlFreeImpl(name_c as *mut c_void);
1591            if !val_c.is_null() {
1592                xmlFreeImpl(val_c as *mut c_void);
1593            }
1594        }
1595    }
1596}
1597
1598/// Process a start tag in the tree builder.
1599unsafe fn handle_start_tag(ctxt: &mut HtmlParserCtxt, tag_name: &[u8], attrs: &[HtmlAttr]) {
1600    let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1601    let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1602
1603    let info = html_tag_lookup(tag_str);
1604
1605    // Determine tag category
1606    let is_head_tag = info.is_some_and(|i| i.flags & HTML_HEAD != 0);
1607    let _is_body_tag = info.is_some_and(|i| i.flags & HTML_BODY != 0);
1608    let is_empty = info.is_some_and(|i| i.flags & HTML_EMPTY != 0);
1609    let _is_block = info.is_some_and(|i| i.flags & HTML_BLOCK != 0);
1610
1611    // Handle special elements
1612    if tag_str == "html" {
1613        if !ctxt.html.is_null() && !ctxt.html_created {
1614            // Second <html> tag, skip it
1615            return;
1616        }
1617        // Create or use existing html
1618        if ctxt.html.is_null() {
1619            let html_node = new_element_node(ptr::null_mut(), tag_name);
1620            if !html_node.is_null() {
1621                attach_attrs(html_node, attrs);
1622                ctxt.html = html_node;
1623                ctxt.html_created = false; // parsed, not implied
1624                tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1625                ctxt.current = html_node;
1626            }
1627        } else {
1628            // html already auto-created, just set current
1629            ctxt.current = ctxt.html;
1630        }
1631        return;
1632    }
1633
1634    if tag_str == "head" {
1635        if !ctxt.head.is_null() && !ctxt.head_created {
1636            // Second <head> tag, skip it
1637            return;
1638        }
1639        // Ensure html exists
1640        ensure_html(ctxt);
1641
1642        if ctxt.head.is_null() {
1643            let head_node = new_element_node(ptr::null_mut(), tag_name);
1644            if !head_node.is_null() {
1645                attach_attrs(head_node, attrs);
1646                ctxt.head = head_node;
1647                ctxt.head_created = false;
1648                // UPSTREAM-PARITY: a source <head> without an <html> parent
1649                // becomes a top-level element under HTML_PARSE_NOIMPLIED.
1650                let parent = if ctxt.html.is_null() {
1651                    ctxt.doc as *mut _xmlNode
1652                } else {
1653                    ctxt.html
1654                };
1655                tree::add_child(parent, head_node);
1656                ctxt.current = head_node;
1657                ctxt.in_head = true;
1658            }
1659        } else {
1660            ctxt.current = ctxt.head;
1661            ctxt.in_head = true;
1662        }
1663        return;
1664    }
1665
1666    if tag_str == "body" {
1667        if !ctxt.body.is_null() && !ctxt.body_created {
1668            // Second <body> tag, skip it
1669            return;
1670        }
1671        // Ensure html exists
1672        ensure_html(ctxt);
1673
1674        if ctxt.body.is_null() {
1675            let body_node = new_element_node(ptr::null_mut(), tag_name);
1676            if !body_node.is_null() {
1677                attach_attrs(body_node, attrs);
1678                ctxt.body = body_node;
1679                ctxt.body_created = false;
1680                // UPSTREAM-PARITY: a source <body> without an <html> parent
1681                // becomes a top-level element under HTML_PARSE_NOIMPLIED.
1682                let parent = if ctxt.html.is_null() {
1683                    ctxt.doc as *mut _xmlNode
1684                } else {
1685                    ctxt.html
1686                };
1687                tree::add_child(parent, body_node);
1688                ctxt.current = body_node;
1689                ctxt.in_body = true;
1690                ctxt.in_head = false;
1691                ctxt.seen_body_content = true;
1692            }
1693        } else {
1694            ctxt.current = ctxt.body;
1695            ctxt.in_body = true;
1696            ctxt.in_head = false;
1697            ctxt.seen_body_content = true;
1698        }
1699        return;
1700    }
1701
1702    // For head-only elements (<title>, <meta>, <link>, <style>, <script>)
1703    if is_head_tag && !ctxt.seen_body_content {
1704        if ctxt.head.is_null() {
1705            ensure_head(ctxt);
1706        }
1707
1708        if is_empty {
1709            // Void element in head
1710            let node = new_element_node(ptr::null_mut(), tag_name);
1711            if !node.is_null() {
1712                attach_attrs(node, attrs);
1713                // UPSTREAM-PARITY (NOIMPLIED): top-level head-only elements
1714                // become document children.
1715                let ip = if ctxt.current.is_null() {
1716                    ctxt.doc as *mut _xmlNode
1717                } else {
1718                    ctxt.current
1719                };
1720                tree::add_child(ip, node);
1721            }
1722            return;
1723        }
1724
1725        let node = new_element_node(ptr::null_mut(), tag_name);
1726        if !node.is_null() {
1727            attach_attrs(node, attrs);
1728            let ip = if ctxt.current.is_null() {
1729                ctxt.doc as *mut _xmlNode
1730            } else {
1731                ctxt.current
1732            };
1733            tree::add_child(ip, node);
1734            ctxt.current = node;
1735        }
1736        return;
1737    }
1738
1739    // Body content - transition from head if needed
1740    if !is_head_tag || ctxt.seen_body_content {
1741        if !ctxt.seen_body_content {
1742            ctxt.seen_body_content = true;
1743            ctxt.in_head = false;
1744            // UPSTREAM-PARITY (HTML_PARSE_NOIMPLIED): body content at the top
1745            // of a no-implied parse stays at document level (ctxt.current
1746            // NULL) instead of materialising the html/body skeleton.
1747            if ctxt.options & HTML_PARSE_NOIMPLIED != 0 {
1748                // current stays NULL -> top-level nodes attach to the doc
1749            } else if ctxt.body.is_null() {
1750                ensure_body(ctxt);
1751            } else {
1752                ctxt.current = ctxt.body;
1753                ctxt.in_body = true;
1754            }
1755        } else if ctxt.body.is_null() && ctxt.options & HTML_PARSE_NOIMPLIED == 0 {
1756            ensure_body(ctxt);
1757        }
1758    }
1759
1760    // Auto-close elements as needed
1761    if !ctxt.current.is_null() {
1762        auto_close_element(ctxt, tag_str);
1763    }
1764
1765    if is_empty {
1766        // Void element: create node, add attributes, add as child (no children)
1767        let node = new_element_node(ptr::null_mut(), tag_name);
1768        if !node.is_null() {
1769            for attr in attrs {
1770                let name_c = bytes_to_xmlstr(&attr.name);
1771                let val_c = bytes_to_xmlstr(&attr.value);
1772                if !name_c.is_null() {
1773                    tree::set_prop(node, name_c, val_c);
1774                    xmlFreeImpl(name_c as *mut c_void);
1775                    if !val_c.is_null() {
1776                        xmlFreeImpl(val_c as *mut c_void);
1777                    }
1778                }
1779            }
1780            let insertion_point = if ctxt.current.is_null() {
1781                if ctxt.options & HTML_PARSE_NOIMPLIED != 0 {
1782                    ctxt.doc as *mut _xmlNode
1783                } else {
1784                    ctxt.body
1785                }
1786            } else {
1787                ctxt.current
1788            };
1789            if !insertion_point.is_null() {
1790                tree::add_child(insertion_point, node);
1791            }
1792        }
1793        return;
1794    }
1795
1796    // Regular element
1797    let node = new_element_node(ptr::null_mut(), tag_name);
1798    if !node.is_null() {
1799        for attr in attrs {
1800            let name_c = bytes_to_xmlstr(&attr.name);
1801            let val_c = bytes_to_xmlstr(&attr.value);
1802            if !name_c.is_null() {
1803                tree::set_prop(node, name_c, val_c);
1804                xmlFreeImpl(name_c as *mut c_void);
1805                if !val_c.is_null() {
1806                    xmlFreeImpl(val_c as *mut c_void);
1807                }
1808            }
1809        }
1810
1811        let insertion_point = if ctxt.current.is_null() {
1812            if ctxt.in_body || ctxt.body_created {
1813                ctxt.body
1814            } else if ctxt.in_head || ctxt.head_created {
1815                ctxt.head
1816            } else if ctxt.html_created {
1817                ctxt.html
1818            } else {
1819                ctxt.doc as *mut _xmlNode
1820            }
1821        } else {
1822            ctxt.current
1823        };
1824
1825        if !insertion_point.is_null() {
1826            tree::add_child(insertion_point, node);
1827            // For non-void elements, this becomes the new insertion point
1828            ctxt.current = node;
1829        }
1830    }
1831}
1832
1833/// Process an end tag in the tree builder.
1834///
1835/// # Safety
1836///
1837/// - `ctxt` must be a valid `HtmlParserCtxt` whose `current` field is NULL or
1838///   points into a well-formed node tree; every node reached through the
1839///   `parent` link must be a valid `_xmlNode` and the chain must terminate in
1840///   a NULL pointer (no cycles).
1841/// - Element `name` pointers visited must be NULL or valid NUL-terminated
1842///   `xmlChar` strings.
1843unsafe fn handle_end_tag(ctxt: &mut HtmlParserCtxt, tag_name: &[u8]) {
1844    let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1845    let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1846
1847    let info = html_tag_lookup(tag_str);
1848
1849    // For elements with no end tag (void elements or optional end tags),
1850    // just ignore the end tag.
1851    if info.is_some_and(|i| i.flags & HTML_EMPTY != 0) {
1852        return;
1853    }
1854
1855    if tag_str == "html" {
1856        ctxt.current = ctxt.doc as *mut _xmlNode;
1857        return;
1858    }
1859
1860    if tag_str == "head" {
1861        ctxt.in_head = false;
1862        ctxt.current = ctxt.html;
1863        return;
1864    }
1865
1866    if tag_str == "body" {
1867        ctxt.in_body = false;
1868        ctxt.current = ctxt.html;
1869        return;
1870    }
1871
1872    // Walk up the tree to find a matching open element
1873    let mut cur = ctxt.current;
1874    while !cur.is_null() {
1875        let ctype = unsafe { (*cur).type_ };
1876        if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur).name.is_null() } {
1877            let name_bytes = unsafe { xmlstr_to_bytes((*cur).name) };
1878            if name_bytes.eq_ignore_ascii_case(tag_name) {
1879                // Found the matching element - close by moving current to parent
1880                ctxt.current = unsafe { (*cur).parent };
1881                return;
1882            }
1883        }
1884        cur = unsafe { (*cur).parent };
1885    }
1886
1887    // If no matching element found, ignore the end tag (tag-recovery behavior)
1888}
1889
1890// ═══════════════════════════════════════════════════════════════════════════════
1891// Main Parse Function
1892// ═══════════════════════════════════════════════════════════════════════════════
1893
1894/// Convert `data` from the declared input encoding to UTF-8.
1895///
1896/// UPSTREAM-PARITY: `xmlCtxtNewInputFromMemory` -> `xmlSwitchInputEncoding`
1897/// installs a decoder on the input buffer so the parser (and the tree it
1898/// builds) always consume UTF-8. Returns `None` when no conversion applies
1899/// (NULL encoding, UTF-8/ASCII input, or an encoding the crate cannot
1900/// convert — the caller then parses the raw bytes, matching upstream's
1901/// recover-with-raw-bytes behavior when switching fails).
1902fn convert_input_to_utf8(encoding: *const c_char, data: &[u8]) -> Option<Vec<u8>> {
1903    if encoding.is_null() {
1904        return None;
1905    }
1906    let name = unsafe { core::ffi::CStr::from_ptr(encoding).to_bytes() };
1907    match crate::xml::encoding::encoding_from_name(name) {
1908        xmlCharEncoding::XML_CHAR_ENCODING_8859_1 => {
1909            Some(crate::xml::encoding::latin1_to_utf8(data))
1910        }
1911        xmlCharEncoding::XML_CHAR_ENCODING_UTF16LE => {
1912            crate::xml::encoding::utf16le_to_utf8(data).ok()
1913        }
1914        xmlCharEncoding::XML_CHAR_ENCODING_UTF16BE => {
1915            crate::xml::encoding::utf16be_to_utf8(data).ok()
1916        }
1917        // UTF-32/UCS-4 (R-000157): lxml feeds PEP-393 KIND-4 python strings to
1918        // htmlCtxtReadMemory as UTF-32LE/BE raw buffers; without conversion the
1919        // parser would consume the raw 4-byte units as UTF-8 and garble the
1920        // tree (wide-character HTML through etree.HTML crashed teardown).
1921        xmlCharEncoding::XML_CHAR_ENCODING_UCS4LE => {
1922            crate::xml::encoding::ucs4le_to_utf8(data).ok()
1923        }
1924        xmlCharEncoding::XML_CHAR_ENCODING_UCS4BE => {
1925            crate::xml::encoding::ucs4be_to_utf8(data).ok()
1926        }
1927        // UTF-8 / US-ASCII / NONE (and unsupported encodings): no conversion.
1928        _ => None,
1929    }
1930}
1931
1932/// Parse a leading `<!DOCTYPE ...>` declaration from HTML input bytes.
1933///
1934/// Returns `Some((name, external_id, system_id))` when the input begins (after
1935/// optional whitespace) with a case-insensitive `<!DOCTYPE`. Quoted external
1936/// and system identifiers are unwrapped; unquoted identifiers are taken as-is
1937/// up to the closing `>`. Returns `None` when no DOCTYPE is declared.
1938fn parse_html_doctype_decl(input: &[u8]) -> Option<(Vec<u8>, Option<Vec<u8>>, Option<Vec<u8>>)> {
1939    let mut i = 0usize;
1940    while i < input.len() && input[i].is_ascii_whitespace() {
1941        i += 1;
1942    }
1943    if i + 2 >= input.len() || input[i] != b'<' || !(input[i + 1] == b'!') {
1944        return None;
1945    }
1946    let kw = b"DOCTYPE";
1947    if !input.get(i + 2..i + 2 + kw.len()).is_some_and(|s| {
1948        s.iter()
1949            .enumerate()
1950            .all(|(k, b)| b.to_ascii_uppercase() == kw[k])
1951    }) {
1952        return None;
1953    }
1954    i += 2 + kw.len();
1955    // Skip whitespace after DOCTYPE, then read the root name.
1956    while i < input.len() && input[i].is_ascii_whitespace() {
1957        i += 1;
1958    }
1959    let name_start = i;
1960    while i < input.len() && !input[i].is_ascii_whitespace() && input[i] != b'>' {
1961        i += 1;
1962    }
1963    // A nameless `<!DOCTYPE>` (nothing before the closing '>') is still a
1964    // declared DOCTYPE — upstream htmlParseDocTypeDecl fires internalSubset
1965    // with a NULL name (gh17500/bug78025: doctype->name is ""), it must NOT
1966    // fall through to the default HTML 4.0 DTD.
1967    let name = if name_start == i {
1968        Vec::new()
1969    } else {
1970        input[name_start..i].to_vec()
1971    };
1972    // If the nameless doctype ends right at '>', there are no ids either.
1973    if name.is_empty() {
1974        return Some((name, None, None));
1975    }
1976    // Skip whitespace, then optional PUBLIC/SYSTEM id.
1977    let mut ext: Option<Vec<u8>> = None;
1978    let mut sys: Option<Vec<u8>> = None;
1979    while i < input.len() && input[i].is_ascii_whitespace() {
1980        i += 1;
1981    }
1982    if i < input.len() && input[i] != b'>' {
1983        // a PUBLIC/SYSTEM keyword may follow
1984        let word_start = i;
1985        while i < input.len() && input[i].is_ascii_alphabetic() {
1986            i += 1;
1987        }
1988        let word = input[word_start..i].to_ascii_uppercase();
1989        if word == b"PUBLIC" {
1990            while i < input.len() && input[i].is_ascii_whitespace() {
1991                i += 1;
1992            }
1993            // external identifier (PUBLIC) comes first
1994            if i < input.len() && (input[i] == b'"' || input[i] == b'\'') {
1995                let q = input[i];
1996                i += 1;
1997                let v_start = i;
1998                while i < input.len() && input[i] != q {
1999                    i += 1;
2000                }
2001                ext = Some(input[v_start..i].to_vec());
2002                if i < input.len() {
2003                    i += 1;
2004                }
2005            }
2006            while i < input.len() && input[i].is_ascii_whitespace() {
2007                i += 1;
2008            }
2009            // system literal (may be absent)
2010            if i < input.len()
2011                && i + 1 < input.len()
2012                && (input[i] == b'"' || input[i] == b'\'')
2013                && input[i] != b'>'
2014            {
2015                let q = input[i];
2016                i += 1;
2017                let v_start = i;
2018                while i < input.len() && input[i] != q {
2019                    i += 1;
2020                }
2021                sys = Some(input[v_start..i].to_vec());
2022            }
2023        } else if word == b"SYSTEM" {
2024            while i < input.len() && input[i].is_ascii_whitespace() {
2025                i += 1;
2026            }
2027            if i < input.len() && (input[i] == b'"' || input[i] == b'\'') {
2028                let q = input[i];
2029                i += 1;
2030                let v_start = i;
2031                while i < input.len() && input[i] != q {
2032                    i += 1;
2033                }
2034                sys = Some(input[v_start..i].to_vec());
2035            }
2036        }
2037    }
2038    Some((name, ext, sys))
2039}
2040
2041/// Parse HTML from a buffer.
2042///
2043/// # Safety
2044///
2045/// - `buffer` must point to valid memory of at least `size` bytes.
2046unsafe fn html_parse_buffer(
2047    ctxt: &mut HtmlParserCtxt,
2048    buffer: *const c_char,
2049    size: c_int,
2050) -> *mut _xmlDoc {
2051    if buffer.is_null() || size <= 0 {
2052        return ptr::null_mut();
2053    }
2054
2055    // Create document with HTML_DOCUMENT_NODE type
2056    let doc = tree::new_doc(ptr::null());
2057    if doc.is_null() {
2058        return ptr::null_mut();
2059    }
2060    unsafe {
2061        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2062        // UPSTREAM-PARITY: HTML documents carry no version (htmlNewDocNoDtD
2063        // leaves version NULL), so drop the XML default set by new_doc.
2064        if !(*doc).version.is_null() {
2065            crate::abi::allocator::xmlFreeImpl((*doc).version as *mut c_void);
2066        }
2067        (*doc).version = ptr::null_mut();
2068        // UPSTREAM-PARITY (SAX2.c xmlSAX2StartDocument for HTML parsers): an
2069        // html-parsed document carries properties = XML_DOC_HTML. The
2070        // pre-fix XML_DOC_WELLFORMED-only value made PHP's spec serializer
2071        // treat html-parsed documents as XML and drop the standalone/HTML
2072        // declaration handling (ext/dom dom005 / gh15670 / gh17397 — the
2073        // saveXML of a loadHTML'd document lost "standalone=yes").
2074        (*doc).properties = crate::abi::types::xmlDocProperties::XML_DOC_HTML as c_int;
2075        // UPSTREAM-PARITY: HTML documents default to standalone="yes"
2076        // (visible when serialized with the XML serializer, e.g. --xmlout).
2077        (*doc).standalone = 1;
2078    }
2079    // UPSTREAM-PARITY: htmlParseDocument honours a DOCTYPE declared in the
2080    // source (htmlParseDocTypeDecl fills doc->intSubset with the declared
2081    // name/public/system ids); only when the source declares none does it
2082    // create the default HTML 4.0 DTD. nokogiri reads those ids back for
2083    // dtd.html_dtd?/html5_dtd?, so a source `<!DOCTYPE html>` must NOT pick up
2084    // the default HTML 4.0 DTD.
2085    let raw_input = unsafe { slice::from_raw_parts(buffer as *const u8, size as usize) };
2086    if let Some((name, ext, sys)) = parse_html_doctype_decl(raw_input) {
2087        // UPSTREAM-PARITY (htmlSAX2InternalSubset / xmlCreateIntSubset): a
2088        // declared DOCTYPE without a name creates the internal subset with a
2089        // NULL name (php doctype->name reads back "").
2090        let name_cstr = if name.is_empty() {
2091            ptr::null()
2092        } else {
2093            crate::xml::string::bytes_to_xmlstr(&name)
2094        };
2095        let ext_cstr = ext
2096            .map(|s| crate::xml::string::bytes_to_xmlstr(&s))
2097            .unwrap_or(ptr::null_mut());
2098        let sys_cstr = sys
2099            .map(|s| crate::xml::string::bytes_to_xmlstr(&s))
2100            .unwrap_or(ptr::null_mut());
2101        unsafe {
2102            crate::xml::dtd::create_int_subset(
2103                doc,
2104                name_cstr as *const xmlChar,
2105                ext_cstr as *const xmlChar,
2106                sys_cstr as *const xmlChar,
2107            );
2108        }
2109        if !name_cstr.is_null() {
2110            unsafe { crate::abi::allocator::xmlFreeImpl(name_cstr as *mut c_void) };
2111        }
2112        if !ext_cstr.is_null() {
2113            unsafe { crate::abi::allocator::xmlFreeImpl(ext_cstr as *mut c_void) };
2114        }
2115        if !sys_cstr.is_null() {
2116            unsafe { crate::abi::allocator::xmlFreeImpl(sys_cstr as *mut c_void) };
2117        }
2118    } else {
2119        // Source declares no DOCTYPE: use the default HTML 4.0 DTD — unless
2120        // HTML_PARSE_NODEFDTD suppresses it (php LIBXML_HTML_NODEFDTD).
2121        if ctxt.options & HTML_PARSE_NODEFDTD == 0 {
2122            unsafe {
2123                crate::xml::dtd::create_int_subset(
2124                    doc,
2125                    b"html\0" as *const u8 as *const xmlChar,
2126                    b"-//W3C//DTD HTML 4.0 Transitional//EN\0" as *const u8 as *const xmlChar,
2127                    b"http://www.w3.org/TR/REC-html40/loose.dtd\0" as *const u8 as *const xmlChar,
2128                );
2129            }
2130        }
2131    }
2132    ctxt.doc = doc;
2133
2134    // UPSTREAM-PARITY: a declared input encoding is converted to UTF-8 before
2135    // parsing (xmlCtxtNewInputFromMemory -> xmlSwitchInputEncodingName installs
2136    // an input-buffer decoder); the parse loop and the tree always consume
2137    // UTF-8. The converted copy lives for the whole parse.
2138    let converted: Option<Vec<u8>> = if !ctxt.encoding.is_null() {
2139        let raw = unsafe { slice::from_raw_parts(buffer as *const u8, size as usize) };
2140        convert_input_to_utf8(ctxt.encoding, raw)
2141    } else {
2142        None
2143    };
2144    let (input_ptr, input_len): (*const u8, usize) = match &converted {
2145        Some(v) => (v.as_ptr(), v.len()),
2146        None => (buffer as *const u8, size as usize),
2147    };
2148
2149    // Set up input
2150    ctxt.input = input_ptr as *mut u8;
2151    ctxt.input_len = input_len;
2152    ctxt.input_pos = 0;
2153    ctxt.line = 1;
2154
2155    // Main parse loop
2156    loop {
2157        if ctxt.is_eof() {
2158            break;
2159        }
2160
2161        let ch = ctxt.peek().unwrap_or(0);
2162
2163        if ch == b'<' {
2164            ctxt.next(); // consume '<'
2165
2166            // Check for </ (end tag)
2167            if ctxt.peek() == Some(b'/') {
2168                ctxt.next(); // consume '/'
2169                let tag_name = ctxt.read_while(|ch| {
2170                    ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
2171                });
2172
2173                // Consume until '>'
2174                while ctxt.peek() != Some(b'>') && !ctxt.is_eof() {
2175                    ctxt.next();
2176                }
2177                if ctxt.peek() == Some(b'>') {
2178                    ctxt.next(); // consume '>'
2179                }
2180
2181                if !tag_name.is_empty() {
2182                    handle_end_tag(ctxt, &tag_name);
2183                }
2184                continue;
2185            }
2186
2187            // Check for <!-- (comment)
2188            if ctxt.peek() == Some(b'!')
2189                && ctxt.peek_at(1) == Some(b'-')
2190                && ctxt.peek_at(2) == Some(b'-')
2191            {
2192                ctxt.next(); // consume '!'
2193                ctxt.next(); // consume '-'
2194                ctxt.next(); // consume '-'
2195
2196                // Read until -->
2197                let mut comment_content = Vec::new();
2198                loop {
2199                    if ctxt.peek() == Some(b'-')
2200                        && ctxt.peek_at(1) == Some(b'-')
2201                        && ctxt.peek_at(2) == Some(b'>')
2202                    {
2203                        ctxt.next(); // consume '-'
2204                        ctxt.next(); // consume '-'
2205                        ctxt.next(); // consume '>'
2206                        break;
2207                    }
2208                    match ctxt.next() {
2209                        Some(ch) => comment_content.push(ch),
2210                        None => break,
2211                    }
2212                }
2213
2214                // Create comment node
2215                if !comment_content.is_empty() {
2216                    // new_comment duplicates its content; free the temporary.
2217                    let cc = bytes_to_xmlstr(&comment_content);
2218                    let comment_node = tree::new_comment(cc);
2219                    if !cc.is_null() {
2220                        xmlFreeImpl(cc as *mut c_void);
2221                    }
2222                    if !comment_node.is_null() {
2223                        let insertion_point = if !ctxt.current.is_null() {
2224                            ctxt.current
2225                        } else {
2226                            ctxt.doc as *mut _xmlNode
2227                        };
2228                        tree::add_child(insertion_point, comment_node);
2229                    }
2230                }
2231                continue;
2232            }
2233
2234            // Check for <!DOCTYPE
2235            if ctxt.peek() == Some(b'!') {
2236                ctxt.next(); // consume '!'
2237                let _rest = ctxt.read_while(|ch| ch != b'>');
2238                if ctxt.peek() == Some(b'>') {
2239                    ctxt.next(); // consume '>'
2240                }
2241                // We don't create a DTD node from HTML DOCTYPE in this implementation
2242                // (matching basic libxml2 behavior where HTML doctype is mostly ignored)
2243                continue;
2244            }
2245
2246            // Check for <? (processing instruction)
2247            if ctxt.peek() == Some(b'?') {
2248                ctxt.next(); // consume '?'
2249                             // Read until we see ?>
2250                let mut pi_content = Vec::new();
2251                loop {
2252                    if ctxt.peek() == Some(b'?') && ctxt.peek_at(1) == Some(b'>') {
2253                        break;
2254                    }
2255                    match ctxt.next() {
2256                        Some(ch) => pi_content.push(ch),
2257                        None => break,
2258                    }
2259                }
2260                // Consume ?>
2261                if ctxt.peek() == Some(b'?') {
2262                    ctxt.next();
2263                }
2264                if ctxt.peek() == Some(b'>') {
2265                    ctxt.next();
2266                }
2267                // Create PI node
2268                if !pi_content.is_empty() {
2269                    // Split into target and value
2270                    let mut parts = pi_content.splitn(2, |b| *b == b' ');
2271                    let target = parts.next().unwrap_or(&pi_content);
2272                    let value = parts.next().unwrap_or(b"");
2273
2274                    // new_pi duplicates both arguments; free the temporaries.
2275                    let t_c = bytes_to_xmlstr(target);
2276                    let v_c = bytes_to_xmlstr(value);
2277                    let pi_node = tree::new_pi(t_c, v_c);
2278                    if !t_c.is_null() {
2279                        xmlFreeImpl(t_c as *mut c_void);
2280                    }
2281                    if !v_c.is_null() {
2282                        xmlFreeImpl(v_c as *mut c_void);
2283                    }
2284                    if !pi_node.is_null() {
2285                        let insertion_point = if !ctxt.current.is_null() {
2286                            ctxt.current
2287                        } else {
2288                            ctxt.doc as *mut _xmlNode
2289                        };
2290                        tree::add_child(insertion_point, pi_node);
2291                    }
2292                }
2293                continue;
2294            }
2295
2296            // Parse start tag
2297            let tag_name = ctxt.read_while(|ch| {
2298                ch != b'>' && ch != b'/' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
2299            });
2300
2301            if tag_name.is_empty() {
2302                // Just a bare '<' with no tag name, treat as text
2303                handle_text(ctxt, b"<");
2304                continue;
2305            }
2306
2307            // Parse attributes
2308            let attrs = parse_attributes(ctxt);
2309
2310            // Check for self-closing (/>) or just >
2311            if ctxt.peek() == Some(b'/') {
2312                ctxt.next(); // consume '/'
2313                if ctxt.peek() == Some(b'>') {
2314                    ctxt.next(); // consume '>'
2315                }
2316            } else if ctxt.peek() == Some(b'>') {
2317                ctxt.next(); // consume '>'
2318            }
2319
2320            // Check if this is a raw text element (script, style)
2321            let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
2322            let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
2323
2324            if tag_str == "script" || tag_str == "style" {
2325                // Handle raw text content
2326                // Create the element first
2327                let raw_node = new_element_node(ptr::null_mut(), &tag_name);
2328                if !raw_node.is_null() {
2329                    for attr in &attrs {
2330                        let name_c = bytes_to_xmlstr(&attr.name);
2331                        let val_c = bytes_to_xmlstr(&attr.value);
2332                        if !name_c.is_null() {
2333                            tree::set_prop(raw_node, name_c, val_c);
2334                            xmlFreeImpl(name_c as *mut c_void);
2335                            if !val_c.is_null() {
2336                                xmlFreeImpl(val_c as *mut c_void);
2337                            }
2338                        }
2339                    }
2340
2341                    let insertion_point = if ctxt.current.is_null() {
2342                        if ctxt.in_head {
2343                            ensure_head(ctxt);
2344                            ctxt.head
2345                        } else {
2346                            ensure_body(ctxt);
2347                            ctxt.body
2348                        }
2349                    } else {
2350                        ctxt.current
2351                    };
2352
2353                    if !insertion_point.is_null() {
2354                        tree::add_child(insertion_point, raw_node);
2355
2356                        // Read raw text until matching </script> or </style>
2357                        let end_tag = format!("</{}", tag_str);
2358                        let end_bytes = end_tag.as_bytes();
2359                        let mut raw_text = Vec::new();
2360                        // Potential end-tag prefix chars ("</script") are
2361                        // buffered separately: on a full match they are
2362                        // DISCARDED (the content ends before the '<' —
2363                        // upstream script-content ends at the '<' that starts
2364                        // the close tag), on a mismatch they are flushed back
2365                        // into the text.
2366                        let mut match_buf: Vec<u8> = Vec::new();
2367                        let mut match_idx = 0;
2368
2369                        loop {
2370                            if ctxt.is_eof() {
2371                                break;
2372                            }
2373                            let ch = ctxt.peek().unwrap();
2374                            if ch.to_ascii_lowercase() == end_bytes[match_idx] {
2375                                match_buf.push(ch);
2376                                match_idx += 1;
2377                                ctxt.next();
2378                                if match_idx == end_bytes.len() {
2379                                    // We found the start of </tag
2380                                    // Add the text before the end tag
2381                                    if !raw_text.is_empty() {
2382                                        let text_node = new_text_node(&raw_text);
2383                                        if !text_node.is_null() {
2384                                            tree::add_child(raw_node, text_node);
2385                                        }
2386                                    }
2387                                    // Consume the rest of the end tag: "tag>"
2388                                    // Now read "tag>"
2389                                    let _suffix = ctxt.read_while(|ch| ch != b'>');
2390                                    if ctxt.peek() == Some(b'>') {
2391                                        ctxt.next();
2392                                    }
2393                                    // Close the element
2394                                    ctxt.current = unsafe { (*raw_node).parent };
2395                                    break;
2396                                }
2397                            } else {
2398                                // Mismatch: flush any buffered end-tag prefix
2399                                // chars back into the text, then the current
2400                                // char.
2401                                if match_idx > 0 {
2402                                    raw_text.extend_from_slice(&match_buf);
2403                                    match_buf.clear();
2404                                    match_idx = 0;
2405                                }
2406                                raw_text.push(ch);
2407                                ctxt.next();
2408                            }
2409                        }
2410
2411                        // If we never found the end tag, add the text (plus
2412                        // any buffered end-tag prefix chars).
2413                        if match_idx < end_bytes.len() {
2414                            raw_text.extend_from_slice(&match_buf);
2415                            if !raw_text.is_empty() {
2416                                let text_node = new_text_node(&raw_text);
2417                                if !text_node.is_null() {
2418                                    tree::add_child(raw_node, text_node);
2419                                }
2420                            }
2421                            ctxt.current = unsafe { (*raw_node).parent };
2422                        }
2423                    }
2424                }
2425                continue;
2426            }
2427
2428            // Regular start tag
2429            handle_start_tag(ctxt, &tag_name, &attrs);
2430        } else {
2431            // Text content - read until next '<' or entity '&'
2432            let mut text = Vec::new();
2433            loop {
2434                match ctxt.peek() {
2435                    Some(b'<') => break,
2436                    Some(b'&') => {
2437                        // Handle entity reference inline
2438                        let entity_text = parse_entity(ctxt);
2439                        text.extend_from_slice(&entity_text);
2440                    }
2441                    Some(0) => {
2442                        // UPSTREAM-PARITY (bug #80268, libxml2 >= 2.9.12): NUL
2443                        // bytes in HTML content are DROPPED and parsing
2444                        // continues — they must neither terminate the text
2445                        // node (truncating at the NUL) nor reach the tree
2446                        // (content is C-string storage).
2447                        ctxt.next();
2448                    }
2449                    Some(ch) => {
2450                        text.push(ch);
2451                        ctxt.next();
2452                    }
2453                    None => break,
2454                }
2455            }
2456
2457            if !text.is_empty() {
2458                handle_text(ctxt, &text);
2459            }
2460        }
2461    }
2462
2463    // Post-processing: ensure html/head/body are created even for empty
2464    // documents (never under HTML_PARSE_NOIMPLIED — the source tree is kept
2465    // as-is, bug76285).
2466    if ctxt.html.is_null() && ctxt.options & HTML_PARSE_NOIMPLIED == 0 {
2467        ensure_html(ctxt);
2468    }
2469
2470    // UPSTREAM-PARITY (xmlSAX2Characters with XML_PARSE_NOBLANKS):
2471    // whitespace-only text runs are reported as ignorableWhitespace and never
2472    // become text nodes. The candidate builds them eagerly, so drop them here
2473    // (ext/dom dom005's loadHTMLFile(…, LIBXML_NOBLANKS) serialization kept
2474    // the <head>-region newline text nodes the oracle drops).
2475    if ctxt.options & (crate::abi::types::XML_PARSE_NOBLANKS as c_int) != 0 && !doc.is_null() {
2476        unsafe {
2477            drop_blank_text_nodes((*doc).children);
2478        }
2479    }
2480
2481    // UPSTREAM-PARITY (SAX2.c xmlSAX2StartElementNs / xmlSAX2AttributeNs):
2482    // ID-bearing attributes (the HTML `id` attribute, `<a name>` and any
2483    // DTD-declared ID) are registered in doc->ids as they are parsed. The
2484    // html tree builder attaches attributes BEFORE a node gains its document
2485    // pointer (add_child propagates doc later), so the per-attribute
2486    // registration cannot run at attribute time — do a tree-order pass once
2487    // the document is complete (first registration wins, matching the
2488    // in-order xmlAddID calls of a SAX2 parse). This is what makes
2489    // DOMDocument::loadHTML + getElementById / HTMLCollection named lookups
2490    // see `id="…"` attributes.
2491    if !doc.is_null() {
2492        unsafe {
2493            register_html_ids(doc, (*doc).children);
2494        }
2495    }
2496
2497    doc
2498}
2499
2500/// Register ID/IDREF attributes of every element in the sibling chain and
2501/// their element descendants (tree order, first registration wins).
2502///
2503/// # Safety
2504///
2505/// - `doc` must be a valid `_xmlDoc` (HTML type) whose tree stays alive for
2506///   the call; `cur` must be NULL or a valid live node chain within `doc`.
2507unsafe fn register_html_ids(doc: *mut _xmlDoc, cur: *mut _xmlNode) {
2508    let mut n = cur;
2509    while !n.is_null() {
2510        let t = unsafe { (*n).type_ };
2511        if t == XML_ELEMENT_NODE as c_int {
2512            let el = n;
2513            let mut attr = unsafe { (*el).properties };
2514            while !attr.is_null() {
2515                if unsafe { (*attr).id }.is_null()
2516                    && !unsafe { (*attr).children }.is_null()
2517                    && unsafe { (*(*attr).children).type_ } == XML_TEXT_NODE as c_int
2518                    && unsafe { (*(*attr).children).next }.is_null()
2519                {
2520                    let v = unsafe { (*(*attr).children).content };
2521                    if !v.is_null() {
2522                        let id_res = crate::xml::validation::is_id(doc, el, attr);
2523                        if id_res > 0 {
2524                            crate::xml::validation::add_id(ptr::null_mut(), doc, v, attr);
2525                        } else if crate::xml::validation::is_ref(doc, el, attr) > 0 {
2526                            crate::xml::validation::add_ref(ptr::null_mut(), doc, v, attr);
2527                        }
2528                    }
2529                }
2530                attr = unsafe { (*attr).next };
2531            }
2532            if !unsafe { (*el).children }.is_null() {
2533                register_html_ids(doc, unsafe { (*el).children });
2534            }
2535        }
2536        n = unsafe { (*n).next };
2537    }
2538}
2539
2540/// Unlink and free every whitespace-only text node in the sibling chain and
2541/// their element descendants (upstream html parse + XML_PARSE_NOBLANKS).
2542///
2543/// # Safety
2544///
2545/// - `cur` must be NULL or a valid live `_xmlNode` chain (element/text/…)
2546///   inside the document being cleaned.
2547unsafe fn drop_blank_text_nodes(cur: *mut _xmlNode) {
2548    let mut n = cur;
2549    while !n.is_null() {
2550        let next = unsafe { (*n).next };
2551        let t = unsafe { (*n).type_ };
2552        if t == XML_TEXT_NODE as c_int {
2553            let content = unsafe { (*n).content };
2554            let blank = if content.is_null() {
2555                true
2556            } else {
2557                let mut p = content;
2558                while unsafe { *p } != 0 {
2559                    match unsafe { *p } {
2560                        b' ' | b'\t' | b'\r' | b'\n' => {}
2561                        _ => break,
2562                    }
2563                    p = unsafe { p.add(1) };
2564                }
2565                (unsafe { *p }) == 0
2566            };
2567            if blank {
2568                tree::unlink_node(n);
2569                tree::free_node(n);
2570            }
2571        } else if t == XML_ELEMENT_NODE as c_int && !unsafe { (*n).children }.is_null() {
2572            drop_blank_text_nodes(unsafe { (*n).children });
2573        }
2574        n = next;
2575    }
2576}
2577
2578// ═══════════════════════════════════════════════════════════════════════════════
2579// Public API Functions
2580// ═══════════════════════════════════════════════════════════════════════════════
2581
2582/// Parse HTML from a file.
2583///
2584/// # UPSTREAM-PARITY
2585///
2586/// Equivalent to `htmlParseFile` in libxml2.
2587///
2588/// # Safety
2589///
2590/// - `filename` must be a valid null-terminated C string or NULL.
2591/// - `encoding` must be a valid null-terminated C string or NULL.
2592pub unsafe fn parse_file(
2593    filename: *const c_char,
2594    encoding: *const c_char,
2595    options: c_int,
2596) -> *mut _xmlDoc {
2597    if filename.is_null() {
2598        return ptr::null_mut();
2599    }
2600
2601    // Read the file into memory
2602    let filename_str = unsafe { std::ffi::CStr::from_ptr(filename) };
2603    let path = filename_str.to_str().unwrap_or("");
2604    let content = match std::fs::read(path) {
2605        Ok(data) => data,
2606        Err(_) => return ptr::null_mut(),
2607    };
2608
2609    let mut ctxt = HtmlParserCtxt::new();
2610    ctxt.options = options;
2611    if !encoding.is_null() {
2612        let _enc_cstr = unsafe { std::ffi::CStr::from_ptr(encoding) };
2613        ctxt.encoding = unsafe { c_strdup(encoding) };
2614    }
2615
2616    let doc = unsafe {
2617        html_parse_buffer(
2618            &mut ctxt,
2619            content.as_ptr() as *const c_char,
2620            content.len() as c_int,
2621        )
2622    };
2623
2624    if !doc.is_null() && !filename.is_null() {
2625        unsafe {
2626            (*doc).URL = c_strdup(filename) as *mut xmlChar;
2627        }
2628    }
2629
2630    doc
2631}
2632
2633/// Parse HTML from a memory buffer.
2634///
2635/// # UPSTREAM-PARITY
2636///
2637/// Equivalent to `htmlParseMemory` in libxml2.
2638///
2639/// # Safety
2640///
2641/// - `buffer` must point to valid memory of at least `size` bytes.
2642/// - `size` must be non-negative.
2643pub unsafe fn parse_memory(buffer: *const c_char, size: c_int) -> *mut _xmlDoc {
2644    unsafe { parse_memory_enc(buffer, size, ptr::null(), 0) }
2645}
2646
2647/// Parse HTML from a memory buffer with an explicit input encoding.
2648///
2649/// UPSTREAM-PARITY: equivalent to `htmlCtxtReadMemory` where the caller's
2650/// `encoding` is wired into the input buffer (`xmlCtxtNewInputFromMemory` ->
2651/// `xmlSwitchInputEncodingName` installs a decoder so the parse loop always
2652/// consumes UTF-8). NULL means no conversion (BOM sniffing only).
2653///
2654/// # Safety
2655///
2656/// - `buffer` must point to valid memory of at least `size` bytes.
2657/// - `size` must be non-negative.
2658/// - `encoding` must be a valid NUL-terminated C string or NULL.
2659pub(crate) unsafe fn parse_memory_enc(
2660    buffer: *const c_char,
2661    size: c_int,
2662    encoding: *const c_char,
2663    options: c_int,
2664) -> *mut _xmlDoc {
2665    if buffer.is_null() || size <= 0 {
2666        return ptr::null_mut();
2667    }
2668
2669    let mut ctxt = HtmlParserCtxt::new();
2670    ctxt.options = options;
2671    if !encoding.is_null() {
2672        ctxt.encoding = unsafe { c_strdup(encoding) };
2673    }
2674    unsafe { html_parse_buffer(&mut ctxt, buffer, size) }
2675}
2676
2677/// Parse HTML from a null-terminated string.
2678///
2679/// # UPSTREAM-PARITY
2680///
2681/// Equivalent to `htmlParseDoc` in libxml2.
2682///
2683/// # Safety
2684///
2685/// - `cur` must be a valid null-terminated xmlChar string or NULL.
2686/// - `encoding` must be a valid null-terminated C string or NULL.
2687pub(crate) unsafe fn parse_doc(
2688    cur: *const xmlChar,
2689    encoding: *const c_char,
2690    options: c_int,
2691) -> *mut _xmlDoc {
2692    if cur.is_null() {
2693        return ptr::null_mut();
2694    }
2695
2696    let len = unsafe { xml_strlen(cur) };
2697    let mut ctxt = HtmlParserCtxt::new();
2698    ctxt.options = options;
2699    if !encoding.is_null() {
2700        ctxt.encoding = unsafe { c_strdup(encoding) };
2701    }
2702
2703    unsafe { html_parse_buffer(&mut ctxt, cur as *const c_char, len as c_int) }
2704}
2705
2706/// Create an HTML parser context for file parsing.
2707///
2708/// # UPSTREAM-PARITY
2709///
2710/// Equivalent to `htmlCreateFileParserCtxt` in libxml2.
2711///
2712/// # Safety
2713///
2714/// - `filename` must be a valid null-terminated C string or NULL.
2715/// - `encoding` must be a valid null-terminated C string or NULL.
2716#[allow(dead_code)]
2717pub(crate) unsafe fn create_file_parser_ctxt(
2718    filename: *const c_char,
2719    encoding: *const c_char,
2720) -> *mut c_void {
2721    if filename.is_null() {
2722        return ptr::null_mut();
2723    }
2724
2725    // Host allocation (R-00019x): real C-visible `_xmlParserCtxt` at offset 0
2726    // followed by the engine state; freed as one block by `free_parser_ctxt`.
2727    let total = size_of::<_xmlParserCtxt>() + size_of::<HtmlParserCtxt>();
2728    let mem = unsafe { xmlMallocZero(total) } as *mut u8;
2729    if mem.is_null() {
2730        return ptr::null_mut();
2731    }
2732
2733    let ctxt = mem.add(size_of::<_xmlParserCtxt>()) as *mut HtmlParserCtxt;
2734    unsafe {
2735        ptr::write(ctxt, HtmlParserCtxt::new());
2736        if !encoding.is_null() {
2737            (*ctxt).encoding = c_strdup(encoding);
2738        }
2739        (*(mem as *mut _xmlParserCtxt)).html = 1;
2740    }
2741
2742    mem as *mut c_void
2743}
2744
2745/// Free an HTML parser context.
2746///
2747/// # UPSTREAM-PARITY
2748///
2749/// Equivalent to `htmlFreeParserCtxt` in libxml2.
2750///
2751/// # Safety
2752///
2753/// - `ctxt` must be a valid pointer returned by `create_file_parser_ctxt`, or NULL.
2754pub(crate) unsafe fn free_parser_ctxt(ctxt: *mut c_void) {
2755    if ctxt.is_null() {
2756        return;
2757    }
2758
2759    // Host allocation (R-00019x): a real C-visible `_xmlParserCtxt` at
2760    // offset 0 with the engine state (`HtmlParserCtxt`) after it. Free the
2761    // engine's owned input buffer and strings, then the single host block.
2762    let state = (ctxt as *mut u8).add(size_of::<_xmlParserCtxt>()) as *mut HtmlParserCtxt;
2763    unsafe {
2764        if !(*state).input.is_null() {
2765            xmlFreeImpl((*state).input as *mut c_void);
2766        }
2767        if !(*state).filename.is_null() {
2768            xmlFreeImpl((*state).filename as *mut c_void);
2769        }
2770        if !(*state).encoding.is_null() {
2771            xmlFreeImpl((*state).encoding as *mut c_void);
2772        }
2773        xmlFreeImpl(ctxt);
2774    }
2775}
2776
2777/// Initialize the HTML parser module.
2778///
2779/// # UPSTREAM-PARITY
2780///
2781/// Equivalent to `htmlInitParser` in libxml2.
2782#[allow(dead_code)]
2783pub(crate) const fn init_parser() {
2784    // Currently a no-op. In the future, may initialize HTML-specific
2785    // entity tables or other global state.
2786}
2787
2788/// Cleanup the HTML parser module.
2789///
2790/// # UPSTREAM-PARITY
2791///
2792/// Equivalent to `htmlCleanupParser` in libxml2.
2793#[allow(dead_code)]
2794pub(crate) const fn cleanup_parser() {
2795    // Currently a no-op. In the future, may free HTML-specific
2796    // global state.
2797}
2798
2799/// Create a new HTML document.
2800///
2801/// # UPSTREAM-PARITY
2802///
2803/// Equivalent to `htmlNewDoc` in libxml2.
2804///
2805/// Creates a new HTML document (type XML_HTML_DOCUMENT_NODE) WITHOUT any
2806/// implicit html/head/body skeleton. Upstream `htmlNewDoc`/`htmlNewDocNoDtD`
2807/// create only the document (the HTML parser grows the html/head/body
2808/// elements lazily); eagerly seeding the skeleton would insert a real
2809/// `<html>` root that diverges from upstream and breaks consumers like
2810/// nokogiri's `HTML4::Document.new` builder (a later `<b>` add would look
2811/// like a second root).
2812///
2813/// # SAFETY
2814///
2815/// - `version` must be a valid null-terminated xmlChar string or NULL.
2816pub(crate) unsafe fn new_doc(version: *const xmlChar) -> *mut _xmlDoc {
2817    let doc = tree::new_doc(version);
2818    if doc.is_null() {
2819        return ptr::null_mut();
2820    }
2821
2822    unsafe {
2823        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2824        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2825    }
2826
2827    doc
2828}
2829
2830/// Create a new HTML document without DTD.
2831///
2832/// # UPSTREAM-PARITY
2833///
2834/// Equivalent to `htmlNewDocNoDtD` in libxml2.
2835///
2836/// Creates a new document with type XML_HTML_DOCUMENT_NODE.
2837/// Unlike `htmlNewDoc`, this does NOT auto-create html/head/body elements.
2838///
2839/// # Safety
2840///
2841/// - `version` must be a valid null-terminated xmlChar string or NULL.
2842pub(crate) unsafe fn new_doc_no_dtd(version: *const xmlChar) -> *mut _xmlDoc {
2843    let doc = tree::new_doc(version);
2844    if doc.is_null() {
2845        return ptr::null_mut();
2846    }
2847
2848    unsafe {
2849        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2850        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2851    }
2852
2853    doc
2854}
2855
2856// ═══════════════════════════════════════════════════════════════════════════════
2857// HTML Serializer
2858// ═══════════════════════════════════════════════════════════════════════════════
2859
2860/// HTML void elements that should not have closing tags.
2861const HTML_VOID_ELEMENTS: &[&str] = &[
2862    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
2863    "track", "wbr", "frame",
2864];
2865
2866/// Check if an element name is an HTML void element.
2867fn is_html_void(name: &str) -> bool {
2868    HTML_VOID_ELEMENTS
2869        .iter()
2870        .any(|v| v.eq_ignore_ascii_case(name))
2871}
2872
2873/// Check if an element has optional end tag in HTML.
2874#[allow(dead_code)]
2875fn has_optional_end_tag(name: &str) -> bool {
2876    matches!(
2877        name.to_ascii_lowercase().as_str(),
2878        "p" | "li" | "tr" | "td" | "th" | "dt" | "dd"
2879    )
2880}
2881
2882/// Serialize a text node for HTML output.
2883///
2884/// In HTML serialization, we escape `<` and `&` but NOT non-ASCII characters
2885/// as numeric entities (unlike XML serialization).
2886/// Write a double-quoted C string to the buffer.
2887unsafe fn html_write_quoted(buf: *mut _xmlBuffer, s: *const xmlChar) {
2888    if buf.is_null() || s.is_null() {
2889        return;
2890    }
2891    io::buf_ccat(buf, b'"');
2892    io::buf_cat(buf, s);
2893    io::buf_ccat(buf, b'"');
2894}
2895
2896/// Trim leading ASCII whitespace.
2897fn trim_ascii_start(s: &[u8]) -> &[u8] {
2898    let start = s
2899        .iter()
2900        .position(|&b| !b.is_ascii_whitespace())
2901        .unwrap_or(s.len());
2902    &s[start..]
2903}
2904
2905/// Write text content into an HTML output buffer, escaping `&`, `<` and
2906/// `>` (upstream htmlTreeDumpText escapes `>` to `&gt;` as well — the corpus
2907/// `ser-methods` html method expects `&lt;x&gt;`, not `&lt;x>`).
2908///
2909/// # Safety
2910///
2911/// - `buf` must be non-NULL and point to a valid `_xmlBuffer` writable via
2912///   `io::buf_add`.
2913/// - `content` must be non-NULL and readable for at least `len` bytes; `len`
2914///   is required to be positive (checked) and the loop reads exactly `len`
2915///   bytes starting at `content`.
2916unsafe fn html_serialize_text(buf: *mut _xmlBuffer, content: *const xmlChar, len: c_int) {
2917    if buf.is_null() || content.is_null() || len <= 0 {
2918        return;
2919    }
2920
2921    let mut i: c_int = 0;
2922    while i < len {
2923        let ch = unsafe { *content.add(i as usize) };
2924
2925        match ch {
2926            b'<' => {
2927                io::buf_add(buf, b"&lt;" as *const u8, 4);
2928            }
2929            b'&' => {
2930                io::buf_add(buf, b"&amp;" as *const u8, 5);
2931            }
2932            b'>' => {
2933                io::buf_add(buf, b"&gt;" as *const u8, 4);
2934            }
2935            _ => {
2936                io::buf_add(buf, &ch as *const u8, 1);
2937            }
2938        }
2939        i += 1;
2940    }
2941}
2942
2943/// Serialize an attribute value for HTML output.
2944///
2945/// In HTML, attribute values should be quoted and have `&`, `"` escaped.
2946///
2947/// # Safety
2948///
2949/// - `buf` must be non-NULL and point to a valid `_xmlBuffer` writable via
2950///   `io::buf_add`.
2951/// - `value` must be non-NULL and point to a valid NUL-terminated `xmlChar`
2952///   string; `xml_strlen` scans it up to the terminator.
2953unsafe fn html_serialize_attr_value(buf: *mut _xmlBuffer, value: *const xmlChar) {
2954    if buf.is_null() || value.is_null() {
2955        return;
2956    }
2957
2958    let len = unsafe { xml_strlen(value) as c_int };
2959    let mut i: c_int = 0;
2960    while i < len {
2961        let ch = unsafe { *value.add(i as usize) };
2962
2963        match ch {
2964            b'&' => {
2965                io::buf_add(buf, b"&amp;" as *const u8, 5);
2966            }
2967            b'"' => {
2968                io::buf_add(buf, b"&quot;" as *const u8, 6);
2969            }
2970            _ => {
2971                io::buf_add(buf, &ch as *const u8, 1);
2972            }
2973        }
2974        i += 1;
2975    }
2976}
2977
2978/// HTML-specific node serialization.
2979///
2980/// Walks the node tree and serializes to HTML format.
2981/// Differs from XML serialization in several ways:
2982/// - No XML declaration for HTML documents
2983/// - No self-closing tags for void elements
2984/// - Case-insensitive tag names preserved as-is
2985/// - No namespace declarations
2986/// - Elements with optional end tags may omit them
2987///
2988/// Whether the head element already contains a <meta> element (so the
2989///
2990/// serializer does not insert a duplicate charset declaration).
2991///
2992/// # SAFETY
2993///
2994/// - `child` must be a valid node or NULL.
2995unsafe fn html_head_has_meta(child: *mut _xmlNode) -> bool {
2996    let mut c = child;
2997    while !c.is_null() {
2998        if (*c).type_ == XML_ELEMENT_NODE as c_int && !(*c).name.is_null() {
2999            let nm = xmlstr_to_bytes((*c).name);
3000            if nm.eq_ignore_ascii_case(b"meta") {
3001                return true;
3002            }
3003        }
3004        c = (*c).next;
3005    }
3006    false
3007}
3008
3009/// Serialize a node tree to HTML output.
3010///
3011/// # Safety
3012///
3013/// - `node` must be non-NULL and point to a valid `_xmlNode` in a well-formed
3014///   tree; the `children`, `next`, `parent`, `properties` and `doc` links
3015///   walked here must be NULL-terminated and point to valid objects.
3016/// - `buf` must be non-NULL and point to a valid `_xmlBuffer`.
3017/// - `name`, `content` and `encoding` fields must be NULL or valid
3018///   NUL-terminated `xmlChar` strings.
3019pub(crate) unsafe fn serialize_node(
3020    node: *mut _xmlNode,
3021    buf: *mut _xmlBuffer,
3022    format: c_int,
3023    level: c_int,
3024) {
3025    unsafe { serialize_node_enc(node, buf, format, level, None) }
3026}
3027
3028/// Serialize an HTML node with an optional output-encoding parameter
3029/// (upstream `htmlNodeDumpInternal`'s `encoding` argument).
3030///
3031/// Upstream inserts a `<meta charset=...>` in the root `<head>` ONLY when
3032/// this `encoding` parameter is non-NULL (htmlDocDumpMemoryFormat and the
3033/// lxml `tostring(method="html")` path pass NULL and never insert one);
3034/// `htmlSaveFileFormat` passes the caller's encoding string.
3035///
3036/// # Safety
3037///
3038/// - `node` must be NULL or a valid `_xmlNode`; `buf` a valid `_xmlBuffer`.
3039pub(crate) unsafe fn serialize_node_enc(
3040    node: *mut _xmlNode,
3041    buf: *mut _xmlBuffer,
3042    format: c_int,
3043    level: c_int,
3044    encoding: Option<&[u8]>,
3045) {
3046    if node.is_null() || buf.is_null() {
3047        return;
3048    }
3049
3050    let n = unsafe { &*node };
3051
3052    match n.type_ {
3053        t if t == XML_ELEMENT_NODE as c_int => {
3054            let name = if n.name.is_null() {
3055                ""
3056            } else {
3057                unsafe { core::str::from_utf8(xmlstr_to_bytes(n.name)).unwrap_or("") }
3058            };
3059
3060            let is_void = is_html_void(name);
3061            // UPSTREAM-PARITY: htmlNodeDumpInternal only adds formatting
3062            // newlines for non-inline elements; p, pre and param are never
3063            // formatted (name[0] == 'p'), and unknown elements are treated
3064            // as inline (info == NULL).
3065            let info = html_tag_lookup(name);
3066            let is_inline = info.is_none_or(|i| i.flags & HTML_INLINE != 0);
3067            let no_format = is_inline || name.starts_with('p');
3068
3069            // Write start tag
3070            io::buf_ccat(buf, b'<');
3071            // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpOutputInternal): an
3072            // element with a bound prefix writes `prefix:name`, and its
3073            // local namespace declarations (nsDef) are dumped right after
3074            // the name — namespaced trees (lxml.html.html5parser's XHTML
3075            // tree) serialize with their namespace declarations.
3076            if !n.ns.is_null() && !(*n.ns).prefix.is_null() {
3077                io::buf_cat(buf, (*n.ns).prefix);
3078                io::buf_ccat(buf, b':');
3079            }
3080            if !n.name.is_null() {
3081                io::buf_cat(buf, n.name);
3082            }
3083            if !n.nsDef.is_null() {
3084                let mut ns = n.nsDef;
3085                while !ns.is_null() {
3086                    let nsp = unsafe { &*ns };
3087                    // UPSTREAM-PARITY (xmlsave.c xmlNsDumpOutput): only
3088                    // LOCAL namespaces with a URI are written; the "xml"
3089                    // prefix declaration is skipped.
3090                    let is_xml = !nsp.prefix.is_null() && xmlstr_to_bytes(nsp.prefix) == b"xml";
3091                    if nsp.type_ == XML_LOCAL_NAMESPACE as c_int && !nsp.href.is_null() && !is_xml {
3092                        io::buf_ccat(buf, b' ');
3093                        if nsp.prefix.is_null() {
3094                            io::buf_add(buf, b"xmlns=\"" as *const u8, 7);
3095                        } else {
3096                            io::buf_add(buf, b"xmlns:" as *const u8, 6);
3097                            io::buf_cat(buf, nsp.prefix);
3098                            io::buf_add(buf, b"=\"" as *const u8, 2);
3099                        }
3100                        html_serialize_attr_value(buf, nsp.href);
3101                        io::buf_ccat(buf, b'\"');
3102                    }
3103                    ns = nsp.next;
3104                }
3105            }
3106
3107            // Write attributes
3108            let mut attr = n.properties;
3109            while !attr.is_null() {
3110                let a = unsafe { &*attr };
3111                io::buf_ccat(buf, b' ');
3112                if !a.name.is_null() {
3113                    io::buf_cat(buf, a.name);
3114                }
3115
3116                // Write attribute value if present
3117                if !a.children.is_null() {
3118                    let child = unsafe { &*a.children };
3119                    if child.type_ == XML_TEXT_NODE as c_int && !child.content.is_null() {
3120                        io::buf_ccat(buf, b'=');
3121                        io::buf_ccat(buf, b'"');
3122                        html_serialize_attr_value(buf, child.content);
3123                        io::buf_ccat(buf, b'"');
3124                    }
3125                }
3126
3127                attr = a.next;
3128            }
3129
3130            // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpInternal): inserts
3131            // <meta charset="..."> as the first child of the <head> of the
3132            // root <html> element when no <meta> is present AND the caller
3133            // passed an explicit output `encoding` parameter (the doc-dump
3134            // path passes NULL and inserts nothing). The meta is synthetic
3135            // here, so it participates in the formatting rules like a real
3136            // child.
3137            let mut meta_bytes: Option<Vec<u8>> = None;
3138            if name.eq_ignore_ascii_case("head") && level == 1 {
3139                if let Some(enc) = encoding {
3140                    let parent_is_html = !n.parent.is_null() && !(*n.parent).name.is_null() && {
3141                        let pn =
3142                            core::str::from_utf8(xmlstr_to_bytes((*n.parent).name)).unwrap_or("");
3143                        pn.eq_ignore_ascii_case("html")
3144                    };
3145                    if parent_is_html && !html_head_has_meta(n.children) {
3146                        meta_bytes = Some(enc.to_vec());
3147                    }
3148                }
3149            }
3150            let meta_inserted = meta_bytes.is_some();
3151
3152            let has_children = !n.children.is_null();
3153            let first_child = if has_children {
3154                unsafe { (*n.children).type_ }
3155            } else {
3156                XML_TEXT_NODE as c_int
3157            };
3158            let first_is_text = first_child == XML_TEXT_NODE as c_int
3159                || first_child == XML_ENTITY_REF_NODE as c_int;
3160            // With a synthetic meta child, an empty head behaves as having
3161            // one element child.
3162            let multi_child = (has_children && n.children != n.last) || meta_inserted;
3163
3164            if is_void {
3165                // Void element: just close the tag, no children
3166                io::buf_ccat(buf, b'>');
3167                // UPSTREAM-PARITY (line 997): a newline follows a non-inline
3168                // element whose next sibling is not text; the caller (parent
3169                // loop) emits it, so nothing here.
3170            } else {
3171                // Element with children (or a head receiving a meta)
3172                io::buf_ccat(buf, b'>');
3173
3174                // Newline after the open tag (upstream line 969): a
3175                // non-inline element whose first child is not text and which
3176                // has more than one child (or receives a meta) starts its
3177                // content on a new line.
3178                if format != 0 && !no_format && !first_is_text && multi_child {
3179                    io::buf_ccat(buf, b'\n');
3180                }
3181
3182                if let Some(enc) = &meta_bytes {
3183                    io::buf_add(buf, b"<meta charset=\"" as *const u8, 15);
3184                    io::buf_add(buf, enc.as_ptr(), enc.len() as c_int);
3185                    io::buf_add(buf, b"\">" as *const u8, 2);
3186                    // UPSTREAM-PARITY (line 983): a newline follows the
3187                    // inserted meta when the next real child is not text.
3188                    if format != 0 && has_children && !first_is_text && !name.starts_with('p') {
3189                        io::buf_ccat(buf, b'\n');
3190                    }
3191                }
3192
3193                // Serialize children inline (HTML formatting adds no
3194                // indentation; the per-element rules emit the newlines).
3195                let mut child = n.children;
3196                while !child.is_null() {
3197                    serialize_node_enc(child, buf, format, level + 1, encoding);
3198                    // UPSTREAM-PARITY (line 997): a newline follows a
3199                    // non-inline element whose next sibling is not text,
3200                    // unless the parent is p/pre/param.
3201                    let next = unsafe { (*child).next };
3202                    if format != 0 && !next.is_null() && !name.starts_with('p') {
3203                        let nt = unsafe { (*next).type_ };
3204                        if nt != XML_TEXT_NODE as c_int && nt != XML_ENTITY_REF_NODE as c_int {
3205                            let cname = if (*child).name.is_null() {
3206                                ""
3207                            } else {
3208                                unsafe {
3209                                    core::str::from_utf8(xmlstr_to_bytes((*child).name))
3210                                        .unwrap_or("")
3211                                }
3212                            };
3213                            let cinfo = html_tag_lookup(cname);
3214                            let c_inline = cinfo.is_none_or(|i| i.flags & HTML_INLINE != 0);
3215                            if !c_inline {
3216                                io::buf_ccat(buf, b'\n');
3217                            }
3218                        }
3219                    }
3220                    child = next;
3221                }
3222
3223                // Newline before the end tag (upstream line 1085): a
3224                // non-inline element whose last child is not text and which
3225                // has more than one child (or is the head receiving a meta).
3226                let last_child = if has_children {
3227                    unsafe { (*n.last).type_ }
3228                } else {
3229                    XML_ELEMENT_NODE as c_int
3230                };
3231                let last_is_text = last_child == XML_TEXT_NODE as c_int
3232                    || last_child == XML_ENTITY_REF_NODE as c_int;
3233                if format != 0 && !no_format && !last_is_text && multi_child {
3234                    io::buf_ccat(buf, b'\n');
3235                }
3236
3237                // Write end tag
3238                io::buf_add(buf, b"</" as *const u8, 2);
3239                // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpOutputInternal
3240                // end-tag emission): the namespace prefix is repeated.
3241                if !n.ns.is_null() && !(*n.ns).prefix.is_null() {
3242                    io::buf_cat(buf, (*n.ns).prefix);
3243                    io::buf_ccat(buf, b':');
3244                }
3245                if !n.name.is_null() {
3246                    io::buf_cat(buf, n.name);
3247                }
3248                io::buf_ccat(buf, b'>');
3249            }
3250        }
3251        t if t == XML_TEXT_NODE as c_int => {
3252            // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpInternal): script/
3253            // style content is DATA_RAWTEXT — written verbatim, never
3254            // escaped (the corpus html-script expects `<` and `&&` raw).
3255            let parent_is_raw = !n.parent.is_null() && !(*n.parent).name.is_null() && {
3256                let pn = xmlstr_to_bytes((*n.parent).name);
3257                pn.eq_ignore_ascii_case(b"script") || pn.eq_ignore_ascii_case(b"style")
3258            };
3259            if parent_is_raw {
3260                io::buf_cat(buf, n.content);
3261            } else {
3262                html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
3263            }
3264        }
3265        t if t == XML_CDATA_SECTION_NODE as c_int => {
3266            io::buf_add(buf, b"<![CDATA[" as *const u8, 9);
3267            html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
3268            io::buf_add(buf, b"]]>" as *const u8, 3);
3269        }
3270        t if t == XML_COMMENT_NODE as c_int => {
3271            if format != 0 && level > 0 {
3272                io::buf_ccat(buf, b'\n');
3273                for _ in 0..level {
3274                    io::buf_add(buf, b"  " as *const u8, 2);
3275                }
3276            }
3277            io::buf_add(buf, b"<!--" as *const u8, 4);
3278            if !n.content.is_null() {
3279                io::buf_cat(buf, n.content);
3280            }
3281            io::buf_add(buf, b"-->" as *const u8, 3);
3282        }
3283        t if t == XML_PI_NODE as c_int => {
3284            if format != 0 && level > 0 {
3285                io::buf_ccat(buf, b'\n');
3286                for _ in 0..level {
3287                    io::buf_add(buf, b"  " as *const u8, 2);
3288                }
3289            }
3290            io::buf_add(buf, b"<?" as *const u8, 2);
3291            if !n.name.is_null() {
3292                io::buf_cat(buf, n.name);
3293            }
3294            if !n.content.is_null() && unsafe { *n.content != 0 } {
3295                io::buf_ccat(buf, b' ');
3296                io::buf_cat(buf, n.content);
3297            }
3298            io::buf_add(buf, b"?>" as *const u8, 2);
3299        }
3300        t if t == XML_DOCUMENT_NODE as c_int || t == XML_HTML_DOCUMENT_NODE as c_int => {
3301            // UPSTREAM-PARITY: htmlDocContentDumpOutput writes the internal
3302            // subset's DOCTYPE before the tree children.
3303            let doc_ptr = n as *const _xmlNode as *mut _xmlDoc;
3304            let d = &*doc_ptr;
3305            if !d.intSubset.is_null() {
3306                let dtd = &*d.intSubset;
3307                io::buf_add(buf, b"<!DOCTYPE " as *const u8, 10);
3308                if !dtd.name.is_null() {
3309                    io::buf_cat(buf, dtd.name);
3310                }
3311                if !dtd.ExternalID.is_null() {
3312                    io::buf_add(buf, b" PUBLIC " as *const u8, 8);
3313                    html_write_quoted(buf, dtd.ExternalID);
3314                    io::buf_ccat(buf, b' ');
3315                    html_write_quoted(buf, dtd.SystemID);
3316                } else if !dtd.SystemID.is_null() {
3317                    io::buf_add(buf, b" SYSTEM " as *const u8, 8);
3318                    html_write_quoted(buf, dtd.SystemID);
3319                }
3320                io::buf_ccat(buf, b'>');
3321                io::buf_ccat(buf, b'\n');
3322            }
3323            // No XML declaration for HTML documents
3324            // Serialize children
3325            let mut child = n.children;
3326            while !child.is_null() {
3327                serialize_node_enc(child, buf, format, 0, encoding);
3328                child = unsafe { (*child).next };
3329            }
3330            // UPSTREAM-PARITY: htmlDocContentDumpOutput terminates with a
3331            // newline.
3332            io::buf_ccat(buf, b'\n');
3333        }
3334        _ => {
3335            if !n.content.is_null() {
3336                html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
3337            }
3338        }
3339    }
3340}
3341
3342/// Dump an HTML document to a buffer.
3343///
3344/// # Safety
3345///
3346/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
3347/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
3348pub(crate) unsafe fn doc_dump(buf: *mut _xmlBuffer, doc: *mut _xmlDoc) -> c_int {
3349    if buf.is_null() || doc.is_null() {
3350        return -1;
3351    }
3352
3353    let before = io::buf_length(buf);
3354    serialize_node(doc as *mut _xmlNode, buf, 0, 0);
3355    let after = io::buf_length(buf);
3356
3357    if after < 0 || before < 0 {
3358        return -1;
3359    }
3360    after - before
3361}
3362
3363// ═══════════════════════════════════════════════════════════════════════════════
3364// Tests
3365// ═══════════════════════════════════════════════════════════════════════════════
3366
3367#[cfg(test)]
3368mod tests {
3369    use super::*;
3370
3371    use crate::xml::io;
3372
3373    /// Helper: create a null-terminated xmlChar* from a byte slice.
3374    #[allow(dead_code)]
3375    unsafe fn to_xmlstr(s: &[u8]) -> *mut xmlChar {
3376        bytes_to_xmlstr(s)
3377    }
3378
3379    /// Helper: serialize an HTML document to a String.
3380    unsafe fn html_doc_to_string(doc: *mut _xmlDoc) -> String {
3381        let buf = io::buf_create(-1);
3382        assert!(!buf.is_null());
3383        doc_dump(buf, doc);
3384        let content = io::buf_content(buf);
3385        let s = if !content.is_null() {
3386            let len = xml_strlen(content);
3387            let slice = slice::from_raw_parts(content, len);
3388            String::from_utf8_lossy(slice).to_string()
3389        } else {
3390            String::new()
3391        };
3392        io::buf_free(buf);
3393        s
3394    }
3395
3396    // ═════════════════════════════════════════════════════════════════════════
3397    // Element Info Lookup
3398    // ═════════════════════════════════════════════════════════════════════════
3399
3400    #[test]
3401    fn test_html_tag_lookup() {
3402        // Known elements
3403        assert!(html_tag_lookup("html").is_some());
3404        assert!(html_tag_lookup("HTML").is_some()); // case-insensitive
3405        assert!(html_tag_lookup("p").is_some());
3406        assert!(html_tag_lookup("br").is_some());
3407        assert!(html_tag_lookup("div").is_some());
3408        assert!(html_tag_lookup("script").is_some());
3409
3410        // Unknown elements
3411        assert!(html_tag_lookup("custom").is_none());
3412        assert!(html_tag_lookup("my-element").is_none());
3413    }
3414
3415    #[test]
3416    fn test_tag_flags() {
3417        let br = html_tag_lookup("br").unwrap();
3418        assert!(br.flags & HTML_INLINE != 0);
3419        assert!(br.flags & HTML_EMPTY != 0);
3420
3421        let div = html_tag_lookup("div").unwrap();
3422        assert!(div.flags & HTML_BLOCK != 0);
3423        assert!(div.flags & HTML_VALID != 0);
3424
3425        let p = html_tag_lookup("p").unwrap();
3426        assert!(p.flags & HTML_NO_END != 0);
3427
3428        let meta = html_tag_lookup("meta").unwrap();
3429        assert!(meta.flags & HTML_HEAD != 0);
3430        assert!(meta.flags & HTML_EMPTY != 0);
3431    }
3432
3433    // ═════════════════════════════════════════════════════════════════════════
3434    // Entity Lookup
3435    // ═════════════════════════════════════════════════════════════════════════
3436
3437    #[test]
3438    fn test_html_entity_lookup() {
3439        assert_eq!(html_entity_lookup("amp"), Some("&"));
3440        assert_eq!(html_entity_lookup("lt"), Some("<"));
3441        assert_eq!(html_entity_lookup("gt"), Some(">"));
3442        assert_eq!(html_entity_lookup("quot"), Some("\""));
3443        assert_eq!(html_entity_lookup("nbsp"), Some("\u{00a0}"));
3444        assert_eq!(html_entity_lookup("copy"), Some("\u{00a9}"));
3445        assert!(html_entity_lookup("unknown_entity").is_none());
3446    }
3447
3448    // ═════════════════════════════════════════════════════════════════════════
3449    // Basic Parsing
3450    // ═════════════════════════════════════════════════════════════════════════
3451
3452    /// Parses a complete HTML document and verifies the serialized output
3453    /// contains the expected elements.
3454    ///
3455    /// # Safety
3456    ///
3457    /// - The NUL-terminated static `html` buffer stays valid for the
3458    ///   `parse_memory` call; the returned document pointer is asserted
3459    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3460    ///   freed exactly once with `tree::free_doc`.
3461    #[test]
3462    fn test_parse_basic_html() {
3463        unsafe {
3464            let html = b"<html><head><title>Test</title></head><body><p>Hello</p></body></html>\0";
3465            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3466            assert!(!doc.is_null());
3467
3468            let s = html_doc_to_string(doc);
3469            assert!(s.contains("<html>"));
3470            assert!(s.contains("<head>"));
3471            assert!(s.contains("<title>Test</title>"));
3472            assert!(s.contains("<body>"));
3473            assert!(s.contains("<p>Hello</p>"));
3474
3475            tree::free_doc(doc);
3476        }
3477    }
3478
3479    /// Verifies that parsing an empty buffer yields a NULL document.
3480    ///
3481    /// # Safety
3482    ///
3483    /// - The static one-byte buffer is valid for the `parse_memory` call with
3484    ///   size 0, which must not read from it; a NULL document is expected.
3485    #[test]
3486    fn test_parse_empty_document() {
3487        unsafe {
3488            let html = b"\0";
3489            let doc = parse_memory(html.as_ptr() as *const c_char, 0);
3490            assert!(doc.is_null());
3491        }
3492    }
3493
3494    // ═════════════════════════════════════════════════════════════════════════
3495    // Implicit html/head/body Creation
3496    // ═════════════════════════════════════════════════════════════════════════
3497
3498    /// Verifies that a bare paragraph triggers implicit `html` and `body`
3499    /// creation during parsing.
3500    ///
3501    /// # Safety
3502    ///
3503    /// - The NUL-terminated static `html` buffer stays valid for the
3504    ///   `parse_memory` call; the returned document pointer is asserted
3505    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3506    ///   freed exactly once with `tree::free_doc`.
3507    #[test]
3508    fn test_implicit_html_head_body() {
3509        unsafe {
3510            // Just a paragraph, no html/head/body
3511            let html = b"<p>Hello</p>\0";
3512            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3513            assert!(!doc.is_null());
3514
3515            let s = html_doc_to_string(doc);
3516            // Should have auto-created html
3517            assert!(s.contains("<html>"));
3518            // Should have auto-created body
3519            assert!(s.contains("<body>"));
3520            // Should have the paragraph
3521            assert!(s.contains("<p>Hello</p>"));
3522
3523            tree::free_doc(doc);
3524        }
3525    }
3526
3527    /// Verifies that a bare `title` triggers implicit `html` and `head`
3528    /// creation during parsing.
3529    ///
3530    /// # Safety
3531    ///
3532    /// - The NUL-terminated static `html` buffer stays valid for the
3533    ///   `parse_memory` call; the returned document pointer is asserted
3534    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3535    ///   freed exactly once with `tree::free_doc`.
3536    #[test]
3537    fn test_implicit_head_with_title() {
3538        unsafe {
3539            // Only a title, no html/head/body
3540            let html = b"<title>My Page</title>\0";
3541            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3542            assert!(!doc.is_null());
3543
3544            let s = html_doc_to_string(doc);
3545            assert!(s.contains("<html>"));
3546            assert!(s.contains("<head>"));
3547            assert!(s.contains("<title>My Page</title>"));
3548
3549            tree::free_doc(doc);
3550        }
3551    }
3552
3553    // ═════════════════════════════════════════════════════════════════════════
3554    // Auto-closing
3555    // ═════════════════════════════════════════════════════════════════════════
3556
3557    /// Verifies that a second `p` start tag auto-closes the first.
3558    ///
3559    /// # Safety
3560    ///
3561    /// - The NUL-terminated static `html` buffer stays valid for the
3562    ///   `parse_memory` call; the returned document pointer is asserted
3563    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3564    ///   freed exactly once with `tree::free_doc`.
3565    #[test]
3566    fn test_auto_close_p() {
3567        unsafe {
3568            // <p> should auto-close before another <p>
3569            let html = b"<p>First<p>Second</p>\0";
3570            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3571            assert!(!doc.is_null());
3572
3573            let s = html_doc_to_string(doc);
3574            // Both paragraphs should be siblings, not nested
3575            let first_pos = s.find("First");
3576            let second_pos = s.find("Second");
3577            assert!(first_pos.is_some());
3578            assert!(second_pos.is_some());
3579
3580            tree::free_doc(doc);
3581        }
3582    }
3583
3584    /// Verifies that an `h1` element is auto-closed before an `h2`.
3585    ///
3586    /// # Safety
3587    ///
3588    /// - The NUL-terminated static `html` buffer stays valid for the
3589    ///   `parse_memory` call; the returned document pointer is asserted
3590    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3591    ///   freed exactly once with `tree::free_doc`.
3592    #[test]
3593    fn test_auto_close_heading() {
3594        unsafe {
3595            // h1 should auto-close before h2
3596            let html = b"<h1>Title</h1><h2>Subtitle</h2>\0";
3597            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3598            assert!(!doc.is_null());
3599
3600            let s = html_doc_to_string(doc);
3601            assert!(s.contains("<h1>Title</h1>"));
3602            assert!(s.contains("<h2>Subtitle</h2>"));
3603
3604            tree::free_doc(doc);
3605        }
3606    }
3607
3608    // ═════════════════════════════════════════════════════════════════════════
3609    // Void Elements
3610    // ═════════════════════════════════════════════════════════════════════════
3611
3612    /// Verifies that void elements are serialized without closing tags.
3613    ///
3614    /// # Safety
3615    ///
3616    /// - The NUL-terminated static `html` buffer stays valid for the
3617    ///   `parse_memory` call; the returned document pointer is asserted
3618    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3619    ///   freed exactly once with `tree::free_doc`.
3620    #[test]
3621    fn test_void_elements() {
3622        unsafe {
3623            let html = b"<br><hr><img src=\"test.jpg\"><input type=\"text\">\0";
3624            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3625            assert!(!doc.is_null());
3626
3627            let s = html_doc_to_string(doc);
3628            assert!(s.contains("<br>"));
3629            assert!(s.contains("<hr>"));
3630            assert!(s.contains("<img"));
3631            assert!(s.contains("<input"));
3632
3633            // Void elements should not have closing tags
3634            assert!(!s.contains("</br>"));
3635            assert!(!s.contains("</hr>"));
3636            assert!(!s.contains("</img>"));
3637
3638            tree::free_doc(doc);
3639        }
3640    }
3641
3642    // ═════════════════════════════════════════════════════════════════════════
3643    // Unquoted and Minimized Attributes
3644    // ═════════════════════════════════════════════════════════════════════════
3645
3646    /// Verifies that unquoted attribute values are parsed and re-serialized
3647    /// with quotes.
3648    ///
3649    /// # Safety
3650    ///
3651    /// - The NUL-terminated static `html` buffer stays valid for the
3652    ///   `parse_memory` call; the returned document pointer is asserted
3653    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3654    ///   freed exactly once with `tree::free_doc`.
3655    #[test]
3656    fn test_unquoted_attributes() {
3657        unsafe {
3658            let html = b"<div class=main id=content>Text</div>\0";
3659            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3660            assert!(!doc.is_null());
3661
3662            let s = html_doc_to_string(doc);
3663            assert!(s.contains("class=\"main\""));
3664            assert!(s.contains("id=\"content\""));
3665
3666            tree::free_doc(doc);
3667        }
3668    }
3669
3670    /// Verifies that minimized (valueless) attributes are preserved.
3671    ///
3672    /// # Safety
3673    ///
3674    /// - The NUL-terminated static `html` buffer stays valid for the
3675    ///   `parse_memory` call; the returned document pointer is asserted
3676    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3677    ///   freed exactly once with `tree::free_doc`.
3678    #[test]
3679    fn test_minimized_attributes() {
3680        unsafe {
3681            let html = b"<option selected disabled>Value</option>\0";
3682            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3683            assert!(!doc.is_null());
3684
3685            let s = html_doc_to_string(doc);
3686            // The minimized attributes should be preserved
3687            assert!(s.contains("selected"));
3688            assert!(s.contains("disabled"));
3689
3690            tree::free_doc(doc);
3691        }
3692    }
3693
3694    // ═════════════════════════════════════════════════════════════════════════
3695    // HTML Entities
3696    // ═════════════════════════════════════════════════════════════════════════
3697
3698    /// Verifies named entity resolution and re-escaping during serialization.
3699    ///
3700    /// # Safety
3701    ///
3702    /// - The NUL-terminated static `html` buffer stays valid for the
3703    ///   `parse_memory` call; the returned document pointer is asserted
3704    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3705    ///   freed exactly once with `tree::free_doc`.
3706    #[test]
3707    fn test_html_entities() {
3708        unsafe {
3709            let html = b"<p>&amp; &lt; &gt; &quot; &nbsp; &copy;</p>\0";
3710            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3711            assert!(!doc.is_null());
3712
3713            let s = html_doc_to_string(doc);
3714            // Entities are resolved in the tree; &amp; and &lt; get re-escaped during serialization
3715            // because & and < are special. &gt; becomes > (serialized as-is, > is safe in text).
3716            assert!(s.contains("&amp;")); // &amp; → & → &amp; (re-escaped)
3717            assert!(s.contains("&lt;")); // &lt; → < → &lt; (re-escaped)
3718            assert!(s.contains(">")); // &gt; → > (not escaped in text)
3719            assert!(s.contains("\u{00a0}")); // &nbsp; → non-breaking space
3720
3721            tree::free_doc(doc);
3722        }
3723    }
3724
3725    /// Verifies decimal and hexadecimal numeric entity resolution.
3726    ///
3727    /// # Safety
3728    ///
3729    /// - The NUL-terminated static `html` buffer stays valid for the
3730    ///   `parse_memory` call; the returned document pointer is asserted
3731    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3732    ///   freed exactly once with `tree::free_doc`.
3733    #[test]
3734    fn test_numeric_entities() {
3735        unsafe {
3736            // &#65; = 'A', &#x41; = 'A'
3737            let html = b"<p>&#65; &#x41;</p>\0";
3738            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3739            assert!(!doc.is_null());
3740
3741            let s = html_doc_to_string(doc);
3742            assert!(s.contains('A'));
3743
3744            tree::free_doc(doc);
3745        }
3746    }
3747
3748    // ═════════════════════════════════════════════════════════════════════════
3749    // Nested Elements
3750    // ═════════════════════════════════════════════════════════════════════════
3751
3752    /// Verifies nested element parsing and serialization.
3753    ///
3754    /// # Safety
3755    ///
3756    /// - The NUL-terminated static `html` buffer stays valid for the
3757    ///   `parse_memory` call; the returned document pointer is asserted
3758    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3759    ///   freed exactly once with `tree::free_doc`.
3760    #[test]
3761    fn test_nested_elements() {
3762        unsafe {
3763            let html = b"<div><ul><li>Item 1</li><li>Item 2</li></ul></div>\0";
3764            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3765            assert!(!doc.is_null());
3766
3767            let s = html_doc_to_string(doc);
3768            assert!(s.contains("<div>"));
3769            assert!(s.contains("<ul>"));
3770            assert!(s.contains("<li>Item 1</li>"));
3771            assert!(s.contains("<li>Item 2</li>"));
3772
3773            tree::free_doc(doc);
3774        }
3775    }
3776
3777    // ═════════════════════════════════════════════════════════════════════════
3778    // Malformed HTML Recovery
3779    // ═════════════════════════════════════════════════════════════════════════
3780
3781    /// Verifies tag-recovery when end tags are missing.
3782    ///
3783    /// # Safety
3784    ///
3785    /// - The NUL-terminated static `html` buffer stays valid for the
3786    ///   `parse_memory` call; the returned document pointer is asserted
3787    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3788    ///   freed exactly once with `tree::free_doc`.
3789    #[test]
3790    fn test_missing_end_tags() {
3791        unsafe {
3792            // Missing closing tags
3793            let html = b"<p>Paragraph without closing<div>Another div\0";
3794            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3795            assert!(!doc.is_null());
3796
3797            let s = html_doc_to_string(doc);
3798            assert!(s.contains("Paragraph without closing"));
3799            assert!(s.contains("Another div"));
3800
3801            tree::free_doc(doc);
3802        }
3803    }
3804
3805    /// Verifies case-insensitive parsing with case-preserved serialization.
3806    ///
3807    /// # Safety
3808    ///
3809    /// - The NUL-terminated static `html` buffer stays valid for the
3810    ///   `parse_memory` call; the returned document pointer is asserted
3811    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3812    ///   freed exactly once with `tree::free_doc`.
3813    #[test]
3814    fn test_mismatched_case() {
3815        unsafe {
3816            let html = b"<HTML><HEAD><TITLE>Test</TITLE></HEAD><BODY><P>Hello</P></BODY></HTML>\0";
3817            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3818            assert!(!doc.is_null());
3819
3820            let s = html_doc_to_string(doc);
3821            // Tag names are case-preserved
3822            assert!(s.contains("<HTML>"));
3823            assert!(s.contains("<HEAD>"));
3824            assert!(s.contains("<BODY>"));
3825            assert!(s.contains("<P>Hello</P>"));
3826
3827            tree::free_doc(doc);
3828        }
3829    }
3830
3831    /// Verifies recovery from deeply nested malformed markup.
3832    ///
3833    /// # Safety
3834    ///
3835    /// - The NUL-terminated static `html` buffer stays valid for the
3836    ///   `parse_memory` call; the returned document pointer is asserted
3837    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3838    ///   freed exactly once with `tree::free_doc`.
3839    #[test]
3840    fn test_nested_malformed() {
3841        unsafe {
3842            // Deeply nested with missing end tags
3843            let html = b"<div><p><span><b>Deep text</div></p>\0";
3844            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3845            assert!(!doc.is_null());
3846
3847            let s = html_doc_to_string(doc);
3848            assert!(s.contains("Deep text"));
3849
3850            tree::free_doc(doc);
3851        }
3852    }
3853
3854    // ═════════════════════════════════════════════════════════════════════════
3855    // HTML Serialization Round-trip
3856    // ═════════════════════════════════════════════════════════════════════════
3857
3858    /// Verifies a simple parse and serialize round trip.
3859    ///
3860    /// # Safety
3861    ///
3862    /// - The NUL-terminated static `original` buffer stays valid for the
3863    ///   `parse_memory` call; the returned document pointer is asserted
3864    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3865    ///   freed exactly once with `tree::free_doc`.
3866    #[test]
3867    fn test_serialization_round_trip_simple() {
3868        unsafe {
3869            let original = b"<p>Hello World</p>\0";
3870            let doc = parse_memory(
3871                original.as_ptr() as *const c_char,
3872                (original.len() - 1) as c_int,
3873            );
3874            assert!(!doc.is_null());
3875
3876            let s = html_doc_to_string(doc);
3877            assert!(s.contains("Hello World"));
3878
3879            tree::free_doc(doc);
3880        }
3881    }
3882
3883    /// Verifies void elements are not serialized with self-closing syntax.
3884    ///
3885    /// # Safety
3886    ///
3887    /// - The NUL-terminated static `html` buffer stays valid for the
3888    ///   `parse_memory` call; the returned document pointer is asserted
3889    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3890    ///   freed exactly once with `tree::free_doc`.
3891    #[test]
3892    fn test_serialize_void_elements_no_self_close() {
3893        unsafe {
3894            let html = b"<br><hr><img src=\"test.png\">\0";
3895            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3896            assert!(!doc.is_null());
3897
3898            let s = html_doc_to_string(doc);
3899            // HTML serialization should NOT use self-closing tags
3900            assert!(!s.contains("<br/>"));
3901            assert!(!s.contains("<hr/>"));
3902
3903            tree::free_doc(doc);
3904        }
3905    }
3906
3907    // ═════════════════════════════════════════════════════════════════════════
3908    // Script and Style Handling
3909    // ═════════════════════════════════════════════════════════════════════════
3910
3911    /// Verifies raw text content inside a `script` element is preserved.
3912    ///
3913    /// # Safety
3914    ///
3915    /// - The NUL-terminated static `html` buffer stays valid for the
3916    ///   `parse_memory` call; the returned document pointer is asserted
3917    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3918    ///   freed exactly once with `tree::free_doc`.
3919    #[test]
3920    fn test_script_content() {
3921        unsafe {
3922            // Use a simpler script content that doesn't contain '<' to avoid parser confusion
3923            let html = b"<script>var x = 1;</script>\0";
3924            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3925            assert!(!doc.is_null());
3926
3927            let s = html_doc_to_string(doc);
3928            assert!(s.contains("<script>"));
3929            // The raw text content should be preserved
3930            assert!(s.contains("var x = 1;"));
3931
3932            // UPSTREAM-PARITY (DATA_RAWTEXT): script content is never
3933            // escaped, even when it contains '<', '&' or '>'.
3934            let html2 = b"<script>if (a < b && c > d) { x(1); }</script>\0";
3935            let doc2 = parse_memory(html2.as_ptr() as *const c_char, (html2.len() - 1) as c_int);
3936            assert!(!doc2.is_null());
3937            let s2 = html_doc_to_string(doc2);
3938            assert!(
3939                s2.contains("if (a < b && c > d) { x(1); }"),
3940                "script content must be raw, got: {s2}"
3941            );
3942            assert!(
3943                !s2.contains("&lt;"),
3944                "script content must not be escaped: {s2}"
3945            );
3946            tree::free_doc(doc2);
3947
3948            tree::free_doc(doc);
3949        }
3950    }
3951
3952    // ═════════════════════════════════════════════════════════════════════════
3953    // Comments and DOCTYPE
3954    // ═════════════════════════════════════════════════════════════════════════
3955
3956    /// Verifies HTML comments are preserved in the serialized output.
3957    ///
3958    /// # Safety
3959    ///
3960    /// - The NUL-terminated static `html` buffer stays valid for the
3961    ///   `parse_memory` call; the returned document pointer is asserted
3962    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3963    ///   freed exactly once with `tree::free_doc`.
3964    #[test]
3965    fn test_html_comment() {
3966        unsafe {
3967            let html = b"<html><!-- This is a comment --><body><p>Text</p></body></html>\0";
3968            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3969            assert!(!doc.is_null());
3970
3971            let s = html_doc_to_string(doc);
3972            assert!(s.contains("<!-- This is a comment -->"));
3973
3974            tree::free_doc(doc);
3975        }
3976    }
3977
3978    // ═════════════════════════════════════════════════════════════════════════
3979    // new_doc / new_doc_no_dtd
3980    // ═════════════════════════════════════════════════════════════════════════
3981
3982    /// Verifies `new_doc` creates an HTML document WITHOUT an implicit
3983    /// `html`/`head`/`body` skeleton (upstream htmlNewDoc does not seed them;
3984    /// the HTML parser grows them lazily). `html_doc_to_string` of an empty
3985    /// body-less HTML doc emits just the trailing newline.
3986    ///
3987    /// # Safety
3988    ///
3989    /// - `new_doc` returns an owned `_xmlDoc` or NULL; the pointer is
3990    ///   asserted non-NULL before its `type_` field is dereferenced and
3991    ///   before `html_doc_to_string`, and is freed exactly once with
3992    ///   `tree::free_doc`.
3993    #[test]
3994    fn test_new_doc_creates_html_head_body() {
3995        unsafe {
3996            let doc = new_doc(ptr::null());
3997            assert!(!doc.is_null());
3998            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
3999
4000            let s = html_doc_to_string(doc);
4001            assert!(!s.contains("<html>"), "htmlNewDoc must not seed <html>");
4002            assert!(!s.contains("<head>"), "htmlNewDoc must not seed <head>");
4003            assert!(!s.contains("<body>"), "htmlNewDoc must not seed <body>");
4004
4005            tree::free_doc(doc);
4006        }
4007    }
4008
4009    /// Verifies `new_doc_no_dtd` creates a document without implicit
4010    /// structure and without a DTD.
4011    ///
4012    /// # Safety
4013    ///
4014    /// - `new_doc_no_dtd` returns an owned `_xmlDoc` or NULL; the pointer is
4015    ///   asserted non-NULL before its `type_` field is dereferenced and
4016    ///   before `html_doc_to_string`, and is freed exactly once with
4017    ///   `tree::free_doc`.
4018    #[test]
4019    fn test_new_doc_no_dtd() {
4020        unsafe {
4021            let doc = new_doc_no_dtd(ptr::null());
4022            assert!(!doc.is_null());
4023            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
4024
4025            // No implicit html/head/body. UPSTREAM-PARITY: the HTML
4026            // serializer (htmlNodeDumpInternal) writes "\n" for a
4027            // document node with no children (HTMLtree.c:861-863).
4028            let s = html_doc_to_string(doc);
4029            assert_eq!(s, "\n");
4030
4031            tree::free_doc(doc);
4032        }
4033    }
4034
4035    /// Phase 14.3 (dom S1 / loadHTML family): an html-parsed document
4036    /// carries properties = XML_DOC_HTML and standalone = 1 — upstream
4037    /// xmlSAX2StartDocument for HTML parsers sets XML_DOC_HTML and
4038    /// htmlNewDocNoDtD defaults standalone=1. The pre-fix XML_DOC_WELLFORMED-
4039    /// only value made PHP's spec serializer and the engine AS_XML save treat
4040    /// loadHTML'd documents as plain XML (declaration/standalone loss;
4041    /// ext/dom dom005/gh15670/gh16535/gh17397/gh19612 + loadHTMLfile*).
4042    ///
4043    /// # Safety
4044    ///
4045    /// - the parsed html doc is freed exactly once; the pointer is asserted
4046    ///   non-NULL before its fields are read.
4047    #[test]
4048    fn test_parsed_html_doc_flags() {
4049        unsafe {
4050            let doc = parse_memory(c"<html><body>x</body></html>".as_ptr(), 23);
4051            assert!(!doc.is_null());
4052            assert_eq!(
4053                (*doc).properties & (crate::abi::types::xmlDocProperties::XML_DOC_HTML as c_int),
4054                crate::abi::types::xmlDocProperties::XML_DOC_HTML as c_int,
4055                "html-parsed docs must carry XML_DOC_HTML"
4056            );
4057            assert_eq!(
4058                (*doc).standalone,
4059                1,
4060                "html-parsed docs default standalone=yes"
4061            );
4062            tree::free_doc(doc);
4063        }
4064    }
4065
4066    // ═════════════════════════════════════════════════════════════════════════
4067    // Entity Resolution in Text
4068    // ═════════════════════════════════════════════════════════════════════════
4069
4070    #[test]
4071    fn test_resolve_numeric_entity() {
4072        assert_eq!(resolve_numeric_entity("65", false), vec![b'A']);
4073        assert_eq!(resolve_numeric_entity("41", true), vec![b'A']);
4074        assert_eq!(resolve_numeric_entity("0", false), Vec::<u8>::new());
4075    }
4076
4077    #[test]
4078    fn test_resolve_entity_unknown() {
4079        let result = resolve_entity("unknown");
4080        assert_eq!(result, b"&unknown;");
4081    }
4082
4083    // ═════════════════════════════════════════════════════════════════════════
4084    // init/cleanup Parser
4085    // ═════════════════════════════════════════════════════════════════════════
4086
4087    #[test]
4088    fn test_init_cleanup_parser() {
4089        // Just ensure no crashes
4090        init_parser();
4091        cleanup_parser();
4092    }
4093
4094    // ═════════════════════════════════════════════════════════════════════════
4095    // Parser Context
4096    // ═════════════════════════════════════════════════════════════════════════
4097
4098    /// Verifies `create_file_parser_ctxt` and `free_parser_ctxt` round trip.
4099    ///
4100    /// # Safety
4101    ///
4102    /// - The static NUL-terminated filename stays valid for the
4103    ///   `create_file_parser_ctxt` call; the returned context is asserted
4104    ///   non-NULL before being freed with `free_parser_ctxt`.
4105    #[test]
4106    fn test_create_free_parser_ctxt() {
4107        unsafe {
4108            let ctxt =
4109                create_file_parser_ctxt(b"test.html\0" as *const u8 as *const c_char, ptr::null());
4110            assert!(!ctxt.is_null());
4111            free_parser_ctxt(ctxt);
4112        }
4113    }
4114
4115    // ═════════════════════════════════════════════════════════════════════════
4116    // Complex HTML Documents
4117    // ═════════════════════════════════════════════════════════════════════════
4118
4119    /// Parses a complex HTML document and verifies the serialized structure.
4120    ///
4121    /// # Safety
4122    ///
4123    /// - The NUL-terminated static `html` buffer stays valid for the
4124    ///   `parse_memory` call; the returned document pointer is asserted
4125    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
4126    ///   freed exactly once with `tree::free_doc`.
4127    #[test]
4128    fn test_complex_html_document() {
4129        unsafe {
4130            let html = b"<!DOCTYPE html>
4131<html>
4132<head>
4133    <meta charset=\"utf-8\">
4134    <title>Test Page</title>
4135    <link rel=\"stylesheet\" href=\"style.css\">
4136</head>
4137<body>
4138    <div id=\"main\">
4139        <h1>Title</h1>
4140        <p>First paragraph with <a href=\"link.html\">a link</a>.</p>
4141        <p>Second paragraph.</p>
4142        <ul>
4143            <li>Item 1</li>
4144            <li>Item 2</li>
4145        </ul>
4146        <br>
4147        <hr>
4148        <img src=\"image.jpg\" alt=\"An image\">
4149    </div>
4150    <script>alert('hello');</script>
4151</body>
4152</html>\0";
4153            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
4154            assert!(!doc.is_null());
4155
4156            let s = html_doc_to_string(doc);
4157            assert!(s.contains("<html>"));
4158            assert!(s.contains("<head>"));
4159            assert!(s.contains("<title>Test Page</title>"));
4160            assert!(s.contains("<body>"));
4161            assert!(s.contains("<h1>Title</h1>"));
4162            assert!(s.contains("a link"));
4163            assert!(s.contains("Second paragraph"));
4164            assert!(s.contains("<br>"));
4165            assert!(s.contains("<hr>"));
4166            assert!(s.contains("<img"));
4167            assert!(s.contains("<script>"));
4168
4169            tree::free_doc(doc);
4170        }
4171    }
4172
4173    // ═════════════════════════════════════════════════════════════════════════
4174    // parse_doc
4175    // ═════════════════════════════════════════════════════════════════════════
4176
4177    /// Verifies `parse_doc` parses from an `xmlChar` buffer.
4178    ///
4179    /// # Safety
4180    ///
4181    /// - The NUL-terminated static `html` buffer is passed to `parse_doc` as
4182    ///   an `xmlChar` pointer and must stay valid for the call; the returned
4183    ///   document pointer is asserted non-NULL before it is dereferenced by
4184    ///   `html_doc_to_string` and is freed exactly once with `tree::free_doc`.
4185    #[test]
4186    fn test_parse_doc() {
4187        unsafe {
4188            let html = b"<p>Hello from parse_doc</p>\0";
4189            let doc = parse_doc(html.as_ptr() as *const xmlChar, ptr::null(), 0);
4190            assert!(!doc.is_null());
4191
4192            let s = html_doc_to_string(doc);
4193            assert!(s.contains("Hello from parse_doc"));
4194
4195            tree::free_doc(doc);
4196        }
4197    }
4198
4199    // ═════════════════════════════════════════════════════════════════════════
4200    // Table elements auto-close
4201    // ═════════════════════════════════════════════════════════════════════════
4202
4203    /// Verifies a `td` element auto-closes a previous `td` inside a row.
4204    ///
4205    /// # Safety
4206    ///
4207    /// - The NUL-terminated static `html` buffer stays valid for the
4208    ///   `parse_memory` call; the returned document pointer is asserted
4209    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
4210    ///   freed exactly once with `tree::free_doc`.
4211    #[test]
4212    fn test_table_element_auto_close() {
4213        unsafe {
4214            let html = b"<table><tr><td>Cell 1<td>Cell 2</td></tr></table>";
4215            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
4216            assert!(!doc.is_null());
4217
4218            let s = html_doc_to_string(doc);
4219            assert!(s.contains("<td>Cell 1"));
4220            assert!(s.contains("<td>Cell 2"));
4221
4222            tree::free_doc(doc);
4223        }
4224    }
4225
4226    #[test]
4227    fn test_table_tr_auto_close() {
4228        // <tr> must auto-close the open row (the corpus html-table op:
4229        // `<table><tr><td>1<td>2<tr><td>3</table>` yields two sibling rows).
4230        unsafe {
4231            let html = b"<table><tr><td>1<td>2<tr><td>3</table>\0";
4232            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
4233            assert!(!doc.is_null());
4234            let s = html_doc_to_string(doc);
4235            assert!(s.contains("</td></tr><tr>"));
4236            tree::free_doc(doc);
4237        }
4238    }
4239}