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.
142 lines
7.5 KiB
Swift
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()
|
|
}
|
|
}
|