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