The attribute-value scanner ran the entity decoder on every value, and the decoder re-scanned the whole value for '&' before doing nothing when none was present. The byte-run scanner already visits every value byte, so it now notes any '&' and an ampersand-free value (the common case) returns its normalized run directly. Measured against a same-machine-state baseline, every run came in below every baseline run, ~4% faster parse on the 20k-item generated corpus. Move the bulk byte-run scanners (contentRunBytes, attributeRunBytes) into ReaderByteRuns.swift and centralize their cursor mutation through a single advanceByteRun method, so Reader's position fields stay write-encapsulated in Reader.swift while both files stay under the length cap. The byte and Character paths are held to identical decoded values by the differential oracle, extended with ampersand-free, entity, and character-reference attribute cases. No spec behavior changed.
99 lines
5.0 KiB
Swift
99 lines
5.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] = []
|
|
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()
|
|
}
|
|
guard reader.consume(String(quote)) else {
|
|
throw PureXML.Parsing.ParseError.unexpectedEndOfInput(reader.mark)
|
|
}
|
|
// No '&' means no references to decode: the normalized run is the value.
|
|
guard sawAmpersand else { return raw }
|
|
return try decodeReferences(raw, at: mark)
|
|
}
|
|
}
|