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