Merge recovering reader slice 2 (#92): deterministic best-effort read

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 12:15:19 +02:00
commit 22c82cc273
6 changed files with 276 additions and 0 deletions

View File

@ -125,6 +125,19 @@ public extension PureXML {
try Model.TreeNode(parse(xml, limits: limits, resolver: resolver))
}
/// Reads a possibly-invalid document without throwing, returning the maximal
/// best-effort tree and one located ``Parsing/Diagnostic`` per problem.
/// Well-formed input gives the same node as ``parse(_:limits:resolver:)`` with
/// no diagnostics; malformed input is recovered deterministically rather than
/// rejected. The result never crashes, whatever the bytes.
static func read(
_ xml: String,
limits: Parsing.Limits = .default,
resolver: Parsing.EntityResolver = .refusing,
) -> Parsing.ReadResult {
Parsing.Parser().read(xml, limits: limits, resolver: resolver)
}
/// Validates a parsed XML node with the default structural rules, throwing a
/// ``PureXML/Validation/ValidationErrorCollection`` that locates every failure
/// by coding path. Pass a custom ``PureXML/Validation/Validator`` to add or

View File

@ -0,0 +1,50 @@
public extension PureXML.Parsing {
/// A located problem found while reading a document. Unlike a thrown
/// ``ParseError``, a diagnostic does not abort the read: the recovering reader
/// records it, repairs as best it can, and continues, so an invalid document
/// still yields a best-effort tree alongside the list of what went wrong.
struct Diagnostic: Equatable, Sendable, CustomStringConvertible {
/// A human-readable, already-located description of the problem.
public var message: String
/// The source position, when known.
public var mark: Mark?
public init(message: String, mark: Mark? = nil) {
self.message = message
self.mark = mark
}
public var description: String {
message
}
}
}
extension PureXML.Parsing.Diagnostic {
/// Wraps a thrown ``PureXML/Parsing/ParseError`` as a diagnostic, keeping its
/// located message and structured position.
init(_ error: PureXML.Parsing.ParseError) {
self.init(message: error.description, mark: error.mark)
}
}
public extension PureXML.Parsing.ParseError {
/// The source position the error occurred at, when the case carries one.
var mark: PureXML.Parsing.Mark? {
switch self {
case .emptyDocument, .malformedEncoding, .notImplemented:
nil
case let .unexpectedEndOfInput(mark), let .unterminatedTag(mark), let .unterminatedComment(mark),
let .unterminatedCDATA(mark), let .expectedName(mark), let .expectedEquals(mark),
let .unquotedAttributeValue(mark), let .amplificationLimitExceeded(mark),
let .junkAfterDocumentElement(mark), let .unsupportedDoctype(mark):
mark
case let .unexpectedCharacter(_, mark), let .mismatchedEndTag(_, _, mark),
let .duplicateAttribute(_, mark), let .invalidReference(_, mark),
let .undefinedEntity(_, mark), let .recursiveEntity(_, mark),
let .unexpectedEndTag(_, mark), let .undefinedNamespacePrefix(_, mark),
let .nestingTooDeep(_, mark), let .nameTooLong(_, mark), let .contentTooLong(_, mark):
mark
}
}
}

View File

@ -124,6 +124,9 @@ public extension PureXML.Parsing {
return .startElement(name: resolved.name, attributes: resolved.attributes)
}
guard reader.consume(">") else {
// Balance the namespace scope entered above so a recovering reader
// can drop this malformed start tag without leaking a scope.
namespaces.leaveElement()
throw ParseError.unterminatedTag(reader.mark)
}
open.append(resolved.name)
@ -286,3 +289,29 @@ public extension PureXML.Parsing {
}
}
}
extension PureXML.Parsing.EventReader {
/// The current source offset, used to tell whether a failing ``next()`` made
/// progress before throwing.
var offset: Int {
reader.mark.offset
}
/// Resynchronizes after a failed ``next()`` that started at `offset`, so
/// reading can resume past a recorded diagnostic. Returns false at end of
/// input. If the failed scan already advanced (it consumed the malformed
/// construct), reading retries from the new position; if it threw without
/// progress, the offending run is skipped up to the next markup start. Either
/// way at least one character is consumed when input remains, so a recovering
/// loop is guaranteed to terminate.
mutating func recover(from offset: Int) -> Bool {
pending.removeAll()
guard reader.peek() != nil else { return false }
if reader.mark.offset > offset { return true }
reader.advance()
while let character = reader.peek(), character != "<" {
reader.advance()
}
return true
}
}

