PureXML/Sources/Parsing/EventReaderAttributes.swift
Mihaela Mihaljevic 2b6da0d0a0 perf(parsing): reuse one scratch set for the attribute duplicate-name check
Fourth profile-guided pass (#139, docs/benchmarks.md pass 28). scanAttributes
allocated a fresh Set<String>'s native storage on every element with
attributes, just to check a handful of names for duplicates; the set is now a
stored EventReader scratch property cleared with removeAll(keepingCapacity:)
per element, so each element's check reuses the previous element's storage.
Safe because scanAttributes cannot re-enter itself (nothing inside an
attribute scan starts another start-tag scan), so the shared storage never
sees two interleaved scans; duplicate detection, recovering-mode behavior,
and thrown errors are unchanged.

Verified in an isolated worktree at the previous commit: full local suite
(1858 tests) and full W3C XSTS suite (14383 groups) pass unchanged. Measured
with an interleaved same-state A/B, baseline/patched/baseline/patched each
best-of-5, because the machine carried concurrent load this session: both
patched runs (0.1073s, 0.1067s) beat both baseline runs (0.1091s, 0.1117s),
~2-4% on parse.

Recorded alongside, per the benchmarks log's discipline: a sibling candidate
(reserving each tree frame's children capacity on first append) measured
neutral under the same protocol and was reverted rather than landed, and the
TreeNode weak-parent side-table cost (~9% of parse, swift_weakAssign per
child plus a runtime side table per parent) is documented under Known
structure of the gap as needing a design decision, since parent's
nil-after-release semantics are documented public API. Touched lines carry
repo-standard swiftformat rewraps applied by a concurrent formatter run.
2026-07-12 12:56:12 +02:00

153 lines
8.0 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] = []
// Reuses the stored scratch set (see its declaration) so an element
// does not allocate fresh Set storage just to check its attribute
// names; clearing keeps the capacity. `scanAttributes` cannot re-enter
// itself (nothing inside an attribute scan starts another start tag),
// so the shared storage never sees two interleaved scans.
attributeNamesSeen.removeAll(keepingCapacity: true)
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 attributeNamesSeen.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()
}
}