From 7e04f6056cbb2f164ff9d6855d87aa3d17bee172 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 16:26:30 +0200 Subject: [PATCH] feat(schematron): abstract rules and extends A is a template that does not fire on its own; a concrete rule pulls in its let bindings and assertions with , prepended so the concrete rule extends rather than replaces them. Part of #80 --- Sources/Validation/SchematronParser.swift | 42 +++++++++++++-- Tests/SchematronAbstractTests.swift | 66 +++++++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 Tests/SchematronAbstractTests.swift diff --git a/Sources/Validation/SchematronParser.swift b/Sources/Validation/SchematronParser.swift index fe00a5b..2fb5e07 100644 --- a/Sources/Validation/SchematronParser.swift +++ b/Sources/Validation/SchematronParser.swift @@ -1,3 +1,10 @@ +/// The lets and assertions of an abstract Schematron rule, named by id so an +/// `` can pull them into a concrete rule. File-scope and private. +private struct AbstractRule { + let lets: [PureXML.Validation.SchematronLet] + let assertions: [PureXML.Validation.SchematronAssertion] +} + extension PureXML.Validation { /// Parses a Schematron schema document into patterns and phases. Namespace /// agnostic: it matches the `schema`, `phase`, `active`, `pattern`, `rule`, @@ -25,16 +32,41 @@ extension PureXML.Validation { } private static func pattern(_ node: PureXML.Model.TreeNode) throws -> SchematronPattern { - try SchematronPattern(id: attribute(node, "id"), rules: children(node, localName: "rule").compactMap(rule)) + let ruleNodes = children(node, localName: "rule") + var abstracts: [String: AbstractRule] = [:] + for ruleNode in ruleNodes where attribute(ruleNode, "abstract") == "true" { + if let id = attribute(ruleNode, "id") { + abstracts[id] = try AbstractRule(lets: ruleLets(ruleNode), assertions: ruleAssertions(ruleNode)) + } + } + let rules = try ruleNodes + .filter { attribute($0, "abstract") != "true" } + .compactMap { try rule($0, abstracts) } + return SchematronPattern(id: attribute(node, "id"), rules: rules) } - private static func rule(_ node: PureXML.Model.TreeNode) throws -> SchematronRule? { + private static func rule(_ node: PureXML.Model.TreeNode, _ abstracts: [String: AbstractRule]) 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 + var lets = try ruleLets(node) + var assertions = try ruleAssertions(node) + // An prepends the abstract rule's lets and + // assertions, so the concrete rule extends rather than replaces them. + for ext in children(node, localName: "extends") { + guard let id = attribute(ext, "rule"), let base = abstracts[id] else { continue } + lets = base.lets + lets + assertions = base.assertions + assertions + } + return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), lets: lets, assertions: assertions) + } + + private static func ruleLets(_ node: PureXML.Model.TreeNode) throws -> [SchematronLet] { + try children(node, localName: "let").compactMap(letBinding) + } + + private static func ruleAssertions(_ node: PureXML.Model.TreeNode) throws -> [SchematronAssertion] { + try node.children .filter { $0.kind == .element && isAssertion($0) } .compactMap(assertion) - return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), lets: lets, assertions: assertions) } private static func letBinding(_ node: PureXML.Model.TreeNode) throws -> SchematronLet? { diff --git a/Tests/SchematronAbstractTests.swift b/Tests/SchematronAbstractTests.swift new file mode 100644 index 0000000..4bb57a9 --- /dev/null +++ b/Tests/SchematronAbstractTests.swift @@ -0,0 +1,66 @@ +@testable import PureXML +import Testing + +@Suite("Schematron abstract rules and extends") +struct SchematronAbstractTests { + private func validate(_ schema: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] { + try PureXML.Validation.Schematron(schema: schema).validate(xml) + } + + @Test("A concrete rule extends an abstract rule's assertions") + func test_extends() throws { + let schema = """ + + + + element needs an id + + + + item needs a name + + + + """ + // Both the inherited and the own assertion hold. + #expect(try validate(schema, "").isEmpty) + // The inherited assertion fails (no id). + #expect(try validate(schema, "").count == 1) + // Both fail. + #expect(try validate(schema, "").count == 2) + } + + @Test("An abstract rule does not fire on its own") + func test_abstractDoesNotFire() throws { + let schema = """ + + + + should never run + + + + """ + // No concrete rule extends it, so nothing fires even on a matching element. + #expect(try validate(schema, "").isEmpty) + } + + @Test("An extends pulls in the abstract rule's let bindings") + func test_extendsLet() throws { + let schema = """ + + + + + + + + over the limit + + + + """ + #expect(try validate(schema, "5").isEmpty) + #expect(try !validate(schema, "50").isEmpty) + } +}