PureXML/Sources/Parsing/Reader.swift
Mihaela Mihaljevic 72eaeeb439 perf(parsing): byte-backed Reader and bulk content runs
String input copies its UTF-8 once into owned storage (an unsafe
pointer encapsulated in a class lifetime, never escaping the parser)
and scalars decode straight off the pointer, eliminating the
per-character closure chain; ASCII literals match and consume at the
byte level; and plain-ASCII character data scans as a single byte run
with one String built per run. Parse drops 32 percent cumulative
(0.49 s to 0.34 s on the 4.2 MB corpus, libxml2 ratio 30x to 20x).
The W3C suite caught one boundary (a run starting with '>' could
swallow the ]]> check after the character path consumed ]]); the run
scanner leaves a leading '>' to the character path, pinned as a unit
regression. All conformance suites, Linux, and WASM verified.
2026-06-10 18:48:57 +02:00

305 lines
13 KiB
Swift

extension PureXML.Parsing {
/// A streaming character cursor. It pulls characters from a source closure
/// one at a time, keeping only a small lookahead buffer, so it never holds
/// the whole input in memory. Tracks line, column, and offset for
/// diagnostics. Internal to the parser; not part of the API.
///
/// Lexing is scalar-level: every buffered `Character` holds exactly one
/// Unicode scalar (multi-scalar graphemes from the source are split), so a
/// combining mark directly after an ASCII delimiter cannot merge with it
/// and XML's scalar-defined productions classify correctly. Grapheme
/// clusters reassemble naturally wherever scanned text is appended to a
/// `String`.
struct Reader {
private var pull: () -> Character?
private var buffer: [Character] = []
private var exhausted = false
private(set) var offset = 0
private(set) var line = 1
private(set) var column = 1
/// When true, also fold the XML 1.1 line terminators NEL (U+0085) and LINE
/// SEPARATOR (U+2028) to a line feed. Set once the declaration is known to
/// name version 1.1.
var xml11 = false
/// A raw character read past a carriage return that was not part of the
/// line ending, held back to be returned next.
private var pendingRaw: Character?
/// The contiguous fast path: when the input is a String, its UTF-8
/// bytes are copied once into owned storage and scalars decode
/// straight off the pointer, bypassing the per-character closure
/// chain entirely.
private let storage: ByteStorage?
private var byteIndex = 0
init(pulling pull: @escaping () -> Character?) {
storage = nil
// Split multi-scalar graphemes so the buffer is scalar-level.
var queued: [Character] = []
self.pull = {
if !queued.isEmpty { return queued.removeFirst() }
guard let next = pull() else { return nil }
let scalars = next.unicodeScalars
guard scalars.count > 1 else { return next }
var parts = scalars.map(Character.init)
let first = parts.removeFirst()
queued = parts
return first
}
}
init(_ string: String) {
// One O(n) copy into owned storage; every scalar after that
// decodes from the pointer. Each yielded Character is one
// scalar, the same contract as the streaming path.
storage = ByteStorage(Array(string.utf8))
pull = { nil }
}
/// Decodes the next scalar from the owned bytes. The input came from
/// a Swift String, so the UTF-8 is valid by construction; the length
/// guard is defensive only.
private mutating func nextFromBytes(_ storage: ByteStorage) -> Character? {
guard byteIndex < storage.count else { return nil }
let pointer = storage.pointer
let first = pointer[byteIndex]
if first < 0x80 {
byteIndex += 1
return Character(Unicode.Scalar(first))
}
let length = first >= 0xF0 ? 4 : (first >= 0xE0 ? 3 : 2)
guard byteIndex + length <= storage.count else {
byteIndex = storage.count
return nil
}
var value = UInt32(first & (0xFF >> UInt8(length + 1)))
for index in 1 ..< length {
value = (value << 6) | UInt32(pointer[byteIndex + index] & 0x3F)
}
byteIndex += length
guard let scalar = Unicode.Scalar(value) else {
return nil
}
return Character(scalar)
}
/// Ensures the lookahead buffer holds at least `count` characters (or the
/// source is exhausted). The buffer stays bounded by the largest lookahead
/// any single scan needs, never by the document size.
private mutating func ensure(_ count: Int) {
while buffer.count < count, !exhausted {
if let character = normalizedPull() {
buffer.append(character)
} else {
exhausted = true
}
}
}
/// Pulls the next character with XML line-ending normalization: a carriage
/// return, alone or followed by a line feed (or by NEL in 1.1), becomes a
/// single line feed, as do a lone NEL and LINE SEPARATOR in 1.1. So every
/// scan downstream sees line feeds only, as the spec requires.
private mutating func normalizedPull() -> Character? {
guard let character = rawNext() else { return nil }
// Swift coalesces a CR+LF pair into one grapheme-cluster character.
if character == "\r\n" { return "\n" }
if character == "\r" {
let next = rawNext()
if next != "\n", !(xml11 && next == "\u{85}") { pendingRaw = next }
return "\n"
}
if xml11, character == "\u{85}" || character == "\u{2028}" {
return "\n"
}
return character
}
private mutating func rawNext() -> Character? {
if let pending = pendingRaw {
pendingRaw = nil
return pending
}
if let storage {
return nextFromBytes(storage)
}
return pull()
}
mutating func peek(_ ahead: Int = 0) -> Character? {
ensure(ahead + 1)
return ahead < buffer.count ? buffer[ahead] : nil
}
var mark: Mark {
Mark(line: line, column: column, offset: offset)
}
@discardableResult
mutating func advance() -> Character? {
ensure(1)
guard !buffer.isEmpty else { return nil }
let character = buffer.removeFirst()
offset += 1
if character == "\n" {
line += 1
column = 1
} else {
column += 1
}
return character
}
/// True if the upcoming characters equal `literal`, without consuming.
mutating func matches(_ literal: String) -> Bool {
if buffer.isEmpty, pendingRaw == nil, let storage {
if let byteResult = matchesBytes(literal, storage) { return byteResult }
}
// No per-call Array materialization: literals are short constants
// and this runs on every scan decision (the hottest comparison in
// the parse profile).
let count = literal.count
ensure(count)
guard buffer.count >= count else { return false }
var index = 0
for character in literal {
if buffer[index] != character { return false }
index += 1
}
return true
}
/// Byte-level compare for plain ASCII literals without CR or LF
/// (line-ending normalization cannot touch those, so raw bytes and
/// the normalized stream agree). Returns nil when the literal needs
/// the Character path.
private func matchesBytes(_ literal: String, _ storage: ByteStorage) -> Bool? {
var index = byteIndex
for byte in literal.utf8 {
guard byte < 0x80, byte != 0x0D, byte != 0x0A else { return nil }
guard index < storage.count, storage.pointer[index] == byte else { return false }
index += 1
}
return true
}
/// If the upcoming characters equal `literal`, consume them and return true.
@discardableResult
mutating func consume(_ literal: String) -> Bool {
if buffer.isEmpty, pendingRaw == nil, let storage, let byteResult = matchesBytes(literal, storage) {
guard byteResult else { return false }
// ASCII literal without newlines: one byte per scalar, no
// line changes, so position tracking is plain arithmetic.
let count = literal.utf8.count
byteIndex += count
offset += count
column += count
return true
}
guard matches(literal) else { return false }
for _ in literal {
advance()
}
return true
}
mutating func skipSpace() {
while let character = peek(), character.isXMLWhitespace {
advance()
}
}
/// 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), 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() -> 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
}
index += 1
}
guard index > byteIndex else { return nil }
let run = String(decoding: UnsafeBufferPointer(start: pointer + byteIndex, count: index - byteIndex), as: UTF8.self)
offset += index - byteIndex
if newlines > 0 {
line += newlines
column = index - lastLineStart
} else {
column += index - byteIndex
}
byteIndex = index
return run
}
/// Prepends `text` to the unread stream: used to splice a general
/// entity's replacement text into content so it is reparsed as markup
/// (4.4.2 Included). Position tracking keeps reporting the source
/// document's marks, the libxml2 model for entity boundaries.
mutating func inject(_ text: String) {
buffer.insert(contentsOf: text.unicodeScalars.map(Character.init), at: 0)
}
}
}
extension StringProtocol {
/// Trims leading and trailing XML whitespace without Foundation.
func trimmingXMLWhitespace() -> String {
var slice = self[...]
while let first = slice.first, first.isXMLWhitespace {
slice = slice.dropFirst()
}
while let last = slice.last, last.isXMLWhitespace {
slice = slice.dropLast()
}
return String(slice)
}
}
extension Character {
private typealias XML = PureXML.Parsing.XMLCharacter
/// XML S production: space, tab, carriage return, line feed.
var isXMLWhitespace: Bool {
unicodeScalars.count == 1 && unicodeScalars.first.map(XML.isWhitespace) == true
}
/// Whether this character may start an XML name. A grapheme qualifies when its
/// first scalar is a NameStartChar and any trailing scalars (combining marks)
/// are NameChar.
var isXMLNameStart: Bool {
guard let first = unicodeScalars.first, XML.isNameStart(first) else { return false }
return unicodeScalars.dropFirst().allSatisfy(XML.isNameChar)
}
/// Whether this character may continue an XML name (every scalar a NameChar).
var isXMLNameContinuation: Bool {
!unicodeScalars.isEmpty && unicodeScalars.allSatisfy(XML.isNameChar)
}
}