1use 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
57fn 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
104fn 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 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
152fn 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 Ok(Value::String(String::from_utf8_lossy(&out).into_owned()))
184}
185
186fn 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
200fn 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 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
222fn 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 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 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 i += needle.len();
262 continue 'outer;
263 }
264 }
265 let c = s[i..].chars().next().unwrap();
267 out.push(c);
268 i += c.len_utf8();
269 }
270 Ok(Value::String(out))
271}
272
273fn 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
283fn 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 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 let truncated: String = out.chars().take(length).collect();
313 Ok(Value::String(truncated))
314}
315
316fn 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 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 _ => {
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 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 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 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 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 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 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 match r { Value::String(s) => assert_eq!(s, "STRASSE"), _ => panic!() }
621 }
622}