feat(schematron): diagnostics
An assert/report may reference <diagnostic> messages by id through its diagnostics= attribute; each referenced diagnostic is rendered (with the same value-of/name/let substitution) and appended to the finding for extra per-failure detail. <diagnostics> is collected schema-wide. Schema/pattern-level let, abstract patterns, and key()/document() remain. Part of #80
This commit is contained in:
parent
27d8eb5e16
commit
a76e9b3da3
@ -28,7 +28,8 @@ public extension PureXML.Validation {
|
||||
/// `#ALL` runs every pattern.
|
||||
public func validate(_ xml: String, phase: String? = nil) throws -> [ValidationError] {
|
||||
let node = try PureXML.parse(xml)
|
||||
return Validator<Void>.blank.validating(Self.rule(activePatterns(phase: phase))).errors(for: node, in: ())
|
||||
let validation = Self.rule(activePatterns(phase: phase), diagnostics: schema.diagnostics)
|
||||
return Validator<Void>.blank.validating(validation).errors(for: node, in: ())
|
||||
}
|
||||
|
||||
/// The patterns active in the requested phase, falling back to the schema's
|
||||
@ -44,24 +45,32 @@ public extension PureXML.Validation {
|
||||
|
||||
/// 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> {
|
||||
static func rule(_ patterns: [SchematronPattern], diagnostics: [String: [SchematronMessagePart]]) -> Validation<PureXML.Model.Node, Void> {
|
||||
.init(
|
||||
description: "Document satisfies the Schematron schema",
|
||||
check: { context in evaluate(patterns, over: PureXML.Model.TreeNode(context.subject)) },
|
||||
check: { context in evaluate(patterns, over: PureXML.Model.TreeNode(context.subject), diagnostics: diagnostics) },
|
||||
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(
|
||||
_ patterns: [SchematronPattern],
|
||||
over root: PureXML.Model.TreeNode,
|
||||
diagnostics: [String: [SchematronMessagePart]],
|
||||
) -> [ValidationError] {
|
||||
patterns.flatMap { evaluate($0, over: root, diagnostics: diagnostics) }
|
||||
}
|
||||
|
||||
private static func evaluate(_ pattern: SchematronPattern, over root: PureXML.Model.TreeNode) -> [ValidationError] {
|
||||
private static func evaluate(
|
||||
_ pattern: SchematronPattern,
|
||||
over root: PureXML.Model.TreeNode,
|
||||
diagnostics: [String: [SchematronMessagePart]],
|
||||
) -> [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, variables: bindings(rule.lets, at: node))
|
||||
errors += apply(rule.assertions, at: node, variables: bindings(rule.lets, at: node), diagnostics: diagnostics)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
@ -83,20 +92,37 @@ public extension PureXML.Validation {
|
||||
_ assertions: [SchematronAssertion],
|
||||
at node: PureXML.Model.TreeNode,
|
||||
variables: [String: PureXML.XPath.Value],
|
||||
diagnostics: [String: [SchematronMessagePart]],
|
||||
) -> [ValidationError] {
|
||||
let path = codingPath(of: node)
|
||||
var errors: [ValidationError] = []
|
||||
for assertion in assertions {
|
||||
let holds = (try? assertion.test.value(at: node, variables: variables).boolean) ?? false
|
||||
if assertion.isReport {
|
||||
if holds { errors.append(ValidationError(reason: render(assertion.message, at: node, variables: variables), at: path, severity: .warning)) }
|
||||
} else if !holds {
|
||||
errors.append(ValidationError(reason: render(assertion.message, at: node, variables: variables), at: path, severity: .error))
|
||||
}
|
||||
let flagged = assertion.isReport ? holds : !holds
|
||||
guard flagged else { continue }
|
||||
let reason = reason(assertion, at: node, variables: variables, diagnostics: diagnostics)
|
||||
errors.append(ValidationError(reason: reason, at: path, severity: assertion.isReport ? .warning : .error))
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/// The finding text: the assertion's rendered message, followed by the
|
||||
/// rendered text of each `<diagnostic>` it references, for extra detail.
|
||||
private static func reason(
|
||||
_ assertion: SchematronAssertion,
|
||||
at node: PureXML.Model.TreeNode,
|
||||
variables: [String: PureXML.XPath.Value],
|
||||
diagnostics: [String: [SchematronMessagePart]],
|
||||
) -> String {
|
||||
var text = render(assertion.message, at: node, variables: variables)
|
||||
for id in assertion.diagnostics {
|
||||
guard let parts = diagnostics[id] else { continue }
|
||||
let rendered = render(parts, at: node, variables: variables)
|
||||
if !rendered.isEmpty { text += " " + rendered }
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/// Renders a message template against the context node: literal text as is,
|
||||
/// `<value-of>` as the string value of its XPath, `<name>` as the node's
|
||||
/// name. Whitespace runs are collapsed so the rendered message reads
|
||||
|
||||
@ -15,6 +15,9 @@ extension PureXML.Validation {
|
||||
let isReport: Bool
|
||||
let test: PureXML.XPath.Query
|
||||
let message: [SchematronMessagePart]
|
||||
/// The ids of `<diagnostic>`s referenced by the `diagnostics=` attribute,
|
||||
/// appended to a failure's message for extra detail.
|
||||
let diagnostics: [String]
|
||||
}
|
||||
|
||||
/// A `<let name= value=>` variable binding: its name and the compiled XPath
|
||||
@ -46,5 +49,8 @@ extension PureXML.Validation {
|
||||
let patterns: [SchematronPattern]
|
||||
let phases: [String: [String]]
|
||||
let defaultPhase: String?
|
||||
/// `<diagnostic id=>` message templates, keyed by id, referenced from an
|
||||
/// assertion's `diagnostics=` attribute.
|
||||
let diagnostics: [String: [SchematronMessagePart]]
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,10 +15,11 @@ extension PureXML.Validation {
|
||||
let root = try PureXML.parseTree(xml)
|
||||
let schema = descendants(root, localName: "schema").first ?? root
|
||||
let patterns = try descendants(root, localName: "pattern").map(pattern)
|
||||
return SchematronSchema(
|
||||
return try SchematronSchema(
|
||||
patterns: patterns,
|
||||
phases: phases(root),
|
||||
defaultPhase: attribute(schema, "defaultPhase"),
|
||||
diagnostics: diagnostics(root),
|
||||
)
|
||||
}
|
||||
|
||||
@ -76,13 +77,26 @@ extension PureXML.Validation {
|
||||
|
||||
private static func assertion(_ node: PureXML.Model.TreeNode) throws -> SchematronAssertion? {
|
||||
guard let test = attribute(node, "test") else { return nil }
|
||||
let referenced = attribute(node, "diagnostics")?.split(whereSeparator: \.isWhitespace).map(String.init) ?? []
|
||||
return try SchematronAssertion(
|
||||
isReport: node.name?.localName == "report",
|
||||
test: PureXML.XPath.Query(test),
|
||||
message: messageParts(node),
|
||||
diagnostics: referenced,
|
||||
)
|
||||
}
|
||||
|
||||
/// The `<diagnostic id=>` message templates, keyed by id.
|
||||
private static func diagnostics(_ root: PureXML.Model.TreeNode) throws -> [String: [SchematronMessagePart]] {
|
||||
var table: [String: [SchematronMessagePart]] = [:]
|
||||
for diagnostic in descendants(root, localName: "diagnostic") {
|
||||
if let id = attribute(diagnostic, "id") {
|
||||
table[id] = try messageParts(diagnostic)
|
||||
}
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
/// Builds an assertion message template from a node's children: text nodes
|
||||
/// become literal parts, `<value-of select=>` an XPath part, and `<name>` a
|
||||
/// context-name part. The parts render against the context node at
|
||||
|
||||
58
Tests/SchematronDiagnosticTests.swift
Normal file
58
Tests/SchematronDiagnosticTests.swift
Normal file
@ -0,0 +1,58 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("Schematron diagnostics")
|
||||
struct SchematronDiagnosticTests {
|
||||
private func validate(_ schema: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] {
|
||||
try PureXML.Validation.Schematron(schema: schema).validate(xml)
|
||||
}
|
||||
|
||||
@Test("A referenced diagnostic is appended to the failure message")
|
||||
func test_diagnostic() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule context="age">
|
||||
<assert test="number(.) < 100" diagnostics="d1">age too high</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
<diagnostics>
|
||||
<diagnostic id="d1">got <value-of select="."/></diagnostic>
|
||||
</diagnostics>
|
||||
</schema>
|
||||
"""
|
||||
let errors = try validate(schema, "<age>120</age>")
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "age too high got 120")
|
||||
}
|
||||
|
||||
@Test("Multiple diagnostics are appended in order")
|
||||
func test_multipleDiagnostics() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule context="x">
|
||||
<assert test="false()" diagnostics="a b">bad</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
<diagnostics>
|
||||
<diagnostic id="a">first</diagnostic>
|
||||
<diagnostic id="b">second</diagnostic>
|
||||
</diagnostics>
|
||||
</schema>
|
||||
"""
|
||||
#expect(try validate(schema, "<x/>").first?.reason == "bad first second")
|
||||
}
|
||||
|
||||
@Test("An assertion with no diagnostics renders only its message")
|
||||
func test_noDiagnostics() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule context="x"><assert test="false()">plain</assert></rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
#expect(try validate(schema, "<x/>").first?.reason == "plain")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user