Skip to main content

libxml_rs/xml/html/
mod.rs

1//! HTML parser and serializer (§29, §85 Phase 4).
2//!
3//! libxml2's 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
14use core::ffi::c_void;
15use core::ptr;
16use core::slice;
17use std::os::raw::{c_char, c_int};
18
19use crate::abi::allocator::{xmlFree, xmlMalloc, xmlMallocZero, xmlRealloc};
20use crate::abi::constants::XML_DEFAULT_VERSION;
21use crate::abi::structs::*;
22use crate::abi::types::xmlCharEncoding::XML_CHAR_ENCODING_UTF8;
23use crate::abi::types::xmlDocProperties::XML_DOC_WELLFORMED;
24use crate::abi::types::xmlElementType::*;
25use crate::abi::types::*;
26use crate::xml::io;
27use crate::xml::string::*;
28use crate::xml::tree;
29
30// ═══════════════════════════════════════════════════════════════════════════════
31// HTML Element Info
32// ═══════════════════════════════════════════════════════════════════════════════
33
34/// HTML element flag constants matching libxml2's HTMLparser.h.
35const HTML_INLINE: u32 = 0x1;
36const HTML_BLOCK: u32 = 0x2;
37const HTML_EMPTY: u32 = 0x4;
38#[allow(dead_code)]
39const HTML_DEPRECATED: u32 = 0x8;
40const HTML_OL: u32 = 0x10;
41const HTML_DL: u32 = 0x20;
42#[allow(dead_code)]
43const HTML_COMPACT: u32 = 0x40;
44const HTML_HEAD: u32 = 0x80;
45const HTML_BODY: u32 = 0x100;
46#[allow(dead_code)]
47const HTML_HEADSTRUCK: u32 = 0x200;
48const HTML_VALID: u32 = 0x400;
49const HTML_NO_END: u32 = 0x800; // end tag optional
50const HTML_IMPLIED: u32 = 0x1000; // implied/auto-created
51
52/// Information about an HTML element.
53#[derive(Clone, Copy)]
54struct HtmlElementInfo {
55    name: &'static str,
56    flags: u32,
57}
58
59/// Lookup table of HTML elements and their properties.
60/// This matches libxml2's `htmlElementInfo` table.
61const HTML_ELEMENTS: &[HtmlElementInfo] = &[
62    // Void / empty elements
63    HtmlElementInfo {
64        name: "br",
65        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
66    },
67    HtmlElementInfo {
68        name: "hr",
69        flags: HTML_BLOCK | HTML_EMPTY | HTML_VALID,
70    },
71    HtmlElementInfo {
72        name: "img",
73        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
74    },
75    HtmlElementInfo {
76        name: "input",
77        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
78    },
79    HtmlElementInfo {
80        name: "meta",
81        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
82    },
83    HtmlElementInfo {
84        name: "link",
85        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
86    },
87    HtmlElementInfo {
88        name: "base",
89        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
90    },
91    HtmlElementInfo {
92        name: "area",
93        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
94    },
95    HtmlElementInfo {
96        name: "col",
97        flags: HTML_BLOCK | HTML_EMPTY | HTML_VALID,
98    },
99    HtmlElementInfo {
100        name: "embed",
101        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
102    },
103    HtmlElementInfo {
104        name: "param",
105        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
106    },
107    HtmlElementInfo {
108        name: "source",
109        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
110    },
111    HtmlElementInfo {
112        name: "track",
113        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
114    },
115    HtmlElementInfo {
116        name: "wbr",
117        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
118    },
119    // Block elements
120    HtmlElementInfo {
121        name: "html",
122        flags: HTML_BLOCK | HTML_VALID,
123    },
124    HtmlElementInfo {
125        name: "head",
126        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
127    },
128    HtmlElementInfo {
129        name: "body",
130        flags: HTML_BODY | HTML_BLOCK | HTML_VALID,
131    },
132    HtmlElementInfo {
133        name: "div",
134        flags: HTML_BLOCK | HTML_VALID,
135    },
136    HtmlElementInfo {
137        name: "p",
138        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
139    },
140    HtmlElementInfo {
141        name: "h1",
142        flags: HTML_BLOCK | HTML_VALID,
143    },
144    HtmlElementInfo {
145        name: "h2",
146        flags: HTML_BLOCK | HTML_VALID,
147    },
148    HtmlElementInfo {
149        name: "h3",
150        flags: HTML_BLOCK | HTML_VALID,
151    },
152    HtmlElementInfo {
153        name: "h4",
154        flags: HTML_BLOCK | HTML_VALID,
155    },
156    HtmlElementInfo {
157        name: "h5",
158        flags: HTML_BLOCK | HTML_VALID,
159    },
160    HtmlElementInfo {
161        name: "h6",
162        flags: HTML_BLOCK | HTML_VALID,
163    },
164    HtmlElementInfo {
165        name: "ul",
166        flags: HTML_BLOCK | HTML_VALID | HTML_OL,
167    },
168    HtmlElementInfo {
169        name: "ol",
170        flags: HTML_BLOCK | HTML_VALID | HTML_OL,
171    },
172    HtmlElementInfo {
173        name: "li",
174        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
175    },
176    HtmlElementInfo {
177        name: "dl",
178        flags: HTML_BLOCK | HTML_VALID | HTML_DL,
179    },
180    HtmlElementInfo {
181        name: "dt",
182        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
183    },
184    HtmlElementInfo {
185        name: "dd",
186        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
187    },
188    HtmlElementInfo {
189        name: "table",
190        flags: HTML_BLOCK | HTML_VALID,
191    },
192    HtmlElementInfo {
193        name: "tr",
194        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
195    },
196    HtmlElementInfo {
197        name: "td",
198        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
199    },
200    HtmlElementInfo {
201        name: "th",
202        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
203    },
204    HtmlElementInfo {
205        name: "thead",
206        flags: HTML_BLOCK | HTML_VALID,
207    },
208    HtmlElementInfo {
209        name: "tbody",
210        flags: HTML_BLOCK | HTML_VALID,
211    },
212    HtmlElementInfo {
213        name: "tfoot",
214        flags: HTML_BLOCK | HTML_VALID,
215    },
216    HtmlElementInfo {
217        name: "colgroup",
218        flags: HTML_BLOCK | HTML_VALID,
219    },
220    HtmlElementInfo {
221        name: "caption",
222        flags: HTML_BLOCK | HTML_VALID,
223    },
224    HtmlElementInfo {
225        name: "form",
226        flags: HTML_BLOCK | HTML_VALID,
227    },
228    HtmlElementInfo {
229        name: "fieldset",
230        flags: HTML_BLOCK | HTML_VALID,
231    },
232    HtmlElementInfo {
233        name: "legend",
234        flags: HTML_BLOCK | HTML_VALID,
235    },
236    HtmlElementInfo {
237        name: "pre",
238        flags: HTML_BLOCK | HTML_VALID,
239    },
240    HtmlElementInfo {
241        name: "blockquote",
242        flags: HTML_BLOCK | HTML_VALID,
243    },
244    HtmlElementInfo {
245        name: "address",
246        flags: HTML_BLOCK | HTML_VALID,
247    },
248    HtmlElementInfo {
249        name: "center",
250        flags: HTML_BLOCK | HTML_VALID,
251    },
252    HtmlElementInfo {
253        name: "dir",
254        flags: HTML_BLOCK | HTML_VALID,
255    },
256    HtmlElementInfo {
257        name: "menu",
258        flags: HTML_BLOCK | HTML_VALID,
259    },
260    HtmlElementInfo {
261        name: "noscript",
262        flags: HTML_BLOCK | HTML_VALID,
263    },
264    HtmlElementInfo {
265        name: "frameset",
266        flags: HTML_BLOCK | HTML_VALID,
267    },
268    HtmlElementInfo {
269        name: "frame",
270        flags: HTML_BLOCK | HTML_EMPTY | HTML_VALID,
271    },
272    HtmlElementInfo {
273        name: "iframe",
274        flags: HTML_BLOCK | HTML_VALID,
275    },
276    HtmlElementInfo {
277        name: "noframes",
278        flags: HTML_BLOCK | HTML_VALID,
279    },
280    // Inline elements
281    HtmlElementInfo {
282        name: "a",
283        flags: HTML_INLINE | HTML_VALID,
284    },
285    HtmlElementInfo {
286        name: "abbr",
287        flags: HTML_INLINE | HTML_VALID,
288    },
289    HtmlElementInfo {
290        name: "acronym",
291        flags: HTML_INLINE | HTML_VALID,
292    },
293    HtmlElementInfo {
294        name: "b",
295        flags: HTML_INLINE | HTML_VALID,
296    },
297    HtmlElementInfo {
298        name: "basefont",
299        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
300    },
301    HtmlElementInfo {
302        name: "bdo",
303        flags: HTML_INLINE | HTML_VALID,
304    },
305    HtmlElementInfo {
306        name: "big",
307        flags: HTML_INLINE | HTML_VALID,
308    },
309    HtmlElementInfo {
310        name: "cite",
311        flags: HTML_INLINE | HTML_VALID,
312    },
313    HtmlElementInfo {
314        name: "code",
315        flags: HTML_INLINE | HTML_VALID,
316    },
317    HtmlElementInfo {
318        name: "dfn",
319        flags: HTML_INLINE | HTML_VALID,
320    },
321    HtmlElementInfo {
322        name: "em",
323        flags: HTML_INLINE | HTML_VALID,
324    },
325    HtmlElementInfo {
326        name: "font",
327        flags: HTML_INLINE | HTML_VALID,
328    },
329    HtmlElementInfo {
330        name: "i",
331        flags: HTML_INLINE | HTML_VALID,
332    },
333    HtmlElementInfo {
334        name: "kbd",
335        flags: HTML_INLINE | HTML_VALID,
336    },
337    HtmlElementInfo {
338        name: "label",
339        flags: HTML_INLINE | HTML_VALID,
340    },
341    HtmlElementInfo {
342        name: "map",
343        flags: HTML_INLINE | HTML_VALID,
344    },
345    HtmlElementInfo {
346        name: "nobr",
347        flags: HTML_INLINE | HTML_VALID,
348    },
349    HtmlElementInfo {
350        name: "object",
351        flags: HTML_INLINE | HTML_VALID,
352    },
353    HtmlElementInfo {
354        name: "q",
355        flags: HTML_INLINE | HTML_VALID,
356    },
357    HtmlElementInfo {
358        name: "rb",
359        flags: HTML_INLINE | HTML_VALID,
360    },
361    HtmlElementInfo {
362        name: "rbc",
363        flags: HTML_INLINE | HTML_VALID,
364    },
365    HtmlElementInfo {
366        name: "rp",
367        flags: HTML_INLINE | HTML_VALID,
368    },
369    HtmlElementInfo {
370        name: "rt",
371        flags: HTML_INLINE | HTML_VALID,
372    },
373    HtmlElementInfo {
374        name: "rtc",
375        flags: HTML_INLINE | HTML_VALID,
376    },
377    HtmlElementInfo {
378        name: "ruby",
379        flags: HTML_INLINE | HTML_VALID,
380    },
381    HtmlElementInfo {
382        name: "s",
383        flags: HTML_INLINE | HTML_VALID,
384    },
385    HtmlElementInfo {
386        name: "samp",
387        flags: HTML_INLINE | HTML_VALID,
388    },
389    HtmlElementInfo {
390        name: "select",
391        flags: HTML_INLINE | HTML_VALID,
392    },
393    HtmlElementInfo {
394        name: "small",
395        flags: HTML_INLINE | HTML_VALID,
396    },
397    HtmlElementInfo {
398        name: "span",
399        flags: HTML_INLINE | HTML_VALID,
400    },
401    HtmlElementInfo {
402        name: "strike",
403        flags: HTML_INLINE | HTML_VALID,
404    },
405    HtmlElementInfo {
406        name: "strong",
407        flags: HTML_INLINE | HTML_VALID,
408    },
409    HtmlElementInfo {
410        name: "sub",
411        flags: HTML_INLINE | HTML_VALID,
412    },
413    HtmlElementInfo {
414        name: "sup",
415        flags: HTML_INLINE | HTML_VALID,
416    },
417    HtmlElementInfo {
418        name: "textarea",
419        flags: HTML_INLINE | HTML_VALID,
420    },
421    HtmlElementInfo {
422        name: "tt",
423        flags: HTML_INLINE | HTML_VALID,
424    },
425    HtmlElementInfo {
426        name: "u",
427        flags: HTML_INLINE | HTML_VALID,
428    },
429    HtmlElementInfo {
430        name: "var",
431        flags: HTML_INLINE | HTML_VALID,
432    },
433    // Heading block elements (also block)
434    HtmlElementInfo {
435        name: "header",
436        flags: HTML_BLOCK | HTML_VALID,
437    },
438    HtmlElementInfo {
439        name: "footer",
440        flags: HTML_BLOCK | HTML_VALID,
441    },
442    HtmlElementInfo {
443        name: "nav",
444        flags: HTML_BLOCK | HTML_VALID,
445    },
446    HtmlElementInfo {
447        name: "article",
448        flags: HTML_BLOCK | HTML_VALID,
449    },
450    HtmlElementInfo {
451        name: "section",
452        flags: HTML_BLOCK | HTML_VALID,
453    },
454    HtmlElementInfo {
455        name: "aside",
456        flags: HTML_BLOCK | HTML_VALID,
457    },
458    HtmlElementInfo {
459        name: "main",
460        flags: HTML_BLOCK | HTML_VALID,
461    },
462    HtmlElementInfo {
463        name: "figure",
464        flags: HTML_BLOCK | HTML_VALID,
465    },
466    HtmlElementInfo {
467        name: "figcaption",
468        flags: HTML_BLOCK | HTML_VALID,
469    },
470    HtmlElementInfo {
471        name: "details",
472        flags: HTML_BLOCK | HTML_VALID,
473    },
474    HtmlElementInfo {
475        name: "summary",
476        flags: HTML_BLOCK | HTML_VALID,
477    },
478    // Script and style
479    HtmlElementInfo {
480        name: "script",
481        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
482    },
483    HtmlElementInfo {
484        name: "style",
485        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
486    },
487    HtmlElementInfo {
488        name: "title",
489        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
490    },
491];
492
493/// Case-insensitive lookup of an HTML element by name.
494/// Returns `None` if the element is not in the table (treated as unknown).
495fn html_tag_lookup(name: &str) -> Option<&'static HtmlElementInfo> {
496    // Lowercase the name for comparison
497    let lower: Vec<u8> = name.bytes().map(|b| b.to_ascii_lowercase()).collect();
498    let lower_str = match core::str::from_utf8(&lower) {
499        Ok(s) => s,
500        Err(_) => return None,
501    };
502    HTML_ELEMENTS.iter().find(|info| info.name == lower_str)
503}
504
505// ═══════════════════════════════════════════════════════════════════════════════
506// HTML Entity Handling
507// ═══════════════════════════════════════════════════════════════════════════════
508
509/// HTML named entities table. Maps entity names to their UTF-8 character(s).
510/// This is a subset of the full HTML entity list, matching what libxml2 supports.
511const HTML_ENTITIES: &[(&str, &str)] = &[
512    ("nbsp", "\u{00a0}"),
513    ("lt", "<"),
514    ("gt", ">"),
515    ("amp", "&"),
516    ("quot", "\""),
517    ("apos", "'"),
518    ("copy", "\u{00a9}"),
519    ("reg", "\u{00ae}"),
520    ("amp", "&"),
521    ("iexcl", "\u{00a1}"),
522    ("cent", "\u{00a2}"),
523    ("pound", "\u{00a3}"),
524    ("curren", "\u{00a4}"),
525    ("yen", "\u{00a5}"),
526    ("brvbar", "\u{00a6}"),
527    ("sect", "\u{00a7}"),
528    ("uml", "\u{00a8}"),
529    ("ordf", "\u{00aa}"),
530    ("laquo", "\u{00ab}"),
531    ("not", "\u{00ac}"),
532    ("shy", "\u{00ad}"),
533    ("macr", "\u{00ae}"),
534    ("deg", "\u{00b0}"),
535    ("plusmn", "\u{00b1}"),
536    ("sup2", "\u{00b2}"),
537    ("sup3", "\u{00b3}"),
538    ("acute", "\u{00b4}"),
539    ("micro", "\u{00b5}"),
540    ("para", "\u{00b6}"),
541    ("middot", "\u{00b7}"),
542    ("cedil", "\u{00b8}"),
543    ("sup1", "\u{00b9}"),
544    ("ordm", "\u{00ba}"),
545    ("raquo", "\u{00bb}"),
546    ("frac14", "\u{00bc}"),
547    ("frac12", "\u{00bd}"),
548    ("frac34", "\u{00be}"),
549    ("iquest", "\u{00bf}"),
550    ("times", "\u{00d7}"),
551    ("divide", "\u{00f7}"),
552    ("ETH", "\u{00d0}"),
553    ("eth", "\u{00f0}"),
554    ("THORN", "\u{00de}"),
555    ("thorn", "\u{00fe}"),
556    ("AElig", "\u{00c6}"),
557    ("aelig", "\u{00e6}"),
558    ("OElig", "\u{0152}"),
559    ("oelig", "\u{0153}"),
560    ("Scaron", "\u{0160}"),
561    ("scaron", "\u{0161}"),
562    ("Yuml", "\u{0178}"),
563    ("circ", "\u{02c6}"),
564    ("tilde", "\u{02dc}"),
565    ("ensp", "\u{2002}"),
566    ("emsp", "\u{2003}"),
567    ("thinsp", "\u{2009}"),
568    ("zwnj", "\u{200c}"),
569    ("zwj", "\u{200d}"),
570    ("lrm", "\u{200e}"),
571    ("rlm", "\u{200f}"),
572    ("ndash", "\u{2013}"),
573    ("mdash", "\u{2014}"),
574    ("lsquo", "\u{2018}"),
575    ("rsquo", "\u{2019}"),
576    ("sbquo", "\u{201a}"),
577    ("ldquo", "\u{201c}"),
578    ("rdquo", "\u{201d}"),
579    ("bdquo", "\u{201e}"),
580    ("dagger", "\u{2020}"),
581    ("Dagger", "\u{2021}"),
582    ("bull", "\u{2022}"),
583    ("hellip", "\u{2026}"),
584    ("permil", "\u{2030}"),
585    ("prime", "\u{2032}"),
586    ("Prime", "\u{2033}"),
587    ("lsaquo", "\u{2039}"),
588    ("rsaquo", "\u{203a}"),
589    ("oline", "\u{203e}"),
590    ("euro", "\u{20ac}"),
591    ("trade", "\u{2122}"),
592    ("larr", "\u{2190}"),
593    ("uarr", "\u{2191}"),
594    ("rarr", "\u{2192}"),
595    ("darr", "\u{2193}"),
596    ("harr", "\u{2194}"),
597    ("crarr", "\u{21b5}"),
598    ("lceil", "\u{2308}"),
599    ("rceil", "\u{2309}"),
600    ("lfloor", "\u{230a}"),
601    ("rfloor", "\u{230b}"),
602    ("loz", "\u{25ca}"),
603    ("spades", "\u{2660}"),
604    ("clubs", "\u{2663}"),
605    ("hearts", "\u{2665}"),
606    ("diams", "\u{2666}"),
607    ("Alpha", "\u{0391}"),
608    ("Beta", "\u{0392}"),
609    ("Gamma", "\u{0393}"),
610    ("Delta", "\u{0394}"),
611    ("Epsilon", "\u{0395}"),
612    ("Zeta", "\u{0396}"),
613    ("Eta", "\u{0397}"),
614    ("Theta", "\u{0398}"),
615    ("Iota", "\u{0399}"),
616    ("Kappa", "\u{039a}"),
617    ("Lambda", "\u{039b}"),
618    ("Mu", "\u{039c}"),
619    ("Nu", "\u{039d}"),
620    ("Xi", "\u{039e}"),
621    ("Omicron", "\u{039f}"),
622    ("Pi", "\u{03a0}"),
623    ("Rho", "\u{03a1}"),
624    ("Sigma", "\u{03a3}"),
625    ("Tau", "\u{03a4}"),
626    ("Upsilon", "\u{03a5}"),
627    ("Phi", "\u{03a6}"),
628    ("Chi", "\u{03a7}"),
629    ("Psi", "\u{03a8}"),
630    ("Omega", "\u{03a9}"),
631    ("alpha", "\u{03b1}"),
632    ("beta", "\u{03b2}"),
633    ("gamma", "\u{03b3}"),
634    ("delta", "\u{03b4}"),
635    ("epsilon", "\u{03b5}"),
636    ("zeta", "\u{03b6}"),
637    ("eta", "\u{03b7}"),
638    ("theta", "\u{03b8}"),
639    ("iota", "\u{03b9}"),
640    ("kappa", "\u{03ba}"),
641    ("lambda", "\u{03bb}"),
642    ("mu", "\u{03bc}"),
643    ("nu", "\u{03bd}"),
644    ("xi", "\u{03be}"),
645    ("omicron", "\u{03bf}"),
646    ("pi", "\u{03c0}"),
647    ("rho", "\u{03c1}"),
648    ("sigmaf", "\u{03c2}"),
649    ("sigma", "\u{03c3}"),
650    ("tau", "\u{03c4}"),
651    ("upsilon", "\u{03c5}"),
652    ("phi", "\u{03c6}"),
653    ("chi", "\u{03c7}"),
654    ("psi", "\u{03c8}"),
655    ("omega", "\u{03c9}"),
656    ("thetasym", "\u{03d1}"),
657    ("upsih", "\u{03d2}"),
658    ("piv", "\u{03d6}"),
659];
660
661/// Look up an HTML entity by name (without the leading '&').
662/// Returns the replacement string, or None if unknown.
663fn html_entity_lookup(name: &str) -> Option<&'static str> {
664    HTML_ENTITIES
665        .iter()
666        .find(|(n, _)| *n == name)
667        .map(|(_, v)| *v)
668}
669
670// ═══════════════════════════════════════════════════════════════════════════════
671// Parser Context
672// ═══════════════════════════════════════════════════════════════════════════════
673
674/// Internal HTML parser context.
675struct HtmlParserCtxt {
676    /// The document being built
677    doc: *mut _xmlDoc,
678    /// Current insertion point (current parent node)
679    current: *mut _xmlNode,
680    /// The html element (auto-created or parsed)
681    html: *mut _xmlNode,
682    /// The head element (auto-created or parsed)
683    head: *mut _xmlNode,
684    /// The body element (auto-created or parsed)
685    body: *mut _xmlNode,
686    /// Whether we're inside <head>
687    in_head: bool,
688    /// Whether we're inside <body>
689    in_body: bool,
690    /// Whether html has been seen/created
691    html_created: bool,
692    /// Whether head has been seen/created
693    head_created: bool,
694    /// Whether body has been seen/created
695    body_created: bool,
696    /// Whether we've seen body content (moves from head to body)
697    seen_body_content: bool,
698    /// Input buffer (the HTML source)
699    input: *mut u8,
700    /// Current position in input
701    input_pos: usize,
702    /// Total input length
703    input_len: usize,
704    /// Line number tracking
705    line: c_int,
706    /// Error flag
707    err: bool,
708    /// Filename (for file parsing)
709    filename: *mut c_char,
710    /// Encoding
711    encoding: *mut c_char,
712}
713
714impl HtmlParserCtxt {
715    fn new() -> Self {
716        HtmlParserCtxt {
717            doc: ptr::null_mut(),
718            current: ptr::null_mut(),
719            html: ptr::null_mut(),
720            head: ptr::null_mut(),
721            body: ptr::null_mut(),
722            in_head: false,
723            in_body: false,
724            html_created: false,
725            head_created: false,
726            body_created: false,
727            seen_body_content: false,
728            input: ptr::null_mut(),
729            input_pos: 0,
730            input_len: 0,
731            line: 1,
732            err: false,
733            filename: ptr::null_mut(),
734            encoding: ptr::null_mut(),
735        }
736    }
737
738    /// Peek at the next byte without consuming it.
739    fn peek(&self) -> Option<u8> {
740        if self.input_pos < self.input_len {
741            unsafe { Some(*self.input.add(self.input_pos)) }
742        } else {
743            None
744        }
745    }
746
747    /// Peek ahead `n` bytes.
748    fn peek_at(&self, offset: usize) -> Option<u8> {
749        let pos = self.input_pos + offset;
750        if pos < self.input_len {
751            unsafe { Some(*self.input.add(pos)) }
752        } else {
753            None
754        }
755    }
756
757    /// Consume and return the next byte.
758    fn next(&mut self) -> Option<u8> {
759        if self.input_pos < self.input_len {
760            let ch = unsafe { *self.input.add(self.input_pos) };
761            self.input_pos += 1;
762            if ch == b'\n' {
763                self.line += 1;
764            }
765            Some(ch)
766        } else {
767            None
768        }
769    }
770
771    /// Skip bytes while the predicate returns true.
772    fn skip_while<F: Fn(u8) -> bool>(&mut self, f: F) {
773        while let Some(ch) = self.peek() {
774            if f(ch) {
775                self.next();
776            } else {
777                break;
778            }
779        }
780    }
781
782    /// Skip ASCII whitespace.
783    fn skip_whitespace(&mut self) {
784        self.skip_while(|ch| ch == b' ' || ch == b'\t' || ch == b'\n' || ch == b'\r');
785    }
786
787    /// Check if we've reached end of input.
788    fn is_eof(&self) -> bool {
789        self.input_pos >= self.input_len
790    }
791
792    /// Read a sequence of bytes while the predicate returns true.
793    fn read_while<F: Fn(u8) -> bool>(&mut self, f: F) -> Vec<u8> {
794        let start = self.input_pos;
795        while let Some(ch) = self.peek() {
796            if f(ch) {
797                self.next();
798            } else {
799                break;
800            }
801        }
802        unsafe { slice::from_raw_parts(self.input.add(start), self.input_pos - start).to_vec() }
803    }
804}
805
806// ═══════════════════════════════════════════════════════════════════════════════
807// Auto-close Logic
808// ═══════════════════════════════════════════════════════════════════════════════
809
810/// Check if a tag name is a "heading" element (h1-h6).
811fn is_heading(name: &str) -> bool {
812    matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
813}
814
815/// Get the parent element of a node, walking up to find the nearest element.
816/// Returns the current node's parent if it's an element, or walks up.
817unsafe fn get_parent_element(node: *mut _xmlNode) -> *mut _xmlNode {
818    if node.is_null() {
819        return ptr::null_mut();
820    }
821    let mut n = node;
822    loop {
823        let parent = unsafe { (*n).parent };
824        if parent.is_null() {
825            return ptr::null_mut();
826        }
827        let ptype = unsafe { (*parent).type_ };
828        if ptype == XML_ELEMENT_NODE as c_int
829            || ptype == XML_HTML_DOCUMENT_NODE as c_int
830            || ptype == XML_DOCUMENT_NODE as c_int
831        {
832            return parent;
833        }
834        n = parent;
835    }
836}
837
838/// Auto-close elements that should be closed before opening a new tag.
839/// Returns the new current insertion point.
840unsafe fn auto_close_element(ctxt: &mut HtmlParserCtxt, tag_name: &str) {
841    let tag_lower: Vec<u8> = tag_name.bytes().map(|b| b.to_ascii_lowercase()).collect();
842    let tag_lower_str = match core::str::from_utf8(&tag_lower) {
843        Ok(s) => s,
844        Err(_) => return,
845    };
846
847    let info = html_tag_lookup(&tag_lower_str);
848
849    let mut current = ctxt.current;
850
851    // Collect the open element names up the tree
852    let mut open_names: Vec<Vec<u8>> = Vec::new();
853    let mut cur = current;
854    while !cur.is_null() {
855        let ctype = unsafe { (*cur).type_ };
856        if ctype == XML_ELEMENT_NODE as c_int {
857            if !unsafe { (*cur).name.is_null() } {
858                let name_bytes = unsafe { xmlstr_to_bytes((*cur).name) };
859                open_names.push(name_bytes.to_vec());
860            }
861        }
862        cur = unsafe { (*cur).parent };
863    }
864
865    // Rule 1: <p> auto-closes before another <p>, and before block elements
866    if tag_lower_str == "p" || info.map_or(false, |i| i.flags & HTML_BLOCK != 0) {
867        // Close any open <p> elements
868        let mut cur2 = current;
869        while !cur2.is_null() {
870            let ctype = unsafe { (*cur2).type_ };
871            if ctype == XML_ELEMENT_NODE as c_int {
872                if !unsafe { (*cur2).name.is_null() } {
873                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
874                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
875                    if name_str.eq_ignore_ascii_case("p") {
876                        // Close this <p> by moving current up past it
877                        current = unsafe { (*cur2).parent };
878                        break;
879                    }
880                }
881            }
882            cur2 = unsafe { (*cur2).parent };
883        }
884    }
885
886    // Rule 2: Headings (h1-h6) auto-close other headings
887    if is_heading(&tag_lower_str) {
888        let mut cur2 = current;
889        while !cur2.is_null() {
890            let ctype = unsafe { (*cur2).type_ };
891            if ctype == XML_ELEMENT_NODE as c_int {
892                if !unsafe { (*cur2).name.is_null() } {
893                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
894                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
895                    if is_heading(name_str) {
896                        current = unsafe { (*cur2).parent };
897                        break;
898                    }
899                }
900            }
901            cur2 = unsafe { (*cur2).parent };
902        }
903    }
904
905    // Rule 3: <li> auto-closes another <li>
906    if tag_lower_str == "li" {
907        let mut cur2 = current;
908        while !cur2.is_null() {
909            let ctype = unsafe { (*cur2).type_ };
910            if ctype == XML_ELEMENT_NODE as c_int {
911                if !unsafe { (*cur2).name.is_null() } {
912                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
913                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
914                    if name_str.eq_ignore_ascii_case("li") {
915                        current = unsafe { (*cur2).parent };
916                        break;
917                    }
918                }
919            }
920            cur2 = unsafe { (*cur2).parent };
921        }
922    }
923
924    // Rule 4: <dt>/<dd> auto-close another <dt>/<dd>
925    if tag_lower_str == "dt" || tag_lower_str == "dd" {
926        let mut cur2 = current;
927        while !cur2.is_null() {
928            let ctype = unsafe { (*cur2).type_ };
929            if ctype == XML_ELEMENT_NODE as c_int {
930                if !unsafe { (*cur2).name.is_null() } {
931                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
932                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
933                    if name_str == "dt" || name_str == "dd" {
934                        current = unsafe { (*cur2).parent };
935                        break;
936                    }
937                }
938            }
939            cur2 = unsafe { (*cur2).parent };
940        }
941    }
942
943    // Rule 5: <tr> auto-closes another <tr>, <td>, <th>
944    if tag_lower_str == "tr" || tag_lower_str == "td" || tag_lower_str == "th" {
945        let mut cur2 = current;
946        while !cur2.is_null() {
947            let ctype = unsafe { (*cur2).type_ };
948            if ctype == XML_ELEMENT_NODE as c_int {
949                if !unsafe { (*cur2).name.is_null() } {
950                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
951                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
952                    if name_str == "tr" || name_str == "td" || name_str == "th" {
953                        current = unsafe { (*cur2).parent };
954                        break;
955                    }
956                }
957            }
958            cur2 = unsafe { (*cur2).parent };
959        }
960    }
961
962    // Rule 6: <thead>, <tbody>, <tfoot> auto-close each other
963    if matches!(tag_lower_str, "thead" | "tbody" | "tfoot") {
964        let mut cur2 = current;
965        while !cur2.is_null() {
966            let ctype = unsafe { (*cur2).type_ };
967            if ctype == XML_ELEMENT_NODE as c_int {
968                if !unsafe { (*cur2).name.is_null() } {
969                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
970                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
971                    if name_str == "thead" || name_str == "tbody" || name_str == "tfoot" {
972                        current = unsafe { (*cur2).parent };
973                        break;
974                    }
975                }
976            }
977            cur2 = unsafe { (*cur2).parent };
978        }
979    }
980
981    // Rule 7: <colgroup> auto-closes another <colgroup>
982    if tag_lower_str == "colgroup" {
983        let mut cur2 = current;
984        while !cur2.is_null() {
985            let ctype = unsafe { (*cur2).type_ };
986            if ctype == XML_ELEMENT_NODE as c_int {
987                if !unsafe { (*cur2).name.is_null() } {
988                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
989                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
990                    if name_str == "colgroup" {
991                        current = unsafe { (*cur2).parent };
992                        break;
993                    }
994                }
995            }
996            cur2 = unsafe { (*cur2).parent };
997        }
998    }
999
1000    // Rule 8: <caption> auto-closes another <caption>
1001    if tag_lower_str == "caption" {
1002        let mut cur2 = current;
1003        while !cur2.is_null() {
1004            let ctype = unsafe { (*cur2).type_ };
1005            if ctype == XML_ELEMENT_NODE as c_int {
1006                if !unsafe { (*cur2).name.is_null() } {
1007                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1008                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1009                    if name_str == "caption" {
1010                        current = unsafe { (*cur2).parent };
1011                        break;
1012                    }
1013                }
1014            }
1015            cur2 = unsafe { (*cur2).parent };
1016        }
1017    }
1018
1019    // Rule 9: <form> auto-closes another <form> (in libxml2 behavior)
1020    if tag_lower_str == "form" {
1021        let mut cur2 = current;
1022        while !cur2.is_null() {
1023            let ctype = unsafe { (*cur2).type_ };
1024            if ctype == XML_ELEMENT_NODE as c_int {
1025                if !unsafe { (*cur2).name.is_null() } {
1026                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1027                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1028                    if name_str.eq_ignore_ascii_case("form") {
1029                        current = unsafe { (*cur2).parent };
1030                        break;
1031                    }
1032                }
1033            }
1034            cur2 = unsafe { (*cur2).parent };
1035        }
1036    }
1037
1038    ctxt.current = current;
1039}
1040
1041// ═══════════════════════════════════════════════════════════════════════════════
1042// Implicit Element Creation
1043// ═══════════════════════════════════════════════════════════════════════════════
1044
1045/// Ensure the html element exists, creating it implicitly if needed.
1046unsafe fn ensure_html(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1047    if !ctxt.html.is_null() {
1048        return ctxt.html;
1049    }
1050
1051    let html_node = tree::new_node(ptr::null_mut(), b"html\0" as *const u8 as *const xmlChar);
1052    if html_node.is_null() {
1053        return ptr::null_mut();
1054    }
1055    unsafe {
1056        // Set HTML_IMPLIED flag concept - mark as auto-created
1057        // (We track this via a separate flag rather than modifying the node structure)
1058    }
1059    ctxt.html = html_node;
1060    ctxt.html_created = true;
1061
1062    // Add to document
1063    tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1064    ctxt.current = html_node;
1065
1066    html_node
1067}
1068
1069/// Ensure the head element exists, creating it implicitly if needed.
1070unsafe fn ensure_head(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1071    if !ctxt.head.is_null() {
1072        return ctxt.head;
1073    }
1074
1075    // Ensure html exists first
1076    ensure_html(ctxt);
1077
1078    let head_node = tree::new_node(ptr::null_mut(), b"head\0" as *const u8 as *const xmlChar);
1079    if head_node.is_null() {
1080        return ptr::null_mut();
1081    }
1082    ctxt.head = head_node;
1083    ctxt.head_created = true;
1084
1085    // Add as child of html
1086    tree::add_child(ctxt.html, head_node);
1087    ctxt.current = head_node;
1088    ctxt.in_head = true;
1089
1090    head_node
1091}
1092
1093/// Ensure the body element exists, creating it implicitly if needed.
1094unsafe fn ensure_body(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1095    if !ctxt.body.is_null() {
1096        return ctxt.body;
1097    }
1098
1099    // Ensure html exists first
1100    ensure_html(ctxt);
1101
1102    let body_node = tree::new_node(ptr::null_mut(), b"body\0" as *const u8 as *const xmlChar);
1103    if body_node.is_null() {
1104        return ptr::null_mut();
1105    }
1106    ctxt.body = body_node;
1107    ctxt.body_created = true;
1108
1109    // Add as child of html
1110    tree::add_child(ctxt.html, body_node);
1111    ctxt.current = body_node;
1112    ctxt.in_body = true;
1113
1114    body_node
1115}
1116
1117/// Transition from head to body when body content is encountered.
1118unsafe fn transition_to_body(ctxt: &mut HtmlParserCtxt) {
1119    if ctxt.in_head && !ctxt.seen_body_content {
1120        ctxt.seen_body_content = true;
1121        ctxt.in_head = false;
1122        ensure_body(ctxt);
1123    }
1124}
1125
1126// ═══════════════════════════════════════════════════════════════════════════════
1127// Tokenizer
1128// ═══════════════════════════════════════════════════════════════════════════════
1129
1130/// Result of parsing an attribute.
1131struct HtmlAttr {
1132    name: Vec<u8>,
1133    value: Vec<u8>,
1134    quoted: bool,
1135}
1136
1137/// Parse an attribute name.
1138fn parse_attr_name(ctxt: &mut HtmlParserCtxt) -> Vec<u8> {
1139    let mut name = Vec::new();
1140    while let Some(ch) = ctxt.peek() {
1141        if ch == b'='
1142            || ch == b'>'
1143            || ch == b'/'
1144            || ch == b' '
1145            || ch == b'\t'
1146            || ch == b'\n'
1147            || ch == b'\r'
1148        {
1149            break;
1150        }
1151        name.push(ch);
1152        ctxt.next();
1153    }
1154    name
1155}
1156
1157/// Parse an attribute value (may be quoted or unquoted).
1158fn parse_attr_value(ctxt: &mut HtmlParserCtxt) -> (Vec<u8>, bool) {
1159    ctxt.skip_whitespace();
1160
1161    let quote = match ctxt.peek() {
1162        Some(b'"') => {
1163            ctxt.next(); // consume opening quote
1164            b'"'
1165        }
1166        Some(b'\'') => {
1167            ctxt.next(); // consume opening quote
1168            b'\''
1169        }
1170        _ => {
1171            // Unquoted value
1172            let value = ctxt.read_while(|ch| {
1173                ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1174            });
1175            return (value, false);
1176        }
1177    };
1178
1179    // Quoted value
1180    let mut value = Vec::new();
1181    loop {
1182        match ctxt.next() {
1183            Some(ch) if ch == quote => break,
1184            Some(ch) => value.push(ch),
1185            None => break,
1186        }
1187    }
1188    (value, true)
1189}
1190
1191/// Parse attributes until we hit '>' or end of tag.
1192fn parse_attributes(ctxt: &mut HtmlParserCtxt) -> Vec<HtmlAttr> {
1193    let mut attrs = Vec::new();
1194
1195    loop {
1196        ctxt.skip_whitespace();
1197
1198        match ctxt.peek() {
1199            Some(b'>') | None => break,
1200            Some(b'/') => {
1201                // Could be self-closing tag like <br/>
1202                if ctxt.peek_at(1) == Some(b'>') {
1203                    break;
1204                }
1205                // Otherwise it's part of a minimized attribute or path
1206            }
1207            _ => {}
1208        }
1209
1210        let name = parse_attr_name(ctxt);
1211        if name.is_empty() {
1212            break;
1213        }
1214
1215        // Check for '='
1216        ctxt.skip_whitespace();
1217        if ctxt.peek() == Some(b'=') {
1218            ctxt.next(); // consume '='
1219            let (value, quoted) = parse_attr_value(ctxt);
1220            attrs.push(HtmlAttr {
1221                name,
1222                value,
1223                quoted,
1224            });
1225        } else {
1226            // Minimized attribute (e.g., <option selected>)
1227            attrs.push(HtmlAttr {
1228                name,
1229                value: Vec::new(),
1230                quoted: false,
1231            });
1232        }
1233    }
1234
1235    attrs
1236}
1237
1238// ═══════════════════════════════════════════════════════════════════════════════
1239// Tree Builder
1240// ═══════════════════════════════════════════════════════════════════════════════
1241
1242/// Process a resolved HTML entity reference and return the replacement bytes.
1243fn resolve_entity(name: &str) -> Vec<u8> {
1244    if let Some(replacement) = html_entity_lookup(name) {
1245        replacement.as_bytes().to_vec()
1246    } else {
1247        // Unknown entity: leave as-is (pass through as text)
1248        let mut result = Vec::new();
1249        result.push(b'&');
1250        result.extend_from_slice(name.as_bytes());
1251        result.push(b';');
1252        result
1253    }
1254}
1255
1256/// Handle a numeric character reference (decimal or hex).
1257fn resolve_numeric_entity(value: &str, is_hex: bool) -> Vec<u8> {
1258    let codepoint = if is_hex {
1259        u32::from_str_radix(value, 16).unwrap_or(0xFFFD)
1260    } else {
1261        u32::from_str_radix(value, 10).unwrap_or(0xFFFD)
1262    };
1263
1264    if codepoint == 0 {
1265        return Vec::new();
1266    }
1267
1268    // Convert codepoint to UTF-8
1269    match char::from_u32(codepoint) {
1270        Some(c) => {
1271            let mut buf = [0u8; 4];
1272            let s = c.encode_utf8(&mut buf);
1273            s.as_bytes().to_vec()
1274        }
1275        None => vec![0xEF, 0xBF, 0xBD], // replacement character
1276    }
1277}
1278
1279/// Parse an entity reference starting at current position (which points to '&').
1280/// Returns the replacement text and advances position.
1281fn parse_entity(ctxt: &mut HtmlParserCtxt) -> Vec<u8> {
1282    // We should be at '&'
1283    match ctxt.peek() {
1284        Some(b'&') => {
1285            ctxt.next(); // consume '&'
1286        }
1287        _ => return vec![b'&'],
1288    }
1289
1290    // Check for numeric entities
1291    if ctxt.peek() == Some(b'#') {
1292        ctxt.next(); // consume '#'
1293        let is_hex = ctxt.peek() == Some(b'x') || ctxt.peek() == Some(b'X');
1294        if is_hex {
1295            ctxt.next(); // consume 'x' or 'X'
1296        }
1297
1298        let digits = ctxt.read_while(|ch| {
1299            if is_hex {
1300                ch.is_ascii_hexdigit()
1301            } else {
1302                ch.is_ascii_digit()
1303            }
1304        });
1305
1306        let digits_str = core::str::from_utf8(&digits).unwrap_or("");
1307        if digits_str.is_empty() {
1308            let mut result = vec![b'&', b'#'];
1309            if is_hex {
1310                result.push(b'x');
1311            }
1312            return result;
1313        }
1314
1315        // Expect semicolon
1316        if ctxt.peek() == Some(b';') {
1317            ctxt.next();
1318        }
1319
1320        return resolve_numeric_entity(digits_str, is_hex);
1321    }
1322
1323    // Named entity
1324    let name = ctxt.read_while(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-');
1325    let name_str = core::str::from_utf8(&name).unwrap_or("");
1326
1327    // Expect semicolon
1328    if ctxt.peek() == Some(b';') {
1329        ctxt.next();
1330    }
1331
1332    resolve_entity(name_str)
1333}
1334
1335/// Handle text content in the tree builder.
1336unsafe fn handle_text(ctxt: &mut HtmlParserCtxt, text: &[u8]) {
1337    if text.is_empty() {
1338        return;
1339    }
1340
1341    // Determine current insertion point
1342    let parent = if ctxt.in_head {
1343        ctxt.head
1344    } else if ctxt.in_body || ctxt.body_created {
1345        ctxt.body
1346    } else if ctxt.html_created {
1347        ctxt.html
1348    } else {
1349        ctxt.doc as *mut _xmlNode
1350    };
1351
1352    let insertion_point = if ctxt.current.is_null() {
1353        parent
1354    } else {
1355        ctxt.current
1356    };
1357
1358    if insertion_point.is_null() {
1359        // Fall back to document
1360        let text_node = tree::new_text(ptr::null_mut());
1361        if !text_node.is_null() {
1362            // Create a null-terminated copy
1363            let content = bytes_to_xmlstr(text);
1364            if !content.is_null() {
1365                unsafe {
1366                    (*text_node).content = content;
1367                }
1368            }
1369            tree::add_child(ctxt.doc as *mut _xmlNode, text_node);
1370        }
1371        return;
1372    }
1373
1374    let text_node = tree::new_text(ptr::null_mut());
1375    if text_node.is_null() {
1376        return;
1377    }
1378
1379    // Set the content
1380    let content = bytes_to_xmlstr(text);
1381    if !content.is_null() {
1382        unsafe {
1383            (*text_node).content = content;
1384        }
1385    }
1386
1387    tree::add_child(insertion_point, text_node);
1388}
1389
1390/// Process a start tag in the tree builder.
1391unsafe fn handle_start_tag(ctxt: &mut HtmlParserCtxt, tag_name: &[u8], attrs: &[HtmlAttr]) {
1392    let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1393    let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1394
1395    let info = html_tag_lookup(tag_str);
1396
1397    // Determine tag category
1398    let is_head_tag = info.map_or(false, |i| i.flags & HTML_HEAD != 0);
1399    let is_body_tag = info.map_or(false, |i| i.flags & HTML_BODY != 0);
1400    let is_empty = info.map_or(false, |i| i.flags & HTML_EMPTY != 0);
1401    let is_block = info.map_or(false, |i| i.flags & HTML_BLOCK != 0);
1402
1403    // Handle special elements
1404    if tag_str == "html" {
1405        if !ctxt.html.is_null() && !ctxt.html_created {
1406            // Second <html> tag, skip it
1407            return;
1408        }
1409        // Create or use existing html
1410        if ctxt.html.is_null() {
1411            let html_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1412            if !html_node.is_null() {
1413                ctxt.html = html_node;
1414                ctxt.html_created = false; // parsed, not implied
1415                tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1416                ctxt.current = html_node;
1417            }
1418        } else {
1419            // html already auto-created, just set current
1420            ctxt.current = ctxt.html;
1421        }
1422        return;
1423    }
1424
1425    if tag_str == "head" {
1426        if !ctxt.head.is_null() && !ctxt.head_created {
1427            // Second <head> tag, skip it
1428            return;
1429        }
1430        // Ensure html exists
1431        ensure_html(ctxt);
1432
1433        if ctxt.head.is_null() {
1434            let head_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1435            if !head_node.is_null() {
1436                ctxt.head = head_node;
1437                ctxt.head_created = false;
1438                tree::add_child(ctxt.html, head_node);
1439                ctxt.current = head_node;
1440                ctxt.in_head = true;
1441            }
1442        } else {
1443            ctxt.current = ctxt.head;
1444            ctxt.in_head = true;
1445        }
1446        return;
1447    }
1448
1449    if tag_str == "body" {
1450        if !ctxt.body.is_null() && !ctxt.body_created {
1451            // Second <body> tag, skip it
1452            return;
1453        }
1454        // Ensure html exists
1455        ensure_html(ctxt);
1456
1457        if ctxt.body.is_null() {
1458            let body_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1459            if !body_node.is_null() {
1460                ctxt.body = body_node;
1461                ctxt.body_created = false;
1462                tree::add_child(ctxt.html, body_node);
1463                ctxt.current = body_node;
1464                ctxt.in_body = true;
1465                ctxt.in_head = false;
1466                ctxt.seen_body_content = true;
1467            }
1468        } else {
1469            ctxt.current = ctxt.body;
1470            ctxt.in_body = true;
1471            ctxt.in_head = false;
1472            ctxt.seen_body_content = true;
1473        }
1474        return;
1475    }
1476
1477    // For head-only elements (<title>, <meta>, <link>, <style>, <script>)
1478    if is_head_tag && !ctxt.seen_body_content {
1479        if ctxt.head.is_null() {
1480            ensure_head(ctxt);
1481        }
1482
1483        if is_empty {
1484            // Void element in head
1485            let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1486            if !node.is_null() {
1487                for attr in attrs {
1488                    let name_c = bytes_to_xmlstr(&attr.name);
1489                    let val_c = bytes_to_xmlstr(&attr.value);
1490                    if !name_c.is_null() {
1491                        tree::set_prop(node, name_c, val_c);
1492                        xmlFree(name_c as *mut c_void);
1493                        if !val_c.is_null() {
1494                            xmlFree(val_c as *mut c_void);
1495                        }
1496                    }
1497                }
1498                tree::add_child(ctxt.current, node);
1499            }
1500            return;
1501        }
1502
1503        let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1504        if !node.is_null() {
1505            for attr in attrs {
1506                let name_c = bytes_to_xmlstr(&attr.name);
1507                let val_c = bytes_to_xmlstr(&attr.value);
1508                if !name_c.is_null() {
1509                    tree::set_prop(node, name_c, val_c);
1510                    xmlFree(name_c as *mut c_void);
1511                    if !val_c.is_null() {
1512                        xmlFree(val_c as *mut c_void);
1513                    }
1514                }
1515            }
1516            tree::add_child(ctxt.current, node);
1517            ctxt.current = node;
1518        }
1519        return;
1520    }
1521
1522    // Body content - transition from head if needed
1523    if !is_head_tag || ctxt.seen_body_content {
1524        if !ctxt.seen_body_content {
1525            ctxt.seen_body_content = true;
1526            ctxt.in_head = false;
1527            if ctxt.body.is_null() {
1528                ensure_body(ctxt);
1529            } else {
1530                ctxt.current = ctxt.body;
1531                ctxt.in_body = true;
1532            }
1533        } else if ctxt.body.is_null() {
1534            ensure_body(ctxt);
1535        }
1536    }
1537
1538    // Auto-close elements as needed
1539    if !ctxt.current.is_null() {
1540        auto_close_element(ctxt, tag_str);
1541    }
1542
1543    if is_empty {
1544        // Void element: create node, add attributes, add as child (no children)
1545        let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1546        if !node.is_null() {
1547            for attr in attrs {
1548                let name_c = bytes_to_xmlstr(&attr.name);
1549                let val_c = bytes_to_xmlstr(&attr.value);
1550                if !name_c.is_null() {
1551                    tree::set_prop(node, name_c, val_c);
1552                    xmlFree(name_c as *mut c_void);
1553                    if !val_c.is_null() {
1554                        xmlFree(val_c as *mut c_void);
1555                    }
1556                }
1557            }
1558            let insertion_point = if ctxt.current.is_null() {
1559                ctxt.body
1560            } else {
1561                ctxt.current
1562            };
1563            if !insertion_point.is_null() {
1564                tree::add_child(insertion_point, node);
1565            }
1566        }
1567        return;
1568    }
1569
1570    // Regular element
1571    let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1572    if !node.is_null() {
1573        for attr in attrs {
1574            let name_c = bytes_to_xmlstr(&attr.name);
1575            let val_c = bytes_to_xmlstr(&attr.value);
1576            if !name_c.is_null() {
1577                tree::set_prop(node, name_c, val_c);
1578                xmlFree(name_c as *mut c_void);
1579                if !val_c.is_null() {
1580                    xmlFree(val_c as *mut c_void);
1581                }
1582            }
1583        }
1584
1585        let insertion_point = if ctxt.current.is_null() {
1586            if ctxt.in_body || ctxt.body_created {
1587                ctxt.body
1588            } else if ctxt.in_head || ctxt.head_created {
1589                ctxt.head
1590            } else if ctxt.html_created {
1591                ctxt.html
1592            } else {
1593                ctxt.doc as *mut _xmlNode
1594            }
1595        } else {
1596            ctxt.current
1597        };
1598
1599        if !insertion_point.is_null() {
1600            tree::add_child(insertion_point, node);
1601            // For non-void elements, this becomes the new insertion point
1602            ctxt.current = node;
1603        }
1604    }
1605}
1606
1607/// Process an end tag in the tree builder.
1608unsafe fn handle_end_tag(ctxt: &mut HtmlParserCtxt, tag_name: &[u8]) {
1609    let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1610    let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1611
1612    let info = html_tag_lookup(tag_str);
1613
1614    // For elements with no end tag (void elements or optional end tags),
1615    // just ignore the end tag.
1616    if info.map_or(false, |i| i.flags & HTML_EMPTY != 0) {
1617        return;
1618    }
1619
1620    if tag_str == "html" {
1621        ctxt.current = ctxt.doc as *mut _xmlNode;
1622        return;
1623    }
1624
1625    if tag_str == "head" {
1626        ctxt.in_head = false;
1627        ctxt.current = ctxt.html;
1628        return;
1629    }
1630
1631    if tag_str == "body" {
1632        ctxt.in_body = false;
1633        ctxt.current = ctxt.html;
1634        return;
1635    }
1636
1637    // Walk up the tree to find a matching open element
1638    let mut cur = ctxt.current;
1639    while !cur.is_null() {
1640        let ctype = unsafe { (*cur).type_ };
1641        if ctype == XML_ELEMENT_NODE as c_int {
1642            if !unsafe { (*cur).name.is_null() } {
1643                let name_bytes = unsafe { xmlstr_to_bytes((*cur).name) };
1644                if name_bytes.eq_ignore_ascii_case(tag_name) {
1645                    // Found the matching element - close by moving current to parent
1646                    ctxt.current = unsafe { (*cur).parent };
1647                    return;
1648                }
1649            }
1650        }
1651        cur = unsafe { (*cur).parent };
1652    }
1653
1654    // If no matching element found, ignore the end tag (tag-recovery behavior)
1655}
1656
1657// ═══════════════════════════════════════════════════════════════════════════════
1658// Main Parse Function
1659// ═══════════════════════════════════════════════════════════════════════════════
1660
1661/// Parse HTML from a buffer.
1662///
1663/// # Safety
1664///
1665/// - `buffer` must point to valid memory of at least `size` bytes.
1666unsafe fn html_parse_buffer(
1667    ctxt: &mut HtmlParserCtxt,
1668    buffer: *const c_char,
1669    size: c_int,
1670) -> *mut _xmlDoc {
1671    if buffer.is_null() || size <= 0 {
1672        return ptr::null_mut();
1673    }
1674
1675    // Create document with HTML_DOCUMENT_NODE type
1676    let doc = tree::new_doc(b"1.0\0" as *const u8 as *const xmlChar);
1677    if doc.is_null() {
1678        return ptr::null_mut();
1679    }
1680    unsafe {
1681        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
1682        (*doc).properties = XML_DOC_WELLFORMED as c_int;
1683    }
1684    ctxt.doc = doc;
1685
1686    // Set up input
1687    ctxt.input = buffer as *mut u8;
1688    ctxt.input_len = size as usize;
1689    ctxt.input_pos = 0;
1690    ctxt.line = 1;
1691
1692    // Main parse loop
1693    loop {
1694        if ctxt.is_eof() {
1695            break;
1696        }
1697
1698        let ch = ctxt.peek().unwrap_or(0);
1699
1700        if ch == b'<' {
1701            ctxt.next(); // consume '<'
1702
1703            // Check for </ (end tag)
1704            if ctxt.peek() == Some(b'/') {
1705                ctxt.next(); // consume '/'
1706                let tag_name = ctxt.read_while(|ch| {
1707                    ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1708                });
1709
1710                // Consume until '>'
1711                while ctxt.peek() != Some(b'>') && !ctxt.is_eof() {
1712                    ctxt.next();
1713                }
1714                if ctxt.peek() == Some(b'>') {
1715                    ctxt.next(); // consume '>'
1716                }
1717
1718                if !tag_name.is_empty() {
1719                    handle_end_tag(ctxt, &tag_name);
1720                }
1721                continue;
1722            }
1723
1724            // Check for <!-- (comment)
1725            if ctxt.peek() == Some(b'!')
1726                && ctxt.peek_at(1) == Some(b'-')
1727                && ctxt.peek_at(2) == Some(b'-')
1728            {
1729                ctxt.next(); // consume '!'
1730                ctxt.next(); // consume '-'
1731                ctxt.next(); // consume '-'
1732
1733                // Read until -->
1734                let mut comment_content = Vec::new();
1735                loop {
1736                    if ctxt.peek() == Some(b'-')
1737                        && ctxt.peek_at(1) == Some(b'-')
1738                        && ctxt.peek_at(2) == Some(b'>')
1739                    {
1740                        ctxt.next(); // consume '-'
1741                        ctxt.next(); // consume '-'
1742                        ctxt.next(); // consume '>'
1743                        break;
1744                    }
1745                    match ctxt.next() {
1746                        Some(ch) => comment_content.push(ch),
1747                        None => break,
1748                    }
1749                }
1750
1751                // Create comment node
1752                if !comment_content.is_empty() {
1753                    let comment_node = tree::new_comment(bytes_to_xmlstr(&comment_content));
1754                    if !comment_node.is_null() {
1755                        let insertion_point = if !ctxt.current.is_null() {
1756                            ctxt.current
1757                        } else {
1758                            ctxt.doc as *mut _xmlNode
1759                        };
1760                        tree::add_child(insertion_point, comment_node);
1761                    }
1762                }
1763                continue;
1764            }
1765
1766            // Check for <!DOCTYPE
1767            if ctxt.peek() == Some(b'!') {
1768                ctxt.next(); // consume '!'
1769                let rest = ctxt.read_while(|ch| ch != b'>');
1770                if ctxt.peek() == Some(b'>') {
1771                    ctxt.next(); // consume '>'
1772                }
1773                // We don't create a DTD node from HTML DOCTYPE in this implementation
1774                // (matching basic libxml2 behavior where HTML doctype is mostly ignored)
1775                continue;
1776            }
1777
1778            // Check for <? (processing instruction)
1779            if ctxt.peek() == Some(b'?') {
1780                ctxt.next(); // consume '?'
1781                             // Read until we see ?>
1782                let mut pi_content = Vec::new();
1783                loop {
1784                    if ctxt.peek() == Some(b'?') && ctxt.peek_at(1) == Some(b'>') {
1785                        break;
1786                    }
1787                    match ctxt.next() {
1788                        Some(ch) => pi_content.push(ch),
1789                        None => break,
1790                    }
1791                }
1792                // Consume ?>
1793                if ctxt.peek() == Some(b'?') {
1794                    ctxt.next();
1795                }
1796                if ctxt.peek() == Some(b'>') {
1797                    ctxt.next();
1798                }
1799                // Create PI node
1800                if !pi_content.is_empty() {
1801                    // Split into target and value
1802                    let mut parts = pi_content.splitn(2, |b| *b == b' ');
1803                    let target = parts.next().unwrap_or(&pi_content);
1804                    let value = parts.next().unwrap_or(b"");
1805
1806                    let pi_node = tree::new_pi(bytes_to_xmlstr(target), bytes_to_xmlstr(value));
1807                    if !pi_node.is_null() {
1808                        let insertion_point = if !ctxt.current.is_null() {
1809                            ctxt.current
1810                        } else {
1811                            ctxt.doc as *mut _xmlNode
1812                        };
1813                        tree::add_child(insertion_point, pi_node);
1814                    }
1815                }
1816                continue;
1817            }
1818
1819            // Parse start tag
1820            let tag_name = ctxt.read_while(|ch| {
1821                ch != b'>' && ch != b'/' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1822            });
1823
1824            if tag_name.is_empty() {
1825                // Just a bare '<' with no tag name, treat as text
1826                handle_text(ctxt, &[b'<']);
1827                continue;
1828            }
1829
1830            // Parse attributes
1831            let attrs = parse_attributes(ctxt);
1832
1833            // Check for self-closing (/>) or just >
1834            if ctxt.peek() == Some(b'/') {
1835                ctxt.next(); // consume '/'
1836                if ctxt.peek() == Some(b'>') {
1837                    ctxt.next(); // consume '>'
1838                }
1839            } else if ctxt.peek() == Some(b'>') {
1840                ctxt.next(); // consume '>'
1841            }
1842
1843            // Check if this is a raw text element (script, style)
1844            let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1845            let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1846
1847            if tag_str == "script" || tag_str == "style" {
1848                // Handle raw text content
1849                // Create the element first
1850                let raw_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(&tag_name));
1851                if !raw_node.is_null() {
1852                    for attr in &attrs {
1853                        let name_c = bytes_to_xmlstr(&attr.name);
1854                        let val_c = bytes_to_xmlstr(&attr.value);
1855                        if !name_c.is_null() {
1856                            tree::set_prop(raw_node, name_c, val_c);
1857                            xmlFree(name_c as *mut c_void);
1858                            if !val_c.is_null() {
1859                                xmlFree(val_c as *mut c_void);
1860                            }
1861                        }
1862                    }
1863
1864                    let insertion_point = if ctxt.current.is_null() {
1865                        if ctxt.in_head {
1866                            ensure_head(ctxt);
1867                            ctxt.head
1868                        } else {
1869                            ensure_body(ctxt);
1870                            ctxt.body
1871                        }
1872                    } else {
1873                        ctxt.current
1874                    };
1875
1876                    if !insertion_point.is_null() {
1877                        tree::add_child(insertion_point, raw_node);
1878
1879                        // Read raw text until matching </script> or </style>
1880                        let end_tag = format!("</{}", tag_str);
1881                        let end_bytes = end_tag.as_bytes();
1882                        let mut raw_text = Vec::new();
1883                        let mut match_idx = 0;
1884
1885                        loop {
1886                            if ctxt.is_eof() {
1887                                break;
1888                            }
1889                            let ch = ctxt.peek().unwrap();
1890                            if ch.to_ascii_lowercase() == end_bytes[match_idx] {
1891                                match_idx += 1;
1892                                if match_idx == end_bytes.len() {
1893                                    // We found the start of </tag
1894                                    // Add the text before the end tag
1895                                    if !raw_text.is_empty() {
1896                                        let text_node = tree::new_text(bytes_to_xmlstr(&raw_text));
1897                                        if !text_node.is_null() {
1898                                            tree::add_child(raw_node, text_node);
1899                                        }
1900                                    }
1901                                    // Consume the rest of the end tag: "tag>"
1902                                    ctxt.next(); // consume the last char of end tag prefix
1903                                                 // Now read "tag>"
1904                                    let _suffix = ctxt.read_while(|ch| ch != b'>');
1905                                    if ctxt.peek() == Some(b'>') {
1906                                        ctxt.next();
1907                                    }
1908                                    // Close the element
1909                                    ctxt.current = unsafe { (*raw_node).parent };
1910                                    break;
1911                                }
1912                                // Store the potential match start
1913                                if match_idx == 1 {
1914                                    raw_text.push(ch);
1915                                }
1916                                ctxt.next();
1917                            } else {
1918                                // If we were building a match, flush all buffered chars
1919                                if match_idx > 0 {
1920                                    // We already pushed some chars, just continue
1921                                    match_idx = 0;
1922                                }
1923                                raw_text.push(ch);
1924                                ctxt.next();
1925                            }
1926                        }
1927
1928                        // If we never found the end tag, just add the text
1929                        if match_idx < end_bytes.len() && !raw_text.is_empty() {
1930                            let text_node = tree::new_text(bytes_to_xmlstr(&raw_text));
1931                            if !text_node.is_null() {
1932                                tree::add_child(raw_node, text_node);
1933                            }
1934                            ctxt.current = unsafe { (*raw_node).parent };
1935                        }
1936                    }
1937                }
1938                continue;
1939            }
1940
1941            // Regular start tag
1942            handle_start_tag(ctxt, &tag_name, &attrs);
1943        } else {
1944            // Text content - read until next '<' or entity '&'
1945            let mut text = Vec::new();
1946            loop {
1947                match ctxt.peek() {
1948                    Some(b'<') => break,
1949                    Some(b'&') => {
1950                        // Handle entity reference inline
1951                        let entity_text = parse_entity(ctxt);
1952                        text.extend_from_slice(&entity_text);
1953                    }
1954                    Some(ch) => {
1955                        text.push(ch);
1956                        ctxt.next();
1957                    }
1958                    None => break,
1959                }
1960            }
1961
1962            if !text.is_empty() {
1963                handle_text(ctxt, &text);
1964            }
1965        }
1966    }
1967
1968    // Post-processing: ensure html/head/body are created even for empty documents
1969    if ctxt.html.is_null() {
1970        ensure_html(ctxt);
1971    }
1972
1973    doc
1974}
1975
1976// ═══════════════════════════════════════════════════════════════════════════════
1977// Public API Functions
1978// ═══════════════════════════════════════════════════════════════════════════════
1979
1980/// Parse HTML from a file.
1981///
1982/// # UPSTREAM-PARITY
1983///
1984/// Equivalent to `htmlParseFile` in libxml2.
1985///
1986/// # Safety
1987///
1988/// - `filename` must be a valid null-terminated C string or NULL.
1989/// - `encoding` must be a valid null-terminated C string or NULL.
1990pub(crate) unsafe fn parse_file(filename: *const c_char, encoding: *const c_char) -> *mut _xmlDoc {
1991    if filename.is_null() {
1992        return ptr::null_mut();
1993    }
1994
1995    // Read the file into memory
1996    let filename_str = unsafe { std::ffi::CStr::from_ptr(filename) };
1997    let path = filename_str.to_str().unwrap_or("");
1998    let content = match std::fs::read(path) {
1999        Ok(data) => data,
2000        Err(_) => return ptr::null_mut(),
2001    };
2002
2003    let mut ctxt = HtmlParserCtxt::new();
2004    if !encoding.is_null() {
2005        let enc_cstr = unsafe { std::ffi::CStr::from_ptr(encoding) };
2006        ctxt.encoding = unsafe { c_strdup(encoding) };
2007    }
2008
2009    let doc = unsafe {
2010        html_parse_buffer(
2011            &mut ctxt,
2012            content.as_ptr() as *const c_char,
2013            content.len() as c_int,
2014        )
2015    };
2016
2017    if !doc.is_null() && !filename.is_null() {
2018        unsafe {
2019            (*doc).URL = c_strdup(filename) as *mut xmlChar;
2020        }
2021    }
2022
2023    doc
2024}
2025
2026/// Parse HTML from a memory buffer.
2027///
2028/// # UPSTREAM-PARITY
2029///
2030/// Equivalent to `htmlParseMemory` in libxml2.
2031///
2032/// # Safety
2033///
2034/// - `buffer` must point to valid memory of at least `size` bytes.
2035/// - `size` must be non-negative.
2036pub(crate) unsafe fn parse_memory(buffer: *const c_char, size: c_int) -> *mut _xmlDoc {
2037    if buffer.is_null() || size <= 0 {
2038        return ptr::null_mut();
2039    }
2040
2041    let mut ctxt = HtmlParserCtxt::new();
2042    unsafe { html_parse_buffer(&mut ctxt, buffer, size) }
2043}
2044
2045/// Parse HTML from a null-terminated string.
2046///
2047/// # UPSTREAM-PARITY
2048///
2049/// Equivalent to `htmlParseDoc` in libxml2.
2050///
2051/// # Safety
2052///
2053/// - `cur` must be a valid null-terminated xmlChar string or NULL.
2054/// - `encoding` must be a valid null-terminated C string or NULL.
2055pub(crate) unsafe fn parse_doc(cur: *const xmlChar, encoding: *const c_char) -> *mut _xmlDoc {
2056    if cur.is_null() {
2057        return ptr::null_mut();
2058    }
2059
2060    let len = unsafe { xml_strlen(cur) };
2061    let mut ctxt = HtmlParserCtxt::new();
2062    if !encoding.is_null() {
2063        ctxt.encoding = unsafe { c_strdup(encoding) };
2064    }
2065
2066    unsafe { html_parse_buffer(&mut ctxt, cur as *const c_char, len as c_int) }
2067}
2068
2069/// Create an HTML parser context for file parsing.
2070///
2071/// # UPSTREAM-PARITY
2072///
2073/// Equivalent to `htmlCreateFileParserCtxt` in libxml2.
2074///
2075/// # Safety
2076///
2077/// - `filename` must be a valid null-terminated C string or NULL.
2078/// - `encoding` must be a valid null-terminated C string or NULL.
2079pub(crate) unsafe fn create_file_parser_ctxt(
2080    filename: *const c_char,
2081    encoding: *const c_char,
2082) -> *mut c_void {
2083    if filename.is_null() {
2084        return ptr::null_mut();
2085    }
2086
2087    let ctxt = unsafe { xmlMallocZero(size_of::<HtmlParserCtxt>() as usize) };
2088    if ctxt.is_null() {
2089        return ptr::null_mut();
2090    }
2091
2092    let ctxt = ctxt as *mut HtmlParserCtxt;
2093    unsafe {
2094        ptr::write(ctxt, HtmlParserCtxt::new());
2095        if !encoding.is_null() {
2096            (*ctxt).encoding = c_strdup(encoding);
2097        }
2098    }
2099
2100    ctxt as *mut c_void
2101}
2102
2103/// Free an HTML parser context.
2104///
2105/// # UPSTREAM-PARITY
2106///
2107/// Equivalent to `htmlFreeParserCtxt` in libxml2.
2108///
2109/// # Safety
2110///
2111/// - `ctxt` must be a valid pointer returned by `create_file_parser_ctxt`, or NULL.
2112pub(crate) unsafe fn free_parser_ctxt(ctxt: *mut c_void) {
2113    if ctxt.is_null() {
2114        return;
2115    }
2116
2117    let ctxt = ctxt as *mut HtmlParserCtxt;
2118    unsafe {
2119        if !(*ctxt).filename.is_null() {
2120            xmlFree((*ctxt).filename as *mut c_void);
2121        }
2122        if !(*ctxt).encoding.is_null() {
2123            xmlFree((*ctxt).encoding as *mut c_void);
2124        }
2125        xmlFree(ctxt as *mut c_void);
2126    }
2127}
2128
2129/// Initialize the HTML parser module.
2130///
2131/// # UPSTREAM-PARITY
2132///
2133/// Equivalent to `htmlInitParser` in libxml2.
2134pub(crate) fn init_parser() {
2135    // Currently a no-op. In the future, may initialize HTML-specific
2136    // entity tables or other global state.
2137}
2138
2139/// Cleanup the HTML parser module.
2140///
2141/// # UPSTREAM-PARITY
2142///
2143/// Equivalent to `htmlCleanupParser` in libxml2.
2144pub(crate) fn cleanup_parser() {
2145    // Currently a no-op. In the future, may free HTML-specific
2146    // global state.
2147}
2148
2149/// Create a new HTML document.
2150///
2151/// # UPSTREAM-PARITY
2152///
2153/// Equivalent to `htmlNewDoc` in libxml2.
2154///
2155/// Creates a new document with type XML_HTML_DOCUMENT_NODE and
2156/// auto-creates html/head/body elements.
2157///
2158/// # Safety
2159///
2160/// - `version` must be a valid null-terminated xmlChar string or NULL.
2161pub(crate) unsafe fn new_doc(version: *const xmlChar) -> *mut _xmlDoc {
2162    let doc = tree::new_doc(version);
2163    if doc.is_null() {
2164        return ptr::null_mut();
2165    }
2166
2167    unsafe {
2168        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2169        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2170    }
2171
2172    // Create implicit html/head/body
2173    let mut ctxt = HtmlParserCtxt::new();
2174    ctxt.doc = doc;
2175
2176    unsafe {
2177        ensure_html(&mut ctxt);
2178        ensure_head(&mut ctxt);
2179        ensure_body(&mut ctxt);
2180    }
2181
2182    doc
2183}
2184
2185/// Create a new HTML document without DTD.
2186///
2187/// # UPSTREAM-PARITY
2188///
2189/// Equivalent to `htmlNewDocNoDtD` in libxml2.
2190///
2191/// Creates a new document with type XML_HTML_DOCUMENT_NODE.
2192/// Unlike `htmlNewDoc`, this does NOT auto-create html/head/body elements.
2193///
2194/// # Safety
2195///
2196/// - `version` must be a valid null-terminated xmlChar string or NULL.
2197pub(crate) unsafe fn new_doc_no_dtd(version: *const xmlChar) -> *mut _xmlDoc {
2198    let doc = tree::new_doc(version);
2199    if doc.is_null() {
2200        return ptr::null_mut();
2201    }
2202
2203    unsafe {
2204        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2205        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2206    }
2207
2208    doc
2209}
2210
2211// ═══════════════════════════════════════════════════════════════════════════════
2212// HTML Serializer
2213// ═══════════════════════════════════════════════════════════════════════════════
2214
2215/// HTML void elements that should not have closing tags.
2216const HTML_VOID_ELEMENTS: &[&str] = &[
2217    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
2218    "track", "wbr", "frame",
2219];
2220
2221/// Check if an element name is an HTML void element.
2222fn is_html_void(name: &str) -> bool {
2223    HTML_VOID_ELEMENTS
2224        .iter()
2225        .any(|v| v.eq_ignore_ascii_case(name))
2226}
2227
2228/// Check if an element has optional end tag in HTML.
2229fn has_optional_end_tag(name: &str) -> bool {
2230    matches!(
2231        name.to_ascii_lowercase().as_str(),
2232        "p" | "li" | "tr" | "td" | "th" | "dt" | "dd"
2233    )
2234}
2235
2236/// Serialize a text node for HTML output.
2237///
2238/// In HTML serialization, we escape `<` and `&` but NOT non-ASCII characters
2239/// as numeric entities (unlike XML serialization).
2240unsafe fn html_serialize_text(buf: *mut _xmlBuffer, content: *const xmlChar, len: c_int) {
2241    if buf.is_null() || content.is_null() || len <= 0 {
2242        return;
2243    }
2244
2245    let mut i: c_int = 0;
2246    while i < len {
2247        let ch = unsafe { *content.add(i as usize) };
2248
2249        match ch {
2250            b'<' => {
2251                io::buf_add(buf, b"&lt;" as *const u8, 4);
2252            }
2253            b'&' => {
2254                io::buf_add(buf, b"&amp;" as *const u8, 5);
2255            }
2256            _ => {
2257                io::buf_add(buf, &ch as *const u8, 1);
2258            }
2259        }
2260        i += 1;
2261    }
2262}
2263
2264/// Serialize an attribute value for HTML output.
2265///
2266/// In HTML, attribute values should be quoted and have `&`, `"` escaped.
2267unsafe fn html_serialize_attr_value(buf: *mut _xmlBuffer, value: *const xmlChar) {
2268    if buf.is_null() || value.is_null() {
2269        return;
2270    }
2271
2272    let len = unsafe { xml_strlen(value) as c_int };
2273    let mut i: c_int = 0;
2274    while i < len {
2275        let ch = unsafe { *value.add(i as usize) };
2276
2277        match ch {
2278            b'&' => {
2279                io::buf_add(buf, b"&amp;" as *const u8, 5);
2280            }
2281            b'"' => {
2282                io::buf_add(buf, b"&quot;" as *const u8, 6);
2283            }
2284            _ => {
2285                io::buf_add(buf, &ch as *const u8, 1);
2286            }
2287        }
2288        i += 1;
2289    }
2290}
2291
2292/// HTML-specific node serialization.
2293///
2294/// Walks the node tree and serializes to HTML format.
2295/// Differs from XML serialization in several ways:
2296/// - No XML declaration for HTML documents
2297/// - No self-closing tags for void elements
2298/// - Case-insensitive tag names preserved as-is
2299/// - No namespace declarations
2300/// - Elements with optional end tags may omit them
2301pub(crate) unsafe fn serialize_node(
2302    node: *mut _xmlNode,
2303    buf: *mut _xmlBuffer,
2304    format: c_int,
2305    level: c_int,
2306) {
2307    if node.is_null() || buf.is_null() {
2308        return;
2309    }
2310
2311    let n = unsafe { &*node };
2312
2313    match n.type_ {
2314        t if t == XML_ELEMENT_NODE as c_int => {
2315            let name = if n.name.is_null() {
2316                ""
2317            } else {
2318                unsafe { core::str::from_utf8(xmlstr_to_bytes(n.name)).unwrap_or("") }
2319            };
2320
2321            let is_void = is_html_void(name);
2322
2323            // Newline + indent before start tag (if formatting)
2324            if format != 0 && level > 0 {
2325                io::buf_ccat(buf, b'\n');
2326                for _ in 0..level {
2327                    io::buf_add(buf, b"  " as *const u8, 2);
2328                }
2329            }
2330
2331            // Write start tag
2332            io::buf_ccat(buf, b'<');
2333            if !n.name.is_null() {
2334                io::buf_cat(buf, n.name);
2335            }
2336
2337            // Write attributes
2338            let mut attr = n.properties;
2339            while !attr.is_null() {
2340                let a = unsafe { &*attr };
2341                io::buf_ccat(buf, b' ');
2342                if !a.name.is_null() {
2343                    io::buf_cat(buf, a.name);
2344                }
2345
2346                // Write attribute value if present
2347                if !a.children.is_null() {
2348                    let child = unsafe { &*a.children };
2349                    if child.type_ == XML_TEXT_NODE as c_int && !child.content.is_null() {
2350                        io::buf_ccat(buf, b'=');
2351                        io::buf_ccat(buf, b'"');
2352                        html_serialize_attr_value(buf, child.content);
2353                        io::buf_ccat(buf, b'"');
2354                    }
2355                }
2356
2357                attr = a.next;
2358            }
2359
2360            if is_void {
2361                // Void element: just close the tag, no children
2362                io::buf_ccat(buf, b'>');
2363            } else if n.children.is_null() {
2364                // Empty non-void element: write full start/end tag
2365                io::buf_ccat(buf, b'>');
2366                // Write end tag
2367                if format != 0 {
2368                    io::buf_ccat(buf, b'\n');
2369                    for _ in 0..level {
2370                        io::buf_add(buf, b"  " as *const u8, 2);
2371                    }
2372                }
2373                io::buf_add(buf, b"</" as *const u8, 2);
2374                if !n.name.is_null() {
2375                    io::buf_cat(buf, n.name);
2376                }
2377                io::buf_ccat(buf, b'>');
2378            } else {
2379                // Element with children
2380                io::buf_ccat(buf, b'>');
2381
2382                // Check if this is a "text-only" element (single text child)
2383                let is_text_only = n.children == n.last
2384                    && !n.children.is_null()
2385                    && unsafe { (*(n.children)).type_ } == XML_TEXT_NODE as c_int;
2386
2387                if is_text_only {
2388                    // Serialize child inline
2389                    let mut child = n.children;
2390                    while !child.is_null() {
2391                        serialize_node(child, buf, format, level + 1);
2392                        child = unsafe { (*child).next };
2393                    }
2394                } else {
2395                    // Serialize children with indentation
2396                    let mut child = n.children;
2397                    while !child.is_null() {
2398                        serialize_node(child, buf, format, level + 1);
2399                        child = unsafe { (*child).next };
2400                    }
2401
2402                    // Indent before end tag (for non-text-only)
2403                    if format != 0 {
2404                        io::buf_ccat(buf, b'\n');
2405                        for _ in 0..level {
2406                            io::buf_add(buf, b"  " as *const u8, 2);
2407                        }
2408                    }
2409                }
2410
2411                // Write end tag
2412                io::buf_add(buf, b"</" as *const u8, 2);
2413                if !n.name.is_null() {
2414                    io::buf_cat(buf, n.name);
2415                }
2416                io::buf_ccat(buf, b'>');
2417            }
2418        }
2419        t if t == XML_TEXT_NODE as c_int => {
2420            html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
2421        }
2422        t if t == XML_CDATA_SECTION_NODE as c_int => {
2423            io::buf_add(buf, b"<![CDATA[" as *const u8, 9);
2424            html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
2425            io::buf_add(buf, b"]]>" as *const u8, 3);
2426        }
2427        t if t == XML_COMMENT_NODE as c_int => {
2428            if format != 0 && level > 0 {
2429                io::buf_ccat(buf, b'\n');
2430                for _ in 0..level {
2431                    io::buf_add(buf, b"  " as *const u8, 2);
2432                }
2433            }
2434            io::buf_add(buf, b"<!--" as *const u8, 4);
2435            if !n.content.is_null() {
2436                io::buf_cat(buf, n.content);
2437            }
2438            io::buf_add(buf, b"-->" as *const u8, 3);
2439        }
2440        t if t == XML_PI_NODE as c_int => {
2441            if format != 0 && level > 0 {
2442                io::buf_ccat(buf, b'\n');
2443                for _ in 0..level {
2444                    io::buf_add(buf, b"  " as *const u8, 2);
2445                }
2446            }
2447            io::buf_add(buf, b"<?" as *const u8, 2);
2448            if !n.name.is_null() {
2449                io::buf_cat(buf, n.name);
2450            }
2451            if !n.content.is_null() && unsafe { *n.content != 0 } {
2452                io::buf_ccat(buf, b' ');
2453                io::buf_cat(buf, n.content);
2454            }
2455            io::buf_add(buf, b"?>" as *const u8, 2);
2456        }
2457        t if t == XML_DOCUMENT_NODE as c_int || t == XML_HTML_DOCUMENT_NODE as c_int => {
2458            // No XML declaration for HTML documents
2459            // Serialize children
2460            let mut child = n.children;
2461            while !child.is_null() {
2462                serialize_node(child, buf, format, 0);
2463                child = unsafe { (*child).next };
2464            }
2465            if format != 0 {
2466                io::buf_ccat(buf, b'\n');
2467            }
2468        }
2469        _ => {
2470            if !n.content.is_null() {
2471                html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
2472            }
2473        }
2474    }
2475}
2476
2477/// Dump an HTML document to a buffer.
2478///
2479/// # Safety
2480///
2481/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
2482/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
2483pub(crate) unsafe fn doc_dump(buf: *mut _xmlBuffer, doc: *mut _xmlDoc) -> c_int {
2484    if buf.is_null() || doc.is_null() {
2485        return -1;
2486    }
2487
2488    let before = io::buf_length(buf);
2489    serialize_node(doc as *mut _xmlNode, buf, 0, 0);
2490    let after = io::buf_length(buf);
2491
2492    if after < 0 || before < 0 {
2493        return -1;
2494    }
2495    after - before
2496}
2497
2498// ═══════════════════════════════════════════════════════════════════════════════
2499// Tests
2500// ═══════════════════════════════════════════════════════════════════════════════
2501
2502#[cfg(test)]
2503mod tests {
2504    use super::*;
2505    use crate::abi::allocator::xmlFree;
2506    use crate::xml::io;
2507
2508    /// Helper: create a null-terminated xmlChar* from a byte slice.
2509    unsafe fn to_xmlstr(s: &[u8]) -> *mut xmlChar {
2510        bytes_to_xmlstr(s)
2511    }
2512
2513    /// Helper: serialize an HTML document to a String.
2514    unsafe fn html_doc_to_string(doc: *mut _xmlDoc) -> String {
2515        let buf = io::buf_create(-1);
2516        assert!(!buf.is_null());
2517        doc_dump(buf, doc);
2518        let content = io::buf_content(buf);
2519        let s = if !content.is_null() {
2520            let len = xml_strlen(content);
2521            let slice = slice::from_raw_parts(content, len as usize);
2522            String::from_utf8_lossy(slice).to_string()
2523        } else {
2524            String::new()
2525        };
2526        io::buf_free(buf);
2527        s
2528    }
2529
2530    // ═════════════════════════════════════════════════════════════════════════
2531    // Element Info Lookup
2532    // ═════════════════════════════════════════════════════════════════════════
2533
2534    #[test]
2535    fn test_html_tag_lookup() {
2536        // Known elements
2537        assert!(html_tag_lookup("html").is_some());
2538        assert!(html_tag_lookup("HTML").is_some()); // case-insensitive
2539        assert!(html_tag_lookup("p").is_some());
2540        assert!(html_tag_lookup("br").is_some());
2541        assert!(html_tag_lookup("div").is_some());
2542        assert!(html_tag_lookup("script").is_some());
2543
2544        // Unknown elements
2545        assert!(html_tag_lookup("custom").is_none());
2546        assert!(html_tag_lookup("my-element").is_none());
2547    }
2548
2549    #[test]
2550    fn test_tag_flags() {
2551        let br = html_tag_lookup("br").unwrap();
2552        assert!(br.flags & HTML_INLINE != 0);
2553        assert!(br.flags & HTML_EMPTY != 0);
2554
2555        let div = html_tag_lookup("div").unwrap();
2556        assert!(div.flags & HTML_BLOCK != 0);
2557        assert!(div.flags & HTML_VALID != 0);
2558
2559        let p = html_tag_lookup("p").unwrap();
2560        assert!(p.flags & HTML_NO_END != 0);
2561
2562        let meta = html_tag_lookup("meta").unwrap();
2563        assert!(meta.flags & HTML_HEAD != 0);
2564        assert!(meta.flags & HTML_EMPTY != 0);
2565    }
2566
2567    // ═════════════════════════════════════════════════════════════════════════
2568    // Entity Lookup
2569    // ═════════════════════════════════════════════════════════════════════════
2570
2571    #[test]
2572    fn test_html_entity_lookup() {
2573        assert_eq!(html_entity_lookup("amp"), Some("&"));
2574        assert_eq!(html_entity_lookup("lt"), Some("<"));
2575        assert_eq!(html_entity_lookup("gt"), Some(">"));
2576        assert_eq!(html_entity_lookup("quot"), Some("\""));
2577        assert_eq!(html_entity_lookup("nbsp"), Some("\u{00a0}"));
2578        assert_eq!(html_entity_lookup("copy"), Some("\u{00a9}"));
2579        assert!(html_entity_lookup("unknown_entity").is_none());
2580    }
2581
2582    // ═════════════════════════════════════════════════════════════════════════
2583    // Basic Parsing
2584    // ═════════════════════════════════════════════════════════════════════════
2585
2586    #[test]
2587    fn test_parse_basic_html() {
2588        unsafe {
2589            let html = b"<html><head><title>Test</title></head><body><p>Hello</p></body></html>\0";
2590            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2591            assert!(!doc.is_null());
2592
2593            let s = html_doc_to_string(doc);
2594            assert!(s.contains("<html>"));
2595            assert!(s.contains("<head>"));
2596            assert!(s.contains("<title>Test</title>"));
2597            assert!(s.contains("<body>"));
2598            assert!(s.contains("<p>Hello</p>"));
2599
2600            tree::free_doc(doc);
2601        }
2602    }
2603
2604    #[test]
2605    fn test_parse_empty_document() {
2606        unsafe {
2607            let html = b"\0";
2608            let doc = parse_memory(html.as_ptr() as *const c_char, 0);
2609            assert!(doc.is_null());
2610        }
2611    }
2612
2613    // ═════════════════════════════════════════════════════════════════════════
2614    // Implicit html/head/body Creation
2615    // ═════════════════════════════════════════════════════════════════════════
2616
2617    #[test]
2618    fn test_implicit_html_head_body() {
2619        unsafe {
2620            // Just a paragraph, no html/head/body
2621            let html = b"<p>Hello</p>\0";
2622            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2623            assert!(!doc.is_null());
2624
2625            let s = html_doc_to_string(doc);
2626            // Should have auto-created html
2627            assert!(s.contains("<html>"));
2628            // Should have auto-created body
2629            assert!(s.contains("<body>"));
2630            // Should have the paragraph
2631            assert!(s.contains("<p>Hello</p>"));
2632
2633            tree::free_doc(doc);
2634        }
2635    }
2636
2637    #[test]
2638    fn test_implicit_head_with_title() {
2639        unsafe {
2640            // Only a title, no html/head/body
2641            let html = b"<title>My Page</title>\0";
2642            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2643            assert!(!doc.is_null());
2644
2645            let s = html_doc_to_string(doc);
2646            assert!(s.contains("<html>"));
2647            assert!(s.contains("<head>"));
2648            assert!(s.contains("<title>My Page</title>"));
2649
2650            tree::free_doc(doc);
2651        }
2652    }
2653
2654    // ═════════════════════════════════════════════════════════════════════════
2655    // Auto-closing
2656    // ═════════════════════════════════════════════════════════════════════════
2657
2658    #[test]
2659    fn test_auto_close_p() {
2660        unsafe {
2661            // <p> should auto-close before another <p>
2662            let html = b"<p>First<p>Second</p>\0";
2663            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2664            assert!(!doc.is_null());
2665
2666            let s = html_doc_to_string(doc);
2667            // Both paragraphs should be siblings, not nested
2668            let first_pos = s.find("First");
2669            let second_pos = s.find("Second");
2670            assert!(first_pos.is_some());
2671            assert!(second_pos.is_some());
2672
2673            tree::free_doc(doc);
2674        }
2675    }
2676
2677    #[test]
2678    fn test_auto_close_heading() {
2679        unsafe {
2680            // h1 should auto-close before h2
2681            let html = b"<h1>Title</h1><h2>Subtitle</h2>\0";
2682            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2683            assert!(!doc.is_null());
2684
2685            let s = html_doc_to_string(doc);
2686            assert!(s.contains("<h1>Title</h1>"));
2687            assert!(s.contains("<h2>Subtitle</h2>"));
2688
2689            tree::free_doc(doc);
2690        }
2691    }
2692
2693    // ═════════════════════════════════════════════════════════════════════════
2694    // Void Elements
2695    // ═════════════════════════════════════════════════════════════════════════
2696
2697    #[test]
2698    fn test_void_elements() {
2699        unsafe {
2700            let html = b"<br><hr><img src=\"test.jpg\"><input type=\"text\">\0";
2701            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2702            assert!(!doc.is_null());
2703
2704            let s = html_doc_to_string(doc);
2705            assert!(s.contains("<br>"));
2706            assert!(s.contains("<hr>"));
2707            assert!(s.contains("<img"));
2708            assert!(s.contains("<input"));
2709
2710            // Void elements should not have closing tags
2711            assert!(!s.contains("</br>"));
2712            assert!(!s.contains("</hr>"));
2713            assert!(!s.contains("</img>"));
2714
2715            tree::free_doc(doc);
2716        }
2717    }
2718
2719    // ═════════════════════════════════════════════════════════════════════════
2720    // Unquoted and Minimized Attributes
2721    // ═════════════════════════════════════════════════════════════════════════
2722
2723    #[test]
2724    fn test_unquoted_attributes() {
2725        unsafe {
2726            let html = b"<div class=main id=content>Text</div>\0";
2727            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2728            assert!(!doc.is_null());
2729
2730            let s = html_doc_to_string(doc);
2731            assert!(s.contains("class=\"main\""));
2732            assert!(s.contains("id=\"content\""));
2733
2734            tree::free_doc(doc);
2735        }
2736    }
2737
2738    #[test]
2739    fn test_minimized_attributes() {
2740        unsafe {
2741            let html = b"<option selected disabled>Value</option>\0";
2742            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2743            assert!(!doc.is_null());
2744
2745            let s = html_doc_to_string(doc);
2746            // The minimized attributes should be preserved
2747            assert!(s.contains("selected"));
2748            assert!(s.contains("disabled"));
2749
2750            tree::free_doc(doc);
2751        }
2752    }
2753
2754    // ═════════════════════════════════════════════════════════════════════════
2755    // HTML Entities
2756    // ═════════════════════════════════════════════════════════════════════════
2757
2758    #[test]
2759    fn test_html_entities() {
2760        unsafe {
2761            let html = b"<p>&amp; &lt; &gt; &quot; &nbsp; &copy;</p>\0";
2762            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2763            assert!(!doc.is_null());
2764
2765            let s = html_doc_to_string(doc);
2766            // Entities are resolved in the tree; &amp; and &lt; get re-escaped during serialization
2767            // because & and < are special. &gt; becomes > (serialized as-is, > is safe in text).
2768            assert!(s.contains("&amp;")); // &amp; → & → &amp; (re-escaped)
2769            assert!(s.contains("&lt;")); // &lt; → < → &lt; (re-escaped)
2770            assert!(s.contains(">")); // &gt; → > (not escaped in text)
2771            assert!(s.contains("\u{00a0}")); // &nbsp; → non-breaking space
2772
2773            tree::free_doc(doc);
2774        }
2775    }
2776
2777    #[test]
2778    fn test_numeric_entities() {
2779        unsafe {
2780            // &#65; = 'A', &#x41; = 'A'
2781            let html = b"<p>&#65; &#x41;</p>\0";
2782            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2783            assert!(!doc.is_null());
2784
2785            let s = html_doc_to_string(doc);
2786            assert!(s.contains('A'));
2787
2788            tree::free_doc(doc);
2789        }
2790    }
2791
2792    // ═════════════════════════════════════════════════════════════════════════
2793    // Nested Elements
2794    // ═════════════════════════════════════════════════════════════════════════
2795
2796    #[test]
2797    fn test_nested_elements() {
2798        unsafe {
2799            let html = b"<div><ul><li>Item 1</li><li>Item 2</li></ul></div>\0";
2800            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2801            assert!(!doc.is_null());
2802
2803            let s = html_doc_to_string(doc);
2804            assert!(s.contains("<div>"));
2805            assert!(s.contains("<ul>"));
2806            assert!(s.contains("<li>Item 1</li>"));
2807            assert!(s.contains("<li>Item 2</li>"));
2808
2809            tree::free_doc(doc);
2810        }
2811    }
2812
2813    // ═════════════════════════════════════════════════════════════════════════
2814    // Malformed HTML Recovery
2815    // ═════════════════════════════════════════════════════════════════════════
2816
2817    #[test]
2818    fn test_missing_end_tags() {
2819        unsafe {
2820            // Missing closing tags
2821            let html = b"<p>Paragraph without closing<div>Another div\0";
2822            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2823            assert!(!doc.is_null());
2824
2825            let s = html_doc_to_string(doc);
2826            assert!(s.contains("Paragraph without closing"));
2827            assert!(s.contains("Another div"));
2828
2829            tree::free_doc(doc);
2830        }
2831    }
2832
2833    #[test]
2834    fn test_mismatched_case() {
2835        unsafe {
2836            let html = b"<HTML><HEAD><TITLE>Test</TITLE></HEAD><BODY><P>Hello</P></BODY></HTML>\0";
2837            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2838            assert!(!doc.is_null());
2839
2840            let s = html_doc_to_string(doc);
2841            // Tag names are case-preserved
2842            assert!(s.contains("<HTML>"));
2843            assert!(s.contains("<HEAD>"));
2844            assert!(s.contains("<BODY>"));
2845            assert!(s.contains("<P>Hello</P>"));
2846
2847            tree::free_doc(doc);
2848        }
2849    }
2850
2851    #[test]
2852    fn test_nested_malformed() {
2853        unsafe {
2854            // Deeply nested with missing end tags
2855            let html = b"<div><p><span><b>Deep text</div></p>\0";
2856            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2857            assert!(!doc.is_null());
2858
2859            let s = html_doc_to_string(doc);
2860            assert!(s.contains("Deep text"));
2861
2862            tree::free_doc(doc);
2863        }
2864    }
2865
2866    // ═════════════════════════════════════════════════════════════════════════
2867    // HTML Serialization Round-trip
2868    // ═════════════════════════════════════════════════════════════════════════
2869
2870    #[test]
2871    fn test_serialization_round_trip_simple() {
2872        unsafe {
2873            let original = b"<p>Hello World</p>\0";
2874            let doc = parse_memory(
2875                original.as_ptr() as *const c_char,
2876                (original.len() - 1) as c_int,
2877            );
2878            assert!(!doc.is_null());
2879
2880            let s = html_doc_to_string(doc);
2881            assert!(s.contains("Hello World"));
2882
2883            tree::free_doc(doc);
2884        }
2885    }
2886
2887    #[test]
2888    fn test_serialize_void_elements_no_self_close() {
2889        unsafe {
2890            let html = b"<br><hr><img src=\"test.png\">\0";
2891            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2892            assert!(!doc.is_null());
2893
2894            let s = html_doc_to_string(doc);
2895            // HTML serialization should NOT use self-closing tags
2896            assert!(!s.contains("<br/>"));
2897            assert!(!s.contains("<hr/>"));
2898
2899            tree::free_doc(doc);
2900        }
2901    }
2902
2903    // ═════════════════════════════════════════════════════════════════════════
2904    // Script and Style Handling
2905    // ═════════════════════════════════════════════════════════════════════════
2906
2907    #[test]
2908    fn test_script_content() {
2909        unsafe {
2910            // Use a simpler script content that doesn't contain '<' to avoid parser confusion
2911            let html = b"<script>var x = 1;</script>\0";
2912            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2913            assert!(!doc.is_null());
2914
2915            let s = html_doc_to_string(doc);
2916            assert!(s.contains("<script>"));
2917            // The raw text content should be preserved
2918            assert!(s.contains("var x = 1;"));
2919
2920            tree::free_doc(doc);
2921        }
2922    }
2923
2924    // ═════════════════════════════════════════════════════════════════════════
2925    // Comments and DOCTYPE
2926    // ═════════════════════════════════════════════════════════════════════════
2927
2928    #[test]
2929    fn test_html_comment() {
2930        unsafe {
2931            let html = b"<html><!-- This is a comment --><body><p>Text</p></body></html>\0";
2932            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2933            assert!(!doc.is_null());
2934
2935            let s = html_doc_to_string(doc);
2936            assert!(s.contains("<!-- This is a comment -->"));
2937
2938            tree::free_doc(doc);
2939        }
2940    }
2941
2942    // ═════════════════════════════════════════════════════════════════════════
2943    // new_doc / new_doc_no_dtd
2944    // ═════════════════════════════════════════════════════════════════════════
2945
2946    #[test]
2947    fn test_new_doc_creates_html_head_body() {
2948        unsafe {
2949            let doc = new_doc(ptr::null());
2950            assert!(!doc.is_null());
2951            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
2952
2953            let s = html_doc_to_string(doc);
2954            assert!(s.contains("<html>"));
2955            assert!(s.contains("<head>"));
2956            assert!(s.contains("<body>"));
2957
2958            tree::free_doc(doc);
2959        }
2960    }
2961
2962    #[test]
2963    fn test_new_doc_no_dtd() {
2964        unsafe {
2965            let doc = new_doc_no_dtd(ptr::null());
2966            assert!(!doc.is_null());
2967            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
2968
2969            // No implicit html/head/body
2970            let s = html_doc_to_string(doc);
2971            // Should be empty (no children)
2972            assert_eq!(s, "");
2973
2974            tree::free_doc(doc);
2975        }
2976    }
2977
2978    // ═════════════════════════════════════════════════════════════════════════
2979    // Entity Resolution in Text
2980    // ═════════════════════════════════════════════════════════════════════════
2981
2982    #[test]
2983    fn test_resolve_numeric_entity() {
2984        assert_eq!(resolve_numeric_entity("65", false), vec![b'A']);
2985        assert_eq!(resolve_numeric_entity("41", true), vec![b'A']);
2986        assert_eq!(resolve_numeric_entity("0", false), Vec::<u8>::new());
2987    }
2988
2989    #[test]
2990    fn test_resolve_entity_unknown() {
2991        let result = resolve_entity("unknown");
2992        assert_eq!(result, b"&unknown;");
2993    }
2994
2995    // ═════════════════════════════════════════════════════════════════════════
2996    // init/cleanup Parser
2997    // ═════════════════════════════════════════════════════════════════════════
2998
2999    #[test]
3000    fn test_init_cleanup_parser() {
3001        // Just ensure no crashes
3002        init_parser();
3003        cleanup_parser();
3004    }
3005
3006    // ═════════════════════════════════════════════════════════════════════════
3007    // Parser Context
3008    // ═════════════════════════════════════════════════════════════════════════
3009
3010    #[test]
3011    fn test_create_free_parser_ctxt() {
3012        unsafe {
3013            let ctxt =
3014                create_file_parser_ctxt(b"test.html\0" as *const u8 as *const c_char, ptr::null());
3015            assert!(!ctxt.is_null());
3016            free_parser_ctxt(ctxt);
3017        }
3018    }
3019
3020    // ═════════════════════════════════════════════════════════════════════════
3021    // Complex HTML Documents
3022    // ═════════════════════════════════════════════════════════════════════════
3023
3024    #[test]
3025    fn test_complex_html_document() {
3026        unsafe {
3027            let html = b"<!DOCTYPE html>
3028<html>
3029<head>
3030    <meta charset=\"utf-8\">
3031    <title>Test Page</title>
3032    <link rel=\"stylesheet\" href=\"style.css\">
3033</head>
3034<body>
3035    <div id=\"main\">
3036        <h1>Title</h1>
3037        <p>First paragraph with <a href=\"link.html\">a link</a>.</p>
3038        <p>Second paragraph.</p>
3039        <ul>
3040            <li>Item 1</li>
3041            <li>Item 2</li>
3042        </ul>
3043        <br>
3044        <hr>
3045        <img src=\"image.jpg\" alt=\"An image\">
3046    </div>
3047    <script>alert('hello');</script>
3048</body>
3049</html>\0";
3050            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3051            assert!(!doc.is_null());
3052
3053            let s = html_doc_to_string(doc);
3054            assert!(s.contains("<html>"));
3055            assert!(s.contains("<head>"));
3056            assert!(s.contains("<title>Test Page</title>"));
3057            assert!(s.contains("<body>"));
3058            assert!(s.contains("<h1>Title</h1>"));
3059            assert!(s.contains("a link"));
3060            assert!(s.contains("Second paragraph"));
3061            assert!(s.contains("<br>"));
3062            assert!(s.contains("<hr>"));
3063            assert!(s.contains("<img"));
3064            assert!(s.contains("<script>"));
3065
3066            tree::free_doc(doc);
3067        }
3068    }
3069
3070    // ═════════════════════════════════════════════════════════════════════════
3071    // parse_doc
3072    // ═════════════════════════════════════════════════════════════════════════
3073
3074    #[test]
3075    fn test_parse_doc() {
3076        unsafe {
3077            let html = b"<p>Hello from parse_doc</p>";
3078            let doc = parse_doc(html.as_ptr() as *const xmlChar, ptr::null());
3079            assert!(!doc.is_null());
3080
3081            let s = html_doc_to_string(doc);
3082            assert!(s.contains("Hello from parse_doc"));
3083
3084            tree::free_doc(doc);
3085        }
3086    }
3087
3088    // ═════════════════════════════════════════════════════════════════════════
3089    // Table elements auto-close
3090    // ═════════════════════════════════════════════════════════════════════════
3091
3092    #[test]
3093    fn test_table_element_auto_close() {
3094        unsafe {
3095            let html = b"<table><tr><td>Cell 1<td>Cell 2</td></tr></table>";
3096            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3097            assert!(!doc.is_null());
3098
3099            let s = html_doc_to_string(doc);
3100            assert!(s.contains("<td>Cell 1"));
3101            assert!(s.contains("<td>Cell 2"));
3102
3103            tree::free_doc(doc);
3104        }
3105    }
3106}