PureXML/Sources/Validation/Schematron.swift
Mihaela Mihaljevic d682f85daf refactor(validation): migrate Schematron, retire Issue
Slice 5 of #92. The last validator moves onto the framework and the legacy
Issue type is removed, so all validation now flows through one error model.

- ValidationError gains a severity (error by default, warning for advisory
  findings), chosen so Schematron's assert/report distinction survives in the
  unified type.
- Schematron is a Validation<Node, Void> over the document root; a failed assert
  is an error finding, a matched report a warning. Each finding is located at the
  context node its rule fired on (built from the element chain, with a sibling
  index only when a name repeats), not the document root.
- Schematron.validate returns [ValidationError] and runs through a Validator.
- Validation.Issue and its Severity-based result are deleted; Severity now backs
  ValidationError.

Every validation layer (structural, DTD, XSD content and identity, Schematron)
now produces located ValidationError values through the shared framework.
2026-06-08 12:49:19 +02:00

92 lines
4.6 KiB
Swift

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. A
/// failed `assert` produces an error finding; a matched `report` produces a
/// warning finding. Both are ``ValidationError`` values carrying the 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 finding per
/// failed assertion and per matched report, in document order. Assertions
/// are errors and reports are warnings.
public func validate(_ xml: String) throws -> [ValidationError] {
let node = try PureXML.parse(xml)
return Validator<Void>.blank.validating(Self.rule(patterns)).errors(for: node, in: ())
}
/// The schema as a single ``Validation`` over the document root, so it
/// composes with the rest of the framework.
static func rule(_ patterns: [SchematronPattern]) -> Validation<PureXML.Model.Node, Void> {
.init(
description: "Document satisfies the Schematron schema",
check: { context in evaluate(patterns, over: PureXML.Model.TreeNode(context.subject)) },
when: { $0.codingPath.isEmpty },
)
}
private static func evaluate(_ patterns: [SchematronPattern], over root: PureXML.Model.TreeNode) -> [ValidationError] {
patterns.flatMap { evaluate($0, over: root) }
}
private static func evaluate(_ pattern: SchematronPattern, over root: PureXML.Model.TreeNode) -> [ValidationError] {
var errors: [ValidationError] = []
var claimed: Set<ObjectIdentifier> = []
for rule in pattern.rules {
for node in rule.context.nodes(over: root) where claimed.insert(ObjectIdentifier(node)).inserted {
errors += apply(rule.assertions, at: node)
}
}
return errors
}
private static func apply(_ assertions: [SchematronAssertion], at node: PureXML.Model.TreeNode) -> [ValidationError] {
let path = codingPath(of: node)
var errors: [ValidationError] = []
for assertion in assertions {
let holds = (try? assertion.test.value(at: node).boolean) ?? false
if assertion.isReport {
if holds { errors.append(ValidationError(reason: assertion.message, at: path, severity: .warning)) }
} else if !holds {
errors.append(ValidationError(reason: assertion.message, at: path, severity: .error))
}
}
return errors
}
/// The coding path of a context node, built from the element chain to the
/// root (a sibling index only when a name repeats), so each finding is
/// located at the node its rule fired on rather than the document root.
private static func codingPath(of node: PureXML.Model.TreeNode) -> [PathKey] {
var steps: [PathKey] = []
var current: PureXML.Model.TreeNode? = node
while let element = current, element.kind == .element, let name = element.name?.description {
steps.append(step(for: element, named: name))
current = element.parent
}
return steps.reversed()
}
private static func step(for element: PureXML.Model.TreeNode, named name: String) -> PathKey {
guard let parent = element.parent else { return .element(name) }
let siblings = parent.children.filter { $0.kind == .element && $0.name?.description == name }
guard siblings.count > 1, let index = siblings.firstIndex(where: { $0 === element }) else {
return .element(name)
}
return .element(name, index: index + 1)
}
}
}