PureXML/Sources/Parsing/EventReaderAttributes.swift
Mihaela Mihaljevic fe0400c3df perf(parsing): byte-path quote handling, lazy expanded-name check, byte-scan entity guard
Third profile-guided pass (#139, docs/benchmarks.md pass 27), targeting
scanContentMarkup(), which inlines the start-tag/attribute/namespace scanners
and carries ~46% of parse time.

scanAttributeValue's opening and closing quote checks ran on the Character
path (peek + advance, buffering the quote as a Character only to compare and
discard it) on every attribute value in the document; both now check the raw
byte first, reaching the byte fast path with no Character buffering, with the
Character path kept as the off-fast-path fallback for streaming sources. The
quote bytes are ASCII and non-CR/LF, so the raw byte and the normalized
stream agree, the same reasoning peekByte's other callers already rely on.

NamespaceContext.checkExpandedNames allocated a Set's native storage and
built a concatenated URI+local key string per element even when at most one
attribute carries a namespace URI, where no duplicate is possible (the
default namespace never applies to attributes, and xmlns declarations carry
no URI). Counting URI-bearing attributes first, with no allocation, and
returning early below two skips all of it on the overwhelming majority of
elements; for xml:* attributes specifically the key exceeded the small-string
limit, so this also removes a real per-element heap allocation.

EntityDecoder.decode/decodeLenient guarded with raw.contains("&"), a
grapheme-by-grapheme walk to find one ASCII byte, once per ampersand-bearing
text node or attribute value; raw.utf8.contains(0x26) finds the same byte
without decoding graphemes, the same shape as checkNameShape's colon check.

The quote-handling extraction into two helpers also resolves the cyclomatic
complexity the inline version tripped. Six new regression tests pin the
quote edge cases (empty value, single-quoted, a quote kind nested inside the
other, unterminated at end of input) plus byte/Character-path agreement for
each in the parser path differential.

Verified: full local suite (1858 tests) and full W3C XSTS suite (14383
groups) pass unchanged. Parse ~6.00x -> ~4.75x versus libxml2 at the
20000-item corpus, best of 5.
2026-07-12 12:27:00 +02:00

142 lines
7.5 KiB
Swift

/// The start-tag attribute scanners, in their own file to keep the reader's
/// type body under the length cap. Behavior is part of EventReader proper.
extension PureXML.Parsing.EventReader {
mutating func scanAttributes() throws -> [PureXML.Model.Attribute] {
var attributes: [PureXML.Model.Attribute] = []
var seen: Set<String> = []
while true {
reader.skipSpace()
// Byte dispatch keeps the buffer empty so scanName() reaches its
// byte fast path; the Character peek is the off-fast-path fallback.
if let byte = reader.peekByte() {
if byte == 0x3E || byte == 0x2F { return attributes } // '>' or '/'
} else {
guard let next = reader.peek(), next != ">", next != "/" else {
return attributes
}
}
let mark = reader.mark
let name = try scanName()
reader.skipSpace()
guard reader.consume("=") else {
throw PureXML.Parsing.ParseError.expectedEquals(reader.mark)
}
reader.skipSpace()
let value = try scanAttributeValue()
guard seen.insert(name.description).inserted else {
if recovering {
// Keep the element: record the duplicate and drop the later
// occurrence rather than failing the whole start tag.
diagnostics.append(PureXML.Parsing.Diagnostic(PureXML.Parsing.ParseError.duplicateAttribute(name: name.description, mark)))
continue
}
throw PureXML.Parsing.ParseError.duplicateAttribute(name: name.description, mark)
}
attributes.append(PureXML.Model.Attribute(name: name, value: value))
// A following attribute needs whitespace before it. Check the raw
// byte when on the fast path to avoid buffering the next name char.
if let byte = reader.peekByte() {
// '>', '/', '?', or whitespace may legally follow a value; any
// other byte means a missing separator before the next attribute.
let isSeparator = byte == 0x3E || byte == 0x2F || byte == 0x3F
|| byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D
if !isSeparator {
throw PureXML.Parsing.ParseError.missingSpaceBeforeAttribute(reader.mark)
}
} else if let next = reader.peek(), next != ">", next != "/", next != "?", !next.isWhitespace {
throw PureXML.Parsing.ParseError.missingSpaceBeforeAttribute(reader.mark)
}
}
}
mutating func scanAttributeValue() throws -> String {
let mark = reader.mark
let (quoteByte, quoteCharacter) = try consumeAttributeValueOpenQuote()
var raw = ""
var length = 0
// Whether any '&' was seen: without one the value has no references, so
// the decode pass (and its full re-scan for '&') is skipped. The
// scanners visit every byte already, so this costs nothing extra.
var sawAmpersand = false
while true {
// Bulk byte runs first: plain ASCII value text (the common case)
// skips the per-character machinery; the run stops before the
// quote, '<', CR, or any byte the Character path must inspect.
if let run = reader.attributeRunBytes(quote: quoteByte, sawAmpersand: &sawAmpersand) {
length += run.utf8.count
try checkContent(length, mark)
raw += run
continue
}
// Byte-level check before the Character peek: whenever
// `attributeRunBytes` stopped because the next byte is the closing
// quote (its only non-empty-input stopping reason besides '<', CR,
// or a control/non-ASCII byte the Character path must inspect),
// this avoids buffering that quote as a Character only to compare
// and discard it a moment later.
if reader.peekByte() == quoteByte { break }
guard let character = reader.peek(), character != quoteCharacter else { break }
length += 1
try checkContent(length, mark)
if character == "<" {
throw PureXML.Parsing.ParseError.rawLessThanInAttribute(reader.mark)
}
guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isChar) else {
throw PureXML.Parsing.ParseError.invalidCharacter(reader.mark)
}
if character == "&" { sawAmpersand = true }
// 3.3.3: a literal whitespace character in an attribute value
// normalizes to a space; this happens before reference decoding,
// so whitespace that arrives via a character reference survives.
raw.append(character.isXMLWhitespace ? " " : character)
reader.advance()
}
// The loop above only exits (breaks) when the next position is nil (end
// of input) or the closing quote: there is no third case.
try consumeAttributeValueCloseQuote(byte: quoteByte, character: quoteCharacter)
// No '&' means no references to decode: the normalized run is the value.
guard sawAmpersand else { return raw }
return try decodeReferences(raw, at: mark)
}
/// Detects and consumes the attribute value's opening quote, returning
/// both its byte and `Character` forms: `quoteByte` drives
/// `attributeRunBytes`'s bulk scan and the closing-quote byte check;
/// `quoteCharacter` is the fallback for wherever the byte fast path does
/// not apply (a streaming source with no owned byte storage, or a
/// lookahead buffer some earlier scan already populated). Byte fast path:
/// `peekByte()` only returns non-nil when the lookahead buffer is empty,
/// so `reader.consume(_:)` below reaches its own byte fast path
/// (`matchesBytes`) directly, with no Character buffering at all.
private mutating func consumeAttributeValueOpenQuote() throws -> (byte: UInt8, character: Character) {
if let byte = reader.peekByte(), byte == 0x22 || byte == 0x27 {
reader.consume(byte == 0x22 ? "\"" : "'")
return (byte, byte == 0x22 ? "\"" : "'")
}
guard let quote = reader.peek(), quote == "\"" || quote == "'" else {
throw PureXML.Parsing.ParseError.unquotedAttributeValue(reader.mark)
}
reader.advance()
return (quote == "\"" ? 0x22 : 0x27, quote)
}
/// Consumes the attribute value's closing quote. Called only once the
/// value-scanning loop has already confirmed (via a peek that consumed
/// nothing) the next position is either the quote or end of input, so
/// whichever check fired that break already buffered nothing new;
/// re-checking the byte first, then falling back to the already-buffered
/// Character peek, reaches the same verdict `reader.consume(String(quote))`
/// would, without that function's fresh `String` allocation and double
/// `Character` iteration on every single attribute value in the document.
private mutating func consumeAttributeValueCloseQuote(byte quoteByte: UInt8, character quoteCharacter: Character) throws {
if reader.peekByte() == quoteByte {
reader.advance()
return
}
guard reader.peek() == quoteCharacter else {
throw PureXML.Parsing.ParseError.unexpectedEndOfInput(reader.mark)
}
reader.advance()
}
}