PureXML/Sources/Validation/SchematronParser.swift
Mihaela Mihaljevic 68122ad887 feat(schematron): dynamic message content and phase support
Two ISO Schematron gaps, the most validator-relevant first:

- Dynamic messages: an assert/report message is now a template, so
  <value-of select=> renders the string value of its XPath at the context
  node and <name> renders the node's name. A finding can report actual
  values ("Age 120 must be under 100") instead of static text.
- Phases: <phase id><active pattern=> scopes which patterns run.
  validate(_:phase:) honors a requested phase or the schema's
  defaultPhase; nil or #ALL runs every pattern.

let variables, abstract rules/extends, diagnostics, and key()/document()
remain (the last depends on XSLT-style function injection).

Part of #80
2026-06-08 15:45:27 +02:00

96 lines
4.6 KiB
Swift

extension PureXML.Validation {
/// Parses a Schematron schema document into patterns and phases. Namespace
/// agnostic: it matches the `schema`, `phase`, `active`, `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 -> 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 SchematronSchema(
patterns: patterns,
phases: phases(root),
defaultPhase: attribute(schema, "defaultPhase"),
)
}
private static func phases(_ root: PureXML.Model.TreeNode) -> [String: [String]] {
var result: [String: [String]] = [:]
for phase in descendants(root, localName: "phase") {
guard let id = attribute(phase, "id") else { continue }
result[id] = children(phase, localName: "active").compactMap { attribute($0, "pattern") }
}
return result
}
private static func pattern(_ node: PureXML.Model.TreeNode) throws -> SchematronPattern {
try SchematronPattern(id: attribute(node, "id"), 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 }
return try SchematronAssertion(
isReport: node.name?.localName == "report",
test: PureXML.XPath.Query(test),
message: messageParts(node),
)
}
/// Builds an assertion message template from a node's children: text nodes
/// 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] {
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)))
} else if child.kind == .element, child.name?.localName == "name" {
parts.append(.name)
}
}
return parts
}
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
}
}
}