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 an open <tr>, <td> or <th> (the previous
944    // row); a new <td>/<th> only auto-closes an open <td>/<th> (the
945    // previous cell) and stays inside the open <tr> (upstream
946    // htmlAutoClose).
947    if tag_lower_str == "tr" {
948        let mut cur2 = current;
949        while !cur2.is_null() {
950            let ctype = unsafe { (*cur2).type_ };
951            if ctype == XML_ELEMENT_NODE as c_int {
952                if !unsafe { (*cur2).name.is_null() } {
953                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
954                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
955                    if name_str == "tr" || name_str == "td" || name_str == "th" {
956                        current = unsafe { (*cur2).parent };
957                        break;
958                    }
959                }
960            }
961            cur2 = unsafe { (*cur2).parent };
962        }
963    } else if tag_lower_str == "td" || tag_lower_str == "th" {
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 == "td" || name_str == "th" {
972                        current = unsafe { (*cur2).parent };
973                        break;
974                    }
975                }
976            }
977            cur2 = unsafe { (*cur2).parent };
978        }
979    }
980
981    // Rule 6: <thead>, <tbody>, <tfoot> auto-close each other
982    if matches!(tag_lower_str, "thead" | "tbody" | "tfoot") {
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 == "thead" || name_str == "tbody" || name_str == "tfoot" {
991                        current = unsafe { (*cur2).parent };
992                        break;
993                    }
994                }
995            }
996            cur2 = unsafe { (*cur2).parent };
997        }
998    }
999
1000    // Rule 7: <colgroup> auto-closes another <colgroup>
1001    if tag_lower_str == "colgroup" {
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 == "colgroup" {
1010                        current = unsafe { (*cur2).parent };
1011                        break;
1012                    }
1013                }
1014            }
1015            cur2 = unsafe { (*cur2).parent };
1016        }
1017    }
1018
1019    // Rule 8: <caption> auto-closes another <caption>
1020    if tag_lower_str == "caption" {
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 == "caption" {
1029                        current = unsafe { (*cur2).parent };
1030                        break;
1031                    }
1032                }
1033            }
1034            cur2 = unsafe { (*cur2).parent };
1035        }
1036    }
1037
1038    // Rule 9: <form> auto-closes another <form> (in libxml2 behavior)
1039    if tag_lower_str == "form" {
1040        let mut cur2 = current;
1041        while !cur2.is_null() {
1042            let ctype = unsafe { (*cur2).type_ };
1043            if ctype == XML_ELEMENT_NODE as c_int {
1044                if !unsafe { (*cur2).name.is_null() } {
1045                    let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1046                    let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1047                    if name_str.eq_ignore_ascii_case("form") {
1048                        current = unsafe { (*cur2).parent };
1049                        break;
1050                    }
1051                }
1052            }
1053            cur2 = unsafe { (*cur2).parent };
1054        }
1055    }
1056
1057    ctxt.current = current;
1058}
1059
1060// ═══════════════════════════════════════════════════════════════════════════════
1061// Implicit Element Creation
1062// ═══════════════════════════════════════════════════════════════════════════════
1063
1064/// Ensure the html element exists, creating it implicitly if needed.
1065unsafe fn ensure_html(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1066    if !ctxt.html.is_null() {
1067        return ctxt.html;
1068    }
1069
1070    let html_node = tree::new_node(ptr::null_mut(), b"html\0" as *const u8 as *const xmlChar);
1071    if html_node.is_null() {
1072        return ptr::null_mut();
1073    }
1074    unsafe {
1075        // Set HTML_IMPLIED flag concept - mark as auto-created
1076        // (We track this via a separate flag rather than modifying the node structure)
1077    }
1078    ctxt.html = html_node;
1079    ctxt.html_created = true;
1080
1081    // Add to document
1082    tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1083    ctxt.current = html_node;
1084
1085    html_node
1086}
1087
1088/// Ensure the head element exists, creating it implicitly if needed.
1089unsafe fn ensure_head(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1090    if !ctxt.head.is_null() {
1091        return ctxt.head;
1092    }
1093
1094    // Ensure html exists first
1095    ensure_html(ctxt);
1096
1097    let head_node = tree::new_node(ptr::null_mut(), b"head\0" as *const u8 as *const xmlChar);
1098    if head_node.is_null() {
1099        return ptr::null_mut();
1100    }
1101    ctxt.head = head_node;
1102    ctxt.head_created = true;
1103
1104    // Add as child of html
1105    tree::add_child(ctxt.html, head_node);
1106    ctxt.current = head_node;
1107    ctxt.in_head = true;
1108
1109    head_node
1110}
1111
1112/// Ensure the body element exists, creating it implicitly if needed.
1113unsafe fn ensure_body(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1114    if !ctxt.body.is_null() {
1115        return ctxt.body;
1116    }
1117
1118    // Ensure html exists first
1119    ensure_html(ctxt);
1120
1121    let body_node = tree::new_node(ptr::null_mut(), b"body\0" as *const u8 as *const xmlChar);
1122    if body_node.is_null() {
1123        return ptr::null_mut();
1124    }
1125    ctxt.body = body_node;
1126    ctxt.body_created = true;
1127
1128    // Add as child of html
1129    tree::add_child(ctxt.html, body_node);
1130    ctxt.current = body_node;
1131    ctxt.in_body = true;
1132
1133    body_node
1134}
1135
1136/// Transition from head to body when body content is encountered.
1137unsafe fn transition_to_body(ctxt: &mut HtmlParserCtxt) {
1138    if ctxt.in_head && !ctxt.seen_body_content {
1139        ctxt.seen_body_content = true;
1140        ctxt.in_head = false;
1141        ensure_body(ctxt);
1142    }
1143}
1144
1145// ═══════════════════════════════════════════════════════════════════════════════
1146// Tokenizer
1147// ═══════════════════════════════════════════════════════════════════════════════
1148
1149/// Result of parsing an attribute.
1150struct HtmlAttr {
1151    name: Vec<u8>,
1152    value: Vec<u8>,
1153    quoted: bool,
1154}
1155
1156/// Parse an attribute name.
1157fn parse_attr_name(ctxt: &mut HtmlParserCtxt) -> Vec<u8> {
1158    let mut name = Vec::new();
1159    while let Some(ch) = ctxt.peek() {
1160        if ch == b'='
1161            || ch == b'>'
1162            || ch == b'/'
1163            || ch == b' '
1164            || ch == b'\t'
1165            || ch == b'\n'
1166            || ch == b'\r'
1167        {
1168            break;
1169        }
1170        name.push(ch);
1171        ctxt.next();
1172    }
1173    name
1174}
1175
1176/// Parse an attribute value (may be quoted or unquoted).
1177fn parse_attr_value(ctxt: &mut HtmlParserCtxt) -> (Vec<u8>, bool) {
1178    ctxt.skip_whitespace();
1179
1180    let quote = match ctxt.peek() {
1181        Some(b'"') => {
1182            ctxt.next(); // consume opening quote
1183            b'"'
1184        }
1185        Some(b'\'') => {
1186            ctxt.next(); // consume opening quote
1187            b'\''
1188        }
1189        _ => {
1190            // Unquoted value
1191            let value = ctxt.read_while(|ch| {
1192                ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1193            });
1194            return (value, false);
1195        }
1196    };
1197
1198    // Quoted value
1199    let mut value = Vec::new();
1200    loop {
1201        match ctxt.next() {
1202            Some(ch) if ch == quote => break,
1203            Some(ch) => value.push(ch),
1204            None => break,
1205        }
1206    }
1207    (value, true)
1208}
1209
1210/// Parse attributes until we hit '>' or end of tag.
1211fn parse_attributes(ctxt: &mut HtmlParserCtxt) -> Vec<HtmlAttr> {
1212    let mut attrs = Vec::new();
1213
1214    loop {
1215        ctxt.skip_whitespace();
1216
1217        match ctxt.peek() {
1218            Some(b'>') | None => break,
1219            Some(b'/') => {
1220                // Could be self-closing tag like <br/>
1221                if ctxt.peek_at(1) == Some(b'>') {
1222                    break;
1223                }
1224                // Otherwise it's part of a minimized attribute or path
1225            }
1226            _ => {}
1227        }
1228
1229        let name = parse_attr_name(ctxt);
1230        if name.is_empty() {
1231            break;
1232        }
1233
1234        // Check for '='
1235        ctxt.skip_whitespace();
1236        if ctxt.peek() == Some(b'=') {
1237            ctxt.next(); // consume '='
1238            let (value, quoted) = parse_attr_value(ctxt);
1239            attrs.push(HtmlAttr {
1240                name,
1241                value,
1242                quoted,
1243            });
1244        } else {
1245            // Minimized attribute (e.g., <option selected>)
1246            attrs.push(HtmlAttr {
1247                name,
1248                value: Vec::new(),
1249                quoted: false,
1250            });
1251        }
1252    }
1253
1254    attrs
1255}
1256
1257// ═══════════════════════════════════════════════════════════════════════════════
1258// Tree Builder
1259// ═══════════════════════════════════════════════════════════════════════════════
1260
1261/// Process a resolved HTML entity reference and return the replacement bytes.
1262fn resolve_entity(name: &str) -> Vec<u8> {
1263    if let Some(replacement) = html_entity_lookup(name) {
1264        replacement.as_bytes().to_vec()
1265    } else {
1266        // Unknown entity: leave as-is (pass through as text)
1267        let mut result = Vec::new();
1268        result.push(b'&');
1269        result.extend_from_slice(name.as_bytes());
1270        result.push(b';');
1271        result
1272    }
1273}
1274
1275/// Handle a numeric character reference (decimal or hex).
1276fn resolve_numeric_entity(value: &str, is_hex: bool) -> Vec<u8> {
1277    let codepoint = if is_hex {
1278        u32::from_str_radix(value, 16).unwrap_or(0xFFFD)
1279    } else {
1280        u32::from_str_radix(value, 10).unwrap_or(0xFFFD)
1281    };
1282
1283    if codepoint == 0 {
1284        return Vec::new();
1285    }
1286
1287    // Convert codepoint to UTF-8
1288    match char::from_u32(codepoint) {
1289        Some(c) => {
1290            let mut buf = [0u8; 4];
1291            let s = c.encode_utf8(&mut buf);
1292            s.as_bytes().to_vec()
1293        }
1294        None => vec![0xEF, 0xBF, 0xBD], // replacement character
1295    }
1296}
1297
1298/// Parse an entity reference starting at current position (which points to '&').
1299/// Returns the replacement text and advances position.
1300fn parse_entity(ctxt: &mut HtmlParserCtxt) -> Vec<u8> {
1301    // We should be at '&'
1302    match ctxt.peek() {
1303        Some(b'&') => {
1304            ctxt.next(); // consume '&'
1305        }
1306        _ => return vec![b'&'],
1307    }
1308
1309    // Check for numeric entities
1310    if ctxt.peek() == Some(b'#') {
1311        ctxt.next(); // consume '#'
1312        let is_hex = ctxt.peek() == Some(b'x') || ctxt.peek() == Some(b'X');
1313        if is_hex {
1314            ctxt.next(); // consume 'x' or 'X'
1315        }
1316
1317        let digits = ctxt.read_while(|ch| {
1318            if is_hex {
1319                ch.is_ascii_hexdigit()
1320            } else {
1321                ch.is_ascii_digit()
1322            }
1323        });
1324
1325        let digits_str = core::str::from_utf8(&digits).unwrap_or("");
1326        if digits_str.is_empty() {
1327            let mut result = vec![b'&', b'#'];
1328            if is_hex {
1329                result.push(b'x');
1330            }
1331            return result;
1332        }
1333
1334        // Expect semicolon
1335        if ctxt.peek() == Some(b';') {
1336            ctxt.next();
1337        }
1338
1339        return resolve_numeric_entity(digits_str, is_hex);
1340    }
1341
1342    // Named entity
1343    let name = ctxt.read_while(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-');
1344    let name_str = core::str::from_utf8(&name).unwrap_or("");
1345
1346    // Expect semicolon
1347    if ctxt.peek() == Some(b';') {
1348        ctxt.next();
1349    }
1350
1351    resolve_entity(name_str)
1352}
1353
1354/// Handle text content in the tree builder.
1355unsafe fn handle_text(ctxt: &mut HtmlParserCtxt, text: &[u8]) {
1356    if text.is_empty() {
1357        return;
1358    }
1359
1360    // Determine current insertion point
1361    let parent = if ctxt.in_head {
1362        ctxt.head
1363    } else if ctxt.in_body || ctxt.body_created {
1364        ctxt.body
1365    } else if ctxt.html_created {
1366        ctxt.html
1367    } else {
1368        ctxt.doc as *mut _xmlNode
1369    };
1370
1371    let insertion_point = if ctxt.current.is_null() {
1372        parent
1373    } else {
1374        ctxt.current
1375    };
1376
1377    if insertion_point.is_null() {
1378        // Fall back to document
1379        let text_node = tree::new_text(ptr::null_mut());
1380        if !text_node.is_null() {
1381            // Create a null-terminated copy
1382            let content = bytes_to_xmlstr(text);
1383            if !content.is_null() {
1384                unsafe {
1385                    (*text_node).content = content;
1386                }
1387            }
1388            tree::add_child(ctxt.doc as *mut _xmlNode, text_node);
1389        }
1390        return;
1391    }
1392
1393    // UPSTREAM-PARITY: whitespace-only text at the document level (before or
1394    // after the root element) is discarded; non-whitespace stray text is
1395    // wrapped in a new `html` element (htmlParseCharData behavior), with
1396    // leading blank characters skipped.
1397    if insertion_point == ctxt.doc as *mut _xmlNode {
1398        if text.iter().all(|b| b.is_ascii_whitespace()) {
1399            return;
1400        }
1401        let content = trim_ascii_start(text);
1402        let html_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(b"html"));
1403        if !html_node.is_null() {
1404            let content_c = bytes_to_xmlstr(content);
1405            let text_node = tree::new_text(ptr::null_mut());
1406            if !text_node.is_null() {
1407                unsafe {
1408                    (*text_node).content = content_c;
1409                }
1410                tree::add_child(html_node, text_node);
1411            }
1412            tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1413            ctxt.html = html_node;
1414            ctxt.html_created = true;
1415            ctxt.current = html_node;
1416        }
1417        return;
1418    }
1419
1420    let text_node = tree::new_text(ptr::null_mut());
1421    if text_node.is_null() {
1422        return;
1423    }
1424
1425    // Set the content
1426    let content = bytes_to_xmlstr(text);
1427    if !content.is_null() {
1428        unsafe {
1429            (*text_node).content = content;
1430        }
1431    }
1432
1433    tree::add_child(insertion_point, text_node);
1434}
1435
1436/// Process a start tag in the tree builder.
1437unsafe fn handle_start_tag(ctxt: &mut HtmlParserCtxt, tag_name: &[u8], attrs: &[HtmlAttr]) {
1438    let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1439    let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1440
1441    let info = html_tag_lookup(tag_str);
1442
1443    // Determine tag category
1444    let is_head_tag = info.map_or(false, |i| i.flags & HTML_HEAD != 0);
1445    let is_body_tag = info.map_or(false, |i| i.flags & HTML_BODY != 0);
1446    let is_empty = info.map_or(false, |i| i.flags & HTML_EMPTY != 0);
1447    let is_block = info.map_or(false, |i| i.flags & HTML_BLOCK != 0);
1448
1449    // Handle special elements
1450    if tag_str == "html" {
1451        if !ctxt.html.is_null() && !ctxt.html_created {
1452            // Second <html> tag, skip it
1453            return;
1454        }
1455        // Create or use existing html
1456        if ctxt.html.is_null() {
1457            let html_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1458            if !html_node.is_null() {
1459                ctxt.html = html_node;
1460                ctxt.html_created = false; // parsed, not implied
1461                tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1462                ctxt.current = html_node;
1463            }
1464        } else {
1465            // html already auto-created, just set current
1466            ctxt.current = ctxt.html;
1467        }
1468        return;
1469    }
1470
1471    if tag_str == "head" {
1472        if !ctxt.head.is_null() && !ctxt.head_created {
1473            // Second <head> tag, skip it
1474            return;
1475        }
1476        // Ensure html exists
1477        ensure_html(ctxt);
1478
1479        if ctxt.head.is_null() {
1480            let head_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1481            if !head_node.is_null() {
1482                ctxt.head = head_node;
1483                ctxt.head_created = false;
1484                tree::add_child(ctxt.html, head_node);
1485                ctxt.current = head_node;
1486                ctxt.in_head = true;
1487            }
1488        } else {
1489            ctxt.current = ctxt.head;
1490            ctxt.in_head = true;
1491        }
1492        return;
1493    }
1494
1495    if tag_str == "body" {
1496        if !ctxt.body.is_null() && !ctxt.body_created {
1497            // Second <body> tag, skip it
1498            return;
1499        }
1500        // Ensure html exists
1501        ensure_html(ctxt);
1502
1503        if ctxt.body.is_null() {
1504            let body_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1505            if !body_node.is_null() {
1506                ctxt.body = body_node;
1507                ctxt.body_created = false;
1508                tree::add_child(ctxt.html, body_node);
1509                ctxt.current = body_node;
1510                ctxt.in_body = true;
1511                ctxt.in_head = false;
1512                ctxt.seen_body_content = true;
1513            }
1514        } else {
1515            ctxt.current = ctxt.body;
1516            ctxt.in_body = true;
1517            ctxt.in_head = false;
1518            ctxt.seen_body_content = true;
1519        }
1520        return;
1521    }
1522
1523    // For head-only elements (<title>, <meta>, <link>, <style>, <script>)
1524    if is_head_tag && !ctxt.seen_body_content {
1525        if ctxt.head.is_null() {
1526            ensure_head(ctxt);
1527        }
1528
1529        if is_empty {
1530            // Void element in head
1531            let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1532            if !node.is_null() {
1533                for attr in attrs {
1534                    let name_c = bytes_to_xmlstr(&attr.name);
1535                    let val_c = bytes_to_xmlstr(&attr.value);
1536                    if !name_c.is_null() {
1537                        tree::set_prop(node, name_c, val_c);
1538                        xmlFree(name_c as *mut c_void);
1539                        if !val_c.is_null() {
1540                            xmlFree(val_c as *mut c_void);
1541                        }
1542                    }
1543                }
1544                tree::add_child(ctxt.current, node);
1545            }
1546            return;
1547        }
1548
1549        let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1550        if !node.is_null() {
1551            for attr in attrs {
1552                let name_c = bytes_to_xmlstr(&attr.name);
1553                let val_c = bytes_to_xmlstr(&attr.value);
1554                if !name_c.is_null() {
1555                    tree::set_prop(node, name_c, val_c);
1556                    xmlFree(name_c as *mut c_void);
1557                    if !val_c.is_null() {
1558                        xmlFree(val_c as *mut c_void);
1559                    }
1560                }
1561            }
1562            tree::add_child(ctxt.current, node);
1563            ctxt.current = node;
1564        }
1565        return;
1566    }
1567
1568    // Body content - transition from head if needed
1569    if !is_head_tag || ctxt.seen_body_content {
1570        if !ctxt.seen_body_content {
1571            ctxt.seen_body_content = true;
1572            ctxt.in_head = false;
1573            if ctxt.body.is_null() {
1574                ensure_body(ctxt);
1575            } else {
1576                ctxt.current = ctxt.body;
1577                ctxt.in_body = true;
1578            }
1579        } else if ctxt.body.is_null() {
1580            ensure_body(ctxt);
1581        }
1582    }
1583
1584    // Auto-close elements as needed
1585    if !ctxt.current.is_null() {
1586        auto_close_element(ctxt, tag_str);
1587    }
1588
1589    if is_empty {
1590        // Void element: create node, add attributes, add as child (no children)
1591        let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1592        if !node.is_null() {
1593            for attr in attrs {
1594                let name_c = bytes_to_xmlstr(&attr.name);
1595                let val_c = bytes_to_xmlstr(&attr.value);
1596                if !name_c.is_null() {
1597                    tree::set_prop(node, name_c, val_c);
1598                    xmlFree(name_c as *mut c_void);
1599                    if !val_c.is_null() {
1600                        xmlFree(val_c as *mut c_void);
1601                    }
1602                }
1603            }
1604            let insertion_point = if ctxt.current.is_null() {
1605                ctxt.body
1606            } else {
1607                ctxt.current
1608            };
1609            if !insertion_point.is_null() {
1610                tree::add_child(insertion_point, node);
1611            }
1612        }
1613        return;
1614    }
1615
1616    // Regular element
1617    let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1618    if !node.is_null() {
1619        for attr in attrs {
1620            let name_c = bytes_to_xmlstr(&attr.name);
1621            let val_c = bytes_to_xmlstr(&attr.value);
1622            if !name_c.is_null() {
1623                tree::set_prop(node, name_c, val_c);
1624                xmlFree(name_c as *mut c_void);
1625                if !val_c.is_null() {
1626                    xmlFree(val_c as *mut c_void);
1627                }
1628            }
1629        }
1630
1631        let insertion_point = if ctxt.current.is_null() {
1632            if ctxt.in_body || ctxt.body_created {
1633                ctxt.body
1634            } else if ctxt.in_head || ctxt.head_created {
1635                ctxt.head
1636            } else if ctxt.html_created {
1637                ctxt.html
1638            } else {
1639                ctxt.doc as *mut _xmlNode
1640            }
1641        } else {
1642            ctxt.current
1643        };
1644
1645        if !insertion_point.is_null() {
1646            tree::add_child(insertion_point, node);
1647            // For non-void elements, this becomes the new insertion point
1648            ctxt.current = node;
1649        }
1650    }
1651}
1652
1653/// Process an end tag in the tree builder.
1654unsafe fn handle_end_tag(ctxt: &mut HtmlParserCtxt, tag_name: &[u8]) {
1655    let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1656    let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1657
1658    let info = html_tag_lookup(tag_str);
1659
1660    // For elements with no end tag (void elements or optional end tags),
1661    // just ignore the end tag.
1662    if info.map_or(false, |i| i.flags & HTML_EMPTY != 0) {
1663        return;
1664    }
1665
1666    if tag_str == "html" {
1667        ctxt.current = ctxt.doc as *mut _xmlNode;
1668        return;
1669    }
1670
1671    if tag_str == "head" {
1672        ctxt.in_head = false;
1673        ctxt.current = ctxt.html;
1674        return;
1675    }
1676
1677    if tag_str == "body" {
1678        ctxt.in_body = false;
1679        ctxt.current = ctxt.html;
1680        return;
1681    }
1682
1683    // Walk up the tree to find a matching open element
1684    let mut cur = ctxt.current;
1685    while !cur.is_null() {
1686        let ctype = unsafe { (*cur).type_ };
1687        if ctype == XML_ELEMENT_NODE as c_int {
1688            if !unsafe { (*cur).name.is_null() } {
1689                let name_bytes = unsafe { xmlstr_to_bytes((*cur).name) };
1690                if name_bytes.eq_ignore_ascii_case(tag_name) {
1691                    // Found the matching element - close by moving current to parent
1692                    ctxt.current = unsafe { (*cur).parent };
1693                    return;
1694                }
1695            }
1696        }
1697        cur = unsafe { (*cur).parent };
1698    }
1699
1700    // If no matching element found, ignore the end tag (tag-recovery behavior)
1701}
1702
1703// ═══════════════════════════════════════════════════════════════════════════════
1704// Main Parse Function
1705// ═══════════════════════════════════════════════════════════════════════════════
1706
1707/// Parse HTML from a buffer.
1708///
1709/// # Safety
1710///
1711/// - `buffer` must point to valid memory of at least `size` bytes.
1712unsafe fn html_parse_buffer(
1713    ctxt: &mut HtmlParserCtxt,
1714    buffer: *const c_char,
1715    size: c_int,
1716) -> *mut _xmlDoc {
1717    if buffer.is_null() || size <= 0 {
1718        return ptr::null_mut();
1719    }
1720
1721    // Create document with HTML_DOCUMENT_NODE type
1722    let doc = tree::new_doc(ptr::null());
1723    if doc.is_null() {
1724        return ptr::null_mut();
1725    }
1726    unsafe {
1727        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
1728        // UPSTREAM-PARITY: HTML documents carry no version (htmlNewDocNoDtD
1729        // leaves version NULL), so drop the XML default set by new_doc.
1730        if !(*doc).version.is_null() {
1731            crate::abi::allocator::xmlFree((*doc).version as *mut c_void);
1732        }
1733        (*doc).version = ptr::null_mut();
1734        (*doc).properties = XML_DOC_WELLFORMED as c_int;
1735        // UPSTREAM-PARITY: HTML documents default to standalone="yes"
1736        // (visible when serialized with the XML serializer, e.g. --xmlout).
1737        (*doc).standalone = 1;
1738    }
1739    // UPSTREAM-PARITY: htmlParseDocument creates a default DTD
1740    // (`<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"
1741    // "http://www.w3.org/TR/REC-html40/loose.dtd">) when the source does
1742    // not declare one; XML_SAVE_NO_DOCTYPE / HTML_PARSE_NODEFDTD suppresses
1743    // it (handled by the caller).
1744    let default_dtd = crate::xml::dtd::create_int_subset(
1745        doc,
1746        b"html\0" as *const u8 as *const xmlChar,
1747        b"-//W3C//DTD HTML 4.0 Transitional//EN\0" as *const u8 as *const xmlChar,
1748        b"http://www.w3.org/TR/REC-html40/loose.dtd\0" as *const u8 as *const xmlChar,
1749    );
1750    let _ = default_dtd;
1751    ctxt.doc = doc;
1752
1753    // Set up input
1754    ctxt.input = buffer as *mut u8;
1755    ctxt.input_len = size as usize;
1756    ctxt.input_pos = 0;
1757    ctxt.line = 1;
1758
1759    // Main parse loop
1760    loop {
1761        if ctxt.is_eof() {
1762            break;
1763        }
1764
1765        let ch = ctxt.peek().unwrap_or(0);
1766
1767        if ch == b'<' {
1768            ctxt.next(); // consume '<'
1769
1770            // Check for </ (end tag)
1771            if ctxt.peek() == Some(b'/') {
1772                ctxt.next(); // consume '/'
1773                let tag_name = ctxt.read_while(|ch| {
1774                    ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1775                });
1776
1777                // Consume until '>'
1778                while ctxt.peek() != Some(b'>') && !ctxt.is_eof() {
1779                    ctxt.next();
1780                }
1781                if ctxt.peek() == Some(b'>') {
1782                    ctxt.next(); // consume '>'
1783                }
1784
1785                if !tag_name.is_empty() {
1786                    handle_end_tag(ctxt, &tag_name);
1787                }
1788                continue;
1789            }
1790
1791            // Check for <!-- (comment)
1792            if ctxt.peek() == Some(b'!')
1793                && ctxt.peek_at(1) == Some(b'-')
1794                && ctxt.peek_at(2) == Some(b'-')
1795            {
1796                ctxt.next(); // consume '!'
1797                ctxt.next(); // consume '-'
1798                ctxt.next(); // consume '-'
1799
1800                // Read until -->
1801                let mut comment_content = Vec::new();
1802                loop {
1803                    if ctxt.peek() == Some(b'-')
1804                        && ctxt.peek_at(1) == Some(b'-')
1805                        && ctxt.peek_at(2) == Some(b'>')
1806                    {
1807                        ctxt.next(); // consume '-'
1808                        ctxt.next(); // consume '-'
1809                        ctxt.next(); // consume '>'
1810                        break;
1811                    }
1812                    match ctxt.next() {
1813                        Some(ch) => comment_content.push(ch),
1814                        None => break,
1815                    }
1816                }
1817
1818                // Create comment node
1819                if !comment_content.is_empty() {
1820                    let comment_node = tree::new_comment(bytes_to_xmlstr(&comment_content));
1821                    if !comment_node.is_null() {
1822                        let insertion_point = if !ctxt.current.is_null() {
1823                            ctxt.current
1824                        } else {
1825                            ctxt.doc as *mut _xmlNode
1826                        };
1827                        tree::add_child(insertion_point, comment_node);
1828                    }
1829                }
1830                continue;
1831            }
1832
1833            // Check for <!DOCTYPE
1834            if ctxt.peek() == Some(b'!') {
1835                ctxt.next(); // consume '!'
1836                let rest = ctxt.read_while(|ch| ch != b'>');
1837                if ctxt.peek() == Some(b'>') {
1838                    ctxt.next(); // consume '>'
1839                }
1840                // We don't create a DTD node from HTML DOCTYPE in this implementation
1841                // (matching basic libxml2 behavior where HTML doctype is mostly ignored)
1842                continue;
1843            }
1844
1845            // Check for <? (processing instruction)
1846            if ctxt.peek() == Some(b'?') {
1847                ctxt.next(); // consume '?'
1848                             // Read until we see ?>
1849                let mut pi_content = Vec::new();
1850                loop {
1851                    if ctxt.peek() == Some(b'?') && ctxt.peek_at(1) == Some(b'>') {
1852                        break;
1853                    }
1854                    match ctxt.next() {
1855                        Some(ch) => pi_content.push(ch),
1856                        None => break,
1857                    }
1858                }
1859                // Consume ?>
1860                if ctxt.peek() == Some(b'?') {
1861                    ctxt.next();
1862                }
1863                if ctxt.peek() == Some(b'>') {
1864                    ctxt.next();
1865                }
1866                // Create PI node
1867                if !pi_content.is_empty() {
1868                    // Split into target and value
1869                    let mut parts = pi_content.splitn(2, |b| *b == b' ');
1870                    let target = parts.next().unwrap_or(&pi_content);
1871                    let value = parts.next().unwrap_or(b"");
1872
1873                    let pi_node = tree::new_pi(bytes_to_xmlstr(target), bytes_to_xmlstr(value));
1874                    if !pi_node.is_null() {
1875                        let insertion_point = if !ctxt.current.is_null() {
1876                            ctxt.current
1877                        } else {
1878                            ctxt.doc as *mut _xmlNode
1879                        };
1880                        tree::add_child(insertion_point, pi_node);
1881                    }
1882                }
1883                continue;
1884            }
1885
1886            // Parse start tag
1887            let tag_name = ctxt.read_while(|ch| {
1888                ch != b'>' && ch != b'/' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1889            });
1890
1891            if tag_name.is_empty() {
1892                // Just a bare '<' with no tag name, treat as text
1893                handle_text(ctxt, &[b'<']);
1894                continue;
1895            }
1896
1897            // Parse attributes
1898            let attrs = parse_attributes(ctxt);
1899
1900            // Check for self-closing (/>) or just >
1901            if ctxt.peek() == Some(b'/') {
1902                ctxt.next(); // consume '/'
1903                if ctxt.peek() == Some(b'>') {
1904                    ctxt.next(); // consume '>'
1905                }
1906            } else if ctxt.peek() == Some(b'>') {
1907                ctxt.next(); // consume '>'
1908            }
1909
1910            // Check if this is a raw text element (script, style)
1911            let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1912            let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1913
1914            if tag_str == "script" || tag_str == "style" {
1915                // Handle raw text content
1916                // Create the element first
1917                let raw_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(&tag_name));
1918                if !raw_node.is_null() {
1919                    for attr in &attrs {
1920                        let name_c = bytes_to_xmlstr(&attr.name);
1921                        let val_c = bytes_to_xmlstr(&attr.value);
1922                        if !name_c.is_null() {
1923                            tree::set_prop(raw_node, name_c, val_c);
1924                            xmlFree(name_c as *mut c_void);
1925                            if !val_c.is_null() {
1926                                xmlFree(val_c as *mut c_void);
1927                            }
1928                        }
1929                    }
1930
1931                    let insertion_point = if ctxt.current.is_null() {
1932                        if ctxt.in_head {
1933                            ensure_head(ctxt);
1934                            ctxt.head
1935                        } else {
1936                            ensure_body(ctxt);
1937                            ctxt.body
1938                        }
1939                    } else {
1940                        ctxt.current
1941                    };
1942
1943                    if !insertion_point.is_null() {
1944                        tree::add_child(insertion_point, raw_node);
1945
1946                        // Read raw text until matching </script> or </style>
1947                        let end_tag = format!("</{}", tag_str);
1948                        let end_bytes = end_tag.as_bytes();
1949                        let mut raw_text = Vec::new();
1950                        let mut match_idx = 0;
1951
1952                        loop {
1953                            if ctxt.is_eof() {
1954                                break;
1955                            }
1956                            let ch = ctxt.peek().unwrap();
1957                            if ch.to_ascii_lowercase() == end_bytes[match_idx] {
1958                                match_idx += 1;
1959                                if match_idx == end_bytes.len() {
1960                                    // We found the start of </tag
1961                                    // Add the text before the end tag
1962                                    if !raw_text.is_empty() {
1963                                        let text_node = tree::new_text(bytes_to_xmlstr(&raw_text));
1964                                        if !text_node.is_null() {
1965                                            tree::add_child(raw_node, text_node);
1966                                        }
1967                                    }
1968                                    // Consume the rest of the end tag: "tag>"
1969                                    ctxt.next(); // consume the last char of end tag prefix
1970                                                 // Now read "tag>"
1971                                    let _suffix = ctxt.read_while(|ch| ch != b'>');
1972                                    if ctxt.peek() == Some(b'>') {
1973                                        ctxt.next();
1974                                    }
1975                                    // Close the element
1976                                    ctxt.current = unsafe { (*raw_node).parent };
1977                                    break;
1978                                }
1979                                // Store the potential match start
1980                                if match_idx == 1 {
1981                                    raw_text.push(ch);
1982                                }
1983                                ctxt.next();
1984                            } else {
1985                                // If we were building a match, flush all buffered chars
1986                                if match_idx > 0 {
1987                                    // We already pushed some chars, just continue
1988                                    match_idx = 0;
1989                                }
1990                                raw_text.push(ch);
1991                                ctxt.next();
1992                            }
1993                        }
1994
1995                        // If we never found the end tag, just add the text
1996                        if match_idx < end_bytes.len() && !raw_text.is_empty() {
1997                            let text_node = tree::new_text(bytes_to_xmlstr(&raw_text));
1998                            if !text_node.is_null() {
1999                                tree::add_child(raw_node, text_node);
2000                            }
2001                            ctxt.current = unsafe { (*raw_node).parent };
2002                        }
2003                    }
2004                }
2005                continue;
2006            }
2007
2008            // Regular start tag
2009            handle_start_tag(ctxt, &tag_name, &attrs);
2010        } else {
2011            // Text content - read until next '<' or entity '&'
2012            let mut text = Vec::new();
2013            loop {
2014                match ctxt.peek() {
2015                    Some(b'<') => break,
2016                    Some(b'&') => {
2017                        // Handle entity reference inline
2018                        let entity_text = parse_entity(ctxt);
2019                        text.extend_from_slice(&entity_text);
2020                    }
2021                    Some(ch) => {
2022                        text.push(ch);
2023                        ctxt.next();
2024                    }
2025                    None => break,
2026                }
2027            }
2028
2029            if !text.is_empty() {
2030                handle_text(ctxt, &text);
2031            }
2032        }
2033    }
2034
2035    // Post-processing: ensure html/head/body are created even for empty documents
2036    if ctxt.html.is_null() {
2037        ensure_html(ctxt);
2038    }
2039
2040    doc
2041}
2042
2043// ═══════════════════════════════════════════════════════════════════════════════
2044// Public API Functions
2045// ═══════════════════════════════════════════════════════════════════════════════
2046
2047/// Parse HTML from a file.
2048///
2049/// # UPSTREAM-PARITY
2050///
2051/// Equivalent to `htmlParseFile` in libxml2.
2052///
2053/// # Safety
2054///
2055/// - `filename` must be a valid null-terminated C string or NULL.
2056/// - `encoding` must be a valid null-terminated C string or NULL.
2057pub unsafe fn parse_file(filename: *const c_char, encoding: *const c_char) -> *mut _xmlDoc {
2058    if filename.is_null() {
2059        return ptr::null_mut();
2060    }
2061
2062    // Read the file into memory
2063    let filename_str = unsafe { std::ffi::CStr::from_ptr(filename) };
2064    let path = filename_str.to_str().unwrap_or("");
2065    let content = match std::fs::read(path) {
2066        Ok(data) => data,
2067        Err(_) => return ptr::null_mut(),
2068    };
2069
2070    let mut ctxt = HtmlParserCtxt::new();
2071    if !encoding.is_null() {
2072        let enc_cstr = unsafe { std::ffi::CStr::from_ptr(encoding) };
2073        ctxt.encoding = unsafe { c_strdup(encoding) };
2074    }
2075
2076    let doc = unsafe {
2077        html_parse_buffer(
2078            &mut ctxt,
2079            content.as_ptr() as *const c_char,
2080            content.len() as c_int,
2081        )
2082    };
2083
2084    if !doc.is_null() && !filename.is_null() {
2085        unsafe {
2086            (*doc).URL = c_strdup(filename) as *mut xmlChar;
2087        }
2088    }
2089
2090    doc
2091}
2092
2093/// Parse HTML from a memory buffer.
2094///
2095/// # UPSTREAM-PARITY
2096///
2097/// Equivalent to `htmlParseMemory` in libxml2.
2098///
2099/// # Safety
2100///
2101/// - `buffer` must point to valid memory of at least `size` bytes.
2102/// - `size` must be non-negative.
2103pub unsafe fn parse_memory(buffer: *const c_char, size: c_int) -> *mut _xmlDoc {
2104    if buffer.is_null() || size <= 0 {
2105        return ptr::null_mut();
2106    }
2107
2108    let mut ctxt = HtmlParserCtxt::new();
2109    unsafe { html_parse_buffer(&mut ctxt, buffer, size) }
2110}
2111
2112/// Parse HTML from a null-terminated string.
2113///
2114/// # UPSTREAM-PARITY
2115///
2116/// Equivalent to `htmlParseDoc` in libxml2.
2117///
2118/// # Safety
2119///
2120/// - `cur` must be a valid null-terminated xmlChar string or NULL.
2121/// - `encoding` must be a valid null-terminated C string or NULL.
2122pub(crate) unsafe fn parse_doc(cur: *const xmlChar, encoding: *const c_char) -> *mut _xmlDoc {
2123    if cur.is_null() {
2124        return ptr::null_mut();
2125    }
2126
2127    let len = unsafe { xml_strlen(cur) };
2128    let mut ctxt = HtmlParserCtxt::new();
2129    if !encoding.is_null() {
2130        ctxt.encoding = unsafe { c_strdup(encoding) };
2131    }
2132
2133    unsafe { html_parse_buffer(&mut ctxt, cur as *const c_char, len as c_int) }
2134}
2135
2136/// Create an HTML parser context for file parsing.
2137///
2138/// # UPSTREAM-PARITY
2139///
2140/// Equivalent to `htmlCreateFileParserCtxt` in libxml2.
2141///
2142/// # Safety
2143///
2144/// - `filename` must be a valid null-terminated C string or NULL.
2145/// - `encoding` must be a valid null-terminated C string or NULL.
2146pub(crate) unsafe fn create_file_parser_ctxt(
2147    filename: *const c_char,
2148    encoding: *const c_char,
2149) -> *mut c_void {
2150    if filename.is_null() {
2151        return ptr::null_mut();
2152    }
2153
2154    let ctxt = unsafe { xmlMallocZero(size_of::<HtmlParserCtxt>() as usize) };
2155    if ctxt.is_null() {
2156        return ptr::null_mut();
2157    }
2158
2159    let ctxt = ctxt as *mut HtmlParserCtxt;
2160    unsafe {
2161        ptr::write(ctxt, HtmlParserCtxt::new());
2162        if !encoding.is_null() {
2163            (*ctxt).encoding = c_strdup(encoding);
2164        }
2165    }
2166
2167    ctxt as *mut c_void
2168}
2169
2170/// Free an HTML parser context.
2171///
2172/// # UPSTREAM-PARITY
2173///
2174/// Equivalent to `htmlFreeParserCtxt` in libxml2.
2175///
2176/// # Safety
2177///
2178/// - `ctxt` must be a valid pointer returned by `create_file_parser_ctxt`, or NULL.
2179pub(crate) unsafe fn free_parser_ctxt(ctxt: *mut c_void) {
2180    if ctxt.is_null() {
2181        return;
2182    }
2183
2184    let ctxt = ctxt as *mut HtmlParserCtxt;
2185    unsafe {
2186        if !(*ctxt).filename.is_null() {
2187            xmlFree((*ctxt).filename as *mut c_void);
2188        }
2189        if !(*ctxt).encoding.is_null() {
2190            xmlFree((*ctxt).encoding as *mut c_void);
2191        }
2192        xmlFree(ctxt as *mut c_void);
2193    }
2194}
2195
2196/// Initialize the HTML parser module.
2197///
2198/// # UPSTREAM-PARITY
2199///
2200/// Equivalent to `htmlInitParser` in libxml2.
2201pub(crate) fn init_parser() {
2202    // Currently a no-op. In the future, may initialize HTML-specific
2203    // entity tables or other global state.
2204}
2205
2206/// Cleanup the HTML parser module.
2207///
2208/// # UPSTREAM-PARITY
2209///
2210/// Equivalent to `htmlCleanupParser` in libxml2.
2211pub(crate) fn cleanup_parser() {
2212    // Currently a no-op. In the future, may free HTML-specific
2213    // global state.
2214}
2215
2216/// Create a new HTML document.
2217///
2218/// # UPSTREAM-PARITY
2219///
2220/// Equivalent to `htmlNewDoc` in libxml2.
2221///
2222/// Creates a new document with type XML_HTML_DOCUMENT_NODE and
2223/// auto-creates html/head/body elements.
2224///
2225/// # Safety
2226///
2227/// - `version` must be a valid null-terminated xmlChar string or NULL.
2228pub(crate) unsafe fn new_doc(version: *const xmlChar) -> *mut _xmlDoc {
2229    let doc = tree::new_doc(version);
2230    if doc.is_null() {
2231        return ptr::null_mut();
2232    }
2233
2234    unsafe {
2235        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2236        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2237    }
2238
2239    // Create implicit html/head/body
2240    let mut ctxt = HtmlParserCtxt::new();
2241    ctxt.doc = doc;
2242
2243    unsafe {
2244        ensure_html(&mut ctxt);
2245        ensure_head(&mut ctxt);
2246        ensure_body(&mut ctxt);
2247    }
2248
2249    doc
2250}
2251
2252/// Create a new HTML document without DTD.
2253///
2254/// # UPSTREAM-PARITY
2255///
2256/// Equivalent to `htmlNewDocNoDtD` in libxml2.
2257///
2258/// Creates a new document with type XML_HTML_DOCUMENT_NODE.
2259/// Unlike `htmlNewDoc`, this does NOT auto-create html/head/body elements.
2260///
2261/// # Safety
2262///
2263/// - `version` must be a valid null-terminated xmlChar string or NULL.
2264pub(crate) unsafe fn new_doc_no_dtd(version: *const xmlChar) -> *mut _xmlDoc {
2265    let doc = tree::new_doc(version);
2266    if doc.is_null() {
2267        return ptr::null_mut();
2268    }
2269
2270    unsafe {
2271        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2272        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2273    }
2274
2275    doc
2276}
2277
2278// ═══════════════════════════════════════════════════════════════════════════════
2279// HTML Serializer
2280// ═══════════════════════════════════════════════════════════════════════════════
2281
2282/// HTML void elements that should not have closing tags.
2283const HTML_VOID_ELEMENTS: &[&str] = &[
2284    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
2285    "track", "wbr", "frame",
2286];
2287
2288/// Check if an element name is an HTML void element.
2289fn is_html_void(name: &str) -> bool {
2290    HTML_VOID_ELEMENTS
2291        .iter()
2292        .any(|v| v.eq_ignore_ascii_case(name))
2293}
2294
2295/// Check if an element has optional end tag in HTML.
2296fn has_optional_end_tag(name: &str) -> bool {
2297    matches!(
2298        name.to_ascii_lowercase().as_str(),
2299        "p" | "li" | "tr" | "td" | "th" | "dt" | "dd"
2300    )
2301}
2302
2303/// Serialize a text node for HTML output.
2304///
2305/// In HTML serialization, we escape `<` and `&` but NOT non-ASCII characters
2306/// as numeric entities (unlike XML serialization).
2307/// Write a double-quoted C string to the buffer.
2308unsafe fn html_write_quoted(buf: *mut _xmlBuffer, s: *const xmlChar) {
2309    if buf.is_null() || s.is_null() {
2310        return;
2311    }
2312    io::buf_ccat(buf, b'"');
2313    io::buf_cat(buf, s);
2314    io::buf_ccat(buf, b'"');
2315}
2316
2317/// Trim leading ASCII whitespace.
2318fn trim_ascii_start(s: &[u8]) -> &[u8] {
2319    let start = s
2320        .iter()
2321        .position(|&b| !b.is_ascii_whitespace())
2322        .unwrap_or(s.len());
2323    &s[start..]
2324}
2325
2326unsafe fn html_serialize_text(buf: *mut _xmlBuffer, content: *const xmlChar, len: c_int) {
2327    if buf.is_null() || content.is_null() || len <= 0 {
2328        return;
2329    }
2330
2331    let mut i: c_int = 0;
2332    while i < len {
2333        let ch = unsafe { *content.add(i as usize) };
2334
2335        match ch {
2336            b'<' => {
2337                io::buf_add(buf, b"&lt;" as *const u8, 4);
2338            }
2339            b'&' => {
2340                io::buf_add(buf, b"&amp;" as *const u8, 5);
2341            }
2342            _ => {
2343                io::buf_add(buf, &ch as *const u8, 1);
2344            }
2345        }
2346        i += 1;
2347    }
2348}
2349
2350/// Serialize an attribute value for HTML output.
2351///
2352/// In HTML, attribute values should be quoted and have `&`, `"` escaped.
2353unsafe fn html_serialize_attr_value(buf: *mut _xmlBuffer, value: *const xmlChar) {
2354    if buf.is_null() || value.is_null() {
2355        return;
2356    }
2357
2358    let len = unsafe { xml_strlen(value) as c_int };
2359    let mut i: c_int = 0;
2360    while i < len {
2361        let ch = unsafe { *value.add(i as usize) };
2362
2363        match ch {
2364            b'&' => {
2365                io::buf_add(buf, b"&amp;" as *const u8, 5);
2366            }
2367            b'"' => {
2368                io::buf_add(buf, b"&quot;" as *const u8, 6);
2369            }
2370            _ => {
2371                io::buf_add(buf, &ch as *const u8, 1);
2372            }
2373        }
2374        i += 1;
2375    }
2376}
2377
2378/// HTML-specific node serialization.
2379///
2380/// Walks the node tree and serializes to HTML format.
2381/// Differs from XML serialization in several ways:
2382/// - No XML declaration for HTML documents
2383/// - No self-closing tags for void elements
2384/// - Case-insensitive tag names preserved as-is
2385/// - No namespace declarations
2386/// - Elements with optional end tags may omit them
2387
2388/// Whether the head element already contains a <meta> element (so the
2389/// serializer does not insert a duplicate charset declaration).
2390///
2391/// # SAFETY
2392///
2393/// - `child` must be a valid node or NULL.
2394unsafe fn html_head_has_meta(child: *mut _xmlNode) -> bool {
2395    let mut c = child;
2396    while !c.is_null() {
2397        if (*c).type_ == XML_ELEMENT_NODE as c_int && !(*c).name.is_null() {
2398            let nm = xmlstr_to_bytes((*c).name);
2399            if nm.eq_ignore_ascii_case(b"meta") {
2400                return true;
2401            }
2402        }
2403        c = (*c).next;
2404    }
2405    false
2406}
2407
2408pub(crate) unsafe fn serialize_node(
2409    node: *mut _xmlNode,
2410    buf: *mut _xmlBuffer,
2411    format: c_int,
2412    level: c_int,
2413) {
2414    if node.is_null() || buf.is_null() {
2415        return;
2416    }
2417
2418    let n = unsafe { &*node };
2419
2420    match n.type_ {
2421        t if t == XML_ELEMENT_NODE as c_int => {
2422            let name = if n.name.is_null() {
2423                ""
2424            } else {
2425                unsafe { core::str::from_utf8(xmlstr_to_bytes(n.name)).unwrap_or("") }
2426            };
2427
2428            let is_void = is_html_void(name);
2429            // UPSTREAM-PARITY: htmlNodeDumpInternal only adds formatting
2430            // newlines for non-inline elements; p, pre and param are never
2431            // formatted (name[0] == 'p'), and unknown elements are treated
2432            // as inline (info == NULL).
2433            let info = html_tag_lookup(name);
2434            let is_inline = info.map_or(true, |i| i.flags & HTML_INLINE != 0);
2435            let no_format = is_inline || name.starts_with('p');
2436
2437            // Write start tag
2438            io::buf_ccat(buf, b'<');
2439            if !n.name.is_null() {
2440                io::buf_cat(buf, n.name);
2441            }
2442
2443            // Write attributes
2444            let mut attr = n.properties;
2445            while !attr.is_null() {
2446                let a = unsafe { &*attr };
2447                io::buf_ccat(buf, b' ');
2448                if !a.name.is_null() {
2449                    io::buf_cat(buf, a.name);
2450                }
2451
2452                // Write attribute value if present
2453                if !a.children.is_null() {
2454                    let child = unsafe { &*a.children };
2455                    if child.type_ == XML_TEXT_NODE as c_int && !child.content.is_null() {
2456                        io::buf_ccat(buf, b'=');
2457                        io::buf_ccat(buf, b'"');
2458                        html_serialize_attr_value(buf, child.content);
2459                        io::buf_ccat(buf, b'"');
2460                    }
2461                }
2462
2463                attr = a.next;
2464            }
2465
2466            // UPSTREAM-PARITY: htmlSetMetaEncoding (htmlsave.c) inserts
2467            // <meta charset="..."> as the first child of the <head> of the
2468            // root <html> element when no <meta> is present and the document
2469            // carries an encoding (htmlNodeDumpInternal only runs the meta
2470            // logic when `encoding != NULL`). The meta is synthetic here, so
2471            // it participates in the formatting rules like a real child.
2472            let mut meta_bytes: Option<Vec<u8>> = None;
2473            if name.eq_ignore_ascii_case("head")
2474                && level == 1
2475                && !n.doc.is_null()
2476                && !(*n.doc).encoding.is_null()
2477            {
2478                let parent_is_html = !n.parent.is_null() && !(*n.parent).name.is_null() && {
2479                    let pn = core::str::from_utf8(xmlstr_to_bytes((*n.parent).name)).unwrap_or("");
2480                    pn.eq_ignore_ascii_case("html")
2481                };
2482                if parent_is_html && !html_head_has_meta(n.children) {
2483                    meta_bytes = Some(xmlstr_to_bytes((*n.doc).encoding).to_vec());
2484                }
2485            }
2486            let meta_inserted = meta_bytes.is_some();
2487
2488            let has_children = !n.children.is_null();
2489            let first_child = if has_children {
2490                unsafe { (*n.children).type_ }
2491            } else {
2492                XML_TEXT_NODE as c_int
2493            };
2494            let first_is_text = first_child == XML_TEXT_NODE as c_int
2495                || first_child == XML_ENTITY_REF_NODE as c_int;
2496            // With a synthetic meta child, an empty head behaves as having
2497            // one element child.
2498            let multi_child = (has_children && n.children != n.last) || meta_inserted;
2499
2500            if is_void {
2501                // Void element: just close the tag, no children
2502                io::buf_ccat(buf, b'>');
2503                // UPSTREAM-PARITY (line 997): a newline follows a non-inline
2504                // element whose next sibling is not text; the caller (parent
2505                // loop) emits it, so nothing here.
2506            } else {
2507                // Element with children (or a head receiving a meta)
2508                io::buf_ccat(buf, b'>');
2509
2510                // Newline after the open tag (upstream line 969): a
2511                // non-inline element whose first child is not text and which
2512                // has more than one child (or receives a meta) starts its
2513                // content on a new line.
2514                if format != 0 && !no_format && !first_is_text && multi_child {
2515                    io::buf_ccat(buf, b'\n');
2516                }
2517
2518                if let Some(enc) = &meta_bytes {
2519                    io::buf_add(buf, b"<meta charset=\"" as *const u8, 15);
2520                    io::buf_add(buf, enc.as_ptr() as *const u8, enc.len() as c_int);
2521                    io::buf_add(buf, b"\">" as *const u8, 2);
2522                    // UPSTREAM-PARITY (line 983): a newline follows the
2523                    // inserted meta when the next real child is not text.
2524                    if format != 0 && has_children && !first_is_text && !name.starts_with('p') {
2525                        io::buf_ccat(buf, b'\n');
2526                    }
2527                }
2528
2529                // Serialize children inline (HTML formatting adds no
2530                // indentation; the per-element rules emit the newlines).
2531                let mut child = n.children;
2532                while !child.is_null() {
2533                    serialize_node(child, buf, format, level + 1);
2534                    // UPSTREAM-PARITY (line 997): a newline follows a
2535                    // non-inline element whose next sibling is not text,
2536                    // unless the parent is p/pre/param.
2537                    let next = unsafe { (*child).next };
2538                    if format != 0 && !next.is_null() && !name.starts_with('p') {
2539                        let nt = unsafe { (*next).type_ };
2540                        if nt != XML_TEXT_NODE as c_int && nt != XML_ENTITY_REF_NODE as c_int {
2541                            let cname = if (*child).name.is_null() {
2542                                ""
2543                            } else {
2544                                unsafe {
2545                                    core::str::from_utf8(xmlstr_to_bytes((*child).name))
2546                                        .unwrap_or("")
2547                                }
2548                            };
2549                            let cinfo = html_tag_lookup(cname);
2550                            let c_inline = cinfo.map_or(true, |i| i.flags & HTML_INLINE != 0);
2551                            if !c_inline {
2552                                io::buf_ccat(buf, b'\n');
2553                            }
2554                        }
2555                    }
2556                    child = next;
2557                }
2558
2559                // Newline before the end tag (upstream line 1085): a
2560                // non-inline element whose last child is not text and which
2561                // has more than one child (or is the head receiving a meta).
2562                let last_child = if has_children {
2563                    unsafe { (*n.last).type_ }
2564                } else {
2565                    XML_ELEMENT_NODE as c_int
2566                };
2567                let last_is_text = last_child == XML_TEXT_NODE as c_int
2568                    || last_child == XML_ENTITY_REF_NODE as c_int;
2569                if format != 0 && !no_format && !last_is_text && multi_child {
2570                    io::buf_ccat(buf, b'\n');
2571                }
2572
2573                // Write end tag
2574                io::buf_add(buf, b"</" as *const u8, 2);
2575                if !n.name.is_null() {
2576                    io::buf_cat(buf, n.name);
2577                }
2578                io::buf_ccat(buf, b'>');
2579            }
2580        }
2581        t if t == XML_TEXT_NODE as c_int => {
2582            html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
2583        }
2584        t if t == XML_CDATA_SECTION_NODE as c_int => {
2585            io::buf_add(buf, b"<![CDATA[" as *const u8, 9);
2586            html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
2587            io::buf_add(buf, b"]]>" as *const u8, 3);
2588        }
2589        t if t == XML_COMMENT_NODE as c_int => {
2590            if format != 0 && level > 0 {
2591                io::buf_ccat(buf, b'\n');
2592                for _ in 0..level {
2593                    io::buf_add(buf, b"  " as *const u8, 2);
2594                }
2595            }
2596            io::buf_add(buf, b"<!--" as *const u8, 4);
2597            if !n.content.is_null() {
2598                io::buf_cat(buf, n.content);
2599            }
2600            io::buf_add(buf, b"-->" as *const u8, 3);
2601        }
2602        t if t == XML_PI_NODE as c_int => {
2603            if format != 0 && level > 0 {
2604                io::buf_ccat(buf, b'\n');
2605                for _ in 0..level {
2606                    io::buf_add(buf, b"  " as *const u8, 2);
2607                }
2608            }
2609            io::buf_add(buf, b"<?" as *const u8, 2);
2610            if !n.name.is_null() {
2611                io::buf_cat(buf, n.name);
2612            }
2613            if !n.content.is_null() && unsafe { *n.content != 0 } {
2614                io::buf_ccat(buf, b' ');
2615                io::buf_cat(buf, n.content);
2616            }
2617            io::buf_add(buf, b"?>" as *const u8, 2);
2618        }
2619        t if t == XML_DOCUMENT_NODE as c_int || t == XML_HTML_DOCUMENT_NODE as c_int => {
2620            // UPSTREAM-PARITY: htmlDocContentDumpOutput writes the internal
2621            // subset's DOCTYPE before the tree children.
2622            let doc_ptr = n as *const _xmlNode as *mut _xmlDoc;
2623            let d = &*doc_ptr;
2624            if !d.intSubset.is_null() {
2625                let dtd = &*d.intSubset;
2626                io::buf_add(buf, b"<!DOCTYPE " as *const u8, 10);
2627                if !dtd.name.is_null() {
2628                    io::buf_cat(buf, dtd.name);
2629                }
2630                if !dtd.ExternalID.is_null() {
2631                    io::buf_add(buf, b" PUBLIC " as *const u8, 8);
2632                    html_write_quoted(buf, dtd.ExternalID);
2633                    io::buf_ccat(buf, b' ');
2634                    html_write_quoted(buf, dtd.SystemID);
2635                } else if !dtd.SystemID.is_null() {
2636                    io::buf_add(buf, b" SYSTEM " as *const u8, 8);
2637                    html_write_quoted(buf, dtd.SystemID);
2638                }
2639                io::buf_ccat(buf, b'>');
2640                io::buf_ccat(buf, b'\n');
2641            }
2642            // No XML declaration for HTML documents
2643            // Serialize children
2644            let mut child = n.children;
2645            while !child.is_null() {
2646                serialize_node(child, buf, format, 0);
2647                child = unsafe { (*child).next };
2648            }
2649            // UPSTREAM-PARITY: htmlDocContentDumpOutput terminates with a
2650            // newline.
2651            io::buf_ccat(buf, b'\n');
2652        }
2653        _ => {
2654            if !n.content.is_null() {
2655                html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
2656            }
2657        }
2658    }
2659}
2660
2661/// Dump an HTML document to a buffer.
2662///
2663/// # Safety
2664///
2665/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
2666/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
2667pub(crate) unsafe fn doc_dump(buf: *mut _xmlBuffer, doc: *mut _xmlDoc) -> c_int {
2668    if buf.is_null() || doc.is_null() {
2669        return -1;
2670    }
2671
2672    let before = io::buf_length(buf);
2673    serialize_node(doc as *mut _xmlNode, buf, 0, 0);
2674    let after = io::buf_length(buf);
2675
2676    if after < 0 || before < 0 {
2677        return -1;
2678    }
2679    after - before
2680}
2681
2682// ═══════════════════════════════════════════════════════════════════════════════
2683// Tests
2684// ═══════════════════════════════════════════════════════════════════════════════
2685
2686#[cfg(test)]
2687mod tests {
2688    use super::*;
2689    use crate::abi::allocator::xmlFree;
2690    use crate::xml::io;
2691
2692    /// Helper: create a null-terminated xmlChar* from a byte slice.
2693    unsafe fn to_xmlstr(s: &[u8]) -> *mut xmlChar {
2694        bytes_to_xmlstr(s)
2695    }
2696
2697    /// Helper: serialize an HTML document to a String.
2698    unsafe fn html_doc_to_string(doc: *mut _xmlDoc) -> String {
2699        let buf = io::buf_create(-1);
2700        assert!(!buf.is_null());
2701        doc_dump(buf, doc);
2702        let content = io::buf_content(buf);
2703        let s = if !content.is_null() {
2704            let len = xml_strlen(content);
2705            let slice = slice::from_raw_parts(content, len as usize);
2706            String::from_utf8_lossy(slice).to_string()
2707        } else {
2708            String::new()
2709        };
2710        io::buf_free(buf);
2711        s
2712    }
2713
2714    // ═════════════════════════════════════════════════════════════════════════
2715    // Element Info Lookup
2716    // ═════════════════════════════════════════════════════════════════════════
2717
2718    #[test]
2719    fn test_html_tag_lookup() {
2720        // Known elements
2721        assert!(html_tag_lookup("html").is_some());
2722        assert!(html_tag_lookup("HTML").is_some()); // case-insensitive
2723        assert!(html_tag_lookup("p").is_some());
2724        assert!(html_tag_lookup("br").is_some());
2725        assert!(html_tag_lookup("div").is_some());
2726        assert!(html_tag_lookup("script").is_some());
2727
2728        // Unknown elements
2729        assert!(html_tag_lookup("custom").is_none());
2730        assert!(html_tag_lookup("my-element").is_none());
2731    }
2732
2733    #[test]
2734    fn test_tag_flags() {
2735        let br = html_tag_lookup("br").unwrap();
2736        assert!(br.flags & HTML_INLINE != 0);
2737        assert!(br.flags & HTML_EMPTY != 0);
2738
2739        let div = html_tag_lookup("div").unwrap();
2740        assert!(div.flags & HTML_BLOCK != 0);
2741        assert!(div.flags & HTML_VALID != 0);
2742
2743        let p = html_tag_lookup("p").unwrap();
2744        assert!(p.flags & HTML_NO_END != 0);
2745
2746        let meta = html_tag_lookup("meta").unwrap();
2747        assert!(meta.flags & HTML_HEAD != 0);
2748        assert!(meta.flags & HTML_EMPTY != 0);
2749    }
2750
2751    // ═════════════════════════════════════════════════════════════════════════
2752    // Entity Lookup
2753    // ═════════════════════════════════════════════════════════════════════════
2754
2755    #[test]
2756    fn test_html_entity_lookup() {
2757        assert_eq!(html_entity_lookup("amp"), Some("&"));
2758        assert_eq!(html_entity_lookup("lt"), Some("<"));
2759        assert_eq!(html_entity_lookup("gt"), Some(">"));
2760        assert_eq!(html_entity_lookup("quot"), Some("\""));
2761        assert_eq!(html_entity_lookup("nbsp"), Some("\u{00a0}"));
2762        assert_eq!(html_entity_lookup("copy"), Some("\u{00a9}"));
2763        assert!(html_entity_lookup("unknown_entity").is_none());
2764    }
2765
2766    // ═════════════════════════════════════════════════════════════════════════
2767    // Basic Parsing
2768    // ═════════════════════════════════════════════════════════════════════════
2769
2770    #[test]
2771    fn test_parse_basic_html() {
2772        unsafe {
2773            let html = b"<html><head><title>Test</title></head><body><p>Hello</p></body></html>\0";
2774            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2775            assert!(!doc.is_null());
2776
2777            let s = html_doc_to_string(doc);
2778            assert!(s.contains("<html>"));
2779            assert!(s.contains("<head>"));
2780            assert!(s.contains("<title>Test</title>"));
2781            assert!(s.contains("<body>"));
2782            assert!(s.contains("<p>Hello</p>"));
2783
2784            tree::free_doc(doc);
2785        }
2786    }
2787
2788    #[test]
2789    fn test_parse_empty_document() {
2790        unsafe {
2791            let html = b"\0";
2792            let doc = parse_memory(html.as_ptr() as *const c_char, 0);
2793            assert!(doc.is_null());
2794        }
2795    }
2796
2797    // ═════════════════════════════════════════════════════════════════════════
2798    // Implicit html/head/body Creation
2799    // ═════════════════════════════════════════════════════════════════════════
2800
2801    #[test]
2802    fn test_implicit_html_head_body() {
2803        unsafe {
2804            // Just a paragraph, no html/head/body
2805            let html = b"<p>Hello</p>\0";
2806            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2807            assert!(!doc.is_null());
2808
2809            let s = html_doc_to_string(doc);
2810            // Should have auto-created html
2811            assert!(s.contains("<html>"));
2812            // Should have auto-created body
2813            assert!(s.contains("<body>"));
2814            // Should have the paragraph
2815            assert!(s.contains("<p>Hello</p>"));
2816
2817            tree::free_doc(doc);
2818        }
2819    }
2820
2821    #[test]
2822    fn test_implicit_head_with_title() {
2823        unsafe {
2824            // Only a title, no html/head/body
2825            let html = b"<title>My Page</title>\0";
2826            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2827            assert!(!doc.is_null());
2828
2829            let s = html_doc_to_string(doc);
2830            assert!(s.contains("<html>"));
2831            assert!(s.contains("<head>"));
2832            assert!(s.contains("<title>My Page</title>"));
2833
2834            tree::free_doc(doc);
2835        }
2836    }
2837
2838    // ═════════════════════════════════════════════════════════════════════════
2839    // Auto-closing
2840    // ═════════════════════════════════════════════════════════════════════════
2841
2842    #[test]
2843    fn test_auto_close_p() {
2844        unsafe {
2845            // <p> should auto-close before another <p>
2846            let html = b"<p>First<p>Second</p>\0";
2847            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2848            assert!(!doc.is_null());
2849
2850            let s = html_doc_to_string(doc);
2851            // Both paragraphs should be siblings, not nested
2852            let first_pos = s.find("First");
2853            let second_pos = s.find("Second");
2854            assert!(first_pos.is_some());
2855            assert!(second_pos.is_some());
2856
2857            tree::free_doc(doc);
2858        }
2859    }
2860
2861    #[test]
2862    fn test_auto_close_heading() {
2863        unsafe {
2864            // h1 should auto-close before h2
2865            let html = b"<h1>Title</h1><h2>Subtitle</h2>\0";
2866            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2867            assert!(!doc.is_null());
2868
2869            let s = html_doc_to_string(doc);
2870            assert!(s.contains("<h1>Title</h1>"));
2871            assert!(s.contains("<h2>Subtitle</h2>"));
2872
2873            tree::free_doc(doc);
2874        }
2875    }
2876
2877    // ═════════════════════════════════════════════════════════════════════════
2878    // Void Elements
2879    // ═════════════════════════════════════════════════════════════════════════
2880
2881    #[test]
2882    fn test_void_elements() {
2883        unsafe {
2884            let html = b"<br><hr><img src=\"test.jpg\"><input type=\"text\">\0";
2885            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2886            assert!(!doc.is_null());
2887
2888            let s = html_doc_to_string(doc);
2889            assert!(s.contains("<br>"));
2890            assert!(s.contains("<hr>"));
2891            assert!(s.contains("<img"));
2892            assert!(s.contains("<input"));
2893
2894            // Void elements should not have closing tags
2895            assert!(!s.contains("</br>"));
2896            assert!(!s.contains("</hr>"));
2897            assert!(!s.contains("</img>"));
2898
2899            tree::free_doc(doc);
2900        }
2901    }
2902
2903    // ═════════════════════════════════════════════════════════════════════════
2904    // Unquoted and Minimized Attributes
2905    // ═════════════════════════════════════════════════════════════════════════
2906
2907    #[test]
2908    fn test_unquoted_attributes() {
2909        unsafe {
2910            let html = b"<div class=main id=content>Text</div>\0";
2911            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2912            assert!(!doc.is_null());
2913
2914            let s = html_doc_to_string(doc);
2915            assert!(s.contains("class=\"main\""));
2916            assert!(s.contains("id=\"content\""));
2917
2918            tree::free_doc(doc);
2919        }
2920    }
2921
2922    #[test]
2923    fn test_minimized_attributes() {
2924        unsafe {
2925            let html = b"<option selected disabled>Value</option>\0";
2926            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2927            assert!(!doc.is_null());
2928
2929            let s = html_doc_to_string(doc);
2930            // The minimized attributes should be preserved
2931            assert!(s.contains("selected"));
2932            assert!(s.contains("disabled"));
2933
2934            tree::free_doc(doc);
2935        }
2936    }
2937
2938    // ═════════════════════════════════════════════════════════════════════════
2939    // HTML Entities
2940    // ═════════════════════════════════════════════════════════════════════════
2941
2942    #[test]
2943    fn test_html_entities() {
2944        unsafe {
2945            let html = b"<p>&amp; &lt; &gt; &quot; &nbsp; &copy;</p>\0";
2946            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2947            assert!(!doc.is_null());
2948
2949            let s = html_doc_to_string(doc);
2950            // Entities are resolved in the tree; &amp; and &lt; get re-escaped during serialization
2951            // because & and < are special. &gt; becomes > (serialized as-is, > is safe in text).
2952            assert!(s.contains("&amp;")); // &amp; → & → &amp; (re-escaped)
2953            assert!(s.contains("&lt;")); // &lt; → < → &lt; (re-escaped)
2954            assert!(s.contains(">")); // &gt; → > (not escaped in text)
2955            assert!(s.contains("\u{00a0}")); // &nbsp; → non-breaking space
2956
2957            tree::free_doc(doc);
2958        }
2959    }
2960
2961    #[test]
2962    fn test_numeric_entities() {
2963        unsafe {
2964            // &#65; = 'A', &#x41; = 'A'
2965            let html = b"<p>&#65; &#x41;</p>\0";
2966            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2967            assert!(!doc.is_null());
2968
2969            let s = html_doc_to_string(doc);
2970            assert!(s.contains('A'));
2971
2972            tree::free_doc(doc);
2973        }
2974    }
2975
2976    // ═════════════════════════════════════════════════════════════════════════
2977    // Nested Elements
2978    // ═════════════════════════════════════════════════════════════════════════
2979
2980    #[test]
2981    fn test_nested_elements() {
2982        unsafe {
2983            let html = b"<div><ul><li>Item 1</li><li>Item 2</li></ul></div>\0";
2984            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2985            assert!(!doc.is_null());
2986
2987            let s = html_doc_to_string(doc);
2988            assert!(s.contains("<div>"));
2989            assert!(s.contains("<ul>"));
2990            assert!(s.contains("<li>Item 1</li>"));
2991            assert!(s.contains("<li>Item 2</li>"));
2992
2993            tree::free_doc(doc);
2994        }
2995    }
2996
2997    // ═════════════════════════════════════════════════════════════════════════
2998    // Malformed HTML Recovery
2999    // ═════════════════════════════════════════════════════════════════════════
3000
3001    #[test]
3002    fn test_missing_end_tags() {
3003        unsafe {
3004            // Missing closing tags
3005            let html = b"<p>Paragraph without closing<div>Another div\0";
3006            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3007            assert!(!doc.is_null());
3008
3009            let s = html_doc_to_string(doc);
3010            assert!(s.contains("Paragraph without closing"));
3011            assert!(s.contains("Another div"));
3012
3013            tree::free_doc(doc);
3014        }
3015    }
3016
3017    #[test]
3018    fn test_mismatched_case() {
3019        unsafe {
3020            let html = b"<HTML><HEAD><TITLE>Test</TITLE></HEAD><BODY><P>Hello</P></BODY></HTML>\0";
3021            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3022            assert!(!doc.is_null());
3023
3024            let s = html_doc_to_string(doc);
3025            // Tag names are case-preserved
3026            assert!(s.contains("<HTML>"));
3027            assert!(s.contains("<HEAD>"));
3028            assert!(s.contains("<BODY>"));
3029            assert!(s.contains("<P>Hello</P>"));
3030
3031            tree::free_doc(doc);
3032        }
3033    }
3034
3035    #[test]
3036    fn test_nested_malformed() {
3037        unsafe {
3038            // Deeply nested with missing end tags
3039            let html = b"<div><p><span><b>Deep text</div></p>\0";
3040            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3041            assert!(!doc.is_null());
3042
3043            let s = html_doc_to_string(doc);
3044            assert!(s.contains("Deep text"));
3045
3046            tree::free_doc(doc);
3047        }
3048    }
3049
3050    // ═════════════════════════════════════════════════════════════════════════
3051    // HTML Serialization Round-trip
3052    // ═════════════════════════════════════════════════════════════════════════
3053
3054    #[test]
3055    fn test_serialization_round_trip_simple() {
3056        unsafe {
3057            let original = b"<p>Hello World</p>\0";
3058            let doc = parse_memory(
3059                original.as_ptr() as *const c_char,
3060                (original.len() - 1) as c_int,
3061            );
3062            assert!(!doc.is_null());
3063
3064            let s = html_doc_to_string(doc);
3065            assert!(s.contains("Hello World"));
3066
3067            tree::free_doc(doc);
3068        }
3069    }
3070
3071    #[test]
3072    fn test_serialize_void_elements_no_self_close() {
3073        unsafe {
3074            let html = b"<br><hr><img src=\"test.png\">\0";
3075            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3076            assert!(!doc.is_null());
3077
3078            let s = html_doc_to_string(doc);
3079            // HTML serialization should NOT use self-closing tags
3080            assert!(!s.contains("<br/>"));
3081            assert!(!s.contains("<hr/>"));
3082
3083            tree::free_doc(doc);
3084        }
3085    }
3086
3087    // ═════════════════════════════════════════════════════════════════════════
3088    // Script and Style Handling
3089    // ═════════════════════════════════════════════════════════════════════════
3090
3091    #[test]
3092    fn test_script_content() {
3093        unsafe {
3094            // Use a simpler script content that doesn't contain '<' to avoid parser confusion
3095            let html = b"<script>var x = 1;</script>\0";
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("<script>"));
3101            // The raw text content should be preserved
3102            assert!(s.contains("var x = 1;"));
3103
3104            tree::free_doc(doc);
3105        }
3106    }
3107
3108    // ═════════════════════════════════════════════════════════════════════════
3109    // Comments and DOCTYPE
3110    // ═════════════════════════════════════════════════════════════════════════
3111
3112    #[test]
3113    fn test_html_comment() {
3114        unsafe {
3115            let html = b"<html><!-- This is a comment --><body><p>Text</p></body></html>\0";
3116            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3117            assert!(!doc.is_null());
3118
3119            let s = html_doc_to_string(doc);
3120            assert!(s.contains("<!-- This is a comment -->"));
3121
3122            tree::free_doc(doc);
3123        }
3124    }
3125
3126    // ═════════════════════════════════════════════════════════════════════════
3127    // new_doc / new_doc_no_dtd
3128    // ═════════════════════════════════════════════════════════════════════════
3129
3130    #[test]
3131    fn test_new_doc_creates_html_head_body() {
3132        unsafe {
3133            let doc = new_doc(ptr::null());
3134            assert!(!doc.is_null());
3135            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
3136
3137            let s = html_doc_to_string(doc);
3138            assert!(s.contains("<html>"));
3139            assert!(s.contains("<head>"));
3140            assert!(s.contains("<body>"));
3141
3142            tree::free_doc(doc);
3143        }
3144    }
3145
3146    #[test]
3147    fn test_new_doc_no_dtd() {
3148        unsafe {
3149            let doc = new_doc_no_dtd(ptr::null());
3150            assert!(!doc.is_null());
3151            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
3152
3153            // No implicit html/head/body. UPSTREAM-PARITY: the HTML
3154            // serializer (htmlNodeDumpInternal) writes "\n" for a
3155            // document node with no children (HTMLtree.c:861-863).
3156            let s = html_doc_to_string(doc);
3157            assert_eq!(s, "\n");
3158
3159            tree::free_doc(doc);
3160        }
3161    }
3162
3163    // ═════════════════════════════════════════════════════════════════════════
3164    // Entity Resolution in Text
3165    // ═════════════════════════════════════════════════════════════════════════
3166
3167    #[test]
3168    fn test_resolve_numeric_entity() {
3169        assert_eq!(resolve_numeric_entity("65", false), vec![b'A']);
3170        assert_eq!(resolve_numeric_entity("41", true), vec![b'A']);
3171        assert_eq!(resolve_numeric_entity("0", false), Vec::<u8>::new());
3172    }
3173
3174    #[test]
3175    fn test_resolve_entity_unknown() {
3176        let result = resolve_entity("unknown");
3177        assert_eq!(result, b"&unknown;");
3178    }
3179
3180    // ═════════════════════════════════════════════════════════════════════════
3181    // init/cleanup Parser
3182    // ═════════════════════════════════════════════════════════════════════════
3183
3184    #[test]
3185    fn test_init_cleanup_parser() {
3186        // Just ensure no crashes
3187        init_parser();
3188        cleanup_parser();
3189    }
3190
3191    // ═════════════════════════════════════════════════════════════════════════
3192    // Parser Context
3193    // ═════════════════════════════════════════════════════════════════════════
3194
3195    #[test]
3196    fn test_create_free_parser_ctxt() {
3197        unsafe {
3198            let ctxt =
3199                create_file_parser_ctxt(b"test.html\0" as *const u8 as *const c_char, ptr::null());
3200            assert!(!ctxt.is_null());
3201            free_parser_ctxt(ctxt);
3202        }
3203    }
3204
3205    // ═════════════════════════════════════════════════════════════════════════
3206    // Complex HTML Documents
3207    // ═════════════════════════════════════════════════════════════════════════
3208
3209    #[test]
3210    fn test_complex_html_document() {
3211        unsafe {
3212            let html = b"<!DOCTYPE html>
3213<html>
3214<head>
3215    <meta charset=\"utf-8\">
3216    <title>Test Page</title>
3217    <link rel=\"stylesheet\" href=\"style.css\">
3218</head>
3219<body>
3220    <div id=\"main\">
3221        <h1>Title</h1>
3222        <p>First paragraph with <a href=\"link.html\">a link</a>.</p>
3223        <p>Second paragraph.</p>
3224        <ul>
3225            <li>Item 1</li>
3226            <li>Item 2</li>
3227        </ul>
3228        <br>
3229        <hr>
3230        <img src=\"image.jpg\" alt=\"An image\">
3231    </div>
3232    <script>alert('hello');</script>
3233</body>
3234</html>\0";
3235            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3236            assert!(!doc.is_null());
3237
3238            let s = html_doc_to_string(doc);
3239            assert!(s.contains("<html>"));
3240            assert!(s.contains("<head>"));
3241            assert!(s.contains("<title>Test Page</title>"));
3242            assert!(s.contains("<body>"));
3243            assert!(s.contains("<h1>Title</h1>"));
3244            assert!(s.contains("a link"));
3245            assert!(s.contains("Second paragraph"));
3246            assert!(s.contains("<br>"));
3247            assert!(s.contains("<hr>"));
3248            assert!(s.contains("<img"));
3249            assert!(s.contains("<script>"));
3250
3251            tree::free_doc(doc);
3252        }
3253    }
3254
3255    // ═════════════════════════════════════════════════════════════════════════
3256    // parse_doc
3257    // ═════════════════════════════════════════════════════════════════════════
3258
3259    #[test]
3260    fn test_parse_doc() {
3261        unsafe {
3262            let html = b"<p>Hello from parse_doc</p>";
3263            let doc = parse_doc(html.as_ptr() as *const xmlChar, ptr::null());
3264            assert!(!doc.is_null());
3265
3266            let s = html_doc_to_string(doc);
3267            assert!(s.contains("Hello from parse_doc"));
3268
3269            tree::free_doc(doc);
3270        }
3271    }
3272
3273    // ═════════════════════════════════════════════════════════════════════════
3274    // Table elements auto-close
3275    // ═════════════════════════════════════════════════════════════════════════
3276
3277    #[test]
3278    fn test_table_element_auto_close() {
3279        unsafe {
3280            let html = b"<table><tr><td>Cell 1<td>Cell 2</td></tr></table>";
3281            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3282            assert!(!doc.is_null());
3283
3284            let s = html_doc_to_string(doc);
3285            assert!(s.contains("<td>Cell 1"));
3286            assert!(s.contains("<td>Cell 2"));
3287
3288            tree::free_doc(doc);
3289        }
3290    }
3291}