diff --git a/Sources/Parsing/EventReader.swift b/Sources/Parsing/EventReader.swift
index 355e9b1..3e1e203 100644
--- a/Sources/Parsing/EventReader.swift
+++ b/Sources/Parsing/EventReader.swift
@@ -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("") {
- 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("") {
- 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("") {
+ 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("") {
+ 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
+ }
}
diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift
index 233ba81..48e6de5 100644
--- a/Sources/Parsing/Parser.swift
+++ b/Sources/Parsing/Parser.swift
@@ -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):
diff --git a/Tests/RecoveringReaderTests.swift b/Tests/RecoveringReaderTests.swift
index a11a9b4..718ea07 100644
--- a/Tests/RecoveringReaderTests.swift
+++ b/Tests/RecoveringReaderTests.swift
@@ -31,6 +31,22 @@ struct RecoveringReaderTests {
#expect(result.diagnostics.allSatisfy { $0.message.contains("expected ") })
}
+ @Test("A duplicate attribute is dropped but the element is kept")
+ func test_duplicateAttributeKeepsElement() throws {
+ let result = PureXML.read("y")
+ #expect(try result.node == PureXML.parse("y"))
+ #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("x")
+ #expect(try result.node == PureXML.parse("x"))
+ #expect(result.diagnostics.count == 1)
+ #expect(result.diagnostics[0].message.contains("expected but found "))
+ }
+
@Test("Junk around the root is dropped and the element is kept")
func test_strayJunk() throws {
let result = PureXML.read("garbagexz")