diff --git a/Sources/Validation/Issue.swift b/Sources/Validation/Issue.swift deleted file mode 100644 index cedd3ac..0000000 --- a/Sources/Validation/Issue.swift +++ /dev/null @@ -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 {} diff --git a/Sources/Validation/Schematron.swift b/Sources/Validation/Schematron.swift index 8675c9c..e673b6c 100644 --- a/Sources/Validation/Schematron.swift +++ b/Sources/Validation/Schematron.swift @@ -3,9 +3,9 @@ public extension PureXML.Validation { /// /// A Schematron schema groups ``s into ``s. Each rule has a /// `context` XPath selecting the nodes it fires on, and ``/`` - /// 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 (``, ``) 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.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 { + .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 = [] 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) } } } diff --git a/Sources/Validation/ValidationCore.swift b/Sources/Validation/ValidationCore.swift index 73bdbb8..0d52ed7 100644 --- a/Sources/Validation/ValidationCore.swift +++ b/Sources/Validation/ValidationCore.swift @@ -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 { diff --git a/Tests/SchematronTests.swift b/Tests/SchematronTests.swift index c90f99d..a5090ab 100644 --- a/Tests/SchematronTests.swift +++ b/Tests/SchematronTests.swift @@ -15,7 +15,7 @@ struct SchematronTests { """ - 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 = "A" 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 = "A" - 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")