Skip to main content

schematron_engine/
engine.rs

1//! Phase 03: evaluates a parsed [`Schema`] against a caller-provided
2//! document, producing structured [`Report`]s for fired checks.
3//!
4//! Execution model (see `plan/03-evaluation.md` for the full rationale,
5//! including why this is based on general knowledge of the Schematron
6//! reference implementation rather than a verbatim ISO 19757-3 quote):
7//!
8//! - Patterns are independent: the same node may be claimed by rules in
9//!   several different patterns.
10//! - Within a single pattern, at most one rule applies per node: rules are
11//!   tried in schema order, and a node already claimed by an earlier rule
12//!   in the same pattern is not re-evaluated by later rules in that
13//!   pattern ("first matching rule wins" — this is not a Stage-1
14//!   simplification but the actual ISO Schematron conflict-resolution
15//!   rule; `rule` has no `priority` attribute, see `plan/04-let-bindings.md`
16//!   for the grammar check that ruled this out).
17//! - `rule context="X"` is evaluated as the path
18//!   `descendant-or-self::node()/X` from the document root — the usual
19//!   pragmatic reduction of an XSLT-style match pattern to a regular
20//!   location path. When `X` is itself a `|`-union of several alternatives
21//!   (e.g. `context="a | b"`), each top-level alternative gets its own
22//!   `descendant-or-self::node()/` prefix (via [`split_top_level_union`])
23//!   rather than prefixing the whole string once: XPath's `/` binds
24//!   tighter than `|`, so a single shared prefix would only apply to the
25//!   first alternative — every later one would be evaluated as a bare
26//!   relative path from the document root instead of "anywhere in the
27//!   document", matching only a direct root child of that name.
28//! - `assert test="X"` fires (produces a `Report`) when `X` evaluates to
29//!   `false`; `report test="X"` fires when `X` evaluates to `true`.
30//! - `<let name="..." value="...">` (Stage 2, `plan/04-let-bindings.md`)
31//!   binds XPath variables visible to `context`/`test` expressions in its
32//!   scope (schema/pattern/rule), evaluated via `extend_lets`.
33//! - `phase` selection (Stage 2, `plan/05-phase-selection.md`) restricts
34//!   evaluation to a subset of patterns via [`evaluate_with_phase`].
35//!   `evaluate` is `evaluate_with_phase` with `schema.default_phase`
36//!   (ISO's `#DEFAULT`); `phase_id: None` in `evaluate_with_phase` means
37//!   "all patterns" (ISO's `#ALL`).
38//! - `diagnostics` (Stage 2, `plan/08-diagnostics.md`): a fired check's
39//!   `diagnostics="id1 id2"` IDREFS are rendered the same way as its own
40//!   message, against the same context node. Unknown ids are skipped, not
41//!   an error (same accepted simplification as `Phase.active`).
42
43use std::collections::HashMap;
44
45use xpath_eval::{Document, EvaluationContext, Node, QName, Value};
46
47use crate::schema::{Check, CheckKind, Diagnostic, LetBinding, MessagePart, Schema};
48
49/// One fired check: a failed `assert`, or a true-evaluating `report`.
50/// Checks that do not fire are not represented at all (no `fired: bool`
51/// field) — this is the list of assertion results, not an evaluation trace.
52#[derive(Clone, Debug)]
53pub struct Report<N> {
54    pub pattern_id: Option<String>,
55    pub check_id: Option<String>,
56    pub role: Option<String>,
57    pub kind: CheckKind,
58    pub message: String,
59    /// Rendered messages of this check's `diagnostics` IDREFS, in
60    /// reference order (see `plan/08-diagnostics.md`).
61    pub diagnostics: Vec<String>,
62    pub node: N,
63}
64
65/// An error evaluating a [`Schema`] against a document.
66///
67/// Distinct from `xpath_eval::EvalError` (note the different name) so
68/// callers can `use` both without a collision. Wraps both failure modes
69/// `xpath-eval` can produce — a `context`/`test` string that is not
70/// syntactically valid XPath ([`SchematronEvalError::Parse`]), and a
71/// well-formed expression that fails at evaluation time
72/// ([`SchematronEvalError::Eval`]) — plus one failure mode specific to
73/// this crate's `context`-as-path-selection simplification: a `context`
74/// expression that parses but does not evaluate to a node-set
75/// ([`SchematronEvalError::ContextNotNodeSet`]).
76#[derive(Debug, Clone, PartialEq)]
77pub enum SchematronEvalError {
78    /// A `context` or `test` expression failed to parse as XPath.
79    Parse(xpath_eval::ParseError),
80    /// A well-formed expression failed at evaluation time.
81    Eval(xpath_eval::EvalError),
82    /// A rule's `context`, once evaluated as `descendant-or-self::node()/`
83    /// prefixed onto the raw context string, did not evaluate to a
84    /// node-set (e.g. it evaluated to a number or string instead) —
85    /// `context` must be a location-path/node-set expression.
86    ContextNotNodeSet { context: String },
87    /// [`evaluate_with_phase`] was called with a `phase_id` that does not
88    /// match any `Schema.phases[_].id` (or `Schema.default_phase` names
89    /// such an unknown phase).
90    UnknownPhase { phase: String },
91}
92
93impl std::fmt::Display for SchematronEvalError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            SchematronEvalError::Parse(err) => write!(f, "XPath parse error: {err}"),
97            SchematronEvalError::Eval(err) => write!(f, "XPath evaluation error: {err}"),
98            SchematronEvalError::ContextNotNodeSet { context } => {
99                write!(f, "rule context {context:?} did not evaluate to a node-set")
100            }
101            SchematronEvalError::UnknownPhase { phase } => {
102                write!(f, "no <phase id={phase:?}> in this schema")
103            }
104        }
105    }
106}
107
108impl std::error::Error for SchematronEvalError {}
109
110/// Splits a `context` (or any XPath `UnionExpr`) string on its top-level
111/// `|` operators, i.e. the ones that are not nested inside `[...]`
112/// predicates, `(...)` groups, or `'...'`/`"..."` string literals. A
113/// context with no top-level `|` is returned as a single-element vec
114/// (unchanged, whitespace and all) — the common case.
115fn split_top_level_union(context: &str) -> Vec<&str> {
116    let mut alternatives = Vec::new();
117    let mut depth: i32 = 0;
118    let mut quote: Option<char> = None;
119    let mut start = 0;
120
121    for (byte_offset, ch) in context.char_indices() {
122        match quote {
123            Some(q) => {
124                if ch == q {
125                    quote = None;
126                }
127            }
128            None => match ch {
129                '\'' | '"' => quote = Some(ch),
130                '(' | '[' => depth += 1,
131                ')' | ']' => depth -= 1,
132                '|' if depth == 0 => {
133                    alternatives.push(&context[start..byte_offset]);
134                    start = byte_offset + ch.len_utf8();
135                }
136                _ => {}
137            },
138        }
139    }
140    alternatives.push(&context[start..]);
141    alternatives
142}
143
144/// Evaluates `schema` against `document` under ISO's `#DEFAULT` phase
145/// resolution: `schema.default_phase` if set, otherwise `#ALL` (every
146/// pattern) — see [`evaluate_with_phase`] and `plan/05-phase-selection.md`.
147/// Returns one [`Report`] per fired check, in the order patterns/rules/
148/// checks appear in the schema and nodes are visited (document order
149/// within a rule's matches).
150pub fn evaluate<'a, D: Document>(
151    schema: &Schema,
152    document: &'a D,
153) -> Result<Vec<Report<D::N<'a>>>, SchematronEvalError> {
154    evaluate_with_phase(schema, document, schema.default_phase.as_deref())
155}
156
157/// Evaluates `schema` against `document`, restricted to the patterns
158/// activated by the phase named `phase_id` — `None` means `#ALL` (every
159/// pattern, ignoring `Schema.phases` entirely). `Some(id)` with no
160/// matching `Schema.phases[_].id` is [`SchematronEvalError::UnknownPhase`].
161/// See `plan/05-phase-selection.md`.
162pub fn evaluate_with_phase<'a, D: Document>(
163    schema: &Schema,
164    document: &'a D,
165    phase_id: Option<&str>,
166) -> Result<Vec<Report<D::N<'a>>>, SchematronEvalError> {
167    let ns_lookup = |prefix: &str| {
168        schema
169            .ns
170            .iter()
171            .find(|binding| binding.prefix == prefix)
172            .map(|binding| binding.uri.clone())
173    };
174
175    let phase = phase_id
176        .map(|id| {
177            schema
178                .phases
179                .iter()
180                .find(|phase| phase.id == id)
181                .ok_or_else(|| SchematronEvalError::UnknownPhase {
182                    phase: id.to_owned(),
183                })
184        })
185        .transpose()?;
186
187    let mut reports = Vec::new();
188
189    let mut schema_vars: HashMap<String, Value<D::N<'a>>> = HashMap::new();
190    extend_lets(&mut schema_vars, &schema.lets, document.root(), &ns_lookup)?;
191
192    let mut phase_vars = schema_vars.clone();
193    if let Some(phase) = phase {
194        extend_lets(&mut phase_vars, &phase.lets, document.root(), &ns_lookup)?;
195    }
196
197    for pattern in &schema.patterns {
198        if let Some(phase) = phase {
199            let is_active = pattern
200                .id
201                .as_deref()
202                .is_some_and(|id| phase.active.iter().any(|active| active == id));
203            if !is_active {
204                continue;
205            }
206        }
207
208        let mut pattern_vars = phase_vars.clone();
209        extend_lets(
210            &mut pattern_vars,
211            &pattern.lets,
212            document.root(),
213            &ns_lookup,
214        )?;
215
216        let mut claimed: Vec<D::N<'a>> = Vec::new();
217
218        for rule in &pattern.rules {
219            let path = split_top_level_union(&rule.context)
220                .into_iter()
221                .map(|alternative| format!("descendant-or-self::node()/{}", alternative.trim()))
222                .collect::<Vec<_>>()
223                .join(" | ");
224            let expr = xpath_eval::parse(&path).map_err(SchematronEvalError::Parse)?;
225
226            let mut ctx = EvaluationContext::new(document.root());
227            ctx.namespaces = Some(&ns_lookup);
228            let value = xpath_eval::evaluate(&expr, &ctx).map_err(SchematronEvalError::Eval)?;
229
230            let mut matches = match value {
231                Value::NodeSet(nodes) => nodes,
232                _ => {
233                    return Err(SchematronEvalError::ContextNotNodeSet {
234                        context: rule.context.clone(),
235                    });
236                }
237            };
238            matches.sort_by(|a, b| a.document_order(*b));
239
240            for node in matches {
241                if claimed.contains(&node) {
242                    continue;
243                }
244                claimed.push(node);
245
246                let mut node_vars = pattern_vars.clone();
247                extend_lets(&mut node_vars, &rule.lets, node, &ns_lookup)?;
248
249                for check in &rule.checks {
250                    if let Some(report) = evaluate_check(
251                        check,
252                        node,
253                        pattern.id.clone(),
254                        &ns_lookup,
255                        &node_vars,
256                        &schema.diagnostics,
257                    )? {
258                        reports.push(report);
259                    }
260                }
261            }
262        }
263    }
264
265    Ok(reports)
266}
267
268/// Parses and evaluates the raw XPath expression `raw` against `node`,
269/// with `ns_lookup`/`vars` wired up as the namespace/variable hooks — the
270/// one place every `context`/`test`/`value-of/@select` expression
271/// evaluation goes through (a `<let>` binding's `value`, a check's
272/// `test`, and a message's `<value-of>` all share this exact shape:
273/// parse, build an unprefixed-name variable lookup over `vars`, evaluate).
274fn evaluate_xpath<'n, N: Node<'n>>(
275    raw: &str,
276    node: N,
277    ns_lookup: &dyn Fn(&str) -> Option<String>,
278    vars: &HashMap<String, Value<N>>,
279) -> Result<Value<N>, SchematronEvalError> {
280    let expr = xpath_eval::parse(raw).map_err(SchematronEvalError::Parse)?;
281    let lookup = |name: &QName| {
282        if name.prefix.is_some() {
283            None
284        } else {
285            vars.get(&name.local).cloned()
286        }
287    };
288    let mut ctx = EvaluationContext::new(node);
289    ctx.namespaces = Some(ns_lookup);
290    ctx.variables = Some(&lookup);
291    xpath_eval::evaluate(&expr, &ctx).map_err(SchematronEvalError::Eval)
292}
293
294/// Evaluates `lets` in document order against `context_node`, inserting
295/// each binding's value into `vars` as it goes — so a binding's `value`
296/// expression can reference earlier bindings already in `vars` (both from
297/// an enclosing scope and from earlier in `lets` itself), but not later
298/// ones in `lets`. See `plan/04-let-bindings.md` for the scoping rationale
299/// (schema/pattern-level `<let>` evaluate against the document root,
300/// rule-level against the matched context node).
301fn extend_lets<'n, N: Node<'n>>(
302    vars: &mut HashMap<String, Value<N>>,
303    lets: &[LetBinding],
304    context_node: N,
305    ns_lookup: &dyn Fn(&str) -> Option<String>,
306) -> Result<(), SchematronEvalError> {
307    for binding in lets {
308        let value = evaluate_xpath(&binding.value, context_node, ns_lookup, vars)?;
309        vars.insert(binding.name.clone(), value);
310    }
311    Ok(())
312}
313
314/// Evaluates a single `assert`/`report` check against a claimed context
315/// node, returning `Some(Report)` if it fires (see [`Report`]'s doc
316/// comment for what "fires" means per [`CheckKind`]), `None` otherwise.
317fn evaluate_check<'n, N: Node<'n>>(
318    check: &Check,
319    node: N,
320    pattern_id: Option<String>,
321    ns_lookup: &dyn Fn(&str) -> Option<String>,
322    vars: &HashMap<String, Value<N>>,
323    diagnostics: &[Diagnostic],
324) -> Result<Option<Report<N>>, SchematronEvalError> {
325    let value = evaluate_xpath(&check.test, node, ns_lookup, vars)?;
326    let boolean = value.to_boolean();
327
328    let fires = match check.kind {
329        CheckKind::Assert => !boolean,
330        CheckKind::Report => boolean,
331    };
332
333    if !fires {
334        return Ok(None);
335    }
336
337    let message = render_message(&check.message, node, ns_lookup, vars)?;
338
339    let rendered_diagnostics = check
340        .diagnostics
341        .iter()
342        .filter_map(|id| diagnostics.iter().find(|d| &d.id == id))
343        .map(|d| render_message(&d.message, node, ns_lookup, vars))
344        .collect::<Result<Vec<_>, _>>()?;
345
346    Ok(Some(Report {
347        pattern_id,
348        check_id: check.id.clone(),
349        role: check.role.clone(),
350        kind: check.kind,
351        message,
352        diagnostics: rendered_diagnostics,
353        node,
354    }))
355}
356
357/// Renders a [`Check`]'s message `parts` against `node` — `MessagePart::Text`
358/// is appended as-is, `MessagePart::ValueOf`'s `select` is parsed and
359/// evaluated against `node` (with the same namespace/variable hooks as the
360/// check's own `test`) and its `Value::to_xpath_string()` appended — then
361/// the whole concatenated result is trimmed (leading/trailing whitespace
362/// only, see `plan/06-value-of-interpolation.md` for why trimming moved
363/// here from parse time). Only called for checks that actually fire (see
364/// call site) — a broken `value-of` in a message that never fires must not
365/// surface as an error.
366fn render_message<'n, N: Node<'n>>(
367    parts: &[MessagePart],
368    node: N,
369    ns_lookup: &dyn Fn(&str) -> Option<String>,
370    vars: &HashMap<String, Value<N>>,
371) -> Result<String, SchematronEvalError> {
372    let mut message = String::new();
373    for part in parts {
374        match part {
375            MessagePart::Text(text) => message.push_str(text),
376            MessagePart::ValueOf(select) => {
377                let value = evaluate_xpath(select, node, ns_lookup, vars)?;
378                message.push_str(&value.to_xpath_string());
379            }
380        }
381    }
382    Ok(message.trim().to_owned())
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use std::cmp::Ordering;
389    use xpath_eval::{ExpandedName, NodeKind};
390
391    /// A minimal in-memory element tree, test-only — `xpath-eval`'s own
392    /// fixture (`document::fixture`) is `pub(crate)` there and not usable
393    /// from this crate, so this is a small, purpose-built equivalent: just
394    /// enough (`Root`/`Element`, parent/children, an optional namespace
395    /// URI per element) to drive the evaluation-model test matrix below.
396    #[derive(Debug)]
397    struct ElementData {
398        namespace_uri: Option<String>,
399        local_name: String,
400        parent: Option<usize>,
401        children: Vec<usize>,
402    }
403
404    #[derive(Debug)]
405    struct Arena {
406        elements: Vec<ElementData>,
407    }
408
409    struct TestDoc {
410        arena: Arena,
411    }
412
413    impl TestDoc {
414        fn node(&self, idx: usize) -> TestNode<'_> {
415            TestNode {
416                arena: &self.arena,
417                idx,
418            }
419        }
420    }
421
422    impl xpath_eval::Document for TestDoc {
423        type N<'a> = TestNode<'a>;
424
425        fn root(&self) -> Self::N<'_> {
426            self.node(0)
427        }
428    }
429
430    #[derive(Clone, Copy, Debug)]
431    struct TestNode<'a> {
432        arena: &'a Arena,
433        idx: usize,
434    }
435
436    impl<'a> PartialEq for TestNode<'a> {
437        fn eq(&self, other: &Self) -> bool {
438            std::ptr::eq(self.arena, other.arena) && self.idx == other.idx
439        }
440    }
441
442    impl<'a> Eq for TestNode<'a> {}
443
444    impl<'a> Node<'a> for TestNode<'a> {
445        fn kind(self) -> NodeKind {
446            if self.idx == 0 {
447                NodeKind::Root
448            } else {
449                NodeKind::Element
450            }
451        }
452
453        fn parent(self) -> Option<Self> {
454            self.arena.elements[self.idx].parent.map(|p| TestNode {
455                arena: self.arena,
456                idx: p,
457            })
458        }
459
460        fn children(self) -> impl Iterator<Item = Self> + 'a {
461            let arena = self.arena;
462            self.arena.elements[self.idx]
463                .children
464                .clone()
465                .into_iter()
466                .map(move |i| TestNode { arena, idx: i })
467        }
468
469        fn attributes(self) -> impl Iterator<Item = Self> + 'a {
470            std::iter::empty()
471        }
472
473        fn namespaces(self) -> impl Iterator<Item = Self> + 'a {
474            std::iter::empty()
475        }
476
477        fn expanded_name(self) -> Option<ExpandedName> {
478            if self.idx == 0 {
479                None
480            } else {
481                let element = &self.arena.elements[self.idx];
482                Some(ExpandedName {
483                    namespace_uri: element.namespace_uri.clone(),
484                    local_name: element.local_name.clone(),
485                })
486            }
487        }
488
489        fn string_value(self) -> String {
490            String::new()
491        }
492
493        fn document_order(self, other: Self) -> Ordering {
494            self.idx.cmp(&other.idx)
495        }
496    }
497
498    /// Builds a document with a root and, for each `(namespace_uri,
499    /// local_name)` pair, one direct root child element, in that order.
500    fn doc_with_root_children(children: &[(Option<&str>, &str)]) -> TestDoc {
501        let mut elements = vec![ElementData {
502            namespace_uri: None,
503            local_name: String::new(),
504            parent: None,
505            children: Vec::new(),
506        }];
507        for (namespace_uri, local_name) in children {
508            let idx = elements.len();
509            elements.push(ElementData {
510                namespace_uri: namespace_uri.map(str::to_owned),
511                local_name: (*local_name).to_owned(),
512                parent: Some(0),
513                children: Vec::new(),
514            });
515            elements[0].children.push(idx);
516        }
517        TestDoc {
518            arena: Arena { elements },
519        }
520    }
521
522    fn schema(sch_xml: &str) -> Schema {
523        crate::parse(sch_xml).expect("test schema must parse")
524    }
525
526    /// Builds `root -> wrapper -> item`, i.e. `item` is a *grandchild* of
527    /// the document root, not a direct child like `doc_with_root_children`
528    /// produces. Needed to distinguish `descendant-or-self::node()/X` (finds
529    /// `item` anywhere) from a bare, unprefixed `X` evaluated relative to
530    /// the root (would only find a direct root child literally named `X`) —
531    /// every other fixture in this module is flat, which happens to make
532    /// both forms equivalent and so cannot catch a union-context bug that
533    /// only manifests once a context alternative isn't parenthesized.
534    fn doc_with_nested_item() -> TestDoc {
535        let elements = vec![
536            ElementData {
537                namespace_uri: None,
538                local_name: String::new(),
539                parent: None,
540                children: vec![1],
541            },
542            ElementData {
543                namespace_uri: None,
544                local_name: "wrapper".to_owned(),
545                parent: Some(0),
546                children: vec![2],
547            },
548            ElementData {
549                namespace_uri: None,
550                local_name: "item".to_owned(),
551                parent: Some(1),
552                children: Vec::new(),
553            },
554        ];
555        TestDoc {
556            arena: Arena { elements },
557        }
558    }
559
560    #[test]
561    fn assert_true_produces_no_report() {
562        let doc = doc_with_root_children(&[(None, "item")]);
563        let schema = schema(
564            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
565                <pattern>
566                    <rule context="item">
567                        <assert test="true()">should not fire</assert>
568                    </rule>
569                </pattern>
570            </schema>"#,
571        );
572
573        let reports = evaluate(&schema, &doc).unwrap();
574        assert_eq!(reports.len(), 0);
575    }
576
577    #[test]
578    fn assert_false_produces_one_report() {
579        let doc = doc_with_root_children(&[(None, "item")]);
580        let schema = schema(
581            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
582                <pattern id="p1">
583                    <rule context="item">
584                        <assert id="a1" role="error" test="false()">item is invalid</assert>
585                    </rule>
586                </pattern>
587            </schema>"#,
588        );
589
590        let reports = evaluate(&schema, &doc).unwrap();
591        assert_eq!(reports.len(), 1);
592        let report = &reports[0];
593        assert_eq!(report.pattern_id.as_deref(), Some("p1"));
594        assert_eq!(report.check_id.as_deref(), Some("a1"));
595        assert_eq!(report.role.as_deref(), Some("error"));
596        assert_eq!(report.kind, CheckKind::Assert);
597        assert_eq!(report.message, "item is invalid");
598        assert_eq!(report.node, doc.node(1));
599    }
600
601    /// A `context` with a `|`-union of two alternatives must match a node
602    /// through *either* alternative, no matter which one is written first —
603    /// same as a bare `context="item"` would for the same node. Regression
604    /// test for a real bug: `evaluate`/`evaluate_with_phase` built the path
605    /// as `format!("descendant-or-self::node()/{}", rule.context)` with no
606    /// parentheses around `rule.context`. XPath's `/` binds tighter than
607    /// `|`, so `descendant-or-self::node()/A | B` parsed as
608    /// `(descendant-or-self::node()/A) | B` — only the *first* alternative
609    /// got the document-wide prefix; every later alternative was evaluated
610    /// as a bare relative path from the document root, matching only a
611    /// direct root child literally named `B`. Every other context-union
612    /// test in this module happened to use `doc_with_root_children` (a flat
613    /// tree), which made a prefixed and an unprefixed match identical and
614    /// so never exposed this — `doc_with_nested_item` puts `item` two
615    /// levels down specifically to catch it.
616    #[test]
617    fn context_union_matches_through_every_alternative() {
618        let doc = doc_with_nested_item();
619
620        let first_alternative = schema(
621            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
622                <pattern>
623                    <rule context="item | does-not-exist">
624                        <report test="true()">observed item</report>
625                    </rule>
626                </pattern>
627            </schema>"#,
628        );
629        let reports = evaluate(&first_alternative, &doc).unwrap();
630        assert_eq!(reports.len(), 1, "item as the first union alternative");
631
632        let second_alternative = schema(
633            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
634                <pattern>
635                    <rule context="does-not-exist | item">
636                        <report test="true()">observed item</report>
637                    </rule>
638                </pattern>
639            </schema>"#,
640        );
641        let reports = evaluate(&second_alternative, &doc).unwrap();
642        assert_eq!(reports.len(), 1, "item as the second union alternative");
643    }
644
645    #[test]
646    fn split_top_level_union_ignores_nested_pipes() {
647        assert_eq!(split_top_level_union("item"), vec!["item"]);
648        assert_eq!(split_top_level_union("a | b | c"), vec!["a ", " b ", " c"]);
649        // A `|` inside a predicate (e.g. an attribute-value alternation)
650        // is not a context-level union and must not be split on.
651        assert_eq!(
652            split_top_level_union("a[@x = 'p|q']"),
653            vec!["a[@x = 'p|q']"]
654        );
655        assert_eq!(split_top_level_union("a[b | c]"), vec!["a[b | c]"]);
656        assert_eq!(split_top_level_union("(a | b)/c"), vec!["(a | b)/c"]);
657        assert_eq!(
658            split_top_level_union("a[@x = 'p|q'] | b"),
659            vec!["a[@x = 'p|q'] ", " b"]
660        );
661    }
662
663    #[test]
664    fn report_true_fires() {
665        let doc = doc_with_root_children(&[(None, "item")]);
666        let schema = schema(
667            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
668                <pattern>
669                    <rule context="item">
670                        <report test="true()">observed item</report>
671                    </rule>
672                </pattern>
673            </schema>"#,
674        );
675
676        let reports = evaluate(&schema, &doc).unwrap();
677        assert_eq!(reports.len(), 1);
678        assert_eq!(reports[0].kind, CheckKind::Report);
679        assert_eq!(reports[0].message, "observed item");
680    }
681
682    /// Two rules in the SAME pattern with overlapping context: only the
683    /// first rule's checks run against the shared node (the core of the
684    /// "one rule per node per pattern, first match wins" model) — belt
685    /// and braces distinctly-recognizable messages so this is proven, not
686    /// just plausible from reading the code.
687    #[test]
688    fn same_pattern_first_matching_rule_wins() {
689        let doc = doc_with_root_children(&[(None, "item")]);
690        let schema = schema(
691            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
692                <pattern>
693                    <rule context="item">
694                        <assert test="false()">RULE-ONE-FIRED</assert>
695                    </rule>
696                    <rule context="item">
697                        <assert test="false()">RULE-TWO-FIRED</assert>
698                    </rule>
699                </pattern>
700            </schema>"#,
701        );
702
703        let reports = evaluate(&schema, &doc).unwrap();
704        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
705        assert_eq!(messages, vec!["RULE-ONE-FIRED"]);
706    }
707
708    /// The counter-case: two PATTERNS with overlapping context both fire
709    /// independently for the same node — patterns do not share a claimed
710    /// set.
711    #[test]
712    fn independent_patterns_both_fire() {
713        let doc = doc_with_root_children(&[(None, "item")]);
714        let schema = schema(
715            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
716                <pattern>
717                    <rule context="item">
718                        <assert test="false()">PATTERN-ONE-FIRED</assert>
719                    </rule>
720                </pattern>
721                <pattern>
722                    <rule context="item">
723                        <assert test="false()">PATTERN-TWO-FIRED</assert>
724                    </rule>
725                </pattern>
726            </schema>"#,
727        );
728
729        let reports = evaluate(&schema, &doc).unwrap();
730        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
731        assert_eq!(messages, vec!["PATTERN-ONE-FIRED", "PATTERN-TWO-FIRED"]);
732    }
733
734    /// A prefixed name test in `context` that would NOT match its target
735    /// node without namespace resolution via `Schema.ns` — proves the
736    /// `Schema.ns` → `xpath-eval` namespace-hook wiring works end-to-end.
737    /// Without it, `t:item`'s prefix falls back to being compared directly
738    /// against the real namespace URI `urn:test` (they're unequal), the
739    /// context would not match, and no report would fire.
740    #[test]
741    fn namespace_binding_resolves_prefixed_context() {
742        let doc = doc_with_root_children(&[(Some("urn:test"), "item")]);
743        let schema = schema(
744            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
745                <ns prefix="t" uri="urn:test"/>
746                <pattern>
747                    <rule context="t:item">
748                        <assert test="false()">NS-MATCH</assert>
749                    </rule>
750                </pattern>
751            </schema>"#,
752        );
753
754        let reports = evaluate(&schema, &doc).unwrap();
755        assert_eq!(reports.len(), 1);
756        assert_eq!(reports[0].message, "NS-MATCH");
757    }
758
759    #[test]
760    fn invalid_test_expression_is_a_parse_error_not_a_panic() {
761        let doc = doc_with_root_children(&[(None, "item")]);
762        let schema = schema(
763            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
764                <pattern>
765                    <rule context="item">
766                        <assert test="((">broken</assert>
767                    </rule>
768                </pattern>
769            </schema>"#,
770        );
771
772        let result = evaluate(&schema, &doc);
773        assert!(matches!(result, Err(SchematronEvalError::Parse(_))));
774    }
775
776    #[test]
777    fn schema_level_let_is_visible_to_checks() {
778        let doc = doc_with_root_children(&[(None, "item")]);
779        let schema = schema(
780            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
781                <let name="x" value="'schema'"/>
782                <pattern>
783                    <rule context="item">
784                        <report test="$x = 'schema'">SCHEMA-LET</report>
785                    </rule>
786                </pattern>
787            </schema>"#,
788        );
789
790        let reports = evaluate(&schema, &doc).unwrap();
791        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
792        assert_eq!(messages, vec!["SCHEMA-LET"]);
793    }
794
795    /// A pattern-level `<let>` with the same name as a schema-level one
796    /// shadows it within that pattern — proven positively (the check fires
797    /// only if `$x` really is the pattern-level value), not just by absence
798    /// of a failure.
799    #[test]
800    fn pattern_level_let_shadows_schema_level_let() {
801        let doc = doc_with_root_children(&[(None, "item")]);
802        let schema = schema(
803            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
804                <let name="x" value="'schema'"/>
805                <pattern>
806                    <let name="x" value="'pattern'"/>
807                    <rule context="item">
808                        <report test="$x = 'pattern'">PATTERN-WINS</report>
809                    </rule>
810                </pattern>
811            </schema>"#,
812        );
813
814        let reports = evaluate(&schema, &doc).unwrap();
815        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
816        assert_eq!(messages, vec!["PATTERN-WINS"]);
817    }
818
819    /// A rule-level `<let>` referencing the context node is re-evaluated
820    /// per matched node, not computed once for the rule — the two root
821    /// children have different names, only one matches.
822    #[test]
823    fn rule_level_let_is_reevaluated_per_context_node() {
824        let doc = doc_with_root_children(&[(None, "alpha"), (None, "beta")]);
825        let schema = schema(
826            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
827                <pattern>
828                    <rule context="*">
829                        <let name="n" value="local-name()"/>
830                        <assert test="$n = 'alpha'">NOT-ALPHA</assert>
831                    </rule>
832                </pattern>
833            </schema>"#,
834        );
835
836        let reports = evaluate(&schema, &doc).unwrap();
837        assert_eq!(reports.len(), 1);
838        assert_eq!(reports[0].message, "NOT-ALPHA");
839        assert_eq!(reports[0].node, doc.node(2));
840    }
841
842    /// A `<let>` can reference an earlier `<let>` declared in the same
843    /// scope (sequential visibility, like sibling `xsl:variable`s).
844    #[test]
845    fn let_can_reference_earlier_let_in_same_scope() {
846        let doc = doc_with_root_children(&[(None, "item")]);
847        let schema = schema(
848            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
849                <let name="a" value="'A'"/>
850                <let name="b" value="concat($a, 'B')"/>
851                <pattern>
852                    <rule context="item">
853                        <report test="$b = 'AB'">SEQUENTIAL</report>
854                    </rule>
855                </pattern>
856            </schema>"#,
857        );
858
859        let reports = evaluate(&schema, &doc).unwrap();
860        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
861        assert_eq!(messages, vec!["SEQUENTIAL"]);
862    }
863
864    #[test]
865    fn unbound_variable_is_an_eval_error_not_a_panic() {
866        let doc = doc_with_root_children(&[(None, "item")]);
867        let schema = schema(
868            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
869                <pattern>
870                    <rule context="item">
871                        <assert test="$missing">broken</assert>
872                    </rule>
873                </pattern>
874            </schema>"#,
875        );
876
877        let result = evaluate(&schema, &doc);
878        assert!(matches!(result, Err(SchematronEvalError::Eval(_))));
879    }
880
881    /// Two patterns, one phase activating only one of them —
882    /// `evaluate_with_phase` fires only the activated pattern's checks.
883    #[test]
884    fn evaluate_with_phase_restricts_to_activated_patterns() {
885        let doc = doc_with_root_children(&[(None, "item")]);
886        let schema = schema(
887            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
888                <phase id="p1">
889                    <active pattern="pa"/>
890                </phase>
891                <phase id="p2">
892                    <active pattern="pb"/>
893                </phase>
894                <pattern id="pa">
895                    <rule context="item">
896                        <assert test="false()">PATTERN-A</assert>
897                    </rule>
898                </pattern>
899                <pattern id="pb">
900                    <rule context="item">
901                        <assert test="false()">PATTERN-B</assert>
902                    </rule>
903                </pattern>
904            </schema>"#,
905        );
906
907        let reports_p1 = evaluate_with_phase(&schema, &doc, Some("p1")).unwrap();
908        let messages_p1: Vec<&str> = reports_p1.iter().map(|r| r.message.as_str()).collect();
909        assert_eq!(messages_p1, vec!["PATTERN-A"]);
910
911        let reports_p2 = evaluate_with_phase(&schema, &doc, Some("p2")).unwrap();
912        let messages_p2: Vec<&str> = reports_p2.iter().map(|r| r.message.as_str()).collect();
913        assert_eq!(messages_p2, vec!["PATTERN-B"]);
914
915        let reports_all = evaluate_with_phase(&schema, &doc, None).unwrap();
916        let messages_all: Vec<&str> = reports_all.iter().map(|r| r.message.as_str()).collect();
917        assert_eq!(messages_all, vec!["PATTERN-A", "PATTERN-B"]);
918    }
919
920    /// `evaluate()` honors `schema/@defaultPhase` (ISO's `#DEFAULT`) — the
921    /// counter-proof to the previous test's `None`/`#ALL` case: with a
922    /// `defaultPhase` set, the non-activated pattern does NOT fire, even
923    /// though calling `evaluate_with_phase(.., None)` on the same schema
924    /// would fire both (proven above).
925    #[test]
926    fn evaluate_honors_default_phase() {
927        let doc = doc_with_root_children(&[(None, "item")]);
928        let schema = schema(
929            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron" defaultPhase="p1">
930                <phase id="p1">
931                    <active pattern="pa"/>
932                </phase>
933                <pattern id="pa">
934                    <rule context="item">
935                        <assert test="false()">PATTERN-A</assert>
936                    </rule>
937                </pattern>
938                <pattern id="pb">
939                    <rule context="item">
940                        <assert test="false()">PATTERN-B</assert>
941                    </rule>
942                </pattern>
943            </schema>"#,
944        );
945
946        let reports = evaluate(&schema, &doc).unwrap();
947        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
948        assert_eq!(messages, vec!["PATTERN-A"]);
949    }
950
951    #[test]
952    fn evaluate_with_phase_unknown_id_is_an_error() {
953        let doc = doc_with_root_children(&[(None, "item")]);
954        let schema = schema(
955            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
956                <pattern id="pa">
957                    <rule context="item">
958                        <assert test="false()">PATTERN-A</assert>
959                    </rule>
960                </pattern>
961            </schema>"#,
962        );
963
964        let error = evaluate_with_phase(&schema, &doc, Some("does-not-exist")).unwrap_err();
965        assert_eq!(
966            error,
967            SchematronEvalError::UnknownPhase {
968                phase: "does-not-exist".to_owned(),
969            }
970        );
971    }
972
973    #[test]
974    fn phase_level_let_is_visible_within_that_phase() {
975        let doc = doc_with_root_children(&[(None, "item")]);
976        let schema = schema(
977            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
978                <phase id="p1">
979                    <let name="x" value="'phase'"/>
980                    <active pattern="pa"/>
981                </phase>
982                <pattern id="pa">
983                    <rule context="item">
984                        <report test="$x = 'phase'">PHASE-LET</report>
985                    </rule>
986                </pattern>
987            </schema>"#,
988        );
989
990        let reports = evaluate_with_phase(&schema, &doc, Some("p1")).unwrap();
991        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
992        assert_eq!(messages, vec!["PHASE-LET"]);
993    }
994
995    /// Regression: a plain-text message (no `value-of`) with leading/
996    /// trailing whitespace is still trimmed the same way it was in Stage 1
997    /// — trimming just moved from parse time to render time.
998    #[test]
999    fn plain_text_message_is_trimmed_at_render_time() {
1000        let doc = doc_with_root_children(&[(None, "item")]);
1001        let schema = schema(
1002            "<schema xmlns=\"http://purl.oclc.org/dsdl/schematron\">\
1003                <pattern>\
1004                    <rule context=\"item\">\
1005                        <assert test=\"false()\">\n  first   second  \n</assert>\
1006                    </rule>\
1007                </pattern>\
1008            </schema>",
1009        );
1010
1011        let reports = evaluate(&schema, &doc).unwrap();
1012        assert_eq!(reports[0].message, "first   second");
1013    }
1014
1015    #[test]
1016    fn value_of_interpolates_the_context_node() {
1017        let doc = doc_with_root_children(&[(None, "item")]);
1018        let schema = schema(
1019            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1020                <pattern>
1021                    <rule context="item">
1022                        <assert test="false()">Element: <value-of select="local-name()"/>.</assert>
1023                    </rule>
1024                </pattern>
1025            </schema>"#,
1026        );
1027
1028        let reports = evaluate(&schema, &doc).unwrap();
1029        assert_eq!(reports[0].message, "Element: item.");
1030    }
1031
1032    #[test]
1033    fn value_of_can_reference_a_let_variable() {
1034        let doc = doc_with_root_children(&[(None, "item")]);
1035        let schema = schema(
1036            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1037                <pattern>
1038                    <rule context="item">
1039                        <let name="x" value="'bound-value'"/>
1040                        <assert test="false()">x is <value-of select="$x"/></assert>
1041                    </rule>
1042                </pattern>
1043            </schema>"#,
1044        );
1045
1046        let reports = evaluate(&schema, &doc).unwrap();
1047        assert_eq!(reports[0].message, "x is bound-value");
1048    }
1049
1050    /// A broken `value-of` in a check that never fires must not surface as
1051    /// an error — the message is only rendered for fired checks.
1052    #[test]
1053    fn broken_value_of_in_a_non_firing_check_is_not_an_error() {
1054        let doc = doc_with_root_children(&[(None, "item")]);
1055        let schema = schema(
1056            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1057                <pattern>
1058                    <rule context="item">
1059                        <assert test="true()">broken: <value-of select="(("/></assert>
1060                    </rule>
1061                </pattern>
1062            </schema>"#,
1063        );
1064
1065        let reports = evaluate(&schema, &doc).unwrap();
1066        assert_eq!(reports.len(), 0);
1067    }
1068
1069    /// The counter-case: the same broken `value-of` in a check that DOES
1070    /// fire surfaces as a parse error, not a panic.
1071    #[test]
1072    fn broken_value_of_in_a_firing_check_is_a_parse_error() {
1073        let doc = doc_with_root_children(&[(None, "item")]);
1074        let schema = schema(
1075            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1076                <pattern>
1077                    <rule context="item">
1078                        <assert test="false()">broken: <value-of select="(("/></assert>
1079                    </rule>
1080                </pattern>
1081            </schema>"#,
1082        );
1083
1084        let result = evaluate(&schema, &doc);
1085        assert!(matches!(result, Err(SchematronEvalError::Parse(_))));
1086    }
1087
1088    /// End-to-end: an `<extends>`-inlined check fires during evaluation
1089    /// exactly like one written inline in the rule (parser-level coverage
1090    /// is in `parser::tests`, this is the evaluation-side integration
1091    /// check).
1092    #[test]
1093    fn extends_inlined_check_fires_during_evaluation() {
1094        let doc = doc_with_root_children(&[(None, "item")]);
1095        let schema = schema(
1096            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1097                <pattern>
1098                    <rule abstract="true" id="base">
1099                        <assert test="false()">FROM-BASE</assert>
1100                    </rule>
1101                    <rule context="item">
1102                        <extends rule="base"/>
1103                    </rule>
1104                </pattern>
1105            </schema>"#,
1106        );
1107
1108        let reports = evaluate(&schema, &doc).unwrap();
1109        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
1110        assert_eq!(messages, vec!["FROM-BASE"]);
1111    }
1112
1113    /// End-to-end: an `is-a`-instantiated abstract pattern evaluates with
1114    /// its parameters correctly substituted into `context`/`test`.
1115    #[test]
1116    fn is_a_instantiated_pattern_evaluates_with_substituted_context() {
1117        let doc = doc_with_root_children(&[(None, "alpha"), (None, "beta")]);
1118        let schema = schema(
1119            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1120                <pattern abstract="true" id="tpl">
1121                    <rule context="$el">
1122                        <assert test="false()">MATCHED-$el</assert>
1123                    </rule>
1124                </pattern>
1125                <pattern is-a="tpl" id="concrete">
1126                    <param name="el" value="alpha"/>
1127                </pattern>
1128            </schema>"#,
1129        );
1130
1131        let reports = evaluate(&schema, &doc).unwrap();
1132        assert_eq!(reports.len(), 1);
1133        // The message text itself is plain (unparameterized) content, so
1134        // it's untouched by substitution — only context/test are.
1135        assert_eq!(reports[0].message, "MATCHED-$el");
1136        assert_eq!(reports[0].node, doc.node(1));
1137    }
1138
1139    #[test]
1140    fn fired_check_carries_its_rendered_diagnostics() {
1141        let doc = doc_with_root_children(&[(None, "item")]);
1142        let schema = schema(
1143            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1144                <diagnostics>
1145                    <diagnostic id="d1">first</diagnostic>
1146                    <diagnostic id="d2">element is <value-of select="local-name()"/></diagnostic>
1147                </diagnostics>
1148                <pattern>
1149                    <rule context="item">
1150                        <assert test="false()" diagnostics="d1 d2">broken</assert>
1151                    </rule>
1152                </pattern>
1153            </schema>"#,
1154        );
1155
1156        let reports = evaluate(&schema, &doc).unwrap();
1157        assert_eq!(reports[0].diagnostics, vec!["first", "element is item"]);
1158    }
1159
1160    #[test]
1161    fn unknown_diagnostic_id_is_silently_skipped() {
1162        // NOTE: this used to be a *parse-time-succeeds* case where an
1163        // unknown `diagnostics="..."` id was silently dropped at
1164        // evaluation time. Referential integrity for `diagnostics="..."`
1165        // is now checked at parse time instead (see `DECISIONS.md` and
1166        // `parser::tests::diagnostics_unknown_reference_is_an_error`) — an
1167        // unknown id no longer reaches `evaluate` at all, it's a
1168        // `ParseError`.
1169        let schema_xml = r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1170                <diagnostics>
1171                    <diagnostic id="d1">known</diagnostic>
1172                </diagnostics>
1173                <pattern>
1174                    <rule context="item">
1175                        <assert test="false()" diagnostics="d1 missing">broken</assert>
1176                    </rule>
1177                </pattern>
1178            </schema>"#;
1179
1180        assert!(crate::parse(schema_xml).is_err());
1181    }
1182
1183    #[test]
1184    fn check_without_diagnostics_attribute_yields_an_empty_list() {
1185        let doc = doc_with_root_children(&[(None, "item")]);
1186        let schema = schema(
1187            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1188                <pattern>
1189                    <rule context="item">
1190                        <assert test="false()">broken</assert>
1191                    </rule>
1192                </pattern>
1193            </schema>"#,
1194        );
1195
1196        let reports = evaluate(&schema, &doc).unwrap();
1197        assert_eq!(reports[0].diagnostics, Vec::<String>::new());
1198    }
1199}