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, LetValue, 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| {
222                    let alternative = alternative.trim();
223                    // A bare "/" (ISO's root pattern — match only the
224                    // document root) is the one case the general
225                    // `descendant-or-self::node()/` prefix breaks outright
226                    // rather than just loosening: prefixed, it becomes
227                    // `descendant-or-self::node()//`, a trailing `//` with
228                    // no following step — a parse error, not just an
229                    // overly-permissive match (issue #11). `/` alone is
230                    // already exactly "the document root", so it's used
231                    // unprefixed instead.
232                    if alternative == "/" {
233                        "/".to_owned()
234                    } else {
235                        format!("descendant-or-self::node()/{alternative}")
236                    }
237                })
238                .collect::<Vec<_>>()
239                .join(" | ");
240            let expr = xpath_eval::parse(&path).map_err(SchematronEvalError::Parse)?;
241
242            let mut ctx = EvaluationContext::new(document.root());
243            ctx.namespaces = Some(&ns_lookup);
244            let value = xpath_eval::evaluate(&expr, &ctx).map_err(SchematronEvalError::Eval)?;
245
246            let mut matches = match value {
247                Value::NodeSet(nodes) => nodes,
248                _ => {
249                    return Err(SchematronEvalError::ContextNotNodeSet {
250                        context: rule.context.clone(),
251                    });
252                }
253            };
254            matches.sort_by(|a, b| a.document_order(*b));
255
256            for node in matches {
257                if claimed.contains(&node) {
258                    continue;
259                }
260                claimed.push(node);
261
262                let mut node_vars = pattern_vars.clone();
263                extend_lets(&mut node_vars, &rule.lets, node, &ns_lookup)?;
264
265                for check in &rule.checks {
266                    if let Some(report) = evaluate_check(
267                        check,
268                        node,
269                        pattern.id.clone(),
270                        &ns_lookup,
271                        &node_vars,
272                        &schema.diagnostics,
273                    )? {
274                        reports.push(report);
275                    }
276                }
277            }
278        }
279    }
280
281    Ok(reports)
282}
283
284/// Parses and evaluates the raw XPath expression `raw` against `node`,
285/// with `ns_lookup`/`vars` wired up as the namespace/variable hooks — the
286/// one place every `context`/`test`/`value-of/@select` expression
287/// evaluation goes through (a `<let>` binding's `value`, a check's
288/// `test`, and a message's `<value-of>` all share this exact shape:
289/// parse, build an unprefixed-name variable lookup over `vars`, evaluate).
290fn evaluate_xpath<'n, N: Node<'n>>(
291    raw: &str,
292    node: N,
293    ns_lookup: &dyn Fn(&str) -> Option<String>,
294    vars: &HashMap<String, Value<N>>,
295) -> Result<Value<N>, SchematronEvalError> {
296    let expr = xpath_eval::parse(raw).map_err(SchematronEvalError::Parse)?;
297    let lookup = |name: &QName| {
298        if name.prefix.is_some() {
299            None
300        } else {
301            vars.get(&name.local).cloned()
302        }
303    };
304    let mut ctx = EvaluationContext::new(node);
305    ctx.namespaces = Some(ns_lookup);
306    ctx.variables = Some(&lookup);
307    xpath_eval::evaluate(&expr, &ctx).map_err(SchematronEvalError::Eval)
308}
309
310/// Evaluates `lets` in document order against `context_node`, inserting
311/// each binding's value into `vars` as it goes — so a binding's `value`
312/// expression can reference earlier bindings already in `vars` (both from
313/// an enclosing scope and from earlier in `lets` itself), but not later
314/// ones in `lets`. See `plan/04-let-bindings.md` for the scoping rationale
315/// (schema/pattern-level `<let>` evaluate against the document root,
316/// rule-level against the matched context node).
317fn extend_lets<'n, N: Node<'n>>(
318    vars: &mut HashMap<String, Value<N>>,
319    lets: &[LetBinding],
320    context_node: N,
321    ns_lookup: &dyn Fn(&str) -> Option<String>,
322) -> Result<(), SchematronEvalError> {
323    for binding in lets {
324        let value = match &binding.value {
325            LetValue::Expr(expr) => evaluate_xpath(expr, context_node, ns_lookup, vars)?,
326            // issue #3: literal-XML content binds as its pre-computed
327            // string-value — see `LetValue::Literal`'s doc comment.
328            LetValue::Literal(text) => Value::String(text.clone()),
329        };
330        vars.insert(binding.name.clone(), value);
331    }
332    Ok(())
333}
334
335/// Evaluates a single `assert`/`report` check against a claimed context
336/// node, returning `Some(Report)` if it fires (see [`Report`]'s doc
337/// comment for what "fires" means per [`CheckKind`]), `None` otherwise.
338fn evaluate_check<'n, N: Node<'n>>(
339    check: &Check,
340    node: N,
341    pattern_id: Option<String>,
342    ns_lookup: &dyn Fn(&str) -> Option<String>,
343    vars: &HashMap<String, Value<N>>,
344    diagnostics: &[Diagnostic],
345) -> Result<Option<Report<N>>, SchematronEvalError> {
346    let value = evaluate_xpath(&check.test, node, ns_lookup, vars)?;
347    let boolean = value.to_boolean();
348
349    let fires = match check.kind {
350        CheckKind::Assert => !boolean,
351        CheckKind::Report => boolean,
352    };
353
354    if !fires {
355        return Ok(None);
356    }
357
358    let message = render_message(&check.message, node, ns_lookup, vars)?;
359
360    let rendered_diagnostics = check
361        .diagnostics
362        .iter()
363        .filter_map(|id| diagnostics.iter().find(|d| &d.id == id))
364        .map(|d| render_message(&d.message, node, ns_lookup, vars))
365        .collect::<Result<Vec<_>, _>>()?;
366
367    Ok(Some(Report {
368        pattern_id,
369        check_id: check.id.clone(),
370        role: check.role.clone(),
371        kind: check.kind,
372        message,
373        diagnostics: rendered_diagnostics,
374        node,
375    }))
376}
377
378/// Renders a [`Check`]'s message `parts` against `node` — `MessagePart::Text`
379/// is appended as-is, `MessagePart::ValueOf`'s `select` is parsed and
380/// evaluated against `node` (with the same namespace/variable hooks as the
381/// check's own `test`) and its `Value::to_xpath_string()` appended,
382/// `MessagePart::Name`'s `path` (or `node` itself, absent a `path`) resolves
383/// to the named node's expanded name via XPath's own `name()` function
384/// (issue #4 — reuses `xpath-eval`'s existing `name()`/`qname_string`
385/// implementation rather than reimplementing qualified-name formatting
386/// here) — then the whole concatenated result is trimmed (leading/trailing
387/// whitespace only, see `plan/06-value-of-interpolation.md` for why
388/// trimming moved here from parse time). Only called for checks that
389/// actually fire (see call site) — a broken `value-of`/`name` in a message
390/// that never fires must not surface as an error.
391fn render_message<'n, N: Node<'n>>(
392    parts: &[MessagePart],
393    node: N,
394    ns_lookup: &dyn Fn(&str) -> Option<String>,
395    vars: &HashMap<String, Value<N>>,
396) -> Result<String, SchematronEvalError> {
397    let mut message = String::new();
398    for part in parts {
399        match part {
400            MessagePart::Text(text) => message.push_str(text),
401            MessagePart::ValueOf(select) => {
402                let value = evaluate_xpath(select, node, ns_lookup, vars)?;
403                message.push_str(&value.to_xpath_string());
404            }
405            MessagePart::Name(path) => {
406                let select = match path {
407                    Some(path) => format!("name({path})"),
408                    None => "name()".to_owned(),
409                };
410                let value = evaluate_xpath(&select, node, ns_lookup, vars)?;
411                message.push_str(&value.to_xpath_string());
412            }
413        }
414    }
415    Ok(message.trim().to_owned())
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use std::cmp::Ordering;
422    use xpath_eval::{ExpandedName, NodeKind};
423
424    /// A minimal in-memory element tree, test-only — `xpath-eval`'s own
425    /// fixture (`document::fixture`) is `pub(crate)` there and not usable
426    /// from this crate, so this is a small, purpose-built equivalent: just
427    /// enough (`Root`/`Element`, parent/children, an optional namespace
428    /// URI per element) to drive the evaluation-model test matrix below.
429    #[derive(Debug)]
430    struct ElementData {
431        namespace_uri: Option<String>,
432        local_name: String,
433        parent: Option<usize>,
434        children: Vec<usize>,
435    }
436
437    #[derive(Debug)]
438    struct Arena {
439        elements: Vec<ElementData>,
440    }
441
442    struct TestDoc {
443        arena: Arena,
444    }
445
446    impl TestDoc {
447        fn node(&self, idx: usize) -> TestNode<'_> {
448            TestNode {
449                arena: &self.arena,
450                idx,
451            }
452        }
453    }
454
455    impl xpath_eval::Document for TestDoc {
456        type N<'a> = TestNode<'a>;
457
458        fn root(&self) -> Self::N<'_> {
459            self.node(0)
460        }
461    }
462
463    #[derive(Clone, Copy, Debug)]
464    struct TestNode<'a> {
465        arena: &'a Arena,
466        idx: usize,
467    }
468
469    impl<'a> PartialEq for TestNode<'a> {
470        fn eq(&self, other: &Self) -> bool {
471            std::ptr::eq(self.arena, other.arena) && self.idx == other.idx
472        }
473    }
474
475    impl<'a> Eq for TestNode<'a> {}
476
477    impl<'a> Node<'a> for TestNode<'a> {
478        fn kind(self) -> NodeKind {
479            if self.idx == 0 {
480                NodeKind::Root
481            } else {
482                NodeKind::Element
483            }
484        }
485
486        fn parent(self) -> Option<Self> {
487            self.arena.elements[self.idx].parent.map(|p| TestNode {
488                arena: self.arena,
489                idx: p,
490            })
491        }
492
493        fn children(self) -> impl Iterator<Item = Self> + 'a {
494            let arena = self.arena;
495            self.arena.elements[self.idx]
496                .children
497                .clone()
498                .into_iter()
499                .map(move |i| TestNode { arena, idx: i })
500        }
501
502        fn attributes(self) -> impl Iterator<Item = Self> + 'a {
503            std::iter::empty()
504        }
505
506        fn namespaces(self) -> impl Iterator<Item = Self> + 'a {
507            std::iter::empty()
508        }
509
510        fn expanded_name(self) -> Option<ExpandedName> {
511            if self.idx == 0 {
512                None
513            } else {
514                let element = &self.arena.elements[self.idx];
515                Some(ExpandedName {
516                    namespace_uri: element.namespace_uri.clone(),
517                    local_name: element.local_name.clone(),
518                })
519            }
520        }
521
522        fn string_value(self) -> String {
523            String::new()
524        }
525
526        fn document_order(self, other: Self) -> Ordering {
527            self.idx.cmp(&other.idx)
528        }
529    }
530
531    /// Builds a document with a root and, for each `(namespace_uri,
532    /// local_name)` pair, one direct root child element, in that order.
533    fn doc_with_root_children(children: &[(Option<&str>, &str)]) -> TestDoc {
534        let mut elements = vec![ElementData {
535            namespace_uri: None,
536            local_name: String::new(),
537            parent: None,
538            children: Vec::new(),
539        }];
540        for (namespace_uri, local_name) in children {
541            let idx = elements.len();
542            elements.push(ElementData {
543                namespace_uri: namespace_uri.map(str::to_owned),
544                local_name: (*local_name).to_owned(),
545                parent: Some(0),
546                children: Vec::new(),
547            });
548            elements[0].children.push(idx);
549        }
550        TestDoc {
551            arena: Arena { elements },
552        }
553    }
554
555    fn schema(sch_xml: &str) -> Schema {
556        crate::parse(sch_xml).expect("test schema must parse")
557    }
558
559    /// Builds `root -> wrapper -> item`, i.e. `item` is a *grandchild* of
560    /// the document root, not a direct child like `doc_with_root_children`
561    /// produces. Needed to distinguish `descendant-or-self::node()/X` (finds
562    /// `item` anywhere) from a bare, unprefixed `X` evaluated relative to
563    /// the root (would only find a direct root child literally named `X`) —
564    /// every other fixture in this module is flat, which happens to make
565    /// both forms equivalent and so cannot catch a union-context bug that
566    /// only manifests once a context alternative isn't parenthesized.
567    fn doc_with_nested_item() -> TestDoc {
568        let elements = vec![
569            ElementData {
570                namespace_uri: None,
571                local_name: String::new(),
572                parent: None,
573                children: vec![1],
574            },
575            ElementData {
576                namespace_uri: None,
577                local_name: "wrapper".to_owned(),
578                parent: Some(0),
579                children: vec![2],
580            },
581            ElementData {
582                namespace_uri: None,
583                local_name: "item".to_owned(),
584                parent: Some(1),
585                children: Vec::new(),
586            },
587        ];
588        TestDoc {
589            arena: Arena { elements },
590        }
591    }
592
593    #[test]
594    fn assert_true_produces_no_report() {
595        let doc = doc_with_root_children(&[(None, "item")]);
596        let schema = schema(
597            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
598                <pattern>
599                    <rule context="item">
600                        <assert test="true()">should not fire</assert>
601                    </rule>
602                </pattern>
603            </schema>"#,
604        );
605
606        let reports = evaluate(&schema, &doc).unwrap();
607        assert_eq!(reports.len(), 0);
608    }
609
610    #[test]
611    fn assert_false_produces_one_report() {
612        let doc = doc_with_root_children(&[(None, "item")]);
613        let schema = schema(
614            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
615                <pattern id="p1">
616                    <rule context="item">
617                        <assert id="a1" role="error" test="false()">item is invalid</assert>
618                    </rule>
619                </pattern>
620            </schema>"#,
621        );
622
623        let reports = evaluate(&schema, &doc).unwrap();
624        assert_eq!(reports.len(), 1);
625        let report = &reports[0];
626        assert_eq!(report.pattern_id.as_deref(), Some("p1"));
627        assert_eq!(report.check_id.as_deref(), Some("a1"));
628        assert_eq!(report.role.as_deref(), Some("error"));
629        assert_eq!(report.kind, CheckKind::Assert);
630        assert_eq!(report.message, "item is invalid");
631        assert_eq!(report.node, doc.node(1));
632    }
633
634    /// A `context` with a `|`-union of two alternatives must match a node
635    /// through *either* alternative, no matter which one is written first —
636    /// same as a bare `context="item"` would for the same node. Regression
637    /// test for a real bug: `evaluate`/`evaluate_with_phase` built the path
638    /// as `format!("descendant-or-self::node()/{}", rule.context)` with no
639    /// parentheses around `rule.context`. XPath's `/` binds tighter than
640    /// `|`, so `descendant-or-self::node()/A | B` parsed as
641    /// `(descendant-or-self::node()/A) | B` — only the *first* alternative
642    /// got the document-wide prefix; every later alternative was evaluated
643    /// as a bare relative path from the document root, matching only a
644    /// direct root child literally named `B`. Every other context-union
645    /// test in this module happened to use `doc_with_root_children` (a flat
646    /// tree), which made a prefixed and an unprefixed match identical and
647    /// so never exposed this — `doc_with_nested_item` puts `item` two
648    /// levels down specifically to catch it.
649    #[test]
650    fn context_union_matches_through_every_alternative() {
651        let doc = doc_with_nested_item();
652
653        let first_alternative = schema(
654            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
655                <pattern>
656                    <rule context="item | does-not-exist">
657                        <report test="true()">observed item</report>
658                    </rule>
659                </pattern>
660            </schema>"#,
661        );
662        let reports = evaluate(&first_alternative, &doc).unwrap();
663        assert_eq!(reports.len(), 1, "item as the first union alternative");
664
665        let second_alternative = schema(
666            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
667                <pattern>
668                    <rule context="does-not-exist | item">
669                        <report test="true()">observed item</report>
670                    </rule>
671                </pattern>
672            </schema>"#,
673        );
674        let reports = evaluate(&second_alternative, &doc).unwrap();
675        assert_eq!(reports.len(), 1, "item as the second union alternative");
676    }
677
678    #[test]
679    fn split_top_level_union_ignores_nested_pipes() {
680        assert_eq!(split_top_level_union("item"), vec!["item"]);
681        assert_eq!(split_top_level_union("a | b | c"), vec!["a ", " b ", " c"]);
682        // A `|` inside a predicate (e.g. an attribute-value alternation)
683        // is not a context-level union and must not be split on.
684        assert_eq!(
685            split_top_level_union("a[@x = 'p|q']"),
686            vec!["a[@x = 'p|q']"]
687        );
688        assert_eq!(split_top_level_union("a[b | c]"), vec!["a[b | c]"]);
689        assert_eq!(split_top_level_union("(a | b)/c"), vec!["(a | b)/c"]);
690        assert_eq!(
691            split_top_level_union("a[@x = 'p|q'] | b"),
692            vec!["a[@x = 'p|q'] ", " b"]
693        );
694    }
695
696    #[test]
697    fn report_true_fires() {
698        let doc = doc_with_root_children(&[(None, "item")]);
699        let schema = schema(
700            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
701                <pattern>
702                    <rule context="item">
703                        <report test="true()">observed item</report>
704                    </rule>
705                </pattern>
706            </schema>"#,
707        );
708
709        let reports = evaluate(&schema, &doc).unwrap();
710        assert_eq!(reports.len(), 1);
711        assert_eq!(reports[0].kind, CheckKind::Report);
712        assert_eq!(reports[0].message, "observed item");
713    }
714
715    /// Two rules in the SAME pattern with overlapping context: only the
716    /// first rule's checks run against the shared node (the core of the
717    /// "one rule per node per pattern, first match wins" model) — belt
718    /// and braces distinctly-recognizable messages so this is proven, not
719    /// just plausible from reading the code.
720    #[test]
721    fn same_pattern_first_matching_rule_wins() {
722        let doc = doc_with_root_children(&[(None, "item")]);
723        let schema = schema(
724            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
725                <pattern>
726                    <rule context="item">
727                        <assert test="false()">RULE-ONE-FIRED</assert>
728                    </rule>
729                    <rule context="item">
730                        <assert test="false()">RULE-TWO-FIRED</assert>
731                    </rule>
732                </pattern>
733            </schema>"#,
734        );
735
736        let reports = evaluate(&schema, &doc).unwrap();
737        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
738        assert_eq!(messages, vec!["RULE-ONE-FIRED"]);
739    }
740
741    /// The counter-case: two PATTERNS with overlapping context both fire
742    /// independently for the same node — patterns do not share a claimed
743    /// set.
744    #[test]
745    fn independent_patterns_both_fire() {
746        let doc = doc_with_root_children(&[(None, "item")]);
747        let schema = schema(
748            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
749                <pattern>
750                    <rule context="item">
751                        <assert test="false()">PATTERN-ONE-FIRED</assert>
752                    </rule>
753                </pattern>
754                <pattern>
755                    <rule context="item">
756                        <assert test="false()">PATTERN-TWO-FIRED</assert>
757                    </rule>
758                </pattern>
759            </schema>"#,
760        );
761
762        let reports = evaluate(&schema, &doc).unwrap();
763        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
764        assert_eq!(messages, vec!["PATTERN-ONE-FIRED", "PATTERN-TWO-FIRED"]);
765    }
766
767    /// A prefixed name test in `context` that would NOT match its target
768    /// node without namespace resolution via `Schema.ns` — proves the
769    /// `Schema.ns` → `xpath-eval` namespace-hook wiring works end-to-end.
770    /// Without it, `t:item`'s prefix falls back to being compared directly
771    /// against the real namespace URI `urn:test` (they're unequal), the
772    /// context would not match, and no report would fire.
773    #[test]
774    fn namespace_binding_resolves_prefixed_context() {
775        let doc = doc_with_root_children(&[(Some("urn:test"), "item")]);
776        let schema = schema(
777            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
778                <ns prefix="t" uri="urn:test"/>
779                <pattern>
780                    <rule context="t:item">
781                        <assert test="false()">NS-MATCH</assert>
782                    </rule>
783                </pattern>
784            </schema>"#,
785        );
786
787        let reports = evaluate(&schema, &doc).unwrap();
788        assert_eq!(reports.len(), 1);
789        assert_eq!(reports[0].message, "NS-MATCH");
790    }
791
792    #[test]
793    fn invalid_test_expression_is_a_parse_error_not_a_panic() {
794        let doc = doc_with_root_children(&[(None, "item")]);
795        let schema = schema(
796            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
797                <pattern>
798                    <rule context="item">
799                        <assert test="((">broken</assert>
800                    </rule>
801                </pattern>
802            </schema>"#,
803        );
804
805        let result = evaluate(&schema, &doc);
806        assert!(matches!(result, Err(SchematronEvalError::Parse(_))));
807    }
808
809    #[test]
810    fn schema_level_let_is_visible_to_checks() {
811        let doc = doc_with_root_children(&[(None, "item")]);
812        let schema = schema(
813            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
814                <let name="x" value="'schema'"/>
815                <pattern>
816                    <rule context="item">
817                        <report test="$x = 'schema'">SCHEMA-LET</report>
818                    </rule>
819                </pattern>
820            </schema>"#,
821        );
822
823        let reports = evaluate(&schema, &doc).unwrap();
824        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
825        assert_eq!(messages, vec!["SCHEMA-LET"]);
826    }
827
828    /// A pattern-level `<let>` with the same name as a schema-level one
829    /// shadows it within that pattern — proven positively (the check fires
830    /// only if `$x` really is the pattern-level value), not just by absence
831    /// of a failure.
832    #[test]
833    fn pattern_level_let_shadows_schema_level_let() {
834        let doc = doc_with_root_children(&[(None, "item")]);
835        let schema = schema(
836            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
837                <let name="x" value="'schema'"/>
838                <pattern>
839                    <let name="x" value="'pattern'"/>
840                    <rule context="item">
841                        <report test="$x = 'pattern'">PATTERN-WINS</report>
842                    </rule>
843                </pattern>
844            </schema>"#,
845        );
846
847        let reports = evaluate(&schema, &doc).unwrap();
848        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
849        assert_eq!(messages, vec!["PATTERN-WINS"]);
850    }
851
852    /// A rule-level `<let>` referencing the context node is re-evaluated
853    /// per matched node, not computed once for the rule — the two root
854    /// children have different names, only one matches.
855    #[test]
856    fn rule_level_let_is_reevaluated_per_context_node() {
857        let doc = doc_with_root_children(&[(None, "alpha"), (None, "beta")]);
858        let schema = schema(
859            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
860                <pattern>
861                    <rule context="*">
862                        <let name="n" value="local-name()"/>
863                        <assert test="$n = 'alpha'">NOT-ALPHA</assert>
864                    </rule>
865                </pattern>
866            </schema>"#,
867        );
868
869        let reports = evaluate(&schema, &doc).unwrap();
870        assert_eq!(reports.len(), 1);
871        assert_eq!(reports[0].message, "NOT-ALPHA");
872        assert_eq!(reports[0].node, doc.node(2));
873    }
874
875    /// A `<let>` can reference an earlier `<let>` declared in the same
876    /// scope (sequential visibility, like sibling `xsl:variable`s).
877    #[test]
878    fn let_can_reference_earlier_let_in_same_scope() {
879        let doc = doc_with_root_children(&[(None, "item")]);
880        let schema = schema(
881            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
882                <let name="a" value="'A'"/>
883                <let name="b" value="concat($a, 'B')"/>
884                <pattern>
885                    <rule context="item">
886                        <report test="$b = 'AB'">SEQUENTIAL</report>
887                    </rule>
888                </pattern>
889            </schema>"#,
890        );
891
892        let reports = evaluate(&schema, &doc).unwrap();
893        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
894        assert_eq!(messages, vec!["SEQUENTIAL"]);
895    }
896
897    /// Issue #3, end-to-end: a `<let>` bound to literal-XML content is
898    /// usable in a `test` expression via its string-value, same as any
899    /// other string.
900    #[test]
901    fn literal_xml_let_is_usable_as_a_string_in_a_test_expression() {
902        let doc = doc_with_root_children(&[(None, "item")]);
903        let schema = schema(
904            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
905                <let name="x"><foo>literal</foo></let>
906                <pattern>
907                    <rule context="item">
908                        <report test="$x = 'literal'">LITERAL-LET</report>
909                    </rule>
910                </pattern>
911            </schema>"#,
912        );
913
914        let reports = evaluate(&schema, &doc).unwrap();
915        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
916        assert_eq!(messages, vec!["LITERAL-LET"]);
917    }
918
919    #[test]
920    fn unbound_variable_is_an_eval_error_not_a_panic() {
921        let doc = doc_with_root_children(&[(None, "item")]);
922        let schema = schema(
923            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
924                <pattern>
925                    <rule context="item">
926                        <assert test="$missing">broken</assert>
927                    </rule>
928                </pattern>
929            </schema>"#,
930        );
931
932        let result = evaluate(&schema, &doc);
933        assert!(matches!(result, Err(SchematronEvalError::Eval(_))));
934    }
935
936    /// Two patterns, one phase activating only one of them —
937    /// `evaluate_with_phase` fires only the activated pattern's checks.
938    #[test]
939    fn evaluate_with_phase_restricts_to_activated_patterns() {
940        let doc = doc_with_root_children(&[(None, "item")]);
941        let schema = schema(
942            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
943                <phase id="p1">
944                    <active pattern="pa"/>
945                </phase>
946                <phase id="p2">
947                    <active pattern="pb"/>
948                </phase>
949                <pattern id="pa">
950                    <rule context="item">
951                        <assert test="false()">PATTERN-A</assert>
952                    </rule>
953                </pattern>
954                <pattern id="pb">
955                    <rule context="item">
956                        <assert test="false()">PATTERN-B</assert>
957                    </rule>
958                </pattern>
959            </schema>"#,
960        );
961
962        let reports_p1 = evaluate_with_phase(&schema, &doc, Some("p1")).unwrap();
963        let messages_p1: Vec<&str> = reports_p1.iter().map(|r| r.message.as_str()).collect();
964        assert_eq!(messages_p1, vec!["PATTERN-A"]);
965
966        let reports_p2 = evaluate_with_phase(&schema, &doc, Some("p2")).unwrap();
967        let messages_p2: Vec<&str> = reports_p2.iter().map(|r| r.message.as_str()).collect();
968        assert_eq!(messages_p2, vec!["PATTERN-B"]);
969
970        let reports_all = evaluate_with_phase(&schema, &doc, None).unwrap();
971        let messages_all: Vec<&str> = reports_all.iter().map(|r| r.message.as_str()).collect();
972        assert_eq!(messages_all, vec!["PATTERN-A", "PATTERN-B"]);
973    }
974
975    /// `evaluate()` honors `schema/@defaultPhase` (ISO's `#DEFAULT`) — the
976    /// counter-proof to the previous test's `None`/`#ALL` case: with a
977    /// `defaultPhase` set, the non-activated pattern does NOT fire, even
978    /// though calling `evaluate_with_phase(.., None)` on the same schema
979    /// would fire both (proven above).
980    #[test]
981    fn evaluate_honors_default_phase() {
982        let doc = doc_with_root_children(&[(None, "item")]);
983        let schema = schema(
984            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron" defaultPhase="p1">
985                <phase id="p1">
986                    <active pattern="pa"/>
987                </phase>
988                <pattern id="pa">
989                    <rule context="item">
990                        <assert test="false()">PATTERN-A</assert>
991                    </rule>
992                </pattern>
993                <pattern id="pb">
994                    <rule context="item">
995                        <assert test="false()">PATTERN-B</assert>
996                    </rule>
997                </pattern>
998            </schema>"#,
999        );
1000
1001        let reports = evaluate(&schema, &doc).unwrap();
1002        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
1003        assert_eq!(messages, vec!["PATTERN-A"]);
1004    }
1005
1006    #[test]
1007    fn evaluate_with_phase_unknown_id_is_an_error() {
1008        let doc = doc_with_root_children(&[(None, "item")]);
1009        let schema = schema(
1010            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1011                <pattern id="pa">
1012                    <rule context="item">
1013                        <assert test="false()">PATTERN-A</assert>
1014                    </rule>
1015                </pattern>
1016            </schema>"#,
1017        );
1018
1019        let error = evaluate_with_phase(&schema, &doc, Some("does-not-exist")).unwrap_err();
1020        assert_eq!(
1021            error,
1022            SchematronEvalError::UnknownPhase {
1023                phase: "does-not-exist".to_owned(),
1024            }
1025        );
1026    }
1027
1028    #[test]
1029    fn phase_level_let_is_visible_within_that_phase() {
1030        let doc = doc_with_root_children(&[(None, "item")]);
1031        let schema = schema(
1032            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1033                <phase id="p1">
1034                    <let name="x" value="'phase'"/>
1035                    <active pattern="pa"/>
1036                </phase>
1037                <pattern id="pa">
1038                    <rule context="item">
1039                        <report test="$x = 'phase'">PHASE-LET</report>
1040                    </rule>
1041                </pattern>
1042            </schema>"#,
1043        );
1044
1045        let reports = evaluate_with_phase(&schema, &doc, Some("p1")).unwrap();
1046        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
1047        assert_eq!(messages, vec!["PHASE-LET"]);
1048    }
1049
1050    /// Regression: a plain-text message (no `value-of`) with leading/
1051    /// trailing whitespace is still trimmed the same way it was in Stage 1
1052    /// — trimming just moved from parse time to render time.
1053    #[test]
1054    fn plain_text_message_is_trimmed_at_render_time() {
1055        let doc = doc_with_root_children(&[(None, "item")]);
1056        let schema = schema(
1057            "<schema xmlns=\"http://purl.oclc.org/dsdl/schematron\">\
1058                <pattern>\
1059                    <rule context=\"item\">\
1060                        <assert test=\"false()\">\n  first   second  \n</assert>\
1061                    </rule>\
1062                </pattern>\
1063            </schema>",
1064        );
1065
1066        let reports = evaluate(&schema, &doc).unwrap();
1067        assert_eq!(reports[0].message, "first   second");
1068    }
1069
1070    #[test]
1071    fn value_of_interpolates_the_context_node() {
1072        let doc = doc_with_root_children(&[(None, "item")]);
1073        let schema = schema(
1074            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1075                <pattern>
1076                    <rule context="item">
1077                        <assert test="false()">Element: <value-of select="local-name()"/>.</assert>
1078                    </rule>
1079                </pattern>
1080            </schema>"#,
1081        );
1082
1083        let reports = evaluate(&schema, &doc).unwrap();
1084        assert_eq!(reports[0].message, "Element: item.");
1085    }
1086
1087    /// Issue #4: a self-closing `<name/>` resolves to the firing check's
1088    /// own context node's name.
1089    #[test]
1090    fn name_resolves_to_the_context_node_name() {
1091        let doc = doc_with_root_children(&[(None, "item")]);
1092        let schema = schema(
1093            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1094                <pattern>
1095                    <rule context="item">
1096                        <assert test="false()">Element <name/> is invalid.</assert>
1097                    </rule>
1098                </pattern>
1099            </schema>"#,
1100        );
1101
1102        let reports = evaluate(&schema, &doc).unwrap();
1103        assert_eq!(reports[0].message, "Element item is invalid.");
1104    }
1105
1106    /// The counter-case: `<name path="...">` resolves to a *different*
1107    /// node's name than the firing check's own context node.
1108    #[test]
1109    fn name_with_path_resolves_to_a_different_node() {
1110        let doc = doc_with_root_children(&[(None, "item")]);
1111        let schema = schema(
1112            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1113                <pattern>
1114                    <rule context="item">
1115                        <assert test="false()">Parent is <name path=".."/>.</assert>
1116                    </rule>
1117                </pattern>
1118            </schema>"#,
1119        );
1120
1121        let reports = evaluate(&schema, &doc).unwrap();
1122        // The root test node has an empty local name (see `TestNode`/
1123        // `doc_with_root_children`) — proves `path` is actually evaluated
1124        // against a different node than `.` (the context node, "item"),
1125        // not just ignored.
1126        assert_eq!(reports[0].message, "Parent is .");
1127    }
1128
1129    #[test]
1130    fn value_of_can_reference_a_let_variable() {
1131        let doc = doc_with_root_children(&[(None, "item")]);
1132        let schema = schema(
1133            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1134                <pattern>
1135                    <rule context="item">
1136                        <let name="x" value="'bound-value'"/>
1137                        <assert test="false()">x is <value-of select="$x"/></assert>
1138                    </rule>
1139                </pattern>
1140            </schema>"#,
1141        );
1142
1143        let reports = evaluate(&schema, &doc).unwrap();
1144        assert_eq!(reports[0].message, "x is bound-value");
1145    }
1146
1147    /// A broken `value-of` in a check that never fires must not surface as
1148    /// an error — the message is only rendered for fired checks.
1149    #[test]
1150    fn broken_value_of_in_a_non_firing_check_is_not_an_error() {
1151        let doc = doc_with_root_children(&[(None, "item")]);
1152        let schema = schema(
1153            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1154                <pattern>
1155                    <rule context="item">
1156                        <assert test="true()">broken: <value-of select="(("/></assert>
1157                    </rule>
1158                </pattern>
1159            </schema>"#,
1160        );
1161
1162        let reports = evaluate(&schema, &doc).unwrap();
1163        assert_eq!(reports.len(), 0);
1164    }
1165
1166    /// The counter-case: the same broken `value-of` in a check that DOES
1167    /// fire surfaces as a parse error, not a panic.
1168    #[test]
1169    fn broken_value_of_in_a_firing_check_is_a_parse_error() {
1170        let doc = doc_with_root_children(&[(None, "item")]);
1171        let schema = schema(
1172            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1173                <pattern>
1174                    <rule context="item">
1175                        <assert test="false()">broken: <value-of select="(("/></assert>
1176                    </rule>
1177                </pattern>
1178            </schema>"#,
1179        );
1180
1181        let result = evaluate(&schema, &doc);
1182        assert!(matches!(result, Err(SchematronEvalError::Parse(_))));
1183    }
1184
1185    /// End-to-end: an `<extends>`-inlined check fires during evaluation
1186    /// exactly like one written inline in the rule (parser-level coverage
1187    /// is in `parser::tests`, this is the evaluation-side integration
1188    /// check).
1189    #[test]
1190    fn extends_inlined_check_fires_during_evaluation() {
1191        let doc = doc_with_root_children(&[(None, "item")]);
1192        let schema = schema(
1193            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1194                <pattern>
1195                    <rule abstract="true" id="base">
1196                        <assert test="false()">FROM-BASE</assert>
1197                    </rule>
1198                    <rule context="item">
1199                        <extends rule="base"/>
1200                    </rule>
1201                </pattern>
1202            </schema>"#,
1203        );
1204
1205        let reports = evaluate(&schema, &doc).unwrap();
1206        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
1207        assert_eq!(messages, vec!["FROM-BASE"]);
1208    }
1209
1210    /// End-to-end (issue #7): a `<let>` declared in an abstract rule pulled
1211    /// in via `<extends>` is visible to the extending rule's own `test`
1212    /// expressions, not just its inlined checks.
1213    #[test]
1214    fn extends_inlined_let_is_visible_to_own_checks() {
1215        let doc = doc_with_root_children(&[(None, "item")]);
1216        let schema = schema(
1217            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1218                <pattern>
1219                    <rule abstract="true" id="base">
1220                        <let name="x" value="'from-base'"/>
1221                    </rule>
1222                    <rule context="item">
1223                        <extends rule="base"/>
1224                        <report test="$x = 'from-base'">INHERITED-LET</report>
1225                    </rule>
1226                </pattern>
1227            </schema>"#,
1228        );
1229
1230        let reports = evaluate(&schema, &doc).unwrap();
1231        let messages: Vec<&str> = reports.iter().map(|r| r.message.as_str()).collect();
1232        assert_eq!(messages, vec!["INHERITED-LET"]);
1233    }
1234
1235    /// End-to-end: an `is-a`-instantiated abstract pattern evaluates with
1236    /// its parameters correctly substituted into `context`/`test`.
1237    #[test]
1238    fn is_a_instantiated_pattern_evaluates_with_substituted_context() {
1239        let doc = doc_with_root_children(&[(None, "alpha"), (None, "beta")]);
1240        let schema = schema(
1241            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1242                <pattern abstract="true" id="tpl">
1243                    <rule context="$el">
1244                        <assert test="false()">MATCHED-$el</assert>
1245                    </rule>
1246                </pattern>
1247                <pattern is-a="tpl" id="concrete">
1248                    <param name="el" value="alpha"/>
1249                </pattern>
1250            </schema>"#,
1251        );
1252
1253        let reports = evaluate(&schema, &doc).unwrap();
1254        assert_eq!(reports.len(), 1);
1255        // The message text itself is plain (unparameterized) content, so
1256        // it's untouched by substitution — only context/test are.
1257        assert_eq!(reports[0].message, "MATCHED-$el");
1258        assert_eq!(reports[0].node, doc.node(1));
1259    }
1260
1261    #[test]
1262    fn fired_check_carries_its_rendered_diagnostics() {
1263        let doc = doc_with_root_children(&[(None, "item")]);
1264        let schema = schema(
1265            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1266                <diagnostics>
1267                    <diagnostic id="d1">first</diagnostic>
1268                    <diagnostic id="d2">element is <value-of select="local-name()"/></diagnostic>
1269                </diagnostics>
1270                <pattern>
1271                    <rule context="item">
1272                        <assert test="false()" diagnostics="d1 d2">broken</assert>
1273                    </rule>
1274                </pattern>
1275            </schema>"#,
1276        );
1277
1278        let reports = evaluate(&schema, &doc).unwrap();
1279        assert_eq!(reports[0].diagnostics, vec!["first", "element is item"]);
1280    }
1281
1282    #[test]
1283    fn unknown_diagnostic_id_is_silently_skipped() {
1284        // NOTE: this used to be a *parse-time-succeeds* case where an
1285        // unknown `diagnostics="..."` id was silently dropped at
1286        // evaluation time. Referential integrity for `diagnostics="..."`
1287        // is now checked at parse time instead (see `DECISIONS.md` and
1288        // `parser::tests::diagnostics_unknown_reference_is_an_error`) — an
1289        // unknown id no longer reaches `evaluate` at all, it's a
1290        // `ParseError`.
1291        let schema_xml = r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1292                <diagnostics>
1293                    <diagnostic id="d1">known</diagnostic>
1294                </diagnostics>
1295                <pattern>
1296                    <rule context="item">
1297                        <assert test="false()" diagnostics="d1 missing">broken</assert>
1298                    </rule>
1299                </pattern>
1300            </schema>"#;
1301
1302        assert!(crate::parse(schema_xml).is_err());
1303    }
1304
1305    #[test]
1306    fn check_without_diagnostics_attribute_yields_an_empty_list() {
1307        let doc = doc_with_root_children(&[(None, "item")]);
1308        let schema = schema(
1309            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1310                <pattern>
1311                    <rule context="item">
1312                        <assert test="false()">broken</assert>
1313                    </rule>
1314                </pattern>
1315            </schema>"#,
1316        );
1317
1318        let reports = evaluate(&schema, &doc).unwrap();
1319        assert_eq!(reports[0].diagnostics, Vec::<String>::new());
1320    }
1321}