A pure-Rust implementation of ISO Schematron
— parses a Schematron .sch schema (pattern/rule/assert/report
structure) and evaluates it against a document, producing structured
assertion results (which rules fired, passed, or failed, with their
messages).
Generic and standalone — not tied to HTML, XML parsing, or any specific
document type. Callers provide their own parsed document via a trait; XPath
test="..." expressions are evaluated using
xpath-eval, a separate crate, so
that XPath evaluation stays independently reusable outside of Schematron.
Two-stage goal, deliberately in that order:
- First: implement the subset of ISO Schematron that
html-conformactually needs for its assertion layer — enoughpattern/rule/assert/reporthandling to run its rule set, nothing speculative. - Then: full ISO Schematron conformance —
phaseselection,<let>variable bindings,<value-of>message interpolation, abstract patterns/extends, diagnostics. (Rule conflict resolution — "first matching rule per pattern wins" — is not a stage-1 placeholder awaiting apriorityattribute; checked against the ISO grammar,rulehas no such attribute, so that behavior already is stage 2's final semantics.)
Both stages are complete: schema parsing, evaluation against a document,
<let> variable bindings, phase selection, <value-of> message
interpolation, abstract patterns/extends, and diagnostics, plus
referential-integrity checking (duplicate/unknown ids) and hardened
structural validation on top — see "Support matrix" below for exactly
what's covered and what's explicitly out of scope.
use schematron_engine::{evaluate, parse};
use xpath_eval::Document;
# fn example<D: Document>(document: &D) -> Result<(), Box<dyn std::error::Error>> {
let schema = parse(
r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="item">
<assert test="@id">item must have an id attribute</assert>
</rule>
</pattern>
</schema>"#,
)?;
let reports = evaluate(&schema, document)?;
for report in &reports {
println!("{:?}: {}", report.kind, report.message);
}
# Ok(())
# }This crate never parses XML itself — document above is anything
implementing xpath_eval::Document/Node over your own tree (an XML DOM,
an HTML parser's tree, ...). Note that this means callers depend on
xpath-eval directly too, not just on schematron-engine: Document/
Node are part of evaluate's own signature (evaluate<D: Document>),
so implementing them requires importing that crate's traits. See
tests/integration.rs for a complete, runnable
adapter over a real XML tree (via roxmltree), evaluating a realistic
schema end to end.
xpath-eval's version is a compatibility boundary, not an implementation
detail — Cargo.toml pins a version requirement, and CI always builds and
tests against whatever version that requirement (and Cargo.lock)
resolves to.
Both ISO Schematron stages this crate set out to implement (see "Scope and the name..." above) are complete. What's explicitly not supported, and what happens when a schema uses it anyway — surfaced as an error rather than silently doing the wrong thing:
| Construct | Behavior |
|---|---|
<extends rule="..."> (same-schema rule reuse) |
supported |
<extends href="..."> (external-file rule reuse) |
MissingAttribute (extends requires @rule) — deliberately not wired up to SchemaResolver even though <include> now is: the ISO reference implementation (iso_dsdl_include.xsl) itself labels extends[@href] "experimental and non-standard", with substitution semantics that differ from <include> (splices the target's children, not the element itself) |
<let value="..."> |
supported (LetValue::Expr) |
<let> with literal-XML (foreign-element+) content instead of value |
supported (LetValue::Literal) — bound as the content's string-value (concatenation of descendant text, like an XSLT 1.0 result-tree-fragment used as a string); not usable as a navigable node-set (no exsl:node-set()-equivalent escape hatch, consistent with implementing XPath 1.0 only) |
<name> in messages |
evaluated — resolves to the named node's expanded name (path="..." selects a node other than the firing check's own context node, same as <value-of select="...">) |
<emph>/<dir>/<span> in messages |
accepted as plain, inert text containers — purely presentational, no data-dependent semantics; their own text still contributes to the message, their tag does not |
rule/@context match-pattern semantics |
reduced to descendant-or-self::node()/context, a location-path selection — covers the common case, not every exotic XSLT-match-pattern-style context |
<include href="..."> (schema composition) |
ParseError::UnexpectedElement with plain parse() (unchanged — still a hard error, not a silent gap); supported end-to-end with parse_with_resolver given a caller-supplied SchemaResolver — splices the resolved root element in place, recursively. Only the common href-with-no-#fragment form resolving to a single Schematron-namespace root is supported; a #fragment-id cross-document lookup (the ISO reference implementation's own, itself-labeled-experimental extension) is not — ParseError::UnresolvableInclude/CyclicInclude/InvalidInclude cover the resolver-supplied failure modes |
schema/@queryBinding |
read into Schema.query_binding; "xslt"/"xpath" (case-insensitive) — both XPath 1.0 — are accepted, anything else (e.g. "xslt2", XPath 2.0) is ParseError::UnsupportedQueryBinding rather than silently evaluated as XPath 1.0 anyway |
| Unknown or misplaced Schematron-namespace elements (typos, elements in the wrong container) | ParseError::UnexpectedElement, not silently dropped — <title>/<p> (prose/documentation) are accepted wherever ISO allows them, even though their content isn't read |
<properties>/<property> (ISO Schematron 2016 extension) |
supported — Schema.properties; assert|report/@properties (the parallel per-check IDREFS attribute) is not modeled, silently unread, same as several other assert/report attributes (flag, subject, ...) |
Duplicate id on pattern/rule/assert/report/phase/diagnostic/property |
ParseError::DuplicateId — one shared, document-wide id space (real XML ID semantics), not one per element type |
active/@pattern referencing an unknown or abstract pattern id |
ParseError::UnknownActivePattern |
assert|report/@diagnostics referencing an unknown diagnostic id |
ParseError::UnknownDiagnosticReference |
| Foreign-namespace elements (any namespace other than Schematron's) | always allowed, anywhere — ISO's own "foreign" content model |
In addition to this crate's own unit/integration tests, tests/schxslt_testsuite.rs
runs a vendored, checksum-pinned, third-party conformance corpus
(tests/corpus/schxslt-testsuite/, MIT-licensed — see its UPSTREAM.md)
end to end. A handful of cases are skipped with a documented reason (see
that test's SKIP list) — mostly constructs this table already lists as
unsupported, plus two node kinds (comment()/processing-instruction())
the test-only fixture adapter doesn't model.
[dependencies]
schematron-engine = "0.2"
xpath-eval = "0.2" # implement Document/Node over your own document treeMIT — see LICENSE.