feat(schematron): support key(), document(), and current() in tests

Schematron tests, lets, and message expressions can now use key() backed
by xsl:key declarations parsed from the schema, document() loading an
external file through an injected documentLoader, and current() resolving
to the rule context node. A node-keyed function-table factory threads
through the evaluation. Completes the Schematron audit.
This commit is contained in:
Mihaela Mihaljevic 2026-06-09 03:30:43 +02:00
parent e1a0b5698c
commit 2db9915fb2
5 changed files with 169 additions and 15 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Schematron `key()`, `document()`, and `current()` in tests (#80), completing the Schematron audit. Test, let, and message expressions can now resolve nodes through `xsl:key` declarations, load an external document for cross-document checks (via an injected `documentLoader`), and refer to the rule context node with `current()`.
- RELAX NG compact `>>` follow-annotations (#79), completing the RNC syntax. A `>>` annotation after a pattern (a foreign element name with optional `[ … ]` content) is skipped, chaining and combining with leading `[ … ]` annotations; both carry no schema semantics. (Namespace declarations inside annotations are already dropped by the annotation skipper.)
- XSLT `document()` fragments and base-URI resolution (#82), completing the XSLT function/output audit. A `#fragment` selects a subset of the loaded document via XPointer, a node-set first argument unions the documents named by each node, and a relative URI is resolved against a caller-supplied `baseURI` (also applied to `xsl:include`/`xsl:import`).
- XSLT result-tree fragments usable as node-sets (#82). An `xsl:variable` with a body now binds a queryable document fragment instead of a plain string, so `count($rtf/*)`, `$rtf/child`, and `xsl:for-each select="$rtf/..."` work; it still has its concatenated text as a string value.

View File

@ -1,3 +1,10 @@
/// A node-keyed function-table factory, so `current()`, `key()`, and `document()`
/// are available in every Schematron test, let, and message expression. File scope
/// and private.
private typealias Functions = (PureXML.Model.TreeNode) -> PureXML.XPath.FunctionTable
/// An `xsl:key` index: key name to value to the matched nodes. File scope and private.
private typealias KeyIndex = [String: [String: [PureXML.Model.TreeNode]]]
public extension PureXML.Validation {
/// Rule-based assertion validation (the libxml2 `schematron.h` model).
///
@ -26,9 +33,9 @@ public extension PureXML.Validation {
/// are errors and reports are warnings. With `phase` (or the schema's
/// `defaultPhase`), only the patterns that phase activates run; nil or
/// `#ALL` runs every pattern.
public func validate(_ xml: String, phase: String? = nil) throws -> [ValidationError] {
public func validate(_ xml: String, phase: String? = nil, documentLoader: @escaping (String) -> String? = { _ in nil }) throws -> [ValidationError] {
let node = try PureXML.parse(xml)
let validation = Self.rule(activePatterns(phase: phase), schemaLets: schema.lets, diagnostics: schema.diagnostics)
let validation = Self.rule(activePatterns(phase: phase), schemaLets: schema.lets, diagnostics: schema.diagnostics, keys: schema.keys, loader: documentLoader)
return Validator<Void>.blank.validating(validation).errors(for: node, in: ())
}
@ -49,25 +56,60 @@ public extension PureXML.Validation {
_ patterns: [SchematronPattern],
schemaLets: [SchematronLet],
diagnostics: [String: [SchematronMessagePart]],
keys: [SchematronKey] = [],
loader: @escaping (String) -> String? = { _ in nil },
) -> Validation<PureXML.Model.Node, Void> {
.init(
description: "Document satisfies the Schematron schema",
check: { context in
let root = PureXML.Model.TreeNode(context.subject)
let base = bindings(schemaLets, at: root, base: [:])
return evaluate(patterns, over: root, base: base, diagnostics: diagnostics)
let keyIndex = keyIndex(keys, root)
let functions: Functions = { node in functionTable(at: node, keyIndex: keyIndex, loader: loader) }
let base = bindings(schemaLets, at: root, base: [:], functions)
return evaluate(patterns, over: root, base: base, diagnostics: diagnostics, functions)
},
when: { $0.codingPath.isEmpty },
)
}
/// Indexes every `xsl:key`-matched node by its `use` value, so `key()` can
/// resolve a name and value to nodes.
private static func keyIndex(_ keys: [SchematronKey], _ root: PureXML.Model.TreeNode) -> KeyIndex {
var index: KeyIndex = [:]
for key in keys {
for node in key.match.nodes(over: root) {
let value = (try? key.use.value(at: node).string) ?? ""
index[key.name, default: [:]][value, default: []].append(node)
}
}
return index
}
/// The XSLT-style functions for a context node: `current`, `key` (over the
/// prebuilt index), and `document` (through the injected loader).
private static func functionTable(at node: PureXML.Model.TreeNode, keyIndex: KeyIndex, loader: @escaping (String) -> String?) -> PureXML.XPath.FunctionTable {
PureXML.XPath.FunctionTable()
.adding("current") { _, _ in .nodeSet([.tree(node)]) }
.adding("key") { arguments, _ in
let name = arguments.first?.string ?? ""
let value = arguments.count > 1 ? arguments[1].string : ""
return .nodeSet((keyIndex[name]?[value] ?? []).map { .tree($0) })
}
.adding("document") { arguments, _ in
guard let uri = arguments.first?.string, let text = loader(uri),
let parsed = try? PureXML.parse(text) else { return .nodeSet([]) }
return .nodeSet([.tree(PureXML.Model.TreeNode(parsed))])
}
}
private static func evaluate(
_ patterns: [SchematronPattern],
over root: PureXML.Model.TreeNode,
base: [String: PureXML.XPath.Value],
diagnostics: [String: [SchematronMessagePart]],
_ functions: Functions,
) -> [ValidationError] {
patterns.flatMap { evaluate($0, over: root, base: base, diagnostics: diagnostics) }
patterns.flatMap { evaluate($0, over: root, base: base, diagnostics: diagnostics, functions) }
}
private static func evaluate(
@ -75,15 +117,16 @@ public extension PureXML.Validation {
over root: PureXML.Model.TreeNode,
base: [String: PureXML.XPath.Value],
diagnostics: [String: [SchematronMessagePart]],
_ functions: Functions,
) -> [ValidationError] {
// Pattern-level lets extend the schema-level base, evaluated at the root.
let patternBase = bindings(pattern.lets, at: root, base: base)
let patternBase = bindings(pattern.lets, at: root, base: base, functions)
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 {
let variables = bindings(rule.lets, at: node, base: patternBase)
errors += apply(rule.assertions, at: node, variables: variables, diagnostics: diagnostics)
let variables = bindings(rule.lets, at: node, base: patternBase, functions)
errors += apply(rule.assertions, at: node, variables: variables, diagnostics: diagnostics, functions)
}
}
return errors
@ -92,10 +135,15 @@ public extension PureXML.Validation {
/// Evaluates `<let>` bindings over `base` at `node`, in order, so a later
/// binding can reference an earlier one (or a wider-scope one) through
/// `$name`.
private static func bindings(_ lets: [SchematronLet], at node: PureXML.Model.TreeNode, base: [String: PureXML.XPath.Value]) -> [String: PureXML.XPath.Value] {
private static func bindings(
_ lets: [SchematronLet],
at node: PureXML.Model.TreeNode,
base: [String: PureXML.XPath.Value],
_ functions: Functions,
) -> [String: PureXML.XPath.Value] {
var variables = base
for binding in lets {
if let value = try? binding.value.value(at: node, variables: variables) {
if let value = try? binding.value.value(at: node, position: 1, size: 1, variables: variables, functions: functions(node)) {
variables[binding.name] = value
}
}
@ -107,14 +155,15 @@ public extension PureXML.Validation {
at node: PureXML.Model.TreeNode,
variables: [String: PureXML.XPath.Value],
diagnostics: [String: [SchematronMessagePart]],
_ functions: Functions,
) -> [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
let holds = (try? assertion.test.value(at: node, position: 1, size: 1, variables: variables, functions: functions(node)).boolean) ?? false
let flagged = assertion.isReport ? holds : !holds
guard flagged else { continue }
let reason = reason(assertion, at: node, variables: variables, diagnostics: diagnostics)
let reason = reason(assertion, at: node, variables: variables, diagnostics: diagnostics, functions)
errors.append(ValidationError(reason: reason, at: path, severity: assertion.isReport ? .warning : .error))
}
return errors
@ -127,11 +176,12 @@ public extension PureXML.Validation {
at node: PureXML.Model.TreeNode,
variables: [String: PureXML.XPath.Value],
diagnostics: [String: [SchematronMessagePart]],
_ functions: Functions,
) -> String {
var text = render(assertion.message, at: node, variables: variables)
var text = render(assertion.message, at: node, variables: variables, functions)
for id in assertion.diagnostics {
guard let parts = diagnostics[id] else { continue }
let rendered = render(parts, at: node, variables: variables)
let rendered = render(parts, at: node, variables: variables, functions)
if !rendered.isEmpty { text += " " + rendered }
}
return text
@ -145,11 +195,12 @@ public extension PureXML.Validation {
_ parts: [SchematronMessagePart],
at node: PureXML.Model.TreeNode,
variables: [String: PureXML.XPath.Value],
_ functions: Functions,
) -> String {
let joined = parts.map { part -> String in
switch part {
case let .text(text): text
case let .valueOf(query): (try? query.value(at: node, variables: variables).string) ?? ""
case let .valueOf(query): (try? query.value(at: node, position: 1, size: 1, variables: variables, functions: functions(node)).string) ?? ""
case .name: node.name?.description ?? ""
}
}.joined()

View File

@ -27,6 +27,15 @@ extension PureXML.Validation {
let value: PureXML.XPath.Query
}
/// An `xsl:key` declaration (the XSLT query binding): its name, the compiled
/// match selecting indexed nodes, and the `use` expression giving each node's
/// key value, so the `key()` function can look nodes up by value.
struct SchematronKey {
let name: String
let match: PureXML.XPath.Query
let use: PureXML.XPath.Query
}
/// One `<rule>`: a compiled context that selects the nodes the rule fires on,
/// its `<let>` variable bindings, and its assertions.
struct SchematronRule {
@ -56,5 +65,7 @@ extension PureXML.Validation {
/// `<diagnostic id=>` message templates, keyed by id, referenced from an
/// assertion's `diagnostics=` attribute.
let diagnostics: [String: [SchematronMessagePart]]
/// `xsl:key` declarations backing the `key()` function in tests.
let keys: [SchematronKey]
}
}

View File

@ -20,9 +20,23 @@ extension PureXML.Validation {
defaultPhase: attribute(schema, "defaultPhase"),
lets: ruleLets(schema),
diagnostics: diagnostics(root),
keys: keys(root),
)
}
/// The `xsl:key` declarations (matched by local name `key`, so the XSLT
/// namespace is not required) that back the `key()` function.
private static func keys(_ root: PureXML.Model.TreeNode) throws -> [SchematronKey] {
try descendants(root, localName: "key").compactMap { node in
guard let name = attribute(node, "name"), let match = attribute(node, "match") else { return nil }
return try SchematronKey(
name: name,
match: PureXML.XPath.Query(contextExpression(match)),
use: PureXML.XPath.Query(attribute(node, "use") ?? "."),
)
}
}
/// Builds the concrete patterns: an `abstract="true"` pattern is a template
/// held aside, an `is-a` pattern is instantiated from its template with each
/// `<param name= value=>` substituted for the matching `$name` reference in

View File

@ -0,0 +1,77 @@
@testable import PureXML
import Testing
@Suite("Schematron key()/document()/current() in tests")
struct SchematronFunctionTests {
private func validate(_ schema: String, _ xml: String, loader: @escaping (String) -> String? = { _ in nil }) throws -> [PureXML.Validation.ValidationError] {
try PureXML.Validation.Schematron(schema: schema).validate(xml, documentLoader: loader)
}
private let sch = "http://purl.oclc.org/dsdl/schematron"
private let xsl = "http://www.w3.org/1999/XSL/Transform"
@Test("key() resolves a node by an xsl:key index")
func test_key() throws {
let schema = """
<schema xmlns="\(sch)" xmlns:xsl="\(xsl)">
<xsl:key name="byId" match="item" use="@id"/>
<pattern>
<rule context="ref">
<assert test="key('byId', @to)">no item with that id</assert>
</rule>
</pattern>
</schema>
"""
// ref/@to=a resolves to item id=a; ref/@to=z resolves to nothing -> one failure.
let xml = "<root><item id='a'/><item id='b'/><ref to='a'/><ref to='z'/></root>"
let errors = try validate(schema, xml)
#expect(errors.count == 1)
#expect(errors.first?.reason.contains("no item") == true)
}
@Test("document() loads an external file for cross-document checks")
func test_document() throws {
let schema = """
<schema xmlns="\(sch)">
<pattern>
<rule context="ref">
<assert test="@to = document('allowed.xml')/allowed/id">id is not in the allowed list</assert>
</rule>
</pattern>
</schema>
"""
let allowed = "<allowed><id>a</id><id>b</id></allowed>"
let xml = "<root><ref to='a'/><ref to='x'/></root>"
let errors = try validate(schema, xml) { $0 == "allowed.xml" ? allowed : nil }
#expect(errors.count == 1)
#expect(errors.first?.reason.contains("not in the allowed") == true)
}
@Test("current() refers to the rule's context node inside a predicate")
func test_current() throws {
let schema = """
<schema xmlns="\(sch)">
<pattern>
<rule context="item">
<assert test="//item[@id = current()/@ref]">the referenced item must exist</assert>
</rule>
</pattern>
</schema>
"""
// item ref=b resolves (item id=b exists); item ref=z does not -> one failure.
let xml = "<root><item id='a' ref='b'/><item id='b' ref='z'/></root>"
let errors = try validate(schema, xml)
#expect(errors.count == 1)
}
@Test("A schema without these functions still validates")
func test_plain() throws {
let schema = """
<schema xmlns="\(sch)">
<pattern><rule context="n"><assert test="number(.) &lt; 10">too big</assert></rule></pattern>
</schema>
"""
#expect(try validate(schema, "<n>5</n>").isEmpty)
#expect(try !validate(schema, "<n>50</n>").isEmpty)
}
}