View File

@ -37,6 +37,18 @@ public extension PureXML.Parsing {
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 {
@ -138,6 +150,73 @@ public extension PureXML.Parsing {
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],

View File

@ -0,0 +1,18 @@
public extension PureXML.Parsing {
/// The outcome of a recovering read: the best-effort node tree and every
/// located ``Diagnostic`` found along the way. Equatable, so a deterministic
/// read can be compared whole.
struct ReadResult: Equatable, Sendable {
/// The best-effort tree. Always a document node; as complete as recovery
/// allowed.
public var node: PureXML.Model.Node
/// One diagnostic per problem, in source order. Empty when the input was
/// well-formed.
public var diagnostics: [Diagnostic]
public init(node: PureXML.Model.Node, diagnostics: [Diagnostic]) {
self.node = node
self.diagnostics = diagnostics
}
}
}

View File

@ -0,0 +1,87 @@
@testable import PureXML
import Testing
/// The recovering reader (`PureXML.read`) never throws and never crashes: it
/// returns the maximal best-effort tree plus one located diagnostic per problem.
/// Its recovery is deterministic, so every assertion here pins an exact result.
@Suite("Recovering reader")
struct RecoveringReaderTests {
@Test("Well-formed input matches the strict parser with no diagnostics")
func test_wellFormed() throws {
let xml = "<a><b>x</b></a>"
let result = PureXML.read(xml)
#expect(result.diagnostics.isEmpty)
#expect(try result.node == PureXML.parse(xml))
}
@Test("Truncated input is closed into a best-effort tree with one diagnostic")
func test_truncated() throws {
let result = PureXML.read("<a><b>x")
#expect(try result.node == PureXML.parse("<a><b>x</b></a>"))
#expect(result.diagnostics.count == 1)
#expect(result.diagnostics[0].mark != nil)
#expect(result.diagnostics[0].message.contains("unexpected end of input"))
}
@Test("A mismatched end tag is reported and the content is salvaged")
func test_mismatchedEndTag() throws {
let result = PureXML.read("<a><b>x</c></a>")
#expect(try result.node == PureXML.parse("<a><b>x</b></a>"))
#expect(result.diagnostics.count == 2)
#expect(result.diagnostics.allSatisfy { $0.message.contains("expected </b>") })
}
@Test("Junk around the root is dropped and the element is kept")
func test_strayJunk() throws {
let result = PureXML.read("garbage<a>x</a>z")
#expect(try result.node == PureXML.parse("<a>x</a>"))
#expect(!result.diagnostics.isEmpty)
}
@Test("Empty input reads as an empty document without crashing")
func test_empty() {
let result = PureXML.read("")
#expect(result.node == .document([]))
#expect(result.diagnostics.isEmpty)
}
@Test("Reading is deterministic: the same bytes give the same result")
func test_deterministic() {
for xml in ["<a><b>x</c>", "<<>><a/>", "<a x= y>z", "<a><b><c>deep"] {
#expect(PureXML.read(xml) == PureXML.read(xml))
}
}
@Test("Arbitrary and truncated input never crashes and stays deterministic")
func test_fuzzNeverCrashes() {
// A seeded generator (no Date/random) makes the sweep reproducible.
var state: UInt64 = 0x9E37_79B9_7F4A_7C15
func nextByte() -> UInt8 {
state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
return UInt8((state >> 33) & 0xFF)
}
let alphabet = Array("<>/&;\"'= \nabcxyz![]-?CDATA")
let seed = "<root attr='v'><child>text</child><!-- c --><![CDATA[d]]></root>"
for iteration in 0 ..< 400 {
// Mix random characters with truncations of a valid document.
var input = ""
let length = Int(nextByte()) % 60
for _ in 0 ..< length {
input.append(alphabet[Int(nextByte()) % alphabet.count])
}
if iteration.isMultiple(of: 3) {
input += String(seed.prefix(Int(nextByte()) % seed.count + 1))
}
let first = PureXML.read(input)
let second = PureXML.read(input)
// Completing without trapping is the no-crash assertion; also check the
// result is a document and the read is deterministic.
guard case .document = first.node else {
Issue.record("read did not return a document for input \(input.debugDescription)")
continue
}
#expect(first == second)
}
}
}