Skip to main content

libxml_rs/xml/xpath/
types.rs

1//! XPath 1.0 Runtime Types (§25).
2//!
3//! Internal Rust representation of XPath values: node-sets, strings,
4//! numbers, booleans, and conversions between them.
5//!
6//! # UPSTREAM-PARITY
7//!
8//! XPath 1.0 type system with exact IEEE 754 floating-point semantics:
9//! NaN, infinity, negative zero, rounding behavior.
10//!
11//! # Courts
12//!
13//! XPATH-TYPES-*
14//!
15//! # Upstream contract
16//!
17//! Mirrors the value model of upstream `xpath.c` / `xpathInternals.h`
18//! (`SRC-LIBXML2-2.15.0-XPATH-C`, parity target libxml2 2.15.3 oracle):
19//! `xmlXPathObject` types (XPATH_NODESET, XPATH_BOOLEAN, XPATH_NUMBER,
20//! XPATH_STRING, XPATH_POINT/RANGE/LOCATIONSET, XPATH_USERS,
21//! XPATH_XSLT_TREE) and the string/number conversion functions
22//! `xmlXPathCastToString`, `xmlXPathStringEvalNumber` and
23//! `xmlXPathCastNumberToString`.
24//!
25//! # Conceptual behavior
26//!
27//! Defines the runtime value types and their conversions. Node-sets are
28//! ordered, deduplicated collections of borrowed document nodes;
29//! `node_string_value` computes the XPath string-value of a node
30//! (R-000114 fixed the empty attribute string-value); `string_bytes_to_
31//! number` / `number_to_string` are faithful ports of the R-000166
32//! number conversion with the 1e9/1e-5 scientific threshold and
33//! DBL_DIG=15 fraction digits.
34//!
35//! # Ownership & safety invariants
36//!
37//! `XPathNode` holds a raw `*mut _xmlNode` that is borrowed from the
38//! document — the tree must outlive evaluation (SAFETY note on the
39//! struct). Values own their storage (String/NodeSet) and are freed by
40//! drop; nothing here allocates through the C allocator except the
41//! exports bridge.
42//!
43//! # Historical quirks & epochs
44//!
45//! The conversion rules track the 2.15.3 oracle epoch: the E-001
46//! newline-separated node-set dump (commit da35eeae, 2.9.10) is the
47//! output epoch the XPath CLI surfaces target, and number formatting
48//! (R-000166, 967/967 number() corpus) is fixed since the same era.
49//!
50//! # Deliberate oddities
51//!
52//! `-0.0` serializes as `0`, NaN as `NaN`, infinities as `Infinity`/
53//! `-Infinity`, and integral values take the integer shortcut — the
54//! upstream xmlXPathFormatNumber quirks reproduced instead of Rust
55//! Display.
56//!
57//! # Proving courts
58//!
59//! XPATH-TYPES-* differential probes and the 967/967 number() corpus
60//! compare conversions byte-identical against the oracle; cargo test
61//! runs the conversion unit suites (incl. test_number_to_string).
62//!
63//! # Tempting simplifications that would break parity
64//!
65//! Do not switch conversions to Rust std float formatting or parsing:
66//! the oracle digit accumulation (MAX_FRAC=20), exponent underflow
67//! (5e-324 → 0), threshold selection and exponent padding are observable
68//! (R-000166). Do not make node-sets own the tree: callers free the
69//! document independently of the XPath object.
70
71use crate::abi::structs::_xmlNode;
72use crate::abi::types::xmlElementType;
73use std::cmp::Ordering;
74use std::ffi::c_int;
75use std::ptr;
76
77// ═══════════════════════════════════════════════════════════════════════════════
78// XPath Value Types
79// ═══════════════════════════════════════════════════════════════════════════════
80
81/// XPath type.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum XPathType {
84    /// A node-set: an ordered, deduplicated collection of document nodes
85    NodeSet,
86    /// A string
87    String,
88    /// A number (IEEE 754 double)
89    Number,
90    /// A boolean
91    Boolean,
92    /// A single point in the tree: a node plus a position within it
93    Point,
94    /// A range of nodes in the tree
95    Range,
96    /// A set of points and ranges
97    LocationSet,
98    /// A user-defined value type
99    Users,
100    /// An XSLT tree fragment (result tree fragment)
101    XsltTree,
102    /// No type assigned yet (uninitialized value)
103    Undefined,
104}
105
106/// A node in a node-set, identified by pointer.
107///
108/// We use raw pointers because:
109/// 1. The tree is owned by the document, not by XPath.
110/// 2. The C ABI exposes node pointers that callers manipulate.
111/// 3. Multiple XPath evaluations may reference the same tree.
112///
113/// SAFETY: Node pointers must remain valid for the duration of evaluation.
114#[derive(Debug, Clone, Copy)]
115pub struct XPathNode(pub *mut _xmlNode);
116
117impl PartialEq for XPathNode {
118    fn eq(&self, other: &Self) -> bool {
119        self.0 == other.0
120    }
121}
122
123impl Eq for XPathNode {}
124
125impl PartialOrd for XPathNode {
126    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
127        Some(self.cmp(other))
128    }
129}
130
131impl Ord for XPathNode {
132    fn cmp(&self, other: &Self) -> Ordering {
133        // Compare by pointer value for document order
134        // In a full implementation, this would use the document order algorithm
135        self.0.cmp(&other.0)
136    }
137}
138
139impl std::hash::Hash for XPathNode {
140    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
141        self.0.hash(state);
142    }
143}
144
145/// An XPath node-set.
146///
147/// Internally stored as a sorted, deduplicated Vec of node pointers
148/// in document order.
149#[derive(Debug, Clone)]
150pub struct NodeSet {
151    nodes: Vec<XPathNode>,
152}
153
154impl NodeSet {
155    /// Create an empty node-set.
156    pub const fn new() -> Self {
157        Self { nodes: Vec::new() }
158    }
159
160    /// Create a node-set containing exactly one node.
161    pub fn singleton(node: *mut _xmlNode) -> Self {
162        Self {
163            nodes: vec![XPathNode(node)],
164        }
165    }
166
167    /// Return `true` if the node-set contains no nodes.
168    pub const fn is_empty(&self) -> bool {
169        self.nodes.is_empty()
170    }
171
172    /// Return the number of nodes in the node-set.
173    pub const fn len(&self) -> usize {
174        self.nodes.len()
175    }
176
177    /// Iterate over the nodes in document order.
178    pub fn iter(&self) -> impl Iterator<Item = *mut _xmlNode> + '_ {
179        self.nodes.iter().map(|n| n.0)
180    }
181
182    /// Return the node at `index` in document order, or `None` if out of bounds.
183    pub fn get(&self, index: usize) -> Option<*mut _xmlNode> {
184        self.nodes.get(index).map(|n| n.0)
185    }
186
187    /// Return the first node in document order.
188    pub fn first(&self) -> Option<*mut _xmlNode> {
189        self.nodes.first().map(|n| n.0)
190    }
191
192    /// Return the last node in document order.
193    pub fn last(&self) -> Option<*mut _xmlNode> {
194        self.nodes.last().map(|n| n.0)
195    }
196
197    /// Return `true` if the given node is in the node-set.
198    pub fn contains(&self, node: *mut _xmlNode) -> bool {
199        self.nodes.iter().any(|n| n.0 == node)
200    }
201
202    /// Add a node to the set, maintaining document order and uniqueness.
203    pub fn push(&mut self, node: *mut _xmlNode) {
204        if !self.nodes.iter().any(|n| n.0 == node) {
205            self.nodes.push(XPathNode(node));
206            self.sort();
207        }
208    }
209
210    /// Extend with another node-set.
211    pub fn extend(&mut self, other: &NodeSet) {
212        for node in other.iter() {
213            self.push(node);
214        }
215    }
216
217    /// Sort nodes in document order.
218    ///
219    /// # UPSTREAM-PARITY
220    ///
221    /// XPath node-sets are always in document order (XPath 1.0 §3.3).
222    /// libxml2 maintains this via its node-set insertion/merge logic plus
223    /// the document-order comparator (xmlXPathNodeSetSort). Sorting by
224    /// pointer address is NOT document order and breaks downstream ordering
225    /// guarantees; the oracle-observed symptom is rotated results on the
226    /// second of two transforms in one process.
227    pub fn sort(&mut self) {
228        self.nodes
229            .sort_by(|a, b| unsafe { compare_document_order(a.0, b.0) });
230        self.nodes.dedup();
231    }
232
233    /// Convert to raw C ABI node-set.
234    ///
235    /// SAFETY: The returned pointer must be freed with xmlXPathFreeNodeSet
236    /// or the owning XPath object must be freed.
237    ///
238    /// # SAFETY
239    ///
240    /// The function touches crate-global state only; it is safe
241    /// as long as the caller respects the library's global
242    /// initialization/cleanup ordering (xmlInitParser before use,
243    /// xmlCleanupParser only after all users are done).
244    ///
245    /// Violating the global lifecycle ordering, or calling this after
246    /// teardown or from a signal handler, is undefined behavior.
247    pub unsafe fn to_raw(&self) -> *mut crate::abi::structs::_xmlNodeSet {
248        let node_max = self.nodes.len();
249        let node_tab = if node_max > 0 {
250            let ptr = crate::abi::allocator::xmlMallocImpl(
251                node_max * std::mem::size_of::<*mut _xmlNode>(),
252            ) as *mut *mut _xmlNode;
253            if ptr.is_null() {
254                return ptr::null_mut();
255            }
256            for (i, node) in self.nodes.iter().enumerate() {
257                ptr::write(ptr.add(i), node.0);
258            }
259            ptr
260        } else {
261            ptr::null_mut()
262        };
263
264        let raw = crate::abi::allocator::xmlMallocImpl(std::mem::size_of::<
265            crate::abi::structs::_xmlNodeSet,
266        >()) as *mut crate::abi::structs::_xmlNodeSet;
267        if raw.is_null() {
268            if !node_tab.is_null() {
269                crate::abi::allocator::xmlFreeImpl(node_tab as *mut _);
270            }
271            return ptr::null_mut();
272        }
273        ptr::write(
274            raw,
275            crate::abi::structs::_xmlNodeSet {
276                nodeNr: node_max as std::os::raw::c_int,
277                nodeMax: node_max as std::os::raw::c_int,
278                nodeTab: node_tab,
279            },
280        );
281        raw
282    }
283}
284
285impl Default for NodeSet {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291/// XPath runtime value.
292#[derive(Debug, Clone)]
293pub enum XPathValue {
294    /// A node-set value
295    NodeSet(NodeSet),
296    /// A string value
297    String(String),
298    /// A number value (IEEE 754 double)
299    Number(f64),
300    /// A boolean value
301    Boolean(bool),
302}
303
304impl XPathValue {
305    /// Get the XPath type of this value.
306    pub const fn xpath_type(&self) -> XPathType {
307        match self {
308            XPathValue::NodeSet(_) => XPathType::NodeSet,
309            XPathValue::String(_) => XPathType::String,
310            XPathValue::Number(_) => XPathType::Number,
311            XPathValue::Boolean(_) => XPathType::Boolean,
312        }
313    }
314
315    /// Convert to boolean (XPath 1.0 §3.4).
316    pub fn as_boolean(&self) -> bool {
317        match self {
318            XPathValue::NodeSet(ns) => !ns.is_empty(),
319            XPathValue::String(s) => !s.is_empty(),
320            XPathValue::Number(n) => *n != 0.0 && !n.is_nan(),
321            XPathValue::Boolean(b) => *b,
322        }
323    }
324
325    /// Convert to number (XPath 1.0 §3.5).
326    pub fn as_number(&self) -> f64 {
327        match self {
328            XPathValue::NodeSet(ns) => {
329                // Convert string value of first node to number
330                if let Some(node) = ns.first() {
331                    let s = node_string_value(node);
332                    string_to_number(&s)
333                } else {
334                    f64::NAN
335                }
336            }
337            XPathValue::String(s) => string_to_number(s),
338            XPathValue::Number(n) => *n,
339            XPathValue::Boolean(true) => 1.0,
340            XPathValue::Boolean(false) => 0.0,
341        }
342    }
343
344    /// Convert to string (XPath 1.0 §3.6).
345    pub fn as_string(&self) -> String {
346        match self {
347            XPathValue::NodeSet(ns) => {
348                if let Some(node) = ns.first() {
349                    node_string_value(node)
350                } else {
351                    String::new()
352                }
353            }
354            XPathValue::String(s) => s.clone(),
355            XPathValue::Number(n) => number_to_string(*n),
356            XPathValue::Boolean(true) => "true".to_string(),
357            XPathValue::Boolean(false) => "false".to_string(),
358        }
359    }
360
361    /// Get node-set reference (panics if not a node-set).
362    pub fn as_node_set(&self) -> &NodeSet {
363        match self {
364            XPathValue::NodeSet(ns) => ns,
365            _ => panic!("XPathValue is not a node-set"),
366        }
367    }
368
369    /// Get mutable node-set reference.
370    pub fn as_node_set_mut(&mut self) -> &mut NodeSet {
371        match self {
372            XPathValue::NodeSet(ns) => ns,
373            _ => panic!("XPathValue is not a node-set"),
374        }
375    }
376}
377
378// ═══════════════════════════════════════════════════════════════════════════════
379// String value of a node
380// ═══════════════════════════════════════════════════════════════════════════════
381
382/// Get the string value of a node (XPath 1.0 §5.1).
383///
384/// For element/root nodes: concatenation of all descendant text nodes.
385/// For text nodes: the text content.
386/// For attribute nodes: the attribute value.
387/// For namespace nodes: the namespace URI.
388/// For comment/PI nodes: the content.
389///
390/// # Safety
391///
392/// - `node` must be NULL or a valid `_xmlNode` that stays alive for the
393///   call; its `children` chain (recursed by `collect_text`) and every
394///   `content`/`name` pointer must be NULL or valid NUL-terminated
395///   strings, and the reachable subtree must be acyclic.
396pub fn node_string_value(node: *mut _xmlNode) -> String {
397    if node.is_null() {
398        return String::new();
399    }
400
401    unsafe {
402        let node_ref = &*node;
403        match node_ref.type_ {
404            1..=20 => {}
405            _ => return String::new(),
406        }
407
408        // Element / document / HTML document: concatenate text descendants
409        if node_ref.type_ == 1 || node_ref.type_ == 9 || node_ref.type_ == 13 {
410            let mut result = String::new();
411            collect_text(&mut result, node);
412            return result;
413        }
414
415        // Attribute node (type 2): the value is stored as the first text
416        // child of the attribute node (tree::set_prop layout, matching
417        // libxml2's xmlAttr->children). NOTE: type 13 is
418        // XML_HTML_DOCUMENT_NODE, not attribute.
419        if node_ref.type_ == 2 {
420            if !node_ref.children.is_null() {
421                let child = &*node_ref.children;
422                if (child.type_ == 3 || child.type_ == 4) && !child.content.is_null() {
423                    return crate::xml::string::xmlstr_to_string(child.content);
424                }
425            }
426            return String::new();
427        }
428
429        // Text / CDATA
430        if node_ref.type_ == 3 || node_ref.type_ == 4 {
431            if !node_ref.content.is_null() {
432                return crate::xml::string::xmlstr_to_string(node_ref.content);
433            }
434            return String::new();
435        }
436
437        // Comment / PI
438        if node_ref.type_ == 7 {
439            // PI: content
440            if !node_ref.content.is_null() {
441                return crate::xml::string::xmlstr_to_string(node_ref.content);
442            }
443            return String::new();
444        }
445
446        String::new()
447    }
448}
449
450/// Recursively collect text content from element/document nodes.
451unsafe fn collect_text(result: &mut String, node: *mut _xmlNode) {
452    if node.is_null() {
453        return;
454    }
455    let node_ref = &*node;
456
457    // If this is a text or CDATA node, append its content
458    if node_ref.type_ == 3 || node_ref.type_ == 4 {
459        if !node_ref.content.is_null() {
460            result.push_str(&crate::xml::string::xmlstr_to_string(node_ref.content));
461        }
462        return;
463    }
464
465    // For element/document nodes, recurse into children
466    if node_ref.type_ == 1 || node_ref.type_ == 9 || node_ref.type_ == 19 {
467        let mut child = node_ref.children;
468        while !child.is_null() {
469            collect_text(result, child);
470            child = (*child).next;
471        }
472    }
473}
474
475// ═══════════════════════════════════════════════════════════════════════════════
476// Number <-> String conversions
477// ═══════════════════════════════════════════════════════════════════════════════
478
479/// Port of upstream xpath.c `xmlXPathStringEvalNumber` (R-000166): the
480/// oracle accumulates digits directly (`ret = ret * 10 + d`), caps the
481/// fraction at MAX_FRAC=20 digits after any leading zeros, applies the
482/// exponent with `pow(10.0, exp)` (underflowing to 0 below the smallest
483/// subnormal, e.g. `5e-324`), accepts XML whitespace around the number, and
484/// returns NaN for anything else — including a leading '+'.
485pub fn string_bytes_to_number(bytes: &[u8]) -> f64 {
486    let len = bytes.len();
487    let mut cur = 0usize;
488    // Skip leading XML whitespace.
489    while cur < len && matches!(bytes[cur], b' ' | b'\t' | b'\n' | b'\r') {
490        cur += 1;
491    }
492    let mut isneg = false;
493    if cur < len && bytes[cur] == b'-' {
494        isneg = true;
495        cur += 1;
496    }
497    if cur >= len || (bytes[cur] != b'.' && !bytes[cur].is_ascii_digit()) {
498        return f64::NAN;
499    }
500
501    let mut ret = 0.0f64;
502    let mut ok = false;
503    while cur < len && bytes[cur].is_ascii_digit() {
504        ret = ret * 10.0 + (bytes[cur] - b'0') as f64;
505        ok = true;
506        cur += 1;
507    }
508
509    let mut frac: i32 = 0;
510    if cur < len && bytes[cur] == b'.' {
511        cur += 1;
512        if (cur >= len || !bytes[cur].is_ascii_digit()) && !ok {
513            return f64::NAN;
514        }
515        while cur < len && bytes[cur] == b'0' {
516            frac += 1;
517            cur += 1;
518        }
519        let max = frac + 20; // MAX_FRAC
520        let mut fraction = 0.0f64;
521        while cur < len && bytes[cur].is_ascii_digit() && frac < max {
522            let v = (bytes[cur] - b'0') as f64;
523            fraction = fraction * 10.0 + v;
524            frac += 1;
525            cur += 1;
526        }
527        fraction /= 10f64.powf(frac as f64);
528        ret += fraction;
529        while cur < len && bytes[cur].is_ascii_digit() {
530            cur += 1;
531        }
532    }
533
534    let mut exponent: i32 = 0;
535    let mut is_exponent_negative = false;
536    if cur < len && (bytes[cur] == b'e' || bytes[cur] == b'E') {
537        cur += 1;
538        if cur < len && bytes[cur] == b'-' {
539            is_exponent_negative = true;
540            cur += 1;
541        } else if cur < len && bytes[cur] == b'+' {
542            cur += 1;
543        }
544        while cur < len && bytes[cur].is_ascii_digit() {
545            if exponent < 1000000 {
546                exponent = exponent * 10 + (bytes[cur] - b'0') as i32;
547            }
548            cur += 1;
549        }
550    }
551    while cur < len && matches!(bytes[cur], b' ' | b'\t' | b'\n' | b'\r') {
552        cur += 1;
553    }
554    if cur != len {
555        return f64::NAN;
556    }
557    if isneg {
558        ret = -ret;
559    }
560    if is_exponent_negative {
561        exponent = -exponent;
562    }
563    ret *= 10f64.powf(exponent as f64);
564    ret
565}
566
567/// Convert a string to a number (XPath 1.0 §4.7.1) — upstream
568/// `xmlXPathStringEvalNumber` semantics.
569pub fn string_to_number(s: &str) -> f64 {
570    string_bytes_to_number(s.as_bytes())
571}
572
573/// Convert a number to a string (XPath 1.0 §4.7.2) — a faithful port of
574/// upstream `xmlXPathCastNumberToString` / `xmlXPathFormatNumber` (xpath.c,
575/// R-000166): the integer shortcut, the 1e9/1e-5 scientific threshold, and
576/// the DBL_DIG=15 fraction-digit computation reproduce the oracle's exact
577/// digits, including exponent formatting (`e+20`, `e-05`) and
578/// trailing-zero trimming.
579pub fn number_to_string(n: f64) -> String {
580    if n.is_nan() {
581        return "NaN".to_string();
582    }
583    if n.is_infinite() {
584        return if n > 0.0 {
585            "Infinity".to_string()
586        } else {
587            "-Infinity".to_string()
588        };
589    }
590    if n == 0.0 {
591        // Both +0 and -0 serialize as "0" per XPath 1.0.
592        return "0".to_string();
593    }
594    // Upstream integer shortcut (xmlXPathFormatNumber): integral values
595    // within the int range print as plain decimal.
596    if n > i32::MIN as f64 && n < i32::MAX as f64 && n == (n as i32) as f64 {
597        return format!("{}", n as i32);
598    }
599
600    let absolute_value = n.abs();
601    let s = if ((absolute_value > 1e9) || (absolute_value < 1e-5)) && absolute_value != 0.0 {
602        // Scientific notation: "%*.*e" with 14 fraction digits, then trim
603        // trailing zeros before the exponent (work[size] == 'e' scan).
604        let raw = format!("{:.14e}", n);
605        let e_pos = raw.find('e').expect("exponent format contains 'e'");
606        let mantissa = &raw[..e_pos];
607        let exponent = &raw[e_pos + 1..];
608        let mut mantissa = mantissa.to_string();
609        while mantissa.ends_with('0') {
610            mantissa.pop();
611        }
612        if mantissa.ends_with('.') {
613            mantissa.pop();
614        }
615        // C's %e pads the exponent to at least two digits and always
616        // includes the sign: "e+20", "e-05", "e+100".
617        let (sign, digits) = if let Some(rest) = exponent.strip_prefix('-') {
618            ("-", rest)
619        } else {
620            ("+", exponent)
621        };
622        let digits = if digits.len() < 2 {
623            format!("0{}", digits)
624        } else {
625            digits.to_string()
626        };
627        format!("{}e{}{}", mantissa, sign, digits)
628    } else {
629        // Regular notation: fraction digits depend on the integer place.
630        let integer_place = absolute_value.log10() as i32;
631        let fraction_place = if integer_place > 0 {
632            15 - integer_place - 1
633        } else {
634            15 - integer_place
635        };
636        let mut s = format!("{:.*}", fraction_place as usize, n);
637        // Trim fractional trailing zeros (and a trailing dot).
638        if s.contains('.') {
639            while s.ends_with('0') {
640                s.pop();
641            }
642            if s.ends_with('.') {
643                s.pop();
644            }
645        }
646        s
647    };
648    if s == "-0" {
649        return "0".to_string();
650    }
651    s
652}
653
654// ═══════════════════════════════════════════════════════════════════════════════
655// Node comparison for document order
656// ═══════════════════════════════════════════════════════════════════════════════
657
658/// Compare two nodes in document order.
659///
660/// Returns:
661/// - `Ordering::Less` if `a` comes before `b` in document order
662/// - `Ordering::Greater` if `a` comes after `b`
663/// - `Ordering::Equal` if `a == b`
664///
665/// UPSTREAM-PARITY: Uses the `xmlXPathCmpNodes` algorithm.
666///
667/// # SAFETY
668///
669/// - `a`, `b` must be valid pointers (or NULL
670///   where the upstream C contract allows), obtained from the
671///   matching constructor/owner and not yet freed; the callee may
672///   take or keep ownership exactly as the C API specifies.
673///
674/// The caller must not race this call with concurrent mutation of the
675/// same objects from other threads (per-object state is not internally
676/// synchronized). Violating any of the above is undefined behavior.
677///
678/// Exercised by the C-API differential courts
679/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
680/// courts; those pass byte-for-byte against the upstream oracle.
681pub unsafe fn compare_document_order(a: *mut _xmlNode, b: *mut _xmlNode) -> Ordering {
682    if a.is_null() && b.is_null() {
683        return Ordering::Equal;
684    }
685    if a.is_null() {
686        return Ordering::Less;
687    }
688    if b.is_null() {
689        return Ordering::Greater;
690    }
691    if a == b {
692        return Ordering::Equal;
693    }
694
695    // UPSTREAM-PARITY (xpath.c xmlXPathCmpNodes): a namespace node in a
696    // node-set is an independent `_xmlNs` copy — its fields are NOT tree
697    // links (the xmlXPathNodeSetDupNs copy has no parent/children), so any
698    // tree walk on it is invalid. Upstream returns 1 for every comparison
699    // involving a namespace node; reporting Equal keeps a stable sort in the
700    // axis emission order (the php-visible contract for `//namespace::*`,
701    // which lists each element's namespace nodes xml-first then reverse
702    // declaration order) and never dereferences the copy's non-node fields.
703    let ta = unsafe { (*a).type_ };
704    let tb = unsafe { (*b).type_ };
705    if ta == xmlElementType::XML_NAMESPACE_DECL as c_int
706        || tb == xmlElementType::XML_NAMESPACE_DECL as c_int
707    {
708        return Ordering::Equal;
709    }
710
711    // Find depths of both nodes
712    let depth_a = node_depth(a);
713    let depth_b = node_depth(b);
714
715    // If one is an ancestor of the other, the ancestor comes first
716    if depth_a < depth_b {
717        let mut n = b;
718        for _ in 0..(depth_b - depth_a) {
719            n = (*n).parent;
720            if n.is_null() {
721                break;
722            }
723        }
724        if n == a {
725            return Ordering::Less;
726        }
727    } else if depth_b < depth_a {
728        let mut n = a;
729        for _ in 0..(depth_a - depth_b) {
730            n = (*n).parent;
731            if n.is_null() {
732                break;
733            }
734        }
735        if n == b {
736            return Ordering::Greater;
737        }
738    }
739
740    // Find the common ancestor and the first differing child
741    let mut parent_a = a;
742    let mut parent_b = b;
743
744    // Move both up to the same depth
745    let mut d_a = depth_a;
746    let mut d_b = depth_b;
747    while d_a > d_b {
748        parent_a = (*parent_a).parent;
749        d_a -= 1;
750    }
751    while d_b > d_a {
752        parent_b = (*parent_b).parent;
753        d_b -= 1;
754    }
755
756    // Move both up until they share the same parent
757    while (*parent_a).parent != (*parent_b).parent {
758        parent_a = (*parent_a).parent;
759        parent_b = (*parent_b).parent;
760        if parent_a.is_null() || parent_b.is_null() {
761            // Fallback: compare by pointer
762            return a.cmp(&b);
763        }
764    }
765
766    // Now parent_a and parent_b are siblings. Find which comes first.
767    let n = (*parent_a).parent;
768    if n.is_null() {
769        return a.cmp(&b);
770    }
771    // UPSTREAM-PARITY (xpath.c xmlXPathCmpNodes): in document order an
772    // ELEMENT's attribute nodes precede its child nodes (so a `node()|@*`
773    // union sorts the attributes first — the XSLT identity copy depends on
774    // copying attributes onto a still childless result element). The
775    // children-list walk below cannot order attributes (they are not in the
776    // list), so resolve attribute siblings explicitly first.
777    let attr_a = (*parent_a).type_ == xmlElementType::XML_ATTRIBUTE_NODE as c_int;
778    let attr_b = (*parent_b).type_ == xmlElementType::XML_ATTRIBUTE_NODE as c_int;
779    if attr_a || attr_b {
780        if attr_a && !attr_b {
781            return Ordering::Less;
782        }
783        if !attr_a && attr_b {
784            return Ordering::Greater;
785        }
786        // Both are attributes of the same element: order by their position in
787        // the properties list.
788        let mut prop = (*n).properties;
789        while !prop.is_null() {
790            if prop as usize == parent_a as usize {
791                return Ordering::Less;
792            }
793            if prop as usize == parent_b as usize {
794                return Ordering::Greater;
795            }
796            prop = (*prop).next;
797        }
798        return a.cmp(&b);
799    }
800    let mut child = (*n).children;
801    while !child.is_null() {
802        if child == parent_a {
803            return Ordering::Less;
804        }
805        if child == parent_b {
806            return Ordering::Greater;
807        }
808        child = (*child).next;
809    }
810
811    // Fallback
812    a.cmp(&b)
813}
814
815/// Compute the depth of a node (root = 0).
816unsafe fn node_depth(node: *mut _xmlNode) -> usize {
817    let mut depth = 0;
818    let mut n = node;
819    while !(*n).parent.is_null() {
820        depth += 1;
821        n = (*n).parent;
822    }
823    depth
824}
825
826// ═══════════════════════════════════════════════════════════════════════════════
827// Tests
828// ═══════════════════════════════════════════════════════════════════════════════
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833    #[allow(clippy::approx_constant)]
834    #[test]
835    fn test_string_to_number() {
836        assert!(string_to_number("").is_nan());
837        assert!(string_to_number("NaN").is_nan());
838        assert_eq!(string_to_number("42"), 42.0);
839        assert_eq!(string_to_number("-42"), -42.0);
840        assert_eq!(string_to_number("3.14"), 3.14);
841        assert_eq!(string_to_number("  42  "), 42.0);
842        assert!(string_to_number("true").is_nan());
843        assert!(string_to_number("false").is_nan());
844        assert_eq!(string_to_number("0"), 0.0);
845    }
846    #[allow(clippy::approx_constant)]
847    #[test]
848    fn test_number_to_string() {
849        assert_eq!(number_to_string(f64::NAN), "NaN");
850        assert_eq!(number_to_string(f64::INFINITY), "Infinity");
851        assert_eq!(number_to_string(f64::NEG_INFINITY), "-Infinity");
852        assert_eq!(number_to_string(0.0), "0");
853        assert_eq!(number_to_string(-0.0), "0");
854        assert_eq!(number_to_string(42.0), "42");
855        assert_eq!(number_to_string(3.14), "3.14");
856    }
857
858    #[test]
859    fn test_value_conversions() {
860        let v = XPathValue::Number(42.0);
861        assert_eq!(v.as_number(), 42.0);
862        assert_eq!(v.as_string(), "42");
863        assert!(v.as_boolean());
864
865        let v = XPathValue::Number(0.0);
866        assert!(!v.as_boolean());
867
868        let v = XPathValue::Number(f64::NAN);
869        assert!(!v.as_boolean());
870
871        let v = XPathValue::String("hello".into());
872        assert_eq!(v.as_string(), "hello");
873        assert!(v.as_boolean());
874
875        let v = XPathValue::String("".into());
876        assert!(!v.as_boolean());
877
878        let v = XPathValue::Boolean(true);
879        assert_eq!(v.as_number(), 1.0);
880        assert_eq!(v.as_string(), "true");
881
882        let v = XPathValue::Boolean(false);
883        assert_eq!(v.as_number(), 0.0);
884        assert_eq!(v.as_string(), "false");
885    }
886
887    #[test]
888    fn test_node_set() {
889        let ns = NodeSet::new();
890        assert!(ns.is_empty());
891        assert_eq!(ns.len(), 0);
892    }
893}