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;
7use std::ops::Range;
8
9use roxmltree::Node;
10
11use crate::resolver::SchemaResolver;
12use crate::schema::{
13    Check, CheckKind, Diagnostic, LetBinding, LetValue, MessagePart, NamespaceBinding, Pattern,
14    Phase, Property, Rule, Schema,
15};
16
17const SCHEMATRON_NAMESPACE: &str = "http://purl.oclc.org/dsdl/schematron";
18
19/// `schema/@queryBinding` values this crate treats as equivalent to
20/// XPath 1.0 (case-insensitive), issue #6. The ISO grammar types
21/// `queryBinding` as an unconstrained non-empty string (implementation-
22/// defined values are legal), but this crate only evaluates XPath 1.0
23/// (via `xpath-eval`) — `"xslt"` is the ISO-conventional default/XPath-1.0
24/// binding name, `"xpath"` the common informal shorthand for the same;
25/// anything else (e.g. `"xslt2"`/`"xpath2"`, XPath 2.0) is rejected with
26/// `ParseError::UnsupportedQueryBinding` rather than silently
27/// misinterpreted as XPath 1.0.
28const XPATH1_QUERY_BINDINGS: &[&str] = &["xslt", "xpath"];
29
30/// Schematron elements whose `id` attribute is `xsd:ID`-typed per the ISO
31/// RNC grammar (verified against the real grammar,
32/// `github.com/Schematron/schema/blob/main/schematron.rnc` — `schema/@id`
33/// and `p/@id` are `xsd:ID`-typed too, but this crate does not parse or
34/// otherwise model either attribute at all, so there is nothing to check
35/// uniqueness *of* for them). Real XML `ID` semantics are a single,
36/// document-wide namespace, not one per element type — see
37/// [`check_document_wide_id_uniqueness`] (issue #8).
38const ID_TYPED_ELEMENTS: &[&str] = &[
39    "pattern",
40    "rule",
41    "assert",
42    "report",
43    "phase",
44    "diagnostic",
45    "property",
46];
47
48/// Walks every Schematron-namespace element in the whole document once
49/// (`root.descendants()`, itself included, in document order), checking
50/// `id` attributes on [`ID_TYPED_ELEMENTS`] for a document-wide collision —
51/// real XML `ID` semantics (issue #8; supersedes three previously separate,
52/// narrower checks — `pattern/@id` in the pattern-parsing loop below,
53/// `diagnostic/@id` in `parse_diagnostics_element`, `property/@id` in
54/// `parse_properties_element` — and additionally catches two cases those
55/// never did: `rule/@id`/`assert|report/@id` collisions, and an abstract
56/// `pattern/@id` colliding with anything, concrete or abstract).
57///
58/// Not affected by this crate's own later *semantic* reuse of a parsed
59/// element (`is-a` pattern instantiation, `<extends>` rule inlining): both
60/// act on the parsed model, not the source XML tree, so neither ever
61/// duplicates a physical element and can't trigger a false positive here —
62/// this walks the raw tree once, before any of that happens.
63fn check_document_wide_id_uniqueness(root: Node<'_, '_>) -> Result<(), ParseError> {
64    let mut seen: HashSet<String> = HashSet::new();
65    for node in root.descendants() {
66        if !node.is_element() || node.tag_name().namespace() != Some(SCHEMATRON_NAMESPACE) {
67            continue;
68        }
69        let Some(&element) = ID_TYPED_ELEMENTS
70            .iter()
71            .find(|&&tag| node.has_tag_name((SCHEMATRON_NAMESPACE, tag)))
72        else {
73            continue;
74        };
75        if let Some(id) = node.attribute("id")
76            && !seen.insert(id.to_owned())
77        {
78            return Err(ParseError::DuplicateId {
79                element,
80                id: id.to_owned(),
81                position: position_of(node),
82            });
83        }
84    }
85    Ok(())
86}
87
88// Direct-child allowlists per container, verified against the ISO
89// Schematron RNC grammar (`github.com/Schematron/schema`; source
90// fragments: `plan/04-let-bindings.md`, `plan/05-phase-selection.md`,
91// `plan/08-diagnostics.md`). `title`/`p` are real ISO prose/documentation
92// elements, accepted but unread. Anything else not listed is either a
93// typo or an unimplemented ISO construct (e.g. `<include>`) — both become
94// `ParseError::UnexpectedElement` (see `validate_known_children`), not a
95// silent drop. Foreign-namespace elements are unaffected by this list.
96const SCHEMA_ALLOWED_CHILDREN: &[&str] = &[
97    "title",
98    "p",
99    "ns",
100    "let",
101    "phase",
102    "pattern",
103    "diagnostics",
104    "properties",
105];
106const PROPERTIES_ALLOWED_CHILDREN: &[&str] = &["property"];
107const PATTERN_ALLOWED_CHILDREN: &[&str] = &["title", "p", "let", "rule"];
108/// A `<pattern is-a="...">` *use* site has a different content model than
109/// a pattern definition — `param*`, not `rule*`.
110const IS_A_USE_ALLOWED_CHILDREN: &[&str] = &["p", "param"];
111const RULE_ALLOWED_CHILDREN: &[&str] = &["let", "assert", "report", "extends", "p"];
112const PHASE_ALLOWED_CHILDREN: &[&str] = &["title", "p", "let", "active"];
113
114/// A 1-based row/column position in the source XML.
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub struct Position {
117    pub row: u32,
118    pub col: u32,
119}
120
121impl fmt::Display for Position {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        write!(formatter, "{}:{}", self.row, self.col)
124    }
125}
126
127/// An error encountered while parsing a Schematron schema.
128#[derive(Clone, Debug, Eq, PartialEq)]
129pub enum ParseError {
130    /// The input is not well-formed XML.
131    InvalidXml(String),
132    /// The root element is not `<schema>` in the Schematron namespace.
133    WrongRootNamespace {
134        found: Option<String>,
135        position: Position,
136    },
137    /// The schema contains no `<pattern>` elements.
138    NoPatterns,
139    /// A required attribute is missing on an element.
140    MissingAttribute {
141        element: &'static str,
142        attribute: &'static str,
143        position: Position,
144    },
145    /// `<pattern is-a="...">` referenced a pattern id with no matching
146    /// `<pattern abstract="true" id="...">` in the schema.
147    UnknownAbstractPattern { pattern: String, position: Position },
148    /// `<extends rule="...">` referenced a rule id with no matching
149    /// `<rule id="...">` in the same pattern (see
150    /// `plan/07-abstract-patterns-and-extends.md` for why the lookup is
151    /// pattern-local, not schema-wide).
152    UnknownRuleReference { rule: String, position: Position },
153    /// `<extends>` formed a cycle (a rule extending itself, directly or
154    /// transitively).
155    CyclicRuleExtends { rule: String, position: Position },
156    /// A Schematron-namespace element appeared as a direct child of a
157    /// container it is not part of that container's grammar — either a
158    /// typo of a known element (e.g. `<asert>`), or a real ISO Schematron
159    /// construct this crate does not implement (e.g. `<include>`). See
160    /// `README.md`'s support matrix. Foreign-namespace elements are never
161    /// affected — ISO's own "foreign" content model already allows those
162    /// anywhere, regardless of this crate's support.
163    UnexpectedElement { element: String, position: Position },
164    /// Two elements in the same ID space (`pattern/@id` or
165    /// `diagnostic/@id`) declared the same `id` — ambiguous for anything
166    /// that references it by IDREF(S) (`active/@pattern`,
167    /// `assert|report/@diagnostics`).
168    DuplicateId {
169        element: &'static str,
170        id: String,
171        position: Position,
172    },
173    /// `<active pattern="...">` referenced a pattern id with no matching
174    /// *concrete* `<pattern id="...">` in the schema (abstract-pattern ids
175    /// don't count — they never appear in `Schema.patterns`; compare
176    /// `UnknownAbstractPattern`, which is `is-a`'s own, separate reference
177    /// kind).
178    UnknownActivePattern { pattern: String, position: Position },
179    /// `assert|report/@diagnostics` referenced a diagnostic id with no
180    /// matching `<diagnostic id="...">` in the schema.
181    UnknownDiagnosticReference { id: String, position: Position },
182    /// `schema/@queryBinding` named a query language binding this crate
183    /// does not treat as equivalent to XPath 1.0 (issue #6) — evaluating
184    /// `test`/`context`/`select` expressions as XPath 1.0 anyway would
185    /// silently misinterpret a schema that explicitly opted into a
186    /// different query language (e.g. `"xslt2"`, XPath 2.0). See
187    /// `README.md`'s support matrix for the accepted binding names.
188    UnsupportedQueryBinding { binding: String, position: Position },
189    /// [`crate::parse_with_resolver`] found an `<include href="...">` it
190    /// had already started resolving further up the same inclusion chain
191    /// (issue #2) — `A` includes `B` includes `A`, directly or
192    /// transitively.
193    CyclicInclude { href: String, position: Position },
194    /// [`crate::parse_with_resolver`]'s [`SchemaResolver`] failed to
195    /// resolve an `<include href="...">` (issue #2) — not found, not
196    /// readable, or disallowed by the resolver's own policy; `message` is
197    /// the resolver's [`crate::ResolveError`], rendered.
198    UnresolvableInclude {
199        href: String,
200        message: String,
201        position: Position,
202    },
203    /// An `<include href="...">` (issue #2) resolved to content this
204    /// crate refuses to splice in: either the resolved root element isn't
205    /// in the Schematron namespace at all, or it's a whole `<schema>`
206    /// (the ISO reference implementation, `iso_dsdl_include.xsl`, treats
207    /// this the same way: "use include to include fragments, not a whole
208    /// schema").
209    InvalidInclude {
210        href: String,
211        reason: &'static str,
212        position: Position,
213    },
214}
215
216impl fmt::Display for ParseError {
217    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
218        match self {
219            ParseError::InvalidXml(message) => {
220                write!(formatter, "invalid XML: {message}")
221            }
222            ParseError::WrongRootNamespace { found, position } => write!(
223                formatter,
224                "schema root must be in the Schematron namespace `{SCHEMATRON_NAMESPACE}`, found `{}` at {position}",
225                found.as_deref().unwrap_or("(none)")
226            ),
227            ParseError::NoPatterns => {
228                write!(formatter, "schema must contain at least one <pattern>")
229            }
230            ParseError::MissingAttribute {
231                element,
232                attribute,
233                position,
234            } => write!(
235                formatter,
236                "<{element}> is missing required attribute `{attribute}` at {position}"
237            ),
238            ParseError::UnknownAbstractPattern { pattern, position } => write!(
239                formatter,
240                "is-a references unknown abstract pattern `{pattern}` at {position}"
241            ),
242            ParseError::UnknownRuleReference { rule, position } => write!(
243                formatter,
244                "extends references unknown rule `{rule}` (not found in the same pattern) at {position}"
245            ),
246            ParseError::CyclicRuleExtends { rule, position } => write!(
247                formatter,
248                "cyclic extends involving rule `{rule}` at {position}"
249            ),
250            ParseError::UnexpectedElement { element, position } => write!(
251                formatter,
252                "unexpected <{element}> at {position} (unknown, or not supported here — see README.md)"
253            ),
254            ParseError::DuplicateId {
255                element,
256                id,
257                position,
258            } => write!(
259                formatter,
260                "duplicate {element} id `{id}` at {position} (already declared earlier in the schema)"
261            ),
262            ParseError::UnknownActivePattern { pattern, position } => write!(
263                formatter,
264                "active references unknown pattern `{pattern}` at {position}"
265            ),
266            ParseError::UnknownDiagnosticReference { id, position } => write!(
267                formatter,
268                "diagnostics references unknown diagnostic `{id}` at {position}"
269            ),
270            ParseError::UnsupportedQueryBinding { binding, position } => write!(
271                formatter,
272                "schema/@queryBinding `{binding}` at {position} is not equivalent to XPath 1.0 (only `xslt`/`xpath`, or an absent queryBinding, are accepted — see README.md)"
273            ),
274            ParseError::CyclicInclude { href, position } => {
275                write!(formatter, "cyclic include involving `{href}` at {position}")
276            }
277            ParseError::UnresolvableInclude {
278                href,
279                message,
280                position,
281            } => write!(
282                formatter,
283                "could not resolve <include href=\"{href}\"> at {position}: {message}"
284            ),
285            ParseError::InvalidInclude {
286                href,
287                reason,
288                position,
289            } => write!(
290                formatter,
291                "<include href=\"{href}\"> at {position} is invalid: {reason}"
292            ),
293        }
294    }
295}
296
297impl std::error::Error for ParseError {}
298
299/// Parses `xml` as a `roxmltree::Document`, mapping a malformed-XML error
300/// to [`ParseError::InvalidXml`] — the one place every top-level XML parse
301/// goes through (a schema's own text in [`parse`], and each resolved
302/// `<include>` fragment's text in [`resolve_includes`]).
303fn parse_xml_document(xml: &str) -> Result<roxmltree::Document<'_>, ParseError> {
304    roxmltree::Document::parse(xml).map_err(|error| ParseError::InvalidXml(error.to_string()))
305}
306
307/// Whether `node` is in the Schematron namespace — the one place every
308/// "is this really a Schematron element?" check goes through ([`parse`]'s
309/// own root-namespace check, [`resolve_includes`]'s check that a resolved
310/// `<include>` target is one too).
311fn is_schematron_element(node: Node<'_, '_>) -> bool {
312    node.tag_name().namespace() == Some(SCHEMATRON_NAMESPACE)
313}
314
315/// Parses a Schematron `.sch` XML document into a [`Schema`].
316pub fn parse(xml: &str) -> Result<Schema, ParseError> {
317    let document = parse_xml_document(xml)?;
318    let root = document.root_element();
319
320    if !is_schematron_element(root) {
321        return Err(ParseError::WrongRootNamespace {
322            found: root.tag_name().namespace().map(str::to_owned),
323            position: position_of(root),
324        });
325    }
326
327    validate_known_children(root, SCHEMA_ALLOWED_CHILDREN)?;
328
329    check_document_wide_id_uniqueness(root)?;
330
331    let ns = collect_children(root, "ns", parse_ns)?;
332
333    let lets = collect_lets(root)?;
334
335    let default_phase = root.attribute("defaultPhase").map(str::to_owned);
336
337    let query_binding = root.attribute("queryBinding").map(str::to_owned);
338    if let Some(binding) = &query_binding
339        && !XPATH1_QUERY_BINDINGS
340            .iter()
341            .any(|known| known.eq_ignore_ascii_case(binding))
342    {
343        return Err(ParseError::UnsupportedQueryBinding {
344            binding: binding.clone(),
345            position: position_of(root),
346        });
347    }
348
349    // Diagnostics are parsed before patterns so `assert|report/@diagnostics`
350    // IDREFS can be validated against a complete `diagnostic/@id` set while
351    // walking checks below (see `parse_check`).
352    let diagnostics = root
353        .children()
354        .find(|node| node.has_tag_name((SCHEMATRON_NAMESPACE, "diagnostics")))
355        .map(parse_diagnostics_element)
356        .transpose()?
357        .unwrap_or_default();
358    let diagnostic_ids: HashSet<String> = diagnostics.iter().map(|d| d.id.clone()).collect();
359
360    let properties = root
361        .children()
362        .find(|node| node.has_tag_name((SCHEMATRON_NAMESPACE, "properties")))
363        .map(parse_properties_element)
364        .transpose()?
365        .unwrap_or_default();
366
367    // Abstract patterns (`plan/07-abstract-patterns-and-extends.md`) are
368    // schema-wide templates, looked up by `is-a` regardless of where in
369    // the schema they're declared — indexed once, up front.
370    let abstract_patterns: HashMap<String, Node<'_, '_>> = root
371        .children()
372        .filter(|node| {
373            node.has_tag_name((SCHEMATRON_NAMESPACE, "pattern"))
374                && node.attribute("abstract") == Some("true")
375        })
376        .map(|node| {
377            node.attribute("id")
378                .map(|id| (id.to_owned(), node))
379                .ok_or_else(|| ParseError::MissingAttribute {
380                    element: "pattern",
381                    attribute: "id",
382                    position: position_of(node),
383                })
384        })
385        .collect::<Result<_, _>>()?;
386
387    // Tracks concrete `pattern/@id`s as they're parsed, to build the id set
388    // `parse_phase`/`parse_active` validate `active/@pattern` against below
389    // (duplicates are already rejected by `check_document_wide_id_uniqueness`
390    // above, with that pattern's own position — nothing left to reject
391    // here).
392    let mut pattern_ids: HashSet<String> = HashSet::new();
393
394    let patterns: Vec<Pattern> = root
395        .children()
396        .filter(|node| {
397            node.has_tag_name((SCHEMATRON_NAMESPACE, "pattern"))
398                && node.attribute("abstract") != Some("true")
399        })
400        .map(|node| {
401            let id = node.attribute("id").map(str::to_owned);
402            if let Some(id) = &id {
403                pattern_ids.insert(id.clone());
404            }
405
406            if let Some(is_a) = node.attribute("is-a") {
407                validate_known_children(node, IS_A_USE_ALLOWED_CHILDREN)?;
408                let abstract_node = abstract_patterns.get(is_a).copied().ok_or_else(|| {
409                    ParseError::UnknownAbstractPattern {
410                        pattern: is_a.to_owned(),
411                        position: position_of(node),
412                    }
413                })?;
414                let params = collect_children(node, "param", parse_param)?;
415                parse_pattern(abstract_node, id, &params, &diagnostic_ids)
416            } else {
417                parse_pattern(node, id, &[], &diagnostic_ids)
418            }
419        })
420        .collect::<Result<_, _>>()?;
421
422    if patterns.is_empty() {
423        return Err(ParseError::NoPatterns);
424    }
425
426    let phases = collect_children(root, "phase", |node| parse_phase(node, &pattern_ids))?;
427
428    Ok(Schema {
429        ns,
430        lets,
431        default_phase,
432        phases,
433        patterns,
434        diagnostics,
435        properties,
436        query_binding,
437    })
438}
439
440/// Parses a Schematron `.sch` XML document into a [`Schema`], first
441/// resolving every `<include href="...">` in it (issue #2) via `resolver`
442/// — unlike [`parse`], which treats `<include>` as an unsupported,
443/// unexpected element (`ParseError::UnexpectedElement`), same as a typo.
444///
445/// `base_uri` is the base URI `xml`'s own top-level `href`s resolve
446/// against (this crate has no notion of "the schema's own file path" — it
447/// never reads files itself, see [`crate::SchemaResolver`] — so the caller
448/// supplies it, exactly mirroring [`crate::SchemaSource::new`]'s own
449/// `base_uri` parameter for nested includes).
450///
451/// Resolution is a pure text-substitution pass (`resolve_includes`) that
452/// runs *before* any structural parsing: every `<include href="...">` is
453/// replaced, in place, by the XML text of its resolved root element,
454/// recursively (an included fragment's own `<include>`s are resolved too,
455/// against its own base URI) — then the fully-expanded text is parsed
456/// exactly like a [`parse`] call, so every existing structural check
457/// (`validate_known_children` and friends) still applies to the expanded
458/// result: an included fragment of the wrong kind for where it was
459/// included (e.g. a whole `<pattern>` included at rule level, where only
460/// `assert`/`report`/`extends`/`p` belong) still surfaces as the same
461/// `ParseError::UnexpectedElement` it would if it had been written inline.
462///
463/// Only the common, unambiguous form is supported: `href` with no
464/// `#fragment-id`, resolving to a *single* Schematron-namespace root
465/// element (anything else — a non-Schematron-namespace root, or a whole
466/// `<schema>` — is [`ParseError::InvalidInclude`]). The ISO reference
467/// implementation's `#fragment-id` cross-document lookup form
468/// (`iso_dsdl_include.xsl`) is not implemented — deferred, not silently
469/// mishandled: an `href` containing `#` is resolved as a literal,
470/// almost-certainly-nonexistent resource name and surfaces as
471/// [`ParseError::UnresolvableInclude`] via the caller's own
472/// [`SchemaResolver`], not silently ignored.
473pub fn parse_with_resolver(
474    xml: &str,
475    base_uri: &str,
476    resolver: &impl SchemaResolver,
477) -> Result<Schema, ParseError> {
478    let mut loading = HashSet::new();
479    let expanded = resolve_includes(xml, base_uri, resolver, &mut loading)?;
480    parse(&expanded)
481}
482
483/// The text-substitution pass behind [`parse_with_resolver`] — see its doc
484/// comment for the overall approach and scope. Splices every top-level
485/// `<include href="...">` in `xml` (there can be no *nested* `<include>`s
486/// within one `<include>` element itself: the ISO grammar declares it
487/// `foreign-empty`, so `<include>` is always a leaf) with the XML text of
488/// its resolved root element, recursing into the resolved text first (so
489/// the spliced-in text is itself already fully expanded) before splicing.
490/// `loading` is the cycle guard, keyed `"{base_uri}::{href}"` (same
491/// convention as the sister crate `relax-ng`'s own `include`/`externalRef`
492/// cycle guard).
493fn resolve_includes(
494    xml: &str,
495    base_uri: &str,
496    resolver: &impl SchemaResolver,
497    loading: &mut HashSet<String>,
498) -> Result<String, ParseError> {
499    let document = parse_xml_document(xml)?;
500
501    let includes: Vec<(Range<usize>, String, Position)> = document
502        .root_element()
503        .descendants()
504        .filter(|node| node.has_tag_name((SCHEMATRON_NAMESPACE, "include")))
505        .map(|node| {
506            let href = required_attribute(node, "include", "href")?.to_owned();
507            Ok((node.range(), href, position_of(node)))
508        })
509        .collect::<Result<_, ParseError>>()?;
510
511    if includes.is_empty() {
512        return Ok(xml.to_owned());
513    }
514
515    let mut result = String::with_capacity(xml.len());
516    let mut cursor = 0usize;
517    for (range, href, position) in includes {
518        result.push_str(&xml[cursor..range.start]);
519
520        let key = format!("{base_uri}::{href}");
521        if !loading.insert(key.clone()) {
522            return Err(ParseError::CyclicInclude { href, position });
523        }
524        let resolved_text = resolver
525            .resolve(&href, base_uri)
526            .map_err(|error| ParseError::UnresolvableInclude {
527                href: href.clone(),
528                message: error.to_string(),
529                position,
530            })
531            .and_then(|source| {
532                resolve_includes(source.text(), source.base_uri(), resolver, loading)
533            });
534        loading.remove(&key);
535        let resolved_text = resolved_text?;
536
537        let included_document = parse_xml_document(&resolved_text)?;
538        let included_root = included_document.root_element();
539        if !is_schematron_element(included_root) {
540            return Err(ParseError::InvalidInclude {
541                href,
542                reason: "included root element is not in the Schematron namespace",
543                position,
544            });
545        }
546        if included_root.has_tag_name((SCHEMATRON_NAMESPACE, "schema")) {
547            return Err(ParseError::InvalidInclude {
548                href,
549                reason: "included content must be a fragment, not a whole <schema> (ISO reference: \"use include to include fragments, not a whole schema\")",
550                position,
551            });
552        }
553        result.push_str(&resolved_text[included_root.range()]);
554
555        cursor = range.end;
556    }
557    result.push_str(&xml[cursor..]);
558    Ok(result)
559}
560
561/// Parses a `<diagnostics><diagnostic id="..." role="...">...</diagnostic>
562/// ...</diagnostics>` element into its `diagnostic` children. A repeated
563/// `diagnostic/@id` is a [`ParseError::DuplicateId`], raised earlier by
564/// [`check_document_wide_id_uniqueness`], not here.
565fn parse_diagnostics_element(node: Node<'_, '_>) -> Result<Vec<Diagnostic>, ParseError> {
566    validate_known_children(node, &["diagnostic"])?;
567    collect_children(node, "diagnostic", parse_diagnostic)
568}
569
570/// Parses a `<diagnostic id="..." role="...">` — same rich-content
571/// message model as [`parse_check`]'s message (`text`/`value-of`), no
572/// parameter substitution (diagnostics are schema-level, never part of an
573/// abstract-pattern instantiation).
574fn parse_diagnostic(node: Node<'_, '_>) -> Result<Diagnostic, ParseError> {
575    let id = required_attribute(node, "diagnostic", "id")?.to_owned();
576    let (role, message) = parse_role_and_message(node, &[])?;
577    Ok(Diagnostic { id, role, message })
578}
579
580/// Parses a `<properties><property id="..." role="..." scheme="...">
581/// ...</property>...</properties>` element (ISO Schematron 2016 extension,
582/// issue #5) into its `property` children. A repeated `property/@id` is a
583/// [`ParseError::DuplicateId`], raised earlier by
584/// [`check_document_wide_id_uniqueness`], not here.
585fn parse_properties_element(node: Node<'_, '_>) -> Result<Vec<Property>, ParseError> {
586    validate_known_children(node, PROPERTIES_ALLOWED_CHILDREN)?;
587    collect_children(node, "property", parse_property)
588}
589
590/// Parses a `<property id="..." role="..." scheme="...">` — same
591/// rich-content message model as [`parse_diagnostic`], plus the
592/// `property`-specific `scheme` attribute.
593fn parse_property(node: Node<'_, '_>) -> Result<Property, ParseError> {
594    let id = required_attribute(node, "property", "id")?.to_owned();
595    let scheme = node.attribute("scheme").map(str::to_owned);
596    let (role, message) = parse_role_and_message(node, &[])?;
597    Ok(Property {
598        id,
599        role,
600        scheme,
601        message,
602    })
603}
604
605/// Reads the optional `role="..."` attribute and the rich-content message
606/// body — shared by [`parse_diagnostic`], [`parse_property`], and
607/// [`parse_check`], the element kinds with that exact `role`/message-content
608/// shape.
609fn parse_role_and_message(
610    node: Node<'_, '_>,
611    params: &[(String, String)],
612) -> Result<(Option<String>, Vec<MessagePart>), ParseError> {
613    let role = node.attribute("role").map(str::to_owned);
614    let mut message = Vec::new();
615    collect_message_parts(node, &mut message, params)?;
616    Ok((role, message))
617}
618
619/// Parses an `<ns prefix="..." uri="...">` binding. Both attributes are
620/// required — a `<ns>` missing either is a regular `MissingAttribute`
621/// error, not a silent empty-string fallback (an empty prefix/uri binding
622/// is never meaningful).
623fn parse_ns(node: Node<'_, '_>) -> Result<NamespaceBinding, ParseError> {
624    let prefix = required_attribute(node, "ns", "prefix")?.to_owned();
625    let uri = required_attribute(node, "ns", "uri")?.to_owned();
626    Ok(NamespaceBinding { prefix, uri })
627}
628
629/// Parses a `<let name="..." value="...">` or `<let name="...">`-with-
630/// literal-XML-content binding (issue #3 — the grammar's
631/// `(attribute value { string } | foreign-element+)` alternative). A
632/// `<let>` with neither a `value` attribute nor any element child is a
633/// regular `MissingAttribute` error, not a silent fallback — matches this
634/// crate's existing "missing required info is always an error" convention
635/// (same as before issue #3, when only the `value`-attribute form was
636/// supported at all). A `<let>` with only whitespace/text content and no
637/// element child is treated the same way (`foreign-element+` requires at
638/// least one actual *element*, per the grammar) — deliberately, so a
639/// `<let name="x">  </let>` (a forgotten `value` attribute, indentation
640/// whitespace mistaken for content) still errors instead of silently
641/// binding `$x` to an empty string.
642fn parse_let(node: Node<'_, '_>) -> Result<LetBinding, ParseError> {
643    let name = required_attribute(node, "let", "name")?.to_owned();
644    let value = if let Some(value) = node.attribute("value") {
645        LetValue::Expr(value.to_owned())
646    } else if node.children().any(|child| child.is_element()) {
647        LetValue::Literal(collect_literal_text(node))
648    } else {
649        return Err(ParseError::MissingAttribute {
650            element: "let",
651            attribute: "value",
652            position: position_of(node),
653        });
654    };
655    Ok(LetBinding { name, value })
656}
657
658/// Computes the string-value of a `<let>`'s literal-XML content
659/// (`LetValue::Literal`, issue #3) — XPath's own string-value-of-a-node
660/// algorithm: the concatenation of every descendant text node's content,
661/// in document order, regardless of element nesting depth. Matches how
662/// XSLT 1.0 treats a variable bound to a result-tree-fragment by default
663/// when used as a string (see [`LetValue::Literal`]'s doc comment).
664fn collect_literal_text(node: Node<'_, '_>) -> String {
665    let mut text = String::new();
666    for child in node.children() {
667        if child.is_text() {
668            if let Some(t) = child.text() {
669                text.push_str(t);
670            }
671        } else if child.is_element() {
672            text.push_str(&collect_literal_text(child));
673        }
674    }
675    text
676}
677
678/// Parses a `<phase id="..."><active pattern="..."/>...</phase>` element.
679/// `active/@pattern` is cross-checked against `pattern_ids` (every
680/// concrete `Pattern::id` in the schema) — an unknown reference is
681/// [`ParseError::UnknownActivePattern`] (superseded `plan/05-phase-
682/// selection.md`'s original "not cross-checked" decision, see
683/// `DECISIONS.md`).
684fn parse_phase(node: Node<'_, '_>, pattern_ids: &HashSet<String>) -> Result<Phase, ParseError> {
685    validate_known_children(node, PHASE_ALLOWED_CHILDREN)?;
686    let id = required_attribute(node, "phase", "id")?.to_owned();
687    let lets = collect_lets(node)?;
688    let active = collect_children(node, "active", |child| parse_active(child, pattern_ids))?;
689    Ok(Phase { id, lets, active })
690}
691
692fn parse_active(node: Node<'_, '_>, pattern_ids: &HashSet<String>) -> Result<String, ParseError> {
693    let pattern = required_attribute(node, "active", "pattern")?;
694    if !pattern_ids.contains(pattern) {
695        return Err(ParseError::UnknownActivePattern {
696            pattern: pattern.to_owned(),
697            position: position_of(node),
698        });
699    }
700    Ok(pattern.to_owned())
701}
702
703/// Parses a `<param name="..." value="...">` child of an `is-a` pattern
704/// use, as a raw `(name, value)` pair for [`substitute`].
705fn parse_param(node: Node<'_, '_>) -> Result<(String, String), ParseError> {
706    let name = required_attribute(node, "param", "name")?.to_owned();
707    let value = required_attribute(node, "param", "value")?.to_owned();
708    Ok((name, value))
709}
710
711/// Replaces `$name` with `value` for each `(name, value)` in `params`,
712/// applied sequentially in order (each step sees the previous step's
713/// result) — the same semantics as the ISO reference implementation's
714/// abstract-pattern parameter substitution (`plan/07-abstract-patterns-
715/// and-extends.md`). A no-op for `params: &[]`, so this is exactly Stage
716/// 1's behavior for schemas without abstract patterns.
717fn substitute(text: &str, params: &[(String, String)]) -> String {
718    params.iter().fold(text.to_owned(), |acc, (name, value)| {
719        acc.replace(&format!("${name}"), value)
720    })
721}
722
723/// Builds a `Pattern` from `node`'s `rule` children. `node` is the
724/// pattern element itself for a normal (non-`is-a`) pattern, or the
725/// *abstract* pattern being instantiated for an `is-a` use (its `rule`s
726/// are the source, but `id` is the `is-a` pattern's own id — see
727/// `plan/07-abstract-patterns-and-extends.md`). `params` drives
728/// [`substitute`] in `context`/`test`/`value-of/@select` (empty for a
729/// normal pattern).
730fn parse_pattern(
731    node: Node<'_, '_>,
732    id: Option<String>,
733    params: &[(String, String)],
734    diagnostic_ids: &HashSet<String>,
735) -> Result<Pattern, ParseError> {
736    validate_known_children(node, PATTERN_ALLOWED_CHILDREN)?;
737
738    let lets = collect_lets(node)?;
739
740    let rule_index: HashMap<String, Node<'_, '_>> = node
741        .children()
742        .filter(|child| child.has_tag_name((SCHEMATRON_NAMESPACE, "rule")))
743        .filter_map(|child| child.attribute("id").map(|id| (id.to_owned(), child)))
744        .collect();
745
746    let rules = node
747        .children()
748        .filter(|child| {
749            child.has_tag_name((SCHEMATRON_NAMESPACE, "rule"))
750                && child.attribute("abstract") != Some("true")
751        })
752        .map(|child| parse_rule(child, &rule_index, params, diagnostic_ids))
753        .collect::<Result<_, _>>()?;
754
755    Ok(Pattern { id, lets, rules })
756}
757
758/// Parses a concrete (non-abstract) `<rule context="...">`. `rule_index`
759/// is every `rule` sibling in the same pattern, by id (abstract and
760/// concrete alike), used to resolve this rule's own `<extends>` children.
761fn parse_rule(
762    node: Node<'_, '_>,
763    rule_index: &HashMap<String, Node<'_, '_>>,
764    params: &[(String, String)],
765    diagnostic_ids: &HashSet<String>,
766) -> Result<Rule, ParseError> {
767    validate_known_children(node, RULE_ALLOWED_CHILDREN)?;
768
769    let context = substitute(required_attribute(node, "rule", "context")?, params);
770
771    // Cycle guard for <extends>, seeded with this rule's own id so a
772    // direct self-extends is caught the same way as a transitive one.
773    let mut visiting: Vec<String> = node
774        .attribute("id")
775        .map(str::to_owned)
776        .into_iter()
777        .collect();
778
779    let mut checks = Vec::new();
780    let mut lets = Vec::new();
781    collect_checks(
782        node,
783        rule_index,
784        params,
785        &mut visiting,
786        &mut checks,
787        &mut lets,
788        diagnostic_ids,
789    )?;
790
791    Ok(Rule {
792        context,
793        lets,
794        checks,
795    })
796}
797
798/// Walks `node`'s children collecting `assert`/`report` as [`Check`]s and
799/// `<let>` as [`LetBinding`]s, inlining `<extends rule="X">` by recursively
800/// collecting `X`'s own checks *and* lets at that position (see
801/// `plan/07-abstract-patterns-and-extends.md` — `X` must be declared in the
802/// same pattern, i.e. present in `rule_index`). An extended rule's `<let>`s
803/// are carried over exactly like its checks are — same recursive-inlining
804/// mechanism, so a `<let>` in a transitively-extended abstract rule is
805/// visible too (issue #7). Both `checks` and `lets` end up ordered by where
806/// the contributing `<assert>`/`<report>`/`<let>`/`<extends>` sits in
807/// document order, walking into an `<extends>` at the position it appears —
808/// the same convention already used for checks, extended here to `<let>`:
809/// an inherited `<let>` before this rule's own `<let name="x">` of the same
810/// name is shadowed by it (later wins, see `extend_lets` in `engine.rs`),
811/// an inherited one *after* an own `<let>` of the same name shadows it
812/// instead — purely a function of where the author placed `<extends>`
813/// relative to their own `<let>`s, not a fixed "own always wins" rule.
814/// `visiting` guards against cycles (`X` extending something already being
815/// resolved).
816fn collect_checks(
817    node: Node<'_, '_>,
818    rule_index: &HashMap<String, Node<'_, '_>>,
819    params: &[(String, String)],
820    visiting: &mut Vec<String>,
821    checks: &mut Vec<Check>,
822    lets: &mut Vec<LetBinding>,
823    diagnostic_ids: &HashSet<String>,
824) -> Result<(), ParseError> {
825    for child in node.children() {
826        if let Some(kind) = check_kind(child) {
827            checks.push(parse_check(kind, child, params, diagnostic_ids)?);
828        } else if child.has_tag_name((SCHEMATRON_NAMESPACE, "let")) {
829            lets.push(parse_let(child)?);
830        } else if child.has_tag_name((SCHEMATRON_NAMESPACE, "extends")) {
831            let target_id =
832                child
833                    .attribute("rule")
834                    .ok_or_else(|| ParseError::MissingAttribute {
835                        element: "extends",
836                        attribute: "rule",
837                        position: position_of(child),
838                    })?;
839            let target = rule_index.get(target_id).copied().ok_or_else(|| {
840                ParseError::UnknownRuleReference {
841                    rule: target_id.to_owned(),
842                    position: position_of(child),
843                }
844            })?;
845            if visiting.iter().any(|id| id == target_id) {
846                return Err(ParseError::CyclicRuleExtends {
847                    rule: target_id.to_owned(),
848                    position: position_of(child),
849                });
850            }
851            visiting.push(target_id.to_owned());
852            collect_checks(
853                target,
854                rule_index,
855                params,
856                visiting,
857                checks,
858                lets,
859                diagnostic_ids,
860            )?;
861            visiting.pop();
862        }
863    }
864    Ok(())
865}
866
867fn check_kind(node: Node<'_, '_>) -> Option<CheckKind> {
868    if node.has_tag_name((SCHEMATRON_NAMESPACE, "assert")) {
869        Some(CheckKind::Assert)
870    } else if node.has_tag_name((SCHEMATRON_NAMESPACE, "report")) {
871        Some(CheckKind::Report)
872    } else {
873        None
874    }
875}
876
877fn parse_check(
878    kind: CheckKind,
879    node: Node<'_, '_>,
880    params: &[(String, String)],
881    diagnostic_ids: &HashSet<String>,
882) -> Result<Check, ParseError> {
883    let element = match kind {
884        CheckKind::Assert => "assert",
885        CheckKind::Report => "report",
886    };
887    let test = substitute(required_attribute(node, element, "test")?, params);
888    let id = node.attribute("id").map(str::to_owned);
889    let (role, message) = parse_role_and_message(node, params)?;
890    let diagnostics: Vec<String> = node
891        .attribute("diagnostics")
892        .map(|ids| ids.split_whitespace().map(str::to_owned).collect())
893        .unwrap_or_default();
894    for diagnostic_id in &diagnostics {
895        if !diagnostic_ids.contains(diagnostic_id) {
896            return Err(ParseError::UnknownDiagnosticReference {
897                id: diagnostic_id.clone(),
898                position: position_of(node),
899            });
900        }
901    }
902
903    Ok(Check {
904        kind,
905        test,
906        id,
907        role,
908        message,
909        diagnostics,
910    })
911}
912
913/// Builds an `<assert>`/`<report>` element's message content in document
914/// order: text nodes anywhere in the subtree become [`MessagePart::Text`]
915/// (recursing through purely presentational wrapper elements like
916/// `<emph>`/`<span>`, which — like Stage 1 — carry no meaning of their own
917/// here, only their text does), `<value-of select="...">` children become
918/// [`MessagePart::ValueOf`], and `<name path="...">` (or self-closing
919/// `<name/>`) children become [`MessagePart::Name`] (issue #4) — both
920/// `select`/`path` are run through [`substitute`], and neither element's
921/// content is descended into (the grammar defines both as empty). No
922/// trimming here: that now happens on the *rendered* message at evaluation
923/// time (`plan/06-value-of-interpolation.md`), since `value-of`/`name` only
924/// have a value once evaluated against a firing check's context node.
925fn collect_message_parts(
926    node: Node<'_, '_>,
927    parts: &mut Vec<MessagePart>,
928    params: &[(String, String)],
929) -> Result<(), ParseError> {
930    for child in node.children() {
931        if child.is_text() {
932            if let Some(text) = child.text() {
933                parts.push(MessagePart::Text(text.to_owned()));
934            }
935        } else if child.has_tag_name((SCHEMATRON_NAMESPACE, "value-of")) {
936            let select = required_attribute(child, "value-of", "select")?;
937            parts.push(MessagePart::ValueOf(substitute(select, params)));
938        } else if child.has_tag_name((SCHEMATRON_NAMESPACE, "name")) {
939            let path = child.attribute("path").map(|path| substitute(path, params));
940            parts.push(MessagePart::Name(path));
941        } else if child.is_element() {
942            collect_message_parts(child, parts, params)?;
943        }
944    }
945    Ok(())
946}
947
948/// Reads a required attribute, or [`ParseError::MissingAttribute`] at
949/// `node`'s position — the one place every `element/@attribute` required-
950/// attribute read goes through.
951fn required_attribute<'a>(
952    node: Node<'a, '_>,
953    element: &'static str,
954    attribute: &'static str,
955) -> Result<&'a str, ParseError> {
956    node.attribute(attribute)
957        .ok_or_else(|| ParseError::MissingAttribute {
958            element,
959            attribute,
960            position: position_of(node),
961        })
962}
963
964/// Collects an element's direct `<let>` children — the one place every
965/// `let*` grammar position (schema/pattern/rule/phase level) goes
966/// through.
967fn collect_lets(node: Node<'_, '_>) -> Result<Vec<LetBinding>, ParseError> {
968    collect_children(node, "let", parse_let)
969}
970
971/// Parses every direct `tag`-named child (Schematron namespace) of `node`
972/// via `f` — the one place every "filter by tag, parse each, collect"
973/// grammar position (`let*`, `param*`, `active*`, ...) goes through.
974fn collect_children<T>(
975    node: Node<'_, '_>,
976    tag: &str,
977    f: impl FnMut(Node<'_, '_>) -> Result<T, ParseError>,
978) -> Result<Vec<T>, ParseError> {
979    node.children()
980        .filter(|child| child.has_tag_name((SCHEMATRON_NAMESPACE, tag)))
981        .map(f)
982        .collect()
983}
984
985/// Errors on any direct child element of `node` that is in the Schematron
986/// namespace but not in `allowed` — see the allowlist constants above for
987/// the rationale. Note this only ever inspects Schematron-namespace
988/// children: a foreign-namespace element is never in `allowed` either, but
989/// is deliberately not checked here at all (ISO's own "foreign" content
990/// model already permits those anywhere).
991fn validate_known_children(node: Node<'_, '_>, allowed: &[&str]) -> Result<(), ParseError> {
992    for child in node.children() {
993        if child.is_element()
994            && child.tag_name().namespace() == Some(SCHEMATRON_NAMESPACE)
995            && !allowed.contains(&child.tag_name().name())
996        {
997            return Err(ParseError::UnexpectedElement {
998                element: child.tag_name().name().to_owned(),
999                position: position_of(child),
1000            });
1001        }
1002    }
1003    Ok(())
1004}
1005
1006fn position_of(node: Node<'_, '_>) -> Position {
1007    let text_pos = node.document().text_pos_at(node.range().start);
1008    Position {
1009        row: text_pos.row,
1010        col: text_pos.col,
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017
1018    #[test]
1019    fn minimal_valid_schema() {
1020        let schema = parse(
1021            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1022                <pattern>
1023                    <rule context="/root">
1024                        <assert test="foo">message</assert>
1025                    </rule>
1026                </pattern>
1027            </schema>"#,
1028        )
1029        .unwrap();
1030
1031        assert_eq!(schema.ns, vec![]);
1032        assert_eq!(schema.patterns.len(), 1);
1033        let pattern = &schema.patterns[0];
1034        assert_eq!(pattern.id, None);
1035        assert_eq!(pattern.rules.len(), 1);
1036        let rule = &pattern.rules[0];
1037        assert_eq!(rule.context, "/root");
1038        assert_eq!(rule.checks.len(), 1);
1039        let check = &rule.checks[0];
1040        assert_eq!(check.kind, CheckKind::Assert);
1041        assert_eq!(check.test, "foo");
1042        assert_eq!(check.message, vec![MessagePart::Text("message".to_owned())]);
1043    }
1044
1045    #[test]
1046    fn multiple_patterns_rules_and_checks() {
1047        let schema = parse(
1048            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1049                <pattern id="p1">
1050                    <rule context="/a">
1051                        <assert test="t1">m1</assert>
1052                        <assert test="t2">m2</assert>
1053                    </rule>
1054                    <rule context="/b">
1055                        <assert test="t3">m3</assert>
1056                    </rule>
1057                </pattern>
1058                <pattern id="p2">
1059                    <rule context="/c">
1060                        <assert test="t4">m4</assert>
1061                    </rule>
1062                </pattern>
1063            </schema>"#,
1064        )
1065        .unwrap();
1066
1067        assert_eq!(schema.patterns.len(), 2);
1068        assert_eq!(schema.patterns[0].id.as_deref(), Some("p1"));
1069        assert_eq!(schema.patterns[0].rules.len(), 2);
1070        assert_eq!(schema.patterns[0].rules[0].checks.len(), 2);
1071        assert_eq!(schema.patterns[0].rules[1].checks.len(), 1);
1072        assert_eq!(schema.patterns[1].id.as_deref(), Some("p2"));
1073        assert_eq!(schema.patterns[1].rules.len(), 1);
1074    }
1075
1076    #[test]
1077    fn report_alongside_assert() {
1078        let schema = parse(
1079            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1080                <pattern>
1081                    <rule context="/root">
1082                        <assert test="a-test">assert message</assert>
1083                        <report test="r-test">report message</report>
1084                    </rule>
1085                </pattern>
1086            </schema>"#,
1087        )
1088        .unwrap();
1089
1090        let checks = &schema.patterns[0].rules[0].checks;
1091        assert_eq!(checks.len(), 2);
1092        assert_eq!(checks[0].kind, CheckKind::Assert);
1093        assert_eq!(checks[0].test, "a-test");
1094        assert_eq!(checks[1].kind, CheckKind::Report);
1095        assert_eq!(checks[1].test, "r-test");
1096    }
1097
1098    #[test]
1099    fn ns_bindings_are_captured() {
1100        let schema = parse(
1101            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1102                <ns prefix="ex" uri="http://example.com/ns"/>
1103                <ns prefix="other" uri="http://example.com/other"/>
1104                <pattern>
1105                    <rule context="/root">
1106                        <assert test="foo">message</assert>
1107                    </rule>
1108                </pattern>
1109            </schema>"#,
1110        )
1111        .unwrap();
1112
1113        assert_eq!(
1114            schema.ns,
1115            vec![
1116                NamespaceBinding {
1117                    prefix: "ex".to_owned(),
1118                    uri: "http://example.com/ns".to_owned(),
1119                },
1120                NamespaceBinding {
1121                    prefix: "other".to_owned(),
1122                    uri: "http://example.com/other".to_owned(),
1123                },
1124            ]
1125        );
1126    }
1127
1128    #[test]
1129    fn ns_missing_prefix_is_an_error() {
1130        let error = parse(
1131            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1132                <ns uri="http://example.com/ns"/>
1133                <pattern>
1134                    <rule context="/root">
1135                        <assert test="foo">message</assert>
1136                    </rule>
1137                </pattern>
1138            </schema>"#,
1139        )
1140        .unwrap_err();
1141
1142        assert_eq!(
1143            error,
1144            ParseError::MissingAttribute {
1145                element: "ns",
1146                attribute: "prefix",
1147                position: Position { row: 2, col: 17 },
1148            }
1149        );
1150    }
1151
1152    #[test]
1153    fn ns_missing_uri_is_an_error() {
1154        let error = parse(
1155            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1156                <ns prefix="ex"/>
1157                <pattern>
1158                    <rule context="/root">
1159                        <assert test="foo">message</assert>
1160                    </rule>
1161                </pattern>
1162            </schema>"#,
1163        )
1164        .unwrap_err();
1165
1166        assert_eq!(
1167            error,
1168            ParseError::MissingAttribute {
1169                element: "ns",
1170                attribute: "uri",
1171                position: Position { row: 2, col: 17 },
1172            }
1173        );
1174    }
1175
1176    #[test]
1177    fn id_and_role_present() {
1178        let schema = parse(
1179            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1180                <pattern>
1181                    <rule context="/root">
1182                        <assert test="foo" id="my-id" role="warning">message</assert>
1183                    </rule>
1184                </pattern>
1185            </schema>"#,
1186        )
1187        .unwrap();
1188
1189        let check = &schema.patterns[0].rules[0].checks[0];
1190        assert_eq!(check.id.as_deref(), Some("my-id"));
1191        assert_eq!(check.role.as_deref(), Some("warning"));
1192    }
1193
1194    #[test]
1195    fn id_and_role_absent() {
1196        let schema = parse(
1197            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1198                <pattern>
1199                    <rule context="/root">
1200                        <assert test="foo">message</assert>
1201                    </rule>
1202                </pattern>
1203            </schema>"#,
1204        )
1205        .unwrap();
1206
1207        let check = &schema.patterns[0].rules[0].checks[0];
1208        assert_eq!(check.id, None);
1209        assert_eq!(check.role, None);
1210    }
1211
1212    /// The parser no longer trims (that moved to render time in
1213    /// `engine.rs`, see `plan/06-value-of-interpolation.md`) — the raw
1214    /// text, whitespace and all, is captured as-is as a single `Text` part.
1215    #[test]
1216    fn message_text_is_captured_untrimmed() {
1217        let schema = parse(
1218            "<schema xmlns=\"http://purl.oclc.org/dsdl/schematron\">\
1219                <pattern>\
1220                    <rule context=\"/root\">\
1221                        <assert test=\"foo\">\n  first   second  \n</assert>\
1222                    </rule>\
1223                </pattern>\
1224            </schema>",
1225        )
1226        .unwrap();
1227
1228        let check = &schema.patterns[0].rules[0].checks[0];
1229        assert_eq!(
1230            check.message,
1231            vec![MessagePart::Text("\n  first   second  \n".to_owned())]
1232        );
1233    }
1234
1235    #[test]
1236    fn message_text_without_surrounding_whitespace_is_unchanged() {
1237        let schema = parse(
1238            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1239                <pattern>
1240                    <rule context="/root">
1241                        <assert test="foo">exact</assert>
1242                    </rule>
1243                </pattern>
1244            </schema>"#,
1245        )
1246        .unwrap();
1247
1248        let check = &schema.patterns[0].rules[0].checks[0];
1249        assert_eq!(check.message, vec![MessagePart::Text("exact".to_owned())]);
1250    }
1251
1252    #[test]
1253    fn value_of_and_wrapper_element_text_are_captured() {
1254        let schema = parse(
1255            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1256                <pattern>
1257                    <rule context="/root">
1258                        <assert test="foo">before <value-of select="@x"/> after <emph>wrapped</emph></assert>
1259                    </rule>
1260                </pattern>
1261            </schema>"#,
1262        )
1263        .unwrap();
1264
1265        let check = &schema.patterns[0].rules[0].checks[0];
1266        assert_eq!(
1267            check.message,
1268            vec![
1269                MessagePart::Text("before ".to_owned()),
1270                MessagePart::ValueOf("@x".to_owned()),
1271                MessagePart::Text(" after ".to_owned()),
1272                MessagePart::Text("wrapped".to_owned()),
1273            ]
1274        );
1275    }
1276
1277    /// Issue #4: a self-closing `<name/>` becomes `MessagePart::Name(None)`,
1278    /// `<name path="...">` becomes `MessagePart::Name(Some(path))` — neither
1279    /// is treated as a plain-text wrapper anymore.
1280    #[test]
1281    fn name_element_is_captured_as_a_message_part() {
1282        let schema = parse(
1283            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1284                <pattern>
1285                    <rule context="/root">
1286                        <assert test="foo">Element <name/> or <name path="../other"/>.</assert>
1287                    </rule>
1288                </pattern>
1289            </schema>"#,
1290        )
1291        .unwrap();
1292
1293        let check = &schema.patterns[0].rules[0].checks[0];
1294        assert_eq!(
1295            check.message,
1296            vec![
1297                MessagePart::Text("Element ".to_owned()),
1298                MessagePart::Name(None),
1299                MessagePart::Text(" or ".to_owned()),
1300                MessagePart::Name(Some("../other".to_owned())),
1301                MessagePart::Text(".".to_owned()),
1302            ]
1303        );
1304    }
1305
1306    #[test]
1307    fn value_of_missing_select_is_an_error() {
1308        let error = parse(
1309            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1310                <pattern>
1311                    <rule context="/root">
1312                        <assert test="foo"><value-of/></assert>
1313                    </rule>
1314                </pattern>
1315            </schema>"#,
1316        )
1317        .unwrap_err();
1318
1319        assert_eq!(
1320            error,
1321            ParseError::MissingAttribute {
1322                element: "value-of",
1323                attribute: "select",
1324                position: Position { row: 4, col: 44 },
1325            }
1326        );
1327    }
1328
1329    #[test]
1330    fn missing_context_is_an_error() {
1331        let error = parse(
1332            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1333                <pattern>
1334                    <rule>
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: "rule",
1346                attribute: "context",
1347                position: Position { row: 3, col: 21 },
1348            }
1349        );
1350    }
1351
1352    #[test]
1353    fn missing_test_is_an_error() {
1354        let error = parse(
1355            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1356                <pattern>
1357                    <rule context="/root">
1358                        <assert>message</assert>
1359                    </rule>
1360                </pattern>
1361            </schema>"#,
1362        )
1363        .unwrap_err();
1364
1365        assert_eq!(
1366            error,
1367            ParseError::MissingAttribute {
1368                element: "assert",
1369                attribute: "test",
1370                position: Position { row: 4, col: 25 },
1371            }
1372        );
1373    }
1374
1375    #[test]
1376    fn no_pattern_is_an_error() {
1377        let error = parse(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron"/>"#).unwrap_err();
1378
1379        assert_eq!(error, ParseError::NoPatterns);
1380    }
1381
1382    #[test]
1383    fn wrong_root_namespace_is_an_error() {
1384        let error = parse(r#"<schema xmlns="http://example.com/not-schematron"/>"#).unwrap_err();
1385
1386        assert_eq!(
1387            error,
1388            ParseError::WrongRootNamespace {
1389                found: Some("http://example.com/not-schematron".to_owned()),
1390                position: Position { row: 1, col: 1 },
1391            }
1392        );
1393    }
1394
1395    #[test]
1396    fn diagnostics_are_captured() {
1397        let schema = parse(
1398            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1399                <diagnostics>
1400                    <diagnostic id="d1" role="warning">before <value-of select="."/> after</diagnostic>
1401                    <diagnostic id="d2">second</diagnostic>
1402                </diagnostics>
1403                <pattern>
1404                    <rule context="/root">
1405                        <assert test="foo" diagnostics="d1 d2">message</assert>
1406                    </rule>
1407                </pattern>
1408            </schema>"#,
1409        )
1410        .unwrap();
1411
1412        assert_eq!(schema.diagnostics.len(), 2);
1413        assert_eq!(schema.diagnostics[0].id, "d1");
1414        assert_eq!(schema.diagnostics[0].role.as_deref(), Some("warning"));
1415        assert_eq!(
1416            schema.diagnostics[0].message,
1417            vec![
1418                MessagePart::Text("before ".to_owned()),
1419                MessagePart::ValueOf(".".to_owned()),
1420                MessagePart::Text(" after".to_owned()),
1421            ]
1422        );
1423
1424        let check = &schema.patterns[0].rules[0].checks[0];
1425        assert_eq!(check.diagnostics, vec!["d1".to_owned(), "d2".to_owned()]);
1426    }
1427
1428    /// Referential integrity for `diagnostics="..."` IDREFS is checked at
1429    /// parse time (superseded `plan/08-diagnostics.md`'s original "not
1430    /// checked" decision — see `DECISIONS.md`): a reference to a
1431    /// `diagnostic/@id` that doesn't exist is a hard `ParseError`, not a
1432    /// silently-empty result at evaluation time.
1433    #[test]
1434    fn diagnostics_unknown_reference_is_an_error() {
1435        let error = parse(
1436            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1437                <diagnostics>
1438                    <diagnostic id="d1">known</diagnostic>
1439                </diagnostics>
1440                <pattern>
1441                    <rule context="/root">
1442                        <assert test="foo" diagnostics="d1 missing">message</assert>
1443                    </rule>
1444                </pattern>
1445            </schema>"#,
1446        )
1447        .unwrap_err();
1448
1449        assert!(matches!(
1450            error,
1451            ParseError::UnknownDiagnosticReference { id, .. } if id == "missing"
1452        ));
1453    }
1454
1455    #[test]
1456    fn duplicate_diagnostic_id_is_an_error() {
1457        let error = parse(
1458            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1459                <diagnostics>
1460                    <diagnostic id="d1">first</diagnostic>
1461                    <diagnostic id="d1">second</diagnostic>
1462                </diagnostics>
1463                <pattern>
1464                    <rule context="/root">
1465                        <assert test="foo" diagnostics="d1">message</assert>
1466                    </rule>
1467                </pattern>
1468            </schema>"#,
1469        )
1470        .unwrap_err();
1471
1472        assert!(matches!(
1473            error,
1474            ParseError::DuplicateId { element: "diagnostic", id, .. } if id == "d1"
1475        ));
1476    }
1477
1478    #[test]
1479    fn check_without_diagnostics_attribute_has_an_empty_list() {
1480        let schema = parse(
1481            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1482                <pattern>
1483                    <rule context="/root">
1484                        <assert test="foo">message</assert>
1485                    </rule>
1486                </pattern>
1487            </schema>"#,
1488        )
1489        .unwrap();
1490
1491        assert_eq!(
1492            schema.patterns[0].rules[0].checks[0].diagnostics,
1493            Vec::<String>::new()
1494        );
1495        assert_eq!(schema.diagnostics, vec![]);
1496    }
1497
1498    #[test]
1499    fn diagnostic_missing_id_is_an_error() {
1500        let error = parse(
1501            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1502                <diagnostics>
1503                    <diagnostic>text</diagnostic>
1504                </diagnostics>
1505                <pattern>
1506                    <rule context="/root">
1507                        <assert test="foo">message</assert>
1508                    </rule>
1509                </pattern>
1510            </schema>"#,
1511        )
1512        .unwrap_err();
1513
1514        assert_eq!(
1515            error,
1516            ParseError::MissingAttribute {
1517                element: "diagnostic",
1518                attribute: "id",
1519                position: Position { row: 3, col: 21 },
1520            }
1521        );
1522    }
1523
1524    /// Issue #5: `<properties>` parses successfully and its `<property>`
1525    /// children are queryable from `Schema.properties`.
1526    #[test]
1527    fn properties_are_captured() {
1528        let schema = parse(
1529            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1530                <properties>
1531                    <property id="p1" role="severity" scheme="acme">before <value-of select="."/> after</property>
1532                    <property id="p2">second</property>
1533                </properties>
1534                <pattern>
1535                    <rule context="/root">
1536                        <assert test="foo">message</assert>
1537                    </rule>
1538                </pattern>
1539            </schema>"#,
1540        )
1541        .unwrap();
1542
1543        assert_eq!(schema.properties.len(), 2);
1544        assert_eq!(schema.properties[0].id, "p1");
1545        assert_eq!(schema.properties[0].role.as_deref(), Some("severity"));
1546        assert_eq!(schema.properties[0].scheme.as_deref(), Some("acme"));
1547        assert_eq!(
1548            schema.properties[0].message,
1549            vec![
1550                MessagePart::Text("before ".to_owned()),
1551                MessagePart::ValueOf(".".to_owned()),
1552                MessagePart::Text(" after".to_owned()),
1553            ]
1554        );
1555        assert_eq!(schema.properties[1].id, "p2");
1556        assert_eq!(schema.properties[1].role, None);
1557        assert_eq!(schema.properties[1].scheme, None);
1558    }
1559
1560    #[test]
1561    fn schema_without_properties_has_an_empty_properties_list() {
1562        let schema = parse(
1563            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1564                <pattern>
1565                    <rule context="/root">
1566                        <assert test="foo">message</assert>
1567                    </rule>
1568                </pattern>
1569            </schema>"#,
1570        )
1571        .unwrap();
1572
1573        assert_eq!(schema.properties, vec![]);
1574    }
1575
1576    #[test]
1577    fn duplicate_property_id_is_an_error() {
1578        let error = parse(
1579            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1580                <properties>
1581                    <property id="p1">first</property>
1582                    <property id="p1">second</property>
1583                </properties>
1584                <pattern>
1585                    <rule context="/root">
1586                        <assert test="foo">message</assert>
1587                    </rule>
1588                </pattern>
1589            </schema>"#,
1590        )
1591        .unwrap_err();
1592
1593        assert!(matches!(
1594            error,
1595            ParseError::DuplicateId { element: "property", id, .. } if id == "p1"
1596        ));
1597    }
1598
1599    #[test]
1600    fn property_missing_id_is_an_error() {
1601        let error = parse(
1602            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1603                <properties>
1604                    <property>text</property>
1605                </properties>
1606                <pattern>
1607                    <rule context="/root">
1608                        <assert test="foo">message</assert>
1609                    </rule>
1610                </pattern>
1611            </schema>"#,
1612        )
1613        .unwrap_err();
1614
1615        assert_eq!(
1616            error,
1617            ParseError::MissingAttribute {
1618                element: "property",
1619                attribute: "id",
1620                position: Position { row: 3, col: 21 },
1621            }
1622        );
1623    }
1624
1625    #[test]
1626    fn unexpected_element_inside_properties_is_an_error() {
1627        let error = parse(
1628            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1629                <properties>
1630                    <bogus/>
1631                </properties>
1632                <pattern>
1633                    <rule context="/root">
1634                        <assert test="foo">message</assert>
1635                    </rule>
1636                </pattern>
1637            </schema>"#,
1638        )
1639        .unwrap_err();
1640
1641        assert!(matches!(
1642            error,
1643            ParseError::UnexpectedElement { element, .. } if element == "bogus"
1644        ));
1645    }
1646
1647    /// Issue #6: `schema/@queryBinding` is captured, and accepted
1648    /// case-insensitively when it names an XPath-1.0-equivalent binding.
1649    #[test]
1650    fn query_binding_xslt_and_xpath_are_accepted_case_insensitively() {
1651        for binding in ["xslt", "XSLT", "xpath", "XPath"] {
1652            let schema = parse(&format!(
1653                r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron" queryBinding="{binding}">
1654                    <pattern>
1655                        <rule context="/root">
1656                            <assert test="foo">message</assert>
1657                        </rule>
1658                    </pattern>
1659                </schema>"#,
1660            ))
1661            .unwrap();
1662
1663            assert_eq!(schema.query_binding.as_deref(), Some(binding));
1664        }
1665    }
1666
1667    #[test]
1668    fn schema_without_query_binding_has_none() {
1669        let schema = parse(
1670            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1671                <pattern>
1672                    <rule context="/root">
1673                        <assert test="foo">message</assert>
1674                    </rule>
1675                </pattern>
1676            </schema>"#,
1677        )
1678        .unwrap();
1679
1680        assert_eq!(schema.query_binding, None);
1681    }
1682
1683    #[test]
1684    fn unsupported_query_binding_is_an_error() {
1685        let error = parse(
1686            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron" queryBinding="xslt2">
1687                <pattern>
1688                    <rule context="/root">
1689                        <assert test="foo">message</assert>
1690                    </rule>
1691                </pattern>
1692            </schema>"#,
1693        )
1694        .unwrap_err();
1695
1696        assert!(matches!(
1697            error,
1698            ParseError::UnsupportedQueryBinding { binding, .. } if binding == "xslt2"
1699        ));
1700    }
1701
1702    #[test]
1703    fn let_bindings_are_captured_at_schema_pattern_and_rule_level() {
1704        let schema = parse(
1705            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1706                <let name="a" value="1"/>
1707                <pattern>
1708                    <let name="b" value="2"/>
1709                    <rule context="/root">
1710                        <let name="c" value="3"/>
1711                        <assert test="foo">message</assert>
1712                    </rule>
1713                </pattern>
1714            </schema>"#,
1715        )
1716        .unwrap();
1717
1718        assert_eq!(
1719            schema.lets,
1720            vec![LetBinding {
1721                name: "a".to_owned(),
1722                value: LetValue::Expr("1".to_owned()),
1723            }]
1724        );
1725        assert_eq!(
1726            schema.patterns[0].lets,
1727            vec![LetBinding {
1728                name: "b".to_owned(),
1729                value: LetValue::Expr("2".to_owned()),
1730            }]
1731        );
1732        assert_eq!(
1733            schema.patterns[0].rules[0].lets,
1734            vec![LetBinding {
1735                name: "c".to_owned(),
1736                value: LetValue::Expr("3".to_owned()),
1737            }]
1738        );
1739    }
1740
1741    #[test]
1742    fn let_missing_value_is_an_error() {
1743        let error = parse(
1744            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1745                <let name="a"/>
1746                <pattern>
1747                    <rule context="/root">
1748                        <assert test="foo">message</assert>
1749                    </rule>
1750                </pattern>
1751            </schema>"#,
1752        )
1753        .unwrap_err();
1754
1755        assert_eq!(
1756            error,
1757            ParseError::MissingAttribute {
1758                element: "let",
1759                attribute: "value",
1760                position: Position { row: 2, col: 17 },
1761            }
1762        );
1763    }
1764
1765    /// Issue #3: `<let>` with literal-XML (`foreign-element+`) content
1766    /// instead of a `value` attribute parses as `LetValue::Literal`, its
1767    /// string-value being the concatenation of every descendant text node
1768    /// (nested elements are walked, only their text contributes).
1769    #[test]
1770    fn let_with_literal_xml_content_is_captured_as_its_string_value() {
1771        let schema = parse(
1772            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1773                <let name="x"><foo>hello <bar>world</bar></foo></let>
1774                <pattern>
1775                    <rule context="/root">
1776                        <assert test="foo">message</assert>
1777                    </rule>
1778                </pattern>
1779            </schema>"#,
1780        )
1781        .unwrap();
1782
1783        assert_eq!(
1784            schema.lets,
1785            vec![LetBinding {
1786                name: "x".to_owned(),
1787                value: LetValue::Literal("hello world".to_owned()),
1788            }]
1789        );
1790    }
1791
1792    /// A `<let>` with only whitespace text and no element child is still
1793    /// `MissingAttribute` (the grammar's `foreign-element+` alternative
1794    /// requires at least one actual element) — not silently accepted as an
1795    /// empty-string literal, which would mask a forgotten `value`
1796    /// attribute.
1797    #[test]
1798    fn let_with_only_whitespace_text_is_still_a_missing_value_error() {
1799        let error = parse(
1800            "<schema xmlns=\"http://purl.oclc.org/dsdl/schematron\">\
1801                <let name=\"a\">   </let>\
1802                <pattern>\
1803                    <rule context=\"/root\">\
1804                        <assert test=\"foo\">message</assert>\
1805                    </rule>\
1806                </pattern>\
1807            </schema>",
1808        )
1809        .unwrap_err();
1810
1811        assert!(matches!(
1812            error,
1813            ParseError::MissingAttribute {
1814                element: "let",
1815                attribute: "value",
1816                ..
1817            }
1818        ));
1819    }
1820
1821    #[test]
1822    fn default_phase_and_phases_are_captured() {
1823        let schema = parse(
1824            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron" defaultPhase="p1">
1825                <phase id="p1">
1826                    <let name="x" value="1"/>
1827                    <active pattern="a"/>
1828                    <active pattern="b"/>
1829                </phase>
1830                <phase id="p2">
1831                    <active pattern="b"/>
1832                </phase>
1833                <pattern id="a">
1834                    <rule context="/root">
1835                        <assert test="foo">message</assert>
1836                    </rule>
1837                </pattern>
1838                <pattern id="b">
1839                    <rule context="/root">
1840                        <assert test="foo">message</assert>
1841                    </rule>
1842                </pattern>
1843            </schema>"#,
1844        )
1845        .unwrap();
1846
1847        assert_eq!(schema.default_phase.as_deref(), Some("p1"));
1848        assert_eq!(schema.phases.len(), 2);
1849        assert_eq!(schema.phases[0].id, "p1");
1850        assert_eq!(
1851            schema.phases[0].lets,
1852            vec![LetBinding {
1853                name: "x".to_owned(),
1854                value: LetValue::Expr("1".to_owned()),
1855            }]
1856        );
1857        assert_eq!(
1858            schema.phases[0].active,
1859            vec!["a".to_owned(), "b".to_owned()]
1860        );
1861        assert_eq!(schema.phases[1].id, "p2");
1862        assert_eq!(schema.phases[1].active, vec!["b".to_owned()]);
1863    }
1864
1865    #[test]
1866    fn schema_without_phase_has_no_default_phase_and_no_phases() {
1867        let schema = parse(
1868            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1869                <pattern>
1870                    <rule context="/root">
1871                        <assert test="foo">message</assert>
1872                    </rule>
1873                </pattern>
1874            </schema>"#,
1875        )
1876        .unwrap();
1877
1878        assert_eq!(schema.default_phase, None);
1879        assert_eq!(schema.phases, vec![]);
1880    }
1881
1882    #[test]
1883    fn phase_missing_id_is_an_error() {
1884        let error = parse(
1885            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1886                <phase>
1887                    <active pattern="a"/>
1888                </phase>
1889                <pattern id="a">
1890                    <rule context="/root">
1891                        <assert test="foo">message</assert>
1892                    </rule>
1893                </pattern>
1894            </schema>"#,
1895        )
1896        .unwrap_err();
1897
1898        assert_eq!(
1899            error,
1900            ParseError::MissingAttribute {
1901                element: "phase",
1902                attribute: "id",
1903                position: Position { row: 2, col: 17 },
1904            }
1905        );
1906    }
1907
1908    #[test]
1909    fn active_missing_pattern_is_an_error() {
1910        let error = parse(
1911            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1912                <phase id="p1">
1913                    <active/>
1914                </phase>
1915                <pattern id="a">
1916                    <rule context="/root">
1917                        <assert test="foo">message</assert>
1918                    </rule>
1919                </pattern>
1920            </schema>"#,
1921        )
1922        .unwrap_err();
1923
1924        assert_eq!(
1925            error,
1926            ParseError::MissingAttribute {
1927                element: "active",
1928                attribute: "pattern",
1929                position: Position { row: 3, col: 21 },
1930            }
1931        );
1932    }
1933
1934    /// Referential integrity for `active/@pattern` is checked at parse
1935    /// time (superseded `plan/05-phase-selection.md`'s original "not
1936    /// checked" decision — see `DECISIONS.md`).
1937    #[test]
1938    fn active_unknown_pattern_is_an_error() {
1939        let error = parse(
1940            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1941                <phase id="p1">
1942                    <active pattern="missing"/>
1943                </phase>
1944                <pattern id="a">
1945                    <rule context="/root">
1946                        <assert test="foo">message</assert>
1947                    </rule>
1948                </pattern>
1949            </schema>"#,
1950        )
1951        .unwrap_err();
1952
1953        assert!(matches!(
1954            error,
1955            ParseError::UnknownActivePattern { pattern, .. } if pattern == "missing"
1956        ));
1957    }
1958
1959    /// An abstract pattern's id is never in `Schema.patterns` (it's never
1960    /// evaluated on its own, see `plan/07-abstract-patterns-and-extends.md`)
1961    /// — so `active` cannot activate it by id either, even though the id
1962    /// itself is declared somewhere in the schema.
1963    #[test]
1964    fn active_referencing_an_abstract_pattern_id_is_an_error() {
1965        let error = parse(
1966            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1967                <phase id="p1">
1968                    <active pattern="tpl"/>
1969                </phase>
1970                <pattern abstract="true" id="tpl">
1971                    <rule context="/root">
1972                        <assert test="foo">message</assert>
1973                    </rule>
1974                </pattern>
1975                <pattern id="a">
1976                    <rule context="/root">
1977                        <assert test="foo">message</assert>
1978                    </rule>
1979                </pattern>
1980            </schema>"#,
1981        )
1982        .unwrap_err();
1983
1984        assert!(matches!(
1985            error,
1986            ParseError::UnknownActivePattern { pattern, .. } if pattern == "tpl"
1987        ));
1988    }
1989
1990    #[test]
1991    fn duplicate_pattern_id_is_an_error() {
1992        let error = parse(
1993            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1994                <pattern id="a">
1995                    <rule context="/root">
1996                        <assert test="foo">message</assert>
1997                    </rule>
1998                </pattern>
1999                <pattern id="a">
2000                    <rule context="/root">
2001                        <assert test="foo">message</assert>
2002                    </rule>
2003                </pattern>
2004            </schema>"#,
2005        )
2006        .unwrap_err();
2007
2008        assert!(matches!(
2009            error,
2010            ParseError::DuplicateId { element: "pattern", id, .. } if id == "a"
2011        ));
2012    }
2013
2014    /// Issue #8: real XML `ID` semantics are a single, document-wide
2015    /// namespace, not one per element type — a `rule/@id` colliding with a
2016    /// `phase/@id` is just as much a duplicate as two `pattern/@id`s would
2017    /// be, even though nothing in this crate's model ever cross-references
2018    /// the two by IDREF(S) against each other.
2019    #[test]
2020    fn cross_element_type_id_collision_is_an_error() {
2021        let error = parse(
2022            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2023                <phase id="shared">
2024                    <active pattern="a"/>
2025                </phase>
2026                <pattern id="a">
2027                    <rule id="shared" context="/root">
2028                        <assert test="foo">message</assert>
2029                    </rule>
2030                </pattern>
2031            </schema>"#,
2032        )
2033        .unwrap_err();
2034
2035        assert!(matches!(
2036            error,
2037            ParseError::DuplicateId { element: "rule", id, .. } if id == "shared"
2038        ));
2039    }
2040
2041    /// The counter-case the old, narrower pattern-only check never caught:
2042    /// two *abstract* `pattern/@id`s were previously never checked for
2043    /// uniqueness at all (silently overwriting each other in an internal
2044    /// lookup table) — now a `DuplicateId` like any other.
2045    #[test]
2046    fn duplicate_abstract_pattern_id_is_an_error() {
2047        let error = parse(
2048            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2049                <pattern abstract="true" id="tpl">
2050                    <rule context="/root">
2051                        <assert test="foo">message</assert>
2052                    </rule>
2053                </pattern>
2054                <pattern abstract="true" id="tpl">
2055                    <rule context="/root">
2056                        <assert test="foo">message</assert>
2057                    </rule>
2058                </pattern>
2059                <pattern is-a="tpl" id="concrete">
2060                    <param name="x" value="1"/>
2061                </pattern>
2062            </schema>"#,
2063        )
2064        .unwrap_err();
2065
2066        assert!(matches!(
2067            error,
2068            ParseError::DuplicateId { element: "pattern", id, .. } if id == "tpl"
2069        ));
2070    }
2071
2072    #[test]
2073    fn unexpected_element_at_schema_level_is_an_error() {
2074        let error = parse(
2075            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2076                <include href="other.sch"/>
2077                <pattern>
2078                    <rule context="/root">
2079                        <assert test="foo">message</assert>
2080                    </rule>
2081                </pattern>
2082            </schema>"#,
2083        )
2084        .unwrap_err();
2085
2086        assert!(matches!(
2087            error,
2088            ParseError::UnexpectedElement { element, .. } if element == "include"
2089        ));
2090    }
2091
2092    #[test]
2093    fn unexpected_element_inside_rule_is_an_error() {
2094        let error = parse(
2095            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2096                <pattern>
2097                    <rule context="/root">
2098                        <asert test="foo">typo'd element name</asert>
2099                    </rule>
2100                </pattern>
2101            </schema>"#,
2102        )
2103        .unwrap_err();
2104
2105        assert!(matches!(
2106            error,
2107            ParseError::UnexpectedElement { element, .. } if element == "asert"
2108        ));
2109    }
2110
2111    /// `title`/`p` are real ISO Schematron elements (prose/documentation)
2112    /// — accepted, not `UnexpectedElement`, even though this crate never
2113    /// reads their content.
2114    #[test]
2115    fn title_and_p_are_accepted_but_ignored() {
2116        let schema = parse(
2117            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2118                <title>My Schema</title>
2119                <p>Some documentation.</p>
2120                <pattern>
2121                    <rule context="/root">
2122                        <assert test="foo">message</assert>
2123                    </rule>
2124                </pattern>
2125            </schema>"#,
2126        )
2127        .unwrap();
2128
2129        assert_eq!(schema.patterns.len(), 1);
2130    }
2131
2132    #[test]
2133    fn extends_inlines_checks_from_another_rule_in_the_same_pattern() {
2134        let schema = parse(
2135            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2136                <pattern>
2137                    <rule abstract="true" id="base">
2138                        <assert test="false()">BASE-CHECK</assert>
2139                    </rule>
2140                    <rule context="/root">
2141                        <extends rule="base"/>
2142                        <assert test="false()">OWN-CHECK</assert>
2143                    </rule>
2144                </pattern>
2145            </schema>"#,
2146        )
2147        .unwrap();
2148
2149        // The abstract rule itself is never a real, evaluable rule.
2150        assert_eq!(schema.patterns[0].rules.len(), 1);
2151        let checks = &schema.patterns[0].rules[0].checks;
2152        assert_eq!(checks.len(), 2);
2153        assert_eq!(
2154            checks[0].message,
2155            vec![MessagePart::Text("BASE-CHECK".to_owned())]
2156        );
2157        assert_eq!(
2158            checks[1].message,
2159            vec![MessagePart::Text("OWN-CHECK".to_owned())]
2160        );
2161    }
2162
2163    #[test]
2164    fn extends_is_transitive() {
2165        let schema = parse(
2166            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2167                <pattern>
2168                    <rule abstract="true" id="a">
2169                        <assert test="false()">A</assert>
2170                    </rule>
2171                    <rule abstract="true" id="b">
2172                        <extends rule="a"/>
2173                        <assert test="false()">B</assert>
2174                    </rule>
2175                    <rule context="/root">
2176                        <extends rule="b"/>
2177                    </rule>
2178                </pattern>
2179            </schema>"#,
2180        )
2181        .unwrap();
2182
2183        let checks = &schema.patterns[0].rules[0].checks;
2184        assert_eq!(
2185            checks.iter().map(|c| c.message.clone()).collect::<Vec<_>>(),
2186            vec![
2187                vec![MessagePart::Text("A".to_owned())],
2188                vec![MessagePart::Text("B".to_owned())],
2189            ]
2190        );
2191    }
2192
2193    /// Issue #7: a `<let>` declared in an abstract rule pulled in via
2194    /// `<extends>` is now carried into the extending (concrete) rule's own
2195    /// `lets`, exactly like its checks already were.
2196    #[test]
2197    fn extends_inlines_lets_from_another_rule_in_the_same_pattern() {
2198        let schema = parse(
2199            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2200                <pattern>
2201                    <rule abstract="true" id="base">
2202                        <let name="x" value="'from-base'"/>
2203                    </rule>
2204                    <rule context="/root">
2205                        <extends rule="base"/>
2206                        <let name="y" value="'own'"/>
2207                        <assert test="false()">CHECK</assert>
2208                    </rule>
2209                </pattern>
2210            </schema>"#,
2211        )
2212        .unwrap();
2213
2214        let lets = &schema.patterns[0].rules[0].lets;
2215        assert_eq!(
2216            lets,
2217            &vec![
2218                LetBinding {
2219                    name: "x".to_owned(),
2220                    value: LetValue::Expr("'from-base'".to_owned()),
2221                },
2222                LetBinding {
2223                    name: "y".to_owned(),
2224                    value: LetValue::Expr("'own'".to_owned()),
2225                },
2226            ]
2227        );
2228    }
2229
2230    /// The transitive counterpart of the above, matching `extends_is_transitive`
2231    /// for checks: `<let>`s from a chain of `<extends>` (rule -> b -> a) are
2232    /// all carried in, in the order each rule's `<extends>` is encountered.
2233    #[test]
2234    fn extends_inlines_lets_transitively() {
2235        let schema = parse(
2236            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2237                <pattern>
2238                    <rule abstract="true" id="a">
2239                        <let name="x" value="'A'"/>
2240                    </rule>
2241                    <rule abstract="true" id="b">
2242                        <extends rule="a"/>
2243                        <let name="y" value="'B'"/>
2244                    </rule>
2245                    <rule context="/root">
2246                        <extends rule="b"/>
2247                    </rule>
2248                </pattern>
2249            </schema>"#,
2250        )
2251        .unwrap();
2252
2253        let lets = &schema.patterns[0].rules[0].lets;
2254        assert_eq!(
2255            lets.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(),
2256            vec!["x", "y"]
2257        );
2258    }
2259
2260    /// A `<let>` inherited via `<extends>` that appears *before* the
2261    /// extending rule's own same-named `<let>` in document order is
2262    /// shadowed by it (own wins) — matching `extend_lets`'s "later in the
2263    /// vector wins" evaluation order in `engine.rs`.
2264    #[test]
2265    fn own_let_shadows_an_earlier_inherited_let_of_the_same_name() {
2266        let schema = parse(
2267            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2268                <pattern>
2269                    <rule abstract="true" id="base">
2270                        <let name="x" value="'from-base'"/>
2271                    </rule>
2272                    <rule context="/root">
2273                        <extends rule="base"/>
2274                        <let name="x" value="'own'"/>
2275                        <assert test="false()">CHECK</assert>
2276                    </rule>
2277                </pattern>
2278            </schema>"#,
2279        )
2280        .unwrap();
2281
2282        let lets = &schema.patterns[0].rules[0].lets;
2283        assert_eq!(
2284            lets,
2285            &vec![
2286                LetBinding {
2287                    name: "x".to_owned(),
2288                    value: LetValue::Expr("'from-base'".to_owned()),
2289                },
2290                LetBinding {
2291                    name: "x".to_owned(),
2292                    value: LetValue::Expr("'own'".to_owned()),
2293                },
2294            ]
2295        );
2296    }
2297
2298    #[test]
2299    fn cyclic_extends_is_an_error() {
2300        let error = parse(
2301            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2302                <pattern>
2303                    <rule abstract="true" id="a">
2304                        <extends rule="b"/>
2305                    </rule>
2306                    <rule abstract="true" id="b">
2307                        <extends rule="a"/>
2308                    </rule>
2309                    <rule context="/root">
2310                        <extends rule="a"/>
2311                    </rule>
2312                </pattern>
2313            </schema>"#,
2314        )
2315        .unwrap_err();
2316
2317        assert!(matches!(
2318            error,
2319            ParseError::CyclicRuleExtends { rule, .. } if rule == "a"
2320        ));
2321    }
2322
2323    #[test]
2324    fn extends_unknown_rule_is_an_error() {
2325        let error = parse(
2326            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2327                <pattern>
2328                    <rule context="/root">
2329                        <extends rule="missing"/>
2330                    </rule>
2331                </pattern>
2332            </schema>"#,
2333        )
2334        .unwrap_err();
2335
2336        assert!(matches!(
2337            error,
2338            ParseError::UnknownRuleReference { rule, .. } if rule == "missing"
2339        ));
2340    }
2341
2342    #[test]
2343    fn is_a_instantiates_abstract_pattern_with_param_substitution() {
2344        let schema = parse(
2345            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2346                <pattern abstract="true" id="tpl">
2347                    <rule context="$el">
2348                        <assert test="$el = 1">msg</assert>
2349                    </rule>
2350                </pattern>
2351                <pattern is-a="tpl" id="concrete">
2352                    <param name="el" value="item"/>
2353                </pattern>
2354            </schema>"#,
2355        )
2356        .unwrap();
2357
2358        // The abstract pattern itself never appears — only the instantiated one.
2359        assert_eq!(schema.patterns.len(), 1);
2360        assert_eq!(schema.patterns[0].id.as_deref(), Some("concrete"));
2361        assert_eq!(schema.patterns[0].rules[0].context, "item");
2362        assert_eq!(schema.patterns[0].rules[0].checks[0].test, "item = 1");
2363    }
2364
2365    #[test]
2366    fn two_is_a_uses_of_the_same_abstract_pattern_are_independent() {
2367        let schema = parse(
2368            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2369                <pattern abstract="true" id="tpl">
2370                    <rule context="$el">
2371                        <assert test="true()">m</assert>
2372                    </rule>
2373                </pattern>
2374                <pattern is-a="tpl" id="p1">
2375                    <param name="el" value="a"/>
2376                </pattern>
2377                <pattern is-a="tpl" id="p2">
2378                    <param name="el" value="b"/>
2379                </pattern>
2380            </schema>"#,
2381        )
2382        .unwrap();
2383
2384        assert_eq!(schema.patterns.len(), 2);
2385        assert_eq!(schema.patterns[0].id.as_deref(), Some("p1"));
2386        assert_eq!(schema.patterns[0].rules[0].context, "a");
2387        assert_eq!(schema.patterns[1].id.as_deref(), Some("p2"));
2388        assert_eq!(schema.patterns[1].rules[0].context, "b");
2389    }
2390
2391    #[test]
2392    fn abstract_pattern_never_appears_in_schema_patterns_even_if_unused() {
2393        let schema = parse(
2394            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2395                <pattern abstract="true" id="tpl">
2396                    <rule context="$el">
2397                        <assert test="true()">m</assert>
2398                    </rule>
2399                </pattern>
2400                <pattern id="concrete">
2401                    <rule context="/root">
2402                        <assert test="foo">message</assert>
2403                    </rule>
2404                </pattern>
2405            </schema>"#,
2406        )
2407        .unwrap();
2408
2409        assert_eq!(schema.patterns.len(), 1);
2410        assert_eq!(schema.patterns[0].id.as_deref(), Some("concrete"));
2411    }
2412
2413    /// Verified against the ISO reference implementation
2414    /// (`plan/07-abstract-patterns-and-extends.md`): `<let value="...">`
2415    /// is NOT among the substituted attributes, unlike `context`/`test`/
2416    /// `select`.
2417    #[test]
2418    fn let_value_is_not_substituted_in_an_instantiated_pattern() {
2419        let schema = parse(
2420            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2421                <pattern abstract="true" id="tpl">
2422                    <rule context="$el">
2423                        <let name="x" value="$el"/>
2424                        <assert test="true()">m</assert>
2425                    </rule>
2426                </pattern>
2427                <pattern is-a="tpl" id="concrete">
2428                    <param name="el" value="item"/>
2429                </pattern>
2430            </schema>"#,
2431        )
2432        .unwrap();
2433
2434        let rule = &schema.patterns[0].rules[0];
2435        assert_eq!(rule.context, "item");
2436        assert_eq!(rule.lets[0].value, LetValue::Expr("$el".to_owned()));
2437    }
2438
2439    #[test]
2440    fn is_a_unknown_abstract_pattern_is_an_error() {
2441        let error = parse(
2442            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2443                <pattern is-a="missing" id="concrete">
2444                    <param name="el" value="item"/>
2445                </pattern>
2446            </schema>"#,
2447        )
2448        .unwrap_err();
2449
2450        assert!(matches!(
2451            error,
2452            ParseError::UnknownAbstractPattern { pattern, .. } if pattern == "missing"
2453        ));
2454    }
2455
2456    // ---- issue #2: <include href="..."> resolution -----------------
2457
2458    use crate::resolver::{ResolveError, SchemaSource};
2459    use std::collections::BTreeMap;
2460
2461    /// A trivial in-memory resolver, test-only — mirrors the sister crate
2462    /// `relax-ng`'s own `examples/validate.rs::InMemoryResolver` pattern.
2463    struct MapResolver(BTreeMap<&'static str, &'static str>);
2464
2465    impl SchemaResolver for MapResolver {
2466        fn resolve(&self, href: &str, _base_uri: &str) -> Result<SchemaSource, ResolveError> {
2467            self.0
2468                .get(href)
2469                .map(|text| SchemaSource::new(*text, href))
2470                .ok_or_else(|| ResolveError::new(format!("no such resource: {href}")))
2471        }
2472    }
2473
2474    #[test]
2475    fn include_at_schema_level_splices_a_pattern_fragment() {
2476        let resolver = MapResolver(BTreeMap::from([(
2477            "pattern.sch",
2478            r#"<pattern xmlns="http://purl.oclc.org/dsdl/schematron" id="included">
2479                <rule context="/root">
2480                    <assert test="foo">from included pattern</assert>
2481                </rule>
2482            </pattern>"#,
2483        )]));
2484
2485        let schema = parse_with_resolver(
2486            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2487                <include href="pattern.sch"/>
2488            </schema>"#,
2489            "root.sch",
2490            &resolver,
2491        )
2492        .unwrap();
2493
2494        assert_eq!(schema.patterns.len(), 1);
2495        assert_eq!(schema.patterns[0].id.as_deref(), Some("included"));
2496        assert_eq!(schema.patterns[0].rules[0].context, "/root");
2497    }
2498
2499    /// The same mechanism works below the schema level too — an `<assert>`
2500    /// fragment included at rule level — proving `resolve_includes` is a
2501    /// position-agnostic text splice, not something special-cased to the
2502    /// schema/pattern level only.
2503    #[test]
2504    fn include_at_rule_level_splices_an_assert_fragment() {
2505        let resolver = MapResolver(BTreeMap::from([(
2506            "assert.sch",
2507            r#"<assert xmlns="http://purl.oclc.org/dsdl/schematron" test="foo">from included assert</assert>"#,
2508        )]));
2509
2510        let schema = parse_with_resolver(
2511            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2512                <pattern>
2513                    <rule context="/root">
2514                        <include href="assert.sch"/>
2515                    </rule>
2516                </pattern>
2517            </schema>"#,
2518            "root.sch",
2519            &resolver,
2520        )
2521        .unwrap();
2522
2523        assert_eq!(
2524            schema.patterns[0].rules[0].checks[0].message,
2525            vec![MessagePart::Text("from included assert".to_owned())]
2526        );
2527    }
2528
2529    /// A resolved fragment's own `<include>` is resolved too, against its
2530    /// own base URI — transitive resolution, not just one level deep.
2531    #[test]
2532    fn include_resolves_transitively() {
2533        let resolver = MapResolver(BTreeMap::from([
2534            (
2535                "outer.sch",
2536                r#"<pattern xmlns="http://purl.oclc.org/dsdl/schematron" id="outer">
2537                    <include href="b.sch"/>
2538                </pattern>"#,
2539            ),
2540            (
2541                "b.sch",
2542                r#"<rule xmlns="http://purl.oclc.org/dsdl/schematron" context="/root">
2543                    <assert test="foo">transitively included</assert>
2544                </rule>"#,
2545            ),
2546        ]));
2547
2548        let schema = parse_with_resolver(
2549            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2550                <include href="outer.sch"/>
2551            </schema>"#,
2552            "root.sch",
2553            &resolver,
2554        )
2555        .unwrap();
2556
2557        assert_eq!(schema.patterns[0].id.as_deref(), Some("outer"));
2558        assert_eq!(
2559            schema.patterns[0].rules[0].checks[0].message,
2560            vec![MessagePart::Text("transitively included".to_owned())]
2561        );
2562    }
2563
2564    #[test]
2565    fn cyclic_include_is_an_error() {
2566        // `a.sch` includes itself — the most direct cycle. (The cycle
2567        // guard's key is `"{base_uri}::{href}"`, same convention as the
2568        // sister crate `relax-ng`'s own include/externalRef guard — for an
2569        // indirect cycle through a *different* intermediate base URI at
2570        // each hop, that scheme still catches it, just not necessarily on
2571        // the very first repeat; a direct self-include like this one is
2572        // always caught immediately, which is what this test pins down.)
2573        let resolver = MapResolver(BTreeMap::from([(
2574            "a.sch",
2575            r#"<pattern xmlns="http://purl.oclc.org/dsdl/schematron" id="a">
2576                <include href="a.sch"/>
2577            </pattern>"#,
2578        )]));
2579
2580        let error = parse_with_resolver(
2581            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2582                <include href="a.sch"/>
2583            </schema>"#,
2584            "root.sch",
2585            &resolver,
2586        )
2587        .unwrap_err();
2588
2589        assert!(matches!(error, ParseError::CyclicInclude { href, .. } if href == "a.sch"));
2590    }
2591
2592    #[test]
2593    fn unresolvable_include_surfaces_the_resolver_error() {
2594        let resolver = MapResolver(BTreeMap::new());
2595
2596        let error = parse_with_resolver(
2597            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2598                <include href="missing.sch"/>
2599            </schema>"#,
2600            "root.sch",
2601            &resolver,
2602        )
2603        .unwrap_err();
2604
2605        assert!(matches!(
2606            error,
2607            ParseError::UnresolvableInclude { href, .. } if href == "missing.sch"
2608        ));
2609    }
2610
2611    #[test]
2612    fn including_a_whole_schema_is_an_error() {
2613        let resolver = MapResolver(BTreeMap::from([(
2614            "whole.sch",
2615            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2616                <pattern>
2617                    <rule context="/root">
2618                        <assert test="foo">m</assert>
2619                    </rule>
2620                </pattern>
2621            </schema>"#,
2622        )]));
2623
2624        let error = parse_with_resolver(
2625            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2626                <include href="whole.sch"/>
2627            </schema>"#,
2628            "root.sch",
2629            &resolver,
2630        )
2631        .unwrap_err();
2632
2633        assert!(matches!(
2634            error,
2635            ParseError::InvalidInclude { href, .. } if href == "whole.sch"
2636        ));
2637    }
2638
2639    #[test]
2640    fn including_a_non_schematron_root_is_an_error() {
2641        let resolver = MapResolver(BTreeMap::from([(
2642            "other.xml",
2643            r#"<foo xmlns="http://example.com/other"/>"#,
2644        )]));
2645
2646        let error = parse_with_resolver(
2647            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2648                <include href="other.xml"/>
2649            </schema>"#,
2650            "root.sch",
2651            &resolver,
2652        )
2653        .unwrap_err();
2654
2655        assert!(matches!(
2656            error,
2657            ParseError::InvalidInclude { href, .. } if href == "other.xml"
2658        ));
2659    }
2660
2661    /// The counter-case to the resolver-based tests above:
2662    /// `unexpected_element_at_schema_level_is_an_error` (elsewhere in this
2663    /// module) already proves plain `parse()` — no resolver — still
2664    /// rejects `<include>` clearly as `ParseError::UnexpectedElement`,
2665    /// rather than silently doing something partial.
2666    #[test]
2667    fn parse_with_resolver_missing_href_is_a_missing_attribute_error() {
2668        let resolver = MapResolver(BTreeMap::new());
2669
2670        let error = parse_with_resolver(
2671            r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
2672                <include/>
2673                <pattern>
2674                    <rule context="/root">
2675                        <assert test="foo">m</assert>
2676                    </rule>
2677                </pattern>
2678            </schema>"#,
2679            "root.sch",
2680            &resolver,
2681        )
2682        .unwrap_err();
2683
2684        assert_eq!(
2685            error,
2686            ParseError::MissingAttribute {
2687                element: "include",
2688                attribute: "href",
2689                position: Position { row: 2, col: 17 },
2690            }
2691        );
2692    }
2693}