PureXML/Sources/Schema/RelaxNGDerivative.swift
Mihaela Mihaljevic c628682086 feat(relaxng): QName values with dual-context resolution, instance level clean
A value of type QName compiles to its (namespace, local) pair via the
schema's xmlns/ns scope; the derivative engine threads the instance
element's in-scope bindings to value comparison in both the tree walk
and the streaming state (frames carry their bindings), so instance
prefixes and the default namespace resolve against the instance
document and unbound prefixes match nothing. Streaming also gains the
tree walk's empty-element rule. The spec suite's instance level is
fully clean: valid 289/289, invalid 291/291; only the asserted
schema-correctness class remains.
2026-06-10 10:57:14 +02:00

393 lines
19 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.
///
/// Bound: there is no derivative memoization (patterns are not hashable by
/// design). The simplifying constructors (`choice`/`group`/`interleave`
/// short-circuit on `notAllowed` and `empty`) keep the residual small for
/// real-world grammars, but an adversarial grammar of deeply nested
/// choices can still grow it; treat untrusted schemas accordingly.
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, env: Self.rootEnvironment))
}
/// The namespace bindings every instance starts with.
static let rootEnvironment: [String: String] = ["xml": "http://www.w3.org/XML/1998/namespace"]
/// `env` extended with the element's own xmlns declarations.
static func extendedEnvironment(_ env: [String: String], with element: PureXML.Model.Element) -> [String: String] {
var extended = env
for attribute in element.attributes where isNamespaceDeclaration(attribute) {
let prefix = attribute.name.prefix == "xmlns" ? attribute.name.localName : ""
extended[prefix] = attribute.value
}
return extended
}
/// Resolves an instance-side QName text against the in-scope bindings:
/// a prefix must be bound; an unprefixed name takes the default
/// namespace (the xs:QName rules).
static func resolveQNameValue(_ raw: String, env: [String: String]) -> (namespace: String, localName: String)? {
let trimmed = raw.trimmingXMLWhitespace()
guard let colon = trimmed.firstIndex(of: ":") else {
return (env[""] ?? "", trimmed)
}
let prefix = String(trimmed[..<colon])
let local = String(trimmed[trimmed.index(after: colon)...])
guard let uri = env[prefix], !uri.isEmpty else { return nil }
return (uri, local)
}
// 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, env: [String: String] = rootEnvironment) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs): return choice(textDeriv(lhs, string, env: env), textDeriv(rhs, string, env: env))
case let .interleave(lhs, rhs):
return choice(interleave(textDeriv(lhs, string, env: env), rhs), interleave(lhs, textDeriv(rhs, string, env: env)))
case let .group(lhs, rhs):
let derived = group(textDeriv(lhs, string, env: env), rhs)
return nullable(lhs) ? choice(derived, textDeriv(rhs, string, env: env)) : derived
case let .after(lhs, rhs): return after(textDeriv(lhs, string, env: env), rhs)
case let .oneOrMore(inner):
return group(textDeriv(inner, string, env: env), choice(.oneOrMore(inner), .empty))
case .text: return .text
case let .ref(name): return textDeriv(resolve(name), string, env: env)
default: return valueDeriv(pattern, string, env: env)
}
}
private func valueDeriv(_ pattern: PureXML.Schema.Pattern, _ string: String, env: [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 .dataExcept(type, except):
type.validate(string) == nil && !nullable(textDeriv(except, string, env: env)) ? .empty : .notAllowed
case let .valueQName(namespace, localName):
Self.resolveQNameValue(string, env: env).map { $0 == (namespace, localName) } == true ? .empty : .notAllowed
case let .list(inner): listMatches(inner, string, env: env) ? .empty : .notAllowed
default: .notAllowed
}
}
private func listMatches(_ pattern: PureXML.Schema.Pattern, _ string: String, env _: [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, env: [String: String] = rootEnvironment) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs): return choice(attributeDeriv(lhs, attribute, env: env), attributeDeriv(rhs, attribute, env: env))
case let .group(lhs, rhs):
return choice(group(attributeDeriv(lhs, attribute, env: env), rhs), group(lhs, attributeDeriv(rhs, attribute, env: env)))
case let .interleave(lhs, rhs):
return choice(interleave(attributeDeriv(lhs, attribute, env: env), rhs), interleave(lhs, attributeDeriv(rhs, attribute, env: env)))
case let .oneOrMore(inner):
return group(attributeDeriv(inner, attribute, env: env), choice(.oneOrMore(inner), .empty))
case let .after(lhs, rhs): return after(attributeDeriv(lhs, attribute, env: env), rhs)
case let .attribute(nameClass, content):
guard nameClass.contains(attribute.name), valueMatches(content, attribute.value, env: env) else { return .notAllowed }
return .empty
case let .ref(name): return attributeDeriv(resolve(name), attribute, env: env)
default: return .notAllowed
}
}
private func valueMatches(_ content: PureXML.Schema.Pattern, _ value: String, env: [String: String]) -> Bool {
if nullable(textDeriv(content, value, env: env)) { return true }
// A whitespace-only attribute value also matches a pattern that
// matches the empty sequence, the same leniency element content
// gets for ignorable whitespace.
return value.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isWhitespace) && nullable(content)
}
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, env: [String: String] = rootEnvironment) -> PureXML.Schema.Pattern {
switch node {
case let .text(string), let .cdata(string):
textDeriv(pattern, string, env: env)
case let .element(element):
processElement(pattern, element, env: env)
default:
pattern
}
}
private func processElement(_ pattern: PureXML.Schema.Pattern, _ element: PureXML.Model.Element, env: [String: String]) -> PureXML.Schema.Pattern {
let extended = Self.extendedEnvironment(env, with: element)
let opened = startTagOpenDeriv(pattern, element.name)
let attributed = element.attributes
.filter { !Self.isNamespaceDeclaration($0) }
.reduce(opened) { attributeDeriv($0, $1, env: extended) }
let closed = startTagCloseDeriv(attributed)
let withChildren = childrenDeriv(closed, element.children, env: extended)
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], env: [String: String]) -> PureXML.Schema.Pattern {
let coalesced = Self.coalesceText(children)
// Clark's algorithm treats an empty element as optional empty text, so
// a data/value pattern whose lexical space admits "" matches <e/>.
if coalesced.isEmpty { return choice(pattern, textDeriv(pattern, "", env: env)) }
if coalesced.count == 1, case let .text(string) = coalesced[0] {
let derived = textDeriv(pattern, string, env: env)
return Self.isWhitespace(string) ? choice(pattern, derived) : derived
}
return coalesced.reduce(pattern) { accumulated, node in
Self.isWhitespaceText(node) ? accumulated : childDeriv(accumulated, node, env: env)
}
}
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
}
}
/// One open element while streaming a RELAX NG derivative: whether it has had an
/// element child yet, and the text buffered since the last structural event. The
/// `after`-threaded pattern is the validation stack; this frame only carries the
/// bookkeeping `childrenDeriv` needs to treat ignorable whitespace correctly.
private struct RelaxNGStreamFrame {
var hadElementChild = false
var pendingText = ""
/// The namespace bindings in scope at this element, for QName values.
var env: [String: String] = [:]
}
public extension PureXML.Schema {
/// The residual derivative plus per-element whitespace bookkeeping carried
/// across the events of a streamed RELAX NG validation.
struct RelaxNGStreamingState {
var current: PureXML.Schema.Pattern
fileprivate var frames: [RelaxNGStreamFrame]
}
}
extension PureXML.Schema.RelaxNGEngine {
/// The initial streaming state for a start pattern, with a document-level frame.
func streamingStart(_ start: PureXML.Schema.Pattern) -> PureXML.Schema.RelaxNGStreamingState {
PureXML.Schema.RelaxNGStreamingState(current: start, frames: [RelaxNGStreamFrame(env: Self.rootEnvironment)])
}
/// Derives the state by one parse event, the streaming form of `childDeriv`.
/// Text is buffered and applied at structural boundaries so ignorable
/// whitespace is treated exactly as the tree walk's `childrenDeriv` does.
func streamingConsume(_ event: PureXML.Parsing.Event, into state: inout PureXML.Schema.RelaxNGStreamingState) {
switch event {
case let .startElement(name, attributes):
flushText(&state, closing: false)
if !state.frames.isEmpty { state.frames[state.frames.count - 1].hadElementChild = true }
var env = state.frames.last?.env ?? Self.rootEnvironment
for attribute in attributes where Self.isNamespaceDeclaration(attribute) {
env[attribute.name.prefix == "xmlns" ? attribute.name.localName : ""] = attribute.value
}
let opened = startTagOpenDeriv(state.current, name)
let attributed = attributes
.filter { !Self.isNamespaceDeclaration($0) }
.reduce(opened) { attributeDeriv($0, $1, env: env) }
state.current = startTagCloseDeriv(attributed)
state.frames.append(RelaxNGStreamFrame(env: env))
case let .characters(text), let .cdata(text):
if !state.frames.isEmpty { state.frames[state.frames.count - 1].pendingText += text }
case .endElement:
flushText(&state, closing: true)
state.current = endTagDeriv(state.current)
if !state.frames.isEmpty { state.frames.removeLast() }
case .comment, .processingInstruction:
break
}
}
/// Whether the streamed document satisfied the schema (the residual is nullable).
func streamingValid(_ state: PureXML.Schema.RelaxNGStreamingState) -> Bool {
nullable(state.current)
}
private func flushText(_ state: inout PureXML.Schema.RelaxNGStreamingState, closing: Bool) {
guard let index = state.frames.indices.last else { return }
let text = state.frames[index].pendingText
let env = state.frames[index].env
guard !text.isEmpty else {
// An empty element is optional empty text (the tree walk's rule),
// so a data/value pattern admitting "" matches <e/>.
if closing, !state.frames[index].hadElementChild {
state.current = choice(state.current, textDeriv(state.current, "", env: env))
}
return
}
state.frames[index].pendingText = ""
if !Self.isWhitespace(text) {
state.current = textDeriv(state.current, text, env: env)
} else if closing, !state.frames[index].hadElementChild {
// A lone whitespace text child is optional, the `childrenDeriv` rule.
state.current = choice(state.current, textDeriv(state.current, text, env: env))
}
// Whitespace among element siblings is ignorable and skipped.
}
}