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/// §3.4, node-set comparison rules for `=`/`!=` (all four cases quoted
245/// verbatim in the plan), falling back to the "no node-set operand" rule
246/// (boolean, else number, else string) otherwise.
247fn compare_equality<'a, N: Node<'a>>(lhs: &Value<N>, op: EqualityOp, rhs: &Value<N>) -> bool {
248    match (lhs, rhs) {
249        (Value::NodeSet(a), Value::NodeSet(b)) => a.iter().copied().any(|na| {
250            b.iter()
251                .copied()
252                .any(|nb| eq_result(op, na.string_value(), nb.string_value()))
253        }),
254        (Value::NodeSet(a), Value::Number(n)) | (Value::Number(n), Value::NodeSet(a)) => a
255            .iter()
256            .copied()
257            .any(|na| eq_result(op, string_to_number(&na.string_value()), *n)),
258        (Value::NodeSet(a), Value::String(s)) | (Value::String(s), Value::NodeSet(a)) => a
259            .iter()
260            .copied()
261            .any(|na| eq_result(op, na.string_value(), s.clone())),
262        (Value::NodeSet(a), Value::Boolean(b)) | (Value::Boolean(b), Value::NodeSet(a)) => {
263            eq_result(op, !a.is_empty(), *b)
264        }
265        _ => {
266            // §3.4, "neither object to be compared is a node-set": boolean
267            // if either side is boolean, else number if either side is
268            // number, else string.
269            if matches!(lhs, Value::Boolean(_)) || matches!(rhs, Value::Boolean(_)) {
270                eq_result(op, lhs.to_boolean(), rhs.to_boolean())
271            } else if matches!(lhs, Value::Number(_)) || matches!(rhs, Value::Number(_)) {
272                eq_result(op, lhs.to_number(), rhs.to_number())
273            } else {
274                eq_result(op, lhs.to_xpath_string(), rhs.to_xpath_string())
275            }
276        }
277    }
278}
279
280/// §3.4, relational operators. When neither operand is a node-set, always
281/// convert both to number (never string/boolean comparison). When a
282/// node-set is involved, the same four node-set-vs-X cases as equality
283/// apply, but — per the plan's clarification of the spec text — the
284/// underlying per-node "comparison" is always a numeric one: node-set vs
285/// node-set compares the two nodes' string-values *converted to numbers*
286/// (not compared as strings), and node-set vs string/boolean likewise
287/// converts the non-node-set side to a number before comparing, since a
288/// relational operator's result must in the end come from a numeric
289/// comparison.
290fn compare_relational<'a, N: Node<'a>>(lhs: &Value<N>, op: RelationalOp, rhs: &Value<N>) -> bool {
291    match (lhs, rhs) {
292        (Value::NodeSet(a), Value::NodeSet(b)) => a.iter().copied().any(|na| {
293            b.iter().copied().any(|nb| {
294                rel_result(
295                    op,
296                    string_to_number(&na.string_value()),
297                    string_to_number(&nb.string_value()),
298                )
299            })
300        }),
301        (Value::NodeSet(a), Value::Number(n)) => a
302            .iter()
303            .copied()
304            .any(|na| rel_result(op, string_to_number(&na.string_value()), *n)),
305        (Value::Number(n), Value::NodeSet(a)) => a
306            .iter()
307            .copied()
308            .any(|na| rel_result(op, *n, string_to_number(&na.string_value()))),
309        (Value::NodeSet(a), Value::String(s)) => {
310            let sn = string_to_number(s);
311            a.iter()
312                .copied()
313                .any(|na| rel_result(op, string_to_number(&na.string_value()), sn))
314        }
315        (Value::String(s), Value::NodeSet(a)) => {
316            let sn = string_to_number(s);
317            a.iter()
318                .copied()
319                .any(|na| rel_result(op, sn, string_to_number(&na.string_value())))
320        }
321        (Value::NodeSet(a), Value::Boolean(b)) => {
322            rel_result(op, bool_to_number(!a.is_empty()), bool_to_number(*b))
323        }
324        (Value::Boolean(b), Value::NodeSet(a)) => {
325            rel_result(op, bool_to_number(*b), bool_to_number(!a.is_empty()))
326        }
327        _ => rel_result(op, lhs.to_number(), rhs.to_number()),
328    }
329}
330
331fn bool_to_number(b: bool) -> f64 {
332    if b { 1.0 } else { 0.0 }
333}
334
335// ---- Location paths / steps / predicates (§2.2-§2.4) -------------------
336
337pub(crate) fn root_of<'a, N: Node<'a>>(n: N) -> N {
338    let mut cur = n;
339    while let Some(p) = cur.parent() {
340        cur = p;
341    }
342    cur
343}
344
345pub(crate) fn sort_dedup<'a, N: Node<'a>>(nodes: &mut Vec<N>) {
346    nodes.sort_by(|a, b| a.document_order(*b));
347    nodes.dedup();
348}
349
350fn axis_nodes<'a, N: Node<'a>>(axis: Axis, n: N) -> Vec<N> {
351    match axis {
352        Axis::Ancestor => axes::ancestor(n).collect(),
353        Axis::AncestorOrSelf => axes::ancestor_or_self(n).collect(),
354        Axis::Attribute => axes::attribute(n).collect(),
355        Axis::Child => axes::child(n).collect(),
356        Axis::Descendant => axes::descendant(n).collect(),
357        Axis::DescendantOrSelf => axes::descendant_or_self(n).collect(),
358        Axis::Following => axes::following(n).collect(),
359        Axis::FollowingSibling => axes::following_sibling(n).collect(),
360        Axis::Namespace => axes::namespace(n).collect(),
361        Axis::Parent => axes::parent(n).collect(),
362        Axis::Preceding => axes::preceding(n).collect(),
363        Axis::PrecedingSibling => axes::preceding_sibling(n).collect(),
364        Axis::SelfAxis => axes::self_axis(n).collect(),
365    }
366}
367
368/// The "principal node type" of an axis (§2.3): attribute nodes for the
369/// attribute axis, namespace nodes for the namespace axis, elements for
370/// every other axis. Only relevant to `NameTest` node tests (`*`,
371/// `prefix:*`, `QName`) — node-type tests (`text()` etc.) match by kind
372/// regardless of axis.
373fn principal_node_kind(axis: Axis) -> NodeKind {
374    match axis {
375        Axis::Attribute => NodeKind::Attribute,
376        Axis::Namespace => NodeKind::Namespace,
377        _ => NodeKind::Element,
378    }
379}
380
381/// Resolves `prefix` to a namespace URI via `namespaces` (Phase 04a).
382/// `None` means the prefix cannot be resolved to any URI — either there is
383/// no resolver hook at all, or one is present but doesn't know this
384/// prefix. Both cases are treated identically: per spec an unbound
385/// namespace prefix is invalid, and a `NodeTest` carrying it can never
386/// match a real node's namespace URI. (Earlier, a missing hook fell back
387/// to comparing the raw prefix string itself against `namespace_uri` —
388/// correct only by coincidence, when a caller happened to use the URI
389/// itself as the "prefix"; that fallback has been removed as a footgun for
390/// callers who don't supply a resolver.)
391fn resolve_prefix<'hook>(
392    prefix: &str,
393    namespaces: Option<&NamespaceLookup<'hook>>,
394) -> Option<String> {
395    namespaces.and_then(|lookup| lookup(prefix))
396}
397
398/// `NodeTest::QName`/`NamespaceWildcard` carry the expression's raw
399/// `prefix` string, which only ever matches through a real, hook-resolved
400/// namespace URI (see `resolve_prefix`) — never through the prefix string
401/// itself.
402fn matches_node_test<'ctx, 'hook, N: Node<'ctx>>(
403    test: &NodeTest,
404    axis: Axis,
405    n: N,
406    namespaces: Option<&NamespaceLookup<'hook>>,
407) -> bool {
408    match test {
409        NodeTest::Node => true,
410        NodeTest::Text => n.kind() == NodeKind::Text,
411        NodeTest::Comment => n.kind() == NodeKind::Comment,
412        NodeTest::ProcessingInstruction(target) => {
413            n.kind() == NodeKind::ProcessingInstruction
414                && match target {
415                    None => true,
416                    Some(t) => n.expanded_name().is_some_and(|e| e.local_name == *t),
417                }
418        }
419        NodeTest::AnyName => n.kind() == principal_node_kind(axis) && n.expanded_name().is_some(),
420        NodeTest::NamespaceWildcard(prefix) => {
421            let Some(uri) = resolve_prefix(prefix, namespaces) else {
422                return false;
423            };
424            n.kind() == principal_node_kind(axis)
425                && n.expanded_name()
426                    .is_some_and(|e| e.namespace_uri.as_deref() == Some(uri.as_str()))
427        }
428        NodeTest::QName(qname) => {
429            let uri = match &qname.prefix {
430                None => None,
431                Some(p) => match resolve_prefix(p, namespaces) {
432                    Some(uri) => Some(uri),
433                    None => return false,
434                },
435            };
436            n.kind() == principal_node_kind(axis)
437                && n.expanded_name().is_some_and(|e| {
438                    e.local_name == qname.local && e.namespace_uri.as_deref() == uri.as_deref()
439                })
440        }
441    }
442}
443
444/// §2.4, verbatim: "A PredicateExpr is evaluated by evaluating the Expr and
445/// converting the result to a boolean. If the result is a number, the
446/// result will be converted to true if the number is equal to the context
447/// position and will be converted to false otherwise; if the result is not
448/// a number, then the result will be converted as if by a call to the
449/// boolean() function."
450fn predicate_truth<'a, N: Node<'a>>(val: &Value<N>, position: usize) -> bool {
451    match val {
452        Value::Number(n) => *n == position as f64,
453        _ => val.to_boolean(),
454    }
455}
456
457/// Applies each predicate in `predicates` to `nodes` **in sequence**: every
458/// predicate's context size/position come from the node-set the *previous*
459/// predicate filtered down to, not from the original, unfiltered input —
460/// this is what makes `child::*[1][self::foo]` differ from
461/// `child::*[self::foo][1]` (see `plan/04-evaluation-core.md`'s worked
462/// example and the module-level test of the same shape).
463fn apply_predicates<'ctx, 'hook, N: Node<'ctx>>(
464    predicates: &[Expr],
465    nodes: Vec<N>,
466    ctx: &EvaluationContext<'ctx, 'hook, N>,
467) -> Result<Vec<N>, EvalError> {
468    let mut current = nodes;
469    for pred in predicates {
470        let size = current.len();
471        let mut next = Vec::with_capacity(current.len());
472        for (i, &n) in current.iter().enumerate() {
473            let position = i + 1;
474            let pred_ctx = ctx.with(n, position, size);
475            let val = evaluate(pred, &pred_ctx)?;
476            if predicate_truth(&val, position) {
477                next.push(n);
478            }
479        }
480        current = next;
481    }
482    Ok(current)
483}
484
485/// Evaluates one `Step` against each of `context_nodes` individually — the
486/// axis is walked and node-test-filtered per context node (so per-node
487/// proximity positions/predicates are computed against that node's own
488/// axis order), and only the union of all those per-node results is sorted
489/// into document order and deduplicated at the end.
490fn evaluate_step<'ctx, 'hook, N: Node<'ctx>>(
491    step: &Step,
492    context_nodes: &[N],
493    ctx: &EvaluationContext<'ctx, 'hook, N>,
494) -> Result<Vec<N>, EvalError> {
495    let mut result = Vec::new();
496    for &n in context_nodes {
497        let candidates: Vec<N> = axis_nodes(step.axis, n)
498            .into_iter()
499            .filter(|&c| matches_node_test(&step.node_test, step.axis, c, ctx.namespaces))
500            .collect();
501        let filtered = apply_predicates(&step.predicates, candidates, ctx)?;
502        result.extend(filtered);
503    }
504    sort_dedup(&mut result);
505    Ok(result)
506}
507
508fn evaluate_location_steps<'ctx, 'hook, N: Node<'ctx>>(
509    steps: &[Step],
510    mut nodes: Vec<N>,
511    ctx: &EvaluationContext<'ctx, 'hook, N>,
512) -> Result<Vec<N>, EvalError> {
513    for step in steps {
514        nodes = evaluate_step(step, &nodes, ctx)?;
515    }
516    Ok(nodes)
517}
518
519fn evaluate_location_path<'ctx, 'hook, N: Node<'ctx>>(
520    loc: &LocationPath,
521    start: Vec<N>,
522    ctx: &EvaluationContext<'ctx, 'hook, N>,
523) -> Result<Vec<N>, EvalError> {
524    let start = if loc.is_absolute {
525        start.first().map(|&n| vec![root_of(n)]).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
600fn check_arity(function: &'static str, args: &[Expr], expected: usize) -> Result<(), EvalError> {
601    if args.len() == expected {
602        Ok(())
603    } else {
604        Err(EvalError::ArgumentCount {
605            function,
606            expected,
607            got: args.len(),
608        })
609    }
610}
611
612/// `last()`/`position()`/`count()` are implemented here (Phase 04); every
613/// other core-library function (Phase 05, `id()` excluded) is dispatched to
614/// `functions::dispatch`, which itself falls back to
615/// `EvalError::UnknownFunction` for any name it does not implement either —
616/// deliberately: a generic fallback would hide the difference between "not
617/// yet implemented" and "genuinely does not exist".
618fn evaluate_function<'ctx, 'hook, N: Node<'ctx>>(
619    call: &FunctionCall,
620    ctx: &EvaluationContext<'ctx, 'hook, N>,
621) -> Result<Value<N>, EvalError> {
622    if call.name.prefix.is_none() {
623        match call.name.local.as_str() {
624            "last" => {
625                check_arity("last", &call.args, 0)?;
626                return Ok(Value::Number(ctx.size as f64));
627            }
628            "position" => {
629                check_arity("position", &call.args, 0)?;
630                return Ok(Value::Number(ctx.position as f64));
631            }
632            "count" => {
633                check_arity("count", &call.args, 1)?;
634                return match evaluate(&call.args[0], ctx)? {
635                    Value::NodeSet(nodes) => Ok(Value::Number(nodes.len() as f64)),
636                    _ => Err(EvalError::ExpectedNodeSet {
637                        context: "count() argument",
638                    }),
639                };
640            }
641            _ => {}
642        }
643    }
644    functions::dispatch(call, ctx)
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::document::fixture::{Fixture, TestNode, build};
651
652    fn eval_str<'a>(src: &str, node: TestNode<'a>) -> Result<Value<TestNode<'a>>, EvalError> {
653        let expr = crate::parse(src).expect("test expression must parse");
654        let ctx = EvaluationContext::new(node);
655        evaluate(&expr, &ctx)
656    }
657
658    fn node_set<'a>(v: Result<Value<TestNode<'a>>, EvalError>) -> Vec<TestNode<'a>> {
659        match v.expect("expected Ok") {
660            Value::NodeSet(nodes) => nodes,
661            other => panic!("expected a node-set, got {other:?}"),
662        }
663    }
664
665    fn root(f: &Fixture) -> TestNode<'_> {
666        f.doc.node(f.html)
667    }
668
669    // ---- Or / And (short-circuit) --------------------------------------
670
671    #[test]
672    fn or_short_circuits_when_left_is_true() {
673        let f = build();
674        assert_eq!(
675            eval_str("(1 = 1) or unknownfn()", root(&f)),
676            Ok(Value::Boolean(true))
677        );
678    }
679
680    #[test]
681    fn or_evaluates_right_when_left_is_false() {
682        let f = build();
683        assert!(matches!(
684            eval_str("(1 = 0) or unknownfn()", root(&f)),
685            Err(EvalError::UnknownFunction(_))
686        ));
687    }
688
689    #[test]
690    fn and_short_circuits_when_left_is_false() {
691        let f = build();
692        assert_eq!(
693            eval_str("(1 = 0) and unknownfn()", root(&f)),
694            Ok(Value::Boolean(false))
695        );
696    }
697
698    #[test]
699    fn and_evaluates_right_when_left_is_true() {
700        let f = build();
701        assert!(matches!(
702            eval_str("(1 = 1) and unknownfn()", root(&f)),
703            Err(EvalError::UnknownFunction(_))
704        ));
705    }
706
707    // ---- Equality: all four node-set comparison cases -------------------
708
709    #[test]
710    fn equality_node_set_vs_node_set() {
711        let f = build();
712        assert_eq!(
713            eval_str(
714                "/html/body/span/@data-x = /html/body/span/@data-x",
715                root(&f)
716            ),
717            Ok(Value::Boolean(true))
718        );
719        assert_eq!(
720            eval_str("/html/body/span/@data-x = /html/@id", root(&f)),
721            Ok(Value::Boolean(false))
722        );
723        assert_eq!(
724            eval_str("/html/body/span/@data-x != /html/@id", root(&f)),
725            Ok(Value::Boolean(true))
726        );
727    }
728
729    #[test]
730    fn equality_node_set_vs_number() {
731        let f = build();
732        assert_eq!(
733            eval_str("/html/body/span/@data-x = 1", root(&f)),
734            Ok(Value::Boolean(true))
735        );
736        assert_eq!(
737            eval_str("/html/body/span/@data-x = 2", root(&f)),
738            Ok(Value::Boolean(false))
739        );
740        assert_eq!(
741            eval_str("/html/body/span/@data-x != 2", root(&f)),
742            Ok(Value::Boolean(true))
743        );
744    }
745
746    #[test]
747    fn equality_node_set_vs_string() {
748        let f = build();
749        assert_eq!(
750            eval_str("/html/body/span/@data-x = '1'", root(&f)),
751            Ok(Value::Boolean(true))
752        );
753        assert_eq!(
754            eval_str("/html/@id = 'nope'", root(&f)),
755            Ok(Value::Boolean(false))
756        );
757    }
758
759    #[test]
760    fn equality_node_set_vs_boolean() {
761        let f = build();
762        // Non-empty node-set converts to boolean true.
763        assert_eq!(
764            eval_str("/html/body/span/@data-x = (1 = 1)", root(&f)),
765            Ok(Value::Boolean(true))
766        );
767        // Empty node-set converts to boolean false.
768        assert_eq!(
769            eval_str("/html/body/nonexistent = (1 = 1)", root(&f)),
770            Ok(Value::Boolean(false))
771        );
772    }
773
774    // ---- Relational operators with a node-set operand -------------------
775
776    #[test]
777    fn relational_with_node_set_operand() {
778        let f = build();
779        assert_eq!(
780            eval_str("/html/body/span/@data-x > 0", root(&f)),
781            Ok(Value::Boolean(true))
782        );
783        assert_eq!(
784            eval_str("/html/body/span/@data-x < 0", root(&f)),
785            Ok(Value::Boolean(false))
786        );
787        // node-set vs string: the string is converted to a number too.
788        assert_eq!(
789            eval_str("/html/body/span/@data-x > '0'", root(&f)),
790            Ok(Value::Boolean(true))
791        );
792        // node-set vs boolean.
793        assert_eq!(
794            eval_str("/html/body/span/@data-x > (1 = 0)", root(&f)),
795            Ok(Value::Boolean(true))
796        );
797    }
798
799    // ---- Additive / Multiplicative / Negate ------------------------------
800
801    #[test]
802    fn arithmetic_operators() {
803        let f = build();
804        assert_eq!(eval_str("1 + 2", root(&f)), Ok(Value::Number(3.0)));
805        assert_eq!(eval_str("1 - 2", root(&f)), Ok(Value::Number(-1.0)));
806        assert_eq!(eval_str("2 * 3", root(&f)), Ok(Value::Number(6.0)));
807        assert_eq!(eval_str("7 div 2", root(&f)), Ok(Value::Number(3.5)));
808        assert_eq!(eval_str("7 mod 3", root(&f)), Ok(Value::Number(1.0)));
809        assert_eq!(eval_str("-1 + 2", root(&f)), Ok(Value::Number(1.0)));
810    }
811
812    // ---- Union with dedup -------------------------------------------------
813
814    #[test]
815    fn union_deduplicates_overlapping_node_sets() {
816        let f = build();
817        let nodes = node_set(eval_str(
818            "/html/body/child::node() | /html/body/child::*",
819            root(&f),
820        ));
821        assert_eq!(
822            nodes,
823            vec![
824                f.doc.node(f.text0),
825                f.doc.node(f.span),
826                f.doc.node(f.comment),
827                f.doc.node(f.pi),
828                f.doc.node(f.text2),
829            ],
830            "span (present in both operands) must appear exactly once, in document order"
831        );
832    }
833
834    // ---- Multi-step paths with predicates --------------------------------
835
836    #[test]
837    fn multi_step_path_with_numeric_predicate() {
838        let f = build();
839        let nodes = node_set(eval_str("/html/body/child::node()[3]", root(&f)));
840        assert_eq!(nodes, vec![f.doc.node(f.comment)]);
841    }
842
843    #[test]
844    fn multi_step_mixed_axis_path_with_predicate() {
845        let f = build();
846        // descendant-or-self (via "//") + child::text()[2] — the classic
847        // XPath gotcha: `[2]` is *not* "the 2nd text node in the whole
848        // subtree". A step's predicates apply once per context node
849        // contributed by the previous step, each with its own local
850        // position/size, and the results are unioned — exactly like the
851        // well-known `//para[1]` example (which selects the first `para`
852        // *child of each element that has one*, not the first `para` in
853        // the document). Here, `descendant-or-self::node()` contributes
854        // html/body/text0/span/text1/comment/pi/text2 as context nodes;
855        // only `body` has 2+ text children ([text0, text2]), so `[2]`
856        // picks `body`'s 2nd text child (text2) — `span`'s single text
857        // child (text1) has no position 2 and contributes nothing.
858        let nodes = node_set(eval_str("/html//text()[2]", root(&f)));
859        assert_eq!(nodes, vec![f.doc.node(f.text2)]);
860    }
861
862    // ---- Sequential predicate filtering (the plan's flagged risk) --------
863
864    #[test]
865    fn predicate_order_changes_the_result() {
866        let f = build();
867        // body's children in document order: text0, span, comment, pi, text2
868        // — span is the 2nd child.
869        let position_then_test = node_set(eval_str(
870            "/html/body/child::node()[2][self::span]",
871            root(&f),
872        ));
873        assert_eq!(
874            position_then_test,
875            vec![f.doc.node(f.span)],
876            "[2] keeps the 2nd child (span); [self::span] then confirms it against itself"
877        );
878
879        let test_then_position = node_set(eval_str(
880            "/html/body/child::node()[self::span][2]",
881            root(&f),
882        ));
883        assert_eq!(
884            test_then_position,
885            Vec::<TestNode>::new(),
886            "[self::span] first narrows to just [span] (size 1); [2] then has nothing at position 2"
887        );
888    }
889
890    // ---- last()/position()/count() ---------------------------------------
891
892    #[test]
893    fn last_position_count_in_predicates() {
894        let f = build();
895        assert_eq!(
896            node_set(eval_str("/html/body/child::node()[last()]", root(&f))),
897            vec![f.doc.node(f.text2)]
898        );
899        assert_eq!(
900            node_set(eval_str(
901                "/html/body/child::node()[position() > 1]",
902                root(&f)
903            )),
904            vec![
905                f.doc.node(f.span),
906                f.doc.node(f.comment),
907                f.doc.node(f.pi),
908                f.doc.node(f.text2),
909            ]
910        );
911        assert_eq!(
912            eval_str("count(/html/body/child::node())", root(&f)),
913            Ok(Value::Number(5.0))
914        );
915    }
916
917    // ---- §2.4 predicate coercion: numeric vs. boolean predicate ----------
918
919    #[test]
920    fn numeric_predicate_selects_by_position() {
921        let f = build();
922        let nodes = node_set(eval_str("/html/body/child::node()[3]", root(&f)));
923        assert_eq!(nodes, vec![f.doc.node(f.comment)]);
924    }
925
926    #[test]
927    fn non_numeric_predicate_uses_boolean_coercion() {
928        let f = build();
929        // "span" as a predicate means "does this candidate have a child
930        // named span?" — none of body's children (text/comment/pi nodes,
931        // or span itself, whose own child is a text node) do.
932        let nodes = node_set(eval_str("/html/body/child::node()[span]", root(&f)));
933        assert_eq!(nodes, Vec::<TestNode>::new());
934    }
935
936    // ---- Errors -----------------------------------------------------------
937
938    #[test]
939    fn unknown_function_is_an_error() {
940        let f = build();
941        assert!(matches!(
942            eval_str("foo()", root(&f)),
943            Err(EvalError::UnknownFunction(_))
944        ));
945    }
946
947    #[test]
948    fn unbound_variable_is_an_error() {
949        let f = build();
950        assert!(matches!(
951            eval_str("$missing", root(&f)),
952            Err(EvalError::UnboundVariable(_))
953        ));
954    }
955
956    #[test]
957    fn wrong_arity_is_an_error() {
958        let f = build();
959        assert!(matches!(
960            eval_str("last(1)", root(&f)),
961            Err(EvalError::ArgumentCount {
962                function: "last",
963                expected: 0,
964                got: 1
965            })
966        ));
967        assert!(matches!(
968            eval_str("count()", root(&f)),
969            Err(EvalError::ArgumentCount {
970                function: "count",
971                expected: 1,
972                got: 0
973            })
974        ));
975    }
976
977    #[test]
978    fn count_of_non_node_set_is_an_error() {
979        let f = build();
980        assert!(matches!(
981            eval_str("count(1)", root(&f)),
982            Err(EvalError::ExpectedNodeSet { .. })
983        ));
984    }
985
986    // ---- Variable-binding hook ---------------------------------------------
987
988    #[test]
989    fn bound_variable_resolves_via_the_lookup_hook() {
990        let f = build();
991        let lookup = |q: &QName| -> Option<Value<TestNode<'_>>> {
992            if q.prefix.is_none() && q.local == "x" {
993                Some(Value::Number(42.0))
994            } else {
995                None
996            }
997        };
998        let ctx = EvaluationContext {
999            node: root(&f),
1000            position: 1,
1001            size: 1,
1002            variables: Some(&lookup),
1003            namespaces: None,
1004            _ctx: std::marker::PhantomData,
1005        };
1006        let expr = crate::parse("$x + 1").unwrap();
1007        assert_eq!(evaluate(&expr, &ctx), Ok(Value::Number(43.0)));
1008    }
1009
1010    #[test]
1011    fn filter_expr_sorts_a_variable_supplied_node_set_into_document_order_before_predicates() {
1012        let f = build();
1013        let span = f.doc.node(f.span);
1014        let pi = f.doc.node(f.pi);
1015        // Deliberately unsorted: `pi` comes after `span` in document order,
1016        // but is listed first here. `$nodes[1]` must still pick the
1017        // document-order-first node (`span`), not the first `Vec` element —
1018        // otherwise it depends on the caller's arbitrary `Vec` order.
1019        let lookup = |q: &QName| -> Option<Value<TestNode<'_>>> {
1020            if q.prefix.is_none() && q.local == "nodes" {
1021                Some(Value::NodeSet(vec![pi, span]))
1022            } else {
1023                None
1024            }
1025        };
1026        let ctx = EvaluationContext {
1027            node: root(&f),
1028            position: 1,
1029            size: 1,
1030            variables: Some(&lookup),
1031            namespaces: None,
1032            _ctx: std::marker::PhantomData,
1033        };
1034        let expr = crate::parse("$nodes[1]").unwrap();
1035        assert_eq!(evaluate(&expr, &ctx), Ok(Value::NodeSet(vec![span])));
1036    }
1037
1038    // ---- Namespace-resolution hook (Phase 04a) -----------------------------
1039    //
1040    // `matches_node_test` is exercised directly here (not through
1041    // `evaluate`/`parse`) because it needs a node whose `expanded_name()`
1042    // carries a real, non-`None` `namespace_uri` — the shared
1043    // `document::fixture` tree never populates one (see the pre-existing
1044    // doc comment on `matches_node_test`), so a minimal standalone `Node`
1045    // impl is used instead of extending that fixture (out of scope here,
1046    // touching `document.rs`).
1047
1048    /// A single-node `Node` impl whose only purpose is carrying a chosen
1049    /// `namespace_uri`/`local_name` pair for the namespace-resolution tests
1050    /// below.
1051    #[derive(Clone, Copy, PartialEq, Eq)]
1052    struct NsTestNode {
1053        namespace_uri: Option<&'static str>,
1054        local_name: &'static str,
1055    }
1056
1057    impl<'a> Node<'a> for NsTestNode {
1058        fn kind(self) -> NodeKind {
1059            NodeKind::Element
1060        }
1061
1062        fn parent(self) -> Option<Self> {
1063            None
1064        }
1065
1066        fn children(self) -> impl Iterator<Item = Self> + 'a {
1067            std::iter::empty()
1068        }
1069
1070        fn attributes(self) -> impl Iterator<Item = Self> + 'a {
1071            std::iter::empty()
1072        }
1073
1074        fn namespaces(self) -> impl Iterator<Item = Self> + 'a {
1075            std::iter::empty()
1076        }
1077
1078        fn expanded_name(self) -> Option<crate::document::ExpandedName> {
1079            Some(crate::document::ExpandedName {
1080                namespace_uri: self.namespace_uri.map(str::to_string),
1081                local_name: self.local_name.to_string(),
1082            })
1083        }
1084
1085        fn string_value(self) -> String {
1086            String::new()
1087        }
1088
1089        fn document_order(self, _other: Self) -> std::cmp::Ordering {
1090            std::cmp::Ordering::Equal
1091        }
1092    }
1093
1094    fn ns_node() -> NsTestNode {
1095        NsTestNode {
1096            namespace_uri: Some("http://example/ns"),
1097            local_name: "rect",
1098        }
1099    }
1100
1101    fn qname_test() -> NodeTest {
1102        NodeTest::QName(QName {
1103            prefix: Some("p".to_string()),
1104            local: "rect".to_string(),
1105        })
1106    }
1107
1108    #[test]
1109    fn qname_test_matches_when_hook_resolves_prefix_to_the_real_uri() {
1110        let lookup =
1111            |p: &str| -> Option<String> { (p == "p").then(|| "http://example/ns".to_string()) };
1112        assert!(matches_node_test(
1113            &qname_test(),
1114            Axis::Child,
1115            ns_node(),
1116            Some(&lookup)
1117        ));
1118    }
1119
1120    #[test]
1121    fn qname_test_does_not_match_when_hook_does_not_know_the_prefix() {
1122        // The hook is present but returns `None` for "p" — that prefix is
1123        // unbound, so no node can match, regardless of what the raw prefix
1124        // string happens to look like.
1125        let lookup = |_: &str| -> Option<String> { None };
1126        assert!(!matches_node_test(
1127            &qname_test(),
1128            Axis::Child,
1129            ns_node(),
1130            Some(&lookup)
1131        ));
1132    }
1133
1134    #[test]
1135    fn qname_test_does_not_match_when_hook_present_but_unresolvable_even_if_raw_prefix_equals_the_uri()
1136     {
1137        // An unresolvable prefix must NOT fall back to comparing the raw
1138        // prefix string against the namespace URI — not even when that
1139        // raw string happens to equal the real URI.
1140        let mut node = ns_node();
1141        node.namespace_uri = Some("p");
1142        let lookup = |_: &str| -> Option<String> { None };
1143        assert!(!matches_node_test(
1144            &qname_test(),
1145            Axis::Child,
1146            node,
1147            Some(&lookup)
1148        ));
1149    }
1150
1151    #[test]
1152    fn qname_test_does_not_match_with_no_hook_at_all() {
1153        // No hook at all (`namespaces: None`) — a prefixed name test can
1154        // never match any node.
1155        assert!(!matches_node_test(
1156            &qname_test(),
1157            Axis::Child,
1158            ns_node(),
1159            None
1160        ));
1161    }
1162
1163    #[test]
1164    fn qname_test_does_not_match_with_no_hook_at_all_even_if_raw_prefix_equals_the_uri() {
1165        // Regression: earlier, no hook at all fell back to comparing the
1166        // raw prefix string itself against the namespace URI, so this
1167        // *would* have matched. That fallback is gone — no hook now means
1168        // no match, symmetric with the hook-present-but-unresolvable case
1169        // above.
1170        let mut node = ns_node();
1171        node.namespace_uri = Some("p");
1172        assert!(!matches_node_test(&qname_test(), Axis::Child, node, None));
1173    }
1174
1175    #[test]
1176    fn namespace_wildcard_matches_when_hook_resolves_prefix_to_the_real_uri() {
1177        let lookup =
1178            |p: &str| -> Option<String> { (p == "p").then(|| "http://example/ns".to_string()) };
1179        let test = NodeTest::NamespaceWildcard("p".to_string());
1180        assert!(matches_node_test(
1181            &test,
1182            Axis::Child,
1183            ns_node(),
1184            Some(&lookup)
1185        ));
1186    }
1187
1188    #[test]
1189    fn namespace_wildcard_does_not_match_when_hook_does_not_know_the_prefix() {
1190        let lookup = |_: &str| -> Option<String> { None };
1191        let test = NodeTest::NamespaceWildcard("p".to_string());
1192        assert!(!matches_node_test(
1193            &test,
1194            Axis::Child,
1195            ns_node(),
1196            Some(&lookup)
1197        ));
1198    }
1199
1200    #[test]
1201    fn namespace_wildcard_does_not_match_when_hook_present_but_unresolvable_even_if_raw_prefix_equals_the_uri()
1202     {
1203        let mut node = ns_node();
1204        node.namespace_uri = Some("p");
1205        let lookup = |_: &str| -> Option<String> { None };
1206        let test = NodeTest::NamespaceWildcard("p".to_string());
1207        assert!(!matches_node_test(&test, Axis::Child, node, Some(&lookup)));
1208    }
1209
1210    #[test]
1211    fn namespace_wildcard_does_not_match_with_no_hook_at_all() {
1212        let test = NodeTest::NamespaceWildcard("p".to_string());
1213        assert!(!matches_node_test(&test, Axis::Child, ns_node(), None));
1214    }
1215
1216    // ---- Phase 05b regression: hook lifetime decoupled from doc lifetime --
1217    //
1218    // A minimal parent/child-linked tree, purpose-built (like `NsTestNode`
1219    // above) because `document::fixture`'s shared tree never sets a real
1220    // namespace URI on an element. Unlike `NsTestNode`, this one has actual
1221    // parent/child links so a real `evaluate()` call — parse, axis walk,
1222    // node-test filtering, all of it — can be exercised end-to-end, not
1223    // just `matches_node_test` in isolation.
1224    #[derive(Debug)]
1225    enum HookLifetimeEntry {
1226        Root {
1227            children: Vec<usize>,
1228        },
1229        Element {
1230            #[allow(dead_code)]
1231            parent: usize,
1232            namespace_uri: Option<&'static str>,
1233            local_name: &'static str,
1234        },
1235    }
1236
1237    #[derive(Debug)]
1238    struct HookLifetimeArena(Vec<HookLifetimeEntry>);
1239
1240    #[derive(Clone, Copy, Debug)]
1241    struct HookLifetimeNode<'a> {
1242        arena: &'a HookLifetimeArena,
1243        idx: usize,
1244    }
1245
1246    impl<'a> PartialEq for HookLifetimeNode<'a> {
1247        fn eq(&self, other: &Self) -> bool {
1248            std::ptr::eq(self.arena, other.arena) && self.idx == other.idx
1249        }
1250    }
1251    impl<'a> Eq for HookLifetimeNode<'a> {}
1252
1253    impl<'a> Node<'a> for HookLifetimeNode<'a> {
1254        fn kind(self) -> NodeKind {
1255            match &self.arena.0[self.idx] {
1256                HookLifetimeEntry::Root { .. } => NodeKind::Root,
1257                HookLifetimeEntry::Element { .. } => NodeKind::Element,
1258            }
1259        }
1260        fn parent(self) -> Option<Self> {
1261            match &self.arena.0[self.idx] {
1262                HookLifetimeEntry::Root { .. } => None,
1263                HookLifetimeEntry::Element { parent, .. } => Some(HookLifetimeNode {
1264                    arena: self.arena,
1265                    idx: *parent,
1266                }),
1267            }
1268        }
1269        fn children(self) -> impl Iterator<Item = Self> + 'a {
1270            let arena = self.arena;
1271            let indices = match &arena.0[self.idx] {
1272                HookLifetimeEntry::Root { children } => children.clone(),
1273                HookLifetimeEntry::Element { .. } => Vec::new(),
1274            };
1275            indices
1276                .into_iter()
1277                .map(move |i| HookLifetimeNode { arena, idx: i })
1278        }
1279        fn attributes(self) -> impl Iterator<Item = Self> + 'a {
1280            std::iter::empty()
1281        }
1282        fn namespaces(self) -> impl Iterator<Item = Self> + 'a {
1283            std::iter::empty()
1284        }
1285        fn expanded_name(self) -> Option<crate::document::ExpandedName> {
1286            match &self.arena.0[self.idx] {
1287                HookLifetimeEntry::Root { .. } => None,
1288                HookLifetimeEntry::Element {
1289                    namespace_uri,
1290                    local_name,
1291                    ..
1292                } => Some(crate::document::ExpandedName {
1293                    namespace_uri: namespace_uri.map(str::to_string),
1294                    local_name: local_name.to_string(),
1295                }),
1296            }
1297        }
1298        fn string_value(self) -> String {
1299            String::new()
1300        }
1301        fn document_order(self, other: Self) -> std::cmp::Ordering {
1302            self.idx.cmp(&other.idx)
1303        }
1304    }
1305
1306    #[test]
1307    fn evaluate_result_with_locally_scoped_namespace_hook_outlives_the_hook() {
1308        // The document: lives for this whole test function — clearly
1309        // longer than the inner block below, exactly like a caller's
1310        // document living as long as its own function's `'a`.
1311        let arena = HookLifetimeArena(vec![
1312            HookLifetimeEntry::Root {
1313                children: vec![1, 2],
1314            },
1315            HookLifetimeEntry::Element {
1316                parent: 0,
1317                namespace_uri: Some("http://example/ns"),
1318                local_name: "rect",
1319            },
1320            HookLifetimeEntry::Element {
1321                parent: 0,
1322                namespace_uri: None,
1323                local_name: "other",
1324            },
1325        ]);
1326        let root_node = HookLifetimeNode {
1327            arena: &arena,
1328            idx: 0,
1329        };
1330
1331        // This inner block is the point of the test (see
1332        // `plan/05b-decouple-hook-lifetime.md`): the namespace-resolution
1333        // hook, and the `EvaluationContext` borrowing it via `'hook`, are
1334        // both built *and dropped* here — modeling a `schematron-engine`
1335        // caller that builds its namespace hook locally from a `Schema`'s
1336        // `<ns>` bindings inside a function body. Before Phase 05b,
1337        // `EvaluationContext`'s single lifetime forced this hook to live
1338        // exactly as long as `root_node`'s document borrow, so returning
1339        // `nodes` (computed via the hook, but only actually borrowing the
1340        // outer, longer-lived `arena`) out of this block could never
1341        // compile (rustc E0515). With `'hook` decoupled from `'ctx`, it
1342        // does.
1343        let nodes = {
1344            let bound_uri = "http://example/ns".to_string();
1345            let namespace_hook = move |prefix: &str| -> Option<String> {
1346                (prefix == "p").then(|| bound_uri.clone())
1347            };
1348            let ctx = EvaluationContext {
1349                node: root_node,
1350                position: 1,
1351                size: 1,
1352                variables: None,
1353                namespaces: Some(&namespace_hook),
1354                _ctx: std::marker::PhantomData,
1355            };
1356            let expr = crate::parse("p:rect").expect("test expression must parse");
1357            match evaluate(&expr, &ctx).expect("expected Ok") {
1358                Value::NodeSet(nodes) => nodes,
1359                other => panic!("expected a node-set, got {other:?}"),
1360            }
1361        };
1362
1363        // Used *after* the inner scope (and the hook it contained) has
1364        // ended — the actual assertion that the fix works, not just that
1365        // it compiles.
1366        assert_eq!(nodes.len(), 1);
1367        assert_eq!(
1368            nodes[0],
1369            HookLifetimeNode {
1370                arena: &arena,
1371                idx: 1
1372            }
1373        );
1374    }
1375}