Skip to main content

sup_xml_xslt/
schematron.rs

1//! ISO Schematron validator — native implementation.
2//!
3//! Schematron is a rule-based validation language defined by
4//! ISO/IEC 19757-3.  A schema is a collection of patterns; each
5//! pattern groups rules; each rule fires for nodes matching its
6//! `context` (an XSLT match pattern); inside a rule, `<assert>`
7//! and `<report>` carry XPath tests against the matched node.
8//!
9//! The canonical libxslt implementation runs the schema through a
10//! 3-step XSLT pipeline (`iso_dsdl_include` →
11//! `iso_abstract_expand` → `iso_svrl_for_xslt1`) and applies the
12//! resulting stylesheet to the instance, then parses SVRL output.
13//! We bypass the meta-XSLT pipeline by interpreting the schema
14//! directly: every Schematron construct corresponds to a Rust
15//! struct and the same XPath / pattern-matching machinery the
16//! XSLT engine uses.
17//!
18//! Coverage:
19//!
20//! | Construct          | Status      |
21//! |--------------------|-------------|
22//! | `schema`           | implemented |
23//! | `ns` (prefix decl) | implemented |
24//! | `pattern`          | implemented |
25//! | `rule` (`context`) | implemented |
26//! | `assert` (`test`)  | implemented |
27//! | `report` (`test`)  | implemented |
28//! | inline `<value-of select="…"/>` in assert/report text | implemented |
29//! | inline `<name path="…"/>` in assert/report text       | implemented |
30//! | `let` / variable bindings        | implemented (top-level + rule-level) |
31//! | `include` (schema/pattern/rule)  | implemented via [`Schematron::compile_str_with_loader`] |
32//! | `abstract` patterns + `is-a` / `param` | implemented (XPath-source substitution) |
33//! | `abstract` rules + `extends`     | implemented (rule-content splicing)    |
34//! | `phase` / `active` filtering     | implemented via [`Schematron::validate_with_phase`] |
35//! | `function` (Schematron QuickFix) | out of scope (Schematron 1.5+      |
36//! |                                              with sch:fix-only-extension) |
37//!
38//! Both Schematron namespace URIs are accepted: the ISO version
39//! (`http://purl.oclc.org/dsdl/schematron`) and the older 1.5
40//! flavour (`http://www.ascc.net/xml/schematron`).
41
42use std::collections::HashMap;
43
44use sup_xml_core::xpath::eval::{
45    eval_expr, EvalCtx, StaticContext, Value, XPathBindings,
46};
47use sup_xml_core::xpath::{parse_xpath, DocIndex, DocIndexLike, Expr, NodeId, XPathNodeKind};
48use sup_xml_tree::dom::{Document, Node, NodeKind};
49
50use crate::error::XsltError;
51
52type Result<T> = std::result::Result<T, XsltError>;
53
54pub const SCH_NS_ISO: &str = "http://purl.oclc.org/dsdl/schematron";
55pub const SCH_NS_1_5: &str = "http://www.ascc.net/xml/schematron";
56
57fn is_schematron_element(node: &Node) -> bool {
58    if !node.is_element() { return false; }
59    let uri = node.namespace.get().map(|ns| ns.href()).unwrap_or("");
60    uri == SCH_NS_ISO || uri == SCH_NS_1_5
61}
62
63// ── compiled schema AST ───────────────────────────────────────────
64
65/// A compiled Schematron schema, ready to validate instances.
66#[derive(Debug)]
67pub struct Schematron {
68    /// Schema-level `<ns prefix="…" uri="…"/>` declarations.
69    namespaces: HashMap<String, String>,
70    /// Schema-level `<let name="…" value="…"/>` bindings.
71    lets:       Vec<Let>,
72    patterns:   Vec<Pattern>,
73    /// `<sch:phase>` declarations, keyed by phase id.  Each phase
74    /// names the subset of patterns active during a `phase`-scoped
75    /// validation run.  Empty when the schema declares no phases
76    /// — every validation runs all patterns in that case.
77    phases:     HashMap<String, Vec<String>>,
78    /// Value of `<schema defaultPhase="…">`, if present.  Used by
79    /// [`Schematron::validate_with_phase`] when the caller passes
80    /// `"#DEFAULT"`.
81    default_phase: Option<String>,
82}
83
84#[derive(Debug)]
85struct Pattern {
86    id:    Option<String>,
87    /// `<pattern name="…">` — captured for forward-compat with
88    /// older Schematron 1.5 schemas that used `name` instead of
89    /// `id`.  Not currently surfaced in `Finding`; available for
90    /// callers via introspection.
91    #[allow(dead_code)]
92    name:  Option<String>,
93    rules: Vec<Rule>,
94}
95
96#[derive(Debug)]
97struct Rule {
98    context: Expr,
99    lets:    Vec<Let>,
100    asserts: Vec<Assertion>,
101}
102
103#[derive(Debug)]
104struct Let {
105    name:  String,
106    value: Expr,
107}
108
109#[derive(Debug)]
110struct Assertion {
111    kind:    AssertKind,
112    test:    Expr,
113    /// Mixed-content message — literal text plus inline
114    /// `<value-of>`/`<name>` substitutions that consult the
115    /// matched node.
116    message: Vec<MessagePart>,
117    id:      Option<String>,
118    role:    Option<String>,
119}
120
121#[derive(Debug, Clone, Copy)]
122enum AssertKind { Assert, Report }
123
124#[derive(Debug)]
125enum MessagePart {
126    Text(String),
127    /// `<value-of select="…"/>` — evaluate XPath and emit string.
128    ValueOf(Expr),
129    /// `<name path="…"/>` — XPath result's name (defaults to `.`).
130    Name(Expr),
131}
132
133// ── compile ───────────────────────────────────────────────────────
134
135impl Schematron {
136    /// Compile a parsed Schematron schema document.  The document
137    /// MUST have been parsed in namespace-aware mode — Schematron
138    /// is namespace-driven (rule contexts and assertion tests use
139    /// prefixes declared by `<sch:ns>` elements).
140    ///
141    /// Returns `XsltError::InvalidStylesheet` for structural
142    /// problems (no `<schema>` root, missing `test=` on assert,
143    /// etc.) — reusing `XsltError` keeps the public surface small.
144    pub fn compile(schema_doc: &Document) -> Result<Self> {
145        Self::compile_with_loader(schema_doc, &crate::loader::NullLoader, None)
146    }
147
148    /// Compile a Schematron schema, resolving any `<sch:include>`
149    /// references via `loader`.  `base` is the URI of the containing
150    /// schema (used by [`crate::loader::Loader::load`] for relative
151    /// href resolution); pass `None` when relative resolution isn't
152    /// needed.
153    ///
154    /// `<sch:include href="other.sch"/>` is supported at schema-,
155    /// pattern-, and rule-level: the referenced document's content
156    /// is spliced in as if it had been inlined at the include site.
157    /// Fragment IDs (`href="other.sch#frag"`) are honoured — when
158    /// present, the element in the loaded doc whose `id="frag"`
159    /// supplies the spliced content; without a fragment, the loaded
160    /// doc's root supplies it.
161    pub fn compile_with_loader(
162        schema_doc: &Document,
163        loader:     &dyn crate::loader::Loader,
164        base:       Option<&str>,
165    ) -> Result<Self> {
166        let root = schema_doc.root();
167        if !is_schematron_element(root) || root.local_name() != "schema" {
168            return Err(XsltError::InvalidStylesheet(
169                "Schematron root element must be sch:schema".into(),
170            ));
171        }
172
173        let mut s = Schematron {
174            namespaces:    HashMap::new(),
175            lets:          Vec::new(),
176            patterns:      Vec::new(),
177            phases:        HashMap::new(),
178            default_phase: attr(root, "defaultPhase").map(|s| s.to_string()),
179        };
180        // First pass: index abstract patterns (referenced by
181        // `<pattern is-a="…">`) and abstract rules (referenced by
182        // `<sch:extends rule="…"/>`) by their `id=` attribute.  The
183        // index borrows from `schema_doc`, which the caller owns
184        // for the duration of this call.
185        let mut abs_patterns: HashMap<String, &Node> = HashMap::new();
186        let mut abs_rules:    HashMap<String, &Node> = HashMap::new();
187        collect_abstract_patterns(root, &mut abs_patterns);
188        collect_abstract_rules(root, &mut abs_rules);
189        let abstracts = Abstracts { patterns: abs_patterns, rules: abs_rules };
190        process_schema_children(root, loader, base, &abstracts, &mut s)?;
191        // libxml2's `xmlSchematronParse` rejects a schema that defines no
192        // rules at all — an empty `<schema/>` or one whose patterns carry
193        // no `<rule>` — so a consumer (lxml) raises SchematronParseError
194        // rather than silently accepting a no-op schema.  Abstract patterns
195        // and rules (templates referenced via `is-a` / `extends`) count:
196        // an abstract-only schema is well-formed even though its concrete
197        // pattern/rule lists are empty.
198        let has_concrete_rule = s.patterns.iter().any(|p| !p.rules.is_empty());
199        let has_abstract = !abstracts.patterns.is_empty() || !abstracts.rules.is_empty();
200        if !has_concrete_rule && !has_abstract {
201            return Err(XsltError::InvalidStylesheet(
202                "Schematron schema defines no rules".into(),
203            ));
204        }
205        Ok(s)
206    }
207
208    /// Compile a Schematron schema directly from its text.
209    /// Convenience wrapper.
210    pub fn compile_str(text: &str) -> Result<Self> {
211        let opts = sup_xml_core::ParseOptions {
212            namespace_aware: true, ..Default::default()
213        };
214        let doc = sup_xml_core::parse_str(text, &opts).map_err(XsltError::from)?;
215        Self::compile(&doc)
216    }
217
218    /// Compile a Schematron schema from its text, resolving any
219    /// `<sch:include>` references via `loader`.  See
220    /// [`Schematron::compile_with_loader`] for include semantics.
221    pub fn compile_str_with_loader(
222        text:   &str,
223        loader: &dyn crate::loader::Loader,
224        base:   Option<&str>,
225    ) -> Result<Self> {
226        let opts = sup_xml_core::ParseOptions {
227            namespace_aware: true, ..Default::default()
228        };
229        let doc = sup_xml_core::parse_str(text, &opts).map_err(XsltError::from)?;
230        Self::compile_with_loader(&doc, loader, base)
231    }
232
233    /// Compile via the official ISO Schematron XSLT pipeline:
234    /// run the schema through `iso_dsdl_include.xsl` →
235    /// `iso_abstract_expand.xsl` → `iso_svrl_for_xslt1.xsl`, then
236    /// use the generated SVRL-emitting validator stylesheet as
237    /// the actual validator.  Output: a [`Schematron`] whose
238    /// `validate` method runs the validator stylesheet against
239    /// the instance and parses the SVRL findings.
240    ///
241    /// `loader` resolves `xsl:import` references inside the
242    /// pipeline stylesheets (`iso_svrl_for_xslt1.xsl` imports
243    /// `iso_schematron_skeleton_for_xslt1.xsl`).  `base_dir` is
244    /// where the pipeline xsl files live; use the host's local
245    /// copy of lxml's `isoschematron/resources/xsl/iso-schematron-xslt1/`.
246    ///
247    /// Compared to [`Schematron::compile_str`] (our native
248    /// implementation), the ISO pipeline understands `<abstract>`
249    /// patterns, `<extends>`, and `<include>` references — at the
250    /// cost of being slower and depending on those vendored XSLT
251    /// files on disk.  Use compile_str for simple schemas; use
252    /// `compile_iso` when you need full Schematron 1.6 features.
253    pub fn compile_iso(
254        schema_text: &str,
255        base_dir:    &str,
256        loader:      &dyn crate::loader::Loader,
257    ) -> Result<IsoSchematronValidator> {
258        let svrl_path = format!("{base_dir}/iso_svrl_for_xslt1.xsl");
259        let svrl_text = loader.load(&svrl_path, None)?;
260        let svrl = crate::Stylesheet::compile_str_with_loader(
261            &svrl_text, loader, Some(&svrl_path))?;
262        let opts = sup_xml_core::ParseOptions {
263            namespace_aware: true, ..Default::default()
264        };
265        let schema_doc = sup_xml_core::parse_str(schema_text, &opts)
266            .map_err(XsltError::from)?;
267        let validator_xsl = svrl.apply(&schema_doc)?.to_string()?;
268        let validator = crate::Stylesheet::compile_str_with_loader(
269            &validator_xsl, loader, Some(&svrl_path))?;
270        Ok(IsoSchematronValidator { validator })
271    }
272}
273
274/// Validator produced by the ISO Schematron XSLT pipeline.
275/// Applying it to an instance produces SVRL (Schematron
276/// Validation Report Language) output — the structured
277/// machine-readable validation report.
278pub struct IsoSchematronValidator {
279    validator: crate::Stylesheet,
280}
281
282impl IsoSchematronValidator {
283    /// Validate `instance_doc` and return the raw SVRL output as
284    /// a string.  Parsing SVRL into structured findings is the
285    /// caller's job (or a future helper in this module).
286    pub fn validate_to_svrl(
287        &self,
288        instance_doc: &sup_xml_tree::dom::Document,
289    ) -> Result<String> {
290        let result = self.validator.apply(instance_doc)?;
291        Ok(result.to_string()?)
292    }
293
294    /// Validate an instance from source text.  Convenience wrapper.
295    pub fn validate_str(&self, instance_text: &str) -> Result<String> {
296        let opts = sup_xml_core::ParseOptions {
297            namespace_aware: true, ..Default::default()
298        };
299        let doc = sup_xml_core::parse_str(instance_text, &opts)
300            .map_err(XsltError::from)?;
301        self.validate_to_svrl(&doc)
302    }
303}
304
305/// Look-up table for `<pattern abstract="true">` and
306/// `<rule abstract="true">` by id, populated in a pre-pass before
307/// concrete patterns are compiled.  Lives as long as the source
308/// schema Document.
309struct Abstracts<'a> {
310    patterns: HashMap<String, &'a Node<'a>>,
311    rules:    HashMap<String, &'a Node<'a>>,
312}
313
314/// Param substitutions active during compilation of an abstract
315/// pattern instantiation.  `None` outside abstract instantiation.
316/// Maps `name` → raw XPath fragment to splice in at each `$name`
317/// occurrence inside the abstract pattern's XPath strings.
318type Params<'a> = Option<&'a HashMap<String, String>>;
319
320/// Pre-pass that walks `parent` (typically `<schema>`) and any
321/// `<pattern abstract="true">` / `<rule abstract="true">` it
322/// contains, indexing them by `id`.  Schemas referenced via
323/// `<include>` aren't visited here — top-level abstracts only.
324fn collect_abstract_patterns<'a>(parent: &'a Node<'a>, out: &mut HashMap<String, &'a Node<'a>>) {
325    walk_abstracts(parent, out, "pattern");
326}
327
328fn collect_abstract_rules<'a>(parent: &'a Node<'a>, out: &mut HashMap<String, &'a Node<'a>>) {
329    walk_abstracts(parent, out, "rule");
330}
331
332fn walk_abstracts<'a>(
333    parent: &'a Node<'a>,
334    out:    &mut HashMap<String, &'a Node<'a>>,
335    want:   &str,
336) {
337    for child in parent.children() {
338        if !is_schematron_element(child) { continue; }
339        let local = child.local_name();
340        if local == want && attr(child, "abstract") == Some("true") {
341            if let Some(id) = attr(child, "id") {
342                out.insert(id.to_string(), child);
343            }
344        }
345        // Recurse into <pattern> so abstract <rule>s inside concrete
346        // patterns get picked up too.  Don't descend into abstract
347        // patterns when looking for rules — those rules belong to
348        // the abstract, not the schema's flat rule pool.
349        if local == "pattern" && attr(child, "abstract") != Some("true") {
350            walk_abstracts(child, out, want);
351        }
352    }
353}
354
355/// Replace `$NCName` references in an XPath source string with the
356/// matching param's value.  Used for abstract-pattern instantiation
357/// per ISO Schematron §6.4 — the `<param value="…">` text is
358/// itself raw XPath, so this is plain textual substitution.
359fn substitute_params(s: &str, params: Params) -> String {
360    let Some(params) = params else { return s.to_string(); };
361    if params.is_empty() { return s.to_string(); }
362    let mut out = String::with_capacity(s.len());
363    let mut iter = s.char_indices().peekable();
364    while let Some((_, c)) = iter.next() {
365        if c == '$' {
366            if let Some(&(name_start, first)) = iter.peek() {
367                if is_ncname_start(first) {
368                    iter.next();
369                    let mut name_end = name_start + first.len_utf8();
370                    while let Some(&(j, c)) = iter.peek() {
371                        if is_ncname_cont(c) {
372                            iter.next();
373                            name_end = j + c.len_utf8();
374                        } else {
375                            break;
376                        }
377                    }
378                    let name = &s[name_start..name_end];
379                    if let Some(v) = params.get(name) {
380                        out.push_str(v);
381                    } else {
382                        out.push('$');
383                        out.push_str(name);
384                    }
385                    continue;
386                }
387            }
388            out.push('$');
389        } else {
390            out.push(c);
391        }
392    }
393    out
394}
395
396fn is_ncname_start(c: char) -> bool {
397    // ASCII-only NCNameStartChar — Schematron `<param>` names in
398    // practice are ASCII; full XML 1.0 §4 production is overkill.
399    matches!(c, 'a'..='z' | 'A'..='Z' | '_')
400}
401
402fn is_ncname_cont(c: char) -> bool {
403    is_ncname_start(c) || c.is_ascii_digit() || c == '-' || c == '.'
404}
405
406/// Parse `s` as XPath after applying abstract-pattern param
407/// substitution (if any).
408fn parse_xpath_with_params(s: &str, params: Params) -> Result<Expr> {
409    parse_xpath(&substitute_params(s, params)).map_err(XsltError::from)
410}
411
412/// Walk a `<schema>` element's children, collecting `<ns>`,
413/// `<let>`, `<pattern>` declarations into `out`.  Recurses through
414/// any `<include>` elements found at this level.
415fn process_schema_children<'a, 'b>(
416    parent:    &'b Node<'b>,
417    loader:    &dyn crate::loader::Loader,
418    base:      Option<&str>,
419    abstracts: &Abstracts<'a>,
420    out:       &mut Schematron,
421) -> Result<()> {
422    for child in parent.children() {
423        if !is_schematron_element(child) { continue; }
424        match child.local_name() {
425            "ns" => {
426                if let (Some(p), Some(u)) = (
427                    attr(child, "prefix"), attr(child, "uri"),
428                ) {
429                    out.namespaces.insert(p.to_string(), u.to_string());
430                }
431            }
432            "let"     => out.lets.push(compile_let(child, None)?),
433            "pattern" => {
434                // Skip `<pattern abstract="true">` — already
435                // indexed via [`collect_abstract_patterns`]; it's
436                // a template, not a runnable pattern.
437                if attr(child, "abstract") == Some("true") { continue; }
438                if let Some(abs_id) = attr(child, "is-a") {
439                    out.patterns.push(instantiate_abstract_pattern(
440                        child, abs_id, loader, base, abstracts,
441                    )?);
442                } else {
443                    out.patterns.push(compile_pattern(child, loader, base, abstracts, None)?);
444                }
445            }
446            "phase" => {
447                if let Some(id) = attr(child, "id") {
448                    let mut active = Vec::new();
449                    for c in child.children() {
450                        if is_schematron_element(c)
451                            && c.local_name() == "active"
452                        {
453                            if let Some(p) = attr(c, "pattern") {
454                                active.push(p.to_string());
455                            }
456                        }
457                    }
458                    out.phases.insert(id.to_string(), active);
459                }
460            }
461            "include" => {
462                let (doc, target_lookup, sub_base) = load_include(child, loader, base)?;
463                let target = locate_include_target(doc.root(), target_lookup.as_deref())?;
464                // The included element can be either another `<schema>`
465                // (top-level include) or a `<pattern>`/`<let>`/`<ns>`
466                // directly (fragment include).  Handle both shapes.
467                if target.local_name() == "schema" && is_schematron_element(target) {
468                    process_schema_children(target, loader, sub_base.as_deref(), abstracts, out)?;
469                } else {
470                    process_schema_one(target, loader, sub_base.as_deref(), abstracts, out)?;
471                }
472            }
473            // <title>, <p>, <diagnostics>, <fix> — documentary.
474            _ => {}
475        }
476    }
477    Ok(())
478}
479
480/// Treat a single included element as a top-level schema child.
481/// Mirrors the `match` inside [`process_schema_children`] but for
482/// the case where the fragment IS the declaration (not its parent).
483fn process_schema_one<'a, 'b>(
484    node:      &'b Node<'b>,
485    loader:    &dyn crate::loader::Loader,
486    base:      Option<&str>,
487    abstracts: &Abstracts<'a>,
488    out:       &mut Schematron,
489) -> Result<()> {
490    if !is_schematron_element(node) { return Ok(()); }
491    match node.local_name() {
492        "ns" => {
493            if let (Some(p), Some(u)) = (attr(node, "prefix"), attr(node, "uri")) {
494                out.namespaces.insert(p.to_string(), u.to_string());
495            }
496        }
497        "let"     => out.lets.push(compile_let(node, None)?),
498        "pattern" => {
499            if attr(node, "abstract") == Some("true") { return Ok(()); }
500            if let Some(abs_id) = attr(node, "is-a") {
501                out.patterns.push(instantiate_abstract_pattern(
502                    node, abs_id, loader, base, abstracts,
503                )?);
504            } else {
505                out.patterns.push(compile_pattern(node, loader, base, abstracts, None)?);
506            }
507        }
508        _ => {} // Other fragment kinds aren't valid as schema-level content.
509    }
510    Ok(())
511}
512
513/// Instantiate an abstract pattern via `<pattern is-a="X">`.  Looks
514/// up the abstract pattern Node by id, gathers `<param>` children
515/// into a substitution map, and compiles each rule with those
516/// substitutions applied to its XPath strings.
517fn instantiate_abstract_pattern<'a, 'b>(
518    instance:  &'b Node<'b>,
519    abs_id:    &str,
520    loader:    &dyn crate::loader::Loader,
521    base:      Option<&str>,
522    abstracts: &Abstracts<'a>,
523) -> Result<Pattern> {
524    let abstract_node = abstracts.patterns.get(abs_id).ok_or_else(|| {
525        XsltError::UnresolvedReference(format!(
526            "<pattern is-a='{abs_id}'> references an abstract pattern that wasn't declared"
527        ))
528    })?;
529    // Collect <param name=… value=…> children from the instance.
530    let mut params: HashMap<String, String> = HashMap::new();
531    for c in instance.children() {
532        if !is_schematron_element(c) || c.local_name() != "param" { continue; }
533        let n = attr(c, "name").ok_or_else(|| XsltError::InvalidStylesheet(
534            "sch:param requires a name= attribute".into(),
535        ))?;
536        let v = attr(c, "value").ok_or_else(|| XsltError::InvalidStylesheet(
537            "sch:param requires a value= attribute".into(),
538        ))?;
539        params.insert(n.to_string(), v.to_string());
540    }
541    // Compile the abstract pattern's rules with the substitution map.
542    let id   = attr(instance, "id").map(|s| s.to_string());
543    let name = attr(instance, "name").map(|s| s.to_string());
544    let mut rules = Vec::new();
545    process_pattern_children(abstract_node, loader, base, abstracts, Some(&params), &mut rules)?;
546    Ok(Pattern { id, name, rules })
547}
548
549fn compile_pattern<'a, 'b>(
550    node:      &'b Node<'b>,
551    loader:    &dyn crate::loader::Loader,
552    base:      Option<&str>,
553    abstracts: &Abstracts<'a>,
554    params:    Params,
555) -> Result<Pattern> {
556    let id   = attr(node, "id").map(|s| s.to_string());
557    let name = attr(node, "name").map(|s| s.to_string());
558    let mut rules = Vec::new();
559    process_pattern_children(node, loader, base, abstracts, params, &mut rules)?;
560    Ok(Pattern { id, name, rules })
561}
562
563fn process_pattern_children<'a, 'b>(
564    parent:    &'b Node<'b>,
565    loader:    &dyn crate::loader::Loader,
566    base:      Option<&str>,
567    abstracts: &Abstracts<'a>,
568    params:    Params,
569    rules:     &mut Vec<Rule>,
570) -> Result<()> {
571    for child in parent.children() {
572        if !is_schematron_element(child) { continue; }
573        match child.local_name() {
574            "rule" => {
575                // Skip abstract rules — they're templates that
576                // contribute via <sch:extends>, not directly.
577                if attr(child, "abstract") == Some("true") { continue; }
578                rules.push(compile_rule(child, loader, base, abstracts, params)?);
579            }
580            "include" => {
581                let (doc, target_lookup, sub_base) = load_include(child, loader, base)?;
582                let target = locate_include_target(doc.root(), target_lookup.as_deref())?;
583                // Two shapes: included is a <pattern> (use its rules)
584                // or a <rule> directly (single-fragment include).
585                match (is_schematron_element(target), target.local_name()) {
586                    (true, "pattern") => process_pattern_children(target, loader, sub_base.as_deref(), abstracts, params, rules)?,
587                    (true, "rule")    => rules.push(compile_rule(target, loader, sub_base.as_deref(), abstracts, params)?),
588                    _ => return Err(XsltError::InvalidStylesheet(format!(
589                        "sch:include inside <pattern> must point to a <pattern> or <rule>; got <{}>",
590                        target.name(),
591                    ))),
592                }
593            }
594            _ => {}
595        }
596    }
597    Ok(())
598}
599
600fn compile_rule<'a, 'b>(
601    node:      &'b Node<'b>,
602    loader:    &dyn crate::loader::Loader,
603    base:      Option<&str>,
604    abstracts: &Abstracts<'a>,
605    params:    Params,
606) -> Result<Rule> {
607    let ctx = attr(node, "context").ok_or_else(|| XsltError::InvalidStylesheet(
608        "sch:rule requires a context= attribute".into(),
609    ))?;
610    let context = parse_xpath_with_params(ctx, params)?;
611    let mut lets    = Vec::new();
612    let mut asserts = Vec::new();
613    process_rule_children(node, loader, base, abstracts, params, &mut lets, &mut asserts)?;
614    Ok(Rule { context, lets, asserts })
615}
616
617fn process_rule_children<'a, 'b>(
618    parent:    &'b Node<'b>,
619    loader:    &dyn crate::loader::Loader,
620    base:      Option<&str>,
621    abstracts: &Abstracts<'a>,
622    params:    Params,
623    lets:      &mut Vec<Let>,
624    asserts:   &mut Vec<Assertion>,
625) -> Result<()> {
626    for child in parent.children() {
627        if !is_schematron_element(child) { continue; }
628        match child.local_name() {
629            "let"    => lets.push(compile_let(child, params)?),
630            "assert" => asserts.push(compile_assertion(child, AssertKind::Assert, params)?),
631            "report" => asserts.push(compile_assertion(child, AssertKind::Report, params)?),
632            "extends" => {
633                // ISO §6.3: `<sch:extends rule="abstract-rule-id"/>`
634                // splices the abstract rule's lets/asserts/reports
635                // in here.  The abstract rule was indexed in the
636                // pre-pass via [`collect_abstract_rules`].
637                let rule_id = attr(child, "rule").ok_or_else(|| XsltError::InvalidStylesheet(
638                    "sch:extends requires a rule= attribute".into(),
639                ))?;
640                let abstract_rule = abstracts.rules.get(rule_id).ok_or_else(||
641                    XsltError::UnresolvedReference(format!(
642                        "<extends rule='{rule_id}'> references an abstract rule that wasn't declared"
643                    ))
644                )?;
645                process_rule_children(abstract_rule, loader, base, abstracts, params, lets, asserts)?;
646            }
647            "include" => {
648                let (doc, target_lookup, sub_base) = load_include(child, loader, base)?;
649                let target = locate_include_target(doc.root(), target_lookup.as_deref())?;
650                // Two shapes: included is a <rule> (splice its
651                // contents) or a single <assert>/<report>/<let>
652                // (treat as the lone declaration at this site).
653                match (is_schematron_element(target), target.local_name()) {
654                    (true, "rule") => process_rule_children(target, loader, sub_base.as_deref(), abstracts, params, lets, asserts)?,
655                    (true, "assert") => asserts.push(compile_assertion(target, AssertKind::Assert, params)?),
656                    (true, "report") => asserts.push(compile_assertion(target, AssertKind::Report, params)?),
657                    (true, "let")    => lets.push(compile_let(target, params)?),
658                    _ => return Err(XsltError::InvalidStylesheet(format!(
659                        "sch:include inside <rule> must point to <rule>/<assert>/<report>/<let>; got <{}>",
660                        target.name(),
661                    ))),
662                }
663            }
664            _ => {}
665        }
666    }
667    Ok(())
668}
669
670/// Resolve a `<sch:include href=…>` element: load the referenced
671/// document via `loader`, parse it, and return `(doc, fragment_id,
672/// resolved_base)`.  Caller uses [`locate_include_target`] to find
673/// the matching element inside `doc`.
674fn load_include(
675    node:   &Node,
676    loader: &dyn crate::loader::Loader,
677    base:   Option<&str>,
678) -> Result<(Document, Option<String>, Option<String>)> {
679    let href = attr(node, "href").ok_or_else(|| XsltError::InvalidStylesheet(
680        "sch:include requires an href= attribute".into(),
681    ))?;
682    // Split off `#fragment` if present — Schematron uses the host
683    // doc + element id to locate the specific declaration.
684    let (path, fragment) = match href.find('#') {
685        Some(i) => (&href[..i], Some(href[i+1..].to_string())),
686        None    => (href, None),
687    };
688    let text = loader.load(path, base)?;
689    let opts = sup_xml_core::ParseOptions {
690        namespace_aware: true, ..Default::default()
691    };
692    let doc = sup_xml_core::parse_str(&text, &opts).map_err(XsltError::from)?;
693    let resolved_base = loader.resolve(path, base).ok();
694    Ok((doc, fragment, resolved_base))
695}
696
697/// Given the root of an included document and an optional fragment
698/// id, find the element to splice in.  Without a fragment, returns
699/// the root itself; with a fragment, searches the tree for an
700/// element whose `id=` or `xml:id=` matches.
701fn locate_include_target<'a>(
702    root:     &'a Node<'a>,
703    fragment: Option<&str>,
704) -> Result<&'a Node<'a>> {
705    let Some(id) = fragment else { return Ok(root); };
706    find_by_id(root, id).ok_or_else(|| XsltError::InvalidStylesheet(format!(
707        "sch:include fragment '#{id}' not found in loaded document"
708    )))
709}
710
711fn find_by_id<'a>(node: &'a Node<'a>, id: &str) -> Option<&'a Node<'a>> {
712    if node.is_element() {
713        if attr(node, "id") == Some(id) {
714            return Some(node);
715        }
716        // `xml:id` per https://www.w3.org/TR/xml-id/ — also honoured.
717        for a in node.attributes() {
718            if a.local_name() == "id"
719               && a.namespace.get().and_then(|n| n.prefix()) == Some("xml") && a.value() == id {
720                return Some(node);
721            }
722        }
723        for c in node.children() {
724            if let Some(hit) = find_by_id(c, id) { return Some(hit); }
725        }
726    }
727    None
728}
729
730fn compile_let(node: &Node, params: Params) -> Result<Let> {
731    let name  = attr(node, "name").ok_or_else(|| XsltError::InvalidStylesheet(
732        "sch:let requires a name= attribute".into(),
733    ))?.to_string();
734    let value = attr(node, "value").ok_or_else(|| XsltError::InvalidStylesheet(
735        "sch:let requires a value= attribute".into(),
736    ))?;
737    Ok(Let { name, value: parse_xpath_with_params(value, params)? })
738}
739
740fn compile_assertion(node: &Node, kind: AssertKind, params: Params) -> Result<Assertion> {
741    let test = attr(node, "test").ok_or_else(|| XsltError::InvalidStylesheet(
742        format!("sch:{} requires a test= attribute",
743            if matches!(kind, AssertKind::Assert) { "assert" } else { "report" }),
744    ))?;
745    let test = parse_xpath_with_params(test, params)?;
746    Ok(Assertion {
747        kind,
748        test,
749        message: compile_message(node, params)?,
750        id:   attr(node, "id").map(str::to_string),
751        role: attr(node, "role").map(str::to_string),
752    })
753}
754
755fn compile_message(node: &Node, params: Params) -> Result<Vec<MessagePart>> {
756    let mut parts = Vec::new();
757    for child in node.children() {
758        match child.kind {
759            NodeKind::Text | NodeKind::CData => {
760                parts.push(MessagePart::Text(child.content().to_string()));
761            }
762            NodeKind::Element if is_schematron_element(child) => {
763                match child.local_name() {
764                    "value-of" => {
765                        let sel = attr(child, "select").ok_or_else(||
766                            XsltError::InvalidStylesheet(
767                                "sch:value-of requires select=".into()))?;
768                        parts.push(MessagePart::ValueOf(
769                            parse_xpath_with_params(sel, params)?));
770                    }
771                    "name" => {
772                        let path = attr(child, "path").unwrap_or(".");
773                        parts.push(MessagePart::Name(
774                            parse_xpath_with_params(path, params)?));
775                    }
776                    _ => {
777                        // Other inline elements (sch:emph, sch:span):
778                        // stringify their text content.
779                        parts.push(MessagePart::Text(child_text(child)));
780                    }
781                }
782            }
783            _ => {}
784        }
785    }
786    Ok(parts)
787}
788
789fn child_text(node: &Node) -> String {
790    let mut s = String::new();
791    for c in node.children() {
792        if matches!(c.kind, NodeKind::Text | NodeKind::CData) {
793            s.push_str(c.content());
794        }
795    }
796    s
797}
798
799fn attr<'a>(node: &'a Node, name: &str) -> Option<&'a str> {
800    for a in node.attributes() {
801        if a.name() == name && !a.name().contains(':') {
802            return Some(a.value());
803        }
804    }
805    None
806}
807
808// ── validation ────────────────────────────────────────────────────
809
810/// One finding from a Schematron validation run.
811#[derive(Debug, Clone)]
812pub struct Finding {
813    pub kind:        FindingKind,
814    pub message:     String,
815    /// `pattern.id` of the pattern that fired.  `None` for
816    /// patterns with no id.
817    pub pattern_id:  Option<String>,
818    pub assertion_id: Option<String>,
819    pub role:        Option<String>,
820    /// `generate-id()`-style stable id for the node where the
821    /// rule fired.
822    pub location_id: String,
823    /// Local-name of the offending node — handy for diagnostic
824    /// messages.
825    pub context_name: String,
826}
827
828#[derive(Debug, Clone, Copy, PartialEq, Eq)]
829pub enum FindingKind {
830    /// An `<assert>` test evaluated to false.
831    FailedAssert,
832    /// A `<report>` test evaluated to true.
833    SuccessfulReport,
834}
835
836/// Result of a validation run.  `valid()` is the common gate
837/// callers reach for; the full `findings` vec carries every
838/// failed-assert and successful-report for richer reporting.
839#[derive(Debug, Clone, Default)]
840pub struct ValidationReport {
841    pub findings: Vec<Finding>,
842}
843
844impl ValidationReport {
845    /// A document is *valid* if no assertions failed.  Successful
846    /// reports are diagnostic (they fire when something noteworthy
847    /// happened, not when something's wrong), so they don't make
848    /// a document invalid.
849    pub fn valid(&self) -> bool {
850        !self.findings.iter().any(|f| matches!(f.kind, FindingKind::FailedAssert))
851    }
852}
853
854/// Bridge Schematron's namespace + let bindings into the XPath
855/// engine.  Each rule-evaluation builds one of these against the
856/// current variable scope.
857struct SchBindings<'a> {
858    namespaces: &'a HashMap<String, String>,
859    vars:       &'a HashMap<String, Value>,
860}
861
862/// Static XPath context for a Schematron expression — seeded from the
863/// bindings' host config (XPath version + regex dialect) so eval
864/// observes the same distinctions Schematron declares.
865fn sch_static_ctx(bindings: &SchBindings<'_>) -> StaticContext {
866    StaticContext {
867        xpath_2_0: bindings.xpath_version_2_or_later(),
868        xpath_3_0: false,
869        libxml2_compatible: false, current_node: None,
870    }
871}
872
873impl XPathBindings for SchBindings<'_> {
874    fn resolve_prefix(&self, prefix: &str) -> Option<String> {
875        self.namespaces.get(prefix).cloned()
876    }
877    fn variable(&self, name: &str) -> Option<Value> {
878        self.vars.get(name).cloned()
879    }
880}
881
882impl Schematron {
883    /// Validate an instance from source text.  Convenience wrapper
884    /// that parses with `namespace_aware: true` — required for
885    /// Schematron's prefix-based rule contexts to resolve.
886    pub fn validate_str(&self, instance_text: &str) -> Result<ValidationReport> {
887        self.validate_str_with_phase(instance_text, "#ALL")
888    }
889
890    /// Validate an instance from source text, running only the
891    /// patterns active under `phase`.  See [`Schematron::validate_with_phase`]
892    /// for phase semantics.
893    pub fn validate_str_with_phase(
894        &self, instance_text: &str, phase: &str,
895    ) -> Result<ValidationReport> {
896        let opts = sup_xml_core::ParseOptions {
897            namespace_aware: true, ..Default::default()
898        };
899        let doc = sup_xml_core::parse_str(instance_text, &opts).map_err(XsltError::from)?;
900        self.validate_with_phase(&doc, phase)
901    }
902
903    /// Validate `instance_doc` against this schema.  Returns a
904    /// [`ValidationReport`] with every failed assert and
905    /// successful report.  The instance document MUST have been
906    /// parsed in namespace-aware mode if any rule contexts or
907    /// assertion tests use prefixed names; use
908    /// [`Schematron::validate_str`] for the common case.
909    pub fn validate(&self, instance_doc: &Document) -> Result<ValidationReport> {
910        self.validate_with_phase(instance_doc, "#ALL")
911    }
912
913    /// Validate `instance_doc`, running only the patterns active
914    /// under `phase`.  ISO Schematron §6.5 — phases let a schema
915    /// expose multiple validation profiles from one declaration set.
916    ///
917    /// Special phase names:
918    /// * `"#ALL"` — run every pattern (the no-phase default).
919    /// * `"#DEFAULT"` — run the phase named by `<schema defaultPhase=…>`;
920    ///   if the schema has no `defaultPhase`, falls back to `#ALL`.
921    ///
922    /// Any other name is looked up in the schema's `<sch:phase>`
923    /// declarations.  An unknown name (one that isn't `#ALL`, isn't
924    /// `#DEFAULT`, and doesn't match a declared phase id) returns
925    /// `Err(InvalidStylesheet)` so callers see the typo instead of
926    /// silently validating against zero patterns.
927    pub fn validate_with_phase(
928        &self, instance_doc: &Document, phase: &str,
929    ) -> Result<ValidationReport> {
930        let active: Option<&[String]> = match phase {
931            "#ALL" => None,
932            "#DEFAULT" => match self.default_phase.as_deref() {
933                None | Some("#ALL") => None,
934                Some(name) => Some(self.phases.get(name)
935                    .ok_or_else(|| XsltError::InvalidStylesheet(format!(
936                        "schema defaultPhase='{name}' but no <sch:phase id='{name}'> declared"
937                    )))?
938                    .as_slice()),
939            },
940            other => Some(self.phases.get(other)
941                .ok_or_else(|| XsltError::InvalidStylesheet(format!(
942                    "unknown phase '{other}': no matching <sch:phase id='{other}'> in schema"
943                )))?
944                .as_slice()),
945        };
946        self.validate_inner(instance_doc, active)
947    }
948
949    fn validate_inner(
950        &self, instance_doc: &Document, active: Option<&[String]>,
951    ) -> Result<ValidationReport> {
952        let idx = DocIndex::build(instance_doc);
953        let mut report = ValidationReport::default();
954
955        // Pre-evaluate schema-level `<let>` bindings.  XPath
956        // context is the document root.  Each let sees the
957        // previously-bound lets — we construct fresh bindings per
958        // iteration so the borrow on `schema_vars` is released
959        // before insert.
960        let mut schema_vars: HashMap<String, Value> = HashMap::new();
961        for l in &self.lets {
962            let v = {
963                let bindings = SchBindings {
964                    namespaces: &self.namespaces,
965                    vars:       &schema_vars,
966                };
967                let sc = sch_static_ctx(&bindings);
968                let ctx = EvalCtx { context_node: 0, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
969                eval_expr(&l.value, &ctx, &idx).map_err(XsltError::from)?
970            };
971            schema_vars.insert(l.name.clone(), v);
972        }
973
974        // Per pattern → per node → first matching rule fires.
975        for pattern in &self.patterns {
976            // Phase filter: if `active` names a subset, skip
977            // patterns whose id isn't in the list.  Patterns
978            // without an id can never be selected by phase.
979            if let Some(list) = active {
980                let Some(pid) = pattern.id.as_deref() else { continue; };
981                if !list.iter().any(|n| n == pid) { continue; }
982            }
983            for node_id in 0..idx.nodes.len() {
984                // Skip the synthetic document and namespace nodes
985                // by default — rule contexts almost always target
986                // elements / attributes.  (Document-rooted rules
987                // are still reachable via context="/".)
988                let kind = idx.kind(node_id);
989                if !matches!(kind,
990                    XPathNodeKind::Element | XPathNodeKind::Document
991                        | XPathNodeKind::Attribute)
992                {
993                    continue;
994                }
995                for rule in &pattern.rules {
996                    if !rule_matches(&rule.context, node_id, &idx,
997                        &self.namespaces, &schema_vars)?
998                    {
999                        continue;
1000                    }
1001                    // This rule fires.  Evaluate its lets, then
1002                    // each assertion.  Lower-cased "first rule
1003                    // wins" semantics: break after this rule.
1004                    let mut local_vars = schema_vars.clone();
1005                    for l in &rule.lets {
1006                        let v = {
1007                            let bindings = SchBindings {
1008                                namespaces: &self.namespaces, vars: &local_vars,
1009                            };
1010                            let sc = sch_static_ctx(&bindings);
1011                            let ctx = EvalCtx { context_node: node_id, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
1012                            eval_expr(&l.value, &ctx, &idx).map_err(XsltError::from)?
1013                        };
1014                        local_vars.insert(l.name.clone(), v);
1015                    }
1016                    for assertion in &rule.asserts {
1017                        evaluate_assertion(
1018                            assertion, node_id, &idx,
1019                            &self.namespaces, &local_vars,
1020                            pattern, &mut report,
1021                        )?;
1022                    }
1023                    break;
1024                }
1025            }
1026        }
1027        Ok(report)
1028    }
1029}
1030
1031fn rule_matches(
1032    context: &Expr, node: NodeId, idx: &DocIndex<'_>,
1033    namespaces: &HashMap<String, String>,
1034    vars:       &HashMap<String, Value>,
1035) -> Result<bool> {
1036    let bindings = SchBindings { namespaces, vars };
1037    let sc = sch_static_ctx(&bindings);
1038    let mut cur = Some(node);
1039    while let Some(ctx_node) = cur {
1040        let ctx = EvalCtx { context_node: ctx_node, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
1041        let v = eval_expr(context, &ctx, idx).map_err(XsltError::from)?;
1042        if let Value::NodeSet(ns) = v {
1043            if ns.contains(&node) {
1044                return Ok(true);
1045            }
1046        }
1047        cur = idx.parent(ctx_node);
1048    }
1049    Ok(false)
1050}
1051
1052fn evaluate_assertion(
1053    a:          &Assertion,
1054    node:       NodeId,
1055    idx:        &DocIndex<'_>,
1056    namespaces: &HashMap<String, String>,
1057    vars:       &HashMap<String, Value>,
1058    pattern:    &Pattern,
1059    report:     &mut ValidationReport,
1060) -> Result<()> {
1061    let bindings = SchBindings { namespaces, vars };
1062    let sc = sch_static_ctx(&bindings);
1063    let ctx = EvalCtx { context_node: node, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
1064    let test_v = eval_expr(&a.test, &ctx, idx).map_err(XsltError::from)?;
1065    let truth = value_to_bool(&test_v);
1066    let fired = match a.kind {
1067        AssertKind::Assert => !truth,  // assert fires when test fails
1068        AssertKind::Report =>  truth,  // report fires when test succeeds
1069    };
1070    if !fired { return Ok(()); }
1071    let message = render_message(&a.message, node, idx, namespaces, vars)?;
1072    report.findings.push(Finding {
1073        kind: match a.kind {
1074            AssertKind::Assert => FindingKind::FailedAssert,
1075            AssertKind::Report => FindingKind::SuccessfulReport,
1076        },
1077        message,
1078        pattern_id:   pattern.id.clone(),
1079        assertion_id: a.id.clone(),
1080        role:         a.role.clone(),
1081        location_id:  format!("id{:x}", node),
1082        context_name: idx.local_name(node).to_string(),
1083    });
1084    Ok(())
1085}
1086
1087fn render_message(
1088    parts:      &[MessagePart],
1089    node:       NodeId,
1090    idx:        &DocIndex<'_>,
1091    namespaces: &HashMap<String, String>,
1092    vars:       &HashMap<String, Value>,
1093) -> Result<String> {
1094    let bindings = SchBindings { namespaces, vars };
1095    let sc = sch_static_ctx(&bindings);
1096    let ctx = EvalCtx { context_node: node, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
1097    let mut s = String::new();
1098    for p in parts {
1099        match p {
1100            MessagePart::Text(t) => s.push_str(t),
1101            MessagePart::ValueOf(e) => {
1102                let v = eval_expr(e, &ctx, idx).map_err(XsltError::from)?;
1103                s.push_str(&value_to_string(&v, idx));
1104            }
1105            MessagePart::Name(e) => {
1106                let v = eval_expr(e, &ctx, idx).map_err(XsltError::from)?;
1107                let target_node = match v {
1108                    Value::NodeSet(ns) if !ns.is_empty() => ns[0],
1109                    _ => node,
1110                };
1111                s.push_str(idx.node_name(target_node));
1112            }
1113        }
1114    }
1115    // Normalise inner whitespace per common Schematron output
1116    // conventions — collapse runs of whitespace to a single space
1117    // and trim leading/trailing whitespace.
1118    let normalised: Vec<&str> = s.split_whitespace().collect();
1119    Ok(normalised.join(" "))
1120}
1121
1122fn value_to_bool(v: &Value) -> bool {
1123    match v {
1124        Value::Boolean(b) => *b,
1125        Value::Number(n)  => n.as_f64() != 0.0 && !n.as_f64().is_nan(),
1126        Value::String(s)  => !s.is_empty(),
1127        Value::NodeSet(n) => !n.is_empty(),
1128        // ForeignNodeSet only originates from document() in compat-
1129        // driven XPath; Schematron runs against single-doc XPath.
1130        Value::ForeignNodeSet(n) => !n.is_empty(),
1131        Value::Typed(t) => {
1132            if let Some(b) = t.boolean { return b; }
1133            if let Some(n) = t.numeric { return n != 0.0 && !n.is_nan(); }
1134            !t.lexical.is_empty()
1135        }
1136        Value::Sequence(items) => match items.first() {
1137            None    => false,
1138            Some(v) => value_to_bool(v),
1139        }
1140        Value::IntRange { lo, hi } if lo == hi => *lo != 0,
1141        Value::IntRange { .. } => true,
1142        Value::Map(_) | Value::Array(_) | Value::Function(_) => true,
1143    }
1144}
1145
1146fn value_to_string<I: DocIndexLike>(v: &Value, idx: &I) -> String {
1147    use sup_xml_core::xpath::eval::value_to_string;
1148    value_to_string(v, idx)
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153    use super::*;
1154
1155    fn compile(text: &str) -> Schematron {
1156        Schematron::compile_str(text).expect("schematron compile")
1157    }
1158
1159    fn validate(sch: &Schematron, xml: &str) -> ValidationReport {
1160        sch.validate_str(xml).expect("validate")
1161    }
1162
1163    // ── compile ─────────────────────────────────────────────
1164
1165    #[test]
1166    fn compile_minimal_schema() {
1167        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1168            <pattern>
1169                <rule context="r">
1170                    <assert test="@x">missing x</assert>
1171                </rule>
1172            </pattern>
1173        </schema>"#);
1174        assert_eq!(s.patterns.len(), 1);
1175        assert_eq!(s.patterns[0].rules.len(), 1);
1176        assert_eq!(s.patterns[0].rules[0].asserts.len(), 1);
1177    }
1178
1179    #[test]
1180    fn compile_rejects_non_schematron_root() {
1181        let err = Schematron::compile_str("<foo/>").unwrap_err();
1182        assert!(format!("{err}").contains("sch:schema"), "got: {err}");
1183    }
1184
1185    #[test]
1186    fn accepts_old_namespace() {
1187        let s = compile(r#"<schema xmlns="http://www.ascc.net/xml/schematron">
1188            <pattern><rule context="*"><assert test="true()">ok</assert></rule></pattern>
1189        </schema>"#);
1190        assert_eq!(s.patterns.len(), 1);
1191    }
1192
1193    // ── validate ───────────────────────────────────────────
1194
1195    #[test]
1196    fn assert_fails_for_missing_attribute() {
1197        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1198            <pattern>
1199                <rule context="r">
1200                    <assert test="@x">missing x</assert>
1201                </rule>
1202            </pattern>
1203        </schema>"#);
1204        let r = validate(&s, "<r/>");
1205        assert!(!r.valid());
1206        assert_eq!(r.findings.len(), 1);
1207        assert_eq!(r.findings[0].kind, FindingKind::FailedAssert);
1208        assert_eq!(r.findings[0].message, "missing x");
1209    }
1210
1211    #[test]
1212    fn assert_passes_when_test_true() {
1213        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1214            <pattern>
1215                <rule context="r">
1216                    <assert test="@x">missing x</assert>
1217                </rule>
1218            </pattern>
1219        </schema>"#);
1220        let r = validate(&s, r#"<r x="42"/>"#);
1221        assert!(r.valid());
1222        assert!(r.findings.is_empty());
1223    }
1224
1225    #[test]
1226    fn report_fires_when_test_true() {
1227        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1228            <pattern>
1229                <rule context="r">
1230                    <report test="@deprecated">element is deprecated</report>
1231                </rule>
1232            </pattern>
1233        </schema>"#);
1234        let r = validate(&s, r#"<r deprecated="yes"/>"#);
1235        // Reports don't make the doc invalid — only asserts do.
1236        assert!(r.valid());
1237        assert_eq!(r.findings.len(), 1);
1238        assert_eq!(r.findings[0].kind, FindingKind::SuccessfulReport);
1239    }
1240
1241    #[test]
1242    fn first_matching_rule_wins_per_pattern() {
1243        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1244            <pattern>
1245                <rule context="*[@kind='a']">
1246                    <assert test="false()">first rule</assert>
1247                </rule>
1248                <rule context="*">
1249                    <assert test="false()">second rule</assert>
1250                </rule>
1251            </pattern>
1252        </schema>"#);
1253        let r = validate(&s, r#"<r kind="a"/>"#);
1254        // First rule matches; second rule should NOT fire.
1255        let messages: Vec<_> = r.findings.iter().map(|f| f.message.clone()).collect();
1256        assert!(messages.iter().any(|m| m == "first rule"));
1257        assert!(!messages.iter().any(|m| m == "second rule"));
1258    }
1259
1260    #[test]
1261    fn namespaces_resolve_via_sch_ns_declaration() {
1262        let s = compile(r##"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1263            <ns prefix="dc" uri="http://purl.org/dc/elements/1.1/"/>
1264            <pattern>
1265                <rule context="dc:title">
1266                    <assert test="normalize-space(.) != ''">title is empty</assert>
1267                </rule>
1268            </pattern>
1269        </schema>"##);
1270        let r = validate(&s, r#"<r xmlns:dc="http://purl.org/dc/elements/1.1/">
1271            <dc:title></dc:title>
1272        </r>"#);
1273        assert!(!r.valid(), "empty title should fail assertion");
1274    }
1275
1276    #[test]
1277    fn message_inlines_value_of() {
1278        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1279            <pattern>
1280                <rule context="r">
1281                    <assert test="@x">value: <value-of select="@y"/></assert>
1282                </rule>
1283            </pattern>
1284        </schema>"#);
1285        let r = validate(&s, r#"<r y="hello"/>"#);
1286        // x is missing → assert fails; message renders value-of @y.
1287        assert_eq!(r.findings[0].message, "value: hello");
1288    }
1289
1290    #[test]
1291    fn message_inlines_name() {
1292        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1293            <pattern>
1294                <rule context="*">
1295                    <report test="@bad">element <name/> has bad attr</report>
1296                </rule>
1297            </pattern>
1298        </schema>"#);
1299        let r = validate(&s, r#"<foo bad="yes"/>"#);
1300        assert!(r.findings.iter().any(|f| f.message.contains("foo")));
1301    }
1302
1303    #[test]
1304    fn pattern_id_propagates_to_findings() {
1305        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1306            <pattern id="check-001">
1307                <rule context="r">
1308                    <assert test="@x">missing x</assert>
1309                </rule>
1310            </pattern>
1311        </schema>"#);
1312        let r = validate(&s, "<r/>");
1313        assert_eq!(r.findings[0].pattern_id.as_deref(), Some("check-001"));
1314    }
1315
1316    #[test]
1317    fn rule_let_provides_local_binding() {
1318        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1319            <pattern>
1320                <rule context="r">
1321                    <let name="count" value="count(item)"/>
1322                    <assert test="$count = 3">expected 3 items</assert>
1323                </rule>
1324            </pattern>
1325        </schema>"#);
1326        // 2 items → assert fails.
1327        let r = validate(&s, "<r><item/><item/></r>");
1328        assert!(!r.valid());
1329        // 3 items → passes.
1330        let r = validate(&s, "<r><item/><item/><item/></r>");
1331        assert!(r.valid());
1332    }
1333
1334    // ── error paths ────────────────────────────────────────────
1335
1336    #[test]
1337    fn rule_without_context_errors() {
1338        let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1339            <pattern><rule><assert test="true()">ok</assert></rule></pattern>
1340        </schema>"#);
1341        assert!(r.is_err());
1342        if let Err(XsltError::InvalidStylesheet(msg)) = r {
1343            assert!(msg.contains("context"), "got {msg}");
1344        }
1345    }
1346
1347    #[test]
1348    fn let_without_name_errors() {
1349        let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1350            <pattern><rule context="r">
1351                <let value="@x"/>
1352                <assert test="true()">ok</assert>
1353            </rule></pattern>
1354        </schema>"#);
1355        assert!(r.is_err());
1356        if let Err(XsltError::InvalidStylesheet(msg)) = r {
1357            assert!(msg.contains("name"), "got {msg}");
1358        }
1359    }
1360
1361    #[test]
1362    fn let_without_value_errors() {
1363        let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1364            <pattern><rule context="r">
1365                <let name="count"/>
1366                <assert test="true()">ok</assert>
1367            </rule></pattern>
1368        </schema>"#);
1369        assert!(r.is_err());
1370        if let Err(XsltError::InvalidStylesheet(msg)) = r {
1371            assert!(msg.contains("value"), "got {msg}");
1372        }
1373    }
1374
1375    #[test]
1376    fn assert_without_test_errors() {
1377        let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1378            <pattern><rule context="r"><assert>missing</assert></rule></pattern>
1379        </schema>"#);
1380        assert!(r.is_err());
1381        if let Err(XsltError::InvalidStylesheet(msg)) = r {
1382            assert!(msg.contains("test"), "got {msg}");
1383            assert!(msg.contains("assert"), "got {msg}");
1384        }
1385    }
1386
1387    #[test]
1388    fn report_without_test_errors() {
1389        let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1390            <pattern><rule context="r"><report>missing</report></rule></pattern>
1391        </schema>"#);
1392        assert!(r.is_err());
1393        if let Err(XsltError::InvalidStylesheet(msg)) = r {
1394            assert!(msg.contains("report"), "got {msg}");
1395        }
1396    }
1397
1398    #[test]
1399    fn value_of_without_select_errors() {
1400        let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1401            <pattern><rule context="r">
1402                <assert test="true()">val=<value-of/></assert>
1403            </rule></pattern>
1404        </schema>"#);
1405        assert!(r.is_err());
1406    }
1407
1408    // ── message inline element handling ────────────────────────
1409
1410    #[test]
1411    fn message_inlines_emph_via_text_content() {
1412        // Non-value-of / non-name child element → fallback path
1413        // (`_ => {...}` arm in compile_message) that just stringifies
1414        // the child's text content.
1415        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1416            <pattern>
1417                <rule context="r">
1418                    <assert test="@x">missing <emph>x</emph> attribute</assert>
1419                </rule>
1420            </pattern>
1421        </schema>"#);
1422        let r = validate(&s, "<r/>");
1423        assert!(r.findings[0].message.contains("missing"));
1424        assert!(r.findings[0].message.contains("x"));
1425        assert!(r.findings[0].message.contains("attribute"));
1426    }
1427
1428    #[test]
1429    fn name_with_path_attribute() {
1430        // <name path="@x"/> — use the optional path attribute (default ".").
1431        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1432            <pattern>
1433                <rule context="r">
1434                    <assert test="false()">node was <name path="."/></assert>
1435                </rule>
1436            </pattern>
1437        </schema>"#);
1438        let r = validate(&s, "<r/>");
1439        assert!(r.findings[0].message.contains("node was"));
1440        assert!(r.findings[0].message.contains("r"));
1441    }
1442
1443    // ── rule unknown children are silently ignored ─────────────
1444
1445    #[test]
1446    fn rule_ignores_unknown_children() {
1447        // <extension> isn't let/assert/report → silently skipped
1448        // (`_ => {}` arm in compile_rule).
1449        let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1450            <pattern>
1451                <rule context="r">
1452                    <extension/>
1453                    <assert test="@x">missing x</assert>
1454                </rule>
1455            </pattern>
1456        </schema>"#);
1457        let r = validate(&s, "<r/>");
1458        assert!(!r.valid());
1459        assert_eq!(r.findings[0].message, "missing x");
1460    }
1461}