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