Merge epic/25-schematron: Schematron rule validation

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 05:58:56 +02:00
commit e8a5d3200e
8 changed files with 248 additions and 1 deletions

View File

@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Schematron validation (#25, the libxml2 `schematron.h` model).
`PureXML.Validation.Schematron(schema:)` compiles a Schematron schema
(namespace-agnostic over the ISO and legacy namespaces) and `validate(_:)`
checks a document: each `<rule>`'s `context` XPath selects the nodes it fires
on, and each `<assert>`/`<report>` `test` is evaluated relative to that node, a
failed assert becoming an error issue and a matched report a warning. Within a
pattern a node fires the first matching rule. Built on the XPath context-node
evaluation, with a new `XPath.Query.nodes(over:)` that returns matched tree
nodes for repeated querying of one tree.
- XPointer (#23, the libxml2 `xpointer.h` schemes). The new `PureXML.XPointer`
namespace resolves the shorthand bare-name form (`name` = `id('name')`), the
`element()` scheme (child-element positions from an id or the document root,

View File

@ -92,7 +92,7 @@ flowchart TB
Querying["Querying: #21 XPath, #22 xmlPattern, #23 XPointer (shipped)"]:::done
Regexp["#30 Regular expression engine"]:::epic
Xsd["#2 XSD / RELAX NG validation"]:::epic
Schematron["#25 Schematron validation"]:::epic
Schematron["#25 Schematron validation"]:::done
Regexp --> Xsd
Querying --> Schematron
```

View File

@ -0,0 +1,53 @@
public extension PureXML.Validation {
/// Rule-based assertion validation (the libxml2 `schematron.h` model).
///
/// A Schematron schema groups `<rule>`s into `<pattern>`s. Each rule has a
/// `context` XPath selecting the nodes it fires on, and `<assert>`/`<report>`
/// children whose `test` XPath is evaluated relative to each context node. An
/// `assert` whose test is false, and a `report` whose test is true, produce an
/// ``Issue`` carrying the element's message.
///
/// Within a pattern a node is processed by the first rule whose context
/// selects it. Dynamic message content (`<value-of>`, `<name>`) is rendered as
/// its static text; the message substitution itself is not evaluated.
struct Schematron {
private let patterns: [SchematronPattern]
/// Compiles a Schematron schema document. Throws if a context or test is
/// not a valid XPath expression.
public init(schema xml: String) throws {
patterns = try SchematronParser.parse(xml)
}
/// Validates an XML document against the schema, returning one issue per
/// failed assertion and per matched report, in document order.
public func validate(_ xml: String) throws -> [Issue] {
let root = try PureXML.parseTree(xml)
var issues: [Issue] = []
for pattern in patterns {
evaluate(pattern, over: root, into: &issues)
}
return issues
}
private func evaluate(_ pattern: SchematronPattern, over root: PureXML.Model.TreeNode, into issues: inout [Issue]) {
var claimed: Set<ObjectIdentifier> = []
for rule in pattern.rules {
for node in rule.context.nodes(over: root) where claimed.insert(ObjectIdentifier(node)).inserted {
apply(rule.assertions, at: node, into: &issues)
}
}
}
private func apply(_ assertions: [SchematronAssertion], at node: PureXML.Model.TreeNode, into issues: inout [Issue]) {
for assertion in assertions {
let holds = (try? assertion.test.value(at: node).boolean) ?? false
if assertion.isReport {
if holds { issues.append(Issue(severity: .warning, message: assertion.message)) }
} else if !holds {
issues.append(Issue(severity: .error, message: assertion.message))
}
}
}
}
}

View File

@ -0,0 +1,22 @@
extension PureXML.Validation {
/// One `<assert>` or `<report>`: a compiled XPath test and its message. An
/// assert flags when its test is false; a report flags when its test is true.
struct SchematronAssertion {
let isReport: Bool
let test: PureXML.XPath.Query
let message: String
}
/// One `<rule>`: a compiled context that selects the nodes the rule fires on,
/// and its assertions.
struct SchematronRule {
let context: PureXML.XPath.Query
let assertions: [SchematronAssertion]
}
/// One `<pattern>`: a list of rules. A node is processed by the first rule in
/// the pattern whose context selects it (the ISO Schematron firing rule).
struct SchematronPattern {
let rules: [SchematronRule]
}
}

View File

@ -0,0 +1,62 @@
extension PureXML.Validation {
/// Parses a Schematron schema document into patterns. Namespace-agnostic: it
/// matches the `schema`, `pattern`, `rule`, `assert`, and `report` elements by
/// local name, so both the ISO and the legacy Schematron namespaces work.
enum SchematronParser {
static func parse(_ xml: String) throws -> [SchematronPattern] {
let root = try PureXML.parseTree(xml)
return try descendants(root, localName: "pattern").map(pattern)
}
private static func pattern(_ node: PureXML.Model.TreeNode) throws -> SchematronPattern {
try SchematronPattern(rules: children(node, localName: "rule").compactMap(rule))
}
private static func rule(_ node: PureXML.Model.TreeNode) throws -> SchematronRule? {
guard let context = attribute(node, "context") else { return nil }
let assertions = try node.children
.filter { $0.kind == .element && isAssertion($0) }
.compactMap(assertion)
return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), assertions: assertions)
}
private static func assertion(_ node: PureXML.Model.TreeNode) throws -> SchematronAssertion? {
guard let test = attribute(node, "test") else { return nil }
let isReport = node.name?.localName == "report"
return try SchematronAssertion(
isReport: isReport,
test: PureXML.XPath.Query(test),
message: node.stringValue.trimmingXMLWhitespace(),
)
}
private static func isAssertion(_ node: PureXML.Model.TreeNode) -> Bool {
node.name?.localName == "assert" || node.name?.localName == "report"
}
/// A relative context selects matching nodes anywhere, so it is searched
/// from the document via `//`; an absolute context is used as written.
private static func contextExpression(_ context: String) -> String {
context.hasPrefix("/") ? context : "//" + context
}
private static func attribute(_ node: PureXML.Model.TreeNode, _ name: String) -> String? {
node.attributes.first { $0.name.localName == name || $0.name.description == name }?.value
}
private static func children(_ node: PureXML.Model.TreeNode, localName: String) -> [PureXML.Model.TreeNode] {
node.children.filter { $0.kind == .element && $0.name?.localName == localName }
}
private static func descendants(_ node: PureXML.Model.TreeNode, localName: String) -> [PureXML.Model.TreeNode] {
var result: [PureXML.Model.TreeNode] = []
for child in node.children {
if child.kind == .element, child.name?.localName == localName {
result.append(child)
}
result.append(contentsOf: descendants(child, localName: localName))
}
return result
}
}
}

View File

@ -29,6 +29,23 @@ extension PureXML.XPath {
try eval(expression, rootContext(node, variables: variables))
}
/// Evaluates a node-set expression against a pre-built tree and returns the
/// matched tree nodes in document order (attribute and namespace results
/// are dropped). Lets a caller build the tree once and query it repeatedly.
static func nodes(_ expression: Expression, over root: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {
let context = EvaluationContext(
node: .tree(root),
position: 1,
size: 1,
variables: [:],
functions: library,
)
guard let value = try? eval(expression, context), case let .nodeSet(nodes) = value else {
return []
}
return orderUnique(nodes).compactMap(\.treeNode)
}
/// Evaluates an expression against an explicit context: a node already in a
/// tree, its proximity position and size, and variable bindings. This is
/// the entry point downstream engines (XSLT, Schematron) drive per node.

View File

@ -38,6 +38,14 @@ public extension PureXML.XPath {
try Evaluator.value(expression, over: node, variables: variables)
}
/// Evaluates the query over a pre-built tree and returns the matched tree
/// nodes in document order. Build the tree once (``PureXML/parseTree(_:)``)
/// and query it repeatedly; pair with ``value(at:position:size:variables:)``
/// to evaluate further expressions relative to each result.
public func nodes(over root: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {
Evaluator.nodes(expression, over: root)
}
/// Evaluates the query against an explicit context: a node already in a
/// tree (``PureXML/Model/TreeNode``), its one-based proximity `position`
/// within a node-set of `size`, and variable bindings. Downstream engines

View File

@ -0,0 +1,76 @@
@testable import PureXML
import Testing
@Suite("Schematron")
struct SchematronTests {
private let schema = """
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="book">
<assert test="title">a book must have a title</assert>
<assert test="@isbn">a book must have an isbn</assert>
<report test="count(author) &gt; 2">more than two authors</report>
</rule>
</pattern>
</schema>
"""
private func validate(_ xml: String) throws -> [PureXML.Validation.Issue] {
try PureXML.Validation.Schematron(schema: schema).validate(xml)
}
@Test("A conforming document produces no issues")
func test_valid() throws {
let xml = "<library><book isbn=\"1\"><title>T</title><author>A</author></book></library>"
#expect(try validate(xml).isEmpty)
}
@Test("A failed assert is reported as an error")
func test_failedAssert() throws {
let xml = "<library><book isbn=\"1\"><author>A</author></book></library>"
let issues = try validate(xml)
#expect(issues.count == 1)
#expect(issues.first?.severity == .error)
#expect(issues.first?.message == "a book must have a title")
}
@Test("Multiple failed asserts each report")
func test_multipleAsserts() throws {
let xml = "<library><book><author>A</author></book></library>"
let messages = try validate(xml).map(\.message)
#expect(messages.contains("a book must have a title"))
#expect(messages.contains("a book must have an isbn"))
}
@Test("A matched report is a warning")
func test_report() throws {
let xml = "<library><book isbn=\"1\"><title>T</title>"
+ "<author>A</author><author>B</author><author>C</author></book></library>"
let issues = try validate(xml)
#expect(issues.count == 1)
#expect(issues.first?.severity == .warning)
#expect(issues.first?.message == "more than two authors")
}
@Test("Rules fire once per matching node across the document")
func test_perNode() throws {
let xml = "<library>"
+ "<book isbn=\"1\"><title>T</title></book>"
+ "<book><author>A</author></book>"
+ "</library>"
// The second book is missing both title and isbn.
let issues = try validate(xml)
#expect(issues.count == 2)
#expect(issues.allSatisfy { $0.severity == .error })
}
@Test("An invalid test expression is rejected at compile time")
func test_invalidSchema() {
let bad = """
<schema><pattern><rule context="book"><assert test="(">x</assert></rule></pattern></schema>
"""
#expect(throws: Error.self) {
_ = try PureXML.Validation.Schematron(schema: bad)
}
}
}