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:
parent
22c82cc273
commit
78a8b628a5
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user