Merge recovering reader slice 2 (#92): deterministic best-effort read
This commit is contained in:
commit
22c82cc273
@ -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
|
||||
|
||||
50
Sources/Parsing/Diagnostic.swift
Normal file
50
Sources/Parsing/Diagnostic.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 </\(name.description)>"))
|
||||
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],
|
||||
|
||||
18
Sources/Parsing/ReadResult.swift
Normal file
18
Sources/Parsing/ReadResult.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
87
Tests/RecoveringReaderTests.swift
Normal file
87
Tests/RecoveringReaderTests.swift
Normal file
@ -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 = "<a><b>x</b></a>"
|
||||
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("<a><b>x")
|
||||
#expect(try result.node == PureXML.parse("<a><b>x</b></a>"))
|
||||
#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("<a><b>x</c></a>")
|
||||
#expect(try result.node == PureXML.parse("<a><b>x</b></a>"))
|
||||
#expect(result.diagnostics.count == 2)
|
||||
#expect(result.diagnostics.allSatisfy { $0.message.contains("expected </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")
|
||||
#expect(try result.node == PureXML.parse("<a>x</a>"))
|
||||
#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 ["<a><b>x</c>", "<<>><a/>", "<a x= y>z", "<a><b><c>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 = "<root attr='v'><child>text</child><!-- c --><![CDATA[d]]></root>"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user