Slice 2 of #92. Add PureXML.read, a deterministic recovering reader: it never throws and never traps, returning the maximal best-effort tree plus one located Diagnostic per problem. - ReadResult { node, diagnostics } is the Equatable result type, so a deterministic read can be compared whole. - Diagnostic carries a message and a structured source Mark; a thrown ParseError maps to one directly (ParseError.mark exposes the position each case carries). - The reader resynchronizes after a failure (EventReader.recover(from:)): progress-aware, so a failure that already consumed the bad construct retries from there, while a stuck failure skips to the next markup start; it always advances when input remains, guaranteeing termination. - The tree builder assembles leniently: elements left open by truncation are closed, an end tag with no open element is dropped. Well-formed input yields exactly parse(_:)'s tree with no diagnostics. Tests pin exact trees and diagnostics for truncation, mismatched tags, and stray junk, prove determinism (same bytes give an identical ReadResult), and fuzz 400 seeded inputs (arbitrary plus truncated valid docs) asserting no crash. Recovery here is resync-based; finer in-reader recovery (keep the element on a duplicate attribute, pop to a matching open tag) is the next slice.
233 lines
10 KiB
Swift
233 lines
10 KiB
Swift
/// A partially-built element while its children stream in. File-scope and
|
|
/// private: an internal detail of the tree builder, not part of the namespace.
|
|
private struct ElementFrame {
|
|
let name: PureXML.Model.QualifiedName
|
|
let attributes: [PureXML.Model.Attribute]
|
|
var children: [PureXML.Model.Node] = []
|
|
}
|
|
|
|
public extension PureXML.Parsing {
|
|
/// Builds a ``PureXML/Model/Node`` tree by draining the streaming
|
|
/// ``EventReader`` with an explicit element stack. It is iterative, not
|
|
/// recursive, and the streaming core means it never requires the whole input
|
|
/// in memory at once: characters are pulled on demand. Callers that do not
|
|
/// need a full tree should consume ``EventReader`` directly.
|
|
struct Parser: Sendable {
|
|
public init() {}
|
|
|
|
/// Parses a single XML document from a string into a document node. Supply
|
|
/// a ``EntityResolver`` to opt into external entities; the default refuses
|
|
/// them, keeping XXE closed.
|
|
public func parse(
|
|
_ xml: String,
|
|
limits: Limits = .default,
|
|
resolver: EntityResolver = .refusing,
|
|
) throws -> PureXML.Model.Node {
|
|
try build(EventReader(xml, limits: limits, resolver: resolver)).node
|
|
}
|
|
|
|
/// Parses a string and surfaces the parsed DTD (entities, element content
|
|
/// models, parameter entities, external identifiers) alongside the tree,
|
|
/// for schema validation.
|
|
public func parseWithDocumentType(
|
|
_ xml: String,
|
|
limits: Limits = .default,
|
|
resolver: EntityResolver = .refusing,
|
|
) throws -> (node: PureXML.Model.Node, documentType: DocumentType) {
|
|
try build(EventReader(xml, limits: limits, resolver: resolver))
|
|
}
|
|
|
|
/// Reads a possibly-invalid document without ever throwing: returns the
|
|
/// maximal best-effort tree and a located ``Diagnostic`` for every problem
|
|
/// found. Well-formed input yields the same tree as ``parse(_:limits:resolver:)``
|
|
/// with no diagnostics. See ``readRecovering(_:)`` for the recovery contract.
|
|
public func read(
|
|
_ xml: String,
|
|
limits: Limits = .default,
|
|
resolver: EntityResolver = .refusing,
|
|
) -> ReadResult {
|
|
readRecovering(EventReader(xml, limits: limits, resolver: resolver))
|
|
}
|
|
|
|
/// Parses a single XML document from raw bytes, detecting the encoding
|
|
/// (UTF-8 or UTF-16, with or without a byte-order mark) before parsing.
|
|
public func parse(bytes: [UInt8], limits: Limits = .default) throws -> PureXML.Model.Node {
|
|
try parse(ByteDecoder.decode(bytes), limits: limits)
|
|
}
|
|
|
|
/// Parses a single XML document from an incremental character source. The
|
|
/// closure returns the next character or nil at end of input, so the
|
|
/// document can arrive in chunks and is never held whole.
|
|
public func parse(
|
|
pulling pull: @escaping () -> Character?,
|
|
limits: Limits = .default,
|
|
) throws -> PureXML.Model.Node {
|
|
try build(EventReader(pulling: pull, limits: limits)).node
|
|
}
|
|
|
|
/// Parses a single XML document from an incremental BYTE source, decoding
|
|
/// the encoding (UTF-8 or UTF-16) on the fly so the bytes are never fully
|
|
/// buffered. The closure returns the next byte or nil at end of input.
|
|
public func parse(
|
|
pullingBytes pull: @escaping () -> UInt8?,
|
|
limits: Limits = .default,
|
|
) throws -> PureXML.Model.Node {
|
|
var decoder = StreamingDecoder(pullingBytes: pull)
|
|
return try build(EventReader(pulling: { decoder.next() }, limits: limits)).node
|
|
}
|
|
|
|
/// Parses a document, delivering SAX-style callbacks instead of building a
|
|
/// tree. The handler's callbacks fire as the parse streams.
|
|
public func parse(
|
|
_ xml: String,
|
|
sax handler: SAXHandler,
|
|
limits: Limits = .default,
|
|
resolver: EntityResolver = .refusing,
|
|
) throws {
|
|
var reader = EventReader(xml, limits: limits, resolver: resolver)
|
|
handler.startDocument?()
|
|
var produced = false
|
|
while let event = try reader.next() {
|
|
produced = true
|
|
deliver(event, to: handler)
|
|
}
|
|
guard produced else { throw ParseError.emptyDocument }
|
|
handler.endDocument?()
|
|
}
|
|
|
|
private func deliver(_ event: Event, to handler: SAXHandler) {
|
|
switch event {
|
|
case let .startElement(name, attributes):
|
|
handler.startElement?(name, attributes)
|
|
case let .endElement(name):
|
|
handler.endElement?(name)
|
|
case let .characters(text):
|
|
handler.characters?(text)
|
|
case let .cdata(text):
|
|
handler.cdata?(text)
|
|
case let .comment(text):
|
|
handler.comment?(text)
|
|
case let .processingInstruction(target, data):
|
|
handler.processingInstruction?(target, data)
|
|
}
|
|
}
|
|
|
|
private func build(_ source: EventReader) throws -> (node: PureXML.Model.Node, documentType: DocumentType) {
|
|
var reader = source
|
|
var roots: [PureXML.Model.Node] = []
|
|
var stack: [ElementFrame] = []
|
|
var produced = false
|
|
|
|
while let event = try reader.next() {
|
|
produced = true
|
|
switch event {
|
|
case let .startElement(name, attributes):
|
|
stack.append(ElementFrame(name: name, attributes: attributes))
|
|
case .endElement:
|
|
guard let frame = stack.popLast() else {
|
|
throw ParseError.unexpectedEndOfInput(.start)
|
|
}
|
|
let element = PureXML.Model.Element(
|
|
name: frame.name,
|
|
attributes: frame.attributes,
|
|
children: frame.children,
|
|
)
|
|
attach(.element(element), to: &stack, roots: &roots)
|
|
case let .characters(text):
|
|
attach(.text(text), to: &stack, roots: &roots)
|
|
case let .cdata(text):
|
|
attach(.cdata(text), to: &stack, roots: &roots)
|
|
case let .comment(text):
|
|
attach(.comment(text), to: &stack, roots: &roots)
|
|
case let .processingInstruction(target, data):
|
|
attach(.processingInstruction(target: target, data: data), to: &stack, roots: &roots)
|
|
}
|
|
}
|
|
|
|
guard produced else {
|
|
throw ParseError.emptyDocument
|
|
}
|
|
return (.document(roots), reader.documentType)
|
|
}
|
|
|
|
/// Drains the reader the way ``build`` does, but never throws: each error
|
|
/// is recorded as a ``Diagnostic`` and the reader resynchronizes past it,
|
|
/// so reading continues to the end of input. Elements left open by
|
|
/// truncated input are closed, and an end tag with no open element is
|
|
/// dropped, yielding the maximal best-effort tree. The transformation is
|
|
/// deterministic: the same input always produces the same tree and the
|
|
/// same diagnostics.
|
|
func readRecovering(_ source: EventReader) -> ReadResult {
|
|
var reader = source
|
|
var roots: [PureXML.Model.Node] = []
|
|
var stack: [ElementFrame] = []
|
|
var diagnostics: [Diagnostic] = []
|
|
|
|
loop: while true {
|
|
let start = reader.offset
|
|
let event: Event?
|
|
do {
|
|
event = try reader.next()
|
|
} catch let error as ParseError {
|
|
diagnostics.append(Diagnostic(error))
|
|
if !reader.recover(from: start) { break loop }
|
|
continue
|
|
} catch {
|
|
diagnostics.append(Diagnostic(message: "\(error)"))
|
|
if !reader.recover(from: start) { break loop }
|
|
continue
|
|
}
|
|
guard let event else { break }
|
|
consume(event, into: &stack, roots: &roots, diagnostics: &diagnostics)
|
|
}
|
|
|
|
// Close any elements the input left open (truncation), innermost first.
|
|
while !stack.isEmpty {
|
|
let frame = stack.removeLast()
|
|
let element = PureXML.Model.Element(name: frame.name, attributes: frame.attributes, children: frame.children)
|
|
attach(.element(element), to: &stack, roots: &roots)
|
|
}
|
|
return ReadResult(node: .document(roots), diagnostics: diagnostics)
|
|
}
|
|
|
|
private func consume(
|
|
_ event: Event,
|
|
into stack: inout [ElementFrame],
|
|
roots: inout [PureXML.Model.Node],
|
|
diagnostics: inout [Diagnostic],
|
|
) {
|
|
switch event {
|
|
case let .startElement(name, attributes):
|
|
stack.append(ElementFrame(name: name, attributes: attributes))
|
|
case let .endElement(name):
|
|
guard let frame = stack.popLast() else {
|
|
diagnostics.append(Diagnostic(message: "unmatched end tag </\(name.description)>"))
|
|
return
|
|
}
|
|
let element = PureXML.Model.Element(name: frame.name, attributes: frame.attributes, children: frame.children)
|
|
attach(.element(element), to: &stack, roots: &roots)
|
|
case let .characters(text):
|
|
attach(.text(text), to: &stack, roots: &roots)
|
|
case let .cdata(text):
|
|
attach(.cdata(text), to: &stack, roots: &roots)
|
|
case let .comment(text):
|
|
attach(.comment(text), to: &stack, roots: &roots)
|
|
case let .processingInstruction(target, data):
|
|
attach(.processingInstruction(target: target, data: data), to: &stack, roots: &roots)
|
|
}
|
|
}
|
|
|
|
private func attach(
|
|
_ node: PureXML.Model.Node,
|
|
to stack: inout [ElementFrame],
|
|
roots: inout [PureXML.Model.Node],
|
|
) {
|
|
if stack.isEmpty {
|
|
roots.append(node)
|
|
} else {
|
|
stack[stack.count - 1].children.append(node)
|
|
}
|
|
}
|
|
}
|
|
}
|