feat(schematron): rule-level let variable bindings

<rule> may now declare <let name= value=> 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 <value-of select="$name"> 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
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 16:23:26 +02:00
parent c7802ed9d0
commit d0671e40ba
4 changed files with 102 additions and 9 deletions

View File

@ -61,21 +61,37 @@ public extension PureXML.Validation {
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)
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 `<let>` 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 {
/// `<value-of>` as the string value of its XPath, `<name>` 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()

View File

@ -17,10 +17,18 @@ extension PureXML.Validation {
let message: [SchematronMessagePart]
}
/// A `<let name= value=>` 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 `<rule>`: a compiled context that selects the nodes the rule fires on,
/// and its assertions.
/// its `<let>` variable bindings, and its assertions.
struct SchematronRule {
let context: PureXML.XPath.Query
let lets: [SchematronLet]
let assertions: [SchematronAssertion]
}

View File

@ -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? {

View File

@ -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 = """
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="order">
<let name="total" value="sum(item/@price)"/>
<assert test="$total &lt;= 100">order total exceeds 100</assert>
</rule>
</pattern>
</schema>
"""
#expect(try validate(schema, "<order><item price=\"40\"/><item price=\"50\"/></order>").isEmpty)
#expect(try !validate(schema, "<order><item price=\"60\"/><item price=\"50\"/></order>").isEmpty)
}
@Test("A let binding can be rendered in a message")
func test_letInMessage() throws {
let schema = """
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="age">
<let name="limit" value="100"/>
<assert test="number(.) &lt; $limit">Age <value-of select="."/> reaches the limit <value-of select="$limit"/></assert>
</rule>
</pattern>
</schema>
"""
let errors = try validate(schema, "<age>120</age>")
#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 = """
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="box">
<let name="base" value="number(@n)"/>
<let name="doubled" value="$base * 2"/>
<assert test="$doubled = 10">doubled is not ten</assert>
</rule>
</pattern>
</schema>
"""
#expect(try validate(schema, "<box n=\"5\"/>").isEmpty)
#expect(try !validate(schema, "<box n=\"3\"/>").isEmpty)
}
}