Skip to main content

xsd_schema/xpath/functions/
string.rs

1//! String functions for XPath 2.0.
2//!
3//! This module implements XPath 2.0 string functions, delegating to
4//! the implementations in `xpath::string_ops`.
5
6use crate::types::value::XmlValue;
7use crate::xpath::error::XPathError;
8use crate::xpath::iterator::XmlItem;
9use crate::xpath::string_ops;
10use crate::xpath::DomNavigator;
11
12use super::{
13    atomize_to_double, atomize_to_string, atomize_to_string_opt, atomize_to_string_required,
14    atomize_to_string_strict, atomize_to_string_strict_opt, XPathValue,
15};
16use crate::xpath::context::DynamicContext;
17
18/// Default collation URI (codepoint collation).
19const DEFAULT_COLLATION: &str = "http://www.w3.org/2005/xpath-functions/collation/codepoint";
20
21/// Validate collation URI - only default collation is supported.
22/// Returns Ok(()) if collation is valid (default or empty), FOCH0002 otherwise.
23fn validate_collation(collation: Option<&str>) -> Result<(), XPathError> {
24    match collation {
25        None => Ok(()),
26        Some(c) if c.is_empty() || c == DEFAULT_COLLATION => Ok(()),
27        Some(c) => Err(XPathError::unknown_collation(c)),
28    }
29}
30
31// ============================================================================
32// String Functions
33// ============================================================================
34
35/// fn:concat($arg1 as xs:anyAtomicType?, $arg2 as xs:anyAtomicType?, ...) as xs:string
36///
37/// Concatenates two or more strings.
38pub fn concat<N: DomNavigator>(
39    _context: &mut DynamicContext<'_, N>,
40    args: Vec<XPathValue<N>>,
41) -> Result<XPathValue<N>, XPathError> {
42    if args.len() < 2 {
43        return Err(XPathError::wrong_number_of_arguments(
44            "concat",
45            2,
46            args.len(),
47        ));
48    }
49
50    let strings: Result<Vec<String>, _> = args.into_iter().map(atomize_to_string).collect();
51    let strings = strings?;
52    let refs: Vec<&str> = strings.iter().map(|s| s.as_str()).collect();
53    let result = string_ops::concat(&refs);
54    Ok(XPathValue::string(result))
55}
56
57/// fn:string-join($arg1 as xs:string*, $arg2 as xs:string) as xs:string
58///
59/// Joins strings with a separator.
60pub fn string_join<N: DomNavigator>(
61    _context: &mut DynamicContext<'_, N>,
62    mut args: Vec<XPathValue<N>>,
63) -> Result<XPathValue<N>, XPathError> {
64    if args.len() != 2 {
65        return Err(XPathError::wrong_number_of_arguments(
66            "string-join",
67            2,
68            args.len(),
69        ));
70    }
71
72    let separator = atomize_to_string_required(args.pop().unwrap())?;
73    let sequence = args.pop().unwrap();
74
75    // Collect all string values from the sequence
76    let strings: Result<Vec<String>, XPathError> = sequence
77        .into_vec()
78        .into_iter()
79        .map(|item| match item {
80            XmlItem::Atomic(v) => Ok(v.to_string_value()),
81            XmlItem::Node(n) => Ok(n.value()),
82        })
83        .collect();
84    let strings = strings?;
85    let refs: Vec<&str> = strings.iter().map(|s| s.as_str()).collect();
86    let result = string_ops::string_join(&refs, &separator);
87    Ok(XPathValue::string(result))
88}
89
90/// fn:substring($sourceString as xs:string?, $start as xs:double) as xs:string
91/// fn:substring($sourceString as xs:string?, $start as xs:double, $length as xs:double) as xs:string
92///
93/// Returns a portion of the source string.
94pub fn substring<N: DomNavigator>(
95    _context: &mut DynamicContext<'_, N>,
96    mut args: Vec<XPathValue<N>>,
97) -> Result<XPathValue<N>, XPathError> {
98    if args.len() < 2 || args.len() > 3 {
99        return Err(XPathError::wrong_number_of_arguments(
100            "substring",
101            2,
102            args.len(),
103        ));
104    }
105
106    let source = atomize_to_string(args.remove(0))?;
107    let start = atomize_to_double(args.remove(0))?;
108    let length = if !args.is_empty() {
109        Some(atomize_to_double(args.remove(0))?)
110    } else {
111        None
112    };
113
114    let result = string_ops::substring(&source, start, length);
115    Ok(XPathValue::string(result))
116}
117
118/// fn:string-length($arg as xs:string?) as xs:integer
119/// fn:string-length() as xs:integer (uses context item)
120///
121/// Returns the length of the string in characters.
122pub fn string_length<N: DomNavigator>(
123    context: &mut DynamicContext<'_, N>,
124    args: Vec<XPathValue<N>>,
125) -> Result<XPathValue<N>, XPathError> {
126    if args.len() > 1 {
127        return Err(XPathError::wrong_number_of_arguments(
128            "string-length",
129            1,
130            args.len(),
131        ));
132    }
133
134    let source = if args.is_empty() {
135        // Use context item
136        match &context.context_item {
137            Some(item) => match item {
138                XmlItem::Atomic(v) => v.to_string_value(),
139                XmlItem::Node(n) => n.value(),
140            },
141            None => {
142                return Err(XPathError::XPDY0002 {
143                    message: "Context item is absent".to_string(),
144                })
145            }
146        }
147    } else {
148        atomize_to_string(args.into_iter().next().unwrap())?
149    };
150
151    let len = string_ops::string_length(&source);
152    Ok(XPathValue::integer(len as i64))
153}
154
155/// fn:normalize-space($arg as xs:string?) as xs:string
156/// fn:normalize-space() as xs:string (uses context item)
157///
158/// Normalizes whitespace in a string.
159pub fn normalize_space<N: DomNavigator>(
160    context: &mut DynamicContext<'_, N>,
161    args: Vec<XPathValue<N>>,
162) -> Result<XPathValue<N>, XPathError> {
163    if args.len() > 1 {
164        return Err(XPathError::wrong_number_of_arguments(
165            "normalize-space",
166            1,
167            args.len(),
168        ));
169    }
170
171    let source = if args.is_empty() {
172        // Use context item
173        match &context.context_item {
174            Some(item) => match item {
175                XmlItem::Atomic(v) => v.to_string_value(),
176                XmlItem::Node(n) => n.value(),
177            },
178            None => {
179                return Err(XPathError::XPDY0002 {
180                    message: "Context item is absent".to_string(),
181                })
182            }
183        }
184    } else {
185        atomize_to_string(args.into_iter().next().unwrap())?
186    };
187
188    let result = string_ops::normalize_space(&source);
189    Ok(XPathValue::string(result))
190}
191
192/// fn:normalize-unicode($arg as xs:string?) as xs:string
193/// fn:normalize-unicode($arg as xs:string?, $normalizationForm as xs:string) as xs:string
194///
195/// Normalizes a string using Unicode normalization.
196pub fn normalize_unicode<N: DomNavigator>(
197    _context: &mut DynamicContext<'_, N>,
198    mut args: Vec<XPathValue<N>>,
199) -> Result<XPathValue<N>, XPathError> {
200    if args.is_empty() || args.len() > 2 {
201        return Err(XPathError::wrong_number_of_arguments(
202            "normalize-unicode",
203            1,
204            args.len(),
205        ));
206    }
207
208    let source = atomize_to_string_strict(args.remove(0))?;
209
210    let form = if !args.is_empty() {
211        let form_str = atomize_to_string_required(args.remove(0))?;
212        let trimmed = form_str.trim();
213        if trimmed.is_empty() {
214            None
215        } else {
216            match string_ops::UnicodeNormalizationForm::parse(trimmed) {
217                Some(f) => Some(f),
218                None => {
219                    return Err(XPathError::FOCH0003 {
220                        normalization_form: form_str,
221                    })
222                }
223            }
224        }
225    } else {
226        // Default is NFC
227        Some(string_ops::UnicodeNormalizationForm::NFC)
228    };
229
230    #[cfg(feature = "unicode-normalization")]
231    let result = string_ops::normalize_unicode(&source, form);
232
233    #[cfg(not(feature = "unicode-normalization"))]
234    let result = string_ops::normalize_unicode(&source, form)?;
235
236    Ok(XPathValue::string(result))
237}
238
239/// fn:upper-case($arg as xs:string?) as xs:string
240///
241/// Converts a string to uppercase.
242pub fn upper_case<N: DomNavigator>(
243    _context: &mut DynamicContext<'_, N>,
244    mut args: Vec<XPathValue<N>>,
245) -> Result<XPathValue<N>, XPathError> {
246    if args.len() != 1 {
247        return Err(XPathError::wrong_number_of_arguments(
248            "upper-case",
249            1,
250            args.len(),
251        ));
252    }
253
254    let source = atomize_to_string(args.remove(0))?;
255    let result = string_ops::upper_case(&source);
256    Ok(XPathValue::string(result))
257}
258
259/// fn:lower-case($arg as xs:string?) as xs:string
260///
261/// Converts a string to lowercase.
262pub fn lower_case<N: DomNavigator>(
263    _context: &mut DynamicContext<'_, N>,
264    mut args: Vec<XPathValue<N>>,
265) -> Result<XPathValue<N>, XPathError> {
266    if args.len() != 1 {
267        return Err(XPathError::wrong_number_of_arguments(
268            "lower-case",
269            1,
270            args.len(),
271        ));
272    }
273
274    let source = atomize_to_string(args.remove(0))?;
275    let result = string_ops::lower_case(&source);
276    Ok(XPathValue::string(result))
277}
278
279/// fn:translate($arg as xs:string?, $mapString as xs:string, $transString as xs:string) as xs:string
280///
281/// Translates characters in a string.
282pub fn translate<N: DomNavigator>(
283    _context: &mut DynamicContext<'_, N>,
284    mut args: Vec<XPathValue<N>>,
285) -> Result<XPathValue<N>, XPathError> {
286    if args.len() != 3 {
287        return Err(XPathError::wrong_number_of_arguments(
288            "translate",
289            3,
290            args.len(),
291        ));
292    }
293
294    let source = atomize_to_string_strict(args.remove(0))?;
295    let map_string = atomize_to_string_strict(args.remove(0))?;
296    let trans_string = atomize_to_string_strict(args.remove(0))?;
297
298    let result = string_ops::translate(&source, &map_string, &trans_string);
299    Ok(XPathValue::string(result))
300}
301
302/// fn:encode-for-uri($uri-part as xs:string?) as xs:string
303///
304/// Encodes a string for use in a URI.
305pub fn encode_for_uri<N: DomNavigator>(
306    _context: &mut DynamicContext<'_, N>,
307    mut args: Vec<XPathValue<N>>,
308) -> Result<XPathValue<N>, XPathError> {
309    if args.len() != 1 {
310        return Err(XPathError::wrong_number_of_arguments(
311            "encode-for-uri",
312            1,
313            args.len(),
314        ));
315    }
316
317    let source = atomize_to_string_strict(args.remove(0))?;
318    let result = string_ops::encode_for_uri(&source);
319    Ok(XPathValue::string(result))
320}
321
322/// fn:iri-to-uri($iri as xs:string?) as xs:string
323///
324/// Converts an IRI to a URI.
325pub fn iri_to_uri<N: DomNavigator>(
326    _context: &mut DynamicContext<'_, N>,
327    mut args: Vec<XPathValue<N>>,
328) -> Result<XPathValue<N>, XPathError> {
329    if args.len() != 1 {
330        return Err(XPathError::wrong_number_of_arguments(
331            "iri-to-uri",
332            1,
333            args.len(),
334        ));
335    }
336
337    let source = atomize_to_string_strict(args.remove(0))?;
338    let result = string_ops::iri_to_uri(&source);
339    Ok(XPathValue::string(result))
340}
341
342/// fn:escape-html-uri($uri as xs:string?) as xs:string
343///
344/// Escapes a URI for use in HTML.
345pub fn escape_html_uri<N: DomNavigator>(
346    _context: &mut DynamicContext<'_, N>,
347    mut args: Vec<XPathValue<N>>,
348) -> Result<XPathValue<N>, XPathError> {
349    if args.len() != 1 {
350        return Err(XPathError::wrong_number_of_arguments(
351            "escape-html-uri",
352            1,
353            args.len(),
354        ));
355    }
356
357    let source = atomize_to_string_strict(args.remove(0))?;
358    let result = string_ops::escape_html_uri(&source);
359    Ok(XPathValue::string(result))
360}
361
362/// fn:contains($arg1 as xs:string?, $arg2 as xs:string?) as xs:boolean
363/// fn:contains($arg1 as xs:string?, $arg2 as xs:string?, $collation as xs:string) as xs:boolean
364///
365/// Checks if a string contains a substring.
366pub fn contains<N: DomNavigator>(
367    _context: &mut DynamicContext<'_, N>,
368    mut args: Vec<XPathValue<N>>,
369) -> Result<XPathValue<N>, XPathError> {
370    if args.len() < 2 || args.len() > 3 {
371        return Err(XPathError::wrong_number_of_arguments(
372            "contains",
373            2,
374            args.len(),
375        ));
376    }
377
378    if args.len() == 3 {
379        let _collation = atomize_to_string_required(args.pop().unwrap())?;
380    }
381    let source = atomize_to_string(args.remove(0))?;
382    let substring = atomize_to_string(args.remove(0))?;
383    // Collation argument is ignored for now (uses default Unicode codepoint collation)
384
385    let result = string_ops::contains(&source, &substring);
386    Ok(XPathValue::boolean(result))
387}
388
389/// fn:starts-with($arg1 as xs:string?, $arg2 as xs:string?) as xs:boolean
390/// fn:starts-with($arg1 as xs:string?, $arg2 as xs:string?, $collation as xs:string) as xs:boolean
391///
392/// Checks if a string starts with a prefix.
393pub fn starts_with<N: DomNavigator>(
394    _context: &mut DynamicContext<'_, N>,
395    mut args: Vec<XPathValue<N>>,
396) -> Result<XPathValue<N>, XPathError> {
397    if args.len() < 2 || args.len() > 3 {
398        return Err(XPathError::wrong_number_of_arguments(
399            "starts-with",
400            2,
401            args.len(),
402        ));
403    }
404
405    if args.len() == 3 {
406        let _collation = atomize_to_string_required(args.pop().unwrap())?;
407    }
408    let source = atomize_to_string(args.remove(0))?;
409    let prefix = atomize_to_string(args.remove(0))?;
410    // Collation argument is ignored for now
411
412    let result = string_ops::starts_with(&source, &prefix);
413    Ok(XPathValue::boolean(result))
414}
415
416/// fn:ends-with($arg1 as xs:string?, $arg2 as xs:string?) as xs:boolean
417/// fn:ends-with($arg1 as xs:string?, $arg2 as xs:string?, $collation as xs:string) as xs:boolean
418///
419/// Checks if a string ends with a suffix.
420pub fn ends_with<N: DomNavigator>(
421    _context: &mut DynamicContext<'_, N>,
422    mut args: Vec<XPathValue<N>>,
423) -> Result<XPathValue<N>, XPathError> {
424    if args.len() < 2 || args.len() > 3 {
425        return Err(XPathError::wrong_number_of_arguments(
426            "ends-with",
427            2,
428            args.len(),
429        ));
430    }
431
432    if args.len() == 3 {
433        let _collation = atomize_to_string_required(args.pop().unwrap())?;
434    }
435    let source = atomize_to_string(args.remove(0))?;
436    let suffix = atomize_to_string(args.remove(0))?;
437    // Collation argument is ignored for now
438
439    let result = string_ops::ends_with(&source, &suffix);
440    Ok(XPathValue::boolean(result))
441}
442
443/// fn:substring-before($arg1 as xs:string?, $arg2 as xs:string?) as xs:string
444/// fn:substring-before($arg1 as xs:string?, $arg2 as xs:string?, $collation as xs:string) as xs:string
445///
446/// Returns the substring before the first occurrence of the pattern.
447pub fn substring_before<N: DomNavigator>(
448    _context: &mut DynamicContext<'_, N>,
449    mut args: Vec<XPathValue<N>>,
450) -> Result<XPathValue<N>, XPathError> {
451    if args.len() < 2 || args.len() > 3 {
452        return Err(XPathError::wrong_number_of_arguments(
453            "substring-before",
454            2,
455            args.len(),
456        ));
457    }
458
459    if args.len() == 3 {
460        let _collation = atomize_to_string_required(args.pop().unwrap())?;
461    }
462    let source = atomize_to_string(args.remove(0))?;
463    let pattern = atomize_to_string(args.remove(0))?;
464    // Collation argument is ignored for now
465
466    let result = string_ops::substring_before(&source, &pattern);
467    Ok(XPathValue::string(result))
468}
469
470/// fn:substring-after($arg1 as xs:string?, $arg2 as xs:string?) as xs:string
471/// fn:substring-after($arg1 as xs:string?, $arg2 as xs:string?, $collation as xs:string) as xs:string
472///
473/// Returns the substring after the first occurrence of the pattern.
474pub fn substring_after<N: DomNavigator>(
475    _context: &mut DynamicContext<'_, N>,
476    mut args: Vec<XPathValue<N>>,
477) -> Result<XPathValue<N>, XPathError> {
478    if args.len() < 2 || args.len() > 3 {
479        return Err(XPathError::wrong_number_of_arguments(
480            "substring-after",
481            2,
482            args.len(),
483        ));
484    }
485
486    if args.len() == 3 {
487        let _collation = atomize_to_string_required(args.pop().unwrap())?;
488    }
489    let source = atomize_to_string(args.remove(0))?;
490    let pattern = atomize_to_string(args.remove(0))?;
491    // Collation argument is ignored for now
492
493    let result = string_ops::substring_after(&source, &pattern);
494    Ok(XPathValue::string(result))
495}
496
497/// fn:string-to-codepoints($arg as xs:string?) as xs:integer*
498///
499/// Converts a string to a sequence of codepoints.
500pub fn string_to_codepoints<N: DomNavigator>(
501    _context: &mut DynamicContext<'_, N>,
502    mut args: Vec<XPathValue<N>>,
503) -> Result<XPathValue<N>, XPathError> {
504    if args.len() != 1 {
505        return Err(XPathError::wrong_number_of_arguments(
506            "string-to-codepoints",
507            1,
508            args.len(),
509        ));
510    }
511
512    let source = atomize_to_string_strict_opt(args.remove(0))?;
513
514    match source {
515        None => Ok(XPathValue::empty()),
516        Some(ref s) if s.is_empty() => Ok(XPathValue::empty()),
517        Some(s) => {
518            let codepoints = string_ops::string_to_codepoints(&s);
519            let items: Vec<XmlItem<N>> = codepoints
520                .into_iter()
521                .map(|cp| XmlItem::Atomic(XmlValue::integer(cp.into())))
522                .collect();
523            Ok(XPathValue::from_sequence(items))
524        }
525    }
526}
527
528/// fn:codepoints-to-string($arg as xs:integer*) as xs:string
529///
530/// Converts a sequence of codepoints to a string.
531/// Accepts any numeric type that can be converted to an integer codepoint.
532pub fn codepoints_to_string<N: DomNavigator>(
533    _context: &mut DynamicContext<'_, N>,
534    mut args: Vec<XPathValue<N>>,
535) -> Result<XPathValue<N>, XPathError> {
536    if args.len() != 1 {
537        return Err(XPathError::wrong_number_of_arguments(
538            "codepoints-to-string",
539            1,
540            args.len(),
541        ));
542    }
543
544    let sequence = args.remove(0);
545    let items = sequence.into_vec();
546
547    if items.is_empty() {
548        return Ok(XPathValue::string(""));
549    }
550
551    let mut codepoints = Vec::with_capacity(items.len());
552    for item in items {
553        match item {
554            XmlItem::Atomic(v) => {
555                let cp = atomize_to_codepoint(&v)?;
556                codepoints.push(cp);
557            }
558            XmlItem::Node(_) => {
559                return Err(XPathError::XPTY0004 {
560                    expected: "xs:integer".to_string(),
561                    found: "node()".to_string(),
562                });
563            }
564        }
565    }
566
567    match string_ops::codepoints_to_string(&codepoints) {
568        Some(s) => Ok(XPathValue::string(s)),
569        None => Err(XPathError::FOCH0001 {
570            codepoint: "invalid".to_string(),
571        }),
572    }
573}
574
575/// Check if a codepoint is a valid XML character per XML 1.0 spec.
576///
577/// Valid XML chars: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
578fn is_valid_xml_char(cp: u32) -> bool {
579    matches!(cp,
580        0x9 | 0xA | 0xD |
581        0x20..=0xD7FF |
582        0xE000..=0xFFFD |
583        0x10000..=0x10FFFF
584    )
585}
586
587/// Convert an atomic value to a codepoint (u32).
588/// Handles integer types directly and numeric types that are whole numbers.
589/// Validates the codepoint is a valid XML character (FOCH0001 if not).
590fn atomize_to_codepoint(value: &XmlValue) -> Result<u32, XPathError> {
591    let cp;
592
593    // Try integer first (most common case)
594    if let Some(i) = value.as_integer() {
595        cp = i.try_into().map_err(|_| XPathError::FOCH0001 {
596            codepoint: i.to_string(),
597        })?;
598    } else if value.type_code.is_numeric() {
599        if let Some(d) = value.as_double() {
600            // Check it's a whole number
601            if d.is_nan() || d.is_infinite() || d.fract() != 0.0 {
602                return Err(XPathError::FORG0001 {
603                    value: d.to_string(),
604                    target_type: "xs:integer".to_string(),
605                });
606            }
607            // Check it's in valid u32 range
608            if d < 0.0 || d > u32::MAX as f64 {
609                return Err(XPathError::FOCH0001 {
610                    codepoint: d.to_string(),
611                });
612            }
613            cp = d as u32;
614        } else {
615            return Err(XPathError::XPTY0004 {
616                expected: "xs:integer".to_string(),
617                found: format!("{:?}", value.type_code),
618            });
619        }
620    } else {
621        return Err(XPathError::XPTY0004 {
622            expected: "xs:integer".to_string(),
623            found: format!("{:?}", value.type_code),
624        });
625    }
626
627    // Validate the codepoint is a valid XML character
628    if !is_valid_xml_char(cp) {
629        return Err(XPathError::FOCH0001 {
630            codepoint: cp.to_string(),
631        });
632    }
633
634    Ok(cp)
635}
636
637/// fn:compare($comparand1 as xs:string?, $comparand2 as xs:string?) as xs:integer?
638/// fn:compare($comparand1 as xs:string?, $comparand2 as xs:string?, $collation as xs:string) as xs:integer?
639///
640/// Compares two strings.
641pub fn compare<N: DomNavigator>(
642    _context: &mut DynamicContext<'_, N>,
643    mut args: Vec<XPathValue<N>>,
644) -> Result<XPathValue<N>, XPathError> {
645    if args.len() < 2 || args.len() > 3 {
646        return Err(XPathError::wrong_number_of_arguments(
647            "compare",
648            2,
649            args.len(),
650        ));
651    }
652
653    // Validate collation if provided (third argument)
654    if args.len() == 3 {
655        let collation = atomize_to_string_required(args.pop().unwrap())?;
656        validate_collation(Some(&collation))?;
657    }
658    let s1 = atomize_to_string_opt(args.remove(0))?;
659    let s2 = atomize_to_string_opt(args.remove(0))?;
660
661    match (s1, s2) {
662        (Some(a), Some(b)) => {
663            let result = string_ops::compare(&a, &b);
664            Ok(XPathValue::integer(result as i64))
665        }
666        _ => Ok(XPathValue::empty()),
667    }
668}
669
670/// fn:codepoint-equal($comparand1 as xs:string?, $comparand2 as xs:string?) as xs:boolean?
671///
672/// Compares two strings by codepoint.
673pub fn codepoint_equal<N: DomNavigator>(
674    _context: &mut DynamicContext<'_, N>,
675    mut args: Vec<XPathValue<N>>,
676) -> Result<XPathValue<N>, XPathError> {
677    if args.len() != 2 {
678        return Err(XPathError::wrong_number_of_arguments(
679            "codepoint-equal",
680            2,
681            args.len(),
682        ));
683    }
684
685    let s1 = atomize_to_string_strict_opt(args.remove(0))?;
686    let s2 = atomize_to_string_strict_opt(args.remove(0))?;
687
688    match (s1, s2) {
689        (Some(a), Some(b)) => {
690            let result = string_ops::codepoint_equal(&a, &b);
691            Ok(XPathValue::boolean(result))
692        }
693        _ => Ok(XPathValue::empty()),
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::namespace::table::NameTable;
701    use crate::xpath::context::XPathContext;
702    use crate::xpath::RoXmlNavigator;
703
704    fn make_context() -> (NameTable, DynamicContext<'static, RoXmlNavigator<'static>>) {
705        let table = Box::leak(Box::new(NameTable::new()));
706        let static_ctx = Box::leak(Box::new(XPathContext::new(table)));
707        let dyn_ctx = DynamicContext::new(static_ctx, 0);
708        (NameTable::new(), dyn_ctx)
709    }
710
711    #[test]
712    fn test_concat() {
713        let (_, mut ctx) = make_context();
714        let args = vec![
715            XPathValue::string("Hello"),
716            XPathValue::string(", "),
717            XPathValue::string("World!"),
718        ];
719        let result = concat(&mut ctx, args).unwrap();
720        match result {
721            XPathValue::Item(XmlItem::Atomic(v)) => {
722                assert_eq!(v.as_string().unwrap(), "Hello, World!");
723            }
724            _ => panic!("Expected string"),
725        }
726    }
727
728    #[test]
729    fn test_string_join() {
730        let (_, mut ctx) = make_context();
731        let seq = XPathValue::from_sequence(vec![
732            XmlItem::Atomic(XmlValue::string("a")),
733            XmlItem::Atomic(XmlValue::string("b")),
734            XmlItem::Atomic(XmlValue::string("c")),
735        ]);
736        let args = vec![seq, XPathValue::string("-")];
737        let result = string_join(&mut ctx, args).unwrap();
738        match result {
739            XPathValue::Item(XmlItem::Atomic(v)) => {
740                assert_eq!(v.as_string().unwrap(), "a-b-c");
741            }
742            _ => panic!("Expected string"),
743        }
744    }
745
746    #[test]
747    fn test_substring() {
748        let (_, mut ctx) = make_context();
749        let args = vec![
750            XPathValue::string("hello"),
751            XPathValue::double(2.0),
752            XPathValue::double(3.0),
753        ];
754        let result = substring(&mut ctx, args).unwrap();
755        match result {
756            XPathValue::Item(XmlItem::Atomic(v)) => {
757                assert_eq!(v.as_string().unwrap(), "ell");
758            }
759            _ => panic!("Expected string"),
760        }
761    }
762
763    #[test]
764    fn test_string_length() {
765        let (_, mut ctx) = make_context();
766        let args = vec![XPathValue::string("hello")];
767        let result = string_length(&mut ctx, args).unwrap();
768        match result {
769            XPathValue::Item(XmlItem::Atomic(v)) => {
770                assert_eq!(*v.as_integer().unwrap(), 5.into());
771            }
772            _ => panic!("Expected integer"),
773        }
774    }
775
776    #[test]
777    fn test_upper_lower_case() {
778        let (_, mut ctx) = make_context();
779
780        let args = vec![XPathValue::string("Hello")];
781        let result = upper_case(&mut ctx, args).unwrap();
782        match result {
783            XPathValue::Item(XmlItem::Atomic(v)) => {
784                assert_eq!(v.as_string().unwrap(), "HELLO");
785            }
786            _ => panic!("Expected string"),
787        }
788
789        let args = vec![XPathValue::string("Hello")];
790        let result = lower_case(&mut ctx, args).unwrap();
791        match result {
792            XPathValue::Item(XmlItem::Atomic(v)) => {
793                assert_eq!(v.as_string().unwrap(), "hello");
794            }
795            _ => panic!("Expected string"),
796        }
797    }
798
799    #[test]
800    fn test_contains() {
801        let (_, mut ctx) = make_context();
802        let args = vec![
803            XPathValue::string("hello world"),
804            XPathValue::string("world"),
805        ];
806        let result = contains(&mut ctx, args).unwrap();
807        match result {
808            XPathValue::Item(XmlItem::Atomic(v)) => {
809                assert!(v.as_boolean().unwrap());
810            }
811            _ => panic!("Expected boolean"),
812        }
813    }
814
815    #[test]
816    fn test_starts_ends_with() {
817        let (_, mut ctx) = make_context();
818
819        let args = vec![XPathValue::string("hello"), XPathValue::string("he")];
820        let result = starts_with(&mut ctx, args).unwrap();
821        match result {
822            XPathValue::Item(XmlItem::Atomic(v)) => {
823                assert!(v.as_boolean().unwrap());
824            }
825            _ => panic!("Expected boolean"),
826        }
827
828        let args = vec![XPathValue::string("hello"), XPathValue::string("lo")];
829        let result = ends_with(&mut ctx, args).unwrap();
830        match result {
831            XPathValue::Item(XmlItem::Atomic(v)) => {
832                assert!(v.as_boolean().unwrap());
833            }
834            _ => panic!("Expected boolean"),
835        }
836    }
837
838    #[test]
839    fn test_encode_for_uri() {
840        let (_, mut ctx) = make_context();
841        let args = vec![XPathValue::string("hello world")];
842        let result = encode_for_uri(&mut ctx, args).unwrap();
843        match result {
844            XPathValue::Item(XmlItem::Atomic(v)) => {
845                assert_eq!(v.as_string().unwrap(), "hello%20world");
846            }
847            _ => panic!("Expected string"),
848        }
849    }
850
851    #[test]
852    fn test_string_to_codepoints() {
853        let (_, mut ctx) = make_context();
854        let args = vec![XPathValue::string("ABC")];
855        let result = string_to_codepoints(&mut ctx, args).unwrap();
856        let items = result.into_vec();
857        assert_eq!(items.len(), 3);
858        match &items[0] {
859            XmlItem::Atomic(v) => assert_eq!(*v.as_integer().unwrap(), 65.into()),
860            _ => panic!("Expected integer"),
861        }
862    }
863
864    #[test]
865    fn test_codepoints_to_string() {
866        let (_, mut ctx) = make_context();
867        let seq = XPathValue::from_sequence(vec![
868            XmlItem::Atomic(XmlValue::integer(65.into())),
869            XmlItem::Atomic(XmlValue::integer(66.into())),
870            XmlItem::Atomic(XmlValue::integer(67.into())),
871        ]);
872        let args = vec![seq];
873        let result = codepoints_to_string(&mut ctx, args).unwrap();
874        match result {
875            XPathValue::Item(XmlItem::Atomic(v)) => {
876                assert_eq!(v.as_string().unwrap(), "ABC");
877            }
878            _ => panic!("Expected string"),
879        }
880    }
881
882    #[test]
883    fn test_codepoints_to_string_from_doubles() {
884        let (_, mut ctx) = make_context();
885        // Use doubles that are whole numbers
886        let seq = XPathValue::from_sequence(vec![
887            XmlItem::Atomic(XmlValue::double(65.0)),
888            XmlItem::Atomic(XmlValue::double(66.0)),
889            XmlItem::Atomic(XmlValue::double(67.0)),
890        ]);
891        let args = vec![seq];
892        let result = codepoints_to_string(&mut ctx, args).unwrap();
893        match result {
894            XPathValue::Item(XmlItem::Atomic(v)) => {
895                assert_eq!(v.as_string().unwrap(), "ABC");
896            }
897            _ => panic!("Expected string"),
898        }
899    }
900
901    #[test]
902    fn test_codepoints_to_string_fractional_double_fails() {
903        let (_, mut ctx) = make_context();
904        // Use a double with a fractional part - should fail
905        let seq = XPathValue::from_sequence(vec![XmlItem::Atomic(XmlValue::double(65.5))]);
906        let args = vec![seq];
907        let result = codepoints_to_string(&mut ctx, args);
908        assert!(result.is_err());
909    }
910
911    #[test]
912    fn test_codepoints_to_string_empty() {
913        let (_, mut ctx) = make_context();
914        let seq = XPathValue::<RoXmlNavigator>::Empty;
915        let args = vec![seq];
916        let result = codepoints_to_string(&mut ctx, args).unwrap();
917        match result {
918            XPathValue::Item(XmlItem::Atomic(v)) => {
919                assert_eq!(v.as_string().unwrap(), "");
920            }
921            _ => panic!("Expected empty string"),
922        }
923    }
924
925    #[test]
926    fn test_compare() {
927        let (_, mut ctx) = make_context();
928        let args = vec![XPathValue::string("abc"), XPathValue::string("abd")];
929        let result = compare(&mut ctx, args).unwrap();
930        match result {
931            XPathValue::Item(XmlItem::Atomic(v)) => {
932                assert_eq!(*v.as_integer().unwrap(), (-1).into());
933            }
934            _ => panic!("Expected integer"),
935        }
936    }
937
938    #[test]
939    fn test_codepoint_equal() {
940        let (_, mut ctx) = make_context();
941        let args = vec![XPathValue::string("abc"), XPathValue::string("abc")];
942        let result = codepoint_equal(&mut ctx, args).unwrap();
943        match result {
944            XPathValue::Item(XmlItem::Atomic(v)) => {
945                assert!(v.as_boolean().unwrap());
946            }
947            _ => panic!("Expected boolean"),
948        }
949
950        let args = vec![XPathValue::string("abc"), XPathValue::string("ABC")];
951        let result = codepoint_equal(&mut ctx, args).unwrap();
952        match result {
953            XPathValue::Item(XmlItem::Atomic(v)) => {
954                assert!(!v.as_boolean().unwrap());
955            }
956            _ => panic!("Expected boolean"),
957        }
958    }
959
960    #[test]
961    fn test_compare_with_default_collation() {
962        let (_, mut ctx) = make_context();
963        // Should work with default collation
964        let args = vec![
965            XPathValue::string("abc"),
966            XPathValue::string("abd"),
967            XPathValue::string(DEFAULT_COLLATION),
968        ];
969        let result = compare(&mut ctx, args).unwrap();
970        match result {
971            XPathValue::Item(XmlItem::Atomic(v)) => {
972                assert_eq!(*v.as_integer().unwrap(), (-1).into());
973            }
974            _ => panic!("Expected integer"),
975        }
976    }
977
978    #[test]
979    fn test_compare_with_invalid_collation() {
980        let (_, mut ctx) = make_context();
981        // Should fail with unsupported collation
982        let args = vec![
983            XPathValue::string("abc"),
984            XPathValue::string("abd"),
985            XPathValue::string("http://example.com/custom-collation"),
986        ];
987        let result = compare(&mut ctx, args);
988        assert!(matches!(result, Err(XPathError::FOCH0002 { .. })));
989    }
990}