Skip to main content

schematron_engine/
parser.rs

1//! Parser for the Stage-1 Schematron XML schema format
2//! (`schema`/`ns`/`pattern`/`rule`/`assert`/`report`) into the
3//! [`crate::schema`] model. See `plan/02-schema-parser.md`.
4
5use std::collections::{HashMap, HashSet};
6use std::fmt;
7
8use roxmltree::Node;
9
10use crate::schema::{
11    Check, CheckKind, Diagnostic, LetBinding, MessagePart, NamespaceBinding, Pattern, Phase, Rule,
12    Schema,
13};
14
15const SCHEMATRON_NAMESPACE: &str = "http://purl.oclc.org/dsdl/schematron";
16
17// Direct-child allowlists per container, verified against the ISO
18// Schematron RNC grammar (`github.com/Schematron/schema`, see
19// `plan/04-let-bindings.md`, `plan/05-phase-selection.md`,
20// `plan/08-diagnostics.md` for the fragments this was checked against).
21// `title`/`p` are real ISO elements (prose/documentation) accepted
22// wherever the grammar allows them, even though this crate never reads
23// their content — everything else not listed here is either a typo of a
24// known element or a genuinely unimplemented ISO construct (e.g.
25// `<include>`), both surfaced as `ParseError::UnexpectedElement` rather
26// than silently dropped. Foreign-namespace elements are untouched by this
27// list entirely (see `validate_known_children`).
28const SCHEMA_ALLOWED_CHILDREN: &[&str] =
29    &["title", "p", "ns", "let", "phase", "pattern", "diagnostics"];
30const PATTERN_ALLOWED_CHILDREN: &[&str] = &["title", "p", "let", "rule"];
31/// A `<pattern is-a="...">` *use* site has a different content model than
32/// a pattern definition — `param*`, not `rule*`.
33const IS_A_USE_ALLOWED_CHILDREN: &[&str] = &["p", "param"];
34const RULE_ALLOWED_CHILDREN: &[&str] = &["let", "assert", "report", "extends", "p"];
35const PHASE_ALLOWED_CHILDREN: &[&str] = &["title", "p", "let", "active"];
36
37/// A 1-based row/column position in the source XML.
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub struct Position {
40    pub row: u32,
41    pub col: u32,
42}
43
44impl fmt::Display for Position {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        write!(formatter, "{}:{}", self.row, self.col)
47    }
48}
49
50/// An error encountered while parsing a Schematron schema.
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub enum ParseError {
53    /// The input is not well-formed XML.
54    InvalidXml(String),
55    /// The root element is not `<schema>` in the Schematron namespace.
56    WrongRootNamespace {
57        found: Option<String>,
58        position: Position,
59    },
60    /// The schema contains no `<pattern>` elements.
61    NoPatterns,
62    /// A required attribute is missing on an element.
63    MissingAttribute {
64        element: &'static str,
65        attribute: &'static str,
66        position: Position,
67    },
68    /// `<pattern is-a="...">` referenced a pattern id with no matching
69    /// `<pattern abstract="true" id="...">` in the schema.
70    UnknownAbstractPattern { pattern: String, position: Position },
71    /// `<extends rule="...">` referenced a rule id with no matching
72    /// `<rule id="...">` in the same pattern (see
73    /// `plan/07-abstract-patterns-and-extends.md` for why the lookup is
74    /// pattern-local, not schema-wide).
75    UnknownRuleReference { rule: String, position: Position },
76    /// `<extends>` formed a cycle (a rule extending itself, directly or
77    /// transitively).
78    CyclicRuleExtends { rule: String, position: Position },
79    /// A Schematron-namespace element appeared as a direct child of a
80    /// container it is not part of that container's grammar — either a
81    /// typo of a known element (e.g. `<asert>`), or a real ISO Schematron
82    /// construct this crate does not implement (e.g. `<include>`). See
83    /// `README.md`'s support matrix. Foreign-namespace elements are never
84    /// affected — ISO's own "foreign" content model already allows those
85    /// anywhere, regardless of this crate's support.
86    UnexpectedElement { element: String, position: Position },
87    /// Two elements in the same ID space (`pattern/@id` or
88    /// `diagnostic/@id`) declared the same `id` — ambiguous for anything
89    /// that references it by IDREF(S) (`active/@pattern`,
90    /// `assert|report/@diagnostics`).
91    DuplicateId {
92        element: &'static str,
93        id: String,
94        position: Position,
95    },
96    /// `<active pattern="...">` referenced a pattern id with no matching
97    /// *concrete* `<pattern id="...">` in the schema (abstract-pattern ids
98    /// don't count — they never appear in `Schema.patterns`; compare
99    /// `UnknownAbstractPattern`, which is `is-a`'s own, separate reference
100    /// kind).
101    UnknownActivePattern { pattern: String, position: Position },
102    /// `assert|report/@diagnostics` referenced a diagnostic id with no
103    /// matching `<diagnostic id="...">` in the schema.
104    UnknownDiagnosticReference { id: String, position: Position },
105}
106
107impl fmt::Display for ParseError {
108    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            ParseError::InvalidXml(message) => {
111                write!(formatter, "invalid XML: {message}")
112            }
113            ParseError::WrongRootNamespace { found, position } => write!(
114                formatter,
115                "schema root must be in the Schematron namespace `{SCHEMATRON_NAMESPACE}`, found `{}` at {position}",
116                found.as_deref().unwrap_or("(none)")
117            ),
118            ParseError::NoPatterns => {
119                write!(formatter, "schema must contain at least one <pattern>")
120            }
121            ParseError::MissingAttribute {
122                element,
123                attribute,
124                position,
125            } => write!(
126                formatter,
127                "<{element}> is missing required attribute `{attribute}` at {position}"
128            ),
129            ParseError::UnknownAbstractPattern { pattern, position } => write!(
130                formatter,
131                "is-a references unknown abstract pattern `{pattern}` at {position}"
132            ),
133            ParseError::UnknownRuleReference { rule, position } => write!(
134                formatter,
135                "extends references unknown rule `{rule}` (not found in the same pattern) at {position}"
136            ),
137            ParseError::CyclicRuleExtends { rule, position } => write!(
138                formatter,
139                "cyclic extends involving rule `{rule}` at {position}"
140            ),
141            ParseError::UnexpectedElement { element, position } => write!(
142                formatter,
143                "unexpected <{element}> at {position} (unknown, or not supported here — see README.md)"
144            ),
145            ParseError::DuplicateId {
146                element,
147                id,
148                position,
149            } => write!(
150                formatter,
151                "duplicate {element} id `{id}` at {position} (already declared earlier in the schema)"
152            ),
153            ParseError::UnknownActivePattern { pattern, position } => write!(
154                formatter,
155                "active references unknown pattern `{pattern}` at {position}"
156            ),
157            ParseError::UnknownDiagnosticReference { id, position } => write!(
158                formatter,
159                "diagnostics references unknown diagnostic `{id}` at {position}"
160            ),
161        }
162    }
163}
164
165impl std::error::Error for ParseError {}
166
167/// Parses a Schematron `.sch` XML document into a [`Schema`].
168pub fn parse(xml: &str) -> Result<Schema, ParseError> {
169    let document = roxmltree::Document::parse(xml)
170        .map_err(|error| ParseError::InvalidXml(error.to_string()))?;
171    let root = document.root_element();
172
173    if root.tag_name().namespace() != Some(SCHEMATRON_NAMESPACE) {
174        return Err(ParseError::WrongRootNamespace {
175            found: root.tag_name().namespace().map(str::to_owned),
176            position: position_of(root),
177        });
178    }
179
180    validate_known_children(root, SCHEMA_ALLOWED_CHILDREN)?;
181
182    let ns = root
183        .children()
184        .filter(|node| node.has_tag_name((SCHEMATRON_NAMESPACE, "ns")))
185        .map(parse_ns)
186        .collect::<Result<_, _>>()?;
187
188    let lets = root
189        .children()
190        .filter(|node| node.has_tag_name((SCHEMATRON_NAMESPACE, "let")))
191        .map(parse_let)
192        .collect::<Result<_, _>>()?;
193
194    let default_phase = root.attribute("defaultPhase").map(str::to_owned);
195
196    // Diagnostics are parsed before patterns so `assert|report/@diagnostics`
197    // IDREFS can be validated against a complete `diagnostic/@id` set while
198    // walking checks below (see `parse_check`).
199    let diagnostics = root
200        .children()
201        .find(|node| node.has_tag_name((SCHEMATRON_NAMESPACE, "diagnostics")))
202        .map(parse_diagnostics_element)
203        .transpose()?
204        .unwrap_or_default();
205    let diagnostic_ids: HashSet<String> = diagnostics.iter().map(|d| d.id.clone()).collect();
206
207    // Abstract patterns (`plan/07-abstract-patterns-and-extends.md`) are
208    // schema-wide templates, looked up by `is-a` regardless of where in
209    // the schema they're declared — indexed once, up front.
210    let abstract_patterns: HashMap<String, Node<'_, '_>> = root
211        .children()
212        .filter(|node| {
213            node.has_tag_name((SCHEMATRON_NAMESPACE, "pattern"))
214                && node.attribute("abstract") == Some("true")
215        })
216        .map(|node| {
217            node.attribute("id")
218                .map(|id| (id.to_owned(), node))
219                .ok_or_else(|| ParseError::MissingAttribute {
220                    element: "pattern",
221                    attribute: "id",
222                    position: position_of(node),
223                })
224        })
225        .collect::<Result<_, _>>()?;
226
227    // Tracks concrete `pattern/@id`s as they're parsed, both to reject a
228    // duplicate the moment it's seen (with that pattern's own position,
229    // not the earlier one's) and to build the id set `parse_phase`/
230    // `parse_active` validate `active/@pattern` against below.
231    let mut pattern_ids: HashSet<String> = HashSet::new();
232
233    let patterns: Vec<Pattern> = root
234        .children()
235        .filter(|node| {
236            node.has_tag_name((SCHEMATRON_NAMESPACE, "pattern"))
237                && node.attribute("abstract") != Some("true")
238        })
239        .map(|node| {
240            let id = node.attribute("id").map(str::to_owned);
241            if let Some(id) = &id
242                && !pattern_ids.insert(id.clone())
243            {
244                return Err(ParseError::DuplicateId {
245                    element: "pattern",
246                    id: id.clone(),
247                    position: position_of(node),
248                });
249            }
250
251            if let Some(is_a) = node.attribute("is-a") {
252                validate_known_children(node, IS_A_USE_ALLOWED_CHILDREN)?;
253                let abstract_node = abstract_patterns.get(is_a).copied().ok_or_else(|| {
254                    ParseError::UnknownAbstractPattern {
255                        pattern: is_a.to_owned(),
256                        position: position_of(node),
257                    }
258                })?;
259                let params = node
260                    .children()
261                    .filter(|child| child.has_tag_name((SCHEMATRON_NAMESPACE, "param")))
262                    .map(parse_param)
263                    .collect::<Result<Vec<_>, _>>()?;
264                parse_pattern(abstract_node, id, &params, &diagnostic_ids)
265            } else {
266                parse_pattern(node, id, &[], &diagnostic_ids)
267            }
268        })
269        .collect::<Result<_, _>>()?;
270
271    if patterns.is_empty() {
272        return Err(ParseError::NoPatterns);
273    }
274
275    let phases = root
276        .children()
277        .filter(|node| node.has_tag_name((SCHEMATRON_NAMESPACE, "phase")))
278        .map(|node| parse_phase(node, &pattern_ids))
279        .collect::<Result<_, _>>()?;
280
281    Ok(Schema {
282        ns,
283        lets,
284        default_phase,
285        phases,
286        patterns,
287        diagnostics,
288    })
289}
290
291/// Parses a `<diagnostics><diagnostic id="..." role="...">...</diagnostic>
292/// ...</diagnostics>` element into its `diagnostic` children. A repeated
293/// `diagnostic/@id` is a [`ParseError::DuplicateId`] — an ambiguous target
294/// for `assert|report/@diagnostics`.
295fn parse_diagnostics_element(node: Node<'_, '_>) -> Result<Vec<Diagnostic>, ParseError> {
296    validate_known_children(node, &["diagnostic"])?;
297    let mut seen = HashSet::new();
298    node.children()
299        .filter(|child| child.has_tag_name((SCHEMATRON_NAMESPACE, "diagnostic")))
300        .map(|child| {
301            let diagnostic = parse_diagnostic(child)?;
302            if !seen.insert(diagnostic.id.clone()) {
303                return Err(ParseError::DuplicateId {
304                    element: "diagnostic",
305                    id: diagnostic.id,
306                    position: position_of(child),
307                });
308            }
309            Ok(diagnostic)
310        })
311        .collect()
312}
313
314/// Parses a `<diagnostic id="..." role="...">` — same rich-content
315/// message model as [`parse_check`]'s message (`text`/`value-of`), no
316/// parameter substitution (diagnostics are schema-level, never part of an
317/// abstract-pattern instantiation).
318fn parse_diagnostic(node: Node<'_, '_>) -> Result<Diagnostic, ParseError> {
319    let id = node
320        .attribute("id")
321        .ok_or_else(|| ParseError::MissingAttribute {
322            element: "diagnostic",
323            attribute: "id",
324            position: position_of(node),
325        })?
326        .to_owned();
327    let role = node.attribute("role").map(str::to_owned);
328    let mut message = Vec::new();
329    collect_message_parts(node, &mut message, &[])?;
330    Ok(Diagnostic { id, role, message })
331}
332
333/// Parses an `<ns prefix="..." uri="...">` binding. Both attributes are
334/// required — a `<ns>` missing either is a regular `MissingAttribute`
335/// error, not a silent empty-string fallback (an empty prefix/uri binding
336/// is never meaningful).
337fn parse_ns(node: Node<'_, '_>) -> Result<NamespaceBinding, ParseError> {
338    let prefix = node
339        .attribute("prefix")
340        .ok_or_else(|| ParseError::MissingAttribute {
341            element: "ns",
342            attribute: "prefix",
343            position: position_of(node),
344        })?
345        .to_owned();
346    let uri = node
347        .attribute("uri")
348        .ok_or_else(|| ParseError::MissingAttribute {
349            element: "ns",
350            attribute: "uri",
351            position: position_of(node),
352        })?
353        .to_owned();
354    Ok(NamespaceBinding { prefix, uri })
355}
356
357/// Parses a `<let name="..." value="...">` binding. Only the
358/// `value`-attribute form is supported (see `plan/04-let-bindings.md`) —
359/// the grammar's alternative `foreign-element+` literal-XML-content form
360/// is out of scope, and a `<let>` without `value` is a regular
361/// `MissingAttribute` error, not a silent fallback.
362fn parse_let(node: Node<'_, '_>) -> Result<LetBinding, ParseError> {
363    let name = node
364        .attribute("name")
365        .ok_or_else(|| ParseError::MissingAttribute {
366            element: "let",
367            attribute: "name",
368            position: position_of(node),
369        })?
370        .to_owned();
371    let value = node
372        .attribute("value")
373        .ok_or_else(|| ParseError::MissingAttribute {
374            element: "let",
375            attribute: "value",
376            position: position_of(node),
377        })?
378        .to_owned();
379    Ok(LetBinding { name, value })
380}
381
382/// Parses a `<phase id="..."><active pattern="..."/>...</phase>` element.
383/// `active/@pattern` is cross-checked against `pattern_ids` (every
384/// concrete `Pattern::id` in the schema) — an unknown reference is
385/// [`ParseError::UnknownActivePattern`] (superseded `plan/05-phase-
386/// selection.md`'s original "not cross-checked" decision, see
387/// `DECISIONS.md`).
388fn parse_phase(node: Node<'_, '_>, pattern_ids: &HashSet<String>) -> Result<Phase, ParseError> {
389    validate_known_children(node, PHASE_ALLOWED_CHILDREN)?;
390    let id = node
391        .attribute("id")
392        .ok_or_else(|| ParseError::MissingAttribute {
393            element: "phase",
394            attribute: "id",
395            position: position_of(node),
396        })?
397        .to_owned();
398    let lets = node
399        .children()
400        .filter(|child| child.has_tag_name((SCHEMATRON_NAMESPACE, "let")))
401        .map(parse_let)
402        .collect::<Result<_, _>>()?;
403    let active = node
404        .children()
405        .filter(|child| child.has_tag_name((SCHEMATRON_NAMESPACE, "active")))
406        .map(|child| parse_active(child, pattern_ids))
407        .collect::<Result<_, _>>()?;
408    Ok(Phase { id, lets, active })
409}
410
411fn parse_active(node: Node<'_, '_>, pattern_ids: &HashSet<String>) -> Result<String, ParseError> {
412    let pattern = node
413        .attribute("pattern")
414        .ok_or_else(|| ParseError::MissingAttribute {
415            element: "active",
416            attribute: "pattern",
417            position: position_of(node),
418        })?;
419    if !pattern_ids.contains(pattern) {
420        return Err(ParseError::UnknownActivePattern {
421            pattern: pattern.to_owned(),
422            position: position_of(node),
423        });
424    }
425    Ok(pattern.to_owned())
426}
427
428/// Parses a `<param name="..." value="...">` child of an `is-a` pattern
429/// use, as a raw `(name, value)` pair for [`substitute`].
430fn parse_param(node: Node<'_, '_>) -> Result<(String, String), ParseError> {
431    let name = node
432        .attribute("name")
433        .ok_or_else(|| ParseError::MissingAttribute {
434            element: "param",
435            attribute: "name",
436            position: position_of(node),
437        })?
438        .to_owned();
439    let value = node
440        .attribute("value")
441        .ok_or_else(|| ParseError::MissingAttribute {
442            element: "param",
443            attribute: "value",
444            position: position_of(node),
445        })?
446        .to_owned();
447    Ok((name, value))
448}
449
450/// Replaces `$name` with `value` for each `(name, value)` in `params`,
451/// applied sequentially in order (each step sees the previous step's
452/// result) — the same semantics as the ISO reference implementation's
453/// abstract-pattern parameter substitution (`plan/07-abstract-patterns-
454/// and-extends.md`). A no-op for `params: &[]`, so this is exactly Stage
455/// 1's behavior for schemas without abstract patterns.
456fn substitute(text: &str, params: &[(String, String)]) -> String {
457    params.iter().fold(text.to_owned(), |acc, (name, value)| {
458        acc.replace(&format!("${name}"), value)
459    })
460}
461
462/// Builds a `Pattern` from `node`'s `rule` children. `node` is the
463/// pattern element itself for a normal (non-`is-a`) pattern, or the
464/// *abstract* pattern being instantiated for an `is-a` use (its `rule`s
465/// are the source, but `id` is the `is-a` pattern's own id — see
466/// `plan/07-abstract-patterns-and-extends.md`). `params` drives
467/// [`substitute`] in `context`/`test`/`value-of/@select` (empty for a
468/// normal pattern).
469fn parse_pattern(
470    node: Node<'_, '_>,
471    id: Option<String>,
472    params: &[(String, String)],
473    diagnostic_ids: &HashSet<String>,
474) -> Result<Pattern, ParseError> {
475    validate_known_children(node, PATTERN_ALLOWED_CHILDREN)?;
476
477    let lets = node
478        .children()
479        .filter(|child| child.has_tag_name((SCHEMATRON_NAMESPACE, "let")))
480        .map(parse_let)
481        .collect::<Result<_, _>>()?;
482
483    let rule_index: HashMap<String, Node<'_, '_>> = node
484        .children()
485        .filter(|child| child.has_tag_name((SCHEMATRON_NAMESPACE, "rule")))
486        .filter_map(|child| child.attribute("id").map(|id| (id.to_owned(), child)))
487        .collect();
488
489    let rules = node
490        .children()
491        .filter(|child| {
492            child.has_tag_name((SCHEMATRON_NAMESPACE, "rule"))
493                && child.attribute("abstract") != Some("true")
494        })
495        .map(|child| parse_rule(child, &rule_index, params, diagnostic_ids))
496        .collect::<Result<_, _>>()?;
497
498    Ok(Pattern { id, lets, rules })
499}
500
501/// Parses a concrete (non-abstract) `<rule context="...">`. `rule_index`
502/// is every `rule` sibling in the same pattern, by id (abstract and
503/// concrete alike), used to resolve this rule's own `<extends>` children.
504fn parse_rule(
505    node: Node<'_, '_>,
506    rule_index: &HashMap<String, Node<'_, '_>>,
507    params: &[(String, String)],
508    diagnostic_ids: &HashSet<String>,
509) -> Result<Rule, ParseError> {
510    validate_known_children(node, RULE_ALLOWED_CHILDREN)?;
511
512    let context = substitute(
513        node.attribute("context")
514            .ok_or_else(|| ParseError::MissingAttribute {
515                element: "rule",
516                attribute: "context",
517                position: position_of(node),
518            })?,
519        params,
520    );
521
522    let lets = node
523        .children()
524        .filter(|child| child.has_tag_name((SCHEMATRON_NAMESPACE, "let")))
525        .map(parse_let)
526        .collect::<Result<_, _>>()?;
527
528    // Cycle guard for <extends>, seeded with this rule's own id so a
529    // direct self-extends is caught the same way as a transitive one.
530    let mut visiting: Vec<String> = node
531        .attribute("id")
532        .map(str::to_owned)
533        .into_iter()
534        .collect();
535
536    let mut checks = Vec::new();
537    collect_checks(
538        node,
539        rule_index,
540        params,
541        &mut visiting,
542        &mut checks,
543        diagnostic_ids,
544    )?;
545
546    Ok(Rule {
547        context,
548        lets,
549        checks,
550    })
551}
552
553/// Walks `node`'s children collecting `assert`/`report` as [`Check`]s and
554/// inlining `<extends rule="X">` by recursively collecting `X`'s own
555/// checks at that position (see `plan/07-abstract-patterns-and-extends.md`
556/// — `X` must be declared in the same pattern, i.e. present in
557/// `rule_index`; `X`'s own `<let>`s are not carried over). `visiting`
558/// guards against cycles (`X` extending something already being resolved).
559fn collect_checks(
560    node: Node<'_, '_>,
561    rule_index: &HashMap<String, Node<'_, '_>>,
562    params: &[(String, String)],
563    visiting: &mut Vec<String>,
564    checks: &mut Vec<Check>,
565    diagnostic_ids: &HashSet<String>,
566) -> Result<(), ParseError> {
567    for child in node.children() {
568        if let Some(kind) = check_kind(child) {
569            checks.push(parse_check(kind, child, params, diagnostic_ids)?);
570        } else if child.has_tag_name((SCHEMATRON_NAMESPACE, "extends")) {
571            let target_id =
572                child
573                    .attribute("rule")
574                    .ok_or_else(|| ParseError::MissingAttribute {
575                        element: "extends",
576                        attribute: "rule",
577                        position: position_of(child),
578                    })?;
579            let target = rule_index.get(target_id).copied().ok_or_else(|| {
580                ParseError::UnknownRuleReference {
581                    rule: target_id.to_owned(),
582                    position: position_of(child),
583                }
584            })?;
585            if visiting.iter().any(|id| id == target_id) {
586                return Err(ParseError::CyclicRuleExtends {
587                    rule: target_id.to_owned(),
588                    position: position_of(child),
589                });
590            }
591            visiting.push(target_id.to_owned());
592            collect_checks(target, rule_index, params, visiting, checks, diagnostic_ids)?;
593            visiting.pop();
594        }
595    }
596    Ok(())
597}
598
599fn check_kind(node: Node<'_, '_>) -> Option<CheckKind> {
600    if node.has_tag_name((SCHEMATRON_NAMESPACE, "assert")) {
601        Some(CheckKind::Assert)
602    } else if node.has_tag_name((SCHEMATRON_NAMESPACE, "report")) {
603        Some(CheckKind::Report)
604    } else {
605        None
606    }
607}
608
609fn parse_check(
610    kind: CheckKind,
611    node: Node<'_, '_>,
612    params: &[(String, String)],
613    diagnostic_ids: &HashSet<String>,
614) -> Result<Check, ParseError> {
615    let element = match kind {
616        CheckKind::Assert => "assert",
617        CheckKind::Report => "report",
618    };
619    let test = substitute(
620        node.attribute("test")
621            .ok_or_else(|| ParseError::MissingAttribute {
622                element,
623                attribute: "test",
624                position: position_of(node),
625            })?,
626        params,
627    );
628    let id = node.attribute("id").map(str::to_owned);
629    let role = node.attribute("role").map(str::to_owned);
630    let mut message = Vec::new();
631    collect_message_parts(node, &mut message, params)?;
632    let diagnostics: Vec<String> = node
633        .attribute("diagnostics")
634        .map(|ids| ids.split_whitespace().map(str::to_owned).collect())
635        .unwrap_or_default();
636    for diagnostic_id in &diagnostics {
637        if !diagnostic_ids.contains(diagnostic_id) {
638            return Err(ParseError::UnknownDiagnosticReference {
639                id: diagnostic_id.clone(),
640                position: position_of(node),
641            });
642        }
643    }
644
645    Ok(Check {
646        kind,
647        test,
648        id,
649        role,
650        message,
651        diagnostics,
652    })
653}
654
655/// Builds an `<assert>`/`<report>` element's message content in document
656/// order: text nodes anywhere in the subtree become [`MessagePart::Text`]
657/// (recursing through wrapper elements like `<emph>`/`<name>`/`<span>`,
658/// which — like Stage 1 — carry no meaning of their own here, only their
659/// text does), and `<value-of select="...">` children become
660/// [`MessagePart::ValueOf`] (`select` is required and run through
661/// [`substitute`] — its content is not descended into, the grammar
662/// defines it as empty anyway). No trimming here: that now happens on the
663/// *rendered* message at evaluation time (`plan/06-value-of-
664/// interpolation.md`), since `value-of` only has a value once evaluated
665/// against a firing check's context node.
666fn collect_message_parts(
667    node: Node<'_, '_>,
668    parts: &mut Vec<MessagePart>,
669    params: &[(String, String)],
670) -> Result<(), ParseError> {
671    for child in node.children() {
672        if child.is_text() {
673            if let Some(text) = child.text() {
674                parts.push(MessagePart::Text(text.to_owned()));
675            }
676        } else if child.has_tag_name((SCHEMATRON_NAMESPACE, "value-of")) {
677            let select = child
678                .attribute("select")
679                .ok_or_else(|| ParseError::MissingAttribute {
680                    element: "value-of",
681                    attribute: "select",
682                    position: position_of(child),
683                })?;
684            parts.push(MessagePart::ValueOf(substitute(select, params)));
685        } else if child.is_element() {
686            collect_message_parts(child, parts, params)?;
687        }
688    }
689    Ok(())
690}
691
692/// Errors on any direct child element of `node` that is in the Schematron
693/// namespace but not in `allowed` — see the allowlist constants above for
694/// the rationale. Note this only ever inspects Schematron-namespace
695/// children: a foreign-namespace element is never in `allowed` either, but
696/// is deliberately not checked here at all (ISO's own "foreign" content
697/// model already permits those anywhere).
698fn validate_known_children(node: Node<'_, '_>, allowed: &[&str]) -> Result<(), ParseError> {
699    for child in node.children() {
700        if child.is_element()
701            && child.tag_name().namespace() == Some(SCHEMATRON_NAMESPACE)
702            && !allowed.contains(&child.tag_name().name())
703        {
704            return Err(ParseError::UnexpectedElement {
705                element: child.tag_name().name().to_owned(),
706                position: position_of(child),
707            });
708        }
709    }
710    Ok(())
711}
712
713fn position_of(node: Node<'_, '_>) -> Position {
714    let text_pos = node.document().text_pos_at(node.range().start);
715    Position {
716        row: text_pos.row,
717        col: text_pos.col,
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn minimal_valid_schema() {
727        let schema = parse(
728            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
729                <pattern>
730                    <rule context="/root">
731                        <assert test="foo">message</assert>
732                    </rule>
733                </pattern>
734            </schema>"#,
735        )
736        .unwrap();
737
738        assert_eq!(schema.ns, vec![]);
739        assert_eq!(schema.patterns.len(), 1);
740        let pattern = &schema.patterns[0];
741        assert_eq!(pattern.id, None);
742        assert_eq!(pattern.rules.len(), 1);
743        let rule = &pattern.rules[0];
744        assert_eq!(rule.context, "/root");
745        assert_eq!(rule.checks.len(), 1);
746        let check = &rule.checks[0];
747        assert_eq!(check.kind, CheckKind::Assert);
748        assert_eq!(check.test, "foo");
749        assert_eq!(check.message, vec![MessagePart::Text("message".to_owned())]);
750    }
751
752    #[test]
753    fn multiple_patterns_rules_and_checks() {
754        let schema = parse(
755            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
756                <pattern id="p1">
757                    <rule context="/a">
758                        <assert test="t1">m1</assert>
759                        <assert test="t2">m2</assert>
760                    </rule>
761                    <rule context="/b">
762                        <assert test="t3">m3</assert>
763                    </rule>
764                </pattern>
765                <pattern id="p2">
766                    <rule context="/c">
767                        <assert test="t4">m4</assert>
768                    </rule>
769                </pattern>
770            </schema>"#,
771        )
772        .unwrap();
773
774        assert_eq!(schema.patterns.len(), 2);
775        assert_eq!(schema.patterns[0].id.as_deref(), Some("p1"));
776        assert_eq!(schema.patterns[0].rules.len(), 2);
777        assert_eq!(schema.patterns[0].rules[0].checks.len(), 2);
778        assert_eq!(schema.patterns[0].rules[1].checks.len(), 1);
779        assert_eq!(schema.patterns[1].id.as_deref(), Some("p2"));
780        assert_eq!(schema.patterns[1].rules.len(), 1);
781    }
782
783    #[test]
784    fn report_alongside_assert() {
785        let schema = parse(
786            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
787                <pattern>
788                    <rule context="/root">
789                        <assert test="a-test">assert message</assert>
790                        <report test="r-test">report message</report>
791                    </rule>
792                </pattern>
793            </schema>"#,
794        )
795        .unwrap();
796
797        let checks = &schema.patterns[0].rules[0].checks;
798        assert_eq!(checks.len(), 2);
799        assert_eq!(checks[0].kind, CheckKind::Assert);
800        assert_eq!(checks[0].test, "a-test");
801        assert_eq!(checks[1].kind, CheckKind::Report);
802        assert_eq!(checks[1].test, "r-test");
803    }
804
805    #[test]
806    fn ns_bindings_are_captured() {
807        let schema = parse(
808            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
809                <ns prefix="ex" uri="http://example.com/ns"/>
810                <ns prefix="other" uri="http://example.com/other"/>
811                <pattern>
812                    <rule context="/root">
813                        <assert test="foo">message</assert>
814                    </rule>
815                </pattern>
816            </schema>"#,
817        )
818        .unwrap();
819
820        assert_eq!(
821            schema.ns,
822            vec![
823                NamespaceBinding {
824                    prefix: "ex".to_owned(),
825                    uri: "http://example.com/ns".to_owned(),
826                },
827                NamespaceBinding {
828                    prefix: "other".to_owned(),
829                    uri: "http://example.com/other".to_owned(),
830                },
831            ]
832        );
833    }
834
835    #[test]
836    fn ns_missing_prefix_is_an_error() {
837        let error = parse(
838            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
839                <ns uri="http://example.com/ns"/>
840                <pattern>
841                    <rule context="/root">
842                        <assert test="foo">message</assert>
843                    </rule>
844                </pattern>
845            </schema>"#,
846        )
847        .unwrap_err();
848
849        assert_eq!(
850            error,
851            ParseError::MissingAttribute {
852                element: "ns",
853                attribute: "prefix",
854                position: Position { row: 2, col: 17 },
855            }
856        );
857    }
858
859    #[test]
860    fn ns_missing_uri_is_an_error() {
861        let error = parse(
862            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
863                <ns prefix="ex"/>
864                <pattern>
865                    <rule context="/root">
866                        <assert test="foo">message</assert>
867                    </rule>
868                </pattern>
869            </schema>"#,
870        )
871        .unwrap_err();
872
873        assert_eq!(
874            error,
875            ParseError::MissingAttribute {
876                element: "ns",
877                attribute: "uri",
878                position: Position { row: 2, col: 17 },
879            }
880        );
881    }
882
883    #[test]
884    fn id_and_role_present() {
885        let schema = parse(
886            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
887                <pattern>
888                    <rule context="/root">
889                        <assert test="foo" id="my-id" role="warning">message</assert>
890                    </rule>
891                </pattern>
892            </schema>"#,
893        )
894        .unwrap();
895
896        let check = &schema.patterns[0].rules[0].checks[0];
897        assert_eq!(check.id.as_deref(), Some("my-id"));
898        assert_eq!(check.role.as_deref(), Some("warning"));
899    }
900
901    #[test]
902    fn id_and_role_absent() {
903        let schema = parse(
904            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
905                <pattern>
906                    <rule context="/root">
907                        <assert test="foo">message</assert>
908                    </rule>
909                </pattern>
910            </schema>"#,
911        )
912        .unwrap();
913
914        let check = &schema.patterns[0].rules[0].checks[0];
915        assert_eq!(check.id, None);
916        assert_eq!(check.role, None);
917    }
918
919    /// The parser no longer trims (that moved to render time in
920    /// `engine.rs`, see `plan/06-value-of-interpolation.md`) — the raw
921    /// text, whitespace and all, is captured as-is as a single `Text` part.
922    #[test]
923    fn message_text_is_captured_untrimmed() {
924        let schema = parse(
925            "<schema xmlns=\"http://purl.oclc.org/dsdl/schematron\">\
926                <pattern>\
927                    <rule context=\"/root\">\
928                        <assert test=\"foo\">\n  first   second  \n</assert>\
929                    </rule>\
930                </pattern>\
931            </schema>",
932        )
933        .unwrap();
934
935        let check = &schema.patterns[0].rules[0].checks[0];
936        assert_eq!(
937            check.message,
938            vec![MessagePart::Text("\n  first   second  \n".to_owned())]
939        );
940    }
941
942    #[test]
943    fn message_text_without_surrounding_whitespace_is_unchanged() {
944        let schema = parse(
945            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
946                <pattern>
947                    <rule context="/root">
948                        <assert test="foo">exact</assert>
949                    </rule>
950                </pattern>
951            </schema>"#,
952        )
953        .unwrap();
954
955        let check = &schema.patterns[0].rules[0].checks[0];
956        assert_eq!(check.message, vec![MessagePart::Text("exact".to_owned())]);
957    }
958
959    #[test]
960    fn value_of_and_wrapper_element_text_are_captured() {
961        let schema = parse(
962            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
963                <pattern>
964                    <rule context="/root">
965                        <assert test="foo">before <value-of select="@x"/> after <emph>wrapped</emph></assert>
966                    </rule>
967                </pattern>
968            </schema>"#,
969        )
970        .unwrap();
971
972        let check = &schema.patterns[0].rules[0].checks[0];
973        assert_eq!(
974            check.message,
975            vec![
976                MessagePart::Text("before ".to_owned()),
977                MessagePart::ValueOf("@x".to_owned()),
978                MessagePart::Text(" after ".to_owned()),
979                MessagePart::Text("wrapped".to_owned()),
980            ]
981        );
982    }
983
984    #[test]
985    fn value_of_missing_select_is_an_error() {
986        let error = parse(
987            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
988                <pattern>
989                    <rule context="/root">
990                        <assert test="foo"><value-of/></assert>
991                    </rule>
992                </pattern>
993            </schema>"#,
994        )
995        .unwrap_err();
996
997        assert_eq!(
998            error,
999            ParseError::MissingAttribute {
1000                element: "value-of",
1001                attribute: "select",
1002                position: Position { row: 4, col: 44 },
1003            }
1004        );
1005    }
1006
1007    #[test]
1008    fn missing_context_is_an_error() {
1009        let error = parse(
1010            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1011                <pattern>
1012                    <rule>
1013                        <assert test="foo">message</assert>
1014                    </rule>
1015                </pattern>
1016            </schema>"#,
1017        )
1018        .unwrap_err();
1019
1020        assert_eq!(
1021            error,
1022            ParseError::MissingAttribute {
1023                element: "rule",
1024                attribute: "context",
1025                position: Position { row: 3, col: 21 },
1026            }
1027        );
1028    }
1029
1030    #[test]
1031    fn missing_test_is_an_error() {
1032        let error = parse(
1033            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1034                <pattern>
1035                    <rule context="/root">
1036                        <assert>message</assert>
1037                    </rule>
1038                </pattern>
1039            </schema>"#,
1040        )
1041        .unwrap_err();
1042
1043        assert_eq!(
1044            error,
1045            ParseError::MissingAttribute {
1046                element: "assert",
1047                attribute: "test",
1048                position: Position { row: 4, col: 25 },
1049            }
1050        );
1051    }
1052
1053    #[test]
1054    fn no_pattern_is_an_error() {
1055        let error = parse(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron"/>"#).unwrap_err();
1056
1057        assert_eq!(error, ParseError::NoPatterns);
1058    }
1059
1060    #[test]
1061    fn wrong_root_namespace_is_an_error() {
1062        let error = parse(r#"<schema xmlns="http://example.com/not-schematron"/>"#).unwrap_err();
1063
1064        assert_eq!(
1065            error,
1066            ParseError::WrongRootNamespace {
1067                found: Some("http://example.com/not-schematron".to_owned()),
1068                position: Position { row: 1, col: 1 },
1069            }
1070        );
1071    }
1072
1073    #[test]
1074    fn diagnostics_are_captured() {
1075        let schema = parse(
1076            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1077                <diagnostics>
1078                    <diagnostic id="d1" role="warning">before <value-of select="."/> after</diagnostic>
1079                    <diagnostic id="d2">second</diagnostic>
1080                </diagnostics>
1081                <pattern>
1082                    <rule context="/root">
1083                        <assert test="foo" diagnostics="d1 d2">message</assert>
1084                    </rule>
1085                </pattern>
1086            </schema>"#,
1087        )
1088        .unwrap();
1089
1090        assert_eq!(schema.diagnostics.len(), 2);
1091        assert_eq!(schema.diagnostics[0].id, "d1");
1092        assert_eq!(schema.diagnostics[0].role.as_deref(), Some("warning"));
1093        assert_eq!(
1094            schema.diagnostics[0].message,
1095            vec![
1096                MessagePart::Text("before ".to_owned()),
1097                MessagePart::ValueOf(".".to_owned()),
1098                MessagePart::Text(" after".to_owned()),
1099            ]
1100        );
1101
1102        let check = &schema.patterns[0].rules[0].checks[0];
1103        assert_eq!(check.diagnostics, vec!["d1".to_owned(), "d2".to_owned()]);
1104    }
1105
1106    /// Referential integrity for `diagnostics="..."` IDREFS is checked at
1107    /// parse time (superseded `plan/08-diagnostics.md`'s original "not
1108    /// checked" decision — see `DECISIONS.md`): a reference to a
1109    /// `diagnostic/@id` that doesn't exist is a hard `ParseError`, not a
1110    /// silently-empty result at evaluation time.
1111    #[test]
1112    fn diagnostics_unknown_reference_is_an_error() {
1113        let error = parse(
1114            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1115                <diagnostics>
1116                    <diagnostic id="d1">known</diagnostic>
1117                </diagnostics>
1118                <pattern>
1119                    <rule context="/root">
1120                        <assert test="foo" diagnostics="d1 missing">message</assert>
1121                    </rule>
1122                </pattern>
1123            </schema>"#,
1124        )
1125        .unwrap_err();
1126
1127        assert!(matches!(
1128            error,
1129            ParseError::UnknownDiagnosticReference { id, .. } if id == "missing"
1130        ));
1131    }
1132
1133    #[test]
1134    fn duplicate_diagnostic_id_is_an_error() {
1135        let error = parse(
1136            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1137                <diagnostics>
1138                    <diagnostic id="d1">first</diagnostic>
1139                    <diagnostic id="d1">second</diagnostic>
1140                </diagnostics>
1141                <pattern>
1142                    <rule context="/root">
1143                        <assert test="foo" diagnostics="d1">message</assert>
1144                    </rule>
1145                </pattern>
1146            </schema>"#,
1147        )
1148        .unwrap_err();
1149
1150        assert!(matches!(
1151            error,
1152            ParseError::DuplicateId { element: "diagnostic", id, .. } if id == "d1"
1153        ));
1154    }
1155
1156    #[test]
1157    fn check_without_diagnostics_attribute_has_an_empty_list() {
1158        let schema = parse(
1159            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1160                <pattern>
1161                    <rule context="/root">
1162                        <assert test="foo">message</assert>
1163                    </rule>
1164                </pattern>
1165            </schema>"#,
1166        )
1167        .unwrap();
1168
1169        assert_eq!(
1170            schema.patterns[0].rules[0].checks[0].diagnostics,
1171            Vec::<String>::new()
1172        );
1173        assert_eq!(schema.diagnostics, vec![]);
1174    }
1175
1176    #[test]
1177    fn diagnostic_missing_id_is_an_error() {
1178        let error = parse(
1179            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1180                <diagnostics>
1181                    <diagnostic>text</diagnostic>
1182                </diagnostics>
1183                <pattern>
1184                    <rule context="/root">
1185                        <assert test="foo">message</assert>
1186                    </rule>
1187                </pattern>
1188            </schema>"#,
1189        )
1190        .unwrap_err();
1191
1192        assert_eq!(
1193            error,
1194            ParseError::MissingAttribute {
1195                element: "diagnostic",
1196                attribute: "id",
1197                position: Position { row: 3, col: 21 },
1198            }
1199        );
1200    }
1201
1202    #[test]
1203    fn let_bindings_are_captured_at_schema_pattern_and_rule_level() {
1204        let schema = parse(
1205            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1206                <let name="a" value="1"/>
1207                <pattern>
1208                    <let name="b" value="2"/>
1209                    <rule context="/root">
1210                        <let name="c" value="3"/>
1211                        <assert test="foo">message</assert>
1212                    </rule>
1213                </pattern>
1214            </schema>"#,
1215        )
1216        .unwrap();
1217
1218        assert_eq!(
1219            schema.lets,
1220            vec![LetBinding {
1221                name: "a".to_owned(),
1222                value: "1".to_owned(),
1223            }]
1224        );
1225        assert_eq!(
1226            schema.patterns[0].lets,
1227            vec![LetBinding {
1228                name: "b".to_owned(),
1229                value: "2".to_owned(),
1230            }]
1231        );
1232        assert_eq!(
1233            schema.patterns[0].rules[0].lets,
1234            vec![LetBinding {
1235                name: "c".to_owned(),
1236                value: "3".to_owned(),
1237            }]
1238        );
1239    }
1240
1241    #[test]
1242    fn let_missing_value_is_an_error() {
1243        let error = parse(
1244            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1245                <let name="a"/>
1246                <pattern>
1247                    <rule context="/root">
1248                        <assert test="foo">message</assert>
1249                    </rule>
1250                </pattern>
1251            </schema>"#,
1252        )
1253        .unwrap_err();
1254
1255        assert_eq!(
1256            error,
1257            ParseError::MissingAttribute {
1258                element: "let",
1259                attribute: "value",
1260                position: Position { row: 2, col: 17 },
1261            }
1262        );
1263    }
1264
1265    #[test]
1266    fn default_phase_and_phases_are_captured() {
1267        let schema = parse(
1268            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron" defaultPhase="p1">
1269                <phase id="p1">
1270                    <let name="x" value="1"/>
1271                    <active pattern="a"/>
1272                    <active pattern="b"/>
1273                </phase>
1274                <phase id="p2">
1275                    <active pattern="b"/>
1276                </phase>
1277                <pattern id="a">
1278                    <rule context="/root">
1279                        <assert test="foo">message</assert>
1280                    </rule>
1281                </pattern>
1282                <pattern id="b">
1283                    <rule context="/root">
1284                        <assert test="foo">message</assert>
1285                    </rule>
1286                </pattern>
1287            </schema>"#,
1288        )
1289        .unwrap();
1290
1291        assert_eq!(schema.default_phase.as_deref(), Some("p1"));
1292        assert_eq!(schema.phases.len(), 2);
1293        assert_eq!(schema.phases[0].id, "p1");
1294        assert_eq!(
1295            schema.phases[0].lets,
1296            vec![LetBinding {
1297                name: "x".to_owned(),
1298                value: "1".to_owned(),
1299            }]
1300        );
1301        assert_eq!(
1302            schema.phases[0].active,
1303            vec!["a".to_owned(), "b".to_owned()]
1304        );
1305        assert_eq!(schema.phases[1].id, "p2");
1306        assert_eq!(schema.phases[1].active, vec!["b".to_owned()]);
1307    }
1308
1309    #[test]
1310    fn schema_without_phase_has_no_default_phase_and_no_phases() {
1311        let schema = parse(
1312            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1313                <pattern>
1314                    <rule context="/root">
1315                        <assert test="foo">message</assert>
1316                    </rule>
1317                </pattern>
1318            </schema>"#,
1319        )
1320        .unwrap();
1321
1322        assert_eq!(schema.default_phase, None);
1323        assert_eq!(schema.phases, vec![]);
1324    }
1325
1326    #[test]
1327    fn phase_missing_id_is_an_error() {
1328        let error = parse(
1329            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1330                <phase>
1331                    <active pattern="a"/>
1332                </phase>
1333                <pattern id="a">
1334                    <rule context="/root">
1335                        <assert test="foo">message</assert>
1336                    </rule>
1337                </pattern>
1338            </schema>"#,
1339        )
1340        .unwrap_err();
1341
1342        assert_eq!(
1343            error,
1344            ParseError::MissingAttribute {
1345                element: "phase",
1346                attribute: "id",
1347                position: Position { row: 2, col: 17 },
1348            }
1349        );
1350    }
1351
1352    #[test]
1353    fn active_missing_pattern_is_an_error() {
1354        let error = parse(
1355            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1356                <phase id="p1">
1357                    <active/>
1358                </phase>
1359                <pattern id="a">
1360                    <rule context="/root">
1361                        <assert test="foo">message</assert>
1362                    </rule>
1363                </pattern>
1364            </schema>"#,
1365        )
1366        .unwrap_err();
1367
1368        assert_eq!(
1369            error,
1370            ParseError::MissingAttribute {
1371                element: "active",
1372                attribute: "pattern",
1373                position: Position { row: 3, col: 21 },
1374            }
1375        );
1376    }
1377
1378    /// Referential integrity for `active/@pattern` is checked at parse
1379    /// time (superseded `plan/05-phase-selection.md`'s original "not
1380    /// checked" decision — see `DECISIONS.md`).
1381    #[test]
1382    fn active_unknown_pattern_is_an_error() {
1383        let error = parse(
1384            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1385                <phase id="p1">
1386                    <active pattern="missing"/>
1387                </phase>
1388                <pattern id="a">
1389                    <rule context="/root">
1390                        <assert test="foo">message</assert>
1391                    </rule>
1392                </pattern>
1393            </schema>"#,
1394        )
1395        .unwrap_err();
1396
1397        assert!(matches!(
1398            error,
1399            ParseError::UnknownActivePattern { pattern, .. } if pattern == "missing"
1400        ));
1401    }
1402
1403    /// An abstract pattern's id is never in `Schema.patterns` (it's never
1404    /// evaluated on its own, see `plan/07-abstract-patterns-and-extends.md`)
1405    /// — so `active` cannot activate it by id either, even though the id
1406    /// itself is declared somewhere in the schema.
1407    #[test]
1408    fn active_referencing_an_abstract_pattern_id_is_an_error() {
1409        let error = parse(
1410            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1411                <phase id="p1">
1412                    <active pattern="tpl"/>
1413                </phase>
1414                <pattern abstract="true" id="tpl">
1415                    <rule context="/root">
1416                        <assert test="foo">message</assert>
1417                    </rule>
1418                </pattern>
1419                <pattern id="a">
1420                    <rule context="/root">
1421                        <assert test="foo">message</assert>
1422                    </rule>
1423                </pattern>
1424            </schema>"#,
1425        )
1426        .unwrap_err();
1427
1428        assert!(matches!(
1429            error,
1430            ParseError::UnknownActivePattern { pattern, .. } if pattern == "tpl"
1431        ));
1432    }
1433
1434    #[test]
1435    fn duplicate_pattern_id_is_an_error() {
1436        let error = parse(
1437            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1438                <pattern id="a">
1439                    <rule context="/root">
1440                        <assert test="foo">message</assert>
1441                    </rule>
1442                </pattern>
1443                <pattern id="a">
1444                    <rule context="/root">
1445                        <assert test="foo">message</assert>
1446                    </rule>
1447                </pattern>
1448            </schema>"#,
1449        )
1450        .unwrap_err();
1451
1452        assert!(matches!(
1453            error,
1454            ParseError::DuplicateId { element: "pattern", id, .. } if id == "a"
1455        ));
1456    }
1457
1458    #[test]
1459    fn unexpected_element_at_schema_level_is_an_error() {
1460        let error = parse(
1461            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1462                <include href="other.sch"/>
1463                <pattern>
1464                    <rule context="/root">
1465                        <assert test="foo">message</assert>
1466                    </rule>
1467                </pattern>
1468            </schema>"#,
1469        )
1470        .unwrap_err();
1471
1472        assert!(matches!(
1473            error,
1474            ParseError::UnexpectedElement { element, .. } if element == "include"
1475        ));
1476    }
1477
1478    #[test]
1479    fn unexpected_element_inside_rule_is_an_error() {
1480        let error = parse(
1481            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1482                <pattern>
1483                    <rule context="/root">
1484                        <asert test="foo">typo'd element name</asert>
1485                    </rule>
1486                </pattern>
1487            </schema>"#,
1488        )
1489        .unwrap_err();
1490
1491        assert!(matches!(
1492            error,
1493            ParseError::UnexpectedElement { element, .. } if element == "asert"
1494        ));
1495    }
1496
1497    /// `title`/`p` are real ISO Schematron elements (prose/documentation)
1498    /// — accepted, not `UnexpectedElement`, even though this crate never
1499    /// reads their content.
1500    #[test]
1501    fn title_and_p_are_accepted_but_ignored() {
1502        let schema = parse(
1503            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1504                <title>My Schema</title>
1505                <p>Some documentation.</p>
1506                <pattern>
1507                    <rule context="/root">
1508                        <assert test="foo">message</assert>
1509                    </rule>
1510                </pattern>
1511            </schema>"#,
1512        )
1513        .unwrap();
1514
1515        assert_eq!(schema.patterns.len(), 1);
1516    }
1517
1518    #[test]
1519    fn extends_inlines_checks_from_another_rule_in_the_same_pattern() {
1520        let schema = parse(
1521            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1522                <pattern>
1523                    <rule abstract="true" id="base">
1524                        <assert test="false()">BASE-CHECK</assert>
1525                    </rule>
1526                    <rule context="/root">
1527                        <extends rule="base"/>
1528                        <assert test="false()">OWN-CHECK</assert>
1529                    </rule>
1530                </pattern>
1531            </schema>"#,
1532        )
1533        .unwrap();
1534
1535        // The abstract rule itself is never a real, evaluable rule.
1536        assert_eq!(schema.patterns[0].rules.len(), 1);
1537        let checks = &schema.patterns[0].rules[0].checks;
1538        assert_eq!(checks.len(), 2);
1539        assert_eq!(
1540            checks[0].message,
1541            vec![MessagePart::Text("BASE-CHECK".to_owned())]
1542        );
1543        assert_eq!(
1544            checks[1].message,
1545            vec![MessagePart::Text("OWN-CHECK".to_owned())]
1546        );
1547    }
1548
1549    #[test]
1550    fn extends_is_transitive() {
1551        let schema = parse(
1552            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1553                <pattern>
1554                    <rule abstract="true" id="a">
1555                        <assert test="false()">A</assert>
1556                    </rule>
1557                    <rule abstract="true" id="b">
1558                        <extends rule="a"/>
1559                        <assert test="false()">B</assert>
1560                    </rule>
1561                    <rule context="/root">
1562                        <extends rule="b"/>
1563                    </rule>
1564                </pattern>
1565            </schema>"#,
1566        )
1567        .unwrap();
1568
1569        let checks = &schema.patterns[0].rules[0].checks;
1570        assert_eq!(
1571            checks.iter().map(|c| c.message.clone()).collect::<Vec<_>>(),
1572            vec![
1573                vec![MessagePart::Text("A".to_owned())],
1574                vec![MessagePart::Text("B".to_owned())],
1575            ]
1576        );
1577    }
1578
1579    #[test]
1580    fn cyclic_extends_is_an_error() {
1581        let error = parse(
1582            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1583                <pattern>
1584                    <rule abstract="true" id="a">
1585                        <extends rule="b"/>
1586                    </rule>
1587                    <rule abstract="true" id="b">
1588                        <extends rule="a"/>
1589                    </rule>
1590                    <rule context="/root">
1591                        <extends rule="a"/>
1592                    </rule>
1593                </pattern>
1594            </schema>"#,
1595        )
1596        .unwrap_err();
1597
1598        assert!(matches!(
1599            error,
1600            ParseError::CyclicRuleExtends { rule, .. } if rule == "a"
1601        ));
1602    }
1603
1604    #[test]
1605    fn extends_unknown_rule_is_an_error() {
1606        let error = parse(
1607            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1608                <pattern>
1609                    <rule context="/root">
1610                        <extends rule="missing"/>
1611                    </rule>
1612                </pattern>
1613            </schema>"#,
1614        )
1615        .unwrap_err();
1616
1617        assert!(matches!(
1618            error,
1619            ParseError::UnknownRuleReference { rule, .. } if rule == "missing"
1620        ));
1621    }
1622
1623    #[test]
1624    fn is_a_instantiates_abstract_pattern_with_param_substitution() {
1625        let schema = parse(
1626            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1627                <pattern abstract="true" id="tpl">
1628                    <rule context="$el">
1629                        <assert test="$el = 1">msg</assert>
1630                    </rule>
1631                </pattern>
1632                <pattern is-a="tpl" id="concrete">
1633                    <param name="el" value="item"/>
1634                </pattern>
1635            </schema>"#,
1636        )
1637        .unwrap();
1638
1639        // The abstract pattern itself never appears — only the instantiated one.
1640        assert_eq!(schema.patterns.len(), 1);
1641        assert_eq!(schema.patterns[0].id.as_deref(), Some("concrete"));
1642        assert_eq!(schema.patterns[0].rules[0].context, "item");
1643        assert_eq!(schema.patterns[0].rules[0].checks[0].test, "item = 1");
1644    }
1645
1646    #[test]
1647    fn two_is_a_uses_of_the_same_abstract_pattern_are_independent() {
1648        let schema = parse(
1649            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1650                <pattern abstract="true" id="tpl">
1651                    <rule context="$el">
1652                        <assert test="true()">m</assert>
1653                    </rule>
1654                </pattern>
1655                <pattern is-a="tpl" id="p1">
1656                    <param name="el" value="a"/>
1657                </pattern>
1658                <pattern is-a="tpl" id="p2">
1659                    <param name="el" value="b"/>
1660                </pattern>
1661            </schema>"#,
1662        )
1663        .unwrap();
1664
1665        assert_eq!(schema.patterns.len(), 2);
1666        assert_eq!(schema.patterns[0].id.as_deref(), Some("p1"));
1667        assert_eq!(schema.patterns[0].rules[0].context, "a");
1668        assert_eq!(schema.patterns[1].id.as_deref(), Some("p2"));
1669        assert_eq!(schema.patterns[1].rules[0].context, "b");
1670    }
1671
1672    #[test]
1673    fn abstract_pattern_never_appears_in_schema_patterns_even_if_unused() {
1674        let schema = parse(
1675            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1676                <pattern abstract="true" id="tpl">
1677                    <rule context="$el">
1678                        <assert test="true()">m</assert>
1679                    </rule>
1680                </pattern>
1681                <pattern id="concrete">
1682                    <rule context="/root">
1683                        <assert test="foo">message</assert>
1684                    </rule>
1685                </pattern>
1686            </schema>"#,
1687        )
1688        .unwrap();
1689
1690        assert_eq!(schema.patterns.len(), 1);
1691        assert_eq!(schema.patterns[0].id.as_deref(), Some("concrete"));
1692    }
1693
1694    /// Verified against the ISO reference implementation
1695    /// (`plan/07-abstract-patterns-and-extends.md`): `<let value="...">`
1696    /// is NOT among the substituted attributes, unlike `context`/`test`/
1697    /// `select`.
1698    #[test]
1699    fn let_value_is_not_substituted_in_an_instantiated_pattern() {
1700        let schema = parse(
1701            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1702                <pattern abstract="true" id="tpl">
1703                    <rule context="$el">
1704                        <let name="x" value="$el"/>
1705                        <assert test="true()">m</assert>
1706                    </rule>
1707                </pattern>
1708                <pattern is-a="tpl" id="concrete">
1709                    <param name="el" value="item"/>
1710                </pattern>
1711            </schema>"#,
1712        )
1713        .unwrap();
1714
1715        let rule = &schema.patterns[0].rules[0];
1716        assert_eq!(rule.context, "item");
1717        assert_eq!(rule.lets[0].value, "$el");
1718    }
1719
1720    #[test]
1721    fn is_a_unknown_abstract_pattern_is_an_error() {
1722        let error = parse(
1723            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1724                <pattern is-a="missing" id="concrete">
1725                    <param name="el" value="item"/>
1726                </pattern>
1727            </schema>"#,
1728        )
1729        .unwrap_err();
1730
1731        assert!(matches!(
1732            error,
1733            ParseError::UnknownAbstractPattern { pattern, .. } if pattern == "missing"
1734        ));
1735    }
1736}