PureXML/Sources/Schema/RelaxNGSchema.swift
Mihaela Mihaljevic 051ef02fdb feat(validation): streaming RELAX NG validation
Add RelaxNG.validate(streaming:), driving the Brzozowski derivative directly
from the parse-event stream (the libxml2 xmlTextReader model). The derivative
is already incremental: the after-threaded pattern chain is the validation
stack, so only the residual pattern and a light per-element frame (for the
childrenDeriv ignorable-whitespace rule) are retained, never the tree. A
differential test asserts streaming agrees with the trusted tree engine across
valid, invalid, whitespace, and mixed-content documents. XSD streaming remains.

Part of #107, #105
2026-06-09 10:35:42 +02:00

79 lines
4.0 KiB
Swift

public extension PureXML.Schema {
/// A compiled RELAX NG schema (XML syntax). `validate(_:)` checks an instance
/// document by the derivative algorithm.
struct RelaxNG: Sendable {
private let start: Pattern
private let defines: [String: Pattern]
/// Compiles a RELAX NG schema document (XML syntax). `schemaLoader`
/// resolves the `href` of `include` and `externalRef` to schema source;
/// it returns nil (the default) when external schemas are not available.
public init(_ rng: String, schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
(start, defines) = try RelaxNGParser.parse(rng, loader: schemaLoader)
}
/// Compiles a RELAX NG schema in the compact syntax (RNC). `schemaLoader`
/// resolves `include` and `external` references to compact-syntax source.
public init(compact rnc: String, schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
(start, defines) = try RelaxNGCompactParser.parse(rnc, loader: schemaLoader)
}
/// Whether `xml` is valid against the schema.
public func validate(_ xml: String) throws -> Bool {
guard case let .document(children) = try PureXML.parse(xml),
let root = children.compactMap(\.element).first
else {
return false
}
return RelaxNGEngine(defines: defines).matches(start: start, root: .element(root))
}
/// Whether `xml` is valid against the schema, validated while it is pulled
/// event by event (the libxml2 `xmlTextReader` model) rather than over a
/// built tree. The Brzozowski derivative is itself incremental, so only the
/// residual pattern and a light per-element whitespace frame are retained.
public func validate(streaming xml: String, limits: PureXML.Parsing.Limits = .default) throws -> Bool {
let engine = RelaxNGEngine(defines: defines)
var state = engine.streamingStart(start)
var reader = PureXML.Parsing.EventReader(xml, limits: limits)
while let event = try reader.next() {
engine.streamingConsume(event, into: &state)
}
return engine.streamingValid(state)
}
/// Every way `xml` fails the schema, as located errors with recovery hints,
/// so an editor can show all of a faulty document's problems at once rather
/// than only the first. An empty array means the document is valid.
public func errors(in xml: String) throws -> [PureXML.Validation.ValidationError] {
guard case let .document(children) = try PureXML.parse(xml),
let root = children.compactMap(\.element).first
else {
return [PureXML.Validation.ValidationError(reason: "the document has no root element", at: [])]
}
return RelaxNGEngine(defines: defines).locatedErrors(start: start, root: root)
}
/// The schema as a single ``PureXML/Validation/Validation`` over the
/// document root, so RELAX NG composes with the rest of the validation
/// framework exactly like the XSD, DTD, and Schematron rules.
public func validation() -> PureXML.Validation.Validation<PureXML.Model.Node, Void> {
let start = start
let defines = defines
return .init(
description: "Document satisfies the RELAX NG schema",
check: { context in
let root: PureXML.Model.Element? = switch context.subject {
case let .document(children): children.compactMap(\.element).first
case let .element(element): element
default: nil
}
guard let root else { return [] }
return RelaxNGEngine(defines: defines).locatedErrors(start: start, root: root)
},
when: { $0.codingPath.isEmpty },
)
}
}
}