EntityExpander.expand located the next '&', '<', and ';' with String.firstIndex over graphemes, so every literal byte of a run was decoded into a grapheme just to look for a one-byte marker. All three markers are ASCII, so a UTF-8 index is a valid string index: scan over raw.utf8 instead, and the found index still subscripts the string directly (the marker byte is never a continuation byte, so its index lands on a scalar and grapheme boundary). The decoded text is unchanged, including runs where the marker follows multibyte UTF-8: a precomposed letter, a combining sequence, or an astral character all leave the marker on a grapheme boundary. Measured parse ~5.5% faster on the 20k-item generated corpus via a same-state A/B (6.5x to ~6.1x slower than libxml2). Verified by EntityDecodeBulkTests (extended with four multibyte-boundary cases), the W3C XML conformance corpora, the Xalan XSLT gold-output corpus, the full suite, and the WASM build.
89 lines
4.4 KiB
Swift
89 lines
4.4 KiB
Swift
import Testing
|
|
@testable import PureXML
|
|
|
|
/// `EntityExpander.expand` copies the literal text between references in bulk
|
|
/// rather than one character at a time. These pin that the bulk path produces
|
|
/// exactly the per-character result it replaced: references at the start, end,
|
|
/// and adjacent; literal runs of every length around them; decimal and hex
|
|
/// character references; declared-entity replacement (the budget-counted path,
|
|
/// including a bulk run that overflows the amplification budget); and a CDATA
|
|
/// section in replacement text copied verbatim without reference recognition.
|
|
@Suite("Entity decode bulk-copy equivalence")
|
|
struct EntityDecodeBulkTests {
|
|
private typealias Decoder = PureXML.Parsing.EntityDecoder
|
|
private static let mark = PureXML.Parsing.Mark(line: 1, column: 1, offset: 0)
|
|
|
|
private func decode(_ raw: String, entities: [String: String] = [:], budget: Int = 1_000_000) throws -> String {
|
|
var remaining = budget
|
|
return try Decoder.decode(raw, entities: entities, budget: &remaining, at: Self.mark)
|
|
}
|
|
|
|
@Test("predefined references inside literal runs decode in place")
|
|
func test_predefinedRuns() throws {
|
|
#expect(try decode("plain text with no references") == "plain text with no references")
|
|
#expect(try decode("a&b") == "a&b")
|
|
#expect(try decode("x & y < z > w "q" 'a'") == "x & y < z > w \"q\" 'a'")
|
|
#expect(try decode("&<>") == "&<>")
|
|
#expect(try decode("&tail") == "&tail")
|
|
#expect(try decode("head&") == "head&")
|
|
// A long literal run on each side of a single reference (the bulk case).
|
|
let long = String(repeating: "abcde ", count: 200)
|
|
#expect(try decode("\(long)&\(long)") == "\(long)&\(long)")
|
|
}
|
|
|
|
@Test("character references decode decimal and hex")
|
|
func test_characterReferences() throws {
|
|
#expect(try decode("ABC") == "ABC")
|
|
#expect(try decode("pre ☺ post") == "pre \u{263A} post")
|
|
}
|
|
|
|
@Test("multibyte literal text around references decodes correctly")
|
|
func test_multibyteRuns() throws {
|
|
// The marker scan is at the byte level; these confirm a reference byte
|
|
// that follows multibyte UTF-8 (precomposed, combining, and astral) is
|
|
// still located at a valid grapheme boundary, so the run splits cleanly.
|
|
#expect(try decode("café&thé") == "café&thé")
|
|
#expect(try decode("e\u{0301}&e\u{0301}") == "e\u{0301}&e\u{0301}")
|
|
#expect(try decode("\u{1F600}<\u{1F601}") == "\u{1F600}<\u{1F601}")
|
|
#expect(try decode("naïve text with no references") == "naïve text with no references")
|
|
}
|
|
|
|
@Test("declared entity replacement expands and counts against the budget")
|
|
func test_declaredReplacement() throws {
|
|
#expect(try decode("<&e;>", entities: ["e": "REPLACEMENT"]) == "<REPLACEMENT>")
|
|
// A replacement run longer than the remaining budget overflows: the
|
|
// per-character path threw at the overrunning character, the bulk path
|
|
// throws on the whole run; both reject the decode.
|
|
#expect(throws: (any Error).self) {
|
|
try decode("&e;", entities: ["e": "ABCDE"], budget: 3)
|
|
}
|
|
// Exactly the budget is allowed (the boundary the per-character guard
|
|
// permitted).
|
|
#expect(try decode("&e;", entities: ["e": "ABCDE"], budget: 5) == "ABCDE")
|
|
}
|
|
|
|
@Test("a CDATA section in replacement text is copied verbatim")
|
|
func test_cdataInReplacement() throws {
|
|
// The '¬' inside CDATA must not be treated as a reference.
|
|
#expect(try decode("&e;", entities: ["e": "<![CDATA[a¬b]]>"]) == "<![CDATA[a¬b]]>")
|
|
}
|
|
|
|
@Test("an unterminated reference is rejected")
|
|
func test_unterminatedReference() throws {
|
|
#expect(throws: (any Error).self) {
|
|
try decode("text & without a semicolon")
|
|
}
|
|
}
|
|
|
|
@Test("text with references decodes through the parser to one text node")
|
|
func test_throughParser() throws {
|
|
let node = try PureXML.parse("<r>x & y < z > w</r>")
|
|
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
|
|
Issue.record("no root")
|
|
return
|
|
}
|
|
let text = root.children.compactMap { if case let .text(value) = $0 { value } else { nil } }.joined()
|
|
#expect(text == "x & y < z > w")
|
|
}
|
|
}
|