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.
85 lines
4.0 KiB
Swift
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 += "&"
|
|
case "<": result += "<"
|
|
case ">": result += ">"
|
|
case "\r" where escapeCarriageReturn: result += "
"
|
|
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 "b" c")
|
|
#expect(Escaping.attribute("a 'b' c", quote: "'") == "a 'b' c")
|
|
#expect(Escaping.attribute(#"a "b" 'c'"#, quote: "\"") == #"a "b" 'c'"#)
|
|
#expect(Escaping.attribute("x & y < z", quote: "\"") == "x & y < z")
|
|
#expect(Escaping.attribute("tab\tnl\ncr\r", quote: "\"") == "tab	nl cr ")
|
|
#expect(Escaping.attribute("café & \"q\"", quote: "\"") == "café & "q"")
|
|
#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 & "y"">t & u < 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))
|
|
}
|
|
}
|