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