PureXML/Sources/Parsing/ReaderByteRuns.swift
Mihaela Mihaljevic 38caec7098
perf(parsing): skip reference decoding for ampersand-free attribute values (#278)
The attribute-value scanner ran the entity decoder on every value, and the
decoder re-scanned the whole value for '&' before doing nothing when none
was present. The byte-run scanner already visits every value byte, so it
now notes any '&' and an ampersand-free value (the common case) returns its
normalized run directly. Measured against a same-machine-state baseline,
every run came in below every baseline run, ~4% faster parse on the
20k-item generated corpus.

Move the bulk byte-run scanners (contentRunBytes, attributeRunBytes) into
ReaderByteRuns.swift and centralize their cursor mutation through a single
advanceByteRun method, so Reader's position fields stay write-encapsulated
in Reader.swift while both files stay under the length cap. The byte and
Character paths are held to identical decoded values by the differential
oracle, extended with ampersand-free, entity, and character-reference
attribute cases. No spec behavior changed.
2026-06-25 00:17:31 +02:00

104 lines
5.1 KiB
Swift

/// The bulk byte-run scanners, split into their own file so `Reader`'s primary
/// type body and `Reader.swift` both stay under the length caps. They consume
/// plain ASCII runs of content or attribute-value text directly from the owned
/// storage (the common case in any real document) and hand subtle bytes back to
/// the Character path. Both engage only when the lookahead buffer is empty, read
/// the cursor through `Reader`'s `private(set)` accessors, and commit their
/// position deltas through `advanceByteRun` so all cursor mutation stays in
/// `Reader.swift`.
extension PureXML.Parsing.Reader {
/// Bulk-scans character data in byte mode: consumes and returns the longest
/// upcoming run of plain ASCII content bytes (no '<', no carriage return, no
/// "]]" pair, every byte a valid XML character), or nil when the fast path
/// does not apply. Anything subtle, an entity boundary aside ('&' is plain
/// content here, noted via `sawAmpersand`), is left for the Character path so
/// error marks stay exact. Returns nil rather than an empty run so callers
/// can alternate with the slow loop.
mutating func contentRunBytes(sawAmpersand: inout Bool) -> String? {
guard buffer.isEmpty, pendingRaw == nil, let storage else { return nil }
let pointer = storage.pointer
let count = storage.count
// A run never contains ']', so "]]>" can only straddle the boundary where
// the slow path consumed "]]" and the run would begin with '>': leave a
// leading '>' to the slow path and its cdataCloseInContent check (W3C
// ibm14n01).
if byteIndex < count, pointer[byteIndex] == 0x3E { return nil }
var index = byteIndex
var newlines = 0
var lastLineStart = -1
while index < count {
let byte = pointer[index]
if byte == 0x3C { break } // '<'
// Valid plain ASCII content only; CR, ']' and non-ASCII go to the
// character path.
guard byte < 0x80, byte != 0x0D, byte != 0x5D,
byte >= 0x20 || byte == 0x09 || byte == 0x0A
else {
if index == byteIndex { return nil }
break
}
if byte == 0x0A {
newlines += 1
lastLineStart = index
} else if byte == 0x26 {
sawAmpersand = true
}
index += 1
}
guard index > byteIndex else { return nil }
let run = String(decoding: UnsafeBufferPointer(start: pointer + byteIndex, count: index - byteIndex), as: UTF8.self)
advanceByteRun(to: index, length: index - byteIndex, newlines: newlines, lastLineStart: lastLineStart)
return run
}
/// Bulk-scans an attribute value in byte mode: consumes and returns the
/// longest upcoming run of plain ASCII value bytes, stopping at the closing
/// `quote`, a raw '<', a carriage return (2.11 folding then 3.3.3
/// normalization must run on the Character path), a control character, or a
/// non-ASCII byte. Tab and line feed inside the run normalize to a single
/// space per 3.3.3; '&' stays raw for later reference decoding and is noted
/// via `sawAmpersand`. Returns nil when the fast path does not apply or the
/// run would be empty, so callers alternate with the slow loop.
mutating func attributeRunBytes(quote: UInt8, sawAmpersand: inout Bool) -> String? {
guard buffer.isEmpty, pendingRaw == nil, let storage else { return nil }
let pointer = storage.pointer
let count = storage.count
var index = byteIndex
var newlines = 0
var lastLineStart = -1
var needsTransform = false
scan: while index < count {
switch pointer[index] {
case quote, 0x3C, 0x80...: break scan
case 0x0A: needsTransform = true
newlines += 1
lastLineStart = index
case 0x09: needsTransform = true
case 0x26: sawAmpersand = true
case ..<0x20: break scan
default: break
}
index += 1
}
guard index > byteIndex else { return nil }
let length = index - byteIndex
let run = attributeRunString(pointer, from: byteIndex, length: length, transform: needsTransform)
advanceByteRun(to: index, length: length, newlines: newlines, lastLineStart: lastLineStart)
return run
}
/// Builds the value string for an attribute byte run, applying the 3.3.3
/// tab/line-feed-to-space normalization only when the run contained one.
private func attributeRunString(_ pointer: UnsafePointer<UInt8>, from start: Int, length: Int, transform: Bool) -> String {
guard transform else {
return String(decoding: UnsafeBufferPointer(start: pointer + start, count: length), as: UTF8.self)
}
var bytes = [UInt8](repeating: 0, count: length)
for position in 0 ..< length {
let byte = pointer[start + position]
bytes[position] = (byte == 0x09 || byte == 0x0A) ? 0x20 : byte
}
return String(decoding: bytes, as: UTF8.self)
}
}