Two profile-guided serialize passes (#139, docs/benchmarks.md passes 29-30). The attribute writer now precomputes the quote character as a String once per element instead of interpolating a Character into every attribute, avoiding the grapheme-breadcrumb machinery that repeats twice per attribute for a value that never changes within an element. Escaping.comment and processingInstruction's escaping fallback (taken whenever a comment has a hyphen or PI data has a '?', a case pass 8 could skip past but not speed up) is rewritten in the same byte-scan bulk-copy shape as passes 23-24, so a value with one rare trigger no longer pays a full character-by-character rebuild. Both are same-observable-behavior changes. The quote change is byte- identical by construction. The comment/PI rewrite is verified against a new differential suite that reimplements the original character-loop as an independent oracle and checks adjacency/trailing/combining-mark cases plus two 20000-case seeded fuzzes, since a byte match inside a combining-mark cluster is not, on its own, the bare trigger character. Full local suite (1925 tests) unchanged. Measured with interleaved same-state A/B: pass 29 ~1.5% at a 100k-item corpus; pass 30 ~2x on a dedicated comment/PI micro-benchmark (the standard corpus has no comment/PI content, so real documents with copyright banners or hyphenated comment prose see this, not the headline ratio). A same- session multi-append restructuring attempt measured a clear ~6% regression and was reverted; a suspected hasInlineContent double-scan was profiled at ~0.2% of samples and not worth a pass. Both outcomes are recorded in docs/benchmarks.md rather than dropped.
234 lines
11 KiB
Swift
234 lines
11 KiB
Swift
extension PureXML.Emitting {
|
|
/// The shared XML output escaping rules, used by both the tree serializer and
|
|
/// the incremental writer so their output is identical. Element content
|
|
/// escapes the markup characters; attribute values additionally escape the
|
|
/// quote and the whitespace characters as numeric references (matching
|
|
/// libxml2), so attributes survive attribute-value normalization.
|
|
enum Escaping {
|
|
static func text(_ value: String, asciiOnly: Bool = false, escapeCarriageReturn: Bool = false) -> String {
|
|
// Fast path: most content has no character that needs escaping, so it
|
|
// is returned unchanged rather than rebuilt character by character.
|
|
if !textNeedsEscaping(value, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCarriageReturn) {
|
|
return value
|
|
}
|
|
var result = ""
|
|
var runStart = value.startIndex
|
|
// Find each character that needs escaping by scanning bytes (every
|
|
// trigger is an ASCII marker, or in ASCII-only mode a non-ASCII lead
|
|
// byte, both of which fall on a grapheme boundary), copy the verbatim
|
|
// run before it in one append, and rewrite only that character.
|
|
while let marker = value.utf8[runStart...].firstIndex(where: {
|
|
textEscapableByte($0, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCarriageReturn)
|
|
}) {
|
|
if runStart < marker {
|
|
result += value[runStart ..< marker]
|
|
}
|
|
let character = value[marker]
|
|
result += textEscape(character, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCarriageReturn) ?? String(character)
|
|
runStart = value.index(after: marker)
|
|
}
|
|
if runStart < value.endIndex {
|
|
result += value[runStart...]
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Whether a byte can begin a character ``text`` must escape: an ASCII
|
|
/// markup byte, a carriage return when asked, or any non-ASCII lead byte
|
|
/// in ASCII-only mode (its character is reference-escaped whole).
|
|
private static func textEscapableByte(_ byte: UInt8, asciiOnly: Bool, escapeCarriageReturn: Bool) -> Bool {
|
|
switch byte {
|
|
case 0x26, 0x3C, 0x3E: true
|
|
case 0x0D where escapeCarriageReturn: true
|
|
case 0x80... where asciiOnly: true
|
|
default: false
|
|
}
|
|
}
|
|
|
|
/// The escaped form of one content character, or nil when it is verbatim.
|
|
private static func textEscape(_ character: Character, asciiOnly: Bool, escapeCarriageReturn: Bool) -> String? {
|
|
switch character {
|
|
case "&": "&"
|
|
case "<": "<"
|
|
case ">": ">"
|
|
case "\r" where escapeCarriageReturn: "
"
|
|
default: asciiOnly ? plainIfNonASCII(character) : nil
|
|
}
|
|
}
|
|
|
|
/// Whether ``text`` would change `value`: it escapes `&`, `<`, `>` always,
|
|
/// a carriage return when asked, and (in ASCII-only mode) any non-ASCII
|
|
/// byte. Scanned at the byte level, since every trigger is ASCII or, for
|
|
/// ASCII-only mode, a non-ASCII lead byte.
|
|
private static func textNeedsEscaping(_ value: String, asciiOnly: Bool, escapeCarriageReturn: Bool) -> Bool {
|
|
for byte in value.utf8 {
|
|
switch byte {
|
|
case 0x26, 0x3C, 0x3E: return true
|
|
case 0x0D where escapeCarriageReturn: return true
|
|
case 0x80... where asciiOnly: return true
|
|
default: continue
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// In ASCII-only mode, the numeric-reference escaping of a character that
|
|
/// carries a non-ASCII scalar, or nil when the character is pure ASCII and
|
|
/// so is copied verbatim with its run.
|
|
private static func plainIfNonASCII(_ character: Character) -> String? {
|
|
guard character.unicodeScalars.contains(where: { $0.value > 0x7F }) else { return nil }
|
|
return plain(character, asciiOnly: true)
|
|
}
|
|
|
|
/// A character verbatim, or, in ASCII-only mode, each of its non-ASCII
|
|
/// scalars as a numeric character reference so the output is pure ASCII.
|
|
private static func plain(_ character: Character, asciiOnly: Bool) -> String {
|
|
guard asciiOnly else { return String(character) }
|
|
var result = ""
|
|
for scalar in character.unicodeScalars {
|
|
result += scalar.value > 0x7F ? "&#x\(String(scalar.value, radix: 16, uppercase: true));" : String(scalar)
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func attribute(_ value: String, quote: Character = "\"", asciiOnly: Bool = false) -> String {
|
|
// Fast path: a value with no character to escape is returned unchanged.
|
|
let quoteByte: UInt8 = quote == "'" ? 0x27 : 0x22
|
|
if !attributeNeedsEscaping(value, quoteByte: quoteByte, asciiOnly: asciiOnly) {
|
|
return value
|
|
}
|
|
var result = ""
|
|
var runStart = value.startIndex
|
|
while let marker = value.utf8[runStart...].firstIndex(where: {
|
|
attributeEscapableByte($0, quoteByte: quoteByte, asciiOnly: asciiOnly)
|
|
}) {
|
|
if runStart < marker {
|
|
result += value[runStart ..< marker]
|
|
}
|
|
let character = value[marker]
|
|
result += attributeEscape(character, quote: quote, asciiOnly: asciiOnly) ?? String(character)
|
|
runStart = value.index(after: marker)
|
|
}
|
|
if runStart < value.endIndex {
|
|
result += value[runStart...]
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Whether a byte can begin a character ``attribute`` must escape: the
|
|
/// quote, an ASCII markup or whitespace byte, or a non-ASCII lead byte in
|
|
/// ASCII-only mode.
|
|
private static func attributeEscapableByte(_ byte: UInt8, quoteByte: UInt8, asciiOnly: Bool) -> Bool {
|
|
switch byte {
|
|
case quoteByte, 0x26, 0x3C, 0x3E, 0x09, 0x0A, 0x0D: true
|
|
case 0x80... where asciiOnly: true
|
|
default: false
|
|
}
|
|
}
|
|
|
|
/// The escaped form of one attribute-value character, or nil when it is
|
|
/// copied verbatim. Split out so ``attribute`` stays a simple run loop.
|
|
private static func attributeEscape(_ character: Character, quote: Character, asciiOnly: Bool) -> String? {
|
|
if character == quote {
|
|
return quote == "'" ? "'" : """
|
|
}
|
|
switch character {
|
|
case "&": return "&"
|
|
case "<": return "<"
|
|
case ">": return ">"
|
|
case "\t": return "	"
|
|
case "\n": return " "
|
|
case "\r": return " "
|
|
default: return asciiOnly ? plainIfNonASCII(character) : nil
|
|
}
|
|
}
|
|
|
|
/// Whether ``attribute`` would change `value`: it escapes the quote, the
|
|
/// markup characters, the whitespace characters, and (in ASCII-only mode)
|
|
/// any non-ASCII byte. Every trigger is ASCII bar the non-ASCII lead.
|
|
private static func attributeNeedsEscaping(_ value: String, quoteByte: UInt8, asciiOnly: Bool) -> Bool {
|
|
for byte in value.utf8 {
|
|
switch byte {
|
|
case quoteByte, 0x26, 0x3C, 0x3E, 0x09, 0x0A, 0x0D: return true
|
|
case 0x80... where asciiOnly: return true
|
|
default: continue
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Makes `value` safe inside a comment: a comment may not contain `--`
|
|
/// or end with `-`, so a space is inserted after each offending hyphen
|
|
/// (the XSLT 1.0 16 recovery, so `after-` becomes `after- `).
|
|
static func comment(_ value: String) -> String {
|
|
// Fast path: a comment with no hyphen has no `--` and no trailing `-`,
|
|
// so it is returned unchanged rather than rebuilt character by character.
|
|
if !value.utf8.contains(0x2D) {
|
|
return value
|
|
}
|
|
var result = ""
|
|
var previousHyphen = false
|
|
var runStart = value.startIndex
|
|
// Find each candidate hyphen byte, copy the verbatim run before it in
|
|
// one append, and resolve it to its full grapheme (a byte match inside
|
|
// a combining-mark cluster is not a bare "-" and is copied like any
|
|
// other character); only a genuine "-" can extend or start a run.
|
|
while let marker = value.utf8[runStart...].firstIndex(where: { $0 == 0x2D }) {
|
|
if runStart < marker {
|
|
result += value[runStart ..< marker]
|
|
previousHyphen = false
|
|
}
|
|
let character = value[marker]
|
|
if character == "-", previousHyphen {
|
|
result += " "
|
|
}
|
|
result.append(character)
|
|
previousHyphen = character == "-"
|
|
runStart = value.index(after: marker)
|
|
}
|
|
if runStart < value.endIndex {
|
|
result += value[runStart...]
|
|
previousHyphen = false
|
|
}
|
|
if previousHyphen {
|
|
result += " "
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Makes `data` safe inside a processing instruction: PI data may not
|
|
/// contain `?>`, so a space is inserted between an offending `?` and `>`
|
|
/// (the XSLT 1.0 7.3 recovery, so `a?>b` becomes `a? >b`).
|
|
static func processingInstruction(_ data: String) -> String {
|
|
// Fast path: data with no '?' cannot contain `?>`, so it is returned
|
|
// unchanged rather than rebuilt character by character.
|
|
if !data.utf8.contains(0x3F) {
|
|
return data
|
|
}
|
|
var result = ""
|
|
var previousQuestion = false
|
|
var runStart = data.startIndex
|
|
// Candidates are '?' (may start the offending pair) and '>' (may close
|
|
// it); every other character, including one that merely shares a byte
|
|
// with a combining-mark cluster, is bulk-copied and resets the flag.
|
|
while let marker = data.utf8[runStart...].firstIndex(where: { $0 == 0x3F || $0 == 0x3E }) {
|
|
if runStart < marker {
|
|
result += data[runStart ..< marker]
|
|
previousQuestion = false
|
|
}
|
|
let character = data[marker]
|
|
if character == ">", previousQuestion {
|
|
result += " "
|
|
}
|
|
result.append(character)
|
|
previousQuestion = character == "?"
|
|
runStart = data.index(after: marker)
|
|
}
|
|
if runStart < data.endIndex {
|
|
result += data[runStart...]
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
}
|