PureXML/Sources/Schema/RelaxNGDerivative.swift
Mihaela Mihaljevic ae271d65f6 feat(relaxng): located, recovering validation errors for editor use
Adds RelaxNG.errors(in:) reporting every way a document fails the schema
as individual located errors with coding paths, recovering past each
failure so all of a faulty document's problems surface at once instead of
only the first. Errors carry recovery hints naming the element types
accepted at the failure point, and distinguish unexpected elements,
invalid or missing attributes, invalid text/datatype content, and missing
required content. RelaxNG.validation() exposes the schema as a
Validation<Node, Void>, composing with the same framework as the XSD,
DTD, and Schematron rules. The boolean derivative engine remains the
authority on validity; the diagnostic walk only places and explains the
failures, recovering each step so it never cascades. Splits the engine
and the diagnostics into separate files.
2026-06-09 00:40:56 +02:00

266 lines
12 KiB
Swift

extension PureXML.Schema {
/// Validates a document against a RELAX NG grammar by Brzozowski-style
/// derivatives (James Clark's "An algorithm for RELAX NG validation"). The
/// pattern is repeatedly derived with respect to each event of the instance;
/// the document is valid when the residual pattern is nullable.
struct RelaxNGEngine {
let defines: [String: PureXML.Schema.Pattern]
/// Whether `root` matches `start`.
func matches(start: PureXML.Schema.Pattern, root: PureXML.Model.Node) -> Bool {
nullable(childDeriv(start, root))
}
// MARK: Simplifying constructors
func choice(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
if case .notAllowed = lhs { return rhs }
if case .notAllowed = rhs { return lhs }
return .choice(lhs, rhs)
}
private func group(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
if case .notAllowed = lhs { return .notAllowed }
if case .notAllowed = rhs { return .notAllowed }
if case .empty = lhs { return rhs }
if case .empty = rhs { return lhs }
return .group(lhs, rhs)
}
private func interleave(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
if case .notAllowed = lhs { return .notAllowed }
if case .notAllowed = rhs { return .notAllowed }
if case .empty = lhs { return rhs }
if case .empty = rhs { return lhs }
return .interleave(lhs, rhs)
}
private func after(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
if case .notAllowed = lhs { return .notAllowed }
if case .notAllowed = rhs { return .notAllowed }
return .after(lhs, rhs)
}
func resolve(_ name: String) -> PureXML.Schema.Pattern {
defines[name] ?? .notAllowed
}
// MARK: Nullability
func nullable(_ pattern: PureXML.Schema.Pattern, visiting: Set<String> = []) -> Bool {
switch pattern {
case .empty, .text: return true
case let .choice(lhs, rhs): return nullable(lhs, visiting: visiting) || nullable(rhs, visiting: visiting)
case let .group(lhs, rhs), let .interleave(lhs, rhs):
return nullable(lhs, visiting: visiting) && nullable(rhs, visiting: visiting)
case let .oneOrMore(inner), let .list(inner): return nullable(inner, visiting: visiting)
case let .ref(name):
guard !visiting.contains(name) else { return false }
return nullable(resolve(name), visiting: visiting.union([name]))
default:
return false
}
}
// MARK: Text and attribute derivatives
func textDeriv(_ pattern: PureXML.Schema.Pattern, _ string: String) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs): return choice(textDeriv(lhs, string), textDeriv(rhs, string))
case let .interleave(lhs, rhs):
return choice(interleave(textDeriv(lhs, string), rhs), interleave(lhs, textDeriv(rhs, string)))
case let .group(lhs, rhs):
let derived = group(textDeriv(lhs, string), rhs)
return nullable(lhs) ? choice(derived, textDeriv(rhs, string)) : derived
case let .after(lhs, rhs): return after(textDeriv(lhs, string), rhs)
case let .oneOrMore(inner):
return group(textDeriv(inner, string), choice(.oneOrMore(inner), .empty))
case .text: return .text
case let .ref(name): return textDeriv(resolve(name), string)
default: return valueDeriv(pattern, string)
}
}
private func valueDeriv(_ pattern: PureXML.Schema.Pattern, _ string: String) -> PureXML.Schema.Pattern {
switch pattern {
case let .value(type, literal):
type.valueMatches(string, literal: literal) ? .empty : .notAllowed
case let .data(type): type.validate(string) == nil ? .empty : .notAllowed
case let .list(inner): listMatches(inner, string) ? .empty : .notAllowed
default: .notAllowed
}
}
private func listMatches(_ pattern: PureXML.Schema.Pattern, _ string: String) -> Bool {
var residual = pattern
for token in string.split(whereSeparator: \.isWhitespace) {
residual = textDeriv(residual, String(token))
}
return nullable(residual)
}
func attributeDeriv(_ pattern: PureXML.Schema.Pattern, _ attribute: PureXML.Model.Attribute) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs): return choice(attributeDeriv(lhs, attribute), attributeDeriv(rhs, attribute))
case let .group(lhs, rhs):
return choice(group(attributeDeriv(lhs, attribute), rhs), group(lhs, attributeDeriv(rhs, attribute)))
case let .interleave(lhs, rhs):
return choice(interleave(attributeDeriv(lhs, attribute), rhs), interleave(lhs, attributeDeriv(rhs, attribute)))
case let .oneOrMore(inner):
return group(attributeDeriv(inner, attribute), choice(.oneOrMore(inner), .empty))
case let .after(lhs, rhs): return after(attributeDeriv(lhs, attribute), rhs)
case let .attribute(nameClass, content):
guard nameClass.contains(attribute.name), valueMatches(content, attribute.value) else { return .notAllowed }
return .empty
case let .ref(name): return attributeDeriv(resolve(name), attribute)
default: return .notAllowed
}
}
private func valueMatches(_ content: PureXML.Schema.Pattern, _ value: String) -> Bool {
value.isEmpty ? nullable(content) : nullable(textDeriv(content, value))
}
private func oneOrMoreP(_ inner: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
if case .notAllowed = inner { return .notAllowed }
return .oneOrMore(inner)
}
}
}
extension PureXML.Schema.RelaxNGEngine {
// MARK: Element derivatives
func childDeriv(_ pattern: PureXML.Schema.Pattern, _ node: PureXML.Model.Node) -> PureXML.Schema.Pattern {
switch node {
case let .text(string), let .cdata(string):
textDeriv(pattern, string)
case let .element(element):
processElement(pattern, element)
default:
pattern
}
}
private func processElement(_ pattern: PureXML.Schema.Pattern, _ element: PureXML.Model.Element) -> PureXML.Schema.Pattern {
let opened = startTagOpenDeriv(pattern, element.name)
let attributed = element.attributes
.filter { !Self.isNamespaceDeclaration($0) }
.reduce(opened) { attributeDeriv($0, $1) }
let closed = startTagCloseDeriv(attributed)
let withChildren = childrenDeriv(closed, element.children)
return endTagDeriv(withChildren)
}
func startTagOpenDeriv(_ pattern: PureXML.Schema.Pattern, _ name: PureXML.Model.QualifiedName) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs):
return choice(startTagOpenDeriv(lhs, name), startTagOpenDeriv(rhs, name))
case let .element(nameClass, content):
return nameClass.contains(name) ? after(content, .empty) : .notAllowed
case let .interleave(lhs, rhs):
return choice(
applyAfter({ interleave($0, rhs) }, startTagOpenDeriv(lhs, name)),
applyAfter({ interleave(lhs, $0) }, startTagOpenDeriv(rhs, name)),
)
case let .group(lhs, rhs):
let derived = applyAfter({ group($0, rhs) }, startTagOpenDeriv(lhs, name))
return nullable(lhs) ? choice(derived, startTagOpenDeriv(rhs, name)) : derived
case let .oneOrMore(inner):
return applyAfter({ group($0, choice(oneOrMoreP(inner), .empty)) }, startTagOpenDeriv(inner, name))
case let .after(lhs, rhs):
return applyAfter({ after($0, rhs) }, startTagOpenDeriv(lhs, name))
case let .ref(reference):
return startTagOpenDeriv(resolve(reference), name)
default:
return .notAllowed
}
}
private func applyAfter(_ transform: (PureXML.Schema.Pattern) -> PureXML.Schema.Pattern, _ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
switch pattern {
case let .after(lhs, rhs): after(lhs, transform(rhs))
case let .choice(lhs, rhs): choice(applyAfter(transform, lhs), applyAfter(transform, rhs))
default: .notAllowed
}
}
func startTagCloseDeriv(_ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs): choice(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs))
case let .group(lhs, rhs): group(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs))
case let .interleave(lhs, rhs): interleave(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs))
case let .oneOrMore(inner): oneOrMoreP(startTagCloseDeriv(inner))
case let .after(lhs, rhs): after(startTagCloseDeriv(lhs), rhs)
case .attribute: .notAllowed
case let .ref(reference): startTagCloseDeriv(resolve(reference))
default: pattern
}
}
private func childrenDeriv(_ pattern: PureXML.Schema.Pattern, _ children: [PureXML.Model.Node]) -> PureXML.Schema.Pattern {
let coalesced = Self.coalesceText(children)
if coalesced.isEmpty { return pattern }
if coalesced.count == 1, case let .text(string) = coalesced[0] {
let derived = textDeriv(pattern, string)
return Self.isWhitespace(string) ? choice(pattern, derived) : derived
}
return coalesced.reduce(pattern) { accumulated, node in
Self.isWhitespaceText(node) ? accumulated : childDeriv(accumulated, node)
}
}
private func endTagDeriv(_ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs): choice(endTagDeriv(lhs), endTagDeriv(rhs))
case let .after(lhs, rhs): nullable(lhs) ? rhs : .notAllowed
case let .ref(reference): endTagDeriv(resolve(reference))
default: .notAllowed
}
}
// MARK: Helpers
/// Merges adjacent text and CDATA nodes into single text nodes.
static func coalesceText(_ children: [PureXML.Model.Node]) -> [PureXML.Model.Node] {
var result: [PureXML.Model.Node] = []
var run = ""
for child in children {
switch child {
case let .text(value), let .cdata(value):
run += value
case .comment, .processingInstruction:
continue
default:
if !run.isEmpty { result.append(.text(run))
run = ""
}
result.append(child)
}
}
if !run.isEmpty { result.append(.text(run)) }
return result
}
private static func isWhitespace(_ string: String) -> Bool {
string.allSatisfy(\.isWhitespace)
}
static func isWhitespaceText(_ node: PureXML.Model.Node) -> Bool {
if case let .text(value) = node { return isWhitespace(value) }
return false
}
static func isNamespaceDeclaration(_ attribute: PureXML.Model.Attribute) -> Bool {
attribute.name.prefix == "xmlns" || (attribute.name.prefix == nil && attribute.name.localName == "xmlns")
}
static func elementNameCounts(_ children: [PureXML.Model.Node]) -> [String: Int] {
var counts: [String: Int] = [:]
for case let .element(element) in children {
counts[element.name.description, default: 0] += 1
}
return counts
}
}