diff --git a/Sources/API.swift b/Sources/API.swift
index 8d470b0..a792396 100644
--- a/Sources/API.swift
+++ b/Sources/API.swift
@@ -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
diff --git a/Sources/Parsing/Diagnostic.swift b/Sources/Parsing/Diagnostic.swift
new file mode 100644
index 0000000..d979310
--- /dev/null
+++ b/Sources/Parsing/Diagnostic.swift
@@ -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
+ }
+ }
+}
diff --git a/Sources/Parsing/EventReader.swift b/Sources/Parsing/EventReader.swift
index 33b4350..355e9b1 100644
--- a/Sources/Parsing/EventReader.swift
+++ b/Sources/Parsing/EventReader.swift
@@ -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
+ }
+}
diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift
index 09ab8a0..233ba81 100644
--- a/Sources/Parsing/Parser.swift
+++ b/Sources/Parsing/Parser.swift
@@ -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],
diff --git a/Sources/Parsing/ReadResult.swift b/Sources/Parsing/ReadResult.swift
new file mode 100644
index 0000000..e0e1b20
--- /dev/null
+++ b/Sources/Parsing/ReadResult.swift
@@ -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
+ }
+ }
+}
diff --git a/Tests/RecoveringReaderTests.swift b/Tests/RecoveringReaderTests.swift
new file mode 100644
index 0000000..a11a9b4
--- /dev/null
+++ b/Tests/RecoveringReaderTests.swift
@@ -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 = "x"
+ 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("x")
+ #expect(try result.node == PureXML.parse("x"))
+ #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("x")
+ #expect(try result.node == PureXML.parse("x"))
+ #expect(result.diagnostics.count == 2)
+ #expect(result.diagnostics.allSatisfy { $0.message.contains("expected ") })
+ }
+
+ @Test("Junk around the root is dropped and the element is kept")
+ func test_strayJunk() throws {
+ let result = PureXML.read("garbagexz")
+ #expect(try result.node == PureXML.parse("x"))
+ #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 ["x", "<<>>", "z", "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 = "text"
+
+ 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)
+ }
+ }
+}