feat(parsing): in-reader recovery keeps elements and pops to matching tags

Closes #93. Recovery moves into a recovering mode on EventReader, so malformed
input is repaired in place rather than dropped to the next markup boundary:

- A duplicate attribute is recorded and the later occurrence dropped, keeping the
  element. <a id='1' id='2'>y</a> now reads as <a id="1">y</a> with one
  diagnostic (was an empty tree with three).
- A mismatched or unexpected end tag pops the open stack to the matching ancestor,
  implicitly closing the elements nested inside it. <a><b><c>x</b></a> reads as
  <a><b><c>x</c></b></a> with a single diagnostic.
- Pre-root junk reports "unexpected character" instead of the misleading
  "content after the root element".

In recovering mode next() no longer throws: it records a Diagnostic and emits
best-effort events, popping to a match on a mismatch and stopping on truncation
so the builder closes what is open. The strict path is unchanged. readRecovering
becomes a plain non-throwing drain that takes the reader's diagnostics. Recovery
stays deterministic; new tests pin the kept-element and pop-to-match results.
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 12:24:43 +02:00
parent 22c82cc273
commit 78a8b628a5
3 changed files with 141 additions and 78 deletions

View File

@ -25,6 +25,12 @@ public extension PureXML.Parsing {
private var pending: [Event] = []
private var sawRoot = false
private var primed = false
/// When true, ``next()`` repairs malformed input in place instead of
/// throwing: it records a ``Diagnostic`` and emits best-effort events.
private let recovering: Bool
private var reachedEnd = false
/// The problems found so far, when reading in recovering mode.
private(set) var diagnostics: [Diagnostic] = []
/// Creates a reader over a character-pulling source. The closure returns
/// the next character or nil at end of input.
@ -32,22 +38,29 @@ public extension PureXML.Parsing {
pulling pull: @escaping () -> Character?,
limits: Limits = .default,
resolver: EntityResolver = .refusing,
recovering: Bool = false,
) {
reader = Reader(pulling: pull)
self.limits = limits
self.resolver = resolver
self.recovering = recovering
entityBudget = limits.maxEntityExpansion
}
/// Creates a reader over a string (a convenience over the streaming init).
init(_ string: String, limits: Limits = .default, resolver: EntityResolver = .refusing) {
init(_ string: String, limits: Limits = .default, resolver: EntityResolver = .refusing, recovering: Bool = false) {
reader = Reader(string)
self.limits = limits
self.resolver = resolver
self.recovering = recovering
entityBudget = limits.maxEntityExpansion
}
/// Returns the next event, or nil at the end of the document.
/// Returns the next event, or nil at the end of the document. In recovering
/// mode it never throws: each malformed construct is recorded as a
/// diagnostic and reading continues, popping to a matching open element on
/// a mismatched end tag and stopping (so the caller closes what is open) on
/// truncation.
mutating func next() throws -> Event? {
if !primed {
primed = true
@ -55,10 +68,21 @@ public extension PureXML.Parsing {
reader.advance()
}
}
if !pending.isEmpty {
return pending.removeFirst()
while true {
if !pending.isEmpty {
return pending.removeFirst()
}
guard recovering else {
return open.isEmpty ? try nextAtTopLevel() : try nextInContent()
}
if reachedEnd { return nil }
let start = offset
do {
return open.isEmpty ? try nextAtTopLevel() : try nextInContent()
} catch let error as ParseError {
if let event = recoveredEvent(from: error, startingAt: start) { return event }
}
}
return open.isEmpty ? try nextAtTopLevel() : try nextInContent()
}
private mutating func nextAtTopLevel() throws -> Event? {
@ -86,7 +110,11 @@ public extension PureXML.Parsing {
guard !sawRoot else { throw ParseError.junkAfterDocumentElement(reader.mark) }
return try scanStartTag()
}
throw ParseError.junkAfterDocumentElement(reader.mark)
// Junk after the root is "content after the root element"; the same
// shape before any root is simply an unexpected character.
throw sawRoot
? ParseError.junkAfterDocumentElement(reader.mark)
: ParseError.unexpectedCharacter(lead, reader.mark)
}
}
@ -168,40 +196,6 @@ public extension PureXML.Parsing {
return try .characters(EntityDecoder.decode(raw, entities: documentType.entities, budget: &entityBudget, at: mark))
}
private mutating func scanComment() throws -> Event {
let mark = reader.mark
reader.consume("<!--")
var content = ""
var length = 0
while !reader.matches("-->") {
guard let character = reader.advance() else {
throw ParseError.unterminatedComment(mark)
}
length += 1
try checkContent(length, mark)
content.append(character)
}
reader.consume("-->")
return .comment(content)
}
private mutating func scanCDATA() throws -> Event {
let mark = reader.mark
reader.consume("<![CDATA[")
var content = ""
var length = 0
while !reader.matches("]]>") {
guard let character = reader.advance() else {
throw ParseError.unterminatedCDATA(mark)
}
length += 1
try checkContent(length, mark)
content.append(character)
}
reader.consume("]]>")
return .cdata(content)
}
private mutating func scanProcessingInstruction() throws -> (target: String, data: String) {
let mark = reader.mark
reader.consume("<?")
@ -238,6 +232,12 @@ public extension PureXML.Parsing {
reader.skipSpace()
let value = try scanAttributeValue()
guard seen.insert(name.description).inserted else {
if recovering {
// Keep the element: record the duplicate and drop the later
// occurrence rather than failing the whole start tag.
diagnostics.append(Diagnostic(.duplicateAttribute(name: name.description, mark)))
continue
}
throw ParseError.duplicateAttribute(name: name.description, mark)
}
attributes.append(PureXML.Model.Attribute(name: name, value: value))
@ -314,4 +314,74 @@ extension PureXML.Parsing.EventReader {
}
return true
}
/// Pops the open-element stack down to (and including) the most recent element
/// named `found`, synthesizing an end-tag event for each closed element,
/// innermost first. Returns the first such event with the rest queued, so a
/// mismatched end tag implicitly closes the elements nested inside the match.
/// Returns nil when no open element matches, dropping the stray end tag.
mutating func scanComment() throws -> PureXML.Parsing.Event {
let mark = reader.mark
reader.consume("<!--")
var content = ""
var length = 0
while !reader.matches("-->") {
guard let character = reader.advance() else {
throw PureXML.Parsing.ParseError.unterminatedComment(mark)
}
length += 1
try checkContent(length, mark)
content.append(character)
}
reader.consume("-->")
return .comment(content)
}
mutating func scanCDATA() throws -> PureXML.Parsing.Event {
let mark = reader.mark
reader.consume("<![CDATA[")
var content = ""
var length = 0
while !reader.matches("]]>") {
guard let character = reader.advance() else {
throw PureXML.Parsing.ParseError.unterminatedCDATA(mark)
}
length += 1
try checkContent(length, mark)
content.append(character)
}
reader.consume("]]>")
return .cdata(content)
}
/// Handles a thrown error in recovering mode: records the diagnostic and
/// returns an event to emit (an end tag synthesized by popping to a match) or
/// nil to continue after resynchronizing. Marks the end of input on truncation
/// or an exhausted source.
mutating func recoveredEvent(from error: PureXML.Parsing.ParseError, startingAt start: Int) -> PureXML.Parsing.Event? {
diagnostics.append(PureXML.Parsing.Diagnostic(error))
switch error {
case .unexpectedEndOfInput:
reachedEnd = true
return nil
case let .mismatchedEndTag(_, found, _), let .unexpectedEndTag(found, _):
return closeTo(found)
default:
if !recover(from: start) { reachedEnd = true }
return nil
}
}
mutating func closeTo(_ found: String) -> PureXML.Parsing.Event? {
guard let index = open.lastIndex(where: { $0.description == found }) else { return nil }
var events: [PureXML.Parsing.Event] = []
while open.count > index {
let closed = open.removeLast()
namespaces.leaveElement()
events.append(.endElement(name: closed))
}
guard let first = events.first else { return nil }
pending.append(contentsOf: events.dropFirst())
return first
}
}

View File

@ -46,7 +46,7 @@ public extension PureXML.Parsing {
limits: Limits = .default,
resolver: EntityResolver = .refusing,
) -> ReadResult {
readRecovering(EventReader(xml, limits: limits, resolver: resolver))
readRecovering(EventReader(xml, limits: limits, resolver: resolver, recovering: true))
}
/// Parses a single XML document from raw bytes, detecting the encoding
@ -150,60 +150,37 @@ 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.
/// Drains a recovering ``EventReader`` (which repairs malformed input in
/// place and never throws), then closes any elements the input left open by
/// truncation, innermost first. The reader's diagnostics carry every
/// problem found. 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)
while let event = try? reader.next() {
consume(event, into: &stack, roots: &roots)
}
// 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)
return ReadResult(node: .document(roots), diagnostics: reader.diagnostics)
}
private func consume(
_ event: Event,
into stack: inout [ElementFrame],
roots: inout [PureXML.Model.Node],
diagnostics: inout [Diagnostic],
) {
private func consume(_ event: Event, into stack: inout [ElementFrame], roots: inout [PureXML.Model.Node]) {
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
}
case .endElement:
// The recovering reader only emits an end tag for an element it
// actually closed, so the builder stack stays in step; a stray one
// (already diagnosed) is simply dropped.
guard let frame = stack.popLast() else { 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):

View File

@ -31,6 +31,22 @@ struct RecoveringReaderTests {
#expect(result.diagnostics.allSatisfy { $0.message.contains("expected </b>") })
}
@Test("A duplicate attribute is dropped but the element is kept")
func test_duplicateAttributeKeepsElement() throws {
let result = PureXML.read("<a id='1' id='2'>y</a>")
#expect(try result.node == PureXML.parse("<a id='1'>y</a>"))
#expect(result.diagnostics.count == 1)
#expect(result.diagnostics[0].message.contains("duplicate attribute 'id'"))
}
@Test("A mismatched end tag pops to the matching ancestor, closing nested elements")
func test_popToMatch() throws {
let result = PureXML.read("<a><b><c>x</b></a>")
#expect(try result.node == PureXML.parse("<a><b><c>x</c></b></a>"))
#expect(result.diagnostics.count == 1)
#expect(result.diagnostics[0].message.contains("expected </c> but found </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")