From 2db9915fb2c8dc4a3b38db91d794021be9683605 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 9 Jun 2026 03:30:43 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + Sources/Validation/Schematron.swift | 81 ++++++++++++++++++----- Sources/Validation/SchematronModel.swift | 11 +++ Sources/Validation/SchematronParser.swift | 14 ++++ Tests/SchematronFunctionTests.swift | 77 +++++++++++++++++++++ 5 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 Tests/SchematronFunctionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2edeb2e..ff8c774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/Validation/Schematron.swift b/Sources/Validation/Schematron.swift index d70b299..f327a88 100644 --- a/Sources/Validation/Schematron.swift +++ b/Sources/Validation/Schematron.swift @@ -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.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 { .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 = [] 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 `` 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() diff --git a/Sources/Validation/SchematronModel.swift b/Sources/Validation/SchematronModel.swift index 10a8bba..2aea675 100644 --- a/Sources/Validation/SchematronModel.swift +++ b/Sources/Validation/SchematronModel.swift @@ -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 ``: a compiled context that selects the nodes the rule fires on, /// its `` variable bindings, and its assertions. struct SchematronRule { @@ -56,5 +65,7 @@ extension PureXML.Validation { /// `` 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] } } diff --git a/Sources/Validation/SchematronParser.swift b/Sources/Validation/SchematronParser.swift index 81e4e0c..0424e0d 100644 --- a/Sources/Validation/SchematronParser.swift +++ b/Sources/Validation/SchematronParser.swift @@ -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 /// `` substituted for the matching `$name` reference in diff --git a/Tests/SchematronFunctionTests.swift b/Tests/SchematronFunctionTests.swift new file mode 100644 index 0000000..93c0f1d --- /dev/null +++ b/Tests/SchematronFunctionTests.swift @@ -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 = """ + + + + + no item with that id + + + + """ + // ref/@to=a resolves to item id=a; ref/@to=z resolves to nothing -> one failure. + let xml = "" + 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 = """ + + + + id is not in the allowed list + + + + """ + let allowed = "ab" + let xml = "" + 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 = """ + + + + the referenced item must exist + + + + """ + // item ref=b resolves (item id=b exists); item ref=z does not -> one failure. + let xml = "" + let errors = try validate(schema, xml) + #expect(errors.count == 1) + } + + @Test("A schema without these functions still validates") + func test_plain() throws { + let schema = """ + + too big + + """ + #expect(try validate(schema, "5").isEmpty) + #expect(try !validate(schema, "50").isEmpty) + } +}