PureXML/Tests/EscapingBulkCopyTests.swift
Mihaela Mihaljevic 0bda990f80
perf(serialize): scan bytes, not graphemes, to find characters to escape (#297)
The bulk-copy escape loop still iterated Characters and compared each to '&',
'<', '>' (a grapheme comparison per character), the dominant remaining serialize
cost for content that needs escaping. Every escaping trigger is an ASCII marker,
or in ASCII-only mode a non-ASCII lead byte, so find the next one with
value.utf8.firstIndex(where:) and decode no grapheme for the run between markers.

The marker byte always lands on a grapheme boundary: an ASCII byte is its own
scalar and forces a cluster break (so '&', '<', '>', the quote, and whitespace
are never mid-cluster), and after a grapheme-aligned runStart the first byte at
or above 0x80 is a lead byte. So value[marker] and value[runStart ..< marker]
stay valid, and a multi-scalar cluster escapes as a unit (Swift rounds a
mid-grapheme index to its boundary, so the base letter is neither dropped nor
duplicated). The output is byte-identical.

Measured serialize ~23% faster on the 20k-item generated corpus via a same-state
A/B (~2.8x to ~2.2x slower than libxml2). Verified by EscapingBulkCopyTests
(the oracle differential extended with combining-mark, ZWJ, and flag clusters),
the Apache Xalan gold-output and C14N suites, the full suite, the WASM build,
and an adversarial grapheme-alignment review.
2026-06-25 15:39:20 +02:00

85 lines
4.0 KiB
Swift

import Testing
@testable import PureXML
/// `Escaping.text` and `Escaping.attribute` copy the verbatim runs between
/// escapable characters in bulk instead of appending each character one at a
/// time. These pin the escaped output against the per-character reference for
/// markers at the start, end, and adjacent; long verbatim runs around them;
/// multibyte runs (the copied run spans whole graphemes); both quote styles;
/// and ASCII-only mode (where a non-ASCII character escapes and an ASCII one is
/// copied with its run).
@Suite("Escaping bulk-copy equivalence")
struct EscapingBulkCopyTests {
private typealias Escaping = PureXML.Emitting.Escaping
/// The per-character escaping the bulk path replaced, kept here as the oracle.
private func referenceText(_ value: String, asciiOnly: Bool, escapeCarriageReturn: Bool) -> String {
var result = ""
for character in value {
switch character {
case "&": result += "&amp;"
case "<": result += "&lt;"
case ">": result += "&gt;"
case "\r" where escapeCarriageReturn: result += "&#xD;"
default:
if asciiOnly {
for scalar in character.unicodeScalars {
result += scalar.value > 0x7F ? "&#x\(String(scalar.value, radix: 16, uppercase: true));" : String(scalar)
}
} else {
result += String(character)
}
}
}
return result
}
private static let texts = [
"plain text no markup",
"a & b < c > d",
"&<>", "&start", "end&", "&&&",
"café & thé", "e\u{0301} < x", "\u{1F600}&\u{1F601}",
"tab\tnewline\ncr\r", "naïve résumé",
// Combining marks: in ASCII-only mode the scan's first byte >= 0x80 is the
// mark's lead byte, mid-grapheme; the whole cluster must still escape as a
// unit, not duplicate or drop the base letter.
"e\u{0301}", "a\u{0301}b", "e\u{0301}\u{0301}", "x\u{0301}y\u{0301}z", "a\u{0301}<b",
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", "\u{1F1FA}\u{1F1F8}",
String(repeating: "x", count: 300) + "&" + String(repeating: "y", count: 300),
]
@Test("text bulk-escape matches the per-character reference")
func test_textEquivalence() {
for value in Self.texts {
for asciiOnly in [false, true] {
for escapeCR in [false, true] {
#expect(
Escaping.text(value, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCR)
== referenceText(value, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCR),
"text mismatch for \(value.debugDescription) ascii=\(asciiOnly) cr=\(escapeCR)",
)
}
}
}
}
@Test("attribute bulk-escape escapes quotes, markup, and whitespace")
func test_attributeEquivalence() {
#expect(Escaping.attribute(#"a "b" c"#, quote: "\"") == "a &quot;b&quot; c")
#expect(Escaping.attribute("a 'b' c", quote: "'") == "a &apos;b&apos; c")
#expect(Escaping.attribute(#"a "b" 'c'"#, quote: "\"") == #"a &quot;b&quot; 'c'"#)
#expect(Escaping.attribute("x & y < z", quote: "\"") == "x &amp; y &lt; z")
#expect(Escaping.attribute("tab\tnl\ncr\r", quote: "\"") == "tab&#9;nl&#10;cr&#13;")
#expect(Escaping.attribute("café & \"q\"", quote: "\"") == "café &amp; &quot;q&quot;")
#expect(Escaping.attribute("no escapes here", quote: "\"") == "no escapes here")
}
@Test("a document with re-escaped content round-trips identically")
func test_documentRoundTrip() throws {
let document = #"<r a="x &amp; &quot;y&quot;">t &amp; u &lt; v</r>"#
let tree = try PureXML.parseTree(document)
let again = try PureXML.parseTree(PureXML.serialize(tree.node))
#expect(PureXML.serialize(tree.node) == PureXML.serialize(again.node))
}
}