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-8 / US-ASCII / NONE (and unsupported encodings): no conversion.
1905        _ => None,
1906    }
1907}
1908
1909/// Parse a leading `<!DOCTYPE ...>` declaration from HTML input bytes.
1910///
1911/// Returns `Some((name, external_id, system_id))` when the input begins (after
1912/// optional whitespace) with a case-insensitive `<!DOCTYPE`. Quoted external
1913/// and system identifiers are unwrapped; unquoted identifiers are taken as-is
1914/// up to the closing `>`. Returns `None` when no DOCTYPE is declared.
1915fn parse_html_doctype_decl(input: &[u8]) -> Option<(Vec<u8>, Option<Vec<u8>>, Option<Vec<u8>>)> {
1916    let mut i = 0usize;
1917    while i < input.len() && input[i].is_ascii_whitespace() {
1918        i += 1;
1919    }
1920    if i + 2 >= input.len() || input[i] != b'<' || !(input[i + 1] == b'!') {
1921        return None;
1922    }
1923    let kw = b"DOCTYPE";
1924    if !input.get(i + 2..i + 2 + kw.len()).is_some_and(|s| {
1925        s.iter()
1926            .enumerate()
1927            .all(|(k, b)| b.to_ascii_uppercase() == kw[k])
1928    }) {
1929        return None;
1930    }
1931    i += 2 + kw.len();
1932    // Skip whitespace after DOCTYPE, then read the root name.
1933    while i < input.len() && input[i].is_ascii_whitespace() {
1934        i += 1;
1935    }
1936    let name_start = i;
1937    while i < input.len() && !input[i].is_ascii_whitespace() && input[i] != b'>' {
1938        i += 1;
1939    }
1940    // A nameless `<!DOCTYPE>` (nothing before the closing '>') is still a
1941    // declared DOCTYPE — upstream htmlParseDocTypeDecl fires internalSubset
1942    // with a NULL name (gh17500/bug78025: doctype->name is ""), it must NOT
1943    // fall through to the default HTML 4.0 DTD.
1944    let name = if name_start == i {
1945        Vec::new()
1946    } else {
1947        input[name_start..i].to_vec()
1948    };
1949    // If the nameless doctype ends right at '>', there are no ids either.
1950    if name.is_empty() {
1951        return Some((name, None, None));
1952    }
1953    // Skip whitespace, then optional PUBLIC/SYSTEM id.
1954    let mut ext: Option<Vec<u8>> = None;
1955    let mut sys: Option<Vec<u8>> = None;
1956    while i < input.len() && input[i].is_ascii_whitespace() {
1957        i += 1;
1958    }
1959    if i < input.len() && input[i] != b'>' {
1960        // a PUBLIC/SYSTEM keyword may follow
1961        let word_start = i;
1962        while i < input.len() && input[i].is_ascii_alphabetic() {
1963            i += 1;
1964        }
1965        let word = input[word_start..i].to_ascii_uppercase();
1966        if word == b"PUBLIC" {
1967            while i < input.len() && input[i].is_ascii_whitespace() {
1968                i += 1;
1969            }
1970            // external identifier (PUBLIC) comes first
1971            if i < input.len() && (input[i] == b'"' || input[i] == b'\'') {
1972                let q = input[i];
1973                i += 1;
1974                let v_start = i;
1975                while i < input.len() && input[i] != q {
1976                    i += 1;
1977                }
1978                ext = Some(input[v_start..i].to_vec());
1979                if i < input.len() {
1980                    i += 1;
1981                }
1982            }
1983            while i < input.len() && input[i].is_ascii_whitespace() {
1984                i += 1;
1985            }
1986            // system literal (may be absent)
1987            if i < input.len()
1988                && i + 1 < input.len()
1989                && (input[i] == b'"' || input[i] == b'\'')
1990                && input[i] != b'>'
1991            {
1992                let q = input[i];
1993                i += 1;
1994                let v_start = i;
1995                while i < input.len() && input[i] != q {
1996                    i += 1;
1997                }
1998                sys = Some(input[v_start..i].to_vec());
1999            }
2000        } else if word == b"SYSTEM" {
2001            while i < input.len() && input[i].is_ascii_whitespace() {
2002                i += 1;
2003            }
2004            if i < input.len() && (input[i] == b'"' || input[i] == b'\'') {
2005                let q = input[i];
2006                i += 1;
2007                let v_start = i;
2008                while i < input.len() && input[i] != q {
2009                    i += 1;
2010                }
2011                sys = Some(input[v_start..i].to_vec());
2012            }
2013        }
2014    }
2015    Some((name, ext, sys))
2016}
2017
2018/// Parse HTML from a buffer.
2019///
2020/// # Safety
2021///
2022/// - `buffer` must point to valid memory of at least `size` bytes.
2023unsafe fn html_parse_buffer(
2024    ctxt: &mut HtmlParserCtxt,
2025    buffer: *const c_char,
2026    size: c_int,
2027) -> *mut _xmlDoc {
2028    if buffer.is_null() || size <= 0 {
2029        return ptr::null_mut();
2030    }
2031
2032    // Create document with HTML_DOCUMENT_NODE type
2033    let doc = tree::new_doc(ptr::null());
2034    if doc.is_null() {
2035        return ptr::null_mut();
2036    }
2037    unsafe {
2038        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2039        // UPSTREAM-PARITY: HTML documents carry no version (htmlNewDocNoDtD
2040        // leaves version NULL), so drop the XML default set by new_doc.
2041        if !(*doc).version.is_null() {
2042            crate::abi::allocator::xmlFreeImpl((*doc).version as *mut c_void);
2043        }
2044        (*doc).version = ptr::null_mut();
2045        // UPSTREAM-PARITY (SAX2.c xmlSAX2StartDocument for HTML parsers): an
2046        // html-parsed document carries properties = XML_DOC_HTML. The
2047        // pre-fix XML_DOC_WELLFORMED-only value made PHP's spec serializer
2048        // treat html-parsed documents as XML and drop the standalone/HTML
2049        // declaration handling (ext/dom dom005 / gh15670 / gh17397 — the
2050        // saveXML of a loadHTML'd document lost "standalone=yes").
2051        (*doc).properties = crate::abi::types::xmlDocProperties::XML_DOC_HTML as c_int;
2052        // UPSTREAM-PARITY: HTML documents default to standalone="yes"
2053        // (visible when serialized with the XML serializer, e.g. --xmlout).
2054        (*doc).standalone = 1;
2055    }
2056    // UPSTREAM-PARITY: htmlParseDocument honours a DOCTYPE declared in the
2057    // source (htmlParseDocTypeDecl fills doc->intSubset with the declared
2058    // name/public/system ids); only when the source declares none does it
2059    // create the default HTML 4.0 DTD. nokogiri reads those ids back for
2060    // dtd.html_dtd?/html5_dtd?, so a source `<!DOCTYPE html>` must NOT pick up
2061    // the default HTML 4.0 DTD.
2062    let raw_input = unsafe { slice::from_raw_parts(buffer as *const u8, size as usize) };
2063    if let Some((name, ext, sys)) = parse_html_doctype_decl(raw_input) {
2064        // UPSTREAM-PARITY (htmlSAX2InternalSubset / xmlCreateIntSubset): a
2065        // declared DOCTYPE without a name creates the internal subset with a
2066        // NULL name (php doctype->name reads back "").
2067        let name_cstr = if name.is_empty() {
2068            ptr::null()
2069        } else {
2070            crate::xml::string::bytes_to_xmlstr(&name)
2071        };
2072        let ext_cstr = ext
2073            .map(|s| crate::xml::string::bytes_to_xmlstr(&s))
2074            .unwrap_or(ptr::null_mut());
2075        let sys_cstr = sys
2076            .map(|s| crate::xml::string::bytes_to_xmlstr(&s))
2077            .unwrap_or(ptr::null_mut());
2078        unsafe {
2079            crate::xml::dtd::create_int_subset(
2080                doc,
2081                name_cstr as *const xmlChar,
2082                ext_cstr as *const xmlChar,
2083                sys_cstr as *const xmlChar,
2084            );
2085        }
2086        if !name_cstr.is_null() {
2087            unsafe { crate::abi::allocator::xmlFreeImpl(name_cstr as *mut c_void) };
2088        }
2089        if !ext_cstr.is_null() {
2090            unsafe { crate::abi::allocator::xmlFreeImpl(ext_cstr as *mut c_void) };
2091        }
2092        if !sys_cstr.is_null() {
2093            unsafe { crate::abi::allocator::xmlFreeImpl(sys_cstr as *mut c_void) };
2094        }
2095    } else {
2096        // Source declares no DOCTYPE: use the default HTML 4.0 DTD — unless
2097        // HTML_PARSE_NODEFDTD suppresses it (php LIBXML_HTML_NODEFDTD).
2098        if ctxt.options & HTML_PARSE_NODEFDTD == 0 {
2099            unsafe {
2100                crate::xml::dtd::create_int_subset(
2101                    doc,
2102                    b"html\0" as *const u8 as *const xmlChar,
2103                    b"-//W3C//DTD HTML 4.0 Transitional//EN\0" as *const u8 as *const xmlChar,
2104                    b"http://www.w3.org/TR/REC-html40/loose.dtd\0" as *const u8 as *const xmlChar,
2105                );
2106            }
2107        }
2108    }
2109    ctxt.doc = doc;
2110
2111    // UPSTREAM-PARITY: a declared input encoding is converted to UTF-8 before
2112    // parsing (xmlCtxtNewInputFromMemory -> xmlSwitchInputEncodingName installs
2113    // an input-buffer decoder); the parse loop and the tree always consume
2114    // UTF-8. The converted copy lives for the whole parse.
2115    let converted: Option<Vec<u8>> = if !ctxt.encoding.is_null() {
2116        let raw = unsafe { slice::from_raw_parts(buffer as *const u8, size as usize) };
2117        convert_input_to_utf8(ctxt.encoding, raw)
2118    } else {
2119        None
2120    };
2121    let (input_ptr, input_len): (*const u8, usize) = match &converted {
2122        Some(v) => (v.as_ptr(), v.len()),
2123        None => (buffer as *const u8, size as usize),
2124    };
2125
2126    // Set up input
2127    ctxt.input = input_ptr as *mut u8;
2128    ctxt.input_len = input_len;
2129    ctxt.input_pos = 0;
2130    ctxt.line = 1;
2131
2132    // Main parse loop
2133    loop {
2134        if ctxt.is_eof() {
2135            break;
2136        }
2137
2138        let ch = ctxt.peek().unwrap_or(0);
2139
2140        if ch == b'<' {
2141            ctxt.next(); // consume '<'
2142
2143            // Check for </ (end tag)
2144            if ctxt.peek() == Some(b'/') {
2145                ctxt.next(); // consume '/'
2146                let tag_name = ctxt.read_while(|ch| {
2147                    ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
2148                });
2149
2150                // Consume until '>'
2151                while ctxt.peek() != Some(b'>') && !ctxt.is_eof() {
2152                    ctxt.next();
2153                }
2154                if ctxt.peek() == Some(b'>') {
2155                    ctxt.next(); // consume '>'
2156                }
2157
2158                if !tag_name.is_empty() {
2159                    handle_end_tag(ctxt, &tag_name);
2160                }
2161                continue;
2162            }
2163
2164            // Check for <!-- (comment)
2165            if ctxt.peek() == Some(b'!')
2166                && ctxt.peek_at(1) == Some(b'-')
2167                && ctxt.peek_at(2) == Some(b'-')
2168            {
2169                ctxt.next(); // consume '!'
2170                ctxt.next(); // consume '-'
2171                ctxt.next(); // consume '-'
2172
2173                // Read until -->
2174                let mut comment_content = Vec::new();
2175                loop {
2176                    if ctxt.peek() == Some(b'-')
2177                        && ctxt.peek_at(1) == Some(b'-')
2178                        && ctxt.peek_at(2) == Some(b'>')
2179                    {
2180                        ctxt.next(); // consume '-'
2181                        ctxt.next(); // consume '-'
2182                        ctxt.next(); // consume '>'
2183                        break;
2184                    }
2185                    match ctxt.next() {
2186                        Some(ch) => comment_content.push(ch),
2187                        None => break,
2188                    }
2189                }
2190
2191                // Create comment node
2192                if !comment_content.is_empty() {
2193                    let comment_node = tree::new_comment(bytes_to_xmlstr(&comment_content));
2194                    if !comment_node.is_null() {
2195                        let insertion_point = if !ctxt.current.is_null() {
2196                            ctxt.current
2197                        } else {
2198                            ctxt.doc as *mut _xmlNode
2199                        };
2200                        tree::add_child(insertion_point, comment_node);
2201                    }
2202                }
2203                continue;
2204            }
2205
2206            // Check for <!DOCTYPE
2207            if ctxt.peek() == Some(b'!') {
2208                ctxt.next(); // consume '!'
2209                let _rest = ctxt.read_while(|ch| ch != b'>');
2210                if ctxt.peek() == Some(b'>') {
2211                    ctxt.next(); // consume '>'
2212                }
2213                // We don't create a DTD node from HTML DOCTYPE in this implementation
2214                // (matching basic libxml2 behavior where HTML doctype is mostly ignored)
2215                continue;
2216            }
2217
2218            // Check for <? (processing instruction)
2219            if ctxt.peek() == Some(b'?') {
2220                ctxt.next(); // consume '?'
2221                             // Read until we see ?>
2222                let mut pi_content = Vec::new();
2223                loop {
2224                    if ctxt.peek() == Some(b'?') && ctxt.peek_at(1) == Some(b'>') {
2225                        break;
2226                    }
2227                    match ctxt.next() {
2228                        Some(ch) => pi_content.push(ch),
2229                        None => break,
2230                    }
2231                }
2232                // Consume ?>
2233                if ctxt.peek() == Some(b'?') {
2234                    ctxt.next();
2235                }
2236                if ctxt.peek() == Some(b'>') {
2237                    ctxt.next();
2238                }
2239                // Create PI node
2240                if !pi_content.is_empty() {
2241                    // Split into target and value
2242                    let mut parts = pi_content.splitn(2, |b| *b == b' ');
2243                    let target = parts.next().unwrap_or(&pi_content);
2244                    let value = parts.next().unwrap_or(b"");
2245
2246                    let pi_node = tree::new_pi(bytes_to_xmlstr(target), bytes_to_xmlstr(value));
2247                    if !pi_node.is_null() {
2248                        let insertion_point = if !ctxt.current.is_null() {
2249                            ctxt.current
2250                        } else {
2251                            ctxt.doc as *mut _xmlNode
2252                        };
2253                        tree::add_child(insertion_point, pi_node);
2254                    }
2255                }
2256                continue;
2257            }
2258
2259            // Parse start tag
2260            let tag_name = ctxt.read_while(|ch| {
2261                ch != b'>' && ch != b'/' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
2262            });
2263
2264            if tag_name.is_empty() {
2265                // Just a bare '<' with no tag name, treat as text
2266                handle_text(ctxt, b"<");
2267                continue;
2268            }
2269
2270            // Parse attributes
2271            let attrs = parse_attributes(ctxt);
2272
2273            // Check for self-closing (/>) or just >
2274            if ctxt.peek() == Some(b'/') {
2275                ctxt.next(); // consume '/'
2276                if ctxt.peek() == Some(b'>') {
2277                    ctxt.next(); // consume '>'
2278                }
2279            } else if ctxt.peek() == Some(b'>') {
2280                ctxt.next(); // consume '>'
2281            }
2282
2283            // Check if this is a raw text element (script, style)
2284            let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
2285            let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
2286
2287            if tag_str == "script" || tag_str == "style" {
2288                // Handle raw text content
2289                // Create the element first
2290                let raw_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(&tag_name));
2291                if !raw_node.is_null() {
2292                    for attr in &attrs {
2293                        let name_c = bytes_to_xmlstr(&attr.name);
2294                        let val_c = bytes_to_xmlstr(&attr.value);
2295                        if !name_c.is_null() {
2296                            tree::set_prop(raw_node, name_c, val_c);
2297                            xmlFreeImpl(name_c as *mut c_void);
2298                            if !val_c.is_null() {
2299                                xmlFreeImpl(val_c as *mut c_void);
2300                            }
2301                        }
2302                    }
2303
2304                    let insertion_point = if ctxt.current.is_null() {
2305                        if ctxt.in_head {
2306                            ensure_head(ctxt);
2307                            ctxt.head
2308                        } else {
2309                            ensure_body(ctxt);
2310                            ctxt.body
2311                        }
2312                    } else {
2313                        ctxt.current
2314                    };
2315
2316                    if !insertion_point.is_null() {
2317                        tree::add_child(insertion_point, raw_node);
2318
2319                        // Read raw text until matching </script> or </style>
2320                        let end_tag = format!("</{}", tag_str);
2321                        let end_bytes = end_tag.as_bytes();
2322                        let mut raw_text = Vec::new();
2323                        // Potential end-tag prefix chars ("</script") are
2324                        // buffered separately: on a full match they are
2325                        // DISCARDED (the content ends before the '<' —
2326                        // upstream script-content ends at the '<' that starts
2327                        // the close tag), on a mismatch they are flushed back
2328                        // into the text.
2329                        let mut match_buf: Vec<u8> = Vec::new();
2330                        let mut match_idx = 0;
2331
2332                        loop {
2333                            if ctxt.is_eof() {
2334                                break;
2335                            }
2336                            let ch = ctxt.peek().unwrap();
2337                            if ch.to_ascii_lowercase() == end_bytes[match_idx] {
2338                                match_buf.push(ch);
2339                                match_idx += 1;
2340                                ctxt.next();
2341                                if match_idx == end_bytes.len() {
2342                                    // We found the start of </tag
2343                                    // Add the text before the end tag
2344                                    if !raw_text.is_empty() {
2345                                        let text_node = tree::new_text(bytes_to_xmlstr(&raw_text));
2346                                        if !text_node.is_null() {
2347                                            tree::add_child(raw_node, text_node);
2348                                        }
2349                                    }
2350                                    // Consume the rest of the end tag: "tag>"
2351                                    // Now read "tag>"
2352                                    let _suffix = ctxt.read_while(|ch| ch != b'>');
2353                                    if ctxt.peek() == Some(b'>') {
2354                                        ctxt.next();
2355                                    }
2356                                    // Close the element
2357                                    ctxt.current = unsafe { (*raw_node).parent };
2358                                    break;
2359                                }
2360                            } else {
2361                                // Mismatch: flush any buffered end-tag prefix
2362                                // chars back into the text, then the current
2363                                // char.
2364                                if match_idx > 0 {
2365                                    raw_text.extend_from_slice(&match_buf);
2366                                    match_buf.clear();
2367                                    match_idx = 0;
2368                                }
2369                                raw_text.push(ch);
2370                                ctxt.next();
2371                            }
2372                        }
2373
2374                        // If we never found the end tag, add the text (plus
2375                        // any buffered end-tag prefix chars).
2376                        if match_idx < end_bytes.len() {
2377                            raw_text.extend_from_slice(&match_buf);
2378                            if !raw_text.is_empty() {
2379                                let text_node = tree::new_text(bytes_to_xmlstr(&raw_text));
2380                                if !text_node.is_null() {
2381                                    tree::add_child(raw_node, text_node);
2382                                }
2383                            }
2384                            ctxt.current = unsafe { (*raw_node).parent };
2385                        }
2386                    }
2387                }
2388                continue;
2389            }
2390
2391            // Regular start tag
2392            handle_start_tag(ctxt, &tag_name, &attrs);
2393        } else {
2394            // Text content - read until next '<' or entity '&'
2395            let mut text = Vec::new();
2396            loop {
2397                match ctxt.peek() {
2398                    Some(b'<') => break,
2399                    Some(b'&') => {
2400                        // Handle entity reference inline
2401                        let entity_text = parse_entity(ctxt);
2402                        text.extend_from_slice(&entity_text);
2403                    }
2404                    Some(0) => {
2405                        // UPSTREAM-PARITY (bug #80268, libxml2 >= 2.9.12): NUL
2406                        // bytes in HTML content are DROPPED and parsing
2407                        // continues — they must neither terminate the text
2408                        // node (truncating at the NUL) nor reach the tree
2409                        // (content is C-string storage).
2410                        ctxt.next();
2411                    }
2412                    Some(ch) => {
2413                        text.push(ch);
2414                        ctxt.next();
2415                    }
2416                    None => break,
2417                }
2418            }
2419
2420            if !text.is_empty() {
2421                handle_text(ctxt, &text);
2422            }
2423        }
2424    }
2425
2426    // Post-processing: ensure html/head/body are created even for empty
2427    // documents (never under HTML_PARSE_NOIMPLIED — the source tree is kept
2428    // as-is, bug76285).
2429    if ctxt.html.is_null() && ctxt.options & HTML_PARSE_NOIMPLIED == 0 {
2430        ensure_html(ctxt);
2431    }
2432
2433    // UPSTREAM-PARITY (xmlSAX2Characters with XML_PARSE_NOBLANKS):
2434    // whitespace-only text runs are reported as ignorableWhitespace and never
2435    // become text nodes. The candidate builds them eagerly, so drop them here
2436    // (ext/dom dom005's loadHTMLFile(…, LIBXML_NOBLANKS) serialization kept
2437    // the <head>-region newline text nodes the oracle drops).
2438    if ctxt.options & (crate::abi::types::XML_PARSE_NOBLANKS as c_int) != 0 && !doc.is_null() {
2439        unsafe {
2440            drop_blank_text_nodes((*doc).children);
2441        }
2442    }
2443
2444    // UPSTREAM-PARITY (SAX2.c xmlSAX2StartElementNs / xmlSAX2AttributeNs):
2445    // ID-bearing attributes (the HTML `id` attribute, `<a name>` and any
2446    // DTD-declared ID) are registered in doc->ids as they are parsed. The
2447    // html tree builder attaches attributes BEFORE a node gains its document
2448    // pointer (add_child propagates doc later), so the per-attribute
2449    // registration cannot run at attribute time — do a tree-order pass once
2450    // the document is complete (first registration wins, matching the
2451    // in-order xmlAddID calls of a SAX2 parse). This is what makes
2452    // DOMDocument::loadHTML + getElementById / HTMLCollection named lookups
2453    // see `id="…"` attributes.
2454    if !doc.is_null() {
2455        unsafe {
2456            register_html_ids(doc, (*doc).children);
2457        }
2458    }
2459
2460    doc
2461}
2462
2463/// Register ID/IDREF attributes of every element in the sibling chain and
2464/// their element descendants (tree order, first registration wins).
2465///
2466/// # Safety
2467///
2468/// - `doc` must be a valid `_xmlDoc` (HTML type) whose tree stays alive for
2469///   the call; `cur` must be NULL or a valid live node chain within `doc`.
2470unsafe fn register_html_ids(doc: *mut _xmlDoc, cur: *mut _xmlNode) {
2471    let mut n = cur;
2472    while !n.is_null() {
2473        let t = unsafe { (*n).type_ };
2474        if t == XML_ELEMENT_NODE as c_int {
2475            let el = n;
2476            let mut attr = unsafe { (*el).properties };
2477            while !attr.is_null() {
2478                if unsafe { (*attr).id }.is_null()
2479                    && !unsafe { (*attr).children }.is_null()
2480                    && unsafe { (*(*attr).children).type_ } == XML_TEXT_NODE as c_int
2481                    && unsafe { (*(*attr).children).next }.is_null()
2482                {
2483                    let v = unsafe { (*(*attr).children).content };
2484                    if !v.is_null() {
2485                        let id_res = crate::xml::validation::is_id(doc, el, attr);
2486                        if id_res > 0 {
2487                            crate::xml::validation::add_id(ptr::null_mut(), doc, v, attr);
2488                        } else if crate::xml::validation::is_ref(doc, el, attr) > 0 {
2489                            crate::xml::validation::add_ref(ptr::null_mut(), doc, v, attr);
2490                        }
2491                    }
2492                }
2493                attr = unsafe { (*attr).next };
2494            }
2495            if !unsafe { (*el).children }.is_null() {
2496                register_html_ids(doc, unsafe { (*el).children });
2497            }
2498        }
2499        n = unsafe { (*n).next };
2500    }
2501}
2502
2503/// Unlink and free every whitespace-only text node in the sibling chain and
2504/// their element descendants (upstream html parse + XML_PARSE_NOBLANKS).
2505///
2506/// # Safety
2507///
2508/// - `cur` must be NULL or a valid live `_xmlNode` chain (element/text/…)
2509///   inside the document being cleaned.
2510unsafe fn drop_blank_text_nodes(cur: *mut _xmlNode) {
2511    let mut n = cur;
2512    while !n.is_null() {
2513        let next = unsafe { (*n).next };
2514        let t = unsafe { (*n).type_ };
2515        if t == XML_TEXT_NODE as c_int {
2516            let content = unsafe { (*n).content };
2517            let blank = if content.is_null() {
2518                true
2519            } else {
2520                let mut p = content;
2521                while unsafe { *p } != 0 {
2522                    match unsafe { *p } {
2523                        b' ' | b'\t' | b'\r' | b'\n' => {}
2524                        _ => break,
2525                    }
2526                    p = unsafe { p.add(1) };
2527                }
2528                (unsafe { *p }) == 0
2529            };
2530            if blank {
2531                tree::unlink_node(n);
2532                tree::free_node(n);
2533            }
2534        } else if t == XML_ELEMENT_NODE as c_int && !unsafe { (*n).children }.is_null() {
2535            drop_blank_text_nodes(unsafe { (*n).children });
2536        }
2537        n = next;
2538    }
2539}
2540
2541// ═══════════════════════════════════════════════════════════════════════════════
2542// Public API Functions
2543// ═══════════════════════════════════════════════════════════════════════════════
2544
2545/// Parse HTML from a file.
2546///
2547/// # UPSTREAM-PARITY
2548///
2549/// Equivalent to `htmlParseFile` in libxml2.
2550///
2551/// # Safety
2552///
2553/// - `filename` must be a valid null-terminated C string or NULL.
2554/// - `encoding` must be a valid null-terminated C string or NULL.
2555pub unsafe fn parse_file(
2556    filename: *const c_char,
2557    encoding: *const c_char,
2558    options: c_int,
2559) -> *mut _xmlDoc {
2560    if filename.is_null() {
2561        return ptr::null_mut();
2562    }
2563
2564    // Read the file into memory
2565    let filename_str = unsafe { std::ffi::CStr::from_ptr(filename) };
2566    let path = filename_str.to_str().unwrap_or("");
2567    let content = match std::fs::read(path) {
2568        Ok(data) => data,
2569        Err(_) => return ptr::null_mut(),
2570    };
2571
2572    let mut ctxt = HtmlParserCtxt::new();
2573    ctxt.options = options;
2574    if !encoding.is_null() {
2575        let _enc_cstr = unsafe { std::ffi::CStr::from_ptr(encoding) };
2576        ctxt.encoding = unsafe { c_strdup(encoding) };
2577    }
2578
2579    let doc = unsafe {
2580        html_parse_buffer(
2581            &mut ctxt,
2582            content.as_ptr() as *const c_char,
2583            content.len() as c_int,
2584        )
2585    };
2586
2587    if !doc.is_null() && !filename.is_null() {
2588        unsafe {
2589            (*doc).URL = c_strdup(filename) as *mut xmlChar;
2590        }
2591    }
2592
2593    doc
2594}
2595
2596/// Parse HTML from a memory buffer.
2597///
2598/// # UPSTREAM-PARITY
2599///
2600/// Equivalent to `htmlParseMemory` in libxml2.
2601///
2602/// # Safety
2603///
2604/// - `buffer` must point to valid memory of at least `size` bytes.
2605/// - `size` must be non-negative.
2606pub unsafe fn parse_memory(buffer: *const c_char, size: c_int) -> *mut _xmlDoc {
2607    unsafe { parse_memory_enc(buffer, size, ptr::null(), 0) }
2608}
2609
2610/// Parse HTML from a memory buffer with an explicit input encoding.
2611///
2612/// UPSTREAM-PARITY: equivalent to `htmlCtxtReadMemory` where the caller's
2613/// `encoding` is wired into the input buffer (`xmlCtxtNewInputFromMemory` ->
2614/// `xmlSwitchInputEncodingName` installs a decoder so the parse loop always
2615/// consumes UTF-8). NULL means no conversion (BOM sniffing only).
2616///
2617/// # Safety
2618///
2619/// - `buffer` must point to valid memory of at least `size` bytes.
2620/// - `size` must be non-negative.
2621/// - `encoding` must be a valid NUL-terminated C string or NULL.
2622pub(crate) unsafe fn parse_memory_enc(
2623    buffer: *const c_char,
2624    size: c_int,
2625    encoding: *const c_char,
2626    options: c_int,
2627) -> *mut _xmlDoc {
2628    if buffer.is_null() || size <= 0 {
2629        return ptr::null_mut();
2630    }
2631
2632    let mut ctxt = HtmlParserCtxt::new();
2633    ctxt.options = options;
2634    if !encoding.is_null() {
2635        ctxt.encoding = unsafe { c_strdup(encoding) };
2636    }
2637    unsafe { html_parse_buffer(&mut ctxt, buffer, size) }
2638}
2639
2640/// Parse HTML from a null-terminated string.
2641///
2642/// # UPSTREAM-PARITY
2643///
2644/// Equivalent to `htmlParseDoc` in libxml2.
2645///
2646/// # Safety
2647///
2648/// - `cur` must be a valid null-terminated xmlChar string or NULL.
2649/// - `encoding` must be a valid null-terminated C string or NULL.
2650pub(crate) unsafe fn parse_doc(
2651    cur: *const xmlChar,
2652    encoding: *const c_char,
2653    options: c_int,
2654) -> *mut _xmlDoc {
2655    if cur.is_null() {
2656        return ptr::null_mut();
2657    }
2658
2659    let len = unsafe { xml_strlen(cur) };
2660    let mut ctxt = HtmlParserCtxt::new();
2661    ctxt.options = options;
2662    if !encoding.is_null() {
2663        ctxt.encoding = unsafe { c_strdup(encoding) };
2664    }
2665
2666    unsafe { html_parse_buffer(&mut ctxt, cur as *const c_char, len as c_int) }
2667}
2668
2669/// Create an HTML parser context for file parsing.
2670///
2671/// # UPSTREAM-PARITY
2672///
2673/// Equivalent to `htmlCreateFileParserCtxt` in libxml2.
2674///
2675/// # Safety
2676///
2677/// - `filename` must be a valid null-terminated C string or NULL.
2678/// - `encoding` must be a valid null-terminated C string or NULL.
2679#[allow(dead_code)]
2680pub(crate) unsafe fn create_file_parser_ctxt(
2681    filename: *const c_char,
2682    encoding: *const c_char,
2683) -> *mut c_void {
2684    if filename.is_null() {
2685        return ptr::null_mut();
2686    }
2687
2688    // Host allocation (R-00019x): real C-visible `_xmlParserCtxt` at offset 0
2689    // followed by the engine state; freed as one block by `free_parser_ctxt`.
2690    let total = size_of::<_xmlParserCtxt>() + size_of::<HtmlParserCtxt>();
2691    let mem = unsafe { xmlMallocZero(total) } as *mut u8;
2692    if mem.is_null() {
2693        return ptr::null_mut();
2694    }
2695
2696    let ctxt = mem.add(size_of::<_xmlParserCtxt>()) as *mut HtmlParserCtxt;
2697    unsafe {
2698        ptr::write(ctxt, HtmlParserCtxt::new());
2699        if !encoding.is_null() {
2700            (*ctxt).encoding = c_strdup(encoding);
2701        }
2702        (*(mem as *mut _xmlParserCtxt)).html = 1;
2703    }
2704
2705    mem as *mut c_void
2706}
2707
2708/// Free an HTML parser context.
2709///
2710/// # UPSTREAM-PARITY
2711///
2712/// Equivalent to `htmlFreeParserCtxt` in libxml2.
2713///
2714/// # Safety
2715///
2716/// - `ctxt` must be a valid pointer returned by `create_file_parser_ctxt`, or NULL.
2717pub(crate) unsafe fn free_parser_ctxt(ctxt: *mut c_void) {
2718    if ctxt.is_null() {
2719        return;
2720    }
2721
2722    // Host allocation (R-00019x): a real C-visible `_xmlParserCtxt` at
2723    // offset 0 with the engine state (`HtmlParserCtxt`) after it. Free the
2724    // engine's owned input buffer and strings, then the single host block.
2725    let state = (ctxt as *mut u8).add(size_of::<_xmlParserCtxt>()) as *mut HtmlParserCtxt;
2726    unsafe {
2727        if !(*state).input.is_null() {
2728            xmlFreeImpl((*state).input as *mut c_void);
2729        }
2730        if !(*state).filename.is_null() {
2731            xmlFreeImpl((*state).filename as *mut c_void);
2732        }
2733        if !(*state).encoding.is_null() {
2734            xmlFreeImpl((*state).encoding as *mut c_void);
2735        }
2736        xmlFreeImpl(ctxt);
2737    }
2738}
2739
2740/// Initialize the HTML parser module.
2741///
2742/// # UPSTREAM-PARITY
2743///
2744/// Equivalent to `htmlInitParser` in libxml2.
2745#[allow(dead_code)]
2746pub(crate) const fn init_parser() {
2747    // Currently a no-op. In the future, may initialize HTML-specific
2748    // entity tables or other global state.
2749}
2750
2751/// Cleanup the HTML parser module.
2752///
2753/// # UPSTREAM-PARITY
2754///
2755/// Equivalent to `htmlCleanupParser` in libxml2.
2756#[allow(dead_code)]
2757pub(crate) const fn cleanup_parser() {
2758    // Currently a no-op. In the future, may free HTML-specific
2759    // global state.
2760}
2761
2762/// Create a new HTML document.
2763///
2764/// # UPSTREAM-PARITY
2765///
2766/// Equivalent to `htmlNewDoc` in libxml2.
2767///
2768/// Creates a new HTML document (type XML_HTML_DOCUMENT_NODE) WITHOUT any
2769/// implicit html/head/body skeleton. Upstream `htmlNewDoc`/`htmlNewDocNoDtD`
2770/// create only the document (the HTML parser grows the html/head/body
2771/// elements lazily); eagerly seeding the skeleton would insert a real
2772/// `<html>` root that diverges from upstream and breaks consumers like
2773/// nokogiri's `HTML4::Document.new` builder (a later `<b>` add would look
2774/// like a second root).
2775///
2776/// # SAFETY
2777///
2778/// - `version` must be a valid null-terminated xmlChar string or NULL.
2779pub(crate) unsafe fn new_doc(version: *const xmlChar) -> *mut _xmlDoc {
2780    let doc = tree::new_doc(version);
2781    if doc.is_null() {
2782        return ptr::null_mut();
2783    }
2784
2785    unsafe {
2786        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2787        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2788    }
2789
2790    doc
2791}
2792
2793/// Create a new HTML document without DTD.
2794///
2795/// # UPSTREAM-PARITY
2796///
2797/// Equivalent to `htmlNewDocNoDtD` in libxml2.
2798///
2799/// Creates a new document with type XML_HTML_DOCUMENT_NODE.
2800/// Unlike `htmlNewDoc`, this does NOT auto-create html/head/body elements.
2801///
2802/// # Safety
2803///
2804/// - `version` must be a valid null-terminated xmlChar string or NULL.
2805pub(crate) unsafe fn new_doc_no_dtd(version: *const xmlChar) -> *mut _xmlDoc {
2806    let doc = tree::new_doc(version);
2807    if doc.is_null() {
2808        return ptr::null_mut();
2809    }
2810
2811    unsafe {
2812        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2813        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2814    }
2815
2816    doc
2817}
2818
2819// ═══════════════════════════════════════════════════════════════════════════════
2820// HTML Serializer
2821// ═══════════════════════════════════════════════════════════════════════════════
2822
2823/// HTML void elements that should not have closing tags.
2824const HTML_VOID_ELEMENTS: &[&str] = &[
2825    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
2826    "track", "wbr", "frame",
2827];
2828
2829/// Check if an element name is an HTML void element.
2830fn is_html_void(name: &str) -> bool {
2831    HTML_VOID_ELEMENTS
2832        .iter()
2833        .any(|v| v.eq_ignore_ascii_case(name))
2834}
2835
2836/// Check if an element has optional end tag in HTML.
2837#[allow(dead_code)]
2838fn has_optional_end_tag(name: &str) -> bool {
2839    matches!(
2840        name.to_ascii_lowercase().as_str(),
2841        "p" | "li" | "tr" | "td" | "th" | "dt" | "dd"
2842    )
2843}
2844
2845/// Serialize a text node for HTML output.
2846///
2847/// In HTML serialization, we escape `<` and `&` but NOT non-ASCII characters
2848/// as numeric entities (unlike XML serialization).
2849/// Write a double-quoted C string to the buffer.
2850unsafe fn html_write_quoted(buf: *mut _xmlBuffer, s: *const xmlChar) {
2851    if buf.is_null() || s.is_null() {
2852        return;
2853    }
2854    io::buf_ccat(buf, b'"');
2855    io::buf_cat(buf, s);
2856    io::buf_ccat(buf, b'"');
2857}
2858
2859/// Trim leading ASCII whitespace.
2860fn trim_ascii_start(s: &[u8]) -> &[u8] {
2861    let start = s
2862        .iter()
2863        .position(|&b| !b.is_ascii_whitespace())
2864        .unwrap_or(s.len());
2865    &s[start..]
2866}
2867
2868/// Write text content into an HTML output buffer, escaping `&`, `<` and
2869/// `>` (upstream htmlTreeDumpText escapes `>` to `&gt;` as well — the corpus
2870/// `ser-methods` html method expects `&lt;x&gt;`, not `&lt;x>`).
2871///
2872/// # Safety
2873///
2874/// - `buf` must be non-NULL and point to a valid `_xmlBuffer` writable via
2875///   `io::buf_add`.
2876/// - `content` must be non-NULL and readable for at least `len` bytes; `len`
2877///   is required to be positive (checked) and the loop reads exactly `len`
2878///   bytes starting at `content`.
2879unsafe fn html_serialize_text(buf: *mut _xmlBuffer, content: *const xmlChar, len: c_int) {
2880    if buf.is_null() || content.is_null() || len <= 0 {
2881        return;
2882    }
2883
2884    let mut i: c_int = 0;
2885    while i < len {
2886        let ch = unsafe { *content.add(i as usize) };
2887
2888        match ch {
2889            b'<' => {
2890                io::buf_add(buf, b"&lt;" as *const u8, 4);
2891            }
2892            b'&' => {
2893                io::buf_add(buf, b"&amp;" as *const u8, 5);
2894            }
2895            b'>' => {
2896                io::buf_add(buf, b"&gt;" as *const u8, 4);
2897            }
2898            _ => {
2899                io::buf_add(buf, &ch as *const u8, 1);
2900            }
2901        }
2902        i += 1;
2903    }
2904}
2905
2906/// Serialize an attribute value for HTML output.
2907///
2908/// In HTML, attribute values should be quoted and have `&`, `"` escaped.
2909///
2910/// # Safety
2911///
2912/// - `buf` must be non-NULL and point to a valid `_xmlBuffer` writable via
2913///   `io::buf_add`.
2914/// - `value` must be non-NULL and point to a valid NUL-terminated `xmlChar`
2915///   string; `xml_strlen` scans it up to the terminator.
2916unsafe fn html_serialize_attr_value(buf: *mut _xmlBuffer, value: *const xmlChar) {
2917    if buf.is_null() || value.is_null() {
2918        return;
2919    }
2920
2921    let len = unsafe { xml_strlen(value) as c_int };
2922    let mut i: c_int = 0;
2923    while i < len {
2924        let ch = unsafe { *value.add(i as usize) };
2925
2926        match ch {
2927            b'&' => {
2928                io::buf_add(buf, b"&amp;" as *const u8, 5);
2929            }
2930            b'"' => {
2931                io::buf_add(buf, b"&quot;" as *const u8, 6);
2932            }
2933            _ => {
2934                io::buf_add(buf, &ch as *const u8, 1);
2935            }
2936        }
2937        i += 1;
2938    }
2939}
2940
2941/// HTML-specific node serialization.
2942///
2943/// Walks the node tree and serializes to HTML format.
2944/// Differs from XML serialization in several ways:
2945/// - No XML declaration for HTML documents
2946/// - No self-closing tags for void elements
2947/// - Case-insensitive tag names preserved as-is
2948/// - No namespace declarations
2949/// - Elements with optional end tags may omit them
2950///
2951/// Whether the head element already contains a <meta> element (so the
2952///
2953/// serializer does not insert a duplicate charset declaration).
2954///
2955/// # SAFETY
2956///
2957/// - `child` must be a valid node or NULL.
2958unsafe fn html_head_has_meta(child: *mut _xmlNode) -> bool {
2959    let mut c = child;
2960    while !c.is_null() {
2961        if (*c).type_ == XML_ELEMENT_NODE as c_int && !(*c).name.is_null() {
2962            let nm = xmlstr_to_bytes((*c).name);
2963            if nm.eq_ignore_ascii_case(b"meta") {
2964                return true;
2965            }
2966        }
2967        c = (*c).next;
2968    }
2969    false
2970}
2971
2972/// Serialize a node tree to HTML output.
2973///
2974/// # Safety
2975///
2976/// - `node` must be non-NULL and point to a valid `_xmlNode` in a well-formed
2977///   tree; the `children`, `next`, `parent`, `properties` and `doc` links
2978///   walked here must be NULL-terminated and point to valid objects.
2979/// - `buf` must be non-NULL and point to a valid `_xmlBuffer`.
2980/// - `name`, `content` and `encoding` fields must be NULL or valid
2981///   NUL-terminated `xmlChar` strings.
2982pub(crate) unsafe fn serialize_node(
2983    node: *mut _xmlNode,
2984    buf: *mut _xmlBuffer,
2985    format: c_int,
2986    level: c_int,
2987) {
2988    unsafe { serialize_node_enc(node, buf, format, level, None) }
2989}
2990
2991/// Serialize an HTML node with an optional output-encoding parameter
2992/// (upstream `htmlNodeDumpInternal`'s `encoding` argument).
2993///
2994/// Upstream inserts a `<meta charset=...>` in the root `<head>` ONLY when
2995/// this `encoding` parameter is non-NULL (htmlDocDumpMemoryFormat and the
2996/// lxml `tostring(method="html")` path pass NULL and never insert one);
2997/// `htmlSaveFileFormat` passes the caller's encoding string.
2998///
2999/// # Safety
3000///
3001/// - `node` must be NULL or a valid `_xmlNode`; `buf` a valid `_xmlBuffer`.
3002pub(crate) unsafe fn serialize_node_enc(
3003    node: *mut _xmlNode,
3004    buf: *mut _xmlBuffer,
3005    format: c_int,
3006    level: c_int,
3007    encoding: Option<&[u8]>,
3008) {
3009    if node.is_null() || buf.is_null() {
3010        return;
3011    }
3012
3013    let n = unsafe { &*node };
3014
3015    match n.type_ {
3016        t if t == XML_ELEMENT_NODE as c_int => {
3017            let name = if n.name.is_null() {
3018                ""
3019            } else {
3020                unsafe { core::str::from_utf8(xmlstr_to_bytes(n.name)).unwrap_or("") }
3021            };
3022
3023            let is_void = is_html_void(name);
3024            // UPSTREAM-PARITY: htmlNodeDumpInternal only adds formatting
3025            // newlines for non-inline elements; p, pre and param are never
3026            // formatted (name[0] == 'p'), and unknown elements are treated
3027            // as inline (info == NULL).
3028            let info = html_tag_lookup(name);
3029            let is_inline = info.is_none_or(|i| i.flags & HTML_INLINE != 0);
3030            let no_format = is_inline || name.starts_with('p');
3031
3032            // Write start tag
3033            io::buf_ccat(buf, b'<');
3034            // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpOutputInternal): an
3035            // element with a bound prefix writes `prefix:name`, and its
3036            // local namespace declarations (nsDef) are dumped right after
3037            // the name — namespaced trees (lxml.html.html5parser's XHTML
3038            // tree) serialize with their namespace declarations.
3039            if !n.ns.is_null() && !(*n.ns).prefix.is_null() {
3040                io::buf_cat(buf, (*n.ns).prefix);
3041                io::buf_ccat(buf, b':');
3042            }
3043            if !n.name.is_null() {
3044                io::buf_cat(buf, n.name);
3045            }
3046            if !n.nsDef.is_null() {
3047                let mut ns = n.nsDef;
3048                while !ns.is_null() {
3049                    let nsp = unsafe { &*ns };
3050                    // UPSTREAM-PARITY (xmlsave.c xmlNsDumpOutput): only
3051                    // LOCAL namespaces with a URI are written; the "xml"
3052                    // prefix declaration is skipped.
3053                    let is_xml = !nsp.prefix.is_null() && xmlstr_to_bytes(nsp.prefix) == b"xml";
3054                    if nsp.type_ == XML_LOCAL_NAMESPACE as c_int && !nsp.href.is_null() && !is_xml {
3055                        io::buf_ccat(buf, b' ');
3056                        if nsp.prefix.is_null() {
3057                            io::buf_add(buf, b"xmlns=\"" as *const u8, 7);
3058                        } else {
3059                            io::buf_add(buf, b"xmlns:" as *const u8, 6);
3060                            io::buf_cat(buf, nsp.prefix);
3061                            io::buf_add(buf, b"=\"" as *const u8, 2);
3062                        }
3063                        html_serialize_attr_value(buf, nsp.href);
3064                        io::buf_ccat(buf, b'\"');
3065                    }
3066                    ns = nsp.next;
3067                }
3068            }
3069
3070            // Write attributes
3071            let mut attr = n.properties;
3072            while !attr.is_null() {
3073                let a = unsafe { &*attr };
3074                io::buf_ccat(buf, b' ');
3075                if !a.name.is_null() {
3076                    io::buf_cat(buf, a.name);
3077                }
3078
3079                // Write attribute value if present
3080                if !a.children.is_null() {
3081                    let child = unsafe { &*a.children };
3082                    if child.type_ == XML_TEXT_NODE as c_int && !child.content.is_null() {
3083                        io::buf_ccat(buf, b'=');
3084                        io::buf_ccat(buf, b'"');
3085                        html_serialize_attr_value(buf, child.content);
3086                        io::buf_ccat(buf, b'"');
3087                    }
3088                }
3089
3090                attr = a.next;
3091            }
3092
3093            // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpInternal): inserts
3094            // <meta charset="..."> as the first child of the <head> of the
3095            // root <html> element when no <meta> is present AND the caller
3096            // passed an explicit output `encoding` parameter (the doc-dump
3097            // path passes NULL and inserts nothing). The meta is synthetic
3098            // here, so it participates in the formatting rules like a real
3099            // child.
3100            let mut meta_bytes: Option<Vec<u8>> = None;
3101            if name.eq_ignore_ascii_case("head") && level == 1 {
3102                if let Some(enc) = encoding {
3103                    let parent_is_html = !n.parent.is_null() && !(*n.parent).name.is_null() && {
3104                        let pn =
3105                            core::str::from_utf8(xmlstr_to_bytes((*n.parent).name)).unwrap_or("");
3106                        pn.eq_ignore_ascii_case("html")
3107                    };
3108                    if parent_is_html && !html_head_has_meta(n.children) {
3109                        meta_bytes = Some(enc.to_vec());
3110                    }
3111                }
3112            }
3113            let meta_inserted = meta_bytes.is_some();
3114
3115            let has_children = !n.children.is_null();
3116            let first_child = if has_children {
3117                unsafe { (*n.children).type_ }
3118            } else {
3119                XML_TEXT_NODE as c_int
3120            };
3121            let first_is_text = first_child == XML_TEXT_NODE as c_int
3122                || first_child == XML_ENTITY_REF_NODE as c_int;
3123            // With a synthetic meta child, an empty head behaves as having
3124            // one element child.
3125            let multi_child = (has_children && n.children != n.last) || meta_inserted;
3126
3127            if is_void {
3128                // Void element: just close the tag, no children
3129                io::buf_ccat(buf, b'>');
3130                // UPSTREAM-PARITY (line 997): a newline follows a non-inline
3131                // element whose next sibling is not text; the caller (parent
3132                // loop) emits it, so nothing here.
3133            } else {
3134                // Element with children (or a head receiving a meta)
3135                io::buf_ccat(buf, b'>');
3136
3137                // Newline after the open tag (upstream line 969): a
3138                // non-inline element whose first child is not text and which
3139                // has more than one child (or receives a meta) starts its
3140                // content on a new line.
3141                if format != 0 && !no_format && !first_is_text && multi_child {
3142                    io::buf_ccat(buf, b'\n');
3143                }
3144
3145                if let Some(enc) = &meta_bytes {
3146                    io::buf_add(buf, b"<meta charset=\"" as *const u8, 15);
3147                    io::buf_add(buf, enc.as_ptr(), enc.len() as c_int);
3148                    io::buf_add(buf, b"\">" as *const u8, 2);
3149                    // UPSTREAM-PARITY (line 983): a newline follows the
3150                    // inserted meta when the next real child is not text.
3151                    if format != 0 && has_children && !first_is_text && !name.starts_with('p') {
3152                        io::buf_ccat(buf, b'\n');
3153                    }
3154                }
3155
3156                // Serialize children inline (HTML formatting adds no
3157                // indentation; the per-element rules emit the newlines).
3158                let mut child = n.children;
3159                while !child.is_null() {
3160                    serialize_node_enc(child, buf, format, level + 1, encoding);
3161                    // UPSTREAM-PARITY (line 997): a newline follows a
3162                    // non-inline element whose next sibling is not text,
3163                    // unless the parent is p/pre/param.
3164                    let next = unsafe { (*child).next };
3165                    if format != 0 && !next.is_null() && !name.starts_with('p') {
3166                        let nt = unsafe { (*next).type_ };
3167                        if nt != XML_TEXT_NODE as c_int && nt != XML_ENTITY_REF_NODE as c_int {
3168                            let cname = if (*child).name.is_null() {
3169                                ""
3170                            } else {
3171                                unsafe {
3172                                    core::str::from_utf8(xmlstr_to_bytes((*child).name))
3173                                        .unwrap_or("")
3174                                }
3175                            };
3176                            let cinfo = html_tag_lookup(cname);
3177                            let c_inline = cinfo.is_none_or(|i| i.flags & HTML_INLINE != 0);
3178                            if !c_inline {
3179                                io::buf_ccat(buf, b'\n');
3180                            }
3181                        }
3182                    }
3183                    child = next;
3184                }
3185
3186                // Newline before the end tag (upstream line 1085): a
3187                // non-inline element whose last child is not text and which
3188                // has more than one child (or is the head receiving a meta).
3189                let last_child = if has_children {
3190                    unsafe { (*n.last).type_ }
3191                } else {
3192                    XML_ELEMENT_NODE as c_int
3193                };
3194                let last_is_text = last_child == XML_TEXT_NODE as c_int
3195                    || last_child == XML_ENTITY_REF_NODE as c_int;
3196                if format != 0 && !no_format && !last_is_text && multi_child {
3197                    io::buf_ccat(buf, b'\n');
3198                }
3199
3200                // Write end tag
3201                io::buf_add(buf, b"</" as *const u8, 2);
3202                // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpOutputInternal
3203                // end-tag emission): the namespace prefix is repeated.
3204                if !n.ns.is_null() && !(*n.ns).prefix.is_null() {
3205                    io::buf_cat(buf, (*n.ns).prefix);
3206                    io::buf_ccat(buf, b':');
3207                }
3208                if !n.name.is_null() {
3209                    io::buf_cat(buf, n.name);
3210                }
3211                io::buf_ccat(buf, b'>');
3212            }
3213        }
3214        t if t == XML_TEXT_NODE as c_int => {
3215            // UPSTREAM-PARITY (HTMLtree.c htmlNodeDumpInternal): script/
3216            // style content is DATA_RAWTEXT — written verbatim, never
3217            // escaped (the corpus html-script expects `<` and `&&` raw).
3218            let parent_is_raw = !n.parent.is_null() && !(*n.parent).name.is_null() && {
3219                let pn = xmlstr_to_bytes((*n.parent).name);
3220                pn.eq_ignore_ascii_case(b"script") || pn.eq_ignore_ascii_case(b"style")
3221            };
3222            if parent_is_raw {
3223                io::buf_cat(buf, n.content);
3224            } else {
3225                html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
3226            }
3227        }
3228        t if t == XML_CDATA_SECTION_NODE as c_int => {
3229            io::buf_add(buf, b"<![CDATA[" as *const u8, 9);
3230            html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
3231            io::buf_add(buf, b"]]>" as *const u8, 3);
3232        }
3233        t if t == XML_COMMENT_NODE as c_int => {
3234            if format != 0 && level > 0 {
3235                io::buf_ccat(buf, b'\n');
3236                for _ in 0..level {
3237                    io::buf_add(buf, b"  " as *const u8, 2);
3238                }
3239            }
3240            io::buf_add(buf, b"<!--" as *const u8, 4);
3241            if !n.content.is_null() {
3242                io::buf_cat(buf, n.content);
3243            }
3244            io::buf_add(buf, b"-->" as *const u8, 3);
3245        }
3246        t if t == XML_PI_NODE as c_int => {
3247            if format != 0 && level > 0 {
3248                io::buf_ccat(buf, b'\n');
3249                for _ in 0..level {
3250                    io::buf_add(buf, b"  " as *const u8, 2);
3251                }
3252            }
3253            io::buf_add(buf, b"<?" as *const u8, 2);
3254            if !n.name.is_null() {
3255                io::buf_cat(buf, n.name);
3256            }
3257            if !n.content.is_null() && unsafe { *n.content != 0 } {
3258                io::buf_ccat(buf, b' ');
3259                io::buf_cat(buf, n.content);
3260            }
3261            io::buf_add(buf, b"?>" as *const u8, 2);
3262        }
3263        t if t == XML_DOCUMENT_NODE as c_int || t == XML_HTML_DOCUMENT_NODE as c_int => {
3264            // UPSTREAM-PARITY: htmlDocContentDumpOutput writes the internal
3265            // subset's DOCTYPE before the tree children.
3266            let doc_ptr = n as *const _xmlNode as *mut _xmlDoc;
3267            let d = &*doc_ptr;
3268            if !d.intSubset.is_null() {
3269                let dtd = &*d.intSubset;
3270                io::buf_add(buf, b"<!DOCTYPE " as *const u8, 10);
3271                if !dtd.name.is_null() {
3272                    io::buf_cat(buf, dtd.name);
3273                }
3274                if !dtd.ExternalID.is_null() {
3275                    io::buf_add(buf, b" PUBLIC " as *const u8, 8);
3276                    html_write_quoted(buf, dtd.ExternalID);
3277                    io::buf_ccat(buf, b' ');
3278                    html_write_quoted(buf, dtd.SystemID);
3279                } else if !dtd.SystemID.is_null() {
3280                    io::buf_add(buf, b" SYSTEM " as *const u8, 8);
3281                    html_write_quoted(buf, dtd.SystemID);
3282                }
3283                io::buf_ccat(buf, b'>');
3284                io::buf_ccat(buf, b'\n');
3285            }
3286            // No XML declaration for HTML documents
3287            // Serialize children
3288            let mut child = n.children;
3289            while !child.is_null() {
3290                serialize_node_enc(child, buf, format, 0, encoding);
3291                child = unsafe { (*child).next };
3292            }
3293            // UPSTREAM-PARITY: htmlDocContentDumpOutput terminates with a
3294            // newline.
3295            io::buf_ccat(buf, b'\n');
3296        }
3297        _ => {
3298            if !n.content.is_null() {
3299                html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
3300            }
3301        }
3302    }
3303}
3304
3305/// Dump an HTML document to a buffer.
3306///
3307/// # Safety
3308///
3309/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
3310/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
3311pub(crate) unsafe fn doc_dump(buf: *mut _xmlBuffer, doc: *mut _xmlDoc) -> c_int {
3312    if buf.is_null() || doc.is_null() {
3313        return -1;
3314    }
3315
3316    let before = io::buf_length(buf);
3317    serialize_node(doc as *mut _xmlNode, buf, 0, 0);
3318    let after = io::buf_length(buf);
3319
3320    if after < 0 || before < 0 {
3321        return -1;
3322    }
3323    after - before
3324}
3325
3326// ═══════════════════════════════════════════════════════════════════════════════
3327// Tests
3328// ═══════════════════════════════════════════════════════════════════════════════
3329
3330#[cfg(test)]
3331mod tests {
3332    use super::*;
3333
3334    use crate::xml::io;
3335
3336    /// Helper: create a null-terminated xmlChar* from a byte slice.
3337    #[allow(dead_code)]
3338    unsafe fn to_xmlstr(s: &[u8]) -> *mut xmlChar {
3339        bytes_to_xmlstr(s)
3340    }
3341
3342    /// Helper: serialize an HTML document to a String.
3343    unsafe fn html_doc_to_string(doc: *mut _xmlDoc) -> String {
3344        let buf = io::buf_create(-1);
3345        assert!(!buf.is_null());
3346        doc_dump(buf, doc);
3347        let content = io::buf_content(buf);
3348        let s = if !content.is_null() {
3349            let len = xml_strlen(content);
3350            let slice = slice::from_raw_parts(content, len);
3351            String::from_utf8_lossy(slice).to_string()
3352        } else {
3353            String::new()
3354        };
3355        io::buf_free(buf);
3356        s
3357    }
3358
3359    // ═════════════════════════════════════════════════════════════════════════
3360    // Element Info Lookup
3361    // ═════════════════════════════════════════════════════════════════════════
3362
3363    #[test]
3364    fn test_html_tag_lookup() {
3365        // Known elements
3366        assert!(html_tag_lookup("html").is_some());
3367        assert!(html_tag_lookup("HTML").is_some()); // case-insensitive
3368        assert!(html_tag_lookup("p").is_some());
3369        assert!(html_tag_lookup("br").is_some());
3370        assert!(html_tag_lookup("div").is_some());
3371        assert!(html_tag_lookup("script").is_some());
3372
3373        // Unknown elements
3374        assert!(html_tag_lookup("custom").is_none());
3375        assert!(html_tag_lookup("my-element").is_none());
3376    }
3377
3378    #[test]
3379    fn test_tag_flags() {
3380        let br = html_tag_lookup("br").unwrap();
3381        assert!(br.flags & HTML_INLINE != 0);
3382        assert!(br.flags & HTML_EMPTY != 0);
3383
3384        let div = html_tag_lookup("div").unwrap();
3385        assert!(div.flags & HTML_BLOCK != 0);
3386        assert!(div.flags & HTML_VALID != 0);
3387
3388        let p = html_tag_lookup("p").unwrap();
3389        assert!(p.flags & HTML_NO_END != 0);
3390
3391        let meta = html_tag_lookup("meta").unwrap();
3392        assert!(meta.flags & HTML_HEAD != 0);
3393        assert!(meta.flags & HTML_EMPTY != 0);
3394    }
3395
3396    // ═════════════════════════════════════════════════════════════════════════
3397    // Entity Lookup
3398    // ═════════════════════════════════════════════════════════════════════════
3399
3400    #[test]
3401    fn test_html_entity_lookup() {
3402        assert_eq!(html_entity_lookup("amp"), Some("&"));
3403        assert_eq!(html_entity_lookup("lt"), Some("<"));
3404        assert_eq!(html_entity_lookup("gt"), Some(">"));
3405        assert_eq!(html_entity_lookup("quot"), Some("\""));
3406        assert_eq!(html_entity_lookup("nbsp"), Some("\u{00a0}"));
3407        assert_eq!(html_entity_lookup("copy"), Some("\u{00a9}"));
3408        assert!(html_entity_lookup("unknown_entity").is_none());
3409    }
3410
3411    // ═════════════════════════════════════════════════════════════════════════
3412    // Basic Parsing
3413    // ═════════════════════════════════════════════════════════════════════════
3414
3415    /// Parses a complete HTML document and verifies the serialized output
3416    /// contains the expected elements.
3417    ///
3418    /// # Safety
3419    ///
3420    /// - The NUL-terminated static `html` buffer stays valid for the
3421    ///   `parse_memory` call; the returned document pointer is asserted
3422    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3423    ///   freed exactly once with `tree::free_doc`.
3424    #[test]
3425    fn test_parse_basic_html() {
3426        unsafe {
3427            let html = b"<html><head><title>Test</title></head><body><p>Hello</p></body></html>\0";
3428            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3429            assert!(!doc.is_null());
3430
3431            let s = html_doc_to_string(doc);
3432            assert!(s.contains("<html>"));
3433            assert!(s.contains("<head>"));
3434            assert!(s.contains("<title>Test</title>"));
3435            assert!(s.contains("<body>"));
3436            assert!(s.contains("<p>Hello</p>"));
3437
3438            tree::free_doc(doc);
3439        }
3440    }
3441
3442    /// Verifies that parsing an empty buffer yields a NULL document.
3443    ///
3444    /// # Safety
3445    ///
3446    /// - The static one-byte buffer is valid for the `parse_memory` call with
3447    ///   size 0, which must not read from it; a NULL document is expected.
3448    #[test]
3449    fn test_parse_empty_document() {
3450        unsafe {
3451            let html = b"\0";
3452            let doc = parse_memory(html.as_ptr() as *const c_char, 0);
3453            assert!(doc.is_null());
3454        }
3455    }
3456
3457    // ═════════════════════════════════════════════════════════════════════════
3458    // Implicit html/head/body Creation
3459    // ═════════════════════════════════════════════════════════════════════════
3460
3461    /// Verifies that a bare paragraph triggers implicit `html` and `body`
3462    /// creation during parsing.
3463    ///
3464    /// # Safety
3465    ///
3466    /// - The NUL-terminated static `html` buffer stays valid for the
3467    ///   `parse_memory` call; the returned document pointer is asserted
3468    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3469    ///   freed exactly once with `tree::free_doc`.
3470    #[test]
3471    fn test_implicit_html_head_body() {
3472        unsafe {
3473            // Just a paragraph, no html/head/body
3474            let html = b"<p>Hello</p>\0";
3475            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3476            assert!(!doc.is_null());
3477
3478            let s = html_doc_to_string(doc);
3479            // Should have auto-created html
3480            assert!(s.contains("<html>"));
3481            // Should have auto-created body
3482            assert!(s.contains("<body>"));
3483            // Should have the paragraph
3484            assert!(s.contains("<p>Hello</p>"));
3485
3486            tree::free_doc(doc);
3487        }
3488    }
3489
3490    /// Verifies that a bare `title` triggers implicit `html` and `head`
3491    /// creation during parsing.
3492    ///
3493    /// # Safety
3494    ///
3495    /// - The NUL-terminated static `html` buffer stays valid for the
3496    ///   `parse_memory` call; the returned document pointer is asserted
3497    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3498    ///   freed exactly once with `tree::free_doc`.
3499    #[test]
3500    fn test_implicit_head_with_title() {
3501        unsafe {
3502            // Only a title, no html/head/body
3503            let html = b"<title>My Page</title>\0";
3504            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3505            assert!(!doc.is_null());
3506
3507            let s = html_doc_to_string(doc);
3508            assert!(s.contains("<html>"));
3509            assert!(s.contains("<head>"));
3510            assert!(s.contains("<title>My Page</title>"));
3511
3512            tree::free_doc(doc);
3513        }
3514    }
3515
3516    // ═════════════════════════════════════════════════════════════════════════
3517    // Auto-closing
3518    // ═════════════════════════════════════════════════════════════════════════
3519
3520    /// Verifies that a second `p` start tag auto-closes the first.
3521    ///
3522    /// # Safety
3523    ///
3524    /// - The NUL-terminated static `html` buffer stays valid for the
3525    ///   `parse_memory` call; the returned document pointer is asserted
3526    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3527    ///   freed exactly once with `tree::free_doc`.
3528    #[test]
3529    fn test_auto_close_p() {
3530        unsafe {
3531            // <p> should auto-close before another <p>
3532            let html = b"<p>First<p>Second</p>\0";
3533            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3534            assert!(!doc.is_null());
3535
3536            let s = html_doc_to_string(doc);
3537            // Both paragraphs should be siblings, not nested
3538            let first_pos = s.find("First");
3539            let second_pos = s.find("Second");
3540            assert!(first_pos.is_some());
3541            assert!(second_pos.is_some());
3542
3543            tree::free_doc(doc);
3544        }
3545    }
3546
3547    /// Verifies that an `h1` element is auto-closed before an `h2`.
3548    ///
3549    /// # Safety
3550    ///
3551    /// - The NUL-terminated static `html` buffer stays valid for the
3552    ///   `parse_memory` call; the returned document pointer is asserted
3553    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3554    ///   freed exactly once with `tree::free_doc`.
3555    #[test]
3556    fn test_auto_close_heading() {
3557        unsafe {
3558            // h1 should auto-close before h2
3559            let html = b"<h1>Title</h1><h2>Subtitle</h2>\0";
3560            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3561            assert!(!doc.is_null());
3562
3563            let s = html_doc_to_string(doc);
3564            assert!(s.contains("<h1>Title</h1>"));
3565            assert!(s.contains("<h2>Subtitle</h2>"));
3566
3567            tree::free_doc(doc);
3568        }
3569    }
3570
3571    // ═════════════════════════════════════════════════════════════════════════
3572    // Void Elements
3573    // ═════════════════════════════════════════════════════════════════════════
3574
3575    /// Verifies that void elements are serialized without closing tags.
3576    ///
3577    /// # Safety
3578    ///
3579    /// - The NUL-terminated static `html` buffer stays valid for the
3580    ///   `parse_memory` call; the returned document pointer is asserted
3581    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3582    ///   freed exactly once with `tree::free_doc`.
3583    #[test]
3584    fn test_void_elements() {
3585        unsafe {
3586            let html = b"<br><hr><img src=\"test.jpg\"><input type=\"text\">\0";
3587            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3588            assert!(!doc.is_null());
3589
3590            let s = html_doc_to_string(doc);
3591            assert!(s.contains("<br>"));
3592            assert!(s.contains("<hr>"));
3593            assert!(s.contains("<img"));
3594            assert!(s.contains("<input"));
3595
3596            // Void elements should not have closing tags
3597            assert!(!s.contains("</br>"));
3598            assert!(!s.contains("</hr>"));
3599            assert!(!s.contains("</img>"));
3600
3601            tree::free_doc(doc);
3602        }
3603    }
3604
3605    // ═════════════════════════════════════════════════════════════════════════
3606    // Unquoted and Minimized Attributes
3607    // ═════════════════════════════════════════════════════════════════════════
3608
3609    /// Verifies that unquoted attribute values are parsed and re-serialized
3610    /// with quotes.
3611    ///
3612    /// # Safety
3613    ///
3614    /// - The NUL-terminated static `html` buffer stays valid for the
3615    ///   `parse_memory` call; the returned document pointer is asserted
3616    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3617    ///   freed exactly once with `tree::free_doc`.
3618    #[test]
3619    fn test_unquoted_attributes() {
3620        unsafe {
3621            let html = b"<div class=main id=content>Text</div>\0";
3622            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3623            assert!(!doc.is_null());
3624
3625            let s = html_doc_to_string(doc);
3626            assert!(s.contains("class=\"main\""));
3627            assert!(s.contains("id=\"content\""));
3628
3629            tree::free_doc(doc);
3630        }
3631    }
3632
3633    /// Verifies that minimized (valueless) attributes are preserved.
3634    ///
3635    /// # Safety
3636    ///
3637    /// - The NUL-terminated static `html` buffer stays valid for the
3638    ///   `parse_memory` call; the returned document pointer is asserted
3639    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3640    ///   freed exactly once with `tree::free_doc`.
3641    #[test]
3642    fn test_minimized_attributes() {
3643        unsafe {
3644            let html = b"<option selected disabled>Value</option>\0";
3645            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3646            assert!(!doc.is_null());
3647
3648            let s = html_doc_to_string(doc);
3649            // The minimized attributes should be preserved
3650            assert!(s.contains("selected"));
3651            assert!(s.contains("disabled"));
3652
3653            tree::free_doc(doc);
3654        }
3655    }
3656
3657    // ═════════════════════════════════════════════════════════════════════════
3658    // HTML Entities
3659    // ═════════════════════════════════════════════════════════════════════════
3660
3661    /// Verifies named entity resolution and re-escaping during serialization.
3662    ///
3663    /// # Safety
3664    ///
3665    /// - The NUL-terminated static `html` buffer stays valid for the
3666    ///   `parse_memory` call; the returned document pointer is asserted
3667    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3668    ///   freed exactly once with `tree::free_doc`.
3669    #[test]
3670    fn test_html_entities() {
3671        unsafe {
3672            let html = b"<p>&amp; &lt; &gt; &quot; &nbsp; &copy;</p>\0";
3673            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3674            assert!(!doc.is_null());
3675
3676            let s = html_doc_to_string(doc);
3677            // Entities are resolved in the tree; &amp; and &lt; get re-escaped during serialization
3678            // because & and < are special. &gt; becomes > (serialized as-is, > is safe in text).
3679            assert!(s.contains("&amp;")); // &amp; → & → &amp; (re-escaped)
3680            assert!(s.contains("&lt;")); // &lt; → < → &lt; (re-escaped)
3681            assert!(s.contains(">")); // &gt; → > (not escaped in text)
3682            assert!(s.contains("\u{00a0}")); // &nbsp; → non-breaking space
3683
3684            tree::free_doc(doc);
3685        }
3686    }
3687
3688    /// Verifies decimal and hexadecimal numeric entity resolution.
3689    ///
3690    /// # Safety
3691    ///
3692    /// - The NUL-terminated static `html` buffer stays valid for the
3693    ///   `parse_memory` call; the returned document pointer is asserted
3694    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3695    ///   freed exactly once with `tree::free_doc`.
3696    #[test]
3697    fn test_numeric_entities() {
3698        unsafe {
3699            // &#65; = 'A', &#x41; = 'A'
3700            let html = b"<p>&#65; &#x41;</p>\0";
3701            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3702            assert!(!doc.is_null());
3703
3704            let s = html_doc_to_string(doc);
3705            assert!(s.contains('A'));
3706
3707            tree::free_doc(doc);
3708        }
3709    }
3710
3711    // ═════════════════════════════════════════════════════════════════════════
3712    // Nested Elements
3713    // ═════════════════════════════════════════════════════════════════════════
3714
3715    /// Verifies nested element parsing and serialization.
3716    ///
3717    /// # Safety
3718    ///
3719    /// - The NUL-terminated static `html` buffer stays valid for the
3720    ///   `parse_memory` call; the returned document pointer is asserted
3721    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3722    ///   freed exactly once with `tree::free_doc`.
3723    #[test]
3724    fn test_nested_elements() {
3725        unsafe {
3726            let html = b"<div><ul><li>Item 1</li><li>Item 2</li></ul></div>\0";
3727            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3728            assert!(!doc.is_null());
3729
3730            let s = html_doc_to_string(doc);
3731            assert!(s.contains("<div>"));
3732            assert!(s.contains("<ul>"));
3733            assert!(s.contains("<li>Item 1</li>"));
3734            assert!(s.contains("<li>Item 2</li>"));
3735
3736            tree::free_doc(doc);
3737        }
3738    }
3739
3740    // ═════════════════════════════════════════════════════════════════════════
3741    // Malformed HTML Recovery
3742    // ═════════════════════════════════════════════════════════════════════════
3743
3744    /// Verifies tag-recovery when end tags are missing.
3745    ///
3746    /// # Safety
3747    ///
3748    /// - The NUL-terminated static `html` buffer stays valid for the
3749    ///   `parse_memory` call; the returned document pointer is asserted
3750    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3751    ///   freed exactly once with `tree::free_doc`.
3752    #[test]
3753    fn test_missing_end_tags() {
3754        unsafe {
3755            // Missing closing tags
3756            let html = b"<p>Paragraph without closing<div>Another div\0";
3757            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3758            assert!(!doc.is_null());
3759
3760            let s = html_doc_to_string(doc);
3761            assert!(s.contains("Paragraph without closing"));
3762            assert!(s.contains("Another div"));
3763
3764            tree::free_doc(doc);
3765        }
3766    }
3767
3768    /// Verifies case-insensitive parsing with case-preserved serialization.
3769    ///
3770    /// # Safety
3771    ///
3772    /// - The NUL-terminated static `html` buffer stays valid for the
3773    ///   `parse_memory` call; the returned document pointer is asserted
3774    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3775    ///   freed exactly once with `tree::free_doc`.
3776    #[test]
3777    fn test_mismatched_case() {
3778        unsafe {
3779            let html = b"<HTML><HEAD><TITLE>Test</TITLE></HEAD><BODY><P>Hello</P></BODY></HTML>\0";
3780            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3781            assert!(!doc.is_null());
3782
3783            let s = html_doc_to_string(doc);
3784            // Tag names are case-preserved
3785            assert!(s.contains("<HTML>"));
3786            assert!(s.contains("<HEAD>"));
3787            assert!(s.contains("<BODY>"));
3788            assert!(s.contains("<P>Hello</P>"));
3789
3790            tree::free_doc(doc);
3791        }
3792    }
3793
3794    /// Verifies recovery from deeply nested malformed markup.
3795    ///
3796    /// # Safety
3797    ///
3798    /// - The NUL-terminated static `html` buffer stays valid for the
3799    ///   `parse_memory` call; the returned document pointer is asserted
3800    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3801    ///   freed exactly once with `tree::free_doc`.
3802    #[test]
3803    fn test_nested_malformed() {
3804        unsafe {
3805            // Deeply nested with missing end tags
3806            let html = b"<div><p><span><b>Deep text</div></p>\0";
3807            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3808            assert!(!doc.is_null());
3809
3810            let s = html_doc_to_string(doc);
3811            assert!(s.contains("Deep text"));
3812
3813            tree::free_doc(doc);
3814        }
3815    }
3816
3817    // ═════════════════════════════════════════════════════════════════════════
3818    // HTML Serialization Round-trip
3819    // ═════════════════════════════════════════════════════════════════════════
3820
3821    /// Verifies a simple parse and serialize round trip.
3822    ///
3823    /// # Safety
3824    ///
3825    /// - The NUL-terminated static `original` buffer stays valid for the
3826    ///   `parse_memory` call; the returned document pointer is asserted
3827    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3828    ///   freed exactly once with `tree::free_doc`.
3829    #[test]
3830    fn test_serialization_round_trip_simple() {
3831        unsafe {
3832            let original = b"<p>Hello World</p>\0";
3833            let doc = parse_memory(
3834                original.as_ptr() as *const c_char,
3835                (original.len() - 1) as c_int,
3836            );
3837            assert!(!doc.is_null());
3838
3839            let s = html_doc_to_string(doc);
3840            assert!(s.contains("Hello World"));
3841
3842            tree::free_doc(doc);
3843        }
3844    }
3845
3846    /// Verifies void elements are not serialized with self-closing syntax.
3847    ///
3848    /// # Safety
3849    ///
3850    /// - The NUL-terminated static `html` buffer stays valid for the
3851    ///   `parse_memory` call; the returned document pointer is asserted
3852    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3853    ///   freed exactly once with `tree::free_doc`.
3854    #[test]
3855    fn test_serialize_void_elements_no_self_close() {
3856        unsafe {
3857            let html = b"<br><hr><img src=\"test.png\">\0";
3858            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3859            assert!(!doc.is_null());
3860
3861            let s = html_doc_to_string(doc);
3862            // HTML serialization should NOT use self-closing tags
3863            assert!(!s.contains("<br/>"));
3864            assert!(!s.contains("<hr/>"));
3865
3866            tree::free_doc(doc);
3867        }
3868    }
3869
3870    // ═════════════════════════════════════════════════════════════════════════
3871    // Script and Style Handling
3872    // ═════════════════════════════════════════════════════════════════════════
3873
3874    /// Verifies raw text content inside a `script` element is preserved.
3875    ///
3876    /// # Safety
3877    ///
3878    /// - The NUL-terminated static `html` buffer stays valid for the
3879    ///   `parse_memory` call; the returned document pointer is asserted
3880    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3881    ///   freed exactly once with `tree::free_doc`.
3882    #[test]
3883    fn test_script_content() {
3884        unsafe {
3885            // Use a simpler script content that doesn't contain '<' to avoid parser confusion
3886            let html = b"<script>var x = 1;</script>\0";
3887            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3888            assert!(!doc.is_null());
3889
3890            let s = html_doc_to_string(doc);
3891            assert!(s.contains("<script>"));
3892            // The raw text content should be preserved
3893            assert!(s.contains("var x = 1;"));
3894
3895            // UPSTREAM-PARITY (DATA_RAWTEXT): script content is never
3896            // escaped, even when it contains '<', '&' or '>'.
3897            let html2 = b"<script>if (a < b && c > d) { x(1); }</script>\0";
3898            let doc2 = parse_memory(html2.as_ptr() as *const c_char, (html2.len() - 1) as c_int);
3899            assert!(!doc2.is_null());
3900            let s2 = html_doc_to_string(doc2);
3901            assert!(
3902                s2.contains("if (a < b && c > d) { x(1); }"),
3903                "script content must be raw, got: {s2}"
3904            );
3905            assert!(
3906                !s2.contains("&lt;"),
3907                "script content must not be escaped: {s2}"
3908            );
3909            tree::free_doc(doc2);
3910
3911            tree::free_doc(doc);
3912        }
3913    }
3914
3915    // ═════════════════════════════════════════════════════════════════════════
3916    // Comments and DOCTYPE
3917    // ═════════════════════════════════════════════════════════════════════════
3918
3919    /// Verifies HTML comments are preserved in the serialized output.
3920    ///
3921    /// # Safety
3922    ///
3923    /// - The NUL-terminated static `html` buffer stays valid for the
3924    ///   `parse_memory` call; the returned document pointer is asserted
3925    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
3926    ///   freed exactly once with `tree::free_doc`.
3927    #[test]
3928    fn test_html_comment() {
3929        unsafe {
3930            let html = b"<html><!-- This is a comment --><body><p>Text</p></body></html>\0";
3931            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3932            assert!(!doc.is_null());
3933
3934            let s = html_doc_to_string(doc);
3935            assert!(s.contains("<!-- This is a comment -->"));
3936
3937            tree::free_doc(doc);
3938        }
3939    }
3940
3941    // ═════════════════════════════════════════════════════════════════════════
3942    // new_doc / new_doc_no_dtd
3943    // ═════════════════════════════════════════════════════════════════════════
3944
3945    /// Verifies `new_doc` creates an HTML document WITHOUT an implicit
3946    /// `html`/`head`/`body` skeleton (upstream htmlNewDoc does not seed them;
3947    /// the HTML parser grows them lazily). `html_doc_to_string` of an empty
3948    /// body-less HTML doc emits just the trailing newline.
3949    ///
3950    /// # Safety
3951    ///
3952    /// - `new_doc` returns an owned `_xmlDoc` or NULL; the pointer is
3953    ///   asserted non-NULL before its `type_` field is dereferenced and
3954    ///   before `html_doc_to_string`, and is freed exactly once with
3955    ///   `tree::free_doc`.
3956    #[test]
3957    fn test_new_doc_creates_html_head_body() {
3958        unsafe {
3959            let doc = new_doc(ptr::null());
3960            assert!(!doc.is_null());
3961            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
3962
3963            let s = html_doc_to_string(doc);
3964            assert!(!s.contains("<html>"), "htmlNewDoc must not seed <html>");
3965            assert!(!s.contains("<head>"), "htmlNewDoc must not seed <head>");
3966            assert!(!s.contains("<body>"), "htmlNewDoc must not seed <body>");
3967
3968            tree::free_doc(doc);
3969        }
3970    }
3971
3972    /// Verifies `new_doc_no_dtd` creates a document without implicit
3973    /// structure and without a DTD.
3974    ///
3975    /// # Safety
3976    ///
3977    /// - `new_doc_no_dtd` returns an owned `_xmlDoc` or NULL; the pointer is
3978    ///   asserted non-NULL before its `type_` field is dereferenced and
3979    ///   before `html_doc_to_string`, and is freed exactly once with
3980    ///   `tree::free_doc`.
3981    #[test]
3982    fn test_new_doc_no_dtd() {
3983        unsafe {
3984            let doc = new_doc_no_dtd(ptr::null());
3985            assert!(!doc.is_null());
3986            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
3987
3988            // No implicit html/head/body. UPSTREAM-PARITY: the HTML
3989            // serializer (htmlNodeDumpInternal) writes "\n" for a
3990            // document node with no children (HTMLtree.c:861-863).
3991            let s = html_doc_to_string(doc);
3992            assert_eq!(s, "\n");
3993
3994            tree::free_doc(doc);
3995        }
3996    }
3997
3998    /// Phase 14.3 (dom S1 / loadHTML family): an html-parsed document
3999    /// carries properties = XML_DOC_HTML and standalone = 1 — upstream
4000    /// xmlSAX2StartDocument for HTML parsers sets XML_DOC_HTML and
4001    /// htmlNewDocNoDtD defaults standalone=1. The pre-fix XML_DOC_WELLFORMED-
4002    /// only value made PHP's spec serializer and the engine AS_XML save treat
4003    /// loadHTML'd documents as plain XML (declaration/standalone loss;
4004    /// ext/dom dom005/gh15670/gh16535/gh17397/gh19612 + loadHTMLfile*).
4005    ///
4006    /// # Safety
4007    ///
4008    /// - the parsed html doc is freed exactly once; the pointer is asserted
4009    ///   non-NULL before its fields are read.
4010    #[test]
4011    fn test_parsed_html_doc_flags() {
4012        unsafe {
4013            let doc = parse_memory(c"<html><body>x</body></html>".as_ptr(), 23);
4014            assert!(!doc.is_null());
4015            assert_eq!(
4016                (*doc).properties & (crate::abi::types::xmlDocProperties::XML_DOC_HTML as c_int),
4017                crate::abi::types::xmlDocProperties::XML_DOC_HTML as c_int,
4018                "html-parsed docs must carry XML_DOC_HTML"
4019            );
4020            assert_eq!(
4021                (*doc).standalone,
4022                1,
4023                "html-parsed docs default standalone=yes"
4024            );
4025            tree::free_doc(doc);
4026        }
4027    }
4028
4029    // ═════════════════════════════════════════════════════════════════════════
4030    // Entity Resolution in Text
4031    // ═════════════════════════════════════════════════════════════════════════
4032
4033    #[test]
4034    fn test_resolve_numeric_entity() {
4035        assert_eq!(resolve_numeric_entity("65", false), vec![b'A']);
4036        assert_eq!(resolve_numeric_entity("41", true), vec![b'A']);
4037        assert_eq!(resolve_numeric_entity("0", false), Vec::<u8>::new());
4038    }
4039
4040    #[test]
4041    fn test_resolve_entity_unknown() {
4042        let result = resolve_entity("unknown");
4043        assert_eq!(result, b"&unknown;");
4044    }
4045
4046    // ═════════════════════════════════════════════════════════════════════════
4047    // init/cleanup Parser
4048    // ═════════════════════════════════════════════════════════════════════════
4049
4050    #[test]
4051    fn test_init_cleanup_parser() {
4052        // Just ensure no crashes
4053        init_parser();
4054        cleanup_parser();
4055    }
4056
4057    // ═════════════════════════════════════════════════════════════════════════
4058    // Parser Context
4059    // ═════════════════════════════════════════════════════════════════════════
4060
4061    /// Verifies `create_file_parser_ctxt` and `free_parser_ctxt` round trip.
4062    ///
4063    /// # Safety
4064    ///
4065    /// - The static NUL-terminated filename stays valid for the
4066    ///   `create_file_parser_ctxt` call; the returned context is asserted
4067    ///   non-NULL before being freed with `free_parser_ctxt`.
4068    #[test]
4069    fn test_create_free_parser_ctxt() {
4070        unsafe {
4071            let ctxt =
4072                create_file_parser_ctxt(b"test.html\0" as *const u8 as *const c_char, ptr::null());
4073            assert!(!ctxt.is_null());
4074            free_parser_ctxt(ctxt);
4075        }
4076    }
4077
4078    // ═════════════════════════════════════════════════════════════════════════
4079    // Complex HTML Documents
4080    // ═════════════════════════════════════════════════════════════════════════
4081
4082    /// Parses a complex HTML document and verifies the serialized structure.
4083    ///
4084    /// # Safety
4085    ///
4086    /// - The NUL-terminated static `html` buffer stays valid for the
4087    ///   `parse_memory` call; the returned document pointer is asserted
4088    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
4089    ///   freed exactly once with `tree::free_doc`.
4090    #[test]
4091    fn test_complex_html_document() {
4092        unsafe {
4093            let html = b"<!DOCTYPE html>
4094<html>
4095<head>
4096    <meta charset=\"utf-8\">
4097    <title>Test Page</title>
4098    <link rel=\"stylesheet\" href=\"style.css\">
4099</head>
4100<body>
4101    <div id=\"main\">
4102        <h1>Title</h1>
4103        <p>First paragraph with <a href=\"link.html\">a link</a>.</p>
4104        <p>Second paragraph.</p>
4105        <ul>
4106            <li>Item 1</li>
4107            <li>Item 2</li>
4108        </ul>
4109        <br>
4110        <hr>
4111        <img src=\"image.jpg\" alt=\"An image\">
4112    </div>
4113    <script>alert('hello');</script>
4114</body>
4115</html>\0";
4116            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
4117            assert!(!doc.is_null());
4118
4119            let s = html_doc_to_string(doc);
4120            assert!(s.contains("<html>"));
4121            assert!(s.contains("<head>"));
4122            assert!(s.contains("<title>Test Page</title>"));
4123            assert!(s.contains("<body>"));
4124            assert!(s.contains("<h1>Title</h1>"));
4125            assert!(s.contains("a link"));
4126            assert!(s.contains("Second paragraph"));
4127            assert!(s.contains("<br>"));
4128            assert!(s.contains("<hr>"));
4129            assert!(s.contains("<img"));
4130            assert!(s.contains("<script>"));
4131
4132            tree::free_doc(doc);
4133        }
4134    }
4135
4136    // ═════════════════════════════════════════════════════════════════════════
4137    // parse_doc
4138    // ═════════════════════════════════════════════════════════════════════════
4139
4140    /// Verifies `parse_doc` parses from an `xmlChar` buffer.
4141    ///
4142    /// # Safety
4143    ///
4144    /// - The NUL-terminated static `html` buffer is passed to `parse_doc` as
4145    ///   an `xmlChar` pointer and must stay valid for the call; the returned
4146    ///   document pointer is asserted non-NULL before it is dereferenced by
4147    ///   `html_doc_to_string` and is freed exactly once with `tree::free_doc`.
4148    #[test]
4149    fn test_parse_doc() {
4150        unsafe {
4151            let html = b"<p>Hello from parse_doc</p>\0";
4152            let doc = parse_doc(html.as_ptr() as *const xmlChar, ptr::null(), 0);
4153            assert!(!doc.is_null());
4154
4155            let s = html_doc_to_string(doc);
4156            assert!(s.contains("Hello from parse_doc"));
4157
4158            tree::free_doc(doc);
4159        }
4160    }
4161
4162    // ═════════════════════════════════════════════════════════════════════════
4163    // Table elements auto-close
4164    // ═════════════════════════════════════════════════════════════════════════
4165
4166    /// Verifies a `td` element auto-closes a previous `td` inside a row.
4167    ///
4168    /// # Safety
4169    ///
4170    /// - The NUL-terminated static `html` buffer stays valid for the
4171    ///   `parse_memory` call; the returned document pointer is asserted
4172    ///   non-NULL before it is dereferenced by `html_doc_to_string` and is
4173    ///   freed exactly once with `tree::free_doc`.
4174    #[test]
4175    fn test_table_element_auto_close() {
4176        unsafe {
4177            let html = b"<table><tr><td>Cell 1<td>Cell 2</td></tr></table>";
4178            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
4179            assert!(!doc.is_null());
4180
4181            let s = html_doc_to_string(doc);
4182            assert!(s.contains("<td>Cell 1"));
4183            assert!(s.contains("<td>Cell 2"));
4184
4185            tree::free_doc(doc);
4186        }
4187    }
4188
4189    #[test]
4190    fn test_table_tr_auto_close() {
4191        // <tr> must auto-close the open row (the corpus html-table op:
4192        // `<table><tr><td>1<td>2<tr><td>3</table>` yields two sibling rows).
4193        unsafe {
4194            let html = b"<table><tr><td>1<td>2<tr><td>3</table>\0";
4195            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
4196            assert!(!doc.is_null());
4197            let s = html_doc_to_string(doc);
4198            assert!(s.contains("</td></tr><tr>"));
4199            tree::free_doc(doc);
4200        }
4201    }
4202}