Skip to main content

sup_xml_core/xpath/exslt/
str.rs

1//! EXSLT str family — https://exslt.org/str/
2//!
3//! All functions live in the `http://exslt.org/strings` namespace.
4//!
5//! Coverage:
6//!
7//! | Function       | Status      | Notes                                         |
8//! |----------------|-------------|-----------------------------------------------|
9//! | `concat`       | implemented | nodeset → string                              |
10//! | `replace`      | implemented | string-pair or nodeset-pair replacements      |
11//! | `padding`      | implemented | repeat-to-length                              |
12//! | `align`        | implemented | left / right / center                         |
13//! | `tokenize`     | implemented | uses the index's RTF allocator                |
14//! | `split`        | implemented | uses the index's RTF allocator                |
15//! | `encode-uri`   | implemented | RFC 3986 percent-encoding                     |
16//! | `decode-uri`   | implemented | percent-decode                                |
17//! | `lower-case`   | implemented | libexslt extension (Unicode case)             |
18//! | `upper-case`   | implemented | libexslt extension (Unicode case)             |
19//!
20//! `tokenize` and `split` produce a node-set of text nodes by
21//! allocating into the index's synthetic-text store; the resulting
22//! `NodeId`s flow through `for-each`, `value-of`, predicates, and
23//! `count()` like any other node-set member.  When invoked under
24//! XSLT, the XSLT engine's bindings intercept these calls first
25//! and use its own RTF pool — both paths produce equivalent
26//! node-sets, just sourced from different stores.
27
28use crate::error::{ErrorDomain, ErrorLevel, XmlError};
29use crate::xpath::eval::{Value, value_to_number, value_to_string};
30use crate::xpath::index::DocIndexLike;
31
32use super::Result;
33
34fn err(msg: impl Into<String>) -> XmlError {
35    XmlError::new(ErrorDomain::XPath, ErrorLevel::Error, msg)
36}
37
38pub fn dispatch<I: DocIndexLike>(
39    name: &str, args: Vec<Value>, idx: &I,
40) -> Option<Result<Value>> {
41    let r: Result<Value> = match name {
42        "concat"     => concat_fn(&args, idx),
43        "replace"    => replace_fn(&args, idx),
44        "padding"    => padding_fn(&args, idx),
45        "align"      => align_fn(&args, idx),
46        "encode-uri" => encode_uri_fn(&args, idx),
47        "decode-uri" => decode_uri_fn(&args, idx),
48        "lower-case" => lower_upper_fn(&args, idx, |c| c.to_lowercase().collect::<String>()),
49        "upper-case" => lower_upper_fn(&args, idx, |c| c.to_uppercase().collect::<String>()),
50        "tokenize"   => tokenize_or_split_fn(name, &args, idx),
51        "split"      => tokenize_or_split_fn(name, &args, idx),
52        _ => return None,
53    };
54    Some(r)
55}
56
57/// `str:tokenize(string, delim?)` / `str:split(string, sep?)`.
58/// Both yield a node-set of text nodes — one per token / fragment.
59/// Semantics (https://exslt.org/str/):
60///
61/// * `tokenize`: split on *any* character in `delim`; default delim
62///   is `"\t\r\n "` (whitespace).  Empty `delim` returns one node
63///   per character.  Empty fragments between adjacent delimiters
64///   are dropped.
65/// * `split`: split on the literal `sep` string; default sep is a
66///   single space.  Empty `sep` returns one node per character.
67///   Empty fragments are kept (libexslt does likewise).
68fn tokenize_or_split_fn<I: DocIndexLike>(
69    fn_name: &str, args: &[Value], idx: &I,
70) -> Result<Value> {
71    if args.is_empty() || args.len() > 2 {
72        return Err(err(format!("str:{fn_name} takes 1 or 2 arguments")));
73    }
74    let s = value_to_string(&args[0], idx);
75    let sep_raw = args.get(1).map(|v| value_to_string(v, idx));
76    let tokens: Vec<String> = match fn_name {
77        "tokenize" => {
78            let delim: &str = sep_raw.as_deref().unwrap_or("\t\r\n ");
79            if delim.is_empty() {
80                s.chars().map(|c| c.to_string()).collect()
81            } else {
82                s.split(|c: char| delim.contains(c))
83                    .filter(|t| !t.is_empty())
84                    .map(|t| t.to_string())
85                    .collect()
86            }
87        }
88        "split" => {
89            let sep: &str = sep_raw.as_deref().unwrap_or(" ");
90            if sep.is_empty() {
91                s.chars().map(|c| c.to_string()).collect()
92            } else {
93                s.split(sep).map(|t| t.to_string()).collect()
94            }
95        }
96        _ => unreachable!(),
97    };
98    let ids = idx.allocate_rtf_text_nodes(tokens).ok_or_else(|| err(format!(
99        "str:{fn_name}: this XPath context does not support RTF allocation"
100    )))?;
101    Ok(Value::NodeSet(ids))
102}
103
104/// `str:encode-uri(uri-part, escape-reserved?, encoding?)` — RFC 3986
105/// percent-encoding.  When `escape-reserved` is false (the default)
106/// only non-ASCII / unsafe characters are escaped, mirroring
107/// JavaScript's `encodeURI`.  When true, reserved gen-delims and
108/// sub-delims (`:/?#[]@!$&'()*+,;=`) are also escaped, mirroring
109/// `encodeURIComponent`.  The `encoding` argument is parsed for
110/// compatibility (EXSLT spec accepts it) but only `UTF-8` is
111/// supported — anything else returns the input unchanged with no
112/// error, same as libexslt.
113fn encode_uri_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Result<Value> {
114    if args.is_empty() || args.len() > 3 {
115        return Err(err("str:encode-uri takes 1, 2, or 3 arguments"));
116    }
117    let s = value_to_string(&args[0], idx);
118    let escape_reserved = match args.get(1) {
119        Some(Value::Boolean(b)) => *b,
120        Some(v) => !value_to_string(v, idx).is_empty()
121                    && value_to_string(v, idx) != "false",
122        None => false,
123    };
124    let encoding = args.get(2).map(|v| value_to_string(v, idx))
125        .unwrap_or_else(|| "UTF-8".into());
126    if !encoding.eq_ignore_ascii_case("UTF-8") && !encoding.is_empty() {
127        return Ok(Value::String(s));
128    }
129    let mut out = String::with_capacity(s.len());
130    for b in s.bytes() {
131        let safe_unreserved = matches!(b,
132            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
133            | b'-' | b'_' | b'.' | b'~'
134        );
135        // Reserved set per RFC 3986 §2.2.  Preserved verbatim
136        // unless escape_reserved is on.
137        let reserved = matches!(b,
138            b':' | b'/' | b'?' | b'#' | b'[' | b']' | b'@'
139            | b'!' | b'$' | b'&' | b'\'' | b'(' | b')'
140            | b'*' | b'+' | b',' | b';' | b'='
141        );
142        if safe_unreserved || (!escape_reserved && reserved) {
143            out.push(b as char);
144        } else {
145            out.push('%');
146            out.push_str(&format!("{b:02X}"));
147        }
148    }
149    Ok(Value::String(out))
150}
151
152/// `str:decode-uri(uri-part, encoding?)` — RFC 3986 percent-decoding.
153/// `%XX` sequences become single bytes; invalid sequences (bad hex)
154/// are passed through unchanged.  Only UTF-8 is supported; other
155/// encodings return the input unchanged.
156fn decode_uri_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Result<Value> {
157    if args.is_empty() || args.len() > 2 {
158        return Err(err("str:decode-uri takes 1 or 2 arguments"));
159    }
160    let s = value_to_string(&args[0], idx);
161    let encoding = args.get(1).map(|v| value_to_string(v, idx))
162        .unwrap_or_else(|| "UTF-8".into());
163    if !encoding.eq_ignore_ascii_case("UTF-8") && !encoding.is_empty() {
164        return Ok(Value::String(s));
165    }
166    let bytes = s.as_bytes();
167    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
168    let mut i = 0;
169    while i < bytes.len() {
170        if bytes[i] == b'%' && i + 2 < bytes.len() {
171            let hex = &s[i + 1..i + 3];
172            if let Ok(b) = u8::from_str_radix(hex, 16) {
173                out.push(b);
174                i += 3;
175                continue;
176            }
177        }
178        out.push(bytes[i]);
179        i += 1;
180    }
181    // Invalid UTF-8 after decode → pass-through with replacement
182    // chars; matches libexslt's "lossy on bad input" stance.
183    Ok(Value::String(String::from_utf8_lossy(&out).into_owned()))
184}
185
186/// `str:lower-case(s)` / `str:upper-case(s)` — Unicode case
187/// conversion.  EXSLT's spec uses C locale, but for real-world
188/// stylesheets the Unicode rules are what callers expect.
189fn lower_upper_fn<I: DocIndexLike>(
190    args: &[Value], idx: &I,
191    mapper: impl Fn(char) -> String,
192) -> Result<Value> {
193    if args.len() != 1 {
194        return Err(err("str:lower-case / str:upper-case takes 1 argument"));
195    }
196    let s = value_to_string(&args[0], idx);
197    Ok(Value::String(s.chars().map(mapper).collect()))
198}
199
200// ── concat ────────────────────────────────────────────────────────
201
202/// `str:concat(nodeset)` — concatenate the string-values of every
203/// node in `nodeset` in document order.  Distinct from XPath's
204/// built-in `concat(s1, s2, …)` which takes 2+ scalar args.
205fn concat_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Result<Value> {
206    if args.len() != 1 {
207        return Err(err("str:concat takes a single nodeset argument"));
208    }
209    let ns = match &args[0] {
210        Value::NodeSet(ns) => ns,
211        // Spec: non-nodeset arg → operate on its string value
212        // (libexslt behaviour — easier to use from outside XSLT).
213        other => return Ok(Value::String(value_to_string(other, idx))),
214    };
215    let mut out = String::new();
216    for &id in ns {
217        out.push_str(&idx.string_value(id));
218    }
219    Ok(Value::String(out))
220}
221
222// ── replace ───────────────────────────────────────────────────────
223
224/// `str:replace(string, search, replace)` — every occurrence of
225/// `search` in `string` is replaced by `replace`.
226///
227/// The spec allows `search` and `replace` to be nodesets,
228/// providing N parallel substitution pairs: position k of `search`
229/// pairs with position k of `replace`.  Substitutions are applied
230/// in document order, left-to-right, non-overlapping.  Items in
231/// `search` beyond `replace`'s length delete the matched text.
232fn replace_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Result<Value> {
233    if args.len() != 3 {
234        return Err(err("str:replace takes 3 arguments"));
235    }
236    let s = value_to_string(&args[0], idx);
237    let searches  = into_str_list(&args[1], idx);
238    let replaces  = into_str_list(&args[2], idx);
239
240    if searches.is_empty() {
241        return Ok(Value::String(s));
242    }
243
244    // Single-pass replace with leftmost-longest match precedence
245    // when multiple search terms could apply at the same position
246    // (mirrors libexslt's behaviour).
247    let mut out = String::with_capacity(s.len());
248    let bytes = s.as_bytes();
249    let mut i = 0;
250    'outer: while i < bytes.len() {
251        // Try each search term in order; first hit wins (the spec
252        // says "in document order" — same thing here since
253        // searches comes from the nodeset in document order).
254        for (k, needle) in searches.iter().enumerate() {
255            if needle.is_empty() { continue; }
256            if s[i..].starts_with(needle.as_str()) {
257                if let Some(rep) = replaces.get(k) {
258                    out.push_str(rep);
259                }
260                // searches beyond replaces' length: delete.
261                i += needle.len();
262                continue 'outer;
263            }
264        }
265        // No match — copy one char (handle UTF-8 boundary).
266        let c = s[i..].chars().next().unwrap();
267        out.push(c);
268        i += c.len_utf8();
269    }
270    Ok(Value::String(out))
271}
272
273/// Coerce a value to a list of strings.  Nodesets → one per node's
274/// string value (document order).  Anything else → a single-element
275/// list holding its string value.
276fn into_str_list<I: DocIndexLike>(v: &Value, idx: &I) -> Vec<String> {
277    match v {
278        Value::NodeSet(ns) => ns.iter().map(|&id| idx.string_value(id)).collect(),
279        other => vec![value_to_string(other, idx)],
280    }
281}
282
283// ── padding ───────────────────────────────────────────────────────
284
285/// `str:padding(length, chars?)` — returns a string of `length`
286/// characters built by repeating `chars` (default `" "`), truncated
287/// to exactly `length`.
288fn padding_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Result<Value> {
289    if args.is_empty() || args.len() > 2 {
290        return Err(err("str:padding takes 1 or 2 arguments"));
291    }
292    let length = value_to_number(&args[0], idx);
293    if !length.is_finite() || length < 0.0 {
294        return Ok(Value::String(String::new()));
295    }
296    let length = length as usize;
297    let pad = if args.len() == 2 {
298        value_to_string(&args[1], idx)
299    } else {
300        " ".to_string()
301    };
302    if pad.is_empty() {
303        // libexslt: empty pad → length spaces, matching its
304        // "default to space" fallback inside the loop.
305        return Ok(Value::String(" ".repeat(length)));
306    }
307    let mut out = String::with_capacity(length * 2);
308    while out.chars().count() < length {
309        out.push_str(&pad);
310    }
311    // Truncate to exactly `length` chars.
312    let truncated: String = out.chars().take(length).collect();
313    Ok(Value::String(truncated))
314}
315
316// ── align ─────────────────────────────────────────────────────────
317
318/// `str:align(target, padding, alignment?)` — return `target`
319/// shifted into a slot whose length and pad chars come from
320/// `padding`.  `alignment` is one of `"left"` (default), `"right"`,
321/// `"center"`.
322fn align_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Result<Value> {
323    if args.len() < 2 || args.len() > 3 {
324        return Err(err("str:align takes 2 or 3 arguments"));
325    }
326    let target  = value_to_string(&args[0], idx);
327    let padding = value_to_string(&args[1], idx);
328    let align   = args.get(2).map(|v| value_to_string(v, idx))
329        .unwrap_or_else(|| "left".to_string());
330
331    let pad_chars: Vec<char> = padding.chars().collect();
332    let target_chars: Vec<char> = target.chars().collect();
333    let n = pad_chars.len();
334
335    if target_chars.len() >= n {
336        // Target already fills (or overflows) the slot — truncate.
337        return Ok(Value::String(target_chars.into_iter().take(n).collect()));
338    }
339
340    let result: String = match align.as_str() {
341        "right" => {
342            let lead = n - target_chars.len();
343            pad_chars[..lead].iter().chain(target_chars.iter()).collect()
344        }
345        "center" => {
346            let space = n - target_chars.len();
347            let lead  = space / 2;
348            let trail_start = lead + target_chars.len();
349            pad_chars[..lead].iter()
350                .chain(target_chars.iter())
351                .chain(pad_chars[trail_start..].iter())
352                .collect()
353        }
354        _ /* "left" or anything else */ => {
355            target_chars.iter()
356                .chain(pad_chars[target_chars.len()..].iter())
357                .collect()
358        }
359    };
360    Ok(Value::String(result))
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::xpath::eval::Numeric;
367    use crate::xpath::XPathContext;
368    use crate::{parse_str, ParseOptions};
369
370    fn tiny() -> sup_xml_tree::dom::Document {
371        parse_str("<r/>", &ParseOptions::default()).unwrap()
372    }
373    fn s(v: &Value) -> String {
374        if let Value::String(s) = v { s.clone() } else { panic!("expected string, got {v:?}") }
375    }
376
377    #[test]
378    fn concat_over_nodeset() {
379        let doc = parse_str(
380            "<r><i>a</i><i>b</i><i>c</i></r>",
381            &ParseOptions::default(),
382        ).unwrap();
383        let ctx = XPathContext::new(&doc);
384        let ns = ctx.eval("/r/i").unwrap();
385        let v = dispatch("concat", vec![ns], &ctx.index).unwrap().unwrap();
386        assert_eq!(s(&v), "abc");
387    }
388
389    #[test]
390    fn replace_single_pair() {
391        let doc = tiny();
392        let ctx = XPathContext::new(&doc);
393        let v = dispatch("replace",
394            vec![Value::String("hello world".into()),
395                 Value::String("world".into()),
396                 Value::String("there".into())],
397            &ctx.index).unwrap().unwrap();
398        assert_eq!(s(&v), "hello there");
399    }
400
401    #[test]
402    fn replace_no_match_returns_input() {
403        let doc = tiny();
404        let ctx = XPathContext::new(&doc);
405        let v = dispatch("replace",
406            vec![Value::String("abc".into()),
407                 Value::String("z".into()),
408                 Value::String("Z".into())],
409            &ctx.index).unwrap().unwrap();
410        assert_eq!(s(&v), "abc");
411    }
412
413    #[test]
414    fn replace_handles_overlapping_matches() {
415        // "aaaa" with search "aa" → "bb" should be "bb"+"bb" = "bbbb",
416        // not "bba" (avoiding overlap).
417        let doc = tiny();
418        let ctx = XPathContext::new(&doc);
419        let v = dispatch("replace",
420            vec![Value::String("aaaa".into()),
421                 Value::String("aa".into()),
422                 Value::String("bb".into())],
423            &ctx.index).unwrap().unwrap();
424        assert_eq!(s(&v), "bbbb");
425    }
426
427    #[test]
428    fn padding_default_space() {
429        let doc = tiny();
430        let ctx = XPathContext::new(&doc);
431        let v = dispatch("padding",
432            vec![Value::Number(Numeric::Double(5.0))], &ctx.index).unwrap().unwrap();
433        assert_eq!(s(&v), "     ");
434    }
435
436    #[test]
437    fn padding_repeats_then_truncates() {
438        let doc = tiny();
439        let ctx = XPathContext::new(&doc);
440        let v = dispatch("padding",
441            vec![Value::Number(Numeric::Double(7.0)), Value::String("ab".into())],
442            &ctx.index).unwrap().unwrap();
443        assert_eq!(s(&v), "abababa");
444    }
445
446    #[test]
447    fn align_left_default() {
448        let doc = tiny();
449        let ctx = XPathContext::new(&doc);
450        let v = dispatch("align",
451            vec![Value::String("hi".into()),
452                 Value::String("....".into())],
453            &ctx.index).unwrap().unwrap();
454        assert_eq!(s(&v), "hi..");
455    }
456
457    #[test]
458    fn align_right() {
459        let doc = tiny();
460        let ctx = XPathContext::new(&doc);
461        let v = dispatch("align",
462            vec![Value::String("hi".into()),
463                 Value::String("....".into()),
464                 Value::String("right".into())],
465            &ctx.index).unwrap().unwrap();
466        assert_eq!(s(&v), "..hi");
467    }
468
469    #[test]
470    fn align_center() {
471        let doc = tiny();
472        let ctx = XPathContext::new(&doc);
473        let v = dispatch("align",
474            vec![Value::String("hi".into()),
475                 Value::String("......".into()),
476                 Value::String("center".into())],
477            &ctx.index).unwrap().unwrap();
478        // 6-char slot, "hi" centred: 2 lead, then "hi", then 2 trail.
479        assert_eq!(s(&v), "..hi..");
480    }
481
482    #[test]
483    fn align_truncates_when_target_overflows() {
484        let doc = tiny();
485        let ctx = XPathContext::new(&doc);
486        let v = dispatch("align",
487            vec![Value::String("longstring".into()),
488                 Value::String("---".into())],
489            &ctx.index).unwrap().unwrap();
490        assert_eq!(s(&v), "lon");
491    }
492
493    #[test]
494    fn tokenize_returns_text_nodeset() {
495        let doc = tiny();
496        let ctx = XPathContext::new(&doc);
497        let r = dispatch("tokenize",
498            vec![Value::String("a,b,c".into()), Value::String(",".into())],
499            &ctx.index).unwrap().unwrap();
500        let ns = match r { Value::NodeSet(ns) => ns, _ => panic!("expected nodeset") };
501        assert_eq!(ns.len(), 3);
502        let strs: Vec<String> = ns.iter().map(|&id| ctx.index.string_value(id)).collect();
503        assert_eq!(strs, vec!["a", "b", "c"]);
504    }
505
506    #[test]
507    fn tokenize_drops_empty_segments_between_adjacent_delims() {
508        let doc = tiny();
509        let ctx = XPathContext::new(&doc);
510        // "a,,b" with delim "," → ["a", "b"] (empty middle segment dropped).
511        let r = dispatch("tokenize",
512            vec![Value::String("a,,b".into()), Value::String(",".into())],
513            &ctx.index).unwrap().unwrap();
514        let ns = match r { Value::NodeSet(ns) => ns, _ => panic!() };
515        let strs: Vec<String> = ns.iter().map(|&id| ctx.index.string_value(id)).collect();
516        assert_eq!(strs, vec!["a", "b"]);
517    }
518
519    #[test]
520    fn tokenize_default_delim_is_whitespace() {
521        let doc = tiny();
522        let ctx = XPathContext::new(&doc);
523        let r = dispatch("tokenize",
524            vec![Value::String("foo  bar\tbaz".into())], &ctx.index).unwrap().unwrap();
525        let ns = match r { Value::NodeSet(ns) => ns, _ => panic!() };
526        let strs: Vec<String> = ns.iter().map(|&id| ctx.index.string_value(id)).collect();
527        assert_eq!(strs, vec!["foo", "bar", "baz"]);
528    }
529
530    #[test]
531    fn tokenize_via_xpath_expression_count_and_strings() {
532        // Full XPath flow: dispatcher reached via str: prefix in the
533        // expression, result is a node-set the rest of XPath can
534        // count, sort, predicate, and string-coerce just like any
535        // other node-set.
536        use crate::xpath::XPathBindingsBuilder;
537        let doc = tiny();
538        let ctx = XPathContext::new(&doc);
539        let mut bind = XPathBindingsBuilder::new();
540        bind.namespace("str", "http://exslt.org/strings");
541        let v = ctx.eval_with("count(str:tokenize('a,b,c,d', ','))", 0, &bind).unwrap();
542        assert_eq!(crate::xpath::eval::value_to_number(&v, &ctx.index), 4.0);
543        let v = ctx.eval_with("str:tokenize('foo bar baz')", 0, &bind).unwrap();
544        let strs = match v {
545            Value::NodeSet(ns) => ns.iter().map(|&id| ctx.index.string_value(id)).collect::<Vec<_>>(),
546            _ => panic!("expected nodeset"),
547        };
548        assert_eq!(strs, vec!["foo", "bar", "baz"]);
549    }
550
551    #[test]
552    fn split_preserves_empty_segments() {
553        let doc = tiny();
554        let ctx = XPathContext::new(&doc);
555        // "a,,b" with sep "," → ["a", "", "b"] (empty kept — split, not tokenize).
556        let r = dispatch("split",
557            vec![Value::String("a,,b".into()), Value::String(",".into())],
558            &ctx.index).unwrap().unwrap();
559        let ns = match r { Value::NodeSet(ns) => ns, _ => panic!() };
560        let strs: Vec<String> = ns.iter().map(|&id| ctx.index.string_value(id)).collect();
561        assert_eq!(strs, vec!["a", "", "b"]);
562    }
563
564    #[test]
565    fn unknown_function_returns_none() {
566        let doc = tiny();
567        let ctx = XPathContext::new(&doc);
568        assert!(dispatch("nonsense", vec![], &ctx.index).is_none());
569    }
570
571    #[test]
572    fn encode_uri_default_preserves_reserved() {
573        let doc = tiny();
574        let ctx = XPathContext::new(&doc);
575        let r = dispatch("encode-uri",
576            vec![Value::String("https://example.com/a b?c=1".into())],
577            &ctx.index).unwrap().unwrap();
578        match r {
579            // Space → %20; reserved chars (`:`, `/`, `?`, `=`) untouched
580            Value::String(s) => assert_eq!(s, "https://example.com/a%20b?c=1"),
581            _ => panic!(),
582        }
583    }
584
585    #[test]
586    fn encode_uri_with_reserved_flag_escapes_all() {
587        let doc = tiny();
588        let ctx = XPathContext::new(&doc);
589        let r = dispatch("encode-uri",
590            vec![Value::String("a/b?c=1".into()), Value::Boolean(true)],
591            &ctx.index).unwrap().unwrap();
592        match r {
593            Value::String(s) => assert_eq!(s, "a%2Fb%3Fc%3D1"),
594            _ => panic!(),
595        }
596    }
597
598    #[test]
599    fn decode_uri_roundtrips_encode() {
600        let doc = tiny();
601        let ctx = XPathContext::new(&doc);
602        let r = dispatch("decode-uri",
603            vec![Value::String("a%20b%2Fc".into())], &ctx.index).unwrap().unwrap();
604        match r {
605            Value::String(s) => assert_eq!(s, "a b/c"),
606            _ => panic!(),
607        }
608    }
609
610    #[test]
611    fn lower_upper_case_unicode() {
612        let doc = tiny();
613        let ctx = XPathContext::new(&doc);
614        let r = dispatch("lower-case",
615            vec![Value::String("HëLLo WÖRLD".into())], &ctx.index).unwrap().unwrap();
616        match r { Value::String(s) => assert_eq!(s, "hëllo wörld"), _ => panic!() }
617        let r = dispatch("upper-case",
618            vec![Value::String("straße".into())], &ctx.index).unwrap().unwrap();
619        // ß uppercases to SS per Unicode case rules.
620        match r { Value::String(s) => assert_eq!(s, "STRASSE"), _ => panic!() }
621    }
622}