use super::context::EvalContext;
use super::eval::EvalError;
use super::value::{parse_number, Value};
use super::version::XPathVersion;
use crate::xml::{Document, NodeId, NodeKind};
const SIGNATURES: &[(&str, usize, Option<usize>)] = &[
("last", 0, Some(0)),
("position", 0, Some(0)),
("count", 1, Some(1)),
("id", 1, Some(1)),
("local-name", 0, Some(1)),
("namespace-uri", 0, Some(1)),
("name", 0, Some(1)),
("string", 0, Some(1)),
("concat", 2, None),
("starts-with", 2, Some(2)),
("contains", 2, Some(2)),
("substring-before", 2, Some(2)),
("substring-after", 2, Some(2)),
("substring", 2, Some(3)),
("string-length", 0, Some(1)),
("normalize-space", 0, Some(1)),
("translate", 3, Some(3)),
("boolean", 1, Some(1)),
("not", 1, Some(1)),
("true", 0, Some(0)),
("false", 0, Some(0)),
("lang", 1, Some(1)),
("number", 0, Some(1)),
("sum", 1, Some(1)),
("floor", 1, Some(1)),
("ceiling", 1, Some(1)),
("round", 1, Some(1)),
("current", 0, Some(0)),
("document", 1, Some(1)),
];
const SIGNATURES_V2: &[(&str, usize, Option<usize>)] = &[
("matches", 2, Some(3)),
("replace", 3, Some(4)),
("upper-case", 1, Some(1)),
("lower-case", 1, Some(1)),
("ends-with", 2, Some(2)),
("abs", 1, Some(1)),
("min", 1, Some(1)),
("max", 1, Some(1)),
("avg", 1, Some(1)),
("exists", 1, Some(1)),
("empty", 1, Some(1)),
("string-join", 2, Some(2)),
];
const V2_FUNCTIONS_NEEDING_SEQUENCES: &[&str] = &[
"tokenize",
"distinct-values",
"index-of",
"subsequence",
"insert-before",
"remove",
"reverse",
"unordered",
"for-each",
];
const V2_FUNCTIONS_NEEDING_DATES: &[&str] = &[
"current-date",
"current-dateTime",
"current-time",
"year-from-date",
"month-from-date",
"day-from-date",
"hours-from-time",
"minutes-from-time",
"seconds-from-time",
"implicit-timezone",
"dateTime",
];
const V2_FUNCTIONS_NOT_IMPLEMENTED: &[&str] = &["data", "deep-equal", "trace", "resolve-uri"];
pub fn check_function(name: &str, arity: usize, version: XPathVersion) -> Result<(), String> {
let tables: &[&[(&str, usize, Option<usize>)]] = if version.is_v2() {
&[SIGNATURES, SIGNATURES_V2]
} else {
&[SIGNATURES]
};
for (function, minimum, maximum) in tables.iter().copied().flatten() {
if *function != name {
continue;
}
if arity < *minimum {
return Err(format!(
"{name}() takes at least {minimum} argument(s), but {arity} were given"
));
}
if let Some(maximum) = maximum {
if arity > *maximum {
return Err(format!(
"{name}() takes at most {maximum} argument(s), but {arity} were given"
));
}
}
return Ok(());
}
if !version.is_v2() && SIGNATURES_V2.iter().any(|(f, _, _)| *f == name) {
return Err(format!(
"{name}() is an XPath 2.0 function, and this schema's query binding is \
XPath 1.0. Set queryBinding=\"xslt2\" to use it; see spec/xpath2.md."
));
}
if V2_FUNCTIONS_NEEDING_SEQUENCES.contains(&name) {
return Err(format!(
"{name}() returns or consumes an XPath 2.0 sequence, which this crate \
does not implement yet; see spec/xpath2.md"
));
}
if V2_FUNCTIONS_NEEDING_DATES.contains(&name) {
return Err(format!(
"{name}() needs the XPath 2.0 date and time types, which this crate \
does not implement yet; see spec/xpath2.md"
));
}
if V2_FUNCTIONS_NOT_IMPLEMENTED.contains(&name) {
return Err(format!(
"{name}() is an XPath 2.0 function this crate does not implement; \
see spec/xpath2.md"
));
}
if name == "key" {
return Err(
"key() needs XSLT keys, which this crate does not implement; \
express the lookup as a path or a predicate instead"
.to_string(),
);
}
Err(format!("unknown function {name}()"))
}
#[must_use]
pub fn function_names() -> Vec<&'static str> {
let mut names: Vec<&'static str> = SIGNATURES.iter().map(|(name, _, _)| *name).collect();
names.sort_unstable();
names
}
pub fn check_regex(pattern: &str, flags: Option<&str>) -> Result<(), String> {
build_regex(pattern, flags.unwrap_or_default()).map(|_| ())
}
#[must_use]
pub fn function_names_v2() -> Vec<&'static str> {
let mut names: Vec<&'static str> = SIGNATURES_V2.iter().map(|(name, _, _)| *name).collect();
names.sort_unstable();
names
}
pub(crate) fn call(
name: &str,
args: &[Value],
context: &EvalContext<'_>,
) -> Result<Value, EvalError> {
check_function(name, args.len(), context.version).map_err(EvalError::new)?;
let document = context.document;
match name {
"last" => Ok(Value::Number(as_f64(context.size))),
"position" => Ok(Value::Number(as_f64(context.position))),
"count" => Ok(Value::Number(as_f64(node_set(name, args, 0)?.len()))),
"id" => Ok(Value::NodeSet(id_function(args, context))),
"local-name" => Ok(Value::String(
node_argument(name, args, context)?
.and_then(|n| document.name(n).map(|q| q.local.clone()))
.unwrap_or_default(),
)),
"namespace-uri" => Ok(Value::String(
node_argument(name, args, context)?
.and_then(|n| document.name(n).and_then(|q| q.uri.clone()))
.unwrap_or_default(),
)),
"name" => Ok(Value::String(
node_argument(name, args, context)?
.and_then(|n| document.name(n).map(crate::xml::QName::display_name))
.unwrap_or_default(),
)),
"string" => Ok(Value::String(match args.first() {
Some(value) => value.to_xpath_string(document),
None => document.string_value(context.node),
})),
"concat" => Ok(Value::String(
args.iter()
.map(|a| a.to_xpath_string(document))
.collect::<String>(),
)),
"starts-with" => {
let (haystack, needle) = two_strings(args, document);
Ok(Value::Boolean(haystack.starts_with(&needle)))
}
"contains" => {
let (haystack, needle) = two_strings(args, document);
Ok(Value::Boolean(haystack.contains(&needle)))
}
"substring-before" => {
let (haystack, needle) = two_strings(args, document);
Ok(Value::String(
haystack
.find(&needle)
.map(|i| haystack[..i].to_string())
.unwrap_or_default(),
))
}
"substring-after" => {
let (haystack, needle) = two_strings(args, document);
Ok(Value::String(
haystack
.find(&needle)
.map(|i| haystack[i + needle.len()..].to_string())
.unwrap_or_default(),
))
}
"substring" => Ok(Value::String(substring(args, document))),
"string-length" => Ok(Value::Number(as_f64(
string_argument(args, context).chars().count(),
))),
"normalize-space" => Ok(Value::String(normalize_space(&string_argument(
args, context,
)))),
"translate" => Ok(Value::String(translate(
&args[0].to_xpath_string(document),
&args[1].to_xpath_string(document),
&args[2].to_xpath_string(document),
))),
"boolean" => Ok(Value::Boolean(args[0].to_boolean())),
"not" => Ok(Value::Boolean(!args[0].to_boolean())),
"true" => Ok(Value::Boolean(true)),
"false" => Ok(Value::Boolean(false)),
"lang" => Ok(Value::Boolean(lang(
&args[0].to_xpath_string(document),
document,
context.node,
))),
"number" => Ok(Value::Number(match args.first() {
Some(value) => value.to_number(document),
None => parse_number(&document.string_value(context.node)),
})),
"sum" => {
let nodes = node_set(name, args, 0)?;
Ok(Value::Number(
nodes
.iter()
.map(|&n| parse_number(&document.string_value(n)))
.sum(),
))
}
"floor" => Ok(Value::Number(args[0].to_number(document).floor())),
"ceiling" => Ok(Value::Number(args[0].to_number(document).ceil())),
"round" => Ok(Value::Number(round_half_up(args[0].to_number(document)))),
"current" => Ok(Value::NodeSet(vec![context.current])),
"document" => document_function(args, context),
_ if context.version.is_v2() => call_v2(name, args, context),
_ => Err(EvalError::new(format!("unknown function {name}()"))),
}
}
fn call_v2(name: &str, args: &[Value], context: &EvalContext<'_>) -> Result<Value, EvalError> {
let document = context.document;
match name { "matches" => {
let regex = compile_regex(&args[1].to_xpath_string(document), args.get(2), document)?;
Ok(Value::Boolean(
regex.is_match(&args[0].to_xpath_string(document)),
))
}
"replace" => {
let regex = compile_regex(&args[1].to_xpath_string(document), args.get(3), document)?;
let input = args[0].to_xpath_string(document);
let replacement = translate_replacement(&args[2].to_xpath_string(document));
Ok(Value::String(
regex.replace_all(&input, replacement.as_str()).into_owned(),
))
}
"upper-case" => Ok(Value::String(args[0].to_xpath_string(document).to_uppercase())),
"lower-case" => Ok(Value::String(args[0].to_xpath_string(document).to_lowercase())),
"ends-with" => {
let (haystack, needle) = two_strings(args, document);
Ok(Value::Boolean(haystack.ends_with(&needle)))
}
"abs" => Ok(Value::Number(args[0].to_number(document).abs())),
"min" => Ok(Value::Number(extreme(args, document, f64::min))),
"max" => Ok(Value::Number(extreme(args, document, f64::max))),
"avg" => {
let numbers = numbers_of(args, document);
if numbers.is_empty() {
return Ok(Value::Number(f64::NAN));
}
let total: f64 = numbers.iter().sum();
#[allow(clippy::cast_precision_loss)]
let count = numbers.len() as f64;
Ok(Value::Number(total / count))
}
"exists" => Ok(Value::Boolean(!node_set(name, args, 0)?.is_empty())),
"empty" => Ok(Value::Boolean(node_set(name, args, 0)?.is_empty())),
"string-join" => {
let nodes = node_set(name, args, 0)?;
let separator = args[1].to_xpath_string(document);
let parts: Vec<String> = nodes.iter().map(|&n| document.string_value(n)).collect();
Ok(Value::String(parts.join(&separator)))
}
_ => Err(EvalError::new(format!("unknown function {name}()"))),
}
}
#[allow(clippy::cast_precision_loss)]
fn as_f64(value: usize) -> f64 {
value as f64
}
fn node_set<'a>(name: &str, args: &'a [Value], index: usize) -> Result<&'a [NodeId], EvalError> {
args.get(index)
.and_then(Value::as_node_set)
.ok_or_else(|| EvalError::new(format!("{name}() requires a node-set argument")))
}
fn node_argument(
name: &str,
args: &[Value],
context: &EvalContext<'_>,
) -> Result<Option<NodeId>, EvalError> {
match args.first() {
None => Ok(Some(context.node)),
Some(Value::NodeSet(nodes)) => Ok(nodes.first().copied()),
Some(other) => Err(EvalError::new(format!(
"{name}() requires a node-set argument, but was given a {}",
other.type_name()
))),
}
}
fn string_argument(args: &[Value], context: &EvalContext<'_>) -> String {
match args.first() {
Some(value) => value.to_xpath_string(context.document),
None => context.document.string_value(context.node),
}
}
fn two_strings(args: &[Value], document: &Document) -> (String, String) {
(
args[0].to_xpath_string(document),
args[1].to_xpath_string(document),
)
}
fn numbers_of(args: &[Value], document: &Document) -> Vec<f64> {
match &args[0] {
Value::NodeSet(nodes) => nodes
.iter()
.map(|&node| parse_number(&document.string_value(node)))
.collect(),
other => vec![other.to_number(document)],
}
}
fn extreme(args: &[Value], document: &Document, pick: fn(f64, f64) -> f64) -> f64 {
let numbers = numbers_of(args, document);
if numbers.is_empty() || numbers.iter().any(|n| n.is_nan()) {
return f64::NAN;
}
numbers.into_iter().fold(f64::NAN, |accumulated, next| {
if accumulated.is_nan() {
next
} else {
pick(accumulated, next)
}
})
}
fn compile_regex(
pattern: &str,
flags: Option<&Value>,
document: &Document,
) -> Result<regex::Regex, EvalError> {
let flags = flags
.map(|value| value.to_xpath_string(document))
.unwrap_or_default();
build_regex(pattern, &flags).map_err(EvalError::new)
}
fn build_regex(pattern: &str, flags: &str) -> Result<regex::Regex, String> {
let mut builder = regex::RegexBuilder::new(pattern);
for flag in flags.chars() {
match flag {
'i' => {
builder.case_insensitive(true);
}
'm' => {
builder.multi_line(true);
}
's' => {
builder.dot_matches_new_line(true);
}
'x' => {
builder.ignore_whitespace(true);
}
other => {
return Err(format!(
"unknown regular expression flag {other:?}; XPath 2.0 defines \
i, m, s and x"
))
}
}
}
builder
.build()
.map_err(|error| format!("the regular expression {pattern:?} did not compile: {error}"))
}
fn translate_replacement(replacement: &str) -> String {
replacement.replace("\\$", "$$$$")
}
fn document_function(args: &[Value], context: &EvalContext<'_>) -> Result<Value, EvalError> {
let Some(registry) = context.documents else {
return Err(EvalError::new(
"document() needs a document registry, which the validator supplies \
and a direct call to evaluate() does not; see EvalContext::with_documents",
));
};
let uris: Vec<String> = match &args[0] {
Value::NodeSet(nodes) => nodes
.iter()
.map(|&node| context.document.string_value(node))
.collect(),
other => vec![other.to_xpath_string(context.document)],
};
let mut roots: Vec<NodeId> = uris
.iter()
.filter_map(|uri| registry.lookup(uri))
.collect();
roots.sort_unstable_by_key(|&root| context.document.order(root));
roots.dedup();
Ok(Value::NodeSet(roots))
}
fn id_function(args: &[Value], context: &EvalContext<'_>) -> Vec<NodeId> {
let document = context.document;
let wanted: Vec<String> = match args.first() {
Some(Value::NodeSet(nodes)) => nodes
.iter()
.flat_map(|&n| {
document
.string_value(n)
.split_whitespace()
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.collect(),
Some(value) => value
.to_xpath_string(document)
.split_whitespace()
.map(ToString::to_string)
.collect(),
None => Vec::new(),
};
let mut found = Vec::new();
for node in document.descendants_or_self(document.root_of(context.node)) {
if document.kind(node) != NodeKind::Element {
continue;
}
for &attribute in document.attributes(node) {
let Some(name) = document.name(attribute) else {
continue;
};
let is_id = name.local == "id"
&& matches!(name.uri.as_deref(), None | Some(crate::xml::XML_NAMESPACE));
if is_id && wanted.iter().any(|w| w == document.value(attribute)) {
found.push(node);
break;
}
}
}
found
}
fn substring(args: &[Value], document: &Document) -> String {
let text: Vec<char> = args[0].to_xpath_string(document).chars().collect();
let start = round_half_up(args[1].to_number(document));
let end = match args.get(2) {
Some(length) => {
let length = round_half_up(length.to_number(document));
if length.is_nan() {
return String::new();
}
start + length
}
None => f64::INFINITY,
};
if start.is_nan() {
return String::new();
}
let mut out = String::new();
for (index, c) in text.iter().enumerate() {
let position = as_f64(index + 1);
if position >= start && position < end {
out.push(*c);
}
}
out
}
fn round_half_up(value: f64) -> f64 {
if value.is_nan() || value.is_infinite() {
return value;
}
(value + 0.5).floor()
}
fn normalize_space(input: &str) -> String {
input.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn translate(text: &str, from: &str, to: &str) -> String {
let from: Vec<char> = from.chars().collect();
let to: Vec<char> = to.chars().collect();
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match from.iter().position(|&f| f == c) {
Some(index) => {
if let Some(&replacement) = to.get(index) {
out.push(replacement);
}
}
None => out.push(c),
}
}
out
}
fn lang(wanted: &str, document: &Document, node: NodeId) -> bool {
let mut current = Some(node);
while let Some(id) = current {
for &attribute in document.attributes(id) {
if let Some(name) = document.name(attribute) {
if name.local == "lang" && name.uri.as_deref() == Some(crate::xml::XML_NAMESPACE) {
let actual = document.value(attribute).to_lowercase();
let wanted = wanted.to_lowercase();
return actual == wanted
|| actual.strip_prefix(&wanted).is_some_and(|r| r.starts_with('-'));
}
}
}
current = document.parent(id);
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arity_is_checked() {
assert!(check_function("contains", 2, XPathVersion::V1).is_ok());
assert!(check_function("contains", 1, XPathVersion::V1).is_err());
assert!(check_function("concat", 5, XPathVersion::V1).is_ok());
assert!(check_function("concat", 1, XPathVersion::V1).is_err());
assert!(check_function("true", 1, XPathVersion::V1).is_err());
assert!(check_function("string", 0, XPathVersion::V1).is_ok());
assert!(check_function("string", 1, XPathVersion::V1).is_ok());
assert!(check_function("string", 2, XPathVersion::V1).is_err());
}
#[test]
fn xpath_two_functions_are_refused_under_a_one_point_zero_binding() {
let message = check_function("matches", 2, XPathVersion::V1).unwrap_err();
assert!(message.contains("XPath 2.0"), "{message}");
assert!(message.contains("xslt2"), "{message}");
}
#[test]
fn xpath_two_functions_are_accepted_under_a_two_point_zero_binding() {
assert!(check_function("matches", 2, XPathVersion::V2).is_ok());
assert!(check_function("matches", 3, XPathVersion::V2).is_ok());
assert!(check_function("matches", 1, XPathVersion::V2).is_err());
assert!(check_function("string-join", 2, XPathVersion::V2).is_ok());
}
#[test]
fn unimplemented_two_point_zero_functions_say_what_they_need() {
let sequences = check_function("tokenize", 2, XPathVersion::V2).unwrap_err();
assert!(sequences.contains("sequence"), "{sequences}");
let dates = check_function("current-date", 0, XPathVersion::V2).unwrap_err();
assert!(dates.contains("date and time"), "{dates}");
}
#[test]
fn the_two_point_zero_library_has_the_documented_names() {
let names = function_names_v2();
assert_eq!(names.len(), 12, "{names:?}");
for expected in ["matches", "replace", "abs", "exists", "string-join"] {
assert!(names.contains(&expected), "{expected} is missing");
}
}
#[test]
fn unknown_functions_are_rejected() {
assert!(check_function("nonsense", 0, XPathVersion::V1).is_err());
}
#[test]
fn library_has_every_expected_name() {
let names = function_names();
assert_eq!(names.len(), 29, "{names:?}");
for from_xslt in ["current", "document"] {
assert!(names.contains(&from_xslt), "{from_xslt}() is missing");
}
}
#[test]
fn document_without_a_registry_is_an_error() {
assert!(check_function("document", 1, XPathVersion::V1).is_ok());
assert!(check_function("document", 0, XPathVersion::V1).is_err());
assert!(check_function("document", 2, XPathVersion::V1).is_err());
}
#[test]
fn round_sends_halves_upwards() {
assert_eq!(round_half_up(0.5), 1.0);
assert_eq!(round_half_up(-0.5), 0.0);
assert_eq!(round_half_up(1.5), 2.0);
assert_eq!(round_half_up(-1.5), -1.0);
}
#[test]
fn normalize_space_collapses_runs() {
assert_eq!(normalize_space(" a b \n c "), "a b c");
assert_eq!(normalize_space(" "), "");
}
#[test]
fn translate_deletes_unmapped_characters() {
assert_eq!(translate("bar", "abc", "ABC"), "BAr");
assert_eq!(translate("--aaa--", "abc-", "ABC"), "AAA");
assert_eq!(translate("abc", "aa", "XY"), "Xbc");
}
#[test]
fn substring_follows_the_specification_not_integer_slicing() {
assert_eq!(round_half_up(1.5), 2.0);
assert_eq!(normalize_space(" a "), "a");
}
}