PureXML/Sources/Parsing/EventReaderAttributes.swift
Mihaela Mihaljevic 7ffd1dd545 perf(parsing): remove redundant work in attribute-value and namespace scanning
Two profile-guided passes (#139), found by sample-profiling a 42MB generated
corpus after re-measuring the parse/libxml2 ratio fresh:

scanAttributeValue's closing-quote check re-derived an already-known answer
through Reader.consume's general String-iteration path, which allocates a
fresh String(quote) and iterates it via Character machinery twice, on every
single attribute value in the document. The content loop's own break
condition already guarantees the buffered peek is either the quote or end of
input, so checking it directly and advancing once is the same behavior for
less work.

NamespaceContext.enterElement, called once per element: checkNameShape's
name.localName.contains(":") resolved to Sequence.contains(_:), walking
extended grapheme clusters to find one ASCII byte; utf8.contains(0x3A) finds
the same colon via the UTF-8 byte view. Separately, attributes.map{
resolveAttribute } allocated a fresh backing array on every element even
though resolveAttribute returns its argument unchanged whenever the attribute
has no prefix or the xmlns prefix (the common case); checking first with no
allocation and reusing the input array when nothing would change gives the
identical result without it.

Both are same-observable-behavior changes, verified against the full local
suite (1852 tests) and the full W3C XSTS conformance suite (14383 groups),
both unchanged. Measured parse ~6.59x -> ~6.00x versus libxml2 at the
20000-item/4.2MB corpus, best of 5; see docs/benchmarks.md passes 25-26.
2026-07-12 11:40:33 +02:00

109 lines
5.6 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
guard let quote = reader.peek(), quote == "\"" || quote == "'" else {
throw PureXML.Parsing.ParseError.unquotedAttributeValue(reader.mark)
}
reader.advance()
let quoteByte: UInt8 = quote == "\"" ? 0x22 : 0x27
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
}
guard let character = reader.peek(), character != quote 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 `reader.peek()` is nil (end of
// input) or equals `quote`: there is no third case, so the closing quote
// is already known and buffered from that last peek. `reader.consume(String(quote))`
// would re-derive the same answer through `matches`/`consume`'s general
// path (which allocates a fresh `String(quote)` and iterates it twice,
// once to compare and once to count how many times to advance), on
// every single attribute value in the document; checking the
// already-buffered peek directly and advancing once is the same
// observable behavior for less work.
guard reader.peek() == quote else {
throw PureXML.Parsing.ParseError.unexpectedEndOfInput(reader.mark)
}
reader.advance()
// No '&' means no references to decode: the normalized run is the value.
guard sawAmpersand else { return raw }
return try decodeReferences(raw, at: mark)
}
}