ISO/IEC 19757-3 · 0.16.0
Schematron in pure Rust.
Rule-based XML validation with no libxml2, no XSLT processor,
no C toolchain, and no FFI. Its own XML parser, its own XPath engine, and a
validator that interprets a schema directly instead of transpiling it.
no unsafe XXE structurally impossible Schema is Send + Sync SVRL, JSON, and text
The problem
Every other route to Schematron in Rust goes through C
Bind to libxml2. Or shell out to Saxon. Or compile the schema
into XSLT and find an XSLT engine to run it. Each choice drags a C
toolchain, an FFI boundary, or a JVM into a project that had none.
This crate does none of that. It contains its own XML parser, its own
XPath 1.0 engine, and its own validator, and it interprets a
schema directly rather than transpiling it. cargo add schematron is the whole install story, on every platform Rust targets.
What Schematron is for
Grammars describe shape. Schematron describes conditions.
DTD, XML Schema, and RELAX NG describe the shape a
document may take. Schematron describes the conditions a
document must satisfy, written as XPath expressions — so it can express
what a grammar cannot:
- co-occurrence rules — "if
@type is invoice, then total is required" - value relationships — "
end must not precede start" - cross-references between distant parts of a document
- cardinality that depends on content rather than position
rules.sch
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="invoice">
<assert test="total">An invoice must have a total.</assert>
<report test="count(line) > 100">This invoice has an unusual number of lines.</report>
</rule>
</pattern>
</schema>
Read that as: for every invoice element, assert that it has
a total child. assert fires when its test is false; report fires when its test is true.
Schematron is normally layered on top of a grammar, not used
instead of one.
Quick start
Two ways in, both about a minute
As a library
use schematron::{Document, Schema};
let schema = Schema::from_path("rules.sch")?;
let document = Document::from_path("data.xml")?;
let report = schema.validate(&document)?;
for failure in report.failures() {
println!("{}: {}", failure.location, failure.text);
}
The full library guide →
As a command line tool
schematron --schema rules.sch data.xml
schematron -s rules.sch -f svrl -o report.svrl data.xml
schematron -s rules.sch --flag error docs/*.xml
cat data.xml | schematron -s rules.sch -
Every option and exit code →
Rules in one pattern compete like the arms of a match expression. This is
a feature — it is how you write an "otherwise" branch:
<pattern>
<rule context="line[@type='discount']">
<assert test="number(@amount) < 0">A discount must be negative.</assert>
</rule>
<rule context="line">
<assert test="number(@amount) >= 0">A normal line must not be negative.</assert>
</rule>
</pattern>
— and it is also the most common way to write a schema that silently does nothing:
<pattern>
<rule context="*">…</rule>
<rule context="invoice">…</rule> <!-- never runs: * claimed everything -->
</pattern>
To apply independent checks to the same node, use separate patterns.
Patterns do not compete; each gets its own pass over the document. The
second-most common cause of a silent schema is a missing namespace
prefix — XPath 1.0 has no default namespace, so an unprefixed name
matches elements in no namespace. Both causes, and how to spot them →
Beyond the standard
A schema that does nothing is now a thing you can catch
Linting
--lint reports constructs that are legal but almost
certainly wrong: a rule shadowed by an earlier one in the same pattern,
an unprefixed name in a namespaced schema, a variable or key nothing
uses, a rule that reports nothing.
No document needed
Portability
--portability asks a different question: will this schema
behave the same under another processor? What it reports is not wrong —
it is correct here, and treated differently by the ISO reference
implementation. Kept out of --lint, because a linter that
reports correct code gets switched off.
Seven checks, each backed by a measured divergence
Reports as data
A report is data, not formatted text, so one run renders three ways —
SVRL, JSON, and prose — and can be queried directly instead of scraped
back out of text.
to_svrl(), to_json(), to_text()
Explain and verbose
--explain prints the compiled schema — patterns, rules,
contexts, tests — before it runs. --verbose shows which
rules actually fired, which is how you learn that none did.
Two flags worth knowing
Guarantees
What the crate promises
The XML parser never resolves an external entity and never processes a
DTD's entity declarations. XXE is structurally impossible rather than merely switched off. The default resolver reads local files
and refuses http: and https:.
Compared against the ISO reference implementation over the whole corpus.
Twenty of twenty-three cases agree exactly; the other three are
documented, and in each the difference is the reference's.
Every XPath expression is parsed once, at compile time. Rule contexts
are evaluated once per document rather than tested node by node, so
matching is linear rather than quadratic. Patterns can evaluate in
parallel.
21 normative specification documents, machine-checked:
the test suite compiles every schema shown in the docs, resolves every
relative link, and ties duplicated facts back to their single source.
Questions?
Good questions, quick answers
Do I need libxml2, Saxon, or a JVM?
No. That is the point of the crate. It is pure Rust with no unsafe, so it builds wherever cargo does —
including musl static builds, cross-compiles, and WebAssembly.
Is XPath 2.0 or 3.0 supported?
Documented subsets of both. XPath 2.0: regular expressions, conditionals,
sequences, dates, durations, type operators, for, some, every, and ranges. XPath 3.0: function
items, the arrow operator =>, string concatenation ||, the simple map operator !, let,
EQNames (Q{uri}local), and the higher-order
sequence functions. Everything outside either subset is a hard error naming the construct, never a wrong answer. The exact boundary →
My schema runs but finds nothing. What happened?
Almost always one of two things: a missing ns prefix, or an
earlier rule in the same pattern that claimed the nodes. --lint detects both automatically, and needs no document. Walk through the diagnosis →
How do I check my schema will work under other processors?
schematron -s rules.sch --portability. It reports constructs
other Schematron processors treat differently — a let that
shadows an outer one, a rule on comment(), a rule's @flag — each backed by a divergence established by running
both implementations against the same schema.
What is the licence?
Your choice of MIT, Apache-2.0, GPL-2.0-only, or GPL-3.0-only.
Read the source on GitHub →