use std::collections::HashMap;
use sup_xml_core::error::{ErrorDomain, ErrorLevel, XmlError};
use sup_xml_core::xpath::eval::{Value, value_to_number, value_to_string};
use sup_xml_core::xpath::{DocIndex, DocIndexLike, NodeId};
use crate::ast::{QName, StylesheetAst};
use crate::format_number::{format_number, DecimalFormat};
type Result<T> = std::result::Result<T, XmlError>;
fn err(msg: impl Into<String>) -> XmlError {
XmlError::new(ErrorDomain::XPath, ErrorLevel::Error, msg)
}
pub struct KeyIndex {
entries: HashMap<(String, NodeId, String), Vec<NodeId>>,
declared: std::collections::HashSet<String>,
collations: HashMap<String, String>,
}
impl KeyIndex {
pub fn new() -> Self {
KeyIndex {
entries: HashMap::new(),
declared: std::collections::HashSet::new(),
collations: HashMap::new(),
}
}
fn fold_for(&self, name_key: &str, s: &str) -> String {
match self.collations.get(name_key) {
Some(uri) if uri ==
"http://www.w3.org/2005/xpath-functions/collation/html-ascii-case-insensitive"
=> {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c.is_ascii_uppercase() { out.push(c.to_ascii_lowercase()); }
else { out.push(c); }
}
out
}
_ => s.to_string(),
}
}
pub fn is_declared(&self, name_key: &str) -> bool {
self.declared.contains(name_key)
}
pub fn add_value<I: DocIndexLike>(
&mut self, name_key: &str, node_id: NodeId, value: &Value, idx: &I,
) {
let doc_root = doc_root_of(idx, node_id);
for s in stringify_use_value(value, idx) {
let folded = self.fold_for(name_key, &s);
self.entries.entry((name_key.to_string(), doc_root, folded))
.or_default()
.push(node_id);
}
}
pub fn build<F>(
style: &StylesheetAst,
idx: &DocIndex<'_>,
mut eval_one: F,
) -> Result<(Self, Vec<(usize, NodeId)>)>
where
F: FnMut(&sup_xml_core::xpath::Expr, NodeId) -> Result<Value>,
{
let mut entries: HashMap<(String, NodeId, String), Vec<NodeId>> = HashMap::new();
let declared: std::collections::HashSet<String> =
style.keys.iter().map(|k| qname_key(&k.name)).collect();
let mut collations: HashMap<String, String> = HashMap::new();
for key in &style.keys {
if let Some(uri) = &key.collation {
if !uri.is_empty() && uri !=
"http://www.w3.org/2005/xpath-functions/collation/codepoint"
{
collations.insert(qname_key(&key.name), uri.clone());
}
}
}
let folder = KeyIndex {
entries: HashMap::new(),
declared: declared.clone(),
collations: collations.clone(),
};
let mut deferred: Vec<(usize, NodeId)> = Vec::new();
for (ki, key) in style.keys.iter().enumerate() {
let nk = qname_key(&key.name);
for node_id in 0..idx.nodes.len() {
if !pattern_matches(&key.matcher, node_id, idx, &mut eval_one)? {
continue;
}
if !key.body.is_empty() {
deferred.push((ki, node_id));
continue;
}
let value = eval_one(&key.use_, node_id)?;
let doc_root = doc_root_of(idx, node_id);
let strings = stringify_use_value(&value, idx);
for s in strings {
let folded = folder.fold_for(&nk, &s);
entries.entry((nk.clone(), doc_root, folded))
.or_default()
.push(node_id);
}
}
}
Ok((KeyIndex { entries, declared, collations }, deferred))
}
pub fn lookup(&self, name_key: &str, doc_root: NodeId, value: &str) -> Vec<NodeId> {
let folded = self.fold_for(name_key, value);
self.entries.get(&(name_key.to_string(), doc_root, folded))
.cloned().unwrap_or_default()
}
}
fn doc_root_of<I: DocIndexLike>(idx: &I, node: NodeId) -> NodeId {
let mut cur = node;
while let Some(p) = idx.parent(cur) { cur = p; }
cur
}
fn stringify_use_value<I: DocIndexLike>(v: &Value, idx: &I) -> Vec<String> {
let strings: Vec<String> = match v {
Value::NodeSet(ns) => ns.iter().map(|&id| idx.string_value(id)).collect(),
Value::Sequence(items) => items.iter()
.map(|it| canonical_key_string(it, idx)).collect(),
Value::IntRange { lo, hi } => (*lo..=*hi).map(|n| n.to_string()).collect(),
other => vec![canonical_key_string(other, idx)],
};
strings.into_iter().filter(|s| s != "NaN").collect()
}
pub(crate) fn canonical_key_string<I: DocIndexLike>(v: &Value, idx: &I) -> String {
use sup_xml_core::xpath::eval::{date_value_to_utc_micros, DateKind};
match v {
Value::Typed(t) => match t.kind {
"date" | "gYear" | "gYearMonth" | "gMonth" | "gMonthDay" | "gDay" =>
date_value_to_utc_micros(&t.lexical, DateKind::Date)
.map(|us| format!("D{us}"))
.unwrap_or_else(|| t.lexical.clone()),
"dateTime" => date_value_to_utc_micros(&t.lexical, DateKind::DateTime)
.map(|us| format!("T{us}"))
.unwrap_or_else(|| t.lexical.clone()),
"time" => date_value_to_utc_micros(&t.lexical, DateKind::Time)
.map(|us| format!("H{us}"))
.unwrap_or_else(|| t.lexical.clone()),
"boolean" => t.lexical.clone(),
_ => {
if let Some(n) = t.numeric {
return canonical_numeric_key(n);
}
t.lexical.clone()
}
}
Value::Number(n) => canonical_numeric_key(n.as_f64()),
other => value_to_string(other, idx),
}
}
fn canonical_numeric_key(n: f64) -> String {
if n.is_nan() { return "NaN".into(); }
if n.is_infinite() { return if n > 0.0 { "INF".into() } else { "-INF".into() }; }
if n == 0.0 { return "0".into(); }
if n.fract() == 0.0 && n.abs() < 1e18 {
return (n as i64).to_string();
}
format!("{n}")
}
fn pattern_matches<F>(
pattern: &sup_xml_core::xpath::Expr,
node: NodeId,
idx: &DocIndex<'_>,
eval_one: &mut F,
) -> Result<bool>
where
F: FnMut(&sup_xml_core::xpath::Expr, NodeId) -> Result<Value>,
{
let mut cur = Some(node);
while let Some(ctx) = cur {
let v = eval_one(pattern, ctx)?;
if let Value::NodeSet(ns) = v {
if ns.contains(&node) {
return Ok(true);
}
}
cur = idx.parent(ctx);
}
Ok(false)
}
fn qname_key(q: &QName) -> String {
if q.uri.is_empty() { q.local.clone() }
else { format!("{{{}}}{}", q.uri, q.local) }
}
pub(crate) fn dispatch<I: DocIndexLike>(
name: &str,
args: Vec<Value>,
idx: &I,
xslt_context_node: NodeId,
xpath_context_node: NodeId,
keys: Option<&KeyIndex>,
instruction_names: &[&str],
documents: Option<&HashMap<String, NodeId>>,
dyn_doc_loader: Option<&dyn Fn(&str) -> Option<std::result::Result<NodeId, sup_xml_core::error::XmlError>>>,
decimal_formats: &HashMap<String, DecimalFormat>,
namespaces: &crate::eval::NamespaceContext,
unparsed_entities: &HashMap<String, sup_xml_tree::UnparsedEntity>,
current_group: Option<&[NodeId]>,
current_grouping_key: Option<&Value>,
regex_groups: Option<&[String]>,
unparsed_texts: Option<&HashMap<String, String>>,
user_functions: &[crate::ast::UserFunction],
xslt_version: &str,
accumulators: Option<&HashMap<String, crate::eval::AccumulatorData>>,
) -> Option<Result<Value>> {
let r = match name {
"current" => current_fn(&args, xslt_context_node),
"accumulator-before" =>
accumulator_fn(&args, idx, namespaces, accumulators, xpath_context_node, true),
"accumulator-after" =>
accumulator_fn(&args, idx, namespaces, accumulators, xpath_context_node, false),
"generate-id" => generate_id_fn(&args, xpath_context_node),
"system-property" => system_property_fn(&args, idx, xslt_version, namespaces),
"element-available" => element_available_fn(&args, idx, instruction_names, xslt_version),
"function-available" => function_available_fn(&args, idx, namespaces, user_functions),
"key" => key_fn(&args, idx, keys, namespaces, xpath_context_node, xslt_version),
"format-number" => format_number_fn(&args, idx, decimal_formats, namespaces),
"document" => document_fn(&args, idx, documents, dyn_doc_loader),
"json-to-xml" => json_to_xml_fn(&args, idx),
"json-doc" => json_doc_fn(&args, idx, unparsed_texts),
"unparsed-entity-uri" => unparsed_entity_uri_fn(
&args, idx, unparsed_entities, xpath_context_node),
"unparsed-entity-public-id" =>
unparsed_entity_public_id_fn(
&args, idx, unparsed_entities, xpath_context_node),
"current-group" => {
if !args.is_empty() {
return Some(Err(err("current-group() takes no arguments")));
}
Ok(Value::NodeSet(current_group.map(|g| g.to_vec()).unwrap_or_default()))
}
"current-grouping-key" => {
if !args.is_empty() {
return Some(Err(err("current-grouping-key() takes no arguments")));
}
Ok(current_grouping_key.cloned().unwrap_or(Value::String(String::new())))
}
"current-merge-group" => {
if args.len() > 1 {
return Some(Err(err("current-merge-group() takes at most one argument")));
}
Ok(Value::NodeSet(current_group.map(|g| g.to_vec()).unwrap_or_default()))
}
"current-merge-key" => {
if !args.is_empty() {
return Some(Err(err("current-merge-key() takes no arguments")));
}
Ok(current_grouping_key.cloned().unwrap_or(Value::String(String::new())))
}
"regex-group" => {
if args.len() != 1 {
return Some(Err(err("regex-group() requires one argument")));
}
let n = value_to_number(&args[0], idx).round() as i64;
let s = if n < 0 { String::new() } else {
regex_groups.and_then(|g| g.get(n as usize)).cloned().unwrap_or_default()
};
Ok(Value::String(s))
}
"unparsed-text" => {
if args.is_empty() || args.len() > 2 {
return Some(Err(err("unparsed-text() takes 1 or 2 arguments")));
}
let uri = value_to_string(&args[0], idx);
match unparsed_texts.and_then(|m| m.get(&uri)) {
Some(text) => Ok(Value::String(text.clone())),
None => Err(err(format!(
"unparsed-text({uri:?}): resource not pre-loaded; \
only static string-literal URIs are supported"
))),
}
}
"unparsed-text-available" => {
if args.is_empty() || args.len() > 2 {
return Some(Err(err("unparsed-text-available() takes 1 or 2 arguments")));
}
let uri = value_to_string(&args[0], idx);
Ok(Value::Boolean(unparsed_texts.is_some_and(|m| m.contains_key(&uri))))
}
"unparsed-text-lines" => {
if args.is_empty() || args.len() > 2 {
return Some(Err(err("unparsed-text-lines() takes 1 or 2 arguments")));
}
let uri = value_to_string(&args[0], idx);
let Some(text) = unparsed_texts.and_then(|m| m.get(&uri)) else {
return Some(Err(err(format!(
"unparsed-text-lines({uri:?}): resource not pre-loaded"
))));
};
let mut lines: Vec<Value> = Vec::new();
let mut last = 0usize;
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
let (skip, end) = match bytes[i] {
b'\r' if i + 1 < bytes.len() && bytes[i + 1] == b'\n' => (2, i),
b'\r' | b'\n' => (1, i),
_ => { i += 1; continue; }
};
lines.push(Value::String(text[last..end].to_string()));
i += skip;
last = i;
}
if last < text.len() {
lines.push(Value::String(text[last..].to_string()));
}
let joined = lines.into_iter()
.map(|v| if let Value::String(s) = v { s } else { String::new() })
.collect::<Vec<_>>()
.join("\n");
Ok(Value::String(joined))
}
_ => return None,
};
Some(r)
}
fn unparsed_entity_uri_fn<I: DocIndexLike>(
args: &[Value],
idx: &I,
table: &HashMap<String, sup_xml_tree::UnparsedEntity>,
context_node: NodeId,
) -> Result<Value> {
if args.len() != 1 {
return Err(err("unparsed-entity-uri() requires exactly 1 argument"));
}
if crate::eval::is_context_undefined()
&& sup_xml_core::xpath::eval::focus_is_undefined()
{
return Err(err(
"unparsed-entity-uri() called with no context (XTDE1370)"));
}
if !root_is_document(idx, context_node) {
return Err(err(
"unparsed-entity-uri(): the tree containing the context node is \
not rooted at a document node (XTDE1370)"));
}
let name = value_to_string(&args[0], idx);
Ok(sup_xml_core::xpath::eval::typed_str(
"anyURI",
table.get(&name).map(|e| e.system_id.clone()).unwrap_or_default()))
}
fn root_is_document<I: DocIndexLike>(idx: &I, node: NodeId) -> bool {
let mut cur = node;
while let Some(p) = idx.parent(cur) { cur = p; }
matches!(idx.kind(cur), sup_xml_core::xpath::XPathNodeKind::Document)
&& !idx.is_synthetic_wrap(cur)
}
fn unparsed_entity_public_id_fn<I: DocIndexLike>(
args: &[Value],
idx: &I,
table: &HashMap<String, sup_xml_tree::UnparsedEntity>,
context_node: NodeId,
) -> Result<Value> {
if args.len() != 1 {
return Err(err("unparsed-entity-public-id() requires exactly 1 argument"));
}
if crate::eval::is_context_undefined()
&& sup_xml_core::xpath::eval::focus_is_undefined()
{
return Err(err(
"unparsed-entity-public-id() called with no context (XTDE1380)"));
}
if !root_is_document(idx, context_node) {
return Err(err(
"unparsed-entity-public-id(): the tree containing the context \
node is not rooted at a document node (XTDE1380)"));
}
let name = value_to_string(&args[0], idx);
Ok(Value::String(
table.get(&name).and_then(|e| e.public_id.clone()).unwrap_or_default()))
}
pub(crate) fn strip_uri_fragment(uri: &str) -> &str {
uri.split_once('#').map(|(base, _)| base).unwrap_or(uri)
}
fn document_fn<I: DocIndexLike>(
args: &[Value],
idx: &I,
documents: Option<&HashMap<String, NodeId>>,
dyn_loader: Option<&dyn Fn(&str) -> Option<std::result::Result<NodeId, sup_xml_core::error::XmlError>>>,
) -> Result<Value> {
if args.is_empty() || args.len() > 2 {
return Err(err("document() requires 1 or 2 arguments"));
}
let documents = documents.ok_or_else(|| err(
"document(): the XSLT engine doesn't currently expose a \
document-loading hook to this dispatcher"
))?;
let (uris, from_node_set): (Vec<String>, bool) = match &args[0] {
Value::String(s) => (vec![s.clone()], false),
Value::Number(n) => (vec![format!("{}", n.as_f64())], false),
Value::Boolean(b) =>
(vec![if *b { "true".to_string() } else { "false".to_string() }], false),
Value::NodeSet(ns) =>
(ns.iter().map(|&id| idx.string_value(id)).collect(), true),
Value::ForeignNodeSet(_) => return Err(err(
"document(): foreign-node-set first argument not yet supported"
)),
Value::Typed(t) => (vec![t.lexical.clone()], false),
Value::IntRange { lo, hi } =>
((*lo..=*hi).map(|i| i.to_string()).collect(), false),
Value::Sequence(items) => {
let strs: Vec<String> = items.iter().map(|v| match v {
Value::String(s) => s.clone(),
Value::Number(n) => format!("{}", n.as_f64()),
Value::Boolean(b) => if *b { "true".into() } else { "false".into() },
Value::Typed(t) => t.lexical.clone(),
Value::NodeSet(ns) => ns.first()
.map(|&id| idx.string_value(id)).unwrap_or_default(),
_ => String::new(),
}).collect();
(strs, true)
}
Value::Map(_) | Value::Array(_) | Value::Function(_) => (Vec::new(), false),
};
let mut out: Vec<NodeId> = Vec::new();
let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
for uri in uris {
if uri.is_empty() && from_node_set { continue; }
let key = strip_uri_fragment(&uri);
match documents.get(key).copied() {
Some(id) => {
if seen.insert(id) { out.push(id); }
}
None => {
if let Some(load) = dyn_loader {
match load(key) {
Some(Ok(id)) => {
if seen.insert(id) { out.push(id); }
continue;
}
Some(Err(e)) => return Err(e),
None => {}
}
}
return Err(err(format!(
"document('{uri}'): URI not pre-loaded and no \
runtime loader is available to fetch it. \
Stylesheet::apply (no loader) never pre-loads \
anything; the LoaderImpl passed to \
apply_with_loader must implement enumeration \
or the URI must appear as a string literal in \
the stylesheet."
)));
}
}
}
Ok(Value::NodeSet(out))
}
fn json_opt_str<I: DocIndexLike>(opts: Option<&Value>, key: &str, idx: &I) -> Option<String> {
match opts {
Some(Value::Map(m)) => m.iter()
.find(|(k, _)| value_to_string(k, idx) == key)
.map(|(_, v)| value_to_string(v, idx)),
_ => None,
}
}
fn json_doc_fn<I: DocIndexLike>(
args: &[Value], idx: &I, unparsed_texts: Option<&HashMap<String, String>>,
) -> Result<Value> {
if args.is_empty() || args.len() > 2 {
return Err(err("json-doc() takes 1 or 2 arguments"));
}
if matches!(&args[0], Value::NodeSet(n) if n.is_empty())
|| matches!(&args[0], Value::Sequence(s) if s.is_empty())
{
return Ok(Value::Sequence(Vec::new()));
}
let href = value_to_string(&args[0], idx);
let Some(text) = unparsed_texts.and_then(|m| m.get(&href)) else {
return Err(err(format!(
"json-doc({href:?}): resource not pre-loaded; only static \
string-literal URIs are supported (FOUT1170)")));
};
let opts = args.get(1);
use sup_xml_core::xpath::eval::json_option_bool;
let dup = json_opt_str(opts, "duplicates", idx).unwrap_or_else(|| "use-first".into());
let escape = json_option_bool(opts, "escape", idx)?.unwrap_or(false);
let liberal = json_option_bool(opts, "liberal", idx)?.unwrap_or(false);
sup_xml_core::xpath::eval::parse_json_value(text, &dup, escape, liberal)
}
fn json_to_xml_fn<I: DocIndexLike>(args: &[Value], idx: &I) -> Result<Value> {
use crate::result_tree::ResultBuilder;
use crate::ast::QName;
use sup_xml_core::xpath::eval::{parse_json_events, JsonEvent};
const FN_NS: &str = "http://www.w3.org/2005/xpath-functions";
if args.is_empty() || args.len() > 2 {
return Err(err("json-to-xml() takes 1 or 2 arguments"));
}
if matches!(&args[0], Value::NodeSet(n) if n.is_empty())
|| matches!(&args[0], Value::Sequence(s) if s.is_empty())
{
return Ok(Value::Sequence(Vec::new()));
}
let text = value_to_string(&args[0], idx);
let opts = args.get(1);
use sup_xml_core::xpath::eval::json_option_bool;
let escape = match json_option_bool(opts, "escape", idx) { Ok(v) => v.unwrap_or(false), Err(e) => return Err(e) };
let liberal = match json_option_bool(opts, "liberal", idx) { Ok(v) => v.unwrap_or(false), Err(e) => return Err(e) };
if let Err(e) = json_option_bool(opts, "validate", idx) { return Err(e); }
if let Some(Value::Map(m)) = opts {
if let Some((_, v)) = m.iter().find(|(k, _)| value_to_string(k, idx) == "fallback") {
if !matches!(v, Value::Function(_)) {
return Err(err("JSON option 'fallback' must be a function (FOJS0005)"));
}
}
}
let fo = |local: &str| QName { prefix: None, local: local.into(), uri: FN_NS.into() };
let plain = |local: &str| QName { prefix: None, local: local.into(), uri: String::new() };
let mut b = ResultBuilder::new();
let mut key: Option<String> = None;
let open = |b: &mut ResultBuilder, key: &mut Option<String>, local: &str| {
b.open_element(fo(local));
b.push_namespace_decl(None, FN_NS.to_string());
if let Some(k) = key.take() {
if escape { b.push_attribute(plain("escaped-key"), "true".into()); }
b.push_attribute(plain("key"), k);
}
};
let parse = parse_json_events(&text, escape, liberal, &mut |ev| match ev {
JsonEvent::Key(k) => key = Some(k),
JsonEvent::StartObject => open(&mut b, &mut key, "map"),
JsonEvent::EndObject => b.close_element(),
JsonEvent::StartArray => open(&mut b, &mut key, "array"),
JsonEvent::EndArray => b.close_element(),
JsonEvent::Str(s) => {
open(&mut b, &mut key, "string");
if escape { b.push_attribute(plain("escaped"), "true".into()); }
b.push_text(s, false);
b.close_element();
}
JsonEvent::Number(n) => {
open(&mut b, &mut key, "number");
b.push_text(n, false);
b.close_element();
}
JsonEvent::Bool(bl) => {
open(&mut b, &mut key, "boolean");
b.push_text(if bl { "true".into() } else { "false".into() }, false);
b.close_element();
}
JsonEvent::Null => { open(&mut b, &mut key, "null"); b.close_element(); }
});
parse?;
let ids = crate::eval::rtf_children_into_index_generic(idx, &b.top);
Ok(Value::NodeSet(ids))
}
fn accumulator_fn<I: DocIndexLike>(
args: &[Value],
idx: &I,
namespaces: &crate::eval::NamespaceContext,
accumulators: Option<&HashMap<String, crate::eval::AccumulatorData>>,
context_node: NodeId,
before: bool,
) -> Result<Value> {
let fname = if before { "accumulator-before" } else { "accumulator-after" };
if args.len() != 1 {
return Err(err(format!("{fname}() takes exactly one argument")));
}
let raw = value_to_string(&args[0], idx);
let key = match raw.split_once(':') {
Some((p, l)) => match namespaces.resolve(p) {
Some(uri) => format!("{{{uri}}}{l}"),
None => raw.clone(),
},
None => raw.clone(),
};
let Some(data) = accumulators.and_then(|m| m.get(&key)) else {
return Err(err(format!(
"accumulator '{raw}' is not declared (or not applicable here) (XTDE3340)")));
};
let map = if before { &data.before } else { &data.after };
Ok(map.get(&context_node).cloned().unwrap_or_else(|| data.initial.clone()))
}
fn current_fn(args: &[Value], xslt_context: NodeId) -> Result<Value> {
if !args.is_empty() {
return Err(err("current() takes no arguments"));
}
Ok(Value::NodeSet(vec![xslt_context]))
}
fn generate_id_fn(args: &[Value], xslt_context: NodeId) -> Result<Value> {
let id = if args.is_empty() {
xslt_context
} else {
match &args[0] {
Value::NodeSet(ns) if !ns.is_empty() => ns[0],
Value::NodeSet(_) => return Ok(Value::String(String::new())),
_ => return Err(err("generate-id() requires a nodeset or no argument")),
}
};
Ok(Value::String(format!("id{:x}", id)))
}
fn system_property_fn<I: DocIndexLike>(
args: &[Value], idx: &I, xslt_version: &str,
namespaces: &crate::eval::NamespaceContext,
) -> Result<Value> {
if args.len() != 1 {
return Err(err("system-property() requires 1 argument"));
}
let raw = value_to_string(&args[0], idx);
if !is_lexical_qname(&raw) {
return Err(err(format!(
"system-property(): '{raw}' is not a valid QName (XTDE1390)"
)));
}
let (prefix, local) = match raw.split_once(':') {
Some((p, l)) => (Some(p), l),
None => (None, raw.as_str()),
};
let xslt_uri = "http://www.w3.org/1999/XSL/Transform";
let uri: Option<String> = match prefix {
Some(p) => Some(namespaces.resolve(p).or_else(|| match p {
"xsl" | "xslt" => Some(xslt_uri.to_string()),
_ => None,
}).ok_or_else(|| err(format!(
"system-property('{raw}'): prefix '{p}' is not declared \
in the in-scope namespaces (XTDE1390)"
)))?),
None => None,
};
let in_xslt = uri.as_deref() == Some(xslt_uri);
if !in_xslt {
return Ok(Value::String(String::new()));
}
Ok(Value::String(match local {
"version" => {
let major = xslt_version.split('.').next()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
if major >= 2 { "2.0".into() } else { "1.0".into() }
}
"vendor" => "sup-xml".to_string(),
"vendor-url" => "https://github.com/super_source/sup_xml".to_string(),
"product-name" => "sup-xml".to_string(),
"product-version" => env!("CARGO_PKG_VERSION").to_string(),
"is-schema-aware" => "no".to_string(),
"supports-serialization" => "yes".to_string(),
"supports-backwards-compatibility" => "yes".to_string(),
"supports-namespace-axis" => "yes".to_string(),
"supports-streaming" => "no".to_string(),
"supports-dynamic-evaluation" => "no".to_string(),
"supports-higher-order-functions" => "no".to_string(),
"xpath-version" => {
let major = xslt_version.split('.').next()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
if major >= 2 { "2.0".into() } else { "1.0".into() }
}
"xsd-version" => "1.0".to_string(),
_ => String::new(),
}))
}
const ELEMENT_NAMES: &[&str] = &[
"apply-imports", "apply-templates", "attribute", "call-template",
"choose", "comment", "copy", "copy-of", "element", "fallback",
"for-each", "if", "message", "number", "processing-instruction",
"text", "value-of", "variable",
"analyze-string", "document", "for-each-group", "namespace",
"next-match", "perform-sort", "result-document", "sequence",
"try", "catch", "fork", "iterate", "next-iteration", "break",
"on-completion", "merge", "assert", "evaluate", "where-populated",
"on-empty", "on-non-empty",
];
pub(crate) fn is_builtin_xslt_instruction(local: &str) -> bool {
ELEMENT_NAMES.iter().any(|e| *e == local)
}
pub(crate) fn is_builtin_function(local: &str) -> bool {
FN_NAMES.iter().any(|e| *e == local)
}
fn element_available_fn<I: DocIndexLike>(
args: &[Value], idx: &I, extras: &[&str], xslt_version: &str,
) -> Result<Value> {
if args.len() != 1 {
return Err(err("element-available() requires 1 argument"));
}
let name = value_to_string(&args[0], idx);
if !is_lexical_qname(&name) {
return Err(err(format!(
"element-available(): '{name}' is not a valid QName (XTDE1440)"
)));
}
let (prefix, local) = match name.split_once(':') {
Some((p, l)) => (Some(p), l),
None => (None, name.as_str()),
};
let is_3_0_only = matches!(local,
"try" | "catch" | "iterate" | "fork" | "merge" | "merge-source"
| "merge-key" | "merge-action" | "evaluate" | "where-populated"
| "assert" | "break" | "next-iteration" | "on-completion"
| "on-empty" | "on-non-empty" | "global-context-item"
| "context-item" | "expose" | "use-package" | "override"
| "package" | "accept" | "mode" | "source-document"
| "result-document"
);
let xslt_uri = "http://www.w3.org/1999/XSL/Transform";
let suppress_3_0 = prefix == Some("xsl")
&& !xslt_version_3_or_more(xslt_version)
&& is_3_0_only
&& local != "result-document" ;
let _ = xslt_uri;
let ok = !suppress_3_0
&& (ELEMENT_NAMES.iter().any(|e| *e == local)
|| extras.iter().any(|e| *e == local));
Ok(Value::Boolean(ok))
}
pub(crate) fn xslt_version_3_or_more(v: &str) -> bool {
v.trim().parse::<f64>().map(|n| n >= 3.0).unwrap_or(false)
}
const FN_NAMES: &[&str] = &[
"last", "position", "count", "id", "local-name", "namespace-uri",
"name", "string", "concat", "starts-with", "contains",
"substring-before", "substring-after", "substring",
"string-length", "normalize-space", "translate",
"boolean", "not", "true", "false", "lang",
"number", "sum", "floor", "ceiling", "round",
"current", "document", "key", "format-number", "generate-id",
"system-property", "element-available", "function-available",
"unparsed-entity-uri",
"abs", "avg", "base-uri", "codepoint-equal", "codepoint-to-string",
"collection", "compare", "current-date", "current-dateTime",
"current-time", "data", "dateTime", "day-from-date",
"day-from-dateTime", "days-from-duration", "deep-equal",
"default-collation", "distinct-values", "doc", "doc-available",
"document-uri", "empty", "encode-for-uri", "ends-with",
"error", "escape-html-uri", "exactly-one", "exists",
"format-date", "format-dateTime", "format-time", "head",
"hours-from-dateTime", "hours-from-duration", "hours-from-time",
"id", "idref", "implicit-timezone", "in-scope-prefixes",
"index-of", "insert-before", "iri-to-uri", "lower-case",
"matches", "max", "min", "minutes-from-dateTime",
"minutes-from-duration", "minutes-from-time", "month-from-date",
"month-from-dateTime", "months-from-duration",
"namespace-uri-for-prefix", "namespace-uri-from-QName",
"nilled", "node-name", "normalize-unicode", "one-or-more",
"prefix-from-QName", "QName", "regex-group",
"remove", "replace", "resolve-QName", "resolve-uri",
"reverse", "root", "seconds-from-dateTime",
"seconds-from-duration", "seconds-from-time", "static-base-uri",
"string-join", "string-to-codepoints", "subsequence",
"tail", "timezone-from-date", "timezone-from-dateTime",
"timezone-from-time", "tokenize", "trace", "type-available",
"unordered", "unparsed-text", "unparsed-text-available",
"upper-case", "year-from-date", "year-from-dateTime",
"years-from-duration", "zero-or-one",
"current-group", "current-grouping-key",
];
fn builtin_arity_ok(name: &str, arity: usize) -> bool {
match name {
"last" | "position" | "true" | "false" | "current"
=> arity == 0,
"boolean" | "not" | "floor" | "ceiling" | "round"
| "generate-id" | "element-available" | "unparsed-entity-uri"
=> arity == 1,
"string" | "name" | "local-name" | "namespace-uri"
| "number" | "string-length" | "normalize-space"
=> arity <= 1,
"starts-with" | "contains" | "substring-before" | "substring-after"
| "key" | "lang"
=> arity == 2,
"substring" | "format-number" | "id"
=> (2..=3).contains(&arity),
"concat" => arity >= 2,
"translate" => arity == 3,
"document" | "function-available" | "system-property"
| "count" | "sum"
=> (1..=2).contains(&arity),
_ => true,
}
}
pub(crate) fn is_lexical_qname(s: &str) -> bool {
fn ncname(p: &str) -> bool {
if p.is_empty() { return false; }
let mut cs = p.chars();
let first = cs.next().unwrap();
if !(first.is_alphabetic() || first == '_') { return false; }
cs.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
}
match s.split_once(':') {
Some((p, l)) => ncname(p) && ncname(l),
None => ncname(s),
}
}
fn function_available_fn<I: DocIndexLike>(
args: &[Value], idx: &I,
namespaces: &crate::eval::NamespaceContext,
user_functions: &[crate::ast::UserFunction],
) -> Result<Value> {
if args.is_empty() || args.len() > 2 {
return Err(err("function-available() requires 1 or 2 arguments"));
}
let name = value_to_string(&args[0], idx);
let arity = args.get(1).map(|v| sup_xml_core::xpath::eval::value_to_number(v, idx) as usize);
if !is_lexical_qname(&name) {
return Err(err(format!(
"function-available(): '{name}' is not a valid QName (XTDE1400)"
)));
}
let (uri, local) = match name.split_once(':') {
Some((p, local)) => match namespaces.resolve(p) {
Some(u) => (u, local.to_string()),
None => return Err(err(format!(
"function-available(): prefix '{p}' is not bound in scope (XTDE1400)"
))),
},
None => (String::new(), name.clone()),
};
let is_builtin = uri.is_empty()
|| uri == "http://www.w3.org/2005/xpath-functions";
if is_builtin && FN_NAMES.iter().any(|e| *e == local) {
return Ok(Value::Boolean(
arity.map_or(true, |n| builtin_arity_ok(&local, n))));
}
let xsd_ns = "http://www.w3.org/2001/XMLSchema";
if uri == xsd_ns
&& sup_xml_core::xpath::eval::atomic_kind_static(&local).is_some()
{
let n = arity.unwrap_or(1);
return Ok(Value::Boolean(n == 1));
}
let found = user_functions.iter().any(|uf| {
uf.name.local == local
&& uf.name.uri == uri
&& arity.map_or(true, |n| uf.params.len() == n)
});
Ok(Value::Boolean(found))
}
fn format_number_fn<I: DocIndexLike>(
args: &[Value], idx: &I,
decimal_formats: &HashMap<String, DecimalFormat>,
namespaces: &crate::eval::NamespaceContext,
) -> Result<Value> {
if args.len() < 2 || args.len() > 3 {
return Err(err("format-number() requires 2 or 3 arguments"));
}
let n = value_to_number(&args[0], idx);
let picture = value_to_string(&args[1], idx);
let name = args.get(2).map(|v| value_to_string(v, idx));
let df: std::borrow::Cow<DecimalFormat> = match &name {
Some(raw) => {
let key = match raw.split_once(':') {
Some((p, local)) => match namespaces.resolve(p) {
Some(uri) => format!("{{{uri}}}{local}"),
None => raw.clone(),
},
None => raw.clone(),
};
decimal_formats.get(key.as_str())
.map(std::borrow::Cow::Borrowed)
.ok_or_else(|| err(format!(
"format-number(): no <xsl:decimal-format name='{raw}'> declared"
)))?
}
None => decimal_formats.get("")
.map(std::borrow::Cow::Borrowed)
.unwrap_or_else(|| std::borrow::Cow::Owned(DecimalFormat::default())),
};
let formatted = format_number(n, &picture, &df).map_err(err)?;
Ok(Value::String(formatted))
}
fn xslt_version_2_or_more(version: &str) -> bool {
version.trim().split('.').next()
.and_then(|s| s.parse::<u32>().ok())
.is_some_and(|major| major >= 2)
}
fn key_fn<I: DocIndexLike>(
args: &[Value], idx: &I, keys: Option<&KeyIndex>,
namespaces: &crate::eval::NamespaceContext,
context_node: NodeId,
xslt_version: &str,
) -> Result<Value> {
if args.len() < 2 || args.len() > 3 {
return Err(err("key() requires 2 or 3 arguments"));
}
if args.len() == 2
&& crate::eval::is_context_undefined()
&& sup_xml_core::xpath::eval::focus_is_undefined()
{
return Err(err(
"key() called with no context (XTDE1270)"));
}
if args.len() == 2 && !root_is_document(idx, context_node) {
return Err(err(
"key(): the tree containing the context node is not rooted at \
a document node (XTDE1270)"));
}
let raw_name = value_to_string(&args[0], idx);
let expanded = expand_qname(&raw_name, namespaces);
let declared = keys.map_or(false, |k| k.is_declared(&expanded));
if !declared {
if xslt_version_2_or_more(xslt_version) {
return Err(err(&format!(
"key(): no xsl:key declaration named '{raw_name}' (XTDE1260)"
)));
}
return Ok(Value::NodeSet(Vec::new()));
}
let keys = keys.unwrap();
let lookup_values: Vec<String> = match &args[1] {
Value::NodeSet(ns) => ns.iter().map(|&id| idx.string_value(id)).collect(),
Value::Sequence(items) => items.iter()
.map(|v| canonical_key_string(v, idx)).collect(),
Value::IntRange { lo, hi } => (*lo..=*hi).map(|n| n.to_string()).collect(),
other => vec![canonical_key_string(other, idx)],
};
let lookup_values: Vec<String> = lookup_values.into_iter()
.filter(|s| s != "NaN").collect();
let doc_root = if let Some(scope) = args.get(2) {
let scope_nodes: Vec<NodeId> = match scope {
Value::NodeSet(ns) => ns.clone(),
Value::Sequence(items) => items.iter().flat_map(|it| {
if let Value::NodeSet(ns) = it { ns.clone() } else { Vec::new() }
}).collect(),
_ => Vec::new(),
};
if scope_nodes.is_empty() {
return Ok(Value::NodeSet(Vec::new()));
}
doc_root_of(idx, scope_nodes[0])
} else {
doc_root_of(idx, context_node)
};
use sup_xml_core::xpath::XPathNodeKind;
if !matches!(idx.kind(doc_root), XPathNodeKind::Document)
|| idx.is_synthetic_wrap(doc_root)
{
return Err(err(
"key(): the root of the tree containing the context node is \
not a document node (XTDE1270)"
));
}
let mut out: Vec<NodeId> = Vec::new();
for v in lookup_values {
out.extend(keys.lookup(&expanded, doc_root, &v));
}
out.sort_unstable();
out.dedup();
Ok(Value::NodeSet(out))
}
fn expand_qname(raw: &str, namespaces: &crate::eval::NamespaceContext) -> String {
match raw.split_once(':') {
Some((prefix, local)) => match namespaces.resolve(prefix) {
Some(uri) => format!("{{{uri}}}{local}"),
None => raw.to_string(),
},
None => raw.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use sup_xml_core::xpath::eval::Numeric;
use sup_xml_core::{parse_str, ParseOptions, XPathContext};
fn dispatch<I: DocIndexLike>(
name: &str, args: Vec<Value>, idx: &I, ctx: NodeId,
keys: Option<&KeyIndex>, instr: &[&str],
docs: Option<&HashMap<String, NodeId>>,
df: &HashMap<String, DecimalFormat>,
ns: &crate::eval::NamespaceContext,
ue: &HashMap<String, sup_xml_tree::UnparsedEntity>,
) -> Option<Result<Value>> {
super::dispatch(name, args, idx, ctx, ctx, keys, instr, docs, None, df, ns, ue,
None, None, None, None, &[], "1.0", None)
}
#[test]
fn current_returns_xslt_context_node() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = dispatch("current", vec![], &ctx.index, 5, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new());
assert!(r.is_some());
let v = r.unwrap().unwrap();
assert!(matches!(v, Value::NodeSet(ns) if ns == vec![5]));
}
#[test]
fn generate_id_produces_stable_id_per_node() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v1 = dispatch("generate-id", vec![], &ctx.index, 7, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap().unwrap();
let v2 = dispatch("generate-id", vec![], &ctx.index, 7, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap().unwrap();
assert_eq!(format!("{v1:?}"), format!("{v2:?}"));
let v3 = dispatch("generate-id", vec![], &ctx.index, 8, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap().unwrap();
assert!(format!("{v1:?}") != format!("{v3:?}"));
}
#[test]
fn system_property_xsl_version() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("system-property",
vec![Value::String("xsl:version".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::String(s) => assert_eq!(s, "1.0"), _ => panic!() }
}
#[test]
fn element_available_for_known_instruction() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("element-available",
vec![Value::String("xsl:for-each".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::Boolean(b) => assert!(b), _ => panic!() }
let v = dispatch("element-available",
vec![Value::String("xsl:made-up".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::Boolean(b) => assert!(!b), _ => panic!() }
}
#[test]
fn function_available_recognises_xpath_and_xslt() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("function-available",
vec![Value::String("count".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::Boolean(b) => assert!(b), _ => panic!() }
let v = dispatch("function-available",
vec![Value::String("generate-id".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::Boolean(b) => assert!(b), _ => panic!() }
}
#[test]
fn unknown_function_returns_none() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
assert!(dispatch("nonsense", vec![], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).is_none());
}
#[test]
fn current_rejects_args() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = dispatch("current",
vec![Value::Number(Numeric::Double(0.0))], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
assert!(r.is_err());
}
#[test]
fn generate_id_with_nodeset_argument_returns_first_node_id() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("generate-id",
vec![Value::NodeSet(vec![5, 9])], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v {
Value::String(s) => assert_eq!(s, "id5"),
_ => panic!(),
}
}
#[test]
fn generate_id_with_empty_nodeset_returns_empty_string() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("generate-id",
vec![Value::NodeSet(vec![])], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v {
Value::String(s) => assert!(s.is_empty()),
_ => panic!(),
}
}
#[test]
fn generate_id_rejects_non_nodeset_argument() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = dispatch("generate-id",
vec![Value::Number(Numeric::Double(3.0))], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
assert!(r.is_err());
}
#[test]
fn system_property_vendor_and_vendor_url() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("system-property",
vec![Value::String("xsl:vendor".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::String(s) => assert!(s.contains("sup-xml")), _ => panic!() }
let v = dispatch("system-property",
vec![Value::String("xsl:vendor-url".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::String(s) => assert!(s.starts_with("http")), _ => panic!() }
}
#[test]
fn system_property_unknown_returns_empty_string() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("system-property",
vec![Value::String("xsl:made-up".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::String(s) => assert!(s.is_empty()), _ => panic!() }
}
#[test]
fn system_property_wrong_argc_errors() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = dispatch("system-property", vec![], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
assert!(r.is_err());
}
#[test]
fn element_available_unprefixed_name() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("element-available",
vec![Value::String("for-each".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::Boolean(b) => assert!(b), _ => panic!() }
}
#[test]
fn element_available_extension_via_extras() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("element-available",
vec![Value::String("ext:my-instr".into())], &ctx.index, 0, None,
&["my-instr"], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::Boolean(b) => assert!(b), _ => panic!() }
}
#[test]
fn element_available_wrong_argc_errors() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = dispatch("element-available", vec![], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
assert!(r.is_err());
}
#[test]
fn function_available_unprefixed_unknown_returns_false() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("function-available",
vec![Value::String("totally-fake".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v { Value::Boolean(b) => assert!(!b), _ => panic!() }
}
#[test]
fn function_available_wrong_argc_errors() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = dispatch("function-available", vec![], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
assert!(r.is_err());
}
#[test]
fn document_without_loader_errors() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = dispatch("document",
vec![Value::String("foo.xml".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
match r {
Err(e) => assert!(e.message.contains("document-loading hook"),
"got: {}", e.message),
_ => panic!("expected an error when no Loader is configured"),
}
}
#[test]
fn document_uses_preloaded_uri_map() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let mut docs = HashMap::new();
docs.insert("foo.xml".to_string(), 42usize);
let r = dispatch("document",
vec![Value::String("foo.xml".into())], &ctx.index, 0, None, &[],
Some(&docs), &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap().unwrap();
match r {
Value::NodeSet(ns) => assert_eq!(ns, vec![42]),
_ => panic!("expected nodeset"),
}
}
#[test]
fn document_rejects_unloaded_uri() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let docs: HashMap<String, usize> = HashMap::new();
let r = dispatch("document",
vec![Value::String("never-loaded.xml".into())], &ctx.index, 0, None, &[],
Some(&docs), &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
match r {
Err(e) => assert!(e.message.contains("not pre-loaded")),
_ => panic!("expected error for unloaded URI"),
}
}
#[test]
fn document_empty_uri_resolved_via_preloaded_map() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let docs: HashMap<String, usize> = HashMap::new();
let r = dispatch("document",
vec![Value::String("".into())], &ctx.index, 0, None, &[],
Some(&docs), &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
match r {
Err(e) => assert!(e.message.contains("not pre-loaded")),
_ => panic!("expected error when map is empty"),
}
}
#[test]
fn unparsed_entity_uri_returns_empty_string() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("unparsed-entity-uri",
vec![Value::String("foo".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new())
.unwrap().unwrap();
match v {
Value::Typed(t) => { assert_eq!(t.kind, "anyURI"); assert!(t.lexical.is_empty()); }
other => panic!("expected typed anyURI, got {other:?}"),
}
}
#[test]
fn format_number_basic() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("format-number",
vec![Value::Number(Numeric::Double(1234.5)), Value::String("#,##0.00".into())],
&ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap().unwrap();
match v { Value::String(s) => assert_eq!(s, "1,234.50"), _ => panic!() }
}
#[test]
fn format_number_with_named_format() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = dispatch("format-number",
vec![Value::Number(Numeric::Double(42.0)), Value::String("0".into()),
Value::String("custom-fmt".into())],
&ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
assert!(r.is_err(), "unknown decimal-format name must error");
let mut formats = HashMap::new();
formats.insert("custom-fmt".to_string(), DecimalFormat::default());
let v = dispatch("format-number",
vec![Value::Number(Numeric::Double(42.0)), Value::String("0".into()),
Value::String("custom-fmt".into())],
&ctx.index, 0, None, &[], None, &formats, &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap().unwrap();
match v { Value::String(s) => assert_eq!(s, "42"), _ => panic!() }
}
#[test]
fn format_number_wrong_argc_errors() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = dispatch("format-number",
vec![Value::Number(Numeric::Double(1.0))], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
assert!(r.is_err());
let r = dispatch("format-number",
vec![Value::Number(Numeric::Double(1.0)), Value::String("0".into()),
Value::String("a".into()), Value::String("b".into())],
&ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
assert!(r.is_err());
}
#[test]
fn key_without_any_index_returns_empty_nodeset() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let v = dispatch("key",
vec![Value::String("idx".into()), Value::String("k".into())],
&ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap().unwrap();
match v { Value::NodeSet(ns) => assert!(ns.is_empty()), _ => panic!() }
}
#[test]
fn key_with_undeclared_name_errors_in_2_0() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = super::dispatch("key",
vec![Value::String("idx".into()), Value::String("k".into())],
&ctx.index, 0, 0, None, &[], None, None, &HashMap::new(),
&crate::eval::NamespaceContext::default(), &HashMap::new(),
None, None, None, None, &[], "2.0", None).unwrap();
let msg = r.unwrap_err().to_string();
assert!(msg.contains("XTDE1260"), "got: {msg}");
}
#[test]
fn key_wrong_argc_errors() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let r = dispatch("key",
vec![Value::String("idx".into())], &ctx.index, 0, None, &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap();
assert!(r.is_err());
}
#[test]
fn key_lookup_with_populated_index() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let mut keys = KeyIndex::new();
let doc_root = 0usize;
keys.declared.insert("idx".into());
keys.entries.insert(("idx".into(), doc_root, "k".into()), vec![5, 7]);
let v = dispatch("key",
vec![Value::String("idx".into()), Value::String("k".into())],
&ctx.index, 0, Some(&keys), &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap().unwrap();
match v {
Value::NodeSet(ns) => assert_eq!(ns, vec![5, 7]),
_ => panic!(),
}
}
#[test]
fn key_lookup_with_nodeset_second_arg_unions() {
let doc = parse_str("<r><a>k1</a><b>k2</b></r>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let mut keys = KeyIndex::new();
let doc_root = 0usize;
keys.declared.insert("idx".into());
keys.entries.insert(("idx".into(), doc_root, "k1".into()), vec![3]);
keys.entries.insert(("idx".into(), doc_root, "k2".into()), vec![5]);
let lookup_nodes = match ctx.eval("/r/*").unwrap() {
Value::NodeSet(ns) => ns,
_ => panic!(),
};
let v = dispatch("key",
vec![Value::String("idx".into()), Value::NodeSet(lookup_nodes)],
&ctx.index, 0, Some(&keys), &[], None, &HashMap::new(), &crate::eval::NamespaceContext::default(), &HashMap::new()).unwrap().unwrap();
match v {
Value::NodeSet(ns) => assert_eq!(ns, vec![3, 5]),
_ => panic!(),
}
}
#[test]
fn qname_key_with_and_without_uri() {
let q1 = QName { prefix: None, local: "foo".into(), uri: String::new() };
let q2 = QName { prefix: Some("ns".into()), local: "foo".into(), uri: "urn:n".into() };
assert_eq!(qname_key(&q1), "foo");
assert_eq!(qname_key(&q2), "{urn:n}foo");
}
#[test]
fn key_index_new_is_empty() {
let k = KeyIndex::new();
assert!(k.lookup("nope", 0usize, "nope").is_empty());
}
#[test]
fn stringify_use_value_for_non_nodeset() {
let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
let ctx = XPathContext::new(&doc);
let s = stringify_use_value(&Value::Number(Numeric::Double(42.0)), &ctx.index);
assert_eq!(s, vec!["42".to_string()]);
}
#[test]
fn arity_zero_arg_functions() {
for name in ["last", "position", "true", "false", "current"] {
assert!( super::builtin_arity_ok(name, 0), "{name}/0 should be ok");
assert!(!super::builtin_arity_ok(name, 1), "{name}/1 should be rejected");
}
}
#[test]
fn arity_optional_arg_functions() {
for name in ["string", "name", "local-name", "namespace-uri",
"number", "string-length", "normalize-space"] {
assert!(super::builtin_arity_ok(name, 0), "{name}/0");
assert!(super::builtin_arity_ok(name, 1), "{name}/1");
assert!(!super::builtin_arity_ok(name, 2), "{name}/2 should be rejected");
}
}
#[test]
fn arity_substring_2_or_3() {
assert!(!super::builtin_arity_ok("substring", 1));
assert!( super::builtin_arity_ok("substring", 2));
assert!( super::builtin_arity_ok("substring", 3));
assert!(!super::builtin_arity_ok("substring", 4));
}
#[test]
fn arity_concat_is_variadic() {
assert!(!super::builtin_arity_ok("concat", 0));
assert!(!super::builtin_arity_ok("concat", 1));
assert!( super::builtin_arity_ok("concat", 2));
assert!( super::builtin_arity_ok("concat", 7));
}
#[test]
fn arity_translate_requires_three() {
assert!(!super::builtin_arity_ok("translate", 2));
assert!( super::builtin_arity_ok("translate", 3));
assert!(!super::builtin_arity_ok("translate", 4));
}
}