PureXML/Sources/Validation/StreamingDTDValidator.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

177 lines
8.1 KiB
Swift

public extension PureXML.Validation {
/// Validates a document against a DTD while it is pulled event by event, the
/// libxml2 `xmlTextReader` validation model. Only the open-element stack is
/// retained (each open element accumulates its direct-child names and a text
/// flag), never the whole tree: an element's content model and attributes are
/// checked the moment it closes, then its frame is dropped. Errors carry a
/// libxml2 `xmlGetNodePath`-style coding path (every child step indexed).
///
/// This reuses the exact per-element DTD rules (``DTD/contentModel`` and the
/// attribute rules), applied to a shallow element synthesized from the
/// streamed names, so streaming and tree validation agree. Document-scoped
/// ID/IDREF integrity, which needs whole-document state, stays on the tree
/// validator and is tracked separately.
struct StreamingDTDValidator {
private let schema: DTDSchema
private let rules: [Validation<PureXML.Model.Element, DTDSchema>]
private var stack: [StreamingDTDFrame] = []
private var collected: [ValidationError] = []
// Document-scoped ID/IDREF state, bounded to the identifier values, not
// the tree. IDREFs may point forward, so they resolve at finish().
private var idCounts: [String: Int] = [:]
private var idDuplicatePath: [String: [PathKey]] = [:]
private var pendingReferences: [StreamingIDReference] = []
public init(schema: DTDSchema, strict: Bool = false) {
self.schema = schema
var rules: [Validation<PureXML.Model.Element, DTDSchema>] = [
DTD.contentModel,
DTD.requiredAttributes,
DTD.fixedAttributeValues,
DTD.enumeratedAttributeValues,
DTD.tokenizedAttributeTypes,
DTD.notationAttributes,
]
if strict {
rules.append(DTD.undeclaredElement)
}
self.rules = rules
}
/// Feeds one parse event into the validator.
public mutating func consume(_ event: PureXML.Parsing.Event) {
switch event {
case let .startElement(name, attributes):
stack.append(StreamingDTDFrame(name: name, attributes: attributes, path: openPath(name)))
case let .characters(text), let .cdata(text):
if !stack.isEmpty, text.contains(where: { !$0.isWhitespace }) {
stack[stack.count - 1].hasText = true
}
case .endElement:
closeTop()
case .comment, .processingInstruction:
break
}
}
/// The per-element errors found so far (content models and attributes).
/// Document-scoped ID/IDREF errors are added by ``finish()``.
public var errors: [ValidationError] {
collected
}
/// The complete error set, including the document-scoped ID/IDREF checks
/// that can only be resolved once every element has been seen. Call after
/// the last event.
public mutating func finish() -> [ValidationError] {
var result = collected
for (value, count) in idCounts where count > 1 {
result.append(ValidationError(
reason: "duplicate ID '\(value)' (declared \(count) times)",
at: idDuplicatePath[value] ?? [],
))
}
for reference in pendingReferences where idCounts[reference.value] == nil {
result.append(ValidationError(
reason: "IDREF '\(reference.value)' on <\(reference.element)> matches no ID",
at: reference.path,
))
}
return result
}
// MARK: Internals
/// The coding path for an opening element, recording it as a child of the
/// current open element with a one-based occurrence index (the
/// `xmlGetNodePath` convention).
private mutating func openPath(_ name: PureXML.Model.QualifiedName) -> [PathKey] {
let key = name.description
guard !stack.isEmpty else { return [.element(key)] }
let parent = stack.count - 1
stack[parent].childCounts[key, default: 0] += 1
stack[parent].childNames.append(key)
let index = stack[parent].childCounts[key] ?? 1
return stack[parent].path + [.element(key, index: index)]
}
/// Validates the closing element against the per-element DTD rules, using a
/// shallow element synthesized from the streamed child names and text flag.
private mutating func closeTop() {
guard let frame = stack.popLast() else { return }
let children: [PureXML.Model.Node] = frame.childNames.map { .element(PureXML.Model.Element($0)) }
+ (frame.hasText ? [.text("x")] : [])
let element = PureXML.Model.Element(name: frame.name, attributes: frame.attributes, children: children)
collected += rules.flatMap { $0.apply(to: element, at: frame.path, in: schema) }
collectIdentifiers(frame)
}
/// Records the closing element's ID and IDREF attribute values, so the
/// document-scoped ID/IDREF integrity check can run at ``finish()``. A
/// duplicate ID is located at its repeat occurrence; an IDREF at the
/// element that carries it.
private mutating func collectIdentifiers(_ frame: StreamingDTDFrame) {
let name = frame.name.description
guard let declarations = schema.attributes[name] else { return }
for declaration in declarations {
guard let value = frame.attributes.first(where: {
$0.name.description == declaration.name || $0.name.localName == declaration.name
})?.value else { continue }
switch declaration.type {
case .id:
idCounts[value, default: 0] += 1
if idCounts[value] == 2 {
idDuplicatePath[value] = frame.path
}
case .idReference:
pendingReferences.append(StreamingIDReference(value: value, element: name, path: frame.path))
case .idReferences:
for token in value.split(whereSeparator: { $0.isWhitespace }) {
pendingReferences.append(StreamingIDReference(value: String(token), element: name, path: frame.path))
}
case .cdata, .enumeration, .notation, .nmToken, .nmTokens, .entity, .entities:
break
}
}
}
}
}
public extension PureXML {
/// Validates `xml` against `dtd` while pulling it event by event, the libxml2
/// `xmlTextReader` validation model. Returns located errors. Memory is bounded
/// to the open-element stack, never the whole tree.
static func validate(
streaming xml: String,
dtd: Validation.DTDSchema,
strict: Bool = false,
limits: Parsing.Limits = .default,
) throws -> [Validation.ValidationError] {
var validator = Validation.StreamingDTDValidator(schema: dtd, strict: strict)
var reader = events(xml, limits: limits)
while let event = try reader.next() {
validator.consume(event)
}
return validator.finish()
}
}
/// A pending IDREF awaiting resolution at the end of the stream: the referenced
/// value, the element that carries it, and that element's coding path.
private struct StreamingIDReference {
let value: String
let element: String
let path: [PureXML.Validation.PathKey]
}
/// One open element while streaming: its name and attributes, the coding path to
/// it, and the direct-child names and text flag accumulated until it closes.
private struct StreamingDTDFrame {
let name: PureXML.Model.QualifiedName
let attributes: [PureXML.Model.Attribute]
let path: [PureXML.Validation.PathKey]
var childNames: [String] = []
var childCounts: [String: Int] = [:]
var hasText = false
}