Skip to main content

xpath_eval/
eval.rs

1//! Evaluates an `ast::Expr` against a `Document`/`Node<'a>` (§2-§3): the
2//! value model and conversions live in `value.rs`; this module adds the
3//! `EvaluationContext`, the operator semantics (§3.3/§3.4), and the full
4//! location-path/predicate evaluation (§2.2-§2.4).
5//!
6//! `last()`/`position()`/`count()` are implemented as functions directly
7//! here; the rest of the core function library (§4, including `id()`) is
8//! implemented in `functions.rs` and dispatched to from
9//! `evaluate_function` below (Phase 05).
10
11use crate::ast::{
12    AdditiveOp, Axis, EqualityOp, Expr, FilterExpr, FunctionCall, LocationPath, MultiplicativeOp,
13    NodeTest, PathExpr, PrimaryExpr, QName, RelationalOp, Step,
14};
15use crate::axes;
16use crate::document::{Node, NodeKind};
17use crate::functions;
18use crate::value::{Value, string_to_number};
19
20/// A variable-binding lookup hook: given a variable's `QName`, returns its
21/// bound value, if any.
22pub type VariableLookup<'a, N> = dyn Fn(&QName) -> Option<Value<N>> + 'a;
23
24/// A namespace-prefix resolution hook: given a name test's raw prefix
25/// string, returns the namespace URI it's bound to, if any. `None` means
26/// the prefix is unresolvable via this hook — a prefixed name test then
27/// never matches (see `resolve_prefix`/`matches_node_test`).
28pub type NamespaceLookup<'a> = dyn Fn(&str) -> Option<String> + 'a;
29
30/// The context an `Expr` is evaluated against (§2.2/§2.4): the context node,
31/// its 1-based proximity position and the context size within whatever
32/// node-set it was drawn from, a variable-binding lookup hook, and a
33/// namespace-prefix resolution hook.
34///
35/// `None` in `variables` means "no variables are bound" — every
36/// `PrimaryExpr::Variable` lookup then fails with `EvalError::UnboundVariable`.
37/// Nothing in this phase binds variables yet (that is a `schematron-engine`
38/// `<let>` concern), but `PrimaryExpr::Variable` still needs this real,
39/// clearly-erroring path rather than a panic or a silently-wrong default.
40///
41/// `None` in `namespaces` means "no namespace bindings are declared" —
42/// prefixed name tests then never match any node (see `resolve_prefix`'s
43/// doc comment for the full rationale).
44///
45/// `'ctx` is the node/document borrow lifetime (`N: Node<'ctx>`); `'hook` is
46/// an independent lifetime for the `variables`/`namespaces` hook references.
47/// The two are deliberately decoupled (see `plan/05b-decouple-hook-lifetime.md`):
48/// a hook built locally within a single `evaluate()` call (e.g. a closure
49/// over a temporary namespace-binding table) does not need to live as long
50/// as the document itself, and there is no `'hook: 'ctx` (or reverse) bound
51/// between them — nothing here ever stores the hook and the node together
52/// in a way that would require one.
53///
54/// `_ctx` is a zero-sized marker: `'ctx` is only used in the `N: Node<'ctx>`
55/// bound above, not in any field type on its own, and Rust requires every
56/// declared lifetime parameter of a struct to appear in a field (`E0392`
57/// otherwise) — this `PhantomData` satisfies that without adding any real
58/// state.
59#[derive(Clone, Copy)]
60pub struct EvaluationContext<'ctx, 'hook, N: Node<'ctx>> {
61    pub node: N,
62    pub position: usize,
63    pub size: usize,
64    pub variables: Option<&'hook VariableLookup<'hook, N>>,
65    pub namespaces: Option<&'hook NamespaceLookup<'hook>>,
66    _ctx: std::marker::PhantomData<&'ctx ()>,
67}
68
69impl<'ctx, 'hook, N: Node<'ctx>> EvaluationContext<'ctx, 'hook, N> {
70    /// A context whose node is both the sole member and the first member of
71    /// its own (size-1) context node-set, with no variable bindings and no
72    /// namespace context.
73    pub fn new(node: N) -> Self {
74        EvaluationContext {
75            node,
76            position: 1,
77            size: 1,
78            variables: None,
79            namespaces: None,
80            _ctx: std::marker::PhantomData,
81        }
82    }
83
84    /// Same variable-binding and namespace-resolution hooks, different
85    /// context node/position/size — used when descending into a predicate
86    /// or a nested path step.
87    fn with(&self, node: N, position: usize, size: usize) -> Self {
88        EvaluationContext {
89            node,
90            position,
91            size,
92            variables: self.variables,
93            namespaces: self.namespaces,
94            _ctx: std::marker::PhantomData,
95        }
96    }
97}
98
99/// An evaluation-time error. Distinct from `ParseError` (`parser.rs`), which
100/// covers syntax — this covers semantic failures once a well-formed `Expr`
101/// is evaluated against a concrete context.
102#[derive(Debug, Clone, PartialEq)]
103pub enum EvalError {
104    /// `PrimaryExpr::Function` named something other than one of the 27
105    /// implemented core-library functions (`last`/`position`/`count`,
106    /// Phase 04, plus the 24 more in `functions.rs`, Phase 05 — including
107    /// `id()`, see its doc comment there).
108    UnknownFunction(QName),
109    /// `PrimaryExpr::Variable` has no binding via the context's lookup hook.
110    UnboundVariable(QName),
111    /// A function called with the wrong number of arguments.
112    ArgumentCount {
113        function: &'static str,
114        expected: usize,
115        got: usize,
116    },
117    /// `count()`'s argument, or a `Union`/`FilterExpr`-predicate operand,
118    /// did not evaluate to a node-set.
119    ExpectedNodeSet { context: &'static str },
120}
121
122impl std::fmt::Display for EvalError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        match self {
125            EvalError::UnknownFunction(name) => {
126                write!(f, "unknown function: {}", format_qname(name))
127            }
128            EvalError::UnboundVariable(name) => {
129                write!(f, "unbound variable: ${}", format_qname(name))
130            }
131            EvalError::ArgumentCount {
132                function,
133                expected,
134                got,
135            } => write!(f, "{function}() expects {expected} argument(s), got {got}"),
136            EvalError::ExpectedNodeSet { context } => {
137                write!(f, "expected a node-set in {context}")
138            }
139        }
140    }
141}
142
143impl std::error::Error for EvalError {}
144
145fn format_qname(q: &QName) -> String {
146    match &q.prefix {
147        Some(p) => format!("{p}:{}", q.local),
148        None => q.local.clone(),
149    }
150}
151
152/// Evaluates `expr` against `ctx`, producing an XPath 1.0 value.
153pub fn evaluate<'ctx, 'hook, N: Node<'ctx>>(
154    expr: &Expr,
155    ctx: &EvaluationContext<'ctx, 'hook, N>,
156) -> Result<Value<N>, EvalError> {
157    match expr {
158        // §3.3: "Or"/"And" convert both operands to boolean. The spec does
159        // not mandate an evaluation order, and expressions here have no
160        // side effects other than errors — short-circuiting is a
161        // deliberate, documented choice (not evaluating the right operand
162        // can hide an error it would have raised).
163        Expr::Or(l, r) => {
164            if evaluate(l, ctx)?.to_boolean() {
165                Ok(Value::Boolean(true))
166            } else {
167                Ok(Value::Boolean(evaluate(r, ctx)?.to_boolean()))
168            }
169        }
170        Expr::And(l, r) => {
171            if !evaluate(l, ctx)?.to_boolean() {
172                Ok(Value::Boolean(false))
173            } else {
174                Ok(Value::Boolean(evaluate(r, ctx)?.to_boolean()))
175            }
176        }
177        Expr::Equality(l, op, r) => {
178            let lv = evaluate(l, ctx)?;
179            let rv = evaluate(r, ctx)?;
180            Ok(Value::Boolean(compare_equality(&lv, *op, &rv)))
181        }
182        Expr::Relational(l, op, r) => {
183            let lv = evaluate(l, ctx)?;
184            let rv = evaluate(r, ctx)?;
185            Ok(Value::Boolean(compare_relational(&lv, *op, &rv)))
186        }
187        Expr::Additive(l, op, r) => {
188            let lv = evaluate(l, ctx)?.to_number();
189            let rv = evaluate(r, ctx)?.to_number();
190            Ok(Value::Number(match op {
191                AdditiveOp::Add => lv + rv,
192                AdditiveOp::Sub => lv - rv,
193            }))
194        }
195        Expr::Multiplicative(l, op, r) => {
196            let lv = evaluate(l, ctx)?.to_number();
197            let rv = evaluate(r, ctx)?.to_number();
198            Ok(Value::Number(match op {
199                MultiplicativeOp::Mul => lv * rv,
200                // Rust's `f64` `/` and `%` already implement IEEE-754
201                // division and remainder, which is exactly what XPath's
202                // `div`/`mod` specify.
203                MultiplicativeOp::Div => lv / rv,
204                MultiplicativeOp::Mod => lv % rv,
205            }))
206        }
207        Expr::Negate(e) => Ok(Value::Number(-evaluate(e, ctx)?.to_number())),
208        Expr::Union(l, r) => {
209            let lv = evaluate(l, ctx)?;
210            let rv = evaluate(r, ctx)?;
211            match (lv, rv) {
212                (Value::NodeSet(mut a), Value::NodeSet(b)) => {
213                    a.extend(b);
214                    sort_dedup(&mut a);
215                    Ok(Value::NodeSet(a))
216                }
217                _ => Err(EvalError::ExpectedNodeSet {
218                    context: "union operand",
219                }),
220            }
221        }
222        Expr::Path(path) => evaluate_path(path, ctx),
223    }
224}
225
226// ---- Equality / Relational (§3.3/§3.4) ---------------------------------
227
228fn eq_result<T: PartialEq>(op: EqualityOp, l: T, r: T) -> bool {
229    match op {
230        EqualityOp::Eq => l == r,
231        EqualityOp::Ne => l != r,
232    }
233}
234
235fn rel_result(op: RelationalOp, l: f64, r: f64) -> bool {
236    match op {
237        RelationalOp::Lt => l < r,
238        RelationalOp::Le => l <= r,
239        RelationalOp::Gt => l > r,
240        RelationalOp::Ge => l >= r,
241    }
242}
243
244/// Whether any pair of nodes from `a` and `b` matches `f` — the shared
245/// node-set/node-set comparison of `compare_equality` and
246/// `compare_relational`.
247fn any_node_pair<'a, N: Node<'a>>(a: &[N], b: &[N], f: impl Fn(N, N) -> bool) -> bool {
248    a.iter()
249        .copied()
250        .any(|na| b.iter().copied().any(|nb| f(na, nb)))
251}
252
253/// §3.4, node-set comparison rules for `=`/`!=` (all four cases quoted
254/// verbatim in the plan), falling back to the "no node-set operand" rule
255/// (boolean, else number, else string) otherwise.
256fn compare_equality<'a, N: Node<'a>>(lhs: &Value<N>, op: EqualityOp, rhs: &Value<N>) -> bool {
257    match (lhs, rhs) {
258        (Value::NodeSet(a), Value::NodeSet(b)) => any_node_pair(a, b, |na, nb| {
259            eq_result(op, na.string_value(), nb.string_value())
260        }),
261        (Value::NodeSet(a), Value::Number(n)) | (Value::Number(n), Value::NodeSet(a)) => a
262            .iter()
263            .copied()
264            .any(|na| eq_result(op, string_to_number(&na.string_value()), *n)),
265        (Value::NodeSet(a), Value::String(s)) | (Value::String(s), Value::NodeSet(a)) => a
266            .iter()
267            .copied()
268            .any(|na| eq_result(op, na.string_value(), s.clone())),
269        (Value::NodeSet(a), Value::Boolean(b)) | (Value::Boolean(b), Value::NodeSet(a)) => {
270            eq_result(op, !a.is_empty(), *b)
271        }
272        _ => {
273            // §3.4, "neither object to be compared is a node-set": boolean
274            // if either side is boolean, else number if either side is
275            // number, else string.
276            if matches!(lhs, Value::Boolean(_)) || matches!(rhs, Value::Boolean(_)) {
277                eq_result(op, lhs.to_boolean(), rhs.to_boolean())
278            } else if matches!(lhs, Value::Number(_)) || matches!(rhs, Value::Number(_)) {
279                eq_result(op, lhs.to_number(), rhs.to_number())
280            } else {
281                eq_result(op, lhs.to_xpath_string(), rhs.to_xpath_string())
282            }
283        }
284    }
285}
286
287/// §3.4, relational operators. When neither operand is a node-set, always
288/// convert both to number (never string/boolean comparison). When a
289/// node-set is involved, the same four node-set-vs-X cases as equality
290/// apply, but — per the plan's clarification of the spec text — the
291/// underlying per-node "comparison" is always a numeric one: node-set vs
292/// node-set compares the two nodes' string-values *converted to numbers*
293/// (not compared as strings), and node-set vs string/boolean likewise
294/// converts the non-node-set side to a number before comparing, since a
295/// relational operator's result must in the end come from a numeric
296/// comparison.
297fn compare_relational<'a, N: Node<'a>>(lhs: &Value<N>, op: RelationalOp, rhs: &Value<N>) -> bool {
298    match (lhs, rhs) {
299        (Value::NodeSet(a), Value::NodeSet(b)) => any_node_pair(a, b, |na, nb| {
300            rel_result(
301                op,
302                string_to_number(&na.string_value()),
303                string_to_number(&nb.string_value()),
304            )
305        }),
306        (Value::NodeSet(a), Value::Number(n)) => a
307            .iter()
308            .copied()
309            .any(|na| rel_result(op, string_to_number(&na.string_value()), *n)),
310        (Value::Number(n), Value::NodeSet(a)) => a
311            .iter()
312            .copied()
313            .any(|na| rel_result(op, *n, string_to_number(&na.string_value()))),
314        (Value::NodeSet(a), Value::String(s)) => {
315            let sn = string_to_number(s);
316            a.iter()
317                .copied()
318                .any(|na| rel_result(op, string_to_number(&na.string_value()), sn))
319        }
320        (Value::String(s), Value::NodeSet(a)) => {
321            let sn = string_to_number(s);
322            a.iter()
323                .copied()
324                .any(|na| rel_result(op, sn, string_to_number(&na.string_value())))
325        }
326        (Value::NodeSet(a), Value::Boolean(b)) => {
327            rel_result(op, bool_to_number(!a.is_empty()), bool_to_number(*b))
328        }
329        (Value::Boolean(b), Value::NodeSet(a)) => {
330            rel_result(op, bool_to_number(*b), bool_to_number(!a.is_empty()))
331        }
332        _ => rel_result(op, lhs.to_number(), rhs.to_number()),
333    }
334}
335
336fn bool_to_number(b: bool) -> f64 {
337    if b { 1.0 } else { 0.0 }
338}
339
340// ---- Location paths / steps / predicates (§2.2-§2.4) -------------------
341
342pub(crate) fn sort_dedup<'a, N: Node<'a>>(nodes: &mut Vec<N>) {
343    nodes.sort_by(|a, b| a.document_order(*b));
344    nodes.dedup();
345}
346
347fn axis_nodes<'a, N: Node<'a>>(axis: Axis, n: N) -> Vec<N> {
348    match axis {
349        Axis::Ancestor => axes::ancestor(n).collect(),
350        Axis::AncestorOrSelf => axes::ancestor_or_self(n).collect(),
351        Axis::Attribute => axes::attribute(n).collect(),
352        Axis::Child => axes::child(n).collect(),
353        Axis::Descendant => axes::descendant(n).collect(),
354        Axis::DescendantOrSelf => axes::descendant_or_self(n).collect(),
355        Axis::Following => axes::following(n).collect(),
356        Axis::FollowingSibling => axes::following_sibling(n).collect(),
357        Axis::Namespace => axes::namespace(n).collect(),
358        Axis::Parent => axes::parent(n).collect(),
359        Axis::Preceding => axes::preceding(n).collect(),
360        Axis::PrecedingSibling => axes::preceding_sibling(n).collect(),
361        Axis::SelfAxis => axes::self_axis(n).collect(),
362    }
363}
364
365/// The "principal node type" of an axis (§2.3): attribute nodes for the
366/// attribute axis, namespace nodes for the namespace axis, elements for
367/// every other axis. Only relevant to `NameTest` node tests (`*`,
368/// `prefix:*`, `QName`) — node-type tests (`text()` etc.) match by kind
369/// regardless of axis.
370fn principal_node_kind(axis: Axis) -> NodeKind {
371    match axis {
372        Axis::Attribute => NodeKind::Attribute,
373        Axis::Namespace => NodeKind::Namespace,
374        _ => NodeKind::Element,
375    }
376}
377
378/// Resolves `prefix` to a namespace URI via `namespaces` (Phase 04a).
379/// `None` means the prefix cannot be resolved to any URI — either there is
380/// no resolver hook at all, or one is present but doesn't know this
381/// prefix. Both cases are treated identically: per spec an unbound
382/// namespace prefix is invalid, and a `NodeTest` carrying it can never
383/// match a real node's namespace URI. (Earlier, a missing hook fell back
384/// to comparing the raw prefix string itself against `namespace_uri` —
385/// correct only by coincidence, when a caller happened to use the URI
386/// itself as the "prefix"; that fallback has been removed as a footgun for
387/// callers who don't supply a resolver.)
388fn resolve_prefix<'hook>(
389    prefix: &str,
390    namespaces: Option<&NamespaceLookup<'hook>>,
391) -> Option<String> {
392    namespaces.and_then(|lookup| lookup(prefix))
393}
394
395/// `NodeTest::QName`/`NamespaceWildcard` carry the expression's raw
396/// `prefix` string, which only ever matches through a real, hook-resolved
397/// namespace URI (see `resolve_prefix`) — never through the prefix string
398/// itself.
399fn matches_node_test<'ctx, 'hook, N: Node<'ctx>>(
400    test: &NodeTest,
401    axis: Axis,
402    n: N,
403    namespaces: Option<&NamespaceLookup<'hook>>,
404) -> bool {
405    match test {
406        NodeTest::Node => true,
407        NodeTest::Text => n.kind() == NodeKind::Text,
408        NodeTest::Comment => n.kind() == NodeKind::Comment,
409        NodeTest::ProcessingInstruction(target) => {
410            n.kind() == NodeKind::ProcessingInstruction
411                && match target {
412                    None => true,
413                    Some(t) => n.expanded_name().is_some_and(|e| e.local_name == *t),
414                }
415        }
416        NodeTest::AnyName => n.kind() == principal_node_kind(axis) && n.expanded_name().is_some(),
417        NodeTest::NamespaceWildcard(prefix) => {
418            let Some(uri) = resolve_prefix(prefix, namespaces) else {
419                return false;
420            };
421            n.kind() == principal_node_kind(axis)
422                && n.expanded_name()
423                    .is_some_and(|e| e.namespace_uri.as_deref() == Some(uri.as_str()))
424        }
425        NodeTest::QName(qname) => {
426            let uri = match &qname.prefix {
427                None => None,
428                Some(p) => match resolve_prefix(p, namespaces) {
429                    Some(uri) => Some(uri),
430                    None => return false,
431                },
432            };
433            n.kind() == principal_node_kind(axis)
434                && n.expanded_name().is_some_and(|e| {
435                    e.local_name == qname.local && e.namespace_uri.as_deref() == uri.as_deref()
436                })
437        }
438    }
439}
440
441/// §2.4, verbatim: "A PredicateExpr is evaluated by evaluating the Expr and
442/// converting the result to a boolean. If the result is a number, the
443/// result will be converted to true if the number is equal to the context
444/// position and will be converted to false otherwise; if the result is not
445/// a number, then the result will be converted as if by a call to the
446/// boolean() function."
447fn predicate_truth<'a, N: Node<'a>>(val: &Value<N>, position: usize) -> bool {
448    match val {
449        Value::Number(n) => *n == position as f64,
450        _ => val.to_boolean(),
451    }
452}
453
454/// Applies each predicate in `predicates` to `nodes` **in sequence**: every
455/// predicate's context size/position come from the node-set the *previous*
456/// predicate filtered down to, not from the original, unfiltered input —
457/// this is what makes `child::*[1][self::foo]` differ from
458/// `child::*[self::foo][1]` (see `plan/04-evaluation-core.md`'s worked
459/// example and the module-level test of the same shape).
460fn apply_predicates<'ctx, 'hook, N: Node<'ctx>>(
461    predicates: &[Expr],
462    nodes: Vec<N>,
463    ctx: &EvaluationContext<'ctx, 'hook, N>,
464) -> Result<Vec<N>, EvalError> {
465    let mut current = nodes;
466    for pred in predicates {
467        let size = current.len();
468        let mut next = Vec::with_capacity(current.len());
469        for (i, &n) in current.iter().enumerate() {
470            let position = i + 1;
471            let pred_ctx = ctx.with(n, position, size);
472            let val = evaluate(pred, &pred_ctx)?;
473            if predicate_truth(&val, position) {
474                next.push(n);
475            }
476        }
477        current = next;
478    }
479    Ok(current)
480}
481
482/// Evaluates one `Step` against each of `context_nodes` individually — the
483/// axis is walked and node-test-filtered per context node (so per-node
484/// proximity positions/predicates are computed against that node's own
485/// axis order), and only the union of all those per-node results is sorted
486/// into document order and deduplicated at the end.
487fn evaluate_step<'ctx, 'hook, N: Node<'ctx>>(
488    step: &Step,
489    context_nodes: &[N],
490    ctx: &EvaluationContext<'ctx, 'hook, N>,
491) -> Result<Vec<N>, EvalError> {
492    let mut result = Vec::new();
493    for &n in context_nodes {
494        let candidates: Vec<N> = axis_nodes(step.axis, n)
495            .into_iter()
496            .filter(|&c| matches_node_test(&step.node_test, step.axis, c, ctx.namespaces))
497            .collect();
498        let filtered = apply_predicates(&step.predicates, candidates, ctx)?;
499        result.extend(filtered);
500    }
501    sort_dedup(&mut result);
502    Ok(result)
503}
504
505fn evaluate_location_steps<'ctx, 'hook, N: Node<'ctx>>(
506    steps: &[Step],
507    mut nodes: Vec<N>,
508    ctx: &EvaluationContext<'ctx, 'hook, N>,
509) -> Result<Vec<N>, EvalError> {
510    for step in steps {
511        nodes = evaluate_step(step, &nodes, ctx)?;
512    }
513    Ok(nodes)
514}
515
516fn evaluate_location_path<'ctx, 'hook, N: Node<'ctx>>(
517    loc: &LocationPath,
518    start: Vec<N>,
519    ctx: &EvaluationContext<'ctx, 'hook, N>,
520) -> Result<Vec<N>, EvalError> {
521    let start = if loc.is_absolute {
522        start
523            .first()
524            .map(|&n| vec![axes::root_of(n)])
525            .unwrap_or_default()
526    } else {
527        start
528    };
529    evaluate_location_steps(&loc.steps, start, ctx)
530}
531
532fn evaluate_path<'ctx, 'hook, N: Node<'ctx>>(
533    path: &PathExpr,
534    ctx: &EvaluationContext<'ctx, 'hook, N>,
535) -> Result<Value<N>, EvalError> {
536    match path {
537        PathExpr::Location(loc) => {
538            let result = evaluate_location_path(loc, vec![ctx.node], ctx)?;
539            Ok(Value::NodeSet(result))
540        }
541        PathExpr::Filter(filter) => evaluate_filter_expr(filter, ctx),
542        PathExpr::FilterLocation(filter, loc) => {
543            let filter_val = evaluate_filter_expr(filter, ctx)?;
544            let start = match filter_val {
545                Value::NodeSet(nodes) => nodes,
546                _ => {
547                    return Err(EvalError::ExpectedNodeSet {
548                        context: "path expression left of '/'",
549                    });
550                }
551            };
552            let result = evaluate_location_path(loc, start, ctx)?;
553            Ok(Value::NodeSet(result))
554        }
555    }
556}
557
558fn evaluate_filter_expr<'ctx, 'hook, N: Node<'ctx>>(
559    filter: &FilterExpr,
560    ctx: &EvaluationContext<'ctx, 'hook, N>,
561) -> Result<Value<N>, EvalError> {
562    let primary_val = evaluate_primary(&filter.primary, ctx)?;
563    if filter.predicates.is_empty() {
564        return Ok(primary_val);
565    }
566    match primary_val {
567        Value::NodeSet(mut nodes) => {
568            // `nodes` may come from a variable binding (`PrimaryExpr::Variable`)
569            // in caller-supplied, arbitrary order — unlike a location step's
570            // result, it isn't guaranteed to already be in document order.
571            // Predicate proximity position must be document-order-based
572            // (§2.4), so sort (and dedup, for the same reason `evaluate_step`
573            // does) before applying predicates.
574            sort_dedup(&mut nodes);
575            let filtered = apply_predicates(&filter.predicates, nodes, ctx)?;
576            Ok(Value::NodeSet(filtered))
577        }
578        _ => Err(EvalError::ExpectedNodeSet {
579            context: "filter expression predicate",
580        }),
581    }
582}
583
584fn evaluate_primary<'ctx, 'hook, N: Node<'ctx>>(
585    primary: &PrimaryExpr,
586    ctx: &EvaluationContext<'ctx, 'hook, N>,
587) -> Result<Value<N>, EvalError> {
588    match primary {
589        PrimaryExpr::Variable(qname) => {
590            let bound = ctx.variables.and_then(|lookup| lookup(qname));
591            bound.ok_or_else(|| EvalError::UnboundVariable(qname.clone()))
592        }
593        PrimaryExpr::Parenthesized(inner) => evaluate(inner, ctx),
594        PrimaryExpr::Literal(s) => Ok(Value::String(s.clone())),
595        PrimaryExpr::Number(n) => Ok(Value::Number(*n)),
596        PrimaryExpr::Function(call) => evaluate_function(call, ctx),
597    }
598}
599
600pub(crate) fn check_arity(
601    function: &'static str,
602    args: &[Expr],
603    expected: usize,
604) -> Result<(), EvalError> {
605    if args.len() == expected {
606        Ok(())
607    } else {
608        Err(EvalError::ArgumentCount {
609            function,
610            expected,
611            got: args.len(),
612        })
613    }
614}
615
616/// `last()`/`position()`/`count()` are implemented here (Phase 04); every
617/// other core-library function (Phase 05, `id()` excluded) is dispatched to
618/// `functions::dispatch`, which itself falls back to
619/// `EvalError::UnknownFunction` for any name it does not implement either —
620/// deliberately: a generic fallback would hide the difference between "not
621/// yet implemented" and "genuinely does not exist".
622fn evaluate_function<'ctx, 'hook, N: Node<'ctx>>(
623    call: &FunctionCall,
624    ctx: &EvaluationContext<'ctx, 'hook, N>,
625) -> Result<Value<N>, EvalError> {
626    if call.name.prefix.is_none() {
627        match call.name.local.as_str() {
628            "last" => {
629                check_arity("last", &call.args, 0)?;
630                return Ok(Value::Number(ctx.size as f64));
631            }
632            "position" => {
633                check_arity("position", &call.args, 0)?;
634                return Ok(Value::Number(ctx.position as f64));
635            }
636            "count" => {
637                check_arity("count", &call.args, 1)?;
638                return match evaluate(&call.args[0], ctx)? {
639                    Value::NodeSet(nodes) => Ok(Value::Number(nodes.len() as f64)),
640                    _ => Err(EvalError::ExpectedNodeSet {
641                        context: "count() argument",
642                    }),
643                };
644            }
645            _ => {}
646        }
647    }
648    functions::dispatch(call, ctx)
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use crate::document::fixture::{Fixture, TestNode, build};
655
656    fn eval_str<'a>(src: &str, node: TestNode<'a>) -> Result<Value<TestNode<'a>>, EvalError> {
657        let expr = crate::parse(src).expect("test expression must parse");
658        let ctx = EvaluationContext::new(node);
659        evaluate(&expr, &ctx)
660    }
661
662    fn node_set<'a>(v: Result<Value<TestNode<'a>>, EvalError>) -> Vec<TestNode<'a>> {
663        match v.expect("expected Ok") {
664            Value::NodeSet(nodes) => nodes,
665            other => panic!("expected a node-set, got {other:?}"),
666        }
667    }
668
669    fn root(f: &Fixture) -> TestNode<'_> {
670        f.doc.node(f.html)
671    }
672
673    // ---- Or / And (short-circuit) --------------------------------------
674
675    #[test]
676    fn or_short_circuits_when_left_is_true() {
677        let f = build();
678        assert_eq!(
679            eval_str("(1 = 1) or unknownfn()", root(&f)),
680            Ok(Value::Boolean(true))
681        );
682    }
683
684    #[test]
685    fn or_evaluates_right_when_left_is_false() {
686        let f = build();
687        assert!(matches!(
688            eval_str("(1 = 0) or unknownfn()", root(&f)),
689            Err(EvalError::UnknownFunction(_))
690        ));
691    }
692
693    #[test]
694    fn and_short_circuits_when_left_is_false() {
695        let f = build();
696        assert_eq!(
697            eval_str("(1 = 0) and unknownfn()", root(&f)),
698            Ok(Value::Boolean(false))
699        );
700    }
701
702    #[test]
703    fn and_evaluates_right_when_left_is_true() {
704        let f = build();
705        assert!(matches!(
706            eval_str("(1 = 1) and unknownfn()", root(&f)),
707            Err(EvalError::UnknownFunction(_))
708        ));
709    }
710
711    // ---- Equality: all four node-set comparison cases -------------------
712
713    #[test]
714    fn equality_node_set_vs_node_set() {
715        let f = build();
716        assert_eq!(
717            eval_str(
718                "/html/body/span/@data-x = /html/body/span/@data-x",
719                root(&f)
720            ),
721            Ok(Value::Boolean(true))
722        );
723        assert_eq!(
724            eval_str("/html/body/span/@data-x = /html/@id", root(&f)),
725            Ok(Value::Boolean(false))
726        );
727        assert_eq!(
728            eval_str("/html/body/span/@data-x != /html/@id", root(&f)),
729            Ok(Value::Boolean(true))
730        );
731    }
732
733    #[test]
734    fn equality_node_set_vs_number() {
735        let f = build();
736        assert_eq!(
737            eval_str("/html/body/span/@data-x = 1", root(&f)),
738            Ok(Value::Boolean(true))
739        );
740        assert_eq!(
741            eval_str("/html/body/span/@data-x = 2", root(&f)),
742            Ok(Value::Boolean(false))
743        );
744        assert_eq!(
745            eval_str("/html/body/span/@data-x != 2", root(&f)),
746            Ok(Value::Boolean(true))
747        );
748    }
749
750    #[test]
751    fn equality_node_set_vs_string() {
752        let f = build();
753        assert_eq!(
754            eval_str("/html/body/span/@data-x = '1'", root(&f)),
755            Ok(Value::Boolean(true))
756        );
757        assert_eq!(
758            eval_str("/html/@id = 'nope'", root(&f)),
759            Ok(Value::Boolean(false))
760        );
761    }
762
763    #[test]
764    fn equality_node_set_vs_boolean() {
765        let f = build();
766        // Non-empty node-set converts to boolean true.
767        assert_eq!(
768            eval_str("/html/body/span/@data-x = (1 = 1)", root(&f)),
769            Ok(Value::Boolean(true))
770        );
771        // Empty node-set converts to boolean false.
772        assert_eq!(
773            eval_str("/html/body/nonexistent = (1 = 1)", root(&f)),
774            Ok(Value::Boolean(false))
775        );
776    }
777
778    // ---- Relational operators with a node-set operand -------------------
779
780    #[test]
781    fn relational_with_node_set_operand() {
782        let f = build();
783        assert_eq!(
784            eval_str("/html/body/span/@data-x > 0", root(&f)),
785            Ok(Value::Boolean(true))
786        );
787        assert_eq!(
788            eval_str("/html/body/span/@data-x < 0", root(&f)),
789            Ok(Value::Boolean(false))
790        );
791        // node-set vs string: the string is converted to a number too.
792        assert_eq!(
793            eval_str("/html/body/span/@data-x > '0'", root(&f)),
794            Ok(Value::Boolean(true))
795        );
796        // node-set vs boolean.
797        assert_eq!(
798            eval_str("/html/body/span/@data-x > (1 = 0)", root(&f)),
799            Ok(Value::Boolean(true))
800        );
801    }
802
803    // ---- Additive / Multiplicative / Negate ------------------------------
804
805    #[test]
806    fn arithmetic_operators() {
807        let f = build();
808        assert_eq!(eval_str("1 + 2", root(&f)), Ok(Value::Number(3.0)));
809        assert_eq!(eval_str("1 - 2", root(&f)), Ok(Value::Number(-1.0)));
810        assert_eq!(eval_str("2 * 3", root(&f)), Ok(Value::Number(6.0)));
811        assert_eq!(eval_str("7 div 2", root(&f)), Ok(Value::Number(3.5)));
812        assert_eq!(eval_str("7 mod 3", root(&f)), Ok(Value::Number(1.0)));
813        assert_eq!(eval_str("-1 + 2", root(&f)), Ok(Value::Number(1.0)));
814    }
815
816    // ---- Union with dedup -------------------------------------------------
817
818    #[test]
819    fn union_deduplicates_overlapping_node_sets() {
820        let f = build();
821        let nodes = node_set(eval_str(
822            "/html/body/child::node() | /html/body/child::*",
823            root(&f),
824        ));
825        assert_eq!(
826            nodes,
827            vec![
828                f.doc.node(f.text0),
829                f.doc.node(f.span),
830                f.doc.node(f.comment),
831                f.doc.node(f.pi),
832                f.doc.node(f.text2),
833            ],
834            "span (present in both operands) must appear exactly once, in document order"
835        );
836    }
837
838    // ---- Multi-step paths with predicates --------------------------------
839
840    #[test]
841    fn multi_step_path_with_numeric_predicate() {
842        let f = build();
843        let nodes = node_set(eval_str("/html/body/child::node()[3]", root(&f)));
844        assert_eq!(nodes, vec![f.doc.node(f.comment)]);
845    }
846
847    #[test]
848    fn multi_step_mixed_axis_path_with_predicate() {
849        let f = build();
850        // descendant-or-self (via "//") + child::text()[2] — the classic
851        // XPath gotcha: `[2]` is *not* "the 2nd text node in the whole
852        // subtree". A step's predicates apply once per context node
853        // contributed by the previous step, each with its own local
854        // position/size, and the results are unioned — exactly like the
855        // well-known `//para[1]` example (which selects the first `para`
856        // *child of each element that has one*, not the first `para` in
857        // the document). Here, `descendant-or-self::node()` contributes
858        // html/body/text0/span/text1/comment/pi/text2 as context nodes;
859        // only `body` has 2+ text children ([text0, text2]), so `[2]`
860        // picks `body`'s 2nd text child (text2) — `span`'s single text
861        // child (text1) has no position 2 and contributes nothing.
862        let nodes = node_set(eval_str("/html//text()[2]", root(&f)));
863        assert_eq!(nodes, vec![f.doc.node(f.text2)]);
864    }
865
866    // ---- Sequential predicate filtering (the plan's flagged risk) --------
867
868    #[test]
869    fn predicate_order_changes_the_result() {
870        let f = build();
871        // body's children in document order: text0, span, comment, pi, text2
872        // — span is the 2nd child.
873        let position_then_test = node_set(eval_str(
874            "/html/body/child::node()[2][self::span]",
875            root(&f),
876        ));
877        assert_eq!(
878            position_then_test,
879            vec![f.doc.node(f.span)],
880            "[2] keeps the 2nd child (span); [self::span] then confirms it against itself"
881        );
882
883        let test_then_position = node_set(eval_str(
884            "/html/body/child::node()[self::span][2]",
885            root(&f),
886        ));
887        assert_eq!(
888            test_then_position,
889            Vec::<TestNode>::new(),
890            "[self::span] first narrows to just [span] (size 1); [2] then has nothing at position 2"
891        );
892    }
893
894    // ---- last()/position()/count() ---------------------------------------
895
896    #[test]
897    fn last_position_count_in_predicates() {
898        let f = build();
899        assert_eq!(
900            node_set(eval_str("/html/body/child::node()[last()]", root(&f))),
901            vec![f.doc.node(f.text2)]
902        );
903        assert_eq!(
904            node_set(eval_str(
905                "/html/body/child::node()[position() > 1]",
906                root(&f)
907            )),
908            vec![
909                f.doc.node(f.span),
910                f.doc.node(f.comment),
911                f.doc.node(f.pi),
912                f.doc.node(f.text2),
913            ]
914        );
915        assert_eq!(
916            eval_str("count(/html/body/child::node())", root(&f)),
917            Ok(Value::Number(5.0))
918        );
919    }
920
921    // ---- §2.4 predicate coercion: numeric vs. boolean predicate ----------
922
923    #[test]
924    fn numeric_predicate_selects_by_position() {
925        let f = build();
926        let nodes = node_set(eval_str("/html/body/child::node()[3]", root(&f)));
927        assert_eq!(nodes, vec![f.doc.node(f.comment)]);
928    }
929
930    #[test]
931    fn non_numeric_predicate_uses_boolean_coercion() {
932        let f = build();
933        // "span" as a predicate means "does this candidate have a child
934        // named span?" — none of body's children (text/comment/pi nodes,
935        // or span itself, whose own child is a text node) do.
936        let nodes = node_set(eval_str("/html/body/child::node()[span]", root(&f)));
937        assert_eq!(nodes, Vec::<TestNode>::new());
938    }
939
940    // ---- Errors -----------------------------------------------------------
941
942    #[test]
943    fn unknown_function_is_an_error() {
944        let f = build();
945        assert!(matches!(
946            eval_str("foo()", root(&f)),
947            Err(EvalError::UnknownFunction(_))
948        ));
949    }
950
951    #[test]
952    fn unbound_variable_is_an_error() {
953        let f = build();
954        assert!(matches!(
955            eval_str("$missing", root(&f)),
956            Err(EvalError::UnboundVariable(_))
957        ));
958    }
959
960    #[test]
961    fn wrong_arity_is_an_error() {
962        let f = build();
963        assert!(matches!(
964            eval_str("last(1)", root(&f)),
965            Err(EvalError::ArgumentCount {
966                function: "last",
967                expected: 0,
968                got: 1
969            })
970        ));
971        assert!(matches!(
972            eval_str("count()", root(&f)),
973            Err(EvalError::ArgumentCount {
974                function: "count",
975                expected: 1,
976                got: 0
977            })
978        ));
979    }
980
981    #[test]
982    fn count_of_non_node_set_is_an_error() {
983        let f = build();
984        assert!(matches!(
985            eval_str("count(1)", root(&f)),
986            Err(EvalError::ExpectedNodeSet { .. })
987        ));
988    }
989
990    // ---- Variable-binding hook ---------------------------------------------
991
992    #[test]
993    fn bound_variable_resolves_via_the_lookup_hook() {
994        let f = build();
995        let lookup = |q: &QName| -> Option<Value<TestNode<'_>>> {
996            if q.prefix.is_none() && q.local == "x" {
997                Some(Value::Number(42.0))
998            } else {
999                None
1000            }
1001        };
1002        let ctx = EvaluationContext {
1003            node: root(&f),
1004            position: 1,
1005            size: 1,
1006            variables: Some(&lookup),
1007            namespaces: None,
1008            _ctx: std::marker::PhantomData,
1009        };
1010        let expr = crate::parse("$x + 1").unwrap();
1011        assert_eq!(evaluate(&expr, &ctx), Ok(Value::Number(43.0)));
1012    }
1013
1014    #[test]
1015    fn filter_expr_sorts_a_variable_supplied_node_set_into_document_order_before_predicates() {
1016        let f = build();
1017        let span = f.doc.node(f.span);
1018        let pi = f.doc.node(f.pi);
1019        // Deliberately unsorted: `pi` comes after `span` in document order,
1020        // but is listed first here. `$nodes[1]` must still pick the
1021        // document-order-first node (`span`), not the first `Vec` element —
1022        // otherwise it depends on the caller's arbitrary `Vec` order.
1023        let lookup = |q: &QName| -> Option<Value<TestNode<'_>>> {
1024            if q.prefix.is_none() && q.local == "nodes" {
1025                Some(Value::NodeSet(vec![pi, span]))
1026            } else {
1027                None
1028            }
1029        };
1030        let ctx = EvaluationContext {
1031            node: root(&f),
1032            position: 1,
1033            size: 1,
1034            variables: Some(&lookup),
1035            namespaces: None,
1036            _ctx: std::marker::PhantomData,
1037        };
1038        let expr = crate::parse("$nodes[1]").unwrap();
1039        assert_eq!(evaluate(&expr, &ctx), Ok(Value::NodeSet(vec![span])));
1040    }
1041
1042    // ---- Namespace-resolution hook (Phase 04a) -----------------------------
1043    //
1044    // `matches_node_test` is exercised directly here (not through
1045    // `evaluate`/`parse`) because it needs a node whose `expanded_name()`
1046    // carries a real, non-`None` `namespace_uri` — the shared
1047    // `document::fixture` tree never populates one (see the pre-existing
1048    // doc comment on `matches_node_test`), so a minimal standalone `Node`
1049    // impl is used instead of extending that fixture (out of scope here,
1050    // touching `document.rs`).
1051
1052    /// A single-node `Node` impl whose only purpose is carrying a chosen
1053    /// `namespace_uri`/`local_name` pair for the namespace-resolution tests
1054    /// below.
1055    #[derive(Clone, Copy, PartialEq, Eq)]
1056    struct NsTestNode {
1057        namespace_uri: Option<&'static str>,
1058        local_name: &'static str,
1059    }
1060
1061    impl<'a> Node<'a> for NsTestNode {
1062        fn kind(self) -> NodeKind {
1063            NodeKind::Element
1064        }
1065
1066        fn parent(self) -> Option<Self> {
1067            None
1068        }
1069
1070        fn children(self) -> impl Iterator<Item = Self> + 'a {
1071            std::iter::empty()
1072        }
1073
1074        fn attributes(self) -> impl Iterator<Item = Self> + 'a {
1075            std::iter::empty()
1076        }
1077
1078        fn namespaces(self) -> impl Iterator<Item = Self> + 'a {
1079            std::iter::empty()
1080        }
1081
1082        fn expanded_name(self) -> Option<crate::document::ExpandedName> {
1083            Some(crate::document::ExpandedName {
1084                namespace_uri: self.namespace_uri.map(str::to_string),
1085                local_name: self.local_name.to_string(),
1086            })
1087        }
1088
1089        fn string_value(self) -> String {
1090            String::new()
1091        }
1092
1093        fn document_order(self, _other: Self) -> std::cmp::Ordering {
1094            std::cmp::Ordering::Equal
1095        }
1096    }
1097
1098    fn ns_node() -> NsTestNode {
1099        NsTestNode {
1100            namespace_uri: Some("http://example/ns"),
1101            local_name: "rect",
1102        }
1103    }
1104
1105    fn qname_test() -> NodeTest {
1106        NodeTest::QName(QName {
1107            prefix: Some("p".to_string()),
1108            local: "rect".to_string(),
1109        })
1110    }
1111
1112    #[test]
1113    fn qname_test_matches_when_hook_resolves_prefix_to_the_real_uri() {
1114        let lookup =
1115            |p: &str| -> Option<String> { (p == "p").then(|| "http://example/ns".to_string()) };
1116        assert!(matches_node_test(
1117            &qname_test(),
1118            Axis::Child,
1119            ns_node(),
1120            Some(&lookup)
1121        ));
1122    }
1123
1124    #[test]
1125    fn qname_test_does_not_match_when_hook_does_not_know_the_prefix() {
1126        // The hook is present but returns `None` for "p" — that prefix is
1127        // unbound, so no node can match, regardless of what the raw prefix
1128        // string happens to look like.
1129        let lookup = |_: &str| -> Option<String> { None };
1130        assert!(!matches_node_test(
1131            &qname_test(),
1132            Axis::Child,
1133            ns_node(),
1134            Some(&lookup)
1135        ));
1136    }
1137
1138    #[test]
1139    fn qname_test_does_not_match_when_hook_present_but_unresolvable_even_if_raw_prefix_equals_the_uri()
1140     {
1141        // An unresolvable prefix must NOT fall back to comparing the raw
1142        // prefix string against the namespace URI — not even when that
1143        // raw string happens to equal the real URI.
1144        let mut node = ns_node();
1145        node.namespace_uri = Some("p");
1146        let lookup = |_: &str| -> Option<String> { None };
1147        assert!(!matches_node_test(
1148            &qname_test(),
1149            Axis::Child,
1150            node,
1151            Some(&lookup)
1152        ));
1153    }
1154
1155    #[test]
1156    fn qname_test_does_not_match_with_no_hook_at_all() {
1157        // No hook at all (`namespaces: None`) — a prefixed name test can
1158        // never match any node.
1159        assert!(!matches_node_test(
1160            &qname_test(),
1161            Axis::Child,
1162            ns_node(),
1163            None
1164        ));
1165    }
1166
1167    #[test]
1168    fn qname_test_does_not_match_with_no_hook_at_all_even_if_raw_prefix_equals_the_uri() {
1169        // Regression: earlier, no hook at all fell back to comparing the
1170        // raw prefix string itself against the namespace URI, so this
1171        // *would* have matched. That fallback is gone — no hook now means
1172        // no match, symmetric with the hook-present-but-unresolvable case
1173        // above.
1174        let mut node = ns_node();
1175        node.namespace_uri = Some("p");
1176        assert!(!matches_node_test(&qname_test(), Axis::Child, node, None));
1177    }
1178
1179    #[test]
1180    fn namespace_wildcard_matches_when_hook_resolves_prefix_to_the_real_uri() {
1181        let lookup =
1182            |p: &str| -> Option<String> { (p == "p").then(|| "http://example/ns".to_string()) };
1183        let test = NodeTest::NamespaceWildcard("p".to_string());
1184        assert!(matches_node_test(
1185            &test,
1186            Axis::Child,
1187            ns_node(),
1188            Some(&lookup)
1189        ));
1190    }
1191
1192    #[test]
1193    fn namespace_wildcard_does_not_match_when_hook_does_not_know_the_prefix() {
1194        let lookup = |_: &str| -> Option<String> { None };
1195        let test = NodeTest::NamespaceWildcard("p".to_string());
1196        assert!(!matches_node_test(
1197            &test,
1198            Axis::Child,
1199            ns_node(),
1200            Some(&lookup)
1201        ));
1202    }
1203
1204    #[test]
1205    fn namespace_wildcard_does_not_match_when_hook_present_but_unresolvable_even_if_raw_prefix_equals_the_uri()
1206     {
1207        let mut node = ns_node();
1208        node.namespace_uri = Some("p");
1209        let lookup = |_: &str| -> Option<String> { None };
1210        let test = NodeTest::NamespaceWildcard("p".to_string());
1211        assert!(!matches_node_test(&test, Axis::Child, node, Some(&lookup)));
1212    }
1213
1214    #[test]
1215    fn namespace_wildcard_does_not_match_with_no_hook_at_all() {
1216        let test = NodeTest::NamespaceWildcard("p".to_string());
1217        assert!(!matches_node_test(&test, Axis::Child, ns_node(), None));
1218    }
1219
1220    // ---- Phase 05b regression: hook lifetime decoupled from doc lifetime --
1221    //
1222    // A minimal parent/child-linked tree, purpose-built (like `NsTestNode`
1223    // above) because `document::fixture`'s shared tree never sets a real
1224    // namespace URI on an element. Unlike `NsTestNode`, this one has actual
1225    // parent/child links so a real `evaluate()` call — parse, axis walk,
1226    // node-test filtering, all of it — can be exercised end-to-end, not
1227    // just `matches_node_test` in isolation.
1228    #[derive(Debug)]
1229    enum HookLifetimeEntry {
1230        Root {
1231            children: Vec<usize>,
1232        },
1233        Element {
1234            #[allow(dead_code)]
1235            parent: usize,
1236            namespace_uri: Option<&'static str>,
1237            local_name: &'static str,
1238        },
1239    }
1240
1241    #[derive(Debug)]
1242    struct HookLifetimeArena(Vec<HookLifetimeEntry>);
1243
1244    #[derive(Clone, Copy, Debug)]
1245    struct HookLifetimeNode<'a> {
1246        arena: &'a HookLifetimeArena,
1247        idx: usize,
1248    }
1249
1250    impl<'a> PartialEq for HookLifetimeNode<'a> {
1251        fn eq(&self, other: &Self) -> bool {
1252            std::ptr::eq(self.arena, other.arena) && self.idx == other.idx
1253        }
1254    }
1255    impl<'a> Eq for HookLifetimeNode<'a> {}
1256
1257    impl<'a> Node<'a> for HookLifetimeNode<'a> {
1258        fn kind(self) -> NodeKind {
1259            match &self.arena.0[self.idx] {
1260                HookLifetimeEntry::Root { .. } => NodeKind::Root,
1261                HookLifetimeEntry::Element { .. } => NodeKind::Element,
1262            }
1263        }
1264        fn parent(self) -> Option<Self> {
1265            match &self.arena.0[self.idx] {
1266                HookLifetimeEntry::Root { .. } => None,
1267                HookLifetimeEntry::Element { parent, .. } => Some(HookLifetimeNode {
1268                    arena: self.arena,
1269                    idx: *parent,
1270                }),
1271            }
1272        }
1273        fn children(self) -> impl Iterator<Item = Self> + 'a {
1274            let arena = self.arena;
1275            let indices = match &arena.0[self.idx] {
1276                HookLifetimeEntry::Root { children } => children.clone(),
1277                HookLifetimeEntry::Element { .. } => Vec::new(),
1278            };
1279            indices
1280                .into_iter()
1281                .map(move |i| HookLifetimeNode { arena, idx: i })
1282        }
1283        fn attributes(self) -> impl Iterator<Item = Self> + 'a {
1284            std::iter::empty()
1285        }
1286        fn namespaces(self) -> impl Iterator<Item = Self> + 'a {
1287            std::iter::empty()
1288        }
1289        fn expanded_name(self) -> Option<crate::document::ExpandedName> {
1290            match &self.arena.0[self.idx] {
1291                HookLifetimeEntry::Root { .. } => None,
1292                HookLifetimeEntry::Element {
1293                    namespace_uri,
1294                    local_name,
1295                    ..
1296                } => Some(crate::document::ExpandedName {
1297                    namespace_uri: namespace_uri.map(str::to_string),
1298                    local_name: local_name.to_string(),
1299                }),
1300            }
1301        }
1302        fn string_value(self) -> String {
1303            String::new()
1304        }
1305        fn document_order(self, other: Self) -> std::cmp::Ordering {
1306            self.idx.cmp(&other.idx)
1307        }
1308    }
1309
1310    #[test]
1311    fn evaluate_result_with_locally_scoped_namespace_hook_outlives_the_hook() {
1312        // The document: lives for this whole test function — clearly
1313        // longer than the inner block below, exactly like a caller's
1314        // document living as long as its own function's `'a`.
1315        let arena = HookLifetimeArena(vec![
1316            HookLifetimeEntry::Root {
1317                children: vec![1, 2],
1318            },
1319            HookLifetimeEntry::Element {
1320                parent: 0,
1321                namespace_uri: Some("http://example/ns"),
1322                local_name: "rect",
1323            },
1324            HookLifetimeEntry::Element {
1325                parent: 0,
1326                namespace_uri: None,
1327                local_name: "other",
1328            },
1329        ]);
1330        let root_node = HookLifetimeNode {
1331            arena: &arena,
1332            idx: 0,
1333        };
1334
1335        // This inner block is the point of the test (see
1336        // `plan/05b-decouple-hook-lifetime.md`): the namespace-resolution
1337        // hook, and the `EvaluationContext` borrowing it via `'hook`, are
1338        // both built *and dropped* here — modeling a `schematron-engine`
1339        // caller that builds its namespace hook locally from a `Schema`'s
1340        // `<ns>` bindings inside a function body. Before Phase 05b,
1341        // `EvaluationContext`'s single lifetime forced this hook to live
1342        // exactly as long as `root_node`'s document borrow, so returning
1343        // `nodes` (computed via the hook, but only actually borrowing the
1344        // outer, longer-lived `arena`) out of this block could never
1345        // compile (rustc E0515). With `'hook` decoupled from `'ctx`, it
1346        // does.
1347        let nodes = {
1348            let bound_uri = "http://example/ns".to_string();
1349            let namespace_hook = move |prefix: &str| -> Option<String> {
1350                (prefix == "p").then(|| bound_uri.clone())
1351            };
1352            let ctx = EvaluationContext {
1353                node: root_node,
1354                position: 1,
1355                size: 1,
1356                variables: None,
1357                namespaces: Some(&namespace_hook),
1358                _ctx: std::marker::PhantomData,
1359            };
1360            let expr = crate::parse("p:rect").expect("test expression must parse");
1361            match evaluate(&expr, &ctx).expect("expected Ok") {
1362                Value::NodeSet(nodes) => nodes,
1363                other => panic!("expected a node-set, got {other:?}"),
1364            }
1365        };
1366
1367        // Used *after* the inner scope (and the hook it contained) has
1368        // ended — the actual assertion that the fix works, not just that
1369        // it compiles.
1370        assert_eq!(nodes.len(), 1);
1371        assert_eq!(
1372            nodes[0],
1373            HookLifetimeNode {
1374                arena: &arena,
1375                idx: 1
1376            }
1377        );
1378    }
1379}