PureXML/Sources/Parsing/EventReader.swift
Mihaela Mihaljevic 7a5761b64d
perf(parse): split the name prefix from a scanned colon offset (#292)
QualifiedName(_:) ran firstIndex(of: ":") over the whole name, grapheme by
grapheme, on every element and attribute. Most names have no prefix, so the
common case scanned the entire name to find no colon at all.

The byte scanner already visits every byte of an all-ASCII name, so have
takeASCIIName record the offset of the first colon (one comparison per byte),
and split at that known offset in QualifiedName(ascii:colonOffset:). The
unprefixed name (no colon) is then built with no scan; a prefixed name splits
at the recorded offset. The names are identical to the firstIndex path for
every colon placement: none, leading (:local), trailing (prefix:), middle,
doubled (::), and multiple (first colon wins). The hint is confined to the
all-ASCII fast path; non-ASCII names and every other construction site keep
QualifiedName(_:).

Measured parse ~12% faster on the 20k-item generated corpus via a same-state
A/B (8.5x to ~7.7x slower than libxml2). Verified by QualifiedNameColonHintTests
(colon-placement differential), the W3C XML conformance corpora, the Xalan XSLT
gold-output corpus, the full suite, the WASM build, and an adversarial
equivalence review.
2026-06-25 12:23:53 +02:00

317 lines
15 KiB
Swift

public extension PureXML.Parsing {
/// A pull-based, streaming XML event reader.
///
/// It consumes its input incrementally through a character-source closure and
/// emits one ``Event`` at a time from ``next()``, holding only bounded state:
/// a stack of open element names and a small lookahead buffer. It never
/// requires the whole document in memory, so it can drive arbitrarily large
/// or chunked input (a file, a socket, a generator). The tree-building
/// ``Parser`` is a thin adapter over this core.
///
/// Safe by default: a `<!DOCTYPE ...>` declaration is rejected rather than
/// processed, removing the DTD-based threat classes (XXE, entity-expansion
/// DoS) at the door. Bounded ``Limits`` cap depth, name, and content size.
struct EventReader {
var reader: Reader
private let limits: Limits
private let resolver: EntityResolver
var open: [PureXML.Model.QualifiedName] = []
var namespaces = NamespaceContext()
/// The DTD information extracted so far (entities, element models,
/// attribute lists, parameter entities, and external identifiers).
var documentType = DocumentType()
/// The parsed XML declaration `<?xml ... ?>`, when the document opens with
/// one; nil otherwise.
var xmlDeclaration: XMLDeclaration?
var entityBudget: Int
var pending: [Event] = []
private var sawRoot = false
private var primed = false
/// When true, ``next()`` repairs malformed input in place instead of
/// throwing: it records a ``Diagnostic`` and emits best-effort events.
let recovering: Bool
var reachedEnd = false
/// The problems found so far, when reading in recovering mode.
var diagnostics: [Diagnostic] = []
/// The source position where the most recently produced event's node began,
/// so a pull cursor can report each node's line and column.
private(set) var eventStart: Mark = .start
/// Creates a reader over a character-pulling source. The closure returns
/// the next character or nil at end of input.
public init(
pulling pull: @escaping () -> Character?,
limits: Limits = .default,
resolver: EntityResolver = .refusing,
recovering: Bool = false,
) {
reader = Reader(pulling: pull)
self.limits = limits
self.resolver = resolver
self.recovering = recovering
entityBudget = limits.maxEntityExpansion
}
/// Creates a reader over a string (a convenience over the streaming init).
public init(_ string: String, limits: Limits = .default, resolver: EntityResolver = .refusing, recovering: Bool = false) {
reader = Reader(string)
self.limits = limits
self.resolver = resolver
self.recovering = recovering
entityBudget = limits.maxEntityExpansion
}
/// Returns the next event, or nil at the end of the document. In recovering
/// mode it never throws: each malformed construct is recorded as a
/// diagnostic and reading continues, popping to a matching open element on
/// a mismatched end tag and stopping (so the caller closes what is open) on
/// truncation.
public mutating func next() throws -> Event? {
if !primed {
primed = true
if reader.peek() == "\u{FEFF}" {
reader.advance()
}
}
while true {
if !pending.isEmpty {
return pending.removeFirst()
}
guard recovering else {
return open.isEmpty ? try nextAtTopLevel() : try nextInContent()
}
if reachedEnd { return nil }
let start = offset
do {
return open.isEmpty ? try nextAtTopLevel() : try nextInContent()
} catch let error as ParseError {
if let event = recoveredEvent(from: error, startingAt: start) { return event }
}
}
}
private mutating func nextAtTopLevel() throws -> Event? {
while true {
reader.skipSpace()
guard let lead = reader.peek() else {
return nil
}
eventStart = reader.mark
if reader.matches("<!--") {
return try scanComment()
}
if reader.matches("<?") {
let mark = reader.mark
let instruction = try scanProcessingInstruction()
if instruction.target.lowercased() == "xml" {
// The reserved target is only legal as the XML declaration,
// which must be the very first bytes of the document.
guard mark == .start, instruction.target == "xml" else {
throw ParseError.reservedProcessingInstructionTarget(mark)
}
try recordDeclaration(instruction.data, at: mark)
continue
}
return .processingInstruction(target: instruction.target, data: instruction.data)
}
if reader.matches("<!DOCTYPE") {
documentType = try DoctypeScanner.scan(
&reader,
limits: limits,
resolver: resolver,
standalone: xmlDeclaration?.standalone == true,
documentVersion: xmlDeclaration?.version,
)
continue
}
if reader.matches("</") || reader.matches("<![CDATA[") {
throw ParseError.junkAfterDocumentElement(reader.mark)
}
if lead == "<" {
guard !sawRoot else { throw ParseError.junkAfterDocumentElement(reader.mark) }
return try scanStartTag()
}
// Junk after the root is "content after the root element"; the same
// shape before any root is simply an unexpected character.
throw sawRoot
? ParseError.junkAfterDocumentElement(reader.mark)
: ParseError.unexpectedCharacter(lead, reader.mark)
}
}
private mutating func nextInContent() throws -> Event? {
// Loops because a text scan can end by splicing an entity's
// replacement into the reader with no text before it: the next
// construct is then read from the spliced replacement.
while true {
// Fast byte dispatch: when the next input is a raw byte (an
// empty lookahead buffer over owned storage), route on it
// without buffering a Character, so every matches()/consume()/
// scan call below stays on its byte fast path. The Character
// path is the identical fallback for a streaming source,
// spliced entity text, or a non-ASCII lead.
if let byte = reader.peekByte() {
eventStart = reader.mark
if byte == 0x3C { return try scanContentMarkup() } // '<'
if let text = try scanText() { return text }
continue
}
guard let lead = reader.peek() else {
throw ParseError.unexpectedEndOfInput(reader.mark)
}
eventStart = reader.mark
if lead == "<" { return try scanContentMarkup() }
if let text = try scanText() { return text }
}
}
mutating func scanStartTag() throws -> Event {
guard open.count < limits.maxDepth else {
throw ParseError.nestingTooDeep(limit: limits.maxDepth, reader.mark)
}
let mark = reader.mark
reader.consume("<")
let rawName = try scanName()
let rawAttributes = try normalizedBindings(element: rawName, attributes: scanAttributes())
let resolved = try namespaces.enterElement(name: rawName, attributes: rawAttributes, at: mark)
if reader.consume("/>") {
namespaces.leaveElement()
pending.append(.endElement(name: resolved.name))
sawRoot = true
return .startElement(name: resolved.name, attributes: resolved.attributes)
}
guard reader.consume(">") else {
// Balance the namespace scope entered above so a recovering reader
// can drop this malformed start tag without leaking a scope.
namespaces.leaveElement()
throw ParseError.unterminatedTag(reader.mark)
}
open.append(resolved.name)
sawRoot = true
return .startElement(name: resolved.name, attributes: resolved.attributes)
}
/// Returns the next text event, or nil after splicing: a reference to
/// an entity whose replacement (directly or transitively) contains
/// markup is validated, budgeted, and pushed back into the reader so
/// it is reparsed as content (4.4.2 Included), elements in replacement
/// text become elements in the stream, not character data.
private mutating func scanText() throws -> Event? {
let mark = reader.mark
var raw = ""
var length = 0
// Whether any '&' was seen: without one the text has no references
// and no markup-entity boundary, so the whole decode/split/findings
// pass is skipped. The scanners visit every byte already, so this
// costs nothing beyond a compare they were doing anyway.
var sawAmpersand = false
while true {
// Bulk byte runs first: plain ASCII content (the bulk of any
// document) skips the per-character machinery entirely; the
// run never spans '<', CR, ']', or invalid bytes, so the
// character path below keeps exact error marks.
if let run = reader.contentRunBytes(sawAmpersand: &sawAmpersand) {
length += run.utf8.count
try checkContent(length, mark)
raw += run
continue
}
// Detect the closing '<' on the byte path so the buffer stays
// empty: a buffered '<' would force the next element's dispatch,
// matches(), and name scan onto the slow Character path.
if reader.peekByte() == 0x3C { break }
guard let character = reader.peek(), character != "<" else { break }
length += 1
try checkContent(length, mark)
if character == ">", raw.hasSuffix("]]") {
throw ParseError.cdataCloseInContent(reader.mark)
}
guard character.unicodeScalars.allSatisfy(XMLCharacter.isChar) else {
throw ParseError.invalidCharacter(reader.mark)
}
if character == "&" { sawAmpersand = true }
raw.append(character)
reader.advance()
}
guard sawAmpersand else { return .characters(raw) }
let split = EntityDecoder.splitAtMarkupEntity(raw, entities: referencableEntities)
if let split {
let replacement = try EntityDecoder.includeForContent(
split.name,
entities: referencableEntities,
budget: &entityBudget,
at: mark,
)
reader.inject(replacement + split.remainder)
let prefix = try decodeReferences(split.prefix, at: mark)
return prefix.isEmpty ? nil : .characters(prefix)
}
let decoded = try decodeReferences(raw, at: mark)
recordReferenceContentFindings(raw: raw, decoded: decoded)
return .characters(decoded)
}
mutating func scanProcessingInstruction() throws -> (target: String, data: String) {
let mark = reader.mark
reader.consume("<?")
let target = try scanName()
// A PI target is an NCName under namespaces (no colon).
if target.description.contains(":") {
throw ParseError.namespaceConstraint(reason: "PI target '\(target.description)' is not a legal NCName", mark)
}
// Production 16: the target and the data must be separated by
// whitespace, so `<?target+++?>` is not well-formed.
if let next = reader.peek(), !next.isWhitespace, !reader.matches("?>") {
throw ParseError.unexpectedCharacter(next, reader.mark)
}
reader.skipSpace()
var data = ""
var length = 0
while !reader.matches("?>") {
guard let character = reader.advance() else {
throw ParseError.unterminatedTag(mark)
}
length += 1
try checkContent(length, mark)
guard character.unicodeScalars.allSatisfy(XMLCharacter.isChar) else {
throw ParseError.invalidCharacter(reader.mark)
}
data.append(character)
}
reader.consume("?>")
return (target.description, data)
}
mutating func scanName() throws -> PureXML.Model.QualifiedName {
let mark = reader.mark
// Fast path: an all-ASCII name scanned directly from the bytes, with
// no per-character buffering. The Character loop below handles a
// non-ASCII name, the non-fast-path, and the too-long error.
if let fast = reader.takeASCIIName(maxLength: limits.maxNameLength) {
return PureXML.Model.QualifiedName(ascii: fast.name, colonOffset: fast.colon)
}
guard let first = reader.peek(), first.isXMLNameStart else {
throw ParseError.expectedName(reader.mark)
}
var raw = ""
var length = 0
while let character = reader.peek(), character.isXMLNameContinuation {
length += 1
guard length <= limits.maxNameLength else {
throw ParseError.nameTooLong(limit: limits.maxNameLength, mark)
}
raw.append(character)
reader.advance()
}
return PureXML.Model.QualifiedName(raw)
}
func checkContent(_ length: Int, _ mark: Mark) throws {
guard length <= limits.maxContentLength else {
throw ParseError.contentTooLong(limit: limits.maxContentLength, mark)
}
}
}
}