Skip to main content

sup_xml_core/xpath/
mod.rs

1//! XPath 1.0 expression parsing and evaluation.
2//!
3//! For repeated queries against the same document, create an
4//! [`XPathContext`] once — it builds the document index on construction
5//! and amortises that cost across every subsequent `eval_*` call.  The free
6//! functions (`xpath_eval`, `xpath_str`, etc.) are one-shot
7//! convenience wrappers that build a fresh context each time; use them when
8//! you only need a single result.
9//!
10//! # Example — one-shot helpers
11//! ```
12//! use sup_xml_core::{parse_str, xpath_str, xpath_count, xpath_bool, ParseOptions};
13//!
14//! let doc = parse_str(r#"<catalog><book id="1"/><book id="2"/></catalog>"#, &ParseOptions::default()).unwrap();
15//!
16//! assert_eq!(xpath_count(&doc, "/catalog/book").unwrap(), 2);
17//! assert_eq!(xpath_str(&doc, "name(/catalog)").unwrap(), "catalog");
18//! assert!(xpath_bool(&doc, "/catalog/book[@id='1']").unwrap());
19//! ```
20//!
21//! # Example — reusable context
22//! ```
23//! use sup_xml_core::{parse_str, XPathContext, ParseOptions};
24//!
25//! let doc = parse_str(r#"<catalog><book id="1"/><book id="2"/></catalog>"#, &ParseOptions::default()).unwrap();
26//! let ctx = XPathContext::new(&doc);
27//!
28//! assert_eq!(ctx.eval_count("/catalog/book").unwrap(), 2);
29//! assert!(ctx.eval_bool("/catalog/book[@id='1']").unwrap());
30//! ```
31
32#![forbid(unsafe_code)]  // see CONTRIBUTING.md § "Unsafe policy"
33
34pub mod ast;
35pub mod context;
36pub mod eval;
37pub mod exslt;
38pub mod pattern;
39pub mod rtf;
40mod index;
41mod lexer;
42mod parser;
43mod bindings_builder;
44
45pub use bindings_builder::XPathBindingsBuilder;
46
47pub use ast::{Axis, Expr, LocationPath, NodeTest, Step};
48pub use ast::{FunctionSig, ItemType, Occurrence, SequenceType};
49pub use parser::parse_sequence_type_str;
50pub use context::{DocIndex, INodeKind, is_synthetic_id};
51pub use eval::Value as XPathValue;
52pub use eval::compile_xpath_2_0_regex;
53pub use index::{DocIndexLike, NodeId, XPathNodeKind};
54
55use crate::error::Result;
56use sup_xml_tree::dom::Document as ArenaDocument;
57
58/// Knobs for the XPath 1.0 lexer + evaluator.  Default is strict
59/// XPath 1.0 spec conformance; flipping `libxml2_compatible` relaxes
60/// three points where libxml2 historically deviates from the spec:
61///
62/// 1. **Number literals with exponents** — XPath 1.0 § 3.5 forbids
63///    `1e10` / `1.5e-3` style exponent notation in number literals;
64///    libxml2 accepts them.  In compat mode we accept too.
65/// 2. **`number('-')`** — XPath 1.0 § 4.4 says the result is `NaN`
66///    when the argument is not a lexical number; libxml2 returns
67///    `-0`.  In compat mode we return `-0`.
68/// 3. **`string()` of large numbers** — XPath 1.0 § 4.2 mandates
69///    decimal-only output; libxml2 emits `1.23456789012346e+19` in
70///    scientific form for very large / very small magnitudes.  In
71///    compat mode we emit libxml2's scientific form.
72///
73/// Default: `false` (strict — recommended).  Set to `true` only when
74/// porting from libxml2-flavoured XPath corpora or pipelines.
75#[derive(Debug, Clone)]
76pub struct XPathOptions {
77    pub libxml2_compatible: bool,
78    /// Enable XPath 2.0 syntax additions that XPath 1.0 forbids:
79    ///
80    /// * `if (cond) then a else b` conditional expression
81    /// * `for $v in seq return body` (with comma-chained bindings)
82    /// * (more 2.0 grammar will land here as it grows)
83    ///
84    /// Off by default — XPath 1.0 is the spec contract callers
85    /// depend on.  The XSLT compiler flips this on when the
86    /// stylesheet declares `version="2.0"` (or higher).
87    pub xpath_2_0: bool,
88    /// Per-evaluation step ceiling — the cap on charged eval steps that
89    /// bounds adversarial nested-predicate complexity (the `//*[//*[…]]`
90    /// O(N^k) shape).  When exceeded, evaluation aborts with an error
91    /// rather than hanging.  Defaults to [`eval::DEFAULT_MAX_EVAL_STEPS`]
92    /// (20M) — comfortable for ordinary and generated XPath.  Lower it
93    /// (e.g. 1–2M) when evaluating untrusted expressions to tighten the
94    /// worst-case CPU bound; raise it for trusted, legitimately-expensive
95    /// generated XPath.  A value of 0 makes every evaluation fail on its
96    /// first step.
97    pub max_eval_steps: u64,
98}
99
100impl Default for XPathOptions {
101    fn default() -> Self {
102        // Hand-written (not derived) because `max_eval_steps` must
103        // default to the budget constant, not `u64`'s `0` — a derived
104        // `Default` would silently zero the budget and reject everything.
105        Self {
106            libxml2_compatible: false,
107            xpath_2_0: false,
108            max_eval_steps: eval::DEFAULT_MAX_EVAL_STEPS,
109        }
110    }
111}
112
113/// Parse an XPath 1.0 expression string into its AST representation
114/// using the default strict options.  See [`parse_xpath_with`] to
115/// opt into libxml2-compatible lexing.
116pub fn parse_xpath(src: &str) -> Result<Expr> {
117    parse_xpath_with(src, &XPathOptions::default())
118}
119
120/// Parse an XPath expression with explicit [`XPathOptions`].  Today
121/// the only knob that affects parsing is `libxml2_compatible`, which
122/// makes the lexer accept exponent-notation in number literals.
123pub fn parse_xpath_with(src: &str, opts: &XPathOptions) -> Result<Expr> {
124    // XPath 2.0 §3.1.1 specifies `[E[sign]]Digits` exponents on
125    // numeric literals (`xs:double(1.1234E99)`); XPath 1.0 doesn't.
126    // Allow either when libxml2-compat is asked for OR when the
127    // caller opted into XPath 2.0 — neither flag accidentally turns
128    // a 1.0-only document's lex into a 2.0 lex because 1.0 doesn't
129    // produce such tokens.
130    let allow_exponent = opts.libxml2_compatible || opts.xpath_2_0;
131    let (tokens, spans) = lexer::tokenize_with(src, allow_exponent)?;
132    let mut p = parser::Parser::new_with_spans(tokens, spans, src);
133    p.set_xpath_2_0(opts.xpath_2_0);
134    let expr = p.parse_expr()?;
135    p.expect_eof()?;
136    let depth = ast::max_predicate_nesting(&expr);
137    if depth > MAX_PREDICATE_NESTING_DEPTH {
138        use crate::error::{ErrorDomain, ErrorLevel, XmlError};
139        return Err(XmlError::new(
140            ErrorDomain::XPath,
141            ErrorLevel::Error,
142            format!(
143                "XPath predicate nesting depth ({depth}) exceeds limit \
144                 ({MAX_PREDICATE_NESTING_DEPTH}); evaluator complexity is \
145                 O(N^k) in document size N and predicate-nesting depth k"
146            ),
147        ));
148    }
149    Ok(expr)
150}
151
152/// Maximum predicate-nesting depth accepted by [`parse_xpath_with`].
153///
154/// XPath eval is O(N^k) in document size N and predicate-nesting depth
155/// k.  Realistic queries rarely exceed depth 3 (`//section[chapter[
156/// paragraph[contains(., 'x')]]]`); legitimate generated XPath
157/// (e.g., SPARQL→XPath translation) sometimes reaches depth 4-5.  Eight
158/// is a generous ceiling that lets every legitimate pattern through
159/// while rejecting the obviously-adversarial inputs the fuzzer finds
160/// (`//*[//*[//*[//*[//*[//*[//*[.='x']]]]]]]` at depth 7 burns the
161/// full 500k step budget in tens of milliseconds — caught here in
162/// microseconds at parse time).
163pub const MAX_PREDICATE_NESTING_DEPTH: u32 = 8;
164
165// ── arena tree variants ─────────────────────────────────────────────────────
166
167/// Reusable XPath context for an arena-allocated [`ArenaDocument`].
168///
169/// Build once, evaluate many times.  Internally the XPath evaluator is
170/// generic over [`DocIndexLike`].
171pub struct XPathContext<'doc> {
172    /// The flat index used by the evaluator.  Exposed so external
173    /// adapters (e.g. the libxml2 C-ABI shim in `sup-xml-compat`)
174    /// can build their own result-object wrappers without rebuilding
175    /// the index.
176    pub index: DocIndex<'doc>,
177    /// Original document reference, retained so callers that need to
178    /// serialize the synthetic Document node (the result of XPath `/`)
179    /// can do so via [`Self::eval_node_xml`].
180    doc: &'doc ArenaDocument,
181    /// Strict / libxml2-compat knobs applied to both the lexer
182    /// (exponent-notation acceptance) and the evaluator (`number('-')`
183    /// and `string(big)` formatting), plus the per-evaluation step
184    /// budget ([`XPathOptions::max_eval_steps`]).
185    options: XPathOptions,
186}
187
188impl<'doc> XPathContext<'doc> {
189    /// Build a strict-mode evaluation context for `doc`.  O(n) in
190    /// the number of nodes.  Equivalent to
191    /// [`new_with`](Self::new_with) with default [`XPathOptions`].
192    pub fn new(doc: &'doc ArenaDocument) -> Self {
193        Self::new_with(doc, XPathOptions::default())
194    }
195
196    /// Build an evaluation context for `doc` with explicit options.
197    /// Use this to opt into libxml2-compatible behaviour for the
198    /// three spec deviations called out on [`XPathOptions`].
199    pub fn new_with(doc: &'doc ArenaDocument, options: XPathOptions) -> Self {
200        Self { index: DocIndex::build(doc), doc, options }
201    }
202
203    pub fn eval(&self, src: &str) -> Result<XPathValue> {
204        self.eval_with(src, 0, &eval::NoBindings)
205    }
206
207    /// Evaluate `src` against a specific context node.  XPath 1.0
208    /// allows the context-node to be any node in the document; the
209    /// libxml2 ABI exposes this via `xmlXPathContext.node`.
210    pub fn eval_at(&self, src: &str, context_node: NodeId) -> Result<XPathValue> {
211        self.eval_with(src, context_node, &eval::NoBindings)
212    }
213
214    /// Evaluate `src` against `context_node`, consulting `bindings`
215    /// for user-registered functions (`extensions=` in lxml),
216    /// variables (`$varname`), and namespace prefix resolution.  This
217    /// is the entry point the libxml2 C-ABI shim
218    /// (`sup-xml-compat::xpath`) uses when its `xmlXPathContext`
219    /// carries registered namespaces, functions, or variables.
220    pub fn eval_with(
221        &self,
222        src: &str,
223        context_node: NodeId,
224        bindings: &dyn eval::XPathBindings,
225    ) -> Result<XPathValue> {
226        let expr = parse_xpath_with(src, &self.options)?;
227        // Static-context check (XPath 1.0 §1): every namespace
228        // prefix in the expression must be bound.  Surfaces as
229        // libxml2's XPATH_UNDEF_PREFIX_ERROR / lxml's
230        // XPathEvalError before any tree walk happens.
231        eval::validate_prefixes(&expr, bindings)?;
232        // Fresh step-budget for this top-level evaluation —
233        // caps adversarial nested-predicate complexity.  Seed the
234        // thread-local ceiling from this context's options
235        // (default 20M; tunable via `XPathOptions::max_eval_steps`).
236        eval::set_eval_budget(self.options.max_eval_steps);
237        eval::reset_eval_budget();
238        // Sample a stable instant for this top-level evaluation so
239        // repeated fn:current-* calls within it agree (XPath 2.0 §16).
240        eval::refresh_stable_now();
241        let static_ctx = eval::StaticContext {
242            xpath_2_0: self.options.xpath_2_0,
243            xpath_3_0: false,
244            libxml2_compatible: self.options.libxml2_compatible,
245            // `current()` returns this node regardless of how deep into
246            // steps/predicates evaluation descends.
247            current_node: Some(context_node),
248        };
249        eval::eval_expr(
250            &expr,
251            &eval::EvalCtx {
252                context_node, pos: 1, size: 1, bindings,
253                static_ctx: &static_ctx,
254            },
255            &self.index,
256        )
257    }
258
259    /// Find the index ID for a node identified by its arena pointer.
260    /// Returns `None` if the pointer doesn't match any indexed node.
261    /// Linear scan; O(n) in document size — call once per evaluation,
262    /// not per expression step.
263    ///
264    /// Handles attribute pointers too: libxslt sets the XPath context
265    /// node to an `xmlAttr*` while iterating `@*` (`<xsl:for-each
266    /// select="@*">`), and the C-ABI layer hands that pointer here cast
267    /// to `*const Node`.  The match is by raw address, so comparing the
268    /// indexed `&Attribute` against `ptr` is sound regardless of the
269    /// nominal pointer type.
270    pub fn id_for_element(&self, ptr: *const sup_xml_tree::dom::Node<'_>) -> Option<NodeId> {
271        use crate::xpath::context::INodeKind;
272        if ptr.is_null() { return Some(0); }
273        let target = ptr as *const ();
274        for (i, n) in self.index.nodes.iter().enumerate() {
275            let matches = match n.kind {
276                INodeKind::Element(p) | INodeKind::Text(p) | INodeKind::Comment(p)
277                | INodeKind::CData(p)   | INodeKind::PI(p)
278                    => (p as *const _ as *const ()) == target,
279                INodeKind::Attribute(a)
280                    => (a as *const _ as *const ()) == target,
281                _ => false,
282            };
283            if matches { return Some(i); }
284        }
285        None
286    }
287    pub fn eval_bool(&self, src: &str) -> Result<bool> {
288        Ok(eval::value_to_bool(&self.eval(src)?, &self.index))
289    }
290    pub fn eval_str(&self, src: &str) -> Result<String> {
291        Ok(eval::value_to_string(&self.eval(src)?, &self.index))
292    }
293    pub fn eval_num(&self, src: &str) -> Result<f64> {
294        Ok(eval::value_to_number(&self.eval(src)?, &self.index))
295    }
296    pub fn eval_strings(&self, src: &str) -> Result<Vec<String>> {
297        let v = self.eval(src)?;
298        Ok(match v {
299            XPathValue::NodeSet(ns) => ns.iter().map(|&id| self.index.string_value(id)).collect(),
300            other => vec![eval::value_to_string(&other, &self.index)],
301        })
302    }
303    pub fn eval_count(&self, src: &str) -> Result<usize> {
304        let v = self.eval(src)?;
305        Ok(match v { XPathValue::NodeSet(ns) => ns.len(), _ => 0 })
306    }
307
308    /// Evaluate `src` and return each matched node serialized as XML
309    /// (the XPath equivalent of `xmllint --xpath`'s default output).
310    ///
311    /// * Element / Text / Comment / CData / PI nodes serialize to their
312    ///   subtree XML form, identical to [`crate::serialize_node_to_string`]
313    ///   with default options.
314    /// * Attribute nodes render as `name="value"` with the value
315    ///   attribute-escaped.
316    /// * Namespace nodes render as `xmlns:prefix="uri"` (or `xmlns="…"`
317    ///   for the default namespace).
318    /// * The synthetic Document node (returned by `/`) serializes as
319    ///   the whole document via [`crate::serialize_to_string`].
320    ///
321    /// Non-NodeSet results (string / number / boolean) collapse to a
322    /// single-element vec containing their XPath 1.0 string form,
323    /// matching [`Self::eval_strings`].
324    pub fn eval_node_xml(&self, src: &str) -> Result<Vec<String>> {
325        use crate::serializer::{serialize_node_to_string, serialize_with, SerializeOptions};
326        use crate::xpath::context::INodeKind;
327        let v = self.eval(src)?;
328        let ns = match v {
329            XPathValue::NodeSet(ns) => ns,
330            other => return Ok(vec![eval::value_to_string(&other, &self.index)]),
331        };
332        let opts = SerializeOptions::default();
333        let mut out = Vec::with_capacity(ns.len());
334        for id in ns {
335            let node = &self.index.nodes[id];
336            let serialized = match &node.kind {
337                INodeKind::Element(n) | INodeKind::Text(n)
338                | INodeKind::Comment(n) | INodeKind::CData(n)
339                | INodeKind::PI(n) => serialize_node_to_string(n, &opts),
340                INodeKind::Attribute(a) => {
341                    let mut s = String::with_capacity(a.name().len() + a.value().len() + 3);
342                    s.push_str(a.name());
343                    s.push_str("=\"");
344                    // XML attribute-value escaping: `&`, `<`, `"`.
345                    // (`'` is allowed inside `"..."` quotes.)
346                    for ch in a.value().chars() {
347                        match ch {
348                            '&' => s.push_str("&amp;"),
349                            '<' => s.push_str("&lt;"),
350                            '"' => s.push_str("&quot;"),
351                            c   => s.push(c),
352                        }
353                    }
354                    s.push('"');
355                    s
356                }
357                INodeKind::Namespace { prefix: Some(p), uri } =>
358                    format!("xmlns:{}=\"{}\"", p, uri),
359                INodeKind::Namespace { prefix: None, uri } =>
360                    format!("xmlns=\"{}\"", uri),
361                INodeKind::Document => serialize_with(self.doc, &opts),
362            };
363            out.push(serialized);
364        }
365        Ok(out)
366    }
367}
368
369/// One-shot XPath evaluation against an arena document.  Builds a fresh
370/// index per call — use [`XPathContext`] to amortise.
371pub fn xpath_eval(doc: &ArenaDocument, src: &str) -> Result<XPathValue> {
372    XPathContext::new(doc).eval(src)
373}
374pub fn xpath_bool(doc: &ArenaDocument, src: &str) -> Result<bool> {
375    XPathContext::new(doc).eval_bool(src)
376}
377pub fn xpath_str(doc: &ArenaDocument, src: &str) -> Result<String> {
378    XPathContext::new(doc).eval_str(src)
379}
380pub fn xpath_num(doc: &ArenaDocument, src: &str) -> Result<f64> {
381    XPathContext::new(doc).eval_num(src)
382}
383pub fn xpath_strings(doc: &ArenaDocument, src: &str) -> Result<Vec<String>> {
384    XPathContext::new(doc).eval_strings(src)
385}
386pub fn xpath_count(doc: &ArenaDocument, src: &str) -> Result<usize> {
387    XPathContext::new(doc).eval_count(src)
388}
389
390// ── tests ───────────────────────────────────────────────────────────────────
391
392#[cfg(test)]
393mod arena_tests {
394    use super::*;
395    use crate::{parse_str, ParseOptions};
396
397    fn parse(xml: &str) -> ArenaDocument {
398        parse_str(xml, &ParseOptions::default()).expect("parse")
399    }
400
401    fn parse_ns(xml: &str) -> ArenaDocument {
402        let opts = ParseOptions { namespace_aware: true, ..ParseOptions::default() };
403        parse_str(xml, &opts).expect("parse")
404    }
405
406    // ── absolute paths + child axis ──────────────────────────────────────
407
408    #[test]
409    fn absolute_path() {
410        let doc = parse(r#"<catalog><book id="1"/><book id="2"/></catalog>"#);
411        assert_eq!(xpath_count(&doc, "/catalog/book").unwrap(), 2);
412        assert_eq!(xpath_count(&doc, "/catalog").unwrap(), 1);
413    }
414
415    #[test]
416    fn wildcard_children() {
417        let doc = parse("<r><a/><b/><c/></r>");
418        assert_eq!(xpath_count(&doc, "/r/*").unwrap(), 3);
419    }
420
421    // ── attribute axis ───────────────────────────────────────────────────
422
423    #[test]
424    fn attribute_predicate() {
425        let doc = parse(r#"<r><book id="1"/><book id="2"/><book id="1"/></r>"#);
426        assert_eq!(xpath_count(&doc, "/r/book[@id='1']").unwrap(), 2);
427        assert!(xpath_bool(&doc, "/r/book[@id='1']").unwrap());
428    }
429
430    #[test]
431    fn current_in_nested_predicate_is_fixed_to_the_expression_context() {
432        // XSLT 1.0 §12.4: current() returns the instruction's current
433        // node, which stays fixed as evaluation descends into steps and
434        // predicates — unlike the context node `.`.  ISO Schematron's
435        // phase selection relies on this:
436        //   ../phase[@id=$p]/active[@pattern=current()/@id]
437        // evaluated from a <pattern> must see current()=<pattern>, not the
438        // <active> the inner predicate is filtering.
439        let doc = parse("<s><phase id='m'><active pat='p1'/></phase>\
440                         <p id='p1'/><p id='p2'/></s>");
441        let ctx = XPathContext::new(&doc);
442        let node_of = |q: &str| match ctx.eval(q).unwrap() {
443            XPathValue::NodeSet(ns) => ns[0],
444            other => panic!("expected node-set, got {other:?}"),
445        };
446        let expr = "../phase[@id='m']/active[@pat=current()/@id]";
447        // From p1 the active row whose @pat = current()/@id (= p1) matches.
448        let hit = ctx.eval_at(expr, node_of("//p[@id='p1']")).unwrap();
449        assert!(matches!(hit, XPathValue::NodeSet(ref ns) if ns.len() == 1),
450            "current() in the nested predicate must resolve to p1: {hit:?}");
451        // From p2 there is no matching active row.
452        let miss = ctx.eval_at(expr, node_of("//p[@id='p2']")).unwrap();
453        assert!(matches!(miss, XPathValue::NodeSet(ref ns) if ns.is_empty()),
454            "expected no match from p2: {miss:?}");
455    }
456
457    #[test]
458    fn attribute_value_extraction() {
459        let doc = parse(r#"<r id="42"/>"#);
460        assert_eq!(xpath_str(&doc, "/r/@id").unwrap(), "42");
461    }
462
463    // ── descendant axis ──────────────────────────────────────────────────
464
465    #[test]
466    fn descendant_or_self() {
467        let doc = parse("<r><a><b><c/></b></a></r>");
468        assert_eq!(xpath_count(&doc, "//c").unwrap(), 1);
469        assert_eq!(xpath_count(&doc, "//*").unwrap(), 4); // r, a, b, c
470    }
471
472    // ── string functions ─────────────────────────────────────────────────
473
474    #[test]
475    fn string_value_extraction() {
476        let doc = parse("<r><title>Hello</title></r>");
477        assert_eq!(xpath_str(&doc, "/r/title").unwrap(), "Hello");
478        assert_eq!(xpath_str(&doc, "string(/r/title)").unwrap(), "Hello");
479    }
480
481    #[test]
482    fn string_concat_children() {
483        let doc = parse("<r>foo<b>bar</b>baz</r>");
484        assert_eq!(xpath_str(&doc, "string(/r)").unwrap(), "foobarbaz");
485    }
486
487    #[test]
488    fn name_functions() {
489        let doc = parse("<r><a/></r>");
490        assert_eq!(xpath_str(&doc, "name(/r)").unwrap(), "r");
491        assert_eq!(xpath_str(&doc, "name(/r/a)").unwrap(), "a");
492    }
493
494    // ── substring ────────────────────────────────────────────────────────
495    //
496    // XPath 1.0 §4.2: result is the chars at 1-based positions p where
497    // round(start) <= p < round(start) + round(len).  NaN / ±Inf in
498    // either argument must not panic — the spec yields an empty range
499    // (or, when only +Inf appears on the length side, the rest of the
500    // string).  Regression coverage for an Inf-induced usize overflow
501    // discovered by fuzz_xpath_eval.
502
503    #[test]
504    fn substring_basic() {
505        let doc = parse("<r/>");
506        assert_eq!(xpath_str(&doc, r#"substring("12345", 2, 3)"#).unwrap(), "234");
507        assert_eq!(xpath_str(&doc, r#"substring("12345", 2)"#).unwrap(), "2345");
508        // Spec example: round(1.5) = 2, round(2.6) = 3, range 2..5.
509        assert_eq!(xpath_str(&doc, r#"substring("12345", 1.5, 2.6)"#).unwrap(), "234");
510        // Spec example: range 0..3 includes positions 1, 2.
511        assert_eq!(xpath_str(&doc, r#"substring("12345", 0, 3)"#).unwrap(), "12");
512    }
513
514    #[test]
515    fn substring_negative_start() {
516        let doc = parse("<r/>");
517        // round(-3) = -3, +5 → range -3..2, includes position 1 only.
518        assert_eq!(xpath_str(&doc, r#"substring("12345", -3, 5)"#).unwrap(), "1");
519        // Start past end of string → empty.
520        assert_eq!(xpath_str(&doc, r#"substring("abc", 10, 5)"#).unwrap(), "");
521        // Length zero → empty.
522        assert_eq!(xpath_str(&doc, r#"substring("abc", 1, 0)"#).unwrap(), "");
523    }
524
525    #[test]
526    fn substring_infinite_start_does_not_panic() {
527        // Regression: `1 div 0` is +Inf in XPath; pre-fix this
528        // overflowed when computing `(start as usize) + (len as usize)`.
529        let doc = parse("<r/>");
530        assert_eq!(xpath_str(&doc, r#"substring("hello", 1 div 0, 5)"#).unwrap(), "");
531        assert_eq!(xpath_str(&doc, r#"substring("hello", -1 div 0, 5)"#).unwrap(), "");
532    }
533
534    #[test]
535    fn substring_infinite_length() {
536        let doc = parse("<r/>");
537        // Spec example: range -42..+Inf includes every position → whole string.
538        assert_eq!(
539            xpath_str(&doc, r#"substring("12345", -42, 1 div 0)"#).unwrap(),
540            "12345",
541        );
542        // Negative-infinity length → empty (range collapses below start).
543        assert_eq!(xpath_str(&doc, r#"substring("abc", 1, -1 div 0)"#).unwrap(), "");
544    }
545
546    #[test]
547    fn substring_nan_yields_empty() {
548        // `0 div 0` is NaN.  Spec: result is empty.
549        let doc = parse("<r/>");
550        assert_eq!(xpath_str(&doc, r#"substring("hello", 0 div 0, 5)"#).unwrap(), "");
551        assert_eq!(xpath_str(&doc, r#"substring("hello", 1, 0 div 0)"#).unwrap(), "");
552        // Mixed: -Inf + +Inf = NaN.
553        assert_eq!(
554            xpath_str(&doc, r#"substring("hello", -1 div 0, 1 div 0)"#).unwrap(),
555            "",
556        );
557    }
558
559    // ── number functions / arithmetic ────────────────────────────────────
560
561    #[test]
562    fn count_function() {
563        let doc = parse("<r><i/><i/><i/></r>");
564        assert_eq!(xpath_num(&doc, "count(/r/i)").unwrap(), 3.0);
565    }
566
567    #[test]
568    fn arithmetic() {
569        let doc = parse("<r/>");
570        assert_eq!(xpath_num(&doc, "2 + 3 * 4").unwrap(), 14.0);
571        assert_eq!(xpath_num(&doc, "10 div 4").unwrap(), 2.5);
572    }
573
574    // ── predicates ───────────────────────────────────────────────────────
575
576    #[test]
577    fn position_predicate() {
578        let doc = parse("<r><i>1</i><i>2</i><i>3</i></r>");
579        assert_eq!(xpath_str(&doc, "/r/i[1]").unwrap(), "1");
580        assert_eq!(xpath_str(&doc, "/r/i[2]").unwrap(), "2");
581        assert_eq!(xpath_str(&doc, "/r/i[last()]").unwrap(), "3");
582    }
583
584    #[test]
585    fn numeric_predicate_on_text() {
586        let doc = parse("<r><i>5</i><i>15</i><i>25</i></r>");
587        assert_eq!(xpath_count(&doc, "/r/i[. > 10]").unwrap(), 2);
588    }
589
590    // ── namespace axis ───────────────────────────────────────────────────
591
592    /// Test-only bindings impl that registers a fixed prefix→URI map.
593    /// Mirrors what lxml's `namespaces=` kwarg / libxslt's
594    /// `xmlXPathRegisterNs` install on the context.
595    struct TestNs(&'static [(&'static str, &'static str)]);
596    impl eval::XPathBindings for TestNs {
597        fn resolve_prefix(&self, prefix: &str) -> Option<String> {
598            self.0.iter().find(|(p, _)| *p == prefix).map(|(_, u)| (*u).to_string())
599        }
600    }
601
602    #[test]
603    fn prefixed_element_addressable_by_qname() {
604        let doc = parse_ns(r#"<r xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>X</dc:title></r>"#);
605        let ctx = XPathContext::new(&doc);
606        let bind = TestNs(&[("dc", "http://purl.org/dc/elements/1.1/")]);
607        let v = ctx.eval_with("namespace-uri(/r/dc:title)", 0, &bind).unwrap();
608        assert_eq!(eval::value_to_string(&v, &ctx.index),
609                   "http://purl.org/dc/elements/1.1/");
610        let v = ctx.eval_with("local-name(/r/dc:title)", 0, &bind).unwrap();
611        assert_eq!(eval::value_to_string(&v, &ctx.index), "title");
612    }
613
614    /// XPath 1.0 §1 — every prefix in the expression must be bound
615    /// in the static context.  Querying a prefixed name without
616    /// registering the prefix must raise an error (libxml2's
617    /// XPATH_UNDEF_PREFIX_ERROR; lxml's XPathEvalError).
618    #[test]
619    fn undefined_prefix_in_qname_errors() {
620        let doc = parse_ns(r#"<r xmlns:fa="urn:x"><fa:d/></r>"#);
621        let err = xpath_str(&doc, "/fa:d").unwrap_err();
622        assert!(
623            err.message.contains("Undefined namespace prefix"),
624            "expected undefined-prefix error, got: {}", err.message,
625        );
626    }
627
628    #[test]
629    fn undefined_prefix_in_prefix_wildcard_errors() {
630        let doc = parse_ns(r#"<r xmlns:fa="urn:x"><fa:d/></r>"#);
631        let err = xpath_str(&doc, "/fa:*").unwrap_err();
632        assert!(
633            err.message.contains("Undefined namespace prefix"),
634            "expected undefined-prefix error, got: {}", err.message,
635        );
636    }
637
638    /// Undefined prefixes nested inside a predicate are also caught
639    /// at static-context check time — before any tree walk happens.
640    #[test]
641    fn undefined_prefix_in_predicate_errors() {
642        let doc = parse_ns("<r><a/></r>");
643        let err = xpath_str(&doc, "/r/a[fa:flag]").unwrap_err();
644        assert!(
645            err.message.contains("Undefined namespace prefix"),
646            "expected undefined-prefix error, got: {}", err.message,
647        );
648    }
649
650    /// Every element carries an implicit `xml` namespace node per
651    /// the XML Namespaces recommendation — even on an element with
652    /// no `xmlns:*` declarations.
653    #[test]
654    fn namespace_axis_implicit_xml_prefix() {
655        let doc = parse_ns("<r/>");
656        assert_eq!(xpath_count(&doc, "/r/namespace::*").unwrap(), 1);
657        // The implicit binding's "name" (local-name) is "xml" and
658        // its string-value is the XML namespace URI.
659        assert_eq!(
660            xpath_str(&doc, "string(/r/namespace::*[1])").unwrap(),
661            "http://www.w3.org/XML/1998/namespace",
662        );
663    }
664
665    /// `xmlns:dc="…"` adds one prefixed binding; combined with the
666    /// implicit `xml` that's two namespace nodes total.
667    #[test]
668    fn namespace_axis_one_declared_plus_xml() {
669        let doc = parse_ns(
670            r#"<r xmlns:dc="http://purl.org/dc/elements/1.1/"><c/></r>"#,
671        );
672        assert_eq!(xpath_count(&doc, "/r/namespace::*").unwrap(), 2);
673        // The child inherits the dc binding, so it also has 2.
674        assert_eq!(xpath_count(&doc, "/r/c/namespace::*").unwrap(), 2);
675    }
676
677    /// Named name-test on the namespace axis — `namespace::dc`
678    /// matches the namespace node whose prefix is `dc`.
679    #[test]
680    fn namespace_axis_name_test_selects_by_prefix() {
681        let doc = parse_ns(
682            r#"<r xmlns:dc="http://purl.org/dc/elements/1.1/"/>"#,
683        );
684        assert_eq!(
685            xpath_str(&doc, "string(/r/namespace::dc)").unwrap(),
686            "http://purl.org/dc/elements/1.1/",
687        );
688        // local-name() of a namespace node is its prefix.
689        assert_eq!(
690            xpath_str(&doc, "local-name(/r/namespace::dc)").unwrap(),
691            "dc",
692        );
693    }
694
695    /// Default namespace (xmlns="…") produces a namespace node
696    /// whose prefix (and therefore local-name) is empty.
697    #[test]
698    fn namespace_axis_default_namespace_has_empty_prefix() {
699        let doc = parse_ns(r#"<r xmlns="http://example.com/ns"/>"#);
700        // Strict XPath §2.3: an unprefixed name has null namespace.
701        // We bind a prefix to address the default-namespaced `r`.
702        let ctx = XPathContext::new(&doc);
703        let bind = TestNs(&[("x", "http://example.com/ns")]);
704        // 2 namespace nodes on `x:r`: the default + xml.
705        let v = ctx.eval_with("count(/x:r/namespace::*)", 0, &bind).unwrap();
706        assert_eq!(eval::value_to_number(&v, &ctx.index), 2.0);
707        // Default ns's local-name is the empty string.
708        let v = ctx
709            .eval_with("string(/x:r/namespace::*[local-name()=''])", 0, &bind)
710            .unwrap();
711        assert_eq!(eval::value_to_string(&v, &ctx.index), "http://example.com/ns");
712    }
713
714    /// A descendant element sees inherited bindings.
715    #[test]
716    fn namespace_axis_inherits_from_ancestor() {
717        let doc = parse_ns(
718            r#"<r xmlns:a="urn:a"><mid xmlns:b="urn:b"><leaf/></mid></r>"#,
719        );
720        // leaf sees a + b + xml = 3.
721        assert_eq!(xpath_count(&doc, "/r/mid/leaf/namespace::*").unwrap(), 3);
722        assert_eq!(
723            xpath_str(&doc, "string(/r/mid/leaf/namespace::a)").unwrap(),
724            "urn:a",
725        );
726        assert_eq!(
727            xpath_str(&doc, "string(/r/mid/leaf/namespace::b)").unwrap(),
728            "urn:b",
729        );
730    }
731
732    /// Re-declaring a prefix on a descendant shadows the ancestor's
733    /// binding: the descendant's `namespace::p` resolves to the
734    /// *closer* declaration.
735    #[test]
736    fn namespace_axis_shadows_ancestor() {
737        let doc = parse_ns(
738            r#"<r xmlns:p="urn:outer"><c xmlns:p="urn:inner"/></r>"#,
739        );
740        assert_eq!(
741            xpath_str(&doc, "string(/r/namespace::p)").unwrap(),
742            "urn:outer",
743        );
744        assert_eq!(
745            xpath_str(&doc, "string(/r/c/namespace::p)").unwrap(),
746            "urn:inner",
747        );
748        // Each element still has exactly p + xml = 2.
749        assert_eq!(xpath_count(&doc, "/r/c/namespace::*").unwrap(), 2);
750    }
751
752    /// `xmlns=""` undeclares the default namespace for this element
753    /// and its descendants — no namespace node for the default.
754    #[test]
755    fn namespace_axis_default_undeclaration() {
756        let doc = parse_ns(
757            r#"<r xmlns="urn:outer"><c xmlns=""/></r>"#,
758        );
759        let ctx = XPathContext::new(&doc);
760        let bind = TestNs(&[("x", "urn:outer")]);
761        // x:r: default + xml = 2.  Inner `c` undeclares the default,
762        // so it lives in no namespace and is reached unprefixed.
763        let r_count = ctx.eval_with("count(/x:r/namespace::*)", 0, &bind).unwrap();
764        assert_eq!(eval::value_to_number(&r_count, &ctx.index), 2.0);
765        let c_count = ctx.eval_with("count(/x:r/c/namespace::*)", 0, &bind).unwrap();
766        assert_eq!(eval::value_to_number(&c_count, &ctx.index), 1.0);
767    }
768
769    // ── EXSLT end-to-end (through eval_with + TestNs bindings) ──────────
770    //
771    // These prove that prefix:function dispatch flows correctly
772    // through XPathBindings → exslt::dispatch, not just that the
773    // family-internal dispatchers work in isolation.
774
775    const EXSLT_NS: &[(&str, &str)] = &[
776        ("math", "http://exslt.org/math"),
777        ("date", "http://exslt.org/dates-and-times"),
778        ("str",  "http://exslt.org/strings"),
779        ("set",  "http://exslt.org/sets"),
780    ];
781
782    #[test]
783    fn exslt_math_max_via_xpath() {
784        let doc = parse("<r><i>3</i><i>7</i><i>1</i><i>5</i></r>");
785        let ctx = XPathContext::new(&doc);
786        let v = ctx.eval_with("math:max(/r/i)", 0, &TestNs(EXSLT_NS)).unwrap();
787        assert_eq!(eval::value_to_number(&v, &ctx.index), 7.0);
788    }
789
790    #[test]
791    fn exslt_str_padding_via_xpath() {
792        let doc = parse("<r/>");
793        let ctx = XPathContext::new(&doc);
794        let v = ctx.eval_with("str:padding(5, '*')", 0, &TestNs(EXSLT_NS)).unwrap();
795        assert_eq!(eval::value_to_string(&v, &ctx.index), "*****");
796    }
797
798    #[test]
799    fn exslt_set_distinct_via_xpath() {
800        let doc = parse("<r><i>x</i><i>y</i><i>x</i><i>y</i><i>z</i></r>");
801        let ctx = XPathContext::new(&doc);
802        let v = ctx.eval_with("count(set:distinct(/r/i))", 0, &TestNs(EXSLT_NS)).unwrap();
803        // 5 nodes, 3 distinct string-values → 3.
804        assert_eq!(eval::value_to_number(&v, &ctx.index), 3.0);
805    }
806
807    #[test]
808    fn exslt_date_year_via_xpath() {
809        let doc = parse("<r/>");
810        let ctx = XPathContext::new(&doc);
811        let v = ctx.eval_with(
812            "date:year('2024-07-04T12:00:00Z')", 0, &TestNs(EXSLT_NS),
813        ).unwrap();
814        assert_eq!(eval::value_to_number(&v, &ctx.index), 2024.0);
815    }
816
817    /// EXSLT functions live under their declared namespace URI, not
818    /// the prefix.  Verifying that the dispatcher matches by URI
819    /// means a caller can pick any prefix for the binding.
820    #[test]
821    fn exslt_prefix_is_user_choice() {
822        let doc = parse("<r><i>5</i><i>3</i></r>");
823        let ctx = XPathContext::new(&doc);
824        // Bind `MATHS` (not the conventional `math`) to the math URI.
825        let bind = TestNs(&[("MATHS", "http://exslt.org/math")]);
826        let v = ctx.eval_with("MATHS:min(/r/i)", 0, &bind).unwrap();
827        assert_eq!(eval::value_to_number(&v, &ctx.index), 3.0);
828    }
829
830    /// Functions in a registered EXSLT namespace but with an unknown
831    /// local name surface as "Unregistered XPath function".
832    #[test]
833    fn unknown_exslt_function_in_registered_ns_errors() {
834        let doc = parse("<r/>");
835        let ctx = XPathContext::new(&doc);
836        let err = ctx.eval_with(
837            "math:does-not-exist()", 0, &TestNs(EXSLT_NS),
838        ).unwrap_err();
839        assert!(
840            err.message.contains("Unregistered XPath function"),
841            "unexpected error: {}", err.message,
842        );
843    }
844
845    // ── boolean / set operators ──────────────────────────────────────────
846
847    #[test]
848    fn union_dedups() {
849        let doc = parse("<r><a/><b/></r>");
850        assert_eq!(xpath_count(&doc, "/r/a | /r/b | /r/a").unwrap(), 2);
851    }
852
853    #[test]
854    fn boolean_and_or() {
855        let doc = parse("<r><a/><b/></r>");
856        assert!(xpath_bool(&doc, "/r/a or /r/missing").unwrap());
857        assert!(!xpath_bool(&doc, "/r/a and /r/missing").unwrap());
858    }
859
860    // ── context reuse ────────────────────────────────────────────────────
861
862    #[test]
863    fn context_amortises_index_build() {
864        let doc = parse("<r><book/><book/><book/></r>");
865        let ctx = XPathContext::new(&doc);
866        assert_eq!(ctx.eval_count("/r/book").unwrap(), 3);
867        assert_eq!(ctx.eval_count("//book").unwrap(), 3);
868        assert!(ctx.eval_bool("/r").unwrap());
869    }
870
871    // ── XPath 3.1 higher-order functions ─────────────────────────────────
872
873    fn eval31(src: &str) -> String {
874        let doc = parse("<r/>");
875        let opts = XPathOptions { xpath_2_0: true, ..XPathOptions::default() };
876        let ctx = XPathContext::new_with(&doc, opts);
877        // The map:/array:/math: prefixes are predeclared in an XSLT 3.0
878        // static context; bind them here so the raw-XPath harness sees
879        // the standard URIs.
880        let bind = TestNs(&[
881            ("map",   "http://www.w3.org/2005/xpath-functions/map"),
882            ("array", "http://www.w3.org/2005/xpath-functions/array"),
883            ("math",  "http://www.w3.org/2005/xpath-functions/math"),
884        ]);
885        eval::value_to_string(&ctx.eval_with(src, 0, &bind).expect("eval"), &ctx.index)
886    }
887
888    #[test]
889    fn inline_function_and_dynamic_call() {
890        assert_eq!(eval31("let $f := function($x) { $x * 2 } return $f(21)"), "42");
891    }
892
893    #[test]
894    fn inline_function_captures_closure() {
895        assert_eq!(
896            eval31("let $n := 10 return (function($x) { $x + $n })(5)"),
897            "15");
898    }
899
900    #[test]
901    fn let_single_and_chained_bindings() {
902        assert_eq!(eval31("let $x := 40 return $x + 2"), "42");
903        // Later bindings see earlier ones.
904        assert_eq!(eval31("let $x := 3, $y := $x * 4 return $x + $y"), "15");
905    }
906
907    #[test]
908    fn let_binds_whole_sequence() {
909        // Unlike `for`, `let` binds the entire sequence as one value.
910        assert_eq!(eval31("let $s := (1, 2, 3, 4) return count($s)"), "4");
911        assert_eq!(eval31("let $s := 1 to 5 return sum($s)"), "15");
912    }
913
914    #[test]
915    fn let_nested_in_for() {
916        assert_eq!(
917            eval31("string-join(for $i in 1 to 3 return \
918                let $sq := $i * $i return string($sq), ' ')"),
919            "1 4 9");
920    }
921
922    #[test]
923    fn parse_json_objects_arrays_scalars() {
924        assert_eq!(eval31("parse-json('[1,2,3]')?2"), "2");
925        assert_eq!(eval31("parse-json('{\"a\":10,\"b\":20}')?b"), "20");
926        assert_eq!(eval31("parse-json('{\"a\":[5,6,7]}')?a?3"), "7");
927        assert_eq!(eval31("parse-json('\"hi\\u0041\"')"), "hiA");
928        assert_eq!(eval31("parse-json('true')"), "true");
929        // null maps to the empty sequence.
930        assert_eq!(eval31("count(parse-json('null'))"), "0");
931        assert_eq!(eval31("parse-json('{\"x\":null}')?x => count()"), "0");
932    }
933
934    #[test]
935    fn parse_json_duplicate_key_policies() {
936        // Default is use-first.
937        assert_eq!(eval31("parse-json('{\"k\":1,\"k\":2}')?k"), "1");
938        assert_eq!(
939            eval31("parse-json('{\"k\":1,\"k\":2}', map{'duplicates':'use-last'})?k"),
940            "2");
941    }
942
943    #[test]
944    fn xml_to_json_round_trips_vocabulary() {
945        let doc = parse_ns(concat!(
946            r#"<map xmlns="http://www.w3.org/2005/xpath-functions">"#,
947            r#"<string key="a">hi</string>"#,
948            r#"<number key="n">42</number>"#,
949            r#"<boolean key="b">true</boolean>"#,
950            r#"<array key="xs"><number>1</number><number>2</number></array>"#,
951            r#"<null key="z"/>"#,
952            r#"</map>"#));
953        let opts = XPathOptions { xpath_2_0: true, ..XPathOptions::default() };
954        let ctx = XPathContext::new_with(&doc, opts);
955        let got = ctx.eval_str("xml-to-json(/)").unwrap();
956        assert_eq!(got, r#"{"a":"hi","n":42,"b":true,"xs":[1,2],"z":null}"#);
957    }
958
959    #[test]
960    fn let_rejected_under_xpath_1_0() {
961        // `let` is an XPath 3.0 construct; under the default XPath 1.0
962        // grammar `let $x := …` must not parse (the `:=` / `$` after a
963        // bare `let` name-test is a syntax error).  Mirrors how `for`,
964        // maps, arrays, and inline functions are all gated on the
965        // 2.0+ flag.
966        let doc = parse("<r/>");
967        let ctx = XPathContext::new(&doc); // default options → XPath 1.0
968        assert!(
969            ctx.eval("let $x := 1 return $x").is_err(),
970            "`let` must not parse under XPath 1.0",
971        );
972        // The same expression parses once 2.0+ syntax is enabled.
973        assert_eq!(eval31("let $x := 1 return $x"), "1");
974    }
975
976    #[test]
977    fn named_function_reference_via_for_each() {
978        assert_eq!(
979            eval31("string-join(for-each(('a','bb','ccc'), string-length#1), ',')"),
980            "1,2,3");
981    }
982
983    #[test]
984    fn fn_for_each_and_filter() {
985        assert_eq!(
986            eval31("string-join(for-each(1 to 4, function($x){ $x * $x }), ' ')"),
987            "1 4 9 16");
988        assert_eq!(
989            eval31("string-join(filter(1 to 6, function($x){ $x mod 2 = 0 }), ' ')"),
990            "2 4 6");
991    }
992
993    #[test]
994    fn fn_fold_left_right() {
995        assert_eq!(
996            eval31("fold-left(1 to 5, 0, function($a,$b){ $a + $b })"),
997            "15");
998        assert_eq!(
999            eval31("fold-right(('a','b','c'), '', function($x,$a){ concat($x,$a) })"),
1000            "abc");
1001    }
1002
1003    #[test]
1004    fn map_for_each_higher_order() {
1005        // Sum the values of a map via map:for-each + sum().
1006        assert_eq!(
1007            eval31("sum(map:for-each(map{'a':1,'b':2,'c':3}, function($k,$v){ $v }))"),
1008            "6");
1009    }
1010
1011    #[test]
1012    fn array_for_each_and_fold() {
1013        assert_eq!(
1014            eval31("array:fold-left([1,2,3,4], 0, function($a,$b){ $a + $b })"),
1015            "10");
1016        assert_eq!(
1017            eval31("array:size(array:for-each([1,2,3], function($x){ $x * $x }))"),
1018            "3");
1019    }
1020
1021    #[test]
1022    fn fn_apply_and_arity() {
1023        assert_eq!(eval31("function-arity(function($a,$b){ $a }) "), "2");
1024        assert_eq!(eval31("apply(concat#3, ['a','b','c'])"), "abc");
1025    }
1026
1027    #[test]
1028    fn array_put_insert_remove() {
1029        // array:put replaces the member at the position (size unchanged).
1030        assert_eq!(eval31("array:size(array:put([1,2,3], 2, 9))"), "3");
1031        assert_eq!(eval31("array:get(array:put([1,2,3], 2, 9), 2)"), "9");
1032        // array:insert-before grows the array; size+1 appends at the end.
1033        assert_eq!(eval31("array:size(array:insert-before([1,2,3], 2, 9))"), "4");
1034        assert_eq!(eval31("array:get(array:insert-before([1,2,3], 2, 9), 2)"), "9");
1035        assert_eq!(eval31("array:get(array:insert-before([1,2,3], 4, 9), 4)"), "9");
1036        // array:remove drops every listed position (sequence of indices).
1037        assert_eq!(eval31("array:size(array:remove([1,2,3,4], (2,4)))"), "2");
1038        assert_eq!(eval31("array:get(array:remove([1,2,3,4], (2,4)), 2)"), "3");
1039        assert_eq!(eval31("array:size(array:remove([1,2,3], 2))"), "2");
1040    }
1041}