Skip to main content

sup_xml_core/xpath/
eval.rs

1use std::cell::{Cell, RefCell};
2use std::collections::HashSet;
3use std::fmt::Write as _;
4
5use sup_xml_tree::dom::Node;
6
7use super::ast::*;
8use super::index::{DocIndexLike, NodeId, XPathNodeKind};
9use crate::error::{ErrorDomain, ErrorLevel, XmlError};
10
11type Result<T> = std::result::Result<T, XmlError>;
12
13pub fn xpath_err(msg: impl Into<String>) -> XmlError {
14    XmlError::new(ErrorDomain::XPath, ErrorLevel::Error, msg)
15}
16
17// ── eval-step budget ─────────────────────────────────────────────────────────
18
19/// Cap on the number of charged "steps" any single XPath evaluation
20/// may perform.  Nested-predicate expressions like
21/// `//*[.=//*[.=//*[.=…]]]` have N^k complexity in document size N
22/// and predicate-nesting depth k — easily minutes-to-hours of CPU
23/// on adversarial input (found by the `fuzz_xpath_eval` target).
24///
25/// 20M covers ordinary XPath comfortably and also accommodates
26/// XPath 2.0 / XSLT 2.0 expressions that iterate the whole Unicode
27/// codepoint range via lazy `IntRange` — each codepoint costs a
28/// handful of charged eval_expr re-entries through SimpleMap and
29/// the predicate, so a 1.1M-codepoint sweep spends ~10M before
30/// the surrounding `count()` materialises.  Adversarial nested-
31/// predicate inputs still hit the cap in well under a second on
32/// release builds — the budget catches super-linear blowup, not
33/// linear iteration over a known-bounded range.  Parallels libxml2's
34/// `XPATH_MAX_STEPS`.
35pub const DEFAULT_MAX_EVAL_STEPS: u64 = 20_000_000;
36
37thread_local! {
38    /// Per-evaluation step ceiling for this thread.  Defaults to
39    /// [`DEFAULT_MAX_EVAL_STEPS`]; override with [`set_eval_budget`]
40    /// (which [`super::XPathContext::eval_with`] calls from the
41    /// context's configured `max_eval_steps`).  Sticky for the thread
42    /// until changed — `reset_eval_budget` reseeds the remaining
43    /// counter from it before each top-level evaluation.
44    static EVAL_STEPS_BUDGET: Cell<u64> = const { Cell::new(DEFAULT_MAX_EVAL_STEPS) };
45    /// Steps remaining in the current evaluation.  Reset to the
46    /// configured budget by [`reset_eval_budget`].  Charged once per
47    /// candidate node in [`apply_predicates`] and once per descendant
48    /// during recursive tree walks — the hot paths that compound
49    /// multiplicatively when predicates nest.
50    static EVAL_STEPS_REMAINING: Cell<u64> = const { Cell::new(DEFAULT_MAX_EVAL_STEPS) };
51}
52
53/// Set this thread's per-evaluation step ceiling.  Takes effect on the
54/// next [`reset_eval_budget`] (i.e. the next top-level evaluation).
55/// Lower it when evaluating untrusted XPath to cap worst-case CPU;
56/// raise it for trusted, legitimately-expensive generated expressions.
57/// A value of 0 makes every evaluation fail on its first step.
58pub fn set_eval_budget(max_steps: u64) {
59    EVAL_STEPS_BUDGET.with(|c| c.set(max_steps));
60}
61
62/// Reset the remaining-step counter to this thread's configured budget
63/// for a fresh top-level evaluation.  Called by
64/// [`super::XPathContext::eval_with`]; XSLT-internal eval call sites
65/// inherit the current budget (in practice the default, since
66/// stylesheets don't pathologically nest and an untrusted-XPath caller
67/// goes through `XPathContext`).
68pub fn reset_eval_budget() {
69    let budget = EVAL_STEPS_BUDGET.with(|c| c.get());
70    EVAL_STEPS_REMAINING.with(|c| c.set(budget));
71}
72
73thread_local! {
74    /// Stable "current instant" (seconds since the Unix epoch) for
75    /// this execution.  XPath 2.0 §16.* requires `fn:current-dateTime`,
76    /// `fn:current-date`, and `fn:current-time` to return the SAME
77    /// value throughout one execution scope (a stylesheet transform or
78    /// a single top-level XPath evaluation).  [`refresh_stable_now`]
79    /// reseeds it at each execution boundary; `stable_now` lazily
80    /// initialises it for any path that didn't.
81    static STABLE_NOW: Cell<Option<i64>> = const { Cell::new(None) };
82}
83
84/// Reseed the stable current-instant for a new execution scope (called
85/// at XSLT-transform entry and at each top-level XPath evaluation), so
86/// the next `fn:current-*` call samples a fresh time while remaining
87/// stable for the rest of that scope.
88pub fn refresh_stable_now() {
89    let now = std::time::SystemTime::now()
90        .duration_since(std::time::UNIX_EPOCH)
91        .map(|d| d.as_secs() as i64)
92        .unwrap_or(0);
93    STABLE_NOW.with(|c| c.set(Some(now)));
94}
95
96/// The stable current instant (seconds since the epoch) for this
97/// execution scope, lazily sampling the system clock on first use.
98fn stable_now() -> i64 {
99    STABLE_NOW.with(|c| {
100        if let Some(n) = c.get() {
101            return n;
102        }
103        let now = std::time::SystemTime::now()
104            .duration_since(std::time::UNIX_EPOCH)
105            .map(|d| d.as_secs() as i64)
106            .unwrap_or(0);
107        c.set(Some(now));
108        now
109    })
110}
111
112thread_local! {
113    /// In-scope `[xsl:]default-collation` URI as resolved at the
114    /// surrounding XSLT element when the current XPath expression
115    /// was compiled.  The XSLT eval pushes the URI before each
116    /// `xpath_eval` call so XPath operators (`eq`, `ne`, `lt`, …)
117    /// can fold string operands per the static collation context
118    /// without threading the URI through every comparison.  Default
119    /// `None` denotes the codepoint collation (no fold).
120    pub static DEFAULT_COLLATION: RefCell<Option<String>> = const { RefCell::new(None) };
121}
122
123/// Run `f` with `uri` installed as the in-scope default-collation
124/// URI, restoring the previous value on return (including on
125/// panic).  Caller is responsible for picking the right URI from
126/// the compiled expression's static context.
127pub fn with_default_collation<R>(uri: Option<String>, f: impl FnOnce() -> R) -> R {
128    let prev = DEFAULT_COLLATION.with(|c| c.borrow().clone());
129    DEFAULT_COLLATION.with(|c| *c.borrow_mut() = uri);
130    struct Guard(Option<String>);
131    impl Drop for Guard {
132        fn drop(&mut self) {
133            let p = self.0.take();
134            DEFAULT_COLLATION.with(|c| *c.borrow_mut() = p);
135        }
136    }
137    let _g = Guard(prev);
138    f()
139}
140
141/// The collation a collation-sensitive function should use: its
142/// explicit collation argument when supplied, otherwise the in-scope
143/// default collation (XPath 2.0 §C.2 — set by `[xsl:]default-collation`
144/// via [`Expr::WithDefaultCollation`]).  Lets a call that omits the
145/// collation argument still honour an in-scope `default-collation`.
146fn effective_collation(explicit: Option<String>) -> Option<String> {
147    explicit.or_else(|| DEFAULT_COLLATION.with(|c| c.borrow().clone()))
148}
149
150thread_local! {
151    /// XPath 1.0 backwards-compatibility mode (XPath 2.0 §B.1).  Set
152    /// while evaluating an [`Expr::BackwardsCompat`] subtree — i.e. an
153    /// expression that the XSLT compiler found inside a
154    /// `[xsl:]version="1.0"` scope.  When true the conversion rules
155    /// differ: arithmetic operands are atomised to xs:double and a
156    /// `to`-range bound takes the first item of a sequence.
157    static XPATH_1_0_COMPAT: Cell<bool> = const { Cell::new(false) };
158}
159
160/// Run `f` with XPath 1.0 backwards-compatibility mode active,
161/// restoring the previous setting on return (including on panic).
162pub fn with_xpath_1_0_compat<R>(f: impl FnOnce() -> R) -> R {
163    let prev = XPATH_1_0_COMPAT.with(|c| c.replace(true));
164    struct Guard(bool);
165    impl Drop for Guard {
166        fn drop(&mut self) { XPATH_1_0_COMPAT.with(|c| c.set(self.0)); }
167    }
168    let _g = Guard(prev);
169    f()
170}
171
172/// True iff evaluation is currently inside an XPath 1.0 backwards-
173/// compatibility scope.  Exposed for the XSLT layer so spec-checks
174/// that loosen in BC mode (e.g. xsl:sort's XTTE1020 cardinality
175/// rule) can probe the current scope without duplicating the
176/// thread-local plumbing.
177pub fn in_xpath_1_0_compat() -> bool {
178    XPATH_1_0_COMPAT.with(|c| c.get())
179}
180
181thread_local! {
182    /// XPath 2.0 §2.1.2 "context item".  Set per-iteration by
183    /// `filter_sequence_by_predicates` and the simple-map
184    /// `lhs ! rhs` evaluator when the iterated items are atomic
185    /// values that don't fit our [`EvalCtx::context_node`] field.
186    /// Read by [`eval_expr`] when it encounters a bare `.` path —
187    /// returning the atomic value directly so predicates like
188    /// `($strings)[matches(., '\w')]` see each string instead of
189    /// the outer context node.
190    static CONTEXT_ITEM: RefCell<Option<Value>> = const { RefCell::new(None) };
191    /// XPath 2.0 §2.1.2 / XSLT 2.0 §10.3 — set to `true` while
192    /// evaluating an `xsl:function` body, where the focus is
193    /// undefined.  Read by ContextItem (`.`) and absolute-path
194    /// evaluation to raise XPDY0002 instead of silently using the
195    /// caller's focus.  Kept in core so both core and the xslt
196    /// crate (which sets it on xsl:function entry) can reach the
197    /// same flag.
198    static FOCUS_UNDEFINED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
199}
200
201/// Set whether the focus (context item / position / size) is
202/// undefined for the duration of `f`.  Restored on return.  XSLT 2.0
203/// §10.3 uses this around xsl:function bodies.
204pub fn with_focus_undefined<R>(undefined: bool, f: impl FnOnce() -> R) -> R {
205    let prev = FOCUS_UNDEFINED.with(|c| c.replace(undefined));
206    let r = f();
207    FOCUS_UNDEFINED.with(|c| c.set(prev));
208    r
209}
210
211/// True iff the focus is currently undefined (we're inside an
212/// xsl:function body or similar context-less scope).
213pub fn focus_is_undefined() -> bool {
214    FOCUS_UNDEFINED.with(|c| c.get())
215}
216
217/// Set `item` as the current atomic context item for the duration
218/// of `f`, restoring the previous value on return (including on
219/// panic via `RefCell`'s borrow).  `None` clears the slot — useful
220/// when stepping into a sub-expression whose `.` should fall back
221/// to the node-based context.
222pub fn with_atomic_context_item<R>(item: Option<Value>, f: impl FnOnce() -> R) -> R {
223    with_context_item(item, f)
224}
225
226fn with_context_item<R>(item: Option<Value>, f: impl FnOnce() -> R) -> R {
227    let prev = CONTEXT_ITEM.with(|c| c.replace(item));
228    let r = f();
229    CONTEXT_ITEM.with(|c| { c.replace(prev); });
230    r
231}
232
233/// Snapshot the current atomic context item, if any.
234pub fn current_context_item() -> Option<Value> {
235    CONTEXT_ITEM.with(|c| c.borrow().clone())
236}
237
238/// True iff `path` is the XPath expression `.` — a single relative
239/// step on the self axis matching any node, with no predicates and
240/// no filter primary.  That's the only path shape where we can
241/// short-circuit to the atomic [`CONTEXT_ITEM`]; anything else
242/// (`./foo`, `.[$pred]`, `self::node()` with predicates) still
243/// needs the regular tree-walking eval.
244fn is_bare_dot_path(path: &crate::xpath::ast::LocationPath) -> bool {
245    use crate::xpath::ast::{Axis, LocationPath, NodeTest};
246    let steps = match path {
247        LocationPath::Relative(s) => s,
248        LocationPath::Absolute(_) => return false,
249    };
250    if steps.len() != 1 { return false; }
251    let s = &steps[0];
252    s.axis == Axis::Self_
253        && matches!(s.node_test, NodeTest::AnyNode)
254        && s.predicates.is_empty()
255        && s.filter.is_none()
256}
257
258/// Charge one step of evaluation work against the per-thread
259/// budget.  Returns an XPath error if the budget is exhausted so
260/// the caller can bail out cleanly.
261#[inline]
262fn charge_eval_step() -> Result<()> {
263    EVAL_STEPS_REMAINING.with(|c| {
264        let r = c.get();
265        if r == 0 {
266            let budget = EVAL_STEPS_BUDGET.with(|b| b.get());
267            return Err(xpath_err(format!(
268                "XPath evaluation step budget exceeded ({budget}); \
269                 expression too expensive for the configured limit"
270            )));
271        }
272        c.set(r - 1);
273        Ok(())
274    })
275}
276
277/// Opaque foreign-doc node pointer.  Pointer identity is meaningful
278/// (used for set dedup); the engine never dereferences this directly
279/// for tree navigation — that goes through the `XPathBindings`
280/// `eval_steps_in_foreign_doc` / `foreign_string_value` callbacks,
281/// which the compat layer implements by routing through the loaded
282/// doc's `DocIndex`.  Reads of structural fields like `kind`/`children`
283/// here are sound because foreign docs always come from our own
284/// parser (libxml2-ABI-compatible Node layout, lifetime-managed by
285/// the doc registry on `XPathBindings`).
286pub type ForeignNodePtr = *const Node<'static>;
287
288/// XPath 2.0 numeric type discriminator carried by [`Value::Number`].
289///
290/// XML Schema / F&O distinguishes four numeric primitives —
291/// `xs:integer`, `xs:decimal`, `xs:double`, `xs:float` — and the
292/// distinction is observable: `instance of` must tell an integer from
293/// a double, and the number→string rule (F&O §17.1.2) stringifies
294/// doubles/floats in scientific notation but integers/decimals in
295/// decimal form.  Tracking the kind on the value itself lets those
296/// operators answer correctly without a second type carrier.
297///
298/// `Decimal` is exact (backed by [`rust_decimal::Decimal`] — 96-bit
299/// mantissa + scale, ≥28 significant digits), so XPath 2.0's
300/// `0.1 + 0.2` is `0.3`, not `0.30000000000000004` (XPath 2.0 §3.1.1
301/// types a literal containing `.` as `xs:decimal`, and the spec
302/// demands exact arithmetic on it).  `Double`/`Float` stay `f64`-backed.
303/// All four payloads are inline and `Copy`, so `Value::Number` stays a
304/// cheap non-allocating variant — a positional predicate's literal `2`
305/// is `Numeric::Integer(2)` with no heap cost.
306#[derive(Debug, Clone, Copy, PartialEq)]
307pub enum Numeric {
308    Integer(i64),
309    Decimal(rust_decimal::Decimal),
310    Double(f64),
311    Float(f64),
312}
313
314impl Numeric {
315    /// The numeric value as an `f64` — the universal accessor every
316    /// XPath 1.0-style consumer reads, regardless of the underlying
317    /// numeric type.  `Integer(i)` widens to `i as f64`; `Decimal(d)`
318    /// projects via [`rust_decimal::prelude::ToPrimitive`] (the lossy
319    /// step — values past 2^53 lose precision, values beyond `f64`
320    /// range collapse to `f64::NAN`).
321    #[inline]
322    pub fn as_f64(self) -> f64 {
323        use rust_decimal::prelude::ToPrimitive;
324        match self {
325            Numeric::Integer(i) => i as f64,
326            Numeric::Decimal(d) => d.to_f64().unwrap_or(f64::NAN),
327            Numeric::Double(n) | Numeric::Float(n) => n,
328        }
329    }
330
331    /// Build an `xs:integer` from an `f64`.  i64-backed (no arbitrary
332    /// precision): a result outside i64 range falls back to an
333    /// `xs:decimal` so the magnitude survives — saturation at
334    /// `i64::MAX` would pin a growing value and hang recursive
335    /// big-integer arithmetic.  Non-finite values stay as `Double`
336    /// (NaN / ±∞ have no decimal representation per XSD §3.2.3).
337    #[inline]
338    pub fn integer_from_f64(n: f64) -> Numeric {
339        const I64_LIMIT: f64 = 9_223_372_036_854_775_808.0; // 2^63
340        if n.is_finite() && n >= -I64_LIMIT && n < I64_LIMIT {
341            Numeric::Integer(n as i64)
342        } else if let Some(d) = rust_decimal::Decimal::from_f64_retain(n) {
343            Numeric::Decimal(d)
344        } else {
345            Numeric::Double(n)
346        }
347    }
348
349    /// Build a `Numeric` of the given XSD primitive kind from an `f64`
350    /// — used by arithmetic promotion to tag a result with its
351    /// promoted type.  An unrecognised kind falls back to `Double`.
352    /// **Note:** this conversion goes through `f64`, so decimal
353    /// arithmetic that needs to stay exact must NOT route through
354    /// here — see [`Numeric::decimal_op`] for the exact path.
355    #[inline]
356    pub fn of_kind(kind: &str, n: f64) -> Numeric {
357        match kind {
358            "integer" => Numeric::integer_from_f64(n),
359            "decimal" => match rust_decimal::Decimal::from_f64_retain(n) {
360                Some(d) => Numeric::Decimal(d),
361                None    => Numeric::Double(n),
362            },
363            // Narrow through f32 — xs:float is 32-bit IEEE 754, so the
364            // value an `as="xs:float"` /  `xs:float(…)` carries must
365            // already be rounded to single precision before subsequent
366            // arithmetic / serialisation sees it.
367            "float" => Numeric::Float(n as f32 as f64),
368            _ => Numeric::Double(n),
369        }
370    }
371
372    /// Promotion rank in the `integer ⊂ decimal ⊂ float ⊂ double`
373    /// lattice (XPath 2.0 §6.2).  Used by the arithmetic fast path to
374    /// promote two numbers without round-tripping through type-name
375    /// strings.
376    #[inline]
377    fn rank(self) -> u8 {
378        match self {
379            Numeric::Integer(_) => 0,
380            Numeric::Decimal(_) => 1,
381            Numeric::Float(_)   => 2,
382            Numeric::Double(_)  => 3,
383        }
384    }
385
386    /// Inverse of [`rank`](Self::rank): build a `Numeric` of the given
387    /// lattice rank from an `f64`.  Lossy for `Decimal` (the f64 is
388    /// what the caller had); callers that have exact decimal operands
389    /// should use the exact path in [`typed_numeric_op`].
390    #[inline]
391    fn from_rank(rank: u8, n: f64) -> Numeric {
392        match rank {
393            0 => Numeric::integer_from_f64(n),
394            1 => match rust_decimal::Decimal::from_f64_retain(n) {
395                Some(d) => Numeric::Decimal(d),
396                None    => Numeric::Double(n),
397            },
398            // Same f32-narrowing rationale as `of_kind` above.
399            2 => Numeric::Float(n as f32 as f64),
400            _ => Numeric::Double(n),
401        }
402    }
403
404    /// XSD primitive type local name (`"integer"`, `"decimal"`,
405    /// `"double"`, `"float"`) — the discriminator `instance of` and
406    /// the stringification rule branch on.
407    #[inline]
408    pub fn kind(self) -> &'static str {
409        match self {
410            Numeric::Integer(_) => "integer",
411            Numeric::Decimal(_) => "decimal",
412            Numeric::Double(_)  => "double",
413            Numeric::Float(_)   => "float",
414        }
415    }
416}
417
418impl From<f64> for Numeric {
419    /// A bare `f64` with no further type information defaults to
420    /// `xs:double` — the XPath 1.0 numeric type and the F&O default
421    /// for an untyped numeric.
422    #[inline]
423    fn from(n: f64) -> Numeric { Numeric::Double(n) }
424}
425
426#[derive(Debug, Clone)]
427pub enum Value {
428    NodeSet(Vec<NodeId>),
429    /// Result of `document(URI)` — nodes that live in a doc the
430    /// engine's current `DocIndex` doesn't cover.  The engine treats
431    /// these opaquely; ops that need traversal/string-value delegate
432    /// to the bindings (compat looks up the right `DocIndex` via its
433    /// doc registry).  Pointer-identity dedup, no document order
434    /// across docs.
435    ForeignNodeSet(Vec<ForeignNodePtr>),
436    String(String),
437    Number(Numeric),
438    Boolean(bool),
439    /// XPath 2.0 typed atomic value — carries the source XSD type
440    /// tag so `instance of xs:T`, `cast as xs:T`, and other
441    /// type-aware operators can answer schema questions about it.
442    /// Numeric / boolean / string operations treat a Typed value
443    /// as its underlying representation (see [`value_to_number`],
444    /// [`value_to_string`], [`value_to_bool`] for the lowering
445    /// rules) so XPath 1.0-style consumers keep working unchanged.
446    ///
447    /// Boxed to keep `sizeof(Value)` at the existing ~32-byte
448    /// footprint — non-2.0 hot paths that never produce typed
449    /// values pay no memory-bandwidth tax on every Value clone /
450    /// push.  The cost shifts to one heap allocation per xs:T(...)
451    /// call, which is bounded and small.
452    Typed(Box<TypedAtomic>),
453    /// XPath 2.0 §2.4 heterogeneous sequence of typed / atomic
454    /// items.  Produced by the parenthesised sequence constructor
455    /// `(a, b, c)` when at least one item is a typed atomic — keeps
456    /// each item's type tag intact so `instance of` / `subsequence` /
457    /// `data()` round-trip correctly.  When every item is a node or
458    /// every item is an untyped atomic, the existing NodeSet / single
459    /// Value paths are still used to avoid the per-item Value
460    /// allocation overhead.
461    ///
462    /// **Invariant:** items are themselves never `Sequence` —
463    /// sequences flatten at construction (XPath 2.0 §3.3.1).
464    Sequence(Vec<Value>),
465    /// XPath 2.0 §3.3.1 integer range (`m to n`) carried as bounds
466    /// instead of an expanded list.  Stays compact for the W3C
467    /// Unicode-category test families that iterate `1 to 0x10FFFF`
468    /// — materialising would cost millions of [`Value`] allocations
469    /// and burn the per-thread eval-step budget on tree construction
470    /// alone.  Consumers that need positional / set semantics
471    /// (`subsequence`, document-order comparisons, `count` of a
472    /// mixed sequence) expand via [`items_of`]; consumers that can
473    /// stay arithmetic (`count`, `sum`, simple-map first projection)
474    /// special-case the variant to keep things O(1).
475    ///
476    /// **Invariant:** `lo <= hi` — empty ranges normalise to
477    /// `NodeSet(vec![])` at construction so consumers never see an
478    /// `IntRange` they need to test for emptiness.
479    IntRange { lo: i64, hi: i64 },
480    /// XPath 3.1 §17.1 map — an ordered list of (key, value) entries.
481    /// Keys are single atomic values; values are arbitrary sequences
482    /// (themselves `Value`s).  Lookup compares keys by value
483    /// ([`map_key_eq`]).  Boxed to keep `sizeof(Value)` small.
484    Map(Box<Vec<(Value, Value)>>),
485    /// XPath 3.1 §17.3 array — an ordered list of members, each an
486    /// arbitrary sequence (a `Value`).  Indexed 1-based via `?`.
487    Array(Box<Vec<Value>>),
488    /// XPath 3.1 function item — an inline function (with its captured
489    /// closure), a named-function reference, or a partial application.
490    Function(Box<FunctionItem>),
491}
492
493/// A callable XPath 3.1 function item.
494#[derive(Debug, Clone)]
495pub enum FunctionItem {
496    /// An inline `function(...){...}` — parameter names, the body
497    /// expression, and the values of the free variables captured from
498    /// the defining scope (static scoping).
499    Inline {
500        params:  Vec<String>,
501        /// Declared signature, for function subtyping in `instance of`.
502        sig:     Box<crate::xpath::ast::FunctionSig>,
503        body:    crate::xpath::ast::Expr,
504        closure: Vec<(String, Value)>,
505    },
506    /// A reference to a named function (built-in or user-defined),
507    /// `name#arity`, invoked by re-entering function dispatch.  `name`
508    /// is the lexical QName used for dispatch; `ns` is its resolved
509    /// namespace URI (the default function namespace for an unprefixed
510    /// name), captured at reference time so `fn:function-name` can
511    /// rebuild the expanded QName without the defining scope.
512    Named {
513        name: String, ns: String, arity: usize,
514        /// The function's declared signature, captured at reference time
515        /// for function subtyping (`instance of function(…)`).  `None`
516        /// when unknown (built-ins / extensions) — subtyping then falls
517        /// back to arity-only matching.
518        sig: Option<Box<crate::xpath::ast::FunctionSig>>,
519    },
520    /// A partial application: a base function with some arguments
521    /// already bound; `None` slots are the remaining parameters.
522    Partial { base: Box<FunctionItem>, bound: Vec<Option<Value>> },
523}
524
525impl FunctionItem {
526    /// The function's arity (number of parameters still to be supplied).
527    pub fn arity(&self) -> usize {
528        match self {
529            FunctionItem::Inline { params, .. } => params.len(),
530            FunctionItem::Named { arity, .. } => *arity,
531            FunctionItem::Partial { bound, .. } =>
532                bound.iter().filter(|b| b.is_none()).count(),
533        }
534    }
535
536    /// The function's declared signature, if captured (named user
537    /// functions record it at reference time).  `None` for inline,
538    /// partial, and built-in items.
539    pub fn declared_sig(&self) -> Option<&crate::xpath::ast::FunctionSig> {
540        match self {
541            FunctionItem::Named { sig, .. } => sig.as_deref(),
542            FunctionItem::Inline { sig, .. } => Some(sig),
543            _ => None,
544        }
545    }
546}
547
548/// One typed atomic value — the carrier for XPath 2.0 schema-aware
549/// semantics.  Holds the XSD type (`kind`, the local name of the
550/// XSD primitive or derived type as a `&'static str` from the
551/// finite type table), the lexical form, plus pre-computed numeric
552/// / boolean views when the type permits them.
553#[derive(Debug, Clone)]
554pub struct TypedAtomic {
555    /// XSD type local name without the `xs:` prefix.  E.g.
556    /// `"integer"`, `"double"`, `"date"`, `"dayTimeDuration"`.
557    /// `&'static str` rather than `String` because the XSD primitive
558    /// + derived type set is finite and known at compile time —
559    /// saves one heap allocation per typed value.
560    pub kind: &'static str,
561    /// The XPath 2.0 §2.6.1 string-value of this atomic.
562    pub lexical: String,
563    /// Cached numeric coercion for numeric XSD types.  `None` for
564    /// non-numeric types (strings, dates, durations, booleans).
565    pub numeric: Option<f64>,
566    /// Boolean form, populated for xs:boolean values.
567    pub boolean: Option<bool>,
568    /// The declared user-defined (schema) type this value was constructed
569    /// as — `(namespace, local)`.  `None` for built-in-typed and untyped
570    /// values.  Read by `instance of my:type` (which is decided by the
571    /// declared type, not the value space).  Boxed so the common
572    /// built-in case keeps `TypedAtomic` small.
573    pub user_type: Option<Box<(String, String)>>,
574}
575
576impl TypedAtomic {
577    /// A typed atomic of a built-in XSD type (no user-type tag).
578    pub fn builtin(kind: &'static str, lexical: String, numeric: Option<f64>, boolean: Option<bool>) -> Self {
579        TypedAtomic { kind, lexical, numeric, boolean, user_type: None }
580    }
581}
582
583/// Wrap a lexical string as a [`Value::Typed`] of a non-numeric,
584/// non-boolean built-in XSD type — the carrier for F&O functions whose
585/// return type is a specific atomic type (`fn:base-uri` → `xs:anyURI`,
586/// `fn:local-name-from-QName` → `xs:NCName`).  Returning a typed value
587/// (not a bare `Value::String`) is what makes `instance of` answer
588/// correctly per XPath 2.0 §3.10.2.
589pub fn typed_str(kind: &'static str, lexical: String) -> Value {
590    Value::Typed(Box::new(TypedAtomic::builtin(kind, lexical, None, None)))
591}
592
593/// XSD type-hierarchy lookup — parent of a derived type per
594/// XML Schema Part 2 §3.  Returns `None` when `t` is unknown or is
595/// already `anyType` (the universal supertype).  The chain bottoms
596/// out at `anyAtomicType ⊂ anySimpleType ⊂ anyType`.
597pub fn parent_atomic_type(t: &str) -> Option<&'static str> {
598    Some(match t {
599        // Integer subtypes
600        "long" => "integer",
601        "int" => "long",
602        "short" => "int",
603        "byte" => "short",
604        "unsignedLong" => "nonNegativeInteger",
605        "unsignedInt" => "unsignedLong",
606        "unsignedShort" => "unsignedInt",
607        "unsignedByte" => "unsignedShort",
608        "nonNegativeInteger" => "integer",
609        "nonPositiveInteger" => "integer",
610        "positiveInteger" => "nonNegativeInteger",
611        "negativeInteger" => "nonPositiveInteger",
612        "integer" => "decimal",
613        // Strings
614        "normalizedString" => "string",
615        "token" => "normalizedString",
616        "language" => "token",
617        "Name" => "token",
618        "NCName" => "Name",
619        "ID" => "NCName",
620        "IDREF" => "NCName",
621        "ENTITY" => "NCName",
622        "NMTOKEN" => "token",
623        // Durations
624        "dayTimeDuration" => "duration",
625        "yearMonthDuration" => "duration",
626        // Primitives ⊂ anyAtomicType
627        "decimal" | "double" | "float" | "boolean" | "string"
628        | "date" | "dateTime" | "time" | "duration"
629        | "gYear" | "gYearMonth" | "gMonth" | "gMonthDay" | "gDay"
630        | "hexBinary" | "base64Binary" | "anyURI" | "QName"
631        | "NOTATION" | "untypedAtomic"
632        | "IDREFS" | "ENTITIES" | "NMTOKENS"
633            => "anyAtomicType",
634        "anyAtomicType" => "anySimpleType",
635        "anySimpleType" => "anyType",
636        _ => return None,
637    })
638}
639
640/// True iff `t` is (transitively) a subtype of `target` in the
641/// XSD type hierarchy.  Reflexive (`t == target`).
642pub fn xsd_is_subtype_of(t: &str, target: &str) -> bool {
643    if t == target { return true; }
644    let mut cur = t;
645    while let Some(p) = parent_atomic_type(cur) {
646        if p == target { return true; }
647        cur = p;
648    }
649    false
650}
651
652impl Value {
653    /// Strip a typed wrapper, returning the underlying untyped Value.
654    /// Numeric typed → Number; boolean typed → Boolean; otherwise →
655    /// String (the typed value's lexical form, moved out without
656    /// cloning).  A Sequence collapses to a NodeSet only when every
657    /// item is already a node (no atomic-sequence lowering); a
658    /// singleton Sequence unwraps to its sole item.  Non-Typed,
659    /// non-Sequence values pass through unchanged.
660    pub fn untyped(self) -> Self {
661        match self {
662            Value::Typed(t) => {
663                if let Some(n) = t.numeric { Value::Number(Numeric::Double(n)) }
664                else if let Some(b) = t.boolean { Value::Boolean(b) }
665                else {
666                    // Move the lexical String out of the Box — no
667                    // String alloc since we own the Box.
668                    Value::String(t.lexical)
669                }
670            }
671            Value::Sequence(mut items) => {
672                if items.len() == 1 {
673                    return items.remove(0).untyped();
674                }
675                // Multi-item sequences with atomic content can't be
676                // re-encoded as a NodeSet without losing items.
677                // Leave as Sequence — downstream consumers that
678                // care will inspect each item.
679                Value::Sequence(items)
680            }
681            other => other,
682        }
683    }
684}
685
686/// Caller-supplied resolver for things outside the XPath spec that
687/// downstream consumers (libxslt, lxml's `extensions=` / `variables=`
688/// / `namespaces=` kwargs) want to inject:
689///
690/// * **Namespace prefixes** used in `prefix:local` name tests must
691///   resolve to a URI somehow — the consumer registers the prefix→URI
692///   map and we consult it during eval.
693/// * **User-defined functions** like `my:foo(bar)` need to dispatch
694///   to caller-provided code.
695/// * **XPath variables** (`$varname`) need a value source.
696///
697/// All three default to "not provided"; the engine falls through to
698/// XPath 1.0 behaviour (error / empty result) when a binding returns
699/// `None`.
700pub trait XPathBindings {
701    /// Resolve a namespace prefix to its URI.  `None` means the prefix
702    /// is undeclared — the engine surfaces that as an XPath error.
703    fn resolve_prefix(&self, _prefix: &str) -> Option<String> { None }
704    /// Invoke a registered XPath function.  `None` means the function
705    /// isn't registered — the engine falls back to its built-in
706    /// function table (count(), string(), etc.).  An inner `Err`
707    /// propagates as the eval result.
708    fn call_function(
709        &self, _ns_uri: &str, _name: &str, _args: Vec<Value>,
710    ) -> Option<Result<Value>> { None }
711    /// Like [`call_function`](Self::call_function), but also receives
712    /// the current XPath context node — the node that
713    /// `position()`/`last()` are reporting against and that a no-arg
714    /// `generate-id()` / `current()` would see if the spec sense
715    /// were "XPath context", not "XSLT current()".  Default
716    /// delegates to `call_function`, dropping the context.  XSLT
717    /// overrides this so `generate-id()` and friends work in
718    /// predicate sub-expressions.
719    fn call_function_in(
720        &self, ns_uri: &str, name: &str, args: Vec<Value>,
721        _xpath_context_node: NodeId,
722    ) -> Option<Result<Value>> {
723        self.call_function(ns_uri, name, args)
724    }
725    /// Is a function with this expanded name and arity available to call —
726    /// a registered user `xsl:function` or extension?  Lets
727    /// `fn:function-lookup` return the empty sequence for unknown names
728    /// without invoking anything.  Default `false`.
729    fn function_available_in(&self, _ns_uri: &str, _name: &str, _arity: usize) -> bool {
730        false
731    }
732    /// The declared signature of a user `xsl:function` with this expanded
733    /// name and arity, if known — used to apply function subtyping in
734    /// `instance of function(…)`.  `None` means the signature is unknown
735    /// (a built-in, an extension, or no such function), in which case the
736    /// caller falls back to arity-only matching.
737    fn function_signature_in(
738        &self, _ns_uri: &str, _name: &str, _arity: usize,
739    ) -> Option<crate::xpath::ast::FunctionSig> {
740        None
741    }
742    /// Schema-aware processing: test whether a lexical value is castable to
743    /// a user-defined (schema) simple type, identified by its expanded
744    /// name.  `Some(bool)` when the type is known (true = castable);
745    /// `None` when no schema is in scope or the type is unknown, in which
746    /// case the caller treats it as an unrecognized type as before.
747    fn castable_as_user_type(
748        &self, _ns_uri: &str, _local: &str, _value: &str, _source_kind: Option<&str>,
749    ) -> Option<bool> {
750        None
751    }
752    /// Schema-aware processing: cast a lexical value to a user-defined
753    /// simple type, returning the typed value.  `None` when no schema is
754    /// in scope or the type is unknown.
755    fn cast_to_user_type(&self, _ns_uri: &str, _local: &str, _value: &str)
756        -> Option<Result<Value>> {
757        None
758    }
759    /// Schema-aware processing: is a value whose declared type is
760    /// `value_type` (its expanded name, or `None` if untyped / built-in)
761    /// an instance of the user-defined target type?  `None` when no schema
762    /// is in scope or the target type is unknown.
763    fn instance_of_user_type(
764        &self, _target_ns: &str, _target_local: &str, _value_type: Option<(&str, &str)>,
765    ) -> Option<bool> {
766        None
767    }
768    /// Schema-aware processing: does a simple type with this expanded
769    /// name exist in an in-scope schema?  Lets `instance of` / `cast as`
770    /// distinguish "the type is unknown" (XPST0051) from "the type is
771    /// known but the value isn't of it" (the operator's normal `false` /
772    /// failure).  Default `false` — no schema in scope.
773    fn schema_type_exists(&self, _ns: &str, _local: &str) -> bool {
774        false
775    }
776    /// Schema-aware processing: the expanded name `(ns, local)` of the
777    /// schema type that governs the source node `node_id`, as recorded
778    /// by validating the source document against the in-scope schema
779    /// (the post-schema-validation infoset).  `None` when the source
780    /// wasn't schema-validated, the node has no annotation, or the
781    /// governing type is anonymous (no name to report).  Callers treat
782    /// `None` as "untyped" — today's behavior.
783    fn node_schema_type(&self, _node_id: NodeId) -> Option<(String, String)> {
784        None
785    }
786    /// Schema-aware atomization (XPath 2.0 §2.5.2): the typed value of
787    /// source node `node_id`, whose string value is `lexical`, when the
788    /// node carries a simple-typed schema annotation.  Returns the typed
789    /// atomic (so `data($n) instance of xs:integer` and arithmetic see
790    /// the real type).  `None` when the node wasn't schema-typed or its
791    /// type has no atomizable typed value — the caller then atomizes to
792    /// xs:untypedAtomic as before.
793    fn node_typed_value(&self, _node_id: NodeId, _lexical: &str) -> Option<Value> {
794        None
795    }
796    /// Look up an XPath variable's value.  `None` means undefined,
797    /// which surfaces as an XPath error.
798    fn variable(&self, _name: &str) -> Option<Value> { None }
799
800    /// Is the host an XPath 2.0 (or later) processor?  Selects the
801    /// `xs:double`/`xs:float` → string canonical form: 1.0 is
802    /// decimal-only, 2.0 uses scientific notation outside
803    /// `[1e-6, 1e6)` (`1.0E6`).  Only affects typed doubles — integer
804    /// / decimal output is unchanged.  Default `false`.
805    fn xpath_version_2_or_later(&self) -> bool { false }
806
807    /// XSLT `document(uri[, base])` — load the URI as XML and return
808    /// the loaded doc's root as a foreign-node pointer set.
809    /// `base_uri` is the base URI of the calling expression (e.g.
810    /// the stylesheet's URL); the bindings impl resolves the URI
811    /// relative to that.  `None` means the bindings don't support
812    /// document loading — the engine reports "unknown function" as
813    /// for any other unrecognized name.
814    fn load_document(
815        &self, _uri: &str, _base_uri: Option<&str>,
816    ) -> Option<Result<Vec<ForeignNodePtr>>> { None }
817
818    /// Evaluate a path expression's predicates + steps against a
819    /// foreign-doc node-set.  The bindings impl looks up each
820    /// pointer's owning doc (compat keeps a registry of docs loaded
821    /// via `document()`), grabs that doc's `DocIndex`, and re-runs
822    /// the core engine's `apply_predicates` / `eval_step_on_nodes`
823    /// against it.  Returns the resulting node-set as foreign
824    /// pointers.  `None` means the bindings don't support foreign-
825    /// doc traversal — engine errors out.
826    fn apply_foreign_path(
827        &self,
828        _nodes:      &[ForeignNodePtr],
829        _predicates: &[Expr],
830        _steps:      &[Step],
831    ) -> Option<Result<Vec<ForeignNodePtr>>> { None }
832
833    /// Static base URI of the expression's surrounding XSLT element
834    /// (XPath 2.0 §C.1).  Consulted by `fn:resolve-uri($rel)` when no
835    /// explicit base is supplied and by `fn:static-base-uri()`.
836    /// Default `None` means the runtime has no static base URI for
837    /// this evaluation — `resolve-uri` then returns its argument
838    /// unchanged.
839    fn static_base_uri(&self) -> Option<String> { None }
840
841    /// XPath 2.0 §3.1.5 base-URI accessor for synthetic nodes the
842    /// runtime constructs.  Default `None` means "no override" —
843    /// `fn:base-uri` then continues walking up the ancestor chain
844    /// (and ultimately returns the empty sequence).  XSLT
845    /// bindings override this to consult the RTF base-URI table
846    /// populated when an `xsl:variable` / `xsl:document` carrying
847    /// `xml:base` materialised a temporary tree.
848    fn node_base_uri(&self, _id: NodeId) -> Option<String> { None }
849
850    /// XPath 1.0 §5 string-value of a foreign-doc node.  Core can't
851    /// dereference the pointer itself (no `unsafe` in this crate);
852    /// the bindings impl (compat) walks the node through libxml2-ABI
853    /// accessors and returns the concatenated text.  Default returns
854    /// empty string — accurate for the lean build where ForeignNodeSet
855    /// is unreachable anyway.
856    fn foreign_string_value(&self, _p: ForeignNodePtr) -> String { String::new() }
857
858    /// Dynamic-doc loader hook.  Invoked by the XSLT `document()` /
859    /// XPath 2.0 `doc()` runtime when the requested URI isn't in the
860    /// statically-discovered pre-load map.  Implementations resolve
861    /// the URI through the surrounding `Loader`, parse the resource,
862    /// graft it into the active `DocIndex` via
863    /// [`super::DocIndex::graft_dynamic_document`], and return the
864    /// new doc-root [`NodeId`].
865    ///
866    /// `Some(Err(...))` means the URI was understood but couldn't be
867    /// resolved (network/IO error, parse error).  `None` means the
868    /// bindings layer doesn't support dynamic doc-loading at all —
869    /// the caller falls back to the legacy "URI not pre-loaded"
870    /// error so the failure mode is the same as before this hook
871    /// existed.
872    fn load_dynamic_document(
873        &self, _uri: &str,
874    ) -> Option<std::result::Result<NodeId, crate::error::XmlError>> {
875        None
876    }
877
878    /// Which regex dialect to use for `fn:matches` / `fn:replace` /
879    /// `fn:tokenize` / `xsl:analyze-string`.  The default is
880    /// [`crate::regex::Dialect::Xpath`] (XSLT 3.0 / XPath 3.0
881    /// grammar).  An XSLT 2.0 host that wants the W3C conformance
882    /// suite's stricter rejection of `(?:…)` overrides this to
883    /// [`crate::regex::Dialect::Xpath20`].
884    fn regex_dialect(&self) -> crate::regex::Dialect {
885        crate::regex::Dialect::Xpath
886    }
887}
888
889/// Default bindings: every callback returns `None`.  Used when the
890/// caller didn't supply bindings explicitly.
891pub struct NoBindings;
892impl XPathBindings for NoBindings {}
893
894/// XML 1.0 §3.1 / XPath 1.0 §3.7 implicit prefix bindings.  Returned
895/// when user bindings don't resolve a prefix — these two prefixes are
896/// reserved and always available without explicit declaration.
897fn implicit_prefix(prefix: &str) -> Option<&'static str> {
898    match prefix {
899        "xml"   => Some("http://www.w3.org/XML/1998/namespace"),
900        "xmlns" => Some("http://www.w3.org/2000/xmlns/"),
901        // XPath 2.0 §1.6 — `xs` / `xsi` are conventionally
902        // pre-bound in XSLT 2.0 static contexts because constructor
903        // calls (`xs:dateTime(...)`) appear all over the test
904        // corpus without explicit xmlns declarations.  The `fn`
905        // prefix is intentionally NOT auto-bound: XSLT 2.0 §3.6
906        // requires the stylesheet to declare it (XSLT 3.0 relaxes
907        // this), and tests like `type/namespace-6202` rely on the
908        // engine erroring on undeclared `fn:`.
909        "xs"    => Some("http://www.w3.org/2001/XMLSchema"),
910        "xsi"   => Some("http://www.w3.org/2001/XMLSchema-instance"),
911        _ => None,
912    }
913}
914
915/// Resolve a prefix consulting user bindings first, then the two
916/// reserved XML prefixes.  Use this instead of calling
917/// `bindings.resolve_prefix` directly so `xml:`/`xmlns:` work without
918/// the consumer having to register them.
919fn resolve_prefix_or_implicit(bindings: &dyn XPathBindings, prefix: &str) -> Option<String> {
920    bindings
921        .resolve_prefix(prefix)
922        .or_else(|| implicit_prefix(prefix).map(str::to_string))
923}
924
925/// Resolve the namespace URI of a `name#arity` function reference.  An
926/// unprefixed name lies in the default function namespace (`fn:`); a
927/// prefixed one resolves through the in-scope bindings (empty URI if the
928/// prefix is unbound).
929fn named_function_namespace(name: &str, bindings: &dyn XPathBindings) -> String {
930    match name.split_once(':') {
931        Some((prefix, _)) => resolve_prefix_or_implicit(bindings, prefix).unwrap_or_default(),
932        None => FN_NAMESPACE.to_string(),
933    }
934}
935
936static NO_BINDINGS: NoBindings = NoBindings;
937
938/// The static evaluation context (XPath 2.0 §2.1.1) — the config that
939/// is fixed for the whole evaluation and travels by reference so it
940/// can't be lost or duplicated.  Holds exactly the knobs that don't
941/// vary within an evaluation; genuinely dynamic state (context item,
942/// position, the `[xsl:]default-collation` that nests via
943/// `Expr::WithDefaultCollation`) lives elsewhere.
944///
945/// Carried on [`EvalCtx`]; a nested context (`for` body, predicate)
946/// copies the same `&StaticContext`, so a value set once at the top is
947/// seen everywhere — unlike the old per-method `XPathBindings` config,
948/// which a binding wrapper could silently reset to its default.
949#[derive(Debug, Clone, Copy)]
950pub struct StaticContext {
951    /// XPath 2.0+ host — selects xs:integer/decimal literal typing and
952    /// the F&O scientific double→string form.
953    pub xpath_2_0: bool,
954    /// XPath 3.0+ host — gates 3.0-only function signatures (e.g. the
955    /// 2-argument `fn:round($arg, $precision)` form) that are a static
956    /// error in a 2.0 context.
957    pub xpath_3_0: bool,
958    /// libxml2-compat mode — where the spec and libxml2 historically
959    /// diverge (`number('-')`, large-magnitude number formatting, …).
960    pub libxml2_compatible: bool,
961    /// The node the XSLT `current()` function returns: the context node
962    /// of the *whole* top-level expression, fixed for the evaluation.
963    /// Unlike [`EvalCtx::context_node`] it does NOT change as evaluation
964    /// descends into location steps and predicates — so `current()`
965    /// inside a nested predicate (`foo[@x=current()/@y]`, as ISO
966    /// Schematron's phase selection uses) resolves to the instruction's
967    /// current node rather than the predicate's context.
968    ///
969    /// `None` means "no fixed current node was threaded" — `current()`
970    /// then falls back to the live context node (the historical
971    /// behaviour, correct outside nested predicates).  Hosts that know
972    /// the instruction's current node (the libxml2 ABI shim) set `Some`.
973    pub current_node: Option<NodeId>,
974}
975// NOTE: the regex dialect is deliberately NOT here. It is read from the
976// bindings at the point of use because, in XSLT, it is effectively
977// scope-dependent today (a scoped binding reports the trait default),
978// and that interacts with the regex engine's anchor handling. Folding
979// it into the fixed static context would change that behaviour.
980
981impl Default for StaticContext {
982    /// Strict XPath 1.0 — the conservative default for a bare context.
983    fn default() -> Self {
984        StaticContext { xpath_2_0: false, xpath_3_0: false, libxml2_compatible: false, current_node: None }
985    }
986}
987
988impl StaticContext {
989    /// Number-serialization style implied by the static context.
990    pub fn num_style(&self) -> NumStyle {
991        NumStyle::from_context(self.libxml2_compatible, self.xpath_2_0)
992    }
993}
994
995/// The strict-1.0 static context, borrowable by contexts that have no
996/// host config (raw [`EvalCtx::root`], the default `value_to_string`).
997pub static DEFAULT_STATIC_CTX: StaticContext = StaticContext {
998    xpath_2_0: false,
999    xpath_3_0: false,
1000    libxml2_compatible: false,
1001    current_node: None,
1002};
1003
1004pub struct EvalCtx<'b> {
1005    pub context_node: NodeId,
1006    pub pos: usize,
1007    pub size: usize,
1008    pub bindings: &'b dyn XPathBindings,
1009    /// Fixed static config for this evaluation (version, libxml2-compat
1010    /// mode).  Threaded by reference so nested contexts share it and it
1011    /// can't be dropped by a binding wrapper.
1012    pub static_ctx: &'b StaticContext,
1013}
1014
1015impl EvalCtx<'static> {
1016    /// Build a default eval context (document root, position 1, no
1017    /// bindings, strict XPath 1.0 static context).  Equivalent to
1018    /// libxml2's `xmlXPathContext` with nothing registered.
1019    pub fn root() -> Self {
1020        Self {
1021            context_node: 0,
1022            pos: 1,
1023            size: 1,
1024            bindings: &NO_BINDINGS,
1025            static_ctx: &DEFAULT_STATIC_CTX,
1026        }
1027    }
1028}
1029
1030// ── prefix validation ─────────────────────────────────────────────────────────
1031
1032/// Walk the expression AST checking that every namespace prefix used
1033/// in a name test (`prefix:local`, `prefix:*`) resolves through the
1034/// supplied bindings.  Surfaces an `XPathEvalError` for the first
1035/// unbound prefix encountered, matching libxml2's
1036/// `XPATH_UNDEF_PREFIX_ERROR`.
1037///
1038/// Function-name prefixes are validated at dispatch time inside
1039/// `eval_function`; variable prefixes are not (variables are stored
1040/// as raw names and looked up verbatim).
1041pub fn validate_prefixes(expr: &Expr, bindings: &dyn XPathBindings) -> Result<()> {
1042    match expr {
1043        Expr::Or(l, r) | Expr::And(l, r) | Expr::Eq(l, r) | Expr::Ne(l, r)
1044        | Expr::Lt(l, r) | Expr::Gt(l, r) | Expr::Le(l, r) | Expr::Ge(l, r)
1045        | Expr::ValueEq(l, r) | Expr::ValueNe(l, r)
1046        | Expr::ValueLt(l, r) | Expr::ValueGt(l, r)
1047        | Expr::ValueLe(l, r) | Expr::ValueGe(l, r)
1048        | Expr::Add(l, r) | Expr::Sub(l, r) | Expr::Mul(l, r) | Expr::Div(l, r)
1049        | Expr::Mod(l, r) | Expr::Union(l, r)
1050        | Expr::SimpleMap(l, r)
1051        | Expr::NodeBefore(l, r) | Expr::NodeAfter(l, r) | Expr::NodeIs(l, r) => {
1052            validate_prefixes(l, bindings)?;
1053            validate_prefixes(r, bindings)
1054        }
1055        Expr::Neg(e) => validate_prefixes(e, bindings),
1056        Expr::Path(p) => match p {
1057            LocationPath::Absolute(steps) | LocationPath::Relative(steps) => {
1058                validate_steps(steps, bindings)
1059            }
1060        },
1061        Expr::FilterPath { primary, predicates, steps } => {
1062            validate_prefixes(primary, bindings)?;
1063            for p in predicates { validate_prefixes(p, bindings)?; }
1064            validate_steps(steps, bindings)
1065        }
1066        Expr::FunctionCall(_, args) => {
1067            for a in args { validate_prefixes(a, bindings)?; }
1068            Ok(())
1069        }
1070        Expr::Variable(_) | Expr::Literal(_)
1071        | Expr::Integer(_) | Expr::Decimal(_) | Expr::Double(_) => Ok(()),
1072        Expr::IfThenElse { cond, then_branch, else_branch } => {
1073            validate_prefixes(cond, bindings)?;
1074            validate_prefixes(then_branch, bindings)?;
1075            validate_prefixes(else_branch, bindings)
1076        }
1077        Expr::For { bindings: binds, body }
1078        | Expr::Let { bindings: binds, body } => {
1079            for (_, e) in binds { validate_prefixes(e, bindings)?; }
1080            validate_prefixes(body, bindings)
1081        }
1082        Expr::Range(a, b) => {
1083            validate_prefixes(a, bindings)?;
1084            validate_prefixes(b, bindings)
1085        }
1086        Expr::Sequence(items) => {
1087            for e in items { validate_prefixes(e, bindings)?; }
1088            Ok(())
1089        }
1090        Expr::Quantified { bindings: binds, test, .. } => {
1091            for (_, e) in binds { validate_prefixes(e, bindings)?; }
1092            validate_prefixes(test, bindings)
1093        }
1094        Expr::IDiv(a, b) | Expr::Intersect(a, b) | Expr::Except(a, b) => {
1095            validate_prefixes(a, bindings)?;
1096            validate_prefixes(b, bindings)
1097        }
1098        Expr::InstanceOf(a, _) | Expr::CastAs(a, _)
1099        | Expr::CastableAs(a, _) | Expr::TreatAs(a, _) => validate_prefixes(a, bindings),
1100        Expr::TryCatch { body, catches } => {
1101            validate_prefixes(body, bindings)?;
1102            for c in catches { validate_prefixes(&c.body, bindings)?; }
1103            Ok(())
1104        }
1105        Expr::WithDefaultCollation(_, inner) => validate_prefixes(inner, bindings),
1106        Expr::BackwardsCompat(inner) => validate_prefixes(inner, bindings),
1107        Expr::MapConstructor(entries) => {
1108            for (k, v) in entries {
1109                validate_prefixes(k, bindings)?;
1110                validate_prefixes(v, bindings)?;
1111            }
1112            Ok(())
1113        }
1114        Expr::ArrayConstructor { members, .. } => {
1115            for m in members { validate_prefixes(m, bindings)?; }
1116            Ok(())
1117        }
1118        Expr::Lookup(base, key) => {
1119            validate_prefixes(base, bindings)?;
1120            validate_lookup_key_prefixes(key, bindings)
1121        }
1122        Expr::UnaryLookup(key) => validate_lookup_key_prefixes(key, bindings),
1123        Expr::InlineFunction { body, .. } => validate_prefixes(body, bindings),
1124        Expr::DynamicCall { func, args } => {
1125            validate_prefixes(func, bindings)?;
1126            for a in args { validate_prefixes(a, bindings)?; }
1127            Ok(())
1128        }
1129        Expr::NamedFunctionRef { .. } | Expr::Placeholder | Expr::ContextItem => Ok(()),
1130    }
1131}
1132
1133fn validate_lookup_key_prefixes(
1134    key: &crate::xpath::ast::LookupKey, bindings: &dyn XPathBindings,
1135) -> Result<()> {
1136    if let crate::xpath::ast::LookupKey::Expr(e) = key {
1137        validate_prefixes(e, bindings)?;
1138    }
1139    Ok(())
1140}
1141
1142fn validate_steps(steps: &[Step], bindings: &dyn XPathBindings) -> Result<()> {
1143    for step in steps {
1144        if let NodeTest::QName(prefix, _) | NodeTest::PrefixWildcard(prefix) = &step.node_test
1145            && resolve_prefix_or_implicit(bindings, prefix).is_none()
1146        {
1147            return Err(xpath_err(format!(
1148                "Undefined namespace prefix: {prefix}"
1149            )));
1150        }
1151        for pred in &step.predicates {
1152            validate_prefixes(pred, bindings)?;
1153        }
1154    }
1155    Ok(())
1156}
1157
1158// ── public entry point ────────────────────────────────────────────────────────
1159
1160/// Convenience for callers that just need the boolean value of an
1161/// expression evaluated against a fresh context (context node passed
1162/// in, position 1 of 1, no libxml2-compat tweaks).  Used by the XSD
1163/// 1.1 assertion evaluator — see [`crate::xsd`].
1164pub fn eval_to_bool<I: DocIndexLike>(
1165    expr:          &Expr,
1166    idx:           &I,
1167    context_node:  NodeId,
1168    bindings:      &dyn XPathBindings,
1169) -> Result<bool> {
1170    let static_ctx = StaticContext {
1171        xpath_2_0: bindings.xpath_version_2_or_later(),
1172        xpath_3_0: false,
1173        libxml2_compatible: false,
1174        current_node: Some(context_node),
1175    };
1176    let ctx = EvalCtx {
1177        context_node,
1178        pos: 1,
1179        size: 1,
1180        bindings,
1181        static_ctx: &static_ctx,
1182    };
1183    let v = eval_expr(expr, &ctx, idx)?;
1184    Ok(value_to_bool(&v, idx))
1185}
1186
1187pub fn eval_expr<I: DocIndexLike>(expr: &Expr, ctx: &EvalCtx<'_>, idx: &I) -> Result<Value> {
1188    // Charge once per AST node evaluation.  This covers the
1189    // AST-traversal axis of cost (deep / wide expressions) and
1190    // every recursive sub-expression a predicate may build.
1191    charge_eval_step()?;
1192    match expr {
1193        // `.` as a primary expression yields the current context item —
1194        // its actual value (which may be a function item), falling back to
1195        // the context node when no non-node context item is set.
1196        Expr::ContextItem => Ok(current_context_item()
1197            .unwrap_or_else(|| Value::NodeSet(vec![ctx.context_node]))),
1198        Expr::Or(l, r) => {
1199            if value_to_bool(&eval_expr(l, ctx, idx)?, idx) {
1200                return Ok(Value::Boolean(true));
1201            }
1202            Ok(Value::Boolean(value_to_bool(&eval_expr(r, ctx, idx)?, idx)))
1203        }
1204        Expr::And(l, r) => {
1205            if !value_to_bool(&eval_expr(l, ctx, idx)?, idx) {
1206                return Ok(Value::Boolean(false));
1207            }
1208            Ok(Value::Boolean(value_to_bool(&eval_expr(r, ctx, idx)?, idx)))
1209        }
1210        Expr::Eq(l, r) => {
1211            let lv = eval_expr(l, ctx, idx)?;
1212            let rv = eval_expr(r, ctx, idx)?;
1213            reject_string_vs_numeric_cmp_2_0(&lv, &rv, ctx, "=")?;
1214            Ok(Value::Boolean(values_eq(&lv, &rv, idx, ctx.bindings)))
1215        }
1216        Expr::Ne(l, r) => {
1217            let lv = eval_expr(l, ctx, idx)?;
1218            let rv = eval_expr(r, ctx, idx)?;
1219            reject_string_vs_numeric_cmp_2_0(&lv, &rv, ctx, "!=")?;
1220            Ok(Value::Boolean(values_ne(&lv, &rv, idx, ctx.bindings)))
1221        }
1222        Expr::Lt(l, r) => cmp_op(l, r, ctx, idx, |a, b| a < b),
1223        Expr::Gt(l, r) => cmp_op(l, r, ctx, idx, |a, b| a > b),
1224        Expr::Le(l, r) => cmp_op(l, r, ctx, idx, |a, b| a <= b),
1225        Expr::Ge(l, r) => cmp_op(l, r, ctx, idx, |a, b| a >= b),
1226        // XPath 2.0 §3.5.1 value comparison operators.  Operands
1227        // must atomise to at most one item each; an empty sequence
1228        // makes the result an empty sequence.
1229        Expr::ValueEq(l, r) => value_compare(l, r, ctx, idx, ValueCmp::Eq),
1230        Expr::ValueNe(l, r) => value_compare(l, r, ctx, idx, ValueCmp::Ne),
1231        Expr::ValueLt(l, r) => value_compare(l, r, ctx, idx, ValueCmp::Lt),
1232        Expr::ValueGt(l, r) => value_compare(l, r, ctx, idx, ValueCmp::Gt),
1233        Expr::ValueLe(l, r) => value_compare(l, r, ctx, idx, ValueCmp::Le),
1234        Expr::ValueGe(l, r) => value_compare(l, r, ctx, idx, ValueCmp::Ge),
1235        Expr::Add(l, r) => {
1236            // XPath 2.0 §10.4 / §10.5 date+duration / duration+duration
1237            // dispatch on typed operands; fall through to numeric.
1238            let lv = eval_expr(l, ctx, idx)?;
1239            let rv = eval_expr(r, ctx, idx)?;
1240            if let Some(v) = date_arith_add(&lv, &rv) { return Ok(v); }
1241            if arith_empty_2_0(&lv, &rv, ctx) { return Ok(Value::NodeSet(Vec::new())); }
1242            reject_string_arith_2_0(&lv, &rv, ctx, "+")?;
1243            Ok(compat_numeric_op(&lv, &rv, idx, ctx.bindings, NumericOp::Add))
1244        }
1245        Expr::Sub(l, r) => {
1246            let lv = eval_expr(l, ctx, idx)?;
1247            let rv = eval_expr(r, ctx, idx)?;
1248            if let Some(v) = date_arith_sub(&lv, &rv) { return Ok(v); }
1249            if arith_empty_2_0(&lv, &rv, ctx) { return Ok(Value::NodeSet(Vec::new())); }
1250            reject_string_arith_2_0(&lv, &rv, ctx, "-")?;
1251            Ok(compat_numeric_op(&lv, &rv, idx, ctx.bindings, NumericOp::Sub))
1252        }
1253        Expr::Mul(l, r) => {
1254            let lv = eval_expr(l, ctx, idx)?;
1255            let rv = eval_expr(r, ctx, idx)?;
1256            // XPath 2.0 §10.6.1 — `duration * number` /
1257            // `number * duration` scales the duration.  Pre-empt
1258            // the typed_numeric_op coercion (which would turn the
1259            // duration into NaN) when one side is a duration and
1260            // the other is a numeric.
1261            if let Some(v) = duration_mul(&lv, &rv, idx, ctx.bindings) { return Ok(v); }
1262            if arith_empty_2_0(&lv, &rv, ctx) { return Ok(Value::NodeSet(Vec::new())); }
1263            reject_string_arith_2_0(&lv, &rv, ctx, "*")?;
1264            Ok(compat_numeric_op(&lv, &rv, idx, ctx.bindings, NumericOp::Mul))
1265        }
1266        Expr::Div(l, r) => {
1267            let lv = eval_expr(l, ctx, idx)?;
1268            let rv = eval_expr(r, ctx, idx)?;
1269            // XPath 2.0 §10.6.2 — `duration div number` /
1270            // `duration div duration` (latter returns a number).
1271            if let Some(v) = duration_div(&lv, &rv, idx, ctx.bindings) { return Ok(v); }
1272            if arith_empty_2_0(&lv, &rv, ctx) { return Ok(Value::NodeSet(Vec::new())); }
1273            if integer_decimal_zero_divisor(&lv, &rv, idx, ctx.bindings) {
1274                return Err(xpath_err("division by zero").with_xpath_code("FOAR0001"));
1275            }
1276            reject_string_arith_2_0(&lv, &rv, ctx, "div")?;
1277            Ok(compat_numeric_op(&lv, &rv, idx, ctx.bindings, NumericOp::Div))
1278        }
1279        Expr::Mod(l, r) => {
1280            let lv = eval_expr(l, ctx, idx)?;
1281            let rv = eval_expr(r, ctx, idx)?;
1282            if arith_empty_2_0(&lv, &rv, ctx) { return Ok(Value::NodeSet(Vec::new())); }
1283            if integer_decimal_zero_divisor(&lv, &rv, idx, ctx.bindings) {
1284                return Err(xpath_err("modulo by zero").with_xpath_code("FOAR0001"));
1285            }
1286            Ok(compat_numeric_op(&lv, &rv, idx, ctx.bindings, NumericOp::Mod))
1287        }
1288        Expr::Neg(inner) => {
1289            // Unary minus keeps the operand's numeric type (XPath 2.0
1290            // §6.2.1 op:numeric-unary-minus) — `-17.5` is xs:decimal,
1291            // `-5` is xs:integer — so `instance of` and stringify stay
1292            // correct.  In XPath 1.0 backwards-compatibility mode the
1293            // operand is atomised to xs:double first, so `-0` is the
1294            // double -0.0 (which carries a sign, unlike integer 0).
1295            let v = eval_expr(inner, ctx, idx)?;
1296            if in_xpath_1_0_compat() {
1297                let n = -value_to_number_with(&v, idx, ctx.bindings);
1298                return Ok(Value::Number(Numeric::Double(n)));
1299            }
1300            // Negate exactly when the operand is integer or decimal —
1301            // `-5.2` must stay xs:decimal `-5.2`, not slip through f64
1302            // and drip precision noise into a subsequent `mod`.  An
1303            // i64::MIN integer has no positive counterpart, so widen
1304            // to xs:decimal on overflow rather than panicking.  Typed
1305            // integer-family values stay integer (xs:int / xs:short /
1306            // …) — only literal `xs:decimal` keeps the Decimal kind.
1307            if let Value::Number(Numeric::Integer(i)) = v {
1308                return Ok(match i.checked_neg() {
1309                    Some(n) => Value::Number(Numeric::Integer(n)),
1310                    None    => Value::Number(Numeric::Decimal(-rust_decimal::Decimal::from(i))),
1311                });
1312            }
1313            if matches!(numeric_kind_of(&v), Some("integer")) {
1314                let f = -value_to_number_with(&v, idx, ctx.bindings);
1315                return Ok(preserve_numeric_kind(&v, f));
1316            }
1317            if let Some(d) = exact_decimal(&v) {
1318                return Ok(Value::Number(Numeric::Decimal(-d)));
1319            }
1320            let n = -value_to_number_with(&v, idx, ctx.bindings);
1321            Ok(preserve_numeric_kind(&v, n))
1322        }
1323        Expr::Union(l, r) => {
1324            let lv = eval_expr(l, ctx, idx)?;
1325            let rv = eval_expr(r, ctx, idx)?;
1326            match (lv, rv) {
1327                (Value::NodeSet(mut a), Value::NodeSet(b)) => {
1328                    a.extend(b);
1329                    dedup_sort(&mut a);
1330                    Ok(Value::NodeSet(a))
1331                }
1332                (Value::ForeignNodeSet(mut a), Value::ForeignNodeSet(b)) => {
1333                    a.extend(b);
1334                    dedup_foreign(&mut a);
1335                    Ok(Value::ForeignNodeSet(a))
1336                }
1337                // Mixed primary+foreign union — XSLT allows it but we
1338                // can't represent both kinds in one Value variant here.
1339                // The lxml tests we target don't exercise this; surface
1340                // a clear error rather than miscompile.
1341                (Value::NodeSet(_), Value::ForeignNodeSet(_))
1342                | (Value::ForeignNodeSet(_), Value::NodeSet(_)) => Err(xpath_err(
1343                    "mixed primary/foreign node-set union is not supported",
1344                )),
1345                _ => Err(xpath_err("union operator requires node-sets on both sides")),
1346            }
1347        }
1348        Expr::Path(path) => {
1349            // Bare `.` short-circuits to the atomic context item
1350            // when one is in scope (set by predicate filtering /
1351            // simple-map over an atomic sequence).  Without this,
1352            // a predicate like `($strings)[matches(., '\w')]`
1353            // would resolve `.` to the outer context node and ask
1354            // matches() about *its* string-value, not each item's.
1355            if is_bare_dot_path(path) {
1356                if let Some(v) = current_context_item() {
1357                    return Ok(v);
1358                }
1359            }
1360            let nodes = eval_path(path, ctx.context_node, idx, ctx.bindings, ctx.static_ctx.libxml2_compatible, ctx.static_ctx.current_node)?;
1361            Ok(Value::NodeSet(nodes))
1362        }
1363        Expr::FilterPath { primary, predicates, steps } => {
1364            let pv = eval_expr(primary, ctx, idx)?;
1365            match pv {
1366                Value::NodeSet(ns) => {
1367                    let mut nodes =
1368                        apply_predicates_cur(ns, predicates, idx, ctx.bindings, ctx.static_ctx.libxml2_compatible, ctx.static_ctx.current_node)?;
1369                    for step in steps {
1370                        nodes = eval_step_on_nodes_cur(nodes, step, idx, ctx.bindings, ctx.static_ctx.libxml2_compatible, ctx.static_ctx.current_node)?;
1371                    }
1372                    Ok(Value::NodeSet(nodes))
1373                }
1374                Value::ForeignNodeSet(ns) => {
1375                    // Foreign-doc path: hand the predicates+steps to
1376                    // the bindings, which knows how to look up the
1377                    // foreign doc's `DocIndex` and run the engine's
1378                    // step/predicate helpers against it.
1379                    match ctx.bindings.apply_foreign_path(&ns, predicates, steps) {
1380                        Some(r) => r.map(Value::ForeignNodeSet),
1381                        None => Err(xpath_err(
1382                            "foreign-doc path traversal not supported by bindings",
1383                        )),
1384                    }
1385                }
1386                // XPath 2.0 §3.5 — a FilterExpr applies its
1387                // predicates to any sequence.  For each item, the
1388                // predicate is evaluated with `position()` /
1389                // `last()` reflecting the sequence position, and the
1390                // item is kept when the predicate's value is a
1391                // number equal to the position OR otherwise its
1392                // EBV is true.
1393                Value::Sequence(items) if steps.is_empty() => {
1394                    let kept = filter_sequence_by_predicates(
1395                        items, predicates, ctx, idx,
1396                    )?;
1397                    if kept.len() == 1 {
1398                        Ok(kept.into_iter().next().unwrap())
1399                    } else {
1400                        Ok(Value::Sequence(kept))
1401                    }
1402                }
1403                // Singleton atomic with a predicate behaves like
1404                // a one-item sequence — same number/EBV rule.
1405                other if steps.is_empty() => {
1406                    let kept = filter_sequence_by_predicates(
1407                        vec![other], predicates, ctx, idx,
1408                    )?;
1409                    match kept.len() {
1410                        0 => Ok(Value::NodeSet(Vec::new())),
1411                        1 => Ok(kept.into_iter().next().unwrap()),
1412                        _ => Ok(Value::Sequence(kept)),
1413                    }
1414                }
1415                // XPath 2.0 §3.5 — `seq[pred]/step` filters the
1416                // sequence, then runs the steps from each surviving
1417                // *node* item.  Atomic items can't carry forward
1418                // axes, so they drop out at the path-step boundary.
1419                Value::Sequence(items) => {
1420                    let kept = filter_sequence_by_predicates(
1421                        items, predicates, ctx, idx,
1422                    )?;
1423                    let mut nodes: Vec<NodeId> = Vec::new();
1424                    for it in kept {
1425                        if let Value::NodeSet(ns) = it {
1426                            nodes.extend(ns);
1427                        }
1428                    }
1429                    nodes.sort_unstable();
1430                    nodes.dedup();
1431                    for step in steps {
1432                        nodes = eval_step_on_nodes_cur(nodes, step, idx, ctx.bindings, ctx.static_ctx.libxml2_compatible, ctx.static_ctx.current_node)?;
1433                    }
1434                    Ok(Value::NodeSet(nodes))
1435                }
1436                _ => Err(xpath_err("predicate applied to non-node-set")),
1437            }
1438        }
1439        Expr::FunctionCall(name, args) => eval_function(name, args, ctx, idx),
1440        Expr::Variable(name) => {
1441            // Consult user-supplied variable bindings first; XPath 1.0
1442            // proper says an undefined variable is an error.
1443            match ctx.bindings.variable(name) {
1444                Some(v) => Ok(v),
1445                None => Err(xpath_err(format!("undefined XPath variable: ${name}"))),
1446            }
1447        }
1448        Expr::Literal(s) => Ok(Value::String(s.clone())),
1449        // An integer / decimal literal carries its XSD type in XPath
1450        // 2.0 so `instance of` and arithmetic promotion see it; XPath
1451        // 1.0 has only the `number` (double) type, so it stays Double.
1452        Expr::Integer(i) => Ok(Value::Number(if ctx.static_ctx.xpath_2_0 {
1453            Numeric::Integer(*i)
1454        } else {
1455            Numeric::Double(*i as f64)
1456        })),
1457        Expr::Decimal(n) => Ok(Value::Number(if ctx.static_ctx.xpath_2_0 {
1458            Numeric::Decimal(*n)
1459        } else {
1460            // XPath 1.0 has only the `number` type (double); flatten
1461            // the exact decimal back to f64 for legacy stylesheets.
1462            use rust_decimal::prelude::ToPrimitive;
1463            Numeric::Double(n.to_f64().unwrap_or(f64::NAN))
1464        })),
1465        // A numeric literal with an exponent is xs:double (XPath 2.0
1466        // §3.1.1).  The Double kind drives the F&O scientific string
1467        // form in a 2.0 host; a 1.0 host stringifies it as a decimal.
1468        Expr::Double(n) => Ok(Value::Number(Numeric::Double(*n))),
1469        Expr::IfThenElse { cond, then_branch, else_branch } => {
1470            // XPath 2.0 § 3.8 — exactly one branch is evaluated.
1471            let truth = value_to_bool(&eval_expr(cond, ctx, idx)?, idx);
1472            let branch = if truth { then_branch } else { else_branch };
1473            eval_expr(branch, ctx, idx)
1474        }
1475        Expr::IDiv(l, r) => {
1476            // XPath 2.0 § 3.4 `idiv` — integer quotient, truncation
1477            // towards zero.  Division by zero raises an error
1478            // (matches the spec's err:FOAR0001).
1479            let a = value_to_number(&eval_expr(l, ctx, idx)?, idx);
1480            let b = value_to_number(&eval_expr(r, ctx, idx)?, idx);
1481            if b == 0.0 || b.is_nan() || a.is_nan() {
1482                return Err(xpath_err("idiv: division by zero or NaN")
1483                    .with_xpath_code("FOAR0001"));
1484            }
1485            // XPath 2.0 §3.4 — the result of `idiv` is always xs:integer.
1486            Ok(integer_result((a / b).trunc() as i64, ctx.bindings))
1487        }
1488        Expr::Intersect(l, r) => {
1489            // Node-set intersection (XPath 2.0 § 3.3.4).  Non-NodeSet
1490            // operands are an error in 2.0 (FORG0006).  We're lenient
1491            // and treat empty/atomic operands as empty node-sets.
1492            let lns = node_set_of(eval_expr(l, ctx, idx)?);
1493            let rns = node_set_of(eval_expr(r, ctx, idx)?);
1494            let rset: std::collections::BTreeSet<NodeId> = rns.into_iter().collect();
1495            let mut out: Vec<NodeId> = lns.into_iter().filter(|n| rset.contains(n)).collect();
1496            out.sort_unstable();
1497            out.dedup();
1498            Ok(Value::NodeSet(out))
1499        }
1500        Expr::Except(l, r) => {
1501            // `lhs except rhs` — items in lhs not in rhs.
1502            let lns = node_set_of(eval_expr(l, ctx, idx)?);
1503            let rns = node_set_of(eval_expr(r, ctx, idx)?);
1504            let rset: std::collections::BTreeSet<NodeId> = rns.into_iter().collect();
1505            let mut out: Vec<NodeId> = lns.into_iter().filter(|n| !rset.contains(n)).collect();
1506            out.sort_unstable();
1507            out.dedup();
1508            Ok(Value::NodeSet(out))
1509        }
1510        Expr::InstanceOf(inner, st) => {
1511            let v = eval_expr(inner, ctx, idx)?;
1512            let st = resolve_kind_test_namespaces(st, ctx.bindings);
1513            // Schema-aware: a user-defined (schema) target type keeps its
1514            // `prefix:local` name; `instance of` is decided by the value's
1515            // *declared* type, not its lexical space.
1516            if let crate::xpath::ast::ItemType::Atomic(name) = &st.item {
1517                // Resolve the target as a schema (user) type: a prefixed
1518                // name through the in-scope namespaces; an unprefixed
1519                // name that isn't a built-in atomic as a no-namespace
1520                // schema type (a `targetNamespace`-less schema's
1521                // `instance of nota`).
1522                let target = match name.split_once(':') {
1523                    Some((prefix, local)) => resolve_prefix_or_implicit(ctx.bindings, prefix)
1524                        .map(|uri| (uri, local)),
1525                    None if atomic_kind_static(name).is_none() =>
1526                        Some((String::new(), name.as_str())),
1527                    None => None,
1528                };
1529                if let Some((uri, local)) = target {
1530                    let value_type = match &v {
1531                        Value::Typed(t) => t.user_type.as_deref()
1532                            .map(|(ns, l)| (ns.as_str(), l.as_str())),
1533                        _ => None,
1534                    };
1535                    // A known schema type with a typed value answers
1536                    // directly.
1537                    if let Some(matches_type) =
1538                        ctx.bindings.instance_of_user_type(&uri, local, value_type)
1539                    {
1540                        let ok = match sequence_len(&v) {
1541                            0 => matches!(st.occurrence,
1542                                crate::xpath::ast::Occurrence::Optional
1543                                | crate::xpath::ast::Occurrence::ZeroOrMore),
1544                            1 => matches_type,
1545                            _ => false,
1546                        };
1547                        return Ok(Value::Boolean(ok));
1548                    }
1549                    // XPath 2.0 §2.5.4.2 / XPST0051 — a type name resolving
1550                    // to a non-XSD namespace (or no namespace) that isn't a
1551                    // declared schema type is unknown.  (XSD-namespace names
1552                    // are built-ins handled by `value_matches`; this fires
1553                    // only for genuine user-type references with no
1554                    // in-scope schema definition.)
1555                    const XSD_NS: &str = "http://www.w3.org/2001/XMLSchema";
1556                    if uri != XSD_NS && !ctx.bindings.schema_type_exists(&uri, local) {
1557                        return Err(xpath_err(format!(
1558                            "instance of: type {name} is not a known schema type \
1559                             (XPST0051)")).with_xpath_code("XPST0051"));
1560                    }
1561                }
1562            }
1563            Ok(Value::Boolean(value_matches_sequence_type(&v, &st, idx)))
1564        }
1565        Expr::CastAs(inner, st) => {
1566            // XPath 2.0 §3.10.2 — `cast as T` converts the input to
1567            // the target type via the XSD casting rules.  Unlike
1568            // `treat as`, it doesn't assert the input ALREADY matches
1569            // T; it performs the conversion.  T must be a generalised
1570            // atomic type — xs:anyType, xs:anySimpleType, xs:untyped,
1571            // and xs:anyAtomicType are XPST0080 (anyAtomicType) or
1572            // XPST0051 (the non-atomic schema types).
1573            if let crate::xpath::ast::ItemType::Atomic(name) = &st.item {
1574                match name.as_str() {
1575                    "anyType" | "anySimpleType" | "untyped" =>
1576                        return Err(xpath_err(format!(
1577                            "cast as: target type xs:{name} is not an atomic \
1578                             type (XPST0051)"))),
1579                    "anyAtomicType" | "NOTATION" =>
1580                        return Err(xpath_err(format!(
1581                            "cast as: xs:{name} is not a permitted target \
1582                             type (XPST0080)"))),
1583                    _ => {}
1584                }
1585            }
1586            let v = eval_expr(inner, ctx, idx)?;
1587            cast_value_to_atomic(&v, st, idx)
1588        }
1589        Expr::TreatAs(inner, st) => {
1590            // XPath 2.0 §3.10.3 — `treat as T` is "assert + identity":
1591            // the input must already match T, otherwise it's a type
1592            // error (XPDY0050).
1593            let v = eval_expr(inner, ctx, idx)?;
1594            let st = resolve_kind_test_namespaces(st, ctx.bindings);
1595            if !value_matches_sequence_type(&v, &st, idx) {
1596                return Err(xpath_err(format!(
1597                    "treat as failed: value doesn't match {st:?}"
1598                )));
1599            }
1600            Ok(v)
1601        }
1602        Expr::WithDefaultCollation(uri, inner) => {
1603            with_default_collation(Some(uri.clone()), ||
1604                eval_expr(inner, ctx, idx))
1605        }
1606        Expr::BackwardsCompat(inner) => {
1607            with_xpath_1_0_compat(|| eval_expr(inner, ctx, idx))
1608        }
1609        Expr::MapConstructor(entries) => {
1610            let mut out: Vec<(Value, Value)> = Vec::with_capacity(entries.len());
1611            for (ke, ve) in entries {
1612                let key = eval_expr(ke, ctx, idx)?;
1613                // The key must be a single atomic value (XPTY0004
1614                // otherwise); take the first item leniently.
1615                let key = first_atomic_key(&key, idx);
1616                let val = eval_expr(ve, ctx, idx)?;
1617                if out.iter().any(|(k, _)| map_key_eq(k, &key, idx)) {
1618                    return Err(xpath_err(
1619                        "duplicate key in map constructor (XQDY0137)"));
1620                }
1621                out.push((key, val));
1622            }
1623            Ok(Value::Map(Box::new(out)))
1624        }
1625        Expr::ArrayConstructor { members, square } => {
1626            if *square {
1627                // One member per expression; each member is that
1628                // expression's value (a sequence).
1629                let mut out = Vec::with_capacity(members.len());
1630                for m in members { out.push(eval_expr(m, ctx, idx)?); }
1631                Ok(Value::Array(Box::new(out)))
1632            } else {
1633                // Curly form: each item of the contained sequence is
1634                // its own (singleton) member.
1635                let v = match members.first() {
1636                    Some(e) => eval_expr(e, ctx, idx)?,
1637                    None    => return Ok(Value::Array(Box::new(Vec::new()))),
1638                };
1639                Ok(Value::Array(Box::new(items_of(&v))))
1640            }
1641        }
1642        Expr::Lookup(base, key) => {
1643            let b = eval_expr(base, ctx, idx)?;
1644            eval_lookup(&b, key, ctx, idx)
1645        }
1646        Expr::UnaryLookup(key) => {
1647            // `?K` applies to the context item.
1648            let ctx_item = current_context_item()
1649                .unwrap_or(Value::NodeSet(vec![ctx.context_node]));
1650            eval_lookup(&ctx_item, key, ctx, idx)
1651        }
1652        Expr::InlineFunction { params, sig, body } => {
1653            // Capture the free variables of the body (minus the
1654            // parameters) from the defining scope — static scoping.
1655            let mut refs = Vec::new();
1656            collect_var_refs(body, &mut refs);
1657            let mut closure = Vec::new();
1658            for name in refs {
1659                if params.iter().any(|p| p == &name) { continue; }
1660                if let Some(v) = ctx.bindings.variable(&name) {
1661                    closure.push((name, v));
1662                }
1663            }
1664            Ok(Value::Function(Box::new(FunctionItem::Inline {
1665                params: params.clone(),
1666                sig: sig.clone(),
1667                body: (**body).clone(),
1668                closure,
1669            })))
1670        }
1671        Expr::NamedFunctionRef { name, arity } => {
1672            let ns = named_function_namespace(name, ctx.bindings);
1673            let local = name.rsplit(':').next().unwrap_or(name);
1674            let sig = ctx.bindings
1675                .function_signature_in(&ns, local, *arity)
1676                .map(Box::new);
1677            Ok(Value::Function(Box::new(FunctionItem::Named {
1678                name: name.clone(), ns, arity: *arity, sig,
1679            })))
1680        }
1681        Expr::DynamicCall { func, args } => {
1682            let f = eval_expr(func, ctx, idx)?;
1683            // XPath 3.1 §3.1.5 — maps and arrays are function items:
1684            // `$map(key)` looks the key up (empty sequence if absent);
1685            // `$array(n)` indexes the array (1-based).
1686            if args.len() == 1 && !matches!(args[0], Expr::Placeholder) {
1687                match &f {
1688                    Value::Map(m) => {
1689                        let key = first_atomic_key(&eval_expr(&args[0], ctx, idx)?, idx);
1690                        return Ok(m.iter().find(|(k, _)| map_key_eq(k, &key, idx))
1691                            .map(|(_, v)| v.clone())
1692                            .unwrap_or(Value::NodeSet(Vec::new())));
1693                    }
1694                    Value::Array(a) => {
1695                        let n = value_to_number(&eval_expr(&args[0], ctx, idx)?, idx) as i64;
1696                        if n < 1 || n as usize > a.len() {
1697                            return Err(xpath_err(format!(
1698                                "array index {n} is out of bounds (FOAY0001)"))
1699                                .with_xpath_code("FOAY0001"));
1700                        }
1701                        return Ok(a[(n - 1) as usize].clone());
1702                    }
1703                    _ => {}
1704                }
1705            }
1706            let fi = match &f {
1707                Value::Function(fi) => (**fi).clone(),
1708                _ => return Err(xpath_err(
1709                    "dynamic call target is not a function item (XPTY0004)")),
1710            };
1711            // Partial application: `?` placeholders leave unbound slots.
1712            if args.iter().any(|a| matches!(a, Expr::Placeholder)) {
1713                let mut bound = Vec::with_capacity(args.len());
1714                for a in args {
1715                    bound.push(match a {
1716                        Expr::Placeholder => None,
1717                        e => Some(eval_expr(e, ctx, idx)?),
1718                    });
1719                }
1720                return Ok(Value::Function(Box::new(
1721                    FunctionItem::Partial { base: Box::new(fi), bound })));
1722            }
1723            let argv: Vec<Value> = args.iter()
1724                .map(|a| eval_expr(a, ctx, idx)).collect::<Result<_>>()?;
1725            call_function_item(&fi, argv, ctx, idx)
1726        }
1727        Expr::Placeholder => Err(xpath_err(
1728            "'?' placeholder is only valid as a function-call argument")),
1729        Expr::CastableAs(inner, st) => {
1730            // XPath 2.0 §3.12.3: `castable as` reports whether the
1731            // cast would succeed — it doesn't propagate the typed
1732            // SequenceType cardinality check.  Skipping the
1733            // strict `value_matches_sequence_type` gate keeps the
1734            // semantics: a `Value::String("5")` *is* castable as
1735            // `xs:integer`, even though our untyped value model
1736            // doesn't initially classify it as one.
1737            let v = eval_expr(inner, ctx, idx)?;
1738            // Schema-aware: a user-defined (schema) target type keeps its
1739            // `prefix:local` name — resolve the prefix and validate the
1740            // value against the imported schema's simple type.
1741            if let crate::xpath::ast::ItemType::Atomic(name) = &st.item {
1742                if let Some((prefix, local)) = name.split_once(':') {
1743                    if let Some(uri) = resolve_prefix_or_implicit(ctx.bindings, prefix) {
1744                        // `castable as` requires a single atomic operand: an
1745                        // empty sequence is castable only to an optional
1746                        // target (`T?`), and a sequence of two or more items
1747                        // is never castable to a single type.
1748                        match sequence_len(&v) {
1749                            0 => return Ok(Value::Boolean(matches!(st.occurrence,
1750                                crate::xpath::ast::Occurrence::Optional
1751                                | crate::xpath::ast::Occurrence::ZeroOrMore))),
1752                            1 => {}
1753                            _ => return Ok(Value::Boolean(false)),
1754                        }
1755                        // The atomic type a typed source carries drives the
1756                        // XSD cast-compatibility check; an untyped / string /
1757                        // node source (`None`) just validates the lexical.
1758                        let source_kind = match &v {
1759                            Value::Typed(t) => Some(t.kind),
1760                            Value::Number(n) => Some(n.kind()),
1761                            Value::Boolean(_) => Some("boolean"),
1762                            _ => None,
1763                        };
1764                        let s = value_to_string(&v, idx);
1765                        if let Some(ok) =
1766                            ctx.bindings.castable_as_user_type(&uri, local, &s, source_kind)
1767                        {
1768                            return Ok(Value::Boolean(ok));
1769                        }
1770                    }
1771                }
1772            }
1773            Ok(Value::Boolean(cast_value_to_atomic(&v, st, idx).is_ok()))
1774        }
1775        Expr::TryCatch { body, catches } => {
1776            // XPath 3.1 §3.16 — evaluate the body; if it raises a
1777            // dynamic error, hand off to the first catch whose
1778            // name-test list covers the caught error's QName.
1779            // Inside the catch body we layer a `ScopedBindings` so
1780            // `$err:code` / `$err:description` lookups resolve
1781            // without polluting the surrounding variable scope.
1782            match eval_expr(body, ctx, idx) {
1783                Ok(v) => Ok(v),
1784                Err(e) => {
1785                    // The caught error's spec code (e.g. FOAR0001) if it
1786                    // carries one, else the generic err:FOER0000 — same
1787                    // projection the XSLT instruction uses.
1788                    let code_local = e.xpath_code.clone()
1789                        .unwrap_or_else(|| "FOER0000".to_string());
1790                    let err_uri    = "http://www.w3.org/2005/xqt-errors";
1791                    for c in catches {
1792                        if !xpath_catch_matches(&c.matchers, err_uri, &code_local, ctx.bindings) {
1793                            continue;
1794                        }
1795                        let scoped = build_err_scope(ctx.bindings, err_uri, &code_local, &e.message);
1796                        let inner_ctx = EvalCtx {
1797                            context_node: ctx.context_node,
1798                            pos: ctx.pos, size: ctx.size,
1799                            bindings: &scoped,
1800                            static_ctx: ctx.static_ctx,
1801                        };
1802                        return eval_expr(&c.body, &inner_ctx, idx);
1803                    }
1804                    Err(e)
1805                }
1806            }
1807        }
1808        Expr::NodeBefore(l, r) | Expr::NodeAfter(l, r) => {
1809            // XPath 2.0 §3.5.3 — node-comparison operators.  Each
1810            // operand must atomise to a single node; empty operand
1811            // yields the empty sequence.  Order follows the
1812            // index's node-id (which mirrors document order
1813            // for nodes from the same tree).
1814            let after = matches!(expr, Expr::NodeAfter(_, _));
1815            let lv = eval_expr(l, ctx, idx)?;
1816            let rv = eval_expr(r, ctx, idx)?;
1817            let pick = |v: &Value| -> Option<NodeId> {
1818                match v {
1819                    Value::NodeSet(ns) if ns.len() == 1 => Some(ns[0]),
1820                    _ => None,
1821                }
1822            };
1823            let (Some(a), Some(b)) = (pick(&lv), pick(&rv)) else {
1824                return Ok(Value::NodeSet(Vec::new()));
1825            };
1826            Ok(Value::Boolean(if after { a > b } else { a < b }))
1827        }
1828        Expr::NodeIs(l, r) => {
1829            // XPath 2.0 §3.5.3 — node identity.  Each operand must
1830            // atomise to at most one node; an empty operand yields the
1831            // empty sequence.  Two nodes are `is`-equal iff they are
1832            // the same node (same index id; foreign nodes compare by
1833            // their pointer identity).
1834            let lv = eval_expr(l, ctx, idx)?;
1835            let rv = eval_expr(r, ctx, idx)?;
1836            let pick = |v: &Value| -> Option<NodeId> {
1837                match v {
1838                    Value::NodeSet(ns) if ns.len() == 1 => Some(ns[0]),
1839                    _ => None,
1840                }
1841            };
1842            let pick_foreign = |v: &Value| -> Option<ForeignNodePtr> {
1843                match v {
1844                    Value::ForeignNodeSet(fs) if fs.len() == 1 => Some(fs[0]),
1845                    _ => None,
1846                }
1847            };
1848            if let (Some(a), Some(b)) = (pick(&lv), pick(&rv)) {
1849                return Ok(Value::Boolean(a == b));
1850            }
1851            if let (Some(a), Some(b)) = (pick_foreign(&lv), pick_foreign(&rv)) {
1852                return Ok(Value::Boolean(a == b));
1853            }
1854            Ok(Value::NodeSet(Vec::new()))
1855        }
1856        Expr::SimpleMap(lhs, rhs) => {
1857            // XPath 3.0 §3.4 — evaluate `rhs` once per item of `lhs`
1858            // with that item as the context item, concatenating
1859            // results in iteration order (no document-order sort).
1860            // Iterate via [`iter_items`] so a 1.1M-item `IntRange`
1861            // doesn't materialise into a `Vec<Value>` up front;
1862            // [`sequence_len`] keeps `last()` correct without
1863            // expansion.
1864            let lv = eval_expr(lhs, ctx, idx)?;
1865            let total = sequence_len(&lv);
1866            let mut out_nodes: Vec<NodeId> = Vec::new();
1867            let mut out_seq: Vec<Value> = Vec::new();
1868            let mut any_atomic = false;
1869            for (i, item) in iter_items(&lv).enumerate() {
1870                // Same context-item plumbing as the predicate
1871                // filter: single-node items use the node; atomic
1872                // items go through the per-thread CONTEXT_ITEM
1873                // slot so bare `.` in `rhs` sees the value.
1874                let (cx, cit) = match &item {
1875                    Value::NodeSet(ns) if ns.len() == 1 => (ns[0], None),
1876                    _ => (ctx.context_node, Some(item.clone())),
1877                };
1878                let inner = EvalCtx {
1879                    context_node: cx, pos: i + 1, size: total,
1880                    bindings: ctx.bindings, static_ctx: ctx.static_ctx,
1881                };
1882                let v = with_context_item(cit, || eval_expr(rhs, &inner, idx))?;
1883                match v {
1884                    Value::NodeSet(ns)       => out_nodes.extend(ns),
1885                    Value::ForeignNodeSet(_) => {}
1886                    Value::Sequence(s)       => { any_atomic = true; out_seq.extend(s); }
1887                    other                    => { any_atomic = true; out_seq.push(other); }
1888                }
1889            }
1890            if !any_atomic {
1891                return Ok(Value::NodeSet(out_nodes));
1892            }
1893            // Atomics in the mix → produce Value::Sequence so the
1894            // typing of individual items survives.
1895            for id in out_nodes {
1896                out_seq.push(Value::NodeSet(vec![id]));
1897            }
1898            if out_seq.len() == 1 {
1899                Ok(out_seq.into_iter().next().unwrap())
1900            } else {
1901                Ok(Value::Sequence(out_seq))
1902            }
1903        }
1904        Expr::Range(lo, hi) => {
1905            // XPath 2.0 § 3.3.1 — `m to n` yields the empty sequence
1906            // when m > n, otherwise the integers m, m+1, …, n
1907            // (inclusive).  Returned as a lazy [`Value::IntRange`]
1908            // so the common big-range cases (Unicode codepoint
1909            // iteration, `for $i in 1 to N`) don't pay the cost of
1910            // pre-materialising N integer items.  Consumers that
1911            // need a concrete list expand on demand via
1912            // [`items_of`]; arithmetic consumers (`count`, `sum`)
1913            // special-case the variant for O(1).
1914            let m_v = eval_expr(lo, ctx, idx)?;
1915            let n_v = eval_expr(hi, ctx, idx)?;
1916            let m   = value_to_number(&m_v, idx).round() as i64;
1917            let n   = value_to_number(&n_v, idx).round() as i64;
1918            if m > n {
1919                return Ok(Value::NodeSet(Vec::new()));
1920            }
1921            Ok(Value::IntRange { lo: m, hi: n })
1922        }
1923        Expr::Sequence(items) => {
1924            // XPath 2.0 § 3.3.1 — parenthesised sequence literal.
1925            // Evaluate each item, then choose the most precise
1926            // result-Value shape that preserves the input's type
1927            // structure:
1928            //
1929            //   * No items                → `Value::NodeSet(vec![])`.
1930            //   * Single item             → return it (flatten).
1931            //   * Any item is `Typed`     → `Value::Sequence(items)`
1932            //     keeps the per-item type tags so downstream
1933            //     `instance of` / `subsequence` answer correctly.
1934            //   * All items are nodes     → `Value::NodeSet(union)`.
1935            //   * Otherwise               → existing NodeSet-of-
1936            //     synthetic-text encoding so XPath 1.0 consumers
1937            //     (`for-each`, `value-of`, …) work unchanged.
1938            let mut evaluated: Vec<Value> = Vec::with_capacity(items.len());
1939            for item in items {
1940                let v = eval_expr(item, ctx, idx)?;
1941                // Inner Sequence flattens per XPath 2.0 §3.3.1.
1942                match v {
1943                    Value::Sequence(inner) => evaluated.extend(inner),
1944                    other => evaluated.push(other),
1945                }
1946            }
1947            if evaluated.is_empty() {
1948                return Ok(Value::NodeSet(Vec::new()));
1949            }
1950            if evaluated.len() == 1 {
1951                return Ok(evaluated.into_iter().next().unwrap());
1952            }
1953            // XPath 2.0 §3.3.1 — a parenthesised sequence of items
1954            // is a `Value::Sequence` that preserves per-item type
1955            // identity.  We only fall back to the legacy NodeSet
1956            // shape when every item is itself a node — in that case
1957            // a flat NodeSet union is the right answer and keeps
1958            // XPath 1.0-shaped consumers happy.  `Value::IntRange` and
1959            // `Value::Typed` items also force a Sequence so their
1960            // lazy / type-tagged shape isn't flattened to a string.
1961            let any_node = evaluated.iter().any(|v|
1962                matches!(v, Value::NodeSet(_) | Value::ForeignNodeSet(_)));
1963            let any_lazy = evaluated.iter().any(|v|
1964                matches!(v, Value::Typed(_) | Value::IntRange { .. }));
1965            if !any_node || any_lazy {
1966                return Ok(Value::Sequence(evaluated));
1967            }
1968            // Legacy paths for backward compat with XPath 1.0 hot
1969            // routes that expect a NodeSet.
1970            let mut nodes: Vec<NodeId> = Vec::new();
1971            let mut atoms: Vec<String> = Vec::new();
1972            for v in evaluated {
1973                match v {
1974                    Value::NodeSet(ns)       => nodes.extend(ns),
1975                    Value::ForeignNodeSet(_) => {}
1976                    Value::String(s)         => atoms.push(s),
1977                    Value::Number(n)         => atoms.push(value_to_string(&Value::Number(n), idx)),
1978                    Value::Boolean(b)        => atoms.push((if b { "true" } else { "false" }).to_string()),
1979                    Value::Typed(t)          => atoms.push(t.lexical),
1980                    Value::Sequence(_)       => unreachable!(), // flattened above
1981                    Value::IntRange { .. }   => unreachable!(), // routed to Sequence above
1982                    // A map / array can't be a member of a node-set;
1983                    // drop it (this path builds a NodeSet result).
1984                    Value::Map(_) | Value::Array(_) | Value::Function(_) => {}
1985                }
1986            }
1987            if atoms.is_empty() {
1988                Ok(Value::NodeSet(nodes))
1989            } else {
1990                for n in &nodes { atoms.push(idx.string_value(*n)); }
1991                match idx.allocate_rtf_text_nodes(atoms.clone()) {
1992                    Some(ids) => Ok(Value::NodeSet(ids)),
1993                    None      => Ok(Value::String(atoms.join(""))),
1994                }
1995            }
1996        }
1997        Expr::Quantified { kind, bindings, test } => {
1998            // XPath 2.0 § 3.9 — `some $v in seq satisfies test` is
1999            // true iff at least one tuple of bindings satisfies the
2000            // test; `every` requires all of them to satisfy.  Empty
2001            // sequences make `some` false and `every` true.
2002            use crate::xpath::ast::QuantifierKind::*;
2003            let mut found_match    = false;
2004            let mut all_match      = true;
2005            let mut seen_any       = false;
2006            eval_quantified_recursive(
2007                bindings, test, 0, ctx, idx,
2008                &mut |result| {
2009                    seen_any = true;
2010                    if result { found_match = true; }
2011                    else      { all_match   = false; }
2012                },
2013            )?;
2014            Ok(Value::Boolean(match kind {
2015                Some  => found_match,
2016                Every => if !seen_any { true } else { all_match },
2017            }))
2018        }
2019        Expr::For { bindings, body } => {
2020            // XPath 2.0 § 3.7 `ForExpr`: each binding ranges over the
2021            // items of its `in` clause; the result is the concatenated
2022            // sequence of `body` values, one per (cartesian) tuple of
2023            // bindings.  Body items keep their per-iteration shape:
2024            // nodes accumulate as NodeIds, atomics keep their full
2025            // `Value` form so downstream type-aware operations (min /
2026            // max / instance of) see the original type tags.
2027            let mut out_items: Vec<Value> = Vec::new();
2028            eval_for_recursive_typed(bindings, body, 0, ctx, idx, &mut out_items)?;
2029            if out_items.is_empty() {
2030                return Ok(Value::NodeSet(Vec::new()));
2031            }
2032            // All-nodes path → flatten to a single NodeSet so XPath 1.0
2033            // consumers downstream (path steps, etc.) still see the
2034            // expected shape.  XPath 2.0 §3.7 specifies result order
2035            // as iteration order, NOT document order, so don't sort.
2036            let all_nodes = out_items.iter().all(|v| matches!(v, Value::NodeSet(_)));
2037            if all_nodes {
2038                let mut nodes: Vec<NodeId> = Vec::new();
2039                for v in out_items {
2040                    if let Value::NodeSet(ns) = v { nodes.extend(ns); }
2041                }
2042                return Ok(Value::NodeSet(nodes));
2043            }
2044            // Mixed / all-atomic → preserve as Value::Sequence so the
2045            // type information survives.
2046            Ok(Value::Sequence(out_items))
2047        }
2048        Expr::Let { bindings, body } => eval_let(bindings, body, 0, ctx, idx),
2049    }
2050}
2051
2052/// XPath 3.0 § 3.10 `LetExpr`: bind each clause once, in source order,
2053/// each visible to later clauses and the body.  Unlike `for`, the bound
2054/// value keeps its full sequence shape (no per-item iteration), so the
2055/// body sees the variable exactly as the `:=` expression produced it.
2056fn eval_let<I: DocIndexLike>(
2057    bindings: &[(String, Expr)],
2058    body:     &Expr,
2059    depth:    usize,
2060    ctx:      &EvalCtx<'_>,
2061    idx:      &I,
2062) -> Result<Value> {
2063    if depth == bindings.len() {
2064        return eval_expr(body, ctx, idx);
2065    }
2066    let (name, bound_expr) = &bindings[depth];
2067    let value = eval_expr(bound_expr, ctx, idx)?;
2068    let scoped = ScopedBindings { parent: ctx.bindings, name, value };
2069    let inner = EvalCtx {
2070        context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2071        bindings: &scoped, static_ctx: ctx.static_ctx,
2072    };
2073    eval_let(bindings, body, depth + 1, &inner, idx)
2074}
2075
2076fn eval_for_recursive_typed<I: DocIndexLike>(
2077    bindings: &[(String, Expr)],
2078    body:     &Expr,
2079    depth:    usize,
2080    ctx:      &EvalCtx<'_>,
2081    idx:      &I,
2082    out_items: &mut Vec<Value>,
2083) -> Result<()> {
2084    if depth == bindings.len() {
2085        match eval_expr(body, ctx, idx)? {
2086            Value::Sequence(items) => out_items.extend(items),
2087            other                  => out_items.push(other),
2088        }
2089        return Ok(());
2090    }
2091    let (name, in_expr) = &bindings[depth];
2092    let seq = eval_expr(in_expr, ctx, idx)?;
2093    match seq {
2094        Value::NodeSet(ns) => {
2095            for id in ns {
2096                let scoped = ScopedBindings {
2097                    parent: ctx.bindings, name, value: Value::NodeSet(vec![id]),
2098                };
2099                let inner_ctx = EvalCtx {
2100                    context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2101                    bindings: &scoped, static_ctx: ctx.static_ctx,
2102                };
2103                eval_for_recursive_typed(bindings, body, depth + 1, &inner_ctx, idx, out_items)?;
2104            }
2105        }
2106        Value::ForeignNodeSet(ns) => {
2107            for p in ns {
2108                let scoped = ScopedBindings {
2109                    parent: ctx.bindings, name, value: Value::ForeignNodeSet(vec![p]),
2110                };
2111                let inner_ctx = EvalCtx {
2112                    context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2113                    bindings: &scoped, static_ctx: ctx.static_ctx,
2114                };
2115                eval_for_recursive_typed(bindings, body, depth + 1, &inner_ctx, idx, out_items)?;
2116            }
2117        }
2118        Value::Sequence(items) => {
2119            for v in items {
2120                let scoped = ScopedBindings {
2121                    parent: ctx.bindings, name, value: v,
2122                };
2123                let inner_ctx = EvalCtx {
2124                    context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2125                    bindings: &scoped, static_ctx: ctx.static_ctx,
2126                };
2127                eval_for_recursive_typed(bindings, body, depth + 1, &inner_ctx, idx, out_items)?;
2128            }
2129        }
2130        Value::IntRange { lo, hi } => {
2131            for i in lo..=hi {
2132                let scoped = ScopedBindings {
2133                    parent: ctx.bindings, name, value: Value::Number(Numeric::Double(i as f64)),
2134                };
2135                let inner_ctx = EvalCtx {
2136                    context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2137                    bindings: &scoped, static_ctx: ctx.static_ctx,
2138                };
2139                eval_for_recursive_typed(bindings, body, depth + 1, &inner_ctx, idx, out_items)?;
2140            }
2141        }
2142        atomic => {
2143            let scoped = ScopedBindings {
2144                parent: ctx.bindings, name, value: atomic,
2145            };
2146            let inner_ctx = EvalCtx {
2147                context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2148                bindings: &scoped, static_ctx: ctx.static_ctx,
2149            };
2150            eval_for_recursive_typed(bindings, body, depth + 1, &inner_ctx, idx, out_items)?;
2151        }
2152    }
2153    Ok(())
2154}
2155
2156/// Same iteration pattern as `eval_for_recursive_typed` but for
2157/// `QuantifiedExpr` — invokes `report` once per inner-binding tuple
2158/// with the test expression's boolean coercion.  `report` is free to
2159/// short-circuit: callers set their accumulator and let the recursion
2160/// run to completion (the cost is bounded by the source sequences).
2161fn eval_quantified_recursive<I: DocIndexLike>(
2162    bindings: &[(String, Expr)],
2163    test:     &Expr,
2164    depth:    usize,
2165    ctx:      &EvalCtx<'_>,
2166    idx:      &I,
2167    report:   &mut dyn FnMut(bool),
2168) -> Result<()> {
2169    if depth == bindings.len() {
2170        let t = eval_expr(test, ctx, idx)?;
2171        report(value_to_bool(&t, idx));
2172        return Ok(());
2173    }
2174    let (name, in_expr) = &bindings[depth];
2175    let seq = eval_expr(in_expr, ctx, idx)?;
2176    match seq {
2177        Value::NodeSet(ns) => {
2178            for id in ns {
2179                let scoped = ScopedBindings { parent: ctx.bindings, name, value: Value::NodeSet(vec![id]) };
2180                let inner = EvalCtx {
2181                    context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2182                    bindings: &scoped, static_ctx: ctx.static_ctx,
2183                };
2184                eval_quantified_recursive(bindings, test, depth + 1, &inner, idx, report)?;
2185            }
2186        }
2187        Value::ForeignNodeSet(ns) => {
2188            for p in ns {
2189                let scoped = ScopedBindings { parent: ctx.bindings, name, value: Value::ForeignNodeSet(vec![p]) };
2190                let inner = EvalCtx {
2191                    context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2192                    bindings: &scoped, static_ctx: ctx.static_ctx,
2193                };
2194                eval_quantified_recursive(bindings, test, depth + 1, &inner, idx, report)?;
2195            }
2196        }
2197        Value::IntRange { lo, hi } => {
2198            for i in lo..=hi {
2199                let scoped = ScopedBindings { parent: ctx.bindings, name, value: Value::Number(Numeric::Double(i as f64)) };
2200                let inner = EvalCtx {
2201                    context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2202                    bindings: &scoped, static_ctx: ctx.static_ctx,
2203                };
2204                eval_quantified_recursive(bindings, test, depth + 1, &inner, idx, report)?;
2205            }
2206        }
2207        Value::Sequence(items) => {
2208            // Iterate each item as the per-binding value.  Inner
2209            // `Sequence` / `IntRange` items flatten via `iter_items`
2210            // so a nested sequence contributes its own per-item
2211            // bindings rather than a single composite atomic.
2212            for item in items.iter().flat_map(iter_items) {
2213                let scoped = ScopedBindings { parent: ctx.bindings, name, value: item };
2214                let inner = EvalCtx {
2215                    context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2216                    bindings: &scoped, static_ctx: ctx.static_ctx,
2217                };
2218                eval_quantified_recursive(bindings, test, depth + 1, &inner, idx, report)?;
2219            }
2220        }
2221        atomic => {
2222            let scoped = ScopedBindings { parent: ctx.bindings, name, value: atomic };
2223            let inner = EvalCtx {
2224                context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2225                bindings: &scoped, static_ctx: ctx.static_ctx,
2226            };
2227            eval_quantified_recursive(bindings, test, depth + 1, &inner, idx, report)?;
2228        }
2229    }
2230    Ok(())
2231}
2232
2233/// XPath 2.0 `for $v in ... return ...` scope: delegates every
2234/// binding lookup to the parent, except for the one variable name
2235/// being iterated, which it resolves directly.  Constructed afresh
2236/// per iteration so each tuple of bindings is independent.
2237/// Bindings layer for evaluating an inline function's body: the
2238/// function's parameters and captured closure variables resolve here;
2239/// every other binding concern (functions, namespaces, base URI,
2240/// version, …) delegates to the defining/calling base bindings.
2241struct ClosureBindings<'p> {
2242    vars: std::collections::HashMap<String, Value>,
2243    base: &'p dyn XPathBindings,
2244}
2245
2246impl<'p> XPathBindings for ClosureBindings<'p> {
2247    fn variable(&self, name: &str) -> Option<Value> {
2248        self.vars.get(name).cloned().or_else(|| self.base.variable(name))
2249    }
2250    fn resolve_prefix(&self, p: &str) -> Option<String> { self.base.resolve_prefix(p) }
2251    fn call_function(&self, ns: &str, n: &str, a: Vec<Value>) -> Option<Result<Value>> {
2252        self.base.call_function(ns, n, a)
2253    }
2254    fn call_function_in(&self, ns: &str, n: &str, a: Vec<Value>, cn: NodeId) -> Option<Result<Value>> {
2255        self.base.call_function_in(ns, n, a, cn)
2256    }
2257    fn function_available_in(&self, ns: &str, n: &str, a: usize) -> bool {
2258        self.base.function_available_in(ns, n, a)
2259    }
2260    fn function_signature_in(&self, ns: &str, n: &str, a: usize)
2261        -> Option<crate::xpath::ast::FunctionSig> {
2262        self.base.function_signature_in(ns, n, a)
2263    }
2264    fn castable_as_user_type(&self, ns: &str, l: &str, v: &str, sk: Option<&str>) -> Option<bool> {
2265        self.base.castable_as_user_type(ns, l, v, sk)
2266    }
2267    fn instance_of_user_type(&self, ns: &str, l: &str, vt: Option<(&str, &str)>) -> Option<bool> {
2268        self.base.instance_of_user_type(ns, l, vt)
2269    }
2270    fn schema_type_exists(&self, ns: &str, l: &str) -> bool {
2271        self.base.schema_type_exists(ns, l)
2272    }
2273    fn cast_to_user_type(&self, ns: &str, l: &str, v: &str) -> Option<Result<Value>> {
2274        self.base.cast_to_user_type(ns, l, v)
2275    }
2276    fn node_schema_type(&self, id: NodeId) -> Option<(String, String)> {
2277        self.base.node_schema_type(id)
2278    }
2279    fn node_typed_value(&self, id: NodeId, lexical: &str) -> Option<Value> {
2280        self.base.node_typed_value(id, lexical)
2281    }
2282    fn xpath_version_2_or_later(&self) -> bool { self.base.xpath_version_2_or_later() }
2283    fn load_document(&self, u: &str, b: Option<&str>) -> Option<Result<Vec<ForeignNodePtr>>> {
2284        self.base.load_document(u, b)
2285    }
2286    fn apply_foreign_path(&self, n: &[ForeignNodePtr], p: &[Expr], s: &[Step])
2287        -> Option<Result<Vec<ForeignNodePtr>>> { self.base.apply_foreign_path(n, p, s) }
2288    fn static_base_uri(&self) -> Option<String> { self.base.static_base_uri() }
2289    fn node_base_uri(&self, id: NodeId) -> Option<String> { self.base.node_base_uri(id) }
2290    fn foreign_string_value(&self, p: ForeignNodePtr) -> String { self.base.foreign_string_value(p) }
2291    fn load_dynamic_document(&self, u: &str)
2292        -> Option<std::result::Result<NodeId, crate::error::XmlError>> {
2293        self.base.load_dynamic_document(u)
2294    }
2295    fn regex_dialect(&self) -> crate::regex::Dialect { self.base.regex_dialect() }
2296}
2297
2298/// Collect the names of every `$variable` referenced anywhere in `e`.
2299/// Used to capture an inline function's closure: each referenced name
2300/// that isn't a parameter is resolved from the defining scope.  Over-
2301/// collection (locally-bound names) is harmless — those resolve to
2302/// nothing in the defining scope and are shadowed when the body runs.
2303fn collect_var_refs(e: &Expr, out: &mut Vec<String>) {
2304    use crate::xpath::ast::Expr as E;
2305    let push = |n: &str, out: &mut Vec<String>| {
2306        if !out.iter().any(|x| x == n) { out.push(n.to_string()); }
2307    };
2308    match e {
2309        E::Variable(n) => push(n, out),
2310        E::Or(a, b) | E::And(a, b) | E::Eq(a, b) | E::Ne(a, b)
2311        | E::Lt(a, b) | E::Gt(a, b) | E::Le(a, b) | E::Ge(a, b)
2312        | E::ValueEq(a, b) | E::ValueNe(a, b) | E::ValueLt(a, b)
2313        | E::ValueGt(a, b) | E::ValueLe(a, b) | E::ValueGe(a, b)
2314        | E::Add(a, b) | E::Sub(a, b) | E::Mul(a, b) | E::Div(a, b)
2315        | E::Mod(a, b) | E::IDiv(a, b) | E::Union(a, b)
2316        | E::Intersect(a, b) | E::Except(a, b) | E::Range(a, b)
2317        | E::SimpleMap(a, b) | E::NodeBefore(a, b) | E::NodeAfter(a, b)
2318        | E::NodeIs(a, b) => {
2319            collect_var_refs(a, out); collect_var_refs(b, out);
2320        }
2321        E::Neg(x) | E::InstanceOf(x, _) | E::CastAs(x, _)
2322        | E::CastableAs(x, _) | E::TreatAs(x, _)
2323        | E::WithDefaultCollation(_, x) | E::BackwardsCompat(x) => collect_var_refs(x, out),
2324        E::IfThenElse { cond, then_branch, else_branch } => {
2325            collect_var_refs(cond, out);
2326            collect_var_refs(then_branch, out);
2327            collect_var_refs(else_branch, out);
2328        }
2329        E::For { bindings, body } | E::Let { bindings, body }
2330        | E::Quantified { bindings, test: body, .. } => {
2331            for (_, ex) in bindings { collect_var_refs(ex, out); }
2332            collect_var_refs(body, out);
2333        }
2334        E::Sequence(items) => for x in items { collect_var_refs(x, out); },
2335        E::FunctionCall(_, args) => for a in args { collect_var_refs(a, out); },
2336        E::DynamicCall { func, args } => {
2337            collect_var_refs(func, out);
2338            for a in args { collect_var_refs(a, out); }
2339        }
2340        E::FilterPath { primary, predicates, steps } => {
2341            collect_var_refs(primary, out);
2342            for pr in predicates { collect_var_refs(pr, out); }
2343            for s in steps { for pr in &s.predicates { collect_var_refs(pr, out); } }
2344        }
2345        E::Path(p) => collect_path_var_refs(p, out),
2346        E::TryCatch { body, catches } => {
2347            collect_var_refs(body, out);
2348            for c in catches { collect_var_refs(&c.body, out); }
2349        }
2350        E::MapConstructor(es) => for (k, v) in es { collect_var_refs(k, out); collect_var_refs(v, out); },
2351        E::ArrayConstructor { members, .. } => for m in members { collect_var_refs(m, out); },
2352        E::Lookup(b, key) => {
2353            collect_var_refs(b, out);
2354            if let crate::xpath::ast::LookupKey::Expr(x) = key { collect_var_refs(x, out); }
2355        }
2356        E::UnaryLookup(key) =>
2357            if let crate::xpath::ast::LookupKey::Expr(x) = key { collect_var_refs(x, out); },
2358        E::InlineFunction { body, .. } => collect_var_refs(body, out),
2359        E::Literal(_) | E::Integer(_) | E::Decimal(_) | E::Double(_)
2360        | E::NamedFunctionRef { .. } | E::Placeholder | E::ContextItem => {}
2361    }
2362}
2363
2364fn collect_path_var_refs(p: &crate::xpath::ast::LocationPath, out: &mut Vec<String>) {
2365    use crate::xpath::ast::LocationPath;
2366    let steps = match p { LocationPath::Absolute(s) | LocationPath::Relative(s) => s };
2367    for s in steps {
2368        for pr in &s.predicates { collect_var_refs(pr, out); }
2369    }
2370}
2371
2372/// Invoke a function item with `args` already evaluated.
2373fn call_function_item<I: DocIndexLike>(
2374    fi: &FunctionItem, args: Vec<Value>, ctx: &EvalCtx<'_>, idx: &I,
2375) -> Result<Value> {
2376    match fi {
2377        FunctionItem::Inline { params, body, closure, .. } => {
2378            if args.len() != params.len() {
2379                return Err(xpath_err(format!(
2380                    "inline function expects {} argument(s), got {}",
2381                    params.len(), args.len())));
2382            }
2383            let mut vars: std::collections::HashMap<String, Value> =
2384                closure.iter().cloned().collect();
2385            for (p, a) in params.iter().zip(args) { vars.insert(p.clone(), a); }
2386            let cb = ClosureBindings { vars, base: ctx.bindings };
2387            let inner = EvalCtx {
2388                context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2389                bindings: &cb, static_ctx: ctx.static_ctx,
2390            };
2391            eval_expr(body, &inner, idx)
2392        }
2393        FunctionItem::Named { name, ns, arity, .. } => {
2394            if args.len() != *arity {
2395                return Err(xpath_err(format!(
2396                    "function {name}#{arity} called with {} argument(s)", args.len())));
2397            }
2398            // A statically-resolved non-default-function namespace lets a
2399            // `name#arity` reference to a user `xsl:function` or extension
2400            // dispatch even when the call site binds no prefix for that
2401            // namespace (the value `fn:function-lookup` produces).  Try the
2402            // user-function hook and EXSLT by (namespace, local) directly;
2403            // anything unmatched falls through to ordinary lexical dispatch
2404            // (XSD constructors, built-ins, and the unknown-function error).
2405            if !ns.is_empty() && ns.as_str() != FN_NAMESPACE {
2406                let local = name.rsplit(':').next().unwrap_or(name);
2407                if let Some(r) =
2408                    ctx.bindings.call_function_in(ns, local, args.clone(), ctx.context_node)
2409                {
2410                    return r;
2411                }
2412                if let Some(r) = super::exslt::dispatch(ns, local, args.clone(), idx) {
2413                    return r;
2414                }
2415            }
2416            // Bind the values as synthetic variables and re-enter
2417            // function dispatch with variable-reference arguments.
2418            let mut vars = std::collections::HashMap::new();
2419            let mut arg_exprs = Vec::with_capacity(args.len());
2420            for (i, a) in args.into_iter().enumerate() {
2421                let vn = format!("\u{1}fnarg{i}");
2422                vars.insert(vn.clone(), a);
2423                arg_exprs.push(Expr::Variable(vn));
2424            }
2425            let cb = ClosureBindings { vars, base: ctx.bindings };
2426            let inner = EvalCtx {
2427                context_node: ctx.context_node, pos: ctx.pos, size: ctx.size,
2428                bindings: &cb, static_ctx: ctx.static_ctx,
2429            };
2430            eval_function(name, &arg_exprs, &inner, idx)
2431        }
2432        FunctionItem::Partial { base, bound } => {
2433            let mut supplied = args.into_iter();
2434            let mut full = Vec::with_capacity(bound.len());
2435            for b in bound {
2436                match b {
2437                    Some(v) => full.push(v.clone()),
2438                    None => full.push(supplied.next().ok_or_else(||
2439                        xpath_err("too few arguments for partial application"))?),
2440                }
2441            }
2442            call_function_item(base, full, ctx, idx)
2443        }
2444    }
2445}
2446
2447struct ScopedBindings<'p> {
2448    parent: &'p dyn XPathBindings,
2449    name:   &'p str,
2450    value:  Value,
2451}
2452
2453impl<'p> XPathBindings for ScopedBindings<'p> {
2454    fn resolve_prefix(&self, prefix: &str) -> Option<String> {
2455        self.parent.resolve_prefix(prefix)
2456    }
2457    fn variable(&self, name: &str) -> Option<Value> {
2458        if name == self.name { Some(self.value.clone()) } else { self.parent.variable(name) }
2459    }
2460    fn call_function(
2461        &self, ns_uri: &str, name: &str, args: Vec<Value>,
2462    ) -> Option<std::result::Result<Value, crate::error::XmlError>> {
2463        self.parent.call_function(ns_uri, name, args)
2464    }
2465    fn call_function_in(
2466        &self, ns_uri: &str, name: &str, args: Vec<Value>,
2467        xpath_context_node: NodeId,
2468    ) -> Option<std::result::Result<Value, crate::error::XmlError>> {
2469        self.parent.call_function_in(ns_uri, name, args, xpath_context_node)
2470    }
2471    fn function_available_in(&self, ns: &str, n: &str, a: usize) -> bool {
2472        self.parent.function_available_in(ns, n, a)
2473    }
2474    fn function_signature_in(&self, ns: &str, n: &str, a: usize)
2475        -> Option<crate::xpath::ast::FunctionSig> {
2476        self.parent.function_signature_in(ns, n, a)
2477    }
2478    fn castable_as_user_type(&self, ns: &str, l: &str, v: &str, sk: Option<&str>) -> Option<bool> {
2479        self.parent.castable_as_user_type(ns, l, v, sk)
2480    }
2481    fn instance_of_user_type(&self, ns: &str, l: &str, vt: Option<(&str, &str)>) -> Option<bool> {
2482        self.parent.instance_of_user_type(ns, l, vt)
2483    }
2484    fn schema_type_exists(&self, ns: &str, l: &str) -> bool {
2485        self.parent.schema_type_exists(ns, l)
2486    }
2487    fn cast_to_user_type(&self, ns: &str, l: &str, v: &str) -> Option<Result<Value>> {
2488        self.parent.cast_to_user_type(ns, l, v)
2489    }
2490    fn node_schema_type(&self, id: NodeId) -> Option<(String, String)> {
2491        self.parent.node_schema_type(id)
2492    }
2493    fn node_typed_value(&self, id: NodeId, lexical: &str) -> Option<Value> {
2494        self.parent.node_typed_value(id, lexical)
2495    }
2496    fn foreign_string_value(
2497        &self, p: crate::xpath::eval::ForeignNodePtr,
2498    ) -> String {
2499        self.parent.foreign_string_value(p)
2500    }
2501}
2502
2503/// Bindings layer that exposes the XPath 3.1 `$err:*` variables
2504/// inside an `Expr::TryCatch` catch handler.  Lookups for any
2505/// other variable fall through to the surrounding context.
2506struct ErrBindings<'p> {
2507    parent:      &'p dyn XPathBindings,
2508    err_uri:     String,
2509    code_local:  String,
2510    description: String,
2511}
2512
2513impl<'p> XPathBindings for ErrBindings<'p> {
2514    fn resolve_prefix(&self, prefix: &str) -> Option<String> {
2515        self.parent.resolve_prefix(prefix)
2516            .or_else(|| (prefix == "err").then(|| self.err_uri.clone()))
2517    }
2518    fn variable(&self, name: &str) -> Option<Value> {
2519        // XPath callers pass either the lexical form (`err:code`)
2520        // or Clark form (`{uri}code`).  Match both shapes so
2521        // either resolves to the synthesized error metadata.
2522        let key_lex   = format!("err:{}", "code");
2523        let key_clark = format!("{{{}}}{}", self.err_uri, "code");
2524        let local = if name == "err:code" || name == key_clark {
2525            Some("code")
2526        } else if name == "err:description"
2527            || name == format!("{{{}}}description", self.err_uri).as_str() {
2528            Some("description")
2529        } else if name == "err:value"
2530            || name == format!("{{{}}}value", self.err_uri).as_str() {
2531            Some("value")
2532        } else if name == "err:module"
2533            || name == format!("{{{}}}module", self.err_uri).as_str() {
2534            Some("module")
2535        } else if name == "err:line-number"
2536            || name == format!("{{{}}}line-number", self.err_uri).as_str() {
2537            Some("line-number")
2538        } else if name == "err:column-number"
2539            || name == format!("{{{}}}column-number", self.err_uri).as_str() {
2540            Some("column-number")
2541        } else {
2542            None
2543        };
2544        // Touch `key_lex` to silence unused warnings without
2545        // burning the string (it's the canonical lexical form we
2546        // accept above).
2547        let _ = key_lex;
2548        match local {
2549            Some("code")          => Some(Value::String(format!("err:{}", self.code_local))),
2550            Some("description")   => Some(Value::String(self.description.clone())),
2551            Some("value")         => Some(Value::NodeSet(Vec::new())),
2552            Some("module")        => Some(Value::NodeSet(Vec::new())),
2553            Some("line-number")   => Some(Value::NodeSet(Vec::new())),
2554            Some("column-number") => Some(Value::NodeSet(Vec::new())),
2555            _ => self.parent.variable(name),
2556        }
2557    }
2558    fn call_function(
2559        &self, ns_uri: &str, name: &str, args: Vec<Value>,
2560    ) -> Option<std::result::Result<Value, crate::error::XmlError>> {
2561        self.parent.call_function(ns_uri, name, args)
2562    }
2563    fn call_function_in(
2564        &self, ns_uri: &str, name: &str, args: Vec<Value>,
2565        xpath_context_node: NodeId,
2566    ) -> Option<std::result::Result<Value, crate::error::XmlError>> {
2567        self.parent.call_function_in(ns_uri, name, args, xpath_context_node)
2568    }
2569    fn function_available_in(&self, ns: &str, n: &str, a: usize) -> bool {
2570        self.parent.function_available_in(ns, n, a)
2571    }
2572    fn function_signature_in(&self, ns: &str, n: &str, a: usize)
2573        -> Option<crate::xpath::ast::FunctionSig> {
2574        self.parent.function_signature_in(ns, n, a)
2575    }
2576    fn castable_as_user_type(&self, ns: &str, l: &str, v: &str, sk: Option<&str>) -> Option<bool> {
2577        self.parent.castable_as_user_type(ns, l, v, sk)
2578    }
2579    fn instance_of_user_type(&self, ns: &str, l: &str, vt: Option<(&str, &str)>) -> Option<bool> {
2580        self.parent.instance_of_user_type(ns, l, vt)
2581    }
2582    fn schema_type_exists(&self, ns: &str, l: &str) -> bool {
2583        self.parent.schema_type_exists(ns, l)
2584    }
2585    fn cast_to_user_type(&self, ns: &str, l: &str, v: &str) -> Option<Result<Value>> {
2586        self.parent.cast_to_user_type(ns, l, v)
2587    }
2588    fn node_schema_type(&self, id: NodeId) -> Option<(String, String)> {
2589        self.parent.node_schema_type(id)
2590    }
2591    fn node_typed_value(&self, id: NodeId, lexical: &str) -> Option<Value> {
2592        self.parent.node_typed_value(id, lexical)
2593    }
2594    fn foreign_string_value(
2595        &self, p: crate::xpath::eval::ForeignNodePtr,
2596    ) -> String {
2597        self.parent.foreign_string_value(p)
2598    }
2599}
2600
2601fn build_err_scope<'p>(
2602    parent: &'p dyn XPathBindings,
2603    err_uri: &str, code_local: &str, description: &str,
2604) -> ErrBindings<'p> {
2605    ErrBindings {
2606        parent,
2607        err_uri:     err_uri.to_string(),
2608        code_local:  code_local.to_string(),
2609        description: description.to_string(),
2610    }
2611}
2612
2613fn xpath_catch_matches(
2614    matchers: &[crate::xpath::ast::CatchNameTest],
2615    err_uri: &str, err_local: &str,
2616    bindings: &dyn XPathBindings,
2617) -> bool {
2618    use crate::xpath::ast::CatchNameTest::*;
2619    matchers.iter().any(|m| match m {
2620        Any => true,
2621        LocalNameOnly(local) => err_local == local,
2622        PrefixWildcard(prefix) => bindings.resolve_prefix(prefix)
2623            .as_deref() == Some(err_uri),
2624        QName { prefix, local } => {
2625            let want_uri = match prefix {
2626                Some(p) => bindings.resolve_prefix(p).unwrap_or_default(),
2627                None    => String::new(),
2628            };
2629            want_uri == err_uri && local == err_local
2630        }
2631    })
2632}
2633
2634// ── location path evaluation ──────────────────────────────────────────────────
2635
2636fn eval_path<I: DocIndexLike>(
2637    path: &LocationPath, ctx_node: NodeId, idx: &I,
2638    bindings: &dyn XPathBindings, compat: bool, current_node: Option<NodeId>,
2639) -> Result<Vec<NodeId>> {
2640    let (initial, steps) = match path {
2641        // XPath 1.0 §3.3: `/` is the root of the document containing
2642        // the context node.  In the single-document case the parent
2643        // chain always terminates at NodeId 0 (the synthetic Document);
2644        // when extra documents have been merged into the index (e.g.
2645        // via XSLT `document()`), the walk lands on the Document of
2646        // whichever doc the context node belongs to.
2647        LocationPath::Absolute(steps) => {
2648            // XPath 2.0 §2.1.2 / XPDY0002 — an absolute path walks
2649            // from the root of the tree containing the context node;
2650            // if the focus is undefined (e.g. inside an xsl:function
2651            // body) there is no such tree.
2652            if focus_is_undefined() {
2653                return Err(xpath_err(
2654                    "absolute path requires a context item (XPDY0002)"
2655                ).with_xpath_code("XPDY0002"));
2656            }
2657            let mut root = ctx_node;
2658            while let Some(p) = idx.parent(root) {
2659                root = p;
2660            }
2661            (vec![root], steps.as_slice())
2662        },
2663        LocationPath::Relative(steps) => (vec![ctx_node], steps.as_slice()),
2664    };
2665
2666    let mut current = initial;
2667    for step in steps {
2668        current = eval_step_on_nodes_cur(current, step, idx, bindings, compat, current_node)?;
2669    }
2670    Ok(current)
2671}
2672
2673/// Evaluate a single XPath step against a node-set, returning the
2674/// post-predicate result.  `compat` propagates the surrounding
2675/// [`XPathContext`](super::XPathContext)'s libxml2-compat flag into
2676/// the per-predicate eval contexts this function constructs.  Callers
2677/// outside the engine (the `sup-xml-compat` C ABI shim) generally
2678/// pass `false`.
2679pub fn eval_step_on_nodes<I: DocIndexLike>(
2680    nodes: Vec<NodeId>, step: &Step, idx: &I,
2681    bindings: &dyn XPathBindings, compat: bool,
2682) -> Result<Vec<NodeId>> {
2683    // The C-ABI foreign-path bridge re-enters here without a parent
2684    // context; `current()` over foreign nodes is not meaningful, so seed
2685    // it with the document root (matches the bare-context default).
2686    eval_step_on_nodes_cur(nodes, step, idx, bindings, compat, None)
2687}
2688
2689/// [`eval_step_on_nodes`] threading the XSLT `current()` node through to
2690/// any predicates on `step` (see [`StaticContext::current_node`]).
2691pub fn eval_step_on_nodes_cur<I: DocIndexLike>(
2692    nodes: Vec<NodeId>, step: &Step, idx: &I,
2693    bindings: &dyn XPathBindings, compat: bool, current_node: Option<NodeId>,
2694) -> Result<Vec<NodeId>> {
2695    // XPath 2.0 FilterExpr step (`path/(expr)` or `path/func(...)`).
2696    // Evaluate `filter` once per input node with that node as the
2697    // context; atomic results are materialised as synthetic text
2698    // nodes so the surrounding path machinery (which speaks
2699    // `Vec<NodeId>`) keeps working.  XPath 2.0 §3.2.1 — when the
2700    // filter produces only nodes, the combined result is sorted
2701    // into document order and deduplicated.  Mixed atomic/node
2702    // returns skip the sort (atomics have no doc position).
2703    let sc = StaticContext {
2704        xpath_2_0: bindings.xpath_version_2_or_later(),
2705        xpath_3_0: false,
2706        libxml2_compatible: compat,
2707        current_node,
2708    };
2709    if let Some(filter) = &step.filter {
2710        let mut out: Vec<NodeId> = Vec::new();
2711        let mut all_native_nodes = true;
2712        for node in nodes {
2713            charge_eval_step()?;
2714            let ctx = EvalCtx {
2715                context_node: node, pos: 1, size: 1, bindings,
2716                static_ctx: &sc,
2717            };
2718            // Inside a step, the context item *is* defined — even
2719            // if the outer XSLT scope (e.g. xsl:function body) had
2720            // set FOCUS_UNDEFINED.  Without this clear, a key() /
2721            // unparsed-entity-uri() / id() called via `$nodes/key(
2722            // ...)` would spuriously raise XTDE1270 / XTDE1370.
2723            let v = with_focus_undefined(false, || eval_expr(filter, &ctx, idx))?;
2724            match v {
2725                Value::NodeSet(ns)        => out.extend(ns),
2726                Value::ForeignNodeSet(_)  => { /* foreign nodes don't
2727                    enter the synthetic id space — silently drop. */ }
2728                Value::Sequence(items) => {
2729                    let mut atoms: Vec<String> = Vec::new();
2730                    for item in items {
2731                        match item {
2732                            Value::NodeSet(ns)        => out.extend(ns),
2733                            Value::ForeignNodeSet(_)  => {}
2734                            atomic => atoms.push(value_to_string_with(&atomic, idx, bindings)),
2735                        }
2736                    }
2737                    if !atoms.is_empty() {
2738                        all_native_nodes = false;
2739                        if let Some(ids) = idx.allocate_rtf_text_nodes(atoms) {
2740                            out.extend(ids);
2741                        }
2742                    }
2743                }
2744                atomic => {
2745                    all_native_nodes = false;
2746                    let s = value_to_string_with(&atomic, idx, bindings);
2747                    if let Some(ids) = idx.allocate_rtf_text_nodes(vec![s]) {
2748                        out.extend(ids);
2749                    }
2750                }
2751            }
2752        }
2753        // Sort + dedup per XPath 2.0 §3.2.1, but only when every
2754        // contribution was a navigable node.  An atomic-derived
2755        // synthetic text node has no meaningful doc position, so
2756        // sorting would scramble user-visible order without spec
2757        // backing.
2758        if all_native_nodes && bindings.xpath_version_2_or_later() {
2759            dedup_sort(&mut out);
2760        }
2761        // Apply predicates to the collected filter result.
2762        return apply_predicates_cur(out, &step.predicates, idx, bindings, compat, current_node);
2763    }
2764    let mut candidates: Vec<NodeId> = Vec::new();
2765    for node in nodes {
2766        // Charge one step per source node — this is what makes
2767        // chained `//*//*//*` cost N^k in path length k.
2768        charge_eval_step()?;
2769        let mut cands = axis_nodes(&step.axis, node, idx);
2770        cands.retain(|&n| node_matches(n, &step.node_test, &step.axis, idx, bindings, compat));
2771        // XPath 1.0 §2.4: predicates see candidates in axis order, so
2772        // `position()` and `[N]` index reverse axes from the proximity
2773        // root (nearest ancestor = pos 1).  Our axis helpers already
2774        // return reverse axes in proximity order; the final `dedup_sort`
2775        // below puts the surviving set back into document order.
2776        let filtered = apply_predicates_cur(cands, &step.predicates, idx, bindings, compat, current_node)?;
2777        candidates.extend(filtered);
2778    }
2779    dedup_sort(&mut candidates);
2780    Ok(candidates)
2781}
2782
2783/// Filter `nodes` by sequentially applying each predicate.  Each
2784/// per-node sub-context inherits the surrounding `compat` flag so
2785/// predicate-internal calls to `string()` / `number()` / etc. observe
2786/// the same libxml2-compat behaviour as the outer expression.
2787pub fn apply_predicates<I: DocIndexLike>(
2788    nodes: Vec<NodeId>, predicates: &[Expr], idx: &I,
2789    bindings: &dyn XPathBindings, compat: bool,
2790) -> Result<Vec<NodeId>> {
2791    apply_predicates_cur(nodes, predicates, idx, bindings, compat, None)
2792}
2793
2794/// [`apply_predicates`] threading the XSLT `current()` node into each
2795/// predicate's sub-context (see [`StaticContext::current_node`]).
2796pub fn apply_predicates_cur<I: DocIndexLike>(
2797    nodes: Vec<NodeId>, predicates: &[Expr], idx: &I,
2798    bindings: &dyn XPathBindings, compat: bool, current_node: Option<NodeId>,
2799) -> Result<Vec<NodeId>> {
2800    let sc = StaticContext {
2801        xpath_2_0: bindings.xpath_version_2_or_later(),
2802        xpath_3_0: false,
2803        libxml2_compatible: compat,
2804        current_node,
2805    };
2806    let mut result = nodes;
2807    for pred in predicates {
2808        // Positional short-circuit: `[N]` (literal integer N) and
2809        // `[position()=N]` / `[N=position()]` reduce a node-set to
2810        // its N-th element without iterating the whole set.  XPath
2811        // 1.0 §3.4 evaluates each predicate against every candidate
2812        // in turn; the optimisation just skips the per-candidate
2813        // eval whose only effect would be `position()==N`.
2814        if let Some(n) = positional_index(pred) {
2815            charge_eval_step()?;
2816            result = match n.checked_sub(1).and_then(|i| result.get(i).copied()) {
2817                Some(node) => vec![node],
2818                None       => Vec::new(),
2819            };
2820            continue;
2821        }
2822        let size = result.len();
2823        let mut next = Vec::new();
2824        for (i, node) in result.into_iter().enumerate() {
2825            // Charge one step per candidate; this is what
2826            // compounds in nested-predicate DoS expressions.
2827            charge_eval_step()?;
2828            let ctx = EvalCtx {
2829                context_node: node, pos: i + 1, size, bindings,
2830                static_ctx: &sc,
2831            };
2832            let v = eval_expr(pred, &ctx, idx)?;
2833            // XPath 2.0 numeric-predicate rule (see
2834            // `filter_sequence_by_predicates`): a numeric predicate
2835            // selects by position.  A single-item NodeSet whose
2836            // synthetic text-node carries an integer value (the
2837            // common XSLT 2.0 shape after `for-each select="1 to N"`)
2838            // counts as numeric here so `$xs[$n]` selects the n-th.
2839            let keep = match &v {
2840                Value::Number(n) => (i + 1) as f64 == n.as_f64(),
2841                Value::Typed(t) => match t.numeric {
2842                    Some(n) => (i + 1) as f64 == n,
2843                    None    => value_to_bool(&v, idx),
2844                },
2845                Value::NodeSet(ns) if ns.len() == 1
2846                    && matches!(idx.kind(ns[0]),
2847                        crate::xpath::XPathNodeKind::Text)
2848                    && idx.parent(ns[0]).is_none()
2849                => {
2850                    let s = idx.string_value(ns[0]);
2851                    match s.trim().parse::<f64>() {
2852                        Ok(n) if n.fract() == 0.0 => (i + 1) as f64 == n,
2853                        _ => value_to_bool(&v, idx),
2854                    }
2855                }
2856                other => value_to_bool(other, idx),
2857            };
2858            if keep {
2859                next.push(node);
2860            }
2861        }
2862        result = next;
2863    }
2864    Ok(result)
2865}
2866
2867/// Detect predicates of the form `[N]`, `[position()=N]`, or
2868/// `[N=position()]` where N is a positive-integer literal.  Returns
2869/// the 1-based index N when matched, `None` otherwise.  Used by
2870/// [`apply_predicates`] to skip the per-candidate eval loop and
2871/// directly index the node-set.
2872fn positional_index(pred: &Expr) -> Option<usize> {
2873    fn lit_pos_int(e: &Expr) -> Option<usize> {
2874        match e {
2875            Expr::Integer(i) if *i >= 1 => Some(*i as usize),
2876            // A decimal literal predicate (`[2.0]`) indexes too, but
2877            // only when it is a positive whole number — a fractional
2878            // value would compare unequal to every integer position.
2879            Expr::Decimal(n) => {
2880                use rust_decimal::prelude::ToPrimitive;
2881                let whole = n.trunc() == *n;
2882                let positive = *n >= rust_decimal::Decimal::ONE;
2883                let fits = n.to_usize();
2884                match (whole, positive, fits) {
2885                    (true, true, Some(u)) => Some(u),
2886                    _                     => None,
2887                }
2888            }
2889            _ => None,
2890        }
2891    }
2892    fn is_position_call(e: &Expr) -> bool {
2893        matches!(e, Expr::FunctionCall(name, args) if name == "position" && args.is_empty())
2894    }
2895    // `[N]`
2896    if let Some(n) = lit_pos_int(pred) { return Some(n); }
2897    // `[position()=N]` / `[N=position()]`
2898    if let Expr::Eq(l, r) = pred {
2899        if is_position_call(l) { return lit_pos_int(r); }
2900        if is_position_call(r) { return lit_pos_int(l); }
2901    }
2902    None
2903}
2904
2905// ── axis navigation ───────────────────────────────────────────────────────────
2906
2907fn axis_nodes<I: DocIndexLike>(axis: &Axis, node: NodeId, idx: &I) -> Vec<NodeId> {
2908    match axis {
2909        Axis::Self_ => vec![node],
2910        Axis::Child => idx.children(node).to_vec(),
2911        Axis::Parent => idx.parent(node).into_iter().collect(),
2912        Axis::Attribute => idx.attr_range(node).collect(),
2913        Axis::Ancestor => ancestors(node, idx),
2914        Axis::AncestorOrSelf => {
2915            let mut a = vec![node];
2916            a.extend(ancestors(node, idx));
2917            a
2918        }
2919        Axis::Descendant => descendants(node, idx, false),
2920        Axis::DescendantOrSelf => descendants(node, idx, true),
2921        Axis::FollowingSibling => following_siblings(node, idx),
2922        Axis::PrecedingSibling => preceding_siblings(node, idx),
2923        Axis::Following => following(node, idx),
2924        Axis::Preceding => preceding(node, idx),
2925        // XPath 1.0 §2.2: namespace nodes are synthetic per-element
2926        // entries materialised at index-build time (see
2927        // context::collect_in_scope_namespaces); we just enumerate
2928        // the precomputed range here.
2929        Axis::Namespace => idx.ns_range(node).collect(),
2930    }
2931}
2932
2933fn ancestors<I: DocIndexLike>(node: NodeId, idx: &I) -> Vec<NodeId> {
2934    let mut result = Vec::new();
2935    let mut cur = idx.parent(node);
2936    while let Some(p) = cur {
2937        result.push(p);
2938        cur = idx.parent(p);
2939    }
2940    result
2941}
2942
2943/// Walk up `parent` links from `node` to the topmost ancestor — used
2944/// when a function (e.g. `fn:id($arg, $node)`) needs the document
2945/// root containing some node.  Returns `node` itself when it has no
2946/// parent.
2947/// XML Namespaces §3 lexical validation: `NCName` or
2948/// `NCName ':' NCName`.  Each NCName starts with a name-start
2949/// character that isn't `:`, then continues with name characters
2950/// that also aren't `:`.  Empty strings and double-colon /
2951/// trailing-colon forms are invalid.
2952fn is_valid_lexical_qname(s: &str) -> bool {
2953    fn is_ncname(s: &str) -> bool {
2954        let mut chars = s.chars();
2955        let first = match chars.next() { Some(c) => c, None => return false };
2956        let start_ok = first == '_' || first.is_ascii_alphabetic()
2957            || (first as u32 >= 0x80 && crate::charsets::is_name_start_char(first));
2958        if !start_ok || first == ':' { return false; }
2959        for c in chars {
2960            if c == ':' { return false; }
2961            let ok = c == '_' || c == '-' || c == '.'
2962                || c.is_ascii_alphanumeric()
2963                || (c as u32 >= 0x80 && crate::charsets::is_name_char_unicode(c));
2964            if !ok { return false; }
2965        }
2966        true
2967    }
2968    match s.split_once(':') {
2969        None => is_ncname(s),
2970        Some((p, l)) => is_ncname(p) && is_ncname(l),
2971    }
2972}
2973
2974fn doc_root_of<I: DocIndexLike>(node: NodeId, idx: &I) -> NodeId {
2975    let mut cur = node;
2976    while let Some(p) = idx.parent(cur) { cur = p; }
2977    cur
2978}
2979
2980fn descendants<I: DocIndexLike>(node: NodeId, idx: &I, include_self: bool) -> Vec<NodeId> {
2981    let mut result = Vec::new();
2982    if include_self {
2983        result.push(node);
2984    }
2985    collect_desc(node, idx, &mut result);
2986    result
2987}
2988
2989fn collect_desc<I: DocIndexLike>(node: NodeId, idx: &I, out: &mut Vec<NodeId>) {
2990    for &child in idx.children(node) {
2991        out.push(child);
2992        collect_desc(child, idx, out);
2993    }
2994}
2995
2996fn following_siblings<I: DocIndexLike>(node: NodeId, idx: &I) -> Vec<NodeId> {
2997    let parent = match idx.parent(node) {
2998        Some(p) => p,
2999        None => return Vec::new(),
3000    };
3001    let siblings = idx.children(parent);
3002    // Attribute and namespace nodes aren't in their parent's `children()`
3003    // list; XPath 1.0 §2.2 defines following-sibling as empty for them.
3004    siblings.iter().position(|&n| n == node)
3005        .map(|p| siblings[p + 1..].to_vec())
3006        .unwrap_or_default()
3007}
3008
3009fn preceding_siblings<I: DocIndexLike>(node: NodeId, idx: &I) -> Vec<NodeId> {
3010    let parent = match idx.parent(node) {
3011        Some(p) => p,
3012        None => return Vec::new(),
3013    };
3014    let siblings = idx.children(parent);
3015    let pos = siblings.iter().position(|&n| n == node).unwrap_or(0);
3016    // Reverse order: closest sibling has position 1
3017    siblings[..pos].iter().rev().copied().collect()
3018}
3019
3020fn following<I: DocIndexLike>(node: NodeId, idx: &I) -> Vec<NodeId> {
3021    // All nodes in document order after `node` that are not descendants of `node`'s ancestors
3022    let desc_set: HashSet<NodeId> = descendants(node, idx, true).into_iter().collect();
3023    let mut result = Vec::new();
3024    let mut cur = node;
3025    while let Some(parent) = idx.parent(cur) {
3026        let siblings = idx.children(parent);
3027        // Attribute and namespace nodes aren't in `children()`; in
3028        // document order (XPath 1.0 §5) they precede the element's
3029        // children, so `following::` from one of them sees every child
3030        // — hence the `None`-branch start at 0.
3031        let start = siblings.iter().position(|&n| n == cur).map_or(0, |p| p + 1);
3032        for &sib in &siblings[start..] {
3033            if !desc_set.contains(&sib) {
3034                result.push(sib);
3035                collect_desc(sib, idx, &mut result);
3036            }
3037        }
3038        cur = parent;
3039    }
3040    dedup_sort(&mut result);
3041    result
3042}
3043
3044fn preceding<I: DocIndexLike>(node: NodeId, idx: &I) -> Vec<NodeId> {
3045    let ancestor_set: HashSet<NodeId> = ancestors(node, idx).into_iter().collect();
3046    let mut result = Vec::new();
3047    let mut cur = node;
3048    while let Some(parent) = idx.parent(cur) {
3049        let siblings = idx.children(parent);
3050        let pos = siblings.iter().position(|&n| n == cur).unwrap_or(0);
3051        for &sib in siblings[..pos].iter().rev() {
3052            if !ancestor_set.contains(&sib) {
3053                // Add in reverse document order
3054                let mut d = Vec::new();
3055                collect_desc(sib, idx, &mut d);
3056                for id in d.into_iter().rev() {
3057                    result.push(id);
3058                }
3059                result.push(sib);
3060            }
3061        }
3062        cur = parent;
3063    }
3064    result
3065}
3066
3067// ── node test matching ────────────────────────────────────────────────────────
3068
3069/// Per XPath 1.0 § 2.3, name tests (`*`, `local`, `prefix:local`, `prefix:*`)
3070/// match the *principal node type* of the axis they're applied on — element
3071/// for every axis except `attribute::` (matches attributes) and `namespace::`
3072/// (matches namespace nodes).  The kind tests (`node()`, `text()`, `comment()`,
3073/// `processing-instruction()`) are unaffected.
3074fn principal_kind(axis: &Axis) -> XPathNodeKind {
3075    match axis {
3076        Axis::Attribute => XPathNodeKind::Attribute,
3077        Axis::Namespace => XPathNodeKind::Namespace,
3078        _               => XPathNodeKind::Element,
3079    }
3080}
3081
3082/// Test `node` against `test` as if reached on the `child::` axis
3083/// (principal node kind = element).  Exposed for the XSLT pattern
3084/// matcher, which handles `document-node(element(N))` patterns
3085/// structurally and needs to check the document element against the
3086/// inner element test.
3087pub fn node_matches_child<I: DocIndexLike>(
3088    node: NodeId, test: &NodeTest, idx: &I, bindings: &dyn XPathBindings,
3089) -> bool {
3090    node_matches(node, test, &Axis::Child, idx, bindings, false)
3091}
3092
3093fn node_matches<I: DocIndexLike>(
3094    node: NodeId, test: &NodeTest, axis: &Axis, idx: &I,
3095    bindings: &dyn XPathBindings, libxml2_compatible: bool,
3096) -> bool {
3097    match test {
3098        NodeTest::AnyNode => true,
3099        NodeTest::Text => matches!(idx.kind(node), XPathNodeKind::Text | XPathNodeKind::CData),
3100        NodeTest::Comment => matches!(idx.kind(node), XPathNodeKind::Comment),
3101        NodeTest::PI(None) => matches!(idx.kind(node), XPathNodeKind::PI),
3102        NodeTest::PI(Some(target)) => {
3103            (matches!(idx.kind(node), XPathNodeKind::PI) && idx.pi_target(node) == *target)
3104        }
3105        NodeTest::Document(inner) => {
3106            if !matches!(idx.kind(node), XPathNodeKind::Document) {
3107                return false;
3108            }
3109            match inner {
3110                None => true,
3111                // `document-node(element(N))` — the document element
3112                // (the sole element child) must satisfy the inner test.
3113                Some(t) => idx.children(node).iter().any(|c|
3114                    idx.kind(*c) == XPathNodeKind::Element
3115                    && node_matches(*c, t, &Axis::Child, idx, bindings, libxml2_compatible)),
3116            }
3117        }
3118        NodeTest::Wildcard => idx.kind(node) == principal_kind(axis),
3119        NodeTest::PrefixWildcard(prefix) => {
3120            if idx.kind(node) != principal_kind(axis) {
3121                return false;
3122            }
3123            // If the bindings resolve the prefix to a URI, match by
3124            // namespace URI on the node — the expression's prefix
3125            // is just an alias defined by the caller (lxml's
3126            // `namespaces=` dict).  Otherwise fall back to literal
3127            // `prefix:` matching on node_name for unbound prefixes.
3128            if let Some(uri) = resolve_prefix_or_implicit(bindings, prefix) {
3129                idx.namespace_uri(node) == uri
3130            } else {
3131                let name = idx.node_name(node);
3132                name.starts_with(&format!("{prefix}:"))
3133            }
3134        }
3135        // XPath 2.0 §2.5.5.3 `*:NCName` — any namespace, matching
3136        // local name.  Used by stylesheets that don't want to bind
3137        // a prefix just to match an element by local name.
3138        NodeTest::LocalNameOnly(local) => {
3139            if idx.kind(node) != principal_kind(axis) {
3140                return false;
3141            }
3142            idx.local_name(node) == local
3143        }
3144        NodeTest::LocalName(local) => {
3145            // XPath 1.0 §2.3: an unprefixed name test has a null
3146            // namespace URI and only matches nodes whose
3147            // expanded-name has no namespace.  libxml2 historically
3148            // ignores the URI here (it does the comparison purely
3149            // on local-name) and a chunk of integration code in the
3150            // wild leans on that — `libxml2_compatible: true`
3151            // preserves the legacy behaviour, while the default
3152            // (XSLT / strict XPath callers) follows the spec.  The
3153            // namespace axis is the lone exception: a namespace
3154            // node's local-name is its prefix (§5.4), so the
3155            // URI-comparison rule is meaningless there.
3156            if idx.kind(node) != principal_kind(axis) {
3157                return false;
3158            }
3159            if idx.local_name(node) != local {
3160                return false;
3161            }
3162            if libxml2_compatible || matches!(axis, Axis::Namespace) {
3163                return true;
3164            }
3165            idx.namespace_uri(node).is_empty()
3166        }
3167        // XSLT 2.0 §5.1.1 — an unprefixed element NameTest in an
3168        // XPath whose host element (or ancestor) declared
3169        // xpath-default-namespace resolves against that URI instead
3170        // of the null namespace.  The compiler bakes the URI into
3171        // the AST so matching here is a single pair compare.  Only
3172        // applies on axes whose principal node kind is element.
3173        NodeTest::DefaultNamespaceName { uri, local } => {
3174            if idx.kind(node) != principal_kind(axis) {
3175                return false;
3176            }
3177            if !matches!(axis,
3178                Axis::Child | Axis::Descendant | Axis::DescendantOrSelf
3179                | Axis::Self_ | Axis::Parent | Axis::Ancestor
3180                | Axis::AncestorOrSelf | Axis::FollowingSibling
3181                | Axis::PrecedingSibling | Axis::Following | Axis::Preceding)
3182            {
3183                return false;
3184            }
3185            idx.local_name(node) == local.as_str()
3186                && idx.namespace_uri(node) == uri.as_str()
3187        }
3188        NodeTest::QName(prefix, local) => {
3189            if idx.kind(node) != principal_kind(axis) {
3190                return false;
3191            }
3192            // Preferred path: the caller registered a URI for this
3193            // prefix (lxml's `namespaces=` kwarg / libxslt's
3194            // xmlXPathRegisterNs).  Match by namespace URI + local
3195            // name — what XPath 1.0 actually requires.
3196            if let Some(uri) = resolve_prefix_or_implicit(bindings, prefix) {
3197                return idx.local_name(node) == local.as_str()
3198                    && idx.namespace_uri(node) == uri;
3199            }
3200            // Fallback when no binding is in scope: support the two
3201            // historical name representations in this codebase.
3202            //   1. Lean build: `node_name` is the full QName, so a
3203            //      direct `"prefix:local"` compare works.
3204            //   2. c-abi build: `node_name` is the local part only;
3205            //      we compare local-name + the node's declared
3206            //      namespace prefix instead.
3207            let expected_qname = format!("{prefix}:{local}");
3208            if idx.node_name(node) == expected_qname {
3209                return true;
3210            }
3211            idx.local_name(node) == local.as_str()
3212                && idx.namespace_prefix(node) == Some(prefix.as_str())
3213        }
3214    }
3215}
3216
3217// ── value coercions ───────────────────────────────────────────────────────────
3218
3219pub fn value_to_bool<I: DocIndexLike>(v: &Value, idx: &I) -> bool {
3220    match v {
3221        Value::Boolean(b) => *b,
3222        Value::Number(n) => { let n = n.as_f64(); n != 0.0 && !n.is_nan() }
3223        Value::String(s) => !s.is_empty(),
3224        Value::NodeSet(ns) => !ns.is_empty(),
3225        Value::ForeignNodeSet(ns) => !ns.is_empty(),
3226        Value::Typed(t) => {
3227            if let Some(b) = t.boolean { return b; }
3228            if let Some(n) = t.numeric { return n != 0.0 && !n.is_nan(); }
3229            !t.lexical.is_empty()
3230        }
3231        // XPath 2.0 §2.4.3 effective boolean value of a sequence:
3232        // empty → false; single boolean → its value; single
3233        // node → true; single string → !empty; otherwise type error
3234        // (we keep it lenient — first item's EBV).
3235        Value::Sequence(items) => match items.first() {
3236            None    => false,
3237            Some(v) => value_to_bool(v, idx),
3238        }
3239        // A non-empty IntRange is non-empty by construction (the
3240        // invariant on Value::IntRange).  EBV of a single integer
3241        // is "true unless the integer is 0" — but a multi-item
3242        // numeric sequence isn't a single integer, so the lenient
3243        // rule is "non-empty → true".
3244        Value::IntRange { lo, hi } if lo == hi => *lo != 0,
3245        Value::IntRange { .. } => true,
3246        // XPath 3.1 §2.4.3 — the effective boolean value of a map or
3247        // array is a type error (FORG0006).  We keep it lenient: a map
3248        // / array is a present item, so treat it as true.
3249        Value::Map(_) | Value::Array(_) | Value::Function(_) => true,
3250    }
3251}
3252
3253pub fn value_to_number<I: DocIndexLike>(v: &Value, idx: &I) -> f64 {
3254    value_to_number_with(v, idx, &NO_BINDINGS)
3255}
3256
3257pub fn value_to_number_with<I: DocIndexLike>(
3258    v: &Value, idx: &I, bindings: &dyn XPathBindings,
3259) -> f64 {
3260    match v {
3261        Value::Number(n) => n.as_f64(),
3262        Value::Boolean(b) => if *b { 1.0 } else { 0.0 },
3263        Value::String(s) => s.trim().parse().unwrap_or(f64::NAN),
3264        Value::NodeSet(ns) => {
3265            if ns.is_empty() {
3266                return f64::NAN;
3267            }
3268            let s = idx.string_value(ns[0]);
3269            s.trim().parse().unwrap_or(f64::NAN)
3270        }
3271        Value::ForeignNodeSet(ns) => {
3272            if ns.is_empty() {
3273                return f64::NAN;
3274            }
3275            bindings.foreign_string_value(ns[0]).trim().parse().unwrap_or(f64::NAN)
3276        }
3277        Value::Typed(t) => {
3278            if let Some(n) = t.numeric { return n; }
3279            if let Some(b) = t.boolean { return if b { 1.0 } else { 0.0 }; }
3280            t.lexical.trim().parse().unwrap_or(f64::NAN)
3281        }
3282        // First item's numeric value — matches the legacy
3283        // NodeSet-of-one-text behaviour for typed sequences.
3284        Value::Sequence(items) => match items.first() {
3285            Some(v) => value_to_number_with(v, idx, bindings),
3286            None    => f64::NAN,
3287        }
3288        // First item of a range is the lower bound.
3289        Value::IntRange { lo, .. } => *lo as f64,
3290        // A map / array has no numeric value (FOTY0014); → NaN.
3291        Value::Map(_) | Value::Array(_) | Value::Function(_) => f64::NAN,
3292    }
3293}
3294
3295/// True iff `v` is a function item.  Used to reject the atomization,
3296/// comparison, and string-value of function items (FOTY0013/0014/0015) —
3297/// operations the spec defines only for nodes and atomic values.
3298fn value_is_function(v: &Value) -> bool {
3299    matches!(v, Value::Function(_))
3300}
3301
3302/// Round a decimal to `precision` fractional digits, ties going toward
3303/// +∞ (the rule `fn:round` uses).  Negative precision rounds to powers of
3304/// ten left of the decimal point.  Exact — no f64 round-trip — so
3305/// `round(1.25, 1)` is `1.3`, not its f64 neighbour.
3306fn round_decimal_half_to_pos_inf(d: rust_decimal::Decimal, precision: i32) -> rust_decimal::Decimal {
3307    use rust_decimal::Decimal;
3308    let half = Decimal::new(5, 1); // 0.5
3309    if precision >= 0 {
3310        let shift = Decimal::from(10i64.pow((precision as u32).min(18)));
3311        (d * shift + half).floor() / shift
3312    } else {
3313        let shift = Decimal::from(10i64.pow(((-precision) as u32).min(18)));
3314        (d / shift + half).floor() * shift
3315    }
3316}
3317
3318/// Round a decimal to `precision` fractional digits with banker's rounding
3319/// (ties to the nearest even digit) — the rule `fn:round-half-to-even`
3320/// uses.  Exact decimal arithmetic.
3321fn round_decimal_half_to_even(d: rust_decimal::Decimal, precision: i32) -> rust_decimal::Decimal {
3322    use rust_decimal::{Decimal, RoundingStrategy};
3323    if precision >= 0 {
3324        d.round_dp_with_strategy((precision as u32).min(28), RoundingStrategy::MidpointNearestEven)
3325    } else {
3326        let shift = Decimal::from(10i64.pow(((-precision) as u32).min(18)));
3327        (d / shift).round_dp_with_strategy(0, RoundingStrategy::MidpointNearestEven) * shift
3328    }
3329}
3330
3331/// True iff `v` is, or contains, a function item.
3332fn value_seq_has_function(v: &Value) -> bool {
3333    match v {
3334        Value::Function(_) => true,
3335        Value::Sequence(items) => items.iter().any(value_seq_has_function),
3336        _ => false,
3337    }
3338}
3339
3340pub fn value_to_string<I: DocIndexLike>(v: &Value, idx: &I) -> String {
3341    value_to_string_with(v, idx, &NO_BINDINGS)
3342}
3343
3344pub fn value_to_string_with<I: DocIndexLike>(
3345    v: &Value, idx: &I, bindings: &dyn XPathBindings,
3346) -> String {
3347    value_to_string_with_compat(v, idx, bindings, false)
3348}
3349
3350/// Number-to-string flavour — the serialization slice of the static
3351/// context.  `Xpath10` is XPath 1.0 §4.2 decimal-only, `Libxml2` is
3352/// libxml2's `1.23e+19` form, `Xpath20` is the F&O §17.1.2 scientific
3353/// form (`1.0E6`) for `xs:double`/`xs:float`.  `xs:integer` /
3354/// `xs:decimal` are decimal in every style, so integer output never
3355/// changes; only doubles/floats vary.
3356///
3357/// Callers that know their version pass the style explicitly (the
3358/// XSLT engine derives it from the stylesheet `version`); the
3359/// no-context [`value_to_string`] defaults to `Xpath10`.
3360#[derive(Clone, Copy, PartialEq, Eq)]
3361pub enum NumStyle { Xpath10, Libxml2, Xpath20 }
3362
3363impl NumStyle {
3364    /// Derive the style from the two static-context flags that govern
3365    /// numeric serialization: libxml2-compat mode wins, otherwise an
3366    /// XPath 2.0 host uses the F&O scientific form and 1.0 stays decimal.
3367    pub fn from_context(compat: bool, xpath_2_or_later: bool) -> NumStyle {
3368        if compat { NumStyle::Libxml2 }
3369        else if xpath_2_or_later { NumStyle::Xpath20 }
3370        else { NumStyle::Xpath10 }
3371    }
3372}
3373
3374/// Serialize a number under an explicit [`NumStyle`].  Doubles/floats
3375/// take scientific form only under `Xpath20`; everything else is
3376/// decimal (or libxml2's form under `Libxml2`).  Exposed so the XSLT
3377/// result serializer can render a `Value::Number` directly under the
3378/// stylesheet's style without rebuilding a `Value`.
3379pub fn format_numeric_styled(n: Numeric, style: NumStyle) -> String {
3380    // xs:decimal serialises from its exact value, not from an f64
3381    // shadow — the whole point of carrying `rust_decimal::Decimal`
3382    // in `Numeric` is that `0.1 + 0.2` renders as `"0.3"` and a
3383    // 17-digit decimal renders without rounding.
3384    if let Numeric::Decimal(d) = n {
3385        return format_decimal_canonical(d);
3386    }
3387    let f = n.as_f64();
3388    // xs:float must serialise at single precision (XSD §F.3), so
3389    // route through `canonical_float_lex` under any 2.0-aware style.
3390    // The 1.0 / libxml2 paths predate xs:float as a distinct kind and
3391    // treat every Numeric as a decimal-style double — match them.
3392    if matches!(n, Numeric::Float(_)) && matches!(style, NumStyle::Xpath20) {
3393        return canonical_float_lex(f, &Value::Number(n));
3394    }
3395    match style {
3396        NumStyle::Libxml2 => format_number_libxml2(f),
3397        NumStyle::Xpath20 if matches!(n, Numeric::Double(_)) =>
3398            format_number_xpath20(f),
3399        _ => format_number(f),
3400    }
3401}
3402
3403/// XSD §3.2.3.2 canonical lexical form for an `xs:decimal` carried
3404/// as a [`rust_decimal::Decimal`].  `Decimal`'s Display already gives
3405/// fixed-point with the scale embedded (e.g. `Decimal::new(30, 2)` →
3406/// `"0.30"`); we strip trailing fractional zeros (and a trailing `.`)
3407/// to match XSD canonical form, and collapse `-0` to `0`.
3408fn format_decimal_canonical(d: rust_decimal::Decimal) -> String {
3409    if d.is_zero() { return "0".into(); }
3410    let s = d.to_string();
3411    let trimmed = match s.split_once('.') {
3412        Some((w, f)) => {
3413            let f = f.trim_end_matches('0');
3414            if f.is_empty() { w.to_string() } else { format!("{w}.{f}") }
3415        }
3416        None => s,
3417    };
3418    if trimmed == "-0" { "0".into() } else { trimmed }
3419}
3420
3421/// libxml2-compat variant of [`value_to_string_with`].  Derives the
3422/// [`NumStyle`] from `compat` plus the bindings' XPath version and
3423/// delegates to [`value_to_string_styled_with`].
3424pub fn value_to_string_with_compat<I: DocIndexLike>(
3425    v: &Value, idx: &I, bindings: &dyn XPathBindings, compat: bool,
3426) -> String {
3427    let style = NumStyle::from_context(compat, bindings.xpath_version_2_or_later());
3428    value_to_string_styled_with(v, idx, bindings, style)
3429}
3430
3431/// Serialize `v` to its string-value under an explicit [`NumStyle`].
3432/// This is the entry point for callers that know their static context
3433/// (notably the XSLT result serializer, which must render an
3434/// `xs:double` in F&O scientific form even though the value itself no
3435/// longer carries a precomputed lexical).
3436pub fn value_to_string_styled<I: DocIndexLike>(
3437    v: &Value, idx: &I, style: NumStyle,
3438) -> String {
3439    value_to_string_styled_with(v, idx, &NO_BINDINGS, style)
3440}
3441
3442pub fn value_to_string_styled_with<I: DocIndexLike>(
3443    v: &Value, idx: &I, bindings: &dyn XPathBindings, style: NumStyle,
3444) -> String {
3445    match v {
3446        Value::String(s) => s.clone(),
3447        Value::Boolean(b) => (if *b { "true" } else { "false" }).to_string(),
3448        Value::Number(n) => format_numeric_styled(*n, style),
3449        Value::NodeSet(ns) => {
3450            if ns.is_empty() {
3451                String::new()
3452            } else {
3453                idx.string_value(ns[0])
3454            }
3455        }
3456        Value::ForeignNodeSet(ns) => {
3457            if ns.is_empty() {
3458                String::new()
3459            } else {
3460                bindings.foreign_string_value(ns[0])
3461            }
3462        }
3463        // A typed atom serialises from its stored canonical lexical
3464        // (dates, durations, derived numeric types like xs:byte, etc.).
3465        Value::Typed(t) => t.lexical.clone(),
3466        // XPath 1.0 `string()` over a sequence picks the first item
3467        // (XPath 2.0 §2.4 effective value for value-of single-item
3468        // contexts).  Multi-item atomic sequences serialize as
3469        // space-joined per XSLT 2.0 §5.7.2 — that happens at the
3470        // serializer / xsl:value-of layer.
3471        Value::Sequence(items) => match items.first() {
3472            Some(v) => value_to_string_styled_with(v, idx, bindings, style),
3473            None    => String::new(),
3474        }
3475        // First-item rule for an IntRange yields its lower bound.
3476        Value::IntRange { lo, .. } => lo.to_string(),
3477        // A map / array has no string value (FOTY0014).  Lenient: "".
3478        Value::Map(_) | Value::Array(_) | Value::Function(_) => String::new(),
3479    }
3480}
3481
3482
3483/// Wrap a numeric result so it keeps the numeric type of its source
3484/// argument (XPath 2.0 §6.4 — `round`/`floor`/`ceiling`/`abs` return
3485/// the same numeric type as the input).  A typed `xs:double` / `xs:float`
3486/// stays on the [`Value::Typed`] path so it stringifies in scientific
3487/// form (until Phase 4 folds it onto [`Numeric`]); an `xs:integer` /
3488/// `xs:decimal` argument keeps its kind via the [`Numeric`] carrier.
3489fn preserve_numeric_kind(arg: &Value, result: f64) -> Value {
3490    match numeric_kind_of(arg) {
3491        Some(kind) => Value::Number(Numeric::of_kind(kind, result)),
3492        None        => Value::Number(Numeric::Double(result)),
3493    }
3494}
3495
3496/// XPath 2.0 / F&O §17.1.2 `xs:double`/`xs:float` → `xs:string`
3497/// canonical form.  Decimal when `|x|` is in `[1e-6, 1e6)`, otherwise
3498/// scientific (`1.0E6`, `2.0E29`, `1.0E-13`): mantissa always carries
3499/// a fractional digit, the exponent has no `+` or leading zeros, and
3500/// `E` is uppercase.  Infinity is `INF`/`-INF` (not 1.0's `Infinity`).
3501fn format_number_xpath20(n: f64) -> String {
3502    if n.is_nan()      { return "NaN".to_string(); }
3503    if n.is_infinite() { return if n > 0.0 { "INF" } else { "-INF" }.to_string(); }
3504    if n == 0.0 {
3505        return if n.is_sign_negative() { "-0".to_string() } else { "0".to_string() };
3506    }
3507    let abs = n.abs();
3508    if (1e-6..1e6).contains(&abs) {
3509        return format_number(n); // in-range: decimal, same as 1.0
3510    }
3511    let s = format!("{n:e}");
3512    let (mantissa, exp) = s.split_once('e').unwrap_or((s.as_str(), "0"));
3513    let mantissa = if mantissa.contains('.') { mantissa.to_string() }
3514                   else { format!("{mantissa}.0") };
3515    format!("{mantissa}E{exp}")
3516}
3517
3518/// XPath 1.0 § 4.2 number-to-string: decimal form only, no scientific
3519/// notation, no trailing zeros beyond round-trip precision.
3520fn format_number(n: f64) -> String {
3521    if n.is_nan() {
3522        "NaN".to_string()
3523    } else if n.is_infinite() {
3524        if n > 0.0 { "Infinity".to_string() } else { "-Infinity".to_string() }
3525    } else if n == 0.0 && n.is_sign_negative() {
3526        // XSLT 2.0 §F.3 — `string()` on xs:double -0 preserves the
3527        // sign.  XPath 1.0 left this implementation-defined; we
3528        // pick the XSLT 2.0 canonical form so the test suite's
3529        // expected `<out>-0</out>` comes out correctly.
3530        "-0".to_string()
3531    } else if n.fract() == 0.0 && n.abs() < 1e15 {
3532        format!("{}", n as i64)
3533    } else {
3534        format!("{n}")
3535    }
3536}
3537
3538/// libxml2's number-to-string: like [`format_number`] but emits
3539/// `1.23456789012346e+19` scientific form for magnitudes outside
3540/// roughly `[10^-5, 10^15)`.  Used only when the surrounding
3541/// [`XPathContext`](super::XPathContext) was built with
3542/// `libxml2_compatible: true`.  Deliberately *less* spec-conformant
3543/// than [`format_number`] — XPath 1.0 § 4.2 mandates decimal-only.
3544fn format_number_libxml2(n: f64) -> String {
3545    if n.is_nan() {
3546        return "NaN".to_string();
3547    }
3548    if n.is_infinite() {
3549        return if n > 0.0 { "Infinity".into() } else { "-Infinity".into() };
3550    }
3551    if n.fract() == 0.0 && n.abs() < 1e15 {
3552        return format!("{}", n as i64);
3553    }
3554    let abs = n.abs();
3555    if abs >= 1e15 || (abs > 0.0 && abs < 1e-5) {
3556        // Match libxml2's `%.14e`-like format: 14 digits after the
3557        // mantissa decimal, explicit `+` on non-negative exponents.
3558        let s = format!("{:.14e}", n);
3559        let Some(idx) = s.find('e') else { return s; };
3560        let (mantissa, exp_with_e) = s.split_at(idx);
3561        let exp = &exp_with_e[1..];
3562        // Strip mantissa trailing zeros (`1.23000000000000e19` →
3563        // `1.23e19`) for readability, then re-attach exponent with
3564        // explicit sign.
3565        let mantissa = trim_mantissa(mantissa);
3566        if exp.starts_with('-') {
3567            format!("{}e{}", mantissa, exp)
3568        } else {
3569            format!("{}e+{}", mantissa, exp)
3570        }
3571    } else {
3572        format!("{n}")
3573    }
3574}
3575
3576fn trim_mantissa(s: &str) -> String {
3577    // Strips trailing zeros after the decimal point, then a lone
3578    // trailing '.' if all fractional digits were zero.  Used by the
3579    // libxml2-style formatter.
3580    let Some(dot_idx) = s.find('.') else { return s.to_string(); };
3581    let trimmed = s.trim_end_matches('0');
3582    let trimmed = trimmed.trim_end_matches('.');
3583    if trimmed.len() <= dot_idx { format!("{}.0", &s[..dot_idx]) } else { trimmed.to_string() }
3584}
3585
3586/// XPath 1.0 §3.4 general comparison for `!=`.
3587///
3588/// When at least one side is a node-set, `!=` is *not* the negation of
3589/// `=` — it's "there exists a pair whose string-values differ."  Two
3590/// distinct nodes in a single set are enough to make `$x != 'a'`
3591/// return true even when one of them does equal `'a'`.  Falling back
3592/// to `!values_eq` silently flips the answer in those cases.
3593/// Materialise an [`Value::IntRange`] into a [`Value::Sequence`] of
3594/// `Value::Number` items.  Used by `=` / `!=` / `<` / `>` comparison
3595/// routines that need full per-item access — the lazy representation
3596/// loses meaning once we're cross-multiplying with another sequence.
3597fn intrange_to_sequence(v: &Value) -> Option<Value> {
3598    if let Value::IntRange { lo, hi } = v {
3599        let items: Vec<Value> = (*lo..=*hi).map(|i| Value::Number(Numeric::Double(i as f64))).collect();
3600        return Some(Value::Sequence(items));
3601    }
3602    None
3603}
3604
3605fn values_ne<I: DocIndexLike>(
3606    l: &Value, r: &Value, idx: &I, bindings: &dyn XPathBindings,
3607) -> bool {
3608    // Comparisons need full per-item access, so normalise away
3609    // any [`Value::IntRange`] up front.  This is the rare case
3610    // (huge-range arithmetic typically flows through `count` /
3611    // `sum` / `codepoints-to-string`, not equality operators).
3612    if let Some(lv) = intrange_to_sequence(l) {
3613        return values_ne(&lv, r, idx, bindings);
3614    }
3615    if let Some(rv) = intrange_to_sequence(r) {
3616        return values_ne(l, &rv, idx, bindings);
3617    }
3618    match (l, r) {
3619        // Maps and arrays have no general-comparison semantics
3620        // (XPTY0004); keep lenient — never "equal" under a general
3621        // comparison.
3622        (Value::Map(_) | Value::Array(_) | Value::Function(_), _) | (_, Value::Map(_) | Value::Array(_) | Value::Function(_)) => false,
3623        (Value::NodeSet(ls), Value::NodeSet(rs)) => {
3624            // XPath 1.0 §3.4: node-set != node-set is true iff some
3625            // pair (l, r) of string-values has l != r.  That holds
3626            // unless *every* left value equals *every* right value,
3627            // which in turn means the union has at most one distinct
3628            // value.  Either side empty: no pair exists, so false.
3629            let l_vals: HashSet<String> = ls.iter().map(|&id| idx.string_value(id)).collect();
3630            let r_vals: HashSet<String> = rs.iter().map(|&id| idx.string_value(id)).collect();
3631            if l_vals.is_empty() || r_vals.is_empty() { return false; }
3632            l_vals.union(&r_vals).count() > 1
3633        }
3634        (Value::ForeignNodeSet(ls), Value::ForeignNodeSet(rs)) => {
3635            let l_vals: HashSet<String> = ls.iter()
3636                .map(|&p| bindings.foreign_string_value(p)).collect();
3637            let r_vals: HashSet<String> = rs.iter()
3638                .map(|&p| bindings.foreign_string_value(p)).collect();
3639            if l_vals.is_empty() || r_vals.is_empty() { return false; }
3640            l_vals.union(&r_vals).count() > 1
3641        }
3642        (Value::NodeSet(ns), Value::ForeignNodeSet(fs))
3643        | (Value::ForeignNodeSet(fs), Value::NodeSet(ns)) => {
3644            let l_vals: HashSet<String> = ns.iter().map(|&id| idx.string_value(id)).collect();
3645            let r_vals: HashSet<String> = fs.iter()
3646                .map(|&p| bindings.foreign_string_value(p)).collect();
3647            if l_vals.is_empty() || r_vals.is_empty() { return false; }
3648            l_vals.union(&r_vals).count() > 1
3649        }
3650        (Value::NodeSet(ns), other) | (other, Value::NodeSet(ns)) => {
3651            match other {
3652                // Boolean comparison: the negation form is correct
3653                // here (booleans reduce to a single boolean).
3654                Value::Boolean(b) => value_to_bool(&Value::NodeSet(ns.clone()), idx) != *b,
3655                Value::Number(n) => ns.iter().any(|&id| {
3656                    idx.string_value(id).trim().parse::<f64>().ok() != Some(n.as_f64())
3657                }),
3658                Value::String(s) => ns.iter().any(|&id| idx.string_value(id) != *s),
3659                // Typed atomic — inspect the underlying repr in place
3660                // so we don't clone the boxed TypedAtomic (or the
3661                // node-set) just to recurse.
3662                Value::Typed(t) => {
3663                    if let Some(n) = t.numeric {
3664                        ns.iter().any(|&id| idx.string_value(id).trim().parse::<f64>().ok() != Some(n))
3665                    } else if let Some(b) = t.boolean {
3666                        !ns.is_empty() != b
3667                    } else {
3668                        ns.iter().any(|&id| idx.string_value(id) != t.lexical)
3669                    }
3670                }
3671                Value::Sequence(items) => items.iter().any(|v| {
3672                    values_ne(&Value::NodeSet(ns.clone()), v, idx, bindings)
3673                }),
3674                Value::NodeSet(_) | Value::ForeignNodeSet(_) | Value::IntRange { .. }
3675                | Value::Map(_) | Value::Array(_) | Value::Function(_) => unreachable!(),
3676            }
3677        }
3678        (Value::ForeignNodeSet(fs), other) | (other, Value::ForeignNodeSet(fs)) => {
3679            match other {
3680                Value::Boolean(b) => !fs.is_empty() != *b,
3681                Value::Number(n) => fs.iter().any(|&p| {
3682                    bindings.foreign_string_value(p).trim().parse::<f64>().ok() != Some(n.as_f64())
3683                }),
3684                Value::String(s) => fs.iter().any(|&p| bindings.foreign_string_value(p) != *s),
3685                Value::Typed(t) => {
3686                    if let Some(n) = t.numeric {
3687                        fs.iter().any(|&p| bindings.foreign_string_value(p)
3688                            .trim().parse::<f64>().ok() != Some(n))
3689                    } else if let Some(b) = t.boolean {
3690                        !fs.is_empty() != b
3691                    } else {
3692                        fs.iter().any(|&p| bindings.foreign_string_value(p) != t.lexical)
3693                    }
3694                }
3695                Value::Sequence(items) => items.iter().any(|v| {
3696                    values_ne(&Value::ForeignNodeSet(fs.clone()), v, idx, bindings)
3697                }),
3698                Value::NodeSet(_) | Value::ForeignNodeSet(_) | Value::IntRange { .. }
3699                | Value::Map(_) | Value::Array(_) | Value::Function(_) => unreachable!(),
3700            }
3701        }
3702        // Atomic sequence on either side: XPath 2.0 general-comparison
3703        // semantics — exists pair (a, b) such that a != b.
3704        (Value::Sequence(items), other) | (other, Value::Sequence(items)) => {
3705            items.iter().any(|v| values_ne(v, other, idx, bindings))
3706        }
3707        // No node-sets involved — `!=` is just the negation of `=`.
3708        _ => !values_eq(l, r, idx, bindings),
3709    }
3710}
3711
3712fn values_eq<I: DocIndexLike>(
3713    l: &Value, r: &Value, idx: &I, bindings: &dyn XPathBindings,
3714) -> bool {
3715    if let Some(lv) = intrange_to_sequence(l) {
3716        return values_eq(&lv, r, idx, bindings);
3717    }
3718    if let Some(rv) = intrange_to_sequence(r) {
3719        return values_eq(l, &rv, idx, bindings);
3720    }
3721    // XPath 2.0 §C.2 — string equality uses the in-scope default
3722    // collation.  We implement the html-ascii-case-insensitive
3723    // collation (case folding); under any other collation `ci` is
3724    // false and string comparison is the codepoint default, so this
3725    // is a no-op outside an explicit `default-collation` scope.
3726    let ci = is_ascii_ci_collation(
3727        DEFAULT_COLLATION.with(|c| c.borrow().clone()).as_deref());
3728    let str_eq = |a: &str, b: &str| if ci {
3729        ascii_ci_fold(a) == ascii_ci_fold(b)
3730    } else { a == b };
3731    match (l, r) {
3732        // Maps and arrays are not equality-comparable here (their
3733        // deep-equal lives in map:/array: functions); lenient false.
3734        (Value::Map(_) | Value::Array(_) | Value::Function(_), _) | (_, Value::Map(_) | Value::Array(_) | Value::Function(_)) => false,
3735        (Value::NodeSet(ls), Value::NodeSet(rs)) => {
3736            // O(M + N) via a hash set on the smaller side — same
3737            // XPath 1.0 §3.4 semantics ("there exists a node in
3738            // each set whose string-values are equal") but without
3739            // the M×N cross product of string-value computations
3740            // the naive loop did.  Build the set from the smaller
3741            // side to keep peak memory bounded.
3742            let (small, large) = if ls.len() <= rs.len() { (ls, rs) } else { (rs, ls) };
3743            let mut set: HashSet<String> = HashSet::with_capacity(small.len());
3744            for &a in small {
3745                set.insert(idx.string_value(a));
3746            }
3747            for &b in large {
3748                if set.contains(&idx.string_value(b)) {
3749                    return true;
3750                }
3751            }
3752            false
3753        }
3754        (Value::ForeignNodeSet(ls), Value::ForeignNodeSet(rs)) => {
3755            let (small, large) = if ls.len() <= rs.len() { (ls, rs) } else { (rs, ls) };
3756            let mut set: HashSet<String> = HashSet::with_capacity(small.len());
3757            for &a in small {
3758                set.insert(bindings.foreign_string_value(a));
3759            }
3760            for &b in large {
3761                if set.contains(&bindings.foreign_string_value(b)) {
3762                    return true;
3763                }
3764            }
3765            false
3766        }
3767        // Cross-kind: compare each foreign node's string-value to each
3768        // primary node's string-value.  Same XPath 1.0 §3.4 semantics
3769        // as NodeSet/NodeSet, just with the right accessor per side.
3770        (Value::NodeSet(ns), Value::ForeignNodeSet(fs))
3771        | (Value::ForeignNodeSet(fs), Value::NodeSet(ns)) => {
3772            // Build a set from the (likely smaller / cheaper-to-
3773            // access) primary side; probe with foreign.
3774            let mut set: HashSet<String> = HashSet::with_capacity(ns.len());
3775            for &id in ns {
3776                set.insert(idx.string_value(id));
3777            }
3778            for &p in fs {
3779                if set.contains(&bindings.foreign_string_value(p)) {
3780                    return true;
3781                }
3782            }
3783            false
3784        }
3785        (Value::NodeSet(ns), other) | (other, Value::NodeSet(ns)) => {
3786            match other {
3787                Value::Boolean(b) => value_to_bool(&Value::NodeSet(ns.clone()), idx) == *b,
3788                Value::Number(n) => ns.iter().any(|&id| {
3789                    idx.string_value(id).trim().parse::<f64>().ok() == Some(n.as_f64())
3790                }),
3791                Value::String(s) => ns.iter().any(|&id| idx.string_value(id) == *s),
3792                Value::Typed(t) => {
3793                    if let Some(n) = t.numeric {
3794                        ns.iter().any(|&id| idx.string_value(id).trim().parse::<f64>().ok() == Some(n))
3795                    } else if let Some(b) = t.boolean {
3796                        !ns.is_empty() == b
3797                    } else {
3798                        ns.iter().any(|&id| idx.string_value(id) == t.lexical)
3799                    }
3800                }
3801                Value::Sequence(items) => items.iter().any(|v| {
3802                    values_eq(&Value::NodeSet(ns.clone()), v, idx, bindings)
3803                }),
3804                Value::NodeSet(_) | Value::ForeignNodeSet(_) | Value::IntRange { .. }
3805                | Value::Map(_) | Value::Array(_) | Value::Function(_) => unreachable!(),
3806            }
3807        }
3808        (Value::ForeignNodeSet(fs), other) | (other, Value::ForeignNodeSet(fs)) => {
3809            match other {
3810                Value::Boolean(b) => !fs.is_empty() == *b,
3811                Value::Number(n) => fs.iter().any(|&p| {
3812                    bindings.foreign_string_value(p).trim().parse::<f64>().ok() == Some(n.as_f64())
3813                }),
3814                Value::String(s) => fs.iter().any(|&p| bindings.foreign_string_value(p) == *s),
3815                Value::Typed(t) => {
3816                    if let Some(n) = t.numeric {
3817                        fs.iter().any(|&p| bindings.foreign_string_value(p)
3818                            .trim().parse::<f64>().ok() == Some(n))
3819                    } else if let Some(b) = t.boolean {
3820                        !fs.is_empty() == b
3821                    } else {
3822                        fs.iter().any(|&p| bindings.foreign_string_value(p) == t.lexical)
3823                    }
3824                }
3825                Value::Sequence(items) => items.iter().any(|v| {
3826                    values_eq(&Value::ForeignNodeSet(fs.clone()), v, idx, bindings)
3827                }),
3828                Value::NodeSet(_) | Value::ForeignNodeSet(_) | Value::IntRange { .. }
3829                | Value::Map(_) | Value::Array(_) | Value::Function(_) => unreachable!(),
3830            }
3831        }
3832        // Atomic sequence on either side: XPath 2.0 general-comparison
3833        // semantics — exists pair (a, b) such that a = b.
3834        (Value::Sequence(items), other) | (other, Value::Sequence(items)) => {
3835            items.iter().any(|v| values_eq(v, other, idx, bindings))
3836        }
3837        (Value::Boolean(a), b) => *a == value_to_bool(b, idx),
3838        (a, Value::Boolean(b)) => value_to_bool(a, idx) == *b,
3839        // Numeric equality is by value, not by kind — `xs:integer 1`
3840        // equals `xs:double 1.0` (F&O numeric-equal promotes both to a
3841        // common type).  `Numeric`'s derived `==` is structural and
3842        // would treat the kinds as distinct, so compare the f64 views.
3843        (Value::Number(a), Value::Number(b)) => a.as_f64() == b.as_f64(),
3844        (Value::Number(a), Value::String(b)) => a.as_f64() == b.trim().parse::<f64>().unwrap_or(f64::NAN),
3845        (Value::String(a), Value::Number(b)) => a.trim().parse::<f64>().unwrap_or(f64::NAN) == b.as_f64(),
3846        (Value::String(a), Value::String(b)) => str_eq(a, b),
3847        // Typed atomics: numeric typed compares numerically against
3848        // any numeric/typed-numeric counterpart, falls back to
3849        // string-equal otherwise.  No clones — read straight out of
3850        // the boxed TypedAtomic.
3851        (Value::Typed(t), Value::Typed(u)) => {
3852            match (t.numeric, u.numeric) {
3853                (Some(a), Some(b)) => a == b,
3854                _ => {
3855                    // Date / dateTime / time equality is semantic, not
3856                    // lexical — `1996-12-12T13:13:00Z` equals
3857                    // `1996-12-12T13:13:00+00:00` even though their
3858                    // text forms differ.  Normalise to UTC seconds
3859                    // (via the existing date parser) when both
3860                    // operands carry a date-like kind.
3861                    let date_eq = matches!(t.kind, "date" | "dateTime" | "time")
3862                               && matches!(u.kind, "date" | "dateTime" | "time")
3863                               && t.kind == u.kind;
3864                    if date_eq {
3865                        if let (Some(a), Some(b)) = (
3866                            dt_to_utc_seconds(&t.lexical, t.kind),
3867                            dt_to_utc_seconds(&u.lexical, u.kind),
3868                        ) {
3869                            return a == b;
3870                        }
3871                    }
3872                    // Duration equality: normalise to total seconds
3873                    // (dayTimeDuration) — yearMonthDuration uses a
3874                    // separate month count we don't unify here.
3875                    if t.kind == "dayTimeDuration" && u.kind == "dayTimeDuration" {
3876                        if let (Some(a), Some(b)) = (
3877                            parse_day_time_duration_secs(&t.lexical),
3878                            parse_day_time_duration_secs(&u.lexical),
3879                        ) {
3880                            return a == b;
3881                        }
3882                    }
3883                    // `xs:duration` (the union type) keeps both
3884                    // year-month and day-time components.  Two
3885                    // durations are equal when each component
3886                    // matches independently (XPath 2.0 §10.4.3).
3887                    if matches!((t.kind, u.kind),
3888                        ("duration", "duration")
3889                        | ("duration", "dayTimeDuration") | ("dayTimeDuration", "duration")
3890                        | ("duration", "yearMonthDuration") | ("yearMonthDuration", "duration"))
3891                    {
3892                        if let (Some(a), Some(b)) = (
3893                            parse_duration_split(&t.lexical),
3894                            parse_duration_split(&u.lexical),
3895                        ) {
3896                            return a == b;
3897                        }
3898                    }
3899                    str_eq(&t.lexical, &u.lexical)
3900                }
3901            }
3902        }
3903        (Value::Typed(t), Value::Number(n)) | (Value::Number(n), Value::Typed(t)) => {
3904            t.numeric.map(|a| a == n.as_f64())
3905                .unwrap_or_else(|| t.lexical.trim().parse::<f64>().ok() == Some(n.as_f64()))
3906        }
3907        (Value::Typed(t), Value::String(s)) | (Value::String(s), Value::Typed(t)) => {
3908            str_eq(&t.lexical, s)
3909        }
3910        // IntRange operands are normalised away at function entry.
3911        (Value::IntRange { .. }, _) | (_, Value::IntRange { .. }) =>
3912            unreachable!("IntRange normalised at values_eq entry"),
3913    }
3914}
3915
3916/// A canonical key for value-equality grouping/distinct (XSLT 2.0
3917/// §14.3 group-by uses the `eq` operator).  Returns `Some` only for
3918/// the typed values whose `eq` semantics differ from their lexical
3919/// string: temporal values normalise to a UTC instant (so two
3920/// dateTimes in different time zones for the same instant share a
3921/// key), durations to their total magnitude.  `None` for everything
3922/// else — the caller uses the string-value, which already matches
3923/// `eq` for strings / numbers / booleans.
3924pub fn value_equality_key(v: &Value) -> Option<String> {
3925    let t = match v { Value::Typed(t) => t, _ => return None };
3926    match t.kind {
3927        "date" | "dateTime" | "time" =>
3928            dt_to_utc_seconds(&t.lexical, t.kind).map(|s| format!("{}#{s}", t.kind)),
3929        "dayTimeDuration" =>
3930            parse_day_time_duration_secs(&t.lexical).map(|s| format!("dtd#{s}")),
3931        "yearMonthDuration" =>
3932            parse_year_month_duration_months(&t.lexical).map(|m| format!("ymd#{m}")),
3933        _ => None,
3934    }
3935}
3936
3937/// Parse an `xs:date` / `xs:dateTime` / `xs:time` lexical form
3938/// to a UTC second count (since the Unix epoch for date / dateTime;
3939/// since midnight UTC for time).  Returns `None` when the input
3940/// doesn't parse — caller falls back to lexical comparison.
3941fn dt_to_utc_seconds(s: &str, kind: &str) -> Option<i64> {
3942    let dk = match kind {
3943        "date"     => DateKind::Date,
3944        "dateTime" => DateKind::DateTime,
3945        "time"     => DateKind::Time,
3946        _ => return None,
3947    };
3948    let (y, mo, d, h, mi, sec, _frac, tz) = parse_xsd_date_time(s, dk)?;
3949    // For xs:time, the date portion is unbound; treat as 1970-01-01
3950    // so comparisons of same-tz times still come out right.
3951    let (yy, mm, dd) = if matches!(dk, DateKind::Time) {
3952        (1970, 1, 1)
3953    } else { (y, mo, d) };
3954    let days = ymd_to_days(yy, mm as u32, dd as u32);
3955    let secs_in_day = (h as i64) * 3600 + (mi as i64) * 60 + sec as i64;
3956    let local_total = days * 86_400 + secs_in_day;
3957    // Timezone offset is in minutes east of UTC; subtract to get
3958    // UTC.  Absent timezone defers to implementation; treat as UTC.
3959    let tz_offset_secs = tz.map(|m| m as i64 * 60).unwrap_or(0);
3960    Some(local_total - tz_offset_secs)
3961}
3962
3963/// True iff the typed value's kind is one of `xs:date`,
3964/// `xs:dateTime`, `xs:time`, `xs:gYear`, `xs:gYearMonth`,
3965/// `xs:gMonth`, `xs:gMonthDay`, `xs:gDay` — anything date-like
3966/// that day-arithmetic can shift.
3967fn is_date_like_kind(k: &str) -> bool {
3968    matches!(k, "date" | "dateTime" | "time"
3969              | "gYear" | "gYearMonth" | "gMonth" | "gMonthDay" | "gDay")
3970}
3971
3972/// True iff `k` is one of the three xs:duration types.
3973fn is_duration_kind(k: &str) -> bool {
3974    matches!(k, "duration" | "dayTimeDuration" | "yearMonthDuration")
3975}
3976
3977/// Parse an `xs:dayTimeDuration` lexical form (e.g. `"P10D"`,
3978/// `"-PT3H"`, `"PT5M"`) into a signed second count.  Returns
3979/// `None` when the input doesn't parse — caller falls back to
3980/// numeric arithmetic.
3981fn parse_day_time_duration_secs(s: &str) -> Option<i64> {
3982    let s = s.trim();
3983    let (sign, body) = if let Some(rest) = s.strip_prefix('-') {
3984        (-1i64, rest)
3985    } else { (1i64, s) };
3986    let body = body.strip_prefix('P')?;
3987    let (day_part, time_part) = match body.find('T') {
3988        Some(i) => (&body[..i], &body[i + 1..]),
3989        None    => (body, ""),
3990    };
3991    let parse_comp = |part: &str, marker: char| -> Option<i64> {
3992        let i = match part.find(marker) { Some(i) => i, None => return Some(0) };
3993        let start = part[..i].rfind(|c: char| !c.is_ascii_digit() && c != '.')
3994            .map(|n| n + 1).unwrap_or(0);
3995        Some(part[start..i].parse::<i64>().unwrap_or(0))
3996    };
3997    let days  = parse_comp(day_part,  'D')?;
3998    let hours = parse_comp(time_part, 'H')?;
3999    let mins  = parse_comp(time_part, 'M')?;
4000    let secs  = parse_comp(time_part, 'S')?;
4001    Some(sign * (days * 86_400 + hours * 3600 + mins * 60 + secs))
4002}
4003
4004/// Sum a uniform sequence of `xs:dayTimeDuration` / `xs:yearMonthDuration`
4005/// values, returning `(kind, total_units, count)` — seconds for
4006/// dayTime, months for yearMonth.  `None` if the items aren't all the
4007/// same duration kind (so the caller falls back to numeric coercion).
4008/// Used by `fn:sum` / `fn:avg` over durations (F&O §10.4).
4009fn duration_seq_total(items: &[Value]) -> Option<(&'static str, i64, i64)> {
4010    let kind = match items.first()? {
4011        Value::Typed(t) if matches!(t.kind, "dayTimeDuration" | "yearMonthDuration") => t.kind,
4012        _ => return None,
4013    };
4014    let mut total: i64 = 0;
4015    for v in items {
4016        let t = match v {
4017            Value::Typed(t) if t.kind == kind => t,
4018            _ => return None,
4019        };
4020        total += if kind == "dayTimeDuration" {
4021            parse_day_time_duration_secs(&t.lexical)?
4022        } else {
4023            parse_year_month_duration_months(&t.lexical)?
4024        };
4025    }
4026    Some((kind, total, items.len() as i64))
4027}
4028
4029/// Build a duration `Value` of `kind` from a unit count (seconds for
4030/// dayTime, months for yearMonth).
4031fn duration_value(kind: &'static str, units: i64) -> Value {
4032    let lexical = if kind == "dayTimeDuration" {
4033        format_day_time_duration_secs(units)
4034    } else {
4035        format_year_month_duration_months(units)
4036    };
4037    Value::Typed(Box::new(TypedAtomic { kind, lexical, numeric: None, boolean: None, user_type: None }))
4038}
4039
4040/// Format a signed second-count back into `xs:dayTimeDuration`
4041/// canonical lexical form.
4042fn format_day_time_duration_secs(mut total: i64) -> String {
4043    let mut out = String::with_capacity(16);
4044    if total < 0 { out.push('-'); total = -total; }
4045    out.push('P');
4046    let days = total / 86_400;
4047    let rem  = total % 86_400;
4048    if days > 0 { out.push_str(&days.to_string()); out.push('D'); }
4049    if rem > 0 || days == 0 {
4050        out.push('T');
4051        let h = rem / 3600;
4052        let m = (rem % 3600) / 60;
4053        let s = rem % 60;
4054        if h > 0 { out.push_str(&h.to_string()); out.push('H'); }
4055        if m > 0 { out.push_str(&m.to_string()); out.push('M'); }
4056        if s > 0 || (h == 0 && m == 0) { out.push_str(&s.to_string()); out.push('S'); }
4057    }
4058    out
4059}
4060
4061/// Parse an `xs:date` lexical form (`YYYY-MM-DD[Z|±HH:MM]`) to
4062/// (year, month, day, tz_minutes_or_none).
4063fn parse_xsd_date_only(s: &str) -> Option<(i32, u32, u32, Option<i16>)> {
4064    let s = s.trim();
4065    let (sign, body) = if let Some(rest) = s.strip_prefix('-') {
4066        (-1i32, rest)
4067    } else { (1i32, s) };
4068    let parts: Vec<&str> = body.splitn(3, '-').collect();
4069    if parts.len() != 3 { return None; }
4070    let y: i32 = parts[0].parse().ok()?;
4071    let m: u32 = parts[1].parse().ok()?;
4072    // parts[2] is "DD" possibly followed by "Z" or "±HH:MM".
4073    let day_tail = parts[2];
4074    let (d_str, tz_tail) = if day_tail.len() >= 2 {
4075        (&day_tail[..2], &day_tail[2..])
4076    } else { return None; };
4077    let d: u32 = d_str.parse().ok()?;
4078    let signed_year = sign * y;
4079    // XSD §3.2.7 — month must be 1..12; day must be in the legal
4080    // range for the given (year, month) pair, honouring February's
4081    // leap-year exception.  Without these bounds checks an invalid
4082    // lexical like `2002-02-29` would silently materialise as a
4083    // typed date, defeating `castable as xs:date` test cases.
4084    if !(1..=12).contains(&m) { return None; }
4085    let max_day = days_in_month(signed_year, m);
4086    if !(1..=max_day).contains(&d) { return None; }
4087    let tz = parse_tz_suffix(tz_tail);
4088    Some((signed_year, m, d, tz))
4089}
4090
4091/// Days-from-1970-01-01 for a (year, month, day) — proleptic
4092/// Gregorian.  Inverse of [`days_to_ymd`].  Used so date+duration
4093/// arithmetic can stay in integer days without pulling in a date
4094/// crate dependency.
4095fn ymd_to_days(y: i32, m: u32, d: u32) -> i64 {
4096    // Howard Hinnant's "days_from_civil" (CC0).
4097    let y = if m <= 2 { y - 1 } else { y } as i64;
4098    let era = if y >= 0 { y } else { y - 399 } / 400;
4099    let yoe = (y - era * 400) as u64;
4100    let m = m as u64;
4101    let d = d as u64;
4102    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
4103    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
4104    era * 146097 + doe as i64 - 719468
4105}
4106
4107/// XPath 2.0 §10.7 `date + xs:dayTimeDuration` etc.  Returns
4108/// `None` when the operands aren't a recognised date-arithmetic
4109/// pair — caller falls through to numeric.
4110/// Best-effort coercion of an arbitrary `Value` to `xs:double`
4111/// for duration arithmetic.  Strings / synthetic-text nodes
4112/// parse via [`f64::from_str`]; typed atomics consult their
4113/// cached numeric value first.  Returns `None` when no numeric
4114/// representation makes sense — caller falls back to numeric
4115/// promotion / NaN.
4116fn coerce_to_double(v: &Value) -> Option<f64> {
4117    match v {
4118        Value::Number(n)  => Some(n.as_f64()),
4119        Value::Boolean(b) => Some(if *b { 1.0 } else { 0.0 }),
4120        Value::Typed(t)   => t.numeric.or_else(|| t.lexical.trim().parse().ok()),
4121        Value::String(s)  => s.trim().parse().ok(),
4122        // NodeSet with one synthetic-text item — common when the
4123        // value comes from `xsl:for-each select="1 to N"` (the
4124        // dot is a synthetic text node holding the integer).
4125        Value::NodeSet(ns) if ns.len() == 1 => {
4126            // We can't read the synthetic store from here; the
4127            // caller can usually pass us a Number directly.
4128            // Stringification is handled by the calling
4129            // typed-numeric path; returning None lets it through.
4130            let _ = ns;
4131            None
4132        }
4133        _ => None,
4134    }
4135}
4136
4137/// Parse an `xs:yearMonthDuration` lexical (`PnYnM`, optional
4138/// `-` prefix) into a signed month count.  Returns `None` on
4139/// malformed input.
4140/// Parse an `xs:duration` lexical into `(months, seconds)`.  Both
4141/// components carry the duration's sign (they always agree because
4142/// the XSD lexical form has one optional leading minus).
4143/// Returns `None` on malformed input.
4144fn parse_duration_split(s: &str) -> Option<(i64, i64)> {
4145    let s = s.trim();
4146    let (sign, body) = if let Some(rest) = s.strip_prefix('-') {
4147        (-1i64, rest)
4148    } else { (1i64, s) };
4149    let body = body.strip_prefix('P')?;
4150    let (date_part, time_part) = match body.find('T') {
4151        Some(i) => (&body[..i], &body[i + 1..]),
4152        None    => (body, ""),
4153    };
4154    let mut years: i64 = 0;
4155    let mut months: i64 = 0;
4156    let mut days: i64 = 0;
4157    let mut cur = String::new();
4158    for c in date_part.chars() {
4159        if c.is_ascii_digit() { cur.push(c); }
4160        else {
4161            let n: i64 = cur.parse().ok()?;
4162            cur.clear();
4163            match c {
4164                'Y' => years = n,
4165                'M' => months = n,
4166                'D' => days = n,
4167                _ => return None,
4168            }
4169        }
4170    }
4171    if !cur.is_empty() { return None; }
4172    let mut hours: i64 = 0;
4173    let mut mins:  i64 = 0;
4174    let mut secs:  i64 = 0;
4175    cur.clear();
4176    let mut frac: f64 = 0.0;
4177    let mut in_frac = false;
4178    let mut frac_str = String::new();
4179    for c in time_part.chars() {
4180        if c.is_ascii_digit() {
4181            if in_frac { frac_str.push(c); }
4182            else        { cur.push(c); }
4183        } else if c == '.' {
4184            in_frac = true;
4185        } else {
4186            let n: i64 = cur.parse().ok()?;
4187            cur.clear();
4188            if !frac_str.is_empty() {
4189                let denom = 10f64.powi(frac_str.len() as i32);
4190                frac = frac_str.parse::<f64>().unwrap_or(0.0) / denom;
4191                frac_str.clear();
4192            }
4193            in_frac = false;
4194            match c {
4195                'H' => hours = n,
4196                'M' => mins  = n,
4197                'S' => secs  = n + frac.round() as i64,
4198                _ => return None,
4199            }
4200        }
4201    }
4202    if !cur.is_empty() { return None; }
4203    Some((sign * (years * 12 + months),
4204          sign * (days * 86_400 + hours * 3600 + mins * 60 + secs)))
4205}
4206
4207fn parse_year_month_duration_months(s: &str) -> Option<i64> {
4208    let (neg, rest) = match s.strip_prefix('-') {
4209        Some(r) => (true, r),
4210        None    => (false, s),
4211    };
4212    let rest = rest.strip_prefix('P')?;
4213    let mut years: i64 = 0;
4214    let mut months: i64 = 0;
4215    let mut cur = String::new();
4216    let mut any_field = false;
4217    for c in rest.chars() {
4218        if c.is_ascii_digit() {
4219            cur.push(c);
4220        } else if c == 'Y' {
4221            years = cur.parse().ok()?;
4222            cur.clear();
4223            any_field = true;
4224        } else if c == 'M' {
4225            months = cur.parse().ok()?;
4226            cur.clear();
4227            any_field = true;
4228        } else {
4229            // Stray character — likely a day/time designator
4230            // that doesn't belong in yearMonthDuration.
4231            return None;
4232        }
4233    }
4234    if !any_field || !cur.is_empty() { return None; }
4235    let total = years * 12 + months;
4236    Some(if neg { -total } else { total })
4237}
4238
4239/// Format a signed month count as an `xs:yearMonthDuration`
4240/// lexical (`P[-]nYnM`).  Empty fields are elided per XSD §F:
4241/// a zero-month value is rendered `P0M`.
4242fn format_year_month_duration_months(months: i64) -> String {
4243    if months == 0 { return "P0M".into(); }
4244    let neg = months < 0;
4245    let abs = months.unsigned_abs() as u64;
4246    let years  = abs / 12;
4247    let months = abs % 12;
4248    let mut out = String::new();
4249    if neg { out.push('-'); }
4250    out.push('P');
4251    if years  > 0 { out.push_str(&format!("{years}Y")); }
4252    if months > 0 { out.push_str(&format!("{months}M")); }
4253    if out.ends_with('P') {
4254        // Both zero — shouldn't reach here given the early
4255        // return, but keep the lexical form well-formed.
4256        out.push_str("0M");
4257    }
4258    out
4259}
4260
4261/// XPath 2.0 §10.6 — duration multiplied by (or by) a numeric.
4262/// Routes both `duration * number` and `number * duration` to
4263/// the same scaling; for `yearMonthDuration` we scale the month
4264/// count, for `dayTimeDuration` we scale the seconds count.
4265/// Returns `None` when neither operand is a Typed duration.
4266/// Round a month count to the nearest integer with ties resolved
4267/// toward positive infinity — the `fn:round` rule that XPath 2.0
4268/// §10.6 mandates for scaling an xs:yearMonthDuration.  Rust's
4269/// `f64::round` rounds half *away from zero* (`-0.5 → -1`), which
4270/// disagrees on negative halves (`fn:round(-0.5)` is `0`).
4271fn round_months_half_up(x: f64) -> i64 {
4272    (x + 0.5).floor() as i64
4273}
4274
4275fn duration_mul<I: DocIndexLike>(
4276    l: &Value, r: &Value, idx: &I, bindings: &dyn XPathBindings,
4277) -> Option<Value> {
4278    let (dur, num) = match (l, r) {
4279        (Value::Typed(d), other) if is_duration_kind(d.kind) => (d, other),
4280        (other, Value::Typed(d)) if is_duration_kind(d.kind) => (d, other),
4281        _ => return None,
4282    };
4283    let factor = coerce_to_double(num)
4284        .unwrap_or_else(|| value_to_number_with(num, idx, bindings));
4285    if !factor.is_finite() { return None; }
4286    if dur.kind == "yearMonthDuration" {
4287        let months = parse_year_month_duration_months(&dur.lexical)?;
4288        let scaled = round_months_half_up(months as f64 * factor);
4289        return Some(Value::Typed(Box::new(TypedAtomic {
4290            kind: "yearMonthDuration",
4291            lexical: format_year_month_duration_months(scaled),
4292            numeric: None, boolean: None, user_type: None,
4293        })));
4294    }
4295    // dayTimeDuration scales in microseconds so fractional seconds
4296    // (`PT5.015S`) survive the multiply.
4297    let us = parse_day_time_duration_micros(&dur.lexical)?;
4298    let scaled = (us as f64 * factor).round() as i64;
4299    Some(Value::Typed(Box::new(TypedAtomic {
4300        kind: "dayTimeDuration",
4301        lexical: canonical_day_time_duration_lex(&format_day_time_duration_micros(scaled)),
4302        numeric: None, boolean: None, user_type: None,
4303    })))
4304}
4305
4306/// XPath 2.0 §10.6.2 — duration divided by number (scales the
4307/// duration) or by another duration (returns the ratio as
4308/// xs:decimal).
4309fn duration_div<I: DocIndexLike>(
4310    l: &Value, r: &Value, idx: &I, bindings: &dyn XPathBindings,
4311) -> Option<Value> {
4312    match (l, r) {
4313        (Value::Typed(a), Value::Typed(b))
4314            if is_duration_kind(a.kind) && is_duration_kind(b.kind) =>
4315        {
4316            // duration / duration → xs:decimal (ratio).
4317            let (av, bv): (f64, f64) = if a.kind == "yearMonthDuration" {
4318                let am = parse_year_month_duration_months(&a.lexical)?;
4319                let bm = parse_year_month_duration_months(&b.lexical)?;
4320                (am as f64, bm as f64)
4321            } else {
4322                let av = parse_day_time_duration_micros(&a.lexical)?;
4323                let bv = parse_day_time_duration_micros(&b.lexical)?;
4324                (av as f64, bv as f64)
4325            };
4326            if bv == 0.0 { return None; }
4327            Some(Value::Number(Numeric::Double(av / bv)))
4328        }
4329        (Value::Typed(d), other) if is_duration_kind(d.kind) => {
4330            let factor = coerce_to_double(other)
4331                .unwrap_or_else(|| value_to_number_with(other, idx, bindings));
4332            if !factor.is_finite() || factor == 0.0 { return None; }
4333            if d.kind == "yearMonthDuration" {
4334                let months = parse_year_month_duration_months(&d.lexical)?;
4335                let scaled = round_months_half_up(months as f64 / factor);
4336                return Some(Value::Typed(Box::new(TypedAtomic {
4337                    kind: "yearMonthDuration",
4338                    lexical: format_year_month_duration_months(scaled),
4339                    numeric: None, boolean: None, user_type: None,
4340                })));
4341            }
4342            let us = parse_day_time_duration_micros(&d.lexical)?;
4343            let scaled = (us as f64 / factor).round() as i64;
4344            Some(Value::Typed(Box::new(TypedAtomic {
4345                kind: "dayTimeDuration",
4346                lexical: canonical_day_time_duration_lex(&format_day_time_duration_micros(scaled)),
4347                numeric: None, boolean: None, user_type: None,
4348            })))
4349        }
4350        _ => None,
4351    }
4352}
4353
4354/// XPath 2.0 §10.5 / §10.7 — add `months` months to the given
4355/// `(year, month, day)`, normalising the month rollover.  Days
4356/// clamp to the last day of the target month (XSD's "month-
4357/// arithmetic" rule: 2003-01-31 + P1M → 2003-02-28).
4358fn add_months_to_ymd(y: i32, m: u32, d: u32, months: i64) -> (i32, u32, u32) {
4359    let total_months = (y as i64) * 12 + (m as i64) - 1 + months;
4360    let ny = total_months.div_euclid(12) as i32;
4361    let nm = total_months.rem_euclid(12) as u32 + 1;
4362    let last = days_in_month(ny, nm);
4363    let nd = d.min(last);
4364    (ny, nm, nd)
4365}
4366
4367fn days_in_month(y: i32, m: u32) -> u32 {
4368    match m {
4369        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4370        4 | 6 | 9 | 11              => 30,
4371        2 => if is_leap_year(y) { 29 } else { 28 },
4372        _ => 0,
4373    }
4374}
4375
4376fn is_leap_year(y: i32) -> bool {
4377    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
4378}
4379
4380/// Advance a proleptic-Gregorian `(year, month, day)` by one calendar
4381/// day, rolling month and year boundaries.  Used to normalise the
4382/// `24:00:00` midnight form of xs:dateTime onto the following day.
4383fn add_one_day(year: i32, month: u8, day: u8) -> (i32, u8, u8) {
4384    if (day as u32) < days_in_month(year, month as u32) {
4385        (year, month, day + 1)
4386    } else if month < 12 {
4387        (year, month + 1, 1)
4388    } else {
4389        (year + 1, 1, 1)
4390    }
4391}
4392
4393/// XPath 2.0 §10.4 — add or subtract two `xs:duration` values.  The
4394/// operation is only defined within a single sub-family:
4395/// `xs:yearMonthDuration` combines by month count, `xs:dayTimeDuration`
4396/// by microseconds (preserving fractional seconds).  Mixing the two
4397/// families — or a bare `xs:duration` — has no defined sum, so this
4398/// returns `None` (the caller surfaces a type error / no-op).
4399fn duration_combine(a: &TypedAtomic, b: &TypedAtomic, subtract: bool) -> Option<Value> {
4400    let mk = |kind: &'static str, lexical: String| {
4401        Value::Typed(Box::new(TypedAtomic { kind, lexical, numeric: None, boolean: None, user_type: None }))
4402    };
4403    match (a.kind, b.kind) {
4404        ("yearMonthDuration", "yearMonthDuration") => {
4405            let lm = parse_year_month_duration_months(&a.lexical)?;
4406            let rm = parse_year_month_duration_months(&b.lexical)?;
4407            // A sum outside i64 range has no representable result; surface
4408            // it as a type error rather than overflowing.
4409            let m = if subtract { lm.checked_sub(rm) } else { lm.checked_add(rm) }?;
4410            Some(mk("yearMonthDuration", format_year_month_duration_months(m)))
4411        }
4412        ("dayTimeDuration", "dayTimeDuration") => {
4413            let lu = parse_day_time_duration_micros(&a.lexical)?;
4414            let ru = parse_day_time_duration_micros(&b.lexical)?;
4415            let u = if subtract { lu.checked_sub(ru) } else { lu.checked_add(ru) }?;
4416            let lex = canonical_day_time_duration_lex(
4417                &format_day_time_duration_micros(i64::try_from(u).ok()?));
4418            Some(mk("dayTimeDuration", lex))
4419        }
4420        _ => None,
4421    }
4422}
4423
4424fn date_arith_add(l: &Value, r: &Value) -> Option<Value> {
4425    let (date, dur, date_kind) = match (l, r) {
4426        (Value::Typed(a), Value::Typed(b))
4427            if is_date_like_kind(a.kind) && is_duration_kind(b.kind)
4428            => (a, b, a.kind),
4429        (Value::Typed(a), Value::Typed(b))
4430            if is_duration_kind(a.kind) && is_date_like_kind(b.kind)
4431            => (b, a, b.kind),
4432        // duration + duration → duration (same sub-family only).
4433        (Value::Typed(a), Value::Typed(b))
4434            if is_duration_kind(a.kind) && is_duration_kind(b.kind) =>
4435        {
4436            return duration_combine(a, b, false);
4437        }
4438        _ => return None,
4439    };
4440    match date_kind {
4441        "date" => {
4442            let (y, m, d, _tz) = parse_xsd_date_only(&date.lexical)?;
4443            // yearMonthDuration shifts by month count; day-of-
4444            // month clamps per XSD §F.
4445            if dur.kind == "yearMonthDuration" {
4446                let months = parse_year_month_duration_months(&dur.lexical)?;
4447                let (ny, nm, nd) = add_months_to_ymd(y, m, d, months);
4448                let lex = format!("{:04}-{:02}-{:02}", ny, nm, nd);
4449                return Some(Value::Typed(Box::new(TypedAtomic {
4450                    kind: "date", lexical: lex, numeric: None, boolean: None, user_type: None,
4451                })));
4452            }
4453            let sec = parse_day_time_duration_secs(&dur.lexical)?;
4454            // Whole-day delta — xs:date has no sub-day precision.
4455            let day_delta = sec / 86_400;
4456            let new_days = ymd_to_days(y, m, d) + day_delta;
4457            let (ny, nm, nd) = days_to_ymd(new_days);
4458            let lex = format!("{:04}-{:02}-{:02}", ny, nm, nd);
4459            Some(Value::Typed(Box::new(TypedAtomic {
4460                kind: "date", lexical: lex, numeric: None, boolean: None, user_type: None,
4461            })))
4462        }
4463        "time" => {
4464            // xs:time + dayTimeDuration → xs:time (modulo 24h).
4465            // yearMonthDuration + time is undefined (the spec
4466            // rejects it as a static type error).  Work in
4467            // microseconds so a duration's sub-second component
4468            // (`PT…0.3S`) survives the wrap, mirroring the dateTime
4469            // arm below.
4470            if dur.kind == "yearMonthDuration" { return None; }
4471            let dur_us = parse_day_time_duration_micros(&dur.lexical)?;
4472            let (h, m, s, time_frac, tz) = parse_xsd_time(&date.lexical)?;
4473            let day_us = ((h as i128) * 3600 + (m as i128) * 60 + s as i128)
4474                * 1_000_000 + time_frac as i128;
4475            let total_us = (day_us + dur_us).rem_euclid(86_400i128 * 1_000_000);
4476            let total = (total_us / 1_000_000) as i64;
4477            let frac = (total_us % 1_000_000) as u32;
4478            let nh = (total / 3600) as u8;
4479            let nm = ((total / 60) % 60) as u8;
4480            let ns = (total % 60) as u8;
4481            let lex = if frac == 0 {
4482                let mut l = format!("{:02}:{:02}:{:02}", nh, nm, ns);
4483                if let Some(tz_m) = tz { l.push_str(&format_tz_suffix(tz_m)); }
4484                l
4485            } else {
4486                let mut l = format!("{:02}:{:02}:{:02}.{:06}", nh, nm, ns, frac);
4487                while l.ends_with('0') { l.pop(); }
4488                if let Some(tz_m) = tz { l.push_str(&format_tz_suffix(tz_m)); }
4489                l
4490            };
4491            Some(Value::Typed(Box::new(TypedAtomic {
4492                kind: "time", lexical: lex, numeric: None, boolean: None, user_type: None,
4493            })))
4494        }
4495        "dateTime" => {
4496            // xs:dateTime + xs:dayTimeDuration → xs:dateTime
4497            // (proper carry across midnight).  yearMonthDuration
4498            // shifts the date portion's month with day clamping.
4499            if dur.kind == "yearMonthDuration" {
4500                let months = parse_year_month_duration_months(&dur.lexical)?;
4501                let (y, mo, d, h, mi, s, frac, tz) =
4502                    parse_xsd_date_time(&date.lexical, DateKind::DateTime)?;
4503                let (ny, nm, nd) = add_months_to_ymd(y, mo as u32, d as u32, months);
4504                let lex = format_datetime_lexical(ny, nm as u8, nd as u8, h, mi, s, frac, tz);
4505                return Some(Value::Typed(Box::new(TypedAtomic {
4506                    kind: "dateTime", lexical: lex, numeric: None, boolean: None, user_type: None,
4507                })));
4508            }
4509            // xs:dateTime + xs:dayTimeDuration with sub-second
4510            // precision: convert both sides to microseconds, add,
4511            // then split back into (date, time, fractional).  Using
4512            // `parse_day_time_duration_secs` here would round away
4513            // the fractional component the test data exercises.
4514            let dur_us = parse_day_time_duration_micros(&dur.lexical)?;
4515            let (y, mo, d, h, mi, s, frac, tz) =
4516                parse_xsd_date_time(&date.lexical, DateKind::DateTime)?;
4517            let day_us = ((h as i128) * 3600 + (mi as i128) * 60 + s as i128)
4518                * 1_000_000 + (frac as i128);
4519            let total = day_us + dur_us;
4520            let us_per_day = 86_400i128 * 1_000_000;
4521            let day_delta = total.div_euclid(us_per_day);
4522            let remain   = total.rem_euclid(us_per_day);
4523            let new_days = ymd_to_days(y, mo as u32, d as u32) + day_delta as i64;
4524            let (ny, nm, nd) = days_to_ymd(new_days);
4525            let remain_secs = (remain / 1_000_000) as i64;
4526            let new_frac = (remain % 1_000_000) as u32;
4527            let nh = (remain_secs / 3600) as u8;
4528            let nmi = ((remain_secs / 60) % 60) as u8;
4529            let ns = (remain_secs % 60) as u8;
4530            let lex = format_datetime_lexical(ny, nm as u8, nd as u8, nh, nmi, ns, new_frac, tz);
4531            Some(Value::Typed(Box::new(TypedAtomic {
4532                kind: "dateTime", lexical: lex, numeric: None, boolean: None, user_type: None,
4533            })))
4534        }
4535        _ => None,
4536    }
4537}
4538
4539/// Render the canonical lexical form of an xs:dateTime tuple
4540/// `YYYY-MM-DDTHH:MM:SS[.fff][TZ]`.  Caller is responsible for
4541/// computing the components (date arithmetic already normalized
4542/// them); we just stitch the string.
4543fn format_datetime_lexical(
4544    y: i32, mo: u8, d: u8, h: u8, mi: u8, s: u8, frac_us: u32, tz: Option<i16>,
4545) -> String {
4546    let mut out = format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
4547        y, mo, d, h, mi, s);
4548    if frac_us != 0 {
4549        let mut frac = format!(".{:06}", frac_us);
4550        while frac.ends_with('0') { frac.pop(); }
4551        out.push_str(&frac);
4552    }
4553    if let Some(tz) = tz {
4554        out.push_str(&format_tz_suffix(tz));
4555    }
4556    out
4557}
4558
4559/// Render a timezone offset (minutes east of UTC) as XSD's
4560/// `Z` / `±HH:MM` suffix.  Zero offset always renders as `Z`.
4561fn format_tz_suffix(minutes: i16) -> String {
4562    if minutes == 0 { return "Z".into(); }
4563    let sign = if minutes < 0 { '-' } else { '+' };
4564    let abs = minutes.unsigned_abs() as i32;
4565    format!("{sign}{:02}:{:02}", abs / 60, abs % 60)
4566}
4567
4568/// XPath 2.0 §10.7 / §10.4 — `date - date → dayTimeDuration`,
4569/// `date - duration → date`, `duration - duration → duration`.
4570fn date_arith_sub(l: &Value, r: &Value) -> Option<Value> {
4571    match (l, r) {
4572        // date - date → dayTimeDuration
4573        (Value::Typed(a), Value::Typed(b))
4574            if a.kind == "date" && b.kind == "date" =>
4575        {
4576            let (ay, am, ad, _) = parse_xsd_date_only(&a.lexical)?;
4577            let (by, bm, bd, _) = parse_xsd_date_only(&b.lexical)?;
4578            let diff_days = ymd_to_days(ay, am, ad) - ymd_to_days(by, bm, bd);
4579            let lex = format_day_time_duration_secs(diff_days * 86_400);
4580            Some(Value::Typed(Box::new(TypedAtomic {
4581                kind: "dayTimeDuration", lexical: lex,
4582                numeric: None, boolean: None, user_type: None,
4583            })))
4584        }
4585        // date - duration → date
4586        (Value::Typed(a), Value::Typed(b))
4587            if a.kind == "date" && is_duration_kind(b.kind) =>
4588        {
4589            let (y, m, d, _) = parse_xsd_date_only(&a.lexical)?;
4590            let sec = parse_day_time_duration_secs(&b.lexical)?;
4591            let day_delta = sec / 86_400;
4592            let new_days = ymd_to_days(y, m, d) - day_delta;
4593            let (ny, nm, nd) = days_to_ymd(new_days);
4594            let lex = format!("{:04}-{:02}-{:02}", ny, nm, nd);
4595            Some(Value::Typed(Box::new(TypedAtomic {
4596                kind: "date", lexical: lex, numeric: None, boolean: None, user_type: None,
4597            })))
4598        }
4599        // dateTime - duration → dateTime / time - duration → time —
4600        // implement as addition of the negated duration so the
4601        // sub-second / month-arithmetic logic stays in one place.
4602        (Value::Typed(a), Value::Typed(b))
4603            if matches!(a.kind, "dateTime" | "time") && is_duration_kind(b.kind) =>
4604        {
4605            let negated = TypedAtomic {
4606                kind: b.kind,
4607                lexical: negate_duration_lex(&b.lexical),
4608                numeric: None,
4609                boolean: None, user_type: None,
4610            };
4611            return date_arith_add(l, &Value::Typed(Box::new(negated)));
4612        }
4613        // dateTime - dateTime / date - date already returns a
4614        // dayTimeDuration above; date - dateTime is a type error
4615        // per spec.  date - time and time - date are also errors.
4616        (Value::Typed(a), Value::Typed(b))
4617            if matches!(a.kind, "dateTime" | "time")
4618                && matches!(b.kind, "dateTime" | "time") && a.kind == b.kind =>
4619        {
4620            // dateTime - dateTime → dayTimeDuration (UTC-normalised
4621            // microseconds difference).
4622            let dk = if a.kind == "dateTime" { DateKind::DateTime } else { DateKind::Time };
4623            let a_us = date_value_to_utc_micros(&a.lexical, dk)?;
4624            let b_us = date_value_to_utc_micros(&b.lexical, dk)?;
4625            let diff_us = (a_us - b_us) as i64;
4626            let lex = canonical_day_time_duration_lex(
4627                &format_day_time_duration_micros(diff_us)
4628            );
4629            Some(Value::Typed(Box::new(TypedAtomic {
4630                kind: "dayTimeDuration", lexical: lex,
4631                numeric: None, boolean: None, user_type: None,
4632            })))
4633        }
4634        // duration - duration → duration (same sub-family only).
4635        (Value::Typed(a), Value::Typed(b))
4636            if is_duration_kind(a.kind) && is_duration_kind(b.kind) =>
4637        {
4638            duration_combine(a, b, true)
4639        }
4640        _ => None,
4641    }
4642}
4643
4644/// Discriminator for typed-aware arithmetic — keeps the
4645/// operator identity around so the result type can follow XPath
4646/// 2.0 §6.2.2 promotion rules (div on two integers → decimal).
4647#[derive(Clone, Copy)]
4648#[allow(dead_code)]
4649enum NumericOp { Add, Sub, Mul, Div, Mod, IDiv }
4650
4651/// XPath 2.0 §6.2 numeric-type promotion: the result type is the
4652/// "widest" of the operand types under the lattice
4653/// `integer ⊂ decimal ⊂ float ⊂ double`.  Returns `None` when
4654/// neither operand carries a typed numeric tag — caller falls
4655/// back to plain `Value::Number` arithmetic.
4656fn numeric_promote_kind(a: Option<&str>, b: Option<&str>) -> Option<&'static str> {
4657    fn rank(k: &str) -> Option<u8> {
4658        Some(match k {
4659            "integer" | "long" | "int" | "short" | "byte"
4660            | "unsignedLong" | "unsignedInt" | "unsignedShort" | "unsignedByte"
4661            | "nonNegativeInteger" | "nonPositiveInteger"
4662            | "positiveInteger" | "negativeInteger" => 0, // integer family
4663            "decimal" => 1,
4664            "float"   => 2,
4665            "double"  => 3,
4666            _ => return None,
4667        })
4668    }
4669    let ra = a.and_then(rank);
4670    let rb = b.and_then(rank);
4671    let r = ra.max(rb)?;
4672    Some(match r {
4673        0 => "integer",
4674        1 => "decimal",
4675        2 => "float",
4676        _ => "double",
4677    })
4678}
4679
4680/// An integer-valued function result (`count`, `position`, `last`,
4681/// `string-length`, `idiv`, …).  XPath 2.0 types these as `xs:integer`
4682/// so `instance of xs:integer` holds; XPath 1.0 has only the `number`
4683/// (double) type, so it keeps the double form — that avoids leaking a
4684/// typed integer into a 1.0-only consumer and keeps 1.0 behaviour
4685/// byte-identical.
4686fn integer_result(n: i64, bindings: &dyn XPathBindings) -> Value {
4687    if bindings.xpath_version_2_or_later() {
4688        Value::Number(Numeric::Integer(n))
4689    } else {
4690        Value::Number(Numeric::Double(n as f64))
4691    }
4692}
4693
4694/// The XSD numeric kind of `v`, read from either a [`Value::Number`]
4695/// carrier or a typed-numeric [`Value::Typed`].  `None` for
4696/// non-numeric values (the caller then falls back to a plain double).
4697fn numeric_kind_of(v: &Value) -> Option<&'static str> {
4698    match v {
4699        Value::Number(n) => Some(n.kind()),
4700        // XPath 2.0 §6.2 — an xs:untypedAtomic operand is cast to
4701        // xs:double for arithmetic, so the result promotes as double
4702        // (otherwise `untypedAtomic('1.5') + 1` would wrongly yield an
4703        // xs:integer and truncate).
4704        Value::Typed(t) if t.kind == "untypedAtomic" => Some("double"),
4705        Value::Typed(t) if t.numeric.is_some() => Some(t.kind),
4706        Value::IntRange { .. } => Some("integer"),
4707        _ => None,
4708    }
4709}
4710
4711/// XPath 2.0 §6.2.4 — `div` / `idiv` / `mod` by zero raises
4712/// err:FOAR0001 when *both* operands are xs:integer or xs:decimal.
4713/// For xs:float / xs:double (and untyped operands, which atomise to
4714/// xs:double) the result is ±INF / NaN, not an error; XPath 1.0
4715/// compatibility mode is likewise all-double.
4716fn integer_decimal_zero_divisor<I: DocIndexLike>(
4717    l: &Value, r: &Value, idx: &I, bindings: &dyn XPathBindings,
4718) -> bool {
4719    if in_xpath_1_0_compat() { return false; }
4720    if value_to_number_with(r, idx, bindings) != 0.0 { return false; }
4721    let is_int_dec = |k| matches!(k, Some("integer") | Some("decimal"));
4722    is_int_dec(numeric_kind_of(l)) && is_int_dec(numeric_kind_of(r))
4723}
4724
4725/// Numeric binary op honouring XPath 1.0 backwards-compatibility:
4726/// in a [`Expr::BackwardsCompat`] scope the operands are atomised to
4727/// xs:double (XPath 2.0 §B.1), so the result is always xs:double.
4728/// Outside compat mode this defers to the typed promotion lattice.
4729/// True when `v` is the empty sequence — an empty node-set, foreign
4730/// node-set, or sequence.  XPath 2.0 §6.2 (op:numeric-* signatures):
4731/// an arithmetic operation with an empty-sequence operand returns the
4732/// empty sequence.  (XPath 1.0 instead coerces the empty node-set to
4733/// `NaN`, so this distinction is gated on 2.0 / non-compat at the call
4734/// site.)
4735fn value_atomizes_empty(v: &Value) -> bool {
4736    match v {
4737        Value::NodeSet(n)        => n.is_empty(),
4738        Value::ForeignNodeSet(n) => n.is_empty(),
4739        Value::Sequence(s)       => s.is_empty(),
4740        _ => false,
4741    }
4742}
4743
4744/// XPath 2.0 arithmetic short-circuit: in 2.0 (and not XPath 1.0
4745/// backwards-compatibility mode) an empty operand makes the whole
4746/// expression the empty sequence.
4747fn arith_empty_2_0(l: &Value, r: &Value, ctx: &EvalCtx) -> bool {
4748    ctx.static_ctx.xpath_2_0
4749        && !in_xpath_1_0_compat()
4750        && (value_atomizes_empty(l) || value_atomizes_empty(r))
4751}
4752
4753fn compat_numeric_op<I: DocIndexLike>(
4754    l: &Value, r: &Value, idx: &I, bindings: &dyn XPathBindings, op: NumericOp,
4755) -> Value {
4756    if in_xpath_1_0_compat() {
4757        let a = value_to_number_with(l, idx, bindings);
4758        let b = value_to_number_with(r, idx, bindings);
4759        let result = match op {
4760            NumericOp::Add => a + b,
4761            NumericOp::Sub => a - b,
4762            NumericOp::Mul => a * b,
4763            NumericOp::Div => a / b,
4764            NumericOp::Mod => a % b,
4765            NumericOp::IDiv => (a / b).trunc(),
4766        };
4767        return Value::Number(Numeric::Double(result));
4768    }
4769    typed_numeric_op(l, r, idx, bindings, op)
4770}
4771
4772/// XPath 2.0 §6.2 / XPTY0004 — arithmetic on an explicit xs:string
4773/// operand is a type error (strings don't promote to numeric the way
4774/// xs:untypedAtomic does).  Untyped atomics from node atomization
4775/// stay lenient; only literal-string operands are rejected here.
4776/// XPath 1.0 / 2.0-backwards-compat path stays permissive.
4777/// XPath 2.0 §3.5.2 / XPTY0004 — a general comparison between an
4778/// xs:string literal and a numeric value is a type error (untypedAtomic
4779/// from atomization stays lenient and converts via xs:double rules).
4780fn reject_string_vs_numeric_cmp_2_0(
4781    l: &Value, r: &Value, ctx: &EvalCtx, op: &str,
4782) -> Result<()> {
4783    if !ctx.static_ctx.xpath_2_0 || in_xpath_1_0_compat() {
4784        return Ok(());
4785    }
4786    let is_str   = |v: &Value| matches!(v, Value::String(_));
4787    let is_num   = |v: &Value| matches!(v, Value::Number(_))
4788        || matches!(v, Value::Typed(t) if t.numeric.is_some()
4789            && !matches!(t.kind, "untypedAtomic"));
4790    if (is_str(l) && is_num(r)) || (is_num(l) && is_str(r)) {
4791        return Err(xpath_err(format!(
4792            "general comparison '{op}' between an xs:string and a \
4793             numeric value (XPTY0004)"
4794        )).with_xpath_code("XPTY0004"));
4795    }
4796    Ok(())
4797}
4798
4799fn reject_string_arith_2_0(
4800    l: &Value, r: &Value, ctx: &EvalCtx, op: &str,
4801) -> Result<()> {
4802    if !ctx.static_ctx.xpath_2_0 || in_xpath_1_0_compat() {
4803        return Ok(());
4804    }
4805    for v in [l, r] {
4806        if let Value::String(s) = v {
4807            return Err(xpath_err(format!(
4808                "arithmetic '{op}' on an xs:string operand '{s}' (XPTY0004)"
4809            )).with_xpath_code("XPTY0004"));
4810        }
4811    }
4812    Ok(())
4813}
4814
4815/// Project `v` to an exact [`rust_decimal::Decimal`] when its type
4816/// promises one — `Value::Number(Integer | Decimal)` directly, and
4817/// `Value::Typed` of an integer-family or decimal kind by parsing
4818/// the preserved lexical form (so the value the user wrote survives
4819/// without an f64 detour).  Returns `None` for double/float/string/
4820/// untyped values, signalling that the caller must drop to the
4821/// lossy f64 path.
4822fn exact_decimal(v: &Value) -> Option<rust_decimal::Decimal> {
4823    match v {
4824        Value::Number(Numeric::Integer(i)) => Some(rust_decimal::Decimal::from(*i)),
4825        Value::Number(Numeric::Decimal(d)) => Some(*d),
4826        Value::Typed(t) if matches!(t.kind,
4827            "integer" | "decimal"
4828            | "int" | "long" | "short" | "byte"
4829            | "unsignedInt" | "unsignedLong" | "unsignedShort" | "unsignedByte"
4830            | "nonNegativeInteger" | "nonPositiveInteger"
4831            | "positiveInteger" | "negativeInteger"
4832        ) => t.lexical.parse().ok(),
4833        _ => None,
4834    }
4835}
4836
4837fn typed_numeric_op<I: DocIndexLike>(
4838    l: &Value, r: &Value, idx: &I, bindings: &dyn XPathBindings, op: NumericOp,
4839) -> Value {
4840    // XPath 2.0 §3.1.1 / §6.2 — when both operands are exact (integer
4841    // or decimal), arithmetic must stay exact: `0.1 + 0.2 = 0.3`, not
4842    // `0.30000000000000004`.  Drop to f64 only when a Double / Float
4843    // operand forces lossy semantics.
4844    if let (Some(da), Some(db)) = (exact_decimal(l), exact_decimal(r)) {
4845        use rust_decimal::prelude::ToPrimitive;
4846        let zero = db.is_zero();
4847        let exact = match op {
4848            NumericOp::Add  => da.checked_add(db),
4849            NumericOp::Sub  => da.checked_sub(db),
4850            NumericOp::Mul  => da.checked_mul(db),
4851            NumericOp::Div  if zero => None,
4852            NumericOp::Div  => da.checked_div(db),
4853            NumericOp::Mod  if zero => None,
4854            NumericOp::Mod  => da.checked_rem(db),
4855            NumericOp::IDiv if zero => None,
4856            NumericOp::IDiv => da.checked_div(db).map(|q| q.trunc()),
4857        };
4858        if let Some(d) = exact {
4859            // Result-type per XPath 2.0 §6.2: idiv → xs:integer;
4860            // div → xs:decimal (even from int÷int per §6.2.4);
4861            // other ops → xs:integer iff both operands integer,
4862            // else xs:decimal.
4863            let both_integer = matches!(
4864                (l, r),
4865                (Value::Number(Numeric::Integer(_)), Value::Number(Numeric::Integer(_)))
4866            );
4867            return match op {
4868                NumericOp::IDiv => match d.to_i64() {
4869                    Some(i) => Value::Number(Numeric::Integer(i)),
4870                    None    => Value::Number(Numeric::Decimal(d)),
4871                },
4872                NumericOp::Div  => Value::Number(Numeric::Decimal(d)),
4873                _ if both_integer => match d.to_i64() {
4874                    Some(i) => Value::Number(Numeric::Integer(i)),
4875                    None    => Value::Number(Numeric::Decimal(d)),
4876                },
4877                _ => Value::Number(Numeric::Decimal(d)),
4878            };
4879        }
4880        // Decimal overflow falls through to the f64 path below — the
4881        // caller gets a wider but lossy answer rather than an error.
4882    }
4883    let a = value_to_number_with(l, idx, bindings);
4884    let b = value_to_number_with(r, idx, bindings);
4885    let result = match op {
4886        NumericOp::Add => a + b,
4887        NumericOp::Sub => a - b,
4888        NumericOp::Mul => a * b,
4889        NumericOp::Div => a / b,
4890        NumericOp::Mod => a % b,
4891        NumericOp::IDiv => (a / b).trunc(),
4892    };
4893    // Fast path for the common case — both operands already carry a
4894    // `Numeric` kind — promotes via the integer rank instead of the
4895    // string-keyed lattice walk below.  `idiv` is always xs:integer;
4896    // `div` is at least xs:decimal (XPath 2.0 §6.2.4).
4897    if let (Value::Number(la), Value::Number(rb)) = (l, r) {
4898        let rank = match op {
4899            NumericOp::IDiv => 0,
4900            NumericOp::Div  => la.rank().max(rb.rank()).max(1),
4901            _               => la.rank().max(rb.rank()),
4902        };
4903        return Value::Number(Numeric::from_rank(rank, result));
4904    }
4905    // XPath 2.0 §6.2 promotion within the integer ⊂ decimal ⊂ float ⊂
4906    // double lattice; the result carries the promoted kind (so an
4907    // xs:double operand makes the result an xs:double, etc.).  `idiv`
4908    // is always xs:integer; `div` between two integers is xs:decimal
4909    // (XPath 2.0 §6.2.4).
4910    match numeric_promote_kind(numeric_kind_of(l), numeric_kind_of(r)) {
4911        Some(mut kind) => {
4912            if matches!(op, NumericOp::IDiv) {
4913                kind = "integer";
4914            } else if matches!(op, NumericOp::Div) && kind == "integer" {
4915                kind = "decimal";
4916            }
4917            Value::Number(Numeric::of_kind(kind, result))
4918        }
4919        None => Value::Number(Numeric::Double(result)),
4920    }
4921}
4922
4923#[allow(dead_code)]
4924fn arith<I: DocIndexLike>(
4925    l: &Expr,
4926    r: &Expr,
4927    ctx: &EvalCtx,
4928    idx: &I,
4929    op: impl Fn(f64, f64) -> f64,
4930) -> Result<Value> {
4931    let lv = eval_expr(l, ctx, idx)?;
4932    let rv = eval_expr(r, ctx, idx)?;
4933    Ok(Value::Number(Numeric::Double(op(
4934        value_to_number_with(&lv, idx, ctx.bindings),
4935        value_to_number_with(&rv, idx, ctx.bindings),
4936    ))))
4937}
4938
4939fn cmp_op<I: DocIndexLike>(
4940    l: &Expr,
4941    r: &Expr,
4942    ctx: &EvalCtx,
4943    idx: &I,
4944    op: impl Fn(f64, f64) -> bool,
4945) -> Result<Value> {
4946    let lv = eval_expr(l, ctx, idx)?;
4947    let rv = eval_expr(r, ctx, idx)?;
4948    reject_string_vs_numeric_cmp_2_0(&lv, &rv, ctx, "<=>")?;
4949    // XPath 1.0 §3.4 — when one operand of `<`/`<=`/`>`/`>=` is a
4950    // node-set, the test is true iff *some* node's numeric
4951    // string-value satisfies the relation against the other side.
4952    // The fallback path collapses both operands to a single number.
4953    let node_to_number = |id: NodeId| -> f64 {
4954        idx.string_value(id).trim().parse::<f64>().unwrap_or(f64::NAN)
4955    };
4956    let foreign_to_number = |p: ForeignNodePtr| -> f64 {
4957        ctx.bindings.foreign_string_value(p)
4958            .trim().parse::<f64>().unwrap_or(f64::NAN)
4959    };
4960    match (&lv, &rv) {
4961        (Value::NodeSet(ns), other) | (other, Value::NodeSet(ns))
4962            if !matches!(other, Value::NodeSet(_) | Value::ForeignNodeSet(_)) =>
4963        {
4964            let n_other = value_to_number_with(other, idx, ctx.bindings);
4965            let any = ns.iter().any(|&id| {
4966                let node_n = node_to_number(id);
4967                // Preserve operand order: if `l` was the node-set,
4968                // compare (node_n, n_other); otherwise (n_other, node_n).
4969                if matches!(lv, Value::NodeSet(_)) { op(node_n, n_other) }
4970                else { op(n_other, node_n) }
4971            });
4972            return Ok(Value::Boolean(any));
4973        }
4974        (Value::ForeignNodeSet(fs), other) | (other, Value::ForeignNodeSet(fs))
4975            if !matches!(other, Value::NodeSet(_) | Value::ForeignNodeSet(_)) =>
4976        {
4977            let n_other = value_to_number_with(other, idx, ctx.bindings);
4978            let any = fs.iter().any(|&p| {
4979                let node_n = foreign_to_number(p);
4980                if matches!(lv, Value::ForeignNodeSet(_)) { op(node_n, n_other) }
4981                else { op(n_other, node_n) }
4982            });
4983            return Ok(Value::Boolean(any));
4984        }
4985        // Two node-sets — true iff any pair (ln, rn) satisfies the
4986        // relation.  Pre-compute one side's numbers to keep the
4987        // search O(L+R) for the boolean answer.
4988        (Value::NodeSet(ls), Value::NodeSet(rs)) => {
4989            let l_nums: Vec<f64> = ls.iter().map(|&id| node_to_number(id)).collect();
4990            let r_nums: Vec<f64> = rs.iter().map(|&id| node_to_number(id)).collect();
4991            let any = l_nums.iter().any(|&a| r_nums.iter().any(|&b| op(a, b)));
4992            return Ok(Value::Boolean(any));
4993        }
4994        _ => {}
4995    }
4996    // Date / dateTime / time / duration ordering — compare in their
4997    // own value space rather than as numbers.  XPath 2.0 §3.5.2.
4998    if let (Value::Typed(t), Value::Typed(u)) = (&lv, &rv) {
4999        if t.kind == u.kind {
5000            if matches!(t.kind, "date" | "dateTime" | "time") {
5001                if let (Some(a), Some(b)) = (
5002                    dt_to_utc_seconds(&t.lexical, t.kind),
5003                    dt_to_utc_seconds(&u.lexical, u.kind),
5004                ) {
5005                    return Ok(Value::Boolean(op(a as f64, b as f64)));
5006                }
5007            }
5008            if t.kind == "dayTimeDuration" {
5009                if let (Some(a), Some(b)) = (
5010                    parse_day_time_duration_secs(&t.lexical),
5011                    parse_day_time_duration_secs(&u.lexical),
5012                ) {
5013                    return Ok(Value::Boolean(op(a as f64, b as f64)));
5014                }
5015            }
5016            if t.kind == "yearMonthDuration" {
5017                if let (Some(a), Some(b)) = (
5018                    parse_year_month_duration_months(&t.lexical),
5019                    parse_year_month_duration_months(&u.lexical),
5020                ) {
5021                    return Ok(Value::Boolean(op(a as f64, b as f64)));
5022                }
5023            }
5024            // String-ordered types: fall through to numeric compare
5025            // — XPath general `<` between strings is implementation-
5026            // defined and tests typically use `lt` for that.
5027        }
5028    }
5029    let ln = value_to_number_with(&lv, idx, ctx.bindings);
5030    let rn = value_to_number_with(&rv, idx, ctx.bindings);
5031    Ok(Value::Boolean(op(ln, rn)))
5032}
5033
5034/// EXSLT `dyn:evaluate(expr-string) → value` — compile `expr-string`
5035/// as an XPath expression and evaluate it against the current
5036/// context.  Per spec, a parse / eval failure yields an empty
5037/// node-set rather than propagating an error (libexslt's behaviour
5038/// — stylesheets use `dyn:evaluate` for "soft" lookups where a
5039/// malformed expression should produce no result, not abort the
5040/// transform).
5041fn dyn_evaluate<I: DocIndexLike>(
5042    args: &[Value], ctx: &EvalCtx<'_>, idx: &I,
5043) -> Result<Value> {
5044    if args.len() != 1 {
5045        return Err(xpath_err("dyn:evaluate takes 1 argument"));
5046    }
5047    let src = value_to_string(&args[0], idx);
5048    let opts = super::XPathOptions {
5049        libxml2_compatible: ctx.static_ctx.libxml2_compatible,
5050        ..super::XPathOptions::default()
5051    };
5052    let expr = match super::parse_xpath_with(&src, &opts) {
5053        Ok(e)  => e,
5054        Err(_) => return Ok(Value::NodeSet(Vec::new())),
5055    };
5056    match eval_expr(&expr, ctx, idx) {
5057        Ok(v)  => Ok(v),
5058        Err(_) => Ok(Value::NodeSet(Vec::new())),
5059    }
5060}
5061
5062// ── built-in functions ────────────────────────────────────────────────────────
5063
5064/// XPath 3.1 §17.1 `map:*` function library (the non-higher-order
5065/// subset; map:for-each / map:find need function items and live with
5066/// the HOF work).
5067fn eval_map_function<I: DocIndexLike>(
5068    local: &str, args: &[Value], ctx: &EvalCtx<'_>, idx: &I,
5069) -> Result<Value> {
5070    let as_map = |v: &Value| -> Result<Vec<(Value, Value)>> {
5071        match v {
5072            Value::Map(m) => Ok((**m).clone()),
5073            _ => Err(xpath_err(format!("map:{local}: expected a map argument"))),
5074        }
5075    };
5076    let empty = || Value::NodeSet(Vec::new());
5077    match local {
5078        "size" => Ok(Value::Number(Numeric::Integer(as_map(&args[0])?.len() as i64))),
5079        "contains" => {
5080            let m = as_map(&args[0])?;
5081            let k = first_atomic_key(&args[1], idx);
5082            Ok(Value::Boolean(m.iter().any(|(mk, _)| map_key_eq(mk, &k, idx))))
5083        }
5084        "get" => {
5085            let m = as_map(&args[0])?;
5086            let k = first_atomic_key(&args[1], idx);
5087            Ok(m.into_iter().find(|(mk, _)| map_key_eq(mk, &k, idx))
5088                .map(|(_, v)| v).unwrap_or_else(empty))
5089        }
5090        "keys" => Ok(seq_from_items(
5091            as_map(&args[0])?.into_iter().map(|(k, _)| k).collect())),
5092        "put" => {
5093            let mut m = as_map(&args[0])?;
5094            let k = first_atomic_key(&args[1], idx);
5095            m.retain(|(mk, _)| !map_key_eq(mk, &k, idx));
5096            m.push((k, args[2].clone()));
5097            Ok(Value::Map(Box::new(m)))
5098        }
5099        "remove" => {
5100            let mut m = as_map(&args[0])?;
5101            let ks = items_of(&args[1]);
5102            m.retain(|(mk, _)| !ks.iter().any(|k| map_key_eq(mk, k, idx)));
5103            Ok(Value::Map(Box::new(m)))
5104        }
5105        "entry" => Ok(Value::Map(Box::new(vec![
5106            (first_atomic_key(&args[0], idx), args[1].clone())]))),
5107        "merge" => {
5108            // map:merge($maps [, $options]) — `duplicates` defaults to
5109            // "use-first"; "use-last" lets later maps win.
5110            let use_last = match args.get(1) {
5111                Some(Value::Map(opts)) => opts.iter()
5112                    .find(|(k, _)| value_to_string(k, idx) == "duplicates")
5113                    .map(|(_, v)| value_to_string(v, idx) == "use-last")
5114                    .unwrap_or(false),
5115                _ => false,
5116            };
5117            let mut out: Vec<(Value, Value)> = Vec::new();
5118            for item in items_of(&args[0]) {
5119                if let Value::Map(m) = item {
5120                    for (k, v) in m.iter() {
5121                        match out.iter().position(|(ok, _)| map_key_eq(ok, k, idx)) {
5122                            Some(p) if use_last => out[p].1 = v.clone(),
5123                            Some(_) => {} // use-first: keep existing
5124                            None => out.push((k.clone(), v.clone())),
5125                        }
5126                    }
5127                }
5128            }
5129            Ok(Value::Map(Box::new(out)))
5130        }
5131        // map:for-each($map, $action) — $action($key, $value) per entry;
5132        // results concatenated into a single sequence.
5133        "for-each" => {
5134            let m = as_map(&args[0])?;
5135            let f = value_as_function(&args[1])?;
5136            let mut out = Vec::new();
5137            for (k, v) in m {
5138                out.push(call_function_item(f, vec![k, v], ctx, idx)?);
5139            }
5140            Ok(seq_from_items(out))
5141        }
5142        // map:find($input, $key) — search maps/arrays reachable from
5143        // $input, collecting matching values into an array.
5144        "find" => {
5145            let key = first_atomic_key(&args[1], idx);
5146            let mut found = Vec::new();
5147            fn search<I: DocIndexLike>(
5148                v: &Value, key: &Value, idx: &I, out: &mut Vec<Value>,
5149            ) {
5150                for item in items_of(v) {
5151                    match item {
5152                        Value::Map(m) => {
5153                            for (k, val) in m.iter() {
5154                                if map_key_eq(k, key, idx) { out.push(val.clone()); }
5155                                search(val, key, idx, out);
5156                            }
5157                        }
5158                        Value::Array(a) => for member in a.iter() {
5159                            search(member, key, idx, out);
5160                        },
5161                        _ => {}
5162                    }
5163                }
5164            }
5165            search(&args[0], &key, idx, &mut found);
5166            Ok(Value::Array(Box::new(found)))
5167        }
5168        _ => Err(xpath_err(format!(
5169            "map:{local} is not supported in this build"))),
5170    }
5171}
5172
5173/// XPath 3.1 §17.3 `array:*` function library.
5174fn eval_array_function<I: DocIndexLike>(
5175    local: &str, args: &[Value], ctx: &EvalCtx<'_>, idx: &I,
5176) -> Result<Value> {
5177    let as_array = |v: &Value| -> Result<Vec<Value>> {
5178        match v {
5179            Value::Array(a) => Ok((**a).clone()),
5180            _ => Err(xpath_err(format!("array:{local}: expected an array argument"))),
5181        }
5182    };
5183    match local {
5184        "size" => Ok(Value::Number(Numeric::Integer(as_array(&args[0])?.len() as i64))),
5185        "get" => {
5186            let a = as_array(&args[0])?;
5187            let pos = value_to_number(&args[1], idx);
5188            if pos.fract() == 0.0 && pos >= 1.0 && (pos as usize) <= a.len() {
5189                Ok(a[pos as usize - 1].clone())
5190            } else {
5191                Err(xpath_err("array:get: index out of bounds (FOAY0001)"))
5192            }
5193        }
5194        "append" => {
5195            let mut a = as_array(&args[0])?;
5196            a.push(args[1].clone());
5197            Ok(Value::Array(Box::new(a)))
5198        }
5199        "head" => {
5200            let a = as_array(&args[0])?;
5201            a.into_iter().next().ok_or_else(|| xpath_err("array:head: empty array (FOAY0001)"))
5202        }
5203        "tail" => {
5204            let mut a = as_array(&args[0])?;
5205            if a.is_empty() { return Err(xpath_err("array:tail: empty array (FOAY0001)")); }
5206            a.remove(0);
5207            Ok(Value::Array(Box::new(a)))
5208        }
5209        "reverse" => {
5210            let mut a = as_array(&args[0])?;
5211            a.reverse();
5212            Ok(Value::Array(Box::new(a)))
5213        }
5214        "subarray" => {
5215            let a = as_array(&args[0])?;
5216            let start = value_to_number(&args[1], idx).round() as i64;
5217            let len = match args.get(2) {
5218                Some(v) => value_to_number(v, idx).round() as i64,
5219                None => a.len() as i64 - start + 1,
5220            };
5221            let s = (start - 1).max(0) as usize;
5222            let e = ((start - 1 + len).max(0) as usize).min(a.len());
5223            Ok(Value::Array(Box::new(a.get(s..e).map(<[_]>::to_vec).unwrap_or_default())))
5224        }
5225        "join" => {
5226            let mut out = Vec::new();
5227            for item in items_of(&args[0]) {
5228                if let Value::Array(a) = item { out.extend((*a).clone()); }
5229            }
5230            Ok(Value::Array(Box::new(out)))
5231        }
5232        "flatten" => {
5233            fn flat(v: &Value, out: &mut Vec<Value>) {
5234                for item in items_of(v) {
5235                    match item {
5236                        Value::Array(a) => for m in a.iter() { flat(m, out); },
5237                        other => out.push(other),
5238                    }
5239                }
5240            }
5241            let mut out = Vec::new();
5242            flat(&args[0], &mut out);
5243            Ok(seq_from_items(out))
5244        }
5245        // array:for-each($array, $action) — apply $action to each
5246        // member; each result becomes one member of the output array.
5247        "for-each" => {
5248            let a = as_array(&args[0])?;
5249            let f = value_as_function(&args[1])?;
5250            let mut out = Vec::with_capacity(a.len());
5251            for m in a {
5252                out.push(call_function_item(f, vec![m], ctx, idx)?);
5253            }
5254            Ok(Value::Array(Box::new(out)))
5255        }
5256        // array:filter($array, $predicate) — keep members for which the
5257        // predicate's effective boolean value is true.
5258        "filter" => {
5259            let a = as_array(&args[0])?;
5260            let f = value_as_function(&args[1])?;
5261            let mut out = Vec::new();
5262            for m in a {
5263                let keep = call_function_item(f, vec![m.clone()], ctx, idx)?;
5264                if value_to_bool(&keep, idx) { out.push(m); }
5265            }
5266            Ok(Value::Array(Box::new(out)))
5267        }
5268        "fold-left" => {
5269            let a = as_array(&args[0])?;
5270            let f = value_as_function(&args[2])?;
5271            let mut acc = args[1].clone();
5272            for m in a {
5273                acc = call_function_item(f, vec![acc, m], ctx, idx)?;
5274            }
5275            Ok(acc)
5276        }
5277        "fold-right" => {
5278            let a = as_array(&args[0])?;
5279            let f = value_as_function(&args[2])?;
5280            let mut acc = args[1].clone();
5281            for m in a.into_iter().rev() {
5282                acc = call_function_item(f, vec![m, acc], ctx, idx)?;
5283            }
5284            Ok(acc)
5285        }
5286        // array:for-each-pair($a, $b, $action) — pairwise over the
5287        // shorter length; each result is one member of the output.
5288        "for-each-pair" => {
5289            let a = as_array(&args[0])?;
5290            let b = as_array(&args[1])?;
5291            let f = value_as_function(&args[2])?;
5292            let mut out = Vec::new();
5293            for (x, y) in a.into_iter().zip(b) {
5294                out.push(call_function_item(f, vec![x, y], ctx, idx)?);
5295            }
5296            Ok(Value::Array(Box::new(out)))
5297        }
5298        // array:sort($array [, $collation [, $key]]) — stable sort by
5299        // the atomic value of each member (or of $key applied to it).
5300        "sort" => {
5301            let a = as_array(&args[0])?;
5302            let keyfn = match args.get(2) {
5303                Some(v) => Some(value_as_function(v)?),
5304                None => None,
5305            };
5306            let mut keyed: Vec<(Value, Value)> = Vec::with_capacity(a.len());
5307            for m in a {
5308                let k = match keyfn {
5309                    Some(f) => call_function_item(f, vec![m.clone()], ctx, idx)?,
5310                    None => m.clone(),
5311                };
5312                keyed.push((k, m));
5313            }
5314            keyed.sort_by(|(ka, _), (kb, _)| {
5315                compare_atomic_for_sort(ka, kb, idx, ctx.bindings)
5316            });
5317            Ok(Value::Array(Box::new(keyed.into_iter().map(|(_, m)| m).collect())))
5318        }
5319        // array:put($array, $position, $member) — a new array with the
5320        // member at $position replaced (F&O 3.1 §17.3.6).  $member is a
5321        // single member (the whole supplied value, which may itself be a
5322        // sequence).  Position must be in 1..size.
5323        "put" => {
5324            let mut a = as_array(&args[0])?;
5325            let pos = value_to_number(&args[1], idx);
5326            if pos.fract() == 0.0 && pos >= 1.0 && (pos as usize) <= a.len() {
5327                a[pos as usize - 1] = args[2].clone();
5328                Ok(Value::Array(Box::new(a)))
5329            } else {
5330                Err(xpath_err("array:put: position out of bounds (FOAY0001)"))
5331            }
5332        }
5333        // array:insert-before($array, $position, $member) — insert
5334        // $member before $position (F&O 3.1 §17.3.7).  Position is in
5335        // 1..size+1 (size+1 appends).
5336        "insert-before" => {
5337            let mut a = as_array(&args[0])?;
5338            let pos = value_to_number(&args[1], idx);
5339            if pos.fract() == 0.0 && pos >= 1.0 && (pos as usize) <= a.len() + 1 {
5340                a.insert(pos as usize - 1, args[2].clone());
5341                Ok(Value::Array(Box::new(a)))
5342            } else {
5343                Err(xpath_err("array:insert-before: position out of bounds (FOAY0001)"))
5344            }
5345        }
5346        // array:remove($array, $positions) — a new array without the
5347        // members at the given positions (F&O 3.1 §17.3.8).  $positions
5348        // is a sequence of integers, each in 1..size.
5349        "remove" => {
5350            let a = as_array(&args[0])?;
5351            let mut drop: std::collections::HashSet<usize> = std::collections::HashSet::new();
5352            for p in items_of(&args[1]) {
5353                let n = value_to_number(&p, idx);
5354                if n.fract() != 0.0 || n < 1.0 || (n as usize) > a.len() {
5355                    return Err(xpath_err("array:remove: position out of bounds (FOAY0001)"));
5356                }
5357                drop.insert(n as usize);
5358            }
5359            let out: Vec<Value> = a.into_iter().enumerate()
5360                .filter(|(i, _)| !drop.contains(&(i + 1)))
5361                .map(|(_, m)| m)
5362                .collect();
5363            Ok(Value::Array(Box::new(out)))
5364        }
5365        // array:members / array:flatten variants returning the members
5366        // as a sequence are covered by `flatten`; expose `members`-style
5367        // access via `subarray`/`get`.
5368        _ => Err(xpath_err(format!(
5369            "array:{local} is not supported in this build"))),
5370    }
5371}
5372
5373/// Extract a function item from a value for higher-order dispatch.
5374fn value_as_function(v: &Value) -> Result<&FunctionItem> {
5375    match v {
5376        Value::Function(fi) => Ok(fi),
5377        _ => Err(xpath_err("expected a function item (XPTY0004)")),
5378    }
5379}
5380
5381/// Ordering used by `array:sort` / `fn:sort` — compares two atomic
5382/// keys by numeric value when both are numeric, else by string value.
5383/// Empty sequences sort before non-empty ones.
5384fn compare_atomic_for_sort<I: DocIndexLike>(
5385    a: &Value, b: &Value, idx: &I, bindings: &dyn XPathBindings,
5386) -> std::cmp::Ordering {
5387    use std::cmp::Ordering;
5388    let numeric = |v: &Value| -> Option<f64> {
5389        match v {
5390            Value::Number(_) => Some(value_to_number(v, idx)),
5391            Value::Typed(t) if matches!(t.kind,
5392                "integer" | "decimal" | "double" | "float" | "long" | "int"
5393                | "short" | "byte" | "numeric") => Some(value_to_number(v, idx)),
5394            _ => None,
5395        }
5396    };
5397    match (numeric(a), numeric(b)) {
5398        (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
5399        _ => value_to_string_with(a, idx, bindings)
5400            .cmp(&value_to_string_with(b, idx, bindings)),
5401    }
5402}
5403
5404/// XPath 3.1 §16.2 higher-order function library (`fn:` namespace).
5405/// Returns `None` when `local` is not a higher-order function, so the
5406/// caller falls through to ordinary built-in dispatch.
5407fn eval_hof_function<I: DocIndexLike>(
5408    local: &str, args: &[Value], ctx: &EvalCtx<'_>, idx: &I,
5409) -> Option<Result<Value>> {
5410    let f = |i: usize| value_as_function(&args[i]);
5411    let res = match local {
5412        // fn:for-each($seq, $action) — apply $action to each item,
5413        // concatenating the results.
5414        "for-each" if args.len() == 2 => (|| {
5415            let func = f(1)?;
5416            let mut out = Vec::new();
5417            for item in iter_items(&args[0]) {
5418                out.push(call_function_item(func, vec![item], ctx, idx)?);
5419            }
5420            Ok(seq_from_items(out))
5421        })(),
5422        // fn:filter($seq, $predicate) — retain items whose predicate
5423        // result is true.
5424        "filter" if args.len() == 2 => (|| {
5425            let func = f(1)?;
5426            let mut out = Vec::new();
5427            for item in iter_items(&args[0]) {
5428                let keep = call_function_item(func, vec![item.clone()], ctx, idx)?;
5429                if value_to_bool(&keep, idx) { out.push(item); }
5430            }
5431            Ok(seq_from_items(out))
5432        })(),
5433        "fold-left" if args.len() == 3 => (|| {
5434            let func = f(2)?;
5435            let mut acc = args[1].clone();
5436            for item in iter_items(&args[0]) {
5437                acc = call_function_item(func, vec![acc, item], ctx, idx)?;
5438            }
5439            Ok(acc)
5440        })(),
5441        "fold-right" if args.len() == 3 => (|| {
5442            let func = f(2)?;
5443            let items: Vec<Value> = iter_items(&args[0]).collect();
5444            let mut acc = args[1].clone();
5445            for item in items.into_iter().rev() {
5446                acc = call_function_item(func, vec![item, acc], ctx, idx)?;
5447            }
5448            Ok(acc)
5449        })(),
5450        // fn:for-each-pair($seq1, $seq2, $action) — pairwise over the
5451        // shorter sequence.
5452        "for-each-pair" if args.len() == 3 => (|| {
5453            let func = f(2)?;
5454            let mut out = Vec::new();
5455            for (x, y) in iter_items(&args[0]).zip(iter_items(&args[1])) {
5456                out.push(call_function_item(func, vec![x, y], ctx, idx)?);
5457            }
5458            Ok(seq_from_items(out))
5459        })(),
5460        // fn:sort($input [, $collation [, $key]]).
5461        "sort" if (1..=3).contains(&args.len()) => (|| {
5462            let keyfn = match args.get(2) {
5463                Some(v) => Some(value_as_function(v)?),
5464                None => None,
5465            };
5466            let mut keyed: Vec<(Value, Value)> = Vec::new();
5467            for item in iter_items(&args[0]) {
5468                let k = match keyfn {
5469                    Some(func) => call_function_item(func, vec![item.clone()], ctx, idx)?,
5470                    None => item.clone(),
5471                };
5472                keyed.push((k, item));
5473            }
5474            keyed.sort_by(|(ka, _), (kb, _)|
5475                compare_atomic_for_sort(ka, kb, idx, ctx.bindings));
5476            Ok(seq_from_items(keyed.into_iter().map(|(_, v)| v).collect()))
5477        })(),
5478        // fn:apply($function, $array) — call $function with the array's
5479        // members as positional arguments.
5480        "apply" if args.len() == 2 => (|| {
5481            let func = f(0)?;
5482            let call_args = match &args[1] {
5483                Value::Array(a) => (**a).clone(),
5484                _ => return Err(xpath_err("fn:apply: second argument must be an array")),
5485            };
5486            call_function_item(func, call_args, ctx, idx)
5487        })(),
5488        // fn:function-arity($function).
5489        "function-arity" if args.len() == 1 =>
5490            f(0).map(|func| Value::Number(Numeric::Integer(func.arity() as i64))),
5491        // fn:function-name($function) as xs:QName? — the expanded QName of
5492        // a named-function reference, or the empty sequence for an
5493        // anonymous (inline / partially-applied) function.
5494        "function-name" if args.len() == 1 => f(0).map(|func| match func {
5495            FunctionItem::Named { name, ns, .. } => {
5496                let local = name.rsplit(':').next().unwrap_or(name);
5497                let lexical = if ns.is_empty() {
5498                    local.to_string()
5499                } else {
5500                    format!("{{{ns}}}{local}")
5501                };
5502                Value::Typed(Box::new(TypedAtomic {
5503                    kind: "QName", lexical, numeric: None, boolean: None, user_type: None,
5504                }))
5505            }
5506            _ => Value::NodeSet(Vec::new()),
5507        }),
5508        // fn:function-lookup($name as xs:QName, $arity as xs:integer) — a
5509        // function item for the named function, or the empty sequence when
5510        // no such function is available in scope.
5511        "function-lookup" if args.len() == 2 => {
5512            let qname = value_to_string(&args[0], idx);
5513            let arity = value_to_number(&args[1], idx) as usize;
5514            // QName string-value is Clark `{uri}local`, lexical
5515            // `prefix:local`, or a bare local in the default function
5516            // namespace.
5517            let (ns, local) = if let Some(rest) = qname.strip_prefix('{') {
5518                rest.split_once('}')
5519                    .map(|(u, l)| (u.to_string(), l.to_string()))
5520                    .unwrap_or_else(|| (String::new(), qname.clone()))
5521            } else if let Some((prefix, l)) = qname.split_once(':') {
5522                (resolve_prefix_or_implicit(ctx.bindings, prefix).unwrap_or_default(),
5523                 l.to_string())
5524            } else {
5525                (FN_NAMESPACE.to_string(), qname.clone())
5526            };
5527            let available = if ns.is_empty() || ns.as_str() == FN_NAMESPACE {
5528                xpath_function_available(&local, ctx)
5529            } else {
5530                ctx.bindings.function_available_in(&ns, &local, arity)
5531            };
5532            if available {
5533                let sig = ctx.bindings.function_signature_in(&ns, &local, arity).map(Box::new);
5534                Ok(Value::Function(Box::new(FunctionItem::Named { name: local, ns, arity, sig })))
5535            } else {
5536                Ok(Value::NodeSet(Vec::new()))
5537            }
5538        }
5539        _ => return None,
5540    };
5541    Some(res)
5542}
5543
5544/// XPath 3.1 §17.5 JSON function library (`fn:` namespace).  Returns
5545/// `None` when `local` is not a JSON function so the caller falls
5546/// through to ordinary built-in dispatch.
5547fn eval_json_function<I: DocIndexLike>(
5548    local: &str, args: &[Value], _ctx: &EvalCtx<'_>, idx: &I,
5549) -> Option<Result<Value>> {
5550    // Read a string-valued entry from an options map (XPath 3.1 §17.5).
5551    let opt_str = |opts: Option<&Value>, key: &str| -> Option<String> {
5552        match opts {
5553            Some(Value::Map(m)) => m.iter()
5554                .find(|(k, _)| value_to_string(k, idx) == key)
5555                .map(|(_, v)| value_to_string(v, idx)),
5556            _ => None,
5557        }
5558    };
5559    let opt_bool = |opts: Option<&Value>, key: &str| -> Option<bool> {
5560        match opts {
5561            Some(Value::Map(m)) => m.iter()
5562                .find(|(k, _)| value_to_string(k, idx) == key)
5563                .map(|(_, v)| value_to_bool(v, idx)),
5564            _ => None,
5565        }
5566    };
5567    let res = match local {
5568        // fn:parse-json($json-text [, $options]) → the XDM map/array
5569        // representation (F&O §17.5.1).
5570        "parse-json" if (1..=2).contains(&args.len()) => (|| {
5571            // An empty sequence argument yields the empty sequence.
5572            if sequence_len(&args[0]) == 0 {
5573                return Ok(Value::Sequence(Vec::new()));
5574            }
5575            let text = value_to_string(&args[0], idx);
5576            let opts = args.get(1);
5577            let dup = opt_str(opts, "duplicates")
5578                .unwrap_or_else(|| "use-first".to_string());
5579            let escape = opt_bool(opts, "escape").unwrap_or(false);
5580            let liberal = opt_bool(opts, "liberal").unwrap_or(false);
5581            parse_json_value(&text, &dup, escape, liberal)
5582        })(),
5583        // fn:xml-to-json($input [, $options]) — serialize a node in the
5584        // F&O JSON element vocabulary to a JSON string (§17.5.5).
5585        "xml-to-json" if (1..=2).contains(&args.len()) => (|| {
5586            let root = match &args[0] {
5587                Value::NodeSet(ns) if ns.len() == 1 => ns[0],
5588                Value::NodeSet(ns) if ns.is_empty() =>
5589                    return Ok(Value::Sequence(Vec::new())),
5590                _ => return Err(xpath_err(
5591                    "xml-to-json: argument must be a single document or element node")),
5592            };
5593            // A document node must wrap exactly one element child
5594            // (F&O §17.5.5 requires a single element as the input).
5595            let elem = match idx.kind(root) {
5596                XPathNodeKind::Document => {
5597                    let mut elems = idx.children(root).iter().copied()
5598                        .filter(|&c| matches!(idx.kind(c), XPathNodeKind::Element));
5599                    let first = elems.next().ok_or_else(||
5600                        xpath_err("xml-to-json: empty document (FOJS0006)"))?;
5601                    if elems.next().is_some() {
5602                        return Err(xpath_err(
5603                            "xml-to-json: more than one top-level element (FOJS0006)"));
5604                    }
5605                    first
5606                }
5607                XPathNodeKind::Element => root,
5608                _ => return Err(xpath_err(
5609                    "xml-to-json: argument must be a document or element node")),
5610            };
5611            let mut out = String::new();
5612            xml_node_to_json(elem, idx, false, &mut out)?;
5613            Ok(Value::String(out))
5614        })(),
5615        // fn:serialize($value [, $options]) (F&O §17.2) — only the JSON
5616        // output method is modelled here; node-tree serialization (the
5617        // xml/html/text methods) is the XSLT result-document layer's job,
5618        // so those fall back to the value's string value.
5619        "serialize" if (1..=2).contains(&args.len()) => (|| {
5620            let method = opt_str(args.get(1), "method").unwrap_or_default();
5621            if method == "json" {
5622                let mut out = String::new();
5623                value_to_json(&args[0], idx, &mut out)?;
5624                Ok(Value::String(out))
5625            } else {
5626                Ok(Value::String(value_to_string(&args[0], idx)))
5627            }
5628        })(),
5629        _ => return None,
5630    };
5631    Some(res)
5632}
5633
5634/// Serialize an XDM value as JSON (the `method=json` output method,
5635/// F&O §17.2 / §17.5): maps become objects, arrays become arrays, the
5636/// numeric/boolean atomics become JSON literals, and everything else its
5637/// quoted string value.
5638fn value_to_json<I: DocIndexLike>(v: &Value, idx: &I, out: &mut String) -> Result<()> {
5639    match v {
5640        Value::Map(m) => {
5641            out.push('{');
5642            for (i, (k, val)) in m.iter().enumerate() {
5643                if i > 0 { out.push(','); }
5644                out.push('"');
5645                json_escape_into(&value_to_string(k, idx), out);
5646                out.push_str("\":");
5647                value_to_json(val, idx, out)?;
5648            }
5649            out.push('}');
5650        }
5651        Value::Array(a) => {
5652            out.push('[');
5653            for (i, val) in a.iter().enumerate() {
5654                if i > 0 { out.push(','); }
5655                value_to_json(val, idx, out)?;
5656            }
5657            out.push(']');
5658        }
5659        Value::Boolean(b) => out.push_str(if *b { "true" } else { "false" }),
5660        Value::Number(_) => out.push_str(&value_to_string(v, idx)),
5661        Value::Typed(t) if t.numeric.is_some() => out.push_str(&value_to_string(v, idx)),
5662        Value::NodeSet(ns) if ns.is_empty() => out.push_str("null"),
5663        Value::Sequence(items) if items.is_empty() => out.push_str("null"),
5664        other => {
5665            out.push('"');
5666            json_escape_into(&value_to_string(other, idx), out);
5667            out.push('"');
5668        }
5669    }
5670    Ok(())
5671}
5672
5673/// A structural event emitted by [`parse_json_events`].  Lets a
5674/// consumer build either the XDM map/array [`Value`] (fn:parse-json)
5675/// or the F&O JSON element vocabulary (fn:json-to-xml) from one
5676/// parser.  `Number` carries the *raw* lexical so json-to-xml can
5677/// preserve the input's number representation.
5678pub enum JsonEvent {
5679    StartObject,
5680    EndObject,
5681    StartArray,
5682    EndArray,
5683    /// Object member key (already unescaped per the `escape` option).
5684    Key(String),
5685    Str(String),
5686    Number(String),
5687    Bool(bool),
5688    Null,
5689}
5690
5691/// Parse `json` (F&O §17.5), driving `sink` with one [`JsonEvent`] per
5692/// structural token.  Duplicate-key policy is left to the consumer —
5693/// the parser reports keys verbatim.  `escape` keeps backslash
5694/// sequences in string content; `liberal` tolerates unescaped control
5695/// characters.
5696pub fn parse_json_events(
5697    json: &str, escape: bool, liberal: bool, sink: &mut dyn FnMut(JsonEvent),
5698) -> Result<()> {
5699    let chars: Vec<char> = json.chars().collect();
5700    let mut p = JsonParser { chars: &chars, pos: 0, escape, liberal };
5701    p.skip_ws();
5702    p.parse_value(sink)?;
5703    p.skip_ws();
5704    if p.pos != p.chars.len() {
5705        return Err(xpath_err("parse-json: trailing content after JSON value (FOJS0001)"));
5706    }
5707    Ok(())
5708}
5709
5710/// Parse `text` to the XDM representation (F&O §17.5): object → map,
5711/// array → array, string → xs:string, number → xs:double, true/false →
5712/// xs:boolean, null → empty sequence.  `duplicates` is one of
5713/// `reject` / `use-first` / `use-last`.  Public so the XSLT layer's
5714/// `fn:json-doc` can reuse it after loading the resource.
5715pub fn parse_json_value(text: &str, duplicates: &str, escape: bool, liberal: bool)
5716    -> Result<Value>
5717{
5718    // Build the Value with an explicit frame stack so the shared
5719    // event parser can drive both this and json-to-xml.
5720    enum Frame { Obj(Vec<(Value, Value)>, Option<String>), Arr(Vec<Value>) }
5721    let mut stack: Vec<Frame> = Vec::new();
5722    let mut result: Option<Value> = None;
5723    let mut dup_err: Option<crate::error::XmlError> = None;
5724
5725    let mut attach = |stack: &mut Vec<Frame>, result: &mut Option<Value>, v: Value| {
5726        match stack.last_mut() {
5727            Some(Frame::Arr(a)) => a.push(v),
5728            Some(Frame::Obj(entries, pending)) => {
5729                let key = pending.take().unwrap_or_default();
5730                match entries.iter().position(|(k, _)| value_as_str(k) == key) {
5731                    Some(i) => match duplicates {
5732                        "reject" => {
5733                            dup_err.get_or_insert_with(|| xpath_err(format!(
5734                                "parse-json: duplicate key {key:?} (FOJS0003)")));
5735                        }
5736                        "use-last" => entries[i].1 = v,
5737                        _ => {}
5738                    },
5739                    None => entries.push((Value::String(key), v)),
5740                }
5741            }
5742            None => *result = Some(v),
5743        }
5744    };
5745
5746    parse_json_events(text, escape, liberal, &mut |ev| match ev {
5747        JsonEvent::StartObject => stack.push(Frame::Obj(Vec::new(), None)),
5748        JsonEvent::StartArray  => stack.push(Frame::Arr(Vec::new())),
5749        JsonEvent::Key(k) => {
5750            if let Some(Frame::Obj(_, pending)) = stack.last_mut() { *pending = Some(k); }
5751        }
5752        JsonEvent::EndObject => {
5753            if let Some(Frame::Obj(entries, _)) = stack.pop() {
5754                attach(&mut stack, &mut result, Value::Map(Box::new(entries)));
5755            }
5756        }
5757        JsonEvent::EndArray => {
5758            if let Some(Frame::Arr(members)) = stack.pop() {
5759                attach(&mut stack, &mut result, Value::Array(Box::new(members)));
5760            }
5761        }
5762        JsonEvent::Str(s)    => attach(&mut stack, &mut result, Value::String(s)),
5763        JsonEvent::Number(n) => attach(&mut stack, &mut result,
5764            Value::Number(Numeric::Double(n.parse::<f64>().unwrap_or(f64::NAN)))),
5765        JsonEvent::Bool(b)   => attach(&mut stack, &mut result, Value::Boolean(b)),
5766        JsonEvent::Null      => attach(&mut stack, &mut result, Value::Sequence(Vec::new())),
5767    })?;
5768    if let Some(e) = dup_err { return Err(e); }
5769    Ok(result.unwrap_or_else(|| Value::Sequence(Vec::new())))
5770}
5771
5772struct JsonParser<'a> {
5773    chars: &'a [char],
5774    pos:   usize,
5775    escape: bool,
5776    liberal: bool,
5777}
5778
5779impl<'a> JsonParser<'a> {
5780    fn peek(&self) -> Option<char> { self.chars.get(self.pos).copied() }
5781
5782    fn skip_ws(&mut self) {
5783        while let Some(c) = self.peek() {
5784            if c == ' ' || c == '\t' || c == '\n' || c == '\r' { self.pos += 1; }
5785            else { break; }
5786        }
5787    }
5788
5789    fn parse_value(&mut self, sink: &mut dyn FnMut(JsonEvent)) -> Result<()> {
5790        match self.peek() {
5791            Some('{') => self.parse_object(sink),
5792            Some('[') => self.parse_array(sink),
5793            Some('"') => { let s = self.parse_string()?; sink(JsonEvent::Str(s)); Ok(()) }
5794            Some('t') | Some('f') => self.parse_bool(sink),
5795            Some('n') => self.parse_null(sink),
5796            Some(c) if c == '-' || c.is_ascii_digit() => self.parse_number(sink),
5797            _ => Err(xpath_err("parse-json: unexpected character (FOJS0001)")),
5798        }
5799    }
5800
5801    fn parse_object(&mut self, sink: &mut dyn FnMut(JsonEvent)) -> Result<()> {
5802        self.pos += 1; // {
5803        sink(JsonEvent::StartObject);
5804        self.skip_ws();
5805        if self.peek() == Some('}') { self.pos += 1; sink(JsonEvent::EndObject); return Ok(()); }
5806        loop {
5807            self.skip_ws();
5808            if self.peek() != Some('"') {
5809                return Err(xpath_err("parse-json: expected object key (FOJS0001)"));
5810            }
5811            let key = self.parse_string()?;
5812            sink(JsonEvent::Key(key));
5813            self.skip_ws();
5814            if self.peek() != Some(':') {
5815                return Err(xpath_err("parse-json: expected ':' in object (FOJS0001)"));
5816            }
5817            self.pos += 1;
5818            self.skip_ws();
5819            self.parse_value(sink)?;
5820            self.skip_ws();
5821            match self.peek() {
5822                Some(',') => { self.pos += 1; continue; }
5823                Some('}') => { self.pos += 1; break; }
5824                _ => return Err(xpath_err("parse-json: expected ',' or '}' (FOJS0001)")),
5825            }
5826        }
5827        sink(JsonEvent::EndObject);
5828        Ok(())
5829    }
5830
5831    fn parse_array(&mut self, sink: &mut dyn FnMut(JsonEvent)) -> Result<()> {
5832        self.pos += 1; // [
5833        sink(JsonEvent::StartArray);
5834        self.skip_ws();
5835        if self.peek() == Some(']') { self.pos += 1; sink(JsonEvent::EndArray); return Ok(()); }
5836        loop {
5837            self.skip_ws();
5838            self.parse_value(sink)?;
5839            self.skip_ws();
5840            match self.peek() {
5841                Some(',') => { self.pos += 1; continue; }
5842                Some(']') => { self.pos += 1; break; }
5843                _ => return Err(xpath_err("parse-json: expected ',' or ']' (FOJS0001)")),
5844            }
5845        }
5846        sink(JsonEvent::EndArray);
5847        Ok(())
5848    }
5849
5850    fn parse_string(&mut self) -> Result<String> {
5851        self.pos += 1; // opening quote
5852        let mut s = String::new();
5853        loop {
5854            match self.peek() {
5855                None => return Err(xpath_err("parse-json: unterminated string (FOJS0001)")),
5856                Some('"') => { self.pos += 1; break; }
5857                Some('\\') => {
5858                    self.pos += 1;
5859                    let esc = self.peek().ok_or_else(||
5860                        xpath_err("parse-json: dangling escape (FOJS0001)"))?;
5861                    self.pos += 1;
5862                    // When `escape` is requested, the lexical backslash
5863                    // sequence is preserved verbatim in the result.
5864                    if self.escape && esc != 'u' {
5865                        s.push('\\'); s.push(esc); continue;
5866                    }
5867                    match esc {
5868                        '"' => s.push('"'),
5869                        '\\' => s.push('\\'),
5870                        '/' => s.push('/'),
5871                        'b' => s.push('\u{0008}'),
5872                        'f' => s.push('\u{000C}'),
5873                        'n' => s.push('\n'),
5874                        'r' => s.push('\r'),
5875                        't' => s.push('\t'),
5876                        'u' => {
5877                            let cp = self.parse_hex4()?;
5878                            // Surrogate pair handling.
5879                            if (0xD800..=0xDBFF).contains(&cp) {
5880                                if self.peek() == Some('\\') {
5881                                    self.pos += 1;
5882                                    if self.peek() == Some('u') {
5883                                        self.pos += 1;
5884                                        let lo = self.parse_hex4()?;
5885                                        let c = 0x10000
5886                                            + ((cp - 0xD800) << 10)
5887                                            + (lo - 0xDC00);
5888                                        if self.escape {
5889                                            s.push_str(&format!("\\u{cp:04X}\\u{lo:04X}"));
5890                                        } else if let Some(ch) = char::from_u32(c) {
5891                                            s.push(ch);
5892                                        }
5893                                        continue;
5894                                    }
5895                                }
5896                                return Err(xpath_err(
5897                                    "parse-json: invalid surrogate (FOJS0001)"));
5898                            }
5899                            if self.escape {
5900                                s.push_str(&format!("\\u{cp:04X}"));
5901                            } else if let Some(ch) = char::from_u32(cp) {
5902                                s.push(ch);
5903                            }
5904                        }
5905                        _ => return Err(xpath_err(format!(
5906                            "parse-json: invalid escape \\{esc} (FOJS0001)"))),
5907                    }
5908                }
5909                Some(c) => {
5910                    // Unescaped control characters are an error unless
5911                    // `liberal` parsing was requested.
5912                    if (c as u32) < 0x20 && !self.liberal {
5913                        return Err(xpath_err(
5914                            "parse-json: unescaped control character (FOJS0001)"));
5915                    }
5916                    s.push(c);
5917                    self.pos += 1;
5918                }
5919            }
5920        }
5921        Ok(s)
5922    }
5923
5924    fn parse_hex4(&mut self) -> Result<u32> {
5925        let mut v = 0u32;
5926        for _ in 0..4 {
5927            let c = self.peek().ok_or_else(||
5928                xpath_err("parse-json: short \\u escape (FOJS0001)"))?;
5929            let d = c.to_digit(16).ok_or_else(||
5930                xpath_err("parse-json: invalid hex in \\u escape (FOJS0001)"))?;
5931            v = v * 16 + d;
5932            self.pos += 1;
5933        }
5934        Ok(v)
5935    }
5936
5937    fn parse_bool(&mut self, sink: &mut dyn FnMut(JsonEvent)) -> Result<()> {
5938        if self.matches_literal("true") { sink(JsonEvent::Bool(true)); Ok(()) }
5939        else if self.matches_literal("false") { sink(JsonEvent::Bool(false)); Ok(()) }
5940        else { Err(xpath_err("parse-json: invalid literal (FOJS0001)")) }
5941    }
5942
5943    fn parse_null(&mut self, sink: &mut dyn FnMut(JsonEvent)) -> Result<()> {
5944        if self.matches_literal("null") { sink(JsonEvent::Null); Ok(()) }
5945        else { Err(xpath_err("parse-json: invalid literal (FOJS0001)")) }
5946    }
5947
5948    fn matches_literal(&mut self, lit: &str) -> bool {
5949        let end = self.pos + lit.len();
5950        if end <= self.chars.len()
5951            && self.chars[self.pos..end].iter().copied().eq(lit.chars())
5952        {
5953            self.pos = end;
5954            true
5955        } else {
5956            false
5957        }
5958    }
5959
5960    fn parse_number(&mut self, sink: &mut dyn FnMut(JsonEvent)) -> Result<()> {
5961        let start = self.pos;
5962        let digits = |p: &mut Self| {
5963            let s = p.pos;
5964            while p.peek().is_some_and(|c| c.is_ascii_digit()) { p.pos += 1; }
5965            p.pos > s
5966        };
5967        let bad = || xpath_err("parse-json: invalid number (FOJS0001)");
5968        if self.liberal {
5969            // Liberal mode tolerates the lexical forms JSON forbids
5970            // (leading zeros, leading `+`, bare fraction); scan loosely.
5971            if matches!(self.peek(), Some('-') | Some('+')) { self.pos += 1; }
5972            while self.peek().is_some_and(|c|
5973                c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')) {
5974                self.pos += 1;
5975            }
5976        } else {
5977            // Strict RFC 8259 number grammar:
5978            // `-? (0 | [1-9][0-9]*) (. [0-9]+)? ([eE] [+-]? [0-9]+)?`.
5979            if self.peek() == Some('-') { self.pos += 1; }
5980            match self.peek() {
5981                Some('0') => self.pos += 1, // a leading 0 stands alone
5982                Some(c) if c.is_ascii_digit() => { digits(self); }
5983                _ => return Err(bad()),
5984            }
5985            if self.peek() == Some('.') {
5986                self.pos += 1;
5987                if !digits(self) { return Err(bad()); }
5988            }
5989            if matches!(self.peek(), Some('e') | Some('E')) {
5990                self.pos += 1;
5991                if matches!(self.peek(), Some('+') | Some('-')) { self.pos += 1; }
5992                if !digits(self) { return Err(bad()); }
5993            }
5994        }
5995        let lex: String = self.chars[start..self.pos].iter().collect();
5996        match lex.parse::<f64>() {
5997            Ok(n) if n.is_finite() => { sink(JsonEvent::Number(lex)); Ok(()) }
5998            _ => Err(xpath_err(format!("parse-json: invalid number {lex:?} (FOJS0001)"))),
5999        }
6000    }
6001}
6002
6003/// Validate and read a boolean-typed JSON option (F&O §17.5): the value
6004/// must be exactly one xs:boolean.  Absent → `Ok(None)`; wrong
6005/// type/cardinality → FOJS0005.
6006pub fn json_option_bool<I: DocIndexLike>(
6007    opts: Option<&Value>, key: &str, idx: &I,
6008) -> Result<Option<bool>> {
6009    fn single_bool(v: &Value) -> Option<bool> {
6010        match v {
6011            Value::Boolean(b) => Some(*b),
6012            Value::Typed(t) if t.kind == "boolean" => t.boolean,
6013            Value::Sequence(items) if items.len() == 1 => single_bool(&items[0]),
6014            _ => None,
6015        }
6016    }
6017    let Some(Value::Map(m)) = opts else { return Ok(None) };
6018    match m.iter().find(|(k, _)| value_to_string(k, idx) == key) {
6019        None => Ok(None),
6020        Some((_, v)) => single_bool(v).map(Some).ok_or_else(||
6021            xpath_err(format!("JSON option {key:?} must be a single boolean (FOJS0005)"))),
6022    }
6023}
6024
6025/// The string value of a map key for duplicate detection.
6026fn value_as_str(v: &Value) -> String {
6027    match v {
6028        Value::String(s) => s.clone(),
6029        Value::Typed(t) => t.lexical.clone(),
6030        _ => String::new(),
6031    }
6032}
6033
6034/// The XPath/XQuery functions-and-operators namespace, which the JSON
6035/// element vocabulary (`map`/`array`/`string`/…) lives in.
6036const FN_NAMESPACE: &str = "http://www.w3.org/2005/xpath-functions";
6037
6038/// Serialize an element in the F&O JSON element vocabulary
6039/// (`map`/`array`/`string`/`number`/`boolean`/`null` in the
6040/// xpath-functions namespace) to a JSON string (F&O §17.5.5),
6041/// validating the vocabulary as it goes (FOJS0006 / FOJS0007).
6042/// `in_map` is true when `elem` is a member of an enclosing `map`,
6043/// where a `key` attribute is required and otherwise forbidden.
6044fn xml_node_to_json<I: DocIndexLike>(
6045    elem: NodeId, idx: &I, in_map: bool, out: &mut String,
6046) -> Result<()> {
6047    let err = |m: &str| xpath_err(format!("xml-to-json: {m} (FOJS0006)"));
6048    if idx.namespace_uri(elem) != FN_NAMESPACE {
6049        return Err(err("element is not in the JSON namespace"));
6050    }
6051    let local = idx.local_name(elem).to_string();
6052    // Validate attributes: only `key`/`escaped`/`escaped-key` in no
6053    // namespace are recognised; foreign-namespace attributes are
6054    // ignored (§17.5.5).
6055    for a in idx.attr_range(elem) {
6056        if !idx.namespace_uri(a).is_empty() { continue; }
6057        match idx.local_name(a) {
6058            "key" | "escaped" | "escaped-key" => {}
6059            other => return Err(err(&format!("unexpected attribute {other:?}"))),
6060        }
6061    }
6062    let has_key = attr_value(elem, "key", idx).is_some();
6063    if in_map && !has_key { return Err(err("map entry is missing its key")); }
6064    if !in_map && has_key { return Err(err("key attribute outside a map")); }
6065
6066    let elem_children: Vec<NodeId> = idx.children(elem).iter().copied()
6067        .filter(|&c| matches!(idx.kind(c), XPathNodeKind::Element)).collect();
6068    let text = direct_text(elem, idx);
6069    let text_is_blank = text.trim().is_empty();
6070
6071    if in_map {
6072        // Emit the (possibly pre-escaped) key before the value.
6073        let key = attr_value(elem, "key", idx).unwrap_or_default();
6074        out.push('"');
6075        if json_bool_attr(elem, "escaped-key", idx)?.unwrap_or(false) {
6076            validate_json_escapes(&key)?;
6077            out.push_str(&key);
6078        } else {
6079            json_escape_into(&key, out);
6080        }
6081        out.push_str("\":");
6082    }
6083
6084    match local.as_str() {
6085        "null" => {
6086            if !elem_children.is_empty() || !text_is_blank {
6087                return Err(err("null must be empty"));
6088            }
6089            out.push_str("null");
6090        }
6091        "boolean" => {
6092            if !elem_children.is_empty() { return Err(err("boolean has element content")); }
6093            match text.trim() {
6094                "true" | "1"  => out.push_str("true"),
6095                "false" | "0" => out.push_str("false"),
6096                _ => return Err(err("boolean value is not true/false")),
6097            }
6098        }
6099        "number" => {
6100            if !elem_children.is_empty() { return Err(err("number has element content")); }
6101            let n = text.trim();
6102            match n.parse::<f64>() {
6103                Ok(f) if f.is_finite() => out.push_str(n),
6104                _ => return Err(err("number is not a valid JSON number")),
6105            }
6106        }
6107        "string" => {
6108            if !elem_children.is_empty() { return Err(err("string has element content")); }
6109            out.push('"');
6110            if json_bool_attr(elem, "escaped", idx)?.unwrap_or(false) {
6111                // Content is already JSON-escaped: validate (FOJS0007)
6112                // and emit verbatim.
6113                validate_json_escapes(&text)?;
6114                out.push_str(&text);
6115            } else {
6116                json_escape_into(&text, out);
6117            }
6118            out.push('"');
6119        }
6120        "array" => {
6121            if !text_is_blank { return Err(err("array has text content")); }
6122            out.push('[');
6123            for (i, &c) in elem_children.iter().enumerate() {
6124                if i > 0 { out.push(','); }
6125                xml_node_to_json(c, idx, false, out)?;
6126            }
6127            out.push(']');
6128        }
6129        "map" => {
6130            if !text_is_blank { return Err(err("map has text content")); }
6131            out.push('{');
6132            let mut seen: Vec<String> = Vec::new();
6133            for (i, &c) in elem_children.iter().enumerate() {
6134                if idx.namespace_uri(c) == FN_NAMESPACE {
6135                    if let Some(k) = attr_value(c, "key", idx) {
6136                        if seen.contains(&k) {
6137                            return Err(err("duplicate key in map"));
6138                        }
6139                        seen.push(k);
6140                    }
6141                }
6142                if i > 0 { out.push(','); }
6143                xml_node_to_json(c, idx, true, out)?;
6144            }
6145            out.push('}');
6146        }
6147        other => return Err(err(&format!("unexpected element {other:?}"))),
6148    }
6149    Ok(())
6150}
6151
6152/// Concatenated string value of an element's direct text / CDATA
6153/// children (ignoring descendant elements).
6154fn direct_text<I: DocIndexLike>(elem: NodeId, idx: &I) -> String {
6155    let mut s = String::new();
6156    for &c in idx.children(elem) {
6157        if matches!(idx.kind(c), XPathNodeKind::Text | XPathNodeKind::CData) {
6158            s.push_str(&idx.string_value(c));
6159        }
6160    }
6161    s
6162}
6163
6164/// Read a boolean-valued attribute (`true`/`false`/`1`/`0`); a value
6165/// outside that set is FOJS0006.
6166fn json_bool_attr<I: DocIndexLike>(elem: NodeId, name: &str, idx: &I) -> Result<Option<bool>> {
6167    match attr_value(elem, name, idx) {
6168        None => Ok(None),
6169        Some(v) => match v.trim() {
6170            "true" | "1"  => Ok(Some(true)),
6171            "false" | "0" => Ok(Some(false)),
6172            _ => Err(xpath_err(format!(
6173                "xml-to-json: {name} is not a boolean (FOJS0006)"))),
6174        },
6175    }
6176}
6177
6178/// Validate that `s` contains only well-formed JSON escape sequences
6179/// (used for content marked `escaped="true"`); FOJS0007 on failure.
6180fn validate_json_escapes(s: &str) -> Result<()> {
6181    let chars: Vec<char> = s.chars().collect();
6182    let mut i = 0;
6183    while i < chars.len() {
6184        if chars[i] == '\\' {
6185            let Some(&next) = chars.get(i + 1) else {
6186                return Err(xpath_err("xml-to-json: dangling backslash (FOJS0007)"));
6187            };
6188            match next {
6189                '"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' => i += 2,
6190                'u' => {
6191                    let hex = chars.get(i + 2..i + 6);
6192                    if !hex.is_some_and(|h| h.iter().all(|c| c.is_ascii_hexdigit())) {
6193                        return Err(xpath_err(
6194                            "xml-to-json: invalid \\u escape (FOJS0007)"));
6195                    }
6196                    i += 6;
6197                }
6198                _ => return Err(xpath_err(format!(
6199                    "xml-to-json: invalid escape \\{next} (FOJS0007)"))),
6200            }
6201        } else {
6202            i += 1;
6203        }
6204    }
6205    Ok(())
6206}
6207
6208/// Look up an unprefixed attribute's value on an element node.
6209fn attr_value<I: DocIndexLike>(elem: NodeId, name: &str, idx: &I) -> Option<String> {
6210    idx.attr_range(elem)
6211        .find(|&a| idx.namespace_uri(a).is_empty() && idx.local_name(a) == name)
6212        .map(|a| idx.string_value(a))
6213}
6214
6215/// Append `s` to `out` with JSON string escaping (F&O §17.4.2).
6216fn json_escape_into(s: &str, out: &mut String) {
6217    for c in s.chars() {
6218        match c {
6219            '"' => out.push_str("\\\""),
6220            '\\' => out.push_str("\\\\"),
6221            '\u{0008}' => out.push_str("\\b"),
6222            '\u{000C}' => out.push_str("\\f"),
6223            '\n' => out.push_str("\\n"),
6224            '\r' => out.push_str("\\r"),
6225            '\t' => out.push_str("\\t"),
6226            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
6227            c => out.push(c),
6228        }
6229    }
6230}
6231
6232fn eval_function<I: DocIndexLike>(name: &str, args: &[Expr], ctx: &EvalCtx<'_>, idx: &I) -> Result<Value> {
6233    // Try the caller-supplied function table.  A `prefix:local`
6234    // function name resolves the prefix to a URI via bindings;
6235    // unprefixed names dispatch under empty URI (lxml's
6236    // `extensions={(None, name): fn}` form).  Built-in XPath 1.0
6237    // functions (count, string, …) only exist under the empty URI,
6238    // so the call_function hook is given priority — it can be used
6239    // to override a built-in but, more commonly, registers
6240    // application-specific functions in the same namespace.
6241    let (call_uri, call_local) = match name.split_once(':') {
6242        Some((prefix, local)) => match resolve_prefix_or_implicit(ctx.bindings, prefix) {
6243            Some(uri) => (uri, local.to_string()),
6244            None => return Err(xpath_err(format!(
6245                "Undefined namespace prefix in function name: {prefix}"
6246            ))),
6247        },
6248        None => (String::new(), name.to_string()),
6249    };
6250    // Eagerly evaluate the args so we can pass concrete values to the
6251    // bindings hook — built-in dispatch below re-evaluates lazily
6252    // through macros, which would double-eval if we used the same
6253    // path for both.  We materialize once and reuse for built-ins.
6254    let mut arg_vals: Option<Vec<Value>> = None;
6255    let take_args = |vals: &mut Option<Vec<Value>>, args: &[Expr]| -> Result<Vec<Value>> {
6256        if let Some(v) = vals.take() { return Ok(v); }
6257        args.iter().map(|e| eval_expr(e, ctx, idx)).collect()
6258    };
6259    {
6260        let vs = take_args(&mut arg_vals, args)?;
6261        // 1. User-registered bindings take priority — lets callers
6262        //    override EXSLT or define their own functions in any
6263        //    namespace.
6264        let res = ctx.bindings.call_function_in(&call_uri, &call_local, vs.clone(), ctx.context_node);
6265        if let Some(r) = res { return r; }
6266        // 2. EXSLT built-in families (math / date / str / set / regexp / common).
6267        //    Returns `Some` iff `call_uri` is one of the EXSLT
6268        //    namespace URIs *and* the family recognises the name.
6269        if let Some(r) = super::exslt::dispatch(&call_uri, &call_local, vs.clone(), idx) {
6270            return r;
6271        }
6272        // 2a. EXSLT `dyn:evaluate(string)` — compile and evaluate the
6273        //     argument as an XPath expression against the current
6274        //     context.  Lives here (rather than in `exslt::dispatch`)
6275        //     because it needs the full `EvalCtx` to re-enter the
6276        //     parser and evaluator at runtime.
6277        if call_uri == super::exslt::DYN_NS && call_local == "evaluate" {
6278            return dyn_evaluate(&vs, ctx, idx);
6279        }
6280        // 2b. XSD-namespace constructor calls — `xs:integer(arg)`,
6281        //     `xs:string(arg)`, etc.  XPath 2.0 promotes these to
6282        //     first-class atomization conversions; we support the
6283        //     common ones (string/number/boolean/decimal/date)
6284        //     because XSD 1.1 `xs:assert` expressions in real
6285        //     schemas use them to coerce attribute values for
6286        //     numeric or temporal comparisons.
6287        if call_uri == "http://www.w3.org/2001/XMLSchema" {
6288            return Ok(xs_constructor(&call_local, &vs, idx, ctx.bindings)?);
6289        }
6290        // XPath 3.1 §17 map:* / array:* function libraries.
6291        if call_uri == "http://www.w3.org/2005/xpath-functions/map" {
6292            return eval_map_function(&call_local, &vs, ctx, idx);
6293        }
6294        if call_uri == "http://www.w3.org/2005/xpath-functions/array" {
6295            return eval_array_function(&call_local, &vs, ctx, idx);
6296        }
6297        // XPath 3.1 §16 higher-order functions live in the `fn:` URI
6298        // (and the no-prefix default) — fn:for-each, fold-left, etc.
6299        if call_uri.is_empty()
6300            || call_uri == "http://www.w3.org/2005/xpath-functions"
6301        {
6302            if let Some(r) = eval_hof_function(&call_local, &vs, ctx, idx) {
6303                return r;
6304            }
6305            // XPath 3.1 §17.5 JSON functions.
6306            if let Some(r) = eval_json_function(&call_local, &vs, ctx, idx) {
6307                return r;
6308            }
6309        }
6310        arg_vals = Some(vs);
6311    }
6312    // Prefixed function with no registered handler and no EXSLT
6313    // match — XPath 1.0 says that's an error (no built-ins exist
6314    // outside the default namespace).  The one exception is the
6315    // XPath 2.0 functions namespace (`fn:`); built-ins live in
6316    // that URI too, so fall through to built-in dispatch when
6317    // the call URI matches.
6318    if !call_uri.is_empty()
6319        && call_uri != "http://www.w3.org/2005/xpath-functions"
6320    {
6321        return Err(xpath_err(format!(
6322            "Unregistered XPath function: {{{call_uri}}}{call_local}"
6323        )));
6324    }
6325    // Pre-evaluated args available — built-ins below can use them via
6326    // the arg!() macro by indexing into `arg_vals`.  Or, since the
6327    // existing macros assume re-evaluation, we leave them alone:
6328    // duplicate eval is benign for XPath 1.0 (no side effects).
6329    let _ = arg_vals; // hand off to built-in dispatch (which re-evaluates)
6330    macro_rules! arg {
6331        ($n:expr) => {
6332            eval_expr(&args[$n], ctx, idx)?
6333        };
6334    }
6335    macro_rules! arg_str {
6336        ($n:expr) => {
6337            value_to_string_with(&arg!($n), idx, ctx.bindings)
6338        };
6339    }
6340    macro_rules! arg_num {
6341        ($n:expr) => {
6342            value_to_number_with(&arg!($n), idx, ctx.bindings)
6343        };
6344    }
6345    macro_rules! check_args {
6346        ($n:expr) => {
6347            if args.len() != $n {
6348                return Err(xpath_err(format!("{}() requires {} argument(s)", name, $n))
6349                    .with_xpath_code("XPTY0004"));
6350            }
6351        };
6352    }
6353
6354    // Built-ins always dispatch on the local part — `fn:`-prefixed
6355    // calls flow through here too after the prefix has been resolved
6356    // to the XPath functions URI above.
6357    let name = call_local.as_str();
6358    match name {
6359        // ── boolean functions ────────────────────────────────────────────────
6360        "true" => { check_args!(0); Ok(Value::Boolean(true)) }
6361        "false" => { check_args!(0); Ok(Value::Boolean(false)) }
6362        "not" => {
6363            check_args!(1);
6364            Ok(Value::Boolean(!value_to_bool(&arg!(0), idx)))
6365        }
6366        "boolean" => {
6367            check_args!(1);
6368            Ok(Value::Boolean(value_to_bool(&arg!(0), idx)))
6369        }
6370        "lang" => {
6371            // XPath 1.0 §4.3 / XPath 2.0 §15.4.5 — `lang($code)`
6372            // walks the context node's ancestor chain looking for
6373            // `xml:lang`; `lang($code, $node)` walks the explicit
6374            // node's chain instead.  Match is case-insensitive
6375            // against the full attribute value OR a hyphen-prefix
6376            // (so `lang('en')` matches `xml:lang="en-US"`).
6377            if args.is_empty() || args.len() > 2 {
6378                return Err(xpath_err("lang() requires 1 or 2 arguments"));
6379            }
6380            let want = value_to_string(&arg!(0), idx);
6381            let want_lc = want.to_ascii_lowercase();
6382            let start = if args.len() == 2 {
6383                match arg!(1) {
6384                    Value::NodeSet(ns) => ns.first().copied(),
6385                    _ => return Err(xpath_err(
6386                        "lang() second argument must be a node")),
6387                }
6388            } else {
6389                Some(ctx.context_node)
6390            };
6391            let mut current = start;
6392            while let Some(node) = current {
6393                for attr in idx.attr_range(node) {
6394                    if !(idx.local_name(attr) == "lang" && idx.namespace_uri(attr) == "http://www.w3.org/XML/1998/namespace") { continue; }
6395                    let val_lc = idx.string_value(attr).to_ascii_lowercase();
6396                    let ok = val_lc == want_lc
6397                        || (val_lc.len() > want_lc.len()
6398                            && val_lc.starts_with(&want_lc)
6399                            && val_lc.as_bytes()[want_lc.len()] == b'-');
6400                    return Ok(Value::Boolean(ok));
6401                }
6402                current = idx.parent(node);
6403            }
6404            Ok(Value::Boolean(false))
6405        }
6406
6407        // ── node-set functions ───────────────────────────────────────────────
6408        "last" => { check_args!(0); Ok(integer_result(ctx.size as i64, ctx.bindings)) }
6409        "position" => { check_args!(0); Ok(integer_result(ctx.pos as i64, ctx.bindings)) }
6410        "count" => {
6411            check_args!(1);
6412            // XPath 2.0 §15.4.2 — `count` returns the number of
6413            // items in any sequence, not just node-sets.  Atomic
6414            // values are themselves single-item sequences.  An
6415            // `IntRange` (lazy `m to n`) contributes its
6416            // cardinality via [`sequence_len`] without expanding
6417            // the range — that's the whole point of the lazy
6418            // representation.
6419            Ok(integer_result(sequence_len(&arg!(0)) as i64, ctx.bindings))
6420        }
6421        "id" => {
6422            if args.is_empty() || args.len() > 2 {
6423                return Err(xpath_err("id() requires 1 or 2 arguments"));
6424            }
6425            // XPath 1.0 § 4.1 / XPath 2.0 § 14.5.4: the first argument's
6426            // string-value is whitespace-tokenised; each token is
6427            // matched against the unique-ID attributes of the document
6428            // identified by the (optional) second argument's node — or
6429            // the document containing the context node if omitted.  A
6430            // node-set first argument is tokenised per-node and the
6431            // result is the union.  An empty second-argument node-set
6432            // yields the empty sequence.
6433            //
6434            // The set of "ID attributes" follows DTD typing when a
6435            // `<!ATTLIST e a ID>` declaration is present; otherwise
6436            // [`DocIndexLike::is_id_attribute`] falls back to the
6437            // libxml2-compatible convention of any `xml:id` or any
6438            // attribute whose local name is literally `id`.
6439            let v = arg!(0);
6440            let search_root = if args.len() == 2 {
6441                match arg!(1) {
6442                    Value::NodeSet(ns) => match ns.first().copied() {
6443                        Some(n) => doc_root_of(n, idx),
6444                        None    => return Ok(Value::NodeSet(Vec::new())),
6445                    },
6446                    _ => return Err(xpath_err(
6447                        "id() second argument must be a node")),
6448                }
6449            } else {
6450                doc_root_of(ctx.context_node, idx)
6451            };
6452            let tokens: Vec<String> = match &v {
6453                Value::NodeSet(ns) => {
6454                    let mut out: Vec<String> = Vec::new();
6455                    for &n in ns {
6456                        out.extend(
6457                            idx.string_value(n)
6458                                .split_whitespace()
6459                                .map(str::to_string),
6460                        );
6461                    }
6462                    out
6463                }
6464                _ => value_to_string(&v, idx)
6465                    .split_whitespace()
6466                    .map(str::to_string)
6467                    .collect(),
6468            };
6469            let mut hits: Vec<NodeId> = Vec::new();
6470            for node in descendants(search_root, idx, true) {
6471                if !matches!(idx.kind(node), XPathNodeKind::Element) { continue; }
6472                for attr in idx.attr_range(node) {
6473                    if !idx.is_id_attribute(attr) { continue; }
6474                    let av = idx.string_value(attr);
6475                    if tokens.iter().any(|t| *t == av) {
6476                        hits.push(node);
6477                        break;
6478                    }
6479                }
6480            }
6481            dedup_sort(&mut hits);
6482            Ok(Value::NodeSet(hits))
6483        }
6484        "idref" => {
6485            if args.is_empty() || args.len() > 2 {
6486                return Err(xpath_err("idref() requires 1 or 2 arguments"));
6487            }
6488            // XPath 2.0 §14.5.5 — the first argument supplies the
6489            // candidate IDs (whitespace-tokenised); the result is the
6490            // `IDREF`/`IDREFS`-typed attribute nodes whose value
6491            // contains one of them.  The optional second argument's
6492            // node identifies the document to search (else the context
6493            // node's document).
6494            let v = arg!(0);
6495            let search_root = if args.len() == 2 {
6496                match arg!(1) {
6497                    Value::NodeSet(ns) => match ns.first().copied() {
6498                        Some(n) => doc_root_of(n, idx),
6499                        None    => return Ok(Value::NodeSet(Vec::new())),
6500                    },
6501                    _ => return Err(xpath_err(
6502                        "idref() second argument must be a node")),
6503                }
6504            } else {
6505                doc_root_of(ctx.context_node, idx)
6506            };
6507            let tokens: Vec<String> = match &v {
6508                Value::NodeSet(ns) => ns.iter()
6509                    .flat_map(|&n| idx.string_value(n)
6510                        .split_whitespace().map(str::to_string).collect::<Vec<_>>())
6511                    .collect(),
6512                _ => value_to_string(&v, idx)
6513                    .split_whitespace().map(str::to_string).collect(),
6514            };
6515            let mut hits: Vec<NodeId> = Vec::new();
6516            for node in descendants(search_root, idx, true) {
6517                if !matches!(idx.kind(node), XPathNodeKind::Element) { continue; }
6518                for attr in idx.attr_range(node) {
6519                    if !idx.is_idref_attribute(attr) { continue; }
6520                    // IDREFS holds a whitespace-separated list; the
6521                    // attribute matches if any of its tokens is a
6522                    // candidate ID.
6523                    let av = idx.string_value(attr);
6524                    if av.split_whitespace().any(|w| tokens.iter().any(|t| t == w)) {
6525                        hits.push(attr);
6526                    }
6527                }
6528            }
6529            dedup_sort(&mut hits);
6530            Ok(Value::NodeSet(hits))
6531        }
6532        "local-name" => {
6533            let id = if args.is_empty() {
6534                // Same XPTY0004 guard as `name()` — an atomic context
6535                // (e.g. `(1 to 5)[local-name()='a']`) isn't a node.
6536                if ctx.static_ctx.xpath_2_0 && matches!(current_context_item(),
6537                    Some(v) if !matches!(v,
6538                        Value::NodeSet(_) | Value::ForeignNodeSet(_)))
6539                {
6540                    return Err(xpath_err(
6541                        "local-name(): context item is not a node (XPTY0004)"
6542                    ).with_xpath_code("XPTY0004"));
6543                }
6544                Some(ctx.context_node)
6545            } else {
6546                // XPath 1.0 §4.1: an explicit but empty node-set
6547                // argument yields the empty string, not the context
6548                // node's name.
6549                match arg!(0) {
6550                    Value::NodeSet(ns) => ns.first().copied(),
6551                    _ => return Err(xpath_err("local-name() requires a node-set or no argument")),
6552                }
6553            };
6554            Ok(Value::String(id.map(|i| idx.local_name(i).to_string()).unwrap_or_default()))
6555        }
6556        "name" => {
6557            // No-arg form takes the context item.  Under XPath 2.0 an
6558            // atomic context (e.g. inside `(1 to 5)[name()='a']`) is
6559            // XPTY0004 — name() requires a node.
6560            let id = if args.is_empty() {
6561                if ctx.static_ctx.xpath_2_0 && matches!(current_context_item(),
6562                    Some(v) if !matches!(v,
6563                        Value::NodeSet(_) | Value::ForeignNodeSet(_)))
6564                {
6565                    return Err(xpath_err(
6566                        "name(): context item is not a node (XPTY0004)"
6567                    ).with_xpath_code("XPTY0004"));
6568                }
6569                Some(ctx.context_node)
6570            } else {
6571                match arg!(0) {
6572                    Value::NodeSet(ns) => ns.first().copied(),
6573                    _ => return Err(xpath_err("name() requires a node-set or no argument")),
6574                }
6575            };
6576            Ok(Value::String(id.map(|i| idx.node_name(i).to_string()).unwrap_or_default()))
6577        }
6578        "namespace-uri" => {
6579            let id = if args.is_empty() {
6580                if ctx.static_ctx.xpath_2_0 && matches!(current_context_item(),
6581                    Some(v) if !matches!(v,
6582                        Value::NodeSet(_) | Value::ForeignNodeSet(_)))
6583                {
6584                    return Err(xpath_err(
6585                        "namespace-uri(): context item is not a node (XPTY0004)"
6586                    ).with_xpath_code("XPTY0004"));
6587                }
6588                Some(ctx.context_node)
6589            } else {
6590                match arg!(0) {
6591                    Value::NodeSet(ns) => ns.first().copied(),
6592                    _ => return Err(xpath_err("namespace-uri() requires a node-set or no argument")),
6593                }
6594            };
6595            Ok(Value::String(id.map(|i| idx.namespace_uri(i).to_string()).unwrap_or_default()))
6596        }
6597
6598        // ── string functions ─────────────────────────────────────────────────
6599        "string" => {
6600            if args.is_empty() {
6601                Ok(Value::String(idx.string_value(ctx.context_node)))
6602            } else {
6603                check_args!(1);
6604                let a = arg!(0);
6605                if value_is_function(&a) {
6606                    return Err(xpath_err(
6607                        "string(): a function item has no string value (FOTY0014)")
6608                        .with_xpath_code("FOTY0014"));
6609                }
6610                Ok(Value::String(value_to_string_with_compat(
6611                    &a, idx, ctx.bindings, ctx.static_ctx.libxml2_compatible)))
6612            }
6613        }
6614        "concat" => {
6615            if args.len() < 2 {
6616                return Err(xpath_err("concat() requires at least 2 arguments"));
6617            }
6618            let mut s = String::new();
6619            for a in args {
6620                let v = eval_expr(a, ctx, idx)?;
6621                s.push_str(&value_to_string_with_compat(
6622                    &v, idx, ctx.bindings, ctx.static_ctx.libxml2_compatible));
6623            }
6624            Ok(Value::String(s))
6625        }
6626        "starts-with" => {
6627            if args.len() < 2 || args.len() > 3 {
6628                return Err(xpath_err("starts-with() requires 2 or 3 arguments"));
6629            }
6630            let s   = arg_str!(0);
6631            let pre = arg_str!(1);
6632            let coll = effective_collation(if args.len() == 3 { Some(arg_str!(2)) } else { None });
6633            Ok(Value::Boolean(collation_starts_with(&s, &pre, coll.as_deref())))
6634        }
6635        "contains" => {
6636            if args.len() < 2 || args.len() > 3 {
6637                return Err(xpath_err("contains() requires 2 or 3 arguments"));
6638            }
6639            let s   = arg_str!(0);
6640            let sub = arg_str!(1);
6641            let coll = effective_collation(if args.len() == 3 { Some(arg_str!(2)) } else { None });
6642            Ok(Value::Boolean(collation_contains(&s, &sub, coll.as_deref())))
6643        }
6644        "substring-before" => {
6645            if args.len() < 2 || args.len() > 3 {
6646                return Err(xpath_err("substring-before() requires 2 or 3 arguments"));
6647            }
6648            let s = arg_str!(0);
6649            let sep = arg_str!(1);
6650            let coll = effective_collation(if args.len() == 3 { Some(arg_str!(2)) } else { None });
6651            // For the HTML ASCII case-insensitive collation, do the
6652            // search on the folded copy but slice the original on the
6653            // matched byte index — the fold is a 1:1 ASCII transform
6654            // so byte offsets line up.
6655            let find_pos = if is_ascii_ci_collation(coll.as_deref()) {
6656                ascii_ci_fold(&s).find(&ascii_ci_fold(&sep))
6657            } else {
6658                s.find(sep.as_str())
6659            };
6660            Ok(Value::String(match find_pos {
6661                Some(pos) => s[..pos].to_string(),
6662                None => String::new(),
6663            }))
6664        }
6665        "substring-after" => {
6666            if args.len() < 2 || args.len() > 3 {
6667                return Err(xpath_err("substring-after() requires 2 or 3 arguments"));
6668            }
6669            let s = arg_str!(0);
6670            let sep = arg_str!(1);
6671            let coll = effective_collation(if args.len() == 3 { Some(arg_str!(2)) } else { None });
6672            let find_pos = if is_ascii_ci_collation(coll.as_deref()) {
6673                ascii_ci_fold(&s).find(&ascii_ci_fold(&sep))
6674            } else {
6675                s.find(sep.as_str())
6676            };
6677            Ok(Value::String(match find_pos {
6678                Some(pos) => s[pos + sep.len()..].to_string(),
6679                None => String::new(),
6680            }))
6681        }
6682        "substring" => {
6683            if args.len() < 2 || args.len() > 3 {
6684                return Err(xpath_err("substring() requires 2 or 3 arguments"));
6685            }
6686            let s = arg_str!(0);
6687            let chars: Vec<char> = s.chars().collect();
6688            let len = chars.len();
6689            // XPath 1.0 §4.2: result is chars at 1-based positions p where
6690            // round(start) <= p < round(start) + round(len).  Compute the
6691            // bounds in f64 so +Inf / -Inf / NaN behave per spec (NaN comparisons
6692            // are always false → empty result; +Inf falls past `len` → empty).
6693            // Convert to usize only after clamping to [0, len] in f64.
6694            let len_f = len as f64;
6695            let start_1based = arg_num!(1).round();
6696            let end_1based = if args.len() == 3 {
6697                // start + len_arg may be NaN (e.g. +Inf + -Inf); guard explicitly.
6698                let cnt = arg_num!(2).round();
6699                let e = start_1based + cnt;
6700                if e.is_nan() { 0.0 } else { e }
6701            } else {
6702                len_f + 1.0
6703            };
6704            // Convert 1-based [start, end) into 0-based [s, e) clamped to [0, len].
6705            let s_f = (start_1based - 1.0).max(0.0).min(len_f);
6706            let e_f = (end_1based - 1.0).max(0.0).min(len_f);
6707            let (s_idx, e_idx) = if s_f <= e_f {
6708                (s_f as usize, e_f as usize)
6709            } else {
6710                (0, 0)
6711            };
6712            let result: String = chars[s_idx..e_idx].iter().collect();
6713            Ok(Value::String(result))
6714        }
6715        "string-length" => {
6716            let s = if args.is_empty() {
6717                idx.string_value(ctx.context_node)
6718            } else {
6719                check_args!(1);
6720                arg_str!(0)
6721            };
6722            Ok(integer_result(s.chars().count() as i64, ctx.bindings))
6723        }
6724        "normalize-space" => {
6725            let s = if args.is_empty() {
6726                idx.string_value(ctx.context_node)
6727            } else {
6728                check_args!(1);
6729                arg_str!(0)
6730            };
6731            let normalized = s.split_whitespace().collect::<Vec<_>>().join(" ");
6732            Ok(Value::String(normalized))
6733        }
6734        "translate" => {
6735            check_args!(3);
6736            let s = arg_str!(0);
6737            let from = arg_str!(1);
6738            let to: Vec<char> = arg_str!(2).chars().collect();
6739            // Build a lookup table once instead of scanning
6740            // `from_chars` linearly per input character.  For
6741            // duplicate keys in `from`, the *first* occurrence
6742            // wins (XSLT 1.0 §6.4.2).  `Some(c)` replaces;
6743            // `None` drops the character entirely (when the
6744            // matched `from` index has no corresponding `to`).
6745            let mut map: std::collections::HashMap<char, Option<char>> =
6746                std::collections::HashMap::new();
6747            for (i, f) in from.chars().enumerate() {
6748                map.entry(f).or_insert_with(|| to.get(i).copied());
6749            }
6750            let result: String = s
6751                .chars()
6752                .filter_map(|c| match map.get(&c) {
6753                    Some(Some(t)) => Some(*t),
6754                    Some(None)    => None,
6755                    None          => Some(c),
6756                })
6757                .collect();
6758            Ok(Value::String(result))
6759        }
6760
6761        // ── number functions ─────────────────────────────────────────────────
6762        "number" => {
6763            let v = if args.is_empty() {
6764                Value::String(idx.string_value(ctx.context_node))
6765            } else {
6766                check_args!(1);
6767                arg!(0)
6768            };
6769            // fn:number atomizes its argument; a function item has no
6770            // typed value, so atomizing one is a type error (FOTY0013),
6771            // not NaN.
6772            if value_is_function(&v)
6773                || matches!(&v, Value::Sequence(items) if items.iter().any(value_is_function))
6774            {
6775                return Err(xpath_err(
6776                    "number(): a function item cannot be atomized (FOTY0013)")
6777                    .with_xpath_code("FOTY0013"));
6778            }
6779            // libxml2 quirk: `number('-')` returns -0, not NaN.  Per
6780            // spec § 4.4 the result is NaN for any non-numeric
6781            // lexical form, but matching libxml2 lets corpora that
6782            // exercise this edge case round-trip cleanly.
6783            if ctx.static_ctx.libxml2_compatible {
6784                if let Value::String(ref s) = v {
6785                    if s.trim() == "-" { return Ok(Value::Number(Numeric::Double(-0.0))); }
6786                }
6787            }
6788            // XPath 2.0 §14 fn:number returns xs:double; the Double
6789            // kind drives `string(number(x))`'s scientific form in 2.0
6790            // and a decimal string in 1.0 / libxml2.
6791            Ok(Value::Number(Numeric::Double(value_to_number(&v, idx))))
6792        }
6793        "sum" => {
6794            // XPath 2.0 §15.4.4: `sum($seq [, $zero])` — the 2-arg
6795            // form returns `$zero` when `$seq` is the empty sequence
6796            // (default `xs:integer(0)` for the 1-arg form).  Atomic
6797            // sequences sum their numeric coercions.
6798            if args.is_empty() || args.len() > 2 {
6799                return Err(xpath_err(format!(
6800                    "sum() requires 1 or 2 arguments (got {})", args.len()
6801                )));
6802            }
6803            // Pre-evaluate the optional zero argument so the
6804            // match arms below can return it without re-entering
6805            // the macro (which uses `?`).
6806            let zero_val: Value = if args.len() == 2 { arg!(1) } else { Value::Number(Numeric::Double(0.0)) };
6807            match arg!(0).untyped() {
6808                Value::NodeSet(ns) => {
6809                    if ns.is_empty() {
6810                        return Ok(zero_val);
6811                    }
6812                    let total: f64 = ns.iter().map(|&id|
6813                        idx.string_value(id).trim().parse::<f64>().unwrap_or(f64::NAN)
6814                    ).sum();
6815                    Ok(Value::Number(Numeric::Double(total)))
6816                }
6817                Value::Number(n) => Ok(Value::Number(n)),
6818                Value::String(s) => Ok(Value::Number(Numeric::Double(s.trim().parse::<f64>().unwrap_or(f64::NAN)))),
6819                Value::Boolean(b) => Ok(Value::Number(Numeric::Double(if b { 1.0 } else { 0.0 }))),
6820                // Atomic sequence (typed or mixed): coerce each item to
6821                // a number and sum.  XPath 2.0 §15.4.4 — empty sequence
6822                // yields the zero argument; otherwise items add.
6823                Value::Sequence(items) => {
6824                    if items.is_empty() {
6825                        return Ok(zero_val);
6826                    }
6827                    // F&O §10.4 — sum of a duration sequence is a
6828                    // duration (sum of dayTimeDurations is a
6829                    // dayTimeDuration, etc.), not a coerced number.
6830                    if let Some((kind, total, _)) = duration_seq_total(&items) {
6831                        return Ok(duration_value(kind, total));
6832                    }
6833                    // F&O §15.4.4 / FORG0006 — every item must promote
6834                    // to a single numeric (or duration) type.  An
6835                    // xs:string literal that can't parse to a number
6836                    // never matches: raise the type error rather than
6837                    // silently propagating NaN.  xs:untypedAtomic
6838                    // (sourced from node string-values) still
6839                    // converts via xs:double per spec, so this only
6840                    // catches the explicit-string case.
6841                    for v in &items {
6842                        if let Value::String(s) = v {
6843                            if s.trim().parse::<f64>().is_err() {
6844                                return Err(xpath_err(format!(
6845                                    "sum(): non-numeric item '{s}' \
6846                                     (FORG0006)"
6847                                )).with_xpath_code("FORG0006"));
6848                            }
6849                        }
6850                    }
6851                    let total: f64 = items.iter()
6852                        .map(|v| value_to_number_with(v, idx, ctx.bindings))
6853                        .sum();
6854                    // XPath 2.0 §15.4.4 — the result type is the
6855                    // promoted numeric type of the items (sum of
6856                    // integers is xs:integer, etc.).  An item without a
6857                    // numeric kind (an untyped atomic) is treated as
6858                    // xs:double, collapsing the result to double.
6859                    let kind = items.iter().fold(Some("integer"), |acc, v| {
6860                        match (acc, numeric_kind_of(v)) {
6861                            (Some(a), Some(b)) => numeric_promote_kind(Some(a), Some(b)),
6862                            _ => None,
6863                        }
6864                    });
6865                    Ok(Value::Number(match kind {
6866                        Some(k) => Numeric::of_kind(k, total),
6867                        None    => Numeric::Double(total),
6868                    }))
6869                }
6870                Value::ForeignNodeSet(_) => Err(xpath_err(
6871                    "sum() over foreign node-sets not supported")),
6872                Value::Typed(_) => unreachable!(),
6873                // Closed-form sum of consecutive integers — the result
6874                // is itself an xs:integer.
6875                Value::IntRange { lo, hi } => {
6876                    let total = ((hi - lo + 1) as f64) * ((lo + hi) as f64) * 0.5;
6877                    Ok(Value::Number(Numeric::of_kind("integer", total)))
6878                }
6879                // sum() over a map / array is a type error (FORG0006);
6880                // lenient — treat as the empty-sequence case → $zero.
6881                Value::Map(_) | Value::Array(_) | Value::Function(_) => Ok(zero_val),
6882            }
6883        }
6884        "floor" => {
6885            check_args!(1);
6886            let a = arg!(0);
6887            if ctx.static_ctx.xpath_2_0 {
6888                if let Value::String(s) = &a {
6889                    if s.trim().parse::<f64>().is_err() {
6890                        return Err(xpath_err(format!(
6891                            "floor(): argument '{s}' is not a number (XPTY0004)"
6892                        )).with_xpath_code("XPTY0004"));
6893                    }
6894                }
6895            }
6896            Ok(preserve_numeric_kind(&a, value_to_number(&a, idx).floor()))
6897        }
6898        "ceiling" => {
6899            check_args!(1);
6900            let a = arg!(0);
6901            if ctx.static_ctx.xpath_2_0 {
6902                if let Value::String(s) = &a {
6903                    if s.trim().parse::<f64>().is_err() {
6904                        return Err(xpath_err(format!(
6905                            "ceiling(): argument '{s}' is not a number (XPTY0004)"
6906                        )).with_xpath_code("XPTY0004"));
6907                    }
6908                }
6909            }
6910            Ok(preserve_numeric_kind(&a, value_to_number(&a, idx).ceil()))
6911        }
6912        "round" => {
6913            // 1-arg `fn:round($arg)`; the 2-arg `fn:round($arg, $precision)`
6914            // form is XPath 3.0 — in a 2.0 host the extra argument is a
6915            // static error (XPST0017).
6916            if args.is_empty() || args.len() > 2 {
6917                return Err(xpath_err("round() requires 1 or 2 argument(s)"));
6918            }
6919            if args.len() == 2 && !ctx.static_ctx.xpath_3_0 {
6920                return Err(xpath_err(
6921                    "round(): the 2-argument form requires XPath 3.0 (XPST0017)")
6922                    .with_xpath_code("XPST0017"));
6923            }
6924            let a = arg!(0);
6925            // XPath 2.0 §3.5.5 / XPTY0004 — fn:round's argument is
6926            // `xs:numeric?`; an xs:string (literal-sourced) doesn't
6927            // promote and must be cast explicitly.  Untyped atomics
6928            // from node atomization stay lenient.
6929            if ctx.static_ctx.xpath_2_0 {
6930                if let Value::String(s) = &a {
6931                    if s.trim().parse::<f64>().is_err() {
6932                        return Err(xpath_err(format!(
6933                            "round(): argument '{s}' is not a number (XPTY0004)"
6934                        )).with_xpath_code("XPTY0004"));
6935                    }
6936                }
6937            }
6938            let precision = if args.len() == 2 {
6939                value_to_number(&arg!(1), idx) as i32
6940            } else { 0 };
6941            // Ties round toward +∞, at the chosen number of decimal
6942            // places.  Decimal / integer inputs round in exact decimal
6943            // arithmetic (so `round(1.25, 1)` is `1.3`, not the f64
6944            // neighbour); double / float inputs round in f64.
6945            use rust_decimal::prelude::ToPrimitive;
6946            match &a {
6947                Value::Number(Numeric::Decimal(d)) =>
6948                    Ok(Value::Number(Numeric::Decimal(round_decimal_half_to_pos_inf(*d, precision)))),
6949                Value::Number(Numeric::Integer(i)) => {
6950                    let d = round_decimal_half_to_pos_inf(rust_decimal::Decimal::from(*i), precision);
6951                    Ok(Value::Number(match d.to_i64() {
6952                        Some(v) => Numeric::Integer(v),
6953                        None    => Numeric::Decimal(d),
6954                    }))
6955                }
6956                _ => {
6957                    let n = value_to_number(&a, idx);
6958                    let r = if !n.is_finite() {
6959                        n
6960                    } else {
6961                        let scale = 10f64.powi(precision);
6962                        let scaled = n * scale;
6963                        let rs = if scaled.is_sign_negative() && scaled >= -0.5 { -0.0 }
6964                                 else { (scaled + 0.5).floor() };
6965                        rs / scale
6966                    };
6967                    Ok(preserve_numeric_kind(&a, r))
6968                }
6969            }
6970        }
6971
6972        // ── XSLT extension: document(URI[, base-node-set]) ──────────────────
6973        // Returns a ForeignNodeSet — pointers to nodes in the loaded
6974        // doc(s).  Bindings impl (compat) keeps a registry of loaded
6975        // docs alive for the XPath context's lifetime.
6976        "document" => {
6977            if args.is_empty() || args.len() > 2 {
6978                return Err(xpath_err("document() requires 1 or 2 arguments"));
6979            }
6980            let uri = value_to_string(&arg!(0), idx);
6981            // 2-arg form: second arg is a node-set whose first node's
6982            // base URI is used to resolve relative URIs.  Not yet
6983            // honored — pass None and let the bindings fall back to
6984            // the calling expression's base URI.
6985            match ctx.bindings.load_document(&uri, None) {
6986                Some(r) => r.map(Value::ForeignNodeSet)
6987                    .map_err(|e| e.or_xpath_code("FODC0002")),
6988                None => Err(xpath_err(
6989                    "document(): bindings don't support external document loading",
6990                ).with_xpath_code("FODC0002")),
6991            }
6992        }
6993
6994        // ── XPath 2.0 functions used by XSD 1.1 `xs:assert` ───────────────
6995        //
6996        // These are the small set of 2.0 functions that show up
6997        // frequently in test-expression bodies and have natural
6998        // XPath-1.0-flavoured implementations.  We keep the dispatch
6999        // here (rather than the EXSLT module) because they're
7000        // unprefixed and live in the default XPath function library.
7001
7002        "ends-with" => {
7003            if args.len() < 2 || args.len() > 3 {
7004                return Err(xpath_err("ends-with() requires 2 or 3 arguments"));
7005            }
7006            let s   = arg_str!(0);
7007            let suf = arg_str!(1);
7008            let coll = effective_collation(if args.len() == 3 { Some(arg_str!(2)) } else { None });
7009            Ok(Value::Boolean(collation_ends_with(&s, &suf, coll.as_deref())))
7010        }
7011        "exists" => {
7012            check_args!(1);
7013            let v = arg!(0);
7014            Ok(Value::Boolean(match v {
7015                // XPath 2.0 `exists` is sequence-cardinality, not
7016                // boolean truthiness: a non-empty node-set, a
7017                // string (incl. ""), a number (incl. NaN), or a
7018                // boolean all count as 1+ item sequences.
7019                Value::NodeSet(ns)        => !ns.is_empty(),
7020                Value::ForeignNodeSet(ns) => !ns.is_empty(),
7021                Value::Sequence(items)    => !items.is_empty(),
7022                Value::IntRange { lo, hi } => hi >= lo,
7023                _                         => true,
7024            }))
7025        }
7026        "empty" => {
7027            check_args!(1);
7028            let v = arg!(0);
7029            Ok(Value::Boolean(match v {
7030                Value::NodeSet(ns)        => ns.is_empty(),
7031                Value::ForeignNodeSet(ns) => ns.is_empty(),
7032                Value::Sequence(items)    => items.is_empty(),
7033                Value::IntRange { lo, hi } => hi < lo,
7034                _                         => false,
7035            }))
7036        }
7037        "data" => {
7038            // Atomization (XPath 2.0 §2.5.2): atomise every item
7039            // in the input sequence to a string atomic.  A
7040            // multi-node input becomes a Sequence of String items,
7041            // not a concatenated single string — downstream
7042            // `xsl:value-of separator=…` consumers depend on the
7043            // per-item shape to interleave the separator correctly.
7044            check_args!(1);
7045            let v = arg!(0);
7046            match v {
7047                Value::NodeSet(ns) => {
7048                    let items: Vec<Value> = ns.iter()
7049                        .map(|&id| {
7050                            let sv = idx.string_value(id);
7051                            // Schema-aware: a node with a recorded simple
7052                            // schema type atomizes to its typed value;
7053                            // otherwise to xs:untypedAtomic (a String).
7054                            ctx.bindings.node_typed_value(id, &sv)
7055                                .unwrap_or(Value::String(sv))
7056                        })
7057                        .collect();
7058                    match items.len() {
7059                        0 => Ok(Value::NodeSet(Vec::new())),
7060                        1 => Ok(items.into_iter().next().unwrap()),
7061                        _ => Ok(Value::Sequence(items)),
7062                    }
7063                }
7064                Value::ForeignNodeSet(ns) => {
7065                    let items: Vec<Value> = ns.iter()
7066                        .map(|&p| Value::String(ctx.bindings.foreign_string_value(p)))
7067                        .collect();
7068                    match items.len() {
7069                        0 => Ok(Value::NodeSet(Vec::new())),
7070                        1 => Ok(items.into_iter().next().unwrap()),
7071                        _ => Ok(Value::Sequence(items)),
7072                    }
7073                }
7074                // A function item has no typed value — atomization is a
7075                // type error.
7076                Value::Function(_) => Err(xpath_err(
7077                    "data(): a function item cannot be atomized (FOTY0013)")
7078                    .with_xpath_code("FOTY0013")),
7079                Value::Sequence(items) if items.iter().any(value_is_function) =>
7080                    Err(xpath_err(
7081                        "data(): a function item cannot be atomized (FOTY0013)")
7082                        .with_xpath_code("FOTY0013")),
7083                other => Ok(other),
7084            }
7085        }
7086        "current-dateTime" => {
7087            check_args!(0);
7088            let now = stable_now();
7089            Ok(Value::Typed(Box::new(TypedAtomic {
7090                kind: "dateTime",
7091                lexical: format_datetime_utc(now),
7092                numeric: None,
7093                boolean: None, user_type: None,
7094            })))
7095        }
7096        "current-date" => {
7097            check_args!(0);
7098            let now = stable_now();
7099            Ok(Value::Typed(Box::new(TypedAtomic {
7100                kind: "date",
7101                lexical: format_date_utc(now),
7102                numeric: None,
7103                boolean: None, user_type: None,
7104            })))
7105        }
7106        "current-time" => {
7107            check_args!(0);
7108            let now = stable_now();
7109            Ok(Value::Typed(Box::new(TypedAtomic {
7110                kind: "time",
7111                lexical: format_time_utc(now),
7112                numeric: None,
7113                boolean: None, user_type: None,
7114            })))
7115        }
7116        // XPath 2.0 §10.5 — `fn:adjust-{date,dateTime,time}-to-timezone`.
7117        // Re-stamps the value to the given timezone (or strips the
7118        // timezone when the second argument is the empty sequence).
7119        // Two-arg form supplies an explicit `xs:dayTimeDuration`
7120        // timezone offset; one-arg form uses the implicit timezone
7121        // (we treat that as `PT0S` since we don't track locale).
7122        "adjust-dateTime-to-timezone"
7123        | "adjust-date-to-timezone"
7124        | "adjust-time-to-timezone" => {
7125            if args.is_empty() || args.len() > 2 {
7126                return Err(xpath_err(format!(
7127                    "{name}() requires 1 or 2 arguments (got {})", args.len()
7128                )));
7129            }
7130            let kind = match name {
7131                "adjust-dateTime-to-timezone" => "dateTime",
7132                "adjust-date-to-timezone"     => "date",
7133                "adjust-time-to-timezone"     => "time",
7134                _ => unreachable!(),
7135            };
7136            // Empty input → empty output.
7137            if let Value::NodeSet(ns) = &arg!(0) {
7138                if ns.is_empty() { return Ok(Value::NodeSet(Vec::new())); }
7139            }
7140            let lex = value_to_string_with(&arg!(0), idx, ctx.bindings);
7141            let new_tz_minutes: Option<i16> = if args.len() == 2 {
7142                // Empty second arg = strip timezone.
7143                let is_empty = matches!(&arg!(1),
7144                    Value::NodeSet(ns) if ns.is_empty());
7145                if is_empty {
7146                    None
7147                } else {
7148                    let dur = value_to_string_with(&arg!(1), idx, ctx.bindings);
7149                    let secs = parse_day_time_duration_secs(&dur).unwrap_or(0);
7150                    let mins = secs / 60;
7151                    if mins.abs() > 14 * 60 {
7152                        return Err(xpath_err(format!(
7153                            "adjust timezone exceeds 14 hours: {dur}"
7154                        )));
7155                    }
7156                    Some(mins as i16)
7157                }
7158            } else {
7159                Some(0) // implicit timezone — we use UTC
7160            };
7161            Ok(Value::Typed(Box::new(TypedAtomic {
7162                kind,
7163                lexical: adjust_timezone(&lex, kind, new_tz_minutes),
7164                numeric: None,
7165                boolean: None, user_type: None,
7166            })))
7167        }
7168
7169        // ── XPath 2.0 regex functions ─────────────────────────────────
7170        //
7171        // `matches` / `replace` / `tokenize` accept an optional 3rd
7172        // `flags` argument (XPath 2.0 §7.6).  The pattern syntax in
7173        // XPath 2.0 is XML Schema's regex flavour, which overlaps
7174        // heavily with the Rust `regex` crate's RE2 syntax for the
7175        // common cases (character classes, anchors, quantifiers,
7176        // alternation, groups, Unicode categories).  We pass the
7177        // pattern through unchanged and translate the flag string
7178        // into Rust's `(?flags)` inline form.
7179        "matches" => {
7180            if args.len() < 2 || args.len() > 3 {
7181                return Err(xpath_err(format!(
7182                    "matches() requires 2 or 3 arguments (got {})", args.len()
7183                )));
7184            }
7185            let input   = arg_str!(0);
7186            let pattern = arg_str!(1);
7187            let flags   = if args.len() == 3 { arg_str!(2) } else { String::new() };
7188            // The native XSD §F / XPath 2.0 engine implements the
7189            // full XPath dialect (`^`/`$` anchors, find semantics,
7190            // class subtraction, `\p{IsBlock}`, XSD `\s`/`\d`/`\w`,
7191            // and the spec's strict rejection of `(?…)` forms).
7192            // It is authoritative when no flags are in play —
7193            // including for syntax errors, which must surface as
7194            // FORX0002 rather than fall back to a more permissive
7195            // engine that would silently accept the bad pattern.
7196            // Flag handling still routes through the Rust crate
7197            // until the native engine grows i/s/m/x support.
7198            if flags.is_empty() {
7199                return crate::regex::compile_with_cached(
7200                    &pattern, ctx.bindings.regex_dialect(),
7201                )
7202                    .map(|p| Value::Boolean(p.find_match(&input)))
7203                    .map_err(|e| xpath_err(format!("invalid regex: {e}"))
7204                        .with_xpath_code("FORX0002"));
7205            }
7206            let re = compile_xpath_regex_dialect(&pattern, &flags,
7207                ctx.bindings.regex_dialect())?;
7208            Ok(Value::Boolean(re.is_match(&input)))
7209        }
7210        "replace" => {
7211            if args.len() < 3 || args.len() > 4 {
7212                return Err(xpath_err(format!(
7213                    "replace() requires 3 or 4 arguments (got {})", args.len()
7214                )));
7215            }
7216            let input       = arg_str!(0);
7217            let pattern     = arg_str!(1);
7218            let replacement = arg_str!(2);
7219            let flags       = if args.len() == 4 { arg_str!(3) } else { String::new() };
7220            let re = compile_xpath_regex_dialect(&pattern, &flags,
7221                ctx.bindings.regex_dialect())?;
7222            // F&O §7.6.3 / FORX0003 — same zero-length-match rule as
7223            // tokenize(): replacing an empty match would loop forever,
7224            // so it's a dynamic error.
7225            if re.is_match("") {
7226                return Err(xpath_err(format!(
7227                    "replace(): the pattern '{pattern}' matches a \
7228                     zero-length string (FORX0003)"
7229                )).with_xpath_code("FORX0003"));
7230            }
7231            // XPath 2.0 §7.6.3 replacement syntax: `\$` represents
7232            // `$`, `\\` represents `\`, `$N` represents group N,
7233            // `$0` represents the full match.  Rust's regex crate
7234            // uses `$N` / `${N}` and treats `$` as the only escape
7235            // (no backslash escaping).  Translate the XPath form
7236            // into the Rust form, capping multi-digit `$N` to the
7237            // number of capture groups in the compiled regex so
7238            // `$10` past a 5-group regex becomes `$1` + literal `0`
7239            // (XPath 2.0 §7.6.3 "longest prefix that yields a valid
7240            // backref").
7241            // captures_len() includes group 0 (the whole match) so
7242            // subtract 1 for the user-visible group count.
7243            let group_count = re.captures_len().saturating_sub(1);
7244            let translated = translate_xpath_replacement(&replacement, group_count)?;
7245            Ok(Value::String(re.replace_all(&input, translated.as_str()).into_owned()))
7246        }
7247        "tokenize" => {
7248            // Two-arg form `tokenize(input, pattern)` is the workhorse.
7249            // The XPath 3.0 zero-pattern form `tokenize(input)` splits
7250            // on whitespace; we accept it too for ergonomics.
7251            if args.is_empty() || args.len() > 3 {
7252                return Err(xpath_err(format!(
7253                    "tokenize() requires 1 to 3 arguments (got {})", args.len()
7254                )));
7255            }
7256            let input   = arg_str!(0);
7257            let pattern = if args.len() >= 2 { arg_str!(1) } else { r"\s+".to_string() };
7258            let flags   = if args.len() == 3 { arg_str!(2) } else { String::new() };
7259            // XPath 2.0 §7.6.4: zero-length input yields the empty
7260            // sequence — *not* a sequence containing one empty
7261            // string the way Rust's `regex::split` would return.
7262            if input.is_empty() {
7263                return Ok(Value::NodeSet(Vec::new()));
7264            }
7265            let re = compile_xpath_regex_dialect(&pattern, &flags,
7266                ctx.bindings.regex_dialect())?;
7267            // F&O §7.6.4 / FORX0003 — a pattern that matches the empty
7268            // string is a dynamic error, since `tokenize` would then
7269            // produce infinite empty separators.
7270            if re.is_match("") {
7271                return Err(xpath_err(format!(
7272                    "tokenize(): the pattern '{pattern}' matches a \
7273                     zero-length string (FORX0003)"
7274                )).with_xpath_code("FORX0003"));
7275            }
7276            let parts: Vec<String> = re.split(&input).map(str::to_string).collect();
7277            // Materialise the result as a node-set of synthetic text
7278            // nodes so callers can iterate / count / index into it the
7279            // way `str:tokenize` already supports.  Indexes that can't
7280            // allocate (test-only stubs) get a string fallback.
7281            match idx.allocate_rtf_text_nodes(parts.clone()) {
7282                Some(ids) => Ok(Value::NodeSet(ids)),
7283                None      => Ok(Value::String(parts.join(" "))),
7284            }
7285        }
7286
7287        // ── XPath 2.0 string / sequence functions ─────────────────
7288        //
7289        // The common 2.0 string helpers: `lower-case`, `upper-case`,
7290        // `string-join`, plus the cardinality helpers `head` / `tail`
7291        // / `reverse` / `subsequence` and the de-duplicating
7292        // `distinct-values` / `index-of`.  We use our NodeSet (with
7293        // synthetic-text allocator) as the sequence carrier.
7294
7295        "lower-case" => {
7296            check_args!(1);
7297            Ok(Value::String(arg_str!(0).to_lowercase()))
7298        }
7299        "upper-case" => {
7300            check_args!(1);
7301            Ok(Value::String(arg_str!(0).to_uppercase()))
7302        }
7303        "string-join" => {
7304            // `string-join(seq)` (XPath 3.0) defaults the separator
7305            // to "".  XPath 2.0 requires 2 args but accepting the
7306            // 1-arg form is a strict superset — and matches every
7307            // shipping engine.
7308            if args.is_empty() || args.len() > 2 {
7309                return Err(xpath_err(format!(
7310                    "string-join() requires 1 or 2 arguments (got {})", args.len()
7311                )));
7312            }
7313            let sep = if args.len() == 2 { arg_str!(1) } else { String::new() };
7314            let pieces = sequence_to_strings(&arg!(0), idx);
7315            Ok(Value::String(pieces.join(&sep)))
7316        }
7317        "abs" => {
7318            check_args!(1);
7319            let a = arg!(0);
7320            Ok(preserve_numeric_kind(&a, value_to_number(&a, idx).abs()))
7321        }
7322        // XPath 2.0 §15.4.3-5 `min` / `max` / `avg`.  Empty input
7323        // returns the empty sequence (the spec form — XPath 1.0
7324        // never offered these functions, so there's no compat
7325        // pressure to surface NaN).  When every item stringifies
7326        // as a valid number we apply numeric `min` / `max`;
7327        // otherwise we fall back to lexicographic ordering so
7328        // string sequences (`min(('apple','banana'))`) behave per
7329        // the spec's "string promotion" rule.
7330        // `min`/`max` take an optional 2nd collation argument; string
7331        // comparison otherwise uses the in-scope default collation.
7332        "min" | "max" => {
7333            if args.is_empty() || args.len() > 2 {
7334                return Err(xpath_err(format!(
7335                    "{name}() requires 1 or 2 arguments (got {})", args.len()
7336                )));
7337            }
7338            let coll = effective_collation(
7339                if args.len() == 2 { Some(arg_str!(1)) } else { None });
7340            let ci = is_ascii_ci_collation(coll.as_deref());
7341            let op = if name == "min" { MinMaxOp::Min } else { MinMaxOp::Max };
7342            min_max_avg(&arg!(0), idx, op, ci)
7343        }
7344        "avg" => {
7345            check_args!(1);
7346            min_max_avg(&arg!(0), idx, MinMaxOp::Avg, false)
7347        }
7348        "distinct-values" => {
7349            if args.is_empty() || args.len() > 2 {
7350                return Err(xpath_err(format!(
7351                    "distinct-values() requires 1 or 2 arguments (got {})", args.len()
7352                )));
7353            }
7354            let coll = effective_collation(if args.len() == 2 { Some(arg_str!(1)) } else { None });
7355            let ci = is_ascii_ci_collation(coll.as_deref());
7356            let mut seen = std::collections::HashSet::new();
7357            let mut keep = Vec::new();
7358            for s in sequence_to_strings(&arg!(0), idx) {
7359                let k = if ci { ascii_ci_fold(&s) } else { s.clone() };
7360                if seen.insert(k) { keep.push(s); }
7361            }
7362            match idx.allocate_rtf_text_nodes(keep.clone()) {
7363                Some(ids) => Ok(Value::NodeSet(ids)),
7364                None      => Ok(Value::String(keep.join(" "))),
7365            }
7366        }
7367        "index-of" => {
7368            // `index-of(seq, target [, collation])` returns the
7369            // 1-based positions of `target` in `seq`.  We compare
7370            // via string-value.
7371            if args.len() < 2 || args.len() > 3 {
7372                return Err(xpath_err(format!(
7373                    "index-of() requires 2 or 3 arguments (got {})", args.len()
7374                )));
7375            }
7376            let target = arg_str!(1);
7377            let coll   = effective_collation(if args.len() == 3 { Some(arg_str!(2)) } else { None });
7378            let items  = sequence_to_strings(&arg!(0), idx);
7379            let (key_target, items_keys): (String, Vec<String>) =
7380                if is_ascii_ci_collation(coll.as_deref()) {
7381                    (ascii_ci_fold(&target),
7382                     items.iter().map(|s| ascii_ci_fold(s)).collect())
7383                } else {
7384                    (target, items)
7385                };
7386            let positions: Vec<String> = items_keys.iter().enumerate()
7387                .filter_map(|(i, s)|
7388                    if s == &key_target { Some((i + 1).to_string()) } else { None })
7389                .collect();
7390            match idx.allocate_rtf_text_nodes(positions.clone()) {
7391                Some(ids) => Ok(Value::NodeSet(ids)),
7392                None      => Ok(Value::String(positions.join(" "))),
7393            }
7394        }
7395        "subsequence" => {
7396            // XPath 2.0 §15.5.6 — `subsequence($s, $start[, $length])`
7397            // keeps items at positions `p` (1-indexed) for which:
7398            //   round($start) <= p < round($start) + round($length)
7399            // AND `1 <= p <= count($s)`.  NaN or `+INF + -INF`
7400            // anywhere in the bounds short-circuits to the empty
7401            // sequence (no integer satisfies `NaN <= p < NaN`).
7402            // Negative `$start` + finite `$length` may still leave
7403            // a window: e.g. subsequence((1..20), -5, 8) keeps
7404            // positions 1..2 because -5 + 8 = 3 caps the upper end.
7405            if args.len() < 2 || args.len() > 3 {
7406                return Err(xpath_err(format!(
7407                    "subsequence() requires 2 or 3 arguments (got {})", args.len()
7408                )));
7409            }
7410            // XPath 2.0 §15.5.6 / XPTY0004 — the start / length
7411            // arguments are typed `xs:double`, so an empty sequence
7412            // doesn't promote.  In 2.0 this is a type error, not the
7413            // silent empty-sequence answer.
7414            let is_empty = |v: &Value| matches!(v,
7415                Value::Sequence(items) if items.is_empty())
7416                || matches!(v, Value::NodeSet(ns) if ns.is_empty());
7417            if ctx.static_ctx.xpath_2_0 && is_empty(&arg!(1)) {
7418                return Err(xpath_err(
7419                    "subsequence(): the start argument must not be the \
7420                     empty sequence (XPTY0004)"
7421                ).with_xpath_code("XPTY0004"));
7422            }
7423            if ctx.static_ctx.xpath_2_0 && args.len() == 3 && is_empty(&arg!(2)) {
7424                return Err(xpath_err(
7425                    "subsequence(): the length argument must not be the \
7426                     empty sequence (XPTY0004)"
7427                ).with_xpath_code("XPTY0004"));
7428            }
7429            let seq      = arg!(0);
7430            let start_f  = value_to_number(&arg!(1), idx).round();
7431            let length_f = if args.len() == 3 {
7432                value_to_number(&arg!(2), idx).round()
7433            } else {
7434                f64::INFINITY
7435            };
7436            // Compute the 0-indexed [lo, hi) slice into the source
7437            // sequence.  An empty slice is signalled by `lo >= hi`.
7438            let slice_bounds = |count: usize| -> (usize, usize) {
7439                if start_f.is_nan() || length_f.is_nan() {
7440                    return (0, 0);
7441                }
7442                let end = start_f + length_f;            // 1-indexed exclusive end
7443                if end.is_nan() { return (0, 0); }       // -INF + INF
7444                let lo_p = start_f.max(1.0);             // first kept 1-indexed pos
7445                if lo_p >= end { return (0, 0); }
7446                let lo = ((lo_p - 1.0) as usize).min(count);
7447                let hi = if end.is_infinite() && end > 0.0 {
7448                    count
7449                } else {
7450                    ((end - 1.0).max(0.0) as usize).min(count)
7451                };
7452                (lo, hi.max(lo))
7453            };
7454            // Preserve node identity when the input is a NodeSet —
7455            // re-allocating into synthetic text would lose
7456            // attribute / element distinctions.
7457            if let Value::NodeSet(ns) = &seq {
7458                let (lo, hi) = slice_bounds(ns.len());
7459                return Ok(Value::NodeSet(ns[lo..hi].to_vec()));
7460            }
7461            if let Value::Sequence(items) = &seq {
7462                let (lo, hi) = slice_bounds(items.len());
7463                let out: Vec<Value> = items[lo..hi].to_vec();
7464                return Ok(if out.len() == 1 {
7465                    out.into_iter().next().unwrap()
7466                } else {
7467                    Value::Sequence(out)
7468                });
7469            }
7470            let pieces = sequence_to_strings(&seq, idx);
7471            let (lo, hi) = slice_bounds(pieces.len());
7472            let pieces: Vec<String> = pieces[lo..hi].to_vec();
7473            match idx.allocate_rtf_text_nodes(pieces.clone()) {
7474                Some(ids) => Ok(Value::NodeSet(ids)),
7475                None      => Ok(Value::String(pieces.join(""))),
7476            }
7477        }
7478        "reverse" => {
7479            check_args!(1);
7480            if let Value::NodeSet(ns) = arg!(0) {
7481                let mut rev = ns;
7482                rev.reverse();
7483                return Ok(Value::NodeSet(rev));
7484            }
7485            if let Value::Sequence(items) = arg!(0) {
7486                let mut rev = items;
7487                rev.reverse();
7488                return Ok(Value::Sequence(rev));
7489            }
7490            let mut pieces = sequence_to_strings(&arg!(0), idx);
7491            pieces.reverse();
7492            match idx.allocate_rtf_text_nodes(pieces.clone()) {
7493                Some(ids) => Ok(Value::NodeSet(ids)),
7494                None      => Ok(Value::String(pieces.join(""))),
7495            }
7496        }
7497        "unordered" => {
7498            // XPath 2.0 §15.1.3 — `fn:unordered($arg)` is an
7499            // optimisation hint: it tells the engine the caller
7500            // doesn't care about iteration order, so the engine
7501            // may reorder freely.  We don't reorder; the spec
7502            // permits returning the input unchanged.
7503            check_args!(1);
7504            Ok(arg!(0))
7505        }
7506        // XPath 3.0 §15.1.9 — `fn:path($node)` returns a string
7507        // locating the node in its tree.  Documents → `/`; for
7508        // every other node we walk up to the root, recording each
7509        // ancestor's element / attribute / kind label, and emit
7510        // `Q{uri}local[pos]` segments separated by `/`.  An
7511        // explicit `Q{}` (empty-namespace) prefix is used for
7512        // elements / attributes with no namespace; a numeric
7513        // position predicate is included for elements, comments,
7514        // text nodes, and PIs (always 1 for attributes).  When
7515        // the node has no ancestor document, we anchor the path
7516        // at `Q{}root()/...` (the spec's signal for an orphan
7517        // subtree).
7518        "path" => {
7519            if args.len() > 1 {
7520                return Err(xpath_err(format!(
7521                    "path() requires 0 or 1 arguments (got {})", args.len()
7522                )));
7523            }
7524            let v = if args.is_empty() {
7525                Value::NodeSet(vec![ctx.context_node])
7526            } else { arg!(0) };
7527            let node = match v {
7528                Value::NodeSet(ref ns) => ns.first().copied(),
7529                _ => None,
7530            };
7531            let Some(node) = node else {
7532                return Ok(Value::NodeSet(Vec::new()));
7533            };
7534            Ok(Value::String(node_path_string(node, idx)))
7535        }
7536        // XPath 3.0 §15.4.10 — `fn:sort($input)` returns the input
7537        // sorted by its atomic value with the default collation.
7538        // The 2-arg form takes a collation URI (we only support
7539        // codepoint; non-codepoint collations fall back to codepoint
7540        // ordering with the warning silenced).  The 3-arg form takes
7541        // a per-item key-extractor function reference — XPath 3.0+
7542        // higher-order functions which we don't carry, so reject.
7543        "sort" => {
7544            if args.is_empty() || args.len() > 3 {
7545                return Err(xpath_err(format!(
7546                    "sort() requires 1 to 3 arguments (got {})", args.len()
7547                )));
7548            }
7549            if args.len() == 3 {
7550                return Err(xpath_err(
7551                    "sort() with key-extractor function not supported"));
7552            }
7553            let input = arg!(0);
7554            // Pull items out so each can be compared by atomic value.
7555            let mut items = items_of(&input);
7556            // Determine whether everything is numeric so we can
7557            // sort numerically; otherwise fall back to codepoint
7558            // string ordering.
7559            let nums: Option<Vec<f64>> = items.iter().map(|v| {
7560                let s = value_to_string_with(v, idx, ctx.bindings);
7561                s.trim().parse::<f64>().ok()
7562            }).collect();
7563            if let Some(nums) = nums {
7564                let mut indexed: Vec<(usize, f64)> = nums.into_iter().enumerate().collect();
7565                indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
7566                let order: Vec<usize> = indexed.into_iter().map(|(i, _)| i).collect();
7567                let sorted: Vec<Value> = order.into_iter().map(|i| items[i].clone()).collect();
7568                return Ok(if sorted.len() == 1 { sorted.into_iter().next().unwrap() }
7569                          else                  { Value::Sequence(sorted) });
7570            }
7571            // String-collation fallback.
7572            let keys: Vec<String> = items.iter()
7573                .map(|v| value_to_string_with(v, idx, ctx.bindings))
7574                .collect();
7575            let mut order: Vec<usize> = (0..items.len()).collect();
7576            order.sort_by(|&a, &b| keys[a].cmp(&keys[b]));
7577            let sorted: Vec<Value> = order.into_iter().map(|i| std::mem::replace(
7578                &mut items[i], Value::NodeSet(Vec::new()))).collect();
7579            Ok(if sorted.len() == 1 { sorted.into_iter().next().unwrap() }
7580               else                  { Value::Sequence(sorted) })
7581        }
7582        "head" => {
7583            check_args!(1);
7584            if let Value::NodeSet(ns) = arg!(0) {
7585                return Ok(Value::NodeSet(ns.into_iter().take(1).collect()));
7586            }
7587            let pieces = sequence_to_strings(&arg!(0), idx);
7588            Ok(match pieces.into_iter().next() {
7589                Some(s) => Value::String(s),
7590                None    => Value::NodeSet(Vec::new()),
7591            })
7592        }
7593        "tail" => {
7594            check_args!(1);
7595            if let Value::NodeSet(ns) = arg!(0) {
7596                return Ok(Value::NodeSet(ns.into_iter().skip(1).collect()));
7597            }
7598            let pieces: Vec<String> = sequence_to_strings(&arg!(0), idx).into_iter().skip(1).collect();
7599            match idx.allocate_rtf_text_nodes(pieces.clone()) {
7600                Some(ids) => Ok(Value::NodeSet(ids)),
7601                None      => Ok(Value::String(pieces.join(""))),
7602            }
7603        }
7604        // XPath 2.0 §15.1.7 `insert-before($seq, $pos, $insert)` —
7605        // insert items into a sequence at 1-based `$pos`.  Out-of-
7606        // range positions clamp to the head / tail.  When either
7607        // operand carries typed-atomic items we preserve the
7608        // Sequence shape; otherwise we keep the legacy NodeSet
7609        // result for node-only callers.
7610        "insert-before" => {
7611            check_args!(3);
7612            let seq = arg!(0);
7613            let pos = value_to_number(&arg!(1), idx).round() as i64;
7614            let ins = arg!(2);
7615            let any_seq = matches!(seq, Value::Sequence(_))
7616                       || matches!(ins, Value::Sequence(_));
7617            if any_seq {
7618                let to_items = |v: Value| -> Vec<Value> {
7619                    match v {
7620                        Value::Sequence(xs) => xs,
7621                        Value::NodeSet(ns) => ns.into_iter()
7622                            .map(|n| Value::NodeSet(vec![n])).collect(),
7623                        other => vec![other],
7624                    }
7625                };
7626                let mut a = to_items(seq);
7627                let mut b = to_items(ins);
7628                let p = if pos < 1 { 0 } else { (pos as usize - 1).min(a.len()) };
7629                let tail = a.split_off(p);
7630                a.append(&mut b);
7631                a.extend(tail);
7632                return Ok(Value::Sequence(a));
7633            }
7634            let into_ids = |v: Value| -> Vec<NodeId> {
7635                match v.untyped() {
7636                    Value::NodeSet(ns) => ns,
7637                    Value::String(s)   => idx.allocate_rtf_text_nodes(vec![s])
7638                                             .unwrap_or_default(),
7639                    Value::Number(n)   => idx.allocate_rtf_text_nodes(
7640                                              vec![value_to_string(&Value::Number(n), idx)])
7641                                              .unwrap_or_default(),
7642                    Value::Boolean(b)  => idx.allocate_rtf_text_nodes(
7643                                              vec![if b { "true".into() } else { "false".into() }])
7644                                              .unwrap_or_default(),
7645                    Value::ForeignNodeSet(_) => Vec::new(),
7646                    Value::IntRange { lo, hi } => idx.allocate_rtf_text_nodes(
7647                        (lo..=hi).map(|i| i.to_string()).collect()
7648                    ).unwrap_or_default(),
7649                    Value::Typed(_) | Value::Sequence(_) => unreachable!(),
7650                    Value::Map(_) | Value::Array(_) | Value::Function(_) => Vec::new(),
7651                }
7652            };
7653            let mut a = into_ids(seq);
7654            let mut b = into_ids(ins);
7655            let p = if pos < 1 { 0 } else { (pos as usize - 1).min(a.len()) };
7656            let tail = a.split_off(p);
7657            a.append(&mut b);
7658            a.extend(tail);
7659            Ok(Value::NodeSet(a))
7660        }
7661        // XPath 2.0 §15.1.8 `remove($seq, $pos)` — remove the item
7662        // at 1-based `$pos`.  Out-of-range positions return the
7663        // sequence unchanged.
7664        "remove" => {
7665            check_args!(2);
7666            let seq = arg!(0);
7667            let pos = value_to_number(&arg!(1), idx).round() as i64;
7668            if let Value::Sequence(mut items) = seq {
7669                if pos >= 1 && (pos as usize) <= items.len() {
7670                    items.remove(pos as usize - 1);
7671                }
7672                return Ok(Value::Sequence(items));
7673            }
7674            let mut items: Vec<NodeId> = match seq.untyped() {
7675                Value::NodeSet(ns) => ns,
7676                Value::String(s)   => idx.allocate_rtf_text_nodes(vec![s])
7677                                         .unwrap_or_default(),
7678                Value::Number(n)   => idx.allocate_rtf_text_nodes(
7679                                          vec![value_to_string(&Value::Number(n), idx)])
7680                                          .unwrap_or_default(),
7681                Value::Boolean(b)  => idx.allocate_rtf_text_nodes(
7682                                          vec![if b { "true".into() } else { "false".into() }])
7683                                          .unwrap_or_default(),
7684                Value::ForeignNodeSet(_) => Vec::new(),
7685                Value::IntRange { lo, hi } => idx.allocate_rtf_text_nodes(
7686                    (lo..=hi).map(|i| i.to_string()).collect()
7687                ).unwrap_or_default(),
7688                // untyped() flattens singletons and never produces a
7689                // Typed; multi-item Sequences were taken above.
7690                Value::Typed(_) | Value::Sequence(_) => unreachable!(),
7691                Value::Map(_) | Value::Array(_) | Value::Function(_) => Vec::new(),
7692            };
7693            if pos >= 1 && (pos as usize) <= items.len() {
7694                items.remove(pos as usize - 1);
7695            }
7696            Ok(Value::NodeSet(items))
7697        }
7698        "empty-sequence" => {
7699            check_args!(0);
7700            Ok(Value::NodeSet(Vec::new()))
7701        }
7702        "format-date" => {
7703            // `format-date(date, picture, [lang, calendar, country])` —
7704            // we honour `date` + `picture` and ignore the locale args.
7705            if args.len() < 2 || args.len() > 5 {
7706                return Err(xpath_err(format!(
7707                    "format-date() requires 2 to 5 arguments (got {})", args.len()
7708                )));
7709            }
7710            let v = format_date_time_picture(&arg_str!(0), &arg_str!(1), DateKind::Date)?;
7711            let lang = if args.len() > 2 { arg_str!(2) } else { String::new() };
7712            let cal  = if args.len() > 3 { arg_str!(3) } else { String::new() };
7713            Ok(Value::String(format!("{}{v}", format_date_locale_prefix(&lang, &cal))))
7714        }
7715        "format-time" => {
7716            if args.len() < 2 || args.len() > 5 {
7717                return Err(xpath_err(format!(
7718                    "format-time() requires 2 to 5 arguments (got {})", args.len()
7719                )));
7720            }
7721            let v = format_date_time_picture(&arg_str!(0), &arg_str!(1), DateKind::Time)?;
7722            let lang = if args.len() > 2 { arg_str!(2) } else { String::new() };
7723            let cal  = if args.len() > 3 { arg_str!(3) } else { String::new() };
7724            Ok(Value::String(format!("{}{v}", format_date_locale_prefix(&lang, &cal))))
7725        }
7726        "deep-equal" => {
7727            // XPath 2.0 §15.3.1 — sequences are deep-equal iff they
7728            // have the same length and each item is pairwise
7729            // deep-equal.  Atomic items compare by value (with
7730            // type-aware promotion); nodes compare by name +
7731            // attributes (set equality) + child sequence.
7732            if args.len() < 2 || args.len() > 3 {
7733                return Err(xpath_err(format!(
7734                    "deep-equal() requires 2 or 3 arguments (got {})", args.len()
7735                )));
7736            }
7737            let a = arg!(0);
7738            let b = arg!(1);
7739            // Function items cannot be compared for deep equality (FOTY0015).
7740            if value_seq_has_function(&a) || value_seq_has_function(&b) {
7741                return Err(xpath_err(
7742                    "deep-equal() cannot compare function items (FOTY0015)")
7743                    .with_xpath_code("FOTY0015"));
7744            }
7745            Ok(Value::Boolean(deep_equal_values(&a, &b, idx, ctx.bindings)))
7746        }
7747        "base-uri" => {
7748            // 0-arg form uses the context node; 1-arg takes the
7749            // supplied node.  XPath 2.0 §15.5.3 — walk up to the
7750            // nearest synthetic-RTF document root and consult the
7751            // bindings' override table, then fall back to the
7752            // empty sequence when no base URI is recorded.
7753            if args.len() > 1 {
7754                return Err(xpath_err(format!(
7755                    "base-uri() requires 0 or 1 arguments (got {})", args.len()
7756                )));
7757            }
7758            let target = if args.is_empty() {
7759                Some(ctx.context_node)
7760            } else {
7761                match arg!(0) {
7762                    Value::NodeSet(ns) => ns.first().copied(),
7763                    Value::ForeignNodeSet(_) => None,
7764                    _ => return Err(xpath_err(
7765                        "base-uri() argument must be a node")),
7766                }
7767            };
7768            let Some(start) = target else {
7769                return Ok(Value::NodeSet(Vec::new()));
7770            };
7771            // XPath 2.0 §2.5 (fn:base-uri accessor): a namespace node
7772            // has no base URI.
7773            if idx.kind(start) == XPathNodeKind::Namespace {
7774                return Ok(Value::NodeSet(Vec::new()));
7775            }
7776            // Walk ancestor-or-self collecting each element's `xml:base`
7777            // declaration (leaf-first), stopping at the first node that
7778            // carries an explicit base-URI override in the bindings'
7779            // table (synthetic-RTF document roots, the source document
7780            // root, or an `xsl:document`/variable `xml:base`).  The
7781            // effective base is that anchor resolved against each
7782            // `xml:base` from the outermost inward (XML Base §3 / RFC
7783            // 3986).  Attribute / text / comment / PI nodes inherit
7784            // their parent element's base, which falls out of the same
7785            // walk since they have no `xml:base` of their own.
7786            let mut chain: Vec<String> = Vec::new();
7787            let mut anchor: Option<String> = None;
7788            let mut n = start;
7789            loop {
7790                if let Some(u) = ctx.bindings.node_base_uri(n) {
7791                    anchor = Some(u);
7792                    break;
7793                }
7794                if idx.kind(n) == XPathNodeKind::Element {
7795                    for a in idx.attr_range(n) {
7796                        if idx.local_name(a) == "base" && idx.namespace_uri(a) == "http://www.w3.org/XML/1998/namespace" {
7797                            chain.push(idx.string_value(a));
7798                            break;
7799                        }
7800                    }
7801                }
7802                match idx.parent(n) {
7803                    Some(p) => n = p,
7804                    None    => break,
7805                }
7806            }
7807            let mut base = anchor.or_else(|| ctx.bindings.static_base_uri());
7808            for b in chain.into_iter().rev() {
7809                base = Some(match base {
7810                    Some(r) => resolve_uri_against(&r, &b),
7811                    None    => b,
7812                });
7813            }
7814            match base {
7815                Some(u) => Ok(typed_str("anyURI", u)),
7816                None    => Ok(Value::NodeSet(Vec::new())),
7817            }
7818        }
7819        "static-base-uri" => {
7820            // XPath 2.0 §15.2.3 — return the static base URI from the
7821            // bindings (set from the stylesheet's `xml:base` or the
7822            // apply-time base URI).  Empty sequence if unset.
7823            if !args.is_empty() {
7824                return Err(xpath_err("static-base-uri() takes no arguments"));
7825            }
7826            match ctx.bindings.static_base_uri() {
7827                Some(s) => Ok(typed_str("anyURI", s)),
7828                None    => Ok(Value::NodeSet(vec![])),
7829            }
7830        }
7831        "resolve-uri" => {
7832            // XPath 2.0 §15.5.7: resolve-uri(relative[, base]).
7833            // Falls back to returning the relative URI unchanged
7834            // when base is empty / absent.  No URI parser dependency —
7835            // perform the join lexically: if `relative` is absolute
7836            // (contains `:` before any `/`), return it; otherwise
7837            // prepend the base's parent directory.
7838            if args.is_empty() || args.len() > 2 {
7839                return Err(xpath_err(format!(
7840                    "resolve-uri() requires 1 or 2 arguments (got {})", args.len()
7841                )));
7842            }
7843            // Empty sequence in → empty sequence out (XPath 2.0
7844            // §15.5.7).  Pre-check before stringifying so a true
7845            // empty-sequence argument doesn't degrade to "".
7846            if let Value::NodeSet(ns) = &arg!(0) {
7847                if ns.is_empty() { return Ok(Value::NodeSet(vec![])); }
7848            }
7849            if let Value::Sequence(items) = &arg!(0) {
7850                if items.is_empty() { return Ok(Value::NodeSet(vec![])); }
7851            }
7852            let rel = value_to_string_with(&arg!(0), idx, ctx.bindings);
7853            let base = if args.len() == 2 {
7854                value_to_string_with(&arg!(1), idx, ctx.bindings)
7855            } else {
7856                ctx.bindings.static_base_uri().unwrap_or_default()
7857            };
7858            // XPath 2.0 §15.5.7 / erratum FO.E1 — in the explicit
7859            // 2-argument form the base must be an absolute URI, and both
7860            // arguments must be valid URI references; a relative base or
7861            // a malformed reference (whitespace, or a second `#` so the
7862            // fragment is ambiguous) is FORG0002.
7863            if args.len() == 2 && !base.is_empty() {
7864                let valid_ref = |u: &str|
7865                    !u.contains(char::is_whitespace) && u.matches('#').count() <= 1;
7866                if !valid_ref(&rel) || !valid_ref(&base) || !uri_has_scheme(&base) {
7867                    return Err(xpath_err(format!(
7868                        "resolve-uri: base '{base}' must be an absolute URI and \
7869                         both arguments valid URI references"
7870                    )).with_xpath_code("FORG0002"));
7871                }
7872            }
7873            // Empty $relative resolves to the base URI itself
7874            // (RFC 3986 §5.2.2 / XPath 2.0 §15.5.7).
7875            if rel.is_empty() && !base.is_empty() {
7876                return Ok(typed_str("anyURI", base));
7877            }
7878            if base.is_empty() {
7879                return Ok(typed_str("anyURI", rel));
7880            }
7881            Ok(typed_str("anyURI", resolve_uri_rfc3986(&base, &rel)))
7882        }
7883        // XPath 2.0 §10.5 — accessor functions on xs:date /
7884        // xs:dateTime / xs:time / xs:duration.  Values flow through
7885        // the engine as strings (no atomic-type system yet); parse
7886        // the lexical form on demand to extract the requested field.
7887        // Lenient: missing fields return 0 / empty-string per spec.
7888        // XPath 2.0 §15.5.5 root([$node]) — return the root of
7889        // the tree containing $node (or context node).  In our
7890        // index that's the ancestor with no parent.
7891        "root" => {
7892            let v = if args.is_empty() {
7893                Value::NodeSet(vec![ctx.context_node])
7894            } else { arg!(0) };
7895            let node = match v {
7896                Value::NodeSet(ref ns) => ns.first().copied(),
7897                _ => None,
7898            };
7899            let r = node.map(|mut n| {
7900                while let Some(p) = idx.parent(n) { n = p; }
7901                vec![n]
7902            }).unwrap_or_default();
7903            Ok(Value::NodeSet(r))
7904        }
7905        // XPath 2.0 §15.5.4 doc($uri) — same as document($uri)
7906        // but always returns the doc as a single document node.
7907        // We can't load here without a Loader, so delegate to the
7908        // bindings layer (which routes through the XSLT runtime's
7909        // pre-loaded document table).
7910        "doc" => {
7911            // XPath 2.0 §15.5.4 — `fn:doc($uri)` returns the empty
7912            // sequence when `$uri` is the empty sequence, rather
7913            // than failing with a not-found.  Also stay quiet (empty
7914            // sequence) for any caller that passes through an empty
7915            // value via untyped sequence: we only fail on a real
7916            // non-empty URI that we can't resolve.
7917            check_args!(1);
7918            let arg0 = arg!(0);
7919            if matches!(&arg0,
7920                Value::NodeSet(ns) if ns.is_empty())
7921                || matches!(&arg0,
7922                    Value::Sequence(items) if items.is_empty())
7923            {
7924                return Ok(Value::NodeSet(Vec::new()));
7925            }
7926            let uri = value_to_string_with(&arg0, idx, ctx.bindings);
7927            match ctx.bindings.call_function_in(
7928                "", "document",
7929                vec![Value::String(uri)],
7930                ctx.context_node,
7931            ) {
7932                Some(r) => r.map_err(|e| e.or_xpath_code("FODC0002")),
7933                None    => Ok(Value::NodeSet(Vec::new())),
7934            }
7935        }
7936        "doc-available" => {
7937            check_args!(1);
7938            let arg0 = arg!(0);
7939            if matches!(&arg0,
7940                Value::NodeSet(ns) if ns.is_empty())
7941                || matches!(&arg0,
7942                    Value::Sequence(items) if items.is_empty())
7943            {
7944                return Ok(Value::Boolean(false));
7945            }
7946            let uri = value_to_string_with(&arg0, idx, ctx.bindings);
7947            match ctx.bindings.call_function_in(
7948                "", "document",
7949                vec![Value::String(uri)],
7950                ctx.context_node,
7951            ) {
7952                Some(Ok(Value::NodeSet(ns))) => Ok(Value::Boolean(!ns.is_empty())),
7953                _ => Ok(Value::Boolean(false)),
7954            }
7955        }
7956        "document-uri" => {
7957            // XPath 2.0 §2.5 (dm:document-uri): the absolute URI of
7958            // the resource a *document node* was constructed from, or
7959            // the empty sequence when the node isn't a document node
7960            // or its URI is unknown.  The source/loaded document's URI
7961            // is recorded as that node's base URI override.
7962            if args.len() > 1 {
7963                return Err(xpath_err(format!(
7964                    "document-uri() takes 0 or 1 arguments (got {})", args.len()
7965                )));
7966            }
7967            let target = if args.is_empty() {
7968                Some(ctx.context_node)
7969            } else {
7970                match arg!(0) {
7971                    Value::NodeSet(ns) => ns.first().copied(),
7972                    _ => None,
7973                }
7974            };
7975            let uri = target.filter(|&n| idx.kind(n) == XPathNodeKind::Document)
7976                .and_then(|n| ctx.bindings.node_base_uri(n));
7977            match uri {
7978                Some(u) => Ok(typed_str("anyURI", u)),
7979                None    => Ok(Value::NodeSet(Vec::new())),
7980            }
7981        }
7982        // XPath 2.0 §6.4.5 round-half-to-even — banker's rounding.
7983        "round-half-to-even" => {
7984            if args.is_empty() || args.len() > 2 {
7985                return Err(xpath_err(format!(
7986                    "round-half-to-even() takes 1 or 2 arguments (got {})", args.len()
7987                )));
7988            }
7989            let a = arg!(0);
7990            let precision = if args.len() == 2 {
7991                value_to_number(&arg!(1), idx) as i32
7992            } else { 0 };
7993            // Exact banker's rounding for decimal / integer inputs;
7994            // double / float inputs round in f64.
7995            match &a {
7996                Value::Number(Numeric::Decimal(d)) =>
7997                    Ok(Value::Number(Numeric::Decimal(round_decimal_half_to_even(*d, precision)))),
7998                Value::Number(Numeric::Integer(i)) => {
7999                    use rust_decimal::prelude::ToPrimitive;
8000                    let d = round_decimal_half_to_even(rust_decimal::Decimal::from(*i), precision);
8001                    Ok(Value::Number(match d.to_i64() {
8002                        Some(v) => Numeric::Integer(v),
8003                        None    => Numeric::Decimal(d),
8004                    }))
8005                }
8006                _ => {
8007                    let n = value_to_number(&a, idx);
8008                    let scale = 10f64.powi(precision);
8009                    let scaled = n * scale;
8010                    // Half-to-even: at an exact 0.5 fraction pick the
8011                    // neighbour with an even integer part (f64::round
8012                    // rounds half away from zero, which doesn't match).
8013                    let rounded = if scaled.fract().abs() == 0.5 {
8014                        let floor = scaled.floor();
8015                        if (floor as i64) % 2 == 0 { floor } else { floor + 1.0 }
8016                    } else {
8017                        scaled.round()
8018                    };
8019                    let result = rounded / scale;
8020                    // F&O §4.5.4 — a zero result is positive zero (avoid the
8021                    // "-0" a negative-scale roundtrip would produce).
8022                    let result = if result == 0.0 { 0.0 } else { result };
8023                    Ok(preserve_numeric_kind(&a, result))
8024                }
8025            }
8026        }
8027        // XPath 2.0 §15.5.6 collection — we never have a collection;
8028        // return empty for any URI so collection-driven tests don't
8029        // panic, matching the "no such collection" XPath 2.0
8030        // fallback.
8031        "collection" => {
8032            if args.len() > 1 {
8033                return Err(xpath_err("collection() takes 0 or 1 arguments"));
8034            }
8035            Ok(Value::NodeSet(Vec::new()))
8036        }
8037        // XPath 2.0 §10.3.4 dateTime($d, $t) — combine a date and a
8038        // time into a dateTime.  Both args are strings in lexical
8039        // xs:date / xs:time form.
8040        "dateTime" => {
8041            check_args!(2);
8042            let d = value_to_string_with(&arg!(0), idx, ctx.bindings);
8043            let t = value_to_string_with(&arg!(1), idx, ctx.bindings);
8044            // Combine the date components of $d with the time
8045            // components of $t.  The result's timezone is the one
8046            // timezone present on either argument; if both carry a
8047            // timezone they must agree (FORG0008 otherwise).  Crucially
8048            // the timezone belongs at the *end* of the dateTime, not
8049            // after the date — `2004-09-05+02:00` + `12:15:00` is
8050            // `2004-09-05T12:15:00+02:00`, never `2004-09-05+02:00T…`.
8051            match (parse_xsd_date_time(&d, DateKind::Date),
8052                   parse_xsd_date_time(&t, DateKind::Time)) {
8053                (Some((y, mo, dd, _, _, _, _, tz_d)),
8054                 Some((_, _, _, h, mi, s, frac, tz_t))) => {
8055                    let tz = match (tz_d, tz_t) {
8056                        (Some(a), Some(b)) if a != b => return Err(xpath_err(
8057                            "dateTime(): the date and time have inconsistent timezones (FORG0008)")),
8058                        (Some(a), _) => Some(a),
8059                        (_, b)       => b,
8060                    };
8061                    Ok(Value::String(format_datetime_lexical(y, mo, dd, h, mi, s, frac, tz)))
8062                }
8063                // Non-lexical inputs — keep a lenient join rather than
8064                // failing the whole expression.
8065                _ => Ok(Value::String(format!("{}T{}", d.trim(), t.trim()))),
8066            }
8067        }
8068        // XPath 2.0 §10.4.4 / §10.4.5 / §10.4.6 — QName accessors.
8069        "local-name-from-QName" => {
8070            check_args!(1);
8071            let a = arg!(0);
8072            // `xs:QName?` argument — an empty sequence yields an empty
8073            // sequence (F&O §17.5.2).
8074            if sequence_len(&a) == 0 { return Ok(Value::NodeSet(Vec::new())); }
8075            let s = value_to_string_with(&a, idx, ctx.bindings);
8076            // The string-value of an xs:QName is `prefix:local` or
8077            // `{uri}local` (Clark form when no prefix).  Take the
8078            // tail after the last `:` or after `}`.
8079            let local = if let Some(i) = s.rfind('}') {
8080                s[i + 1..].to_string()
8081            } else if let Some(i) = s.rfind(':') {
8082                s[i + 1..].to_string()
8083            } else { s };
8084            Ok(typed_str("NCName", local))
8085        }
8086        "prefix-from-QName" => {
8087            check_args!(1);
8088            let s = value_to_string_with(&arg!(0), idx, ctx.bindings);
8089            // Clark form has no prefix.  Otherwise take leading
8090            // chars up to first `:`.
8091            if s.starts_with('{') {
8092                return Ok(Value::String(String::new()));
8093            }
8094            let prefix = s.split_once(':').map(|(p, _)| p).unwrap_or("");
8095            Ok(Value::String(prefix.to_string()))
8096        }
8097        "namespace-uri-from-QName" => {
8098            check_args!(1);
8099            let a = arg!(0);
8100            if sequence_len(&a) == 0 { return Ok(Value::NodeSet(Vec::new())); }
8101            let s = value_to_string_with(&a, idx, ctx.bindings);
8102            if s.starts_with('{') {
8103                if let Some(end) = s.find('}') {
8104                    return Ok(typed_str("anyURI", s[1..end].to_string()));
8105                }
8106            }
8107            Ok(Value::String(String::new()))
8108        }
8109        // XPath 2.0 §15.1.10 QName($uri, $lex) — construct.  Round-
8110        // trip via Clark form so subsequent accessors above work.
8111        "QName" => {
8112            check_args!(2);
8113            let uri   = value_to_string_with(&arg!(0), idx, ctx.bindings);
8114            let lex   = value_to_string_with(&arg!(1), idx, ctx.bindings);
8115            let local = lex.rsplit(':').next().unwrap_or(&lex);
8116            if uri.is_empty() {
8117                Ok(Value::String(lex.clone()))
8118            } else {
8119                Ok(Value::String(format!("{{{uri}}}{local}")))
8120            }
8121        }
8122        "resolve-QName" => {
8123            // XPath 2.0 §15.1.10.2 — resolve a lexical QName against
8124            // the in-scope namespaces of an element node, returning the
8125            // expanded QName in Clark form `{uri}local`.  An empty
8126            // first argument yields the empty sequence; a malformed
8127            // lexical QName is FOCA0002; a prefix with no in-scope
8128            // binding is FONS0004.
8129            check_args!(2);
8130            if matches!(&arg!(0), Value::NodeSet(ns) if ns.is_empty()) {
8131                return Ok(Value::NodeSet(Vec::new()));
8132            }
8133            let lex = value_to_string_with(&arg!(0), idx, ctx.bindings);
8134            if !is_valid_lexical_qname(&lex) {
8135                return Err(xpath_err(format!(
8136                    "resolve-QName: '{lex}' is not a valid lexical QName"
8137                )).with_xpath_code("FOCA0002"));
8138            }
8139            let (prefix, local) = match lex.split_once(':') {
8140                Some((p, l)) => (p, l),
8141                None         => ("", lex.as_str()),
8142            };
8143            // Resolve the prefix against the element's namespace nodes
8144            // (same walk as namespace-uri-for-prefix); `xml` is implicit.
8145            let elem = match arg!(1) {
8146                Value::NodeSet(ref ns) => ns.first().copied(),
8147                _ => None,
8148            };
8149            let uri = elem.and_then(|id| {
8150                idx.ns_range(id)
8151                    .into_iter()
8152                    .find(|&ns_id| {
8153                        let p = idx.local_name(ns_id);
8154                        (prefix.is_empty() && p.is_empty()) || p == prefix
8155                    })
8156                    .map(|ns_id| idx.string_value(ns_id))
8157                    .or_else(|| (prefix == "xml")
8158                        .then(|| "http://www.w3.org/XML/1998/namespace".to_string()))
8159            });
8160            match uri {
8161                Some(u) if !u.is_empty() => Ok(Value::String(format!("{{{u}}}{local}"))),
8162                // A non-empty prefix that resolves to nothing is FONS0004;
8163                // an unprefixed name simply stays in no namespace.
8164                _ if !prefix.is_empty() => Err(xpath_err(format!(
8165                    "resolve-QName: no namespace declaration for prefix '{prefix}'"
8166                )).with_xpath_code("FONS0004")),
8167                _ => Ok(Value::String(local.to_string())),
8168            }
8169        }
8170        // XPath 2.0 §15.2.4 normalize-unicode($s [, $form]).  We
8171        // don't carry a Unicode normalization table; emit the input
8172        // unchanged for any normalisation form except an unknown one
8173        // (where the spec wants an error).
8174        "normalize-unicode" => {
8175            if args.is_empty() || args.len() > 2 {
8176                return Err(xpath_err("normalize-unicode() takes 1 or 2 arguments"));
8177            }
8178            let s = value_to_string_with(&arg!(0), idx, ctx.bindings);
8179            Ok(Value::String(s))
8180        }
8181        "type-available" => {
8182            // XSLT 2.0 §16.5.6 — `type-available(name)` answers true
8183            // iff the processor recognises `name` as the in-scope
8184            // (expanded) name of an XSD type.  We don't implement
8185            // user-defined schema types, but the XSD built-in types
8186            // are all available; resolve the QName, check namespace,
8187            // then look up the local name in the static type table.
8188            check_args!(1);
8189            let name = value_to_string_with(&arg!(0), idx, ctx.bindings);
8190            let (prefix, local) = match name.split_once(':') {
8191                Some((p, l)) => (Some(p), l),
8192                None         => (None, name.as_str()),
8193            };
8194            let uri = match prefix {
8195                Some(p) => ctx.bindings.resolve_prefix(p)
8196                    .or_else(|| match p {
8197                        "xml" => Some("http://www.w3.org/XML/1998/namespace".into()),
8198                        "xs"  => Some("http://www.w3.org/2001/XMLSchema".into()),
8199                        _ => None,
8200                    }),
8201                None    => None,
8202            };
8203            let in_xsd = uri.as_deref() == Some("http://www.w3.org/2001/XMLSchema");
8204            // Unprefixed names match nothing under the default
8205            // (empty) namespace.
8206            if prefix.is_none() {
8207                return Ok(Value::Boolean(false));
8208            }
8209            if !in_xsd {
8210                return Ok(Value::Boolean(false));
8211            }
8212            let known = matches!(local,
8213                "anyType" | "anySimpleType" | "anyAtomicType" | "untyped" | "untypedAtomic"
8214                | "string" | "boolean" | "decimal" | "float" | "double"
8215                | "integer" | "long" | "int" | "short" | "byte"
8216                | "nonNegativeInteger" | "nonPositiveInteger"
8217                | "positiveInteger" | "negativeInteger"
8218                | "unsignedLong" | "unsignedInt" | "unsignedShort" | "unsignedByte"
8219                | "duration" | "dateTime" | "time" | "date"
8220                | "dayTimeDuration" | "yearMonthDuration"
8221                | "gYearMonth" | "gYear" | "gMonth" | "gMonthDay" | "gDay"
8222                | "hexBinary" | "base64Binary" | "anyURI" | "QName" | "NOTATION"
8223                | "normalizedString" | "token" | "language"
8224                | "Name" | "NCName" | "ID" | "IDREF" | "IDREFS"
8225                | "ENTITY" | "ENTITIES" | "NMTOKEN" | "NMTOKENS"
8226                | "numeric"
8227            );
8228            Ok(Value::Boolean(known))
8229        }
8230        "unparsed-entity-public-id" => {
8231            check_args!(1);
8232            Ok(Value::String(String::new()))
8233        }
8234        // XPath 2.0 §15.5.8 / §15.5.9 namespace accessors.
8235        // We approximate from the in-scope namespace nodes of the
8236        // supplied element (ns_range gives the element's in-scope
8237        // bindings).  When the argument isn't an element node we
8238        // return the empty sequence.
8239        "in-scope-prefixes" => {
8240            check_args!(1);
8241            let elem = match arg!(0) {
8242                Value::NodeSet(ref ns) => ns.first().copied(),
8243                _ => None,
8244            };
8245            let Some(id) = elem else {
8246                return Ok(Value::NodeSet(Vec::new()));
8247            };
8248            let mut out: Vec<String> = Vec::new();
8249            for ns_id in idx.ns_range(id) {
8250                let p = idx.local_name(ns_id);
8251                out.push(if p.is_empty() { "".into() } else { p.to_string() });
8252            }
8253            // Always include "xml" since it's implicit.
8254            if !out.iter().any(|s| s == "xml") {
8255                out.push("xml".into());
8256            }
8257            match idx.allocate_rtf_text_nodes(out.clone()) {
8258                Some(ids) => Ok(Value::NodeSet(ids)),
8259                None      => Ok(Value::String(out.join(" "))),
8260            }
8261        }
8262        "namespace-uri-for-prefix" => {
8263            check_args!(2);
8264            let prefix = value_to_string_with(&arg!(0), idx, ctx.bindings);
8265            let elem = match arg!(1) {
8266                Value::NodeSet(ref ns) => ns.first().copied(),
8267                _ => None,
8268            };
8269            let Some(id) = elem else {
8270                return Ok(Value::NodeSet(Vec::new()));
8271            };
8272            for ns_id in idx.ns_range(id) {
8273                let p = idx.local_name(ns_id);
8274                let match_default = prefix.is_empty() && p.is_empty();
8275                if match_default || p == prefix {
8276                    return Ok(Value::String(idx.string_value(ns_id)));
8277                }
8278            }
8279            // Implicit `xml` binding.
8280            if prefix == "xml" {
8281                return Ok(Value::String("http://www.w3.org/XML/1998/namespace".into()));
8282            }
8283            Ok(Value::NodeSet(Vec::new()))
8284        }
8285        "year-from-dateTime" | "year-from-date" => {
8286            check_args!(1);
8287            let s = value_to_string_with(&arg!(0), idx, ctx.bindings);
8288            let v = s.trim();
8289            if v.is_empty() {
8290                return Ok(Value::NodeSet(Vec::new()));
8291            }
8292            let (sign, rest) = if let Some(r) = v.strip_prefix('-') { (-1i64, r) } else { (1i64, v) };
8293            let yr_end = rest.find('-').unwrap_or(rest.len());
8294            let yr: i64 = rest[..yr_end].parse().map_err(|_|
8295                xpath_err(format!("invalid date-like value: {s:?}")))?;
8296            Ok(Value::Number(Numeric::Double((sign * yr) as f64)))
8297        }
8298        "month-from-dateTime" | "month-from-date" => {
8299            check_args!(1);
8300            let s = value_to_string_with(&arg!(0), idx, ctx.bindings);
8301            let v = s.trim();
8302            if v.is_empty() {
8303                return Ok(Value::NodeSet(Vec::new()));
8304            }
8305            let body = v.strip_prefix('-').unwrap_or(v);
8306            // Year is digits up to first '-'; month is next 2 digits.
8307            let after_year = body.split_once('-').map(|(_, r)| r).unwrap_or("");
8308            let mm: u32 = after_year.get(..2).and_then(|s| s.parse().ok())
8309                .ok_or_else(|| xpath_err(format!("invalid date-like value: {s:?}")))?;
8310            Ok(Value::Number(Numeric::Double(mm as f64)))
8311        }
8312        "day-from-dateTime" | "day-from-date" => {
8313            check_args!(1);
8314            let s = value_to_string_with(&arg!(0), idx, ctx.bindings);
8315            let v = s.trim();
8316            if v.is_empty() {
8317                return Ok(Value::NodeSet(Vec::new()));
8318            }
8319            let body = v.strip_prefix('-').unwrap_or(v);
8320            // Year-Month-Day: take chars after the second '-'.
8321            let mut parts = body.splitn(3, '-');
8322            parts.next(); parts.next();
8323            let dd_seg = parts.next().unwrap_or("");
8324            let dd: u32 = dd_seg.get(..2).and_then(|s| s.parse().ok())
8325                .ok_or_else(|| xpath_err(format!("invalid date-like value: {s:?}")))?;
8326            Ok(Value::Number(Numeric::Double(dd as f64)))
8327        }
8328        "hours-from-dateTime" | "hours-from-time" => {
8329            check_args!(1);
8330            let s = value_to_string_with(&arg!(0), idx, ctx.bindings);
8331            // For dateTime the time portion is after 'T'.  Time-only
8332            // values start with HH:MM:SS directly.
8333            let t = s.split_once('T').map(|(_, r)| r).unwrap_or(s.trim());
8334            let hh: u32 = t.get(..2).and_then(|s| s.parse().ok())
8335                .ok_or_else(|| xpath_err(format!("invalid time-like value: {s:?}")))?;
8336            Ok(Value::Number(Numeric::Double(hh as f64)))
8337        }
8338        "minutes-from-dateTime" | "minutes-from-time" => {
8339            check_args!(1);
8340            let s = value_to_string_with(&arg!(0), idx, ctx.bindings);
8341            let t = s.split_once('T').map(|(_, r)| r).unwrap_or(s.trim());
8342            let mm: u32 = t.get(3..5).and_then(|s| s.parse().ok())
8343                .ok_or_else(|| xpath_err(format!("invalid time-like value: {s:?}")))?;
8344            Ok(Value::Number(Numeric::Double(mm as f64)))
8345        }
8346        "seconds-from-dateTime" | "seconds-from-time" => {
8347            check_args!(1);
8348            let s = value_to_string_with(&arg!(0), idx, ctx.bindings);
8349            let t = s.split_once('T').map(|(_, r)| r).unwrap_or(s.trim());
8350            // Seconds: from char 6 up to first 'Z' or '+'/'-' (zone) or end.
8351            let after = t.get(6..).unwrap_or("");
8352            let end = after.find(|c: char| c == 'Z' || c == '+' || c == '-')
8353                .unwrap_or(after.len());
8354            let ss: f64 = after[..end].parse()
8355                .map_err(|_| xpath_err(format!("invalid time-like value: {s:?}")))?;
8356            Ok(Value::Number(Numeric::Double(ss)))
8357        }
8358        "timezone-from-dateTime" | "timezone-from-date" | "timezone-from-time" => {
8359            check_args!(1);
8360            let s = value_to_string_with(&arg!(0), idx, ctx.bindings);
8361            let v = s.trim();
8362            // Empty input → empty result (no timezone).
8363            if v.is_empty() {
8364                return Ok(Value::NodeSet(Vec::new()));
8365            }
8366            // Trailing 'Z' → PT0S; trailing ±HH:MM → P[T]Hh[Mm]…
8367            let tz_start = v.rfind(|c: char| c == 'Z' || c == '+' || c == '-')
8368                .filter(|&i| i > 0);
8369            let tz = match tz_start.map(|i| &v[i..]) {
8370                Some("Z") => "PT0S".to_string(),
8371                Some(off) if off.len() == 6 => {
8372                    let sign = &off[0..1];
8373                    let hh: i32 = off[1..3].parse().unwrap_or(0);
8374                    let mm: i32 = off[4..6].parse().unwrap_or(0);
8375                    if hh == 0 && mm == 0 { "PT0S".to_string() }
8376                    else if mm == 0 { format!("{sign}PT{hh}H") }
8377                    else { format!("{sign}PT{hh}H{mm}M") }
8378                }
8379                _ => return Ok(Value::NodeSet(Vec::new())),
8380            };
8381            Ok(Value::String(tz.replace("+", "")))
8382        }
8383        "years-from-duration" | "months-from-duration"
8384        | "days-from-duration" | "hours-from-duration"
8385        | "minutes-from-duration" | "seconds-from-duration" => {
8386            check_args!(1);
8387            let s = value_to_string_with(&arg!(0), idx, ctx.bindings);
8388            let (sign, rest) = if let Some(r) = s.trim().strip_prefix('-') {
8389                (-1.0_f64, r)
8390            } else { (1.0, s.trim()) };
8391            let body = rest.strip_prefix('P').unwrap_or(rest);
8392            let (date_part, time_part) = match body.find('T') {
8393                Some(i) => (&body[..i], &body[i + 1..]),
8394                None    => (body, ""),
8395            };
8396            // Pull each component out as a number; treat missing
8397            // components as 0.
8398            fn extract(part: &str, marker: char) -> f64 {
8399                if let Some(i) = part.find(marker) {
8400                    let start = part[..i].rfind(|c: char| !c.is_ascii_digit() && c != '.')
8401                        .map(|n| n + 1).unwrap_or(0);
8402                    part[start..i].parse().unwrap_or(0.0)
8403                } else { 0.0 }
8404            }
8405            let years   = extract(date_part, 'Y');
8406            let months  = extract(date_part, 'M');
8407            let days    = extract(date_part, 'D');
8408            let hours   = extract(time_part, 'H');
8409            let minutes = extract(time_part, 'M');
8410            let seconds = extract(time_part, 'S');
8411            // XPath 2.0 §10.5: each accessor returns the *signed*
8412            // value of its component within the canonical form;
8413            // canonical normalisation isn't required by the
8414            // accessor itself, only by the duration type's
8415            // value-space comparisons.
8416            let v = match name.as_ref() {
8417                "years-from-duration"   => years,
8418                "months-from-duration"  => months,
8419                "days-from-duration"    => days,
8420                "hours-from-duration"   => hours,
8421                "minutes-from-duration" => minutes,
8422                "seconds-from-duration" => seconds,
8423                _ => unreachable!(),
8424            };
8425            Ok(Value::Number(Numeric::Double(sign * v)))
8426        }
8427        "nilled" => {
8428            // XPath 2.0 §15.4.6 — `nilled($node)`.  Per the spec:
8429            // * empty sequence → empty sequence
8430            // * non-element node → empty sequence
8431            // * element node in an untyped data model (we never
8432            //   surface PSVI) → false, even when the element carries
8433            //   `xsi:nil="true"`; that flag only takes effect when
8434            //   the element has been schema-validated as nillable.
8435            if args.len() != 1 {
8436                return Err(xpath_err("nilled() requires one argument"));
8437            }
8438            let v = arg!(0);
8439            let n = match v {
8440                Value::NodeSet(ref ns) => ns.first().copied(),
8441                _ => return Ok(Value::NodeSet(Vec::new())),
8442            };
8443            let id = match n { Some(id) => id, None => return Ok(Value::NodeSet(Vec::new())) };
8444            if !matches!(idx.kind(id), crate::xpath::XPathNodeKind::Element) {
8445                return Ok(Value::NodeSet(Vec::new()));
8446            }
8447            Ok(Value::Boolean(false))
8448        }
8449        "default-collation" => {
8450            check_args!(0);
8451            // Spec default: the XPath codepoint collation URI.
8452            Ok(Value::String("http://www.w3.org/2005/xpath-functions/collation/codepoint".into()))
8453        }
8454        "implicit-timezone" => {
8455            check_args!(0);
8456            // We don't model implicit timezone — return PT0S which
8457            // most code paths tolerate.
8458            Ok(Value::String("PT0S".into()))
8459        }
8460        "format-dateTime" => {
8461            if args.len() < 2 || args.len() > 5 {
8462                return Err(xpath_err(format!(
8463                    "format-dateTime() requires 2 to 5 arguments (got {})", args.len()
8464                )));
8465            }
8466            let v = format_date_time_picture(&arg_str!(0), &arg_str!(1), DateKind::DateTime)?;
8467            let lang = if args.len() > 2 { arg_str!(2) } else { String::new() };
8468            let cal  = if args.len() > 3 { arg_str!(3) } else { String::new() };
8469            Ok(Value::String(format!("{}{v}", format_date_locale_prefix(&lang, &cal))))
8470        }
8471
8472        // ── XPath 2.0 misc functions ──────────────────────────────
8473        "compare" => {
8474            if args.len() < 2 || args.len() > 3 {
8475                return Err(xpath_err(format!(
8476                    "compare() requires 2 or 3 arguments (got {})", args.len()
8477                )));
8478            }
8479            let a = arg_str!(0);
8480            let b = arg_str!(1);
8481            let coll = effective_collation(if args.len() == 3 { Some(arg_str!(2)) } else { None });
8482            let (ka, kb) = if is_ascii_ci_collation(coll.as_deref()) {
8483                (ascii_ci_fold(&a), ascii_ci_fold(&b))
8484            } else {
8485                (a, b)
8486            };
8487            Ok(Value::Number(Numeric::Double(match ka.cmp(&kb) {
8488                std::cmp::Ordering::Less    => -1.0,
8489                std::cmp::Ordering::Equal   =>  0.0,
8490                std::cmp::Ordering::Greater =>  1.0,
8491            })))
8492        }
8493        "codepoint-equal" => {
8494            check_args!(2);
8495            Ok(Value::Boolean(arg_str!(0) == arg_str!(1)))
8496        }
8497        "string-to-codepoints" => {
8498            check_args!(1);
8499            let s = arg_str!(0);
8500            let pieces: Vec<String> = s.chars().map(|c| (c as u32).to_string()).collect();
8501            match idx.allocate_rtf_text_nodes(pieces.clone()) {
8502                Some(ids) => Ok(Value::NodeSet(ids)),
8503                None      => Ok(Value::String(pieces.join(" "))),
8504            }
8505        }
8506        "codepoints-to-string" => {
8507            // Each item of the input sequence is a codepoint (number).
8508            check_args!(1);
8509            let codes = sequence_to_numbers(&arg!(0), idx);
8510            let mut out = String::with_capacity(codes.len());
8511            for n in codes {
8512                if let Some(c) = u32::try_from(n as i64).ok().and_then(char::from_u32) {
8513                    out.push(c);
8514                }
8515            }
8516            Ok(Value::String(out))
8517        }
8518        "encode-for-uri" => {
8519            check_args!(1);
8520            // RFC 3986 percent-encoding for "unreserved" → leave
8521            // alone, everything else → %HH.  Unreserved per RFC:
8522            // ALPHA / DIGIT / "-" / "." / "_" / "~".
8523            let s = arg_str!(0);
8524            let mut out = String::with_capacity(s.len());
8525            for c in s.chars() {
8526                if c.is_ascii_alphanumeric()
8527                    || matches!(c, '-' | '.' | '_' | '~')
8528                {
8529                    out.push(c);
8530                } else {
8531                    let mut buf = [0u8; 4];
8532                    for b in c.encode_utf8(&mut buf).as_bytes() {
8533                        let _ = write!(out, "%{:02X}", b);
8534                    }
8535                }
8536            }
8537            Ok(Value::String(out))
8538        }
8539        "iri-to-uri" => {
8540            check_args!(1);
8541            // Encode non-ASCII chars; leave most ASCII alone (RFC 3987).
8542            let s = arg_str!(0);
8543            let mut out = String::with_capacity(s.len());
8544            for c in s.chars() {
8545                if (c as u32) < 0x80
8546                    && !matches!(c, ' ' | '<' | '>' | '"' | '{' | '}' | '|' | '\\' | '^' | '`')
8547                {
8548                    out.push(c);
8549                } else {
8550                    let mut buf = [0u8; 4];
8551                    for b in c.encode_utf8(&mut buf).as_bytes() {
8552                        let _ = write!(out, "%{:02X}", b);
8553                    }
8554                }
8555            }
8556            Ok(Value::String(out))
8557        }
8558        "escape-html-uri" => {
8559            check_args!(1);
8560            // Same shape as iri-to-uri but only non-ASCII gets escaped.
8561            let s = arg_str!(0);
8562            let mut out = String::with_capacity(s.len());
8563            for c in s.chars() {
8564                if (c as u32) < 0x80 { out.push(c); }
8565                else {
8566                    let mut buf = [0u8; 4];
8567                    for b in c.encode_utf8(&mut buf).as_bytes() {
8568                        let _ = write!(out, "%{:02X}", b);
8569                    }
8570                }
8571            }
8572            Ok(Value::String(out))
8573        }
8574        "error" => {
8575            // F&O §3.2.1.  Signatures: error(), error($code as xs:QName?),
8576            // error($code, $description), error($code, $description,
8577            // $error-object).  The first argument is the error-code
8578            // QName whose local part becomes $err:code; the second is
8579            // the description.  An empty first argument (or none) uses
8580            // the default err:FOER0000.
8581            let code_local = if args.is_empty() { None } else {
8582                let s = arg_str!(0);
8583                // Local part of a QName-shaped string: after the last
8584                // ':' (lexical) or '}' (Clark notation), else the whole.
8585                s.rsplit([':', '}']).next()
8586                    .filter(|l| !l.is_empty())
8587                    .map(|l| l.to_string())
8588            };
8589            let msg = if args.len() >= 2 { arg_str!(1) }
8590                      else if args.is_empty() { "fn:error invoked".to_string() }
8591                      else { arg_str!(0) };
8592            let mut e = xpath_err(format!("fn:error: {msg}"));
8593            if let Some(code) = code_local { e = e.with_xpath_code(code); }
8594            Err(e)
8595        }
8596        "trace" => {
8597            // `trace(value, label)` — pass `value` through; emit the
8598            // label to stderr.  Spec leaves the trace destination
8599            // implementation-defined.
8600            if args.is_empty() || args.len() > 2 {
8601                return Err(xpath_err(format!(
8602                    "trace() requires 1 or 2 arguments (got {})", args.len()
8603                )));
8604            }
8605            let v = arg!(0);
8606            if args.len() == 2 {
8607                eprintln!("[xpath trace] {}: {}", arg_str!(1), value_to_string(&v, idx));
8608            }
8609            Ok(v)
8610        }
8611        // Cardinality assertions — passthrough with bounds check.
8612        // [`sequence_len`] handles every shape uniformly: empty
8613        // node-set / sequence → 0, IntRange → its full cardinality,
8614        // single atomic → 1, multi-item Sequence → count.
8615        "exactly-one" => {
8616            check_args!(1);
8617            let v = arg!(0);
8618            let n = sequence_len(&v);
8619            if n != 1 {
8620                return Err(xpath_err(format!(
8621                    "exactly-one(): expected one item, got {n}"
8622                )));
8623            }
8624            Ok(v)
8625        }
8626        "one-or-more" => {
8627            check_args!(1);
8628            let v = arg!(0);
8629            if sequence_len(&v) < 1 {
8630                return Err(xpath_err("one-or-more(): empty input"));
8631            }
8632            Ok(v)
8633        }
8634        "zero-or-one" => {
8635            check_args!(1);
8636            let v = arg!(0);
8637            let n = sequence_len(&v);
8638            if n > 1 {
8639                return Err(xpath_err(format!(
8640                    "zero-or-one(): expected at most one item, got {n}"
8641                )));
8642            }
8643            Ok(v)
8644        }
8645        "node-name" => {
8646            check_args!(1);
8647            // Result is the node's expanded-name as `xs:QName`.
8648            // Encode the QName as a Typed value with kind="QName"
8649            // and Clark-notation lexical (`{uri}local`) so the
8650            // accessor helpers (`local-name-from-QName`,
8651            // `namespace-uri-from-QName`, `prefix-from-QName`)
8652            // can recover the URI.  When callers want the lexical
8653            // QName instead — `xsl:value-of select="node-name(.)"`
8654            // — the typed-to-string path falls back to the
8655            // stored lexical, which already contains `{uri}local`;
8656            // most XSLT 2.0 tests compare on this Clark form too.
8657            match arg!(0) {
8658                Value::NodeSet(ns) => {
8659                    let Some(&id) = ns.first() else {
8660                        return Ok(Value::NodeSet(Vec::new()));
8661                    };
8662                    let local = idx.local_name(id);
8663                    let uri   = idx.namespace_uri(id);
8664                    let lex = if uri.is_empty() {
8665                        local.to_string()
8666                    } else {
8667                        format!("{{{uri}}}{local}")
8668                    };
8669                    Ok(Value::Typed(Box::new(TypedAtomic {
8670                        kind: "QName",
8671                        lexical: lex,
8672                        numeric: None,
8673                        boolean: None, user_type: None,
8674                    })))
8675                }
8676                _ => Ok(Value::NodeSet(Vec::new())),
8677            }
8678        }
8679
8680        // XSLT 1.0 §15 `function-available()` / `element-available()`.
8681        // libxslt registers these on the XPath context, but our compat
8682        // bridge skips the no-namespace XSLT functions (their libxslt
8683        // implementations need transform-context state we don't mirror),
8684        // so they land here as ordinary calls.  Answer from the function
8685        // set this engine actually provides: the XPath 1.0/2.0/3.0
8686        // built-ins plus the EXSLT families we implement natively.
8687        "function-available" if !args.is_empty() => {
8688            let qname = arg_str!(0);
8689            Ok(Value::Boolean(xpath_function_available(&qname, ctx)))
8690        }
8691        // XSLT 1.0 §12.4 `current()`.  libxslt registers it but our
8692        // bridge skips the no-namespace XSLT functions, so it lands
8693        // XSLT 1.0 §12.4: current() returns the node that was the
8694        // context node of the *whole* expression — the instruction's
8695        // current node — which stays fixed as evaluation descends into
8696        // steps and predicates.  `static_ctx.current_node` carries it
8697        // (set once at the top-level entry); `context_node` would be the
8698        // predicate's context, wrong inside `foo[@x=current()/@y]`.
8699        "current" if args.is_empty() => {
8700            Ok(Value::NodeSet(vec![ctx.static_ctx.current_node.unwrap_or(ctx.context_node)]))
8701        }
8702
8703        _ => Err(xpath_err(format!("unknown XPath function: {name}()"))),
8704    }
8705}
8706
8707/// Best-effort `function-available()` predicate.  A prefixed name is
8708/// available when its prefix resolves to an EXSLT namespace family this
8709/// engine implements; an unprefixed name when it's a known XPath/XSLT
8710/// built-in.  Functions we don't provide (e.g. `msxsl:node-set`) report
8711/// `false`, which is what callers like the ISO Schematron skeleton use
8712/// to choose a supported code path.
8713fn xpath_function_available(qname: &str, ctx: &EvalCtx<'_>) -> bool {
8714    use super::exslt;
8715    match qname.split_once(':') {
8716        Some((prefix, _local)) => {
8717            match resolve_prefix_or_implicit(ctx.bindings, prefix) {
8718                Some(uri) => matches!(uri.as_str(),
8719                    exslt::MATH_NS | exslt::DATE_NS | exslt::STR_NS | exslt::SET_NS
8720                    | exslt::REGEXP_NS | exslt::COMMON_NS | exslt::DYN_NS),
8721                None => false,
8722            }
8723        }
8724        None => matches!(qname,
8725            // XPath 1.0 core library (§4).
8726            "last" | "position" | "count" | "id" | "local-name" | "namespace-uri"
8727            | "name" | "string" | "concat" | "starts-with" | "contains"
8728            | "substring-before" | "substring-after" | "substring" | "string-length"
8729            | "normalize-space" | "translate" | "boolean" | "not" | "true" | "false"
8730            | "lang" | "number" | "sum" | "floor" | "ceiling" | "round"
8731            // XSLT 1.0 additions (§12) we honour in this engine.
8732            | "document" | "key" | "format-number" | "current" | "unparsed-entity-uri"
8733            | "generate-id" | "system-property" | "element-available" | "function-available"),
8734    }
8735}
8736
8737/// Extract a Vec<NodeId> from a Value, treating non-NodeSet inputs
8738/// as empty.  Used for the set-flavoured XPath 2.0 operators
8739/// (`intersect`, `except`) where atomic operands surface as the
8740/// empty node-set per the spec's atomic-vs-node-set rules.
8741fn node_set_of(v: Value) -> Vec<NodeId> {
8742    match v {
8743        Value::NodeSet(ns) => ns,
8744        _ => Vec::new(),
8745    }
8746}
8747
8748/// True if `v` matches the XPath 2.0 SequenceType `st`.  Covers the
8749/// subset our parser actually emits — atomic types we know
8750/// (xs:string/integer/decimal/double/boolean/date/dateTime/time/etc.)
8751/// and the standard KindTest forms (item, node, element, attribute,
8752/// text, comment, processing-instruction, document-node).
8753/// Resolve the namespace prefixes used in any `element(N)` /
8754/// `attribute(N)` / `processing-instruction(N)` kind test inside `st`
8755/// into Clark form `{uri}local`, using the in-scope namespace
8756/// bindings.  `value_matches_sequence_type` then compares against the
8757/// node's namespace URI rather than its (prefix-dependent) lexical
8758/// QName, so `instance of element(my:foo)` matches a node in the same
8759/// namespace regardless of which prefix the source document used.
8760/// A prefix that doesn't resolve is left lexical for a best-effort
8761/// QName comparison.
8762fn resolve_kind_test_namespaces(
8763    st: &crate::xpath::ast::SequenceType,
8764    bindings: &dyn XPathBindings,
8765) -> crate::xpath::ast::SequenceType {
8766    use crate::xpath::ast::ItemType;
8767    let resolve = |name: &Option<String>| -> Option<String> {
8768        let n = name.as_ref()?;
8769        let (prefix, local) = n.split_once(':')?;
8770        let uri = resolve_prefix_or_implicit(bindings, prefix)?;
8771        Some(format!("{{{uri}}}{local}"))
8772    };
8773    let item = match &st.item {
8774        ItemType::Element(name @ Some(_)) =>
8775            ItemType::Element(resolve(name).or_else(|| name.clone())),
8776        ItemType::Attribute(name @ Some(_)) =>
8777            ItemType::Attribute(resolve(name).or_else(|| name.clone())),
8778        other => other.clone(),
8779    };
8780    crate::xpath::ast::SequenceType { item, occurrence: st.occurrence }
8781}
8782
8783/// Match the name in an `element(N)` / `attribute(N)` kind test
8784/// against the node at `id`.  `None` (the bare-paren form) matches
8785/// any name.  A name in Clark form `{uri}local` (produced by
8786/// [`resolve_kind_test_namespaces`]) is compared against the node's
8787/// namespace URI + local name.  A still-prefixed name is compared
8788/// against the node's lexical QName as a best-effort fallback; an
8789/// unprefixed name against the local part.
8790fn kind_test_name_matches<I: DocIndexLike>(
8791    name: Option<&str>, id: NodeId, idx: &I,
8792) -> bool {
8793    match name {
8794        None => true,
8795        Some(n) => match n.strip_prefix('{').and_then(|r| r.split_once('}')) {
8796            Some((uri, local)) =>
8797                idx.namespace_uri(id) == uri && idx.local_name(id) == local,
8798            None if n.contains(':') => idx.node_name(id) == n,
8799            None => idx.local_name(id) == n,
8800        },
8801    }
8802}
8803
8804fn value_matches_sequence_type<I: DocIndexLike>(
8805    v: &Value, st: &crate::xpath::ast::SequenceType, idx: &I,
8806) -> bool {
8807    use crate::xpath::ast::{ItemType, Occurrence};
8808    // Cardinality check first — sequence size against occurrence.
8809    let count = sequence_len(v);
8810    let card_ok = match st.occurrence {
8811        Occurrence::One        => count == 1,
8812        Occurrence::Optional   => count <= 1,
8813        Occurrence::OneOrMore  => count >= 1,
8814        Occurrence::ZeroOrMore => true,
8815    };
8816    if !card_ok { return false; }
8817    // The item-type test applies to every item; with an empty
8818    // sequence it is vacuously satisfied (cardinality already
8819    // confirmed the occurrence indicator admits zero items).  This
8820    // also covers empty sequences not represented as a NodeSet
8821    // (e.g. `Value::Sequence([])` from `<xsl:sequence select="()"/>`),
8822    // which the per-kind arms below would otherwise reject.
8823    if count == 0 { return true; }
8824    // Item-type check — applied to every item in the sequence.
8825    match &st.item {
8826        ItemType::Any => true,
8827        ItemType::Atomic(name) => {
8828            // Atomic types: a NodeSet is fine if the string-value
8829            // round-trips through the atomic parser.  Singleton
8830            // atomic values do the obvious match.
8831            let try_one = |s: &str| atomic_string_castable(s, name);
8832            match v {
8833                // XPath 2.0 §3.10.2 — `instance of` tests the value's
8834                // dynamic type; it does NOT cast.  A `Value::String` is
8835                // an xs:string (literal) or xs:untypedAtomic (atomized
8836                // untyped content); typed values flow through
8837                // `Value::Typed`/`Value::Number`.  So it matches only
8838                // the string family and the atomic super-types.
8839                Value::String(_)  => matches!(name.as_str(),
8840                    "string" | "untypedAtomic" | "anyAtomicType" | "anySimpleType"),
8841                // A number matches `xs:T` per the XSD subtype lattice
8842                // read from its own kind — `xs:integer 1` is an
8843                // instance of xs:integer / xs:decimal / xs:anyAtomicType
8844                // but NOT xs:double, and an `xs:double` is NOT an
8845                // xs:integer.  `xs:numeric` is the F&O union of the four
8846                // numeric primitives, outside the hierarchy, so any
8847                // number matches it.
8848                Value::Number(n)  => name == "numeric"
8849                    || xsd_is_subtype_of(n.kind(), name),
8850                Value::Boolean(_) => matches!(name.as_str(),
8851                    "boolean" | "anyAtomicType"),
8852                // XPath 2.0 §3.10.2 — `instance of xs:T` does NOT
8853                // atomize: a node IS NOT an instance of an atomic
8854                // type (xs:anyAtomicType, xs:string, etc.) even if
8855                // its string-value would atomize to a matching atom.
8856                // Only synthetic-text "nodes" representing atomic
8857                // singletons can match — we identify them by their
8858                // synthetic-store NodeId.
8859                Value::NodeSet(ns) => ns.iter().all(|&id|
8860                    crate::xpath::is_synthetic_id(id)
8861                    && try_one(&idx.string_value(id))),
8862                Value::ForeignNodeSet(_) => true,
8863                // Typed atomics match by subtype lattice — same
8864                // logic instance-of uses elsewhere.  See
8865                // `xsd_is_subtype_of`.
8866                Value::Typed(t) => xsd_is_subtype_of(t.kind, name),
8867                // Heterogeneous typed sequence: every item must
8868                // satisfy the atomic test on its own terms.
8869                Value::Sequence(items) => items.iter().all(|item|
8870                    value_matches_sequence_type(item, st, idx)),
8871                // An IntRange is a sequence of `xs:integer` values, so
8872                // it matches exactly what an xs:integer does.
8873                Value::IntRange { .. } => name == "numeric"
8874                    || xsd_is_subtype_of("integer", name),
8875                // A map / array is not an instance of any atomic type.
8876                Value::Map(_) | Value::Array(_) | Value::Function(_) => false,
8877            }
8878        }
8879        ItemType::AnyNode => matches!(v,
8880            Value::NodeSet(_) | Value::ForeignNodeSet(_)),
8881        ItemType::Element(name) => match v {
8882            Value::NodeSet(ns) => ns.iter().all(|&id|
8883                matches!(idx.kind(id), crate::xpath::XPathNodeKind::Element)
8884                && kind_test_name_matches(name.as_deref(), id, idx)),
8885            _ => false,
8886        },
8887        ItemType::Attribute(name) => match v {
8888            Value::NodeSet(ns) => ns.iter().all(|&id|
8889                matches!(idx.kind(id), crate::xpath::XPathNodeKind::Attribute)
8890                && kind_test_name_matches(name.as_deref(), id, idx)),
8891            _ => false,
8892        },
8893        ItemType::Text => match v {
8894            Value::NodeSet(ns) => ns.iter().all(|&id|
8895                matches!(idx.kind(id),
8896                    crate::xpath::XPathNodeKind::Text |
8897                    crate::xpath::XPathNodeKind::CData)),
8898            _ => false,
8899        },
8900        ItemType::Comment => match v {
8901            Value::NodeSet(ns) => ns.iter().all(|&id|
8902                matches!(idx.kind(id), crate::xpath::XPathNodeKind::Comment)),
8903            _ => false,
8904        },
8905        ItemType::PI(target) => match v {
8906            Value::NodeSet(ns) => ns.iter().all(|&id|
8907                matches!(idx.kind(id), crate::xpath::XPathNodeKind::PI)
8908                && target.as_ref().map_or(true, |t| idx.pi_target(id) == t)),
8909            _ => false,
8910        },
8911        ItemType::Document => match v {
8912            Value::NodeSet(ns) => ns.iter().all(|&id|
8913                matches!(idx.kind(id), crate::xpath::XPathNodeKind::Document)),
8914            _ => false,
8915        },
8916        // `function(*)` matches any function item; a specific
8917        // `function(T1, …, Tn) as R` applies function subtyping against
8918        // the item's own declared signature when known (named user
8919        // functions capture it), else falls back to arity.
8920        ItemType::Function(want) => match v {
8921            Value::Function(fi) => match want {
8922                None => true,
8923                Some(want) => fi.arity() == want.params.len()
8924                    && match fi.declared_sig() {
8925                        Some(have) => function_sig_subtype_of(have, want),
8926                        None => true,
8927                    },
8928            },
8929            Value::Sequence(items) => items.iter().all(|item|
8930                value_matches_sequence_type(item, st, idx)),
8931            _ => false,
8932        },
8933        // `map(*)` / `array(*)` — any map / any array item.
8934        ItemType::Map => match v {
8935            Value::Map(_) => true,
8936            Value::Sequence(items) => items.iter().all(|item|
8937                value_matches_sequence_type(item, st, idx)),
8938            _ => false,
8939        },
8940        ItemType::Array => match v {
8941            Value::Array(_) => true,
8942            Value::Sequence(items) => items.iter().all(|item|
8943                value_matches_sequence_type(item, st, idx)),
8944            _ => false,
8945        },
8946        // `empty-sequence()` — no individual item matches; a non-empty
8947        // value reaches here only after the count==0 short-circuit
8948        // above declined it, so it is not the empty sequence.
8949        ItemType::EmptySequence => false,
8950    }
8951}
8952
8953/// XPath 3.1 occurrence-indicator subsumption: are all cardinalities
8954/// permitted by `sub` also permitted by `sup`?
8955fn occurrence_subsumes(sub: Occurrence, sup: Occurrence) -> bool {
8956    let allows_zero = |o: Occurrence| matches!(o, Occurrence::Optional | Occurrence::ZeroOrMore);
8957    let allows_many = |o: Occurrence| matches!(o, Occurrence::OneOrMore | Occurrence::ZeroOrMore);
8958    (!allows_zero(sub) || allows_zero(sup)) && (!allows_many(sub) || allows_many(sup))
8959}
8960
8961/// `a` is a subtype of `b` (XPath 3.1 §2.5.6) to the resolution this
8962/// engine models — atomic types via the XSD lattice, node kinds, and
8963/// nested function signatures.
8964fn sequence_type_subtype_of(a: &SequenceType, b: &SequenceType) -> bool {
8965    occurrence_subsumes(a.occurrence, b.occurrence)
8966        && item_type_subtype_of(&a.item, &b.item)
8967}
8968
8969fn item_type_subtype_of(a: &ItemType, b: &ItemType) -> bool {
8970    match (a, b) {
8971        (_, ItemType::Any) => true,
8972        (ItemType::EmptySequence, _) => true,
8973        (ItemType::Atomic(x), ItemType::Atomic(y)) => xsd_is_subtype_of(x, y),
8974        (ItemType::AnyNode, ItemType::AnyNode) => true,
8975        (ItemType::Element(_) | ItemType::Attribute(_) | ItemType::Text
8976            | ItemType::Comment | ItemType::PI(_) | ItemType::Document,
8977         ItemType::AnyNode) => true,
8978        (ItemType::Element(x), ItemType::Element(y)) => y.is_none() || x == y,
8979        (ItemType::Attribute(x), ItemType::Attribute(y)) => y.is_none() || x == y,
8980        (ItemType::PI(x), ItemType::PI(y)) => y.is_none() || x == y,
8981        (ItemType::Text, ItemType::Text)
8982            | (ItemType::Comment, ItemType::Comment)
8983            | (ItemType::Document, ItemType::Document) => true,
8984        (ItemType::Function(_), ItemType::Function(None)) => true,
8985        (ItemType::Function(Some(sa)), ItemType::Function(Some(sb))) =>
8986            function_sig_subtype_of(sa, sb),
8987        (ItemType::Map, ItemType::Map) => true,
8988        (ItemType::Array, ItemType::Array) => true,
8989        _ => false,
8990    }
8991}
8992
8993/// Function subtyping (XPath 3.1 §2.5.6.3): `have` is a subtype of `want`
8994/// iff same arity, `have`'s return type is a subtype of `want`'s
8995/// (covariant), and each of `want`'s parameter types is a subtype of the
8996/// corresponding `have` parameter type (contravariant).
8997fn function_sig_subtype_of(have: &FunctionSig, want: &FunctionSig) -> bool {
8998    have.params.len() == want.params.len()
8999        && sequence_type_subtype_of(&have.ret, &want.ret)
9000        && have.params.iter().zip(&want.params)
9001            .all(|(hp, wp)| sequence_type_subtype_of(wp, hp))
9002}
9003
9004/// Lenient lexical check for the common atomic XSD types.  Used by
9005/// `instance of` / `castable as` to ask "is this string a valid
9006/// `xs:<name>` literal?" without actually allocating a converted
9007/// value.  Returns true on a successful round-trip.
9008/// Lexical-form sanity check for the date / duration / binary
9009/// atomic types — used by the `cast as` path so an integer node
9010/// value like `"43"` doesn't silently pass as `xs:date`.  The
9011/// checks are minimal (shape-only) rather than full XSD
9012/// validators — enough to reject obvious cross-type casts.
9013fn lexical_matches_type(s: &str, name: &str) -> bool {
9014    if s.is_empty() { return false; }
9015    let bytes = s.as_bytes();
9016    match name {
9017        "date" => {
9018            // YYYY-MM-DD with optional timezone suffix.
9019            s.len() >= 10
9020                && parse_xsd_date_time(s, DateKind::Date).is_some()
9021        }
9022        "dateTime" => {
9023            s.contains('T') && parse_xsd_date_time(s, DateKind::DateTime).is_some()
9024        }
9025        "time" => {
9026            bytes.len() >= 8 && bytes[2] == b':' && bytes[5] == b':'
9027                && parse_xsd_date_time(s, DateKind::Time).is_some()
9028        }
9029        // Duration forms — `P[nY][nM][nD][T[nH][nM][nS]]`.  All durations
9030        // start with `P` (or `-P` for negatives).
9031        "duration" => {
9032            let rest = s.strip_prefix('-').unwrap_or(s);
9033            rest.starts_with('P') && rest.len() > 1
9034        }
9035        "dayTimeDuration" => {
9036            let rest = s.strip_prefix('-').unwrap_or(s);
9037            // No year / month designators.
9038            rest.starts_with('P')
9039                && !rest.contains('Y')
9040                && !rest_contains_month_designator(rest)
9041        }
9042        "yearMonthDuration" => {
9043            let rest = s.strip_prefix('-').unwrap_or(s);
9044            // No day / time components.
9045            rest.starts_with('P')
9046                && !rest.contains('D')
9047                && !rest.contains('T')
9048        }
9049        // Gregorian forms have at least one ASCII digit.
9050        "gYear"      => s.len() >= 4 && bytes.iter().all(|&b| b == b'-' || b.is_ascii_digit() || b == b'+' || b == b'Z' || b == b':'),
9051        "gYearMonth" => s.contains('-') && s.len() >= 7,
9052        "gMonth"     => s.starts_with("--") && s.len() >= 4,
9053        "gMonthDay"  => s.starts_with("--") && s.len() >= 7,
9054        "gDay"       => s.starts_with("---") && s.len() >= 5,
9055        // Binary forms — hex requires even length of hex digits;
9056        // base64 is at least empty-friendly (4-byte aligned).
9057        "hexBinary"  => s.len() % 2 == 0 && bytes.iter().all(|b| b.is_ascii_hexdigit()),
9058        "base64Binary" => true, // permissive
9059        _ => true,
9060    }
9061}
9062
9063/// True iff `s` is a well-formed lexical value of the year-bearing type
9064/// `name` (`date` / `dateTime` / `gYear` / `gYearMonth`) whose ONLY
9065/// defect is that the year magnitude exceeds the range this engine can
9066/// represent (`i32`).  Such a value is lexically valid but outside the
9067/// implementation-defined supported range, so a cast/constructor must
9068/// raise `err:FODT0001` (overflow) rather than `err:FORG0001`
9069/// (malformed).  Years within `i32` — including 5+ digit and negative
9070/// years — are supported and return `false` here.
9071fn date_year_out_of_range(s: &str, name: &str) -> bool {
9072    if !matches!(name, "date" | "dateTime" | "gYear" | "gYearMonth") {
9073        return false;
9074    }
9075    let neg = s.starts_with('-');
9076    let body = s.strip_prefix('-').unwrap_or(s);
9077    let ylen = body.bytes().take_while(u8::is_ascii_digit).count();
9078    if ylen == 0 { return false; }
9079    let ynum = &body[..ylen];
9080    // In `i32` → representable (not an overflow); not even `i128` → an
9081    // absurd numeral we treat as malformed, not overflow.
9082    if ynum.parse::<i32>().is_ok() || ynum.parse::<i128>().is_err() {
9083        return false;
9084    }
9085    // The value is well-formed apart from the year magnitude iff
9086    // substituting an in-range year makes it match the type.
9087    let probe = format!("{}0001{}", if neg { "-" } else { "" }, &body[ylen..]);
9088    lexical_matches_type(&probe, name)
9089}
9090
9091/// Helper for `lexical_matches_type("dayTimeDuration", …)` —
9092/// XSD `P…M` could mean months (date side) or minutes (time
9093/// side, after `T`).  Months are illegal in a dayTimeDuration;
9094/// minutes are fine.  This walks the string and reports true
9095/// when an `M` appears before the `T`.
9096fn rest_contains_month_designator(s: &str) -> bool {
9097    let t_pos = s.find('T');
9098    for (i, c) in s.char_indices() {
9099        if c == 'M' {
9100            match t_pos {
9101                Some(t) if i > t => continue,
9102                _                => return true,
9103            }
9104        }
9105    }
9106    false
9107}
9108
9109fn atomic_string_castable(s: &str, name: &str) -> bool {
9110    match name {
9111        "string" | "anyURI" | "anyAtomicType" | "untypedAtomic" => true,
9112        "boolean" => matches!(s.trim(), "true" | "false" | "1" | "0"),
9113        "integer" | "long" | "int" | "short" | "byte"
9114        | "nonNegativeInteger" | "nonPositiveInteger"
9115        | "positiveInteger" | "negativeInteger"
9116        | "unsignedLong" | "unsignedInt" | "unsignedShort" | "unsignedByte"
9117            => s.trim().parse::<i64>().is_ok(),
9118        "decimal" => s.trim().parse::<f64>().is_ok() && !s.trim().contains(['e', 'E']),
9119        "double" | "float" | "numeric" => s.trim().parse::<f64>().is_ok()
9120                                       || matches!(s.trim(), "NaN" | "INF" | "-INF" | "Infinity" | "-Infinity"),
9121        "date"     => parse_xsd_date_only(s).is_some(),
9122        "dateTime" => parse_xsd_date_time(s, DateKind::DateTime).is_some(),
9123        "time"     => parse_xsd_date_time(s, DateKind::Time).is_some(),
9124        _ => true,    // unknown atomic types pass — best-effort, no schema lookup
9125    }
9126}
9127
9128/// Convert a Value to its atomic-typed counterpart.  Most casts in
9129/// our model collapse to a string + the runtime's native interpretation
9130/// (e.g. `xs:integer("42")` → `Value::Number(42)`).  Errors when the
9131/// target type can't accept the lexical form.
9132/// XSD §F.3 canonical lexical form for `xs:double` / `xs:float`.
9133/// Always written in scientific notation when the magnitude is
9134/// outside `[1e-6, 1e7)` (matching Saxon's rendering of the XSD
9135/// canonical form, which the XSLT test suite expects).  Preserves
9136/// the sign of negative zero — `xs:float(-0)` round-trips as
9137/// `"-0"`.
9138/// XSD §F.3 canonical lexical form for `xs:float` — the value is
9139/// stored as f64 (after a `n as f32 as f64` round-trip narrowing in
9140/// the constructor / cast path), but the rendered form must reflect
9141/// f32's ~7-digit precision.  Re-formatting through `f32` gives
9142/// Rust's shortest round-trip output at single precision —
9143/// `1.2345679` instead of `1.2345678806304932`.
9144fn canonical_float_lex(n: f64, source: &Value) -> String {
9145    let f = n as f32;
9146    if f.is_nan()      { return "NaN".to_string(); }
9147    if f.is_infinite() { return (if f > 0.0 { "INF" } else { "-INF" }).to_string(); }
9148    let is_neg_zero = f == 0.0
9149        && (f.is_sign_negative()
9150            || matches!(source, Value::Number(v) if v.as_f64().is_sign_negative())
9151            || matches!(source, Value::String(s) if s.trim().starts_with('-')));
9152    if f == 0.0 {
9153        return if is_neg_zero { "-0".to_string() } else { "0".to_string() };
9154    }
9155    let abs = f.abs();
9156    if (1e-6..1e7).contains(&abs) {
9157        if f.fract() == 0.0 {
9158            return format!("{}", f as i64);
9159        }
9160        return format!("{f}");
9161    }
9162    let formatted = format!("{f:E}");
9163    let (mantissa, exp) = formatted.split_once('E').unwrap_or((formatted.as_str(), "0"));
9164    let mantissa = if mantissa.contains('.') { mantissa.to_string() }
9165                   else { format!("{mantissa}.0") };
9166    let exp = exp.trim_start_matches('+');
9167    format!("{mantissa}E{exp}")
9168}
9169
9170fn canonical_double_lex(n: f64, source: &Value) -> String {
9171    if n.is_nan()      { return "NaN".to_string(); }
9172    if n.is_infinite() { return (if n > 0.0 { "INF" } else { "-INF" }).to_string(); }
9173    // Negative-zero detection.  Reading the sign bit picks both `-0.0`
9174    // and a literal `xs:float(-0)` produced by Rust's normal parsing
9175    // (which collapses `-0` → `-0.0`).  Carry the sign through only
9176    // when the source explicitly carried it (otherwise xs:double of
9177    // `0` becomes `0`).
9178    let is_neg_zero = n == 0.0
9179        && (n.is_sign_negative()
9180            || matches!(source, Value::Number(v) if v.as_f64().is_sign_negative())
9181            || matches!(source, Value::String(s) if s.trim().starts_with('-')));
9182    if n == 0.0 {
9183        return if is_neg_zero { "-0".to_string() } else { "0".to_string() };
9184    }
9185    let abs = n.abs();
9186    if abs >= 1e-6 && abs < 1e7 {
9187        // Inside the "no exponent" window — use fixed-point form.
9188        // `format!("{}", n)` would emit shortest round-trip; but
9189        // canonical form requires at least one fractional digit
9190        // unless the value is integer-valued, so handle both shapes.
9191        if n.fract() == 0.0 {
9192            return format!("{}", n as i64);
9193        }
9194        // Rust's default Display for f64 already gives shortest
9195        // round-trip without trailing zeros — pass through.
9196        return format!("{n}");
9197    }
9198    // Outside the window — scientific with explicit `E`.  Saxon's
9199    // rendering uses one mantissa digit + fractional digits, capital
9200    // `E`, no leading zeros on the exponent.  Replicate via `{:E}` and
9201    // strip Rust's leading-zero padding.
9202    let formatted = format!("{n:E}");
9203    // Rust prints `1E-8` (no `.0`) — canonical form requires `1.0E-8`.
9204    // Insert `.0` before the `E` if there's no fractional digit.
9205    let (mantissa, exp) = match formatted.split_once('E') {
9206        Some((m, e)) => (m.to_string(), e.to_string()),
9207        None         => return formatted,
9208    };
9209    let mantissa = if !mantissa.contains('.') {
9210        format!("{mantissa}.0")
9211    } else { mantissa };
9212    format!("{mantissa}E{exp}")
9213}
9214
9215/// XSD §F.1 canonical lexical form for `xs:decimal` — fixed-point,
9216/// no exponent.  Negative-zero collapses to `0` for xs:decimal
9217/// (decimal doesn't have a signed zero).  Pass-through-friendly:
9218/// when the source is already in fixed-point form we keep its
9219/// representation so trailing zeros that the author wrote survive
9220/// (xs:decimal lexical isn't *strictly* canonical until the
9221/// formatter normalises, but most tests accept the input form).
9222fn canonical_decimal_lex(n: f64, src: &str) -> String {
9223    if n == 0.0 { return "0".to_string(); }
9224    if src.contains(['e', 'E']) {
9225        if n.fract() == 0.0 && n.abs() < 1e15 {
9226            return (n as i64).to_string();
9227        }
9228        return format!("{n}");
9229    }
9230    // XSD §3.2.3.2 canonical lexical for decimal: no leading `+`,
9231    // no leading zeros (except one before the decimal point), and
9232    // no trailing zeros after the decimal point (drop the decimal
9233    // point too if the fractional part collapses to nothing).  We
9234    // canonicalise from the source string rather than via f64 so
9235    // high-precision decimals like `10.0000000001` survive the
9236    // round trip — the key tests rely on exact-value comparison.
9237    let t = src.trim();
9238    let (sign, body) = match t.strip_prefix('-') {
9239        Some(rest) => ("-", rest),
9240        None       => ("", t.strip_prefix('+').unwrap_or(t)),
9241    };
9242    let (whole, frac) = match body.split_once('.') {
9243        Some((w, f)) => (w, f),
9244        None         => (body, ""),
9245    };
9246    let whole_trimmed = whole.trim_start_matches('0');
9247    let whole_canon = if whole_trimmed.is_empty() { "0" } else { whole_trimmed };
9248    let frac_trimmed = frac.trim_end_matches('0');
9249    if frac_trimmed.is_empty() {
9250        // Drop sign on a zero result so "-0.0" → "0".
9251        if whole_canon == "0" { return "0".into(); }
9252        return format!("{sign}{whole_canon}");
9253    }
9254    format!("{sign}{whole_canon}.{frac_trimmed}")
9255}
9256
9257/// Cast a value to an atomic sequence type.  Any failure to convert
9258/// the lexical value to the target type is a dynamic error
9259/// `err:FORG0001` (F&O §17.1); the `_impl` body reports the specific
9260/// failing form and this wrapper attaches the spec code (without
9261/// overwriting a more specific code an inner step may have set).
9262pub fn cast_value_to_atomic<I: DocIndexLike>(
9263    v: &Value, st: &crate::xpath::ast::SequenceType, idx: &I,
9264) -> Result<Value> {
9265    cast_value_to_atomic_impl(v, st, idx).map_err(|e| e.or_xpath_code("FORG0001"))
9266}
9267
9268fn cast_value_to_atomic_impl<I: DocIndexLike>(
9269    v: &Value, st: &crate::xpath::ast::SequenceType, idx: &I,
9270) -> Result<Value> {
9271    use crate::xpath::ast::ItemType;
9272    let s = value_to_string(v, idx);
9273    let make_typed = |kind: &'static str, lex: String,
9274                      numeric: Option<f64>, boolean: Option<bool>| -> Value {
9275        Value::Typed(Box::new(TypedAtomic {
9276            kind, lexical: lex, numeric, boolean, user_type: None,
9277        }))
9278    };
9279    match &st.item {
9280        ItemType::Atomic(name) => {
9281            let kind = match atomic_kind_static(name) {
9282                Some(k) => k,
9283                None => return Ok(Value::String(s)),
9284            };
9285            match name.as_str() {
9286                "string" | "anyURI" | "anyAtomicType" | "untypedAtomic"
9287                | "normalizedString" | "token" | "Name" | "NCName"
9288                | "language" | "ID" | "IDREF" | "IDREFS" | "ENTITY"
9289                | "ENTITIES" | "NMTOKEN" | "NMTOKENS" | "NOTATION" | "QName" => {
9290                    Ok(make_typed(kind, s, None, None))
9291                }
9292                "boolean" => {
9293                    // XPath 2.0 §17.1.4 — numeric → boolean: zero
9294                    // and NaN are false, every other finite value is
9295                    // true.  Strings accept the four canonical forms.
9296                    if let Value::Number(n) = v {
9297                        let b = !(n.as_f64() == 0.0 || n.as_f64().is_nan());
9298                        return Ok(make_typed("boolean",
9299                            (if b { "true" } else { "false" }).into(),
9300                            None, Some(b)));
9301                    }
9302                    if let Value::Typed(t) = v {
9303                        if let Some(n) = t.numeric {
9304                            let b = !(n == 0.0 || n.is_nan());
9305                            return Ok(make_typed("boolean",
9306                                (if b { "true" } else { "false" }).into(),
9307                                None, Some(b)));
9308                        }
9309                    }
9310                    match s.trim() {
9311                        "true"  | "1" => Ok(make_typed("boolean", "true".into(), None, Some(true))),
9312                        "false" | "0" => Ok(make_typed("boolean", "false".into(), None, Some(false))),
9313                        _ => Err(xpath_err(format!("cast to xs:boolean failed: {s:?}"))),
9314                    }
9315                }
9316                "integer" | "long" | "int" | "short" | "byte"
9317                | "nonNegativeInteger" | "nonPositiveInteger"
9318                | "positiveInteger" | "negativeInteger"
9319                | "unsignedLong" | "unsignedInt" | "unsignedShort" | "unsignedByte" => {
9320                    let n: i64 = s.trim().parse().map_err(|_| xpath_err(
9321                        format!("cast to xs:{name} failed: {s:?}")))?;
9322                    Ok(make_typed(kind, n.to_string(), Some(n as f64), None))
9323                }
9324                "decimal" | "double" | "float" | "numeric" => {
9325                    // XSD §F.3 — `INF` / `-INF` / `NaN` are the
9326                    // canonical lexical forms for xs:double / xs:float
9327                    // (not "Infinity"/"NaN" as XPath 1.0 used).  Accept
9328                    // both spellings on input but normalise on output.
9329                    let trimmed = s.trim();
9330                    let raw: f64 = match trimmed {
9331                        "INF"  | "Infinity"   => f64::INFINITY,
9332                        "-INF" | "-Infinity"  => f64::NEG_INFINITY,
9333                        "NaN"                 => f64::NAN,
9334                        _ => trimmed.parse().map_err(|_| xpath_err(
9335                            format!("cast to xs:{name} failed: {s:?}")))?,
9336                    };
9337                    // xs:float is IEEE 754 single — narrow through f32
9338                    // so high-precision literals lose their tail (cf.
9339                    // xs_constructor).
9340                    let n = if name == "float" { raw as f32 as f64 } else { raw };
9341                    let canon = match name.as_str() {
9342                        "double" => canonical_double_lex(n, v),
9343                        "float"  => canonical_float_lex(n, v),
9344                        // xs:decimal canonicalises to fixed-point.
9345                        _ => canonical_decimal_lex(n, trimmed),
9346                    };
9347                    Ok(make_typed(kind, canon, Some(n), None))
9348                }
9349                "date" | "dateTime" | "time"
9350                | "duration" | "dayTimeDuration" | "yearMonthDuration"
9351                | "gYear" | "gYearMonth" | "gMonth" | "gMonthDay" | "gDay"
9352                | "hexBinary" | "base64Binary" => {
9353                    // Same-family precision-changing casts F&O §17.1 —
9354                    // xs:date → xs:dateTime appends `T00:00:00` while
9355                    // preserving the timezone.  Without this the
9356                    // lexical-form check below rejects "2004-02-02"
9357                    // because xs:dateTime requires a `T` separator.
9358                    if name == "dateTime" {
9359                        if let Value::Typed(t) = v {
9360                            if t.kind == "date" {
9361                                // xs:date lexical: optional leading `-`,
9362                                // YYYY-MM-DD, optional tz (`Z` or
9363                                // `±HH:MM`).  Strip the tz before
9364                                // splicing in the midnight time.
9365                                let raw = t.lexical.as_str();
9366                                let (date_part, tz) = if let Some(rest) =
9367                                    raw.strip_suffix('Z')
9368                                {
9369                                    (rest, "Z")
9370                                } else if raw.len() >= 6 {
9371                                    let cand = &raw[raw.len() - 6..];
9372                                    let b = cand.as_bytes();
9373                                    if (b[0] == b'+' || b[0] == b'-')
9374                                        && b[1].is_ascii_digit() && b[2].is_ascii_digit()
9375                                        && b[3] == b':'
9376                                        && b[4].is_ascii_digit() && b[5].is_ascii_digit()
9377                                    {
9378                                        (&raw[..raw.len() - 6], cand)
9379                                    } else {
9380                                        (raw, "")
9381                                    }
9382                                } else {
9383                                    (raw, "")
9384                                };
9385                                let lex = format!("{date_part}T00:00:00{tz}");
9386                                return Ok(make_typed("dateTime", lex, None, None));
9387                            }
9388                        }
9389                    }
9390                    // Casting an xs:date / xs:dateTime to a gregorian
9391                    // type extracts the relevant components (F&O §17.1.7),
9392                    // carrying the source timezone — e.g.
9393                    // `xs:gDay(xs:date('2001-02-15'))` is `---15`.
9394                    if matches!(name.as_str(),
9395                        "gYear" | "gYearMonth" | "gMonth" | "gMonthDay" | "gDay")
9396                    {
9397                        if let Value::Typed(t) = v {
9398                            let dk = match t.kind {
9399                                "date"     => Some(DateKind::Date),
9400                                "dateTime" => Some(DateKind::DateTime),
9401                                _          => None,
9402                            };
9403                            if let Some(dk) = dk {
9404                                if let Some((y, mo, d, _, _, _, _, tz)) =
9405                                    parse_xsd_date_time(&t.lexical, dk)
9406                                {
9407                                    let tzs = tz.map(format_tz_suffix).unwrap_or_default();
9408                                    let lex = match name.as_str() {
9409                                        "gYear"      => format!("{y:04}{tzs}"),
9410                                        "gYearMonth" => format!("{y:04}-{mo:02}{tzs}"),
9411                                        "gMonth"     => format!("--{mo:02}{tzs}"),
9412                                        "gMonthDay"  => format!("--{mo:02}-{d:02}{tzs}"),
9413                                        _            => format!("---{d:02}{tzs}"),
9414                                    };
9415                                    return Ok(make_typed(kind, lex, None, None));
9416                                }
9417                            }
9418                        }
9419                    }
9420                    // F&O §17.1.6 — xs:hexBinary ⇄ xs:base64Binary
9421                    // reinterprets the same octets.
9422                    if matches!(name.as_str(), "hexBinary" | "base64Binary") {
9423                        if let Some(lex) = convert_binary_kind(v, name) {
9424                            return Ok(make_typed(kind, lex, None, None));
9425                        }
9426                    }
9427                    let trimmed = s.trim();
9428                    if !lexical_matches_type(trimmed, name) {
9429                        // A lexically-valid value whose year exceeds the
9430                        // representable range is an overflow (FODT0001),
9431                        // not a malformed lexical form (FORG0001).
9432                        if date_year_out_of_range(trimmed, name) {
9433                            return Err(xpath_err(format!(
9434                                "cast to xs:{name}: year is outside the \
9435                                 supported range: {trimmed:?}"
9436                            )).with_xpath_code("FODT0001"));
9437                        }
9438                        return Err(xpath_err(format!(
9439                            "cast to xs:{name} failed: {trimmed:?}"
9440                        )));
9441                    }
9442                    // XSD §3.2.7/§3.2.8 — the `24:00:00` midnight form is
9443                    // valid but non-canonical; store the normalised value
9444                    // (`00:00:00`, with the day rolled for dateTime) so the
9445                    // typed value's string and component accessors agree.
9446                    // Other lexical forms are preserved verbatim.
9447                    if matches!(name.as_str(), "dateTime" | "time")
9448                        && trimmed.contains("24:00:00")
9449                    {
9450                        let dk = if name == "dateTime" {
9451                            DateKind::DateTime } else { DateKind::Time };
9452                        if let Some((y, mo, d, h, mi, sec, frac, tz)) =
9453                            parse_xsd_date_time(trimmed, dk)
9454                        {
9455                            let lex = if name == "dateTime" {
9456                                format_datetime_lexical(y, mo, d, h, mi, sec, frac, tz)
9457                            } else {
9458                                let mut l = format!("{h:02}:{mi:02}:{sec:02}");
9459                                if frac != 0 {
9460                                    let mut f = format!(".{frac:06}");
9461                                    while f.ends_with('0') { f.pop(); }
9462                                    l.push_str(&f);
9463                                }
9464                                if let Some(tz_m) = tz { l.push_str(&format_tz_suffix(tz_m)); }
9465                                l
9466                            };
9467                            return Ok(make_typed(kind, lex, None, None));
9468                        }
9469                    }
9470                    // XSD §3.2.15 — xs:hexBinary canonicalises to
9471                    // upper-case hex digits.
9472                    if name == "hexBinary" {
9473                        return Ok(make_typed(kind, trimmed.to_ascii_uppercase(), None, None));
9474                    }
9475                    Ok(make_typed(kind, trimmed.to_string(), None, None))
9476                }
9477                _ => Ok(Value::String(s)),
9478            }
9479        }
9480        // Non-atomic target — caller has already cardinality-checked,
9481        // so just pass the value through.
9482        _ => Ok(v.clone()),
9483    }
9484}
9485
9486/// Which lexical form to expect when parsing the value argument of
9487/// `format-date` / `format-time` / `format-dateTime`.
9488#[derive(Debug, Clone, Copy)]
9489pub enum DateKind { Date, Time, DateTime }
9490
9491/// Decompose an `xs:date`/`xs:time`/`xs:dateTime` lexical form into
9492/// (year, month, day, hour, minute, second, frac_microseconds,
9493/// tz_minutes).  Returns `None` if the input doesn't look like the
9494/// expected shape.  The parser is lenient: `Z` suffix and `±HH:MM`
9495/// offsets are both accepted; fractional seconds are captured as a
9496/// microsecond count (`0..=999999`).
9497fn parse_xsd_date_time(s: &str, kind: DateKind)
9498    -> Option<(i32, u8, u8, u8, u8, u8, u32, Option<i16>)>
9499{
9500    let s = s.trim();
9501    // For pure time, we only fill (h,m,s,frac,tz) — date components 0.
9502    if matches!(kind, DateKind::Time) {
9503        let (mut h, m, sec, frac, tz) = parse_xsd_time(s)?;
9504        // XSD §3.2.8 — `24:00:00` denotes midnight and normalises to
9505        // `00:00:00`.  Without a date there is no day to roll into.
9506        if h == 24 && m == 0 && sec == 0 && frac == 0 { h = 0; }
9507        return Some((0, 0, 0, h, m, sec, frac, tz));
9508    }
9509    // Date / DateTime share the YYYY-MM-DD prefix.  XSD 1.1 §3.3.7
9510    // permits years beyond four digits and negative years (`-0001-…`
9511    // == 1 BCE) — split on the second-to-last `-` to isolate the
9512    // YYYY[…] portion from the MM-DD suffix.
9513    let bytes = s.as_bytes();
9514    if bytes.len() < 10 { return None; }
9515    let (neg_year, body) = match s.strip_prefix('-') {
9516        Some(rest) => (true, rest),
9517        None       => (false, s),
9518    };
9519    // Find positions of `-` in the body: first is YYYY-MM, second
9520    // is MM-DD.  Anything before the first `-` is the year.
9521    let body_bytes = body.as_bytes();
9522    let first_dash = body_bytes.iter().position(|&c| c == b'-')?;
9523    if first_dash < 4 { return None; }
9524    if body_bytes.len() < first_dash + 6 { return None; }
9525    if body_bytes[first_dash + 3] != b'-' { return None; }
9526    let year_str = &body[..first_dash];
9527    let mut year: i32 = year_str.parse().ok()?;
9528    if neg_year { year = -year; }
9529    let month: u8 = std::str::from_utf8(&body_bytes[first_dash + 1..first_dash + 3])
9530        .ok()?.parse().ok()?;
9531    let day: u8 = std::str::from_utf8(&body_bytes[first_dash + 4..first_dash + 6])
9532        .ok()?.parse().ok()?;
9533    // XSD §3.2.7 — month must be 1..12 and the day must be within
9534    // the legal range for the (year, month) pair (Feb 29 only in
9535    // leap years).  An out-of-range value is *not* a valid lexical
9536    // form, so reject here to keep `castable as xs:date` honest.
9537    if !(1..=12).contains(&month) { return None; }
9538    let max_day = days_in_month(year, month as u32);
9539    if !(1..=max_day as u8).contains(&day) { return None; }
9540    let rest = &body[first_dash + 6..];
9541    match kind {
9542        DateKind::Date => {
9543            let tz = parse_tz_suffix(rest);
9544            Some((year, month, day, 0, 0, 0, 0, tz))
9545        }
9546        DateKind::DateTime => {
9547            // `T` separator followed by HH:MM:SS plus optional tz.
9548            if !rest.starts_with('T') || rest.len() < 9 { return None; }
9549            let (h, m, sec, frac, tz) = parse_xsd_time(&rest[1..])?;
9550            // XSD §3.2.7 — `24:00:00` is midnight at the *start of the
9551            // next day*, so it normalises to `00:00:00` with the date
9552            // rolled forward one day.
9553            if h == 24 && m == 0 && sec == 0 && frac == 0 {
9554                let (y, mo, d) = add_one_day(year, month, day);
9555                return Some((y, mo, d, 0, 0, 0, 0, tz));
9556            }
9557            Some((year, month, day, h, m, sec, frac, tz))
9558        }
9559        DateKind::Time => unreachable!(),
9560    }
9561}
9562
9563/// Parse `HH:MM:SS[.frac][tz]` into (h, m, s, frac_microseconds, tz).
9564/// Fractional seconds are truncated or zero-padded to six digits
9565/// so the caller can render any sub-second precision up to a
9566/// microsecond.
9567fn parse_xsd_time(s: &str) -> Option<(u8, u8, u8, u32, Option<i16>)> {
9568    let bytes = s.as_bytes();
9569    if bytes.len() < 8 { return None; }
9570    let h: u8 = std::str::from_utf8(&bytes[..2]).ok()?.parse().ok()?;
9571    if bytes[2] != b':' { return None; }
9572    let m: u8 = std::str::from_utf8(&bytes[3..5]).ok()?.parse().ok()?;
9573    if bytes[5] != b':' { return None; }
9574    let sec: u8 = std::str::from_utf8(&bytes[6..8]).ok()?.parse().ok()?;
9575    let mut rest_start = 8;
9576    let mut frac_us: u32 = 0;
9577    if s.as_bytes().get(8) == Some(&b'.') {
9578        let mut i = 9;
9579        while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
9580        let digits = &s[9..i];
9581        // Right-pad / truncate to exactly six digits of microseconds.
9582        let take: String = digits.chars().chain(std::iter::repeat('0')).take(6).collect();
9583        frac_us = take.parse().ok().unwrap_or(0);
9584        rest_start = i;
9585    }
9586    let tz = parse_tz_suffix(&s[rest_start..]);
9587    Some((h, m, sec, frac_us, tz))
9588}
9589
9590/// Parse a timezone suffix (`Z`, `+HH:MM`, `-HH:MM`, or empty) and
9591/// return the offset in minutes east of UTC.  `None` means no tz
9592/// was present in the input.
9593fn parse_tz_suffix(s: &str) -> Option<i16> {
9594    if s.is_empty() { return None; }
9595    if s == "Z" { return Some(0); }
9596    let bytes = s.as_bytes();
9597    if bytes.len() < 6 { return None; }
9598    let sign = match bytes[0] { b'+' => 1, b'-' => -1, _ => return None };
9599    let hh: i16 = std::str::from_utf8(&bytes[1..3]).ok()?.parse().ok()?;
9600    if bytes[3] != b':' { return None; }
9601    let mm: i16 = std::str::from_utf8(&bytes[4..6]).ok()?.parse().ok()?;
9602    Some(sign * (hh * 60 + mm))
9603}
9604
9605/// Fallback marker prefix for an unsupported `format-date` /
9606/// `format-time` / `format-dateTime` language or calendar (XSLT 2.0
9607/// §16.5).  This engine only renders names in English (`en`) and the
9608/// Gregorian / `AD` calendar; when the call requests anything else the
9609/// processor falls back and signals it with a `[Language: en]` /
9610/// `[Calendar: AD]` prefix on the result, as the reference processors do.
9611fn format_date_locale_prefix(lang: &str, calendar: &str) -> String {
9612    let mut prefix = String::new();
9613    let lang = lang.trim();
9614    if !lang.is_empty() && !lang.eq_ignore_ascii_case("en") {
9615        prefix.push_str("[Language: en]");
9616    }
9617    // A calendar may be given as a QName / `Q{uri}local`; the supported
9618    // set is keyed by the local part.
9619    let cal = calendar.trim().rsplit(['}', ':']).next().unwrap_or("").trim();
9620    if !cal.is_empty()
9621        && !matches!(cal.to_ascii_uppercase().as_str(), "AD" | "ISO" | "CE") {
9622        prefix.push_str("[Calendar: AD]");
9623    }
9624    prefix
9625}
9626
9627/// Best-effort XPath 2.0 `format-date` / `format-time` /
9628/// `format-dateTime` picture-string interpreter.  Handles the
9629/// common `[Y…]`, `[M…]`, `[D…]`, `[H…]`, `[h…]`, `[m…]`, `[s…]`,
9630/// `[f…]`, `[Z]`, `[P]` variable markers; `[[` / `]]` escapes; and
9631/// literal text passthrough.  Locale-sensitive forms (named months,
9632/// alternative calendars) emit the numeric digit form rather than
9633/// failing.
9634fn format_date_time_picture(value: &str, picture: &str, kind: DateKind) -> Result<String> {
9635    let (y, mo, d, h, mi, s, frac, tz) = match parse_xsd_date_time(value, kind) {
9636        Some(t) => t,
9637        None    => return Ok(value.to_string()), // unrecognised input → echo back
9638    };
9639    let mut out = String::with_capacity(picture.len());
9640    let mut chars = picture.chars().peekable();
9641    while let Some(c) = chars.next() {
9642        match c {
9643            '[' => {
9644                // `[[` is a literal `[`.
9645                if chars.peek() == Some(&'[') {
9646                    chars.next();
9647                    out.push('[');
9648                    continue;
9649                }
9650                // Read the variable marker up to the matching `]`.
9651                let mut marker = String::new();
9652                while let Some(&nc) = chars.peek() {
9653                    if nc == ']' { break; }
9654                    marker.push(nc);
9655                    chars.next();
9656                }
9657                // XSLT 2.0 §16.5.1 — every variable marker must close
9658                // with `]`; an unterminated marker is XTDE1340.
9659                if chars.peek() != Some(&']') {
9660                    return Err(xpath_err(format!(
9661                        "format-date/format-time: unterminated picture \
9662                         marker '[{marker}' (XTDE1340)"
9663                    )));
9664                }
9665                chars.next();
9666                let comp = marker.trim().chars().next().unwrap_or('\0');
9667                // XSLT 2.0 §16.5.1 — components not present in the
9668                // value type raise XTDE1350.  Date carries no time
9669                // components; Time carries no Y/M/D.
9670                let kind_mismatch = match (kind, comp) {
9671                    (DateKind::Date, 'H' | 'h' | 'm' | 's' | 'f' | 'P') => true,
9672                    (DateKind::Time, 'Y' | 'M' | 'D' | 'd' | 'F' | 'W' | 'w' | 'C' | 'E') => true,
9673                    _ => false,
9674                };
9675                if kind_mismatch {
9676                    return Err(xpath_err(format!(
9677                        "format-date/format-time: component '{comp}' not \
9678                         available for this date/time value (XTDE1350)"
9679                    )));
9680                }
9681                if !is_known_picture_component(comp) {
9682                    return Err(xpath_err(format!(
9683                        "format-date/format-time: unknown component '{comp}' \
9684                         in picture string (XTDE1340)"
9685                    )));
9686                }
9687                out.push_str(&format_picture_marker(&marker, y, mo, d, h, mi, s, frac, tz));
9688            }
9689            ']' => {
9690                // `]]` is a literal `]`; lone `]` is also accepted
9691                // (lenient parsing — strict XPath would error).
9692                if chars.peek() == Some(&']') {
9693                    chars.next();
9694                    out.push(']');
9695                } else {
9696                    out.push(']');
9697                }
9698            }
9699            other => out.push(other),
9700        }
9701    }
9702    Ok(out)
9703}
9704
9705/// XSLT 2.0 §16.5.1 component letters.  Anything outside this set
9706/// in a picture marker raises XTDE1340.
9707fn is_known_picture_component(c: char) -> bool {
9708    matches!(c, 'Y' | 'M' | 'D' | 'd' | 'F' | 'W' | 'w' | 'H' | 'h'
9709        | 'm' | 's' | 'f' | 'Z' | 'z' | 'P' | 'C' | 'E')
9710}
9711
9712/// XSLT 2.0 § 16.5.1 picture-string component formatter.
9713///
9714/// `marker` is the variable-marker body (text between `[` and `]`)
9715/// shaped as `component [presentation] [, width]`, e.g. `Y0001`,
9716/// `MNn`, `D1`, `MI`, `H01`, `Y,2-2`.  The component letter
9717/// selects which date field; the presentation modifier picks a
9718/// digit pattern (`01`, `001`), a Roman-numeral form (`I` / `i`),
9719/// an alphabetic form (`A` / `a`), or a name form (`N` / `n` /
9720/// `Nn`).  Width / max-width come from the `,` clause.
9721fn format_picture_marker(
9722    marker: &str, y: i32, mo: u8, d: u8, h: u8, mi: u8, s: u8,
9723    frac_us: u32, tz: Option<i16>,
9724) -> String {
9725    let marker = marker.trim();
9726    let comp = match marker.chars().next() { Some(c) => c, None => return String::new() };
9727    let rest = &marker[comp.len_utf8()..];
9728    // Split off the width modifier `,min-max` if present.
9729    let (presentation, width_spec) = match rest.split_once(',') {
9730        Some((p, w)) => (p.trim(), Some(w.trim())),
9731        None         => (rest.trim(), None),
9732    };
9733    let (min_w_raw, max_w_raw) = parse_width_spec(width_spec);
9734    // `parse_width_spec` returns `Some(usize::MAX)` as the `*`
9735    // sentinel.  The fractional-seconds formatter wants the
9736    // sentinel intact so it can distinguish "explicit unbounded"
9737    // from "omitted entirely"; every other formatter only cares
9738    // about a concrete width, so unwrap the sentinel to `None`
9739    // for them.
9740    let unwrap_star = |v: Option<usize>|
9741        v.and_then(|n| if n == usize::MAX { None } else { Some(n) });
9742    let min_w = unwrap_star(min_w_raw);
9743    let max_w = unwrap_star(max_w_raw);
9744    // Per-component default presentation modifier (XSLT 2.0
9745    // §16.5.1 table): Y/M/D/H/h/f default to `"1"` (no padding);
9746    // m/s default to `"01"` (2-digit zero-pad).  Empty presentation
9747    // means "use the default for this component."
9748    let pres = if !presentation.is_empty() { presentation }
9749               else { match comp {
9750                   'm' | 's' => "01",
9751                   _         => "1",
9752               }};
9753
9754    match comp {
9755        // The year component shows the absolute year; the era ([E])
9756        // conveys BC/AD (XSLT 2.0 §16.5.1), so a proleptic year like
9757        // -0055 formats as `55`, not `-55`.
9758        'Y' => format_numeric_component((y as i64).abs(), pres, min_w, max_w, /*truncate_year=*/ true),
9759        'M' if name_presentation(pres) =>
9760            format_named_component(mo as i64, pres, max_w, MONTH_NAMES),
9761        'M' => format_numeric_component(mo as i64, pres, min_w, max_w, false),
9762        'D' => format_numeric_component(d as i64,  pres, min_w, max_w, false),
9763        'H' => format_numeric_component(h as i64,  pres, min_w, max_w, false),
9764        'h' => {
9765            // 12-hour clock — convert 24h to 1..=12.
9766            let hh = if h == 0 { 12 } else if h > 12 { h - 12 } else { h } as i64;
9767            format_numeric_component(hh, pres, min_w, max_w, false)
9768        }
9769        'm' => format_numeric_component(mi as i64, pres, min_w, max_w, false),
9770        's' => format_numeric_component(s as i64,  pres, min_w, max_w, false),
9771        'f' => format_fractional_seconds(frac_us, pres, min_w_raw, max_w_raw),
9772        // Day of the week (XSLT 2.0 §16.5.1 component `F`).
9773        // Presentation modifiers behave like `M`: numeric, Roman,
9774        // alphabetic, or named.  We only carry y/m/d so we
9775        // compute the weekday on the fly.
9776        'F' => {
9777            let weekday = day_of_week(y, mo as u32, d as u32);
9778            if name_presentation(pres) {
9779                format_named_component(weekday as i64, pres, max_w, DAY_NAMES)
9780            } else {
9781                format_numeric_component(weekday as i64, pres, min_w, max_w, false)
9782            }
9783        }
9784        // Week in year — ISO 8601 week number (weeks start Monday;
9785        // week 1 contains the year's first Thursday).
9786        'W' => format_numeric_component(
9787            iso_week_of_year(y, mo as u32, d as u32), pres, min_w, max_w, false),
9788        // Week in month — the week containing this date's Thursday,
9789        // counted from the start of the month (XSLT 2.0 §16.5.1).
9790        'w' => {
9791            let wd = day_of_week(y, mo as u32, d as u32) as i64; // Mon=1..Sun=7
9792            let thursday_dom = d as i64 + 4 - wd;
9793            let wim = (thursday_dom + 6).div_euclid(7);
9794            format_numeric_component(wim, pres, min_w, max_w, false)
9795        }
9796        // Day-of-year — straightforward computation; report
9797        // 1-based ordinal within the year.
9798        'd' => {
9799            let day_of_year = month_start_day(y, mo as u32) + d as u32;
9800            format_numeric_component(day_of_year as i64, pres, min_w, max_w, false)
9801        }
9802        // Era marker (AD/BC).  XSLT 2.0 §16.5.1 reserves `E`.
9803        'E' => {
9804            if y >= 0 { "AD".to_string() } else { "BC".to_string() }
9805        }
9806        // Timezone (XSLT 2.0 §16.5.2).  `Z` outputs the numeric
9807        // offset `±HH:MM` (UTC → `+00:00`); `z` prefixes it with
9808        // `GMT`.  Presentation `0` renders the hours without a leading
9809        // zero; a width whose maximum is ≤ 2 (e.g. `[z,2-2]`)
9810        // suppresses the `:MM` group unless the minutes are non-zero,
9811        // while the default and wider widths always show it.
9812        'Z' | 'z' => match tz {
9813            None      => String::new(),
9814            Some(off) => {
9815                let sign = if off < 0 { '-' } else { '+' };
9816                let abs  = off.unsigned_abs() as i64;
9817                let (hh, mm) = (abs / 60, abs % 60);
9818                let minimal = pres == "0";
9819                let show_min_always = !minimal && max_w.map_or(true, |w| w >= 3);
9820                let mut out = String::new();
9821                if comp == 'z' { out.push_str("GMT"); }
9822                out.push(sign);
9823                if minimal { out.push_str(&hh.to_string()); }
9824                else       { out.push_str(&format!("{hh:02}")); }
9825                if show_min_always || mm != 0 {
9826                    out.push(':');
9827                    out.push_str(&format!("{mm:02}"));
9828                }
9829                out
9830            }
9831        },
9832        'P' => {
9833            // AM/PM marker.  XSLT 2.0 §16.5.1 — the default
9834            // presentation is the lower-case name (`am`/`pm`); only an
9835            // explicit `N` upper-cases it.
9836            let label = if h < 12 { "am" } else { "pm" };
9837            match pres.chars().next() {
9838                Some('n') => label.to_string(),     // explicit lower-case name
9839                Some('N') => label.to_uppercase(),  // upper-case name
9840                _         => label.to_string(),     // default: lower-case (§16.5.1)
9841            }
9842        }
9843        _ => marker.to_string(),
9844    }
9845}
9846
9847/// Format a single numeric date component according to `presentation`.
9848/// `min_w` / `max_w` come from the optional `,min-max` width spec.
9849/// `truncate_year` switches in the XSLT 2.0 special-case: when a
9850/// 2-digit (or N-digit) presentation is applied to the year and the
9851/// year has more digits than the format allows, the low-order
9852/// digits win (`2003` with `Y01` → `03`).  All other components
9853/// reject silently if they overflow the width — we mirror Saxon's
9854/// permissive behaviour and emit the full number anyway.
9855fn format_numeric_component(
9856    n: i64, presentation: &str,
9857    min_w: Option<usize>, max_w: Option<usize>,
9858    truncate_year: bool,
9859) -> String {
9860    // XSLT 2.0 §16.5.1 — the `o` suffix on a numeric presentation
9861    // requests an ordinal rendering ("1st", "2nd", "3rd", "4th").
9862    // The traditional `c` suffix is "cardinal" — same digits, no
9863    // suffix; treat it as a no-op for now.  `t` would be alphabetic
9864    // ordinal (e.g. "first").
9865    let (presentation, ordinal) = if let Some(rest) = presentation.strip_suffix('o') {
9866        (rest, true)
9867    } else if let Some(rest) = presentation.strip_suffix('c') {
9868        (rest, false)
9869    } else {
9870        (presentation, false)
9871    };
9872    // Roman / alphabetic / name / word presentations short-circuit
9873    // the numeric formatter entirely.  XSLT 2.0 §16.5.1 reserves
9874    // `W` / `w` / `Ww` for cardinal-word forms ("ONE" / "one" /
9875    // "One"), with the `o` ordinal-suffix tag opting into ordinal
9876    // words ("FIRST" / "first" / "First").  We implement English
9877    // only — the spec is locale-dependent and English is what the
9878    // W3C test suite exercises for the unprefixed `W` / `w`.
9879    let worded = match presentation {
9880        "I"  => Some(roman_numeral(n, /*upper=*/ true)),
9881        "i"  => Some(roman_numeral(n, /*upper=*/ false)),
9882        "A"  => Some(alpha_label(n, true)),
9883        "a"  => Some(alpha_label(n, false)),
9884        "W"  => Some(english_words(n, ordinal, WordCase::Upper)),
9885        "w"  => Some(english_words(n, ordinal, WordCase::Lower)),
9886        "Ww" => Some(english_words(n, ordinal, WordCase::Title)),
9887        _    => None,
9888    };
9889    if let Some(mut s) = worded {
9890        // XSLT 2.0 §16.5.1 — min-width applies to every presentation,
9891        // but a roman / alphabetic / word form can't be zero-padded,
9892        // so it's space-padded to the minimum width (`[Yi,4-4]` →
9893        // `miv ` for 1004).  max-width never truncates these forms.
9894        if let Some(mw) = min_w {
9895            let len = s.chars().count();
9896            if len < mw { s.extend(std::iter::repeat(' ').take(mw - len)); }
9897        }
9898        return s;
9899    }
9900    // XSLT 2.0 §16.5.1 — a presentation written with a non-ASCII
9901    // Unicode decimal-digit family (e.g. Thai `๐๐๐๑`) renders the
9902    // value in that family; the number of digit characters is the
9903    // mandatory (zero-padded) width.
9904    if let Some((zero_cp, count)) = picture_digit_family(presentation) {
9905        // Year truncation (XSLT 2.0 §16.5.1) applies regardless of the
9906        // digit family: the count of family glyphs is the picture's
9907        // digit width, so `[Y๐๑]` (two Thai zeros) truncates `2003` to
9908        // the low-order two digits just like the ASCII `[Y01]`.
9909        let cap = max_w.unwrap_or(count);
9910        let (value, want) = if truncate_year && n >= 0
9911            && cap < 5 && n.to_string().len() > cap {
9912            (n % 10_i64.pow(cap as u32), cap)
9913        } else {
9914            (n, min_w.unwrap_or(count))
9915        };
9916        let ascii = if value < 0 { format!("-{:0w$}", -value, w = want) }
9917                    else         { format!("{:0w$}", value, w = want) };
9918        return ascii.chars()
9919            .map(|c| c.to_digit(10)
9920                .and_then(|v| char::from_u32(zero_cp + v))
9921                .unwrap_or(c))
9922            .collect();
9923    }
9924    // Numeric pattern: count digits in the presentation to derive
9925    // the minimum width (`01` = 2 digits, `0001` = 4 digits, `1` = 1).
9926    let pres_digits = presentation.chars().filter(|c| c.is_ascii_digit()).count().max(1);
9927    let want_width = min_w.unwrap_or(pres_digits);
9928    // Year truncation (XSLT 2.0 §16.5.1): only fires when the
9929    // caller asked for a bounded width — either an explicit
9930    // `,min-max` clause OR a presentation with more than one digit
9931    // place like `Y01` / `Y0001`.  Bare `[Y]` (presentation just
9932    // `"1"`) means "no padding, no truncation" and must emit the
9933    // full year.
9934    if truncate_year && n >= 0 {
9935        // Year width spec: only force right-truncation when the
9936        // year actually exceeds the declared max; pad to min when
9937        // it falls short; otherwise emit unchanged.  This is what
9938        // Saxon's `[Y,3-4]` does — `985` stays `985`, `19850`
9939        // becomes `9850`, `98` becomes `098`.
9940        let bounded_by_pres = pres_digits >= 2;
9941        if max_w.is_some() || bounded_by_pres {
9942            let cap = max_w.unwrap_or(pres_digits);
9943            let raw = n.to_string();
9944            let digits = raw.len();
9945            if cap < 5 && digits > cap {
9946                let modulus = 10_i64.pow(cap as u32);
9947                let truncated = n % modulus;
9948                return format!("{:0width$}", truncated, width = cap);
9949            }
9950            // Within range — pad to the larger of (min_w, want_width).
9951            let pad = want_width.max(min_w.unwrap_or(0));
9952            return format!("{:0width$}", n, width = pad);
9953        }
9954    }
9955    let formatted = if n < 0 {
9956        format!("-{:0width$}", -n, width = want_width)
9957    } else {
9958        format!("{:0width$}", n, width = want_width)
9959    };
9960    if ordinal {
9961        // English ordinal suffix.  XSLT 2.0 leaves the suffix
9962        // language-dependent; we only implement English because
9963        // the test suite only exercises that locale.
9964        format!("{formatted}{}", english_ordinal_suffix(n))
9965    } else {
9966        formatted
9967    }
9968}
9969
9970/// English ordinal suffix for a non-negative integer — `1` → `st`,
9971/// `2` → `nd`, `3` → `rd`, etc.  The teens are all `th` (11th, 12th,
9972/// 13th).
9973fn english_ordinal_suffix(n: i64) -> &'static str {
9974    let abs = n.unsigned_abs();
9975    if (11..=13).contains(&(abs % 100)) {
9976        return "th";
9977    }
9978    match abs % 10 {
9979        1 => "st",
9980        2 => "nd",
9981        3 => "rd",
9982        _ => "th",
9983    }
9984}
9985
9986/// Format the fractional-seconds component (`[f…]`).  Unlike the
9987/// other numeric components, `f` reads from the *left*: a marker
9988/// like `[f01]` asks for two digits of precision counting from the
9989/// most-significant fractional digit, so `.456` → `46`.  When the
9990/// stored precision exceeds the requested width we round half-up
9991/// (XSLT 2.0 §16.5.1 leaves rounding to the implementation but
9992/// Saxon and libxslt both round; tests expect rounding too).
9993fn format_fractional_seconds(
9994    frac_us: u32, presentation: &str,
9995    min_w: Option<usize>, max_w: Option<usize>,
9996) -> String {
9997    // XSLT 2.0 §16.5.1 — the fractional-second formatter has two
9998    // width inputs:
9999    //
10000    //   * `min_w` / `max_w` from the `,min-max` clause of the
10001    //     picture marker (`*` means unbounded; missing means equal
10002    //     to the other or to the digit-count from the presentation).
10003    //   * The number of digits in the presentation modifier
10004    //     (`[f001]` → 3, `[f,2-5]` → 1 default).
10005    //
10006    // Algorithm:
10007    //   1. Round half-up to `max_w` digits (or, when max is
10008    //      unbounded, the microsecond precision we store — 6).
10009    //   2. Pad with trailing zeros to `max_w` so the rounded
10010    //      digits are right-justified across the maximum field.
10011    //   3. Strip trailing zeros down to `min_w` (no shorter).
10012    //
10013    // Examples for `0.456` (= 456000 µs):
10014    //   `[f,4-4]`    → max=4 round→4560      min=4: keep      → "4560"
10015    //   `[f,1-4]`    → max=4 round→4560      min=1: strip 0   → "456"
10016    //   `[f,1-*]`    → max=∞ keep "456"      min=1: keep      → "456"
10017    //   `[f001]`     → pres=3 min=max=3 round→456              → "456"
10018    let pres_digits = presentation.chars().filter(|c| c.is_ascii_digit()).count();
10019    // Default min / max from the picture marker if the width spec
10020    // didn't supply them.  XSLT 2.0 §16.5.1 — when only the
10021    // presentation modifier is present it sets both bounds; when a
10022    // width spec is present the modifier's digit count is the
10023    // default for whichever bound the spec omitted.
10024    // Convert `Some(usize::MAX)` (the `*` sentinel from
10025    // `parse_width_spec`) into `None` for downstream "unbounded"
10026    // checks, keeping the case analysis crisp.
10027    let unwrap_star = |v: Option<usize>| v.and_then(|n| if n == usize::MAX { None } else { Some(n) });
10028    let min_w_explicit = min_w.map(|n| n != usize::MAX).unwrap_or(false);
10029    let max_w_explicit_star = max_w == Some(usize::MAX);
10030    let min_w = unwrap_star(min_w);
10031    let max_w = unwrap_star(max_w);
10032    let (min_w, max_w) = match (min_w, max_w) {
10033        (Some(mn), Some(mx))     => (mn, Some(mx)),
10034        (Some(mn), None) if max_w_explicit_star => (mn, None),
10035        (Some(mn), None)         => (mn, Some(mn.max(pres_digits))),
10036        (None,     Some(mx))     => (pres_digits.min(mx).max(1), Some(mx)),
10037        (None,     None) if pres_digits > 0 => (pres_digits, Some(pres_digits)),
10038        (None,     None) if min_w_explicit => (1, None),
10039        (None,     None)         => (1, None),
10040    };
10041    // Round to `max_w` digits.  `None` max means unbounded — keep
10042    // the full 6-digit microsecond precision (we don't store more).
10043    let target = max_w.unwrap_or(6).min(6);
10044    if target == 0 { return String::new(); }
10045    let divisor = 10u32.pow((6 - target) as u32);
10046    let half    = divisor / 2;
10047    let rounded = (frac_us + half) / divisor;
10048    let max_value = 10u32.pow(target as u32);
10049    let clamped = rounded.min(max_value - 1);
10050    // Right-justified field of `target` digits, then trim trailing
10051    // zeros down to `min_w` (never below).
10052    let padded = format!("{:0width$}", clamped, width = target);
10053    let padded_bytes = padded.as_bytes();
10054    let mut end = padded_bytes.len();
10055    while end > min_w && padded_bytes[end - 1] == b'0' { end -= 1; }
10056    let trimmed = &padded[..end];
10057    // If max was unbounded (no `,` clause and the presentation had
10058    // no digit count) we may need extra zeros beyond µs precision.
10059    // That's rare; for now we cap at 6 — XPath 2.0 only mandates
10060    // implementation-defined precision past that.
10061    trimmed.to_string()
10062}
10063
10064/// Parse the `,min-max` width-spec clause of a picture marker.
10065/// `min` and `max` may both be `*` meaning "unbounded".  Returns
10066/// `(min_width, max_width)` — either may be `None`.
10067fn parse_width_spec(spec: Option<&str>) -> (Option<usize>, Option<usize>) {
10068    // XSLT 2.0 §16.5.1 — `min-max` with `*` meaning unbounded.
10069    // We encode "explicit unbounded" as `Some(usize::MAX)` so the
10070    // caller can distinguish it from "field omitted entirely"
10071    // (`None`).  Picture-marker formatters then handle MAX as
10072    // "no upper cap on width."
10073    let spec = match spec { Some(s) => s, None => return (None, None) };
10074    let (min_s, max_s) = match spec.split_once('-') {
10075        Some((a, b)) => (a, Some(b)),
10076        None         => (spec, None),
10077    };
10078    let parse = |s: &str| -> Option<usize> {
10079        let s = s.trim();
10080        if s.is_empty()  { None }
10081        else if s == "*" { Some(usize::MAX) }
10082        else             { s.parse().ok() }
10083    };
10084    (parse(min_s), max_s.and_then(parse))
10085}
10086
10087/// RFC 3986 §5.3 reference resolution.  Combine `base` (treated as
10088/// already absolute) and `rel` (a possibly-relative URI reference)
10089/// into an absolute URI string.  Implements the strict form: a
10090/// `rel` with its own scheme returns essentially unchanged (with
10091/// dot-segments removed), matching `fn:resolve-uri`'s spec.
10092pub fn resolve_uri_against(base: &str, rel: &str) -> String {
10093    resolve_uri_rfc3986(base, rel)
10094}
10095
10096
10097/// True iff `uri` carries a scheme (the `scheme:` prefix of RFC 3986
10098/// §3.1) — i.e. it is an absolute URI rather than a relative reference.
10099fn uri_has_scheme(uri: &str) -> bool {
10100    split_uri(uri).0.is_some()
10101}
10102
10103fn resolve_uri_rfc3986(base: &str, rel: &str) -> String {
10104    let (rs, ra, rp, rq, rf) = split_uri(rel);
10105    let (bs, ba, bp, bq, _bf) = split_uri(base);
10106    // Target components per RFC 3986 §5.3.
10107    let (ts, ta, tp, tq) = if let Some(s) = rs {
10108        (s, ra, remove_dot_segments(rp), rq)
10109    } else if ra.is_some() {
10110        (bs.unwrap_or(""), ra, remove_dot_segments(rp), rq)
10111    } else if rp.is_empty() {
10112        let q = if rq.is_some() { rq } else { bq };
10113        (bs.unwrap_or(""), ba, bp.to_string(), q)
10114    } else if rp.starts_with('/') {
10115        (bs.unwrap_or(""), ba, remove_dot_segments(rp), rq)
10116    } else {
10117        let merged = merge_paths(ba.is_some(), bp, rp);
10118        (bs.unwrap_or(""), ba, remove_dot_segments(&merged), rq)
10119    };
10120    // Recompose.
10121    let mut out = String::new();
10122    if !ts.is_empty() {
10123        out.push_str(ts);
10124        out.push(':');
10125    }
10126    if let Some(a) = ta {
10127        out.push_str("//");
10128        out.push_str(a);
10129    }
10130    out.push_str(&tp);
10131    if let Some(q) = tq {
10132        out.push('?');
10133        out.push_str(q);
10134    }
10135    if let Some(f) = rf {
10136        out.push('#');
10137        out.push_str(f);
10138    }
10139    out
10140}
10141
10142/// Parse a URI string into `(scheme, authority, path, query, fragment)`.
10143/// Scheme is `Some` only when a valid scheme prefix is detected
10144/// (alpha + alnum/`+-.` + colon).  Authority is `Some` only when the
10145/// path starts with `//`.  Path is always returned (possibly empty);
10146/// query / fragment are `Some` only when present.
10147fn split_uri(s: &str) -> (Option<&str>, Option<&str>, &str, Option<&str>, Option<&str>) {
10148    let bytes = s.as_bytes();
10149    // Scheme: must start with ALPHA, then alnum/+/-/. up to ':'.
10150    let scheme_end = bytes.iter().position(|&b|
10151        !(b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.'));
10152    let (scheme, mut rest) = match scheme_end {
10153        Some(i) if i > 0 && bytes[i] == b':' && bytes[0].is_ascii_alphabetic() =>
10154            (Some(&s[..i]), &s[i + 1..]),
10155        _ => (None, s),
10156    };
10157    // Fragment.
10158    let fragment = rest.find('#').map(|i| {
10159        let f = &rest[i + 1..];
10160        rest = &rest[..i];
10161        f
10162    });
10163    // Query.
10164    let query = rest.find('?').map(|i| {
10165        let q = &rest[i + 1..];
10166        rest = &rest[..i];
10167        q
10168    });
10169    // Authority.
10170    let (authority, path) = if let Some(after) = rest.strip_prefix("//") {
10171        let end = after.find(|c: char| c == '/' || c == '?' || c == '#')
10172            .unwrap_or(after.len());
10173        (Some(&after[..end]), &after[end..])
10174    } else {
10175        (None, rest)
10176    };
10177    (scheme, authority, path, query, fragment)
10178}
10179
10180fn remove_dot_segments(input: &str) -> String {
10181    // RFC 3986 §5.2.4.
10182    let mut in_buf = input.to_string();
10183    let mut out = String::with_capacity(input.len());
10184    while !in_buf.is_empty() {
10185        if in_buf.starts_with("../") {
10186            in_buf.replace_range(..3, "");
10187        } else if in_buf.starts_with("./") {
10188            in_buf.replace_range(..2, "");
10189        } else if in_buf.starts_with("/./") {
10190            in_buf.replace_range(..3, "/");
10191        } else if in_buf == "/." {
10192            in_buf = "/".to_string();
10193        } else if in_buf.starts_with("/../") {
10194            in_buf.replace_range(..4, "/");
10195            // Remove last segment in `out` (back to but not including
10196            // the previous '/', or empty out if none).
10197            if let Some(i) = out.rfind('/') { out.truncate(i); } else { out.clear(); }
10198        } else if in_buf == "/.." {
10199            in_buf = "/".to_string();
10200            if let Some(i) = out.rfind('/') { out.truncate(i); } else { out.clear(); }
10201        } else if in_buf == "." || in_buf == ".." {
10202            in_buf.clear();
10203        } else {
10204            // Move first segment (up to but not including the next '/')
10205            // from `in_buf` to `out`.
10206            let split = if let Some(stripped) = in_buf.strip_prefix('/') {
10207                stripped.find('/').map(|i| i + 1).unwrap_or(in_buf.len())
10208            } else {
10209                in_buf.find('/').unwrap_or(in_buf.len())
10210            };
10211            out.push_str(&in_buf[..split]);
10212            in_buf.replace_range(..split, "");
10213        }
10214    }
10215    out
10216}
10217
10218fn merge_paths(base_has_authority: bool, base_path: &str, rel_path: &str) -> String {
10219    // RFC 3986 §5.2.3.
10220    if base_has_authority && base_path.is_empty() {
10221        let mut s = String::with_capacity(rel_path.len() + 1);
10222        s.push('/');
10223        s.push_str(rel_path);
10224        s
10225    } else {
10226        let cut = base_path.rfind('/').map(|i| i + 1).unwrap_or(0);
10227        let mut s = String::with_capacity(cut + rel_path.len());
10228        s.push_str(&base_path[..cut]);
10229        s.push_str(rel_path);
10230        s
10231    }
10232}
10233
10234/// Implement `fn:adjust-*-to-timezone`.  Re-stamp `lex` (a
10235/// `date` / `dateTime` / `time` lexical) to the supplied
10236/// `new_tz_minutes` offset (`None` strips the timezone, `Some(n)`
10237/// stamps `n`).  If the input already has a timezone, the wall-
10238/// clock parts are first shifted to keep the absolute moment
10239/// constant; if the input is timezone-less, the wall-clock parts
10240/// are kept and the new offset stamped.
10241fn adjust_timezone(lex: &str, kind: &str, new_tz_minutes: Option<i16>) -> String {
10242    let dk = match kind {
10243        "date"     => DateKind::Date,
10244        "dateTime" => DateKind::DateTime,
10245        "time"     => DateKind::Time,
10246        _ => return lex.to_string(),
10247    };
10248    let Some((y, mo, d, mut h, mut mi, s, frac, tz)) = parse_xsd_date_time(lex, dk) else {
10249        return lex.to_string();
10250    };
10251    // Convert (year, month, day, h, mi) to a single integer
10252    // "minutes since 0000-01-01 (proleptic)" so we can do
10253    // arithmetic regardless of DST and rollover.
10254    let (mut yy, mut mm, mut dd) = (y as i64, mo as i64, d as i64);
10255    let needs_shift = match (tz, new_tz_minutes) {
10256        (Some(_), Some(_)) => true,   // both present — shift wall-clock
10257        _ => false,                   // either side absent — keep wall-clock
10258    };
10259    if needs_shift {
10260        let old_tz = tz.unwrap() as i64;
10261        let new_tz = new_tz_minutes.unwrap() as i64;
10262        let delta_minutes = new_tz - old_tz;
10263        if matches!(dk, DateKind::Time) {
10264            // Pure time wraps modulo 1440 minutes per spec.
10265            let total = (h as i64) * 60 + (mi as i64) + delta_minutes;
10266            let wrapped = ((total % 1440) + 1440) % 1440;
10267            h  = (wrapped / 60) as u8;
10268            mi = (wrapped % 60) as u8;
10269        } else {
10270            let days = ymd_to_days(yy as i32, mm as u32, dd as u32);
10271            let total_min = days * 24 * 60 + (h as i64) * 60 + (mi as i64) + delta_minutes;
10272            let new_days = total_min.div_euclid(24 * 60);
10273            let leftover = total_min.rem_euclid(24 * 60);
10274            h  = (leftover / 60) as u8;
10275            mi = (leftover % 60) as u8;
10276            let (ny, nm, nd) = days_to_ymd(new_days);
10277            yy = ny as i64; mm = nm as i64; dd = nd as i64;
10278        }
10279    }
10280    let tz_str = match new_tz_minutes {
10281        None      => String::new(),
10282        Some(0)   => "Z".to_string(),
10283        Some(m)   => format!("{}{:02}:{:02}",
10284            if m < 0 { '-' } else { '+' },
10285            (m.unsigned_abs() / 60), (m.unsigned_abs() % 60)),
10286    };
10287    match dk {
10288        DateKind::Time => {
10289            let mut s_out = format!("{:02}:{:02}:{:02}", h, mi, s);
10290            if frac > 0 {
10291                // Trim trailing zeros from the microseconds field.
10292                let mut frac_s = format!("{:06}", frac);
10293                while frac_s.ends_with('0') { frac_s.pop(); }
10294                if !frac_s.is_empty() { s_out.push('.'); s_out.push_str(&frac_s); }
10295            }
10296            s_out.push_str(&tz_str);
10297            s_out
10298        }
10299        DateKind::Date => {
10300            let sign = if yy < 0 { "-" } else { "" };
10301            format!("{}{:04}-{:02}-{:02}{}", sign, yy.unsigned_abs(), mm, dd, tz_str)
10302        }
10303        DateKind::DateTime => {
10304            let sign = if yy < 0 { "-" } else { "" };
10305            let mut s_out = format!("{}{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
10306                sign, yy.unsigned_abs(), mm, dd, h, mi, s);
10307            if frac > 0 {
10308                let mut frac_s = format!("{:06}", frac);
10309                while frac_s.ends_with('0') { frac_s.pop(); }
10310                if !frac_s.is_empty() { s_out.push('.'); s_out.push_str(&frac_s); }
10311            }
10312            s_out.push_str(&tz_str);
10313            s_out
10314        }
10315    }
10316}
10317
10318/// XPath 3.0 §15.1.9 `fn:path` — render a node-locating path.
10319/// The result is interpretable as an XPath that selects `node`
10320/// from the document root: each step uses an `Q{uri}local[pos]`
10321/// (or attribute / kind-test) segment.  A pure document node
10322/// yields `/`.  Orphan subtrees (no document ancestor) anchor
10323/// at `Q{}root()`.
10324fn node_path_string<I: DocIndexLike>(node: NodeId, idx: &I) -> String {
10325    use crate::xpath::XPathNodeKind;
10326    // Document → "/" — the empty path.
10327    if matches!(idx.kind(node), XPathNodeKind::Document) {
10328        return "/".to_string();
10329    }
10330    // Walk up to the root, pushing per-step segments.
10331    let mut segments: Vec<String> = Vec::new();
10332    let mut cur = node;
10333    loop {
10334        let parent = idx.parent(cur);
10335        let segment = match idx.kind(cur) {
10336            XPathNodeKind::Element => {
10337                let local = idx.local_name(cur).to_string();
10338                let uri   = idx.namespace_uri(cur).to_string();
10339                let pos = match parent {
10340                    Some(p) => element_index_among_siblings(p, cur, &local, &uri, idx),
10341                    None    => 1,
10342                };
10343                format!("Q{{{uri}}}{local}[{pos}]")
10344            }
10345            XPathNodeKind::Attribute => {
10346                let local = idx.local_name(cur).to_string();
10347                let uri   = idx.namespace_uri(cur).to_string();
10348                format!("@Q{{{uri}}}{local}")
10349            }
10350            XPathNodeKind::Text | XPathNodeKind::CData => {
10351                let pos = match parent {
10352                    Some(p) => kind_index_among_siblings(p, cur, XPathNodeKind::Text, idx),
10353                    None    => 1,
10354                };
10355                format!("text()[{pos}]")
10356            }
10357            XPathNodeKind::Comment => {
10358                let pos = match parent {
10359                    Some(p) => kind_index_among_siblings(p, cur, XPathNodeKind::Comment, idx),
10360                    None    => 1,
10361                };
10362                format!("comment()[{pos}]")
10363            }
10364            XPathNodeKind::PI => {
10365                let target = idx.pi_target(cur).to_string();
10366                let pos = match parent {
10367                    Some(p) => pi_index_among_siblings(p, cur, &target, idx),
10368                    None    => 1,
10369                };
10370                format!("processing-instruction({target})[{pos}]")
10371            }
10372            XPathNodeKind::Namespace => {
10373                let prefix = idx.local_name(cur);
10374                format!("namespace::{prefix}")
10375            }
10376            XPathNodeKind::Document => break,
10377        };
10378        segments.push(segment);
10379        match parent {
10380            Some(p) if matches!(idx.kind(p), XPathNodeKind::Document) => break,
10381            Some(p) => { cur = p; }
10382            None    => {
10383                // Orphan subtree — anchor at the spec's `Q{}root()`
10384                // placeholder so the path is syntactically still
10385                // an absolute XPath.
10386                segments.push("Q{}root()".to_string());
10387                return segments.into_iter().rev().collect::<Vec<_>>().join("/");
10388            }
10389        }
10390    }
10391    // Prepend "" so the join produces a leading "/".
10392    let mut parts: Vec<String> = Vec::with_capacity(segments.len() + 1);
10393    parts.push(String::new());
10394    parts.extend(segments.into_iter().rev());
10395    parts.join("/")
10396}
10397
10398/// Position of `child` among its sibling element children of
10399/// `parent` that share the same expanded name (1-based).
10400fn element_index_among_siblings<I: DocIndexLike>(
10401    parent: NodeId, child: NodeId, local: &str, uri: &str, idx: &I,
10402) -> usize {
10403    let mut count = 0usize;
10404    for &sib in idx.children(parent) {
10405        if matches!(idx.kind(sib), crate::xpath::XPathNodeKind::Element)
10406            && idx.local_name(sib) == local
10407            && idx.namespace_uri(sib) == uri
10408        {
10409            count += 1;
10410            if sib == child { return count; }
10411        }
10412    }
10413    count
10414}
10415
10416/// Position of `child` among its sibling children of `parent`
10417/// that have node kind `kind` (1-based).  Used for `text()` and
10418/// `comment()` path segments.
10419fn kind_index_among_siblings<I: DocIndexLike>(
10420    parent: NodeId, child: NodeId,
10421    kind: crate::xpath::XPathNodeKind, idx: &I,
10422) -> usize {
10423    let mut count = 0usize;
10424    for &sib in idx.children(parent) {
10425        if idx.kind(sib) == kind {
10426            count += 1;
10427            if sib == child { return count; }
10428        }
10429    }
10430    count
10431}
10432
10433/// Position of `child` among its sibling PI children with
10434/// matching target (1-based).
10435fn pi_index_among_siblings<I: DocIndexLike>(
10436    parent: NodeId, child: NodeId, target: &str, idx: &I,
10437) -> usize {
10438    let mut count = 0usize;
10439    for &sib in idx.children(parent) {
10440        if matches!(idx.kind(sib), crate::xpath::XPathNodeKind::PI)
10441            && idx.pi_target(sib) == target
10442        {
10443            count += 1;
10444            if sib == child { return count; }
10445        }
10446    }
10447    count
10448}
10449
10450/// XPath 2.0 §15.3.1 `fn:deep-equal` — recursive value
10451/// equality.  Two `Value`s are deep-equal iff:
10452///
10453/// * Same length when atomised to a sequence of items.
10454/// * Pairwise items are deep-equal.  Atomic items use the same
10455///   `values_eq` semantics as the `=` general comparison;
10456///   nodes use kind-specific structural recursion.
10457fn deep_equal_values<I: DocIndexLike>(
10458    a: &Value, b: &Value, idx: &I, bindings: &dyn XPathBindings,
10459) -> bool {
10460    let a_items = items_of(a);
10461    let b_items = items_of(b);
10462    if a_items.len() != b_items.len() { return false; }
10463    a_items.iter().zip(b_items.iter()).all(|(x, y)|
10464        deep_equal_one(x, y, idx, bindings))
10465}
10466
10467/// Decompose a `Value` into the per-item view that `deep-equal`
10468/// iterates over.  NodeSets become one `NodeSet(vec![id])` per
10469/// node so each pair compares as a single node.
10470/// Collapse a list of items back into a single `Value` (empty → empty
10471/// node-set, one → itself, many → a `Sequence`).
10472fn seq_from_items(mut items: Vec<Value>) -> Value {
10473    match items.len() {
10474        0 => Value::NodeSet(Vec::new()),
10475        1 => items.pop().unwrap(),
10476        _ => Value::Sequence(items),
10477    }
10478}
10479
10480/// Numeric view of a map key, if it is a numeric atomic.  `None` for
10481/// non-numeric keys (so a string key never equals a numeric one).
10482fn key_number(v: &Value) -> Option<f64> {
10483    match v {
10484        Value::Number(n) => Some(n.as_f64()),
10485        Value::Typed(t)  => t.numeric,
10486        _ => None,
10487    }
10488}
10489
10490/// XPath 3.1 §17.1 map-key equality: numeric keys compare numerically
10491/// (`1` = `1.0`), with `NaN` equal to itself; otherwise compare by
10492/// string value (covering string / untyped / anyURI / boolean keys).
10493pub fn map_key_eq<I: DocIndexLike>(a: &Value, b: &Value, idx: &I) -> bool {
10494    match (key_number(a), key_number(b)) {
10495        (Some(x), Some(y)) => (x.is_nan() && y.is_nan()) || x == y,
10496        (None, None)       => value_to_string(a, idx) == value_to_string(b, idx),
10497        _                  => false,
10498    }
10499}
10500
10501/// Reduce a value to the single atomic value usable as a map key
10502/// (XPTY0004 if not a singleton atomic; lenient — first item, nodes
10503/// contribute their string value).
10504pub fn first_atomic_key<I: DocIndexLike>(v: &Value, idx: &I) -> Value {
10505    match v {
10506        Value::NodeSet(ns) => Value::String(
10507            ns.first().map(|&id| idx.string_value(id)).unwrap_or_default()),
10508        Value::ForeignNodeSet(_) => Value::String(value_to_string(v, idx)),
10509        Value::Sequence(items) => items.first()
10510            .map(|x| first_atomic_key(x, idx))
10511            .unwrap_or(Value::String(String::new())),
10512        Value::IntRange { lo, .. } => Value::Number(Numeric::Integer(*lo)),
10513        other => other.clone(),
10514    }
10515}
10516
10517/// Evaluate an XPath 3.1 lookup (`base ? key`) — index into each map
10518/// or array item of `base`, concatenating the selected values.
10519fn eval_lookup<I: DocIndexLike>(
10520    base: &Value, key: &crate::xpath::ast::LookupKey,
10521    ctx: &EvalCtx<'_>, idx: &I,
10522) -> Result<Value> {
10523    use crate::xpath::ast::LookupKey;
10524    // `None` selects everything (`?*`); otherwise the resolved keys.
10525    let keys: Option<Vec<Value>> = match key {
10526        LookupKey::Wildcard   => None,
10527        LookupKey::Name(s)    => Some(vec![Value::String(s.clone())]),
10528        LookupKey::Integer(i) => Some(vec![Value::Number(Numeric::Integer(*i))]),
10529        LookupKey::Expr(e)    => Some(items_of(&eval_expr(e, ctx, idx)?)),
10530    };
10531    let mut out: Vec<Value> = Vec::new();
10532    for item in items_of(base) {
10533        match &item {
10534            Value::Map(entries) => match &keys {
10535                None => for (_, val) in entries.iter() { out.extend(items_of(val)); },
10536                Some(ks) => for k in ks {
10537                    for (mk, mv) in entries.iter() {
10538                        if map_key_eq(mk, k, idx) { out.extend(items_of(mv)); }
10539                    }
10540                },
10541            },
10542            Value::Array(members) => match &keys {
10543                None => for m in members.iter() { out.extend(items_of(m)); },
10544                Some(ks) => for k in ks {
10545                    let pos = value_to_number(k, idx);
10546                    if pos.fract() == 0.0 && pos >= 1.0
10547                        && (pos as usize) <= members.len()
10548                    {
10549                        out.extend(items_of(&members[pos as usize - 1]));
10550                    }
10551                },
10552            },
10553            _ => return Err(xpath_err(
10554                "the '?' lookup operator requires a map or array (XPTY0004)")),
10555        }
10556    }
10557    Ok(seq_from_items(out))
10558}
10559
10560fn items_of(v: &Value) -> Vec<Value> {
10561    match v {
10562        Value::NodeSet(ns) => ns.iter()
10563            .map(|&id| Value::NodeSet(vec![id])).collect(),
10564        Value::Sequence(items) => items.clone(),
10565        Value::ForeignNodeSet(ns) => ns.iter()
10566            .map(|&p| Value::ForeignNodeSet(vec![p])).collect(),
10567        Value::IntRange { lo, hi } =>
10568            (*lo..=*hi).map(|i| Value::Number(Numeric::Double(i as f64))).collect(),
10569        other => vec![other.clone()],
10570    }
10571}
10572
10573/// Cardinality of a value as a sequence — the cheap analogue of
10574/// `items_of(v).len()` that doesn't materialise [`Value::IntRange`].
10575/// Used by `fn:count`, `fn:last`, the `position()/last()` runtime,
10576/// and any other consumer that only needs the item count.  Recurses
10577/// through nested `Sequence` items so an inline `IntRange` fragment
10578/// contributes its full cardinality (not 1).
10579fn sequence_len(v: &Value) -> usize {
10580    match v {
10581        Value::NodeSet(ns)        => ns.len(),
10582        Value::ForeignNodeSet(ns) => ns.len(),
10583        Value::Sequence(items)    => items.iter().map(sequence_len).sum(),
10584        Value::IntRange { lo, hi } => ((hi - lo) as usize).saturating_add(1),
10585        _                          => 1,
10586    }
10587}
10588
10589/// Iterate a value as a sequence of items without materialising
10590/// [`Value::IntRange`].  Lets `xsl:for-each`, predicates, and
10591/// simple-map produce items pull-style so a 1.1M-item range
10592/// doesn't allocate 1.1M `Value`s up front.  Recurses through
10593/// nested `Sequence` items so a `Sequence` that carries inline
10594/// `IntRange` fragments flattens transparently.
10595fn iter_items<'a>(v: &'a Value) -> Box<dyn Iterator<Item = Value> + 'a> {
10596    match v {
10597        Value::NodeSet(ns)        => Box::new(ns.iter().map(|&id| Value::NodeSet(vec![id]))),
10598        Value::ForeignNodeSet(ns) => Box::new(ns.iter().map(|&p| Value::ForeignNodeSet(vec![p]))),
10599        Value::Sequence(items)    => Box::new(items.iter().flat_map(iter_items)),
10600        Value::IntRange { lo, hi } => Box::new((*lo..=*hi).map(|i| Value::Number(Numeric::Double(i as f64)))),
10601        other                      => Box::new(std::iter::once(other.clone())),
10602    }
10603}
10604
10605fn deep_equal_one<I: DocIndexLike>(
10606    a: &Value, b: &Value, idx: &I, bindings: &dyn XPathBindings,
10607) -> bool {
10608    match (a, b) {
10609        (Value::NodeSet(an), Value::NodeSet(bn))
10610            if an.len() == 1 && bn.len() == 1 =>
10611        {
10612            deep_equal_node(an[0], bn[0], idx)
10613        }
10614        // Text-node-vs-atomic: many path expressions
10615        // (`/x/y/string()`, `/x/y/concat(.,'')`) flow atomic results
10616        // through the path machinery wrapped as synthetic RTF text
10617        // nodes; a strict node-vs-atomic rejection would then fail
10618        // deep-equal against atomic sequences that any reasonable
10619        // stylesheet would expect to match.  Compare by string value
10620        // when the node side is a single text node.
10621        (Value::NodeSet(ns), other) | (other, Value::NodeSet(ns))
10622            if ns.len() == 1 && matches!(idx.kind(ns[0]), XPathNodeKind::Text) =>
10623        {
10624            value_to_string_with(other, idx, bindings) == idx.string_value(ns[0])
10625        }
10626        // Mixed node / non-node items are NEVER deep-equal per the
10627        // spec (FOTY0004 doesn't apply because we don't surface
10628        // type errors; falsiness is good enough).
10629        (Value::NodeSet(_), _) | (_, Value::NodeSet(_)) => false,
10630        // Atomic comparisons use value-comparison (eq) semantics
10631        // per XPath 2.0 §15.3.1: a type error between the operands
10632        // means "not deep-equal", not the general-comparison
10633        // lenient fallback.  Reject pairs whose XSD type families
10634        // can't `eq` each other (numeric vs string, boolean vs
10635        // anything else, etc.).
10636        _ if !deep_equal_types_comparable(a, b) => false,
10637        _ => values_eq(a, b, idx, bindings),
10638    }
10639}
10640
10641/// True iff the two atomic values can be compared by XPath 2.0
10642/// `eq` without raising a type error.  Used by [`deep_equal_one`]
10643/// to treat un-comparable pairs as "not deep-equal".
10644fn deep_equal_types_comparable(a: &Value, b: &Value) -> bool {
10645    #[derive(PartialEq, Eq, Clone, Copy)]
10646    enum Family { Numeric, String, Boolean, Date, Duration, Other }
10647    fn family_of(v: &Value) -> Family {
10648        match v {
10649            Value::Number(_) => Family::Numeric,
10650            Value::Boolean(_) => Family::Boolean,
10651            Value::String(_) => Family::String,
10652            Value::Typed(t) => match t.kind {
10653                "string" | "anyURI" | "untypedAtomic" | "normalizedString"
10654                | "token" | "Name" | "NCName" | "QName"
10655                | "language" | "NMTOKEN" | "ID" | "IDREF" | "ENTITY"
10656                    => Family::String,
10657                "boolean" => Family::Boolean,
10658                "date" | "dateTime" | "time"
10659                | "gYear" | "gYearMonth" | "gMonth" | "gMonthDay" | "gDay"
10660                    => Family::Date,
10661                "duration" | "dayTimeDuration" | "yearMonthDuration"
10662                    => Family::Duration,
10663                k if matches!(k,
10664                    "integer" | "decimal" | "double" | "float"
10665                    | "long" | "int" | "short" | "byte"
10666                    | "unsignedLong" | "unsignedInt" | "unsignedShort" | "unsignedByte"
10667                    | "nonNegativeInteger" | "nonPositiveInteger"
10668                    | "positiveInteger" | "negativeInteger" | "numeric"
10669                )   => Family::Numeric,
10670                _ => Family::Other,
10671            },
10672            _ => Family::Other,
10673        }
10674    }
10675    let fa = family_of(a);
10676    let fb = family_of(b);
10677    // Untyped atomics (Family::Other) are permissive — they can
10678    // compare against anything because their value is undecided.
10679    if fa == Family::Other || fb == Family::Other { return true; }
10680    fa == fb
10681}
10682
10683fn deep_equal_node<I: DocIndexLike>(a: NodeId, b: NodeId, idx: &I) -> bool {
10684    if idx.kind(a) != idx.kind(b) { return false; }
10685    match idx.kind(a) {
10686        XPathNodeKind::Element => {
10687            if idx.namespace_uri(a) != idx.namespace_uri(b) { return false; }
10688            if idx.local_name(a)    != idx.local_name(b)    { return false; }
10689            // Attributes: set-equal (XPath 2.0 §15.3.1 treats
10690            // attribute order as insignificant).
10691            let mut a_attrs: Vec<NodeId> = idx.attr_range(a).collect();
10692            let mut b_attrs: Vec<NodeId> = idx.attr_range(b).collect();
10693            if a_attrs.len() != b_attrs.len() { return false; }
10694            // Sort by (uri, local-name) so the per-attr match
10695            // doesn't depend on declaration order.  We can't use
10696            // raw NodeId because the two nodes live in possibly
10697            // different positions in the index.
10698            a_attrs.sort_by_key(|&id| (idx.namespace_uri(id).to_string(),
10699                                       idx.local_name(id).to_string()));
10700            b_attrs.sort_by_key(|&id| (idx.namespace_uri(id).to_string(),
10701                                       idx.local_name(id).to_string()));
10702            for (&aa, &bb) in a_attrs.iter().zip(b_attrs.iter()) {
10703                if idx.namespace_uri(aa) != idx.namespace_uri(bb) { return false; }
10704                if idx.local_name(aa)    != idx.local_name(bb)    { return false; }
10705                if idx.string_value(aa)  != idx.string_value(bb)  { return false; }
10706            }
10707            // Children: filter out whitespace-only text nodes
10708            // (XPath 2.0 actually keeps them, but typed strip-
10709            // space is implementation-defined; we mirror Saxon).
10710            // Compare positionally.
10711            let a_kids = idx.children(a);
10712            let b_kids = idx.children(b);
10713            if a_kids.len() != b_kids.len() { return false; }
10714            a_kids.iter().zip(b_kids.iter())
10715                .all(|(&x, &y)| deep_equal_node(x, y, idx))
10716        }
10717        XPathNodeKind::Document => {
10718            let a_kids = idx.children(a);
10719            let b_kids = idx.children(b);
10720            if a_kids.len() != b_kids.len() { return false; }
10721            a_kids.iter().zip(b_kids.iter())
10722                .all(|(&x, &y)| deep_equal_node(x, y, idx))
10723        }
10724        XPathNodeKind::Attribute => {
10725            idx.namespace_uri(a) == idx.namespace_uri(b)
10726                && idx.local_name(a) == idx.local_name(b)
10727                && idx.string_value(a) == idx.string_value(b)
10728        }
10729        XPathNodeKind::Text | XPathNodeKind::CData => {
10730            idx.string_value(a) == idx.string_value(b)
10731        }
10732        XPathNodeKind::Comment => {
10733            idx.string_value(a) == idx.string_value(b)
10734        }
10735        XPathNodeKind::PI => {
10736            idx.pi_target(a) == idx.pi_target(b)
10737                && idx.string_value(a) == idx.string_value(b)
10738        }
10739        XPathNodeKind::Namespace => {
10740            idx.local_name(a) == idx.local_name(b)
10741                && idx.string_value(a) == idx.string_value(b)
10742        }
10743    }
10744}
10745
10746/// English month names — index 1 is January (matches the XSLT
10747/// 1-based month component).  Used by `[MNn]` / `[MN,3-3]`
10748/// markers in `format-date`.
10749const MONTH_NAMES: &[&str] = &[
10750    "", "January", "February", "March", "April", "May", "June",
10751    "July", "August", "September", "October", "November", "December",
10752];
10753
10754/// English day-of-week names — index 1 = Monday per ISO 8601
10755/// (which is what XSLT 2.0 §16.5.1 uses for the `F` component).
10756const DAY_NAMES: &[&str] = &[
10757    "", "Monday", "Tuesday", "Wednesday", "Thursday",
10758    "Friday", "Saturday", "Sunday",
10759];
10760
10761/// True iff the picture-marker presentation modifier asks for a
10762/// named (rather than numeric / Roman / alphabetic) rendering.
10763fn name_presentation(pres: &str) -> bool {
10764    matches!(pres, "N" | "n" | "Nn"
10765                 | "NN" | "nn"     // also accept short forms
10766    )
10767}
10768
10769/// Render a numeric date component using a localized name table
10770/// (English).  `value` is 1-based.  `max_w` truncates the name
10771/// to the given length, with `N`/`n`/`Nn` controlling case.
10772fn format_named_component(value: i64, pres: &str, max_w: Option<usize>, table: &[&str]) -> String {
10773    let idx = value as usize;
10774    let name = table.get(idx).copied().unwrap_or("");
10775    // When the maximum width is shorter than the full name, prefer
10776    // the locale's standard abbreviation (3 letters for English
10777    // months and days, e.g., "Sun" / "Mon", "Jan" / "Feb") over
10778    // raw truncation.  Saxon and libxslt both do this; XSLT 2.0
10779    // §16.5.2 calls it "implementation-defined" but the test suite
10780    // expects the conventional shape.  We pick the longest
10781    // abbreviation that fits, falling back to char truncation if
10782    // even three letters don't fit.
10783    let chosen: String = match max_w {
10784        Some(max) if max < name.chars().count() => {
10785            // English standard 3-letter abbreviations.  We don't
10786            // distinguish month vs day here — both share the
10787            // "first three letters" pattern.
10788            let abbrev3: String = name.chars().take(3).collect();
10789            if max >= 3 { abbrev3 } else { name.chars().take(max).collect() }
10790        }
10791        _ => name.to_string(),
10792    };
10793    match pres {
10794        "N"  => chosen.to_uppercase(),
10795        "n"  => chosen.to_lowercase(),
10796        _    => chosen,
10797    }
10798}
10799
10800/// Day of the week — Monday = 1 through Sunday = 7 (ISO 8601).
10801/// Uses the same Hinnant `days_from_civil` helper, then offsets.
10802/// Zero codepoints of the Unicode decimal-digit families a
10803/// format-date picture may select via its presentation modifier.
10804const DIGIT_FAMILY_ZEROS: &[u32] = &[
10805    0x0660,  // Arabic-Indic
10806    0x06F0,  // Extended Arabic-Indic
10807    0x0966,  // Devanagari
10808    0x09E6,  // Bengali
10809    0x0A66,  // Gurmukhi
10810    0x0AE6,  // Gujarati
10811    0x0B66,  // Oriya
10812    0x0BE6,  // Tamil
10813    0x0C66,  // Telugu
10814    0x0CE6,  // Kannada
10815    0x0D66,  // Malayalam
10816    0x0E50,  // Thai
10817    0x0ED0,  // Lao
10818    0x0F20,  // Tibetan
10819    0x1040,  // Myanmar
10820    0x17E0,  // Khmer
10821    0x1810,  // Mongolian
10822    0xFF10,  // Fullwidth
10823    0x104A0, // Osmanya
10824    0x1D7CE, // Mathematical bold
10825];
10826
10827/// If `pres` is written entirely with the digits of one *non-ASCII*
10828/// decimal-digit family, return that family's zero codepoint and the
10829/// digit count (the mandatory width).  ASCII `0`/`1` patterns return
10830/// `None` so they take the normal formatting path.
10831fn picture_digit_family(pres: &str) -> Option<(u32, usize)> {
10832    if pres.is_empty() || pres.is_ascii() { return None; }
10833    let mut zero = None;
10834    let mut count = 0;
10835    for c in pres.chars() {
10836        let cp = c as u32;
10837        let z = DIGIT_FAMILY_ZEROS.iter().copied()
10838            .find(|&z| cp >= z && cp <= z + 9)?;
10839        match zero {
10840            Some(prev) if prev != z => return None, // mixed families
10841            _ => zero = Some(z),
10842        }
10843        count += 1;
10844    }
10845    zero.map(|z| (z, count))
10846}
10847
10848/// ISO 8601 week-in-year (1..=53).  Weeks start Monday; week 1 is the
10849/// week containing the year's first Thursday.  A January date can
10850/// belong to the last week of the previous year and a late-December
10851/// date to week 1 of the next.
10852fn iso_week_of_year(y: i32, m: u32, d: u32) -> i64 {
10853    let ord = (month_start_day(y, m) + d) as i64; // 1-based day of year
10854    let wd  = day_of_week(y, m, d) as i64;         // Mon=1..Sun=7
10855    let week = (ord - wd + 10).div_euclid(7);
10856    if week < 1 {
10857        iso_weeks_in_year(y - 1)
10858    } else if week > iso_weeks_in_year(y) {
10859        1
10860    } else {
10861        week
10862    }
10863}
10864
10865/// Number of ISO 8601 weeks in year `y` — 53 when 1 Jan is a Thursday
10866/// or (in a leap year) a Wednesday, else 52.
10867fn iso_weeks_in_year(y: i32) -> i64 {
10868    let p = |yy: i32| (yy + yy.div_euclid(4) - yy.div_euclid(100) + yy.div_euclid(400))
10869        .rem_euclid(7);
10870    if p(y) == 4 || p(y - 1) == 3 { 53 } else { 52 }
10871}
10872
10873fn day_of_week(y: i32, m: u32, d: u32) -> u32 {
10874    // ymd_to_days returns days since 1970-01-01 (a Thursday →
10875    // weekday 4 in ISO).  Add the offset and modulo to 7,
10876    // shifting Sunday=0 → 7 to match the spec.
10877    let days = ymd_to_days(y, m, d);
10878    let wd = ((days + 3).rem_euclid(7) as u32) + 1;
10879    wd
10880}
10881
10882/// Number of days from January 1 to the first of `month` in
10883/// year `y`.  Used to compute the day-of-year ordinal for the
10884/// `d` picture component.
10885fn month_start_day(y: i32, m: u32) -> u32 {
10886    let mut total = 0;
10887    let leap = is_leap_year(y);
10888    for mm in 1..m {
10889        total += match mm {
10890            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
10891            4 | 6 | 9 | 11              => 30,
10892            2 => if leap { 29 } else { 28 },
10893            _ => 0,
10894        };
10895    }
10896    total
10897}
10898
10899/// Render `n` as a Roman numeral.  Range is 1..=3999 — outside that
10900/// the spec lets us fall back to decimal.  `upper` picks I/V/X/…
10901/// vs i/v/x/….
10902fn roman_numeral(n: i64, upper: bool) -> String {
10903    if !(1..=3999).contains(&n) { return n.to_string(); }
10904    const PAIRS: &[(i64, &str, &str)] = &[
10905        (1000, "M",  "m"),
10906        (900,  "CM", "cm"),
10907        (500,  "D",  "d"),
10908        (400,  "CD", "cd"),
10909        (100,  "C",  "c"),
10910        (90,   "XC", "xc"),
10911        (50,   "L",  "l"),
10912        (40,   "XL", "xl"),
10913        (10,   "X",  "x"),
10914        (9,    "IX", "ix"),
10915        (5,    "V",  "v"),
10916        (4,    "IV", "iv"),
10917        (1,    "I",  "i"),
10918    ];
10919    let mut n = n;
10920    let mut out = String::new();
10921    for &(val, hi, lo) in PAIRS {
10922        while n >= val {
10923            out.push_str(if upper { hi } else { lo });
10924            n -= val;
10925        }
10926    }
10927    out
10928}
10929
10930/// Render `n` as an alphabetic label (`A`, `B`, …, `Z`, `AA`, `AB`, …).
10931/// `upper` selects uppercase vs lowercase.  Out-of-range falls back
10932/// to decimal.
10933fn alpha_label(n: i64, upper: bool) -> String {
10934    if n < 1 { return n.to_string(); }
10935    let base = if upper { b'A' } else { b'a' };
10936    let mut buf = Vec::new();
10937    let mut n = n;
10938    while n > 0 {
10939        n -= 1;
10940        buf.push(base + (n % 26) as u8);
10941        n /= 26;
10942    }
10943    buf.reverse();
10944    String::from_utf8(buf).unwrap_or_default()
10945}
10946
10947#[derive(Clone, Copy)]
10948pub enum WordCase { Upper, Lower, Title }
10949
10950/// XSLT 2.0 §16.5.1 cardinal / ordinal word presentation for
10951/// numeric format markers.  English-only — the spec is
10952/// locale-dependent and the W3C conformance suite exercises only
10953/// the `'en'` language for the bare `W` / `w` / `Ww` modifiers.
10954/// Negative inputs fall through to the decimal form (rare in
10955/// date/time contexts).
10956pub fn english_words(n: i64, ordinal: bool, case: WordCase) -> String {
10957    if n < 0 {
10958        let s = english_words(-n, ordinal, case);
10959        return format!("MINUS {s}");
10960    }
10961    let words = if ordinal { english_ordinal(n) } else { english_cardinal(n) };
10962    case_transform(&words, case)
10963}
10964
10965pub fn case_transform(s: &str, case: WordCase) -> String {
10966    match case {
10967        WordCase::Upper => s.to_uppercase(),
10968        WordCase::Lower => s.to_lowercase(),
10969        WordCase::Title => {
10970            // Title-case each whitespace- or hyphen-separated word.
10971            let mut out = String::with_capacity(s.len());
10972            let mut start_of_word = true;
10973            for c in s.chars() {
10974                if c.is_whitespace() || c == '-' {
10975                    out.push(c);
10976                    start_of_word = true;
10977                } else if start_of_word {
10978                    for u in c.to_uppercase() { out.push(u); }
10979                    start_of_word = false;
10980                } else {
10981                    for l in c.to_lowercase() { out.push(l); }
10982                }
10983            }
10984            out
10985        }
10986    }
10987}
10988
10989fn english_cardinal(n: i64) -> String {
10990    const UNDER_20: &[&str] = &[
10991        "zero", "one", "two", "three", "four", "five", "six", "seven",
10992        "eight", "nine", "ten", "eleven", "twelve", "thirteen",
10993        "fourteen", "fifteen", "sixteen", "seventeen", "eighteen",
10994        "nineteen",
10995    ];
10996    const TENS: &[&str] = &[
10997        "", "", "twenty", "thirty", "forty", "fifty", "sixty",
10998        "seventy", "eighty", "ninety",
10999    ];
11000    if (0..20).contains(&n) { return UNDER_20[n as usize].to_string(); }
11001    if n < 100 {
11002        let t = (n / 10) as usize;
11003        let r = (n % 10) as usize;
11004        if r == 0 { return TENS[t].to_string(); }
11005        // British / W3C-suite convention: space-separated, no hyphen.
11006        return format!("{} {}", TENS[t], UNDER_20[r]);
11007    }
11008    if n < 1000 {
11009        let h = (n / 100) as i64;
11010        let r = n % 100;
11011        let head = format!("{} hundred", UNDER_20[h as usize]);
11012        if r == 0 { head }
11013        // British: insert "and" between hundreds and the tens/units.
11014        else { format!("{head} and {}", english_cardinal(r)) }
11015    } else if n < 1_000_000 {
11016        let th = n / 1000;
11017        let r  = n % 1000;
11018        let head = format!("{} thousand", english_cardinal(th));
11019        if r == 0 { head }
11020        // British convention: when the trailing remainder is below
11021        // 100, the "and" goes between the higher group and the
11022        // tens/units ("two thousand and twelve").  When the remainder
11023        // includes a hundreds component, the "and" already lives
11024        // inside that hundreds clause ("two thousand one hundred and
11025        // five") so no extra one at the thousands boundary.
11026        else if r < 100 { format!("{head} and {}", english_cardinal(r)) }
11027        else { format!("{head} {}", english_cardinal(r)) }
11028    } else if n < 1_000_000_000 {
11029        let mil = n / 1_000_000;
11030        let r   = n % 1_000_000;
11031        let head = format!("{} million", english_cardinal(mil));
11032        if r == 0 { head }
11033        else if r < 100 { format!("{head} and {}", english_cardinal(r)) }
11034        else { format!("{head} {}", english_cardinal(r)) }
11035    } else {
11036        // Beyond billions — fall back to decimal digits; the
11037        // W3C suite doesn't exercise this.
11038        n.to_string()
11039    }
11040}
11041
11042fn english_ordinal(n: i64) -> String {
11043    const ORDINAL_UNDER_20: &[&str] = &[
11044        "zeroth", "first", "second", "third", "fourth", "fifth",
11045        "sixth", "seventh", "eighth", "ninth", "tenth", "eleventh",
11046        "twelfth", "thirteenth", "fourteenth", "fifteenth",
11047        "sixteenth", "seventeenth", "eighteenth", "nineteenth",
11048    ];
11049    const ORDINAL_TENS: &[&str] = &[
11050        "", "", "twentieth", "thirtieth", "fortieth", "fiftieth",
11051        "sixtieth", "seventieth", "eightieth", "ninetieth",
11052    ];
11053    const TENS: &[&str] = &[
11054        "", "", "twenty", "thirty", "forty", "fifty", "sixty",
11055        "seventy", "eighty", "ninety",
11056    ];
11057    if (0..20).contains(&n) { return ORDINAL_UNDER_20[n as usize].to_string(); }
11058    if n < 100 {
11059        let t = (n / 10) as usize;
11060        let r = (n % 10) as usize;
11061        if r == 0 { return ORDINAL_TENS[t].to_string(); }
11062        // British: space-separated, no hyphen.
11063        return format!("{} {}", TENS[t], ORDINAL_UNDER_20[r]);
11064    }
11065    // 100+: cardinal head + ordinal tail (with "and" between
11066    // hundreds and tens for the British convention the W3C suite
11067    // expects).
11068    let head_div = if n < 1000 { 100 }
11069                   else if n < 1_000_000 { 1000 }
11070                   else { 1_000_000 };
11071    let head_word = if n < 1000 {
11072        format!("{} hundred", english_cardinal(n / 100))
11073    } else if n < 1_000_000 {
11074        format!("{} thousand", english_cardinal(n / 1000))
11075    } else {
11076        format!("{} million", english_cardinal(n / 1_000_000))
11077    };
11078    let r = n % head_div;
11079    if r == 0 {
11080        // Last whole word becomes ordinal: "hundred" → "hundredth"
11081        // etc.  Append "th" because all three are 1-syllable nouns
11082        // that take a regular -th.
11083        format!("{head_word}th")
11084    } else if n < 1000 {
11085        format!("{head_word} and {}", english_ordinal(r))
11086    } else if r < 100 {
11087        // British convention parallel to `english_cardinal`: insert
11088        // "and" before the trailing tens/units when the higher group
11089        // has no hundreds component of its own.
11090        format!("{head_word} and {}", english_ordinal(r))
11091    } else {
11092        format!("{head_word} {}", english_ordinal(r))
11093    }
11094}
11095
11096#[derive(Clone, Copy)]
11097enum MinMaxOp { Min, Max, Avg }
11098
11099/// Shared implementation of `fn:min` / `fn:max` / `fn:avg`.  Empty
11100/// input yields the empty sequence.  If every item parses as a
11101/// number we apply the numeric reduction; otherwise (for min/max)
11102/// we fall back to lexicographic ordering — XPath 2.0 §15.4.3
11103/// allows string-typed sequences and the answer must be the
11104/// lexicographically-min/max string, not "NaN" from parse failure.
11105fn min_max_avg<I: DocIndexLike>(
11106    v: &Value, idx: &I, op: MinMaxOp, ci: bool,
11107) -> Result<Value> {
11108    let strs = sequence_to_strings(v, idx);
11109    if strs.is_empty() {
11110        return Ok(Value::NodeSet(Vec::new()));
11111    }
11112    // Typed values (durations, dates, dateTimes, times) aggregate by
11113    // their semantic value, not as numbers or strings.
11114    if let Value::Sequence(items) = v {
11115        // F&O §10.4 — avg of a duration sequence is a duration.
11116        if matches!(op, MinMaxOp::Avg) {
11117            if let Some((kind, total, count)) = duration_seq_total(items) {
11118                // Dividing a yearMonthDuration by the item count rounds
11119                // the resulting month total to the nearest integer
11120                // (F&O §10.6.4 / fn:round — ties toward +infinity);
11121                // truncating `total / count` would report e.g.
11122                // avg((P15M,P15M,-P1M)) = P9M instead of P10M.
11123                let units = if kind == "yearMonthDuration" {
11124                    round_months_half_up(total as f64 / count as f64)
11125                } else {
11126                    total / count
11127                };
11128                return Ok(duration_value(kind, units));
11129            }
11130        }
11131        // F&O §15.4.2/3 — min/max of a uniform comparable-typed
11132        // sequence returns the extreme item (earliest dateTime,
11133        // shortest duration, …).
11134        if matches!(op, MinMaxOp::Min | MinMaxOp::Max)
11135            && items.iter().all(|x| matches!(x, Value::Typed(_)))
11136        {
11137            use std::cmp::Ordering;
11138            let mut best = &items[0];
11139            let mut comparable = true;
11140            for x in &items[1..] {
11141                match compare_typed_values(best, x) {
11142                    Some(ord) => {
11143                        let take = match op {
11144                            MinMaxOp::Max => ord == Ordering::Less,
11145                            _             => ord == Ordering::Greater,
11146                        };
11147                        if take { best = x; }
11148                    }
11149                    None => { comparable = false; break; }
11150                }
11151            }
11152            if comparable { return Ok(best.clone()); }
11153        }
11154    }
11155    // XPath 2.0 §15.4.3 — when every item is typed as xs:string
11156    // (e.g. `for $x in seq return xs:string($x)`), comparison uses
11157    // codepoint collation rather than numeric promotion.  Check
11158    // the source value's type-tag shape before attempting the
11159    // numeric path.
11160    // True iff every item is a string-typed atomic.  Recognise both
11161    // the explicit `Value::Typed{kind:"string"}` shape and the
11162    // implicit `Value::String` produced by `fn:string()` /
11163    // `xs:string()` whose returned-type semantics treat it as
11164    // xs:string.
11165    fn is_string_item(v: &Value) -> bool {
11166        match v {
11167            Value::String(_) => true,
11168            Value::Typed(t) => matches!(t.kind,
11169                "string" | "anyURI" | "normalizedString" | "token"
11170                | "Name" | "NCName" | "language"),
11171            _ => false,
11172        }
11173    }
11174    let all_typed_string = match v {
11175        Value::Typed(_) | Value::String(_) => is_string_item(v),
11176        Value::Sequence(items) => !items.is_empty()
11177            && items.iter().all(is_string_item),
11178        _ => false,
11179    };
11180    if all_typed_string {
11181        if matches!(op, MinMaxOp::Avg) {
11182            return Ok(Value::Number(Numeric::Double(f64::NAN)));
11183        }
11184        // String min/max order by the in-scope collation.  Under the
11185        // case-insensitive collation, fold for the comparison but keep
11186        // the original spelling of the chosen item.
11187        let key = |s: &String| if ci { ascii_ci_fold(s) } else { s.clone() };
11188        let chosen = match op {
11189            MinMaxOp::Min => strs.iter().min_by(|a, b| key(a).cmp(&key(b))).cloned(),
11190            MinMaxOp::Max => strs.iter().max_by(|a, b| key(a).cmp(&key(b))).cloned(),
11191            MinMaxOp::Avg => unreachable!(),
11192        };
11193        return Ok(chosen.map(Value::String).unwrap_or(Value::NodeSet(Vec::new())));
11194    }
11195    // Try the numeric path: every item must parse.
11196    let numeric: Option<Vec<f64>> = strs.iter()
11197        .map(|s| s.trim().parse::<f64>().ok())
11198        .collect();
11199    if let Some(nums) = numeric {
11200        let n = nums.len() as f64;
11201        let result = match op {
11202            MinMaxOp::Min => nums.into_iter().fold(f64::INFINITY,     f64::min),
11203            MinMaxOp::Max => nums.into_iter().fold(f64::NEG_INFINITY, f64::max),
11204            MinMaxOp::Avg => nums.into_iter().sum::<f64>() / n,
11205        };
11206        // Result type follows the promoted numeric type of the items
11207        // (F&O §15.4): min/max return that type; avg divides, so an
11208        // all-integer input yields xs:decimal.  An untyped atomic
11209        // (string-value of a node) has no numeric kind and promotes
11210        // to xs:double.
11211        let kind = match v {
11212            Value::Sequence(items) => items.iter().fold(Some("integer"), |acc, it| {
11213                match (acc, numeric_kind_of(it)) {
11214                    (Some(a), Some(b)) => numeric_promote_kind(Some(a), Some(b)),
11215                    _ => None,
11216                }
11217            }),
11218            Value::IntRange { .. } => Some("integer"),
11219            other => numeric_kind_of(other),
11220        }.unwrap_or("double");
11221        let kind = if matches!(op, MinMaxOp::Avg) && kind == "integer" { "decimal" } else { kind };
11222        return Ok(Value::Number(Numeric::of_kind(kind, result)));
11223    }
11224    // String fallback — `avg` over non-numeric items is a type
11225    // error per the spec; we return NaN to match what most
11226    // implementations do under loose typing.
11227    if matches!(op, MinMaxOp::Avg) {
11228        return Ok(Value::Number(Numeric::Double(f64::NAN)));
11229    }
11230    let key = |s: &String| if ci { ascii_ci_fold(s) } else { s.clone() };
11231    let chosen = match op {
11232        MinMaxOp::Min => strs.iter().min_by(|a, b| key(a).cmp(&key(b))).cloned(),
11233        MinMaxOp::Max => strs.iter().max_by(|a, b| key(a).cmp(&key(b))).cloned(),
11234        MinMaxOp::Avg => unreachable!(),
11235    };
11236    Ok(chosen.map(Value::String).unwrap_or(Value::NodeSet(Vec::new())))
11237}
11238
11239/// XPath 2.0 §3.5.1 value-comparison operator.
11240#[derive(Clone, Copy)]
11241enum ValueCmp { Eq, Ne, Lt, Gt, Le, Ge }
11242
11243/// Evaluate a value-comparison expression.  Returns the empty
11244/// sequence (here `Value::NodeSet(vec![])` — XPath 1.0 callers
11245/// flatten it to false / "") when either operand is empty.  Raises
11246/// a type error when either operand atomises to more than one
11247/// item.  Otherwise applies the requested operator to the single-
11248/// item operand pair via the existing typed-aware comparison
11249/// helpers.
11250/// True for atomic type kinds that carry only equality, not a total
11251/// order (XPath 2.0 §3.5.2): the gregorian types, the xs:duration base
11252/// type, the binary types, and xs:QName / xs:NOTATION.  The ordered
11253/// `xs:dayTimeDuration` / `xs:yearMonthDuration` subtypes are excluded.
11254fn is_unordered_atomic_kind(kind: &str) -> bool {
11255    matches!(kind,
11256        "gYear" | "gYearMonth" | "gMonth" | "gMonthDay" | "gDay"
11257        | "duration" | "hexBinary" | "base64Binary"
11258        | "QName" | "NOTATION")
11259}
11260
11261fn value_compare<I: DocIndexLike>(
11262    l: &Expr, r: &Expr, ctx: &EvalCtx<'_>, idx: &I, op: ValueCmp,
11263) -> Result<Value> {
11264    let lv = atomise_singleton(eval_expr(l, ctx, idx)?, idx, ctx.bindings)?;
11265    let rv = atomise_singleton(eval_expr(r, ctx, idx)?, idx, ctx.bindings)?;
11266    let (lv, rv) = match (lv, rv) {
11267        (None, _) | (_, None) => return Ok(Value::NodeSet(Vec::new())),
11268        (Some(a), Some(b))    => (a, b),
11269    };
11270    // A function item has no typed value, so it cannot take part in a
11271    // value comparison (FOTY0013).
11272    if value_is_function(&lv) || value_is_function(&rv) {
11273        return Err(xpath_err(
11274            "a function item cannot appear in a value comparison (FOTY0013)")
11275            .with_xpath_code("FOTY0013"));
11276    }
11277    // XPath 2.0 §3.5.2 — when both operands atomise to strings (or
11278    // untyped, which converts to string for comparison), the
11279    // in-scope default-collation drives equality / ordering.
11280    let coll = DEFAULT_COLLATION.with(|c| c.borrow().clone());
11281    let collated = coll.as_deref().filter(|u|
11282        *u == "http://www.w3.org/2005/xpath-functions/collation/html-ascii-case-insensitive");
11283    if let Some(_) = collated {
11284        if values_both_stringy(&lv, &rv) {
11285            let a = ascii_ci_fold(&value_to_string_with(&lv, idx, ctx.bindings));
11286            let b = ascii_ci_fold(&value_to_string_with(&rv, idx, ctx.bindings));
11287            let result = match op {
11288                ValueCmp::Eq => a == b,
11289                ValueCmp::Ne => a != b,
11290                ValueCmp::Lt => a < b,
11291                ValueCmp::Gt => a > b,
11292                ValueCmp::Le => a <= b,
11293                ValueCmp::Ge => a >= b,
11294            };
11295            return Ok(Value::Boolean(result));
11296        }
11297    }
11298    let result = match op {
11299        ValueCmp::Eq => values_eq(&lv, &rv, idx, ctx.bindings),
11300        ValueCmp::Ne => values_ne(&lv, &rv, idx, ctx.bindings),
11301        ValueCmp::Lt | ValueCmp::Gt | ValueCmp::Le | ValueCmp::Ge => {
11302            // XPath 2.0 §3.5.2 — `lt`/`gt`/`le`/`ge` are only defined on
11303            // types with a total order.  The gregorian types, the
11304            // xs:duration base type, the binary types, and QName /
11305            // NOTATION provide only `eq`/`ne`; applying an ordering
11306            // operator to them is a type error (XPTY0004).
11307            for v in [&lv, &rv] {
11308                if let Value::Typed(t) = v {
11309                    if is_unordered_atomic_kind(t.kind) {
11310                        return Err(xpath_err(format!(
11311                            "value comparison operator is not defined on xs:{}",
11312                            t.kind,
11313                        )).with_xpath_code("XPTY0004"));
11314                    }
11315                }
11316            }
11317            // XPath 2.0 §3.5.2 — value comparison routes to
11318            // op:date-less-than / op:time-greater-than / etc. when the
11319            // operands carry a date/time/duration type tag; otherwise
11320            // to op:numeric-* (the f64 path below) or
11321            // op:string-compare for strings.  We special-case the
11322            // common typed pairs before falling back to numeric.
11323            if let Some(ord) = compare_typed_values(&lv, &rv) {
11324                use std::cmp::Ordering;
11325                match (op, ord) {
11326                    (ValueCmp::Lt, Ordering::Less)    => true,
11327                    (ValueCmp::Gt, Ordering::Greater) => true,
11328                    (ValueCmp::Le, Ordering::Less | Ordering::Equal) => true,
11329                    (ValueCmp::Ge, Ordering::Greater | Ordering::Equal) => true,
11330                    _ => false,
11331                }
11332            } else {
11333                let a = value_to_number_with(&lv, idx, ctx.bindings);
11334                let b = value_to_number_with(&rv, idx, ctx.bindings);
11335                match op {
11336                    ValueCmp::Lt => a < b,
11337                    ValueCmp::Gt => a > b,
11338                    ValueCmp::Le => a <= b,
11339                    ValueCmp::Ge => a >= b,
11340                    _ => unreachable!(),
11341                }
11342            }
11343        }
11344    };
11345    Ok(Value::Boolean(result))
11346}
11347
11348/// Both operands either are strings, untyped atomics, or come from
11349/// a node-set (whose atomization yields strings).  Used to gate the
11350/// collated comparison path — typed numerics / dates fall through
11351/// to their dedicated comparison ops.
11352fn values_both_stringy(a: &Value, b: &Value) -> bool {
11353    fn is_str(v: &Value) -> bool {
11354        match v {
11355            Value::String(_) => true,
11356            Value::Typed(t) => matches!(t.kind,
11357                "string" | "untypedAtomic" | "anyURI"
11358                | "normalizedString" | "token" | "Name" | "NCName"
11359                | "language" | "ID" | "IDREF" | "ENTITY" | "NMTOKEN"
11360                | "NOTATION" | "QName"),
11361            _ => false,
11362        }
11363    }
11364    is_str(a) && is_str(b)
11365}
11366
11367/// Convert an `xs:date` / `xs:dateTime` / `xs:time` lexical to a
11368/// signed microsecond count after timezone normalisation.  A missing
11369/// timezone is treated as UTC (XPath 2.0 §10.4 implementation-defined
11370/// implicit timezone — we pick UTC for stability across runs).
11371/// Returns `None` when the lexical doesn't parse.
11372pub fn date_value_to_utc_micros(lex: &str, kind: DateKind) -> Option<i128> {
11373    let (y, mo, d, h, mi, sec, frac, tz) = parse_xsd_date_time(lex, kind)?;
11374    let tz_min = tz.unwrap_or(0) as i64;
11375    let day_count = match kind {
11376        DateKind::Time => 0,
11377        _              => ymd_to_days(y, mo as u32, d as u32),
11378    };
11379    let secs = day_count * 86_400
11380        + (h as i64) * 3600 + (mi as i64) * 60 + (sec as i64)
11381        - tz_min * 60;
11382    Some((secs as i128) * 1_000_000 + (frac as i128))
11383}
11384
11385/// XPath 2.0 typed value comparison.  Returns the per-spec ordering
11386/// when both operands share a comparable type family (date / time /
11387/// dateTime / duration / string).  Numeric values fall through —
11388/// the caller handles them with `value_to_number` → IEEE compare.
11389fn compare_typed_values(a: &Value, b: &Value) -> Option<std::cmp::Ordering> {
11390    // Pull (kind, lexical) for each side.  An xs:date / xs:dateTime
11391    // node-set string survives as `Value::String` (after
11392    // atomise_singleton's NodeSet→String conversion); the typed
11393    // path carries the explicit kind.
11394    fn date_kind(v: &Value) -> Option<(&'static str, String)> {
11395        match v {
11396            Value::Typed(t) => {
11397                let kind = match t.kind {
11398                    "date"             => "date",
11399                    "dateTime"         => "dateTime",
11400                    "time"             => "time",
11401                    "gYear" | "gYearMonth"
11402                    | "gMonth" | "gMonthDay" | "gDay" => "date",
11403                    "dayTimeDuration"  => "dayTime",
11404                    "yearMonthDuration"=> "yearMonth",
11405                    "duration"         => "duration",
11406                    _ => return None,
11407                };
11408                Some((kind, t.lexical.clone()))
11409            }
11410            _ => None,
11411        }
11412    }
11413    let (ka, la) = date_kind(a)?;
11414    let (kb, lb) = date_kind(b)?;
11415    // Same-family compare.  Cross-family (date vs dateTime,
11416    // dayTime vs yearMonth) is a type error per spec — fall back
11417    // to the numeric path so the caller gets `NaN < x = false`.
11418    if ka != kb { return None; }
11419    match ka {
11420        "date" | "dateTime" | "time" => {
11421            let dk = match ka {
11422                "date"     => DateKind::Date,
11423                "dateTime" => DateKind::DateTime,
11424                "time"     => DateKind::Time,
11425                _ => unreachable!(),
11426            };
11427            let a_us = date_value_to_utc_micros(&la, dk)?;
11428            let b_us = date_value_to_utc_micros(&lb, dk)?;
11429            Some(a_us.cmp(&b_us))
11430        }
11431        "dayTime" => {
11432            // op:dayTimeDuration-less-than — compare total seconds
11433            // via the shared parser used by duration arithmetic.
11434            let a_s = parse_day_time_duration_secs(&la)?;
11435            let b_s = parse_day_time_duration_secs(&lb)?;
11436            Some(a_s.cmp(&b_s))
11437        }
11438        "yearMonth" => {
11439            // op:yearMonthDuration-less-than — compare total months.
11440            let a_m = parse_year_month_duration_months(&la)?;
11441            let b_m = parse_year_month_duration_months(&lb)?;
11442            Some(a_m.cmp(&b_m))
11443        }
11444        _ => None,
11445    }
11446}
11447
11448/// Atomise an operand of a value comparison: extract its single
11449/// item (or `None` for empty), erroring when the operand has more
11450/// than one item.  A node-set operand stringifies each node to its
11451/// string-value; multi-node operands are a type error per
11452/// XPath 2.0 §3.5.1.
11453fn atomise_singleton<I: DocIndexLike>(
11454    v: Value, idx: &I, _bindings: &dyn XPathBindings,
11455) -> Result<Option<Value>> {
11456    match v {
11457        Value::NodeSet(ns) => match ns.len() {
11458            0 => Ok(None),
11459            1 => Ok(Some(Value::String(idx.string_value(ns[0])))),
11460            _ => Err(xpath_err(
11461                "value-comparison operand must have at most one item")),
11462        }
11463        Value::ForeignNodeSet(_) => Ok(Some(v)),
11464        Value::Sequence(items) => match items.len() {
11465            0 => Ok(None),
11466            1 => Ok(Some(items.into_iter().next().unwrap())),
11467            _ => Err(xpath_err(
11468                "value-comparison operand must have at most one item")),
11469        }
11470        // Already a singleton atomic.
11471        other => Ok(Some(other)),
11472    }
11473}
11474
11475/// Filter a sequence by a chain of XPath 2.0 predicates.  Each
11476/// predicate runs against each item with `position()` / `last()`
11477/// reflecting the surviving sequence at that step.  A numeric
11478/// predicate value `N` keeps only the item at position `N`; any
11479/// other value applies the predicate's effective boolean value to
11480/// each item.
11481/// Detect predicates of the form `[. = E]` / `[. != E]` /
11482/// `[not(. = E)]` where `E` is loop-invariant (no `.` reference),
11483/// and evaluate them via a hash-set membership test instead of
11484/// the quadratic XPath general-comparison cross-product.  Returns
11485/// `Some(filtered)` on a hit, `None` to let the caller fall back
11486/// to the normal per-item predicate evaluation.
11487///
11488/// Supports homogeneous numeric and string sequences on the
11489/// hoisted side; heterogeneous / typed sequences route through
11490/// the slow path, which preserves XPath 2.0 §3.5.2's
11491/// type-coercion rules.
11492fn try_membership_filter<I: DocIndexLike>(
11493    pred:      &Expr,
11494    items:     &[Value],
11495    ctx:       &EvalCtx<'_>,
11496    idx:       &I,
11497) -> Result<Option<Vec<Value>>> {
11498    let Some((rhs, negated)) = classify_membership_pred(pred) else {
11499        return Ok(None);
11500    };
11501    // The hoisted side must not depend on the current item — it
11502    // would change per iteration if it did, defeating the point
11503    // of building the set once.
11504    if expr_references_context_item(rhs) {
11505        return Ok(None);
11506    }
11507    let rhs_val = eval_expr(rhs, ctx, idx)?;
11508    // Build the set.  Two specialisations cover the common cases:
11509    // integer values (the W3C unicode-90 set-difference shape) and
11510    // string values (token-membership filters).  Mixed or typed
11511    // sequences fall through to the slow path.
11512    let set = match build_membership_set(&rhs_val, idx) {
11513        Some(s) => s,
11514        None    => return Ok(None),
11515    };
11516    let mut kept = Vec::with_capacity(items.len());
11517    for item in items {
11518        let hit = match &set {
11519            MembershipSet::Int(s) => match item_as_int(item) {
11520                Some(n) => s.contains(&n),
11521                None    => false,
11522            },
11523            MembershipSet::Str(s) => {
11524                let item_s = match item {
11525                    Value::String(t) => t.clone(),
11526                    Value::NodeSet(ns) if ns.len() == 1 => idx.string_value(ns[0]),
11527                    _ => continue,
11528                };
11529                s.contains(&item_s)
11530            }
11531        };
11532        if hit ^ negated {
11533            kept.push(item.clone());
11534        }
11535    }
11536    Ok(Some(kept))
11537}
11538
11539/// Classify a predicate AST node as a hash-set membership test.
11540/// Returns `(rhs, negated)` where `rhs` is the hoistable expr
11541/// (the side of `=` / `!=` that isn't bare `.`) and `negated` is
11542/// true iff the predicate is "keep when NOT a member."
11543fn classify_membership_pred(pred: &Expr) -> Option<(&Expr, bool)> {
11544    // `not(...)` wraps a single Eq/Ne predicate.
11545    if let Expr::FunctionCall(name, args) = pred {
11546        if name == "not" && args.len() == 1 {
11547            return classify_membership_pred(&args[0]).map(|(rhs, neg)| (rhs, !neg));
11548        }
11549    }
11550    match pred {
11551        Expr::Eq(a, b) => match (is_bare_dot(a), is_bare_dot(b)) {
11552            (true,  false) => Some((b.as_ref(), false)),
11553            (false, true)  => Some((a.as_ref(), false)),
11554            _              => None,
11555        },
11556        Expr::Ne(a, b) => match (is_bare_dot(a), is_bare_dot(b)) {
11557            (true,  false) => Some((b.as_ref(), true)),
11558            (false, true)  => Some((a.as_ref(), true)),
11559            _              => None,
11560        },
11561        _ => None,
11562    }
11563}
11564
11565fn is_bare_dot(e: &Expr) -> bool {
11566    if let Expr::Path(p) = e {
11567        return is_bare_dot_path(p);
11568    }
11569    false
11570}
11571
11572/// True iff `e` syntactically references the context item (`.`
11573/// or `self::*` axis steps anywhere within).  Used to decide
11574/// whether an expression can be hoisted out of a per-item loop.
11575/// Conservative: returns true on anything that *might* read `.`,
11576/// so the fast path bails to the safe slow path in ambiguous
11577/// cases.
11578pub fn expr_references_context_item(e: &Expr) -> bool {
11579    use Expr::*;
11580    match e {
11581        Path(p) => path_uses_context_item(p),
11582        ContextItem => true,
11583        Variable(_) | Literal(_) | Integer(_) | Decimal(_) | Double(_) => false,
11584        Or(l, r) | And(l, r)
11585        | Eq(l, r) | Ne(l, r) | Lt(l, r) | Gt(l, r) | Le(l, r) | Ge(l, r)
11586        | ValueEq(l, r) | ValueNe(l, r)
11587        | ValueLt(l, r) | ValueGt(l, r) | ValueLe(l, r) | ValueGe(l, r)
11588        | Add(l, r) | Sub(l, r) | Mul(l, r) | Div(l, r) | Mod(l, r)
11589        | Union(l, r) | IDiv(l, r) | Intersect(l, r) | Except(l, r)
11590        | Range(l, r) | SimpleMap(l, r) | NodeBefore(l, r) | NodeAfter(l, r)
11591        | NodeIs(l, r) =>
11592            expr_references_context_item(l) || expr_references_context_item(r),
11593        Neg(x) | InstanceOf(x, _) | CastAs(x, _)
11594        | CastableAs(x, _) | TreatAs(x, _) => expr_references_context_item(x),
11595        IfThenElse { cond, then_branch, else_branch } =>
11596            expr_references_context_item(cond)
11597                || expr_references_context_item(then_branch)
11598                || expr_references_context_item(else_branch),
11599        For { bindings, body } | Let { bindings, body }
11600        | Quantified { bindings, test: body, .. } =>
11601            bindings.iter().any(|(_, e)| expr_references_context_item(e))
11602                || expr_references_context_item(body),
11603        Sequence(items) => items.iter().any(expr_references_context_item),
11604        FilterPath { primary, predicates, steps } => {
11605            expr_references_context_item(primary)
11606                || predicates.iter().any(expr_references_context_item)
11607                || steps.iter().any(|s| s.predicates.iter().any(expr_references_context_item))
11608        }
11609        FunctionCall(_, args) => args.iter().any(expr_references_context_item),
11610        TryCatch { body, catches } =>
11611            expr_references_context_item(body)
11612                || catches.iter().any(|c| expr_references_context_item(&c.body)),
11613        WithDefaultCollation(_, inner) => expr_references_context_item(inner),
11614        BackwardsCompat(inner) => expr_references_context_item(inner),
11615        MapConstructor(entries) => entries.iter()
11616            .any(|(k, v)| expr_references_context_item(k) || expr_references_context_item(v)),
11617        ArrayConstructor { members, .. } =>
11618            members.iter().any(expr_references_context_item),
11619        Lookup(base, key) => expr_references_context_item(base)
11620            || lookup_key_references_context_item(key),
11621        // `?K` is a unary lookup on the context item itself.
11622        UnaryLookup(_) => true,
11623        InlineFunction { .. } | NamedFunctionRef { .. } | Placeholder => false,
11624        DynamicCall { func, args } => expr_references_context_item(func)
11625            || args.iter().any(expr_references_context_item),
11626    }
11627}
11628
11629fn lookup_key_references_context_item(key: &crate::xpath::ast::LookupKey) -> bool {
11630    matches!(key, crate::xpath::ast::LookupKey::Expr(e)
11631        if expr_references_context_item(e))
11632}
11633
11634/// True iff a `LocationPath` reads the context item — either a
11635/// bare `.` (single Self_ step) or a relative path starting at
11636/// `.` implicitly.  Absolute paths (`/foo`) don't depend on `.`.
11637fn path_uses_context_item(path: &crate::xpath::ast::LocationPath) -> bool {
11638    use crate::xpath::ast::LocationPath;
11639    match path {
11640        LocationPath::Absolute(_) => false,
11641        LocationPath::Relative(_) => true,
11642    }
11643}
11644
11645enum MembershipSet {
11646    Int(HashSet<i64>),
11647    Str(HashSet<String>),
11648}
11649
11650/// Build a membership set from a sequence-valued `Value`.  Returns
11651/// `Some(set)` when every item collapses to an integer or every
11652/// item collapses to a string; `None` otherwise (caller falls
11653/// back to the per-item general-comparison loop).
11654fn build_membership_set<I: DocIndexLike>(v: &Value, idx: &I) -> Option<MembershipSet> {
11655    // Try integer membership first — common shape for codepoint /
11656    // identifier sets.  Two passes are cheaper than building a
11657    // wrong set and discarding.
11658    let mut all_int  = true;
11659    let mut all_str  = true;
11660    for item in iter_items(v) {
11661        if item_as_int(&item).is_none()  { all_int  = false; }
11662        if !item_as_string_kind(&item)   { all_str  = false; }
11663        if !all_int && !all_str { return None; }
11664    }
11665    if all_int {
11666        let s: HashSet<i64> = iter_items(v)
11667            .filter_map(|it| item_as_int(&it))
11668            .collect();
11669        return Some(MembershipSet::Int(s));
11670    }
11671    if all_str {
11672        let s: HashSet<String> = iter_items(v)
11673            .filter_map(|it| item_as_string(&it, idx))
11674            .collect();
11675        return Some(MembershipSet::Str(s));
11676    }
11677    None
11678}
11679
11680fn item_as_int(v: &Value) -> Option<i64> {
11681    match v {
11682        Value::Number(n) if n.as_f64().is_finite() && n.as_f64().fract() == 0.0 => Some(n.as_f64() as i64),
11683        Value::Typed(t) => t.numeric.and_then(|n|
11684            if n.is_finite() && n.fract() == 0.0 { Some(n as i64) } else { None }),
11685        _ => None,
11686    }
11687}
11688
11689fn item_as_string_kind(v: &Value) -> bool {
11690    matches!(v, Value::String(_) | Value::NodeSet(_)) || matches!(v, Value::Typed(_))
11691}
11692
11693fn item_as_string<I: DocIndexLike>(v: &Value, idx: &I) -> Option<String> {
11694    match v {
11695        Value::String(s) => Some(s.clone()),
11696        Value::NodeSet(ns) if ns.len() == 1 => Some(idx.string_value(ns[0])),
11697        Value::Typed(t) => Some(t.lexical.clone()),
11698        _ => None,
11699    }
11700}
11701
11702fn filter_sequence_by_predicates<I: DocIndexLike>(
11703    items: Vec<Value>, predicates: &[Expr], ctx: &EvalCtx<'_>, idx: &I,
11704) -> Result<Vec<Value>> {
11705    // Inline `IntRange` fragments (or nested `Sequence`) inside the
11706    // input flatten to individual items here so `position()` / `last()`
11707    // count the logical items rather than their lazy containers.
11708    let needs_flatten = items.iter().any(|v|
11709        matches!(v, Value::IntRange { .. } | Value::Sequence(_)));
11710    let mut surviving: Vec<Value> = if needs_flatten {
11711        items.iter().flat_map(iter_items).collect()
11712    } else {
11713        items
11714    };
11715    for pred in predicates {
11716        // Loop-invariant fast path: `[. = $expr]` / `[. != $expr]` /
11717        // `[not(. = $expr)]` where `$expr` doesn't depend on the
11718        // current item.  Evaluate `$expr` once, build a hash set,
11719        // then drop to O(1) membership per surviving item instead
11720        // of O(M·N) cross-product general-comparison.  Covers the
11721        // W3C `$validrange[not(. = $c)]` shape (set-difference of
11722        // the full Unicode codepoint range against ~1300 source
11723        // values) — without this the predicate is ~2min/case
11724        // through the standard `values_eq` Sequence × scalar path.
11725        if let Some(fast) = try_membership_filter(pred, &surviving, ctx, idx)? {
11726            surviving = fast;
11727            continue;
11728        }
11729        let size = surviving.len();
11730        let mut next: Vec<Value> = Vec::with_capacity(size);
11731        for (i, item) in surviving.into_iter().enumerate() {
11732            let pos = i + 1;
11733            // Single-node items thread the node through
11734            // `context_node` so node-axis predicates work; atomic
11735            // items thread their value through the per-thread
11736            // [`CONTEXT_ITEM`] slot so bare `.` resolves to the
11737            // current item rather than the outer document.
11738            let (ctx_node, ctx_item) = match &item {
11739                Value::NodeSet(ns) if ns.len() == 1 =>
11740                    (ns[0], None),
11741                _ => (ctx.context_node, Some(item.clone())),
11742            };
11743            let inner_ctx = EvalCtx {
11744                context_node: ctx_node,
11745                pos, size,
11746                bindings: ctx.bindings,
11747                static_ctx: ctx.static_ctx,
11748            };
11749            let pv = with_context_item(ctx_item, ||
11750                eval_expr(pred, &inner_ctx, idx)
11751            )?;
11752            // XPath 2.0 §2.5.4 — when the predicate value is numeric,
11753            // it's a position predicate (keep iff value == position).
11754            // Otherwise the predicate's effective boolean value
11755            // decides.  A single-item NodeSet whose synthetic text
11756            // node carries a numeric value (the common XSLT 2.0
11757            // shape after `for $x in 1 to N` / `<xsl:for-each select="1 to N">`)
11758            // is treated as numeric so `$seq[$x]` selects the
11759            // x-th item rather than always-true.
11760            let keep = match pv {
11761                Value::Number(n) => (n.as_f64() as usize) == pos && n.as_f64().fract() == 0.0,
11762                Value::Typed(ref t) if t.numeric.is_some() => {
11763                    let n = t.numeric.unwrap();
11764                    (n as usize) == pos && n.fract() == 0.0
11765                }
11766                // Synthetic text-node from XPath 2.0 atomisation of
11767                // `1 to N` (etc.): the node has no parent, kind is
11768                // Text, and its string-value parses as a positive
11769                // integer.  Treat as a position predicate so
11770                // `$xs[$n]` selects the n-th item; a real RTF
11771                // document node (with a Document root kind) keeps
11772                // XSLT 1.0's "EBV = node-set non-empty" behaviour
11773                // so tests like `decl/variable-1101` stay correct.
11774                Value::NodeSet(ref ns) if ns.len() == 1
11775                    && matches!(idx.kind(ns[0]),
11776                        crate::xpath::XPathNodeKind::Text)
11777                    && idx.parent(ns[0]).is_none()
11778                => {
11779                    let s = idx.string_value(ns[0]);
11780                    match s.trim().parse::<f64>() {
11781                        Ok(n) if n.fract() == 0.0 => (n as usize) == pos,
11782                        _ => value_to_bool(&pv, idx),
11783                    }
11784                }
11785                v                => value_to_bool(&v, idx),
11786            };
11787            if keep { next.push(item); }
11788        }
11789        surviving = next;
11790    }
11791    Ok(surviving)
11792}
11793
11794/// Atomise a Value as a sequence of strings (XPath 2.0 sequence-as-
11795/// strings projection used by `string-join`, `distinct-values`, etc.).
11796fn sequence_to_strings<I: DocIndexLike>(v: &Value, idx: &I) -> Vec<String> {
11797    match v {
11798        Value::NodeSet(ns)        => ns.iter().map(|&id| idx.string_value(id)).collect(),
11799        Value::ForeignNodeSet(_)  => vec![value_to_string(v, idx)],
11800        Value::String(s)          => vec![s.clone()],
11801        Value::Number(_) | Value::Boolean(_) => vec![value_to_string(v, idx)],
11802        Value::Typed(t)           => vec![t.lexical.clone()],
11803        Value::Sequence(items)    => items.iter()
11804            .flat_map(|item| sequence_to_strings(item, idx))
11805            .collect(),
11806        Value::IntRange { lo, hi } => (*lo..=*hi).map(|i| i.to_string()).collect(),
11807        // A map / array has no string projection; contributes nothing.
11808        Value::Map(_) | Value::Array(_) | Value::Function(_) => Vec::new(),
11809    }
11810}
11811
11812fn sequence_to_numbers<I: DocIndexLike>(v: &Value, idx: &I) -> Vec<f64> {
11813    sequence_to_strings(v, idx).into_iter()
11814        .map(|s| s.trim().parse().unwrap_or(f64::NAN))
11815        .collect()
11816}
11817
11818/// Translate an XPath 2.0 regex pattern + flag string into a compiled
11819/// `regex::Regex`.  Flags map to Rust's inline `(?flags)` group:
11820///
11821/// * `s` — dotall (`.` matches newline)
11822/// * `m` — multiline (`^`/`$` match line boundaries)
11823/// * `i` — case-insensitive
11824/// * `x` — ignore whitespace + `#` comments
11825///
11826/// XPath 2.0's `q` flag (treat pattern as a literal) is honoured by
11827/// pre-escaping the pattern with `regex::escape`.
11828/// Public entry point for callers outside the XPath evaluator (e.g.
11829/// `xsl:analyze-string`) that need the same XPath 2.0 regex
11830/// translation we use internally.
11831pub fn compile_xpath_2_0_regex(pattern: &str, flags: &str) -> Result<regex::Regex> {
11832    compile_xpath_regex(pattern, flags)
11833}
11834
11835/// True iff `uri` names the W3C HTML ASCII case-insensitive
11836/// collation defined in [XPath F&O 5.1].  Comparisons against this
11837/// collation lower-case the ASCII letters and leave everything else
11838/// alone; non-ASCII bytes never collide across case under this rule.
11839fn is_ascii_ci_collation(uri: Option<&str>) -> bool {
11840    matches!(uri, Some(u) if u ==
11841        "http://www.w3.org/2005/xpath-functions/collation/html-ascii-case-insensitive")
11842}
11843
11844fn ascii_ci_fold(s: &str) -> String {
11845    // ASCII A-Z → a-z; every other codepoint passes through.
11846    let mut out = String::with_capacity(s.len());
11847    for c in s.chars() {
11848        if c.is_ascii_uppercase() { out.push(c.to_ascii_lowercase()); }
11849        else                       { out.push(c); }
11850    }
11851    out
11852}
11853
11854fn collation_starts_with(s: &str, pre: &str, uri: Option<&str>) -> bool {
11855    if is_ascii_ci_collation(uri) {
11856        ascii_ci_fold(s).starts_with(ascii_ci_fold(pre).as_str())
11857    } else {
11858        s.starts_with(pre)
11859    }
11860}
11861
11862fn collation_contains(s: &str, sub: &str, uri: Option<&str>) -> bool {
11863    if is_ascii_ci_collation(uri) {
11864        ascii_ci_fold(s).contains(ascii_ci_fold(sub).as_str())
11865    } else {
11866        s.contains(sub)
11867    }
11868}
11869
11870fn collation_ends_with(s: &str, suf: &str, uri: Option<&str>) -> bool {
11871    if is_ascii_ci_collation(uri) {
11872        ascii_ci_fold(s).ends_with(ascii_ci_fold(suf).as_str())
11873    } else {
11874        s.ends_with(suf)
11875    }
11876}
11877
11878/// Translate an XPath 2.0 §7.6.3 replacement string to the Rust
11879/// regex crate's replacement form.  XPath escapes `\$` and `\\`
11880/// where the regex crate only special-cases `$`.  The function
11881/// also raises FORX0004 on any unescaped `\` followed by a non-
11882/// `$` / non-`\` character (XPath 2.0 §7.6.3 prohibits the
11883/// pattern `\X` for arbitrary X in the replacement).
11884fn translate_xpath_replacement(s: &str, group_count: usize) -> Result<String> {
11885    let mut out = String::with_capacity(s.len());
11886    let mut chars = s.chars().peekable();
11887    while let Some(c) = chars.next() {
11888        match c {
11889            '\\' => {
11890                match chars.next() {
11891                    Some('\\') => out.push('\\'),
11892                    Some('$')  => out.push('$'),
11893                    Some(other) => return Err(xpath_err(format!(
11894                        "replace(): replacement contains illegal escape '\\{other}'"
11895                    )).with_xpath_code("FORX0004")),
11896                    None => return Err(xpath_err(
11897                        "replace(): replacement ends with a trailing backslash"
11898                    ).with_xpath_code("FORX0004")),
11899                }
11900            }
11901            '$' => {
11902                match chars.peek() {
11903                    Some(d) if d.is_ascii_digit() => {
11904                        // XPath 2.0 §7.6.3: read digits greedily,
11905                        // then truncate the trailing digits until the
11906                        // resulting backref index is ≤ group_count.
11907                        // The truncated digits become literal text.
11908                        let mut digits = String::new();
11909                        while let Some(&d) = chars.peek() {
11910                            if d.is_ascii_digit() { digits.push(d); chars.next(); }
11911                            else { break; }
11912                        }
11913                        // Take the longest prefix whose numeric value
11914                        // is ≤ group_count.  The remainder is literal.
11915                        let mut take = digits.len();
11916                        while take > 0 {
11917                            let n: usize = digits[..take].parse().unwrap_or(usize::MAX);
11918                            if n <= group_count { break; }
11919                            take -= 1;
11920                        }
11921                        if take == 0 {
11922                            // No valid backref — the whole "$" + digits
11923                            // becomes literal text in Rust replacement
11924                            // syntax.  Escape the `$` so the regex
11925                            // crate doesn't treat it as a sigil.
11926                            out.push_str("$$");
11927                            out.push_str(&digits);
11928                        } else if take == digits.len() {
11929                            // All digits form a single backref — emit
11930                            // `$N` (Rust regex accepts it directly).
11931                            out.push('$');
11932                            out.push_str(&digits);
11933                        } else {
11934                            // Some trailing digits are literal text.
11935                            // Use the `${N}` form so Rust regex parses
11936                            // the backref precisely; then concatenate
11937                            // the literal digits.
11938                            out.push_str("${");
11939                            out.push_str(&digits[..take]);
11940                            out.push('}');
11941                            out.push_str(&digits[take..]);
11942                        }
11943                    }
11944                    _ => return Err(xpath_err(
11945                        "replace(): unescaped '$' in replacement"
11946                    ).with_xpath_code("FORX0004")),
11947                }
11948            }
11949            other => out.push(other),
11950        }
11951    }
11952    Ok(out)
11953}
11954
11955fn compile_xpath_regex(pattern: &str, flags: &str) -> Result<regex::Regex> {
11956    compile_xpath_regex_dialect(pattern, flags, crate::regex::Dialect::Xpath)
11957}
11958
11959/// Like [`compile_xpath_regex`] but pre-validates the pattern against
11960/// the given dialect's grammar so XSLT-2.0 hosts surface FORX0002 on
11961/// constructs only XPath 3.0+ permits (notably non-capturing `(?:`).
11962fn compile_xpath_regex_dialect(
11963    pattern: &str, flags: &str, dialect: crate::regex::Dialect,
11964) -> Result<regex::Regex> {
11965    let literal = flags.contains('q');
11966    // Strict pre-parse: in XPath 2.0 / Xpath20 mode reject patterns
11967    // that include `(?:…)` and the XPath 3.0 inline-modifier forms.
11968    // Skip the pre-parse in literal mode (q flag) — there the
11969    // pattern is interpreted as plain text, not regex syntax.
11970    if !literal && dialect == crate::regex::Dialect::Xpath20 {
11971        crate::regex::parser::parse_with(pattern, dialect)
11972            .map_err(|e| xpath_err(format!("invalid regex: {e}"))
11973                .with_xpath_code("FORX0002"))?;
11974    }
11975    let mut inline = String::new();
11976    if !flags.is_empty() {
11977        // Dedupe flag letters — Rust's regex crate rejects duplicate
11978        // inline flag characters (`(?ii)` → error), but XPath 2.0
11979        // §7.6.1 silently accepts a flag appearing more than once.
11980        let mut seen = [false; 128];
11981        inline.push_str("(?");
11982        for c in flags.chars() {
11983            if matches!(c, 's' | 'm' | 'i' | 'x') {
11984                let idx = c as usize;
11985                if !seen[idx] {
11986                    seen[idx] = true;
11987                    inline.push(c);
11988                }
11989            }
11990        }
11991        inline.push(')');
11992        // No flags were translatable → drop the empty prefix.
11993        if inline.ends_with("(?)") { inline.clear(); }
11994    }
11995    let body = if literal {
11996        regex::escape(pattern)
11997    } else {
11998        translate_xsd_regex_escapes(pattern)
11999    };
12000    let full = format!("{inline}{body}");
12001    regex::Regex::new(&full)
12002        .map_err(|e| xpath_err(format!("invalid regex: {e}")).with_xpath_code("FORX0002"))
12003}
12004
12005/// Convert the XSD-specific regex escapes (`\c`, `\C`, `\i`, `\I`)
12006/// into character-class equivalents Rust's `regex` crate understands.
12007///
12008/// * `\c` — XML NameChar:  letters, digits, `.`, `-`, `_`, `:`,
12009///   plus the Unicode extensions specified in XML 1.0 § 2.3.  We
12010///   approximate with the practical ASCII subset most tests rely on.
12011/// * `\i` — XML NameStartChar — like `\c` minus the digit /
12012///   dash / dot characters that can't open a name.
12013/// * `\C` / `\I` — complement of the above.
12014///
12015/// Escapes inside `[...]` character classes are translated too, with
12016/// the same approximate expansion.  Other XSD-specific constructs
12017/// (`\p{Is...}` block test, character-class subtraction) pass through
12018/// unchanged and may error at compile time — the caller surfaces the
12019/// regex-crate error verbatim, which is the right diagnostic.
12020fn translate_xsd_regex_escapes(pattern: &str) -> String {
12021    const NAME_CHAR:        &str = "A-Za-z0-9._\\-:\u{00B7}\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}\u{200C}-\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}";
12022    const NAME_CHAR_NEG:    &str = "^A-Za-z0-9._\\-:\u{00B7}\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}\u{200C}-\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}";
12023    const NAME_START:       &str = "A-Za-z_:\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}\u{200C}-\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}";
12024    const NAME_START_NEG:   &str = "^A-Za-z_:\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}\u{200C}-\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}";
12025
12026    let mut out = String::with_capacity(pattern.len());
12027    let mut chars = pattern.chars().peekable();
12028    let mut in_class = false;
12029    while let Some(c) = chars.next() {
12030        match c {
12031            '\\' => {
12032                let next = chars.next().unwrap_or('\0');
12033                match next {
12034                    'c' => if in_class { out.push_str(NAME_CHAR) }    else { out.push_str(&format!("[{NAME_CHAR}]")) },
12035                    'C' => if in_class { out.push_str(NAME_CHAR_NEG) } else { out.push_str(&format!("[{NAME_CHAR_NEG}]")) },
12036                    'i' => if in_class { out.push_str(NAME_START) }   else { out.push_str(&format!("[{NAME_START}]")) },
12037                    'I' => if in_class { out.push_str(NAME_START_NEG) } else { out.push_str(&format!("[{NAME_START_NEG}]")) },
12038                    other => { out.push('\\'); out.push(other); }
12039                }
12040            }
12041            '[' => { out.push('['); in_class = true; }
12042            ']' => { out.push(']'); in_class = false; }
12043            _   => out.push(c),
12044        }
12045    }
12046    out
12047}
12048
12049/// XSD-namespace constructor functions used in XPath 2.0 / XSD 1.1
12050/// assertion expressions.  `xs:integer("42")` coerces a string to a
12051/// number, `xs:date("2024-01-01")` round-trips a date as a string,
12052/// etc.
12053///
12054/// Strict XPath 2.0 semantics distinguish these by atomic-value
12055/// type; our XPath 1.0 value model has only String / Number /
12056/// Boolean / NodeSet, so each constructor returns the variant the
12057/// downstream comparison operators will produce the right answer
12058/// for: numeric constructors → `Number`, boolean → `Boolean`, the
12059/// rest → `String` (round-tripped through normalize-space so leading/
12060/// trailing whitespace doesn't break a subsequent string equality).
12061fn xs_constructor<I: DocIndexLike>(
12062    local:    &str,
12063    args:     &[Value],
12064    idx:      &I,
12065    bindings: &dyn XPathBindings,
12066) -> Result<Value> {
12067    if args.len() != 1 {
12068        return Err(xpath_err(format!("xs:{local}(): requires exactly 1 argument")));
12069    }
12070    // XPath 2.0 §3.12.3: casting from an empty sequence yields the
12071    // empty sequence (when the target permits zero occurrences,
12072    // which the constructor functions do via their `T?` signature).
12073    if let Value::NodeSet(ref ns) = args[0] {
12074        if ns.is_empty() {
12075            return Ok(Value::NodeSet(Vec::new()));
12076        }
12077    }
12078    // F&O §17.1.5 — same-family precision-narrowing casts that
12079    // would lose information through the value_to_string round-trip.
12080    // Handle xs:date(xs:dateTime) here so the date portion + tz is
12081    // preserved (the dateTime lexical contains a `T` separator the
12082    // date lexical can't carry, breaking lexical_matches_type).
12083    if local == "date" {
12084        if let Value::Typed(t) = &args[0] {
12085            if t.kind == "dateTime" {
12086                // Strip the `T...` time portion, preserving any
12087                // trailing timezone designator.
12088                if let Some(t_pos) = t.lexical.find('T') {
12089                    let after = &t.lexical[t_pos + 1..];
12090                    let tz_start = after.rfind(|c| c == 'Z' || c == '+' || c == '-')
12091                        .filter(|&i| {
12092                            // Heuristic: a `+` or `-` near the END
12093                            // (within the last 6 chars) is the tz
12094                            // separator; earlier ones belong to the
12095                            // time portion's seconds (none usually).
12096                            i >= after.len().saturating_sub(6)
12097                        });
12098                    let tz = tz_start.map(|i| &after[i..]).unwrap_or("");
12099                    let lex = format!("{}{}", &t.lexical[..t_pos], tz);
12100                    return Ok(Value::Typed(Box::new(TypedAtomic {
12101                        kind: "date", lexical: lex, numeric: None, boolean: None, user_type: None,
12102                    })));
12103                }
12104            }
12105        }
12106    }
12107    // F&O §17.1.6 — xs:hexBinary ⇄ xs:base64Binary reinterprets the
12108    // same octets in the target's lexical form.
12109    if matches!(local, "hexBinary" | "base64Binary") {
12110        if let Some(lex) = convert_binary_kind(&args[0], local) {
12111            let kind = atomic_kind_static(local).unwrap();
12112            return Ok(Value::Typed(Box::new(TypedAtomic {
12113                kind, lexical: lex, numeric: None, boolean: None, user_type: None,
12114            })));
12115        }
12116    }
12117    // Constructing a gregorian type from an xs:date / xs:dateTime
12118    // extracts the relevant components, carrying the timezone (F&O
12119    // §17.1.7): `xs:gDay(xs:date('2001-02-15'))` is `---15`.
12120    if matches!(local, "gYear" | "gYearMonth" | "gMonth" | "gMonthDay" | "gDay") {
12121        if let Value::Typed(t) = &args[0] {
12122            let dk = match t.kind {
12123                "date"     => Some(DateKind::Date),
12124                "dateTime" => Some(DateKind::DateTime),
12125                _          => None,
12126            };
12127            if let Some(dk) = dk {
12128                if let Some((y, mo, d, _, _, _, _, tz)) = parse_xsd_date_time(&t.lexical, dk) {
12129                    let tzs = tz.map(format_tz_suffix).unwrap_or_default();
12130                    let lex = match local {
12131                        "gYear"      => format!("{y:04}{tzs}"),
12132                        "gYearMonth" => format!("{y:04}-{mo:02}{tzs}"),
12133                        "gMonth"     => format!("--{mo:02}{tzs}"),
12134                        "gMonthDay"  => format!("--{mo:02}-{d:02}{tzs}"),
12135                        _            => format!("---{d:02}{tzs}"),
12136                    };
12137                    let kind = atomic_kind_static(local).unwrap();
12138                    return Ok(Value::Typed(Box::new(TypedAtomic {
12139                        kind, lexical: lex, numeric: None, boolean: None, user_type: None,
12140                    })));
12141                }
12142            }
12143        }
12144    }
12145    let s = value_to_string_with(&args[0], idx, bindings);
12146    let trimmed = s.trim();
12147    // Resolve the static kind tag once.  Returning `None` means the
12148    // local name isn't a recognised XSD constructor.
12149    let kind = atomic_kind_static(local).ok_or_else(|| xpath_err(format!(
12150        "xs:{local}(): unknown XSD constructor function"
12151    )))?;
12152    // FORG0001 — reject lexical forms the target type can't represent.
12153    // Kept narrow to avoid disturbing the lenient numeric-truncation
12154    // path (`xs:integer(3.5)` truncates a *number* but rejects the
12155    // *string* '3.5'): only the binary lexical and xs:decimal's
12156    // no-exponent rule are enforced here.
12157    let lexically_invalid = match kind {
12158        "date" | "dateTime" | "time" | "duration" | "dayTimeDuration"
12159        | "yearMonthDuration" | "gYear" | "gYearMonth" | "gMonth"
12160        | "gMonthDay" | "gDay" | "hexBinary"
12161                       => !lexical_matches_type(trimmed, kind),
12162        "decimal"      => trimmed.contains(['e', 'E'])
12163                          || trimmed.parse::<f64>().is_err(),
12164        _ => false,
12165    };
12166    if lexically_invalid {
12167        // A lexically-valid year-bearing value beyond the representable
12168        // range is an overflow (FODT0001), not a malformed form.
12169        if date_year_out_of_range(trimmed, kind) {
12170            return Err(xpath_err(format!(
12171                "xs:{local}: year is outside the supported range: '{trimmed}'"
12172            )).with_xpath_code("FODT0001"));
12173        }
12174        return Err(xpath_err(format!(
12175            "xs:{local}: '{trimmed}' is not a valid lexical xs:{local}"
12176        )).with_xpath_code("FORG0001"));
12177    }
12178    // Build a TypedAtomic whose `numeric` / `boolean` caches reflect
12179    // the kind family — numeric kinds parse the lexical to f64,
12180    // xs:boolean parses lexical to bool, everything else carries
12181    // only the lexical form.
12182    // xs:double / xs:float accept `INF` / `-INF` / `NaN` (XSD §F.3)
12183    // and the XPath 1.0 spellings `Infinity` / `-Infinity`; map both
12184    // to the same numeric value so subsequent comparisons line up.
12185    let numeric = if is_numeric_kind(kind) {
12186        let raw = match trimmed {
12187            "INF"  | "Infinity"   => f64::INFINITY,
12188            "-INF" | "-Infinity"  => f64::NEG_INFINITY,
12189            "NaN"                 => f64::NAN,
12190            _ => match trimmed.parse::<f64>() {
12191                Ok(n)  => n,
12192                // FORG0001 — a string that doesn't parse as any
12193                // numeric literal isn't castable to xs:double / -float
12194                // / -decimal etc.  The XSD §F.3 special values
12195                // (INF/-INF/NaN) are matched explicitly above.
12196                Err(_) => return Err(xpath_err(format!(
12197                    "xs:{local}: '{trimmed}' is not a valid numeric \
12198                     lexical form"
12199                )).with_xpath_code("FORG0001")),
12200            },
12201        };
12202        // xs:float is 32-bit (IEEE 754 single, ~7 significant
12203        // digits) — round-trip through f32 so casting a high-
12204        // precision literal truncates: `xs:float(1.234567890123)`
12205        // ≈ `1.2345679`, not the source f64's 16-digit value.
12206        Some(if kind == "float" { raw as f32 as f64 } else { raw })
12207    } else { None };
12208    let boolean = if kind == "boolean" {
12209        // XPath 2.0 §17.1.4 — numeric → xs:boolean: zero / NaN are
12210        // false, every other finite value is true.  Strings keep
12211        // the four canonical XSD lexical forms.
12212        let from_num = match &args[0] {
12213            Value::Number(n)              => Some(!(n.as_f64() == 0.0 || n.as_f64().is_nan())),
12214            Value::Typed(t) if t.numeric.is_some() => {
12215                let n = t.numeric.unwrap();
12216                Some(!(n == 0.0 || n.is_nan()))
12217            }
12218            _ => None,
12219        };
12220        Some(from_num.unwrap_or_else(|| matches!(trimmed, "true" | "1")))
12221    } else { None };
12222    let lexical = if kind == "boolean" {
12223        if boolean == Some(true) { "true".into() } else { "false".into() }
12224    } else if kind == "double" {
12225        canonical_double_lex(numeric.unwrap_or(f64::NAN), &args[0])
12226    } else if kind == "float" {
12227        canonical_float_lex(numeric.unwrap_or(f64::NAN), &args[0])
12228    } else if kind == "decimal" {
12229        canonical_decimal_lex(numeric.unwrap_or(f64::NAN), trimmed)
12230    } else if is_integer_subkind(kind) && source_is_numeric(&args[0]) {
12231        // F&O §17.1.3 — casting a number to xs:integer (or any
12232        // integer subtype) truncates toward zero.  Casting from a
12233        // string still requires an integer lexical and is rejected
12234        // through the lexical-parse path.
12235        let n = numeric.unwrap_or(f64::NAN);
12236        if !n.is_finite() {
12237            return Err(xpath_err(format!(
12238                "xs:{local}: cannot cast non-finite numeric to {kind}"
12239            )).with_xpath_code("FOCA0002"));
12240        }
12241        (n.trunc() as i64).to_string()
12242    } else if matches!(kind, "date" | "dateTime" | "time") {
12243        canonical_date_time_lex(trimmed, kind)
12244    } else if matches!(kind, "gYear" | "gYearMonth" | "gMonth" | "gMonthDay" | "gDay") {
12245        // XSD §F.3 gregorian types — normalise the timezone tail so
12246        // `+00:00` becomes `Z`.  Otherwise preserve the lexical
12247        // form, including its sign and digit count.
12248        if let Some(stripped) = trimmed.strip_suffix("+00:00")
12249            .or_else(|| trimmed.strip_suffix("-00:00")) {
12250            format!("{stripped}Z")
12251        } else {
12252            trimmed.to_string()
12253        }
12254    } else if kind == "yearMonthDuration" {
12255        canonical_year_month_duration_lex(trimmed)
12256    } else if kind == "dayTimeDuration" {
12257        canonical_day_time_duration_lex(trimmed)
12258    } else if kind == "hexBinary" {
12259        // XSD §3.2.15 — the canonical lexical form uses upper-case
12260        // hex digits.
12261        trimmed.to_ascii_uppercase()
12262    } else { trimmed.to_string() };
12263    Ok(Value::Typed(Box::new(TypedAtomic { kind, lexical, numeric, boolean, user_type: None })))
12264}
12265
12266/// Resolve an XSD type local name to a stable `&'static str`.  Used
12267/// by the `xs:` constructor dispatch and the cast/instance-of paths
12268/// so the kind tag costs zero allocation.
12269pub fn atomic_kind_static(local: &str) -> Option<&'static str> {
12270    Some(match local {
12271        "string" => "string",
12272        "normalizedString" => "normalizedString",
12273        "token" => "token",
12274        "Name" => "Name",
12275        "NCName" => "NCName",
12276        "QName" => "QName",
12277        "ID" => "ID",
12278        "IDREF" => "IDREF",
12279        "IDREFS" => "IDREFS",
12280        "ENTITY" => "ENTITY",
12281        "ENTITIES" => "ENTITIES",
12282        "NMTOKEN" => "NMTOKEN",
12283        "NMTOKENS" => "NMTOKENS",
12284        "anyURI" => "anyURI",
12285        "language" => "language",
12286        "NOTATION" => "NOTATION",
12287        "boolean" => "boolean",
12288        "integer" => "integer",
12289        "int" => "int",
12290        "long" => "long",
12291        "short" => "short",
12292        "byte" => "byte",
12293        "unsignedInt" => "unsignedInt",
12294        "unsignedLong" => "unsignedLong",
12295        "unsignedShort" => "unsignedShort",
12296        "unsignedByte" => "unsignedByte",
12297        "nonNegativeInteger" => "nonNegativeInteger",
12298        "nonPositiveInteger" => "nonPositiveInteger",
12299        "positiveInteger" => "positiveInteger",
12300        "negativeInteger" => "negativeInteger",
12301        "decimal" => "decimal",
12302        "double" => "double",
12303        "float" => "float",
12304        "date" => "date",
12305        "dateTime" => "dateTime",
12306        "time" => "time",
12307        "duration" => "duration",
12308        "dayTimeDuration" => "dayTimeDuration",
12309        "yearMonthDuration" => "yearMonthDuration",
12310        "gYear" => "gYear",
12311        "gYearMonth" => "gYearMonth",
12312        "gMonth" => "gMonth",
12313        "gMonthDay" => "gMonthDay",
12314        "gDay" => "gDay",
12315        "hexBinary" => "hexBinary",
12316        "base64Binary" => "base64Binary",
12317        "untypedAtomic" => "untypedAtomic",
12318        "anyAtomicType" => "anyAtomicType",
12319        _ => return None,
12320    })
12321}
12322
12323fn is_numeric_kind(k: &str) -> bool {
12324    matches!(k,
12325        "integer" | "int" | "long" | "short" | "byte"
12326        | "unsignedInt" | "unsignedLong" | "unsignedShort" | "unsignedByte"
12327        | "nonNegativeInteger" | "nonPositiveInteger"
12328        | "positiveInteger" | "negativeInteger"
12329        | "decimal" | "double" | "float")
12330}
12331
12332/// Integer-family XSD types (xs:integer and every derived subtype).
12333fn is_integer_subkind(k: &str) -> bool {
12334    matches!(k,
12335        "integer" | "int" | "long" | "short" | "byte"
12336        | "unsignedInt" | "unsignedLong" | "unsignedShort" | "unsignedByte"
12337        | "nonNegativeInteger" | "nonPositiveInteger"
12338        | "positiveInteger" | "negativeInteger")
12339}
12340
12341/// True iff `v` carries an XPath numeric value — either a bare
12342/// `Value::Number` or a `TypedAtomic` whose cached `numeric` slot was
12343/// populated by a numeric constructor.
12344fn source_is_numeric(v: &Value) -> bool {
12345    match v {
12346        Value::Number(_) => true,
12347        Value::Typed(t)  => t.numeric.is_some(),
12348        _                => false,
12349    }
12350}
12351
12352/// XSD canonical form of `xs:yearMonthDuration` — total months
12353/// decomposed into `PnYnM`, dropping zero-valued components.  Zero
12354/// duration is `P0M`.
12355fn canonical_year_month_duration_lex(s: &str) -> String {
12356    let months = match parse_year_month_duration_months(s) {
12357        Some(m) => m,
12358        None    => return s.to_string(),
12359    };
12360    if months == 0 { return "P0M".into(); }
12361    let mut out = String::with_capacity(8);
12362    let total = if months < 0 { out.push('-'); -months } else { months };
12363    out.push('P');
12364    let y = total / 12;
12365    let m = total % 12;
12366    if y > 0 { out.push_str(&y.to_string()); out.push('Y'); }
12367    if m > 0 { out.push_str(&m.to_string()); out.push('M'); }
12368    out
12369}
12370
12371/// Toggle the leading sign on a duration lexical (`P…` ↔ `-P…`).
12372/// Idempotent for the empty / zero case (`PT0S` stays `PT0S` since
12373/// negative zero is the same value).
12374fn negate_duration_lex(s: &str) -> String {
12375    let t = s.trim();
12376    if t == "PT0S" || t == "P0M" { return t.to_string(); }
12377    if let Some(rest) = t.strip_prefix('-') {
12378        rest.to_string()
12379    } else {
12380        format!("-{t}")
12381    }
12382}
12383
12384/// Render a signed second-count (with micro-second precision) into
12385/// the unnormalised `PnDTnHnMnS` lexical form.  Caller should pipe
12386/// through [`canonical_day_time_duration_lex`] to drop redundant
12387/// `0` components and reduce to canonical form.
12388fn format_day_time_duration_micros(total_us: i64) -> String {
12389    if total_us == 0 { return "PT0S".into(); }
12390    let neg = total_us < 0;
12391    let mut rem = total_us.unsigned_abs() as u64;
12392    let mut out = String::with_capacity(16);
12393    if neg { out.push('-'); }
12394    out.push('P');
12395    let us_per_day = 86_400u64 * 1_000_000;
12396    let days = rem / us_per_day;
12397    rem %= us_per_day;
12398    if days > 0 { out.push_str(&days.to_string()); out.push('D'); }
12399    if rem == 0 { return out; }
12400    out.push('T');
12401    let us_per_hour = 3600u64 * 1_000_000;
12402    let h = rem / us_per_hour;
12403    rem %= us_per_hour;
12404    if h > 0 { out.push_str(&h.to_string()); out.push('H'); }
12405    let us_per_min = 60u64 * 1_000_000;
12406    let m = rem / us_per_min;
12407    rem %= us_per_min;
12408    if m > 0 { out.push_str(&m.to_string()); out.push('M'); }
12409    if rem > 0 {
12410        let secs = rem / 1_000_000;
12411        let frac = rem % 1_000_000;
12412        out.push_str(&secs.to_string());
12413        if frac != 0 {
12414            let frac_str = format!("{frac:06}");
12415            let trimmed  = frac_str.trim_end_matches('0');
12416            out.push('.');
12417            out.push_str(trimmed);
12418        }
12419        out.push('S');
12420    }
12421    out
12422}
12423
12424/// XSD canonical form of `xs:dayTimeDuration` — total seconds (with
12425/// fractional precision) decomposed into `PnDTnHnMnS`.  Zero
12426/// duration is `PT0S`.  Fractional seconds drop trailing zeros and
12427/// the decimal point when integral.
12428fn canonical_day_time_duration_lex(s: &str) -> String {
12429    // Total micro-seconds, then re-decompose.  Parsing fractional
12430    // input keeps the fraction intact for round-tripping `PT10.03S`
12431    // etc. without underflow to integer seconds.
12432    let total_us = match parse_day_time_duration_micros(s) {
12433        Some(u) => u,
12434        None    => return s.to_string(),
12435    };
12436    if total_us == 0 { return "PT0S".into(); }
12437    let mut out = String::with_capacity(16);
12438    let mut rem = if total_us < 0 { out.push('-'); -total_us } else { total_us };
12439    out.push('P');
12440    let us_per_day = 86_400 * 1_000_000;
12441    let days = rem / us_per_day;
12442    rem %= us_per_day;
12443    if days > 0 { out.push_str(&days.to_string()); out.push('D'); }
12444    if rem == 0 { return out; }
12445    out.push('T');
12446    let us_per_hour = 3600 * 1_000_000;
12447    let h = rem / us_per_hour;
12448    rem %= us_per_hour;
12449    if h > 0 { out.push_str(&h.to_string()); out.push('H'); }
12450    let us_per_min = 60 * 1_000_000;
12451    let m = rem / us_per_min;
12452    rem %= us_per_min;
12453    if m > 0 { out.push_str(&m.to_string()); out.push('M'); }
12454    if rem > 0 {
12455        let secs = rem / 1_000_000;
12456        let frac = rem % 1_000_000;
12457        out.push_str(&secs.to_string());
12458        if frac != 0 {
12459            let frac_str = format!("{frac:06}");
12460            let trimmed  = frac_str.trim_end_matches('0');
12461            out.push('.');
12462            out.push_str(trimmed);
12463        }
12464        out.push('S');
12465    }
12466    out
12467}
12468
12469/// Parse an `xs:dayTimeDuration` into signed micro-seconds, keeping
12470/// fractional seconds.  Returns `None` for unrecognised shapes.
12471fn parse_day_time_duration_micros(s: &str) -> Option<i128> {
12472    let s = s.trim();
12473    let (sign, body) = match s.strip_prefix('-') {
12474        Some(rest) => (-1i128, rest),
12475        None       => (1i128, s),
12476    };
12477    let body = body.strip_prefix('P')?;
12478    let (day_part, time_part) = match body.find('T') {
12479        Some(i) => (&body[..i], &body[i + 1..]),
12480        None    => (body, ""),
12481    };
12482    let pull = |part: &str, marker: char| -> i128 {
12483        let Some(i) = part.find(marker) else { return 0; };
12484        let start = part[..i].rfind(|c: char| !c.is_ascii_digit() && c != '.')
12485            .map(|n| n + 1).unwrap_or(0);
12486        part[start..i].parse().unwrap_or(0)
12487    };
12488    // Seconds may carry a decimal point — handle separately so the
12489    // fractional value contributes microseconds.
12490    let pull_secs = |part: &str| -> i128 {
12491        let Some(i) = part.find('S') else { return 0; };
12492        let start = part[..i].rfind(|c: char| !c.is_ascii_digit() && c != '.')
12493            .map(|n| n + 1).unwrap_or(0);
12494        let lex = &part[start..i];
12495        if let Some((whole, frac)) = lex.split_once('.') {
12496            let w: i128 = whole.parse().unwrap_or(0);
12497            let take: String = frac.chars().chain(std::iter::repeat('0')).take(6).collect();
12498            let f: i128 = take.parse().unwrap_or(0);
12499            w * 1_000_000 + f
12500        } else {
12501            lex.parse::<i128>().unwrap_or(0) * 1_000_000
12502        }
12503    };
12504    let days   = pull(day_part,  'D');
12505    let hours  = pull(time_part, 'H');
12506    let mins   = pull(time_part, 'M');
12507    let secs_us = pull_secs(time_part);
12508    Some(sign * (days * 86_400 * 1_000_000
12509        + hours * 3600 * 1_000_000
12510        + mins  * 60   * 1_000_000
12511        + secs_us))
12512}
12513
12514/// Strip trailing zeros from the fractional-seconds component of an
12515/// `xs:dateTime` / `xs:time` lexical (XSD canonical form has no
12516/// trailing zeros, and drops the `.` entirely when all digits are
12517/// zero).  `xs:date` has no fractional seconds, so it round-trips.
12518/// Decode an `xs:hexBinary` lexical form into its octets.  `None` on
12519/// an odd length or a non-hex digit.
12520fn hex_to_bytes(s: &str) -> Option<Vec<u8>> {
12521    let s = s.trim();
12522    if s.len() % 2 != 0 { return None; }
12523    (0..s.len()).step_by(2)
12524        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
12525        .collect()
12526}
12527
12528/// Encode octets as the canonical upper-case `xs:hexBinary` lexical
12529/// form (XSD §3.2.15).
12530fn bytes_to_hex_upper(bytes: &[u8]) -> String {
12531    use std::fmt::Write;
12532    let mut s = String::with_capacity(bytes.len() * 2);
12533    for b in bytes { let _ = write!(s, "{b:02X}"); }
12534    s
12535}
12536
12537const BASE64_ALPHABET: &[u8; 64] =
12538    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
12539
12540/// Encode octets as the canonical `xs:base64Binary` lexical form.
12541fn bytes_to_base64(bytes: &[u8]) -> String {
12542    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
12543    for chunk in bytes.chunks(3) {
12544        let b0 = chunk[0] as u32;
12545        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
12546        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
12547        let n = (b0 << 16) | (b1 << 8) | b2;
12548        out.push(BASE64_ALPHABET[(n >> 18 & 63) as usize] as char);
12549        out.push(BASE64_ALPHABET[(n >> 12 & 63) as usize] as char);
12550        out.push(if chunk.len() > 1 { BASE64_ALPHABET[(n >> 6 & 63) as usize] as char } else { '=' });
12551        out.push(if chunk.len() > 2 { BASE64_ALPHABET[(n & 63) as usize] as char } else { '=' });
12552    }
12553    out
12554}
12555
12556/// Decode an `xs:base64Binary` lexical form into its octets, ignoring
12557/// insignificant whitespace.  `None` on a malformed input.
12558fn base64_to_bytes(s: &str) -> Option<Vec<u8>> {
12559    let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
12560    if clean.is_empty() || clean.len() % 4 != 0 { return None; }
12561    let val = |c: u8| -> Option<u32> {
12562        match c {
12563            b'A'..=b'Z' => Some((c - b'A') as u32),
12564            b'a'..=b'z' => Some((c - b'a' + 26) as u32),
12565            b'0'..=b'9' => Some((c - b'0' + 52) as u32),
12566            b'+' => Some(62),
12567            b'/' => Some(63),
12568            _ => None,
12569        }
12570    };
12571    let mut out = Vec::with_capacity(clean.len() / 4 * 3);
12572    for chunk in clean.chunks(4) {
12573        let pad = chunk.iter().filter(|&&c| c == b'=').count();
12574        let c0 = val(chunk[0])?;
12575        let c1 = val(chunk[1])?;
12576        let c2 = if pad >= 2 { 0 } else { val(chunk[2])? };
12577        let c3 = if pad >= 1 { 0 } else { val(chunk[3])? };
12578        let n = (c0 << 18) | (c1 << 12) | (c2 << 6) | c3;
12579        out.push((n >> 16) as u8);
12580        if pad < 2 { out.push((n >> 8) as u8); }
12581        if pad < 1 { out.push(n as u8); }
12582    }
12583    Some(out)
12584}
12585
12586/// F&O §17.1.6 — casting between xs:hexBinary and xs:base64Binary
12587/// reinterprets the same octets.  Returns the target's canonical
12588/// lexical form when `src` is a typed binary value of the other kind.
12589fn convert_binary_kind(src: &Value, target: &str) -> Option<String> {
12590    let t = match src { Value::Typed(t) => t, _ => return None };
12591    let bytes = match t.kind {
12592        "hexBinary"    => hex_to_bytes(&t.lexical)?,
12593        "base64Binary" => base64_to_bytes(&t.lexical)?,
12594        _ => return None,
12595    };
12596    Some(match target {
12597        "hexBinary"    => bytes_to_hex_upper(&bytes),
12598        "base64Binary" => bytes_to_base64(&bytes),
12599        _ => return None,
12600    })
12601}
12602
12603fn canonical_date_time_lex(s: &str, kind: &str) -> String {
12604    // XSD §F.3 — `+00:00` and `-00:00` are equivalent to `Z` in
12605    // every date-bearing type's canonical form.  Normalise the
12606    // tail so castings preserve only the canonical spelling
12607    // (date-065 expects `---31Z`, not `---31+00:00`).
12608    let s = if let Some(stripped) = s.strip_suffix("+00:00")
12609        .or_else(|| s.strip_suffix("-00:00")) {
12610        format!("{stripped}Z")
12611    } else {
12612        s.to_string()
12613    };
12614    let s = s.as_str();
12615    // XSD §3.2.7/§3.2.8 — `24:00:00` is the non-canonical midnight
12616    // form; normalise it to `00:00:00` (rolling the day for dateTime).
12617    if s.contains("24:00:00") {
12618        let dk = match kind {
12619            "dateTime" => DateKind::DateTime,
12620            "time"     => DateKind::Time,
12621            _          => DateKind::Date,
12622        };
12623        if !matches!(dk, DateKind::Date) {
12624            if let Some((y, mo, d, h, mi, sec, frac, tz)) = parse_xsd_date_time(s, dk) {
12625                return if matches!(dk, DateKind::DateTime) {
12626                    format_datetime_lexical(y, mo, d, h, mi, sec, frac, tz)
12627                } else {
12628                    let mut l = format!("{h:02}:{mi:02}:{sec:02}");
12629                    if frac != 0 {
12630                        let mut f = format!(".{frac:06}");
12631                        while f.ends_with('0') { f.pop(); }
12632                        l.push_str(&f);
12633                    }
12634                    if let Some(tz_m) = tz { l.push_str(&format_tz_suffix(tz_m)); }
12635                    l
12636                };
12637            }
12638        }
12639    }
12640    let dot = match s.find('.') { Some(i) => i, None => return s.to_string() };
12641    // The fractional digits run until the timezone designator or end.
12642    let after = &s[dot + 1..];
12643    let tz_off = after.find(|c: char| c == 'Z' || c == '+' || c == '-')
12644        .unwrap_or(after.len());
12645    let (digits, tz) = (&after[..tz_off], &after[tz_off..]);
12646    let trimmed = digits.trim_end_matches('0');
12647    if trimmed.is_empty() {
12648        // All-zero fractional — drop the `.` along with the digits.
12649        let mut out = String::with_capacity(s.len());
12650        out.push_str(&s[..dot]);
12651        out.push_str(tz);
12652        out
12653    } else {
12654        let mut out = String::with_capacity(s.len());
12655        out.push_str(&s[..dot]);
12656        out.push('.');
12657        out.push_str(trimmed);
12658        out.push_str(tz);
12659        out
12660    }
12661}
12662
12663/// Format a Unix timestamp as `YYYY-MM-DDZ` (xs:date with UTC zone).
12664fn format_date_utc(secs: i64) -> String {
12665    let (y, m, d) = days_to_ymd(secs.div_euclid(86_400));
12666    format!("{y:04}-{m:02}-{d:02}Z")
12667}
12668/// Format a Unix timestamp as `YYYY-MM-DDTHH:MM:SSZ` (xs:dateTime UTC).
12669fn format_datetime_utc(secs: i64) -> String {
12670    let days   = secs.div_euclid(86_400);
12671    let day_sec = secs.rem_euclid(86_400);
12672    let (y, m, d) = days_to_ymd(days);
12673    let h  = day_sec / 3600;
12674    let mi = (day_sec % 3600) / 60;
12675    let s  = day_sec % 60;
12676    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
12677}
12678/// Format a Unix timestamp as `HH:MM:SSZ` (xs:time UTC).
12679fn format_time_utc(secs: i64) -> String {
12680    let day_sec = secs.rem_euclid(86_400);
12681    let h  = day_sec / 3600;
12682    let mi = (day_sec % 3600) / 60;
12683    let s  = day_sec % 60;
12684    format!("{h:02}:{mi:02}:{s:02}Z")
12685}
12686/// Convert days-since-1970-01-01 to (year, month, day).  Proleptic
12687/// Gregorian; sufficient for the 1970..=9999 range our XPath
12688/// `current-date()` will ever encounter.
12689fn days_to_ymd(days: i64) -> (i32, u32, u32) {
12690    // Howard Hinnant's "civil_from_days" — branchless and exact.
12691    let z = days + 719_468;
12692    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
12693    let doe = (z - era * 146_097) as u64;
12694    let yoe = (doe - doe/1460 + doe/36_524 - doe/146_096) / 365;
12695    let y   = yoe as i64 + era * 400;
12696    let doy = doe - (365 * yoe + yoe/4 - yoe/100);
12697    let mp  = (5 * doy + 2) / 153;
12698    let d   = (doy - (153 * mp + 2) / 5 + 1) as u32;
12699    let m   = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
12700    let y   = if m <= 2 { y + 1 } else { y };
12701    (y as i32, m, d)
12702}
12703
12704// ── helpers ───────────────────────────────────────────────────────────────────
12705
12706fn dedup_sort(nodes: &mut Vec<NodeId>) {
12707    nodes.sort_unstable();
12708    nodes.dedup();
12709}
12710
12711/// Dedup foreign-node pointers by identity.  We don't sort by
12712/// document order — XPath 1.0 doesn't define cross-document order,
12713/// and the consumers we target (libxslt's `xsl:copy-of` etc.)
12714/// iterate in encounter order regardless.
12715fn dedup_foreign(nodes: &mut Vec<ForeignNodePtr>) {
12716    let mut seen = HashSet::new();
12717    nodes.retain(|&p| seen.insert(p as usize));
12718}
12719
12720// ── kani panic-freedom proofs ────────────────────────────────────────────────
12721//
12722// Each `#[kani::proof]` below is a panic-freedom guarantee for one of the
12723// XPath axis-navigation helpers, exhaustively explored on bounded
12724// symbolic doc shapes (≤ `MAX_NODES` nodes, ≤ `MAX_CHILDREN` per node).
12725// The bug the fuzzer found in `following()` (slice OOB when the context
12726// node wasn't in its parent's children list) would be caught here in
12727// seconds — the harnesses then act as a regression barrier going forward.
12728//
12729// Run with `cargo kani`; install via:
12730//   cargo install --locked kani-verifier
12731//   cargo kani setup
12732//
12733// The module is gated on `#[cfg(kani)]`, so `cargo build` / `cargo test`
12734// never see it.
12735/// Unit tests for the pure helpers added in the 2.0 conformance
12736/// push.  These pin behaviour the W3C conformance walk would
12737/// otherwise be the only check on; they run as part of
12738/// `cargo test-all` so a regression surfaces immediately rather
12739/// than through a drop in the conformance score.
12740#[cfg(test)]
12741mod tests {
12742    use super::*;
12743
12744    // ── date/time year range (FODT0001 vs FORG0001) ──────────────
12745
12746    #[test]
12747    fn out_of_range_year_is_overflow_not_malformed() {
12748        // Years within the representable (i32) range — including
12749        // 5-digit and negative years — are supported, NOT overflows.
12750        assert!(!date_year_out_of_range("2024-05-01T00:00:00", "dateTime"));
12751        assert!(!date_year_out_of_range("21999-05-01", "date"));
12752        assert!(!date_year_out_of_range("-1999-05-01", "date"));
12753        assert!(!date_year_out_of_range("99999", "gYear"));
12754        assert!(!date_year_out_of_range("123456-05", "gYearMonth"));
12755
12756        // Lexically well-formed but the year exceeds i32 → overflow
12757        // (the caller raises FODT0001 rather than FORG0001).
12758        assert!(date_year_out_of_range("999999999999-05-01T00:00:00", "dateTime"));
12759        assert!(date_year_out_of_range("-999999999999-05-01", "date"));
12760        assert!(date_year_out_of_range("12345678901-05", "gYearMonth"));
12761        assert!(date_year_out_of_range("999999999999", "gYear"));
12762
12763        // A genuinely malformed value is NOT an overflow — it stays a
12764        // lexical error (FORG0001): a huge year doesn't excuse a bad
12765        // month/day or non-date text.
12766        assert!(!date_year_out_of_range("999999999999-13-99", "date"));
12767        assert!(!date_year_out_of_range("not-a-date", "date"));
12768        // Types without a year never report a year overflow.
12769        assert!(!date_year_out_of_range("999999999999", "gMonth"));
12770        assert!(!date_year_out_of_range("999999999999", "time"));
12771    }
12772
12773    // ── duration add / subtract (XPath 2.0 §10.4) ────────────────
12774
12775    #[test]
12776    fn duration_combine_dispatches_by_family() {
12777        let dur = |k: &'static str, lex: &str| TypedAtomic {
12778            kind: k, lexical: lex.to_string(), numeric: None, boolean: None, user_type: None,
12779        };
12780        let lex = |v: Option<Value>| match v {
12781            Some(Value::Typed(t)) => t.lexical.clone(),
12782            other => panic!("expected typed duration, got {other:?}"),
12783        };
12784        // yearMonthDuration combines by month count, not seconds.
12785        assert_eq!(
12786            lex(duration_combine(&dur("yearMonthDuration", "P2Y6M"),
12787                                 &dur("yearMonthDuration", "P6M"), false)),
12788            "P3Y");
12789        assert_eq!(
12790            lex(duration_combine(&dur("yearMonthDuration", "P0M"),
12791                                 &dur("yearMonthDuration", "P2Y"), true)),
12792            "-P2Y");
12793        // dayTimeDuration preserves fractional seconds via microseconds.
12794        assert_eq!(
12795            lex(duration_combine(&dur("dayTimeDuration", "P2D"),
12796                                 &dur("dayTimeDuration", "PT10.03S"), false)),
12797            "P2DT10.03S");
12798        // Mixing the two families has no defined sum.
12799        assert!(duration_combine(&dur("yearMonthDuration", "P1Y"),
12800                                 &dur("dayTimeDuration", "PT1H"), false).is_none());
12801    }
12802
12803    // ── format_number / negative-zero ────────────────────────────
12804
12805    #[test]
12806    fn format_number_preserves_negative_zero() {
12807        assert_eq!(format_number(-0.0), "-0");
12808        assert_eq!(format_number(0.0),  "0");
12809    }
12810
12811    #[test]
12812    fn format_number_handles_special_values() {
12813        assert_eq!(format_number(f64::INFINITY),     "Infinity");
12814        assert_eq!(format_number(f64::NEG_INFINITY), "-Infinity");
12815        assert_eq!(format_number(f64::NAN),          "NaN");
12816    }
12817
12818    #[test]
12819    fn format_number_integers_are_decimal_no_dot() {
12820        assert_eq!(format_number(42.0),   "42");
12821        assert_eq!(format_number(-7.0),   "-7");
12822        assert_eq!(format_number(0.5),    "0.5");
12823    }
12824
12825    // ── english_ordinal_suffix ───────────────────────────────────
12826
12827    #[test]
12828    fn ordinal_suffix_picks_st_nd_rd_th() {
12829        assert_eq!(english_ordinal_suffix(1),  "st");
12830        assert_eq!(english_ordinal_suffix(2),  "nd");
12831        assert_eq!(english_ordinal_suffix(3),  "rd");
12832        assert_eq!(english_ordinal_suffix(4),  "th");
12833        assert_eq!(english_ordinal_suffix(21), "st");
12834        assert_eq!(english_ordinal_suffix(22), "nd");
12835        assert_eq!(english_ordinal_suffix(23), "rd");
12836    }
12837
12838    #[test]
12839    fn ordinal_suffix_teens_are_all_th() {
12840        // 11th, 12th, 13th — the carve-out from the units rule.
12841        assert_eq!(english_ordinal_suffix(11), "th");
12842        assert_eq!(english_ordinal_suffix(12), "th");
12843        assert_eq!(english_ordinal_suffix(13), "th");
12844        // 111th, 112th, 113th — same carve-out at the century level.
12845        assert_eq!(english_ordinal_suffix(111), "th");
12846        assert_eq!(english_ordinal_suffix(112), "th");
12847        assert_eq!(english_ordinal_suffix(113), "th");
12848    }
12849
12850    // ── parse_duration_split ─────────────────────────────────────
12851
12852    #[test]
12853    fn duration_split_pure_year_month() {
12854        // P1Y = 12 months, no time component.
12855        assert_eq!(parse_duration_split("P1Y"),    Some((12,  0)));
12856        assert_eq!(parse_duration_split("P0Y12M"), Some((12,  0)));
12857        assert_eq!(parse_duration_split("P12M"),   Some((12,  0)));
12858        // P1Y6M = 18 months.
12859        assert_eq!(parse_duration_split("P1Y6M"),  Some((18,  0)));
12860    }
12861
12862    #[test]
12863    fn duration_split_pure_day_time() {
12864        assert_eq!(parse_duration_split("P1D"),       Some((0, 86_400)));
12865        assert_eq!(parse_duration_split("PT24H"),     Some((0, 86_400)));
12866        assert_eq!(parse_duration_split("PT1H30M"),   Some((0, 5400)));
12867        assert_eq!(parse_duration_split("-PT1H"),     Some((0, -3600)));
12868        assert_eq!(parse_duration_split("-P1DT12H"),  Some((0, -129_600)));
12869        assert_eq!(parse_duration_split("-PT36H"),    Some((0, -129_600)));
12870    }
12871
12872    #[test]
12873    fn duration_split_mixed_components() {
12874        assert_eq!(parse_duration_split("P1Y2M3DT4H5M6S"),
12875            Some((14, 3 * 86_400 + 4 * 3600 + 5 * 60 + 6)));
12876    }
12877
12878    #[test]
12879    fn duration_split_rejects_malformed() {
12880        assert_eq!(parse_duration_split("1Y"),  None); // missing P
12881        assert_eq!(parse_duration_split("PY"),  None); // empty digits
12882        assert_eq!(parse_duration_split("P1X"), None); // unknown component
12883        assert_eq!(parse_duration_split(""),    None);
12884    }
12885
12886    // ── canonical_double_lex (XSD §F.3) ──────────────────────────
12887
12888    #[test]
12889    fn canonical_double_zero_and_neg_zero() {
12890        assert_eq!(canonical_double_lex( 0.0, &Value::Number(Numeric::Double( 0.0))), "0");
12891        assert_eq!(canonical_double_lex(-0.0, &Value::Number(Numeric::Double(-0.0))), "-0");
12892        // Source-string sign carries through too — important when
12893        // the cast path stringified `-0` to `"0"` before reaching us.
12894        assert_eq!(canonical_double_lex(0.0, &Value::String("-0".into())), "-0");
12895    }
12896
12897    #[test]
12898    fn canonical_double_special_values() {
12899        assert_eq!(canonical_double_lex(f64::INFINITY,     &Value::Number(Numeric::Double(0.0))), "INF");
12900        assert_eq!(canonical_double_lex(f64::NEG_INFINITY, &Value::Number(Numeric::Double(0.0))), "-INF");
12901        assert_eq!(canonical_double_lex(f64::NAN,          &Value::Number(Numeric::Double(0.0))), "NaN");
12902    }
12903
12904    #[test]
12905    fn canonical_double_fixed_point_window() {
12906        // Inside [1e-6, 1e7): decimal notation.
12907        assert_eq!(canonical_double_lex(1.5,    &Value::Number(Numeric::Double(1.5))),    "1.5");
12908        assert_eq!(canonical_double_lex(0.001,  &Value::Number(Numeric::Double(0.001))),  "0.001");
12909        assert_eq!(canonical_double_lex(42.0,   &Value::Number(Numeric::Double(42.0))),   "42");
12910        assert_eq!(canonical_double_lex(9_999_999.0,
12911                                        &Value::Number(Numeric::Double(9_999_999.0))),
12912                   "9999999");
12913    }
12914
12915    #[test]
12916    fn canonical_double_scientific_outside_window() {
12917        // 1e7 and above → scientific with explicit `.0`.
12918        assert_eq!(canonical_double_lex(1e7,  &Value::Number(Numeric::Double(1e7))),  "1.0E7");
12919        assert_eq!(canonical_double_lex(1e-8, &Value::Number(Numeric::Double(1e-8))), "1.0E-8");
12920        // Saxon's mantissa form has `.0` even when the value is
12921        // representable as a single mantissa digit.
12922        assert_eq!(canonical_double_lex(1.5e10, &Value::Number(Numeric::Double(1.5e10))), "1.5E10");
12923    }
12924
12925    // ── canonical_decimal_lex ────────────────────────────────────
12926
12927    #[test]
12928    fn canonical_decimal_drops_negative_zero() {
12929        assert_eq!(canonical_decimal_lex(-0.0, "-0.0"), "0");
12930        assert_eq!(canonical_decimal_lex( 0.0,  "0"),   "0");
12931    }
12932
12933    #[test]
12934    fn canonical_decimal_passes_fixed_point_through() {
12935        assert_eq!(canonical_decimal_lex(1.5,  "1.5"),  "1.5");
12936        assert_eq!(canonical_decimal_lex(42.0, "42"),   "42");
12937    }
12938
12939    #[test]
12940    fn canonical_decimal_strips_scientific_input() {
12941        // Input in scientific form is canonicalised to integer
12942        // form when the value is integer-valued.
12943        assert_eq!(canonical_decimal_lex(1e3, "1e3"), "1000");
12944    }
12945
12946    // ── resolve_uri_rfc3986 / split_uri ──────────────────────────
12947
12948    #[test]
12949    fn split_uri_full_form() {
12950        let (s, a, p, q, f) = split_uri("http://host/path?q#frag");
12951        assert_eq!(s, Some("http"));
12952        assert_eq!(a, Some("host"));
12953        assert_eq!(p, "/path");
12954        assert_eq!(q, Some("q"));
12955        assert_eq!(f, Some("frag"));
12956    }
12957
12958    #[test]
12959    fn split_uri_relative_no_scheme() {
12960        let (s, a, p, q, f) = split_uri("path/to/file");
12961        assert_eq!(s, None);
12962        assert_eq!(a, None);
12963        assert_eq!(p, "path/to/file");
12964        assert_eq!(q, None);
12965        assert_eq!(f, None);
12966    }
12967
12968    #[test]
12969    fn remove_dot_segments_matches_rfc3986_examples() {
12970        // RFC 3986 §5.2.4 sample reductions.
12971        assert_eq!(remove_dot_segments("/a/b/c/./../../g"), "/a/g");
12972        assert_eq!(remove_dot_segments("mid/content=5/../6"), "mid/6");
12973        assert_eq!(remove_dot_segments("/./a/b"), "/a/b");
12974        assert_eq!(remove_dot_segments(""), "");
12975    }
12976
12977    #[test]
12978    fn resolve_uri_handles_rfc3986_normal_examples() {
12979        // RFC 3986 §5.4.1 — base http://a/b/c/d;p?q
12980        let base = "http://a/b/c/d;p?q";
12981        assert_eq!(resolve_uri_rfc3986(base, "g:h"),    "g:h");
12982        assert_eq!(resolve_uri_rfc3986(base, "g"),      "http://a/b/c/g");
12983        assert_eq!(resolve_uri_rfc3986(base, "./g"),    "http://a/b/c/g");
12984        assert_eq!(resolve_uri_rfc3986(base, "g/"),     "http://a/b/c/g/");
12985        assert_eq!(resolve_uri_rfc3986(base, "/g"),     "http://a/g");
12986        assert_eq!(resolve_uri_rfc3986(base, "?y"),     "http://a/b/c/d;p?y");
12987        assert_eq!(resolve_uri_rfc3986(base, "g?y"),    "http://a/b/c/g?y");
12988        assert_eq!(resolve_uri_rfc3986(base, "#s"),     "http://a/b/c/d;p?q#s");
12989        assert_eq!(resolve_uri_rfc3986(base, "g#s"),    "http://a/b/c/g#s");
12990        assert_eq!(resolve_uri_rfc3986(base, "../g"),   "http://a/b/g");
12991        assert_eq!(resolve_uri_rfc3986(base, "../../g"),"http://a/g");
12992    }
12993
12994    #[test]
12995    fn resolve_uri_empty_rel_yields_base() {
12996        let base = "http://www.baseuri.exmpl/tests/";
12997        assert_eq!(resolve_uri_rfc3986(base, ""), "http://www.baseuri.exmpl/tests/");
12998    }
12999
13000    // ── pattern_is_document_node / rewrite_document_node_prefix ──
13001    //
13002    // These live in xslt/src/pattern.rs; only the underlying
13003    // NodeTest::Document tag is exercised here through the
13004    // step-builder shape since the helpers are private to that
13005    // module.  Pattern-side coverage lives in xslt/tests.
13006
13007    // ── builtin_arity_ok ────────────────────────────────────────
13008    //
13009    // Lives in xslt/src/functions.rs; tested there.
13010}
13011
13012#[cfg(kani)]
13013mod proofs {
13014    use super::*;
13015    use std::ops::Range;
13016
13017    const MAX_NODES: usize = 3;
13018    const MAX_CHILDREN: usize = 2;
13019
13020    /// Symbolic doc index. `parent` and `children` are produced from
13021    /// `kani::any()` and constrained to an acyclic shape so the tree
13022    /// walks terminate within the harness unwind bounds.
13023    ///
13024    /// Cycle prevention: every `parent(i)` is either `None` or some id
13025    /// `< i`; every child id `> i`. That mirrors arena document order
13026    /// (parents indexed before children) without restricting the bug
13027    /// shape under test (slice OOB depends on `pos` vs. `len`, not on
13028    /// which specific ids occupy the slice).
13029    struct AnyIndex {
13030        parents: [Option<NodeId>; MAX_NODES],
13031        child_lens: [usize; MAX_NODES],
13032        child_buf: [[NodeId; MAX_CHILDREN]; MAX_NODES],
13033    }
13034
13035    impl AnyIndex {
13036        fn any() -> Self {
13037            let mut idx = AnyIndex {
13038                parents: [None; MAX_NODES],
13039                child_lens: [0; MAX_NODES],
13040                child_buf: [[0; MAX_CHILDREN]; MAX_NODES],
13041            };
13042            for i in 0..MAX_NODES {
13043                let p: Option<NodeId> = kani::any();
13044                if let Some(pid) = p {
13045                    kani::assume(pid < i);
13046                }
13047                idx.parents[i] = p;
13048
13049                let n: usize = kani::any();
13050                kani::assume(n <= MAX_CHILDREN);
13051                idx.child_lens[i] = n;
13052                for j in 0..MAX_CHILDREN {
13053                    let c: NodeId = kani::any();
13054                    kani::assume(c > i && c < MAX_NODES);
13055                    idx.child_buf[i][j] = c;
13056                }
13057            }
13058            idx
13059        }
13060    }
13061
13062    impl DocIndexLike for AnyIndex {
13063        fn children(&self, id: NodeId) -> &[NodeId] {
13064            if id >= MAX_NODES { return &[]; }
13065            &self.child_buf[id][..self.child_lens[id]]
13066        }
13067        fn parent(&self, id: NodeId) -> Option<NodeId> {
13068            if id >= MAX_NODES { None } else { self.parents[id] }
13069        }
13070        fn attr_range(&self, _: NodeId) -> Range<NodeId> { 0..0 }
13071
13072        // The four axis helpers under proof never call these; if a
13073        // future refactor reaches one, the proof will fail loudly.
13074        fn kind(&self, _: NodeId) -> XPathNodeKind  { unreachable!() }
13075        fn pi_target(&self, _: NodeId) -> &str       { unreachable!() }
13076        fn string_value(&self, _: NodeId) -> String  { unreachable!() }
13077        fn node_name(&self, _: NodeId) -> &str        { unreachable!() }
13078        fn local_name(&self, _: NodeId) -> &str       { unreachable!() }
13079        fn namespace_uri(&self, _: NodeId) -> &str    { unreachable!() }
13080    }
13081
13082    fn any_node() -> NodeId {
13083        let n: NodeId = kani::any();
13084        kani::assume(n < MAX_NODES);
13085        n
13086    }
13087
13088    #[kani::proof]
13089    #[kani::unwind(4)]
13090    fn following_siblings_never_panics() {
13091        let _ = following_siblings(any_node(), &AnyIndex::any());
13092    }
13093
13094    #[kani::proof]
13095    #[kani::unwind(4)]
13096    fn preceding_siblings_never_panics() {
13097        let _ = preceding_siblings(any_node(), &AnyIndex::any());
13098    }
13099
13100    // Disabled: `following` and `preceding` recurse through `collect_desc`,
13101    // which composes with the symbolic parent/children model to produce a
13102    // SAT formula Cadical can't converge on within any practical time
13103    // budget — multi-hour runs at `unwind=4` (and `unwind=8`) never
13104    // terminated.  The slice-OOB bug class the fuzzer found in `following`
13105    // is structurally identical in `following_siblings`, so the
13106    // `_siblings` proofs above already exhaust the panic shape worth
13107    // proving.  Left here as a marker for if/when a future Kani release
13108    // (or a different harness shape using `kani::cover!` on specific
13109    // assertions) makes these tractable.
13110    //
13111    // #[kani::proof]
13112    // #[kani::unwind(4)]
13113    // fn following_never_panics() {
13114    //     let _ = following(any_node(), &AnyIndex::any());
13115    // }
13116    //
13117    // #[kani::proof]
13118    // #[kani::unwind(4)]
13119    // fn preceding_never_panics() {
13120    //     let _ = preceding(any_node(), &AnyIndex::any());
13121    // }
13122}