From 80fffd8127e645880d60a3cf1ee1c7df2b377183 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 05:58:55 +0200 Subject: [PATCH] feat(validation): Schematron rule validation Add PureXML.Validation.Schematron: compile a Schematron schema (matching schema/pattern/rule/assert/report by local name, so the ISO and legacy namespaces both work) and validate a document. A rule's context XPath selects the nodes it fires on; each assert/report test is evaluated relative to the node via the XPath context-node API. A failed assert is an error, a matched report a warning; within a pattern a node fires the first matching rule. Adds XPath.Query.nodes(over:) returning matched tree nodes so one tree can be queried repeatedly. Closes #52 Closes #53 Closes #25 --- CHANGELOG.md | 9 +++ README.md | 2 +- Sources/Validation/Schematron.swift | 53 ++++++++++++++++ Sources/Validation/SchematronModel.swift | 22 +++++++ Sources/Validation/SchematronParser.swift | 62 ++++++++++++++++++ Sources/XPath/Evaluator.swift | 17 +++++ Sources/XPath/Query.swift | 8 +++ Tests/SchematronTests.swift | 76 +++++++++++++++++++++++ 8 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 Sources/Validation/Schematron.swift create mode 100644 Sources/Validation/SchematronModel.swift create mode 100644 Sources/Validation/SchematronParser.swift create mode 100644 Tests/SchematronTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0220ea1..62ae4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Schematron validation (#25, the libxml2 `schematron.h` model). + `PureXML.Validation.Schematron(schema:)` compiles a Schematron schema + (namespace-agnostic over the ISO and legacy namespaces) and `validate(_:)` + checks a document: each ``'s `context` XPath selects the nodes it fires + on, and each ``/`` `test` is evaluated relative to that node, a + failed assert becoming an error issue and a matched report a warning. Within a + pattern a node fires the first matching rule. Built on the XPath context-node + evaluation, with a new `XPath.Query.nodes(over:)` that returns matched tree + nodes for repeated querying of one tree. - XPointer (#23, the libxml2 `xpointer.h` schemes). The new `PureXML.XPointer` namespace resolves the shorthand bare-name form (`name` = `id('name')`), the `element()` scheme (child-element positions from an id or the document root, diff --git a/README.md b/README.md index 5cdbcdf..1172a43 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ flowchart TB Querying["Querying: #21 XPath, #22 xmlPattern, #23 XPointer (shipped)"]:::done Regexp["#30 Regular expression engine"]:::epic Xsd["#2 XSD / RELAX NG validation"]:::epic - Schematron["#25 Schematron validation"]:::epic + Schematron["#25 Schematron validation"]:::done Regexp --> Xsd Querying --> Schematron ``` diff --git a/Sources/Validation/Schematron.swift b/Sources/Validation/Schematron.swift new file mode 100644 index 0000000..8675c9c --- /dev/null +++ b/Sources/Validation/Schematron.swift @@ -0,0 +1,53 @@ +public extension PureXML.Validation { + /// Rule-based assertion validation (the libxml2 `schematron.h` model). + /// + /// A Schematron schema groups ``s into ``s. Each rule has a + /// `context` XPath selecting the nodes it fires on, and ``/`` + /// children whose `test` XPath is evaluated relative to each context node. An + /// `assert` whose test is false, and a `report` whose test is true, produce an + /// ``Issue`` carrying the element's message. + /// + /// Within a pattern a node is processed by the first rule whose context + /// selects it. Dynamic message content (``, ``) is rendered as + /// its static text; the message substitution itself is not evaluated. + struct Schematron { + private let patterns: [SchematronPattern] + + /// Compiles a Schematron schema document. Throws if a context or test is + /// not a valid XPath expression. + public init(schema xml: String) throws { + patterns = try SchematronParser.parse(xml) + } + + /// Validates an XML document against the schema, returning one issue per + /// failed assertion and per matched report, in document order. + public func validate(_ xml: String) throws -> [Issue] { + let root = try PureXML.parseTree(xml) + var issues: [Issue] = [] + for pattern in patterns { + evaluate(pattern, over: root, into: &issues) + } + return issues + } + + private func evaluate(_ pattern: SchematronPattern, over root: PureXML.Model.TreeNode, into issues: inout [Issue]) { + var claimed: Set = [] + for rule in pattern.rules { + for node in rule.context.nodes(over: root) where claimed.insert(ObjectIdentifier(node)).inserted { + apply(rule.assertions, at: node, into: &issues) + } + } + } + + private func apply(_ assertions: [SchematronAssertion], at node: PureXML.Model.TreeNode, into issues: inout [Issue]) { + for assertion in assertions { + let holds = (try? assertion.test.value(at: node).boolean) ?? false + if assertion.isReport { + if holds { issues.append(Issue(severity: .warning, message: assertion.message)) } + } else if !holds { + issues.append(Issue(severity: .error, message: assertion.message)) + } + } + } + } +} diff --git a/Sources/Validation/SchematronModel.swift b/Sources/Validation/SchematronModel.swift new file mode 100644 index 0000000..6c2617a --- /dev/null +++ b/Sources/Validation/SchematronModel.swift @@ -0,0 +1,22 @@ +extension PureXML.Validation { + /// One `` or ``: a compiled XPath test and its message. An + /// assert flags when its test is false; a report flags when its test is true. + struct SchematronAssertion { + let isReport: Bool + let test: PureXML.XPath.Query + let message: String + } + + /// One ``: a compiled context that selects the nodes the rule fires on, + /// and its assertions. + struct SchematronRule { + let context: PureXML.XPath.Query + let assertions: [SchematronAssertion] + } + + /// One ``: a list of rules. A node is processed by the first rule in + /// the pattern whose context selects it (the ISO Schematron firing rule). + struct SchematronPattern { + let rules: [SchematronRule] + } +} diff --git a/Sources/Validation/SchematronParser.swift b/Sources/Validation/SchematronParser.swift new file mode 100644 index 0000000..25a2d40 --- /dev/null +++ b/Sources/Validation/SchematronParser.swift @@ -0,0 +1,62 @@ +extension PureXML.Validation { + /// Parses a Schematron schema document into patterns. Namespace-agnostic: it + /// matches the `schema`, `pattern`, `rule`, `assert`, and `report` elements by + /// local name, so both the ISO and the legacy Schematron namespaces work. + enum SchematronParser { + static func parse(_ xml: String) throws -> [SchematronPattern] { + let root = try PureXML.parseTree(xml) + return try descendants(root, localName: "pattern").map(pattern) + } + + private static func pattern(_ node: PureXML.Model.TreeNode) throws -> SchematronPattern { + try SchematronPattern(rules: children(node, localName: "rule").compactMap(rule)) + } + + private static func rule(_ node: PureXML.Model.TreeNode) throws -> SchematronRule? { + guard let context = attribute(node, "context") else { return nil } + let assertions = try node.children + .filter { $0.kind == .element && isAssertion($0) } + .compactMap(assertion) + return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), assertions: assertions) + } + + private static func assertion(_ node: PureXML.Model.TreeNode) throws -> SchematronAssertion? { + guard let test = attribute(node, "test") else { return nil } + let isReport = node.name?.localName == "report" + return try SchematronAssertion( + isReport: isReport, + test: PureXML.XPath.Query(test), + message: node.stringValue.trimmingXMLWhitespace(), + ) + } + + private static func isAssertion(_ node: PureXML.Model.TreeNode) -> Bool { + node.name?.localName == "assert" || node.name?.localName == "report" + } + + /// A relative context selects matching nodes anywhere, so it is searched + /// from the document via `//`; an absolute context is used as written. + private static func contextExpression(_ context: String) -> String { + context.hasPrefix("/") ? context : "//" + context + } + + private static func attribute(_ node: PureXML.Model.TreeNode, _ name: String) -> String? { + node.attributes.first { $0.name.localName == name || $0.name.description == name }?.value + } + + private static func children(_ node: PureXML.Model.TreeNode, localName: String) -> [PureXML.Model.TreeNode] { + node.children.filter { $0.kind == .element && $0.name?.localName == localName } + } + + private static func descendants(_ node: PureXML.Model.TreeNode, localName: String) -> [PureXML.Model.TreeNode] { + var result: [PureXML.Model.TreeNode] = [] + for child in node.children { + if child.kind == .element, child.name?.localName == localName { + result.append(child) + } + result.append(contentsOf: descendants(child, localName: localName)) + } + return result + } + } +} diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift index 2394017..c5aba48 100644 --- a/Sources/XPath/Evaluator.swift +++ b/Sources/XPath/Evaluator.swift @@ -29,6 +29,23 @@ extension PureXML.XPath { try eval(expression, rootContext(node, variables: variables)) } + /// Evaluates a node-set expression against a pre-built tree and returns the + /// matched tree nodes in document order (attribute and namespace results + /// are dropped). Lets a caller build the tree once and query it repeatedly. + static func nodes(_ expression: Expression, over root: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] { + let context = EvaluationContext( + node: .tree(root), + position: 1, + size: 1, + variables: [:], + functions: library, + ) + guard let value = try? eval(expression, context), case let .nodeSet(nodes) = value else { + return [] + } + return orderUnique(nodes).compactMap(\.treeNode) + } + /// Evaluates an expression against an explicit context: a node already in a /// tree, its proximity position and size, and variable bindings. This is /// the entry point downstream engines (XSLT, Schematron) drive per node. diff --git a/Sources/XPath/Query.swift b/Sources/XPath/Query.swift index 70e7771..33c7aed 100644 --- a/Sources/XPath/Query.swift +++ b/Sources/XPath/Query.swift @@ -38,6 +38,14 @@ public extension PureXML.XPath { try Evaluator.value(expression, over: node, variables: variables) } + /// Evaluates the query over a pre-built tree and returns the matched tree + /// nodes in document order. Build the tree once (``PureXML/parseTree(_:)``) + /// and query it repeatedly; pair with ``value(at:position:size:variables:)`` + /// to evaluate further expressions relative to each result. + public func nodes(over root: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] { + Evaluator.nodes(expression, over: root) + } + /// Evaluates the query against an explicit context: a node already in a /// tree (``PureXML/Model/TreeNode``), its one-based proximity `position` /// within a node-set of `size`, and variable bindings. Downstream engines diff --git a/Tests/SchematronTests.swift b/Tests/SchematronTests.swift new file mode 100644 index 0000000..c90f99d --- /dev/null +++ b/Tests/SchematronTests.swift @@ -0,0 +1,76 @@ +@testable import PureXML +import Testing + +@Suite("Schematron") +struct SchematronTests { + private let schema = """ + + + + a book must have a title + a book must have an isbn + more than two authors + + + + """ + + private func validate(_ xml: String) throws -> [PureXML.Validation.Issue] { + try PureXML.Validation.Schematron(schema: schema).validate(xml) + } + + @Test("A conforming document produces no issues") + func test_valid() throws { + let xml = "TA" + #expect(try validate(xml).isEmpty) + } + + @Test("A failed assert is reported as an error") + func test_failedAssert() throws { + let xml = "A" + let issues = try validate(xml) + #expect(issues.count == 1) + #expect(issues.first?.severity == .error) + #expect(issues.first?.message == "a book must have a title") + } + + @Test("Multiple failed asserts each report") + func test_multipleAsserts() throws { + let xml = "A" + let messages = try validate(xml).map(\.message) + #expect(messages.contains("a book must have a title")) + #expect(messages.contains("a book must have an isbn")) + } + + @Test("A matched report is a warning") + func test_report() throws { + let xml = "T" + + "ABC" + let issues = try validate(xml) + #expect(issues.count == 1) + #expect(issues.first?.severity == .warning) + #expect(issues.first?.message == "more than two authors") + } + + @Test("Rules fire once per matching node across the document") + func test_perNode() throws { + let xml = "" + + "T" + + "A" + + "" + // The second book is missing both title and isbn. + let issues = try validate(xml) + #expect(issues.count == 2) + #expect(issues.allSatisfy { $0.severity == .error }) + } + + @Test("An invalid test expression is rejected at compile time") + func test_invalidSchema() { + let bad = """ + x + """ + #expect(throws: Error.self) { + _ = try PureXML.Validation.Schematron(schema: bad) + } + } +}