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
9//! observable behavior, including edge cases and historical quirks.
10//!
11//! # Courts
12//!
13//! XPATH-FUNCTIONS-*
14//!
15//! # Upstream contract
16//!
17//! Mirrors the core-function library of upstream `xpath.c`
18//! (`SRC-LIBXML2-2.15.0-XPATH-C`, parity target libxml2 2.15.3 oracle):
19//! the 25 XPath 1.0 §4 functions (node-set, string, boolean, number
20//! groups) with libxml2 observable edge cases.
21//!
22//! # Conceptual behavior
23//!
24//! Implements each function over already-evaluated `XPathValue` arguments
25//! with the upstream argument-count and coercion behavior: node-set
26//! functions (last, position, count, id, local-name, namespace-uri, name),
27//! string functions (string, concat, starts-with, substring, translate,
28//! ...), boolean functions and number functions (number, sum, floor,
29//! ceiling, round). number()/string() route through the R-000166 number
30//! formatter (1e9/1e-5 scientific threshold, DBL_DIG=15 fraction digits).
31//!
32//! # Ownership & safety invariants
33//!
34//! Functions return owned `XPathValue`s; node-set arguments are borrowed
35//! views over the tree (valid for the call). No function stores or caches
36//! argument pointers — values are copied at the boundary, so the registry
37//! is safe to share.
38//!
39//! # Historical quirks & epochs
40//!
41//! R-000114 (attribute string-value must be the attribute content, not
42//! empty) and R-000166 (full double-precision value-of printing) were
43//! fixed against the 2.15.3 oracle; the number() corpus (967/967 cases)
44//! locks the formatting epoch. The E-008 stable libxslt epoch means any
45//! function-level divergence is a candidate bug, not an epoch difference.
46//!
47//! # Deliberate oddities
48//!
49//! round()/floor()/ceiling() reproduce libxml2 IEEE-754 handling
50//! (including negative zero and NaN propagation) rather than Rust
51//! rounding helpers, which differ on ties and sign.
52//!
53//! # Proving courts
54//!
55//! XPATH-FUNCTIONS-* differential probes and the 967/967 number() corpus
56//! compare results byte-identical against the oracle; the XSLT courts
57//! (CLI-XSLTPROC-0014/0015/0017) exercise value-of/format-number through
58//! these functions.
59//!
60//! # Tempting simplifications that would break parity
61//!
62//! Do not delegate number formatting to Rust float formatting: the
63//! scientific threshold, digit counts and exponent padding are
64//! oracle-observable (R-000166). Do not coerce arguments more eagerly
65//! than upstream (e.g. empty node-sets to string) — R-000114 proved the
66//! string-value rules are observable.
67
68use crate::xml::xpath::context::{BoxedXPathFunction, XPathContext};
69use crate::xml::xpath::types::{node_string_value, string_to_number, NodeSet, XPathValue};
70use once_cell::sync::Lazy;
71use std::collections::HashMap;
72
73/// Type alias for XPath functions.
74///
75/// Functions receive already-evaluated arguments as `XPathValue` slices.
76pub type XPathFunction = fn(&mut XPathContext, &[XPathValue]) -> Result<XPathValue, String>;
77
78/// The XPath 1.0 core function library, as a static `(name, fn)` slice.
79///
80/// Phase 16.5.9: these are built-ins and must not be rebuilt/reboxed into a
81/// fresh `HashMap` for every new context. The slice is the single source of
82/// truth; `lookup_core_function` consults a lazily-built static table so the
83/// hot path is a hash lookup with zero per-context allocation.
84pub static CORE_FUNCTION_SLICE: &[(&str, XPathFunction)] = &[
85    // Node set functions (§4.1)
86    ("last", fn_last),
87    ("position", fn_position),
88    ("count", fn_count),
89    ("id", fn_id),
90    ("local-name", fn_local_name),
91    ("namespace-uri", fn_namespace_uri),
92    ("name", fn_name),
93    // String functions (§4.2)
94    ("string", fn_string),
95    ("concat", fn_concat),
96    ("starts-with", fn_starts_with),
97    ("contains", fn_contains),
98    ("substring-before", fn_substring_before),
99    ("substring-after", fn_substring_after),
100    ("substring", fn_substring),
101    ("string-length", fn_string_length),
102    ("normalize-space", fn_normalize_space),
103    ("translate", fn_translate),
104    // Boolean functions (§4.3)
105    ("boolean", fn_boolean),
106    ("not", fn_not),
107    ("true", fn_true),
108    ("false", fn_false),
109    ("lang", fn_lang),
110    // Number functions (§4.4)
111    ("number", fn_number),
112    ("sum", fn_sum),
113    ("floor", fn_floor),
114    ("ceiling", fn_ceiling),
115    ("round", fn_round),
116];
117
118static CORE_FUNCTION_TABLE: Lazy<HashMap<&'static str, BoxedXPathFunction>> = Lazy::new(|| {
119    CORE_FUNCTION_SLICE
120        .iter()
121        .map(|&(name, f)| (name, Box::new(f) as BoxedXPathFunction))
122        .collect()
123});
124
125/// Look up a core XPath 1.0 built-in by name (allocation-free; the table is
126/// built once and shared across every context).
127pub fn lookup_core_function(name: &str) -> Option<&'static BoxedXPathFunction> {
128    CORE_FUNCTION_TABLE.get(name)
129}
130
131/// The XPath 1.0 core function library as a name→fn map.
132///
133/// Kept for call sites/tests that need a `HashMap`; new hot-path code should
134/// prefer [`lookup_core_function`] to avoid the per-context rebuild.
135pub fn core_functions() -> HashMap<String, XPathFunction> {
136    CORE_FUNCTION_SLICE
137        .iter()
138        .map(|&(name, f)| (name.to_string(), f))
139        .collect()
140}
141
142// ═══════════════════════════════════════════════════════════════════════════════
143// Helper: extract typed arguments
144// ═══════════════════════════════════════════════════════════════════════════════
145
146fn get_string_arg(args: &[XPathValue], index: usize) -> String {
147    if index < args.len() {
148        args[index].as_string()
149    } else {
150        String::new()
151    }
152}
153
154fn get_number_arg(args: &[XPathValue], index: usize) -> f64 {
155    if index < args.len() {
156        args[index].as_number()
157    } else {
158        f64::NAN
159    }
160}
161
162fn get_boolean_arg(args: &[XPathValue], index: usize) -> bool {
163    if index < args.len() {
164        args[index].as_boolean()
165    } else {
166        false
167    }
168}
169
170fn get_node_set_arg(args: &[XPathValue], index: usize) -> NodeSet {
171    if index < args.len() {
172        match &args[index] {
173            XPathValue::NodeSet(ns) => ns.clone(),
174            _ => NodeSet::new(),
175        }
176    } else {
177        NodeSet::new()
178    }
179}
180
181fn get_first_node(
182    ctx: &XPathContext,
183    args: &[XPathValue],
184    index: usize,
185) -> Option<*mut crate::abi::structs::_xmlNode> {
186    if index < args.len() {
187        match &args[index] {
188            XPathValue::NodeSet(ns) => ns.first(),
189            _ => None,
190        }
191    } else {
192        // Default to context node
193        Some(ctx.context_node)
194    }
195}
196
197// ═══════════════════════════════════════════════════════════════════════════════
198// Node Set Functions (§4.1)
199// ═══════════════════════════════════════════════════════════════════════════════
200
201/// last() — context size.
202const fn fn_last(ctx: &mut XPathContext, _args: &[XPathValue]) -> Result<XPathValue, String> {
203    Ok(XPathValue::Number(ctx.last() as f64))
204}
205
206/// position() — context position.
207const fn fn_position(ctx: &mut XPathContext, _args: &[XPathValue]) -> Result<XPathValue, String> {
208    Ok(XPathValue::Number(ctx.position() as f64))
209}
210
211/// count(node-set) — number of nodes in node-set.
212fn fn_count(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
213    let ns = get_node_set_arg(args, 0);
214    Ok(XPathValue::Number(ns.len() as f64))
215}
216
217/// id(object) — select elements by ID.
218const fn fn_id(_ctx: &mut XPathContext, _args: &[XPathValue]) -> Result<XPathValue, String> {
219    // id() is complex: requires DTD validation to know which attributes are ID.
220    // For now, return empty node-set.
221    Ok(XPathValue::NodeSet(NodeSet::new()))
222}
223
224/// local-name(node-set?) — local part of name.
225///
226/// # Safety
227///
228/// - The node returned by `get_first_node` must be NULL or a valid
229///   `_xmlNode` that stays alive for the call; its `name` field must be
230///   NULL or a valid NUL-terminated string.
231fn fn_local_name(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
232    let node = get_first_node(ctx, args, 0);
233    if let Some(node) = node {
234        unsafe {
235            let name = crate::xml::string::xmlstr_to_string((*node).name);
236            // Strip prefix if present
237            if let Some(pos) = name.find(':') {
238                Ok(XPathValue::String(name[pos + 1..].to_string()))
239            } else {
240                Ok(XPathValue::String(name))
241            }
242        }
243    } else {
244        Ok(XPathValue::String(String::new()))
245    }
246}
247
248/// namespace-uri(node-set?) — namespace URI of node.
249///
250/// # Safety
251///
252/// - The node returned by `get_first_node` must be NULL or a valid
253///   `_xmlNode` that stays alive for the call; its `ns` pointer, when
254///   non-NULL, must point to a valid `_xmlNs` whose `href` is NULL or a
255///   valid NUL-terminated string.
256fn fn_namespace_uri(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
257    let node = get_first_node(ctx, args, 0);
258    if let Some(node) = node {
259        unsafe {
260            if let Some(ns) = (*node).ns.as_ref() {
261                let uri = crate::xml::string::xmlstr_to_string(ns.href);
262                Ok(XPathValue::String(uri))
263            } else {
264                Ok(XPathValue::String(String::new()))
265            }
266        }
267    } else {
268        Ok(XPathValue::String(String::new()))
269    }
270}
271
272/// name(node-set?) — QName of the first node in document order.
273///
274/// Element/attribute nodes bound to a prefixed namespace return
275/// `prefix:local`; everything else follows the local-name rule.
276///
277/// # Safety
278///
279/// - The node returned by `get_first_node` must be NULL or a valid
280///   `_xmlNode` that stays alive for the call; its `name` and `ns` fields
281///   must be valid.
282fn fn_name(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
283    let node = get_first_node(ctx, args, 0);
284    if let Some(node) = node {
285        unsafe {
286            use crate::abi::types::xmlElementType as ET;
287            let t = (*node).type_;
288            let name = crate::xml::string::xmlstr_to_string((*node).name);
289            if (t == ET::XML_ELEMENT_NODE as i32 || t == ET::XML_ATTRIBUTE_NODE as i32)
290                && !name.is_empty()
291                && !(*node).ns.is_null()
292                && !(*(*node).ns).prefix.is_null()
293            {
294                let prefix = crate::xml::string::xmlstr_to_string((*(*node).ns).prefix);
295                Ok(XPathValue::String(format!("{prefix}:{name}")))
296            } else {
297                Ok(XPathValue::String(name))
298            }
299        }
300    } else {
301        Ok(XPathValue::String(String::new()))
302    }
303}
304
305// ═══════════════════════════════════════════════════════════════════════════════
306// String Functions (§4.2)
307// ═══════════════════════════════════════════════════════════════════════════════
308
309/// string(object?) — convert to string.
310fn fn_string(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
311    if args.is_empty() {
312        // Default: context node's string value
313        Ok(XPathValue::String(node_string_value(ctx.context_node)))
314    } else {
315        Ok(XPathValue::String(args[0].as_string()))
316    }
317}
318
319/// concat(string, string, ...) — concatenate strings.
320fn fn_concat(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
321    let mut result = String::new();
322    for arg in args {
323        result.push_str(&arg.as_string());
324    }
325    Ok(XPathValue::String(result))
326}
327
328/// starts-with(string1, string2) — check prefix.
329fn fn_starts_with(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
330    let s1 = get_string_arg(args, 0);
331    let s2 = get_string_arg(args, 1);
332    Ok(XPathValue::Boolean(s1.starts_with(&s2)))
333}
334
335/// contains(string1, string2) — check substring.
336fn fn_contains(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
337    let s1 = get_string_arg(args, 0);
338    let s2 = get_string_arg(args, 1);
339    Ok(XPathValue::Boolean(s1.contains(&s2)))
340}
341
342/// substring-before(string1, string2) — before first occurrence.
343fn fn_substring_before(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
344    let s1 = get_string_arg(args, 0);
345    let s2 = get_string_arg(args, 1);
346    if let Some(pos) = s1.find(&s2) {
347        Ok(XPathValue::String(s1[..pos].to_string()))
348    } else {
349        Ok(XPathValue::String(String::new()))
350    }
351}
352
353/// substring-after(string1, string2) — after first occurrence.
354fn fn_substring_after(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
355    let s1 = get_string_arg(args, 0);
356    let s2 = get_string_arg(args, 1);
357    if let Some(pos) = s1.find(&s2) {
358        Ok(XPathValue::String(s1[pos + s2.len()..].to_string()))
359    } else {
360        Ok(XPathValue::String(String::new()))
361    }
362}
363
364/// substring(string, number, number?) — substring extraction.
365///
366/// UPSTREAM-PARITY (XPath 1.0 §4.2): substring operates on CHARACTERS
367/// (Unicode code points), and a character at 1-based position P is included
368/// when `P >= round(start)` and `P < round(start) + round(length)`. Byte
369/// slicing is wrong for multibyte strings (bug26384: xsl:key over a
370/// Cyrillic value panicked on a non-char-boundary slice).
371fn fn_substring(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
372    let s = get_string_arg(args, 0);
373    let start = get_number_arg(args, 1);
374    let has_length = args.len() >= 3;
375    let length = if has_length {
376        get_number_arg(args, 2)
377    } else {
378        f64::MAX
379    };
380
381    let start_r = start.round();
382    let end_r = start_r + length.round();
383
384    let mut out = String::new();
385    for (i, c) in s.chars().enumerate() {
386        let p = (i + 1) as f64;
387        if p >= start_r && p < end_r {
388            out.push(c);
389        }
390    }
391    Ok(XPathValue::String(out))
392}
393
394/// string-length(string?) — length of string.
395///
396/// UPSTREAM-PARITY (XPath 1.0 §4.2): string-length counts CHARACTERS (Unicode
397/// code points), not bytes.
398fn fn_string_length(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
399    let s = if args.is_empty() {
400        node_string_value(ctx.context_node)
401    } else {
402        get_string_arg(args, 0)
403    };
404    Ok(XPathValue::Number(s.chars().count() as f64))
405}
406
407/// normalize-space(string?) — normalize whitespace.
408fn fn_normalize_space(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
409    let s = if args.is_empty() {
410        node_string_value(ctx.context_node)
411    } else {
412        get_string_arg(args, 0)
413    };
414    let normalized: Vec<&str> = s.split_whitespace().collect();
415    Ok(XPathValue::String(normalized.join(" ")))
416}
417
418/// translate(string1, string2, string3) — character translation.
419fn fn_translate(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
420    let s = get_string_arg(args, 0);
421    let from = get_string_arg(args, 1);
422    let to = get_string_arg(args, 2);
423
424    let result: String = s
425        .chars()
426        .map(|c| {
427            // UPSTREAM-PARITY (XPath 1.0 §4.2): translate maps by CHARACTER
428            // position within the "from" string (byte offsets are wrong for
429            // multibyte "from" strings).
430            if let Some(pos) = from.chars().position(|x| x == c) {
431                let to_chars: Vec<char> = to.chars().collect();
432                if pos < to_chars.len() {
433                    to_chars[pos]
434                } else {
435                    '\0' // Remove character
436                }
437            } else {
438                c
439            }
440        })
441        .filter(|&c| c != '\0')
442        .collect();
443
444    Ok(XPathValue::String(result))
445}
446
447// ═══════════════════════════════════════════════════════════════════════════════
448// Boolean Functions (§4.3)
449// ═══════════════════════════════════════════════════════════════════════════════
450
451/// boolean(object) — convert to boolean.
452fn fn_boolean(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
453    Ok(XPathValue::Boolean(get_boolean_arg(args, 0)))
454}
455
456/// not(boolean) — logical NOT.
457fn fn_not(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
458    Ok(XPathValue::Boolean(!get_boolean_arg(args, 0)))
459}
460
461/// true() — constant true.
462const fn fn_true(_ctx: &mut XPathContext, _args: &[XPathValue]) -> Result<XPathValue, String> {
463    Ok(XPathValue::Boolean(true))
464}
465
466/// false() — constant false.
467const fn fn_false(_ctx: &mut XPathContext, _args: &[XPathValue]) -> Result<XPathValue, String> {
468    Ok(XPathValue::Boolean(false))
469}
470
471/// lang(string) — language test.
472///
473/// # Safety
474///
475/// - `ctx.context_node` must be NULL or a valid `_xmlNode`; the walk up
476///   the `parent` chain and through each node's `properties` must only
477///   touch valid nodes and attributes whose `name` and child `content`
478///   fields are NULL or valid NUL-terminated strings; the chain must stay
479///   alive and acyclic for the duration of the call.
480fn fn_lang(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
481    let lang = get_string_arg(args, 0);
482    let mut node = ctx.context_node;
483    unsafe {
484        while !node.is_null() {
485            let mut prop = (*node).properties;
486            while !prop.is_null() {
487                let attr_name = crate::xml::string::xmlstr_to_string((*prop).name);
488                if attr_name == "lang" || attr_name == "xml:lang" {
489                    // Attribute value is stored in children (text node's content)
490                    if !(*prop).children.is_null() {
491                        let attr_val =
492                            crate::xml::string::xmlstr_to_string((*(*prop).children).content);
493                        if attr_val.to_lowercase() == lang.to_lowercase()
494                            || attr_val
495                                .to_lowercase()
496                                .starts_with(&format!("{}-", lang.to_lowercase()))
497                        {
498                            return Ok(XPathValue::Boolean(true));
499                        }
500                    }
501                }
502                prop = (*prop).next;
503            }
504            node = (*node).parent;
505        }
506    }
507    Ok(XPathValue::Boolean(false))
508}
509
510// ═══════════════════════════════════════════════════════════════════════════════
511// Number Functions (§4.4)
512// ═══════════════════════════════════════════════════════════════════════════════
513
514/// number(object?) — convert to number.
515fn fn_number(ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
516    if args.is_empty() {
517        Ok(XPathValue::Number(string_to_number(&node_string_value(
518            ctx.context_node,
519        ))))
520    } else {
521        Ok(XPathValue::Number(get_number_arg(args, 0)))
522    }
523}
524
525/// sum(node-set) — sum of string->number conversions.
526fn fn_sum(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
527    let ns = get_node_set_arg(args, 0);
528    let mut total = 0.0;
529    for node in ns.iter() {
530        let s = node_string_value(node);
531        total += string_to_number(&s);
532    }
533    Ok(XPathValue::Number(total))
534}
535
536/// floor(number) — largest integer <= value.
537fn fn_floor(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
538    let n = get_number_arg(args, 0);
539    Ok(XPathValue::Number(n.floor()))
540}
541
542/// ceiling(number) — smallest integer >= value.
543fn fn_ceiling(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
544    let n = get_number_arg(args, 0);
545    Ok(XPathValue::Number(n.ceil()))
546}
547
548/// round(number) — round to nearest integer.
549///
550/// UPSTREAM-PARITY: XPath 1.0 rounds towards positive infinity for .5 cases.
551/// Rust's f64::round() rounds half away from zero, which differs for negative .5 values.
552/// See XPath 1.0 §4.4.
553fn fn_round(_ctx: &mut XPathContext, args: &[XPathValue]) -> Result<XPathValue, String> {
554    let n = get_number_arg(args, 0);
555    if n.is_nan() || n.is_infinite() || n == 0.0 {
556        return Ok(XPathValue::Number(n));
557    }
558    // Rust's f64::round() uses "round half away from zero"
559    // XPath 1.0 uses "round half towards positive infinity"
560    // These differ for negative numbers with .5 fractional part
561    let rust_rounded = n.round();
562    let result = if n.is_sign_negative() && (n - rust_rounded).abs() == 0.5 {
563        // XPath: move towards positive infinity (i.e., add 1.0 to the Rust result)
564        rust_rounded + 1.0
565    } else {
566        rust_rounded
567    };
568    Ok(XPathValue::Number(result))
569}
570
571// ═══════════════════════════════════════════════════════════════════════════════
572// Tests
573// ═══════════════════════════════════════════════════════════════════════════════
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn test_true_false() {
581        let mut ctx = XPathContext::new(std::ptr::null_mut());
582        assert!(fn_true(&mut ctx, &[]).unwrap().as_boolean());
583        assert!(!fn_false(&mut ctx, &[]).unwrap().as_boolean());
584    }
585
586    #[test]
587    fn test_boolean_conversion() {
588        let mut ctx = XPathContext::new(std::ptr::null_mut());
589        assert!(fn_boolean(&mut ctx, &[XPathValue::Boolean(true)])
590            .unwrap()
591            .as_boolean());
592        assert!(!fn_boolean(&mut ctx, &[XPathValue::Boolean(false)])
593            .unwrap()
594            .as_boolean());
595    }
596
597    #[test]
598    fn test_not() {
599        let mut ctx = XPathContext::new(std::ptr::null_mut());
600        assert!(!fn_not(&mut ctx, &[XPathValue::Boolean(true)])
601            .unwrap()
602            .as_boolean());
603        assert!(fn_not(&mut ctx, &[XPathValue::Boolean(false)])
604            .unwrap()
605            .as_boolean());
606    }
607
608    #[test]
609    fn test_number_round() {
610        let mut ctx = XPathContext::new(std::ptr::null_mut());
611        assert_eq!(
612            fn_floor(&mut ctx, &[XPathValue::Number(3.7)])
613                .unwrap()
614                .as_number(),
615            3.0
616        );
617        assert_eq!(
618            fn_ceiling(&mut ctx, &[XPathValue::Number(3.2)])
619                .unwrap()
620                .as_number(),
621            4.0
622        );
623        assert_eq!(
624            fn_round(&mut ctx, &[XPathValue::Number(3.5)])
625                .unwrap()
626                .as_number(),
627            4.0
628        );
629        assert_eq!(
630            fn_round(&mut ctx, &[XPathValue::Number(-3.5)])
631                .unwrap()
632                .as_number(),
633            -3.0
634        );
635    }
636
637    #[test]
638    fn test_string_functions() {
639        let mut ctx = XPathContext::new(std::ptr::null_mut());
640        assert_eq!(
641            fn_concat(
642                &mut ctx,
643                &[
644                    XPathValue::String("a".into()),
645                    XPathValue::String("b".into()),
646                    XPathValue::String("c".into())
647                ]
648            )
649            .unwrap()
650            .as_string(),
651            "abc"
652        );
653        assert!(fn_starts_with(
654            &mut ctx,
655            &[
656                XPathValue::String("hello".into()),
657                XPathValue::String("he".into())
658            ]
659        )
660        .unwrap()
661        .as_boolean());
662        assert!(!fn_starts_with(
663            &mut ctx,
664            &[
665                XPathValue::String("hello".into()),
666                XPathValue::String("x".into())
667            ]
668        )
669        .unwrap()
670        .as_boolean());
671        assert!(fn_contains(
672            &mut ctx,
673            &[
674                XPathValue::String("hello".into()),
675                XPathValue::String("ell".into())
676            ]
677        )
678        .unwrap()
679        .as_boolean());
680        assert_eq!(
681            fn_string_length(&mut ctx, &[XPathValue::String("hello".into())])
682                .unwrap()
683                .as_number(),
684            5.0
685        );
686    }
687
688    #[test]
689    fn test_core_functions_registered() {
690        let funcs = core_functions();
691        assert!(funcs.contains_key("last"));
692        assert!(funcs.contains_key("position"));
693        assert!(funcs.contains_key("count"));
694        assert!(funcs.contains_key("string"));
695        assert!(funcs.contains_key("concat"));
696        assert!(funcs.contains_key("boolean"));
697        assert!(funcs.contains_key("not"));
698        assert!(funcs.contains_key("number"));
699        assert!(funcs.contains_key("sum"));
700        assert!(funcs.contains_key("floor"));
701        assert!(funcs.contains_key("ceiling"));
702        assert!(funcs.contains_key("round"));
703        assert!(funcs.contains_key("name"));
704        assert!(funcs.contains_key("local-name"));
705        assert_eq!(funcs.len(), 27);
706        assert!(funcs.contains_key("namespace-uri"));
707    }
708}