PureXML/Tests/RecoveringReaderTests.swift
Mihaela Mihaljevic 78a8b628a5 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.
2026-06-08 12:24:43 +02:00

104 lines
4.4 KiB
Swift

@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("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")
#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)
}
}
}