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.
387pub fn node_string_value(node: *mut _xmlNode) -> String {
388 if node.is_null() {
389 return String::new();
390 }
391
392 unsafe {
393 let node_ref = &*node;
394 match node_ref.type_ {
395 1..=20 => {}
396 _ => return String::new(),
397 }
398
399 // Element / document / HTML document: concatenate text descendants
400 if node_ref.type_ == 1 || node_ref.type_ == 9 || node_ref.type_ == 13 {
401 let mut result = String::new();
402 collect_text(&mut result, node);
403 return result;
404 }
405
406 // Attribute node (type 2): the value is stored as the first text
407 // child of the attribute node (tree::set_prop layout, matching
408 // libxml2's xmlAttr->children). NOTE: type 13 is
409 // XML_HTML_DOCUMENT_NODE, not attribute.
410 if node_ref.type_ == 2 {
411 if !node_ref.children.is_null() {
412 let child = &*node_ref.children;
413 if (child.type_ == 3 || child.type_ == 4) && !child.content.is_null() {
414 return crate::xml::string::xmlstr_to_string(child.content);
415 }
416 }
417 return String::new();
418 }
419
420 // Text / CDATA
421 if node_ref.type_ == 3 || node_ref.type_ == 4 {
422 if !node_ref.content.is_null() {
423 return crate::xml::string::xmlstr_to_string(node_ref.content);
424 }
425 return String::new();
426 }
427
428 // Comment / PI
429 if node_ref.type_ == 7 {
430 // PI: content
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 String::new()
438 }
439}
440
441/// Recursively collect text content from element/document nodes.
442unsafe fn collect_text(result: &mut String, node: *mut _xmlNode) {
443 if node.is_null() {
444 return;
445 }
446 let node_ref = &*node;
447
448 // If this is a text or CDATA node, append its content
449 if node_ref.type_ == 3 || node_ref.type_ == 4 {
450 if !node_ref.content.is_null() {
451 result.push_str(&crate::xml::string::xmlstr_to_string(node_ref.content));
452 }
453 return;
454 }
455
456 // For element/document nodes, recurse into children
457 if node_ref.type_ == 1 || node_ref.type_ == 9 || node_ref.type_ == 19 {
458 let mut child = node_ref.children;
459 while !child.is_null() {
460 collect_text(result, child);
461 child = (*child).next;
462 }
463 }
464}
465
466// ═══════════════════════════════════════════════════════════════════════════════
467// Number <-> String conversions
468// ═══════════════════════════════════════════════════════════════════════════════
469
470/// Port of upstream xpath.c `xmlXPathStringEvalNumber` (R-000166): the
471/// oracle accumulates digits directly (`ret = ret * 10 + d`), caps the
472/// fraction at MAX_FRAC=20 digits after any leading zeros, applies the
473/// exponent with `pow(10.0, exp)` (underflowing to 0 below the smallest
474/// subnormal, e.g. `5e-324`), accepts XML whitespace around the number, and
475/// returns NaN for anything else — including a leading '+'.
476pub fn string_bytes_to_number(bytes: &[u8]) -> f64 {
477 let len = bytes.len();
478 let mut cur = 0usize;
479 // Skip leading XML whitespace.
480 while cur < len && matches!(bytes[cur], b' ' | b'\t' | b'\n' | b'\r') {
481 cur += 1;
482 }
483 let mut isneg = false;
484 if cur < len && bytes[cur] == b'-' {
485 isneg = true;
486 cur += 1;
487 }
488 if cur >= len || (bytes[cur] != b'.' && !bytes[cur].is_ascii_digit()) {
489 return f64::NAN;
490 }
491
492 let mut ret = 0.0f64;
493 let mut ok = false;
494 while cur < len && bytes[cur].is_ascii_digit() {
495 ret = ret * 10.0 + (bytes[cur] - b'0') as f64;
496 ok = true;
497 cur += 1;
498 }
499
500 let mut frac: i32 = 0;
501 if cur < len && bytes[cur] == b'.' {
502 cur += 1;
503 if (cur >= len || !bytes[cur].is_ascii_digit()) && !ok {
504 return f64::NAN;
505 }
506 while cur < len && bytes[cur] == b'0' {
507 frac += 1;
508 cur += 1;
509 }
510 let max = frac + 20; // MAX_FRAC
511 let mut fraction = 0.0f64;
512 while cur < len && bytes[cur].is_ascii_digit() && frac < max {
513 let v = (bytes[cur] - b'0') as f64;
514 fraction = fraction * 10.0 + v;
515 frac += 1;
516 cur += 1;
517 }
518 fraction /= 10f64.powf(frac as f64);
519 ret += fraction;
520 while cur < len && bytes[cur].is_ascii_digit() {
521 cur += 1;
522 }
523 }
524
525 let mut exponent: i32 = 0;
526 let mut is_exponent_negative = false;
527 if cur < len && (bytes[cur] == b'e' || bytes[cur] == b'E') {
528 cur += 1;
529 if cur < len && bytes[cur] == b'-' {
530 is_exponent_negative = true;
531 cur += 1;
532 } else if cur < len && bytes[cur] == b'+' {
533 cur += 1;
534 }
535 while cur < len && bytes[cur].is_ascii_digit() {
536 if exponent < 1000000 {
537 exponent = exponent * 10 + (bytes[cur] - b'0') as i32;
538 }
539 cur += 1;
540 }
541 }
542 while cur < len && matches!(bytes[cur], b' ' | b'\t' | b'\n' | b'\r') {
543 cur += 1;
544 }
545 if cur != len {
546 return f64::NAN;
547 }
548 if isneg {
549 ret = -ret;
550 }
551 if is_exponent_negative {
552 exponent = -exponent;
553 }
554 ret *= 10f64.powf(exponent as f64);
555 ret
556}
557
558/// Convert a string to a number (XPath 1.0 §4.7.1) — upstream
559/// `xmlXPathStringEvalNumber` semantics.
560pub fn string_to_number(s: &str) -> f64 {
561 string_bytes_to_number(s.as_bytes())
562}
563
564/// Convert a number to a string (XPath 1.0 §4.7.2) — a faithful port of
565/// upstream `xmlXPathCastNumberToString` / `xmlXPathFormatNumber` (xpath.c,
566/// R-000166): the integer shortcut, the 1e9/1e-5 scientific threshold, and
567/// the DBL_DIG=15 fraction-digit computation reproduce the oracle's exact
568/// digits, including exponent formatting (`e+20`, `e-05`) and
569/// trailing-zero trimming.
570pub fn number_to_string(n: f64) -> String {
571 if n.is_nan() {
572 return "NaN".to_string();
573 }
574 if n.is_infinite() {
575 return if n > 0.0 {
576 "Infinity".to_string()
577 } else {
578 "-Infinity".to_string()
579 };
580 }
581 if n == 0.0 {
582 // Both +0 and -0 serialize as "0" per XPath 1.0.
583 return "0".to_string();
584 }
585 // Upstream integer shortcut (xmlXPathFormatNumber): integral values
586 // within the int range print as plain decimal.
587 if n > i32::MIN as f64 && n < i32::MAX as f64 && n == (n as i32) as f64 {
588 return format!("{}", n as i32);
589 }
590
591 let absolute_value = n.abs();
592 let s = if ((absolute_value > 1e9) || (absolute_value < 1e-5)) && absolute_value != 0.0 {
593 // Scientific notation: "%*.*e" with 14 fraction digits, then trim
594 // trailing zeros before the exponent (work[size] == 'e' scan).
595 let raw = format!("{:.14e}", n);
596 let e_pos = raw.find('e').expect("exponent format contains 'e'");
597 let mantissa = &raw[..e_pos];
598 let exponent = &raw[e_pos + 1..];
599 let mut mantissa = mantissa.to_string();
600 while mantissa.ends_with('0') {
601 mantissa.pop();
602 }
603 if mantissa.ends_with('.') {
604 mantissa.pop();
605 }
606 // C's %e pads the exponent to at least two digits and always
607 // includes the sign: "e+20", "e-05", "e+100".
608 let (sign, digits) = if let Some(rest) = exponent.strip_prefix('-') {
609 ("-", rest)
610 } else {
611 ("+", exponent)
612 };
613 let digits = if digits.len() < 2 {
614 format!("0{}", digits)
615 } else {
616 digits.to_string()
617 };
618 format!("{}e{}{}", mantissa, sign, digits)
619 } else {
620 // Regular notation: fraction digits depend on the integer place.
621 let integer_place = absolute_value.log10() as i32;
622 let fraction_place = if integer_place > 0 {
623 15 - integer_place - 1
624 } else {
625 15 - integer_place
626 };
627 let mut s = format!("{:.*}", fraction_place as usize, n);
628 // Trim fractional trailing zeros (and a trailing dot).
629 if s.contains('.') {
630 while s.ends_with('0') {
631 s.pop();
632 }
633 if s.ends_with('.') {
634 s.pop();
635 }
636 }
637 s
638 };
639 if s == "-0" {
640 return "0".to_string();
641 }
642 s
643}
644
645// ═══════════════════════════════════════════════════════════════════════════════
646// Node comparison for document order
647// ═══════════════════════════════════════════════════════════════════════════════
648
649/// Compare two nodes in document order.
650///
651/// Returns:
652/// - `Ordering::Less` if `a` comes before `b` in document order
653/// - `Ordering::Greater` if `a` comes after `b`
654/// - `Ordering::Equal` if `a == b`
655///
656/// UPSTREAM-PARITY: Uses the `xmlXPathCmpNodes` algorithm.
657///
658/// # SAFETY
659///
660/// - `a`, `b` must be valid pointers (or NULL
661/// where the upstream C contract allows), obtained from the
662/// matching constructor/owner and not yet freed; the callee may
663/// take or keep ownership exactly as the C API specifies.
664///
665/// The caller must not race this call with concurrent mutation of the
666/// same objects from other threads (per-object state is not internally
667/// synchronized). Violating any of the above is undefined behavior.
668///
669/// Exercised by the C-API differential courts
670/// (courts/suites/data-abi/*-family-probe.c) and the CLI differential
671/// courts; those pass byte-for-byte against the upstream oracle.
672pub unsafe fn compare_document_order(a: *mut _xmlNode, b: *mut _xmlNode) -> Ordering {
673 if a.is_null() && b.is_null() {
674 return Ordering::Equal;
675 }
676 if a.is_null() {
677 return Ordering::Less;
678 }
679 if b.is_null() {
680 return Ordering::Greater;
681 }
682 if a == b {
683 return Ordering::Equal;
684 }
685
686 // Find depths of both nodes
687 let depth_a = node_depth(a);
688 let depth_b = node_depth(b);
689
690 // If one is an ancestor of the other, the ancestor comes first
691 if depth_a < depth_b {
692 let mut n = b;
693 for _ in 0..(depth_b - depth_a) {
694 n = (*n).parent;
695 if n.is_null() {
696 break;
697 }
698 }
699 if n == a {
700 return Ordering::Less;
701 }
702 } else if depth_b < depth_a {
703 let mut n = a;
704 for _ in 0..(depth_a - depth_b) {
705 n = (*n).parent;
706 if n.is_null() {
707 break;
708 }
709 }
710 if n == b {
711 return Ordering::Greater;
712 }
713 }
714
715 // Find the common ancestor and the first differing child
716 let mut parent_a = a;
717 let mut parent_b = b;
718
719 // Move both up to the same depth
720 let mut d_a = depth_a;
721 let mut d_b = depth_b;
722 while d_a > d_b {
723 parent_a = (*parent_a).parent;
724 d_a -= 1;
725 }
726 while d_b > d_a {
727 parent_b = (*parent_b).parent;
728 d_b -= 1;
729 }
730
731 // Move both up until they share the same parent
732 while (*parent_a).parent != (*parent_b).parent {
733 parent_a = (*parent_a).parent;
734 parent_b = (*parent_b).parent;
735 if parent_a.is_null() || parent_b.is_null() {
736 // Fallback: compare by pointer
737 return a.cmp(&b);
738 }
739 }
740
741 // Now parent_a and parent_b are siblings. Find which comes first.
742 let n = (*parent_a).parent;
743 if n.is_null() {
744 return a.cmp(&b);
745 }
746 let mut child = (*n).children;
747 while !child.is_null() {
748 if child == parent_a {
749 return Ordering::Less;
750 }
751 if child == parent_b {
752 return Ordering::Greater;
753 }
754 child = (*child).next;
755 }
756
757 // Fallback
758 a.cmp(&b)
759}
760
761/// Compute the depth of a node (root = 0).
762unsafe fn node_depth(node: *mut _xmlNode) -> usize {
763 let mut depth = 0;
764 let mut n = node;
765 while !(*n).parent.is_null() {
766 depth += 1;
767 n = (*n).parent;
768 }
769 depth
770}
771
772// ═══════════════════════════════════════════════════════════════════════════════
773// Tests
774// ═══════════════════════════════════════════════════════════════════════════════
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 #[allow(clippy::approx_constant)]
780 #[test]
781 fn test_string_to_number() {
782 assert!(string_to_number("").is_nan());
783 assert!(string_to_number("NaN").is_nan());
784 assert_eq!(string_to_number("42"), 42.0);
785 assert_eq!(string_to_number("-42"), -42.0);
786 assert_eq!(string_to_number("3.14"), 3.14);
787 assert_eq!(string_to_number(" 42 "), 42.0);
788 assert!(string_to_number("true").is_nan());
789 assert!(string_to_number("false").is_nan());
790 assert_eq!(string_to_number("0"), 0.0);
791 }
792 #[allow(clippy::approx_constant)]
793 #[test]
794 fn test_number_to_string() {
795 assert_eq!(number_to_string(f64::NAN), "NaN");
796 assert_eq!(number_to_string(f64::INFINITY), "Infinity");
797 assert_eq!(number_to_string(f64::NEG_INFINITY), "-Infinity");
798 assert_eq!(number_to_string(0.0), "0");
799 assert_eq!(number_to_string(-0.0), "0");
800 assert_eq!(number_to_string(42.0), "42");
801 assert_eq!(number_to_string(3.14), "3.14");
802 }
803
804 #[test]
805 fn test_value_conversions() {
806 let v = XPathValue::Number(42.0);
807 assert_eq!(v.as_number(), 42.0);
808 assert_eq!(v.as_string(), "42");
809 assert!(v.as_boolean());
810
811 let v = XPathValue::Number(0.0);
812 assert!(!v.as_boolean());
813
814 let v = XPathValue::Number(f64::NAN);
815 assert!(!v.as_boolean());
816
817 let v = XPathValue::String("hello".into());
818 assert_eq!(v.as_string(), "hello");
819 assert!(v.as_boolean());
820
821 let v = XPathValue::String("".into());
822 assert!(!v.as_boolean());
823
824 let v = XPathValue::Boolean(true);
825 assert_eq!(v.as_number(), 1.0);
826 assert_eq!(v.as_string(), "true");
827
828 let v = XPathValue::Boolean(false);
829 assert_eq!(v.as_number(), 0.0);
830 assert_eq!(v.as_string(), "false");
831 }
832
833 #[test]
834 fn test_node_set() {
835 let ns = NodeSet::new();
836 assert!(ns.is_empty());
837 assert_eq!(ns.len(), 0);
838 }
839}