From d0671e40baa91346352b8768809d34f11f19f06b Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 16:23:26 +0200 Subject: [PATCH] feat(schematron): rule-level let variable bindings may now declare bindings, evaluated at each context node in order (so a later binding can reference an earlier one through $name). The bindings are available to the rule's assert/report tests and to in messages, using the XPath engine's existing variable support. Schema- and pattern-level let, abstract rules/extends, diagnostics, and key()/document() remain. Part of #80 --- Sources/Validation/Schematron.swift | 34 ++++++++++--- Sources/Validation/SchematronModel.swift | 10 +++- Sources/Validation/SchematronParser.swift | 8 ++- Tests/SchematronLetTests.swift | 59 +++++++++++++++++++++++ 4 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 Tests/SchematronLetTests.swift diff --git a/Sources/Validation/Schematron.swift b/Sources/Validation/Schematron.swift index 1e9e865..f916b9b 100644 --- a/Sources/Validation/Schematron.swift +++ b/Sources/Validation/Schematron.swift @@ -61,21 +61,37 @@ public extension PureXML.Validation { 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) + errors += apply(rule.assertions, at: node, variables: bindings(rule.lets, at: node)) } } return errors } - private static func apply(_ assertions: [SchematronAssertion], at node: PureXML.Model.TreeNode) -> [ValidationError] { + /// Evaluates a rule's `` bindings at the context node, in order, so a + /// later binding can reference an earlier one through `$name`. + private static func bindings(_ lets: [SchematronLet], at node: PureXML.Model.TreeNode) -> [String: PureXML.XPath.Value] { + var variables: [String: PureXML.XPath.Value] = [:] + for binding in lets { + if let value = try? binding.value.value(at: node, variables: variables) { + variables[binding.name] = value + } + } + return variables + } + + private static func apply( + _ assertions: [SchematronAssertion], + at node: PureXML.Model.TreeNode, + variables: [String: PureXML.XPath.Value], + ) -> [ValidationError] { let path = codingPath(of: node) var errors: [ValidationError] = [] for assertion in assertions { - let holds = (try? assertion.test.value(at: node).boolean) ?? false + 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), at: path, severity: .warning)) } + 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), at: path, severity: .error)) + errors.append(ValidationError(reason: render(assertion.message, at: node, variables: variables), at: path, severity: .error)) } } return errors @@ -85,11 +101,15 @@ public extension PureXML.Validation { /// `` as the string value of its XPath, `` as the node's /// name. Whitespace runs are collapsed so the rendered message reads /// cleanly regardless of source indentation. - private static func render(_ parts: [SchematronMessagePart], at node: PureXML.Model.TreeNode) -> String { + private static func render( + _ parts: [SchematronMessagePart], + at node: PureXML.Model.TreeNode, + variables: [String: PureXML.XPath.Value], + ) -> String { let joined = parts.map { part -> String in switch part { case let .text(text): text - case let .valueOf(query): (try? query.value(at: node).string) ?? "" + case let .valueOf(query): (try? query.value(at: node, variables: variables).string) ?? "" case .name: node.name?.description ?? "" } }.joined() diff --git a/Sources/Validation/SchematronModel.swift b/Sources/Validation/SchematronModel.swift index e4312ff..ad5b55f 100644 --- a/Sources/Validation/SchematronModel.swift +++ b/Sources/Validation/SchematronModel.swift @@ -17,10 +17,18 @@ extension PureXML.Validation { let message: [SchematronMessagePart] } + /// A `` variable binding: its name and the compiled XPath + /// whose value is bound to `$name` for the rule's assertions. + struct SchematronLet { + let name: String + let value: PureXML.XPath.Query + } + /// One ``: a compiled context that selects the nodes the rule fires on, - /// and its assertions. + /// its `` variable bindings, and its assertions. struct SchematronRule { let context: PureXML.XPath.Query + let lets: [SchematronLet] let assertions: [SchematronAssertion] } diff --git a/Sources/Validation/SchematronParser.swift b/Sources/Validation/SchematronParser.swift index 61e0b0a..fe00a5b 100644 --- a/Sources/Validation/SchematronParser.swift +++ b/Sources/Validation/SchematronParser.swift @@ -30,10 +30,16 @@ extension PureXML.Validation { private static func rule(_ node: PureXML.Model.TreeNode) throws -> SchematronRule? { guard let context = attribute(node, "context") else { return nil } + let lets = try children(node, localName: "let").compactMap(letBinding) let assertions = try node.children .filter { $0.kind == .element && isAssertion($0) } .compactMap(assertion) - return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), assertions: assertions) + return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), lets: lets, assertions: assertions) + } + + private static func letBinding(_ node: PureXML.Model.TreeNode) throws -> SchematronLet? { + guard let name = attribute(node, "name"), let value = attribute(node, "value") else { return nil } + return try SchematronLet(name: name, value: PureXML.XPath.Query(value)) } private static func assertion(_ node: PureXML.Model.TreeNode) throws -> SchematronAssertion? { diff --git a/Tests/SchematronLetTests.swift b/Tests/SchematronLetTests.swift new file mode 100644 index 0000000..62cc811 --- /dev/null +++ b/Tests/SchematronLetTests.swift @@ -0,0 +1,59 @@ +@testable import PureXML +import Testing + +@Suite("Schematron let variables") +struct SchematronLetTests { + private func validate(_ schema: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] { + try PureXML.Validation.Schematron(schema: schema).validate(xml) + } + + @Test("A let binding is available to the rule's test") + func test_letInTest() throws { + let schema = """ + + + + + order total exceeds 100 + + + + """ + #expect(try validate(schema, "").isEmpty) + #expect(try !validate(schema, "").isEmpty) + } + + @Test("A let binding can be rendered in a message") + func test_letInMessage() throws { + let schema = """ + + + + + Age reaches the limit + + + + """ + let errors = try validate(schema, "120") + #expect(errors.count == 1) + #expect(errors.first?.reason == "Age 120 reaches the limit 100") + } + + @Test("A later let can reference an earlier one") + func test_letChaining() throws { + let schema = """ + + + + + + doubled is not ten + + + + """ + #expect(try validate(schema, "").isEmpty) + #expect(try !validate(schema, "").isEmpty) + } +}