Skip to main content

libxml_rs/xml/tree/
mod.rs

1//! XML tree construction and manipulation (§17, §18, §85 Phase 1).
2//!
3//! Complete tree construction/manipulation, namespaces, attributes,
4//! dictionaries, entity structures, document ownership, copying, linking,
5//! and freeing.
6//!
7//! # UPSTREAM-PARITY
8//!
9//! The libxml2 tree is an observable data structure. The pointer topology
10//! (parent, children, last, next, prev, doc, ns, properties, nsDef) is
11//! part of the compatibility contract and must be court-tested.
12//!
13//! Key invariants (matching upstream):
14//!
15//! - `node->doc` points to the owning document (or NULL if not owned)
16//! - `node->parent` points to the parent element (or NULL for root)
17//! - `node->children` points to the first child
18//! - `node->last` points to the last child
19//! - `node->next` / `node->prev` form a doubly-linked list of siblings
20//! - `node->properties` points to the first attribute (for elements)
21//! - `node->nsDef` points to the first namespace declaration (for elements)
22//! - `doc->children` points to the root element
23//! - `doc->doc` points to itself (self-reference)
24//!
25//! # Ownership model
26//!
27//! Documents own all their nodes. When a document is freed, all nodes
28//! are freed. Nodes can be moved between documents via unlinking and
29//! re-adding.
30//!
31//! # Phase 1 status
32//!
33//! Complete — all tree operations are implemented.
34//! Future phases may add more edge-case handling for historical quirks.
35//!
36//! # Upstream contract
37//!
38//! Mirrors upstream tree.c and buf.c (SRC-LIBXML2-2.15.0, oracle tree
39//! `oracle/historical/src/libxml2-2.15.0/`). The tree is an observable data
40//! structure: pointer topology (parent, children, last, next, prev, doc, ns,
41//! properties, nsDef) is part of the compatibility contract and must be
42//! court-tested. Parity target: the system libxml2 2.15.3 oracle.
43//!
44//! # Conceptual behavior
45//!
46//! Complete tree construction/manipulation: namespaces, attributes,
47//! dictionaries, entity structures, document ownership, copying, linking and
48//! freeing. Nodes are C-layout mirrors (tree.h); copy/link/free semantics
49//! follow xmlCopyNode / xmlAddChild / xmlFreeNodeList / xmlFreeDoc.
50//!
51//! # Ownership & safety invariants
52//!
53//! Documents own all their nodes; freeing the document frees the subtree.
54//! node->parent, node->doc, node->ns, node->next/prev are borrowed pointers —
55//! never freed by the reader. Allocator domain: xmlMalloc, freed with xmlFree
56//! (atlas/OWNERSHIP_ATLAS.md). SAFETY: the Rust mirrors enforce layout exactly
57//! (`#[repr(C)]`); `_xmlElement` is 104 bytes upstream and must stay that size
58//! (R-000139: a 56-byte mirror under-allocated every element declaration).
59//!
60//! # Historical quirks & epochs
61//!
62//! QUIRK-0002 / LORE-0006: namespace nodes have no parent — a long-standing
63//! divergence upstream was aware of since the c14n fix commit 044fc6b7
64//! (2002). E-004: entity-content text nodes became TEXT compact at 2.13.0
65//! (commit 8d04f0ee). The 11.1-N structural alignment (R-000164) pinned
66//! doc->children DTD placement, CDATA node names, standalone=-2 and the
67//! attribute hash (name,prefix,elem) key order.
68//!
69//! # Deliberate oddities
70//!
71//! Deliberate oddities preserved for parity: an xmlns= declaration with an
72//! empty value yields href pointing at an empty string (not NULL), parsed
73//! attributes keep atype=0, the DTD node joins doc->children before the first
74//! element, and xmlGetLineNo returns long with the upstream -1 walk for
75//! non-element nodes (all R-000164).
76//!
77//! # Proving courts
78//!
79//! OWNERSHIP and TREE-STRUCTURE court families; TREE-001 (27-block structural
80//! fingerprint of 20 corpus docs x 8 option variants, byte-identical), ASan
81//! full-suite runs, and `cargo test --lib` (counts generated into
82//! atlas/TEST_COUNTS.json by tools/evidence/test_counts.py). Receipts under
83//! courts/receipts/phase-11.
84//!
85//! # Tempting simplifications that would break parity
86//!
87//! A tempting simplification is a nicer Rust node type instead of the exact
88//! `_xmlNode` / `_xmlElement` mirrors — it would break the C ABI layout
89//! (R-000139 class) and every C consumer reading fields at upstream offsets.
90//! Do not auto-maintain parent pointers for namespace nodes (QUIRK-0002); do
91//! not drop the last/next/prev links — TREE-001 fingerprints them.
92//!
93//! # Safety
94//!
95//! - The unsafe entry points in this module accept raw pointers that must be
96//!   valid, correctly typed, and live for the duration of the call: `_xmlDoc`,
97//!   `_xmlNode`, `_xmlAttr`, `_xmlNs`, `_xmlDtd`, `_xmlBuffer`, and
98//!   NUL-terminated `xmlChar` strings. NULL is permitted only where an
99//!   individual function's contract explicitly allows it.
100//! - Tree links (`parent`, `children`, `last`, `next`, `prev`, `properties`,
101//!   `nsDef`) must form a consistent, live tree; documents own their node
102//!   subtrees, so callers must not free a node that still belongs to a live
103//!   document.
104
105use core::ffi::c_void;
106use core::ptr;
107use std::os::raw::{c_char, c_int, c_long, c_ulong};
108
109use crate::abi::allocator;
110use crate::abi::constants::*;
111use crate::abi::structs::*;
112use crate::abi::types::xmlCharEncoding::XML_CHAR_ENCODING_UTF8;
113use crate::abi::types::xmlDocProperties::XML_DOC_USERBUILT;
114use crate::abi::types::xmlElementType::*;
115use crate::abi::types::*;
116use crate::xml::io;
117
118// ═══════════════════════════════════════════════════════════════════════════════
119// String Helpers
120// ═══════════════════════════════════════════════════════════════════════════════
121
122/// Duplicate an xmlChar string using xmlMalloc.
123///
124/// # SAFETY
125///
126/// - `str` must be a valid null-terminated xmlChar* or NULL.
127unsafe fn dup_xml_str(str: *const xmlChar) -> *mut xmlChar {
128    if str.is_null() {
129        return ptr::null_mut();
130    }
131    let len = unsafe { crate::abi::exports_xml2::xmlStrlen(str) as usize };
132    if len == 0 {
133        // Return a pointer to a null byte
134        let buf = unsafe { allocator::xmlMallocImpl(1) as *mut xmlChar };
135        if !buf.is_null() {
136            unsafe { *buf = 0 };
137        }
138        return buf;
139    }
140    let buf = unsafe { allocator::xmlMallocImpl(len + 1) as *mut xmlChar };
141    if !buf.is_null() {
142        unsafe {
143            ptr::copy_nonoverlapping(str, buf, len + 1);
144        }
145    }
146    buf
147}
148
149/// Copy an xmlChar string into an already-allocated buffer, or return NULL.
150#[allow(dead_code)]
151unsafe fn copy_xml_str_content(dest: *mut xmlChar, src: *const xmlChar, max_len: usize) -> bool {
152    if src.is_null() || dest.is_null() || max_len == 0 {
153        return false;
154    }
155    let len = unsafe { crate::abi::exports_xml2::xmlStrlen(src) as usize };
156    if len >= max_len {
157        return false;
158    }
159    unsafe {
160        ptr::copy_nonoverlapping(src, dest, len);
161        *dest.add(len) = 0;
162    }
163    true
164}
165
166/// Get the length of a null-terminated xmlChar string.
167///
168/// # SAFETY
169///
170///
171/// - `str` must point to valid NUL-terminated
172///   strings (or NULL where the C contract allows) for the lifetime
173///   of the call.
174///
175/// The caller must not race this call with concurrent mutation of the
176/// same objects from other threads (per-object state is not internally
177/// synchronized). Violating any of the above is undefined behavior.
178///
179/// Exercised by the C-API differential courts
180/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
181/// courts; those pass byte-for-byte against the upstream oracle.
182pub const unsafe fn xml_strlen(str: *const xmlChar) -> c_int {
183    if str.is_null() {
184        return 0;
185    }
186    let mut len: c_int = 0;
187    while unsafe { *str.add(len as usize) != 0 } {
188        len += 1;
189    }
190    len
191}
192
193// ═══════════════════════════════════════════════════════════════════════════════
194// Document Operations
195// ═══════════════════════════════════════════════════════════════════════════════
196
197/// Create a new XML document.
198///
199/// # UPSTREAM-PARITY
200///
201/// ```c
202/// xmlDocPtr xmlNewDoc(const xmlChar *version);
203/// ```
204///
205/// Creates a new document with the given version string (or "1.0" if NULL).
206/// The document is initialized with:
207/// - type = XML_DOCUMENT_NODE
208/// - standalone = -1 (unknown)
209/// - doc->doc = self (self-reference)
210/// - properties = XML_DOC_WELLFORMED
211///
212/// # SAFETY
213///
214/// - `version` must be a valid null-terminated string or NULL.
215pub unsafe fn new_doc(version: *const xmlChar) -> *mut _xmlDoc {
216    // SAFETY: Allocate zero-initialized memory for the document.
217    let doc = allocator::xmlMallocZero(size_of::<_xmlDoc>() as usize) as *mut _xmlDoc;
218    if doc.is_null() {
219        return ptr::null_mut();
220    }
221
222    unsafe {
223        (*doc).type_ = XML_DOCUMENT_NODE as c_int;
224        (*doc).standalone = -1; // unknown
225        (*doc).doc = doc; // self-reference
226        (*doc).properties = XML_DOC_USERBUILT as c_int;
227        (*doc).compression = -1; // not initialized (upstream xmlNewDoc)
228        (*doc).charset = XML_CHAR_ENCODING_UTF8 as c_int;
229
230        // Set version
231        let ver = if version.is_null() {
232            XML_DEFAULT_VERSION.as_ptr() as *const xmlChar
233        } else {
234            version
235        };
236        (*doc).version = dup_xml_str(ver);
237    }
238
239    // UPSTREAM-PARITY (tree.c xmlNewDoc): the document node is registered.
240    crate::abi::data_globals::register_node_hook(doc as *mut _xmlNode);
241
242    doc
243}
244
245/// Free a document and all its contents.
246///
247/// # UPSTREAM-PARITY
248///
249/// ```c
250/// void xmlFreeDoc(xmlDocPtr doc);
251/// ```
252///
253/// Frees the document, its DTDs, and all nodes in the tree.
254///
255/// # SAFETY
256///
257/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
258pub unsafe fn free_doc(doc: *mut _xmlDoc) {
259    if doc.is_null() {
260        return;
261    }
262
263    // UPSTREAM-PARITY (tree.c xmlFreeDoc): the deregister hook fires before
264    // the document is torn down.
265    crate::abi::data_globals::deregister_node_hook(doc as *mut _xmlNode);
266
267    let d = unsafe { &mut *doc };
268
269    // UPSTREAM-PARITY (tree.c xmlFreeDoc): the ID/REF tables are freed
270    // BEFORE the tree walk — attribute frees must not need ID-table lookups
271    // during document teardown (xmlRemoveID on a NULL table is a no-op).
272    if !d.ids.is_null() {
273        crate::xml::validation::free_id_table(d.ids as *mut crate::xml::hash::HashTable);
274        d.ids = ptr::null_mut();
275    }
276
277    // UPSTREAM-PARITY (tree.c xmlFreeDoc): the subset DTD nodes are unlinked
278    // from the child list before the tree is freed (they may be part of
279    // doc->children after a parse).
280    let dict = d.dict;
281    let mut ext_subset = d.extSubset;
282    let int_subset = d.intSubset;
283    if !ext_subset.is_null() && ext_subset == int_subset {
284        ext_subset = ptr::null_mut();
285    }
286    if !ext_subset.is_null() {
287        unlink_node_internal(ext_subset as *mut _xmlNode, d as *mut _xmlDoc);
288        d.extSubset = ptr::null_mut();
289        free_dtd(ext_subset);
290    }
291    if !int_subset.is_null() {
292        unlink_node_internal(int_subset as *mut _xmlNode, d as *mut _xmlDoc);
293        d.intSubset = ptr::null_mut();
294        free_dtd(int_subset);
295    }
296
297    // Free the tree
298    if !d.children.is_null() {
299        free_node_list(d.children);
300    }
301
302    // Free oldNs list
303    if !d.oldNs.is_null() {
304        free_ns_list(d.oldNs);
305    }
306
307    // Free strings
308    if !d.version.is_null() {
309        allocator::xmlFreeImpl(d.version as *mut c_void);
310    }
311    if !d.encoding.is_null() {
312        allocator::xmlFreeImpl(d.encoding as *mut c_void);
313    }
314    if !d.URL.is_null() {
315        allocator::xmlFreeImpl(d.URL as *mut c_void);
316    }
317
318    // Free the document itself
319    allocator::xmlFreeImpl(doc as *mut c_void);
320
321    // UPSTREAM-PARITY: the document holds a reference on its dictionary.
322    if !dict.is_null() {
323        crate::abi::exports_xml2::xmlDictFree(dict);
324    }
325}
326
327/// Rebind the `ns` pointer of one element (and each of its attributes) to a
328/// namespace declaration owned by `new_doc`'s tree, declaring it on `top`
329/// when nothing in scope matches the prefix (upstream tree.c
330/// `xmlStaticCopyNode`: `xmlSearchNsSafe(ret, node->ns->prefix, &ns)` then
331/// "search it in the original tree and add it at the top of the new tree").
332///
333/// # Safety
334///
335/// - `new_doc`/`el`/`top` must be valid tree pointers in the same document.
336unsafe fn rebind_copied_ns(new_doc: *mut _xmlDoc, top: *mut _xmlNode, el: *mut _xmlNode) {
337    unsafe {
338        let bind = |n: *mut _xmlNode| {
339            let ns = (*n).ns;
340            if ns.is_null() {
341                return;
342            }
343            let prefix = (*ns).prefix;
344            let mut found = search_ns(new_doc, el, prefix);
345            if found.is_null() {
346                found = new_ns(top, (*ns).href, prefix);
347            }
348            if !found.is_null() {
349                (*n).ns = found;
350            }
351        };
352        if (*el).type_ == XML_ELEMENT_NODE as c_int {
353            bind(el);
354            let mut a = (*el).properties;
355            while !a.is_null() {
356                if !(*a).ns.is_null() && a as *mut _xmlNode != el {
357                    // Attributes resolve their prefix against the element's
358                    // in-scope declarations.
359                    let prefix = (*(*a).ns).prefix;
360                    let mut found = search_ns(new_doc, el, prefix);
361                    if found.is_null() {
362                        found = new_ns(top, (*(*a).ns).href, prefix);
363                    }
364                    if !found.is_null() {
365                        (*a).ns = found;
366                    }
367                }
368                a = (*a).next;
369            }
370        }
371    }
372}
373
374/// Rebind every element/attribute namespace in a freshly deep-copied
375/// document to declarations owned by the copy (upstream xmlStaticCopyNode
376/// semantics for `xmlCopyDoc`). The generic copy keeps the source pointers
377/// verbatim; without this pass the copied tree's namespace pointers dangle
378/// as soon as the source document is freed.
379///
380/// # Safety
381///
382/// - `new_doc` must be the fresh copy; its tree must be fully built.
383pub(crate) unsafe fn reconcile_copied_tree_ns(new_doc: *mut _xmlDoc) {
384    if new_doc.is_null() {
385        return;
386    }
387    unsafe {
388        // The top element is where new declarations are attached (upstream
389        // adds missing namespaces "at the top of the new tree").
390        let mut top: *mut _xmlNode = ptr::null_mut();
391        let mut c = (*new_doc).children;
392        while !c.is_null() {
393            if (*c).type_ == XML_ELEMENT_NODE as c_int {
394                top = c;
395                break;
396            }
397            c = (*c).next;
398        }
399        if top.is_null() {
400            return;
401        }
402        // Pre-order walk: parents are processed before children, so a
403        // declaration attached to the top element is found by descendants.
404        let mut stack: Vec<*mut _xmlNode> = vec![top];
405        while let Some(el) = stack.pop() {
406            rebind_copied_ns(new_doc, top, el);
407            // Push children in reverse so they pop in document order.
408            let mut kids: Vec<*mut _xmlNode> = Vec::new();
409            let mut ch = (*el).children;
410            while !ch.is_null() {
411                if (*ch).type_ == XML_ELEMENT_NODE as c_int {
412                    kids.push(ch);
413                }
414                ch = (*ch).next;
415            }
416            for k in kids.into_iter().rev() {
417                stack.push(k);
418            }
419        }
420    }
421}
422
423/// Unlink a node from its parent's child list without freeing it
424/// (upstream xmlUnlinkNodeInternal semantics; doc is used for ID/ref
425/// bookkeeping which the candidate does not maintain on unlink).
426unsafe fn unlink_node_internal(node: *mut _xmlNode, _doc: *mut _xmlDoc) {
427    if node.is_null() {
428        return;
429    }
430    let parent = (*node).parent;
431    if parent.is_null() {
432        return;
433    }
434    if (*node).prev.is_null() {
435        (*parent).children = (*node).next;
436    } else {
437        (*(*node).prev).next = (*node).next;
438    }
439    if (*node).next.is_null() {
440        (*parent).last = (*node).prev;
441    } else {
442        (*(*node).next).prev = (*node).prev;
443    }
444    (*node).next = ptr::null_mut();
445    (*node).prev = ptr::null_mut();
446    (*node).parent = ptr::null_mut();
447}
448
449/// Copy a document (deep copy by default).
450///
451/// # UPSTREAM-PARITY
452///
453/// ```c
454/// xmlDocPtr xmlCopyDoc(xmlDocPtr doc, int recursive);
455/// ```
456///
457/// If `recursive` is 1, the entire tree is copied.
458/// If `recursive` is 0, only the document structure is copied (no children).
459///
460/// # SAFETY
461///
462/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
463pub unsafe fn copy_doc(doc: *const _xmlDoc, recursive: c_int) -> *mut _xmlDoc {
464    if doc.is_null() {
465        return ptr::null_mut();
466    }
467
468    let d = unsafe { &*doc };
469
470    let new_doc = new_doc(d.version);
471    if new_doc.is_null() {
472        return ptr::null_mut();
473    }
474
475    unsafe {
476        (*new_doc).type_ = d.type_;
477        (*new_doc).standalone = d.standalone;
478        (*new_doc).encoding = dup_xml_str(d.encoding);
479        (*new_doc).URL = dup_xml_str(d.URL);
480        (*new_doc).charset = d.charset;
481        (*new_doc).properties = d.properties;
482
483        // UPSTREAM-PARITY (tree.c xmlCopyDoc + xmlStaticCopyNodeList): the
484        // internal subset is copied FIRST (a plain DTD copy) and the SAME
485        // node is then linked into the children list at the DTD child's
486        // position — one shared DTD node serves both `intSubset` and the
487        // DocumentType child (the copy is NOT duplicated). The generic node
488        // copy must never be applied to a DTD child (its ExternalID/SystemID
489        // live at offsets the node copy leaves untouched — the corrupt-clone
490        // SEGV in DOMNode_isEqualNode / clone-of-doctype docs).
491        if recursive != 0 {
492            if !d.intSubset.is_null() {
493                let dtd_copy = crate::xml::dtd::copy_dtd(d.intSubset);
494                if !dtd_copy.is_null() {
495                    (*new_doc).intSubset = dtd_copy;
496                }
497            }
498        }
499
500        if recursive != 0 && !d.children.is_null() {
501            // DTD-aware children walk (upstream xmlStaticCopyNodeList): a DTD
502            // child contributes the intSubset copy (created above, or a fresh
503            // DTD copy when the source child is not the recorded subset) at
504            // its original position; every other child is copied generically.
505            let mut head: *mut _xmlNode = ptr::null_mut();
506            let mut tail: *mut _xmlNode = ptr::null_mut();
507            let mut cur = d.children;
508            while !cur.is_null() {
509                let ct = unsafe { (*cur).type_ };
510                let copy: *mut _xmlNode = if ct == XML_DTD_NODE as c_int {
511                    unsafe {
512                        if (*new_doc).intSubset.is_null() {
513                            let dc = crate::xml::dtd::copy_dtd(
514                                cur as *const crate::abi::structs::_xmlDtd,
515                            );
516                            if dc.is_null() {
517                                break;
518                            }
519                            (*new_doc).intSubset = dc;
520                            dc as *mut _xmlNode
521                        } else {
522                            (*new_doc).intSubset as *mut _xmlNode
523                        }
524                    }
525                } else {
526                    copy_node(cur, recursive)
527                };
528                if copy.is_null() {
529                    break;
530                }
531                unsafe {
532                    if tail.is_null() {
533                        head = copy;
534                    } else {
535                        (*tail).next = copy;
536                        (*copy).prev = tail;
537                    }
538                    tail = copy;
539                }
540                cur = unsafe { (*cur).next };
541            }
542            (*new_doc).children = head;
543            (*new_doc).last = tail;
544            if !head.is_null() {
545                // UPSTREAM-PARITY (tree.c xmlCopyDoc): every copied top-level
546                // child keeps the new DOCUMENT node as its parent
547                // (`xmlStaticCopyNodeList(doc->children, ret,
548                // (xmlNodePtr)ret)`). The pre-fix NULL parent made PHP treat a
549                // cloned document's root element as ownerless: its proxy
550                // teardown (php_libxml_node_free_resource, `parent == NULL`
551                // branch) freed the whole subtree while the cloned doc still
552                // referenced it, so the doc teardown double-freed the root
553                // (Phase 14.3 Bug-3 — DOMDocument clone + navigation).
554                let mut child = head;
555                while !child.is_null() {
556                    (*child).parent = new_doc as *mut _xmlNode;
557                    (*child).doc = new_doc;
558                    child = (*child).next;
559                }
560                propagate_doc(head, new_doc);
561                // UPSTREAM-PARITY (tree.c xmlStaticCopyNode): after a
562                // CROSS-document deep copy every element/attribute namespace
563                // pointer must reference namespace declarations owned by the
564                // COPY (the generic copy keeps the original pointers
565                // verbatim, which dangle once the source document is freed).
566                // Resolve each against the copied tree and declare missing
567                // namespaces on the top element — xmlCopyNode (same-doc)
568                // keeps verbatim pointers, only xmlCopyDoc/xmlDocCopyNode
569                // reconcile.
570                reconcile_copied_tree_ns(new_doc);
571            }
572        }
573    }
574
575    new_doc
576}
577
578/// Set the root element of a document.
579///
580/// # UPSTREAM-PARITY
581///
582/// ```c
583/// xmlNodePtr xmlDocSetRootElement(xmlDocPtr doc, xmlNodePtr root);
584/// ```
585///
586/// If the document already has a root element, the old root is returned.
587/// The new root is added as a child of the document.
588///
589/// # SAFETY
590///
591/// - `doc` must be a valid pointer to an _xmlDoc.
592/// - `root` must be a valid pointer to an _xmlNode, or NULL.
593pub unsafe fn doc_set_root_element(doc: *mut _xmlDoc, root: *mut _xmlNode) -> *mut _xmlNode {
594    if doc.is_null() || root.is_null() || unsafe { (*root).type_ } == XML_NAMESPACE_DECL as c_int {
595        return ptr::null_mut();
596    }
597
598    let old_root = doc_get_root_element(doc);
599    if old_root == root {
600        return old_root;
601    }
602
603    unsafe {
604        // UPSTREAM-PARITY (tree.c xmlDocSetRootElement): unlink the node
605        // from its current tree, move doc pointers, and set the parent to
606        // the DOCUMENT NODE itself. A NULL parent here is observable: lxml
607        // walks `parent` to decide whether a node is still in a document
608        // (proxy.pxi getDeallocationTop) and would free an orphaned-looking
609        // root directly, after which free_doc walks the doc children and
610        // frees it again — a double free.
611        if !(*root).parent.is_null() {
612            unlink_node(root);
613        }
614        if (*root).doc != doc {
615            propagate_doc(root, doc);
616        }
617        (*root).parent = doc as *mut _xmlNode;
618        (*root).doc = doc;
619
620        if old_root.is_null() {
621            // No previous root element: append after the existing doc-level
622            // nodes (PIs/comments may precede the root).
623            if (*doc).children.is_null() {
624                (*doc).children = root;
625                (*doc).last = root;
626                (*root).prev = ptr::null_mut();
627                (*root).next = ptr::null_mut();
628            } else {
629                add_sibling((*doc).last, root);
630            }
631        } else {
632            // Replace the old root in position (upstream xmlReplaceNode).
633            if (*old_root).prev.is_null() {
634                (*doc).children = root;
635                (*root).prev = ptr::null_mut();
636            } else {
637                (*(*old_root).prev).next = root;
638                (*root).prev = (*old_root).prev;
639            }
640            if (*old_root).next.is_null() {
641                (*doc).last = root;
642                (*root).next = ptr::null_mut();
643            } else {
644                (*(*old_root).next).prev = root;
645                (*root).next = (*old_root).next;
646            }
647            (*old_root).parent = ptr::null_mut();
648            (*old_root).prev = ptr::null_mut();
649            (*old_root).next = ptr::null_mut();
650        }
651    }
652
653    old_root
654}
655
656/// Get the root element of a document.
657///
658/// # UPSTREAM-PARITY
659///
660/// ```c
661/// xmlNodePtr xmlDocGetRootElement(xmlDocPtr doc);
662/// ```
663///
664/// Returns the root element, or NULL if the document has no root element.
665/// Skips non-element nodes (like PIs, comments) at the document level.
666///
667/// # Safety
668///
669/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL; when non-NULL it
670///   is dereferenced and its `children` chain is walked.
671pub fn doc_get_root_element(doc: *mut _xmlDoc) -> *mut _xmlNode {
672    if doc.is_null() {
673        return ptr::null_mut();
674    }
675
676    let d = unsafe { &*doc };
677    let mut cur = d.children;
678
679    while !cur.is_null() {
680        let node = unsafe { &*cur };
681        if node.type_ == XML_ELEMENT_NODE as c_int {
682            return cur;
683        }
684        cur = node.next;
685    }
686
687    ptr::null_mut()
688}
689
690/// Get the line number of a node.
691///
692/// # UPSTREAM-PARITY
693///
694/// ```c
695/// long xmlGetLineNo(xmlNodePtr node);
696/// ```
697///
698/// Returns the line number, or 0 if not available.
699///
700/// # Safety
701///
702/// - `node` must be NULL or a valid pointer to an `_xmlNode`; it is forwarded
703///   to `get_line_no_internal`, which walks the tree links.
704pub fn get_line_no(node: *const _xmlNode) -> c_long {
705    unsafe { get_line_no_internal(node, 0) }
706}
707
708/// UPSTREAM-PARITY (tree.c xmlGetLineNoInternal): element/text/comment/PI
709/// nodes report their stored line; other node types (DTD nodes, entity
710/// references, declarations, ...) walk to the nearest previous or ancestor
711/// element-ish node and return -1 when none exists.
712///
713/// # Safety
714///
715/// - `node` must be NULL or a valid pointer to an `_xmlNode` in a consistent
716///   tree: the `children`, `next`, `prev`, and `parent` links it follows must
717///   themselves point to valid `_xmlNode` structs.
718/// - `depth` bounds the recursion; callers start at 0.
719unsafe fn get_line_no_internal(node: *const _xmlNode, depth: c_int) -> c_long {
720    if depth >= 5 {
721        return -1;
722    }
723    if node.is_null() {
724        return -1;
725    }
726    let n = unsafe { &*node };
727    if n.type_ == XML_ELEMENT_NODE as c_int
728        || n.type_ == XML_TEXT_NODE as c_int
729        || n.type_ == XML_COMMENT_NODE as c_int
730        || n.type_ == XML_PI_NODE as c_int
731    {
732        if n.line == 65535 {
733            // UPSTREAM-PARITY (tree.c xmlGetLineNoInternal): text nodes whose
734            // real line exceeded USHRT_MAX at parse time store the line in
735            // psvi (XML_PARSE_BIG_LINES / the PHP html5 lexbor bridge does
736            // XML_INT_TO_PTR(line)); read it back (XML_PTR_TO_INT).
737            if n.type_ == XML_TEXT_NODE as c_int && !n.psvi.is_null() {
738                return n.psvi as usize as c_long;
739            }
740            if n.type_ == XML_ELEMENT_NODE as c_int && !n.children.is_null() {
741                let r = unsafe { get_line_no_internal(n.children, depth + 1) };
742                if r != -1 {
743                    return r;
744                }
745            }
746            if !n.next.is_null() {
747                let r = unsafe { get_line_no_internal(n.next, depth + 1) };
748                if r != -1 {
749                    return r;
750                }
751            }
752            if !n.prev.is_null() {
753                let r = unsafe { get_line_no_internal(n.prev, depth + 1) };
754                if r != -1 {
755                    return r;
756                }
757            }
758        }
759        n.line as c_long
760    } else if !n.prev.is_null()
761        && (unsafe { (*n.prev).type_ } == XML_ELEMENT_NODE as c_int
762            || unsafe { (*n.prev).type_ } == XML_TEXT_NODE as c_int
763            || unsafe { (*n.prev).type_ } == XML_COMMENT_NODE as c_int
764            || unsafe { (*n.prev).type_ } == XML_PI_NODE as c_int)
765    {
766        unsafe { get_line_no_internal(n.prev, depth + 1) }
767    } else if !n.parent.is_null() && unsafe { (*n.parent).type_ } == XML_ELEMENT_NODE as c_int {
768        unsafe { get_line_no_internal(n.parent, depth + 1) }
769    } else {
770        -1
771    }
772}
773
774/// Get the content of a node, recursively concatenating child text.
775///
776/// # UPSTREAM-PARITY
777///
778/// ```c
779/// xmlChar *xmlNodeGetContent(const xmlNode *cur);
780/// ```
781///
782/// Oracle behavior (tree.c `xmlNodeGetContent`):
783/// - For text/CDATA nodes: returns the content directly.
784/// - For element nodes: recursively concatenates the string values of
785///   children (text and CDATA; entity references are expanded via their
786///   content when available).
787/// - For attribute nodes: returns the attribute value (first child).
788/// - For comments/PIs: returns the content field.
789/// - For documents: returns content of the root element.
790/// - Returns NULL on error, empty string for empty nodes.
791///
792/// Returns a newly allocated string; caller frees with `xmlFree`.
793///
794/// # SAFETY
795///
796/// - `node` must be valid pointers (or NULL
797///   where the upstream C contract allows), obtained from the
798///   matching constructor/owner and not yet freed; the callee may
799///   take or keep ownership exactly as the C API specifies.
800///
801/// The caller must not race this call with concurrent mutation of the
802/// same objects from other threads (per-object state is not internally
803/// synchronized). Violating any of the above is undefined behavior.
804///
805/// Exercised by the C-API differential courts
806/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
807/// courts; those pass byte-for-byte against the upstream oracle.
808pub unsafe fn node_get_content(node: *mut _xmlNode) -> *mut xmlChar {
809    if node.is_null() {
810        return ptr::null_mut();
811    }
812    let typ = (*node).type_;
813    let mut result: Vec<u8> = Vec::new();
814    match typ {
815        t if t == XML_TEXT_NODE as c_int
816            || t == XML_CDATA_SECTION_NODE as c_int
817            || t == XML_COMMENT_NODE as c_int
818            || t == XML_PI_NODE as c_int =>
819        {
820            if !(*node).content.is_null() {
821                let len = crate::abi::exports_xml2::xmlStrlen((*node).content);
822                result
823                    .extend_from_slice(core::slice::from_raw_parts((*node).content, len as usize));
824            } else if t == XML_TEXT_NODE as c_int && !(*node).children.is_null() {
825                // Non-compact text node (entity merge): content lives in the
826                // child text nodes.
827                let mut child = (*node).children;
828                while !child.is_null() {
829                    if !(*child).content.is_null() {
830                        let len = crate::abi::exports_xml2::xmlStrlen((*child).content);
831                        result.extend_from_slice(core::slice::from_raw_parts(
832                            (*child).content,
833                            len as usize,
834                        ));
835                    }
836                    child = (*child).next;
837                }
838            }
839        }
840        t if t == XML_ATTRIBUTE_NODE as c_int => {
841            // Attribute: the value is the concatenation of ALL children (text
842            // runs plus expanded entity references) — upstream
843            // xmlNodeGetContent(attr) walks the children via
844            // xmlBufGetChildContent. A single text child is the common case
845            // (php_libxml_attr_value fast path); values containing references
846            // keep entity-REF children (`<root a="x&ent;x"/>` reads "xfoox"
847            // while serialization round-trips `&ent;`).
848            let mut child = (*node).children;
849            while !child.is_null() {
850                let ctype = (*child).type_;
851                if ctype == XML_TEXT_NODE as c_int || ctype == XML_CDATA_SECTION_NODE as c_int {
852                    if !(*child).content.is_null() {
853                        let len = crate::abi::exports_xml2::xmlStrlen((*child).content);
854                        result.extend_from_slice(core::slice::from_raw_parts(
855                            (*child).content,
856                            len as usize,
857                        ));
858                    }
859                } else if ctype == XML_ENTITY_REF_NODE as c_int
860                    || ctype == XML_ELEMENT_NODE as c_int
861                {
862                    let sub = node_get_content(child);
863                    if !sub.is_null() {
864                        let len = crate::abi::exports_xml2::xmlStrlen(sub);
865                        result.extend_from_slice(core::slice::from_raw_parts(sub, len as usize));
866                        allocator::xmlFreeImpl(sub as *mut c_void);
867                    }
868                }
869                child = (*child).next;
870            }
871        }
872        t if t == XML_ENTITY_REF_NODE as c_int => {
873            // Entity reference: expand via the declaration its `children`
874            // points at (xmlNewReference / the parser bind the entity decl
875            // there), falling back to the document/predefined lookup.
876            //
877            // UPSTREAM-PARITY (tree.c xmlBufGetEntityRefContent): a
878            // PREDEFINED entity contributes its `content`; any OTHER entity
879            // contributes its CHILD content (the parsed replacement tree).
880            // An internal entity declaration created from `<!ENTITY test
881            // "...">` carries only `content` and NO child tree, so a
882            // reference to it reads as "" — php delayed_freeing/
883            // entity_reference expects exactly this (and
884            // `new DOMEntityReference("amp")` reads "&").
885            let mut ent = if (*node).children.is_null() {
886                ptr::null_mut()
887            } else {
888                (*node).children as *mut _xmlEntity
889            };
890            if ent.is_null() {
891                let name = (*node).name;
892                if !name.is_null() {
893                    ent = crate::xml::tree::get_doc_entity((*node).doc, name);
894                }
895            }
896            if !ent.is_null() {
897                let is_predef = (*ent).etype
898                    == crate::abi::types::xmlEntityType::XML_INTERNAL_PREDEFINED_ENTITY as c_int;
899                if is_predef {
900                    if !(*ent).content.is_null() {
901                        let len = crate::abi::exports_xml2::xmlStrlen((*ent).content);
902                        result.extend_from_slice(core::slice::from_raw_parts(
903                            (*ent).content,
904                            len as usize,
905                        ));
906                    }
907                } else if (*ent).flags & (1 << 3) == 0 {
908                    // UPSTREAM-PARITY (tree.c xmlBufGetEntityRefContent): the
909                    // XML_ENT_EXPANDING flag (candidate bit 1 << 3, parser
910                    // convention) breaks self-referential loops while the
911                    // declaration's replacement tree is walked.
912                    (*ent).flags |= 1 << 3;
913                    let mut child = (*ent).children;
914                    while !child.is_null() {
915                        let sub = node_get_content(child);
916                        if !sub.is_null() {
917                            let len = crate::abi::exports_xml2::xmlStrlen(sub);
918                            result
919                                .extend_from_slice(core::slice::from_raw_parts(sub, len as usize));
920                            allocator::xmlFreeImpl(sub as *mut c_void);
921                        }
922                        child = (*child).next;
923                    }
924                    (*ent).flags &= !(1 << 3);
925                }
926            }
927        }
928        t if t == XML_DOCUMENT_NODE as c_int || t == XML_HTML_DOCUMENT_NODE as c_int => {
929            let root = doc_get_root_element(node as *mut _xmlDoc);
930            if !root.is_null() {
931                let sub = node_get_content(root);
932                if !sub.is_null() {
933                    let len = crate::abi::exports_xml2::xmlStrlen(sub);
934                    result.extend_from_slice(core::slice::from_raw_parts(sub, len as usize));
935                    allocator::xmlFreeImpl(sub as *mut c_void);
936                }
937            }
938        }
939        t if t == XML_NAMESPACE_DECL as c_int => {
940            // XPath namespace node (an independent `_xmlNs` copy cast to
941            // `_xmlNode`, xmlXPathNodeSetDupNs semantics): its string-value is
942            // the namespace URI. The copy has NO tree links — the fields that
943            // _xmlNode accessors read (children at the `prefix` offset, etc.)
944            // are ns data, so this must not fall into the element arm.
945            // php's DOMXPath php:functionString relies on this conversion
946            // (xmlXPathCastToString of a namespace node-set).
947            let ns = node as *mut crate::abi::structs::_xmlNs;
948            if !(*ns).href.is_null() {
949                let len = crate::abi::exports_xml2::xmlStrlen((*ns).href);
950                result.extend_from_slice(core::slice::from_raw_parts((*ns).href, len as usize));
951            }
952        }
953        _ => {
954            // Element and everything else: concatenate descendant text
955            // content (XPath 1.0 string-value semantics — §4.2 / tree.c
956            // xmlNodeGetContent, which walks the full subtree, not just
957            // direct text children).
958            let mut child = (*node).children;
959            while !child.is_null() {
960                let ctype = (*child).type_;
961                if ctype == XML_TEXT_NODE as c_int || ctype == XML_CDATA_SECTION_NODE as c_int {
962                    if !(*child).content.is_null() {
963                        let len = crate::abi::exports_xml2::xmlStrlen((*child).content);
964                        result.extend_from_slice(core::slice::from_raw_parts(
965                            (*child).content,
966                            len as usize,
967                        ));
968                    }
969                } else if ctype == XML_ENTITY_REF_NODE as c_int
970                    || ctype == XML_ELEMENT_NODE as c_int
971                {
972                    let sub = node_get_content(child);
973                    if !sub.is_null() {
974                        let len = crate::abi::exports_xml2::xmlStrlen(sub);
975                        result.extend_from_slice(core::slice::from_raw_parts(sub, len as usize));
976                        allocator::xmlFreeImpl(sub as *mut c_void);
977                    }
978                }
979                child = (*child).next;
980            }
981        }
982    }
983    // Allocate the C string.
984    let buf = allocator::xmlMallocImpl(result.len() + 1) as *mut xmlChar;
985    if buf.is_null() {
986        return ptr::null_mut();
987    }
988    if !result.is_empty() {
989        ptr::copy_nonoverlapping(result.as_ptr(), buf, result.len());
990    }
991    *buf.add(result.len()) = 0;
992    buf
993}
994
995// ═══════════════════════════════════════════════════════════════════════════════
996// Node Operations
997// ═══════════════════════════════════════════════════════════════════════════════
998
999/// Create a new XML node.
1000///
1001/// # UPSTREAM-PARITY
1002///
1003/// ```c
1004/// xmlNodePtr xmlNewNode(xmlNsPtr ns, const xmlChar *name);
1005/// ```
1006///
1007/// Creates a new element node with the given name and namespace.
1008///
1009/// # SAFETY
1010///
1011/// - `ns` may be NULL.
1012/// - `name` must be a valid null-terminated string or NULL (NULL returns
1013///   NULL — upstream tree.c `xmlNewNode` rejects a NULL name up front,
1014///   HOSTILE-ABI A48).
1015pub unsafe fn new_node(ns: *mut _xmlNs, name: *const xmlChar) -> *mut _xmlNode {
1016    if name.is_null() {
1017        return ptr::null_mut();
1018    }
1019    let node = allocator::xmlMallocZero(size_of::<_xmlNode>() as usize) as *mut _xmlNode;
1020    if node.is_null() {
1021        return ptr::null_mut();
1022    }
1023
1024    unsafe {
1025        (*node).type_ = XML_ELEMENT_NODE as c_int;
1026        (*node).name = dup_xml_str(name);
1027        (*node).ns = ns;
1028        (*node).line = 0;
1029        (*node).extra = 0;
1030
1031        if !ns.is_null() {
1032            (*ns).context = node as *mut _xmlDoc;
1033        }
1034    }
1035
1036    // UPSTREAM-PARITY (tree.c): the node-registration hook fires after a
1037    // node is fully initialised.
1038    crate::abi::data_globals::register_node_hook(node);
1039
1040    node
1041}
1042
1043/// Create a new XML element node whose name is BORROWED (not duplicated).
1044///
1045/// # UPSTREAM-PARITY
1046///
1047/// ```c
1048/// xmlNodePtr xmlNewDocNodeEatName(xmlDocPtr doc, xmlNsPtr ns,
1049///                                 const xmlChar *name, const xmlChar *content);
1050/// ```
1051///
1052/// The name pointer is stored as-is; the caller keeps ownership. The XML
1053/// parser uses this when `dictNames` is enabled: the name is an interned
1054/// dictionary string owned by the document dictionary, and consumers (lxml
1055/// objectify `_tagMatches`) rely on node names being pointer-identical to
1056/// `xmlDictLookup`/`xmlDictExists` results. `free_node` consults
1057/// `xmlDictOwns` (UPSTREAM-PARITY `DICT_FREE`) so borrowed dictionary names
1058/// are never freed.
1059///
1060/// # SAFETY
1061///
1062/// - `ns` may be NULL.
1063/// - `name` must be non-NULL and remain valid for the lifetime of the node.
1064pub unsafe fn new_node_eat_name(ns: *mut _xmlNs, name: *const xmlChar) -> *mut _xmlNode {
1065    if name.is_null() {
1066        return ptr::null_mut();
1067    }
1068    let node = allocator::xmlMallocZero(size_of::<_xmlNode>() as usize) as *mut _xmlNode;
1069    if node.is_null() {
1070        return ptr::null_mut();
1071    }
1072
1073    unsafe {
1074        (*node).type_ = XML_ELEMENT_NODE as c_int;
1075        (*node).name = name as *mut xmlChar;
1076        (*node).ns = ns;
1077        (*node).line = 0;
1078        (*node).extra = 0;
1079
1080        if !ns.is_null() {
1081            (*ns).context = node as *mut _xmlDoc;
1082        }
1083    }
1084
1085    // UPSTREAM-PARITY (tree.c): the node-registration hook fires after a
1086    // node is fully initialised.
1087    crate::abi::data_globals::register_node_hook(node);
1088
1089    node
1090}
1091
1092/// Free a single node (without freeing children).
1093///
1094/// # UPSTREAM-PARITY
1095///
1096/// ```c
1097/// void xmlFreeNode(xmlNodePtr node);
1098/// ```
1099///
1100/// Frees a node and its properties/namespaces, but NOT its children.
1101/// Children must be freed separately or reattached.
1102///
1103/// # SAFETY
1104///
1105/// - `node` must be a valid pointer to an _xmlNode, or NULL.
1106pub unsafe fn free_node(node: *mut _xmlNode) {
1107    if node.is_null() {
1108        return;
1109    }
1110
1111    let n = unsafe { &mut *node };
1112
1113    // UPSTREAM-PARITY (tree.c xmlFreeNode): declaration nodes and namespace
1114    // declarations are routed to their dedicated free functions (their
1115    // struct layouts diverge from _xmlNode).
1116    if n.type_ == XML_DTD_NODE as c_int {
1117        free_dtd(node as *mut _xmlDtd);
1118        return;
1119    } else if n.type_ == XML_NAMESPACE_DECL as c_int {
1120        free_ns(node as *mut _xmlNs);
1121        return;
1122    } else if n.type_ == XML_ATTRIBUTE_NODE as c_int {
1123        free_prop(node as *mut _xmlAttr);
1124        return;
1125    } else if n.type_ == XML_ELEMENT_DECL as c_int {
1126        crate::xml::dtd::free_element(node as *mut _xmlElement);
1127        return;
1128    } else if n.type_ == XML_ATTRIBUTE_DECL as c_int {
1129        crate::xml::dtd::free_attribute(node as *mut _xmlAttribute);
1130        return;
1131    } else if n.type_ == XML_ENTITY_DECL as c_int {
1132        crate::xml::entities::free_entity(node as *mut _xmlEntity);
1133        return;
1134    }
1135
1136    // UPSTREAM-PARITY (tree.c xmlFreeNode): the deregister hook fires before
1137    // the node is torn down.
1138    crate::abi::data_globals::deregister_node_hook(node);
1139
1140    // Free properties and namespace declarations. Only element nodes carry
1141    // them; compact text nodes store their inline content at the address of
1142    // the `properties` field (and the following `nsDef` field), so touching
1143    // these for other node types would read text bytes as pointers.
1144    let is_element = n.type_ == XML_ELEMENT_NODE as c_int;
1145    if is_element && !n.properties.is_null() {
1146        free_prop_list(n.properties);
1147    }
1148
1149    if is_element && !n.nsDef.is_null() {
1150        free_ns_list(n.nsDef);
1151    }
1152
1153    // UPSTREAM-PARITY (tree.c xmlFreeNode + DICT_FREE): node names and
1154    // content may live in the document dictionary — consumers (lxml
1155    // `_fixHtmlDictNodeNames`) intern HTML element/attribute names with
1156    // xmlDictLookup, so the same interned pointer is shared by every node
1157    // with that name. Dict-owned strings must NOT be freed here; the guard
1158    // is: free iff there is no dict, or the dict does not own the string.
1159    let dict = if n.doc.is_null() {
1160        ptr::null_mut()
1161    } else {
1162        unsafe { (*n.doc).dict }
1163    };
1164
1165    // Free the name.
1166    // UPSTREAM-PARITY (tree.c xmlNewText / xmlNewComment + xmlCopyNode): text,
1167    // CDATA, comment and PI nodes store their `name` as one of the SHARED static
1168    // markers xmlStringText / xmlStringTextNoenc / xmlStringComment (never
1169    // dict- or heap-owned) and those must never be freed here — freeing one
1170    // aborts with "free(): invalid pointer" at teardown (PHP modern/spec
1171    // Node_isDefaultNamespace was the trigger). The only non-dict node names the
1172    // candidate attaches are these statics, so pointer-equality guards fully
1173    // close the leak/double-free.
1174    if !n.name.is_null() && !crate::abi::exports_hash::dict_owns_str(dict, n.name) {
1175        let sentinels = [
1176            crate::abi::data_globals::xmlStringText.as_ptr() as *const c_void,
1177            crate::abi::data_globals::xmlStringTextNoenc.as_ptr() as *const c_void,
1178            crate::abi::data_globals::xmlStringComment.as_ptr() as *const c_void,
1179        ];
1180        let is_sentinel = sentinels.iter().any(|&p| p == n.name as *const c_void);
1181        if !is_sentinel {
1182            allocator::xmlFreeImpl(n.name as *mut c_void);
1183        }
1184    }
1185
1186    // Free content (for text/CDATA nodes). Compact text content lives inside
1187    // the node struct (at the `properties` field address) and must not be
1188    // freed separately. UPSTREAM-PARITY: entity-reference content is shared
1189    // with the entity declaration and must not be freed here.
1190    if !n.content.is_null() {
1191        let node_type = n.type_;
1192        if node_type == XML_TEXT_NODE as c_int
1193            || node_type == XML_CDATA_SECTION_NODE as c_int
1194            || node_type == XML_COMMENT_NODE as c_int
1195            || node_type == XML_PI_NODE as c_int
1196        {
1197            let inline_addr = std::ptr::addr_of_mut!((*node).properties) as *const c_void;
1198            if n.content as *const c_void != inline_addr
1199                && !crate::abi::exports_hash::dict_owns_str(dict, n.content)
1200            {
1201                allocator::xmlFreeImpl(n.content as *mut c_void);
1202            }
1203        }
1204    }
1205
1206    allocator::xmlFreeImpl(node as *mut c_void);
1207}
1208
1209/// Free a linked list of nodes.
1210///
1211/// Frees all nodes in the list and their children (depth-first, children
1212/// before parents) WITHOUT C recursion — upstream xmlFreeNodeList walks the
1213/// tree with an explicit depth counter (tree.c 2.15), and a 100k-deep
1214/// document must tear down without overflowing the stack (GH-22570: the
1215/// recursive version segv'd at php shutdown on the deep Dom\XMLDocument).
1216///
1217/// # UPSTREAM-PARITY
1218///
1219/// ```c
1220/// void xmlFreeNodeList(xmlNodePtr node);
1221/// ```
1222///
1223/// Does NOT descend into `XML_ENTITY_REF_NODE` children (their child list
1224/// points at the shared entity declaration, owned by the DTD) nor free
1225/// `XML_DTD_NODE` children (a DTD node in the list is unlinked, not freed —
1226/// xmlFreeDtd owns the subset teardown).
1227///
1228/// # SAFETY
1229///
1230/// - `node` must be a valid pointer to an _xmlNode, or NULL.
1231pub unsafe fn free_node_list(mut cur: *mut _xmlNode) {
1232    // Explicit resume frames replace the old recursion: each frame is a node
1233    // whose children-subtree free is in progress, plus the node's next
1234    // sibling read before the descent (the struct is freed on resume, so the
1235    // `next` pointer must not be read afterwards).
1236    let mut frames: Vec<(*mut _xmlNode, *mut _xmlNode)> = Vec::new();
1237    loop {
1238        // Process the current sibling list; descend into the first node that
1239        // carries a freeable children subtree (post-order: children first).
1240        while !cur.is_null() {
1241            let next = unsafe { (*cur).next };
1242            let t = unsafe { (*cur).type_ };
1243
1244            if t == XML_DTD_NODE as c_int {
1245                // UPSTREAM-PARITY: DTD nodes are unlinked but not freed here.
1246                unsafe {
1247                    (*cur).prev = ptr::null_mut();
1248                    (*cur).next = ptr::null_mut();
1249                }
1250                cur = next;
1251                continue;
1252            }
1253
1254            // Free children first (entity-ref children are shared with the
1255            // entity declaration and are owned by the DTD).
1256            if t != XML_ENTITY_REF_NODE as c_int && !unsafe { (*cur).children }.is_null() {
1257                frames.push((cur, next));
1258                cur = unsafe { (*cur).children };
1259                continue;
1260            }
1261
1262            free_node(cur);
1263            cur = next;
1264        }
1265        // Sibling list exhausted: resume the innermost descended parent.
1266        let Some((parent, next)) = frames.pop() else {
1267            break;
1268        };
1269        free_node(parent);
1270        cur = next;
1271    }
1272}
1273
1274/// Free a linked list of properties.
1275///
1276/// # SAFETY
1277///
1278/// - `prop` must be a valid pointer to an _xmlAttr, or NULL.
1279unsafe fn free_prop_list(prop: *mut _xmlAttr) {
1280    let mut cur = prop;
1281    while !cur.is_null() {
1282        let next = unsafe { (*cur).next };
1283        free_prop(cur);
1284        cur = next;
1285    }
1286}
1287
1288/// Free a single attribute (upstream `xmlFreeProp`).
1289///
1290/// # SAFETY
1291///
1292/// - `prop` must be a valid pointer to an _xmlAttr, or NULL.
1293unsafe fn free_prop(prop: *mut _xmlAttr) {
1294    if prop.is_null() {
1295        return;
1296    }
1297
1298    // UPSTREAM-PARITY (tree.c xmlFreeProp): freeing an ID attribute removes
1299    // its entry from the document's ID table (xmlRemoveID), so xmlGetID stops
1300    // reporting it. A NULL doc->ids (document teardown) is a no-op.
1301    if !unsafe { (*prop).doc }.is_null() && !unsafe { (*prop).id }.is_null() {
1302        crate::xml::validation::remove_id(unsafe { (*prop).doc }, prop);
1303    }
1304
1305    // Free children (text nodes with value)
1306    if !unsafe { (*prop).children }.is_null() {
1307        free_node_list(unsafe { (*prop).children });
1308    }
1309
1310    // UPSTREAM-PARITY (tree.c xmlFreeProp + DICT_FREE): attribute names may
1311    // be dict-interned (lxml `_fixHtmlDictNodeNames`); interned names are
1312    // shared and must not be freed here.
1313    let dict = if unsafe { (*prop).doc }.is_null() {
1314        ptr::null_mut()
1315    } else {
1316        unsafe { (*(*prop).doc).dict }
1317    };
1318
1319    // Free name
1320    if !unsafe { (*prop).name }.is_null()
1321        && !crate::abi::exports_hash::dict_owns_str(dict, unsafe { (*prop).name })
1322    {
1323        allocator::xmlFreeImpl(unsafe { (*prop).name } as *mut c_void);
1324    }
1325
1326    allocator::xmlFreeImpl(prop as *mut c_void);
1327}
1328
1329/// Free a linked list of namespace declarations.
1330///
1331/// # SAFETY
1332///
1333/// - `ns` must be a valid pointer to an _xmlNs, or NULL.
1334unsafe fn free_ns_list(ns: *mut _xmlNs) {
1335    let mut cur = ns;
1336    while !cur.is_null() {
1337        let next = unsafe { (*cur).next };
1338        free_ns(cur);
1339        cur = next;
1340    }
1341}
1342
1343/// Free a single namespace declaration (upstream `xmlFreeNs`).
1344///
1345/// # SAFETY
1346///
1347/// - `ns` must be a valid pointer to an _xmlNs, or NULL.
1348unsafe fn free_ns(ns: *mut _xmlNs) {
1349    if ns.is_null() {
1350        return;
1351    }
1352
1353    // Free href and prefix
1354    if !unsafe { (*ns).href }.is_null() {
1355        allocator::xmlFreeImpl(unsafe { (*ns).href } as *mut c_void);
1356    }
1357    if !unsafe { (*ns).prefix }.is_null() {
1358        allocator::xmlFreeImpl(unsafe { (*ns).prefix } as *mut c_void);
1359    }
1360
1361    allocator::xmlFreeImpl(ns as *mut c_void);
1362}
1363
1364/// Copy a node (shallow or deep).
1365///
1366/// # UPSTREAM-PARITY
1367///
1368/// ```c
1369/// xmlNodePtr xmlCopyNode(xmlNodePtr node, int recursive);
1370/// ```
1371///
1372/// If `recursive` is 1, children are also copied.
1373/// Returns the new node, or NULL on failure.
1374///
1375/// # SAFETY
1376///
1377/// - `node` must be a valid pointer to an _xmlNode, or NULL.
1378pub unsafe fn copy_node(node: *const _xmlNode, recursive: c_int) -> *mut _xmlNode {
1379    if node.is_null() {
1380        return ptr::null_mut();
1381    }
1382
1383    let n = unsafe { &*node };
1384
1385    let new_node = allocator::xmlMallocZero(size_of::<_xmlNode>() as usize) as *mut _xmlNode;
1386    if new_node.is_null() {
1387        return ptr::null_mut();
1388    }
1389
1390    unsafe {
1391        (*new_node).type_ = n.type_;
1392        (*new_node).name = dup_xml_str(n.name);
1393        // UPSTREAM-PARITY (tree.c xmlStaticCopyNode): the line number is
1394        // copied for element nodes only; text/CDATA/comment/PI copies keep
1395        // line 0.
1396        if n.type_ == XML_ELEMENT_NODE as c_int {
1397            (*new_node).line = n.line;
1398        }
1399        (*new_node).extra = n.extra;
1400        (*new_node).psvi = n.psvi;
1401        // UPSTREAM-PARITY (tree.c xmlStaticCopyNode): _private is NOT copied
1402        // — the copy is a fresh zeroed node (memset 0). Consumers (PHP's
1403        // php_libxml_* wrappers) key their registrations on node->_private:
1404        // a copied node must look UNREGISTERED so the clone gets its own
1405        // wrapper and document binding (ext/simplexml bug63575 — the cloned
1406        // document's nodes inherited the original's php wrappers, so XPath
1407        // and mutations resolved into the ORIGINAL document).
1408
1409        // Copy namespace pointer (NOT the ns declaration — just the reference)
1410        (*new_node).ns = n.ns;
1411
1412        // Copy namespace declarations (element nodes only; compact text nodes
1413        // store inline content over the `properties`/`nsDef` fields).
1414        let is_element = n.type_ == XML_ELEMENT_NODE as c_int;
1415        if is_element && !n.nsDef.is_null() {
1416            (*new_node).nsDef = copy_ns_list(n.nsDef);
1417        }
1418
1419        // Copy content for text/CDATA/comment/PI nodes
1420        let node_type = n.type_;
1421        if (node_type == XML_TEXT_NODE as c_int
1422            || node_type == XML_CDATA_SECTION_NODE as c_int
1423            || node_type == XML_COMMENT_NODE as c_int
1424            || node_type == XML_PI_NODE as c_int)
1425            && !n.content.is_null()
1426        {
1427            (*new_node).content = dup_xml_str(n.content);
1428        }
1429
1430        // Copy properties (element nodes only).
1431        if is_element && !n.properties.is_null() {
1432            (*new_node).properties = copy_prop_list(n.properties);
1433            // Update doc links on properties
1434            let mut prop = (*new_node).properties;
1435            while !prop.is_null() {
1436                (*prop).parent = new_node;
1437                if !(*prop).children.is_null() {
1438                    propagate_doc((*prop).children, (*new_node).doc);
1439                }
1440                prop = (*prop).next;
1441            }
1442        }
1443
1444        // Copy children if recursive (each copied child gets its parent and
1445        // document pointers; `last` follows the upstream link order).
1446        if recursive != 0 && !n.children.is_null() {
1447            (*new_node).children = copy_node_list(n.children, recursive);
1448            if !(*new_node).children.is_null() {
1449                let mut child = (*new_node).children;
1450                let mut last_child = child;
1451                while !child.is_null() {
1452                    (*child).parent = new_node;
1453                    (*child).doc = (*new_node).doc;
1454                    propagate_doc(child, (*new_node).doc);
1455                    if (*child).next.is_null() {
1456                        last_child = child;
1457                    }
1458                    child = (*child).next;
1459                }
1460                (*new_node).last = last_child;
1461            }
1462        }
1463    }
1464
1465    new_node
1466}
1467
1468/// Copy a linked list of nodes.
1469///
1470/// Returns the first node of the new list, or NULL on failure.
1471///
1472/// # Safety
1473///
1474/// - `node` must be NULL or a valid pointer to an `_xmlNode`; when non-NULL it
1475///   is copied with `copy_node` and its `next` chain is walked, so every node
1476///   reachable through `next` must be valid and alive.
1477/// - `recursive` is forwarded to `copy_node` and selects deep versus shallow
1478///   copy; deep copies require valid `children` subtrees.
1479unsafe fn copy_node_list(node: *const _xmlNode, recursive: c_int) -> *mut _xmlNode {
1480    if node.is_null() {
1481        return ptr::null_mut();
1482    }
1483
1484    let n = unsafe { &*node };
1485    let new_node = copy_node(node, recursive);
1486    if new_node.is_null() {
1487        return ptr::null_mut();
1488    }
1489
1490    let mut prev = new_node;
1491    let mut cur = n.next;
1492
1493    while !cur.is_null() {
1494        let new_cur = copy_node(cur, recursive);
1495        if new_cur.is_null() {
1496            break;
1497        }
1498        unsafe {
1499            (*prev).next = new_cur;
1500            (*new_cur).prev = prev;
1501        }
1502        prev = new_cur;
1503        cur = unsafe { (*cur).next };
1504    }
1505
1506    new_node
1507}
1508
1509/// Copy a linked list of namespace declarations.
1510///
1511/// # Safety
1512///
1513/// - `ns` must be NULL or a valid pointer to an `_xmlNs`; when non-NULL its
1514///   fields are read and its `next` chain is walked, so every reachable
1515///   `_xmlNs` must be valid and alive.
1516/// - `href` and `prefix` of each entry may be NULL or NUL-terminated `xmlChar`
1517///   strings; `dup_xml_str` reads them as C strings.
1518unsafe fn copy_ns_list(ns: *const _xmlNs) -> *mut _xmlNs {
1519    if ns.is_null() {
1520        return ptr::null_mut();
1521    }
1522
1523    let n = unsafe { &*ns };
1524    let new_ns = allocator::xmlMallocZero(size_of::<_xmlNs>() as usize) as *mut _xmlNs;
1525    if new_ns.is_null() {
1526        return ptr::null_mut();
1527    }
1528
1529    unsafe {
1530        (*new_ns).type_ = n.type_;
1531        (*new_ns).href = dup_xml_str(n.href);
1532        (*new_ns).prefix = dup_xml_str(n.prefix);
1533        // UPSTREAM-PARITY (tree.c xmlCopyNamespaceList): _private is NOT
1534        // copied (PHP tags some namespace declarations through ns->_private;
1535        // a copied declaration must not alias the original's registration).
1536    }
1537
1538    let mut prev = new_ns;
1539    let mut cur = n.next;
1540
1541    while !cur.is_null() {
1542        let c = unsafe { &*cur };
1543        let new_cur = allocator::xmlMallocZero(size_of::<_xmlNs>() as usize) as *mut _xmlNs;
1544        if new_cur.is_null() {
1545            break;
1546        }
1547        unsafe {
1548            (*new_cur).type_ = c.type_;
1549            (*new_cur).href = dup_xml_str(c.href);
1550            (*new_cur).prefix = dup_xml_str(c.prefix);
1551            (*prev).next = new_cur;
1552        }
1553        prev = new_cur;
1554        cur = c.next;
1555    }
1556
1557    new_ns
1558}
1559
1560/// Copy a linked list of properties.
1561///
1562/// # Safety
1563///
1564/// - `prop` must be NULL or a valid pointer to an `_xmlAttr`; its fields are
1565///   read and its `next` chain is walked, so every reachable `_xmlAttr` must
1566///   be valid and alive.
1567/// - `children` of each attribute must be NULL or a valid node list; it is
1568///   copied recursively via `copy_node_list`. `name` may be NULL or a
1569///   NUL-terminated `xmlChar` string read by `dup_xml_str`.
1570unsafe fn copy_prop_list(prop: *const _xmlAttr) -> *mut _xmlAttr {
1571    if prop.is_null() {
1572        return ptr::null_mut();
1573    }
1574
1575    let p = unsafe { &*prop };
1576    let new_prop = allocator::xmlMallocZero(size_of::<_xmlAttr>() as usize) as *mut _xmlAttr;
1577    if new_prop.is_null() {
1578        return ptr::null_mut();
1579    }
1580
1581    unsafe {
1582        (*new_prop).type_ = p.type_;
1583        (*new_prop).name = dup_xml_str(p.name);
1584        (*new_prop).ns = p.ns;
1585        (*new_prop).atype = p.atype;
1586
1587        // Copy children (text value nodes)
1588        if !p.children.is_null() {
1589            (*new_prop).children = copy_node_list(p.children, 1);
1590            if !(*new_prop).children.is_null() {
1591                (*(*new_prop).children).parent = new_prop as *mut _xmlNode;
1592            }
1593        }
1594    }
1595
1596    let mut prev = new_prop;
1597    let mut cur = p.next;
1598
1599    while !cur.is_null() {
1600        let c = unsafe { &*cur };
1601        let new_cur = allocator::xmlMallocZero(size_of::<_xmlAttr>() as usize) as *mut _xmlAttr;
1602        if new_cur.is_null() {
1603            break;
1604        }
1605        unsafe {
1606            (*new_cur).type_ = c.type_;
1607            (*new_cur).name = dup_xml_str(c.name);
1608            (*new_cur).ns = c.ns;
1609            (*new_cur).atype = c.atype;
1610
1611            if !c.children.is_null() {
1612                (*new_cur).children = copy_node_list(c.children, 1);
1613                if !(*new_cur).children.is_null() {
1614                    (*(*new_cur).children).parent = new_cur as *mut _xmlNode;
1615                }
1616            }
1617
1618            (*prev).next = new_cur;
1619        }
1620        prev = new_cur;
1621        cur = c.next;
1622    }
1623
1624    new_prop
1625}
1626
1627/// Propagate the document pointer to all descendants of a node.
1628///
1629/// # Safety
1630///
1631/// - `node` must be NULL or a valid pointer to an `_xmlNode`; the function
1632///   walks the whole subtree through `properties`, `children`, and `next`
1633///   links, so every reachable node and attribute must be a valid, live
1634///   struct.
1635/// - `doc` may be NULL or a valid pointer to an `_xmlDoc`; it is only stored
1636///   into `doc` fields, never dereferenced.
1637unsafe fn propagate_doc(node: *mut _xmlNode, doc: *mut _xmlDoc) {
1638    let mut cur = node;
1639    while !cur.is_null() {
1640        unsafe {
1641            // UPSTREAM-PARITY (tree.c xmlSetTreeDoc/xmlNodeSetDoc): a node
1642            // whose document actually changes must move its dict-owned
1643            // name/content into the destination document's dictionary (or
1644            // heap copies) and drop its ID-table entry, otherwise the source
1645            // doc's teardown frees strings the moved subtree still points at
1646            // (double free). No-op delegation when the doc pointer already
1647            // matches.
1648            if (*cur).doc != doc {
1649                crate::abi::exports_tree::node_set_doc_impl(cur, doc);
1650            }
1651
1652            // Propagate to properties (element nodes only; other node types
1653            // never carry properties, and compact text nodes store inline
1654            // content at the `properties` field address).
1655            if (*cur).type_ == XML_ELEMENT_NODE as c_int {
1656                let mut prop = (*cur).properties;
1657                while !prop.is_null() {
1658                    if (*prop).doc != doc {
1659                        crate::abi::exports_tree::node_set_doc_impl(prop as *mut _xmlNode, doc);
1660                    }
1661                    if !(*prop).children.is_null() {
1662                        propagate_doc((*prop).children, doc);
1663                    }
1664                    prop = (*prop).next;
1665                }
1666            }
1667
1668            // Recurse into children
1669            if !(*cur).children.is_null() {
1670                propagate_doc((*cur).children, doc);
1671            }
1672        }
1673        cur = unsafe { (*cur).next };
1674    }
1675}
1676
1677/// Unlink a node from its parent/siblings.
1678///
1679/// # UPSTREAM-PARITY
1680///
1681/// ```c
1682/// void xmlUnlinkNode(xmlNodePtr node);
1683/// ```
1684///
1685/// Removes the node from its parent's child list and sibling list.
1686/// The node's parent, prev, and next pointers are cleared.
1687/// The node is NOT freed — the caller is responsible for freeing it.
1688///
1689/// # SAFETY
1690///
1691/// - `node` must be a valid pointer to an _xmlNode, or NULL.
1692pub unsafe fn unlink_node(node: *mut _xmlNode) {
1693    if node.is_null() {
1694        return;
1695    }
1696
1697    let n = unsafe { &mut *node };
1698
1699    // UPSTREAM-PARITY (tree.c xmlUnlinkNode): an unlinked DTD node is
1700    // detached from the document's internal/external subset pointers so a
1701    // later xmlFreeDoc doesn't free it again (nokogiri pins an unlinked DTD
1702    // and frees it via xmlFreeDtd, then frees the doc — without clearing
1703    // doc->intSubset the doc double-frees the DTD).
1704    let doc = n.doc;
1705    if n.type_ == XML_DTD_NODE as c_int && !doc.is_null() {
1706        if (*doc).intSubset == node as *mut _xmlDtd {
1707            (*doc).intSubset = ptr::null_mut();
1708        }
1709        if (*doc).extSubset == node as *mut _xmlDtd {
1710            (*doc).extSubset = ptr::null_mut();
1711        }
1712    }
1713
1714    // Fix up prev/next chain
1715    let prev = n.prev;
1716    let next = n.next;
1717
1718    if !prev.is_null() {
1719        unsafe { (*prev).next = next };
1720    }
1721    if !next.is_null() {
1722        unsafe { (*next).prev = prev };
1723    }
1724
1725    // Fix up parent's children/last pointers. UPSTREAM-PARITY
1726    // (tree.c xmlUnlinkNodeInternal): an attribute node is tracked by the
1727    // parent element's `properties` chain, not by children/last — when the
1728    // unlinked node is an attribute, advance the `properties` head past it so
1729    // the parent no longer references a (possibly freed) attribute. Without
1730    // this, nokogiri unlinks an attribute and later frees it while the element
1731    // still hangs off the stale `properties` slot, and an XPath attribute-axis
1732    // walk dereferences freed memory.
1733    let parent = n.parent;
1734    if !parent.is_null() {
1735        if n.type_ == XML_ATTRIBUTE_NODE as c_int {
1736            if unsafe { (*parent).properties } == node as *mut _xmlAttr {
1737                unsafe { (*parent).properties = next as *mut _xmlAttr };
1738            }
1739        } else {
1740            if unsafe { (*parent).children } == node {
1741                unsafe { (*parent).children = next };
1742            }
1743            if unsafe { (*parent).last } == node {
1744                unsafe { (*parent).last = prev };
1745            }
1746        }
1747    }
1748
1749    // Also fix up doc-level children/last if node is a direct doc child
1750    let doc = n.doc;
1751    if !doc.is_null() && !parent.is_null() {
1752        // Already handled above
1753    }
1754    if !doc.is_null() && parent.is_null() {
1755        // Node is a direct child of the document
1756        if unsafe { (*doc).children } == node {
1757            unsafe { (*doc).children = next };
1758        }
1759        if unsafe { (*doc).last } == node {
1760            unsafe { (*doc).last = prev };
1761        }
1762    }
1763
1764    // Clear the node's links
1765    n.parent = ptr::null_mut();
1766    n.prev = ptr::null_mut();
1767    n.next = ptr::null_mut();
1768}
1769
1770/// Add a child node to a parent.
1771///
1772/// # UPSTREAM-PARITY
1773///
1774/// ```c
1775/// xmlNodePtr xmlAddChild(xmlNodePtr parent, xmlNodePtr cur);
1776/// ```
1777///
1778/// Adds `cur` as the last child of `parent`.
1779/// Returns the child, or NULL on failure.
1780///
1781/// # SAFETY
1782///
1783/// - `parent` must be a valid pointer to an _xmlNode.
1784/// - `cur` must be a valid pointer to an _xmlNode.
1785pub unsafe fn add_child(parent: *mut _xmlNode, cur: *mut _xmlNode) -> *mut _xmlNode {
1786    if parent.is_null() || cur.is_null() {
1787        return ptr::null_mut();
1788    }
1789
1790    let p = unsafe { &mut *parent };
1791    let c = unsafe { &mut *cur };
1792
1793    // If cur is already linked, unlink it first
1794    if !c.parent.is_null() || !c.prev.is_null() || !c.next.is_null() {
1795        unlink_node(cur);
1796    }
1797
1798    // UPSTREAM-PARITY (tree.c xmlAddChild): attaching an ATTRIBUTE node routes
1799    // it into the parent element's PROPERTIES list, not its children list
1800    // (lxml/PHP `element->setAttributeNode(attr)` calls xmlAddChild(elem, attr)
1801    // literally). Without this branch the attribute was appended to `children`
1802    // and then serialized as a bogus child text node / doubly freed on teardown.
1803    // The attribute keeps its own name (already set by xmlNewProp) and is
1804    // appended after the existing properties, mirroring how set_prop links new
1805    // attributes so serialization, clone, and free all treat it as a real attr.
1806    if c.type_ == XML_ATTRIBUTE_NODE as c_int {
1807        c.parent = parent;
1808        c.prev = ptr::null_mut();
1809        c.next = ptr::null_mut();
1810        if p.properties.is_null() {
1811            p.properties = cur as *mut crate::abi::structs::_xmlAttr;
1812        } else {
1813            let mut last = p.properties;
1814            while !unsafe { (*last).next }.is_null() {
1815                last = unsafe { (*last).next };
1816            }
1817            unsafe { (*last).next = cur as *mut crate::abi::structs::_xmlAttr };
1818            c.prev = last as *mut _xmlNode;
1819        }
1820        // Re-parent into the element's document so the attribute and its text
1821        // value share the owner element's doc (propagate_doc also descends into
1822        // attribute text children).
1823        if !p.doc.is_null() && c.doc != p.doc {
1824            propagate_doc(cur, p.doc);
1825        }
1826        return cur;
1827    }
1828
1829    // Update parent/child links
1830    c.parent = parent;
1831
1832    if p.children.is_null() {
1833        // First child
1834        p.children = cur;
1835        p.last = cur;
1836        c.prev = ptr::null_mut();
1837        c.next = ptr::null_mut();
1838    } else {
1839        // Append to end
1840        c.prev = p.last;
1841        c.next = ptr::null_mut();
1842        if !p.last.is_null() {
1843            unsafe { (*p.last).next = cur };
1844        }
1845        p.last = cur;
1846    }
1847
1848    // Update doc
1849    let doc = if !p.doc.is_null() {
1850        p.doc
1851    } else {
1852        ptr::null_mut()
1853    };
1854    if !doc.is_null() && c.doc != doc {
1855        propagate_doc(cur, doc);
1856    }
1857
1858    cur
1859}
1860
1861/// Add a sibling node after another.
1862///
1863/// # UPSTREAM-PARITY
1864///
1865/// ```c
1866/// xmlNodePtr xmlAddSibling(xmlNodePtr cur, xmlNodePtr elem);
1867/// ```
1868///
1869/// Adds `elem` as the next sibling of `cur`.
1870/// Returns `elem`, or NULL on failure.
1871///
1872/// # SAFETY
1873///
1874/// - `cur` must be a valid pointer to an _xmlNode.
1875/// - `elem` must be a valid pointer to an _xmlNode.
1876pub unsafe fn add_sibling(cur: *mut _xmlNode, elem: *mut _xmlNode) -> *mut _xmlNode {
1877    if cur.is_null() || elem.is_null() {
1878        return ptr::null_mut();
1879    }
1880
1881    let c = unsafe { &mut *cur };
1882
1883    // If elem is already linked, unlink it first
1884    let e = unsafe { &mut *elem };
1885    if !e.parent.is_null() || !e.prev.is_null() || !e.next.is_null() {
1886        unlink_node(elem);
1887    }
1888
1889    // Set parent
1890    e.parent = c.parent;
1891
1892    // Link elem after cur
1893    e.prev = cur;
1894    e.next = c.next;
1895
1896    if !c.next.is_null() {
1897        unsafe { (*c.next).prev = elem };
1898    }
1899    c.next = elem;
1900
1901    // Update parent's last if needed
1902    let parent = c.parent;
1903    if !parent.is_null() && unsafe { (*parent).last } == cur {
1904        unsafe { (*parent).last = elem };
1905    }
1906
1907    // Update doc
1908    if !c.doc.is_null() && e.doc != c.doc {
1909        propagate_doc(elem, c.doc);
1910    }
1911
1912    elem
1913}
1914
1915/// Add a sibling node before another.
1916///
1917/// # UPSTREAM-PARITY
1918///
1919/// ```c
1920/// xmlNodePtr xmlAddPrevSibling(xmlNodePtr cur, xmlNodePtr elem);
1921/// ```
1922///
1923/// Adds `elem` as the previous sibling of `cur`.
1924/// Returns `elem`, or NULL on failure.
1925///
1926/// # SAFETY
1927///
1928/// - `cur` must be a valid pointer to an _xmlNode.
1929/// - `elem` must be a valid pointer to an _xmlNode.
1930pub unsafe fn add_sibling_before(cur: *mut _xmlNode, elem: *mut _xmlNode) -> *mut _xmlNode {
1931    if cur.is_null() || elem.is_null() {
1932        return ptr::null_mut();
1933    }
1934
1935    let c = unsafe { &mut *cur };
1936
1937    // If elem is already linked, unlink it first
1938    let e = unsafe { &mut *elem };
1939    if !e.parent.is_null() || !e.prev.is_null() || !e.next.is_null() {
1940        unlink_node(elem);
1941    }
1942
1943    // Set parent
1944    e.parent = c.parent;
1945
1946    // Link elem before cur
1947    e.prev = c.prev;
1948    e.next = cur;
1949
1950    if !c.prev.is_null() {
1951        unsafe { (*c.prev).next = elem };
1952    }
1953    c.prev = elem;
1954
1955    // Update parent's first if needed
1956    let parent = c.parent;
1957    if !parent.is_null() && unsafe { (*parent).children } == cur {
1958        unsafe { (*parent).children = elem };
1959    }
1960
1961    // Update doc-level children if node is a direct doc child
1962    let doc = c.doc;
1963    if !doc.is_null() && parent.is_null() && unsafe { (*doc).children } == cur {
1964        unsafe { (*doc).children = elem };
1965    }
1966
1967    // Update doc
1968    if !c.doc.is_null() && e.doc != c.doc {
1969        propagate_doc(elem, c.doc);
1970    }
1971
1972    elem
1973}
1974
1975/// Create a new child element.
1976///
1977/// # UPSTREAM-PARITY
1978///
1979/// ```c
1980/// xmlNodePtr xmlNewChild(xmlNodePtr parent, xmlNsPtr ns, const xmlChar *name);
1981/// ```
1982///
1983/// Creates a new element and adds it as the last child of `parent`.
1984///
1985/// # SAFETY
1986///
1987/// - `parent` must be a valid pointer to an _xmlNode, or NULL.
1988/// - `name` must be a valid null-terminated string or NULL.
1989pub unsafe fn new_child(
1990    parent: *mut _xmlNode,
1991    ns: *mut _xmlNs,
1992    name: *const xmlChar,
1993) -> *mut _xmlNode {
1994    let node = new_node(ns, name);
1995    if node.is_null() {
1996        return ptr::null_mut();
1997    }
1998
1999    if !parent.is_null() {
2000        add_child(parent, node);
2001    }
2002
2003    node
2004}
2005
2006// ═══════════════════════════════════════════════════════════════════════════════
2007// Text / Content Nodes
2008// ═══════════════════════════════════════════════════════════════════════════════
2009
2010/// Create a new text node.
2011///
2012/// # UPSTREAM-PARITY
2013///
2014/// ```c
2015/// xmlNodePtr xmlNewText(const xmlChar *content);
2016/// ```
2017///
2018/// Creates a text node with the given content.
2019/// If content is NULL, creates an empty text node.
2020///
2021/// # SAFETY
2022///
2023/// - `content` must be a valid null-terminated string or NULL.
2024pub unsafe fn new_text(content: *const xmlChar) -> *mut _xmlNode {
2025    let node = allocator::xmlMallocZero(size_of::<_xmlNode>() as usize) as *mut _xmlNode;
2026    if node.is_null() {
2027        return ptr::null_mut();
2028    }
2029
2030    unsafe {
2031        (*node).type_ = XML_TEXT_NODE as c_int;
2032        (*node).name = dup_xml_str(b"text\0" as *const u8 as *const xmlChar);
2033        (*node).content = if content.is_null() {
2034            let empty = allocator::xmlMallocImpl(1) as *mut xmlChar;
2035            if !empty.is_null() {
2036                *empty = 0;
2037            }
2038            empty
2039        } else {
2040            dup_xml_str(content)
2041        };
2042        (*node).line = 0;
2043    }
2044
2045    // UPSTREAM-PARITY (tree.c): the node-registration hook fires after a
2046    // node is fully initialised.
2047    crate::abi::data_globals::register_node_hook(node);
2048
2049    node
2050}
2051
2052/// Create a new comment node.
2053///
2054/// # UPSTREAM-PARITY
2055///
2056/// ```c
2057/// xmlNodePtr xmlNewComment(const xmlChar *content);
2058/// ```
2059///
2060/// Creates a comment node with the given content.
2061///
2062/// # SAFETY
2063///
2064/// - `content` must be a valid null-terminated string or NULL.
2065pub unsafe fn new_comment(content: *const xmlChar) -> *mut _xmlNode {
2066    let node = allocator::xmlMallocZero(size_of::<_xmlNode>() as usize) as *mut _xmlNode;
2067    if node.is_null() {
2068        return ptr::null_mut();
2069    }
2070
2071    unsafe {
2072        (*node).type_ = XML_COMMENT_NODE as c_int;
2073        (*node).name = dup_xml_str(b"comment\0" as *const u8 as *const xmlChar);
2074        (*node).content = dup_xml_str(content);
2075        (*node).line = 0;
2076    }
2077
2078    // UPSTREAM-PARITY (tree.c): the node-registration hook fires after a
2079    // node is fully initialised.
2080    crate::abi::data_globals::register_node_hook(node);
2081
2082    node
2083}
2084
2085/// Create a new processing instruction node.
2086///
2087/// # UPSTREAM-PARITY
2088///
2089/// ```c
2090/// xmlNodePtr xmlNewPI(const xmlChar *name, const xmlChar *content);
2091/// ```
2092///
2093/// Creates a PI node with the given target name and content.
2094///
2095/// # SAFETY
2096///
2097/// - `name` must be a valid null-terminated string.
2098/// - `content` must be a valid null-terminated string or NULL.
2099pub unsafe fn new_pi(name: *const xmlChar, content: *const xmlChar) -> *mut _xmlNode {
2100    // UPSTREAM-PARITY (tree.c xmlNewPI): a NULL target name is rejected up
2101    // front — HOSTILE-ABI A46.
2102    if name.is_null() {
2103        return ptr::null_mut();
2104    }
2105    let node = allocator::xmlMallocZero(size_of::<_xmlNode>() as usize) as *mut _xmlNode;
2106    if node.is_null() {
2107        return ptr::null_mut();
2108    }
2109
2110    unsafe {
2111        (*node).type_ = XML_PI_NODE as c_int;
2112        (*node).name = dup_xml_str(name);
2113        (*node).content = dup_xml_str(content);
2114        (*node).line = 0;
2115    }
2116
2117    // UPSTREAM-PARITY (tree.c): the node-registration hook fires after a
2118    // node is fully initialised.
2119    crate::abi::data_globals::register_node_hook(node);
2120
2121    node
2122}
2123
2124/// Create a new CDATA section node.
2125///
2126/// # UPSTREAM-PARITY
2127///
2128/// ```c
2129/// xmlNodePtr xmlNewCDataBlock(xmlDocPtr doc, const xmlChar *content, int len);
2130/// ```
2131///
2132/// Creates a CDATA section node with the given content.
2133///
2134/// # SAFETY
2135///
2136/// - `doc` may be NULL.
2137/// - `content` must be a valid pointer to a buffer of at least `len` bytes,
2138///   or NULL.
2139pub unsafe fn new_cdata_block(
2140    doc: *mut _xmlDoc,
2141    content: *const xmlChar,
2142    len: c_int,
2143) -> *mut _xmlNode {
2144    let node = allocator::xmlMallocZero(size_of::<_xmlNode>() as usize) as *mut _xmlNode;
2145    if node.is_null() {
2146        return ptr::null_mut();
2147    }
2148
2149    unsafe {
2150        (*node).type_ = XML_CDATA_SECTION_NODE as c_int;
2151        // UPSTREAM-PARITY (tree.c xmlNewCDataBlock): the name field is left
2152        // NULL (zero-initialised).
2153        (*node).doc = doc;
2154
2155        if !content.is_null() && len > 0 {
2156            (*node).content = allocator::xmlMallocImpl((len + 1) as usize) as *mut xmlChar;
2157            if !(*node).content.is_null() {
2158                ptr::copy_nonoverlapping(content, (*node).content, len as usize);
2159                *((*node).content.add(len as usize)) = 0;
2160            }
2161        } else {
2162            let empty = allocator::xmlMallocImpl(1) as *mut xmlChar;
2163            if !empty.is_null() {
2164                *empty = 0;
2165            }
2166            (*node).content = empty;
2167        }
2168
2169        (*node).line = 0;
2170    }
2171
2172    node
2173}
2174
2175// ═══════════════════════════════════════════════════════════════════════════════
2176// Namespace Operations
2177// ═══════════════════════════════════════════════════════════════════════════════
2178
2179/// Create a new namespace declaration.
2180///
2181/// # UPSTREAM-PARITY
2182///
2183/// ```c
2184/// xmlNsPtr xmlNewNs(xmlNodePtr node, const xmlChar *href, const xmlChar *prefix);
2185/// ```
2186///
2187/// Creates a new namespace declaration on the given node.
2188/// The namespace is added to the node's nsDef list.
2189///
2190/// If `href` is NULL, the namespace is a default namespace undeclaration.
2191/// If `prefix` is NULL, this is the default namespace (xmlns="...").
2192///
2193/// # SAFETY
2194///
2195/// - `node` must be a valid pointer to an _xmlNode, or NULL.
2196/// - `href` must be a valid null-terminated string or NULL.
2197/// - `prefix` must be a valid null-terminated string or NULL.
2198pub unsafe fn new_ns(
2199    node: *mut _xmlNode,
2200    href: *const xmlChar,
2201    prefix: *const xmlChar,
2202) -> *mut _xmlNs {
2203    let ns = allocator::xmlMallocZero(size_of::<_xmlNs>() as usize) as *mut _xmlNs;
2204    if ns.is_null() {
2205        return ptr::null_mut();
2206    }
2207
2208    unsafe {
2209        (*ns).type_ = XML_LOCAL_NAMESPACE as c_int;
2210        (*ns).href = dup_xml_str(href);
2211        (*ns).prefix = dup_xml_str(prefix);
2212        (*ns).context = node as *mut _xmlDoc;
2213
2214        // UPSTREAM-PARITY (tree.c xmlNewNs): a node may not declare the
2215        // same prefix twice — when an existing declaration on the node's
2216        // OWN nsDef chain shares the prefix and has a non-NULL href the new
2217        // declaration is rejected (freed, NULL returned). PHP's
2218        // setAttributeNS/createAttributeNS conflict resolution relies on
2219        // this NULL to allocate a FRESH prefix instead (dom_get_ns_unchecked
2220        // -> dom_get_ns_resolve_prefix_conflict: xmlns:default,
2221        // xmlns:default1, ...) — createAttributeNS_prefix_conflicts /
2222        // Element_setAttributeNS.
2223        if !node.is_null() {
2224            let n = &mut *node;
2225            let same_prefix = |a: *mut _xmlNs| {
2226                let other = &*a;
2227                // xmlStrEqual semantics: NULL == NULL (two default
2228                // declarations conflict), otherwise byte equality.
2229                match ((*ns).prefix, other.prefix) {
2230                    (x, y) if x == y => true,
2231                    (x, y) if x.is_null() || y.is_null() => false,
2232                    (x, y) => crate::abi::exports_xml2::xmlStrEqual(x, y) != 0,
2233                }
2234            };
2235            let conflict = |a: *mut _xmlNs| -> bool {
2236                let other = &*a;
2237                same_prefix(a) && !other.href.is_null()
2238            };
2239            if n.nsDef.is_null() {
2240                n.nsDef = ns;
2241            } else {
2242                // Mirror upstream's first-element check then the walk.
2243                let mut prev = n.nsDef;
2244                if conflict(prev) {
2245                    free_ns(ns);
2246                    return ptr::null_mut();
2247                }
2248                while !(*prev).next.is_null() {
2249                    prev = (*prev).next;
2250                    if conflict(prev) {
2251                        free_ns(ns);
2252                        return ptr::null_mut();
2253                    }
2254                }
2255                (*prev).next = ns;
2256            }
2257        }
2258    }
2259
2260    ns
2261}
2262
2263/// Set the namespace of a node.
2264///
2265/// # UPSTREAM-PARITY
2266///
2267/// ```c
2268/// void xmlSetNs(xmlNodePtr node, xmlNsPtr ns);
2269/// ```
2270///
2271/// # SAFETY
2272///
2273/// - `node` must be a valid pointer to an _xmlNode, or NULL.
2274/// - `ns` must be a valid pointer to an _xmlNs, or NULL.
2275pub unsafe fn set_ns(node: *mut _xmlNode, ns: *mut _xmlNs) {
2276    if node.is_null() {
2277        return;
2278    }
2279    unsafe {
2280        (*node).ns = ns;
2281    }
2282}
2283
2284/// Get a list of namespaces in scope for a node.
2285///
2286/// # UPSTREAM-PARITY
2287///
2288/// ```c
2289/// xmlNsPtr *xmlGetNsList(xmlDocPtr doc, xmlNodePtr node);
2290/// ```
2291///
2292/// Returns a NULL-terminated array of namespace pointers in scope,
2293/// or NULL on failure.
2294///
2295/// # SAFETY
2296///
2297/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
2298/// - `node` must be a valid pointer to an _xmlNode, or NULL.
2299pub unsafe fn get_ns_list(_doc: *mut _xmlDoc, node: *mut _xmlNode) -> *mut *mut _xmlNs {
2300    // Phase 1: basic implementation
2301    // A more complete implementation would walk the node's ancestors
2302    // and collect all in-scope namespaces.
2303    if node.is_null() {
2304        return ptr::null_mut();
2305    }
2306
2307    // Collect namespaces from this node and ancestors
2308    let mut ns_ptrs: Vec<*mut _xmlNs> = Vec::new();
2309    let mut cur = node;
2310
2311    while !cur.is_null() {
2312        let n = unsafe { &*cur };
2313        let mut ns_def = n.nsDef;
2314        while !ns_def.is_null() {
2315            // Avoid duplicates
2316            let ns = unsafe { &*ns_def };
2317            let mut found = false;
2318            for &existing in &ns_ptrs {
2319                if existing == ns_def {
2320                    found = true;
2321                    break;
2322                }
2323                let e = unsafe { &*existing };
2324                if !ns.href.is_null() && !e.href.is_null() {
2325                    let href_match =
2326                        unsafe { crate::abi::exports_xml2::xmlStrEqual(ns.href, e.href) != 0 };
2327                    if href_match {
2328                        if ns.prefix.is_null() && e.prefix.is_null() {
2329                            found = true;
2330                            break;
2331                        }
2332                        if !ns.prefix.is_null() && !e.prefix.is_null() {
2333                            let prefix_match = unsafe {
2334                                crate::abi::exports_xml2::xmlStrEqual(ns.prefix, e.prefix) != 0
2335                            };
2336                            if prefix_match {
2337                                found = true;
2338                                break;
2339                            }
2340                        }
2341                    }
2342                }
2343            }
2344            if !found {
2345                ns_ptrs.push(ns_def);
2346            }
2347            ns_def = unsafe { (*ns_def).next };
2348        }
2349        cur = n.parent;
2350    }
2351
2352    if ns_ptrs.is_empty() {
2353        return ptr::null_mut();
2354    }
2355
2356    // Allocate NULL-terminated array
2357    let arr = allocator::xmlMallocImpl((ns_ptrs.len() + 1) * size_of::<*mut _xmlNs>())
2358        as *mut *mut _xmlNs;
2359    if arr.is_null() {
2360        return ptr::null_mut();
2361    }
2362
2363    for (i, ns) in ns_ptrs.iter().enumerate() {
2364        unsafe { *arr.add(i) = *ns };
2365    }
2366    unsafe { *arr.add(ns_ptrs.len()) = ptr::null_mut() };
2367
2368    arr
2369}
2370
2371/// Search for a namespace by prefix.
2372///
2373/// # UPSTREAM-PARITY
2374///
2375/// ```c
2376/// xmlNsPtr xmlSearchNs(xmlDocPtr doc, xmlNodePtr node, const xmlChar *nameSpace);
2377/// ```
2378///
2379/// Searches for a namespace declaration with the given prefix.
2380/// If `nameSpace` is NULL, searches for the default namespace.
2381///
2382/// # SAFETY
2383///
2384/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
2385/// - `node` must be a valid pointer to an _xmlNode, or NULL.
2386/// - `nameSpace` must be a valid null-terminated string or NULL.
2387/// `IS_STR_XML` from upstream tree.c: the string is exactly "xml".
2388fn is_str_xml(s: *const xmlChar) -> bool {
2389    if s.is_null() {
2390        return false;
2391    }
2392    let b = unsafe { core::slice::from_raw_parts(s, 3) };
2393    b[0] == b'x' && b[1] == b'm' && b[2] == b'l'
2394}
2395
2396/// UPSTREAM-PARITY (tree.c xmlNsInScope): walk from `node` up to (excluding)
2397/// `ancestor`, checking that no closer declaration binds the same `prefix`
2398/// (NULL prefix = the default namespace). Returns 1 when `ancestor`'s decl is
2399/// still in scope, 0 when it is shadowed, -1 when the walk cannot reach
2400/// `ancestor` or crosses an entity boundary.
2401unsafe fn ns_in_scope(
2402    node: *mut _xmlNode,
2403    ancestor: *mut _xmlNode,
2404    prefix: *const xmlChar,
2405) -> c_int {
2406    let mut cur = node;
2407    while !cur.is_null() && cur != ancestor {
2408        let t = unsafe { (*cur).type_ };
2409        if t == XML_ENTITY_REF_NODE as c_int || t == XML_ENTITY_DECL as c_int {
2410            return -1;
2411        }
2412        if t == XML_ELEMENT_NODE as c_int {
2413            let mut tst = unsafe { (*cur).nsDef };
2414            while !tst.is_null() {
2415                let ns = unsafe { &*tst };
2416                if ns.prefix.is_null() && prefix.is_null() {
2417                    return 0;
2418                }
2419                if !ns.prefix.is_null()
2420                    && !prefix.is_null()
2421                    && unsafe { crate::abi::exports_xml2::xmlStrEqual(ns.prefix, prefix) != 0 }
2422                {
2423                    return 0;
2424                }
2425                tst = unsafe { (*tst).next };
2426            }
2427        }
2428        cur = unsafe { (*cur).parent };
2429    }
2430    if cur != ancestor {
2431        return -1;
2432    }
2433    1
2434}
2435
2436/// Ensure `doc->oldNs` holds the implicit XML namespace declaration (upstream
2437/// xmlTreeEnsureXMLDecl). The engine models `doc->oldNs` as a list whose HEAD is
2438/// always the xml declaration; parked/retired declarations live after it.
2439unsafe fn ensure_doc_xml_ns(doc: *mut _xmlDoc) -> *mut _xmlNs {
2440    if doc.is_null() {
2441        return ptr::null_mut();
2442    }
2443    if !(*doc).oldNs.is_null() {
2444        return (*doc).oldNs;
2445    }
2446    let ns = allocator::xmlMallocZero(size_of::<_xmlNs>()) as *mut _xmlNs;
2447    if ns.is_null() {
2448        return ptr::null_mut();
2449    }
2450    unsafe {
2451        (*ns).type_ = XML_LOCAL_NAMESPACE as c_int;
2452        (*ns).href = crate::xml::string::xml_strdup(
2453            c"http://www.w3.org/XML/1998/namespace".as_ptr() as *const xmlChar,
2454        );
2455        (*ns).prefix = crate::xml::string::xml_strdup(c"xml".as_ptr() as *const xmlChar);
2456        (*doc).oldNs = ns;
2457    }
2458    ns
2459}
2460
2461/// UPSTREAM-PARITY (tree.c xmlSearchNsSafe): search for a namespace bound to
2462/// the given PREFIX in scope of `node`. A NULL `name_space` searches the
2463/// default namespace. The walk only ever reads ELEMENT nodes' `nsDef` chains
2464/// (the document is NOT an element: its `oldNs` list must never be mistaken for
2465/// declarations), and a declaration with a NULL href does not bind its prefix.
2466pub unsafe fn search_ns(
2467    doc: *mut _xmlDoc,
2468    node: *mut _xmlNode,
2469    name_space: *const xmlChar,
2470) -> *mut _xmlNs {
2471    if node.is_null() || unsafe { (*node).type_ } == XML_NAMESPACE_DECL as c_int {
2472        return ptr::null_mut();
2473    }
2474    let orig = node;
2475
2476    // The XML-1.0 namespace is implicitly bound to the prefix "xml" on every
2477    // document (xmlTreeEnsureXMLDecl keeps it on doc->oldNs).
2478    if !doc.is_null() && is_str_xml(name_space) {
2479        return unsafe { ensure_doc_xml_ns(doc) };
2480    }
2481
2482    // Climb from a non-element node to its owning element, if any.
2483    let mut cur = node;
2484    while unsafe { (*cur).type_ } != XML_ELEMENT_NODE as c_int {
2485        cur = unsafe { (*cur).parent };
2486        if cur.is_null() {
2487            return ptr::null_mut();
2488        }
2489    }
2490    let parent = cur;
2491
2492    // UPSTREAM-PARITY: `while ((node != NULL) && (node->type ==
2493    // XML_ELEMENT_NODE))` — a detached element (parent == NULL) terminates the
2494    // walk instead of dereferencing NULL.
2495    while !cur.is_null() && unsafe { (*cur).type_ } == XML_ELEMENT_NODE as c_int {
2496        let mut ns_def = unsafe { (*cur).nsDef };
2497        while !ns_def.is_null() {
2498            let ns = unsafe { &*ns_def };
2499            if unsafe { crate::abi::exports_xml2::xmlStrEqual(ns.prefix, name_space) != 0 }
2500                && !ns.href.is_null()
2501            {
2502                return ns_def;
2503            }
2504            ns_def = unsafe { (*ns_def).next };
2505        }
2506        if orig != cur {
2507            let el_ns = unsafe { (*cur).ns };
2508            if !el_ns.is_null()
2509                && unsafe {
2510                    crate::abi::exports_xml2::xmlStrEqual((*el_ns).prefix, name_space) != 0
2511                }
2512                && !(*el_ns).href.is_null()
2513            {
2514                return el_ns;
2515            }
2516        }
2517        cur = unsafe { (*cur).parent };
2518    }
2519
2520    // No document but the node belongs to a doc-less tree: exceptionally create
2521    // the xml declaration on the nearest element (upstream tree.c).
2522    if doc.is_null() && is_str_xml(name_space) {
2523        let ns = allocator::xmlMallocZero(size_of::<_xmlNs>()) as *mut _xmlNs;
2524        if !ns.is_null() {
2525            unsafe {
2526                (*ns).type_ = XML_LOCAL_NAMESPACE as c_int;
2527                (*ns).href = crate::xml::string::xml_strdup(
2528                    c"http://www.w3.org/XML/1998/namespace".as_ptr() as *const xmlChar,
2529                );
2530                (*ns).prefix = crate::xml::string::xml_strdup(c"xml".as_ptr() as *const xmlChar);
2531                (*ns).next = (*parent).nsDef;
2532                (*parent).nsDef = ns;
2533            }
2534            return ns;
2535        }
2536    }
2537
2538    ptr::null_mut()
2539}
2540
2541/// Search for a namespace by href (URI).
2542///
2543/// # UPSTREAM-PARITY
2544///
2545/// ```c
2546/// xmlNsPtr xmlSearchNsByHref(xmlDocPtr doc, xmlNodePtr node, const xmlChar *href);
2547/// ```
2548///
2549/// Searches for a namespace declaration with the given URI.
2550///
2551/// # SAFETY
2552///
2553/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
2554/// - `node` must be a valid pointer to an _xmlNode, or NULL.
2555/// - `href` must be a valid null-terminated string or NULL.
2556/// UPSTREAM-PARITY (tree.c xmlSearchNsByHrefSafe): search for a namespace
2557/// declaration with the given URI in scope of `node`. Attributes are never in
2558/// the default namespace, so a prefix-less declaration cannot satisfy an
2559/// attribute search (`is_attr`). A declaration only counts when it is actually
2560/// in scope (not shadowed between `node` and the declaring element, and never
2561/// across entity boundaries). The walk only reads ELEMENT nodes.
2562pub unsafe fn search_ns_by_href(
2563    doc: *mut _xmlDoc,
2564    node: *mut _xmlNode,
2565    href: *const xmlChar,
2566) -> *mut _xmlNs {
2567    if node.is_null() || href.is_null() || unsafe { (*node).type_ } == XML_NAMESPACE_DECL as c_int {
2568        return ptr::null_mut();
2569    }
2570
2571    let orig = node;
2572
2573    // The XML-1.0 namespace is implicitly in scope everywhere via the prefix
2574    // "xml" (xmlTreeEnsureXMLDecl keeps it on doc->oldNs).
2575    let is_xml_ns_uri = unsafe {
2576        crate::abi::exports_xml2::xmlStrEqual(
2577            href,
2578            c"http://www.w3.org/XML/1998/namespace".as_ptr() as *const xmlChar,
2579        ) != 0
2580    };
2581    if is_xml_ns_uri && !doc.is_null() {
2582        return unsafe { ensure_doc_xml_ns(doc) };
2583    }
2584
2585    let is_attr = unsafe { (*node).type_ } == XML_ATTRIBUTE_NODE as c_int;
2586
2587    // Climb from a non-element node to its owning element, if any.
2588    let mut cur = node;
2589    while unsafe { (*cur).type_ } != XML_ELEMENT_NODE as c_int {
2590        cur = unsafe { (*cur).parent };
2591        if cur.is_null() {
2592            return ptr::null_mut();
2593        }
2594    }
2595    let parent = cur;
2596
2597    // UPSTREAM-PARITY: `while ((node != NULL) && (node->type ==
2598    // XML_ELEMENT_NODE))` — a detached element (parent == NULL) terminates the
2599    // walk instead of dereferencing NULL.
2600    while !cur.is_null() && unsafe { (*cur).type_ } == XML_ELEMENT_NODE as c_int {
2601        let mut ns_def = unsafe { (*cur).nsDef };
2602        while !ns_def.is_null() {
2603            let ns = unsafe { &*ns_def };
2604            if !ns.href.is_null()
2605                && unsafe { crate::abi::exports_xml2::xmlStrEqual(ns.href, href) != 0 }
2606            {
2607                let ns_prefix = ns.prefix;
2608                if ((!is_attr) || !ns_prefix.is_null())
2609                    && unsafe { ns_in_scope(orig, cur, ns_prefix) } == 1
2610                {
2611                    return ns_def;
2612                }
2613            }
2614            ns_def = unsafe { (*ns_def).next };
2615        }
2616        if orig != cur {
2617            let el_ns = unsafe { (*cur).ns };
2618            if !el_ns.is_null() {
2619                let eh = (*el_ns).href;
2620                if !eh.is_null() && unsafe { crate::abi::exports_xml2::xmlStrEqual(eh, href) != 0 }
2621                {
2622                    let el_prefix = (*el_ns).prefix;
2623                    if ((!is_attr) || !el_prefix.is_null())
2624                        && unsafe { ns_in_scope(orig, cur, el_prefix) } == 1
2625                    {
2626                        return el_ns;
2627                    }
2628                }
2629            }
2630        }
2631        cur = unsafe { (*cur).parent };
2632    }
2633
2634    // No document but the node belongs to a doc-less tree: exceptionally create
2635    // the xml declaration on the nearest element (upstream tree.c).
2636    if doc.is_null() && is_xml_ns_uri {
2637        let ns = allocator::xmlMallocZero(size_of::<_xmlNs>()) as *mut _xmlNs;
2638        if !ns.is_null() {
2639            unsafe {
2640                (*ns).type_ = XML_LOCAL_NAMESPACE as c_int;
2641                (*ns).href = crate::xml::string::xml_strdup(
2642                    c"http://www.w3.org/XML/1998/namespace".as_ptr() as *const xmlChar,
2643                );
2644                (*ns).prefix = crate::xml::string::xml_strdup(c"xml".as_ptr() as *const xmlChar);
2645                (*ns).next = (*parent).nsDef;
2646                (*parent).nsDef = ns;
2647            }
2648            return ns;
2649        }
2650    }
2651
2652    ptr::null_mut()
2653}
2654
2655// ═══════════════════════════════════════════════════════════════════════════════
2656// Attribute Operations
2657// ═══════════════════════════════════════════════════════════════════════════════
2658
2659/// UPSTREAM-PARITY (tree.c xmlGetPropNodeInternal DTD arm): when an element has
2660/// no matching real attribute, an ATTLIST default/#FIXED declaration whose
2661/// `defaultValue` is non-NULL is reported as the attribute (returned as an
2662/// `XML_ATTRIBUTE_DECL` node). `ns_name` is the namespace URI: NULL selects
2663/// prefix-less declarations, the XML namespace selects the reserved "xml"
2664/// prefix, anything else is matched through the in-scope prefixes bound to that
2665/// URI. Returns the declaration cast to `*mut _xmlAttr`, or NULL.
2666unsafe fn dtd_default_decl_lookup(
2667    node: *mut _xmlNode,
2668    name: *const xmlChar,
2669    ns_name: *const xmlChar,
2670) -> *mut _xmlAttr {
2671    let n = unsafe { &*node };
2672    if n.doc.is_null() {
2673        return ptr::null_mut();
2674    }
2675    let doc = unsafe { &*(n.doc) };
2676    if doc.intSubset.is_null() && doc.extSubset.is_null() {
2677        return ptr::null_mut();
2678    }
2679
2680    // We need the QName of the element for the DTD lookup.
2681    let ns = n.ns;
2682    let mut tmp: *mut xmlChar = ptr::null_mut();
2683    let elem_qname: *const xmlChar = if !ns.is_null() && !unsafe { (*ns).prefix.is_null() } {
2684        tmp = unsafe { crate::abi::exports_xml2::xmlStrdup((*ns).prefix) };
2685        if !tmp.is_null() {
2686            tmp = unsafe {
2687                crate::abi::exports_xml2::xmlStrcat(tmp, b":\0" as *const u8 as *const xmlChar)
2688            };
2689        }
2690        if !tmp.is_null() {
2691            tmp = unsafe { crate::abi::exports_xml2::xmlStrcat(tmp, n.name) };
2692        }
2693        if tmp.is_null() {
2694            return ptr::null_mut();
2695        }
2696        tmp
2697    } else {
2698        n.name
2699    };
2700
2701    let mut attr_decl: *mut crate::abi::structs::_xmlAttribute = ptr::null_mut();
2702    let xml_ns_uri = b"http://www.w3.org/XML/1998/namespace\0";
2703    if ns_name.is_null() {
2704        attr_decl = crate::xml::validation::get_dtd_qattr_desc(
2705            doc.intSubset,
2706            elem_qname,
2707            name,
2708            ptr::null(),
2709        );
2710        if attr_decl.is_null() && !doc.extSubset.is_null() {
2711            attr_decl = crate::xml::validation::get_dtd_qattr_desc(
2712                doc.extSubset,
2713                elem_qname,
2714                name,
2715                ptr::null(),
2716            );
2717        }
2718    } else if unsafe {
2719        crate::abi::exports_xml2::xmlStrEqual(ns_name, xml_ns_uri.as_ptr() as *const xmlChar) != 0
2720    } {
2721        // The XML namespace must be bound to prefix 'xml'.
2722        let xml_prefix = b"xml\0";
2723        attr_decl = crate::xml::validation::get_dtd_qattr_desc(
2724            doc.intSubset,
2725            elem_qname,
2726            name,
2727            xml_prefix.as_ptr() as *const xmlChar,
2728        );
2729        if attr_decl.is_null() && !doc.extSubset.is_null() {
2730            attr_decl = crate::xml::validation::get_dtd_qattr_desc(
2731                doc.extSubset,
2732                elem_qname,
2733                name,
2734                xml_prefix.as_ptr() as *const xmlChar,
2735            );
2736        }
2737    } else {
2738        // The ugly case: search using the prefixes of in-scope ns-decls
2739        // corresponding to ns_name.
2740        let ns_list = unsafe { get_ns_list(n.doc, node) };
2741        if ns_list.is_null() {
2742            if !tmp.is_null() {
2743                allocator::xmlFreeImpl(tmp as *mut c_void);
2744            }
2745            return ptr::null_mut();
2746        }
2747        let mut cur = ns_list;
2748        while !unsafe { *cur }.is_null() {
2749            let d = unsafe { *cur };
2750            if !unsafe { (*d).href }.is_null()
2751                && unsafe { crate::abi::exports_xml2::xmlStrEqual((*d).href, ns_name) != 0 }
2752            {
2753                attr_decl = crate::xml::validation::get_dtd_qattr_desc(
2754                    doc.intSubset,
2755                    elem_qname,
2756                    name,
2757                    (*d).prefix,
2758                );
2759                if attr_decl.is_null() && !doc.extSubset.is_null() {
2760                    attr_decl = crate::xml::validation::get_dtd_qattr_desc(
2761                        doc.extSubset,
2762                        elem_qname,
2763                        name,
2764                        (*d).prefix,
2765                    );
2766                }
2767                if !attr_decl.is_null() {
2768                    break;
2769                }
2770            }
2771            cur = cur.add(1);
2772        }
2773        allocator::xmlFreeImpl(ns_list as *mut c_void);
2774    }
2775    if !tmp.is_null() {
2776        allocator::xmlFreeImpl(tmp as *mut c_void);
2777    }
2778
2779    if !attr_decl.is_null() && !unsafe { (*attr_decl).defaultValue.is_null() } {
2780        return attr_decl as *mut _xmlAttr;
2781    }
2782    ptr::null_mut()
2783}
2784
2785/// UPSTREAM-PARITY (tree.c xmlHasProp DTD arm): plain-element-name variant of
2786/// the default/#FIXED lookup (no QName prefix expansion, matching upstream
2787/// xmlHasProp's xmlGetDtdAttrDesc call).
2788unsafe fn dtd_default_decl_lookup_plain(
2789    node: *mut _xmlNode,
2790    name: *const xmlChar,
2791) -> *mut _xmlAttr {
2792    let n = unsafe { &*node };
2793    if n.doc.is_null() {
2794        return ptr::null_mut();
2795    }
2796    let doc = unsafe { &*(n.doc) };
2797    let mut attr_decl: *mut crate::abi::structs::_xmlAttribute = ptr::null_mut();
2798    if !doc.intSubset.is_null() {
2799        attr_decl = crate::xml::validation::get_dtd_attr_desc(doc.intSubset, n.name, name);
2800        if attr_decl.is_null() && !doc.extSubset.is_null() {
2801            attr_decl = crate::xml::validation::get_dtd_attr_desc(doc.extSubset, n.name, name);
2802        }
2803        if !attr_decl.is_null() && !unsafe { (*attr_decl).defaultValue.is_null() } {
2804            return attr_decl as *mut _xmlAttr;
2805        }
2806    }
2807    ptr::null_mut()
2808}
2809
2810/// Set an attribute on a node.
2811///
2812/// # UPSTREAM-PARITY
2813///
2814/// ```c
2815/// xmlAttrPtr xmlSetProp(xmlNodePtr node, const xmlChar *name, const xmlChar *value);
2816/// ```
2817///
2818/// Sets the attribute with the given name to the given value.
2819/// If the attribute already exists, its value is updated.
2820/// Creates the attribute if it doesn't exist.
2821///
2822/// Returns the attribute pointer, or NULL on failure.
2823///
2824/// # SAFETY
2825///
2826/// - `node` must be a valid pointer to an _xmlNode, or NULL.
2827/// - `name` must be a valid null-terminated string.
2828/// - `value` must be a valid null-terminated string or NULL.
2829/// UPSTREAM-PARITY (tree.c xmlSetProp): set an attribute given its QName.
2830/// A prefixed name resolves the prefix through the in-scope namespace
2831/// declarations and delegates to `xmlSetNsProp` with the LOCAL name; when the
2832/// prefix is unbound (or the name is unprefixed) the attribute is set in no
2833/// namespace under its (raw) name — an unprefixed attribute is never in a
2834/// namespace, and an unbound prefix keeps the raw QName (matching the SAX2
2835/// tree-builder convention for undefined prefixes).
2836pub unsafe fn set_prop(
2837    node: *mut _xmlNode,
2838    name: *const xmlChar,
2839    value: *const xmlChar,
2840) -> *mut _xmlAttr {
2841    if node.is_null() || name.is_null() || unsafe { (*node).type_ } != XML_ELEMENT_NODE as c_int {
2842        return ptr::null_mut();
2843    }
2844
2845    let mut prefix: *mut xmlChar = ptr::null_mut();
2846    let localname = crate::xml::validation::split_qname4(name, &mut prefix);
2847    if localname.is_null() {
2848        if !prefix.is_null() {
2849            allocator::xmlFreeImpl(prefix as *mut c_void);
2850        }
2851        return ptr::null_mut();
2852    }
2853    if !prefix.is_null() {
2854        let ns = unsafe { search_ns((*node).doc, node, prefix) };
2855        if !ns.is_null() {
2856            allocator::xmlFreeImpl(prefix as *mut c_void);
2857            return unsafe { set_ns_prop(node, ns, localname, value) };
2858        }
2859        allocator::xmlFreeImpl(prefix as *mut c_void);
2860        return unsafe { set_ns_prop(node, ptr::null_mut(), name, value) };
2861    }
2862
2863    unsafe { set_ns_prop(node, ptr::null_mut(), name, value) }
2864}
2865
2866/// Get an attribute value by name.
2867///
2868/// # UPSTREAM-PARITY
2869///
2870/// ```c
2871/// xmlChar *xmlGetProp(xmlNodePtr node, const xmlChar *name);
2872/// ```
2873///
2874/// Returns the attribute value as an xmlChar* (caller must free with xmlFree),
2875/// or NULL if the attribute doesn't exist.
2876///
2877/// # SAFETY
2878///
2879/// - `node` must be a valid pointer to an _xmlNode, or NULL.
2880/// - `name` must be a valid null-terminated string.
2881pub unsafe fn get_prop(node: *mut _xmlNode, name: *const xmlChar) -> *mut xmlChar {
2882    if node.is_null() || name.is_null() || unsafe { (*node).type_ } != XML_ELEMENT_NODE as c_int {
2883        return ptr::null_mut();
2884    }
2885
2886    let n = unsafe { &*node };
2887    let mut cur = n.properties;
2888
2889    while !cur.is_null() {
2890        let attr = unsafe { &*cur };
2891        if !attr.name.is_null()
2892            && unsafe { crate::abi::exports_xml2::xmlStrEqual(attr.name, name) != 0 }
2893        {
2894            // Get the text content of the attribute
2895            if !attr.children.is_null() {
2896                let text = unsafe { &*attr.children };
2897                if text.type_ == XML_TEXT_NODE as c_int && !text.content.is_null() {
2898                    return dup_xml_str(text.content);
2899                }
2900            }
2901            return dup_xml_str(b"\0" as *const u8 as *const xmlChar);
2902        }
2903        cur = unsafe { (*cur).next };
2904    }
2905
2906    // UPSTREAM-PARITY (tree.c xmlGetProp/xmlHasProp): when the element has no
2907    // matching real attribute, an ATTLIST default/#FIXED declaration value is
2908    // returned.
2909    let decl = unsafe { dtd_default_decl_lookup_plain(node, name) };
2910    if !decl.is_null() {
2911        let a = decl as *mut crate::abi::structs::_xmlAttribute;
2912        let dv = unsafe { (*a).defaultValue };
2913        if !dv.is_null() {
2914            return dup_xml_str(dv);
2915        }
2916    }
2917
2918    ptr::null_mut()
2919}
2920
2921/// Get a namespaced attribute value.
2922///
2923/// # UPSTREAM-PARITY
2924///
2925/// ```c
2926/// xmlChar *xmlGetNsProp(xmlNodePtr node, const xmlChar *name, const xmlChar *nameSpace);
2927/// ```
2928///
2929/// Returns the attribute value, or NULL if not found.
2930///
2931/// # SAFETY
2932///
2933/// - `node` must be a valid pointer to an _xmlNode, or NULL.
2934/// - `name` must be a valid null-terminated string.
2935/// - `nameSpace` may be NULL.
2936pub unsafe fn get_ns_prop(
2937    node: *mut _xmlNode,
2938    name: *const xmlChar,
2939    name_space: *const xmlChar,
2940) -> *mut xmlChar {
2941    if node.is_null() || name.is_null() || unsafe { (*node).type_ } != XML_ELEMENT_NODE as c_int {
2942        return ptr::null_mut();
2943    }
2944    // UPSTREAM-PARITY (tree.c xmlGetNsProp/xmlGetPropNodeInternal): match the
2945    // LOCAL name plus the namespace — a NULL nameSpace matches only
2946    // UNPREFIXED attributes; a non-NULL nameSpace matches only attributes
2947    // whose ns href equals it (an unprefixed attribute is NEVER in a
2948    // namespace, not even the element's default one). Returns the value (""
2949    // when empty) or NULL.
2950    let mut cur = unsafe { (*node).properties };
2951    while !cur.is_null() {
2952        let attr = unsafe { &*cur };
2953        if !attr.name.is_null()
2954            && unsafe { crate::abi::exports_xml2::xmlStrEqual(attr.name, name) != 0 }
2955        {
2956            let matches = if name_space.is_null() {
2957                attr.ns.is_null()
2958            } else if !attr.ns.is_null() && !(*attr.ns).href.is_null() {
2959                unsafe { crate::abi::exports_xml2::xmlStrEqual((*attr.ns).href, name_space) != 0 }
2960            } else {
2961                false
2962            };
2963            if matches {
2964                if !attr.children.is_null() {
2965                    let text = unsafe { &*attr.children };
2966                    if text.type_ == XML_TEXT_NODE as c_int && !text.content.is_null() {
2967                        return dup_xml_str(text.content);
2968                    }
2969                }
2970                return dup_xml_str(b"\0" as *const u8 as *const xmlChar);
2971            }
2972        }
2973        cur = unsafe { (*cur).next };
2974    }
2975
2976    // UPSTREAM-PARITY (tree.c xmlGetPropNodeInternal useDTD arm): DTD
2977    // default/#FIXED declarations are reported as present attributes.
2978    let decl = unsafe { dtd_default_decl_lookup(node, name, name_space) };
2979    if !decl.is_null() {
2980        let a = decl as *mut crate::abi::structs::_xmlAttribute;
2981        let dv = unsafe { (*a).defaultValue };
2982        if !dv.is_null() {
2983            return dup_xml_str(dv);
2984        }
2985    }
2986    ptr::null_mut()
2987}
2988
2989/// Set a namespaced attribute.
2990///
2991/// # UPSTREAM-PARITY
2992///
2993/// ```c
2994/// xmlAttrPtr xmlSetNsProp(xmlNodePtr node, xmlNsPtr ns, const xmlChar *name, const xmlChar *value);
2995/// ```
2996///
2997/// # SAFETY
2998///
2999/// - `node` must be a valid pointer to an _xmlNode, or NULL.
3000/// - `ns` may be NULL.
3001/// - `name` must be a valid null-terminated string.
3002/// - `value` must be a valid null-terminated string or NULL.
3003pub unsafe fn set_ns_prop(
3004    node: *mut _xmlNode,
3005    ns: *mut _xmlNs,
3006    name: *const xmlChar,
3007    value: *const xmlChar,
3008) -> *mut _xmlAttr {
3009    if node.is_null() || name.is_null() {
3010        return ptr::null_mut();
3011    }
3012    let n = unsafe { &mut *node };
3013
3014    // Namespace-aware variant of xmlSetProp (upstream xmlSetNsProp / tree.c
3015    // xmlSetNsPropInternal): find an existing attribute that shares name AND
3016    // namespace (href); when found, update its value, otherwise create a new
3017    // namespaced attribute bound to `ns`. The legacy Phase-1 stub ignored `ns`
3018    // and created an UNNAMESPACED attribute, which broke the modern DOM ns
3019    // mapper that materialises xmlns declarations as real XMLNS-ns attributes
3020    // (php dom_mark_namespaces_as_attributes_too -> xmlSetNsProp).
3021
3022    let mut existing = n.properties;
3023    while !existing.is_null() {
3024        let attr = unsafe { &*existing };
3025        let same_ns = if !ns.is_null() {
3026            // Both declare the same href (prefix may legitimately come from
3027            // different mapper instances, so compare by href).
3028            if unsafe { (*existing).ns }.is_null() {
3029                false
3030            } else {
3031                let an = unsafe { &*(*existing).ns };
3032                let bn = unsafe { &*ns };
3033                (!an.href.is_null()
3034                    && !bn.href.is_null()
3035                    && unsafe {
3036                        crate::abi::exports_xml2::xmlStrEqual(
3037                            an.href as *const crate::abi::types::xmlChar,
3038                            bn.href as *const crate::abi::types::xmlChar,
3039                        ) != 0
3040                    })
3041                    || (an.href.is_null() && bn.href.is_null())
3042            }
3043        } else {
3044            unsafe { (*existing).ns }.is_null()
3045        };
3046        if !attr.name.is_null()
3047            && unsafe { crate::abi::exports_xml2::xmlStrEqual(attr.name, name) != 0 }
3048            && same_ns
3049        {
3050            // UPSTREAM-PARITY (tree.c xmlSetNsProp modify branch): the
3051            // attribute is rebound to the NEW namespace — the passed `ns`
3052            // may carry a different prefix for the same URI (the modern
3053            // DOM ns-mapper allocates a fresh prefix per qualified name,
3054            // so setAttributeNS("urn:a", "y:foo", ...) renames x:foo to
3055            // y:foo rather than keeping the stale prefix).
3056            let attr_mut = existing;
3057            unsafe { (*attr_mut).ns = ns };
3058            // UPSTREAM-PARITY (tree.c xmlSetNsProp modify branch): an
3059            // attribute whose current value is registered as an ID drops its
3060            // old entry and keeps its ID type, so the new value is
3061            // re-registered below (xml:id / HTML id value changes move the
3062            // doc->ids mapping — bug79701).
3063            let mut was_id = false;
3064            if !n.doc.is_null() && !attr.id.is_null() {
3065                crate::xml::validation::remove_id(n.doc, existing);
3066                was_id = true;
3067                let am = existing;
3068                unsafe {
3069                    (*am).atype = crate::abi::types::xmlAttributeType::XML_ATTRIBUTE_ID as c_int;
3070                }
3071            }
3072            // Update existing attribute value (mirror set_prop: free old
3073            // text children and set the new text value).
3074            if !attr.children.is_null() {
3075                free_node_list(attr.children);
3076                let attr_mut = existing;
3077                unsafe {
3078                    (*attr_mut).children = ptr::null_mut();
3079                    (*attr_mut).last = ptr::null_mut();
3080                }
3081            }
3082            if !value.is_null() {
3083                let text = new_text(value);
3084                if !text.is_null() {
3085                    let attr_mut = existing;
3086                    unsafe {
3087                        (*attr_mut).children = text;
3088                        (*attr_mut).last = text;
3089                        (*text).parent = existing as *mut _xmlNode;
3090                        (*text).doc = n.doc;
3091                    }
3092                }
3093                if was_id {
3094                    crate::xml::validation::add_id_safe(existing, value);
3095                }
3096            }
3097            return existing;
3098        }
3099        existing = unsafe { (*existing).next };
3100    }
3101
3102    // Create a new namespaced attribute.
3103    let attr = allocator::xmlMallocZero(size_of::<_xmlAttr>() as usize) as *mut _xmlAttr;
3104    if attr.is_null() {
3105        return ptr::null_mut();
3106    }
3107    unsafe {
3108        (*attr).type_ = XML_ATTRIBUTE_NODE as c_int;
3109        (*attr).name = dup_xml_str(name);
3110        (*attr).ns = ns;
3111        (*attr).parent = node;
3112        (*attr).doc = n.doc;
3113    }
3114    if !value.is_null() {
3115        let text = new_text(value);
3116        if !text.is_null() {
3117            unsafe {
3118                (*attr).children = text;
3119                (*attr).last = text;
3120                (*text).parent = attr as *mut _xmlNode;
3121                (*text).doc = n.doc;
3122            }
3123        }
3124        // UPSTREAM-PARITY (tree.c xmlNewPropInternal): a newly created
3125        // attribute whose name/type makes it an ID (HTML id, xml:id, DTD ID
3126        // declarations) is registered against the document's ID table
3127        // immediately.
3128        if !n.doc.is_null() {
3129            let res = crate::xml::validation::is_id(n.doc, node, attr);
3130            if res > 0 {
3131                crate::xml::validation::add_id_safe(attr, value);
3132            }
3133        }
3134    }
3135    // Attach to the node's property list.
3136    unsafe {
3137        if n.properties.is_null() {
3138            n.properties = attr;
3139        } else {
3140            let mut last = n.properties;
3141            while !(*last).next.is_null() {
3142                last = (*last).next;
3143            }
3144            (*last).next = attr;
3145            (*attr).prev = last;
3146        }
3147    }
3148    attr
3149}
3150
3151/// Remove a property from a node.
3152///
3153/// # UPSTREAM-PARITY
3154///
3155/// ```c
3156/// int xmlRemoveProp(xmlAttrPtr attr);
3157/// ```
3158///
3159/// Removes the attribute from its parent node and frees it.
3160/// Returns 0 on success, -1 on failure.
3161///
3162/// # SAFETY
3163///
3164/// - `attr` must be a valid pointer to an _xmlAttr, or NULL.
3165pub unsafe fn remove_prop(attr: *mut _xmlAttr) -> c_int {
3166    if attr.is_null() {
3167        return -1;
3168    }
3169
3170    let a = unsafe { &mut *attr };
3171
3172    // UPSTREAM-PARITY (tree.c xmlRemoveProp -> xmlFreeProp): removing an ID
3173    // attribute unregisters it from the document's ID table.
3174    if !a.doc.is_null() && !a.id.is_null() {
3175        crate::xml::validation::remove_id(a.doc, attr);
3176    }
3177
3178    // Unlink from parent's property list
3179    let parent = a.parent;
3180    if !parent.is_null() {
3181        let p = unsafe { &mut *parent };
3182        if p.properties == attr {
3183            p.properties = a.next;
3184        }
3185    }
3186
3187    // Fix up prev/next chain
3188    if !a.prev.is_null() {
3189        unsafe { (*a.prev).next = a.next };
3190    }
3191    if !a.next.is_null() {
3192        unsafe { (*a.next).prev = a.prev };
3193    }
3194
3195    // Free children (text value nodes)
3196    if !a.children.is_null() {
3197        free_node_list(a.children);
3198    }
3199
3200    // Free name
3201    if !a.name.is_null() {
3202        allocator::xmlFreeImpl(a.name as *mut c_void);
3203    }
3204
3205    allocator::xmlFreeImpl(attr as *mut c_void);
3206    0
3207}
3208
3209/// Check whether a node has a property with the given name (upstream tree.c
3210/// `xmlHasProp`): returns the attribute pointer or NULL.
3211///
3212/// # SAFETY
3213///
3214/// - `node` must be a valid node pointer or NULL.
3215/// - `name` must be a valid null-terminated string.
3216pub unsafe fn has_prop(node: *mut _xmlNode, name: *const xmlChar) -> *mut _xmlAttr {
3217    if node.is_null() || name.is_null() || unsafe { (*node).type_ } != XML_ELEMENT_NODE as c_int {
3218        return ptr::null_mut();
3219    }
3220    let mut cur = unsafe { (*node).properties };
3221    while !cur.is_null() {
3222        let attr = unsafe { &*cur };
3223        // UPSTREAM-PARITY (tree.c xmlHasProp): the search matches the LOCAL
3224        // name only — namespaced attributes are found too (php's
3225        // setAttributeNode replacement lookup relies on this). The NULL-
3226        // namespace restriction belongs to xmlHasNsProp.
3227        if !attr.name.is_null()
3228            && unsafe { crate::abi::exports_xml2::xmlStrEqual(attr.name, name) != 0 }
3229        {
3230            return cur;
3231        }
3232        cur = unsafe { (*cur).next };
3233    }
3234
3235    // UPSTREAM-PARITY (tree.c xmlHasProp): report ATTLIST default/#FIXED
3236    // declarations as present (php removeAttribute/toggleAttribute rely on the
3237    // returned XML_ATTRIBUTE_DECL being non-removable).
3238    unsafe { dtd_default_decl_lookup_plain(node, name) }
3239}
3240
3241/// Check whether a node has a namespaced property (upstream tree.c
3242/// `xmlHasNsProp`): returns the attribute pointer or NULL. A NULL
3243/// `nameSpace` matches the no-namespace case.
3244///
3245/// # SAFETY
3246///
3247/// - `node` must be a valid node pointer or NULL.
3248/// - `name` must be a valid null-terminated string.
3249/// - `nameSpace` may be NULL.
3250pub unsafe fn has_ns_prop(
3251    node: *mut _xmlNode,
3252    name: *const xmlChar,
3253    name_space: *const xmlChar,
3254) -> *mut _xmlAttr {
3255    if node.is_null() || name.is_null() || unsafe { (*node).type_ } != XML_ELEMENT_NODE as c_int {
3256        return ptr::null_mut();
3257    }
3258    let mut cur = unsafe { (*node).properties };
3259    while !cur.is_null() {
3260        let attr = unsafe { &*cur };
3261        if !attr.name.is_null()
3262            && unsafe { crate::abi::exports_xml2::xmlStrEqual(attr.name, name) != 0 }
3263        {
3264            if name_space.is_null() {
3265                if attr.ns.is_null() {
3266                    return cur;
3267                }
3268            } else if !attr.ns.is_null()
3269                && !(*attr.ns).href.is_null()
3270                && unsafe {
3271                    crate::abi::exports_xml2::xmlStrEqual((*attr.ns).href, name_space) != 0
3272                }
3273            {
3274                return cur;
3275            }
3276        }
3277        cur = unsafe { (*cur).next };
3278    }
3279
3280    // UPSTREAM-PARITY (tree.c xmlGetPropNodeInternal useDTD arm): report
3281    // ATTLIST default/#FIXED declarations as present (php's
3282    // setAttributeNode replacement lookup treats the returned
3283    // XML_ATTRIBUTE_DECL as "no existing attribute").
3284    unsafe { dtd_default_decl_lookup(node, name, name_space) }
3285}
3286
3287/// Remove a property by name from a node (upstream tree.c `xmlUnsetProp`):
3288/// returns 0 on success, -1 if the property does not exist or arguments are
3289/// NULL.
3290///
3291/// # SAFETY
3292///
3293/// - `node` must be a valid node pointer or NULL.
3294/// - `name` must be a valid null-terminated string.
3295pub unsafe fn unset_prop(node: *mut _xmlNode, name: *const xmlChar) -> c_int {
3296    let attr = unsafe { has_prop(node, name) };
3297    if attr.is_null() {
3298        return -1;
3299    }
3300    unsafe { remove_prop(attr) }
3301}
3302
3303/// Remove a namespaced property by name (upstream tree.c `xmlUnsetNsProp`).
3304///
3305/// # SAFETY
3306///
3307/// - `node` must be a valid node pointer or NULL.
3308/// - `name` must be a valid null-terminated string.
3309/// - `nameSpace` may be NULL.
3310pub unsafe fn unset_ns_prop(
3311    node: *mut _xmlNode,
3312    name: *const xmlChar,
3313    name_space: *const xmlChar,
3314) -> c_int {
3315    let attr = unsafe { has_ns_prop(node, name, name_space) };
3316    if attr.is_null() {
3317        return -1;
3318    }
3319    unsafe { remove_prop(attr) }
3320}
3321
3322/// Return the first child ELEMENT of a node, or NULL (upstream tree.c
3323/// `xmlFirstElementChild`).
3324///
3325/// # SAFETY
3326///
3327/// - `node` must be a valid node pointer or NULL.
3328pub unsafe fn first_element_child(node: *mut _xmlNode) -> *mut _xmlNode {
3329    if node.is_null() {
3330        return ptr::null_mut();
3331    }
3332    let mut cur = unsafe { (*node).children };
3333    while !cur.is_null() {
3334        if unsafe { (*cur).type_ } == XML_ELEMENT_NODE as c_int {
3335            return cur;
3336        }
3337        cur = unsafe { (*cur).next };
3338    }
3339    ptr::null_mut()
3340}
3341
3342/// Return the last child ELEMENT of a node, or NULL (upstream tree.c
3343/// `xmlLastElementChild`).
3344///
3345/// # SAFETY
3346///
3347/// - `node` must be a valid node pointer or NULL.
3348pub unsafe fn last_element_child(node: *mut _xmlNode) -> *mut _xmlNode {
3349    if node.is_null() {
3350        return ptr::null_mut();
3351    }
3352    let mut cur = unsafe { (*node).last };
3353    while !cur.is_null() {
3354        if unsafe { (*cur).type_ } == XML_ELEMENT_NODE as c_int {
3355            return cur;
3356        }
3357        cur = unsafe { (*cur).prev };
3358    }
3359    ptr::null_mut()
3360}
3361
3362/// Return the next ELEMENT sibling of a node, or NULL (upstream tree.c
3363/// `xmlNextElementSibling`).
3364///
3365/// # SAFETY
3366///
3367/// - `node` must be a valid node pointer or NULL.
3368pub unsafe fn next_element_sibling(node: *mut _xmlNode) -> *mut _xmlNode {
3369    if node.is_null() {
3370        return ptr::null_mut();
3371    }
3372    let mut cur = unsafe { (*node).next };
3373    while !cur.is_null() {
3374        if unsafe { (*cur).type_ } == XML_ELEMENT_NODE as c_int {
3375            return cur;
3376        }
3377        cur = unsafe { (*cur).next };
3378    }
3379    ptr::null_mut()
3380}
3381
3382/// Return the previous ELEMENT sibling of a node, or NULL (upstream tree.c
3383/// `xmlPreviousElementSibling`).
3384///
3385/// # SAFETY
3386///
3387/// - `node` must be a valid node pointer or NULL.
3388pub unsafe fn previous_element_sibling(node: *mut _xmlNode) -> *mut _xmlNode {
3389    if node.is_null() {
3390        return ptr::null_mut();
3391    }
3392    let mut cur = unsafe { (*node).prev };
3393    while !cur.is_null() {
3394        if unsafe { (*cur).type_ } == XML_ELEMENT_NODE as c_int {
3395            return cur;
3396        }
3397        cur = unsafe { (*cur).prev };
3398    }
3399    ptr::null_mut()
3400}
3401
3402/// Count the child ELEMENT nodes of a node (upstream tree.c
3403/// `xmlChildElementCount`).
3404///
3405/// # SAFETY
3406///
3407/// - `node` must be a valid node pointer or NULL.
3408pub unsafe fn child_element_count(node: *mut _xmlNode) -> c_ulong {
3409    if node.is_null() {
3410        return 0;
3411    }
3412    let mut cur = unsafe { (*node).children };
3413    let mut count: c_ulong = 0;
3414    while !cur.is_null() {
3415        if unsafe { (*cur).type_ } == XML_ELEMENT_NODE as c_int {
3416            count += 1;
3417        }
3418        cur = unsafe { (*cur).next };
3419    }
3420    count
3421}
3422
3423/// Concatenate text to a node's content (upstream tree.c `xmlTextConcat`):
3424/// appends `num` bytes of `str` to the node's text content. Returns 0 on
3425/// success, -1 on error.
3426///
3427/// # SAFETY
3428///
3429/// - `node` must be a valid text node or NULL.
3430/// - `str` must be a valid buffer of `num` bytes.
3431pub unsafe fn text_concat(node: *mut _xmlNode, str: *const xmlChar, num: c_int) -> c_int {
3432    if node.is_null() || str.is_null() || num <= 0 {
3433        return -1;
3434    }
3435    let cur = unsafe { &mut *node };
3436    if cur.content.is_null() {
3437        let p = unsafe { allocator::xmlMallocImpl(num as usize + 1) as *mut xmlChar };
3438        if p.is_null() {
3439            return -1;
3440        }
3441        unsafe {
3442            ptr::copy_nonoverlapping(str, p, num as usize);
3443            *p.add(num as usize) = 0;
3444        }
3445        cur.content = p;
3446        return 0;
3447    }
3448    let old_len = unsafe { crate::xml::string::xml_strlen(cur.content) };
3449    let p = unsafe {
3450        allocator::xmlReallocImpl(cur.content as *mut c_void, old_len + num as usize + 1)
3451            as *mut xmlChar
3452    };
3453    if p.is_null() {
3454        return -1;
3455    }
3456    unsafe {
3457        ptr::copy_nonoverlapping(str, p.add(old_len), num as usize);
3458        *p.add(old_len + num as usize) = 0;
3459    }
3460    cur.content = p;
3461    0
3462}
3463
3464/// Merge the text content of two nodes (upstream tree.c `xmlTextMerge`):
3465/// appends `ntext`'s content to `text`'s content and frees `ntext`.
3466/// Returns the first node, or NULL on error.
3467///
3468/// # SAFETY
3469///
3470/// - `text` and `ntext` must be valid text nodes or NULL.
3471pub unsafe fn text_merge(text: *mut _xmlNode, ntext: *mut _xmlNode) -> *mut _xmlNode {
3472    if text.is_null() || ntext.is_null() {
3473        return ptr::null_mut();
3474    }
3475    if unsafe { (*ntext).content.is_null() } {
3476        unsafe { free_node(ntext) };
3477        return text;
3478    }
3479    let num = unsafe { crate::xml::string::xml_strlen((*ntext).content) };
3480    if unsafe { text_concat(text, (*ntext).content, num as c_int) } != 0 {
3481        return ptr::null_mut();
3482    }
3483    unsafe { free_node(ntext) };
3484    text
3485}
3486
3487// ═══════════════════════════════════════════════════════════════════════════════
3488// DTD Operations
3489// ═══════════════════════════════════════════════════════════════════════════════
3490
3491/// Get the internal DTD subset of a document.
3492///
3493/// # UPSTREAM-PARITY
3494///
3495/// ```c
3496/// xmlDtdPtr xmlGetIntSubset(xmlDocPtr doc);
3497/// ```
3498pub const fn get_int_subset(doc: *const _xmlDoc) -> *mut _xmlDtd {
3499    if doc.is_null() {
3500        return ptr::null_mut();
3501    }
3502    let d = unsafe { &*doc };
3503    d.intSubset
3504}
3505
3506/// Create a new DTD node.
3507///
3508/// # UPSTREAM-PARITY
3509///
3510/// ```c
3511/// xmlDtdPtr xmlNewDtd(xmlDocPtr doc, const xmlChar *name,
3512///                     const xmlChar *ExternalID, const xmlChar *SystemID);
3513/// ```
3514///
3515/// Creates a new DTD and attaches it to the document.
3516///
3517/// # SAFETY
3518///
3519/// - `doc` must be a valid pointer to an _xmlDoc.
3520/// - `name` must be a valid null-terminated string or NULL.
3521/// - `ExternalID`, `SystemID` may be NULL.
3522pub unsafe fn new_dtd(
3523    doc: *mut _xmlDoc,
3524    name: *const xmlChar,
3525    ExternalID: *const xmlChar,
3526    SystemID: *const xmlChar,
3527) -> *mut _xmlDtd {
3528    let dtd = allocator::xmlMallocZero(size_of::<_xmlDtd>() as usize) as *mut _xmlDtd;
3529    if dtd.is_null() {
3530        return ptr::null_mut();
3531    }
3532
3533    // UPSTREAM-PARITY (tree.c xmlNewDtd): a document that already has an
3534    // external subset cannot take another — upstream returns NULL.
3535    if !doc.is_null() && !(*doc).extSubset.is_null() {
3536        allocator::xmlFreeImpl(dtd as *mut c_void);
3537        return ptr::null_mut();
3538    }
3539
3540    unsafe {
3541        (*dtd).type_ = XML_DTD_NODE as c_int;
3542        (*dtd).name = dup_xml_str(name);
3543        (*dtd).ExternalID = dup_xml_str(ExternalID);
3544        (*dtd).SystemID = dup_xml_str(SystemID);
3545        (*dtd).parent = doc;
3546        (*dtd).doc = doc;
3547
3548        // UPSTREAM-PARITY (tree.c xmlNewDtd): a freshly created DTD node
3549        // becomes the document's EXTERNAL subset (doc->extSubset); the
3550        // internal subset is created via xmlCreateIntSubset.
3551        // nokogiri create_external_subset reads doc->extSubset back as
3552        // Document#external_subset.
3553
3554        // Attach to document as the external subset
3555        if !doc.is_null() {
3556            (*doc).extSubset = dtd;
3557        }
3558    }
3559
3560    dtd
3561}
3562
3563/// Free a DTD.
3564///
3565/// # SAFETY
3566///
3567/// - `dtd` must be a valid pointer to an _xmlDtd, or NULL.
3568unsafe fn free_dtd(dtd: *mut _xmlDtd) {
3569    if dtd.is_null() {
3570        return;
3571    }
3572
3573    let _d = unsafe { &mut *dtd };
3574    let d = &mut *dtd;
3575
3576    // UPSTREAM-PARITY (tree.c xmlFreeDtd): element/attribute/entity
3577    // declaration nodes in the child list are owned by the hash tables and
3578    // are freed by the deallocators below; only non-declaration children
3579    // (comments, PIs) are unlinked and freed from the list here. This must
3580    // run BEFORE the hash tables are freed so the decl nodes are still alive
3581    // when their type is inspected.
3582    if !d.children.is_null() {
3583        let mut c = d.children;
3584        while !c.is_null() {
3585            let next = (*c).next;
3586            let t = (*c).type_;
3587            if t != XML_ELEMENT_DECL as c_int
3588                && t != XML_ATTRIBUTE_DECL as c_int
3589                && t != XML_ENTITY_DECL as c_int
3590            {
3591                unlink_node_internal(c, ptr::null_mut());
3592                free_node(c);
3593            }
3594            c = next;
3595        }
3596    }
3597
3598    // Free name
3599    if !d.name.is_null() {
3600        allocator::xmlFreeImpl(d.name as *mut c_void);
3601    }
3602    if !d.ExternalID.is_null() {
3603        allocator::xmlFreeImpl(d.ExternalID as *mut c_void);
3604    }
3605    if !d.SystemID.is_null() {
3606        allocator::xmlFreeImpl(d.SystemID as *mut c_void);
3607    }
3608
3609    // Free hash tables for declarations
3610    /// Hash-table deallocator shim that frees an `_xmlNotation` payload.
3611    ///
3612    /// # Safety
3613    ///
3614    /// - `payload` must be NULL or a valid pointer to an `_xmlNotation` owned
3615    ///   exclusively by the hash table being freed; it is freed with
3616    ///   `free_notation`.
3617    /// - `_name` is unused.
3618    unsafe extern "C" fn free_notation_wrapper(payload: *mut c_void, _name: *mut u8) {
3619        crate::xml::dtd::free_notation(payload as *mut _xmlNotation);
3620    }
3621    /// Hash-table deallocator shim that frees an `_xmlElement` payload.
3622    ///
3623    /// # Safety
3624    ///
3625    /// - `payload` must be NULL or a valid pointer to an `_xmlElement` owned
3626    ///   exclusively by the hash table being freed; it is freed with
3627    ///   `free_element`.
3628    /// - `_name` is unused.
3629    unsafe extern "C" fn free_element_wrapper(payload: *mut c_void, _name: *mut u8) {
3630        crate::xml::dtd::free_element(payload as *mut _xmlElement);
3631    }
3632    /// Hash-table deallocator shim that frees an `_xmlAttribute` payload.
3633    ///
3634    /// # Safety
3635    ///
3636    /// - `payload` must be NULL or a valid pointer to an `_xmlAttribute` owned
3637    ///   exclusively by the hash table being freed; it is freed with
3638    ///   `free_attribute`.
3639    /// - `_name` is unused.
3640    unsafe extern "C" fn free_attribute_wrapper(payload: *mut c_void, _name: *mut u8) {
3641        crate::xml::dtd::free_attribute(payload as *mut _xmlAttribute);
3642    }
3643    /// Hash-table deallocator shim that frees an `_xmlEntity` payload.
3644    ///
3645    /// # Safety
3646    ///
3647    /// - `payload` must be NULL or a valid pointer to an `_xmlEntity` owned
3648    ///   exclusively by the hash table being freed; it is freed with
3649    ///   `free_entity`.
3650    /// - `_name` is unused.
3651    unsafe extern "C" fn free_entity_wrapper(payload: *mut c_void, _name: *mut u8) {
3652        crate::xml::entities::free_entity(payload as *mut _xmlEntity);
3653    }
3654
3655    if !d.notations.is_null() {
3656        crate::xml::hash::hash_free(
3657            d.notations as *mut crate::xml::hash::HashTable,
3658            Some(free_notation_wrapper),
3659        );
3660        d.notations = ptr::null_mut();
3661    }
3662    if !d.elements.is_null() {
3663        crate::xml::hash::hash_free(
3664            d.elements as *mut crate::xml::hash::HashTable,
3665            Some(free_element_wrapper),
3666        );
3667        d.elements = ptr::null_mut();
3668    }
3669    if !d.attributes.is_null() {
3670        crate::xml::hash::hash_free(
3671            d.attributes as *mut crate::xml::hash::HashTable,
3672            Some(free_attribute_wrapper),
3673        );
3674        d.attributes = ptr::null_mut();
3675    }
3676    if !d.entities.is_null() {
3677        crate::xml::hash::hash_free(
3678            d.entities as *mut crate::xml::hash::HashTable,
3679            Some(free_entity_wrapper),
3680        );
3681        d.entities = ptr::null_mut();
3682    }
3683    if !d.pentities.is_null() {
3684        crate::xml::hash::hash_free(
3685            d.pentities as *mut crate::xml::hash::HashTable,
3686            Some(free_entity_wrapper),
3687        );
3688        d.pentities = ptr::null_mut();
3689    }
3690
3691    allocator::xmlFreeImpl(dtd as *mut c_void);
3692}
3693
3694// ═══════════════════════════════════════════════════════════════════════════════
3695// Entity Operations
3696// ═══════════════════════════════════════════════════════════════════════════════
3697
3698/// Create a new entity.
3699///
3700/// # UPSTREAM-PARITY
3701///
3702/// ```c
3703/// xmlEntityPtr xmlNewEntity(xmlDocPtr doc, const xmlChar *name, int type,
3704///                           const xmlChar *ExternalID, const xmlChar *SystemID,
3705///                           const xmlChar *content);
3706/// ```
3707///
3708/// # SAFETY
3709///
3710/// - `doc` may be NULL.
3711/// - `name` must be a valid null-terminated string.
3712/// - `ExternalID`, `SystemID`, `content` may be NULL.
3713pub unsafe fn new_entity(
3714    _doc: *mut _xmlDoc,
3715    name: *const xmlChar,
3716    etype: c_int,
3717    ExternalID: *const xmlChar,
3718    SystemID: *const xmlChar,
3719    content: *const xmlChar,
3720) -> *mut _xmlEntity {
3721    let entity = allocator::xmlMallocZero(size_of::<_xmlEntity>() as usize) as *mut _xmlEntity;
3722    if entity.is_null() {
3723        return ptr::null_mut();
3724    }
3725
3726    unsafe {
3727        (*entity).type_ = XML_ENTITY_DECL as c_int;
3728        (*entity).name = dup_xml_str(name);
3729        (*entity).etype = etype;
3730        (*entity).ExternalID = dup_xml_str(ExternalID);
3731        (*entity).SystemID = dup_xml_str(SystemID);
3732        (*entity).content = dup_xml_str(content);
3733        (*entity).length = if content.is_null() {
3734            0
3735        } else {
3736            crate::abi::exports_xml2::xmlStrlen(content)
3737        };
3738        (*entity).flags = 0;
3739        (*entity).expandedSize = 0;
3740    }
3741
3742    entity
3743}
3744
3745/// Get a document entity by name.
3746///
3747/// # UPSTREAM-PARITY
3748///
3749/// ```c
3750/// xmlEntityPtr xmlGetDocEntity(xmlDocPtr doc, const xmlChar *name);
3751/// ```
3752///
3753/// Returns the entity, or NULL if not found.
3754///
3755/// # SAFETY
3756///
3757/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
3758/// - `name` must be a valid null-terminated string.
3759pub unsafe fn get_doc_entity(doc: *const _xmlDoc, name: *const xmlChar) -> *mut _xmlEntity {
3760    crate::xml::entities::get_entity(doc as *mut _xmlDoc, name)
3761}
3762
3763/// Add an entity declaration to the document's internal subset (upstream
3764/// entities.c `xmlAddDocEntity`); creates the internal subset when absent.
3765///
3766/// # SAFETY
3767///
3768/// - `doc` must be a valid document pointer or NULL.
3769/// - `name` must be a valid null-terminated string.
3770pub unsafe fn add_doc_entity(
3771    doc: *mut _xmlDoc,
3772    name: *const xmlChar,
3773    etype: c_int,
3774    ExternalID: *const xmlChar,
3775    SystemID: *const xmlChar,
3776    content: *const xmlChar,
3777) -> *mut _xmlEntity {
3778    if doc.is_null() || name.is_null() {
3779        return ptr::null_mut();
3780    }
3781    unsafe {
3782        let mut dtd = (*doc).intSubset;
3783        if dtd.is_null() {
3784            dtd = new_dtd(
3785                doc,
3786                c"internal".as_ptr() as *const xmlChar,
3787                ptr::null(),
3788                ptr::null(),
3789            );
3790            if dtd.is_null() {
3791                return ptr::null_mut();
3792            }
3793        }
3794        crate::xml::entities::add_entity(dtd, name, etype, ExternalID, SystemID, content)
3795    }
3796}
3797
3798/// Add an entity declaration to the document's external subset (upstream
3799/// entities.c `xmlAddDtdEntity`); creates the external subset when absent.
3800///
3801/// # SAFETY
3802///
3803/// - `doc` must be a valid document pointer or NULL.
3804/// - `name` must be a valid null-terminated string.
3805pub unsafe fn add_dtd_entity(
3806    doc: *mut _xmlDoc,
3807    name: *const xmlChar,
3808    etype: c_int,
3809    ExternalID: *const xmlChar,
3810    SystemID: *const xmlChar,
3811    content: *const xmlChar,
3812) -> *mut _xmlEntity {
3813    if doc.is_null() || name.is_null() {
3814        return ptr::null_mut();
3815    }
3816    unsafe {
3817        let mut dtd = (*doc).extSubset;
3818        if dtd.is_null() {
3819            dtd = new_dtd(
3820                doc,
3821                c"internal".as_ptr() as *const xmlChar,
3822                ptr::null(),
3823                ptr::null(),
3824            );
3825            if dtd.is_null() {
3826                return ptr::null_mut();
3827            }
3828            (*doc).extSubset = dtd;
3829        }
3830        crate::xml::entities::add_entity(dtd, name, etype, ExternalID, SystemID, content)
3831    }
3832}
3833
3834/// Get an entity declaration from the internal or external subset (upstream
3835/// entities.c `xmlGetDtdEntity`).
3836///
3837/// # SAFETY
3838///
3839/// - `doc` must be a valid document pointer or NULL.
3840/// - `name` must be a valid null-terminated string.
3841pub unsafe fn get_dtd_entity(doc: *const _xmlDoc, name: *const xmlChar) -> *mut _xmlEntity {
3842    if doc.is_null() || name.is_null() {
3843        return ptr::null_mut();
3844    }
3845    unsafe {
3846        if !(*doc).intSubset.is_null() {
3847            let e = crate::xml::entities::get_entity_from_dtd((*doc).intSubset, name);
3848            if !e.is_null() {
3849                return e;
3850            }
3851        }
3852        if !(*doc).extSubset.is_null() {
3853            return crate::xml::entities::get_entity_from_dtd((*doc).extSubset, name);
3854        }
3855        ptr::null_mut()
3856    }
3857}
3858
3859/// Get a parameter entity by name.
3860///
3861/// # UPSTREAM-PARITY
3862///
3863/// ```c
3864/// xmlEntityPtr xmlGetParameterEntity(xmlDocPtr doc, const xmlChar *name);
3865/// ```
3866///
3867/// # SAFETY
3868///
3869/// - `doc` must be a valid pointer to an _xmlDoc, or NULL.
3870/// - `name` must be a valid null-terminated string.
3871pub unsafe fn get_parameter_entity(doc: *const _xmlDoc, name: *const xmlChar) -> *mut _xmlEntity {
3872    crate::xml::entities::get_parameter_entity(doc as *mut _xmlDoc, name)
3873}
3874
3875// ═══════════════════════════════════════════════════════════════════════════════
3876// XML Serialization
3877// ═══════════════════════════════════════════════════════════════════════════════
3878//
3879// Functions for serializing XML document/node trees to text.
3880// All output is UTF-8.
3881
3882/// Entity replacement strings (as xmlChar byte slices).
3883const ENTITY_LT: &[xmlChar] = b"&lt;";
3884const ENTITY_GT: &[xmlChar] = b"&gt;";
3885const ENTITY_AMP: &[xmlChar] = b"&amp;";
3886const ENTITY_QUOT: &[xmlChar] = b"&quot;";
3887#[allow(dead_code)]
3888const ENTITY_APOS: &[xmlChar] = b"&apos;";
3889
3890/// Indentation string (libxml2's default `xmlTreeIndentString`).
3891const INDENT: &[xmlChar] = b"  ";
3892
3893/// Maximum indent buffer size (libxml2 `MAX_INDENT` in xmlsave.c).
3894const MAX_INDENT: c_int = 60;
3895
3896/// Serialize text content with XML escaping.
3897///
3898/// Decode one UTF-8 sequence whose first byte is at `bytes[i]` and write the
3899/// upstream `xmlSerializeHexCharRef` form (`&#x%X;`, uppercase hex, no
3900/// padding). Returns the number of input bytes consumed (1 on invalid
3901/// sequences — the upstream U+FFFD fallback advances one byte).
3902///
3903/// # Safety
3904///
3905/// - `bytes` must be readable for `len` bytes with `i < len`.
3906unsafe fn write_utf8_hex_char_ref(buf: *mut _xmlBuffer, bytes: &[u8], i: usize) -> usize {
3907    // SAFETY: caller guarantees i < len.
3908    let first = bytes[i];
3909    let (n, mut val): (usize, u32) = if first < 0x80 {
3910        (1, first as u32)
3911    } else if first < 0xE0 {
3912        if i + 1 < bytes.len() {
3913            (
3914                2,
3915                ((first & 0x1F) as u32) << 6 | (bytes[i + 1] & 0x3F) as u32,
3916            )
3917        } else {
3918            (1, 0xFFFD)
3919        }
3920    } else if first < 0xF0 {
3921        if i + 2 < bytes.len() {
3922            (
3923                3,
3924                ((first & 0x0F) as u32) << 12
3925                    | ((bytes[i + 1] & 0x3F) as u32) << 6
3926                    | (bytes[i + 2] & 0x3F) as u32,
3927            )
3928        } else {
3929            (1, 0xFFFD)
3930        }
3931    } else if first < 0xF8 {
3932        if i + 3 < bytes.len() {
3933            (
3934                4,
3935                ((first & 0x07) as u32) << 18
3936                    | ((bytes[i + 1] & 0x3F) as u32) << 12
3937                    | ((bytes[i + 2] & 0x3F) as u32) << 6
3938                    | (bytes[i + 3] & 0x3F) as u32,
3939            )
3940        } else {
3941            (1, 0xFFFD)
3942        }
3943    } else {
3944        (1, 0xFFFD)
3945    };
3946    if val == 0xFFFE || val == 0xFFFF || val > 0x10FFFF {
3947        val = 0xFFFD;
3948    }
3949    // SAFETY: writes the formatted reference (xmlSerializeHexCharRef).
3950    let hex = format!("&#x{:X};", val);
3951    io::buf_add(buf, hex.as_ptr(), hex.len() as c_int);
3952    n
3953}
3954
3955/// Serialize text content with XML escaping.
3956///
3957/// # UPSTREAM-PARITY
3958///
3959/// Mirrors libxml2 2.15 `xmlSerializeText`. When `escape_non_ascii` is set
3960/// (upstream `XML_ESCAPE_NON_ASCII` — chosen by xmlsave.c `xmlSaveWriteText`
3961/// when the save context has NO output encoder, `ctxt->encoding == NULL`),
3962/// every non-ASCII byte is decoded as UTF-8 and written as a hexadecimal
3963/// character reference — `café` → `caf&#xE9;`, U+00A0 → `&#xA0;` (ext/dom
3964/// dom005's xml save of html-origin text, xmlsave oracle parity). Without the
3965/// flag (a non-NULL save encoding, as in the libxslt save path): `<` →
3966/// `&lt;`, `>` → `&gt;`, `&` → `&amp;`, `\r` → `&#13;`, other control
3967/// characters → hexadecimal character references, while `\n` and `\t` are
3968/// emitted literally and non-ASCII bytes are passed through.
3969///
3970/// # SAFETY
3971///
3972/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
3973/// - `content` must be a valid pointer to `len` bytes of xmlChar data, or NULL.
3974pub(crate) unsafe fn serialize_text_flags(
3975    buf: *mut _xmlBuffer,
3976    content: *const xmlChar,
3977    len: c_int,
3978    escape_non_ascii: bool,
3979) {
3980    if buf.is_null() || content.is_null() || len <= 0 {
3981        return;
3982    }
3983
3984    let bytes = core::slice::from_raw_parts(content, len as usize);
3985    let mut i: usize = 0;
3986    while i < bytes.len() {
3987        let ch = bytes[i];
3988
3989        // Check for `]]>` sequence
3990        if ch == b']' && i + 2 < bytes.len() && bytes[i + 1] == b']' && bytes[i + 2] == b'>' {
3991            // Write `]]&gt;` — escape the `>` that ends `]]>`
3992            io::buf_add(buf, b"]]" as *const u8, 2);
3993            io::buf_add(buf, ENTITY_GT.as_ptr(), ENTITY_GT.len() as c_int);
3994            i += 3;
3995            continue;
3996        }
3997
3998        if ch >= 0x80 && escape_non_ascii {
3999            // SAFETY: i < bytes.len() holds; the helper decodes the UTF-8
4000            // sequence and advances past it.
4001            i += unsafe { write_utf8_hex_char_ref(buf, bytes, i) };
4002            continue;
4003        }
4004
4005        match ch {
4006            b'<' => {
4007                io::buf_add(buf, ENTITY_LT.as_ptr(), ENTITY_LT.len() as c_int);
4008            }
4009            b'&' => {
4010                io::buf_add(buf, ENTITY_AMP.as_ptr(), ENTITY_AMP.len() as c_int);
4011            }
4012            b'>' => {
4013                // UPSTREAM-PARITY: libxml2 escapes `>` to `&gt;` in text content.
4014                // While the XML spec only requires escaping `>` in `]]>`, libxml2's
4015                // serializer escapes all `>` characters.
4016                io::buf_add(buf, ENTITY_GT.as_ptr(), ENTITY_GT.len() as c_int);
4017            }
4018            b'\r' => {
4019                // Carriage return is not allowed literally in XML content.
4020                io::buf_add(buf, b"&#13;" as *const u8, 5);
4021            }
4022            0x01..=0x08 | 0x0B | 0x0C | 0x0E..=0x1F => {
4023                // Other control characters are emitted as hex character refs.
4024                let hex = format!("&#x{:X};", ch);
4025                io::buf_add(buf, hex.as_ptr(), hex.len() as c_int);
4026            }
4027            _ => {
4028                io::buf_add(buf, &ch as *const u8, 1);
4029            }
4030        }
4031        i += 1;
4032    }
4033}
4034
4035/// Serialize an attribute value with XML escaping.
4036///
4037/// # UPSTREAM-PARITY
4038///
4039/// Mirrors libxml2 `xmlBufAttrSerializeTxtContent` (xmlsave.c):
4040/// `\n` → `&#10;`, `\r` → `&#13;`, `\t` → `&#9;`, `"` → `&quot;`,
4041/// `<` → `&lt;`, `>` → `&gt;`, `&` → `&amp;`.
4042///
4043/// # SAFETY
4044///
4045/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
4046/// - `value` must be a valid null-terminated xmlChar string, or NULL.
4047pub(crate) unsafe fn serialize_attr_value(buf: *mut _xmlBuffer, value: *const xmlChar) {
4048    unsafe { serialize_attr_value_flags(buf, value, false) };
4049}
4050
4051/// Like [`serialize_attr_value`] plus the upstream `XML_ESCAPE_NON_ASCII`
4052/// flag (xmlsave.c `xmlSaveWriteText` / `xmlBufAttrSerializeTxtContent`):
4053/// non-ASCII bytes become hexadecimal character references when the save
4054/// context has no output encoder.
4055///
4056/// # SAFETY
4057///
4058/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
4059/// - `value` must be a valid null-terminated xmlChar string, or NULL.
4060pub(crate) unsafe fn serialize_attr_value_flags(
4061    buf: *mut _xmlBuffer,
4062    value: *const xmlChar,
4063    escape_non_ascii: bool,
4064) {
4065    if buf.is_null() || value.is_null() {
4066        return;
4067    }
4068
4069    let len = xml_strlen(value);
4070    let bytes = core::slice::from_raw_parts(value, len as usize);
4071    let mut i: usize = 0;
4072    while i < bytes.len() {
4073        let ch = bytes[i];
4074
4075        if ch >= 0x80 && escape_non_ascii {
4076            // SAFETY: i < bytes.len() holds; the helper decodes the UTF-8
4077            // sequence and advances past it.
4078            i += unsafe { write_utf8_hex_char_ref(buf, bytes, i) };
4079            continue;
4080        }
4081
4082        match ch {
4083            b'\n' => {
4084                io::buf_add(buf, b"&#10;" as *const u8, 5);
4085            }
4086            b'\r' => {
4087                io::buf_add(buf, b"&#13;" as *const u8, 5);
4088            }
4089            b'\t' => {
4090                io::buf_add(buf, b"&#9;" as *const u8, 4);
4091            }
4092            b'<' => {
4093                io::buf_add(buf, ENTITY_LT.as_ptr(), ENTITY_LT.len() as c_int);
4094            }
4095            b'&' => {
4096                io::buf_add(buf, ENTITY_AMP.as_ptr(), ENTITY_AMP.len() as c_int);
4097            }
4098            b'"' => {
4099                io::buf_add(buf, ENTITY_QUOT.as_ptr(), ENTITY_QUOT.len() as c_int);
4100            }
4101            b'>' => {
4102                io::buf_add(buf, ENTITY_GT.as_ptr(), ENTITY_GT.len() as c_int);
4103            }
4104            _ => {
4105                io::buf_add(buf, &ch as *const u8, 1);
4106            }
4107        }
4108        i += 1;
4109    }
4110}
4111
4112/// Write indentation.
4113///
4114/// # UPSTREAM-PARITY
4115///
4116/// Mirrors libxml2 `xmlSaveWriteIndent` (xmlsave.c 2.15): the level is
4117/// capped at `MAX_INDENT / indent_size` (= 30 with the default two-space
4118/// indent string).
4119///
4120/// # SAFETY
4121///
4122/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
4123unsafe fn write_indent(
4124    buf: *mut _xmlBuffer,
4125    level: c_int,
4126    indent: *const xmlChar,
4127    indent_len: c_int,
4128) {
4129    if buf.is_null() || level <= 0 || indent.is_null() || indent_len <= 0 {
4130        return;
4131    }
4132    let indent_nr = MAX_INDENT / indent_len;
4133    let mut lvl = level;
4134    if lvl > indent_nr {
4135        lvl = indent_nr;
4136    }
4137    for _ in 0..lvl {
4138        io::buf_add(buf, indent, indent_len);
4139    }
4140}
4141
4142/// Whether the save context should escape non-ASCII as hex references:
4143/// upstream xmlSaveWriteText sets XML_ESCAPE_NON_ASCII only when
4144/// `ctxt->encoding == NULL` after xmlSaveDocInternal fell back to the
4145/// document's own encoding and tried to switch an output encoder — a real
4146/// (non-native) document encoding gets a converter (no escape; the converter
4147/// produces the target bytes), while a NULL/UTF-8/US-ASCII encoding leaves
4148/// no encoder (escape). The candidate has no converters, so a declared
4149/// non-native encoding keeps the pre-existing pass-through.
4150///
4151/// # Safety
4152///
4153/// - `doc` may be NULL or a valid `_xmlDoc`.
4154pub(crate) fn save_escapes_non_ascii(save_encoding: *const xmlChar, doc: *mut _xmlDoc) -> bool {
4155    if !save_encoding.is_null() {
4156        return false;
4157    }
4158    if doc.is_null() || unsafe { (*doc).encoding }.is_null() {
4159        return true;
4160    }
4161    let enc = unsafe { (*doc).encoding };
4162    let name = unsafe { std::ffi::CStr::from_ptr(enc as *const c_char) }
4163        .to_string_lossy()
4164        .into_owned();
4165    let lower = name.to_ascii_lowercase();
4166    !(lower == "utf-8" || lower == "utf8" || lower == "us-ascii" || lower == "ascii")
4167}
4168
4169/// True if the text node is marked as unescaped (`disable-output-escaping`).
4170///
4171/// # UPSTREAM-PARITY
4172///
4173/// Upstream compares `node->name == xmlStringTextNoenc` (pointer equality
4174/// against a static marker). Our trees carry the marker as a duplicated
4175/// `"textnoenc"` string, so we compare contents.
4176///
4177/// # Safety
4178///
4179/// - `node` must be NULL or a valid pointer to an `_xmlNode`.
4180/// - When `node.name` is non-NULL it must be a valid NUL-terminated `xmlChar`
4181///   string; `c_str_eq_bytes` scans it until the NUL byte.
4182unsafe fn is_noenc_text(node: *mut _xmlNode) -> bool {
4183    if node.is_null() {
4184        return false;
4185    }
4186    let n = unsafe { &*node };
4187    if n.name.is_null() {
4188        return false;
4189    }
4190    c_str_eq_bytes(n.name, b"textnoenc")
4191}
4192
4193/// Compare a NUL-terminated xmlChar string with a byte slice.
4194///
4195/// # Safety
4196///
4197/// - `s` must be a valid pointer to a NUL-terminated `xmlChar` buffer; the
4198///   scan reads `b.len()` bytes plus the terminating NUL at `s[b.len()]`, so
4199///   the buffer must be at least `b.len() + 1` readable bytes.
4200const unsafe fn c_str_eq_bytes(s: *const xmlChar, b: &[u8]) -> bool {
4201    let mut i = 0usize;
4202    while i < b.len() {
4203        if unsafe { *s.add(i) } != b[i] {
4204            return false;
4205        }
4206        i += 1;
4207    }
4208    unsafe { *s.add(i) == 0 }
4209}
4210
4211/// Serialization state mirroring the formatting state of libxml2's
4212/// `xmlSaveCtxt` (xmlsave.c 2.15).
4213#[derive(Clone, Copy)]
4214struct DumpState {
4215    /// `ctxt->format`: 0 = no formatting, 1 = XML_SAVE_FORMAT.
4216    format: c_int,
4217    /// The format value captured at dump entry; restored when leaving an
4218    /// element whose children disabled formatting (upstream local `format`).
4219    saved: c_int,
4220    /// The element whose children disabled formatting (upstream
4221    /// `unformattedNode`).
4222    unformatted: *mut _xmlNode,
4223    /// Per-context indent string (upstream `ctxt->indent`); NULL falls back
4224    /// to the default `xmlTreeIndentString`.
4225    indent: *const xmlChar,
4226    /// Byte length of `indent`.
4227    indent_len: c_int,
4228    /// Suppress the XML declaration (XML_SAVE_NO_DECL, upstream `no_decl`).
4229    no_decl: c_int,
4230    /// The encoding name for the XML declaration (upstream `ctxt->encoding`);
4231    /// NULL means "use the document's own encoding" (upstream
4232    /// `if (encoding == NULL) encoding = cur->encoding;`).
4233    encoding: *const xmlChar,
4234    /// True when this dump entered through the full save path
4235    /// (serialize_node_opts_enc_full — xmlSaveDoc / xmlNodeDumpOutput-style
4236    /// saves): only those honor the upstream XML_ESCAPE_NON_ASCII decision.
4237    /// Bare node dumps (xslt per-child output, debug helpers) keep the raw
4238    /// pass-through.
4239    explicit_save: bool,
4240    /// XHTML mode (upstream `xhtmlNodeDumpOutput`): the document's DTD is an
4241    /// XHTML DTD, so a bare `html` element gets
4242    /// `xmlns="http://www.w3.org/1999/xhtml"` and non-HTML-empty elements
4243    /// serialize as open/close instead of self-closing.
4244    xhtml: bool,
4245    /// HTML output mode (nokogiri SaveOptions::AS_HTML): empty HTML void
4246    /// elements stay `<br>`-style (no slash, no end tag) and other empty
4247    /// non-void elements serialize as `<a></a>`.
4248    as_html: bool,
4249    /// `XML_SAVE_NO_EMPTY`: empty non-void elements get an explicit end tag.
4250    no_empty: bool,
4251}
4252
4253impl DumpState {
4254    const fn new(format: c_int) -> Self {
4255        let f = if format != 0 { 1 } else { 0 };
4256        DumpState {
4257            format: f,
4258            saved: f,
4259            unformatted: ptr::null_mut(),
4260            indent: INDENT.as_ptr(),
4261            indent_len: INDENT.len() as c_int,
4262            no_decl: 0,
4263            encoding: ptr::null(),
4264            xhtml: false,
4265            as_html: false,
4266            no_empty: false,
4267            explicit_save: false,
4268        }
4269    }
4270
4271    /// Create a state with a custom indent string (xmlSaveSetIndentString),
4272    /// the XML_SAVE_NO_DECL option, and an encoding name for the XML
4273    /// declaration (upstream `ctxt->encoding`; NULL = use `doc->encoding`).
4274    ///
4275    /// When `indent` is NULL the caller's global `xmlTreeIndentString` is
4276    /// used (upstream xmlsave.c: `if (ctxt->indent == NULL) indent =
4277    /// xmlTreeIndentString`; nokogiri sets that global before xmlSaveToIO).
4278    ///
4279    /// # SAFETY
4280    ///
4281    /// - `indent` must be NULL or a valid NUL-terminated string that stays
4282    ///   alive for the whole dump.
4283    /// - `encoding` must be NULL or a valid NUL-terminated string that stays
4284    ///   alive for the whole dump.
4285    unsafe fn with_indent_enc(
4286        format: c_int,
4287        indent: *const xmlChar,
4288        no_decl: c_int,
4289        encoding: *const xmlChar,
4290    ) -> Self {
4291        let f = if format != 0 { 1 } else { 0 };
4292        let (ptr, len) = if indent.is_null() {
4293            let g = crate::xml::globals::get_tree_indent_string();
4294            if g.is_null() {
4295                (INDENT.as_ptr(), INDENT.len() as c_int)
4296            } else {
4297                let mut n = 0i32;
4298                while unsafe { *g.add(n as usize) } != 0 {
4299                    n += 1;
4300                }
4301                (g, n)
4302            }
4303        } else {
4304            let mut n = 0i32;
4305            while unsafe { *indent.add(n as usize) } != 0 {
4306                n += 1;
4307            }
4308            (indent, n)
4309        };
4310        DumpState {
4311            format: f,
4312            saved: f,
4313            unformatted: ptr::null_mut(),
4314            indent: ptr,
4315            indent_len: len,
4316            no_decl,
4317            encoding,
4318            as_html: false,
4319            no_empty: false,
4320            xhtml: false,
4321            explicit_save: false,
4322        }
4323    }
4324}
4325
4326/// UPSTREAM-PARITY (tree.c xmlIsXHTML): whether the document's internal
4327/// subset is one of the XHTML 1.0 DTDs (strict, frameset or transitional).
4328/// The XML serializer (xmlsave.c xhtmlNodeDumpOutput) switches to XHTML mode
4329/// when this returns 1.
4330pub(crate) unsafe fn xml_is_xhtml(doc: *mut _xmlDoc) -> bool {
4331    unsafe {
4332        if doc.is_null() {
4333            return false;
4334        }
4335        let d = &*doc;
4336        if d.intSubset.is_null() {
4337            return false;
4338        }
4339        let dtd = &*d.intSubset;
4340        if !dtd.ExternalID.is_null()
4341            && (c_str_eq_bytes(dtd.ExternalID, b"-//W3C//DTD XHTML 1.0 Strict//EN")
4342                || c_str_eq_bytes(dtd.ExternalID, b"-//W3C//DTD XHTML 1.0 Frameset//EN")
4343                || c_str_eq_bytes(dtd.ExternalID, b"-//W3C//DTD XHTML 1.0 Transitional//EN"))
4344        {
4345            return true;
4346        }
4347        if !dtd.SystemID.is_null()
4348            && (c_str_eq_bytes(
4349                dtd.SystemID,
4350                b"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd",
4351            ) || c_str_eq_bytes(
4352                dtd.SystemID,
4353                b"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd",
4354            ) || c_str_eq_bytes(
4355                dtd.SystemID,
4356                b"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd",
4357            ))
4358        {
4359            return true;
4360        }
4361        false
4362    }
4363}
4364
4365/// UPSTREAM-PARITY (xmlsave.c xhtmlIsEmpty): HTML empty elements which stay
4366/// self-closing in XHTML mode.
4367const unsafe fn xhtml_is_empty(name: *const xmlChar) -> bool {
4368    unsafe {
4369        if name.is_null() {
4370            return false;
4371        }
4372        c_str_eq_bytes(name, b"area")
4373            || c_str_eq_bytes(name, b"base")
4374            || c_str_eq_bytes(name, b"basefont")
4375            || c_str_eq_bytes(name, b"br")
4376            || c_str_eq_bytes(name, b"col")
4377            || c_str_eq_bytes(name, b"frame")
4378            || c_str_eq_bytes(name, b"hr")
4379            || c_str_eq_bytes(name, b"img")
4380            || c_str_eq_bytes(name, b"input")
4381            || c_str_eq_bytes(name, b"isindex")
4382            || c_str_eq_bytes(name, b"link")
4383            || c_str_eq_bytes(name, b"meta")
4384            || c_str_eq_bytes(name, b"param")
4385    }
4386}
4387
4388/// Write an element/attribute name with its namespace prefix.
4389///
4390/// # SAFETY
4391///
4392/// - `buf` must be valid; `node` must be a valid element node.
4393unsafe fn write_qname(buf: *mut _xmlBuffer, node: *mut _xmlNode) {
4394    let n = unsafe { &*node };
4395    if !n.ns.is_null() {
4396        let ns = unsafe { &*n.ns };
4397        if !ns.prefix.is_null() {
4398            io::buf_cat(buf, ns.prefix);
4399            io::buf_ccat(buf, b':');
4400        }
4401    }
4402    if !n.name.is_null() {
4403        io::buf_cat(buf, n.name);
4404    }
4405}
4406
4407/// Dump a local namespace definition (upstream `xmlNsDumpOutput`).
4408///
4409/// # SAFETY
4410///
4411/// - `buf` must be valid; `cur` must be a valid `_xmlNs`.
4412unsafe fn ns_dump_output(buf: *mut _xmlBuffer, cur: *mut _xmlNs) {
4413    if cur.is_null() || buf.is_null() {
4414        return;
4415    }
4416    let ns = unsafe { &*cur };
4417    if ns.type_ == XML_LOCAL_NAMESPACE as c_int && !ns.href.is_null() {
4418        // The xml namespace is implicit and never re-declared.
4419        if !ns.prefix.is_null() && c_str_eq_bytes(ns.prefix, b"xml") {
4420            return;
4421        }
4422        io::buf_ccat(buf, b' ');
4423        if !ns.prefix.is_null() {
4424            io::buf_add(buf, b"xmlns:" as *const u8, 6);
4425            io::buf_cat(buf, ns.prefix);
4426        } else {
4427            io::buf_add(buf, b"xmlns" as *const u8, 5);
4428        }
4429        io::buf_add(buf, b"=\"" as *const u8, 2);
4430        serialize_attr_value(buf, ns.href);
4431        io::buf_ccat(buf, b'"');
4432    }
4433}
4434
4435/// Dump an attribute node (upstream `xmlAttrDumpOutput`).
4436///
4437/// `escape_non_ascii` mirrors xmlsave.c `xmlSaveWriteText`'s
4438/// `XML_ESCAPE_NON_ASCII` flag: attribute CONTENT is escaped as hex char
4439/// references when the save context has no output encoder.
4440///
4441/// # SAFETY
4442///
4443/// - `buf` must be valid; `cur` must be a valid `_xmlAttr`.
4444unsafe fn attr_dump_output(buf: *mut _xmlBuffer, cur: *mut _xmlAttr, escape_non_ascii: bool) {
4445    if cur.is_null() || buf.is_null() {
4446        return;
4447    }
4448    io::buf_ccat(buf, b' ');
4449    let a = unsafe { &*cur };
4450    if !a.ns.is_null() {
4451        let ans = unsafe { &*a.ns };
4452        if !ans.prefix.is_null() {
4453            io::buf_cat(buf, ans.prefix);
4454            io::buf_ccat(buf, b':');
4455        }
4456    }
4457    if !a.name.is_null() {
4458        io::buf_cat(buf, a.name);
4459    }
4460    io::buf_add(buf, b"=\"" as *const u8, 2);
4461    // Attribute content: text children are escaped, entity references are
4462    // emitted as `&name;` (upstream `xmlSaveWriteAttrContent`).
4463    let mut child = a.children;
4464    while !child.is_null() {
4465        let ct = unsafe { (*child).type_ };
4466        if ct == XML_TEXT_NODE as c_int && !unsafe { (*child).content }.is_null() {
4467            serialize_attr_value_flags(buf, unsafe { (*child).content }, escape_non_ascii);
4468        } else if ct == XML_ENTITY_REF_NODE as c_int && !unsafe { (*child).name }.is_null() {
4469            io::buf_ccat(buf, b'&');
4470            io::buf_cat(buf, unsafe { (*child).name });
4471            io::buf_ccat(buf, b';');
4472        }
4473        child = unsafe { (*child).next };
4474    }
4475    io::buf_ccat(buf, b'"');
4476}
4477
4478/// Dump a notation declaration (upstream `xmlBufDumpNotationDecl`).
4479///
4480/// # SAFETY
4481///
4482/// - `buf` must be valid; `nota` must be a valid `_xmlNotation`.
4483unsafe fn dump_notation_decl(buf: *mut _xmlBuffer, nota: *mut _xmlNotation) {
4484    let n = unsafe { &*nota };
4485    io::buf_add(buf, b"<!NOTATION " as *const u8, 11);
4486    if !n.name.is_null() {
4487        io::buf_cat(buf, n.name);
4488    }
4489    if !n.PublicID.is_null() {
4490        io::buf_add(buf, b" PUBLIC " as *const u8, 8);
4491        write_quoted_string(buf, n.PublicID);
4492        if !n.SystemID.is_null() {
4493            io::buf_ccat(buf, b' ');
4494            write_quoted_string(buf, n.SystemID);
4495        }
4496    } else {
4497        io::buf_add(buf, b" SYSTEM " as *const u8, 8);
4498        write_quoted_string(buf, n.SystemID);
4499    }
4500    io::buf_add(buf, b" >\n" as *const u8, 4);
4501}
4502
4503/// Dump an occurrence operator (upstream `xmlBufDumpElementOccur`).
4504unsafe fn dump_element_occur(buf: *mut _xmlBuffer, ocur: c_int) {
4505    use crate::abi::types::xmlElementContentOccur::*;
4506    if ocur == XML_ELEMENT_CONTENT_OPT as c_int {
4507        io::buf_ccat(buf, b'?');
4508    } else if ocur == XML_ELEMENT_CONTENT_MULT as c_int {
4509        io::buf_ccat(buf, b'*');
4510    } else if ocur == XML_ELEMENT_CONTENT_PLUS as c_int {
4511        io::buf_ccat(buf, b'+');
4512    }
4513}
4514
4515/// Dump an element content model (upstream `xmlBufDumpElementContent`).
4516///
4517/// # SAFETY
4518///
4519/// - `buf` must be valid; `content` must be a valid content tree or NULL.
4520unsafe fn dump_element_content(buf: *mut _xmlBuffer, content: *mut _xmlElementContent) {
4521    use crate::abi::types::xmlElementContentOccur::*;
4522    use crate::abi::types::xmlElementContentType::*;
4523    if content.is_null() {
4524        return;
4525    }
4526    io::buf_ccat(buf, b'(');
4527    let mut cur = content;
4528    loop {
4529        if cur.is_null() {
4530            return;
4531        }
4532        let c = unsafe { &*cur };
4533        match c.type_ {
4534            t if t == XML_ELEMENT_CONTENT_PCDATA as c_int => {
4535                io::buf_add(buf, b"#PCDATA" as *const u8, 7);
4536            }
4537            t if t == XML_ELEMENT_CONTENT_ELEMENT as c_int => {
4538                if !c.prefix.is_null() {
4539                    io::buf_cat(buf, c.prefix);
4540                    io::buf_ccat(buf, b':');
4541                }
4542                if !c.name.is_null() {
4543                    io::buf_cat(buf, c.name);
4544                }
4545            }
4546            t if t == XML_ELEMENT_CONTENT_SEQ as c_int || t == XML_ELEMENT_CONTENT_OR as c_int => {
4547                if cur != content
4548                    && !c.parent.is_null()
4549                    && (c.type_ != unsafe { (*c.parent).type_ }
4550                        || c.ocur != XML_ELEMENT_CONTENT_ONCE as c_int)
4551                {
4552                    io::buf_ccat(buf, b'(');
4553                }
4554                cur = c.c1;
4555                continue;
4556            }
4557            _ => {}
4558        }
4559
4560        // Walk up until we find the next sibling to process.
4561        while cur != content {
4562            let ccur = unsafe { &*cur };
4563            let parent = ccur.parent;
4564            if parent.is_null() {
4565                return;
4566            }
4567            let p = unsafe { &*parent };
4568            if (ccur.type_ == XML_ELEMENT_CONTENT_OR as c_int
4569                || ccur.type_ == XML_ELEMENT_CONTENT_SEQ as c_int)
4570                && (ccur.type_ != p.type_ || ccur.ocur != XML_ELEMENT_CONTENT_ONCE as c_int)
4571            {
4572                io::buf_ccat(buf, b')');
4573            }
4574            dump_element_occur(buf, ccur.ocur);
4575
4576            if cur == p.c1 {
4577                // UPSTREAM-PARITY (xmlsave.c xmlBufDumpElementContent): the
4578                // " , "/" | " separator belongs to the PARENT combinator and
4579                // is written when ascending from the c1 subtree to c2 — a
4580                // plain-element c1 must still separate (a sequence like
4581                // (title, author) previously lost its separators entirely).
4582                if p.type_ == XML_ELEMENT_CONTENT_SEQ as c_int {
4583                    io::buf_add(buf, b" , " as *const u8, 3);
4584                } else if p.type_ == XML_ELEMENT_CONTENT_OR as c_int {
4585                    io::buf_add(buf, b" | " as *const u8, 3);
4586                }
4587                cur = p.c2;
4588                break;
4589            }
4590            cur = parent;
4591        }
4592        if cur == content {
4593            break;
4594        }
4595    }
4596    io::buf_ccat(buf, b')');
4597    let cc = unsafe { &*content };
4598    dump_element_occur(buf, cc.ocur);
4599}
4600
4601/// Dump an element declaration (upstream `xmlBufDumpElementDecl`).
4602///
4603/// # SAFETY
4604///
4605/// - `buf` must be valid; `elem` must be a valid `_xmlElement`.
4606unsafe fn dump_element_decl(buf: *mut _xmlBuffer, elem: *mut _xmlElement) {
4607    use crate::abi::types::xmlElementTypeVal::*;
4608    let e = unsafe { &*elem };
4609    io::buf_add(buf, b"<!ELEMENT " as *const u8, 10);
4610    if !e.prefix.is_null() {
4611        io::buf_cat(buf, e.prefix);
4612        io::buf_ccat(buf, b':');
4613    }
4614    if !e.name.is_null() {
4615        io::buf_cat(buf, e.name);
4616    }
4617    io::buf_ccat(buf, b' ');
4618    match e.etype {
4619        t if t == XML_ELEMENT_TYPE_EMPTY as c_int => {
4620            io::buf_add(buf, b"EMPTY" as *const u8, 5);
4621        }
4622        t if t == XML_ELEMENT_TYPE_ANY as c_int => {
4623            io::buf_add(buf, b"ANY" as *const u8, 3);
4624        }
4625        t if t == XML_ELEMENT_TYPE_MIXED as c_int || t == XML_ELEMENT_TYPE_ELEMENT as c_int => {
4626            dump_element_content(buf, e.content);
4627        }
4628        _ => {}
4629    }
4630    io::buf_add(buf, b">\n" as *const u8, 2);
4631}
4632
4633/// Dump an enumeration (upstream `xmlBufDumpEnumeration`).
4634///
4635/// # SAFETY
4636///
4637/// - `buf` must be valid; `cur` must be a valid enumeration or NULL.
4638unsafe fn dump_enumeration(buf: *mut _xmlBuffer, cur: *mut _xmlEnumeration) {
4639    let mut e = cur;
4640    while !e.is_null() {
4641        let en = unsafe { &*e };
4642        if !en.name.is_null() {
4643            io::buf_cat(buf, en.name);
4644        }
4645        if !en.next.is_null() {
4646            io::buf_add(buf, b" | " as *const u8, 3);
4647        }
4648        e = en.next;
4649    }
4650    io::buf_ccat(buf, b')');
4651}
4652
4653/// Dump an attribute declaration (upstream `xmlSaveWriteAttributeDecl`).
4654///
4655/// # SAFETY
4656///
4657/// - `buf` must be valid; `attr` must be a valid `_xmlAttribute` decl.
4658unsafe fn dump_attribute_decl(buf: *mut _xmlBuffer, attr: *mut _xmlAttribute) {
4659    use crate::abi::types::xmlAttributeDefault::*;
4660    use crate::abi::types::xmlAttributeType::*;
4661    let a = unsafe { &*attr };
4662    io::buf_add(buf, b"<!ATTLIST " as *const u8, 10);
4663    if !a.elem.is_null() {
4664        io::buf_cat(buf, a.elem);
4665    }
4666    io::buf_ccat(buf, b' ');
4667    if !a.prefix.is_null() {
4668        io::buf_cat(buf, a.prefix);
4669        io::buf_ccat(buf, b':');
4670    }
4671    if !a.name.is_null() {
4672        io::buf_cat(buf, a.name);
4673    }
4674    match a.atype {
4675        t if t == XML_ATTRIBUTE_CDATA as c_int => {
4676            io::buf_add(buf, b" CDATA" as *const u8, 6);
4677        }
4678        t if t == XML_ATTRIBUTE_ID as c_int => {
4679            io::buf_add(buf, b" ID" as *const u8, 3);
4680        }
4681        t if t == XML_ATTRIBUTE_IDREF as c_int => {
4682            io::buf_add(buf, b" IDREF" as *const u8, 6);
4683        }
4684        t if t == XML_ATTRIBUTE_IDREFS as c_int => {
4685            io::buf_add(buf, b" IDREFS" as *const u8, 7);
4686        }
4687        t if t == XML_ATTRIBUTE_ENTITY as c_int => {
4688            io::buf_add(buf, b" ENTITY" as *const u8, 7);
4689        }
4690        t if t == XML_ATTRIBUTE_ENTITIES as c_int => {
4691            io::buf_add(buf, b" ENTITIES" as *const u8, 9);
4692        }
4693        t if t == XML_ATTRIBUTE_NMTOKEN as c_int => {
4694            io::buf_add(buf, b" NMTOKEN" as *const u8, 8);
4695        }
4696        t if t == XML_ATTRIBUTE_NMTOKENS as c_int => {
4697            io::buf_add(buf, b" NMTOKENS" as *const u8, 9);
4698        }
4699        t if t == XML_ATTRIBUTE_ENUMERATION as c_int => {
4700            io::buf_add(buf, b" (" as *const u8, 2);
4701            dump_enumeration(buf, a.tree);
4702        }
4703        t if t == XML_ATTRIBUTE_NOTATION as c_int => {
4704            io::buf_add(buf, b" NOTATION (" as *const u8, 11);
4705            dump_enumeration(buf, a.tree);
4706        }
4707        _ => {}
4708    }
4709    match a.def {
4710        t if t == XML_ATTRIBUTE_REQUIRED as c_int => {
4711            io::buf_add(buf, b" #REQUIRED" as *const u8, 10);
4712        }
4713        t if t == XML_ATTRIBUTE_IMPLIED as c_int => {
4714            io::buf_add(buf, b" #IMPLIED" as *const u8, 9);
4715        }
4716        t if t == XML_ATTRIBUTE_FIXED as c_int => {
4717            io::buf_add(buf, b" #FIXED" as *const u8, 7);
4718        }
4719        _ => {}
4720    }
4721    if !a.defaultValue.is_null() {
4722        io::buf_add(buf, b" \"" as *const u8, 2);
4723        serialize_attr_value(buf, a.defaultValue);
4724        io::buf_ccat(buf, b'"');
4725    }
4726    io::buf_add(buf, b">\n" as *const u8, 2);
4727}
4728
4729/// Write a quoted string (upstream `xmlOutputBufferWriteQuotedString`).
4730///
4731/// # SAFETY
4732///
4733/// - `buf` must be valid; `str` must be a valid NUL-terminated string.
4734unsafe fn write_quoted_string(buf: *mut _xmlBuffer, str: *const xmlChar) {
4735    if buf.is_null() {
4736        return;
4737    }
4738    io::buf_ccat(buf, b'"');
4739    if !str.is_null() {
4740        let mut i = 0usize;
4741        while unsafe { *str.add(i) != 0 } {
4742            let ch = unsafe { *str.add(i) };
4743            if ch == b'"' {
4744                io::buf_add(buf, b"&quot;" as *const u8, 6);
4745            } else {
4746                io::buf_add(buf, &ch as *const u8, 1);
4747            }
4748            i += 1;
4749        }
4750    }
4751    io::buf_ccat(buf, b'"');
4752}
4753
4754/// Dump an entity declaration (upstream `xmlBufDumpEntityDecl`).
4755///
4756/// # SAFETY
4757///
4758/// - `buf` must be valid; `ent` must be a valid `_xmlEntity` decl.
4759unsafe fn dump_entity_decl(buf: *mut _xmlBuffer, ent: *mut _xmlEntity) {
4760    use crate::abi::types::xmlEntityType::*;
4761    let e = unsafe { &*ent };
4762    if e.etype == XML_INTERNAL_PARAMETER_ENTITY as c_int
4763        || e.etype == XML_EXTERNAL_PARAMETER_ENTITY as c_int
4764    {
4765        io::buf_add(buf, b"<!ENTITY % " as *const u8, 11);
4766    } else {
4767        io::buf_add(buf, b"<!ENTITY " as *const u8, 9);
4768    }
4769    if !e.name.is_null() {
4770        io::buf_cat(buf, e.name);
4771    }
4772    io::buf_ccat(buf, b' ');
4773
4774    if e.etype == XML_EXTERNAL_GENERAL_PARSED_ENTITY as c_int
4775        || e.etype == XML_EXTERNAL_GENERAL_UNPARSED_ENTITY as c_int
4776        || e.etype == XML_EXTERNAL_PARAMETER_ENTITY as c_int
4777    {
4778        if !e.ExternalID.is_null() {
4779            io::buf_add(buf, b"PUBLIC " as *const u8, 7);
4780            write_quoted_string(buf, e.ExternalID);
4781            io::buf_ccat(buf, b' ');
4782        } else {
4783            io::buf_add(buf, b"SYSTEM " as *const u8, 7);
4784        }
4785        write_quoted_string(buf, e.SystemID);
4786    }
4787
4788    if e.etype == XML_EXTERNAL_GENERAL_UNPARSED_ENTITY as c_int && !e.content.is_null() {
4789        io::buf_add(buf, b" NDATA " as *const u8, 7);
4790        if !e.orig.is_null() {
4791            io::buf_cat(buf, e.orig);
4792        } else if !e.content.is_null() {
4793            io::buf_cat(buf, e.content);
4794        }
4795    }
4796
4797    if e.etype == XML_INTERNAL_GENERAL_ENTITY as c_int
4798        || e.etype == XML_INTERNAL_PARAMETER_ENTITY as c_int
4799    {
4800        if !e.orig.is_null() {
4801            write_quoted_string(buf, e.orig);
4802        } else {
4803            // Entity content is quoted, escaping `"` and `%`.
4804            io::buf_ccat(buf, b'"');
4805            if !e.content.is_null() {
4806                let mut i = 0usize;
4807                while unsafe { *e.content.add(i) != 0 } {
4808                    let ch = unsafe { *e.content.add(i) };
4809                    match ch {
4810                        b'"' => io::buf_add(buf, b"&quot;" as *const u8, 6),
4811                        b'%' => io::buf_add(buf, b"&#x25;" as *const u8, 6),
4812                        _ => io::buf_add(buf, &ch as *const u8, 1),
4813                    };
4814                    i += 1;
4815                }
4816            }
4817            io::buf_ccat(buf, b'"');
4818        }
4819    }
4820    io::buf_add(buf, b">\n" as *const u8, 2);
4821}
4822
4823/// Dump a DTD node (upstream `xmlDtdDumpOutput`).
4824///
4825/// # SAFETY
4826///
4827/// - `buf` must be valid; `cur` must be a valid DTD node.
4828unsafe fn dtd_dump_output(
4829    buf: *mut _xmlBuffer,
4830    cur: *mut _xmlNode,
4831    state: &mut DumpState,
4832    level: &mut c_int,
4833) {
4834    let dtd = cur as *mut _xmlDtd;
4835    let d = unsafe { &*dtd };
4836    io::buf_add(buf, b"<!DOCTYPE " as *const u8, 10);
4837    if !d.name.is_null() {
4838        io::buf_cat(buf, d.name);
4839    }
4840    if !d.ExternalID.is_null() {
4841        io::buf_add(buf, b" PUBLIC " as *const u8, 8);
4842        write_quoted_string(buf, d.ExternalID);
4843        io::buf_ccat(buf, b' ');
4844        write_quoted_string(buf, d.SystemID);
4845    } else if !d.SystemID.is_null() {
4846        io::buf_add(buf, b" SYSTEM " as *const u8, 8);
4847        write_quoted_string(buf, d.SystemID);
4848    }
4849    // UPSTREAM-PARITY (xmlsave.c xmlDtdDumpOutput): the internal-subset
4850    // brackets are written only when a declaration table is non-NULL — an
4851    // empty DTD (all tables NULL) emits `>`. (hash_size() cannot be used
4852    // here: it returns -1 for NULL tables.)
4853    if d.entities.is_null()
4854        && d.elements.is_null()
4855        && d.attributes.is_null()
4856        && d.notations.is_null()
4857        && d.pentities.is_null()
4858    {
4859        io::buf_ccat(buf, b'>');
4860        return;
4861    }
4862    io::buf_add(buf, b" [\n" as *const u8, 3);
4863    // UPSTREAM-PARITY (xmlsave.c xmlDtdDumpOutput): the internal-subset
4864    // declarations are dumped from the DTD NODE's children list — the
4865    // declaration nodes are linked there in declaration order — NOT from the
4866    // hash tables (which upstream also keeps, but only for lookups; notations
4867    // are hash-only because they never join the child list). The old
4868    // hash-scan emitted declarations in hash-bucket order, which reversed
4869    // multi-declaration files (RESIDUAL R-DTD-DUMP-ORDER; ext/dom +
4870    // ext/simplexml xml_parsing_LIBXML_NO_XXE show `xxe` before `foo`).
4871    let format = state.format;
4872    let lvl = *level;
4873    state.format = 0;
4874    *level = -1;
4875    // Notations first: upstream xmlBufDumpNotationTable (hash-only table).
4876    if !d.notations.is_null() {
4877        crate::xml::hash::hash_scan(
4878            d.notations as *mut crate::xml::hash::HashTable,
4879            Some(dump_notation_decl_cb),
4880            buf as *mut c_void,
4881        );
4882    }
4883    // Declarations in child-list (declaration) order: element, attribute,
4884    // entity and parameter-entity declaration nodes.
4885    let mut decl = d.children;
4886    while !decl.is_null() {
4887        let dt = unsafe { (*decl).type_ };
4888        match dt {
4889            t if t == XML_ELEMENT_DECL as c_int => {
4890                dump_element_decl(buf, decl as *mut _xmlElement);
4891            }
4892            t if t == XML_ATTRIBUTE_DECL as c_int => {
4893                dump_attribute_decl(buf, decl as *mut _xmlAttribute);
4894            }
4895            t if t == XML_ENTITY_DECL as c_int => {
4896                dump_entity_decl(buf, decl as *mut _xmlEntity);
4897            }
4898            _ => {}
4899        }
4900        decl = unsafe { (*decl).next };
4901    }
4902    state.format = format;
4903    *level = lvl;
4904    io::buf_add(buf, b"]>" as *const u8, 2);
4905}
4906
4907/// Hash-scan callback for notation declarations.
4908unsafe extern "C" fn dump_notation_decl_cb(
4909    payload: *mut c_void,
4910    data: *mut c_void,
4911    _name: *const crate::abi::types::xmlChar,
4912) {
4913    if !payload.is_null() && !data.is_null() {
4914        dump_notation_decl(data as *mut _xmlBuffer, payload as *mut _xmlNotation);
4915    }
4916}
4917
4918/// Dump the content of a document (upstream `xmlSaveDocInternal`, XML path).
4919///
4920/// Writes the XML declaration (when not suppressed) followed by each child
4921/// separated by a newline.
4922///
4923/// # SAFETY
4924///
4925/// - `buf` must be valid; `cur` must be a valid document node.
4926unsafe fn doc_content_dump_output(
4927    buf: *mut _xmlBuffer,
4928    cur: *mut _xmlNode,
4929    state: &mut DumpState,
4930    level: &mut c_int,
4931) {
4932    let doc = cur as *mut _xmlDoc;
4933    let d = unsafe { &*doc };
4934
4935    // XML declaration: `<?xml version="..."?>` plus the encoding and
4936    // standalone attributes. The encoding is the save-context encoding when
4937    // one is set (upstream `ctxt->encoding`), falling back to the
4938    // document's own encoding (upstream xmlsave.c xmlSaveDocInternal:
4939    // `if (encoding == NULL) encoding = cur->encoding;`). Suppressed by the
4940    // XML_SAVE_NO_DECL save option (upstream `no_decl`).
4941    if state.no_decl == 0 {
4942        io::buf_add(buf, b"<?xml version=\"" as *const u8, 15);
4943        if !d.version.is_null() {
4944            io::buf_cat(buf, d.version);
4945        } else {
4946            io::buf_add(buf, b"1.0" as *const u8, 3);
4947        }
4948        io::buf_ccat(buf, b'"');
4949        let enc = if state.encoding.is_null() {
4950            d.encoding
4951        } else {
4952            state.encoding
4953        };
4954        if !enc.is_null() {
4955            io::buf_add(buf, b" encoding=\"" as *const u8, 11);
4956            io::buf_cat(buf, enc);
4957            io::buf_ccat(buf, b'"');
4958        }
4959        match d.standalone {
4960            0 => {
4961                io::buf_add(buf, b" standalone=\"no\"" as *const u8, 16);
4962            }
4963            1 => {
4964                io::buf_add(buf, b" standalone=\"yes\"" as *const u8, 17);
4965            }
4966            _ => {}
4967        }
4968        io::buf_add(buf, b"?>\n" as *const u8, 3);
4969    }
4970
4971    // UPSTREAM-PARITY (xmlsave.c xmlSaveDocInternal): the internal subset
4972    // DTD is a member of the children chain (xmlCreateIntSubset inserts it
4973    // before the first element), and the children loop below dumps it once.
4974    // Construction paths that keep the DTD only on doc->intSubset
4975    // (xmlCopyDoc, lazily-created subsets) need the explicit dump. Never
4976    // dump both — that double-prints <!DOCTYPE>. When doc->children is
4977    // EMPTY the DTD must NOT be dumped either: php's modern serializer
4978    // temporarily NULLs doc->children around xmlSaveDoc (to get a
4979    // declaration-only pass) and re-dumps the children itself — dumping the
4980    // intSubset there produced a duplicated <!DOCTYPE> (ext/dom +
4981    // ext/simplexml xml_parsing_LIBXML_NO_XXE).
4982    if !d.children.is_null() && !d.intSubset.is_null() {
4983        let mut in_chain = false;
4984        let mut c = d.children;
4985        while !c.is_null() {
4986            if c as *mut c_void == d.intSubset as *mut c_void {
4987                in_chain = true;
4988                break;
4989            }
4990            c = unsafe { (*c).next };
4991        }
4992        if !in_chain {
4993            let mut lvl = 0;
4994            dtd_dump_output(buf, d.intSubset as *mut _xmlNode, state, &mut lvl);
4995            io::buf_ccat(buf, b'\n');
4996        }
4997    }
4998
4999    if !d.children.is_null() {
5000        let mut child = d.children;
5001        while !child.is_null() {
5002            *level = 0;
5003            node_dump_internal(buf, child, child, cur, state, level);
5004            let ct = unsafe { (*child).type_ };
5005            if ct != XML_XINCLUDE_START as c_int && ct != XML_XINCLUDE_END as c_int {
5006                io::buf_ccat(buf, b'\n');
5007            }
5008            child = unsafe { (*child).next };
5009        }
5010    }
5011}
5012
5013/// Faithful port of libxml2's `xmlNodeDumpOutputInternal` (xmlsave.c 2.15).
5014///
5015/// Serializes `cur` and its descendants into `buf`. `root` is the node this
5016/// invocation started with: the root node itself is never indented, and no
5017/// trailing separator is emitted for it (the caller separates siblings).
5018/// `parent` is the expected parent of `cur`, used by the corrupted-tree
5019/// fallback.
5020///
5021/// # UPSTREAM-PARITY
5022///
5023/// - Indentation (two spaces per level, capped at 30 levels) is written
5024///   before every non-root element, PI and comment when formatting.
5025/// - An element whose children include a text, CDATA or entity-reference
5026///   node disables formatting for its whole content (the `unformattedNode`
5027///   mechanism); formatting is restored when its closing tag is emitted.
5028/// - `\n` separators between siblings are emitted after every child of a
5029///   formatted element (the upstream unwind loop).
5030///
5031/// # SAFETY
5032///
5033/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
5034/// - `cur` must be a valid node pointer; `root`/`parent` must be stable
5035///   pointers into the same tree.
5036unsafe fn node_dump_internal(
5037    buf: *mut _xmlBuffer,
5038    cur: *mut _xmlNode,
5039    root: *mut _xmlNode,
5040    parent: *mut _xmlNode,
5041    state: &mut DumpState,
5042    level: &mut c_int,
5043) {
5044    if cur.is_null() || buf.is_null() {
5045        return;
5046    }
5047    let n = unsafe { &*cur };
5048    match n.type_ {
5049        t if t == XML_ELEMENT_NODE as c_int => {
5050            if cur != root && state.format == 1 {
5051                write_indent(buf, *level, state.indent, state.indent_len);
5052            }
5053            // Corrupted-tree fallback (upstream handles nodes passed with a
5054            // broken parent link by dumping the subtree as its own root).
5055            if !n.parent.is_null() && n.parent != parent && !n.children.is_null() {
5056                let mut sub = DumpState::new(state.format);
5057                let mut sub_level = *level;
5058                node_dump_internal(buf, cur, cur, n.parent, &mut sub, &mut sub_level);
5059                return;
5060            }
5061            // Start tag.
5062            io::buf_ccat(buf, b'<');
5063            write_qname(buf, cur);
5064            // UPSTREAM-PARITY (xmlsave.c xhtmlNodeDumpOutput): in XHTML mode
5065            // a bare <html> element (no ns, no nsDef) gets the XHTML default
5066            // namespace declaration.
5067            if state.xhtml
5068                && !n.name.is_null()
5069                && c_str_eq_bytes(n.name, b"html")
5070                && n.ns.is_null()
5071                && n.nsDef.is_null()
5072            {
5073                io::buf_add(
5074                    buf,
5075                    b" xmlns=\"http://www.w3.org/1999/xhtml\"" as *const u8,
5076                    37,
5077                );
5078            }
5079            let mut nsdef = n.nsDef;
5080            while !nsdef.is_null() {
5081                ns_dump_output(buf, nsdef);
5082                nsdef = unsafe { (*nsdef).next };
5083            }
5084            let mut attr = n.properties;
5085            while !attr.is_null() {
5086                attr_dump_output(
5087                    buf,
5088                    attr,
5089                    state.explicit_save
5090                        && !state.as_html
5091                        && save_escapes_non_ascii(state.encoding, unsafe { (*attr).doc }),
5092                );
5093                attr = unsafe { (*attr).next };
5094            }
5095            if n.children.is_null() {
5096                if state.as_html {
5097                    // UPSTREAM-PARITY (nokogiri to_html / AS_HTML save): in
5098                    // HTML output an empty HTML void element stays
5099                    // `<br>`-style (no slash, no end tag); any other empty
5100                    // element serializes as `<a></a>`.
5101                    if xhtml_is_empty(n.name) {
5102                        io::buf_ccat(buf, b'>');
5103                    } else {
5104                        io::buf_ccat(buf, b'>');
5105                        io::buf_add(buf, b"</" as *const u8, 2);
5106                        write_qname(buf, cur);
5107                        io::buf_ccat(buf, b'>');
5108                    }
5109                } else if state.no_empty {
5110                    // UPSTREAM-PARITY (xmlsave.c XML_SAVE_NO_EMPTY): empty
5111                    // elements get an explicit end tag.
5112                    io::buf_ccat(buf, b'>');
5113                    io::buf_add(buf, b"</" as *const u8, 2);
5114                    write_qname(buf, cur);
5115                    io::buf_ccat(buf, b'>');
5116                } else if state.xhtml && !xhtml_is_empty(n.name) {
5117                    // UPSTREAM-PARITY (xhtmlNodeDumpOutput C.2): in XHTML
5118                    // mode only the HTML-empty elements stay self-closing;
5119                    // everything else (e.g. <html>) serializes as
5120                    // open/close.
5121                    io::buf_ccat(buf, b'>');
5122                    io::buf_add(buf, b"</" as *const u8, 2);
5123                    write_qname(buf, cur);
5124                    io::buf_ccat(buf, b'>');
5125                } else {
5126                    io::buf_add(buf, b"/>" as *const u8, 2);
5127                }
5128            } else {
5129                if state.format == 1 {
5130                    // An element with text/CDATA/entity-ref children is
5131                    // serialized unformatted (upstream unformattedNode).
5132                    let mut tmp = n.children;
5133                    while !tmp.is_null() {
5134                        let tt = unsafe { (*tmp).type_ };
5135                        if tt == XML_TEXT_NODE as c_int
5136                            || tt == XML_CDATA_SECTION_NODE as c_int
5137                            || tt == XML_ENTITY_REF_NODE as c_int
5138                        {
5139                            state.format = 0;
5140                            state.unformatted = cur;
5141                            break;
5142                        }
5143                        tmp = unsafe { (*tmp).next };
5144                    }
5145                }
5146                io::buf_ccat(buf, b'>');
5147                if state.format == 1 {
5148                    io::buf_ccat(buf, b'\n');
5149                }
5150                if *level >= 0 {
5151                    *level += 1;
5152                }
5153                let mut child = n.children;
5154                while !child.is_null() {
5155                    node_dump_internal(buf, child, root, cur, state, level);
5156                    if state.format == 1 {
5157                        let ct = unsafe { (*child).type_ };
5158                        if ct != XML_XINCLUDE_START as c_int && ct != XML_XINCLUDE_END as c_int {
5159                            io::buf_ccat(buf, b'\n');
5160                        }
5161                    }
5162                    child = unsafe { (*child).next };
5163                }
5164                // Closing tag.
5165                if *level > 0 {
5166                    *level -= 1;
5167                }
5168                if state.format == 1 {
5169                    write_indent(buf, *level, state.indent, state.indent_len);
5170                }
5171                io::buf_add(buf, b"</" as *const u8, 2);
5172                write_qname(buf, cur);
5173                io::buf_ccat(buf, b'>');
5174                if cur == state.unformatted {
5175                    state.format = state.saved;
5176                    state.unformatted = ptr::null_mut();
5177                }
5178            }
5179        }
5180        t if t == XML_TEXT_NODE as c_int => {
5181            // UPSTREAM-PARITY (xmlsave.c xmlSaveWriteText): with no output
5182            // encoder on the save context, non-ASCII text is written as hex
5183            // character references. HTML-method output (XSLT method=html /
5184            // AS_HTML) writes raw like upstream's HTML serializer.
5185            let esc = state.explicit_save
5186                && !state.as_html
5187                && save_escapes_non_ascii(state.encoding, n.doc);
5188            if !n.content.is_null() {
5189                if is_noenc_text(cur) {
5190                    io::buf_cat(buf, n.content);
5191                } else {
5192                    serialize_text_flags(buf, n.content, xml_strlen(n.content), esc);
5193                }
5194            } else if !n.children.is_null() {
5195                // Non-compact text node (entity merge): content lives in a
5196                // child text node.
5197                let c = node_get_content(cur);
5198                if !c.is_null() {
5199                    if is_noenc_text(cur) {
5200                        io::buf_cat(buf, c);
5201                    } else {
5202                        serialize_text_flags(buf, c, xml_strlen(c), esc);
5203                    }
5204                    allocator::xmlFreeImpl(c as *mut c_void);
5205                }
5206            }
5207        }
5208        t if t == XML_CDATA_SECTION_NODE as c_int => {
5209            if n.content.is_null() || unsafe { *n.content == 0 } {
5210                io::buf_add(buf, b"<![CDATA[]]>" as *const u8, 12);
5211            } else {
5212                let len = xml_strlen(n.content) as usize;
5213                let bytes = core::slice::from_raw_parts(n.content, len);
5214                let mut i = 0usize;
5215                let mut seg_start = 0usize;
5216                while i < len {
5217                    if bytes[i] == b']'
5218                        && i + 2 < len
5219                        && bytes[i + 1] == b']'
5220                        && bytes[i + 2] == b'>'
5221                    {
5222                        io::buf_add(buf, b"<![CDATA[" as *const u8, 9);
5223                        io::buf_add(buf, n.content.add(seg_start), (i + 2 - seg_start) as c_int);
5224                        io::buf_add(buf, b"]]>" as *const u8, 3);
5225                        seg_start = i + 2;
5226                        i += 3;
5227                        continue;
5228                    }
5229                    i += 1;
5230                }
5231                if seg_start < len {
5232                    io::buf_add(buf, b"<![CDATA[" as *const u8, 9);
5233                    io::buf_add(buf, n.content.add(seg_start), (len - seg_start) as c_int);
5234                    io::buf_add(buf, b"]]>" as *const u8, 3);
5235                }
5236            }
5237        }
5238        t if t == XML_COMMENT_NODE as c_int => {
5239            if cur != root && state.format == 1 {
5240                write_indent(buf, *level, state.indent, state.indent_len);
5241            }
5242            if !n.content.is_null() {
5243                io::buf_add(buf, b"<!--" as *const u8, 4);
5244                io::buf_cat(buf, n.content);
5245                io::buf_add(buf, b"-->" as *const u8, 3);
5246            }
5247        }
5248        t if t == XML_PI_NODE as c_int => {
5249            if cur != root && state.format == 1 {
5250                write_indent(buf, *level, state.indent, state.indent_len);
5251            }
5252            io::buf_add(buf, b"<?" as *const u8, 2);
5253            if !n.name.is_null() {
5254                io::buf_cat(buf, n.name);
5255            }
5256            if !n.content.is_null() && unsafe { *n.content != 0 } {
5257                io::buf_ccat(buf, b' ');
5258                io::buf_cat(buf, n.content);
5259            }
5260            io::buf_add(buf, b"?>" as *const u8, 2);
5261        }
5262        t if t == XML_ENTITY_REF_NODE as c_int => {
5263            io::buf_ccat(buf, b'&');
5264            if !n.name.is_null() {
5265                io::buf_cat(buf, n.name);
5266            }
5267            io::buf_ccat(buf, b';');
5268        }
5269        t if t == XML_DOCUMENT_FRAG_NODE as c_int => {
5270            // UPSTREAM-PARITY (xmlsave.c 2.15 xmlNodeDumpOutputInternal
5271            // `case XML_DOCUMENT_FRAG_NODE:`): a document fragment is a
5272            // transparent container. Upstream trampolines from the fragment
5273            // into its children (validated against `cur->parent`) and dumps
5274            // them as free siblings — the fragment emits no tags, no XML
5275            // declaration, and no indentation of its own. Children keep the
5276            // caller/level semantics of the enclosing context.
5277            let mut child = n.children;
5278            while !child.is_null() {
5279                // Each child is dumped as its own root so a fragment's first
5280                // child gets no leading indent and the caller separates
5281                // sibling content (mirrors doc children handling).
5282                let mut sublevel = *level;
5283                node_dump_internal(buf, child, child, cur, state, &mut sublevel);
5284                child = unsafe { (*child).next };
5285            }
5286        }
5287        t if t == XML_DOCUMENT_NODE as c_int => {
5288            doc_content_dump_output(buf, cur, state, level);
5289        }
5290        t if t == XML_HTML_DOCUMENT_NODE as c_int => {
5291            // UPSTREAM-PARITY (xmlsave.c xmlSaveDocInternal): an HTML
5292            // document is serialized by the HTML serializer only when an
5293            // HTML/XHTML save was requested. Saved as XML (PHP
5294            // DOMDocument::saveXML passes XML_SAVE_AS_XML), the document is
5295            // dumped by the XML serializer — XML declaration (with
5296            // doc->standalone), XML escaping — which is what makes a
5297            // loadHTML()'d document saveXml() print `<?xml version="1.0"
5298            // standalone="yes"?>` (ext/dom dom005/gh15670/gh16535/...).
5299            if state.as_html {
5300                crate::xml::html::serialize_node(cur, buf, state.format, *level);
5301            } else {
5302                doc_content_dump_output(buf, cur, state, level);
5303            }
5304        }
5305        t if t == XML_DTD_NODE as c_int => {
5306            dtd_dump_output(buf, cur, state, level);
5307        }
5308        t if t == XML_ATTRIBUTE_NODE as c_int => {
5309            attr_dump_output(
5310                buf,
5311                cur as *mut _xmlAttr,
5312                save_escapes_non_ascii(state.encoding, unsafe { (*cur).doc }),
5313            );
5314        }
5315        t if t == XML_NAMESPACE_DECL as c_int => {
5316            ns_dump_output(buf, cur as *mut _xmlNs);
5317        }
5318        t if t == XML_ELEMENT_DECL as c_int => {
5319            dump_element_decl(buf, cur as *mut _xmlElement);
5320        }
5321        t if t == XML_ATTRIBUTE_DECL as c_int => {
5322            dump_attribute_decl(buf, cur as *mut _xmlAttribute);
5323        }
5324        t if t == XML_ENTITY_DECL as c_int => {
5325            dump_entity_decl(buf, cur as *mut _xmlEntity);
5326        }
5327        _ => {}
5328    }
5329}
5330
5331/// Recursively serialize a node tree to a buffer.
5332///
5333/// `buf` is an `_xmlBuffer*`, `format` controls indentation (non-zero = pretty-print).
5334///
5335/// # UPSTREAM-PARITY
5336///
5337/// Mirrors `xmlNodeDumpOutputInternal` (xmlsave.c 2.15): the node is treated
5338/// as the root of the dump (no leading indentation, no trailing separator).
5339///
5340/// # SAFETY
5341///
5342/// - `node` must be a valid pointer to an `_xmlNode`, or NULL.
5343/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
5344pub(crate) unsafe fn serialize_node(
5345    node: *mut _xmlNode,
5346    buf: *mut _xmlBuffer,
5347    format: c_int,
5348    level: c_int,
5349) {
5350    unsafe { serialize_node_opt(node, buf, format, level, ptr::null()) };
5351}
5352
5353/// Like `serialize_node`, with a per-context indent string
5354/// (xmlSaveSetIndentString); NULL indent uses the default.
5355///
5356/// # SAFETY
5357///
5358/// - `indent` must be NULL or a valid NUL-terminated string that stays
5359///   alive for the whole dump.
5360pub(crate) unsafe fn serialize_node_opt(
5361    node: *mut _xmlNode,
5362    buf: *mut _xmlBuffer,
5363    format: c_int,
5364    level: c_int,
5365    indent: *const xmlChar,
5366) {
5367    unsafe { serialize_node_opts(node, buf, format, level, indent, 0) };
5368}
5369
5370/// Like `serialize_node_opt`, plus the XML_SAVE_NO_DECL flag.
5371///
5372/// # SAFETY
5373///
5374/// - `indent` must be NULL or a valid NUL-terminated string that stays
5375///   alive for the whole dump.
5376pub(crate) unsafe fn serialize_node_opts(
5377    node: *mut _xmlNode,
5378    buf: *mut _xmlBuffer,
5379    format: c_int,
5380    level: c_int,
5381    indent: *const xmlChar,
5382    no_decl: c_int,
5383) {
5384    unsafe { serialize_node_opts_enc(node, buf, format, level, indent, no_decl, ptr::null()) };
5385}
5386
5387/// Like `serialize_node_opts`, plus an encoding name for the XML
5388/// declaration (upstream `ctxt->encoding`; NULL = use `doc->encoding`).
5389///
5390/// # SAFETY
5391///
5392/// - `indent` must be NULL or a valid NUL-terminated string that stays
5393///   alive for the whole dump.
5394/// - `encoding` must be NULL or a valid NUL-terminated string that stays
5395///   alive for the whole dump.
5396pub(crate) unsafe fn serialize_node_opts_enc(
5397    node: *mut _xmlNode,
5398    buf: *mut _xmlBuffer,
5399    format: c_int,
5400    level: c_int,
5401    indent: *const xmlChar,
5402    no_decl: c_int,
5403    encoding: *const xmlChar,
5404) {
5405    unsafe {
5406        serialize_node_opts_xhtml(node, buf, format, level, indent, no_decl, encoding, false)
5407    };
5408}
5409
5410/// Serialize a node with the full save-option set (node, buffer, format,
5411/// level, indent, no-declaration, no-empty-tags, HTML mode, encoding).
5412/// Mirrors the fields nokogiri's `xmlSaveToIO`/`xmlSaveTree` path threads so
5413/// HTML serialization (`SaveOptions::AS_HTML`) controls empty-element output.
5414///
5415/// # SAFETY
5416///
5417/// - `node` must be NULL or a valid `_xmlNode`; `buf` a valid `_xmlBuffer`;
5418///   `indent`/`encoding` NULL or valid NUL-terminated strings.
5419#[allow(clippy::too_many_arguments)]
5420pub(crate) unsafe fn serialize_node_opts_enc_full(
5421    node: *mut _xmlNode,
5422    buf: *mut _xmlBuffer,
5423    format: c_int,
5424    level: c_int,
5425    indent: *const xmlChar,
5426    no_decl: c_int,
5427    no_empty: c_int,
5428    as_html: c_int,
5429    encoding: *const xmlChar,
5430) {
5431    unsafe {
5432        if node.is_null() || buf.is_null() {
5433            return;
5434        }
5435        let parent = (*node).parent;
5436        let mut state = DumpState::with_indent_enc(format, indent, no_decl, encoding);
5437        state.no_empty = no_empty != 0;
5438        state.as_html = as_html != 0;
5439        state.explicit_save = true;
5440        let mut lvl = level;
5441        node_dump_internal(buf, node, node, parent, &mut state, &mut lvl);
5442    }
5443}
5444
5445/// Serialize a node with full options plus XHTML mode (upstream
5446/// `xhtmlNodeDumpOutput`). When `xhtml` is set, a bare `<html>` element
5447/// receives the XHTML default namespace and non-HTML-empty elements are
5448/// serialized as open/close pairs.
5449///
5450/// # SAFETY
5451///
5452/// - `node` must be NULL or a valid `_xmlNode`; `buf` a valid `_xmlBuffer`;
5453///   `indent`/`encoding` NULL or valid NUL-terminated strings.
5454// The 8 parameters mirror the upstream xmlsave.c dump state (node, buffer,
5455// format, level, indent, no-declaration, encoding, xhtml mode) — the XHTML
5456// flag is threaded alongside the existing serializer state rather than
5457// through the DumpState struct to keep the xhtml gate visible at the call
5458// site.
5459#[allow(clippy::too_many_arguments)]
5460pub(crate) unsafe fn serialize_node_opts_xhtml(
5461    node: *mut _xmlNode,
5462    buf: *mut _xmlBuffer,
5463    format: c_int,
5464    level: c_int,
5465    indent: *const xmlChar,
5466    no_decl: c_int,
5467    encoding: *const xmlChar,
5468    xhtml: bool,
5469) {
5470    unsafe {
5471        if node.is_null() || buf.is_null() {
5472            return;
5473        }
5474        let parent = (*node).parent;
5475        let mut state = DumpState::with_indent_enc(format, indent, no_decl, encoding);
5476        state.xhtml = xhtml;
5477        let mut lvl = level;
5478        node_dump_internal(buf, node, node, parent, &mut state, &mut lvl);
5479    }
5480}
5481
5482/// Dump a document to a buffer.
5483///
5484/// Serializes the entire document tree into `buf`.
5485/// Returns the number of bytes written, or -1 on error.
5486///
5487/// # SAFETY
5488///
5489/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
5490/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5491pub(crate) unsafe fn doc_dump(buf: *mut _xmlBuffer, doc: *mut _xmlDoc) -> c_int {
5492    if buf.is_null() || doc.is_null() {
5493        return -1;
5494    }
5495
5496    let before = io::buf_length(buf);
5497    serialize_node(doc as *mut _xmlNode, buf, 0, 0);
5498    let after = io::buf_length(buf);
5499
5500    if after < 0 || before < 0 {
5501        return -1;
5502    }
5503    after - before
5504}
5505
5506/// Dump a node tree to a buffer.
5507///
5508/// Serializes the node and its descendants into `buf`.
5509/// `level` is the initial indentation level, `format` controls pretty-printing.
5510/// Returns the number of bytes written, or -1 on error.
5511///
5512/// # SAFETY
5513///
5514/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
5515/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5516/// - `node` must be a valid pointer to an `_xmlNode`, or NULL.
5517pub(crate) unsafe fn node_dump(
5518    buf: *mut _xmlBuffer,
5519    doc: *mut _xmlDoc,
5520    node: *mut _xmlNode,
5521    level: c_int,
5522    format: c_int,
5523) -> c_int {
5524    let _ = doc; // Used for entity resolution in full implementation
5525    if buf.is_null() || node.is_null() {
5526        return -1;
5527    }
5528
5529    let before = io::buf_length(buf);
5530    serialize_node(node, buf, format, level);
5531    let after = io::buf_length(buf);
5532
5533    if after < 0 || before < 0 {
5534        return -1;
5535    }
5536    after - before
5537}
5538
5539/// Save a document to a file descriptor.
5540///
5541/// # SAFETY
5542///
5543/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5544/// - `fd` must be a valid open file descriptor.
5545#[allow(dead_code)]
5546pub(crate) unsafe fn save_doc_to_fd(doc: *mut _xmlDoc, fd: c_int, _compression: c_int) -> c_int {
5547    if doc.is_null() || fd < 0 {
5548        return -1;
5549    }
5550
5551    let out = io::output_buffer_create_fd(fd, ptr::null_mut());
5552    if out.is_null() {
5553        return -1;
5554    }
5555
5556    let buf = io::buf_create(-1);
5557    if buf.is_null() {
5558        io::output_buffer_close(out);
5559        return -1;
5560    }
5561
5562    let ret = doc_dump(buf, doc);
5563    if ret >= 0 {
5564        io::output_buffer_write_string(out, io::buf_content(buf) as *const c_char);
5565        io::output_buffer_flush(out);
5566    }
5567
5568    io::buf_free(buf);
5569    io::output_buffer_close(out);
5570    ret
5571}
5572
5573/// Save a document to an xmlBuffer.
5574///
5575/// # SAFETY
5576///
5577/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5578/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
5579#[allow(dead_code)]
5580pub(crate) unsafe fn save_doc_to_buf(
5581    doc: *mut _xmlDoc,
5582    buf: *mut _xmlBuffer,
5583    compression: c_int,
5584) -> c_int {
5585    let _ = compression;
5586    if doc.is_null() || buf.is_null() {
5587        return -1;
5588    }
5589
5590    doc_dump(buf, doc)
5591}
5592
5593/// Format (pretty-print) a document to a buffer.
5594///
5595/// # SAFETY
5596///
5597/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5598/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
5599#[allow(dead_code)]
5600pub(crate) unsafe fn save_format_doc_to_buf(
5601    doc: *mut _xmlDoc,
5602    buf: *mut _xmlBuffer,
5603    compression: c_int,
5604) -> c_int {
5605    let _ = compression;
5606    if doc.is_null() || buf.is_null() {
5607        return -1;
5608    }
5609
5610    let before = io::buf_length(buf);
5611    serialize_node(doc as *mut _xmlNode, buf, 1, 0);
5612    let after = io::buf_length(buf);
5613
5614    if after < 0 || before < 0 {
5615        return -1;
5616    }
5617    after - before
5618}
5619
5620/// Dump a node to a null-terminated string.
5621///
5622/// Returns a pointer to the string (caller must free with `xmlFree`).
5623/// Returns NULL on error.
5624///
5625/// # SAFETY
5626///
5627/// - `node` must be a valid pointer to an `_xmlNode`, or NULL.
5628#[allow(dead_code)]
5629pub(crate) unsafe fn dump_node(node: *mut _xmlNode) -> *mut xmlChar {
5630    if node.is_null() {
5631        return ptr::null_mut();
5632    }
5633
5634    let buf = io::buf_create(-1);
5635    if buf.is_null() {
5636        return ptr::null_mut();
5637    }
5638
5639    serialize_node(node, buf, 0, 0);
5640
5641    let content = io::buf_content(buf);
5642    if content.is_null() {
5643        io::buf_free(buf);
5644        return ptr::null_mut();
5645    }
5646
5647    // Duplicate the string so we can free the buffer
5648    let result = dup_xml_str(content);
5649    io::buf_free(buf);
5650    result
5651}
5652
5653/// Dump a document to a null-terminated string.
5654///
5655/// Returns a pointer to the string (caller must free with `xmlFree`).
5656/// Returns NULL on error.
5657///
5658/// # SAFETY
5659///
5660/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5661pub unsafe fn dump_doc(doc: *mut _xmlDoc) -> *mut xmlChar {
5662    if doc.is_null() {
5663        return ptr::null_mut();
5664    }
5665
5666    let buf = io::buf_create(-1);
5667    if buf.is_null() {
5668        return ptr::null_mut();
5669    }
5670
5671    serialize_node(doc as *mut _xmlNode, buf, 0, 0);
5672
5673    let content = io::buf_content(buf);
5674    if content.is_null() {
5675        io::buf_free(buf);
5676        return ptr::null_mut();
5677    }
5678
5679    let result = dup_xml_str(content);
5680    io::buf_free(buf);
5681    result
5682}
5683
5684// ═══════════════════════════════════════════════════════════════════════════════
5685// ABI-compatible export wrappers
5686// ═══════════════════════════════════════════════════════════════════════════════
5687
5688/// Dump a node to a buffer (ABI wrapper).
5689///
5690/// # UPSTREAM-PARITY
5691///
5692/// ```c
5693/// int xmlNodeDump(xmlBufferPtr buf, xmlDocPtr doc, xmlNodePtr node, int level, int format);
5694/// ```
5695///
5696/// # SAFETY
5697///
5698/// - All pointer arguments must be valid or NULL.
5699pub(crate) unsafe fn xmlNodeDump(
5700    buf: *mut _xmlBuffer,
5701    doc: *mut _xmlDoc,
5702    node: *mut _xmlNode,
5703    level: c_int,
5704    format: c_int,
5705) -> c_int {
5706    node_dump(buf, doc, node, level, format)
5707}
5708
5709/// Dump a document to a FILE*.
5710///
5711/// # UPSTREAM-PARITY
5712///
5713/// ```c
5714/// int xmlDocDump(FILE *fp, xmlDocPtr doc);
5715/// ```
5716///
5717/// # SAFETY
5718///
5719/// - `fp` must be a valid FILE* pointer.
5720/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5721pub(crate) unsafe fn xmlDocDump(fp: *mut c_void, doc: *mut _xmlDoc) -> c_int {
5722    if fp.is_null() || doc.is_null() {
5723        return -1;
5724    }
5725
5726    let buf = io::buf_create(-1);
5727    if buf.is_null() {
5728        return -1;
5729    }
5730
5731    let ret = doc_dump(buf, doc);
5732    if ret < 0 {
5733        io::buf_free(buf);
5734        return -1;
5735    }
5736
5737    let content = io::buf_content(buf);
5738    let len = io::buf_length(buf);
5739    if !content.is_null() && len > 0 {
5740        let written = libc::fwrite(
5741            content as *const c_void,
5742            1,
5743            len as usize,
5744            fp as *mut libc::FILE,
5745        );
5746        io::buf_free(buf);
5747        written as c_int
5748    } else {
5749        io::buf_free(buf);
5750        0
5751    }
5752}
5753
5754/// Dump a document to memory (with format flag).
5755///
5756/// # UPSTREAM-PARITY
5757///
5758/// ```c
5759/// void xmlDocDumpFormatMemory(xmlDocPtr doc, xmlChar **mem, int *size, int format);
5760/// ```
5761///
5762/// # SAFETY
5763///
5764/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5765/// - `mem` must be a valid pointer to an xmlChar* that will receive the
5766///   allocated memory.
5767/// - `size` may be NULL (upstream tree.c xmlDocDumpFormatMemoryEnc only
5768///   writes the length when the pointer is given; `mem` is still produced —
5769///   the recover-cont-probe passes NULL for the length).
5770pub(crate) unsafe fn xmlDocDumpFormatMemory(
5771    doc: *mut _xmlDoc,
5772    mem: *mut *mut xmlChar,
5773    size: *mut c_int,
5774    format: c_int,
5775) {
5776    if doc.is_null() || mem.is_null() {
5777        return;
5778    }
5779
5780    let buf = io::buf_create(-1);
5781    if buf.is_null() {
5782        unsafe {
5783            *mem = ptr::null_mut();
5784            if !size.is_null() {
5785                *size = 0;
5786            }
5787        }
5788        return;
5789    }
5790
5791    serialize_node(doc as *mut _xmlNode, buf, format, 0);
5792
5793    let content = io::buf_content(buf);
5794    let len = io::buf_length(buf);
5795
5796    if !content.is_null() && len > 0 {
5797        // Allocate memory for the result (+1 for null terminator)
5798        let result = allocator::xmlMallocImpl((len + 1) as usize) as *mut xmlChar;
5799        if !result.is_null() {
5800            ptr::copy_nonoverlapping(content, result, len as usize);
5801            *result.add(len as usize) = 0;
5802            unsafe {
5803                *mem = result;
5804                if !size.is_null() {
5805                    *size = len;
5806                }
5807            }
5808        } else {
5809            unsafe {
5810                *mem = ptr::null_mut();
5811                if !size.is_null() {
5812                    *size = 0;
5813                }
5814            }
5815        }
5816    } else {
5817        unsafe {
5818            *mem = ptr::null_mut();
5819            if !size.is_null() {
5820                *size = 0;
5821            }
5822        }
5823    }
5824
5825    io::buf_free(buf);
5826}
5827
5828/// Dump a document to memory (unformatted).
5829///
5830/// # UPSTREAM-PARITY
5831///
5832/// ```c
5833/// void xmlDocDumpMemory(xmlDocPtr doc, xmlChar **mem, int *size);
5834/// ```
5835///
5836/// # SAFETY
5837///
5838/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5839/// - `mem` must be a valid pointer to an xmlChar* that will receive the allocated memory.
5840/// - `size` may be NULL (upstream tree.c xmlDocDumpFormatMemoryEnc).
5841pub(crate) unsafe fn xmlDocDumpMemory(doc: *mut _xmlDoc, mem: *mut *mut xmlChar, size: *mut c_int) {
5842    xmlDocDumpFormatMemory(doc, mem, size, 0)
5843}
5844
5845/// Save a document to a file (ABI wrapper). Upstream tree.c 2.15:
5846/// `xmlSaveFile` = `xmlSaveFormatFileEnc(filename, cur, NULL, 0)`.
5847///
5848/// # UPSTREAM-PARITY
5849///
5850/// ```c
5851/// int xmlSaveFile(const char *filename, xmlDocPtr cur);
5852/// ```
5853///
5854/// # SAFETY
5855///
5856/// - `filename` must be a valid null-terminated C string.
5857/// - `cur` must be a valid pointer to an `_xmlDoc`, or NULL.
5858pub(crate) unsafe fn xmlSaveFile(filename: *const c_char, cur: *mut _xmlDoc) -> c_int {
5859    unsafe { xmlSaveFormatFileEnc(filename, cur, ptr::null(), 0) }
5860}
5861
5862/// Save a document to a file with encoding. Upstream tree.c 2.15:
5863/// `xmlSaveFileEnc` = `xmlSaveFormatFileEnc(filename, cur, encoding, 0)`.
5864///
5865/// # UPSTREAM-PARITY
5866///
5867/// ```c
5868/// int xmlSaveFileEnc(const char *filename, xmlDocPtr cur, const char *encoding);
5869/// ```
5870///
5871/// # SAFETY
5872///
5873/// - `filename` must be a valid null-terminated C string.
5874/// - `cur` must be a valid pointer to an `_xmlDoc`, or NULL.
5875/// - `encoding` may be NULL (uses UTF-8).
5876pub(crate) unsafe fn xmlSaveFileEnc(
5877    filename: *const c_char,
5878    cur: *mut _xmlDoc,
5879    encoding: *const c_char,
5880) -> c_int {
5881    unsafe { xmlSaveFormatFileEnc(filename, cur, encoding, 0) }
5882}
5883
5884/// Save a document to a file with format flag. Upstream tree.c 2.15:
5885/// `xmlSaveFormatFile` = `xmlSaveFormatFileEnc(filename, cur, NULL, format)`.
5886///
5887/// # UPSTREAM-PARITY
5888///
5889/// ```c
5890/// int xmlSaveFormatFile(const char *filename, xmlDocPtr cur, int format);
5891/// ```
5892///
5893/// # SAFETY
5894///
5895/// - `filename` must be a valid null-terminated C string.
5896/// - `cur` must be a valid pointer to an `_xmlDoc`, or NULL.
5897pub(crate) unsafe fn xmlSaveFormatFile(
5898    filename: *const c_char,
5899    cur: *mut _xmlDoc,
5900    format: c_int,
5901) -> c_int {
5902    unsafe { xmlSaveFormatFileEnc(filename, cur, ptr::null(), format) }
5903}
5904
5905/// Save a document to a file with encoding and format flag.
5906///
5907/// Mirrors upstream xmlsave.c 2.15 `xmlSaveFormatFileEnc`: create the output
5908/// buffer ("-" maps to stdout like the oracle), serialize through the save
5909/// machinery (formatting + the encoding declaration), and return the close
5910/// result. The save context's encoding is emitted in the XML declaration and
5911/// drives the output-buffer encoder exactly like upstream xmlDocDumpInternal.
5912///
5913/// # UPSTREAM-PARITY
5914///
5915/// ```c
5916/// int xmlSaveFormatFileEnc(const char *filename, xmlDocPtr cur, const char *encoding, int format);
5917/// ```
5918///
5919/// # SAFETY
5920///
5921/// - `filename` must be a valid null-terminated C string.
5922/// - `cur` must be a valid pointer to an `_xmlDoc`, or NULL.
5923/// - `encoding` may be NULL (uses UTF-8).
5924pub(crate) unsafe fn xmlSaveFormatFileEnc(
5925    filename: *const c_char,
5926    cur: *mut _xmlDoc,
5927    encoding: *const c_char,
5928    format: c_int,
5929) -> c_int {
5930    if cur.is_null() {
5931        return -1;
5932    }
5933    let options = if format != 0 {
5934        crate::xml::save::XML_SAVE_FORMAT
5935    } else {
5936        0
5937    };
5938    let ctxt = crate::xml::save::xmlSaveToFilename(filename, encoding, options);
5939    if ctxt.is_null() {
5940        return -1;
5941    }
5942    crate::xml::save::xmlSaveDoc(ctxt, cur);
5943    crate::xml::save::xmlSaveClose(ctxt)
5944}
5945
5946/// Get the compression mode of a document.
5947///
5948/// # UPSTREAM-PARITY
5949///
5950/// ```c
5951/// int xmlGetDocCompressMode(xmlDocPtr doc);
5952/// ```
5953///
5954/// # SAFETY
5955///
5956/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5957pub(crate) unsafe fn xmlGetDocCompressMode(doc: *mut _xmlDoc) -> c_int {
5958    if doc.is_null() {
5959        return -1;
5960    }
5961    unsafe { (*doc).compression }
5962}
5963
5964/// Set the compression mode of a document.
5965///
5966/// # UPSTREAM-PARITY
5967///
5968/// ```c
5969/// void xmlSetDocCompressMode(xmlDocPtr doc, int mode);
5970/// ```
5971///
5972/// # SAFETY
5973///
5974/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
5975pub(crate) unsafe fn xmlSetDocCompressMode(doc: *mut _xmlDoc, mode: c_int) {
5976    if doc.is_null() {
5977        return;
5978    }
5979    unsafe {
5980        (*doc).compression = mode;
5981    }
5982}
5983
5984#[cfg(test)]
5985mod tests {
5986    use super::*;
5987    use core::ffi::c_void;
5988
5989    /// Helper: allocate a NUL-terminated xmlChar copy of `s`.
5990    ///
5991    /// # Safety
5992    ///
5993    /// - The returned buffer is heap-allocated with `xmlMallocImpl` and must
5994    ///   be freed by the caller with `xmlFreeImpl`; it may be NULL on OOM, so
5995    ///   callers check it before use.
5996    fn c_str(s: &str) -> *const xmlChar {
5997        let bytes = s.as_bytes();
5998        let buf = unsafe { allocator::xmlMallocImpl(bytes.len() + 1) as *mut u8 };
5999        if !buf.is_null() {
6000            unsafe {
6001                ptr::copy_nonoverlapping(bytes.as_ptr(), buf, bytes.len());
6002                *buf.add(bytes.len()) = 0;
6003            }
6004        }
6005        buf as *const xmlChar
6006    }
6007
6008    /// Verify creating and freeing a document.
6009    ///
6010    /// # Safety
6011    ///
6012    /// - `doc` from `new_doc` must be a valid `_xmlDoc` while its fields are
6013    ///   read; it is freed with `free_doc`.
6014    #[test]
6015    fn test_new_free_doc() {
6016        unsafe {
6017            let doc = new_doc(ptr::null());
6018            assert!(!doc.is_null());
6019            assert_eq!((*doc).type_, XML_DOCUMENT_NODE as c_int);
6020            assert_eq!((*doc).standalone, -1);
6021            assert_eq!((*doc).doc, doc);
6022            assert!(!(*doc).version.is_null());
6023            free_doc(doc);
6024        }
6025    }
6026
6027    /// Verify creating a document with a version string.
6028    ///
6029    /// # Safety
6030    ///
6031    /// - The `c_str` buffer must be NUL-terminated and alive while `new_doc`
6032    ///   duplicates it; the buffer is freed with `xmlFreeImpl` and the doc
6033    ///   with `free_doc`.
6034    #[test]
6035    fn test_new_doc_with_version() {
6036        unsafe {
6037            let ver = c_str("2.0");
6038            let doc = new_doc(ver);
6039            assert!(!doc.is_null());
6040            let doc_ver = (*doc).version;
6041            assert!(!doc_ver.is_null());
6042            assert!(crate::abi::exports_xml2::xmlStrEqual(doc_ver, ver,) != 0);
6043            allocator::xmlFreeImpl(ver as *mut c_void);
6044            free_doc(doc);
6045        }
6046    }
6047
6048    /// Verify creating a node.
6049    ///
6050    /// # Safety
6051    ///
6052    /// - The `c_str` buffer must be NUL-terminated and alive while `new_node`
6053    ///   duplicates it; the node is freed with `free_node` and the doc with
6054    ///   `free_doc`.
6055    #[test]
6056    fn test_new_node() {
6057        unsafe {
6058            let doc = new_doc(ptr::null());
6059            let node = new_node(ptr::null_mut(), c_str("root"));
6060            assert!(!node.is_null());
6061            assert_eq!((*node).type_, XML_ELEMENT_NODE as c_int);
6062            assert!(!(*node).name.is_null());
6063            free_node(node);
6064            free_doc(doc);
6065        }
6066    }
6067
6068    /// Verify that `node_get_content` concatenates all descendant text.
6069    ///
6070    /// # Safety
6071    ///
6072    /// - The doc and nodes built by the tree helpers must be valid and linked
6073    ///   before `node_get_content` walks them; `content` is read with
6074    ///   `strlen` and freed with `xmlFreeImpl`, and the doc with `free_doc`.
6075    #[test]
6076    fn test_node_get_content_recurses_descendants() {
6077        // UPSTREAM-PARITY: xmlNodeGetContent (tree.c) concatenates ALL
6078        // descendant text, not just direct text children — the XPath 1.0
6079        // string-value of an element. Regression test for the Phase 9 fix
6080        // where <book><title>Rust</title></book> produced empty content.
6081        unsafe {
6082            let doc = new_doc(ptr::null());
6083            let root = new_node(ptr::null_mut(), c_str("library"));
6084            doc_set_root_element(doc, root);
6085            let book = new_child(root, ptr::null_mut(), c_str("book"));
6086            let title = new_child(book, ptr::null_mut(), c_str("title"));
6087            let text = new_text(c_str("Rust"));
6088            add_child(title, text);
6089
6090            let content = node_get_content(book);
6091            assert!(!content.is_null());
6092            let s = core::slice::from_raw_parts(
6093                content,
6094                libc::strlen(content as *const libc::c_char) as usize,
6095            );
6096            assert_eq!(s, b"Rust", "descendant text not concatenated");
6097            allocator::xmlFreeImpl(content as *mut c_void);
6098
6099            free_doc(doc);
6100        }
6101    }
6102
6103    /// Verify setting the root element of a document.
6104    ///
6105    /// # Safety
6106    ///
6107    /// - `doc` and `root` must be valid pointers while `doc_set_root_element`
6108    ///   relinks them; the doc is freed with `free_doc`.
6109    #[test]
6110    fn test_doc_set_root_element() {
6111        unsafe {
6112            let doc = new_doc(ptr::null());
6113            let root = new_node(ptr::null_mut(), c_str("root"));
6114            let old = doc_set_root_element(doc, root);
6115            assert!(old.is_null());
6116            assert_eq!(doc_get_root_element(doc), root);
6117            assert_eq!((*doc).children, root);
6118            free_doc(doc);
6119        }
6120    }
6121
6122    /// Verify adding children and siblings.
6123    ///
6124    /// # Safety
6125    ///
6126    /// - The doc and nodes built by the tree helpers must be valid and linked
6127    ///   before their `parent`, `children`, `last`, `next`, and `prev` fields
6128    ///   are read; the doc is freed with `free_doc`.
6129    #[test]
6130    fn test_add_child_and_sibling() {
6131        unsafe {
6132            let doc = new_doc(ptr::null());
6133            let root = new_node(ptr::null_mut(), c_str("root"));
6134            doc_set_root_element(doc, root);
6135
6136            let child1 = new_child(root, ptr::null_mut(), c_str("child1"));
6137            assert!(!child1.is_null());
6138            assert_eq!((*child1).parent, root);
6139            assert_eq!((*root).children, child1);
6140            assert_eq!((*root).last, child1);
6141
6142            let child2 = new_child(root, ptr::null_mut(), c_str("child2"));
6143            assert!(!child2.is_null());
6144            assert_eq!((*child2).parent, root);
6145            assert_eq!((*child1).next, child2);
6146            assert_eq!((*child2).prev, child1);
6147            assert_eq!((*root).last, child2);
6148
6149            // Test add_sibling
6150            let sibling = new_node(ptr::null_mut(), c_str("sibling"));
6151            add_sibling(child2, sibling);
6152            assert_eq!((*child2).next, sibling);
6153            assert_eq!((*sibling).prev, child2);
6154            assert_eq!((*root).last, sibling);
6155
6156            free_doc(doc);
6157        }
6158    }
6159
6160    /// Phase 14 PHP DOM regression (domattributes): attaching an ATTRIBUTE
6161    /// node with `xmlAddChild(element, attr)` must route it into the element's
6162    /// PROPERTIES list, not its children list. php's `element->setAttributeNode`
6163    /// (new DOMAttr + setAttributeNode) does exactly this via xmlAddChild. The
6164    /// pre-fix behaviour appended the attribute to `children` — the attribute
6165    /// serialized as a bogus child text node and doubly freed on teardown.
6166    ///
6167    /// # Safety
6168    ///
6169    /// - `root`/`attr` are built and linked as in upstream: an element under a
6170    ///   doc, and a standalone attr from `xmlNewProp(NULL, ...)`, then attached
6171    ///   with `add_child`. The doc is freed once with `free_doc` (proving no
6172    ///   double free of the attached attribute).
6173    #[test]
6174    fn test_add_child_attribute_goes_to_properties() {
6175        unsafe {
6176            let doc = new_doc(ptr::null());
6177            let root = new_node(ptr::null_mut(), c_str("chapter"));
6178            doc_set_root_element(doc, root);
6179
6180            // Mirror php DOMAttr::__construct: a standalone (doc NULL,
6181            // unlinked) attribute with a text value.
6182            let attr = crate::abi::exports_tree::xmlNewProp(
6183                ptr::null_mut(),
6184                c"num".as_ptr() as *const crate::abi::types::xmlChar,
6185                c"1".as_ptr() as *const crate::abi::types::xmlChar,
6186            );
6187            assert!(!attr.is_null());
6188            assert_eq!((*attr).type_, XML_ATTRIBUTE_NODE as c_int);
6189
6190            // xmlAddChild(element, attr) — upstream routes attrs to properties.
6191            let ret = add_child(root, attr as *mut _xmlNode);
6192            assert_eq!(ret, attr as *mut _xmlNode);
6193            assert_eq!((*root).properties, attr);
6194            assert!(
6195                (*root).children.is_null(),
6196                "attr must NOT become a child node"
6197            );
6198            assert!(!(*attr).children.is_null());
6199            assert_eq!((*(*attr).children).parent, attr as *mut _xmlNode);
6200
6201            // Crucially: the doc teardown must not double-free the attribute.
6202            free_doc(doc);
6203        }
6204    }
6205
6206    /// Phase 14 PHP DOM regression (modern/spec serialize_*_xmlns and the
6207    /// modern Dom\XMLDocument namespace mapper): xmlSetNsProp must bind the new
6208    /// attribute to the given `ns`, not drop it. php's modern DOM materialises
6209    /// xmlns declarations as real attributes in the XMLNS namespace via
6210    /// xmlSetNsProp(node, xmlnsNs, prefix|xmlns, href); the legacy stub ignored
6211    /// `ns` and created an UNNAMESPACED `a="urn:a"` attribute, so the doc
6212    /// serialized with a spurious bare attribute and a missing/duplicated
6213    /// namespace declaration.
6214    ///
6215    /// # Safety
6216    ///
6217    /// - The doc/root/ns are valid while `set_ns_prop` runs; the namespace is
6218    ///   a detached, dedicated declaration on `root`; the doc is freed with
6219    ///   `free_doc`.
6220    #[test]
6221    fn test_set_ns_prop_binds_namespace() {
6222        unsafe {
6223            let doc = new_doc(ptr::null());
6224            let root = new_node(ptr::null_mut(), c_str("root"));
6225            doc_set_root_element(doc, root);
6226
6227            // xmlNewNs(node, href, prefix): declares xmlns:a="urn:a" on root.
6228            let ns = new_ns(root, c_str("urn:a"), c_str("a"));
6229
6230            let attr = set_ns_prop(root, ns, c_str("a"), c_str("urn:a"));
6231            assert!(!attr.is_null());
6232            assert_eq!((*root).properties, attr);
6233            // CRITICAL: the attribute must carry the namespace, not be bare.
6234            assert_eq!((*attr).ns, ns);
6235            assert!(!(*attr).name.is_null());
6236
6237            free_doc(doc);
6238        }
6239    }
6240
6241    /// Phase 14 PHP DOM regression (gh12870_b): searching for the reserved XML
6242    /// namespace URI must bind prefix `xml` (it is implicitly in scope), even
6243    /// on a freshly built document with no xmlns:xml declaration. Without the
6244    /// doc->oldNs fallback php DOMDocument::createAttributeNS
6245    /// ('http://www.w3.org/XML/1998/namespace', 'xml') resolved to a synthetic
6246    /// "default" prefix instead of the fixed xml prefix.
6247    ///
6248    /// # Safety
6249    ///
6250    /// - The doc/root are valid while search_ns_by_href runs; the doc is freed
6251    ///   with `free_doc` (freeing the materialised doc-level xml declaration).
6252    #[test]
6253    fn test_search_ns_by_href_xml_namespace_returns_xml_prefix() {
6254        unsafe {
6255            let doc = new_doc(ptr::null());
6256            let root = new_node(ptr::null_mut(), c_str("root"));
6257            doc_set_root_element(doc, root);
6258            // root->doc is set by doc_set_root_element; documentElement::first
6259            // usage in php starts from root. Query must succeed.
6260            let found = search_ns_by_href(
6261                doc,
6262                root,
6263                c"http://www.w3.org/XML/1998/namespace".as_ptr() as *const xmlChar,
6264            );
6265            assert!(!found.is_null());
6266            assert!(!(*found).prefix.is_null());
6267            assert!(
6268                crate::abi::exports_xml2::xmlStrEqual(
6269                    (*found).prefix,
6270                    c"xml".as_ptr() as *const xmlChar
6271                ) != 0
6272            );
6273            free_doc(doc);
6274        }
6275    }
6276
6277    /// Phase 14 PHP DOM regression (modern/spec Node_isDefaultNamespace): text,
6278    /// CDATA, comment and PI nodes may carry their `name` as the SHARED static
6279    /// marker `xmlStringText`/`xmlStringComment` (upstream tree.c xmlNewText/
6280    /// xmlNewComment). `free_node` must not `xmlFree` such a name or teardown
6281    /// aborts with `free(): invalid pointer`. Regression guard for the sentinel
6282    /// name-free skip.
6283    ///
6284    /// # Safety
6285    ///
6286    /// - The node is a freshly xmlMalloc'd text node, fully zero-initialised
6287    ///   except its type and sentinel `name`; it owns no other allocations, so
6288    ///   `free_node` releases only the struct.
6289    #[test]
6290    fn test_free_text_node_with_static_name_sentinel() {
6291        unsafe {
6292            let node = allocator::xmlMallocZero(size_of::<_xmlNode>()) as *mut _xmlNode;
6293            assert!(!node.is_null());
6294            (*node).type_ = XML_TEXT_NODE as c_int;
6295            // A text node whose name aliases the shared static marker text.
6296            (*node).name = crate::abi::data_globals::xmlStringText.as_ptr();
6297            // no children/content owned by the node.
6298            free_node(node); // must not free the static marker.
6299        }
6300    }
6301
6302    /// Verify unlinking a node.
6303    ///
6304    /// # Safety
6305    ///
6306    /// - The doc and nodes must be valid while `unlink_node` rewrites the
6307    ///   sibling links; the unlinked node is freed with `free_node` and the
6308    ///   doc with `free_doc`.
6309    #[test]
6310    fn test_unlink_node() {
6311        unsafe {
6312            let doc = new_doc(ptr::null());
6313            let root = new_node(ptr::null_mut(), c_str("root"));
6314            doc_set_root_element(doc, root);
6315
6316            let child1 = new_child(root, ptr::null_mut(), c_str("c1"));
6317            let child2 = new_child(root, ptr::null_mut(), c_str("c2"));
6318
6319            unlink_node(child1);
6320            assert!((*child1).parent.is_null());
6321            assert!((*child1).prev.is_null());
6322            assert!((*child1).next.is_null());
6323            assert_eq!((*root).children, child2);
6324            assert_eq!((*root).last, child2);
6325
6326            free_node(child1);
6327            free_doc(doc);
6328        }
6329    }
6330
6331    /// Verify creating text, comment, and PI nodes.
6332    ///
6333    /// # Safety
6334    ///
6335    /// - The `c_str` buffers must be NUL-terminated and alive while the
6336    ///   creators duplicate them; each node is freed with `free_node`.
6337    #[test]
6338    fn test_text_and_comment_nodes() {
6339        unsafe {
6340            let text = new_text(c_str("hello world"));
6341            assert!(!text.is_null());
6342            assert_eq!((*text).type_, XML_TEXT_NODE as c_int);
6343            assert!(!(*text).content.is_null());
6344            free_node(text);
6345
6346            let comment = new_comment(c_str("my comment"));
6347            assert!(!comment.is_null());
6348            assert_eq!((*comment).type_, XML_COMMENT_NODE as c_int);
6349            free_node(comment);
6350
6351            let pi = new_pi(c_str("xml"), c_str("version='1.0'"));
6352            assert!(!pi.is_null());
6353            assert_eq!((*pi).type_, XML_PI_NODE as c_int);
6354            free_node(pi);
6355        }
6356    }
6357
6358    /// Verify setting and getting a property.
6359    ///
6360    /// # Safety
6361    ///
6362    /// - `doc` and `root` must be valid while `set_prop` and `get_prop` run;
6363    ///   the value returned by `get_prop` is freed with `xmlFreeImpl` and the
6364    ///   doc with `free_doc`.
6365    #[test]
6366    fn test_set_and_get_prop() {
6367        unsafe {
6368            let doc = new_doc(ptr::null());
6369            let root = new_node(ptr::null_mut(), c_str("root"));
6370            doc_set_root_element(doc, root);
6371
6372            let attr = set_prop(root, c_str("id"), c_str("42"));
6373            assert!(!attr.is_null());
6374            assert_eq!((*attr).type_, XML_ATTRIBUTE_NODE as c_int);
6375
6376            let value = get_prop(root, c_str("id"));
6377            assert!(!value.is_null());
6378            assert!(crate::abi::exports_xml2::xmlStrEqual(value, c_str("42")) != 0);
6379            allocator::xmlFreeImpl(value as *mut c_void);
6380
6381            free_doc(doc);
6382        }
6383    }
6384
6385    /// Verify removing a property.
6386    ///
6387    /// # Safety
6388    ///
6389    /// - `doc` and `root` must be valid while `set_prop`, `get_prop`, and
6390    ///   `remove_prop` run; values returned by `get_prop` are freed with
6391    ///   `xmlFreeImpl` and the doc with `free_doc`.
6392    #[test]
6393    fn test_remove_prop() {
6394        unsafe {
6395            let doc = new_doc(ptr::null());
6396            let root = new_node(ptr::null_mut(), c_str("root"));
6397            doc_set_root_element(doc, root);
6398
6399            set_prop(root, c_str("a"), c_str("1"));
6400            set_prop(root, c_str("b"), c_str("2"));
6401
6402            let value = get_prop(root, c_str("a"));
6403            assert!(!value.is_null());
6404            allocator::xmlFreeImpl(value as *mut c_void);
6405
6406            // Remove prop
6407            let attr = (*root).properties;
6408            assert!(!attr.is_null());
6409            let result = remove_prop(attr);
6410            assert_eq!(result, 0);
6411
6412            // Should no longer be found
6413            let value2 = get_prop(root, c_str("a"));
6414            assert!(value2.is_null());
6415
6416            free_doc(doc);
6417        }
6418    }
6419
6420    /// Verify namespace operations: creation, binding, and search.
6421    ///
6422    /// # Safety
6423    ///
6424    /// - `doc`, `root`, and `ns` must be valid while the namespace helpers
6425    ///   run; the `c_str` buffers must be NUL-terminated and alive for the
6426    ///   calls. The doc is freed with `free_doc`.
6427    #[test]
6428    fn test_namespace_operations() {
6429        unsafe {
6430            let doc = new_doc(ptr::null());
6431            let root = new_node(ptr::null_mut(), c_str("root"));
6432            doc_set_root_element(doc, root);
6433
6434            let ns = new_ns(root, c_str("http://example.com"), c_str("ex"));
6435            assert!(!ns.is_null());
6436            assert!(!(*root).nsDef.is_null());
6437
6438            set_ns(root, ns);
6439            assert_eq!((*root).ns, ns);
6440
6441            let found = search_ns(doc, root, c_str("ex"));
6442            assert_eq!(found, ns);
6443
6444            let found_href = search_ns_by_href(doc, root, c_str("http://example.com"));
6445            assert_eq!(found_href, ns);
6446
6447            free_doc(doc);
6448        }
6449    }
6450
6451    /// Verify creating a DTD and attaching it to a document.
6452    ///
6453    /// # Safety
6454    ///
6455    /// - `doc` and `dtd` must be valid while `new_dtd` and `get_int_subset`
6456    ///   run; the `c_str` buffers must be NUL-terminated and alive for the
6457    ///   calls. The doc is freed with `free_doc`.
6458    #[test]
6459    fn test_new_dtd() {
6460        unsafe {
6461            let doc = new_doc(ptr::null());
6462            let dtd = new_dtd(doc, c_str("root"), c_str("-//TEST//DTD"), c_str("test.dtd"));
6463            assert!(!dtd.is_null());
6464            assert_eq!((*dtd).type_, XML_DTD_NODE as c_int);
6465            // UPSTREAM-PARITY (tree.c xmlNewDtd): xmlNewDtd creates the
6466            // document's EXTERNAL subset, so the DTD is reachable as
6467            // doc->extSubset (the xmlCreateIntSubset call sets intSubset).
6468            assert_eq!((*doc).extSubset, dtd);
6469            free_doc(doc);
6470        }
6471    }
6472
6473    /// Verify deep-copying a node.
6474    ///
6475    /// # Safety
6476    ///
6477    /// - `doc` and `root` must be valid while `copy_node` walks the subtree;
6478    ///   the copy is freed with `free_node` and the doc with `free_doc`.
6479    #[test]
6480    fn test_copy_node_deep() {
6481        unsafe {
6482            let doc = new_doc(ptr::null());
6483            let root = new_node(ptr::null_mut(), c_str("root"));
6484            doc_set_root_element(doc, root);
6485            let _child = new_child(root, ptr::null_mut(), c_str("child"));
6486
6487            let copy = copy_node(root, 1);
6488            assert!(!copy.is_null());
6489            assert_eq!((*copy).type_, XML_ELEMENT_NODE as c_int);
6490            // Check child was copied
6491            assert!(!(*copy).children.is_null());
6492            assert_eq!((*(*copy).children).type_, XML_ELEMENT_NODE as c_int);
6493
6494            free_node(copy);
6495            free_doc(doc);
6496        }
6497    }
6498
6499    /// Verify creating a CDATA section node.
6500    ///
6501    /// # Safety
6502    ///
6503    /// - `doc` must be valid and `content` NUL-terminated and alive while
6504    ///   `new_cdata_block` runs; the node is freed with `free_node` and the
6505    ///   doc with `free_doc`.
6506    #[test]
6507    fn test_new_cdata_block() {
6508        unsafe {
6509            let doc = new_doc(ptr::null());
6510            let content = c_str("some <cdata> content");
6511            let cdata = new_cdata_block(doc, content, 20);
6512            assert!(!cdata.is_null());
6513            assert_eq!((*cdata).type_, XML_CDATA_SECTION_NODE as c_int);
6514            free_node(cdata);
6515            free_doc(doc);
6516        }
6517    }
6518
6519    /// A half-million-deep element chain must free WITHOUT C recursion —
6520    /// upstream xmlFreeNodeList walks iteratively (tree.c 2.15) and the
6521    /// recursive version overflowed the stack at php shutdown on the deep
6522    /// Dom\XMLDocument (GH-22570: segv after saveXml's "Maximum call stack
6523    /// size" Error, which this test's process would hit too on a regression
6524    /// because the test thread stack is far below 500k frames).
6525    ///
6526    /// # Safety
6527    ///
6528    /// - doc/root/chain are built with the tree API and freed exactly once
6529    ///   through `free_doc` (children before parents, post-order).
6530    #[test]
6531    fn test_free_deeply_nested_chain_is_iterative() {
6532        unsafe {
6533            let doc = new_doc(c"1.0".as_ptr() as *const xmlChar);
6534            assert!(!doc.is_null());
6535            const DEPTH: usize = 500_000;
6536            let root = new_node(ptr::null_mut(), c_str("a"));
6537            assert!(!root.is_null());
6538            doc_set_root_element(doc, root);
6539            let mut child = root;
6540            for _ in 0..DEPTH {
6541                let next = new_node(ptr::null_mut(), c_str("a"));
6542                assert!(!next.is_null());
6543                assert!(!add_child(child, next).is_null());
6544                child = next;
6545            }
6546            free_doc(doc);
6547        }
6548    }
6549
6550    /// Verify NULL handling in the tree API entry points.
6551    ///
6552    /// # Safety
6553    ///
6554    /// - NULL pointers passed to `new_doc`, `new_node`, `free_node`,
6555    ///   `free_doc`, `unlink_node`, `add_child`, and `add_sibling` must be
6556    ///   accepted without dereference (the test asserts this); all created
6557    ///   objects are freed.
6558    #[test]
6559    fn test_null_handling() {
6560        unsafe {
6561            assert!(!new_doc(ptr::null()).is_null()); // Should succeed with default version
6562            let doc = new_doc(ptr::null());
6563            // UPSTREAM-PARITY (tree.c xmlNewNode): a NULL name is rejected
6564            // up front (HOSTILE-ABI A48).
6565            assert!(new_node(ptr::null_mut(), ptr::null()).is_null());
6566            free_node(ptr::null_mut()); // Should not crash
6567            free_doc(ptr::null_mut()); // Should not crash
6568            unlink_node(ptr::null_mut()); // Should not crash
6569            assert!(add_child(ptr::null_mut(), ptr::null_mut()).is_null());
6570            assert!(add_sibling(ptr::null_mut(), ptr::null_mut()).is_null());
6571            free_doc(doc);
6572        }
6573    }
6574
6575    // ═══════════════════════════════════════════════════════════════════
6576    // Serialization Tests
6577    // ═══════════════════════════════════════════════════════════════════
6578
6579    /// Helper: compare a buffer's content to an expected string.
6580    ///
6581    /// # Safety
6582    ///
6583    /// - `buf` must be a valid pointer to an `_xmlBuffer`; its content and
6584    ///   length are read through `io::buf_content` and `io::buf_length`.
6585    unsafe fn buf_eq_str(buf: *mut _xmlBuffer, expected: &str) -> bool {
6586        let content = io::buf_content(buf);
6587        if content.is_null() {
6588            return expected.is_empty();
6589        }
6590        let len = io::buf_length(buf) as usize;
6591        if len != expected.len() {
6592            return false;
6593        }
6594        let slice = unsafe { core::slice::from_raw_parts(content, len) };
6595        slice == expected.as_bytes()
6596    }
6597
6598    /// Verify serializing an empty document.
6599    ///
6600    /// # Safety
6601    ///
6602    /// - `doc` must be a valid `_xmlDoc` and `buf` a valid `_xmlBuffer` while
6603    ///   `doc_dump` runs; `buf` is freed with `io::buf_free` and the doc with
6604    ///   `free_doc`.
6605    #[test]
6606    fn test_serialize_empty_document() {
6607        unsafe {
6608            let doc = new_doc(ptr::null());
6609            let buf = io::buf_create(-1);
6610            assert!(!buf.is_null());
6611
6612            let ret = doc_dump(buf, doc);
6613            assert!(ret >= 0);
6614
6615            // UPSTREAM-PARITY: xmlDocDump writes the declaration with no
6616            // encoding attribute (doc->encoding is NULL) and a trailing
6617            // newline after it.
6618            let expected = "<?xml version=\"1.0\"?>\n";
6619            assert!(buf_eq_str(buf, expected));
6620
6621            io::buf_free(buf);
6622            free_doc(doc);
6623        }
6624    }
6625
6626    /// Verify serializing an element with text content.
6627    ///
6628    /// # Safety
6629    ///
6630    /// - `doc` and `buf` must be valid while `doc_dump` runs; `buf` is freed
6631    ///   with `io::buf_free` and the doc with `free_doc`.
6632    #[test]
6633    fn test_serialize_element_with_text() {
6634        unsafe {
6635            let doc = new_doc(ptr::null());
6636            let root = new_node(ptr::null_mut(), c_str("root"));
6637            doc_set_root_element(doc, root);
6638
6639            // Add text child
6640            let text = new_text(c_str("hello world"));
6641            add_child(root, text);
6642
6643            let buf = io::buf_create(-1);
6644            assert!(!buf.is_null());
6645
6646            let ret = doc_dump(buf, doc);
6647            assert!(ret >= 0);
6648
6649            let expected = "<?xml version=\"1.0\"?>\n<root>hello world</root>\n";
6650            assert!(buf_eq_str(buf, expected));
6651
6652            io::buf_free(buf);
6653            free_doc(doc);
6654        }
6655    }
6656
6657    /// Verify serializing an element with attributes.
6658    ///
6659    /// # Safety
6660    ///
6661    /// - `doc` and `buf` must be valid while `doc_dump` runs; `buf` is freed
6662    ///   with `io::buf_free` and the doc with `free_doc`.
6663    #[test]
6664    fn test_serialize_element_with_attributes() {
6665        unsafe {
6666            let doc = new_doc(ptr::null());
6667            let root = new_node(ptr::null_mut(), c_str("root"));
6668            doc_set_root_element(doc, root);
6669
6670            set_prop(root, c_str("id"), c_str("42"));
6671            set_prop(root, c_str("name"), c_str("test"));
6672
6673            let buf = io::buf_create(-1);
6674            assert!(!buf.is_null());
6675
6676            let ret = doc_dump(buf, doc);
6677            assert!(ret >= 0);
6678
6679            let expected = "<?xml version=\"1.0\"?>\n<root id=\"42\" name=\"test\"/>\n";
6680            assert!(buf_eq_str(buf, expected));
6681
6682            io::buf_free(buf);
6683            free_doc(doc);
6684        }
6685    }
6686
6687    /// Verify serializing nested elements.
6688    ///
6689    /// # Safety
6690    ///
6691    /// - `doc` and `buf` must be valid while `doc_dump` walks the tree; `buf`
6692    ///   is freed with `io::buf_free` and the doc with `free_doc`.
6693    #[test]
6694    fn test_serialize_nested_elements() {
6695        unsafe {
6696            let doc = new_doc(ptr::null());
6697            let root = new_node(ptr::null_mut(), c_str("root"));
6698            doc_set_root_element(doc, root);
6699
6700            let child = new_child(root, ptr::null_mut(), c_str("child"));
6701            let grandchild = new_child(child, ptr::null_mut(), c_str("gc"));
6702            let text = new_text(c_str("text"));
6703            add_child(grandchild, text);
6704
6705            let buf = io::buf_create(-1);
6706            assert!(!buf.is_null());
6707
6708            let ret = doc_dump(buf, doc);
6709            assert!(ret >= 0);
6710
6711            let expected = "<?xml version=\"1.0\"?>\n<root><child><gc>text</gc></child></root>\n";
6712            assert!(buf_eq_str(buf, expected));
6713
6714            io::buf_free(buf);
6715            free_doc(doc);
6716        }
6717    }
6718
6719    /// Verify formatted serialization output.
6720    ///
6721    /// # Safety
6722    ///
6723    /// - `doc` and `buf` must be valid while `serialize_node` runs; `buf` is
6724    ///   freed with `io::buf_free` and the doc with `free_doc`.
6725    #[test]
6726    fn test_serialize_with_formatting() {
6727        unsafe {
6728            let doc = new_doc(ptr::null());
6729            let root = new_node(ptr::null_mut(), c_str("root"));
6730            doc_set_root_element(doc, root);
6731
6732            let child = new_child(root, ptr::null_mut(), c_str("child"));
6733            let text = new_text(c_str("text"));
6734            add_child(child, text);
6735
6736            let buf = io::buf_create(-1);
6737            assert!(!buf.is_null());
6738
6739            serialize_node(doc as *mut _xmlNode, buf, 1, 0);
6740
6741            let expected = "<?xml version=\"1.0\"?>\n<root>\n  <child>text</child>\n</root>\n";
6742            assert!(buf_eq_str(buf, expected));
6743
6744            io::buf_free(buf);
6745            free_doc(doc);
6746        }
6747    }
6748
6749    /// Verify that `&` is escaped in serialized text.
6750    ///
6751    /// # Safety
6752    ///
6753    /// - `doc` and `buf` must be valid while `doc_dump` runs; `buf` is freed
6754    ///   with `io::buf_free` and the doc with `free_doc`.
6755    #[test]
6756    fn test_serialize_escape_ampersand() {
6757        unsafe {
6758            let doc = new_doc(ptr::null());
6759            let root = new_node(ptr::null_mut(), c_str("root"));
6760            doc_set_root_element(doc, root);
6761
6762            let text = new_text(c_str("a & b"));
6763            add_child(root, text);
6764
6765            let buf = io::buf_create(-1);
6766            assert!(!buf.is_null());
6767
6768            let ret = doc_dump(buf, doc);
6769            assert!(ret >= 0);
6770
6771            let expected = "<?xml version=\"1.0\"?>\n<root>a &amp; b</root>\n";
6772            assert!(buf_eq_str(buf, expected));
6773
6774            io::buf_free(buf);
6775            free_doc(doc);
6776        }
6777    }
6778
6779    /// Verify that angle brackets are escaped in serialized text.
6780    ///
6781    /// # Safety
6782    ///
6783    /// - `doc` and `buf` must be valid while `doc_dump` runs; `buf` is freed
6784    ///   with `io::buf_free` and the doc with `free_doc`.
6785    #[test]
6786    fn test_serialize_escape_angle_brackets() {
6787        unsafe {
6788            let doc = new_doc(ptr::null());
6789            let root = new_node(ptr::null_mut(), c_str("root"));
6790            doc_set_root_element(doc, root);
6791
6792            let text = new_text(c_str("x < y > z"));
6793            add_child(root, text);
6794
6795            let buf = io::buf_create(-1);
6796            assert!(!buf.is_null());
6797
6798            let ret = doc_dump(buf, doc);
6799            assert!(ret >= 0);
6800
6801            let expected = "<?xml version=\"1.0\"?>\n<root>x &lt; y &gt; z</root>\n";
6802            assert!(buf_eq_str(buf, expected));
6803
6804            io::buf_free(buf);
6805            free_doc(doc);
6806        }
6807    }
6808
6809    /// Verify serializing a comment node.
6810    ///
6811    /// # Safety
6812    ///
6813    /// - `doc` and `buf` must be valid while `doc_dump` runs; `buf` is freed
6814    ///   with `io::buf_free` and the doc with `free_doc`.
6815    #[test]
6816    fn test_serialize_comment() {
6817        unsafe {
6818            let doc = new_doc(ptr::null());
6819            let root = new_node(ptr::null_mut(), c_str("root"));
6820            doc_set_root_element(doc, root);
6821
6822            let comment = new_comment(c_str("my comment"));
6823            add_child(root, comment);
6824
6825            let buf = io::buf_create(-1);
6826            assert!(!buf.is_null());
6827
6828            let ret = doc_dump(buf, doc);
6829            assert!(ret >= 0);
6830
6831            let expected = "<?xml version=\"1.0\"?>\n<root><!--my comment--></root>\n";
6832            assert!(buf_eq_str(buf, expected));
6833
6834            io::buf_free(buf);
6835            free_doc(doc);
6836        }
6837    }
6838
6839    /// Verify serializing a processing instruction.
6840    ///
6841    /// # Safety
6842    ///
6843    /// - `doc` and `buf` must be valid while `doc_dump` runs; `buf` is freed
6844    ///   with `io::buf_free` and the doc with `free_doc`.
6845    #[test]
6846    fn test_serialize_pi() {
6847        unsafe {
6848            let doc = new_doc(ptr::null());
6849            let root = new_node(ptr::null_mut(), c_str("root"));
6850            doc_set_root_element(doc, root);
6851
6852            let pi = new_pi(
6853                c_str("xml-stylesheet"),
6854                c_str("href=\"style.xsl\" type=\"text/xsl\""),
6855            );
6856            add_child(root, pi);
6857
6858            let buf = io::buf_create(-1);
6859            assert!(!buf.is_null());
6860
6861            let ret = doc_dump(buf, doc);
6862            assert!(ret >= 0);
6863
6864            let expected = "<?xml version=\"1.0\"?>\n<root><?xml-stylesheet href=\"style.xsl\" type=\"text/xsl\"?></root>\n";
6865            assert!(buf_eq_str(buf, expected));
6866
6867            io::buf_free(buf);
6868            free_doc(doc);
6869        }
6870    }
6871
6872    /// Verify serializing an empty element in self-closing form.
6873    ///
6874    /// # Safety
6875    ///
6876    /// - `doc` and `buf` must be valid while `doc_dump` runs; `buf` is freed
6877    ///   with `io::buf_free` and the doc with `free_doc`.
6878    #[test]
6879    fn test_serialize_self_closing() {
6880        unsafe {
6881            let doc = new_doc(ptr::null());
6882            let root = new_node(ptr::null_mut(), c_str("empty"));
6883            doc_set_root_element(doc, root);
6884
6885            let buf = io::buf_create(-1);
6886            assert!(!buf.is_null());
6887
6888            let ret = doc_dump(buf, doc);
6889            assert!(ret >= 0);
6890
6891            let expected = "<?xml version=\"1.0\"?>\n<empty/>\n";
6892            assert!(buf_eq_str(buf, expected));
6893
6894            io::buf_free(buf);
6895            free_doc(doc);
6896        }
6897    }
6898
6899    /// Verify dumping a node to a string.
6900    ///
6901    /// # Safety
6902    ///
6903    /// - `node` must be a valid `_xmlNode` while `dump_node` serializes it;
6904    ///   `result` is read with `xml_strlen` and freed with `xmlFreeImpl`, and
6905    ///   the node with `free_node`.
6906    #[test]
6907    fn test_dump_node_to_string() {
6908        unsafe {
6909            let node = new_node(ptr::null_mut(), c_str("foo"));
6910            let text = new_text(c_str("bar"));
6911            add_child(node, text);
6912
6913            let result = dump_node(node);
6914            assert!(!result.is_null());
6915
6916            let len = xml_strlen(result);
6917            let slice = { core::slice::from_raw_parts(result, len as usize) };
6918            assert_eq!(slice, b"<foo>bar</foo>");
6919
6920            allocator::xmlFreeImpl(result as *mut c_void);
6921            free_node(node);
6922        }
6923    }
6924
6925    /// Verify dumping a document to a string.
6926    ///
6927    /// # Safety
6928    ///
6929    /// - `doc` must be a valid `_xmlDoc` while `dump_doc` serializes it;
6930    ///   `result` is read with `xml_strlen` and freed with `xmlFreeImpl`, and
6931    ///   the doc with `free_doc`.
6932    #[test]
6933    fn test_dump_doc_to_string() {
6934        unsafe {
6935            let doc = new_doc(ptr::null());
6936            let root = new_node(ptr::null_mut(), c_str("root"));
6937            doc_set_root_element(doc, root);
6938
6939            let result = dump_doc(doc);
6940            assert!(!result.is_null());
6941
6942            let len = xml_strlen(result);
6943            let slice = { core::slice::from_raw_parts(result, len as usize) };
6944            let expected = "<?xml version=\"1.0\"?>\n<root/>\n";
6945            assert_eq!(slice, expected.as_bytes());
6946
6947            allocator::xmlFreeImpl(result as *mut c_void);
6948            free_doc(doc);
6949        }
6950    }
6951
6952    /// Verify `xmlDocDumpFormatMemory`.
6953    ///
6954    /// # Safety
6955    ///
6956    /// - `doc` must be a valid `_xmlDoc` while the export runs; `mem` receives
6957    ///   a callee-owned buffer read as `size` bytes and freed with
6958    ///   `xmlFreeImpl`, and the doc with `free_doc`.
6959    #[test]
6960    fn test_xmlDocDumpFormatMemory() {
6961        unsafe {
6962            let doc = new_doc(ptr::null());
6963            let root = new_node(ptr::null_mut(), c_str("root"));
6964            doc_set_root_element(doc, root);
6965
6966            let mut mem: *mut xmlChar = ptr::null_mut();
6967            let mut size: c_int = 0;
6968
6969            xmlDocDumpFormatMemory(doc, &mut mem, &mut size, 0);
6970
6971            assert!(!mem.is_null());
6972            assert!(size > 0);
6973
6974            let slice = { core::slice::from_raw_parts(mem, size as usize) };
6975            // UPSTREAM-PARITY: xmlDocDumpFormatMemory with a NULL encoding
6976            // writes no encoding attribute and a newline after each child.
6977            let expected = "<?xml version=\"1.0\"?>\n<root/>\n";
6978            assert_eq!(slice, expected.as_bytes());
6979
6980            allocator::xmlFreeImpl(mem as *mut c_void);
6981            free_doc(doc);
6982        }
6983    }
6984
6985    /// Verify escaping of special characters in attribute values.
6986    ///
6987    /// # Safety
6988    ///
6989    /// - `doc` and `buf` must be valid while `doc_dump` runs; `buf` is freed
6990    ///   with `io::buf_free` and the doc with `free_doc`.
6991    #[test]
6992    fn test_serialize_escape_attribute() {
6993        unsafe {
6994            let doc = new_doc(ptr::null());
6995            let root = new_node(ptr::null_mut(), c_str("root"));
6996            doc_set_root_element(doc, root);
6997
6998            // Attribute with special chars
6999            set_prop(root, c_str("desc"), c_str("a < b & c \"quoted\""));
7000
7001            let buf = io::buf_create(-1);
7002            assert!(!buf.is_null());
7003
7004            let ret = doc_dump(buf, doc);
7005            assert!(ret >= 0);
7006
7007            let expected =
7008                "<?xml version=\"1.0\"?>\n<root desc=\"a &lt; b &amp; c &quot;quoted&quot;\"/>\n";
7009            assert!(buf_eq_str(buf, expected));
7010
7011            io::buf_free(buf);
7012            free_doc(doc);
7013        }
7014    }
7015
7016    /// Phase 14.3 PHP regression (DOMParentNode_empty_argument): serializing a
7017    /// document-fragment node must emit its children (transparent container).
7018    /// The missing XML_DOCUMENT_FRAG_NODE arm returned empty output (and a
7019    /// downstream PHP double-destroy) where upstream xmlsave.c trampolines the
7020    /// fragment's children.
7021    ///
7022    /// # Safety
7023    ///
7024    /// - `frag`/`foo` are built under `doc`; `node_dump` walks them; the buffer
7025    ///   is freed with `io::buf_free` and the doc with `free_doc`.
7026    #[test]
7027    fn test_dump_document_fragment_serializes_children() {
7028        unsafe {
7029            let doc = new_doc(ptr::null());
7030            let frag = crate::abi::exports_tree::xmlNewDocFragment(doc);
7031            assert!(!frag.is_null());
7032            // add an element child `<foo/>` to the fragment
7033            let foo = new_child(frag, ptr::null_mut(), c_str("foo"));
7034            assert!(!foo.is_null());
7035
7036            let buf = io::buf_create(-1);
7037            assert!(!buf.is_null());
7038            // Serialize the fragment itself (xmlNodeDump semantics, same
7039            // node_dump_internal path as xmlNodeDumpOutput).
7040            let ret = node_dump(buf, doc, frag, 0, 0);
7041            assert!(ret >= 0);
7042            assert!(buf_eq_str(buf, "<foo/>"));
7043
7044            io::buf_free(buf);
7045            free_doc(doc);
7046        }
7047    }
7048
7049    /// Phase 14.3 Bug-3 regression: `copy_doc` must link the copied
7050    /// top-level children with the NEW document node as their parent (and set
7051    /// `doc->last`), mirroring upstream xmlCopyDoc
7052    /// (`xmlStaticCopyNodeList(doc->children, ret, (xmlNodePtr)ret)`). The
7053    /// pre-fix NULL parent made PHP treat a cloned document's root element as
7054    /// ownerless: its proxy teardown (php_libxml_node_free_resource,
7055    /// `parent == NULL` branch) freed the whole subtree while the cloned doc
7056    /// still referenced it, so the doc teardown double-freed the root
7057    /// (DOMDocument clone + navigation crash).
7058    ///
7059    /// # Safety
7060    ///
7061    /// - doc/root are valid while copy_doc runs; both docs are freed with
7062    ///   `free_doc`.
7063    #[test]
7064    fn test_copy_doc_keeps_doc_as_root_parent() {
7065        unsafe {
7066            let doc = new_doc(ptr::null());
7067            let root = new_node(ptr::null_mut(), c_str("root"));
7068            doc_set_root_element(doc, root);
7069            let child = new_child(root, ptr::null_mut(), c_str("kid"));
7070            assert!(!child.is_null());
7071
7072            let copy = copy_doc(doc, 1);
7073            assert!(!copy.is_null());
7074            let copied_root = (*copy).children;
7075            assert!(!copied_root.is_null());
7076            // UPSTREAM-PARITY: root's parent is the DOCUMENT node.
7077            assert_eq!((*copied_root).parent as *mut c_void, copy as *mut c_void);
7078            assert_eq!((*copied_root).doc as *mut c_void, copy as *mut c_void);
7079            // doc->last tracks the final child.
7080            assert_eq!((*copy).last as *mut c_void, copied_root as *mut c_void);
7081            // child keeps its element parent.
7082            let copied_child = (*copied_root).children;
7083            assert!(!copied_child.is_null());
7084            assert_eq!(
7085                (*copied_child).parent as *mut c_void,
7086                copied_root as *mut c_void
7087            );
7088
7089            free_doc(copy);
7090            free_doc(doc);
7091        }
7092    }
7093}