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    // Lowercase the name for comparison
575    let lower: Vec<u8> = name.bytes().map(|b| b.to_ascii_lowercase()).collect();
576    let lower_str = match core::str::from_utf8(&lower) {
577        Ok(s) => s,
578        Err(_) => return None,
579    };
580    HTML_ELEMENTS.iter().find(|info| info.name == lower_str)
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    // Collect the open element names up the tree
978    let mut open_names: Vec<Vec<u8>> = Vec::new();
979    let mut cur = current;
980    while !cur.is_null() {
981        let ctype = unsafe { (*cur).type_ };
982        if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur).name.is_null() } {
983            let name_bytes = unsafe { xmlstr_to_bytes((*cur).name) };
984            open_names.push(name_bytes.to_vec());
985        }
986        cur = unsafe { (*cur).parent };
987    }
988
989    // Rule 1: <p> auto-closes before another <p>, and before block elements
990    if tag_lower_str == "p" || info.is_some_and(|i| i.flags & HTML_BLOCK != 0) {
991        // Close any open <p> elements
992        let mut cur2 = current;
993        while !cur2.is_null() {
994            let ctype = unsafe { (*cur2).type_ };
995            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
996                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
997                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
998                if name_str.eq_ignore_ascii_case("p") {
999                    // Close this <p> by moving current up past it
1000                    current = unsafe { (*cur2).parent };
1001                    break;
1002                }
1003            }
1004            cur2 = unsafe { (*cur2).parent };
1005        }
1006    }
1007
1008    // Rule 2: Headings (h1-h6) auto-close other headings
1009    if is_heading(tag_lower_str) {
1010        let mut cur2 = current;
1011        while !cur2.is_null() {
1012            let ctype = unsafe { (*cur2).type_ };
1013            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1014                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1015                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1016                if is_heading(name_str) {
1017                    current = unsafe { (*cur2).parent };
1018                    break;
1019                }
1020            }
1021            cur2 = unsafe { (*cur2).parent };
1022        }
1023    }
1024
1025    // Rule 3: <li> auto-closes another <li>
1026    if tag_lower_str == "li" {
1027        let mut cur2 = current;
1028        while !cur2.is_null() {
1029            let ctype = unsafe { (*cur2).type_ };
1030            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1031                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1032                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1033                if name_str.eq_ignore_ascii_case("li") {
1034                    current = unsafe { (*cur2).parent };
1035                    break;
1036                }
1037            }
1038            cur2 = unsafe { (*cur2).parent };
1039        }
1040    }
1041
1042    // Rule 4: <dt>/<dd> auto-close another <dt>/<dd>
1043    if tag_lower_str == "dt" || tag_lower_str == "dd" {
1044        let mut cur2 = current;
1045        while !cur2.is_null() {
1046            let ctype = unsafe { (*cur2).type_ };
1047            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1048                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1049                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1050                if name_str == "dt" || name_str == "dd" {
1051                    current = unsafe { (*cur2).parent };
1052                    break;
1053                }
1054            }
1055            cur2 = unsafe { (*cur2).parent };
1056        }
1057    }
1058
1059    // Rule 5: <tr> auto-closes the open row — the <tr> itself and any open
1060    // <td>/<th> cell (which is a child of that row); a new <td>/<th> only
1061    // auto-closes an open <td>/<th> (the previous cell) and stays inside the
1062    // open <tr> (upstream htmlAutoClose). The walk must NOT stop at a cell:
1063    // it has to keep climbing to the enclosing <tr> and close the whole row
1064    // (otherwise the new <tr> would nest inside the old one — corpus
1065    // html-table).
1066    if tag_lower_str == "tr" {
1067        let mut cur2 = current;
1068        while !cur2.is_null() {
1069            let ctype = unsafe { (*cur2).type_ };
1070            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1071                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1072                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1073                if name_str == "tr" {
1074                    current = unsafe { (*cur2).parent };
1075                    break;
1076                }
1077            }
1078            cur2 = unsafe { (*cur2).parent };
1079        }
1080    } else if tag_lower_str == "td" || tag_lower_str == "th" {
1081        let mut cur2 = current;
1082        while !cur2.is_null() {
1083            let ctype = unsafe { (*cur2).type_ };
1084            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1085                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1086                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1087                if name_str == "td" || name_str == "th" {
1088                    current = unsafe { (*cur2).parent };
1089                    break;
1090                }
1091            }
1092            cur2 = unsafe { (*cur2).parent };
1093        }
1094    }
1095
1096    // Rule 6: <thead>, <tbody>, <tfoot> auto-close each other
1097    if matches!(tag_lower_str, "thead" | "tbody" | "tfoot") {
1098        let mut cur2 = current;
1099        while !cur2.is_null() {
1100            let ctype = unsafe { (*cur2).type_ };
1101            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1102                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1103                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1104                if name_str == "thead" || name_str == "tbody" || name_str == "tfoot" {
1105                    current = unsafe { (*cur2).parent };
1106                    break;
1107                }
1108            }
1109            cur2 = unsafe { (*cur2).parent };
1110        }
1111    }
1112
1113    // Rule 7: <colgroup> auto-closes another <colgroup>
1114    if tag_lower_str == "colgroup" {
1115        let mut cur2 = current;
1116        while !cur2.is_null() {
1117            let ctype = unsafe { (*cur2).type_ };
1118            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1119                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1120                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1121                if name_str == "colgroup" {
1122                    current = unsafe { (*cur2).parent };
1123                    break;
1124                }
1125            }
1126            cur2 = unsafe { (*cur2).parent };
1127        }
1128    }
1129
1130    // Rule 8: <caption> auto-closes another <caption>
1131    if tag_lower_str == "caption" {
1132        let mut cur2 = current;
1133        while !cur2.is_null() {
1134            let ctype = unsafe { (*cur2).type_ };
1135            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1136                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1137                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1138                if name_str == "caption" {
1139                    current = unsafe { (*cur2).parent };
1140                    break;
1141                }
1142            }
1143            cur2 = unsafe { (*cur2).parent };
1144        }
1145    }
1146
1147    // Rule 9: <form> auto-closes another <form> (in libxml2 behavior)
1148    if tag_lower_str == "form" {
1149        let mut cur2 = current;
1150        while !cur2.is_null() {
1151            let ctype = unsafe { (*cur2).type_ };
1152            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1153                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1154                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1155                if name_str.eq_ignore_ascii_case("form") {
1156                    current = unsafe { (*cur2).parent };
1157                    break;
1158                }
1159            }
1160            cur2 = unsafe { (*cur2).parent };
1161        }
1162    }
1163
1164    ctxt.current = current;
1165}
1166
1167// ═══════════════════════════════════════════════════════════════════════════════
1168// Implicit Element Creation
1169// ═══════════════════════════════════════════════════════════════════════════════
1170
1171/// Ensure the html element exists, creating it implicitly if needed.
1172unsafe fn ensure_html(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1173    // UPSTREAM-PARITY (HTML_PARSE_NOIMPLIED): never auto-create the html
1174    // skeleton when the caller suppressed implied elements (bug76285).
1175    if ctxt.options & HTML_PARSE_NOIMPLIED != 0 {
1176        return ptr::null_mut();
1177    }
1178    if !ctxt.html.is_null() {
1179        return ctxt.html;
1180    }
1181
1182    let html_node = tree::new_node(ptr::null_mut(), b"html\0" as *const u8 as *const xmlChar);
1183    if html_node.is_null() {
1184        return ptr::null_mut();
1185    }
1186    {
1187        // Set HTML_IMPLIED flag concept - mark as auto-created
1188        // (We track this via a separate flag rather than modifying the node structure)
1189    }
1190    ctxt.html = html_node;
1191    ctxt.html_created = true;
1192
1193    // Add to document
1194    tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1195    ctxt.current = html_node;
1196
1197    html_node
1198}
1199
1200/// Ensure the head element exists, creating it implicitly if needed.
1201unsafe fn ensure_head(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1202    if ctxt.options & HTML_PARSE_NOIMPLIED != 0 {
1203        return ptr::null_mut();
1204    }
1205    if !ctxt.head.is_null() {
1206        return ctxt.head;
1207    }
1208
1209    // Ensure html exists first
1210    ensure_html(ctxt);
1211
1212    let head_node = tree::new_node(ptr::null_mut(), b"head\0" as *const u8 as *const xmlChar);
1213    if head_node.is_null() {
1214        return ptr::null_mut();
1215    }
1216    ctxt.head = head_node;
1217    ctxt.head_created = true;
1218
1219    // Add as child of html
1220    tree::add_child(ctxt.html, head_node);
1221    ctxt.current = head_node;
1222    ctxt.in_head = true;
1223
1224    head_node
1225}
1226
1227/// Ensure the body element exists, creating it implicitly if needed.
1228unsafe fn ensure_body(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1229    if ctxt.options & HTML_PARSE_NOIMPLIED != 0 {
1230        return ptr::null_mut();
1231    }
1232    if !ctxt.body.is_null() {
1233        return ctxt.body;
1234    }
1235
1236    // Ensure html exists first
1237    ensure_html(ctxt);
1238
1239    let body_node = tree::new_node(ptr::null_mut(), b"body\0" as *const u8 as *const xmlChar);
1240    if body_node.is_null() {
1241        return ptr::null_mut();
1242    }
1243    ctxt.body = body_node;
1244    ctxt.body_created = true;
1245
1246    // Add as child of html
1247    tree::add_child(ctxt.html, body_node);
1248    ctxt.current = body_node;
1249    ctxt.in_body = true;
1250
1251    body_node
1252}
1253
1254/// Transition from head to body when body content is encountered.
1255#[allow(dead_code)]
1256unsafe fn transition_to_body(ctxt: &mut HtmlParserCtxt) {
1257    if ctxt.in_head && !ctxt.seen_body_content {
1258        ctxt.seen_body_content = true;
1259        ctxt.in_head = false;
1260        ensure_body(ctxt);
1261    }
1262}
1263
1264// ═══════════════════════════════════════════════════════════════════════════════
1265// Tokenizer
1266// ═══════════════════════════════════════════════════════════════════════════════
1267
1268/// Result of parsing an attribute.
1269struct HtmlAttr {
1270    name: Vec<u8>,
1271    value: Vec<u8>,
1272    #[allow(dead_code)]
1273    quoted: bool,
1274}
1275
1276/// Parse an attribute name.
1277fn parse_attr_name(ctxt: &mut HtmlParserCtxt) -> Vec<u8> {
1278    let mut name = Vec::new();
1279    while let Some(ch) = ctxt.peek() {
1280        if ch == b'='
1281            || ch == b'>'
1282            || ch == b'/'
1283            || ch == b' '
1284            || ch == b'\t'
1285            || ch == b'\n'
1286            || ch == b'\r'
1287        {
1288            break;
1289        }
1290        name.push(ch);
1291        ctxt.next();
1292    }
1293    name
1294}
1295
1296/// Parse an attribute value (may be quoted or unquoted).
1297fn parse_attr_value(ctxt: &mut HtmlParserCtxt) -> (Vec<u8>, bool) {
1298    ctxt.skip_whitespace();
1299
1300    let quote = match ctxt.peek() {
1301        Some(b'"') => {
1302            ctxt.next(); // consume opening quote
1303            b'"'
1304        }
1305        Some(b'\'') => {
1306            ctxt.next(); // consume opening quote
1307            b'\''
1308        }
1309        _ => {
1310            // Unquoted value
1311            let value = ctxt.read_while(|ch| {
1312                ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1313            });
1314            return (value, false);
1315        }
1316    };
1317
1318    // Quoted value
1319    let mut value = Vec::new();
1320    loop {
1321        match ctxt.next() {
1322            Some(ch) if ch == quote => break,
1323            Some(ch) => value.push(ch),
1324            None => break,
1325        }
1326    }
1327    (value, true)
1328}
1329
1330/// Parse attributes until we hit '>' or end of tag.
1331fn parse_attributes(ctxt: &mut HtmlParserCtxt) -> Vec<HtmlAttr> {
1332    let mut attrs = Vec::new();
1333
1334    loop {
1335        ctxt.skip_whitespace();
1336
1337        match ctxt.peek() {
1338            Some(b'>') | None => break,
1339            Some(b'/')
1340                // Could be self-closing tag like <br/>
1341                if ctxt.peek_at(1) == Some(b'>') => {
1342                    break;
1343                }
1344                // Otherwise it's part of a minimized attribute or path
1345            _ => {}
1346        }
1347
1348        let name = parse_attr_name(ctxt);
1349        if name.is_empty() {
1350            break;
1351        }
1352
1353        // Check for '='
1354        ctxt.skip_whitespace();
1355        if ctxt.peek() == Some(b'=') {
1356            ctxt.next(); // consume '='
1357            let (value, quoted) = parse_attr_value(ctxt);
1358            attrs.push(HtmlAttr {
1359                name,
1360                value,
1361                quoted,
1362            });
1363        } else {
1364            // Minimized attribute (e.g., <option selected>)
1365            attrs.push(HtmlAttr {
1366                name,
1367                value: Vec::new(),
1368                quoted: false,
1369            });
1370        }
1371    }
1372
1373    attrs
1374}
1375
1376// ═══════════════════════════════════════════════════════════════════════════════
1377// Tree Builder
1378// ═══════════════════════════════════════════════════════════════════════════════
1379
1380/// Process a resolved HTML entity reference and return the replacement bytes.
1381fn resolve_entity(name: &str) -> Vec<u8> {
1382    if let Some(replacement) = html_entity_lookup(name) {
1383        replacement.as_bytes().to_vec()
1384    } else {
1385        // Unknown entity: leave as-is (pass through as text)
1386        let mut result = Vec::new();
1387        result.push(b'&');
1388        result.extend_from_slice(name.as_bytes());
1389        result.push(b';');
1390        result
1391    }
1392}
1393
1394/// Handle a numeric character reference (decimal or hex).
1395fn resolve_numeric_entity(value: &str, is_hex: bool) -> Vec<u8> {
1396    let codepoint = if is_hex {
1397        u32::from_str_radix(value, 16).unwrap_or(0xFFFD)
1398    } else {
1399        value.parse::<u32>().unwrap_or(0xFFFD)
1400    };
1401
1402    if codepoint == 0 {
1403        return Vec::new();
1404    }
1405
1406    // Convert codepoint to UTF-8
1407    match char::from_u32(codepoint) {
1408        Some(c) => {
1409            let mut buf = [0u8; 4];
1410            let s = c.encode_utf8(&mut buf);
1411            s.as_bytes().to_vec()
1412        }
1413        None => vec![0xEF, 0xBF, 0xBD], // replacement character
1414    }
1415}
1416
1417/// Parse an entity reference starting at current position (which points to '&').
1418/// Returns the replacement text and advances position.
1419fn parse_entity(ctxt: &mut HtmlParserCtxt) -> Vec<u8> {
1420    // We should be at '&'
1421    match ctxt.peek() {
1422        Some(b'&') => {
1423            ctxt.next(); // consume '&'
1424        }
1425        _ => return vec![b'&'],
1426    }
1427
1428    // Check for numeric entities
1429    if ctxt.peek() == Some(b'#') {
1430        ctxt.next(); // consume '#'
1431        let is_hex = ctxt.peek() == Some(b'x') || ctxt.peek() == Some(b'X');
1432        if is_hex {
1433            ctxt.next(); // consume 'x' or 'X'
1434        }
1435
1436        let digits = ctxt.read_while(|ch| {
1437            if is_hex {
1438                ch.is_ascii_hexdigit()
1439            } else {
1440                ch.is_ascii_digit()
1441            }
1442        });
1443
1444        let digits_str = core::str::from_utf8(&digits).unwrap_or("");
1445        if digits_str.is_empty() {
1446            let mut result = vec![b'&', b'#'];
1447            if is_hex {
1448                result.push(b'x');
1449            }
1450            return result;
1451        }
1452
1453        // Expect semicolon
1454        if ctxt.peek() == Some(b';') {
1455            ctxt.next();
1456        }
1457
1458        return resolve_numeric_entity(digits_str, is_hex);
1459    }
1460
1461    // Named entity
1462    let name = ctxt.read_while(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-');
1463    let name_str = core::str::from_utf8(&name).unwrap_or("");
1464
1465    // Expect semicolon
1466    if ctxt.peek() == Some(b';') {
1467        ctxt.next();
1468    }
1469
1470    resolve_entity(name_str)
1471}
1472
1473/// Handle text content in the tree builder.
1474///
1475/// # Safety
1476///
1477/// - `ctxt` must be a valid `HtmlParserCtxt` with a non-NULL `doc` pointing
1478///   to a valid `_xmlDoc`.
1479/// - The `head`, `body`, `html` and `current` fields, when non-NULL, must be
1480///   valid `_xmlNode` pointers owned by that document tree.
1481/// - Nodes returned by `tree::new_text` and `tree::new_node` are NULL-checked
1482///   before their fields are written, and `tree::add_child` requires a valid
1483///   non-NULL parent node.
1484unsafe fn handle_text(ctxt: &mut HtmlParserCtxt, text: &[u8]) {
1485    if text.is_empty() {
1486        return;
1487    }
1488
1489    // Determine current insertion point
1490    let parent = if ctxt.in_head {
1491        ctxt.head
1492    } else if ctxt.in_body || ctxt.body_created {
1493        ctxt.body
1494    } else if ctxt.html_created {
1495        ctxt.html
1496    } else {
1497        ctxt.doc as *mut _xmlNode
1498    };
1499
1500    let insertion_point = if ctxt.current.is_null() {
1501        parent
1502    } else {
1503        ctxt.current
1504    };
1505
1506    if insertion_point.is_null() {
1507        // Fall back to document
1508        let text_node = tree::new_text(ptr::null_mut());
1509        if !text_node.is_null() {
1510            // Create a null-terminated copy
1511            let content = bytes_to_xmlstr(text);
1512            if !content.is_null() {
1513                unsafe {
1514                    (*text_node).content = content;
1515                }
1516            }
1517            tree::add_child(ctxt.doc as *mut _xmlNode, text_node);
1518        }
1519        return;
1520    }
1521
1522    // UPSTREAM-PARITY: whitespace-only text at the document level (before or
1523    // after the root element) is discarded; non-whitespace stray text is
1524    // wrapped in a new `html` element (htmlParseCharData behavior), with
1525    // leading blank characters skipped.
1526    if insertion_point == ctxt.doc as *mut _xmlNode {
1527        if text.iter().all(|b| b.is_ascii_whitespace()) {
1528            return;
1529        }
1530        let content = trim_ascii_start(text);
1531        let html_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(b"html"));
1532        if !html_node.is_null() {
1533            let content_c = bytes_to_xmlstr(content);
1534            let text_node = tree::new_text(ptr::null_mut());
1535            if !text_node.is_null() {
1536                unsafe {
1537                    (*text_node).content = content_c;
1538                }
1539                tree::add_child(html_node, text_node);
1540            }
1541            tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1542            ctxt.html = html_node;
1543            ctxt.html_created = true;
1544            ctxt.current = html_node;
1545        }
1546        return;
1547    }
1548
1549    let text_node = tree::new_text(ptr::null_mut());
1550    if text_node.is_null() {
1551        return;
1552    }
1553
1554    // Set the content
1555    let content = bytes_to_xmlstr(text);
1556    if !content.is_null() {
1557        unsafe {
1558            (*text_node).content = content;
1559        }
1560    }
1561
1562    tree::add_child(insertion_point, text_node);
1563}
1564
1565/// Attach parsed HTML attributes to a freshly created element node.
1566///
1567/// # Safety
1568///
1569/// - `node` must be a valid element node; `attrs` a live slice of parsed
1570///   attributes.
1571unsafe fn attach_attrs(node: *mut _xmlNode, attrs: &[HtmlAttr]) {
1572    for attr in attrs {
1573        let name_c = bytes_to_xmlstr(&attr.name);
1574        let val_c = bytes_to_xmlstr(&attr.value);
1575        if !name_c.is_null() {
1576            tree::set_prop(node, name_c, val_c);
1577            xmlFreeImpl(name_c as *mut c_void);
1578            if !val_c.is_null() {
1579                xmlFreeImpl(val_c as *mut c_void);
1580            }
1581        }
1582    }
1583}
1584
1585/// Process a start tag in the tree builder.
1586unsafe fn handle_start_tag(ctxt: &mut HtmlParserCtxt, tag_name: &[u8], attrs: &[HtmlAttr]) {
1587    let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1588    let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1589
1590    let info = html_tag_lookup(tag_str);
1591
1592    // Determine tag category
1593    let is_head_tag = info.is_some_and(|i| i.flags & HTML_HEAD != 0);
1594    let _is_body_tag = info.is_some_and(|i| i.flags & HTML_BODY != 0);
1595    let is_empty = info.is_some_and(|i| i.flags & HTML_EMPTY != 0);
1596    let _is_block = info.is_some_and(|i| i.flags & HTML_BLOCK != 0);
1597
1598    // Handle special elements
1599    if tag_str == "html" {
1600        if !ctxt.html.is_null() && !ctxt.html_created {
1601            // Second <html> tag, skip it
1602            return;
1603        }
1604        // Create or use existing html
1605        if ctxt.html.is_null() {
1606            let html_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1607            if !html_node.is_null() {
1608                attach_attrs(html_node, attrs);
1609                ctxt.html = html_node;
1610                ctxt.html_created = false; // parsed, not implied
1611                tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1612                ctxt.current = html_node;
1613            }
1614        } else {
1615            // html already auto-created, just set current
1616            ctxt.current = ctxt.html;
1617        }
1618        return;
1619    }
1620
1621    if tag_str == "head" {
1622        if !ctxt.head.is_null() && !ctxt.head_created {
1623            // Second <head> tag, skip it
1624            return;
1625        }
1626        // Ensure html exists
1627        ensure_html(ctxt);
1628
1629        if ctxt.head.is_null() {
1630            let head_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1631            if !head_node.is_null() {
1632                attach_attrs(head_node, attrs);
1633                ctxt.head = head_node;
1634                ctxt.head_created = false;
1635                // UPSTREAM-PARITY: a source <head> without an <html> parent
1636                // becomes a top-level element under HTML_PARSE_NOIMPLIED.
1637                let parent = if ctxt.html.is_null() {
1638                    ctxt.doc as *mut _xmlNode
1639                } else {
1640                    ctxt.html
1641                };
1642                tree::add_child(parent, head_node);
1643                ctxt.current = head_node;
1644                ctxt.in_head = true;
1645            }
1646        } else {
1647            ctxt.current = ctxt.head;
1648            ctxt.in_head = true;
1649        }
1650        return;
1651    }
1652
1653    if tag_str == "body" {
1654        if !ctxt.body.is_null() && !ctxt.body_created {
1655            // Second <body> tag, skip it
1656            return;
1657        }
1658        // Ensure html exists
1659        ensure_html(ctxt);
1660
1661        if ctxt.body.is_null() {
1662            let body_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1663            if !body_node.is_null() {
1664                attach_attrs(body_node, attrs);
1665                ctxt.body = body_node;
1666                ctxt.body_created = false;
1667                // UPSTREAM-PARITY: a source <body> without an <html> parent
1668                // becomes a top-level element under HTML_PARSE_NOIMPLIED.
1669                let parent = if ctxt.html.is_null() {
1670                    ctxt.doc as *mut _xmlNode
1671                } else {
1672                    ctxt.html
1673                };
1674                tree::add_child(parent, body_node);
1675                ctxt.current = body_node;
1676                ctxt.in_body = true;
1677                ctxt.in_head = false;
1678                ctxt.seen_body_content = true;
1679            }
1680        } else {
1681            ctxt.current = ctxt.body;
1682            ctxt.in_body = true;
1683            ctxt.in_head = false;
1684            ctxt.seen_body_content = true;
1685        }
1686        return;
1687    }
1688
1689    // For head-only elements (<title>, <meta>, <link>, <style>, <script>)
1690    if is_head_tag && !ctxt.seen_body_content {
1691        if ctxt.head.is_null() {
1692            ensure_head(ctxt);
1693        }
1694
1695        if is_empty {
1696            // Void element in head
1697            let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1698            if !node.is_null() {
1699                attach_attrs(node, attrs);
1700                // UPSTREAM-PARITY (NOIMPLIED): top-level head-only elements
1701                // become document children.
1702                let ip = if ctxt.current.is_null() {
1703                    ctxt.doc as *mut _xmlNode
1704                } else {
1705                    ctxt.current
1706                };
1707                tree::add_child(ip, node);
1708            }
1709            return;
1710        }
1711
1712        let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1713        if !node.is_null() {
1714            attach_attrs(node, attrs);
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            ctxt.current = node;
1722        }
1723        return;
1724    }
1725
1726    // Body content - transition from head if needed
1727    if !is_head_tag || ctxt.seen_body_content {
1728        if !ctxt.seen_body_content {
1729            ctxt.seen_body_content = true;
1730            ctxt.in_head = false;
1731            // UPSTREAM-PARITY (HTML_PARSE_NOIMPLIED): body content at the top
1732            // of a no-implied parse stays at document level (ctxt.current
1733            // NULL) instead of materialising the html/body skeleton.
1734            if ctxt.options & HTML_PARSE_NOIMPLIED != 0 {
1735                // current stays NULL -> top-level nodes attach to the doc
1736            } else if ctxt.body.is_null() {
1737                ensure_body(ctxt);
1738            } else {
1739                ctxt.current = ctxt.body;
1740                ctxt.in_body = true;
1741            }
1742        } else if ctxt.body.is_null() && ctxt.options & HTML_PARSE_NOIMPLIED == 0 {
1743            ensure_body(ctxt);
1744        }
1745    }
1746
1747    // Auto-close elements as needed
1748    if !ctxt.current.is_null() {
1749        auto_close_element(ctxt, tag_str);
1750    }
1751
1752    if is_empty {
1753        // Void element: create node, add attributes, add as child (no children)
1754        let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1755        if !node.is_null() {
1756            for attr in attrs {
1757                let name_c = bytes_to_xmlstr(&attr.name);
1758                let val_c = bytes_to_xmlstr(&attr.value);
1759                if !name_c.is_null() {
1760                    tree::set_prop(node, name_c, val_c);
1761                    xmlFreeImpl(name_c as *mut c_void);
1762                    if !val_c.is_null() {
1763                        xmlFreeImpl(val_c as *mut c_void);
1764                    }
1765                }
1766            }
1767            let insertion_point = if ctxt.current.is_null() {
1768                if ctxt.options & HTML_PARSE_NOIMPLIED != 0 {
1769                    ctxt.doc as *mut _xmlNode
1770                } else {
1771                    ctxt.body
1772                }
1773            } else {
1774                ctxt.current
1775            };
1776            if !insertion_point.is_null() {
1777                tree::add_child(insertion_point, node);
1778            }
1779        }
1780        return;
1781    }
1782
1783    // Regular element
1784    let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1785    if !node.is_null() {
1786        for attr in attrs {
1787            let name_c = bytes_to_xmlstr(&attr.name);
1788            let val_c = bytes_to_xmlstr(&attr.value);
1789            if !name_c.is_null() {
1790                tree::set_prop(node, name_c, val_c);
1791                xmlFreeImpl(name_c as *mut c_void);
1792                if !val_c.is_null() {
1793                    xmlFreeImpl(val_c as *mut c_void);
1794                }
1795            }
1796        }
1797
1798        let insertion_point = if ctxt.current.is_null() {
1799            if ctxt.in_body || ctxt.body_created {
1800                ctxt.body
1801            } else if ctxt.in_head || ctxt.head_created {
1802                ctxt.head
1803            } else if ctxt.html_created {
1804                ctxt.html
1805            } else {
1806                ctxt.doc as *mut _xmlNode
1807            }
1808        } else {
1809            ctxt.current
1810        };
1811
1812        if !insertion_point.is_null() {
1813            tree::add_child(insertion_point, node);
1814            // For non-void elements, this becomes the new insertion point
1815            ctxt.current = node;
1816        }
1817    }
1818}
1819
1820/// Process an end tag in the tree builder.
1821///
1822/// # Safety
1823///
1824/// - `ctxt` must be a valid `HtmlParserCtxt` whose `current` field is NULL or
1825///   points into a well-formed node tree; every node reached through the
1826///   `parent` link must be a valid `_xmlNode` and the chain must terminate in
1827///   a NULL pointer (no cycles).
1828/// - Element `name` pointers visited must be NULL or valid NUL-terminated
1829///   `xmlChar` strings.
1830unsafe fn handle_end_tag(ctxt: &mut HtmlParserCtxt, tag_name: &[u8]) {
1831    let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1832    let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1833
1834    let info = html_tag_lookup(tag_str);
1835
1836    // For elements with no end tag (void elements or optional end tags),
1837    // just ignore the end tag.
1838    if info.is_some_and(|i| i.flags & HTML_EMPTY != 0) {
1839        return;
1840    }
1841
1842    if tag_str == "html" {
1843        ctxt.current = ctxt.doc as *mut _xmlNode;
1844        return;
1845    }
1846
1847    if tag_str == "head" {
1848        ctxt.in_head = false;
1849        ctxt.current = ctxt.html;
1850        return;
1851    }
1852
1853    if tag_str == "body" {
1854        ctxt.in_body = false;
1855        ctxt.current = ctxt.html;
1856        return;
1857    }
1858
1859    // Walk up the tree to find a matching open element
1860    let mut cur = ctxt.current;
1861    while !cur.is_null() {
1862        let ctype = unsafe { (*cur).type_ };
1863        if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur).name.is_null() } {
1864            let name_bytes = unsafe { xmlstr_to_bytes((*cur).name) };
1865            if name_bytes.eq_ignore_ascii_case(tag_name) {
1866                // Found the matching element - close by moving current to parent
1867                ctxt.current = unsafe { (*cur).parent };
1868                return;
1869            }
1870        }
1871        cur = unsafe { (*cur).parent };
1872    }
1873
1874    // If no matching element found, ignore the end tag (tag-recovery behavior)
1875}
1876
1877// ═══════════════════════════════════════════════════════════════════════════════
1878// Main Parse Function
1879// ═══════════════════════════════════════════════════════════════════════════════
1880
1881/// Convert `data` from the declared input encoding to UTF-8.
1882///
1883/// UPSTREAM-PARITY: `xmlCtxtNewInputFromMemory` -> `xmlSwitchInputEncoding`
1884/// installs a decoder on the input buffer so the parser (and the tree it
1885/// builds) always consume UTF-8. Returns `None` when no conversion applies
1886/// (NULL encoding, UTF-8/ASCII input, or an encoding the crate cannot
1887/// convert — the caller then parses the raw bytes, matching upstream's
1888/// recover-with-raw-bytes behavior when switching fails).
1889fn convert_input_to_utf8(encoding: *const c_char, data: &[u8]) -> Option<Vec<u8>> {
1890    if encoding.is_null() {
1891        return None;
1892    }
1893    let name = unsafe { core::ffi::CStr::from_ptr(encoding).to_bytes() };
1894    match crate::xml::encoding::encoding_from_name(name) {
1895        xmlCharEncoding::XML_CHAR_ENCODING_8859_1 => {
1896            Some(crate::xml::encoding::latin1_to_utf8(data))
1897        }
1898        xmlCharEncoding::XML_CHAR_ENCODING_UTF16LE => {
1899            crate::xml::encoding::utf16le_to_utf8(data).ok()
1900        }
1901        xmlCharEncoding::XML_CHAR_ENCODING_UTF16BE => {
1902            crate::xml::encoding::utf16be_to_utf8(data).ok()
1903        }
1904        // UTF-32/UCS-4 (R-000157): lxml feeds PEP-393 KIND-4 python strings to
1905        // htmlCtxtReadMemory as UTF-32LE/BE raw buffers; without conversion the
1906        // parser would consume the raw 4-byte units as UTF-8 and garble the
1907        // tree (wide-character HTML through etree.HTML crashed teardown).
1908        xmlCharEncoding::XML_CHAR_ENCODING_UCS4LE => {
1909            crate::xml::encoding::ucs4le_to_utf8(data).ok()
1910        }
1911        xmlCharEncoding::XML_CHAR_ENCODING_UCS4BE => {
1912            crate::xml::encoding::ucs4be_to_utf8(data).ok()
1913        }
1914        // UTF-8 / US-ASCII / NONE (and unsupported encodings): no conversion.
1915        _ => None,
1916    }
1917}
1918
1919/// Parse a leading `<!DOCTYPE ...>` declaration from HTML input bytes.
1920///
1921/// Returns `Some((name, external_id, system_id))` when the input begins (after
1922/// optional whitespace) with a case-insensitive `<!DOCTYPE`. Quoted external
1923/// and system identifiers are unwrapped; unquoted identifiers are taken as-is
1924/// up to the closing `>`. Returns `None` when no DOCTYPE is declared.
1925fn parse_html_doctype_decl(input: &[u8]) -> Option<(Vec<u8>, Option<Vec<u8>>, Option<Vec<u8>>)> {
1926    let mut i = 0usize;
1927    while i < input.len() && input[i].is_ascii_whitespace() {
1928        i += 1;
1929    }
1930    if i + 2 >= input.len() || input[i] != b'<' || !(input[i + 1] == b'!') {
1931        return None;
1932    }
1933    let kw = b"DOCTYPE";
1934    if !input.get(i + 2..i + 2 + kw.len()).is_some_and(|s| {
1935        s.iter()
1936            .enumerate()
1937            .all(|(k, b)| b.to_ascii_uppercase() == kw[k])
1938    }) {
1939        return None;
1940    }
1941    i += 2 + kw.len();
1942    // Skip whitespace after DOCTYPE, then read the root name.
1943    while i < input.len() && input[i].is_ascii_whitespace() {
1944        i += 1;
1945    }
1946    let name_start = i;
1947    while i < input.len() && !input[i].is_ascii_whitespace() && input[i] != b'>' {
1948        i += 1;
1949    }
1950    // A nameless `<!DOCTYPE>` (nothing before the closing '>') is still a
1951    // declared DOCTYPE — upstream htmlParseDocTypeDecl fires internalSubset
1952    // with a NULL name (gh17500/bug78025: doctype->name is ""), it must NOT
1953    // fall through to the default HTML 4.0 DTD.
1954    let name = if name_start == i {
1955        Vec::new()
1956    } else {
1957        input[name_start..i].to_vec()
1958    };
1959    // If the nameless doctype ends right at '>', there are no ids either.
1960    if name.is_empty() {
1961        return Some((name, None, None));
1962    }
1963    // Skip whitespace, then optional PUBLIC/SYSTEM id.
1964    let mut ext: Option<Vec<u8>> = None;
1965    let mut sys: Option<Vec<u8>> = None;
1966    while i < input.len() && input[i].is_ascii_whitespace() {
1967        i += 1;
1968    }
1969    if i < input.len() && input[i] != b'>' {
1970        // a PUBLIC/SYSTEM keyword may follow
1971        let word_start = i;
1972        while i < input.len() && input[i].is_ascii_alphabetic() {
1973            i += 1;
1974        }
1975        let word = input[word_start..i].to_ascii_uppercase();
1976        if word == b"PUBLIC" {
1977            while i < input.len() && input[i].is_ascii_whitespace() {
1978                i += 1;
1979            }
1980            // external identifier (PUBLIC) comes first
1981            if i < input.len() && (input[i] == b'"' || input[i] == b'\'') {
1982                let q = input[i];
1983                i += 1;
1984                let v_start = i;
1985                while i < input.len() && input[i] != q {
1986                    i += 1;
1987                }
1988                ext = Some(input[v_start..i].to_vec());
1989                if i < input.len() {
1990                    i += 1;
1991                }
1992            }
1993            while i < input.len() && input[i].is_ascii_whitespace() {
1994                i += 1;
1995            }
1996            // system literal (may be absent)
1997            if i < input.len()
1998                && i + 1 < input.len()
1999                && (input[i] == b'"' || input[i] == b'\'')
2000                && input[i] != b'>'
2001            {
2002                let q = input[i];
2003                i += 1;
2004                let v_start = i;
2005                while i < input.len() && input[i] != q {
2006                    i += 1;
2007                }
2008                sys = Some(input[v_start..i].to_vec());
2009            }
2010        } else if word == b"SYSTEM" {
2011            while i < input.len() && input[i].is_ascii_whitespace() {
2012                i += 1;
2013            }
2014            if i < input.len() && (input[i] == b'"' || input[i] == b'\'') {
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        }
2024    }
2025    Some((name, ext, sys))
2026}
2027
2028/// Parse HTML from a buffer.
2029///
2030/// # Safety
2031///
2032/// - `buffer` must point to valid memory of at least `size` bytes.
2033unsafe fn html_parse_buffer(
2034    ctxt: &mut HtmlParserCtxt,
2035    buffer: *const c_char,
2036    size: c_int,
2037) -> *mut _xmlDoc {
2038    if buffer.is_null() || size <= 0 {
2039        return ptr::null_mut();
2040    }
2041
2042    // Create document with HTML_DOCUMENT_NODE type
2043    let doc = tree::new_doc(ptr::null());
2044    if doc.is_null() {
2045        return ptr::null_mut();
2046    }
2047    unsafe {
2048        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2049        // UPSTREAM-PARITY: HTML documents carry no version (htmlNewDocNoDtD
2050        // leaves version NULL), so drop the XML default set by new_doc.
2051        if !(*doc).version.is_null() {
2052            crate::abi::allocator::xmlFreeImpl((*doc).version as *mut c_void);
2053        }
2054        (*doc).version = ptr::null_mut();
2055        // UPSTREAM-PARITY (SAX2.c xmlSAX2StartDocument for HTML parsers): an
2056        // html-parsed document carries properties = XML_DOC_HTML. The
2057        // pre-fix XML_DOC_WELLFORMED-only value made PHP's spec serializer
2058        // treat html-parsed documents as XML and drop the standalone/HTML
2059        // declaration handling (ext/dom dom005 / gh15670 / gh17397 — the
2060        // saveXML of a loadHTML'd document lost "standalone=yes").
2061        (*doc).properties = crate::abi::types::xmlDocProperties::XML_DOC_HTML as c_int;
2062        // UPSTREAM-PARITY: HTML documents default to standalone="yes"
2063        // (visible when serialized with the XML serializer, e.g. --xmlout).
2064        (*doc).standalone = 1;
2065    }
2066    // UPSTREAM-PARITY: htmlParseDocument honours a DOCTYPE declared in the
2067    // source (htmlParseDocTypeDecl fills doc->intSubset with the declared
2068    // name/public/system ids); only when the source declares none does it
2069    // create the default HTML 4.0 DTD. nokogiri reads those ids back for
2070    // dtd.html_dtd?/html5_dtd?, so a source `<!DOCTYPE html>` must NOT pick up
2071    // the default HTML 4.0 DTD.
2072    let raw_input = unsafe { slice::from_raw_parts(buffer as *const u8, size as usize) };
2073    if let Some((name, ext, sys)) = parse_html_doctype_decl(raw_input) {
2074        // UPSTREAM-PARITY (htmlSAX2InternalSubset / xmlCreateIntSubset): a
2075        // declared DOCTYPE without a name creates the internal subset with a
2076        // NULL name (php doctype->name reads back "").
2077        let name_cstr = if name.is_empty() {
2078            ptr::null()
2079        } else {
2080            crate::xml::string::bytes_to_xmlstr(&name)
2081        };
2082        let ext_cstr = ext
2083            .map(|s| crate::xml::string::bytes_to_xmlstr(&s))
2084            .unwrap_or(ptr::null_mut());
2085        let sys_cstr = sys
2086            .map(|s| crate::xml::string::bytes_to_xmlstr(&s))
2087            .unwrap_or(ptr::null_mut());
2088        unsafe {
2089            crate::xml::dtd::create_int_subset(
2090                doc,
2091                name_cstr as *const xmlChar,
2092                ext_cstr as *const xmlChar,
2093                sys_cstr as *const xmlChar,
2094            );
2095        }
2096        if !name_cstr.is_null() {
2097            unsafe { crate::abi::allocator::xmlFreeImpl(name_cstr as *mut c_void) };
2098        }
2099        if !ext_cstr.is_null() {
2100            unsafe { crate::abi::allocator::xmlFreeImpl(ext_cstr as *mut c_void) };
2101        }
2102        if !sys_cstr.is_null() {
2103            unsafe { crate::abi::allocator::xmlFreeImpl(sys_cstr as *mut c_void) };
2104        }
2105    } else {
2106        // Source declares no DOCTYPE: use the default HTML 4.0 DTD — unless
2107        // HTML_PARSE_NODEFDTD suppresses it (php LIBXML_HTML_NODEFDTD).
2108        if ctxt.options & HTML_PARSE_NODEFDTD == 0 {
2109            unsafe {
2110                crate::xml::dtd::create_int_subset(
2111                    doc,
2112                    b"html\0" as *const u8 as *const xmlChar,
2113                    b"-//W3C//DTD HTML 4.0 Transitional//EN\0" as *const u8 as *const xmlChar,
2114                    b"http://www.w3.org/TR/REC-html40/loose.dtd\0" as *const u8 as *const xmlChar,
2115                );
2116            }
2117        }
2118    }
2119    ctxt.doc = doc;
2120
2121    // UPSTREAM-PARITY: a declared input encoding is converted to UTF-8 before
2122    // parsing (xmlCtxtNewInputFromMemory -> xmlSwitchInputEncodingName installs
2123    // an input-buffer decoder); the parse loop and the tree always consume
2124    // UTF-8. The converted copy lives for the whole parse.
2125    let converted: Option<Vec<u8>> = if !ctxt.encoding.is_null() {
2126        let raw = unsafe { slice::from_raw_parts(buffer as *const u8, size as usize) };
2127        convert_input_to_utf8(ctxt.encoding, raw)
2128    } else {
2129        None
2130    };
2131    let (input_ptr, input_len): (*const u8, usize) = match &converted {
2132        Some(v) => (v.as_ptr(), v.len()),
2133        None => (buffer as *const u8, size as usize),
2134    };
2135
2136    // Set up input
2137    ctxt.input = input_ptr as *mut u8;
2138    ctxt.input_len = input_len;
2139    ctxt.input_pos = 0;
2140    ctxt.line = 1;
2141
2142    // Main parse loop
2143    loop {
2144        if ctxt.is_eof() {
2145            break;
2146        }
2147
2148        let ch = ctxt.peek().unwrap_or(0);
2149
2150        if ch == b'<' {
2151            ctxt.next(); // consume '<'
2152
2153            // Check for </ (end tag)
2154            if ctxt.peek() == Some(b'/') {
2155                ctxt.next(); // consume '/'
2156                let tag_name = ctxt.read_while(|ch| {
2157                    ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
2158                });
2159
2160                // Consume until '>'
2161                while ctxt.peek() != Some(b'>') && !ctxt.is_eof() {
2162                    ctxt.next();
2163                }
2164                if ctxt.peek() == Some(b'>') {
2165                    ctxt.next(); // consume '>'
2166                }
2167
2168                if !tag_name.is_empty() {
2169                    handle_end_tag(ctxt, &tag_name);
2170                }
2171                continue;
2172            }
2173
2174            // Check for <!-- (comment)
2175            if ctxt.peek() == Some(b'!')
2176                && ctxt.peek_at(1) == Some(b'-')
2177                && ctxt.peek_at(2) == Some(b'-')
2178            {
2179                ctxt.next(); // consume '!'
2180                ctxt.next(); // consume '-'
2181                ctxt.next(); // consume '-'
2182
2183                // Read until -->
2184                let mut comment_content = Vec::new();
2185                loop {
2186                    if ctxt.peek() == Some(b'-')
2187                        && ctxt.peek_at(1) == Some(b'-')
2188                        && ctxt.peek_at(2) == Some(b'>')
2189                    {
2190                        ctxt.next(); // consume '-'
2191                        ctxt.next(); // consume '-'
2192                        ctxt.next(); // consume '>'
2193                        break;
2194                    }
2195                    match ctxt.next() {
2196                        Some(ch) => comment_content.push(ch),
2197                        None => break,
2198                    }
2199                }
2200
2201                // Create comment node
2202                if !comment_content.is_empty() {
2203                    let comment_node = tree::new_comment(bytes_to_xmlstr(&comment_content));
2204                    if !comment_node.is_null() {
2205                        let insertion_point = if !ctxt.current.is_null() {
2206                            ctxt.current
2207                        } else {
2208                            ctxt.doc as *mut _xmlNode
2209                        };
2210                        tree::add_child(insertion_point, comment_node);
2211                    }
2212                }
2213                continue;
2214            }
2215
2216            // Check for <!DOCTYPE
2217            if ctxt.peek() == Some(b'!') {
2218                ctxt.next(); // consume '!'
2219                let _rest = ctxt.read_while(|ch| ch != b'>');
2220                if ctxt.peek() == Some(b'>') {
2221                    ctxt.next(); // consume '>'
2222                }
2223                // We don't create a DTD node from HTML DOCTYPE in this implementation
2224                // (matching basic libxml2 behavior where HTML doctype is mostly ignored)
2225                continue;
2226            }
2227
2228            // Check for <? (processing instruction)
2229            if ctxt.peek() == Some(b'?') {
2230                ctxt.next(); // consume '?'
2231                             // Read until we see ?>
2232                let mut pi_content = Vec::new();
2233                loop {
2234                    if ctxt.peek() == Some(b'?') && ctxt.peek_at(1) == Some(b'>') {
2235                        break;
2236                    }
2237                    match ctxt.next() {
2238                        Some(ch) => pi_content.push(ch),
2239                        None => break,
2240                    }
2241                }
2242                // Consume ?>
2243                if ctxt.peek() == Some(b'?') {
2244                    ctxt.next();
2245                }
2246                if ctxt.peek() == Some(b'>') {
2247                    ctxt.next();
2248                }
2249                // Create PI node
2250                if !pi_content.is_empty() {
2251                    // Split into target and value
2252                    let mut parts = pi_content.splitn(2, |b| *b == b' ');
2253                    let target = parts.next().unwrap_or(&pi_content);
2254                    let value = parts.next().unwrap_or(b"");
2255
2256                    let pi_node = tree::new_pi(bytes_to_xmlstr(target), bytes_to_xmlstr(value));
2257                    if !pi_node.is_null() {
2258                        let insertion_point = if !ctxt.current.is_null() {
2259                            ctxt.current
2260                        } else {
2261                            ctxt.doc as *mut _xmlNode
2262                        };
2263                        tree::add_child(insertion_point, pi_node);
2264                    }
2265                }
2266                continue;
2267            }
2268
2269            // Parse start tag
2270            let tag_name = ctxt.read_while(|ch| {
2271                ch != b'>' && ch != b'/' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
2272            });
2273
2274            if tag_name.is_empty() {
2275                // Just a bare '<' with no tag name, treat as text
2276                handle_text(ctxt, b"<");
2277                continue;
2278            }
2279
2280            // Parse attributes
2281            let attrs = parse_attributes(ctxt);
2282
2283            // Check for self-closing (/>) or just >
2284            if ctxt.peek() == Some(b'/') {
2285                ctxt.next(); // consume '/'
2286                if ctxt.peek() == Some(b'>') {
2287                    ctxt.next(); // consume '>'
2288                }
2289            } else if ctxt.peek() == Some(b'>') {
2290                ctxt.next(); // consume '>'
2291            }
2292
2293            // Check if this is a raw text element (script, style)
2294            let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
2295            let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
2296
2297            if tag_str == "script" || tag_str == "style" {
2298                // Handle raw text content
2299                // Create the element first
2300                let raw_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(&tag_name));
2301                if !raw_node.is_null() {
2302                    for attr in &attrs {
2303                        let name_c = bytes_to_xmlstr(&attr.name);
2304                        let val_c = bytes_to_xmlstr(&attr.value);
2305                        if !name_c.is_null() {
2306                            tree::set_prop(raw_node, name_c, val_c);
2307                            xmlFreeImpl(name_c as *mut c_void);
2308                            if !val_c.is_null() {
2309                                xmlFreeImpl(val_c as *mut c_void);
2310                            }
2311                        }
2312                    }
2313
2314                    let insertion_point = if ctxt.current.is_null() {
2315                        if ctxt.in_head {
2316                            ensure_head(ctxt);
2317                            ctxt.head
2318                        } else {
2319                            ensure_body(ctxt);
2320                            ctxt.body
2321                        }
2322                    } else {
2323                        ctxt.current
2324                    };
2325
2326                    if !insertion_point.is_null() {
2327                        tree::add_child(insertion_point, raw_node);
2328
2329                        // Read raw text until matching </script> or </style>
2330                        let end_tag = format!("</{}", tag_str);
2331                        let end_bytes = end_tag.as_bytes();
2332                        let mut raw_text = Vec::new();
2333                        // Potential end-tag prefix chars ("</script") are
2334                        // buffered separately: on a full match they are
2335                        // DISCARDED (the content ends before the '<' —
2336                        // upstream script-content ends at the '<' that starts
2337                        // the close tag), on a mismatch they are flushed back
2338                        // into the text.
2339                        let mut match_buf: Vec<u8> = Vec::new();
2340                        let mut match_idx = 0;
2341
2342                        loop {
2343                            if ctxt.is_eof() {
2344                                break;
2345                            }
2346                            let ch = ctxt.peek().unwrap();
2347                            if ch.to_ascii_lowercase() == end_bytes[match_idx] {
2348                                match_buf.push(ch);
2349                                match_idx += 1;
2350                                ctxt.next();
2351                                if match_idx == end_bytes.len() {
2352                                    // We found the start of </tag
2353                                    // Add the text before the end tag
2354                                    if !raw_text.is_empty() {
2355                                        let text_node = tree::new_text(bytes_to_xmlstr(&raw_text));
2356                                        if !text_node.is_null() {
2357                                            tree::add_child(raw_node, text_node);
2358                                        }
2359                                    }
2360                                    // Consume the rest of the end tag: "tag>"
2361                                    // Now read "tag>"
2362                                    let _suffix = ctxt.read_while(|ch| ch != b'>');
2363                                    if ctxt.peek() == Some(b'>') {
2364                                        ctxt.next();
2365                                    }
2366                                    // Close the element
2367                                    ctxt.current = unsafe { (*raw_node).parent };
2368                                    break;
2369                                }
2370                            } else {
2371                                // Mismatch: flush any buffered end-tag prefix
2372                                // chars back into the text, then the current
2373                                // char.
2374                                if match_idx > 0 {
2375                                    raw_text.extend_from_slice(&match_buf);
2376                                    match_buf.clear();
2377                                    match_idx = 0;
2378                                }
2379                                raw_text.push(ch);
2380                                ctxt.next();
2381                            }
2382                        }
2383
2384                        // If we never found the end tag, add the text (plus
2385                        // any buffered end-tag prefix chars).
2386                        if match_idx < end_bytes.len() {
2387                            raw_text.extend_from_slice(&match_buf);
2388                            if !raw_text.is_empty() {
2389                                let text_node = tree::new_text(bytes_to_xmlstr(&raw_text));
2390                                if !text_node.is_null() {
2391                                    tree::add_child(raw_node, text_node);
2392                                }
2393                            }
2394                            ctxt.current = unsafe { (*raw_node).parent };
2395                        }
2396                    }
2397                }
2398                continue;
2399            }
2400
2401            // Regular start tag
2402            handle_start_tag(ctxt, &tag_name, &attrs);
2403        } else {
2404            // Text content - read until next '<' or entity '&'
2405            let mut text = Vec::new();
2406            loop {
2407                match ctxt.peek() {
2408                    Some(b'<') => break,
2409                    Some(b'&') => {
2410                        // Handle entity reference inline
2411                        let entity_text = parse_entity(ctxt);
2412                        text.extend_from_slice(&entity_text);
2413                    }
2414                    Some(0) => {
2415                        // UPSTREAM-PARITY (bug #80268, libxml2 >= 2.9.12): NUL
2416                        // bytes in HTML content are DROPPED and parsing
2417                        // continues — they must neither terminate the text
2418                        // node (truncating at the NUL) nor reach the tree
2419                        // (content is C-string storage).
2420                        ctxt.next();
2421                    }
2422                    Some(ch) => {
2423                        text.push(ch);
2424                        ctxt.next();
2425                    }
2426                    None => break,
2427                }
2428            }
2429
2430            if !text.is_empty() {
2431                handle_text(ctxt, &text);
2432            }
2433        }
2434    }
2435
2436    // Post-processing: ensure html/head/body are created even for empty
2437    // documents (never under HTML_PARSE_NOIMPLIED — the source tree is kept
2438    // as-is, bug76285).
2439    if ctxt.html.is_null() && ctxt.options & HTML_PARSE_NOIMPLIED == 0 {
2440        ensure_html(ctxt);
2441    }
2442
2443    // UPSTREAM-PARITY (xmlSAX2Characters with XML_PARSE_NOBLANKS):
2444    // whitespace-only text runs are reported as ignorableWhitespace and never
2445    // become text nodes. The candidate builds them eagerly, so drop them here
2446    // (ext/dom dom005's loadHTMLFile(…, LIBXML_NOBLANKS) serialization kept
2447    // the <head>-region newline text nodes the oracle drops).
2448    if ctxt.options & (crate::abi::types::XML_PARSE_NOBLANKS as c_int) != 0 && !doc.is_null() {
2449        unsafe {
2450            drop_blank_text_nodes((*doc).children);
2451        }
2452    }
2453
2454    // UPSTREAM-PARITY (SAX2.c xmlSAX2StartElementNs / xmlSAX2AttributeNs):
2455    // ID-bearing attributes (the HTML `id` attribute, `<a name>` and any
2456    // DTD-declared ID) are registered in doc->ids as they are parsed. The
2457    // html tree builder attaches attributes BEFORE a node gains its document
2458    // pointer (add_child propagates doc later), so the per-attribute
2459    // registration cannot run at attribute time — do a tree-order pass once
2460    // the document is complete (first registration wins, matching the
2461    // in-order xmlAddID calls of a SAX2 parse). This is what makes
2462    // DOMDocument::loadHTML + getElementById / HTMLCollection named lookups
2463    // see `id="…"` attributes.
2464    if !doc.is_null() {
2465        unsafe {
2466            register_html_ids(doc, (*doc).children);
2467        }
2468    }
2469
2470    doc
2471}
2472
2473/// Register ID/IDREF attributes of every element in the sibling chain and
2474/// their element descendants (tree order, first registration wins).
2475///
2476/// # Safety
2477///
2478/// - `doc` must be a valid `_xmlDoc` (HTML type) whose tree stays alive for
2479///   the call; `cur` must be NULL or a valid live node chain within `doc`.
2480unsafe fn register_html_ids(doc: *mut _xmlDoc, cur: *mut _xmlNode) {
2481    let mut n = cur;
2482    while !n.is_null() {
2483        let t = unsafe { (*n).type_ };
2484        if t == XML_ELEMENT_NODE as c_int {
2485            let el = n;
2486            let mut attr = unsafe { (*el).properties };
2487            while !attr.is_null() {
2488                if unsafe { (*attr).id }.is_null()
2489                    && !unsafe { (*attr).children }.is_null()
2490                    && unsafe { (*(*attr).children).type_ } == XML_TEXT_NODE as c_int
2491                    && unsafe { (*(*attr).children).next }.is_null()
2492                {
2493                    let v = unsafe { (*(*attr).children).content };
2494                    if !v.is_null() {
2495                        let id_res = crate::xml::validation::is_id(doc, el, attr);
2496                        if id_res > 0 {
2497                            crate::xml::validation::add_id(ptr::null_mut(), doc, v, attr);
2498                        } else if crate::xml::validation::is_ref(doc, el, attr) > 0 {
2499                            crate::xml::validation::add_ref(ptr::null_mut(), doc, v, attr);
2500                        }
2501                    }
2502                }
2503                attr = unsafe { (*attr).next };
2504            }
2505            if !unsafe { (*el).children }.is_null() {
2506                register_html_ids(doc, unsafe { (*el).children });
2507            }
2508        }
2509        n = unsafe { (*n).next };
2510    }
2511}
2512
2513/// Unlink and free every whitespace-only text node in the sibling chain and
2514/// their element descendants (upstream html parse + XML_PARSE_NOBLANKS).
2515///
2516/// # Safety
2517///
2518/// - `cur` must be NULL or a valid live `_xmlNode` chain (element/text/…)
2519///   inside the document being cleaned.
2520unsafe fn drop_blank_text_nodes(cur: *mut _xmlNode) {
2521    let mut n = cur;
2522    while !n.is_null() {
2523        let next = unsafe { (*n).next };
2524        let t = unsafe { (*n).type_ };
2525        if t == XML_TEXT_NODE as c_int {
2526            let content = unsafe { (*n).content };
2527            let blank = if content.is_null() {
2528                true
2529            } else {
2530                let mut p = content;
2531                while unsafe { *p } != 0 {
2532                    match unsafe { *p } {
2533                        b' ' | b'\t' | b'\r' | b'\n' => {}
2534                        _ => break,
2535                    }
2536                    p = unsafe { p.add(1) };
2537                }
2538                (unsafe { *p }) == 0
2539            };
2540            if blank {
2541                tree::unlink_node(n);
2542                tree::free_node(n);
2543            }
2544        } else if t == XML_ELEMENT_NODE as c_int && !unsafe { (*n).children }.is_null() {
2545            drop_blank_text_nodes(unsafe { (*n).children });
2546        }
2547        n = next;
2548    }
2549}
2550
2551// ═══════════════════════════════════════════════════════════════════════════════
2552// Public API Functions
2553// ═══════════════════════════════════════════════════════════════════════════════
2554
2555/// Parse HTML from a file.
2556///
2557/// # UPSTREAM-PARITY
2558///
2559/// Equivalent to `htmlParseFile` in libxml2.
2560///
2561/// # Safety
2562///
2563/// - `filename` must be a valid null-terminated C string or NULL.
2564/// - `encoding` must be a valid null-terminated C string or NULL.
2565pub unsafe fn parse_file(
2566    filename: *const c_char,
2567    encoding: *const c_char,
2568    options: c_int,
2569) -> *mut _xmlDoc {
2570    if filename.is_null() {
2571        return ptr::null_mut();
2572    }
2573
2574    // Read the file into memory
2575    let filename_str = unsafe { std::ffi::CStr::from_ptr(filename) };
2576    let path = filename_str.to_str().unwrap_or("");
2577    let content = match std::fs::read(path) {
2578        Ok(data) => data,
2579        Err(_) => return ptr::null_mut(),
2580    };
2581
2582    let mut ctxt = HtmlParserCtxt::new();
2583    ctxt.options = options;
2584    if !encoding.is_null() {
2585        let _enc_cstr = unsafe { std::ffi::CStr::from_ptr(encoding) };
2586        ctxt.encoding = unsafe { c_strdup(encoding) };
2587    }
2588
2589    let doc = unsafe {
2590        html_parse_buffer(
2591            &mut ctxt,
2592            content.as_ptr() as *const c_char,
2593            content.len() as c_int,
2594        )
2595    };
2596
2597    if !doc.is_null() && !filename.is_null() {
2598        unsafe {
2599            (*doc).URL = c_strdup(filename) as *mut xmlChar;
2600        }
2601    }
2602
2603    doc
2604}
2605
2606/// Parse HTML from a memory buffer.
2607///
2608/// # UPSTREAM-PARITY
2609///
2610/// Equivalent to `htmlParseMemory` in libxml2.
2611///
2612/// # Safety
2613///
2614/// - `buffer` must point to valid memory of at least `size` bytes.
2615/// - `size` must be non-negative.
2616pub unsafe fn parse_memory(buffer: *const c_char, size: c_int) -> *mut _xmlDoc {
2617    unsafe { parse_memory_enc(buffer, size, ptr::null(), 0) }
2618}
2619
2620/// Parse HTML from a memory buffer with an explicit input encoding.
2621///
2622/// UPSTREAM-PARITY: equivalent to `htmlCtxtReadMemory` where the caller's
2623/// `encoding` is wired into the input buffer (`xmlCtxtNewInputFromMemory` ->
2624/// `xmlSwitchInputEncodingName` installs a decoder so the parse loop always
2625/// consumes UTF-8). NULL means no conversion (BOM sniffing only).
2626///
2627/// # Safety
2628///
2629/// - `buffer` must point to valid memory of at least `size` bytes.
2630/// - `size` must be non-negative.
2631/// - `encoding` must be a valid NUL-terminated C string or NULL.
2632pub(crate) unsafe fn parse_memory_enc(
2633    buffer: *const c_char,
2634    size: c_int,
2635    encoding: *const c_char,
2636    options: c_int,
2637) -> *mut _xmlDoc {
2638    if buffer.is_null() || size <= 0 {
2639        return ptr::null_mut();
2640    }
2641
2642    let mut ctxt = HtmlParserCtxt::new();
2643    ctxt.options = options;
2644    if !encoding.is_null() {
2645        ctxt.encoding = unsafe { c_strdup(encoding) };
2646    }
2647    unsafe { html_parse_buffer(&mut ctxt, buffer, size) }
2648}
2649
2650/// Parse HTML from a null-terminated string.
2651///
2652/// # UPSTREAM-PARITY
2653///
2654/// Equivalent to `htmlParseDoc` in libxml2.
2655///
2656/// # Safety
2657///
2658/// - `cur` must be a valid null-terminated xmlChar string or NULL.
2659/// - `encoding` must be a valid null-terminated C string or NULL.
2660pub(crate) unsafe fn parse_doc(
2661    cur: *const xmlChar,
2662    encoding: *const c_char,
2663    options: c_int,
2664) -> *mut _xmlDoc {
2665    if cur.is_null() {
2666        return ptr::null_mut();
2667    }
2668
2669    let len = unsafe { xml_strlen(cur) };
2670    let mut ctxt = HtmlParserCtxt::new();
2671    ctxt.options = options;
2672    if !encoding.is_null() {
2673        ctxt.encoding = unsafe { c_strdup(encoding) };
2674    }
2675
2676    unsafe { html_parse_buffer(&mut ctxt, cur as *const c_char, len as c_int) }
2677}
2678
2679/// Create an HTML parser context for file parsing.
2680///
2681/// # UPSTREAM-PARITY
2682///
2683/// Equivalent to `htmlCreateFileParserCtxt` in libxml2.
2684///
2685/// # Safety
2686///
2687/// - `filename` must be a valid null-terminated C string or NULL.
2688/// - `encoding` must be a valid null-terminated C string or NULL.
2689#[allow(dead_code)]
2690pub(crate) unsafe fn create_file_parser_ctxt(
2691    filename: *const c_char,
2692    encoding: *const c_char,
2693) -> *mut c_void {
2694    if filename.is_null() {
2695        return ptr::null_mut();
2696    }
2697
2698    // Host allocation (R-00019x): real C-visible `_xmlParserCtxt` at offset 0
2699    // followed by the engine state; freed as one block by `free_parser_ctxt`.
2700    let total = size_of::<_xmlParserCtxt>() + size_of::<HtmlParserCtxt>();
2701    let mem = unsafe { xmlMallocZero(total) } as *mut u8;
2702    if mem.is_null() {
2703        return ptr::null_mut();
2704    }
2705
2706    let ctxt = mem.add(size_of::<_xmlParserCtxt>()) as *mut HtmlParserCtxt;
2707    unsafe {
2708        ptr::write(ctxt, HtmlParserCtxt::new());
2709        if !encoding.is_null() {
2710            (*ctxt).encoding = c_strdup(encoding);
2711        }
2712        (*(mem as *mut _xmlParserCtxt)).html = 1;
2713    }
2714
2715    mem as *mut c_void
2716}
2717
2718/// Free an HTML parser context.
2719///
2720/// # UPSTREAM-PARITY
2721///
2722/// Equivalent to `htmlFreeParserCtxt` in libxml2.
2723///
2724/// # Safety
2725///
2726/// - `ctxt` must be a valid pointer returned by `create_file_parser_ctxt`, or NULL.
2727pub(crate) unsafe fn free_parser_ctxt(ctxt: *mut c_void) {
2728    if ctxt.is_null() {
2729        return;
2730    }
2731
2732    // Host allocation (R-00019x): a real C-visible `_xmlParserCtxt` at
2733    // offset 0 with the engine state (`HtmlParserCtxt`) after it. Free the
2734    // engine's owned input buffer and strings, then the single host block.
2735    let state = (ctxt as *mut u8).add(size_of::<_xmlParserCtxt>()) as *mut HtmlParserCtxt;
2736    unsafe {
2737        if !(*state).input.is_null() {
2738            xmlFreeImpl((*state).input as *mut c_void);
2739        }
2740        if !(*state).filename.is_null() {
2741            xmlFreeImpl((*state).filename as *mut c_void);
2742        }
2743        if !(*state).encoding.is_null() {
2744            xmlFreeImpl((*state).encoding as *mut c_void);
2745        }
2746        xmlFreeImpl(ctxt);
2747    }
2748}
2749
2750/// Initialize the HTML parser module.
2751///
2752/// # UPSTREAM-PARITY
2753///
2754/// Equivalent to `htmlInitParser` in libxml2.
2755#[allow(dead_code)]
2756pub(crate) const fn init_parser() {
2757    // Currently a no-op. In the future, may initialize HTML-specific
2758    // entity tables or other global state.
2759}
2760
2761/// Cleanup the HTML parser module.
2762///
2763/// # UPSTREAM-PARITY
2764///
2765/// Equivalent to `htmlCleanupParser` in libxml2.
2766#[allow(dead_code)]
2767pub(crate) const fn cleanup_parser() {
2768    // Currently a no-op. In the future, may free HTML-specific
2769    // global state.
2770}
2771
2772/// Create a new HTML document.
2773///
2774/// # UPSTREAM-PARITY
2775///
2776/// Equivalent to `htmlNewDoc` in libxml2.
2777///
2778/// Creates a new HTML document (type XML_HTML_DOCUMENT_NODE) WITHOUT any
2779/// implicit html/head/body skeleton. Upstream `htmlNewDoc`/`htmlNewDocNoDtD`
2780/// create only the document (the HTML parser grows the html/head/body
2781/// elements lazily); eagerly seeding the skeleton would insert a real
2782/// `<html>` root that diverges from upstream and breaks consumers like
2783/// nokogiri's `HTML4::Document.new` builder (a later `<b>` add would look
2784/// like a second root).
2785///
2786/// # SAFETY
2787///
2788/// - `version` must be a valid null-terminated xmlChar string or NULL.
2789pub(crate) unsafe fn new_doc(version: *const xmlChar) -> *mut _xmlDoc {
2790    let doc = tree::new_doc(version);
2791    if doc.is_null() {
2792        return ptr::null_mut();
2793    }
2794
2795    unsafe {
2796        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2797        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2798    }
2799
2800    doc
2801}
2802
2803/// Create a new HTML document without DTD.
2804///
2805/// # UPSTREAM-PARITY
2806///
2807/// Equivalent to `htmlNewDocNoDtD` in libxml2.
2808///
2809/// Creates a new document with type XML_HTML_DOCUMENT_NODE.
2810/// Unlike `htmlNewDoc`, this does NOT auto-create html/head/body elements.
2811///
2812/// # Safety
2813///
2814/// - `version` must be a valid null-terminated xmlChar string or NULL.
2815pub(crate) unsafe fn new_doc_no_dtd(version: *const xmlChar) -> *mut _xmlDoc {
2816    let doc = tree::new_doc(version);
2817    if doc.is_null() {
2818        return ptr::null_mut();
2819    }
2820
2821    unsafe {
2822        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2823        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2824    }
2825
2826    doc
2827}
2828
2829// ═══════════════════════════════════════════════════════════════════════════════
2830// HTML Serializer
2831// ═══════════════════════════════════════════════════════════════════════════════
2832
2833/// HTML void elements that should not have closing tags.
2834const HTML_VOID_ELEMENTS: &[&str] = &[
2835    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
2836    "track", "wbr", "frame",
2837];
2838
2839/// Check if an element name is an HTML void element.
2840fn is_html_void(name: &str) -> bool {
2841    HTML_VOID_ELEMENTS
2842        .iter()
2843        .any(|v| v.eq_ignore_ascii_case(name))
2844}
2845
2846/// Check if an element has optional end tag in HTML.
2847#[allow(dead_code)]
2848fn has_optional_end_tag(name: &str) -> bool {
2849    matches!(
2850        name.to_ascii_lowercase().as_str(),
2851        "p" | "li" | "tr" | "td" | "th" | "dt" | "dd"
2852    )
2853}
2854
2855/// Serialize a text node for HTML output.
2856///
2857/// In HTML serialization, we escape `<` and `&` but NOT non-ASCII characters
2858/// as numeric entities (unlike XML serialization).
2859/// Write a double-quoted C string to the buffer.
2860unsafe fn html_write_quoted(buf: *mut _xmlBuffer, s: *const xmlChar) {
2861    if buf.is_null() || s.is_null() {
2862        return;
2863    }
2864    io::buf_ccat(buf, b'"');
2865    io::buf_cat(buf, s);
2866    io::buf_ccat(buf, b'"');
2867}
2868
2869/// Trim leading ASCII whitespace.
2870fn trim_ascii_start(s: &[u8]) -> &[u8] {
2871    let start = s
2872        .iter()
2873        .position(|&b| !b.is_ascii_whitespace())
2874        .unwrap_or(s.len());
2875    &s[start..]
2876}
2877
2878/// Write text content into an HTML output buffer, escaping `&`, `<` and
2879/// `>` (upstream htmlTreeDumpText escapes `>` to `&gt;` as well — the corpus
2880/// `ser-methods` html method expects `&lt;x&gt;`, not `&lt;x>`).
2881///
2882/// # Safety
2883///
2884/// - `buf` must be non-NULL and point to a valid `_xmlBuffer` writable via
2885///   `io::buf_add`.
2886/// - `content` must be non-NULL and readable for at least `len` bytes; `len`
2887///   is required to be positive (checked) and the loop reads exactly `len`
2888///   bytes starting at `content`.
2889unsafe fn html_serialize_text(buf: *mut _xmlBuffer, content: *const xmlChar, len: c_int) {
2890    if buf.is_null() || content.is_null() || len <= 0 {
2891        return;
2892    }
2893
2894    let mut i: c_int = 0;
2895    while i < len {
2896        let ch = unsafe { *content.add(i as usize) };
2897
2898        match ch {
2899            b'<' => {
2900                io::buf_add(buf, b"&lt;" as *const u8, 4);
2901            }
2902            b'&' => {
2903                io::buf_add(buf, b"&amp;" as *const u8, 5);
2904            }
2905            b'>' => {
2906                io::buf_add(buf, b"&gt;" as *const u8, 4);
2907            }
2908            _ => {
2909                io::buf_add(buf, &ch as *const u8, 1);
2910            }
2911        }
2912        i += 1;
2913    }
2914}
2915
2916/// Serialize an attribute value for HTML output.
2917///
2918/// In HTML, attribute values should be quoted and have `&`, `"` escaped.
2919///
2920/// # Safety
2921///
2922/// - `buf` must be non-NULL and point to a valid `_xmlBuffer` writable via
2923///   `io::buf_add`.
2924/// - `value` must be non-NULL and point to a valid NUL-terminated `xmlChar`
2925///   string; `xml_strlen` scans it up to the terminator.
2926unsafe fn html_serialize_attr_value(buf: *mut _xmlBuffer, value: *const xmlChar) {
2927    if buf.is_null() || value.is_null() {
2928        return;
2929    }
2930
2931    let len = unsafe { xml_strlen(value) as c_int };
2932    let mut i: c_int = 0;
2933    while i < len {
2934        let ch = unsafe { *value.add(i as usize) };
2935
2936        match ch {
2937            b'&' => {
2938                io::buf_add(buf, b"&amp;" as *const u8, 5);
2939            }
2940            b'"' => {
2941                io::buf_add(buf, b"&quot;" as *const u8, 6);
2942            }
2943            _ => {
2944                io::buf_add(buf, &ch as *const u8, 1);
2945            }
2946        }
2947        i += 1;
2948    }
2949}
2950
2951/// HTML-specific node serialization.
2952///
2953/// Walks the node tree and serializes to HTML format.
2954/// Differs from XML serialization in several ways:
2955/// - No XML declaration for HTML documents
2956/// - No self-closing tags for void elements
2957/// - Case-insensitive tag names preserved as-is
2958/// - No namespace declarations
2959/// - Elements with optional end tags may omit them
2960///
2961/// Whether the head element already contains a <meta> element (so the
2962///
2963/// serializer does not insert a duplicate charset declaration).
2964///
2965/// # SAFETY
2966///
2967/// - `child` must be a valid node or NULL.
2968unsafe fn html_head_has_meta(child: *mut _xmlNode) -> bool {
2969    let mut c = child;
2970    while !c.is_null() {
2971        if (*c).type_ == XML_ELEMENT_NODE as c_int && !(*c).name.is_null() {
2972            let nm = xmlstr_to_bytes((*c).name);
2973            if nm.eq_ignore_ascii_case(b"meta") {
2974                return true;
2975            }
2976        }
2977        c = (*c).next;
2978    }
2979    false
2980}
2981
2982/// Serialize a node tree to HTML output.
2983///
2984/// # Safety
2985///
2986/// - `node` must be non-NULL and point to a valid `_xmlNode` in a well-formed
2987///   tree; the `children`, `next`, `parent`, `properties` and `doc` links
2988///   walked here must be NULL-terminated and point to valid objects.
2989/// - `buf` must be non-NULL and point to a valid `_xmlBuffer`.
2990/// - `name`, `content` and `encoding` fields must be NULL or valid
2991///   NUL-terminated `xmlChar` strings.
2992pub(crate) unsafe fn serialize_node(
2993    node: *mut _xmlNode,
2994    buf: *mut _xmlBuffer,
2995    format: c_int,
2996    level: c_int,
2997) {
2998    unsafe { serialize_node_enc(node, buf, format, level, None) }
2999}
3000
3001/// Serialize an HTML node with an optional output-encoding parameter
3002/// (upstream `htmlNodeDumpInternal`'s `encoding` argument).
3003///
3004/// Upstream inserts a `<meta charset=...>` in the root `<head>` ONLY when
3005/// this `encoding` parameter is non-NULL (htmlDocDumpMemoryFormat and the
3006/// lxml `tostring(method="html")` path pass NULL and never insert one);
3007/// `htmlSaveFileFormat` passes the caller's encoding string.
3008///
3009/// # Safety
3010///
3011/// - `node` must be NULL or a valid `_xmlNode`; `buf` a valid `_xmlBuffer`.
3012pub(crate) unsafe fn serialize_node_enc(
3013    node: *mut _xmlNode,
3014    buf: *mut _xmlBuffer,
3015    format: c_int,
3016    level: c_int,
3017    encoding: Option<&[u8]>,
3018) {
3019    if node.is_null() || buf.is_null() {
3020        return;
3021    }
3022
3023    let n = unsafe { &*node };
3024
3025    match n.type_ {
3026        t if t == XML_ELEMENT_NODE as c_int => {
3027            let name = if n.name.is_null() {
3028                ""
3029            } else {
3030                unsafe { core::str::from_utf8(xmlstr_to_bytes(n.name)).unwrap_or("") }
3031            };
3032
3033            let is_void = is_html_void(name);
3034            // UPSTREAM-PARITY: htmlNodeDumpInternal only adds formatting
3035            // newlines for non-inline elements; p, pre and param are never
3036            // formatted (name[0] == 'p'), and unknown elements are treated
3037            // as inline (info == NULL).
3038            let info = html_tag_lookup(name);
3039            let is_inline = info.is_none_or(|i| i.flags & HTML_INLINE != 0);
3040            let no_format = is_inline || name.starts_with('p');
3041
3042            // Write start tag
3043            io::buf_ccat(buf, b'<');
3044            // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpOutputInternal): an
3045            // element with a bound prefix writes `prefix:name`, and its
3046            // local namespace declarations (nsDef) are dumped right after
3047            // the name — namespaced trees (lxml.html.html5parser's XHTML
3048            // tree) serialize with their namespace declarations.
3049            if !n.ns.is_null() && !(*n.ns).prefix.is_null() {
3050                io::buf_cat(buf, (*n.ns).prefix);
3051                io::buf_ccat(buf, b':');
3052            }
3053            if !n.name.is_null() {
3054                io::buf_cat(buf, n.name);
3055            }
3056            if !n.nsDef.is_null() {
3057                let mut ns = n.nsDef;
3058                while !ns.is_null() {
3059                    let nsp = unsafe { &*ns };
3060                    // UPSTREAM-PARITY (xmlsave.c xmlNsDumpOutput): only
3061                    // LOCAL namespaces with a URI are written; the "xml"
3062                    // prefix declaration is skipped.
3063                    let is_xml = !nsp.prefix.is_null() && xmlstr_to_bytes(nsp.prefix) == b"xml";
3064                    if nsp.type_ == XML_LOCAL_NAMESPACE as c_int && !nsp.href.is_null() && !is_xml {
3065                        io::buf_ccat(buf, b' ');
3066                        if nsp.prefix.is_null() {
3067                            io::buf_add(buf, b"xmlns=\"" as *const u8, 7);
3068                        } else {
3069                            io::buf_add(buf, b"xmlns:" as *const u8, 6);
3070                            io::buf_cat(buf, nsp.prefix);
3071                            io::buf_add(buf, b"=\"" as *const u8, 2);
3072                        }
3073                        html_serialize_attr_value(buf, nsp.href);
3074                        io::buf_ccat(buf, b'\"');
3075                    }
3076                    ns = nsp.next;
3077                }
3078            }
3079
3080            // Write attributes
3081            let mut attr = n.properties;
3082            while !attr.is_null() {
3083                let a = unsafe { &*attr };
3084                io::buf_ccat(buf, b' ');
3085                if !a.name.is_null() {
3086                    io::buf_cat(buf, a.name);
3087                }
3088
3089                // Write attribute value if present
3090                if !a.children.is_null() {
3091                    let child = unsafe { &*a.children };
3092                    if child.type_ == XML_TEXT_NODE as c_int && !child.content.is_null() {
3093                        io::buf_ccat(buf, b'=');
3094                        io::buf_ccat(buf, b'"');
3095                        html_serialize_attr_value(buf, child.content);
3096                        io::buf_ccat(buf, b'"');
3097                    }
3098                }
3099
3100                attr = a.next;
3101            }
3102
3103            // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpInternal): inserts
3104            // <meta charset="..."> as the first child of the <head> of the
3105            // root <html> element when no <meta> is present AND the caller
3106            // passed an explicit output `encoding` parameter (the doc-dump
3107            // path passes NULL and inserts nothing). The meta is synthetic
3108            // here, so it participates in the formatting rules like a real
3109            // child.
3110            let mut meta_bytes: Option<Vec<u8>> = None;
3111            if name.eq_ignore_ascii_case("head") && level == 1 {
3112                if let Some(enc) = encoding {
3113                    let parent_is_html = !n.parent.is_null() && !(*n.parent).name.is_null() && {
3114                        let pn =
3115                            core::str::from_utf8(xmlstr_to_bytes((*n.parent).name)).unwrap_or("");
3116                        pn.eq_ignore_ascii_case("html")
3117                    };
3118                    if parent_is_html && !html_head_has_meta(n.children) {
3119                        meta_bytes = Some(enc.to_vec());
3120                    }
3121                }
3122            }
3123            let meta_inserted = meta_bytes.is_some();
3124
3125            let has_children = !n.children.is_null();
3126            let first_child = if has_children {
3127                unsafe { (*n.children).type_ }
3128            } else {
3129                XML_TEXT_NODE as c_int
3130            };
3131            let first_is_text = first_child == XML_TEXT_NODE as c_int
3132                || first_child == XML_ENTITY_REF_NODE as c_int;
3133            // With a synthetic meta child, an empty head behaves as having
3134            // one element child.
3135            let multi_child = (has_children && n.children != n.last) || meta_inserted;
3136
3137            if is_void {
3138                // Void element: just close the tag, no children
3139                io::buf_ccat(buf, b'>');
3140                // UPSTREAM-PARITY (line 997): a newline follows a non-inline
3141                // element whose next sibling is not text; the caller (parent
3142                // loop) emits it, so nothing here.
3143            } else {
3144                // Element with children (or a head receiving a meta)
3145                io::buf_ccat(buf, b'>');
3146
3147                // Newline after the open tag (upstream line 969): a
3148                // non-inline element whose first child is not text and which
3149                // has more than one child (or receives a meta) starts its
3150                // content on a new line.
3151                if format != 0 && !no_format && !first_is_text && multi_child {
3152                    io::buf_ccat(buf, b'\n');
3153                }
3154
3155                if let Some(enc) = &meta_bytes {
3156                    io::buf_add(buf, b"<meta charset=\"" as *const u8, 15);
3157                    io::buf_add(buf, enc.as_ptr(), enc.len() as c_int);
3158                    io::buf_add(buf, b"\">" as *const u8, 2);
3159                    // UPSTREAM-PARITY (line 983): a newline follows the
3160                    // inserted meta when the next real child is not text.
3161                    if format != 0 && has_children && !first_is_text && !name.starts_with('p') {
3162                        io::buf_ccat(buf, b'\n');
3163                    }
3164                }
3165
3166                // Serialize children inline (HTML formatting adds no
3167                // indentation; the per-element rules emit the newlines).
3168                let mut child = n.children;
3169                while !child.is_null() {
3170                    serialize_node_enc(child, buf, format, level + 1, encoding);
3171                    // UPSTREAM-PARITY (line 997): a newline follows a
3172                    // non-inline element whose next sibling is not text,
3173                    // unless the parent is p/pre/param.
3174                    let next = unsafe { (*child).next };
3175                    if format != 0 && !next.is_null() && !name.starts_with('p') {
3176                        let nt = unsafe { (*next).type_ };
3177                        if nt != XML_TEXT_NODE as c_int && nt != XML_ENTITY_REF_NODE as c_int {
3178                            let cname = if (*child).name.is_null() {
3179                                ""
3180                            } else {
3181                                unsafe {
3182                                    core::str::from_utf8(xmlstr_to_bytes((*child).name))
3183                                        .unwrap_or("")
3184                                }
3185                            };
3186                            let cinfo = html_tag_lookup(cname);
3187                            let c_inline = cinfo.is_none_or(|i| i.flags & HTML_INLINE != 0);
3188                            if !c_inline {
3189                                io::buf_ccat(buf, b'\n');
3190                            }
3191                        }
3192                    }
3193                    child = next;
3194                }
3195
3196                // Newline before the end tag (upstream line 1085): a
3197                // non-inline element whose last child is not text and which
3198                // has more than one child (or is the head receiving a meta).
3199                let last_child = if has_children {
3200                    unsafe { (*n.last).type_ }
3201                } else {
3202                    XML_ELEMENT_NODE as c_int
3203                };
3204                let last_is_text = last_child == XML_TEXT_NODE as c_int
3205                    || last_child == XML_ENTITY_REF_NODE as c_int;
3206                if format != 0 && !no_format && !last_is_text && multi_child {
3207                    io::buf_ccat(buf, b'\n');
3208                }
3209
3210                // Write end tag
3211                io::buf_add(buf, b"</" as *const u8, 2);
3212                // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpOutputInternal
3213                // end-tag emission): the namespace prefix is repeated.
3214                if !n.ns.is_null() && !(*n.ns).prefix.is_null() {
3215                    io::buf_cat(buf, (*n.ns).prefix);
3216                    io::buf_ccat(buf, b':');
3217                }
3218                if !n.name.is_null() {
3219                    io::buf_cat(buf, n.name);
3220                }
3221                io::buf_ccat(buf, b'>');
3222            }
3223        }
3224        t if t == XML_TEXT_NODE as c_int => {
3225            // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpInternal): script/
3226            // style content is DATA_RAWTEXT — written verbatim, never
3227            // escaped (the corpus html-script expects `<` and `&&` raw).
3228            let parent_is_raw = !n.parent.is_null() && !(*n.parent).name.is_null() && {
3229                let pn = xmlstr_to_bytes((*n.parent).name);
3230                pn.eq_ignore_ascii_case(b"script") || pn.eq_ignore_ascii_case(b"style")
3231            };
3232            if parent_is_raw {
3233                io::buf_cat(buf, n.content);
3234            } else {
3235                html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
3236            }
3237        }
3238        t if t == XML_CDATA_SECTION_NODE as c_int => {
3239            io::buf_add(buf, b"<![CDATA[" as *const u8, 9);
3240            html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
3241            io::buf_add(buf, b"]]>" as *const u8, 3);
3242        }
3243        t if t == XML_COMMENT_NODE as c_int => {
3244            if format != 0 && level > 0 {
3245                io::buf_ccat(buf, b'\n');
3246                for _ in 0..level {
3247                    io::buf_add(buf, b"  " as *const u8, 2);
3248                }
3249            }
3250            io::buf_add(buf, b"<!--" as *const u8, 4);
3251            if !n.content.is_null() {
3252                io::buf_cat(buf, n.content);
3253            }
3254            io::buf_add(buf, b"-->" as *const u8, 3);
3255        }
3256        t if t == XML_PI_NODE as c_int => {
3257            if format != 0 && level > 0 {
3258                io::buf_ccat(buf, b'\n');
3259                for _ in 0..level {
3260                    io::buf_add(buf, b"  " as *const u8, 2);
3261                }
3262            }
3263            io::buf_add(buf, b"<?" as *const u8, 2);
3264            if !n.name.is_null() {
3265                io::buf_cat(buf, n.name);
3266            }
3267            if !n.content.is_null() && unsafe { *n.content != 0 } {
3268                io::buf_ccat(buf, b' ');
3269                io::buf_cat(buf, n.content);
3270            }
3271            io::buf_add(buf, b"?>" as *const u8, 2);
3272        }
3273        t if t == XML_DOCUMENT_NODE as c_int || t == XML_HTML_DOCUMENT_NODE as c_int => {
3274            // UPSTREAM-PARITY: htmlDocContentDumpOutput writes the internal
3275            // subset's DOCTYPE before the tree children.
3276            let doc_ptr = n as *const _xmlNode as *mut _xmlDoc;
3277            let d = &*doc_ptr;
3278            if !d.intSubset.is_null() {
3279                let dtd = &*d.intSubset;
3280                io::buf_add(buf, b"<!DOCTYPE " as *const u8, 10);
3281                if !dtd.name.is_null() {
3282                    io::buf_cat(buf, dtd.name);
3283                }
3284                if !dtd.ExternalID.is_null() {
3285                    io::buf_add(buf, b" PUBLIC " as *const u8, 8);
3286                    html_write_quoted(buf, dtd.ExternalID);
3287                    io::buf_ccat(buf, b' ');
3288                    html_write_quoted(buf, dtd.SystemID);
3289                } else if !dtd.SystemID.is_null() {
3290                    io::buf_add(buf, b" SYSTEM " as *const u8, 8);
3291                    html_write_quoted(buf, dtd.SystemID);
3292                }
3293                io::buf_ccat(buf, b'>');
3294                io::buf_ccat(buf, b'\n');
3295            }
3296            // No XML declaration for HTML documents
3297            // Serialize children
3298            let mut child = n.children;
3299            while !child.is_null() {
3300                serialize_node_enc(child, buf, format, 0, encoding);
3301                child = unsafe { (*child).next };
3302            }
3303            // UPSTREAM-PARITY: htmlDocContentDumpOutput terminates with a
3304            // newline.
3305            io::buf_ccat(buf, b'\n');
3306        }
3307        _ => {
3308            if !n.content.is_null() {
3309                html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
3310            }
3311        }
3312    }
3313}
3314
3315/// Dump an HTML document to a buffer.
3316///
3317/// # Safety
3318///
3319/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
3320/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
3321pub(crate) unsafe fn doc_dump(buf: *mut _xmlBuffer, doc: *mut _xmlDoc) -> c_int {
3322    if buf.is_null() || doc.is_null() {
3323        return -1;
3324    }
3325
3326    let before = io::buf_length(buf);
3327    serialize_node(doc as *mut _xmlNode, buf, 0, 0);
3328    let after = io::buf_length(buf);
3329
3330    if after < 0 || before < 0 {
3331        return -1;
3332    }
3333    after - before
3334}
3335
3336// ═══════════════════════════════════════════════════════════════════════════════
3337// Tests
3338// ═══════════════════════════════════════════════════════════════════════════════
3339
3340#[cfg(test)]
3341mod tests {
3342    use super::*;
3343
3344    use crate::xml::io;
3345
3346    /// Helper: create a null-terminated xmlChar* from a byte slice.
3347    #[allow(dead_code)]
3348    unsafe fn to_xmlstr(s: &[u8]) -> *mut xmlChar {
3349        bytes_to_xmlstr(s)
3350    }
3351
3352    /// Helper: serialize an HTML document to a String.
3353    unsafe fn html_doc_to_string(doc: *mut _xmlDoc) -> String {
3354        let buf = io::buf_create(-1);
3355        assert!(!buf.is_null());
3356        doc_dump(buf, doc);
3357        let content = io::buf_content(buf);
3358        let s = if !content.is_null() {
3359            let len = xml_strlen(content);
3360            let slice = slice::from_raw_parts(content, len);
3361            String::from_utf8_lossy(slice).to_string()
3362        } else {
3363            String::new()
3364        };
3365        io::buf_free(buf);
3366        s
3367    }
3368
3369    // ═════════════════════════════════════════════════════════════════════════
3370    // Element Info Lookup
3371    // ═════════════════════════════════════════════════════════════════════════
3372
3373    #[test]
3374    fn test_html_tag_lookup() {
3375        // Known elements
3376        assert!(html_tag_lookup("html").is_some());
3377        assert!(html_tag_lookup("HTML").is_some()); // case-insensitive
3378        assert!(html_tag_lookup("p").is_some());
3379        assert!(html_tag_lookup("br").is_some());
3380        assert!(html_tag_lookup("div").is_some());
3381        assert!(html_tag_lookup("script").is_some());
3382
3383        // Unknown elements
3384        assert!(html_tag_lookup("custom").is_none());
3385        assert!(html_tag_lookup("my-element").is_none());
3386    }
3387
3388    #[test]
3389    fn test_tag_flags() {
3390        let br = html_tag_lookup("br").unwrap();
3391        assert!(br.flags & HTML_INLINE != 0);
3392        assert!(br.flags & HTML_EMPTY != 0);
3393
3394        let div = html_tag_lookup("div").unwrap();
3395        assert!(div.flags & HTML_BLOCK != 0);
3396        assert!(div.flags & HTML_VALID != 0);
3397
3398        let p = html_tag_lookup("p").unwrap();
3399        assert!(p.flags & HTML_NO_END != 0);
3400
3401        let meta = html_tag_lookup("meta").unwrap();
3402        assert!(meta.flags & HTML_HEAD != 0);
3403        assert!(meta.flags & HTML_EMPTY != 0);
3404    }
3405
3406    // ═════════════════════════════════════════════════════════════════════════
3407    // Entity Lookup
3408    // ═════════════════════════════════════════════════════════════════════════
3409
3410    #[test]
3411    fn test_html_entity_lookup() {
3412        assert_eq!(html_entity_lookup("amp"), Some("&"));
3413        assert_eq!(html_entity_lookup("lt"), Some("<"));
3414        assert_eq!(html_entity_lookup("gt"), Some(">"));
3415        assert_eq!(html_entity_lookup("quot"), Some("\""));
3416        assert_eq!(html_entity_lookup("nbsp"), Some("\u{00a0}"));
3417        assert_eq!(html_entity_lookup("copy"), Some("\u{00a9}"));
3418        assert!(html_entity_lookup("unknown_entity").is_none());
3419    }
3420
3421    // ═════════════════════════════════════════════════════════════════════════
3422    // Basic Parsing
3423    // ═════════════════════════════════════════════════════════════════════════
3424
3425    /// Parses a complete HTML document and verifies the serialized output
3426    /// contains the expected elements.
3427    ///
3428    /// # Safety
3429    ///
3430    /// - The NUL-terminated static `html` buffer stays valid for the
3431    ///   `parse_memory` call; the returned document pointer is asserted
3432    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3433    ///   freed exactly once with `tree::free_doc`.
3434    #[test]
3435    fn test_parse_basic_html() {
3436        unsafe {
3437            let html = b"<html><head><title>Test</title></head><body><p>Hello</p></body></html>\0";
3438            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3439            assert!(!doc.is_null());
3440
3441            let s = html_doc_to_string(doc);
3442            assert!(s.contains("<html>"));
3443            assert!(s.contains("<head>"));
3444            assert!(s.contains("<title>Test</title>"));
3445            assert!(s.contains("<body>"));
3446            assert!(s.contains("<p>Hello</p>"));
3447
3448            tree::free_doc(doc);
3449        }
3450    }
3451
3452    /// Verifies that parsing an empty buffer yields a NULL document.
3453    ///
3454    /// # Safety
3455    ///
3456    /// - The static one-byte buffer is valid for the `parse_memory` call with
3457    ///   size 0, which must not read from it; a NULL document is expected.
3458    #[test]
3459    fn test_parse_empty_document() {
3460        unsafe {
3461            let html = b"\0";
3462            let doc = parse_memory(html.as_ptr() as *const c_char, 0);
3463            assert!(doc.is_null());
3464        }
3465    }
3466
3467    // ═════════════════════════════════════════════════════════════════════════
3468    // Implicit html/head/body Creation
3469    // ═════════════════════════════════════════════════════════════════════════
3470
3471    /// Verifies that a bare paragraph triggers implicit `html` and `body`
3472    /// creation during parsing.
3473    ///
3474    /// # Safety
3475    ///
3476    /// - The NUL-terminated static `html` buffer stays valid for the
3477    ///   `parse_memory` call; the returned document pointer is asserted
3478    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3479    ///   freed exactly once with `tree::free_doc`.
3480    #[test]
3481    fn test_implicit_html_head_body() {
3482        unsafe {
3483            // Just a paragraph, no html/head/body
3484            let html = b"<p>Hello</p>\0";
3485            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3486            assert!(!doc.is_null());
3487
3488            let s = html_doc_to_string(doc);
3489            // Should have auto-created html
3490            assert!(s.contains("<html>"));
3491            // Should have auto-created body
3492            assert!(s.contains("<body>"));
3493            // Should have the paragraph
3494            assert!(s.contains("<p>Hello</p>"));
3495
3496            tree::free_doc(doc);
3497        }
3498    }
3499
3500    /// Verifies that a bare `title` triggers implicit `html` and `head`
3501    /// creation during parsing.
3502    ///
3503    /// # Safety
3504    ///
3505    /// - The NUL-terminated static `html` buffer stays valid for the
3506    ///   `parse_memory` call; the returned document pointer is asserted
3507    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3508    ///   freed exactly once with `tree::free_doc`.
3509    #[test]
3510    fn test_implicit_head_with_title() {
3511        unsafe {
3512            // Only a title, no html/head/body
3513            let html = b"<title>My Page</title>\0";
3514            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3515            assert!(!doc.is_null());
3516
3517            let s = html_doc_to_string(doc);
3518            assert!(s.contains("<html>"));
3519            assert!(s.contains("<head>"));
3520            assert!(s.contains("<title>My Page</title>"));
3521
3522            tree::free_doc(doc);
3523        }
3524    }
3525
3526    // ═════════════════════════════════════════════════════════════════════════
3527    // Auto-closing
3528    // ═════════════════════════════════════════════════════════════════════════
3529
3530    /// Verifies that a second `p` start tag auto-closes the first.
3531    ///
3532    /// # Safety
3533    ///
3534    /// - The NUL-terminated static `html` buffer stays valid for the
3535    ///   `parse_memory` call; the returned document pointer is asserted
3536    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3537    ///   freed exactly once with `tree::free_doc`.
3538    #[test]
3539    fn test_auto_close_p() {
3540        unsafe {
3541            // <p> should auto-close before another <p>
3542            let html = b"<p>First<p>Second</p>\0";
3543            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3544            assert!(!doc.is_null());
3545
3546            let s = html_doc_to_string(doc);
3547            // Both paragraphs should be siblings, not nested
3548            let first_pos = s.find("First");
3549            let second_pos = s.find("Second");
3550            assert!(first_pos.is_some());
3551            assert!(second_pos.is_some());
3552
3553            tree::free_doc(doc);
3554        }
3555    }
3556
3557    /// Verifies that an `h1` element is auto-closed before an `h2`.
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_heading() {
3567        unsafe {
3568            // h1 should auto-close before h2
3569            let html = b"<h1>Title</h1><h2>Subtitle</h2>\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            assert!(s.contains("<h1>Title</h1>"));
3575            assert!(s.contains("<h2>Subtitle</h2>"));
3576
3577            tree::free_doc(doc);
3578        }
3579    }
3580
3581    // ═════════════════════════════════════════════════════════════════════════
3582    // Void Elements
3583    // ═════════════════════════════════════════════════════════════════════════
3584
3585    /// Verifies that void elements are serialized without closing tags.
3586    ///
3587    /// # Safety
3588    ///
3589    /// - The NUL-terminated static `html` buffer stays valid for the
3590    ///   `parse_memory` call; the returned document pointer is asserted
3591    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3592    ///   freed exactly once with `tree::free_doc`.
3593    #[test]
3594    fn test_void_elements() {
3595        unsafe {
3596            let html = b"<br><hr><img src=\"test.jpg\"><input type=\"text\">\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("<br>"));
3602            assert!(s.contains("<hr>"));
3603            assert!(s.contains("<img"));
3604            assert!(s.contains("<input"));
3605
3606            // Void elements should not have closing tags
3607            assert!(!s.contains("</br>"));
3608            assert!(!s.contains("</hr>"));
3609            assert!(!s.contains("</img>"));
3610
3611            tree::free_doc(doc);
3612        }
3613    }
3614
3615    // ═════════════════════════════════════════════════════════════════════════
3616    // Unquoted and Minimized Attributes
3617    // ═════════════════════════════════════════════════════════════════════════
3618
3619    /// Verifies that unquoted attribute values are parsed and re-serialized
3620    /// with quotes.
3621    ///
3622    /// # Safety
3623    ///
3624    /// - The NUL-terminated static `html` buffer stays valid for the
3625    ///   `parse_memory` call; the returned document pointer is asserted
3626    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3627    ///   freed exactly once with `tree::free_doc`.
3628    #[test]
3629    fn test_unquoted_attributes() {
3630        unsafe {
3631            let html = b"<div class=main id=content>Text</div>\0";
3632            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3633            assert!(!doc.is_null());
3634
3635            let s = html_doc_to_string(doc);
3636            assert!(s.contains("class=\"main\""));
3637            assert!(s.contains("id=\"content\""));
3638
3639            tree::free_doc(doc);
3640        }
3641    }
3642
3643    /// Verifies that minimized (valueless) attributes are preserved.
3644    ///
3645    /// # Safety
3646    ///
3647    /// - The NUL-terminated static `html` buffer stays valid for the
3648    ///   `parse_memory` call; the returned document pointer is asserted
3649    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3650    ///   freed exactly once with `tree::free_doc`.
3651    #[test]
3652    fn test_minimized_attributes() {
3653        unsafe {
3654            let html = b"<option selected disabled>Value</option>\0";
3655            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3656            assert!(!doc.is_null());
3657
3658            let s = html_doc_to_string(doc);
3659            // The minimized attributes should be preserved
3660            assert!(s.contains("selected"));
3661            assert!(s.contains("disabled"));
3662
3663            tree::free_doc(doc);
3664        }
3665    }
3666
3667    // ═════════════════════════════════════════════════════════════════════════
3668    // HTML Entities
3669    // ═════════════════════════════════════════════════════════════════════════
3670
3671    /// Verifies named entity resolution and re-escaping during serialization.
3672    ///
3673    /// # Safety
3674    ///
3675    /// - The NUL-terminated static `html` buffer stays valid for the
3676    ///   `parse_memory` call; the returned document pointer is asserted
3677    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3678    ///   freed exactly once with `tree::free_doc`.
3679    #[test]
3680    fn test_html_entities() {
3681        unsafe {
3682            let html = b"<p>&amp; &lt; &gt; &quot; &nbsp; &copy;</p>\0";
3683            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3684            assert!(!doc.is_null());
3685
3686            let s = html_doc_to_string(doc);
3687            // Entities are resolved in the tree; &amp; and &lt; get re-escaped during serialization
3688            // because & and < are special. &gt; becomes > (serialized as-is, > is safe in text).
3689            assert!(s.contains("&amp;")); // &amp; → & → &amp; (re-escaped)
3690            assert!(s.contains("&lt;")); // &lt; → < → &lt; (re-escaped)
3691            assert!(s.contains(">")); // &gt; → > (not escaped in text)
3692            assert!(s.contains("\u{00a0}")); // &nbsp; → non-breaking space
3693
3694            tree::free_doc(doc);
3695        }
3696    }
3697
3698    /// Verifies decimal and hexadecimal numeric entity resolution.
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_numeric_entities() {
3708        unsafe {
3709            // &#65; = 'A', &#x41; = 'A'
3710            let html = b"<p>&#65; &#x41;</p>\0";
3711            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3712            assert!(!doc.is_null());
3713
3714            let s = html_doc_to_string(doc);
3715            assert!(s.contains('A'));
3716
3717            tree::free_doc(doc);
3718        }
3719    }
3720
3721    // ═════════════════════════════════════════════════════════════════════════
3722    // Nested Elements
3723    // ═════════════════════════════════════════════════════════════════════════
3724
3725    /// Verifies nested element parsing and serialization.
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_nested_elements() {
3735        unsafe {
3736            let html = b"<div><ul><li>Item 1</li><li>Item 2</li></ul></div>\0";
3737            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3738            assert!(!doc.is_null());
3739
3740            let s = html_doc_to_string(doc);
3741            assert!(s.contains("<div>"));
3742            assert!(s.contains("<ul>"));
3743            assert!(s.contains("<li>Item 1</li>"));
3744            assert!(s.contains("<li>Item 2</li>"));
3745
3746            tree::free_doc(doc);
3747        }
3748    }
3749
3750    // ═════════════════════════════════════════════════════════════════════════
3751    // Malformed HTML Recovery
3752    // ═════════════════════════════════════════════════════════════════════════
3753
3754    /// Verifies tag-recovery when end tags are missing.
3755    ///
3756    /// # Safety
3757    ///
3758    /// - The NUL-terminated static `html` buffer stays valid for the
3759    ///   `parse_memory` call; the returned document pointer is asserted
3760    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3761    ///   freed exactly once with `tree::free_doc`.
3762    #[test]
3763    fn test_missing_end_tags() {
3764        unsafe {
3765            // Missing closing tags
3766            let html = b"<p>Paragraph without closing<div>Another div\0";
3767            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3768            assert!(!doc.is_null());
3769
3770            let s = html_doc_to_string(doc);
3771            assert!(s.contains("Paragraph without closing"));
3772            assert!(s.contains("Another div"));
3773
3774            tree::free_doc(doc);
3775        }
3776    }
3777
3778    /// Verifies case-insensitive parsing with case-preserved serialization.
3779    ///
3780    /// # Safety
3781    ///
3782    /// - The NUL-terminated static `html` buffer stays valid for the
3783    ///   `parse_memory` call; the returned document pointer is asserted
3784    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3785    ///   freed exactly once with `tree::free_doc`.
3786    #[test]
3787    fn test_mismatched_case() {
3788        unsafe {
3789            let html = b"<HTML><HEAD><TITLE>Test</TITLE></HEAD><BODY><P>Hello</P></BODY></HTML>\0";
3790            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3791            assert!(!doc.is_null());
3792
3793            let s = html_doc_to_string(doc);
3794            // Tag names are case-preserved
3795            assert!(s.contains("<HTML>"));
3796            assert!(s.contains("<HEAD>"));
3797            assert!(s.contains("<BODY>"));
3798            assert!(s.contains("<P>Hello</P>"));
3799
3800            tree::free_doc(doc);
3801        }
3802    }
3803
3804    /// Verifies recovery from deeply nested malformed markup.
3805    ///
3806    /// # Safety
3807    ///
3808    /// - The NUL-terminated static `html` buffer stays valid for the
3809    ///   `parse_memory` call; the returned document pointer is asserted
3810    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3811    ///   freed exactly once with `tree::free_doc`.
3812    #[test]
3813    fn test_nested_malformed() {
3814        unsafe {
3815            // Deeply nested with missing end tags
3816            let html = b"<div><p><span><b>Deep text</div></p>\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            assert!(s.contains("Deep text"));
3822
3823            tree::free_doc(doc);
3824        }
3825    }
3826
3827    // ═════════════════════════════════════════════════════════════════════════
3828    // HTML Serialization Round-trip
3829    // ═════════════════════════════════════════════════════════════════════════
3830
3831    /// Verifies a simple parse and serialize round trip.
3832    ///
3833    /// # Safety
3834    ///
3835    /// - The NUL-terminated static `original` 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_serialization_round_trip_simple() {
3841        unsafe {
3842            let original = b"<p>Hello World</p>\0";
3843            let doc = parse_memory(
3844                original.as_ptr() as *const c_char,
3845                (original.len() - 1) as c_int,
3846            );
3847            assert!(!doc.is_null());
3848
3849            let s = html_doc_to_string(doc);
3850            assert!(s.contains("Hello World"));
3851
3852            tree::free_doc(doc);
3853        }
3854    }
3855
3856    /// Verifies void elements are not serialized with self-closing syntax.
3857    ///
3858    /// # Safety
3859    ///
3860    /// - The NUL-terminated static `html` buffer stays valid for the
3861    ///   `parse_memory` call; the returned document pointer is asserted
3862    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3863    ///   freed exactly once with `tree::free_doc`.
3864    #[test]
3865    fn test_serialize_void_elements_no_self_close() {
3866        unsafe {
3867            let html = b"<br><hr><img src=\"test.png\">\0";
3868            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3869            assert!(!doc.is_null());
3870
3871            let s = html_doc_to_string(doc);
3872            // HTML serialization should NOT use self-closing tags
3873            assert!(!s.contains("<br/>"));
3874            assert!(!s.contains("<hr/>"));
3875
3876            tree::free_doc(doc);
3877        }
3878    }
3879
3880    // ═════════════════════════════════════════════════════════════════════════
3881    // Script and Style Handling
3882    // ═════════════════════════════════════════════════════════════════════════
3883
3884    /// Verifies raw text content inside a `script` element is preserved.
3885    ///
3886    /// # Safety
3887    ///
3888    /// - The NUL-terminated static `html` buffer stays valid for the
3889    ///   `parse_memory` call; the returned document pointer is asserted
3890    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3891    ///   freed exactly once with `tree::free_doc`.
3892    #[test]
3893    fn test_script_content() {
3894        unsafe {
3895            // Use a simpler script content that doesn't contain '<' to avoid parser confusion
3896            let html = b"<script>var x = 1;</script>\0";
3897            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3898            assert!(!doc.is_null());
3899
3900            let s = html_doc_to_string(doc);
3901            assert!(s.contains("<script>"));
3902            // The raw text content should be preserved
3903            assert!(s.contains("var x = 1;"));
3904
3905            // UPSTREAM-PARITY (DATA_RAWTEXT): script content is never
3906            // escaped, even when it contains '<', '&' or '>'.
3907            let html2 = b"<script>if (a < b && c > d) { x(1); }</script>\0";
3908            let doc2 = parse_memory(html2.as_ptr() as *const c_char, (html2.len() - 1) as c_int);
3909            assert!(!doc2.is_null());
3910            let s2 = html_doc_to_string(doc2);
3911            assert!(
3912                s2.contains("if (a < b && c > d) { x(1); }"),
3913                "script content must be raw, got: {s2}"
3914            );
3915            assert!(
3916                !s2.contains("&lt;"),
3917                "script content must not be escaped: {s2}"
3918            );
3919            tree::free_doc(doc2);
3920
3921            tree::free_doc(doc);
3922        }
3923    }
3924
3925    // ═════════════════════════════════════════════════════════════════════════
3926    // Comments and DOCTYPE
3927    // ═════════════════════════════════════════════════════════════════════════
3928
3929    /// Verifies HTML comments are preserved in the serialized output.
3930    ///
3931    /// # Safety
3932    ///
3933    /// - The NUL-terminated static `html` buffer stays valid for the
3934    ///   `parse_memory` call; the returned document pointer is asserted
3935    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3936    ///   freed exactly once with `tree::free_doc`.
3937    #[test]
3938    fn test_html_comment() {
3939        unsafe {
3940            let html = b"<html><!-- This is a comment --><body><p>Text</p></body></html>\0";
3941            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3942            assert!(!doc.is_null());
3943
3944            let s = html_doc_to_string(doc);
3945            assert!(s.contains("<!-- This is a comment -->"));
3946
3947            tree::free_doc(doc);
3948        }
3949    }
3950
3951    // ═════════════════════════════════════════════════════════════════════════
3952    // new_doc / new_doc_no_dtd
3953    // ═════════════════════════════════════════════════════════════════════════
3954
3955    /// Verifies `new_doc` creates an HTML document WITHOUT an implicit
3956    /// `html`/`head`/`body` skeleton (upstream htmlNewDoc does not seed them;
3957    /// the HTML parser grows them lazily). `html_doc_to_string` of an empty
3958    /// body-less HTML doc emits just the trailing newline.
3959    ///
3960    /// # Safety
3961    ///
3962    /// - `new_doc` returns an owned `_xmlDoc` or NULL; the pointer is
3963    ///   asserted non-NULL before its `type_` field is dereferenced and
3964    ///   before `html_doc_to_string`, and is freed exactly once with
3965    ///   `tree::free_doc`.
3966    #[test]
3967    fn test_new_doc_creates_html_head_body() {
3968        unsafe {
3969            let doc = new_doc(ptr::null());
3970            assert!(!doc.is_null());
3971            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
3972
3973            let s = html_doc_to_string(doc);
3974            assert!(!s.contains("<html>"), "htmlNewDoc must not seed <html>");
3975            assert!(!s.contains("<head>"), "htmlNewDoc must not seed <head>");
3976            assert!(!s.contains("<body>"), "htmlNewDoc must not seed <body>");
3977
3978            tree::free_doc(doc);
3979        }
3980    }
3981
3982    /// Verifies `new_doc_no_dtd` creates a document without implicit
3983    /// structure and without a DTD.
3984    ///
3985    /// # Safety
3986    ///
3987    /// - `new_doc_no_dtd` returns an owned `_xmlDoc` or NULL; the pointer is
3988    ///   asserted non-NULL before its `type_` field is dereferenced and
3989    ///   before `html_doc_to_string`, and is freed exactly once with
3990    ///   `tree::free_doc`.
3991    #[test]
3992    fn test_new_doc_no_dtd() {
3993        unsafe {
3994            let doc = new_doc_no_dtd(ptr::null());
3995            assert!(!doc.is_null());
3996            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
3997
3998            // No implicit html/head/body. UPSTREAM-PARITY: the HTML
3999            // serializer (htmlNodeDumpInternal) writes "\n" for a
4000            // document node with no children (HTMLtree.c:861-863).
4001            let s = html_doc_to_string(doc);
4002            assert_eq!(s, "\n");
4003
4004            tree::free_doc(doc);
4005        }
4006    }
4007
4008    /// Phase 14.3 (dom S1 / loadHTML family): an html-parsed document
4009    /// carries properties = XML_DOC_HTML and standalone = 1 — upstream
4010    /// xmlSAX2StartDocument for HTML parsers sets XML_DOC_HTML and
4011    /// htmlNewDocNoDtD defaults standalone=1. The pre-fix XML_DOC_WELLFORMED-
4012    /// only value made PHP's spec serializer and the engine AS_XML save treat
4013    /// loadHTML'd documents as plain XML (declaration/standalone loss;
4014    /// ext/dom dom005/gh15670/gh16535/gh17397/gh19612 + loadHTMLfile*).
4015    ///
4016    /// # Safety
4017    ///
4018    /// - the parsed html doc is freed exactly once; the pointer is asserted
4019    ///   non-NULL before its fields are read.
4020    #[test]
4021    fn test_parsed_html_doc_flags() {
4022        unsafe {
4023            let doc = parse_memory(c"<html><body>x</body></html>".as_ptr(), 23);
4024            assert!(!doc.is_null());
4025            assert_eq!(
4026                (*doc).properties & (crate::abi::types::xmlDocProperties::XML_DOC_HTML as c_int),
4027                crate::abi::types::xmlDocProperties::XML_DOC_HTML as c_int,
4028                "html-parsed docs must carry XML_DOC_HTML"
4029            );
4030            assert_eq!(
4031                (*doc).standalone,
4032                1,
4033                "html-parsed docs default standalone=yes"
4034            );
4035            tree::free_doc(doc);
4036        }
4037    }
4038
4039    // ═════════════════════════════════════════════════════════════════════════
4040    // Entity Resolution in Text
4041    // ═════════════════════════════════════════════════════════════════════════
4042
4043    #[test]
4044    fn test_resolve_numeric_entity() {
4045        assert_eq!(resolve_numeric_entity("65", false), vec![b'A']);
4046        assert_eq!(resolve_numeric_entity("41", true), vec![b'A']);
4047        assert_eq!(resolve_numeric_entity("0", false), Vec::<u8>::new());
4048    }
4049
4050    #[test]
4051    fn test_resolve_entity_unknown() {
4052        let result = resolve_entity("unknown");
4053        assert_eq!(result, b"&unknown;");
4054    }
4055
4056    // ═════════════════════════════════════════════════════════════════════════
4057    // init/cleanup Parser
4058    // ═════════════════════════════════════════════════════════════════════════
4059
4060    #[test]
4061    fn test_init_cleanup_parser() {
4062        // Just ensure no crashes
4063        init_parser();
4064        cleanup_parser();
4065    }
4066
4067    // ═════════════════════════════════════════════════════════════════════════
4068    // Parser Context
4069    // ═════════════════════════════════════════════════════════════════════════
4070
4071    /// Verifies `create_file_parser_ctxt` and `free_parser_ctxt` round trip.
4072    ///
4073    /// # Safety
4074    ///
4075    /// - The static NUL-terminated filename stays valid for the
4076    ///   `create_file_parser_ctxt` call; the returned context is asserted
4077    ///   non-NULL before being freed with `free_parser_ctxt`.
4078    #[test]
4079    fn test_create_free_parser_ctxt() {
4080        unsafe {
4081            let ctxt =
4082                create_file_parser_ctxt(b"test.html\0" as *const u8 as *const c_char, ptr::null());
4083            assert!(!ctxt.is_null());
4084            free_parser_ctxt(ctxt);
4085        }
4086    }
4087
4088    // ═════════════════════════════════════════════════════════════════════════
4089    // Complex HTML Documents
4090    // ═════════════════════════════════════════════════════════════════════════
4091
4092    /// Parses a complex HTML document and verifies the serialized structure.
4093    ///
4094    /// # Safety
4095    ///
4096    /// - The NUL-terminated static `html` buffer stays valid for the
4097    ///   `parse_memory` call; the returned document pointer is asserted
4098    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
4099    ///   freed exactly once with `tree::free_doc`.
4100    #[test]
4101    fn test_complex_html_document() {
4102        unsafe {
4103            let html = b"<!DOCTYPE html>
4104<html>
4105<head>
4106    <meta charset=\"utf-8\">
4107    <title>Test Page</title>
4108    <link rel=\"stylesheet\" href=\"style.css\">
4109</head>
4110<body>
4111    <div id=\"main\">
4112        <h1>Title</h1>
4113        <p>First paragraph with <a href=\"link.html\">a link</a>.</p>
4114        <p>Second paragraph.</p>
4115        <ul>
4116            <li>Item 1</li>
4117            <li>Item 2</li>
4118        </ul>
4119        <br>
4120        <hr>
4121        <img src=\"image.jpg\" alt=\"An image\">
4122    </div>
4123    <script>alert('hello');</script>
4124</body>
4125</html>\0";
4126            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
4127            assert!(!doc.is_null());
4128
4129            let s = html_doc_to_string(doc);
4130            assert!(s.contains("<html>"));
4131            assert!(s.contains("<head>"));
4132            assert!(s.contains("<title>Test Page</title>"));
4133            assert!(s.contains("<body>"));
4134            assert!(s.contains("<h1>Title</h1>"));
4135            assert!(s.contains("a link"));
4136            assert!(s.contains("Second paragraph"));
4137            assert!(s.contains("<br>"));
4138            assert!(s.contains("<hr>"));
4139            assert!(s.contains("<img"));
4140            assert!(s.contains("<script>"));
4141
4142            tree::free_doc(doc);
4143        }
4144    }
4145
4146    // ═════════════════════════════════════════════════════════════════════════
4147    // parse_doc
4148    // ═════════════════════════════════════════════════════════════════════════
4149
4150    /// Verifies `parse_doc` parses from an `xmlChar` buffer.
4151    ///
4152    /// # Safety
4153    ///
4154    /// - The NUL-terminated static `html` buffer is passed to `parse_doc` as
4155    ///   an `xmlChar` pointer and must stay valid for the call; the returned
4156    ///   document pointer is asserted non-NULL before it is dereferenced by
4157    ///   `html_doc_to_string` and is freed exactly once with `tree::free_doc`.
4158    #[test]
4159    fn test_parse_doc() {
4160        unsafe {
4161            let html = b"<p>Hello from parse_doc</p>\0";
4162            let doc = parse_doc(html.as_ptr() as *const xmlChar, ptr::null(), 0);
4163            assert!(!doc.is_null());
4164
4165            let s = html_doc_to_string(doc);
4166            assert!(s.contains("Hello from parse_doc"));
4167
4168            tree::free_doc(doc);
4169        }
4170    }
4171
4172    // ═════════════════════════════════════════════════════════════════════════
4173    // Table elements auto-close
4174    // ═════════════════════════════════════════════════════════════════════════
4175
4176    /// Verifies a `td` element auto-closes a previous `td` inside a row.
4177    ///
4178    /// # Safety
4179    ///
4180    /// - The NUL-terminated static `html` buffer stays valid for the
4181    ///   `parse_memory` call; the returned document pointer is asserted
4182    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
4183    ///   freed exactly once with `tree::free_doc`.
4184    #[test]
4185    fn test_table_element_auto_close() {
4186        unsafe {
4187            let html = b"<table><tr><td>Cell 1<td>Cell 2</td></tr></table>";
4188            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
4189            assert!(!doc.is_null());
4190
4191            let s = html_doc_to_string(doc);
4192            assert!(s.contains("<td>Cell 1"));
4193            assert!(s.contains("<td>Cell 2"));
4194
4195            tree::free_doc(doc);
4196        }
4197    }
4198
4199    #[test]
4200    fn test_table_tr_auto_close() {
4201        // <tr> must auto-close the open row (the corpus html-table op:
4202        // `<table><tr><td>1<td>2<tr><td>3</table>` yields two sibling rows).
4203        unsafe {
4204            let html = b"<table><tr><td>1<td>2<tr><td>3</table>\0";
4205            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
4206            assert!(!doc.is_null());
4207            let s = html_doc_to_string(doc);
4208            assert!(s.contains("</td></tr><tr>"));
4209            tree::free_doc(doc);
4210        }
4211    }
4212}