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.
This commit is contained in:
parent
e925ba6bea
commit
d682f85daf
@ -1,18 +0,0 @@
|
||||
public extension PureXML.Validation {
|
||||
/// A single validation finding with a severity and human-readable message.
|
||||
struct Issue: Equatable, Hashable, Sendable, CustomStringConvertible {
|
||||
public var severity: Severity
|
||||
public var message: String
|
||||
|
||||
public init(severity: Severity, message: String) {
|
||||
self.severity = severity
|
||||
self.message = message
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
"\(severity.rawValue): \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Validation.Issue: Swift.Error {}
|
||||
@ -3,9 +3,9 @@ public extension PureXML.Validation {
|
||||
///
|
||||
/// 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.
|
||||
/// 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
|
||||
@ -19,35 +19,73 @@ public extension PureXML.Validation {
|
||||
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
|
||||
/// 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: ())
|
||||
}
|
||||
|
||||
private func evaluate(_ pattern: SchematronPattern, over root: PureXML.Model.TreeNode, into issues: inout [Issue]) {
|
||||
/// 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 {
|
||||
apply(rule.assertions, at: node, into: &issues)
|
||||
errors += apply(rule.assertions, at: node)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
private func apply(_ assertions: [SchematronAssertion], at node: PureXML.Model.TreeNode, into issues: inout [Issue]) {
|
||||
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 { issues.append(Issue(severity: .warning, message: assertion.message)) }
|
||||
if holds { errors.append(ValidationError(reason: assertion.message, at: path, severity: .warning)) }
|
||||
} else if !holds {
|
||||
issues.append(Issue(severity: .error, message: assertion.message))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,15 +54,19 @@ public extension PureXML.Validation {
|
||||
public let codingPath: [PathKey]
|
||||
}
|
||||
|
||||
/// A validation failure: a reason and the coding path where it occurred. The
|
||||
/// description strips a trailing period and renders the location consistently.
|
||||
/// A validation finding: a reason, the coding path where it occurred, and a
|
||||
/// severity (an error by default; a warning for advisory findings such as a
|
||||
/// matched Schematron `report`). The description strips a trailing period and
|
||||
/// renders the location consistently.
|
||||
struct ValidationError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
public let reason: String
|
||||
public let codingPath: [PathKey]
|
||||
public let severity: Severity
|
||||
|
||||
public init(reason: String, at path: [PathKey]) {
|
||||
public init(reason: String, at path: [PathKey], severity: Severity = .error) {
|
||||
self.reason = reason
|
||||
codingPath = path
|
||||
self.severity = severity
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
|
||||
@ -15,7 +15,7 @@ struct SchematronTests {
|
||||
</schema>
|
||||
"""
|
||||
|
||||
private func validate(_ xml: String) throws -> [PureXML.Validation.Issue] {
|
||||
private func validate(_ xml: String) throws -> [PureXML.Validation.ValidationError] {
|
||||
try PureXML.Validation.Schematron(schema: schema).validate(xml)
|
||||
}
|
||||
|
||||
@ -25,19 +25,20 @@ struct SchematronTests {
|
||||
#expect(try validate(xml).isEmpty)
|
||||
}
|
||||
|
||||
@Test("A failed assert is reported as an error")
|
||||
@Test("A failed assert is reported as an error located at its context node")
|
||||
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")
|
||||
#expect(issues.first?.reason == "a book must have a title")
|
||||
#expect(String(describing: issues[0]) == "a book must have a title at path: library/book")
|
||||
}
|
||||
|
||||
@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)
|
||||
let messages = try validate(xml).map(\.reason)
|
||||
#expect(messages.contains("a book must have a title"))
|
||||
#expect(messages.contains("a book must have an isbn"))
|
||||
}
|
||||
@ -49,7 +50,7 @@ struct SchematronTests {
|
||||
let issues = try validate(xml)
|
||||
#expect(issues.count == 1)
|
||||
#expect(issues.first?.severity == .warning)
|
||||
#expect(issues.first?.message == "more than two authors")
|
||||
#expect(issues.first?.reason == "more than two authors")
|
||||
}
|
||||
|
||||
@Test("Rules fire once per matching node across the document")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user