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(" 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 ")) - 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")