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 ")) + 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) + } + } +}