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