PureXML/Sources/Validation/SchematronParser.swift
Mihaela Mihaljevic 80fffd8127 feat(validation): Schematron rule validation
Add PureXML.Validation.Schematron: compile a Schematron schema (matching
schema/pattern/rule/assert/report by local name, so the ISO and legacy
namespaces both work) and validate a document. A rule's context XPath
selects the nodes it fires on; each assert/report test is evaluated relative
to the node via the XPath context-node API. A failed assert is an error, a
matched report a warning; within a pattern a node fires the first matching
rule. Adds XPath.Query.nodes(over:) returning matched tree nodes so one tree
can be queried repeatedly.

Closes #52
Closes #53
Closes #25
2026-06-08 05:58:55 +02:00

63 lines
3.0 KiB
Swift

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
}
}
}