From d7a40b9a7a882f015f69a980d630beca95c45d8f Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 17:58:33 +0200 Subject: [PATCH] feat(schematron): instantiate abstract patterns with is-a/param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A is a template whose rule queries carry $name references; a with children instantiates it, substituting each parameter into the template's context, test, value, and before the XPath is compiled. The substitution is a single left-to-right pass, so a substituted value is never rescanned and a short name never captures a longer one; $name references that are not parameters stay as written. The template itself contributes no rules. --- CHANGELOG.md | 7 ++ Sources/Validation/SchematronParser.swift | 121 +++++++++++++++++---- Tests/SchematronAbstractPatternTests.swift | 100 +++++++++++++++++ 3 files changed, 205 insertions(+), 23 deletions(-) create mode 100644 Tests/SchematronAbstractPatternTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0700dae..c6efec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Schematron abstract patterns (#80). A `` is a template + whose rule queries carry `$name` references; a `` with + `` children instantiates it, substituting each parameter + into the template's `context`, `test`, `` value, and `` + in a single pass that never rescans a substituted value or lets a short name + capture a longer one. The template itself contributes no rules, and `$name` + references that are not parameters (ordinary `` variables) are preserved. - RELAX NG conformance: `datatypeLibrary`, value-space ``, name-class subtraction, and compact-syntax breadth (#79). The in-scope `datatypeLibrary` is now inherited from the nearest ancestor; the default library defines only diff --git a/Sources/Validation/SchematronParser.swift b/Sources/Validation/SchematronParser.swift index f9d9555..81e4e0c 100644 --- a/Sources/Validation/SchematronParser.swift +++ b/Sources/Validation/SchematronParser.swift @@ -14,9 +14,8 @@ extension PureXML.Validation { static func parse(_ xml: String) throws -> SchematronSchema { let root = try PureXML.parseTree(xml) let schema = descendants(root, localName: "schema").first ?? root - let patterns = try descendants(root, localName: "pattern").map(pattern) return try SchematronSchema( - patterns: patterns, + patterns: instantiatedPatterns(root), phases: phases(root), defaultPhase: attribute(schema, "defaultPhase"), lets: ruleLets(schema), @@ -24,6 +23,37 @@ extension PureXML.Validation { ) } + /// 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 + /// the template's queries, and any other pattern is parsed as written. + private static func instantiatedPatterns(_ root: PureXML.Model.TreeNode) throws -> [SchematronPattern] { + let patternNodes = descendants(root, localName: "pattern") + var templates: [String: PureXML.Model.TreeNode] = [:] + for node in patternNodes where attribute(node, "abstract") == "true" { + if let id = attribute(node, "id") { templates[id] = node } + } + var patterns: [SchematronPattern] = [] + for node in patternNodes where attribute(node, "abstract") != "true" { + guard let isa = attribute(node, "is-a") else { + try patterns.append(pattern(node, substitutions: [:], id: attribute(node, "id"))) + continue + } + guard let template = templates[isa] else { continue } + try patterns.append(pattern(template, substitutions: parameters(node), id: attribute(node, "id"))) + } + return patterns + } + + /// The `` bindings a concrete `is-a` pattern supplies. + private static func parameters(_ node: PureXML.Model.TreeNode) -> [String: String] { + var result: [String: String] = [:] + for param in children(node, localName: "param") { + if let name = attribute(param, "name"), let value = attribute(param, "value") { result[name] = value } + } + return result + } + private static func phases(_ root: PureXML.Model.TreeNode) -> [String: [String]] { var result: [String: [String]] = [:] for phase in descendants(root, localName: "phase") { @@ -33,24 +63,27 @@ extension PureXML.Validation { return result } - private static func pattern(_ node: PureXML.Model.TreeNode) throws -> SchematronPattern { + private static func pattern(_ node: PureXML.Model.TreeNode, substitutions: [String: String], id: String?) throws -> SchematronPattern { 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)) + if let identifier = attribute(ruleNode, "id") { + abstracts[identifier] = try AbstractRule( + lets: ruleLets(ruleNode, substitutions), + assertions: ruleAssertions(ruleNode, substitutions), + ) } } let rules = try ruleNodes .filter { attribute($0, "abstract") != "true" } - .compactMap { try rule($0, abstracts) } - return try SchematronPattern(id: attribute(node, "id"), lets: ruleLets(node), rules: rules) + .compactMap { try rule($0, abstracts, substitutions) } + return try SchematronPattern(id: id, lets: ruleLets(node, substitutions), rules: rules) } - private static func rule(_ node: PureXML.Model.TreeNode, _ abstracts: [String: AbstractRule]) throws -> SchematronRule? { + private static func rule(_ node: PureXML.Model.TreeNode, _ abstracts: [String: AbstractRule], _ subs: [String: String]) throws -> SchematronRule? { guard let context = attribute(node, "context") else { return nil } - var lets = try ruleLets(node) - var assertions = try ruleAssertions(node) + var lets = try ruleLets(node, subs) + var assertions = try ruleAssertions(node, subs) // 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") { @@ -58,31 +91,32 @@ extension PureXML.Validation { lets = base.lets + lets assertions = base.assertions + assertions } - return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), lets: lets, assertions: assertions) + let expression = contextExpression(substitute(context, subs)) + return try SchematronRule(context: PureXML.XPath.Query(expression), lets: lets, assertions: assertions) } - private static func ruleLets(_ node: PureXML.Model.TreeNode) throws -> [SchematronLet] { - try children(node, localName: "let").compactMap(letBinding) + private static func ruleLets(_ node: PureXML.Model.TreeNode, _ subs: [String: String] = [:]) throws -> [SchematronLet] { + try children(node, localName: "let").compactMap { try letBinding($0, subs) } } - private static func ruleAssertions(_ node: PureXML.Model.TreeNode) throws -> [SchematronAssertion] { + private static func ruleAssertions(_ node: PureXML.Model.TreeNode, _ subs: [String: String]) throws -> [SchematronAssertion] { try node.children .filter { $0.kind == .element && isAssertion($0) } - .compactMap(assertion) + .compactMap { try assertion($0, subs) } } - private static func letBinding(_ node: PureXML.Model.TreeNode) throws -> SchematronLet? { + private static func letBinding(_ node: PureXML.Model.TreeNode, _ subs: [String: String]) 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)) + return try SchematronLet(name: name, value: PureXML.XPath.Query(substitute(value, subs))) } - private static func assertion(_ node: PureXML.Model.TreeNode) throws -> SchematronAssertion? { + private static func assertion(_ node: PureXML.Model.TreeNode, _ subs: [String: String]) throws -> SchematronAssertion? { guard let test = attribute(node, "test") else { return nil } let referenced = attribute(node, "diagnostics")?.split(whereSeparator: \.isWhitespace).map(String.init) ?? [] return try SchematronAssertion( isReport: node.name?.localName == "report", - test: PureXML.XPath.Query(test), - message: messageParts(node), + test: PureXML.XPath.Query(substitute(test, subs)), + message: messageParts(node, subs), diagnostics: referenced, ) } @@ -92,7 +126,7 @@ extension PureXML.Validation { var table: [String: [SchematronMessagePart]] = [:] for diagnostic in descendants(root, localName: "diagnostic") { if let id = attribute(diagnostic, "id") { - table[id] = try messageParts(diagnostic) + table[id] = try messageParts(diagnostic, [:]) } } return table @@ -102,13 +136,13 @@ extension PureXML.Validation { /// become literal parts, `` an XPath part, and `` a /// context-name part. The parts render against the context node at /// validation time. - private static func messageParts(_ node: PureXML.Model.TreeNode) throws -> [SchematronMessagePart] { + private static func messageParts(_ node: PureXML.Model.TreeNode, _ subs: [String: String]) throws -> [SchematronMessagePart] { var parts: [SchematronMessagePart] = [] for child in node.children { if child.kind == .text || child.kind == .cdata { parts.append(.text(child.value)) } else if child.kind == .element, child.name?.localName == "value-of", let select = attribute(child, "select") { - try parts.append(.valueOf(PureXML.XPath.Query(select))) + try parts.append(.valueOf(PureXML.XPath.Query(substitute(select, subs)))) } else if child.kind == .element, child.name?.localName == "name" { parts.append(.name) } @@ -116,6 +150,47 @@ extension PureXML.Validation { return parts } + /// Replaces each `$name` reference whose name is a supplied parameter with + /// that parameter's value, in a single left-to-right pass so a substituted + /// value is never rescanned and `$ab` never captures `$abc`. References to + /// names that are not parameters (ordinary `` variables) are left as + /// written. + private static func substitute(_ expression: String, _ subs: [String: String]) -> String { + guard !subs.isEmpty, expression.contains("$") else { return expression } + let characters = Array(expression) + var result = "" + var index = 0 + while index < characters.count { + guard characters[index] == "$", index + 1 < characters.count, isNameStart(characters[index + 1]) else { + result.append(characters[index]) + index += 1 + continue + } + var end = index + 1 + var name = "" + while end < characters.count, isNameChar(characters[end]) { + name.append(characters[end]) + end += 1 + } + if let value = subs[name] { + result += value + } else { + result.append("$") + result += name + } + index = end + } + return result + } + + private static func isNameStart(_ character: Character) -> Bool { + character.isLetter || character == "_" + } + + private static func isNameChar(_ character: Character) -> Bool { + character.isLetter || character.isNumber || character == "_" || character == "-" || character == "." + } + private static func isAssertion(_ node: PureXML.Model.TreeNode) -> Bool { node.name?.localName == "assert" || node.name?.localName == "report" } diff --git a/Tests/SchematronAbstractPatternTests.swift b/Tests/SchematronAbstractPatternTests.swift new file mode 100644 index 0000000..beaf618 --- /dev/null +++ b/Tests/SchematronAbstractPatternTests.swift @@ -0,0 +1,100 @@ +@testable import PureXML +import Testing + +@Suite("Schematron abstract patterns (is-a / param)") +struct SchematronAbstractPatternTests { + private func validate(_ schema: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] { + try PureXML.Validation.Schematron(schema: schema).validate(xml) + } + + private func reasons(_ schema: String, _ xml: String) throws -> [String] { + try validate(schema, xml).map(\.reason) + } + + @Test("An is-a pattern substitutes $params into context and test") + func test_paramSubstitution() throws { + let schema = """ + + + + missing + + + + + + + + """ + #expect(try validate(schema, "").isEmpty) + let failed = try reasons(schema, "") + #expect(failed.count == 1) + #expect(failed.first?.contains("missing") == true) + } + + @Test("The abstract template itself is not evaluated") + func test_abstractTemplateInert() throws { + // $ctx as a raw context would select nothing useful if evaluated; the + // template must contribute no rules of its own. + let schema = """ + + + x + + + """ + #expect(try validate(schema, "").isEmpty) + } + + @Test("One template instantiates for two different concrete patterns") + func test_reuseTemplate() throws { + let schema = """ + + + absent + + + + + """ + #expect(try validate(schema, "").isEmpty) + } + + @Test("A longer param name is not captured by a shorter one") + func test_noPrefixCapture() throws { + let schema = """ + + + bad + + + + + + + """ + // $nm must map to "status", not be eaten by $n -> "item" + "m". + #expect(try validate(schema, "").isEmpty) + #expect(try !validate(schema, "").isEmpty) + } + + @Test("A $name that is not a param (an ordinary let variable) is preserved") + func test_letVariablePreserved() throws { + let schema = """ + + + + + wrong count + + + + + + + + """ + #expect(try validate(schema, "").isEmpty) + #expect(try !validate(schema, "").isEmpty) + } +}