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