Merge: Schematron abstract patterns (#80)
This commit is contained in:
commit
cc44800a4a
@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Schematron abstract patterns (#80). A `<pattern abstract="true">` is a template
|
||||
whose rule queries carry `$name` references; a `<pattern is-a="…">` with
|
||||
`<param name= value=>` children instantiates it, substituting each parameter
|
||||
into the template's `context`, `test`, `<let>` value, and `<value-of select=>`
|
||||
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 `<let>` variables) are preserved.
|
||||
- RELAX NG conformance: `datatypeLibrary`, value-space `<value>`, name-class
|
||||
subtraction, and compact-syntax breadth (#79). The in-scope `datatypeLibrary`
|
||||
is now inherited from the nearest ancestor; the default library defines only
|
||||
|
||||
@ -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
|
||||
/// `<param name= value=>` 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 `<param name= value=>` 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 <extends rule="id"> 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, `<value-of select=>` an XPath part, and `<name>` 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 `<let>` 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"
|
||||
}
|
||||
|
||||
100
Tests/SchematronAbstractPatternTests.swift
Normal file
100
Tests/SchematronAbstractPatternTests.swift
Normal file
@ -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 = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern abstract="true" id="required">
|
||||
<rule context="$element">
|
||||
<assert test="$attribute">missing</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
<pattern is-a="required" id="car-needs-wheels">
|
||||
<param name="element" value="car"/>
|
||||
<param name="attribute" value="@wheels"/>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
#expect(try validate(schema, "<car wheels='4'/>").isEmpty)
|
||||
let failed = try reasons(schema, "<car/>")
|
||||
#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 = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern abstract="true" id="t">
|
||||
<rule context="$ctx"><assert test="$cond">x</assert></rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
#expect(try validate(schema, "<doc/>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("One template instantiates for two different concrete patterns")
|
||||
func test_reuseTemplate() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern abstract="true" id="present">
|
||||
<rule context="$el"><assert test="count(.) = 1">absent</assert></rule>
|
||||
</pattern>
|
||||
<pattern is-a="present" id="a"><param name="el" value="alpha"/></pattern>
|
||||
<pattern is-a="present" id="b"><param name="el" value="beta"/></pattern>
|
||||
</schema>
|
||||
"""
|
||||
#expect(try validate(schema, "<root><alpha/><beta/></root>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A longer param name is not captured by a shorter one")
|
||||
func test_noPrefixCapture() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern abstract="true" id="t">
|
||||
<rule context="$n"><assert test="@$nm = 'ok'">bad</assert></rule>
|
||||
</pattern>
|
||||
<pattern is-a="t" id="c">
|
||||
<param name="n" value="item"/>
|
||||
<param name="nm" value="status"/>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
// $nm must map to "status", not be eaten by $n -> "item" + "m".
|
||||
#expect(try validate(schema, "<item status='ok'/>").isEmpty)
|
||||
#expect(try !validate(schema, "<item status='no'/>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A $name that is not a param (an ordinary let variable) is preserved")
|
||||
func test_letVariablePreserved() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern abstract="true" id="t">
|
||||
<rule context="$el">
|
||||
<let name="actual" value="@count"/>
|
||||
<assert test="$actual = $expected">wrong count</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
<pattern is-a="t" id="c">
|
||||
<param name="el" value="bag"/>
|
||||
<param name="expected" value="3"/>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
#expect(try validate(schema, "<bag count='3'/>").isEmpty)
|
||||
#expect(try !validate(schema, "<bag count='2'/>").isEmpty)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user