Skip to main content

libxml_rs/xml/xpath/
functions.rs

1//! XPath 1.0 Core Function Library (§25).
2//!
3//! Implements all XPath 1.0 core functions as specified in §4 of the
4//! XPath 1.0 Recommendation.
5//!
6//! # UPSTREAM-PARITY
7//!
8//! All functions follow the XPath 1.0 specification and libxml2's
9//! observable behavior, including edge cases and historical quirks.
10//!
11//! # Courts
12//!
13//! XPATH-FUNCTIONS-*
14
15use crate::xml::xpath::context::XPathContext;
16use crate::xml::xpath::types::{node_string_value, string_to_number, NodeSet, XPathValue};
17use std::collections::HashMap;
18
19/// Type alias for XPath functions.
20///
21/// Functions receive already-evaluated arguments as `XPathValue` slices.
22pub type XPathFunction = fn(&mut XPathContext, &[XPathValue]) -> Result<XPathValue, String>;
23
24/// Get all registered XPath core functions.
25pub fn core_functions() -> HashMap<String, XPathFunction> {
26    let mut funcs: HashMap<String, XPathFunction> = HashMap::new();
27
28    // Node set functions (§4.1)
29    funcs.insert("last".into(), fn_last);
30    funcs.insert("position".into(), fn_position);
31    funcs.insert("count".into(), fn_count);
32    funcs.insert("id".into(), fn_id);
33    funcs.insert("local-name".into(), fn_local_name);
34    funcs.insert("namespace-uri".into(), fn_namespace_uri);
35    funcs.insert("name".into(), fn_name);
36
37    // String functions (§4.2)
38    funcs.insert("string".into(), fn_string);
39    funcs.insert("concat".into(), fn_concat);
40    funcs.insert("starts-with".into(), fn_starts_with);
41    funcs.insert("contains".into(), fn_contains);
42    funcs.insert("substring-before".into(), fn_substring_before);
43    funcs.insert("substring-after".into(), fn_substring_after);
44    funcs.insert("substring".into(), fn_substring);
45    funcs.insert("string-length".into(), fn_string_length);
46    funcs.insert("normalize-space".into(), fn_normalize_space);
47    funcs.insert("translate".into(), fn_translate);
48
49    // Boolean functions (§4.3)
50    funcs.insert("boolean".into(), fn_boolean);
51    funcs.insert("not".into(), fn_not);
52    funcs.insert("true".into(), fn_true);
53    funcs.insert("false".into(), fn_false);
54    funcs.insert("lang".into(), fn_lang);
55
56    // Number functions (§4.4)
57    funcs.insert("number".into(), fn_number);
58    funcs.insert("sum".into(), fn_sum);
59    funcs.insert("floor".into(), fn_floor);
60    funcs.insert("ceiling".into(), fn_ceiling);
61    funcs.insert("round".into(), fn_round);
62
63    funcs
64}
65
66// ═══════════════════════════════════════════════════════════════════════════════
67// Helper: extract typed arguments
68// ═══════════════════════════════════════════════════════════════════════════════
69
70fn get_string_arg(args: &[XPathValue], index: usize) -> String {
71    if index < args.len() {
72        args[index].as_string()
73    } else {
74        String::new()
75    }
76}
77
78fn get_number_arg(args: &[XPathValue], index: usize) -> f64 {
79    if index < args.len() {
80        args[index].as_number()
81    } else {
82        f64::NAN
83    }
84}
85
86fn get_boolean_arg(args: &[XPathValue], index: usize) -> bool {
87    if index < args.len() {
88        args[index].as_boolean()
89    } else {
90        false
91    }
92}
93
94fn get_node_set_arg(args: &[XPathValue], index: usize) -> NodeSet {
95    if index < args.len() {
96        match &args[index] {
97            XPathValue::NodeSet(ns) => ns.clone(),
98            _ => NodeSet::new(),
99        }
100    } else {
101        NodeSet::new()
102    }
103}
104
105fn get_first_node(
106    ctx: &XPathContext,
107    args: &[XPathValue],
108    index: usize,
109) -> Option<*mut crate::abi::structs::_xmlNode> {
110    if index < args.len() {
111        match &args[index] {
112            XPathValue::NodeSet(ns) => ns.first(),
113            _ => None,
114        }
115    } else {
116        // Default to context node
117        Some(ctx.context_node)
118    }
119}
120
121// ═══════════════════════════════════════════════════════════════════════════════
122// Node Set Functions (§4.1)
123// ═══════════════════════════════════════════════════════════════════════════════
124
125/// last() — context size.
126fn fn_last(ctx: &mut XPathContext, _args: &[XPathValue]) -> Result<XPathValue, String> {
127    Ok(XPathValue::Number(ctx.last() as f64))
128}
129
130/// position() — context position.
131fn fn_position(ctx: &mut XPathContext, _args: &[XPathValue]) -> Result<XPathValue, String> {
132    Ok(XPathValue::Number(ctx.position() as f64))
133}
134
135/// count(node-set) — number of nodes in node-set.
136fn fn_count(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
137    let ns = get_node_set_arg(args, 0);
138    Ok(XPathValue::Number(ns.len() as f64))
139}
140
141/// id(object) — select elements by ID.
142fn fn_id(_ctx: &mut XPathContext, _args: &[XPathValue]) -> Result<XPathValue, String> {
143    // id() is complex: requires DTD validation to know which attributes are ID.
144    // For now, return empty node-set.
145    Ok(XPathValue::NodeSet(NodeSet::new()))
146}
147
148/// local-name(node-set?) — local part of name.
149fn fn_local_name(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
150    let node = get_first_node(ctx, args, 0);
151    if let Some(node) = node {
152        unsafe {
153            let name = crate::xml::string::xmlstr_to_string((*node).name);
154            // Strip prefix if present
155            if let Some(pos) = name.find(':') {
156                Ok(XPathValue::String(name[pos + 1..].to_string()))
157            } else {
158                Ok(XPathValue::String(name))
159            }
160        }
161    } else {
162        Ok(XPathValue::String(String::new()))
163    }
164}
165
166/// namespace-uri(node-set?) — namespace URI of node.
167fn fn_namespace_uri(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
168    let node = get_first_node(ctx, args, 0);
169    if let Some(node) = node {
170        unsafe {
171            if let Some(ns) = (*node).ns.as_ref() {
172                let uri = crate::xml::string::xmlstr_to_string(ns.href);
173                Ok(XPathValue::String(uri))
174            } else {
175                Ok(XPathValue::String(String::new()))
176            }
177        }
178    } else {
179        Ok(XPathValue::String(String::new()))
180    }
181}
182
183/// name(node-set?) — QName of node.
184fn fn_name(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
185    let node = get_first_node(ctx, args, 0);
186    if let Some(node) = node {
187        unsafe {
188            let name = crate::xml::string::xmlstr_to_string((*node).name);
189            Ok(XPathValue::String(name))
190        }
191    } else {
192        Ok(XPathValue::String(String::new()))
193    }
194}
195
196// ═══════════════════════════════════════════════════════════════════════════════
197// String Functions (§4.2)
198// ═══════════════════════════════════════════════════════════════════════════════
199
200/// string(object?) — convert to string.
201fn fn_string(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
202    if args.is_empty() {
203        // Default: context node's string value
204        Ok(XPathValue::String(node_string_value(ctx.context_node)))
205    } else {
206        Ok(XPathValue::String(args[0].as_string()))
207    }
208}
209
210/// concat(string, string, ...) — concatenate strings.
211fn fn_concat(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
212    let mut result = String::new();
213    for arg in args {
214        result.push_str(&arg.as_string());
215    }
216    Ok(XPathValue::String(result))
217}
218
219/// starts-with(string1, string2) — check prefix.
220fn fn_starts_with(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
221    let s1 = get_string_arg(args, 0);
222    let s2 = get_string_arg(args, 1);
223    Ok(XPathValue::Boolean(s1.starts_with(&s2)))
224}
225
226/// contains(string1, string2) — check substring.
227fn fn_contains(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
228    let s1 = get_string_arg(args, 0);
229    let s2 = get_string_arg(args, 1);
230    Ok(XPathValue::Boolean(s1.contains(&s2)))
231}
232
233/// substring-before(string1, string2) — before first occurrence.
234fn fn_substring_before(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
235    let s1 = get_string_arg(args, 0);
236    let s2 = get_string_arg(args, 1);
237    if let Some(pos) = s1.find(&s2) {
238        Ok(XPathValue::String(s1[..pos].to_string()))
239    } else {
240        Ok(XPathValue::String(String::new()))
241    }
242}
243
244/// substring-after(string1, string2) — after first occurrence.
245fn fn_substring_after(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
246    let s1 = get_string_arg(args, 0);
247    let s2 = get_string_arg(args, 1);
248    if let Some(pos) = s1.find(&s2) {
249        Ok(XPathValue::String(s1[pos + s2.len()..].to_string()))
250    } else {
251        Ok(XPathValue::String(String::new()))
252    }
253}
254
255/// substring(string, number, number?) — substring extraction.
256///
257/// UPSTREAM-PARITY: XPath substring uses 1-based indexing with rounding.
258fn fn_substring(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
259    let s = get_string_arg(args, 0);
260    let start = get_number_arg(args, 1);
261    let has_length = args.len() >= 3;
262    let length = if has_length {
263        get_number_arg(args, 2)
264    } else {
265        f64::MAX
266    };
267
268    let start_rounded = start.round() as isize;
269    let length_rounded = length.round() as isize;
270
271    // XPath 1.0: 1-based indexing
272    let start_index = if start_rounded < 1 {
273        0
274    } else {
275        (start_rounded - 1) as usize
276    };
277    let length = if length_rounded < 0 {
278        0
279    } else {
280        length_rounded as usize
281    };
282
283    if start_index >= s.len() || length == 0 {
284        Ok(XPathValue::String(String::new()))
285    } else {
286        let end = std::cmp::min(start_index + length, s.len());
287        Ok(XPathValue::String(s[start_index..end].to_string()))
288    }
289}
290
291/// string-length(string?) — length of string.
292fn fn_string_length(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
293    let s = if args.is_empty() {
294        node_string_value(ctx.context_node)
295    } else {
296        get_string_arg(args, 0)
297    };
298    Ok(XPathValue::Number(s.len() as f64))
299}
300
301/// normalize-space(string?) — normalize whitespace.
302fn fn_normalize_space(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
303    let s = if args.is_empty() {
304        node_string_value(ctx.context_node)
305    } else {
306        get_string_arg(args, 0)
307    };
308    let normalized: Vec<&str> = s.split_whitespace().collect();
309    Ok(XPathValue::String(normalized.join(" ")))
310}
311
312/// translate(string1, string2, string3) — character translation.
313fn fn_translate(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
314    let s = get_string_arg(args, 0);
315    let from = get_string_arg(args, 1);
316    let to = get_string_arg(args, 2);
317
318    let result: String = s
319        .chars()
320        .map(|c| {
321            if let Some(pos) = from.find(c) {
322                if pos < to.len() {
323                    to.chars().nth(pos).unwrap_or(c)
324                } else {
325                    '\0' // Remove character
326                }
327            } else {
328                c
329            }
330        })
331        .filter(|&c| c != '\0')
332        .collect();
333
334    Ok(XPathValue::String(result))
335}
336
337// ═══════════════════════════════════════════════════════════════════════════════
338// Boolean Functions (§4.3)
339// ═══════════════════════════════════════════════════════════════════════════════
340
341/// boolean(object) — convert to boolean.
342fn fn_boolean(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
343    Ok(XPathValue::Boolean(get_boolean_arg(args, 0)))
344}
345
346/// not(boolean) — logical NOT.
347fn fn_not(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
348    Ok(XPathValue::Boolean(!get_boolean_arg(args, 0)))
349}
350
351/// true() — constant true.
352fn fn_true(_ctx: &mut XPathContext, _args: &[XPathValue]) -> Result<XPathValue, String> {
353    Ok(XPathValue::Boolean(true))
354}
355
356/// false() — constant false.
357fn fn_false(_ctx: &mut XPathContext, _args: &[XPathValue]) -> Result<XPathValue, String> {
358    Ok(XPathValue::Boolean(false))
359}
360
361/// lang(string) — language test.
362fn fn_lang(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
363    let lang = get_string_arg(args, 0);
364    let mut node = ctx.context_node;
365    unsafe {
366        while !node.is_null() {
367            let mut prop = (*node).properties;
368            while !prop.is_null() {
369                let attr_name = crate::xml::string::xmlstr_to_string((*prop).name);
370                if attr_name == "lang" || attr_name == "xml:lang" {
371                    // Attribute value is stored in children (text node's content)
372                    if !(*prop).children.is_null() {
373                        let attr_val =
374                            crate::xml::string::xmlstr_to_string((*(*prop).children).content);
375                        if attr_val.to_lowercase() == lang.to_lowercase()
376                            || attr_val
377                                .to_lowercase()
378                                .starts_with(&format!("{}-", lang.to_lowercase()))
379                        {
380                            return Ok(XPathValue::Boolean(true));
381                        }
382                    }
383                }
384                prop = (*prop).next;
385            }
386            node = (*node).parent;
387        }
388    }
389    Ok(XPathValue::Boolean(false))
390}
391
392// ═══════════════════════════════════════════════════════════════════════════════
393// Number Functions (§4.4)
394// ═══════════════════════════════════════════════════════════════════════════════
395
396/// number(object?) — convert to number.
397fn fn_number(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
398    if args.is_empty() {
399        Ok(XPathValue::Number(string_to_number(&node_string_value(
400            ctx.context_node,
401        ))))
402    } else {
403        Ok(XPathValue::Number(get_number_arg(args, 0)))
404    }
405}
406
407/// sum(node-set) — sum of string->number conversions.
408fn fn_sum(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
409    let ns = get_node_set_arg(args, 0);
410    let mut total = 0.0;
411    for node in ns.iter() {
412        let s = node_string_value(node);
413        total += string_to_number(&s);
414    }
415    Ok(XPathValue::Number(total))
416}
417
418/// floor(number) — largest integer <= value.
419fn fn_floor(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
420    let n = get_number_arg(args, 0);
421    Ok(XPathValue::Number(n.floor()))
422}
423
424/// ceiling(number) — smallest integer >= value.
425fn fn_ceiling(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
426    let n = get_number_arg(args, 0);
427    Ok(XPathValue::Number(n.ceil()))
428}
429
430/// round(number) — round to nearest integer.
431///
432/// UPSTREAM-PARITY: XPath 1.0 rounds towards positive infinity for .5 cases.
433/// Rust's f64::round() rounds half away from zero, which differs for negative .5 values.
434/// See XPath 1.0 §4.4.
435fn fn_round(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
436    let n = get_number_arg(args, 0);
437    if n.is_nan() || n.is_infinite() || n == 0.0 {
438        return Ok(XPathValue::Number(n));
439    }
440    // Rust's f64::round() uses "round half away from zero"
441    // XPath 1.0 uses "round half towards positive infinity"
442    // These differ for negative numbers with .5 fractional part
443    let rust_rounded = n.round();
444    let result = if n.is_sign_negative() && (n - rust_rounded).abs() == 0.5 {
445        // XPath: move towards positive infinity (i.e., add 1.0 to the Rust result)
446        rust_rounded + 1.0
447    } else {
448        rust_rounded
449    };
450    Ok(XPathValue::Number(result))
451}
452
453// ═══════════════════════════════════════════════════════════════════════════════
454// Tests
455// ═══════════════════════════════════════════════════════════════════════════════
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn test_true_false() {
463        let mut ctx = XPathContext::new(std::ptr::null_mut());
464        assert_eq!(fn_true(&mut ctx, &[]).unwrap().as_boolean(), true);
465        assert_eq!(fn_false(&mut ctx, &[]).unwrap().as_boolean(), false);
466    }
467
468    #[test]
469    fn test_boolean_conversion() {
470        let mut ctx = XPathContext::new(std::ptr::null_mut());
471        assert_eq!(
472            fn_boolean(&mut ctx, &[XPathValue::Boolean(true)])
473                .unwrap()
474                .as_boolean(),
475            true
476        );
477        assert_eq!(
478            fn_boolean(&mut ctx, &[XPathValue::Boolean(false)])
479                .unwrap()
480                .as_boolean(),
481            false
482        );
483    }
484
485    #[test]
486    fn test_not() {
487        let mut ctx = XPathContext::new(std::ptr::null_mut());
488        assert_eq!(
489            fn_not(&mut ctx, &[XPathValue::Boolean(true)])
490                .unwrap()
491                .as_boolean(),
492            false
493        );
494        assert_eq!(
495            fn_not(&mut ctx, &[XPathValue::Boolean(false)])
496                .unwrap()
497                .as_boolean(),
498            true
499        );
500    }
501
502    #[test]
503    fn test_number_round() {
504        let mut ctx = XPathContext::new(std::ptr::null_mut());
505        assert_eq!(
506            fn_floor(&mut ctx, &[XPathValue::Number(3.7)])
507                .unwrap()
508                .as_number(),
509            3.0
510        );
511        assert_eq!(
512            fn_ceiling(&mut ctx, &[XPathValue::Number(3.2)])
513                .unwrap()
514                .as_number(),
515            4.0
516        );
517        assert_eq!(
518            fn_round(&mut ctx, &[XPathValue::Number(3.5)])
519                .unwrap()
520                .as_number(),
521            4.0
522        );
523        assert_eq!(
524            fn_round(&mut ctx, &[XPathValue::Number(-3.5)])
525                .unwrap()
526                .as_number(),
527            -3.0
528        );
529    }
530
531    #[test]
532    fn test_string_functions() {
533        let mut ctx = XPathContext::new(std::ptr::null_mut());
534        assert_eq!(
535            fn_concat(
536                &mut ctx,
537                &[
538                    XPathValue::String("a".into()),
539                    XPathValue::String("b".into()),
540                    XPathValue::String("c".into())
541                ]
542            )
543            .unwrap()
544            .as_string(),
545            "abc"
546        );
547        assert_eq!(
548            fn_starts_with(
549                &mut ctx,
550                &[
551                    XPathValue::String("hello".into()),
552                    XPathValue::String("he".into())
553                ]
554            )
555            .unwrap()
556            .as_boolean(),
557            true
558        );
559        assert_eq!(
560            fn_starts_with(
561                &mut ctx,
562                &[
563                    XPathValue::String("hello".into()),
564                    XPathValue::String("x".into())
565                ]
566            )
567            .unwrap()
568            .as_boolean(),
569            false
570        );
571        assert_eq!(
572            fn_contains(
573                &mut ctx,
574                &[
575                    XPathValue::String("hello".into()),
576                    XPathValue::String("ell".into())
577                ]
578            )
579            .unwrap()
580            .as_boolean(),
581            true
582        );
583        assert_eq!(
584            fn_string_length(&mut ctx, &[XPathValue::String("hello".into())])
585                .unwrap()
586                .as_number(),
587            5.0
588        );
589    }
590
591    #[test]
592    fn test_core_functions_registered() {
593        let funcs = core_functions();
594        assert!(funcs.contains_key("last"));
595        assert!(funcs.contains_key("position"));
596        assert!(funcs.contains_key("count"));
597        assert!(funcs.contains_key("string"));
598        assert!(funcs.contains_key("concat"));
599        assert!(funcs.contains_key("boolean"));
600        assert!(funcs.contains_key("not"));
601        assert!(funcs.contains_key("number"));
602        assert!(funcs.contains_key("sum"));
603        assert!(funcs.contains_key("floor"));
604        assert!(funcs.contains_key("ceiling"));
605        assert!(funcs.contains_key("round"));
606        assert!(funcs.contains_key("name"));
607        assert!(funcs.contains_key("local-name"));
608        assert_eq!(funcs.len(), 27);
609        assert!(funcs.contains_key("namespace-uri"));
610    }
611}