From a76e9b3da33154bd3952b0fc6f32eb7beef18839 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 16:30:13 +0200 Subject: [PATCH] feat(schematron): diagnostics An assert/report may reference 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. is collected schema-wide. Schema/pattern-level let, abstract patterns, and key()/document() remain. Part of #80 --- Sources/Validation/Schematron.swift | 50 ++++++++++++++----- Sources/Validation/SchematronModel.swift | 6 +++ Sources/Validation/SchematronParser.swift | 16 ++++++- Tests/SchematronDiagnosticTests.swift | 58 +++++++++++++++++++++++ 4 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 Tests/SchematronDiagnosticTests.swift diff --git a/Sources/Validation/Schematron.swift b/Sources/Validation/Schematron.swift index f916b9b..1f0d56a 100644 --- a/Sources/Validation/Schematron.swift +++ b/Sources/Validation/Schematron.swift @@ -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.blank.validating(Self.rule(activePatterns(phase: phase))).errors(for: node, in: ()) + let validation = Self.rule(activePatterns(phase: phase), diagnostics: schema.diagnostics) + return Validator.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 { + static func rule(_ patterns: [SchematronPattern], diagnostics: [String: [SchematronMessagePart]]) -> Validation { .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 = [] 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 `` 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, /// `` as the string value of its XPath, `` as the node's /// name. Whitespace runs are collapsed so the rendered message reads diff --git a/Sources/Validation/SchematronModel.swift b/Sources/Validation/SchematronModel.swift index ad5b55f..bd17a9f 100644 --- a/Sources/Validation/SchematronModel.swift +++ b/Sources/Validation/SchematronModel.swift @@ -15,6 +15,9 @@ extension PureXML.Validation { let isReport: Bool let test: PureXML.XPath.Query let message: [SchematronMessagePart] + /// The ids of ``s referenced by the `diagnostics=` attribute, + /// appended to a failure's message for extra detail. + let diagnostics: [String] } /// A `` 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? + /// `` message templates, keyed by id, referenced from an + /// assertion's `diagnostics=` attribute. + let diagnostics: [String: [SchematronMessagePart]] } } diff --git a/Sources/Validation/SchematronParser.swift b/Sources/Validation/SchematronParser.swift index 2fb5e07..0b19d59 100644 --- a/Sources/Validation/SchematronParser.swift +++ b/Sources/Validation/SchematronParser.swift @@ -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 `` 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, `` an XPath part, and `` a /// context-name part. The parts render against the context node at diff --git a/Tests/SchematronDiagnosticTests.swift b/Tests/SchematronDiagnosticTests.swift new file mode 100644 index 0000000..d04331b --- /dev/null +++ b/Tests/SchematronDiagnosticTests.swift @@ -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 = """ + + + + age too high + + + + got + + + """ + let errors = try validate(schema, "120") + #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 = """ + + + + bad + + + + first + second + + + """ + #expect(try validate(schema, "").first?.reason == "bad first second") + } + + @Test("An assertion with no diagnostics renders only its message") + func test_noDiagnostics() throws { + let schema = """ + + + plain + + + """ + #expect(try validate(schema, "").first?.reason == "plain") + } +}