Skip to main content

sup_xml_core/xpath/
bindings_builder.rs

1//! Ergonomic builder for [`XPathBindings`].
2//!
3//! Implementing the trait by hand is cheap once you've seen it, but
4//! the boilerplate (one `match` per slot, threading `Option`,
5//! tracking namespaces in a `HashMap`) gets old fast.
6//! [`XPathBindingsBuilder`] wraps the three knobs callers actually
7//! reach for — functions, variables, namespace prefixes — behind a
8//! fluent builder, and impls the trait itself so it drops straight
9//! into [`super::XPathContext::eval_with`].
10//!
11//! # Quick example
12//!
13//! ```
14//! use sup_xml_core::{parse_str, ParseOptions, XPathContext};
15//! use sup_xml_core::xpath::{XPathBindingsBuilder, eval::Value};
16//!
17//! let doc = parse_str("<r/>", &ParseOptions::default()).unwrap();
18//! let ctx = XPathContext::new(&doc);
19//!
20//! let mut bindings = XPathBindingsBuilder::new();
21//! bindings
22//!     .namespace("my", "urn:my:ns")
23//!     .bind_variable("greeting", Value::String("hello".into()))
24//!     .function("urn:my:ns", "exclaim", |args| match args.into_iter().next() {
25//!         Some(Value::String(s)) => Ok(Value::String(format!("{s}!"))),
26//!         _ => Ok(Value::String(String::new())),
27//!     });
28//!
29//! let v = ctx.eval_with("my:exclaim($greeting)", 0, &bindings).unwrap();
30//! // v is `Value::String("hello!")`
31//! ```
32
33use std::collections::HashMap;
34use std::sync::Arc;
35
36use crate::error::XmlError;
37use super::eval::{Value, XPathBindings};
38use super::index::NodeId;
39
40type BoxedFn = Arc<dyn Fn(Vec<Value>) -> Result<Value, XmlError> + Send + Sync>;
41
42/// Builder + [`XPathBindings`] impl for caller-supplied namespaces,
43/// variables, and extension functions.  Cheap to clone (functions
44/// are stored in `Arc`).  Pass `&builder` (or any `&dyn
45/// XPathBindings`) to [`super::XPathContext::eval_with`].
46#[derive(Clone, Default)]
47pub struct XPathBindingsBuilder {
48    namespaces: HashMap<String, String>,
49    variables:  HashMap<String, Value>,
50    /// `(uri, local) -> closure`.  Two-level for fast lookup by
51    /// namespace.  Unprefixed functions go under `""`.
52    functions:  HashMap<String, HashMap<String, BoxedFn>>,
53}
54
55impl XPathBindingsBuilder {
56    /// Empty builder — start here and chain.
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Bind `prefix` to `uri` for QName resolution in the XPath
62    /// expression's static context.  Mirrors libxml2's
63    /// `xmlXPathRegisterNs` / lxml's `namespaces=` kwarg.
64    pub fn namespace(
65        &mut self,
66        prefix: impl Into<String>,
67        uri:    impl Into<String>,
68    ) -> &mut Self {
69        self.namespaces.insert(prefix.into(), uri.into());
70        self
71    }
72
73    /// Bind `$name` to `value`.  XPath references like `$name` (no
74    /// prefix) and `$prefix:name` (prefix resolved against the
75    /// namespace map) both consult this table.
76    ///
77    /// Method name is `bind_variable` (not `variable`) so it doesn't
78    /// shadow the `XPathBindings::variable` trait method on the
79    /// same type.
80    pub fn bind_variable(
81        &mut self,
82        name:  impl Into<String>,
83        value: Value,
84    ) -> &mut Self {
85        self.variables.insert(name.into(), value);
86        self
87    }
88
89    /// Register `f` to handle `ns_uri:name(...)` calls.  Pass `""`
90    /// as `ns_uri` to register a function under the default
91    /// (unprefixed) namespace — that lets `name(...)` work without
92    /// a prefix, but is rarely what you want because it can shadow
93    /// XPath 1.0 built-ins.
94    pub fn function<F>(
95        &mut self,
96        ns_uri: impl Into<String>,
97        name:   impl Into<String>,
98        f:      F,
99    ) -> &mut Self
100    where
101        F: Fn(Vec<Value>) -> Result<Value, XmlError> + Send + Sync + 'static,
102    {
103        self.functions
104            .entry(ns_uri.into())
105            .or_default()
106            .insert(name.into(), Arc::new(f));
107        self
108    }
109}
110
111impl XPathBindings for XPathBindingsBuilder {
112    fn resolve_prefix(&self, prefix: &str) -> Option<String> {
113        self.namespaces.get(prefix).cloned()
114    }
115    fn variable(&self, name: &str) -> Option<Value> {
116        // Try the raw lookup first.  If the name carries a prefix
117        // and the raw lookup misses, try the Clark form
118        // `{uri}local` so callers can register either way.
119        if let Some(v) = self.variables.get(name) { return Some(v.clone()); }
120        if let Some((prefix, local)) = name.split_once(':') {
121            if let Some(uri) = self.namespaces.get(prefix) {
122                let clark = format!("{{{uri}}}{local}");
123                if let Some(v) = self.variables.get(&clark) { return Some(v.clone()); }
124            }
125        }
126        None
127    }
128    fn call_function(
129        &self, ns_uri: &str, name: &str, args: Vec<Value>,
130    ) -> Option<Result<Value, XmlError>> {
131        let f = self.functions.get(ns_uri)?.get(name)?;
132        Some(f(args))
133    }
134    fn call_function_in(
135        &self, ns_uri: &str, name: &str, args: Vec<Value>,
136        _xpath_context_node: NodeId,
137    ) -> Option<Result<Value, XmlError>> {
138        self.call_function(ns_uri, name, args)
139    }
140}
141
142impl std::fmt::Debug for XPathBindingsBuilder {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        let mut fn_names: Vec<String> = Vec::new();
145        for (ns, m) in &self.functions {
146            for k in m.keys() {
147                fn_names.push(format!("{{{ns}}}{k}"));
148            }
149        }
150        fn_names.sort();
151        f.debug_struct("XPathBindingsBuilder")
152            .field("namespaces", &self.namespaces)
153            .field("variables",  &self.variables.keys().collect::<Vec<_>>())
154            .field("functions",  &fn_names)
155            .finish()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use super::super::eval::Numeric;
163    use crate::{parse_str, ParseOptions, XPathContext};
164
165    fn doc() -> sup_xml_tree::dom::Document {
166        parse_str("<r><n>1</n><n>2</n><n>3</n></r>", &ParseOptions::default()).unwrap()
167    }
168
169    #[test]
170    fn function_registered_and_called() {
171        let d = doc();
172        let ctx = XPathContext::new(&d);
173        let mut b = XPathBindingsBuilder::new();
174        b.namespace("my", "urn:my:ns")
175            .function("urn:my:ns", "square", |args| {
176                let n = match args.into_iter().next() {
177                    Some(Value::Number(n)) => n.as_f64(),
178                    _ => return Ok(Value::Number(Numeric::Double(f64::NAN))),
179                };
180                Ok(Value::Number(Numeric::Double(n * n)))
181            });
182        let v = ctx.eval_with("my:square(7)", 0, &b).unwrap();
183        match v { Value::Number(n) => assert_eq!(n.as_f64(), 49.0), _ => panic!() }
184    }
185
186    #[test]
187    fn variable_resolved() {
188        let d = doc();
189        let ctx = XPathContext::new(&d);
190        let mut b = XPathBindingsBuilder::new();
191        b.bind_variable("answer", Value::Number(Numeric::Double(42.0)));
192        let v = ctx.eval_with("$answer + 1", 0, &b).unwrap();
193        match v { Value::Number(n) => assert_eq!(n.as_f64(), 43.0), _ => panic!() }
194    }
195
196    #[test]
197    fn namespace_prefix_resolves() {
198        let d = parse_str(
199            r#"<r xmlns:x="urn:x"><x:item>found</x:item></r>"#,
200            &ParseOptions { namespace_aware: true, ..Default::default() },
201        ).unwrap();
202        let ctx = XPathContext::new(&d);
203        let mut b = XPathBindingsBuilder::new();
204        b.namespace("x", "urn:x");
205        let v = ctx.eval_with("string(/r/x:item)", 0, &b).unwrap();
206        match v { Value::String(s) => assert_eq!(s, "found"), _ => panic!() }
207    }
208
209    #[test]
210    fn function_propagates_error() {
211        use crate::error::{ErrorDomain, ErrorLevel};
212        let d = doc();
213        let ctx = XPathContext::new(&d);
214        let mut b = XPathBindingsBuilder::new();
215        b.namespace("my", "urn:my:ns")
216            .function("urn:my:ns", "fail", |_args| {
217                Err(XmlError::new(ErrorDomain::XPath, ErrorLevel::Error, "boom"))
218            });
219        let r = ctx.eval_with("my:fail()", 0, &b);
220        assert!(r.is_err());
221        assert_eq!(r.unwrap_err().message, "boom");
222    }
223
224    #[test]
225    fn unregistered_function_falls_through_to_unknown_error() {
226        let d = doc();
227        let ctx = XPathContext::new(&d);
228        let mut b = XPathBindingsBuilder::new();
229        b.namespace("my", "urn:my:ns");
230        let r = ctx.eval_with("my:missing()", 0, &b);
231        assert!(r.is_err());
232    }
233}