PureXML/Sources/Parsing/EventReaderValidity.swift
Mihaela Mihaljevic 2cca4912ff
perf(parse): bulk-copy literal runs in the entity decoder (#291)
EntityExpander.expand rebuilt every reference-bearing text run one character at
a time: result.append(character) per character, with a per-character charge
against the amplification budget. A text node holding a single & was copied
grapheme by grapheme, which is the bulk of the per-text-node cost in a document
whose content uses entities.

Scan to the next '&' or '<' and append the literal run in one operation,
charging the budget by the run length. The decoded text, the CDATA-verbatim
shielding, and the billion-laughs budget enforcement are unchanged: the same
overrun throws at the same input, since charging a run of length L against
budget B fails exactly when L > B, the same condition the per-character guard
enforced.

Also return early from recordReferenceContentFindings when there are no DTD
content models (the common no-DTD case): it otherwise scanned each text run for
'&' and rendered the enclosing element's qualified name before the model lookup
that would fail anyway.

Measured parse ~5% faster on the 20k-item generated corpus via a same-state A/B
(9.06x to ~8.5x slower than libxml2). Verified by the W3C XML conformance
corpora, the new EntityDecodeBulkTests six-case decode differential, the full
suite, the Xalan XSLT gold-output corpus, and the WASM build, with an
adversarial equivalence review of the rewrite.
2026-06-25 11:45:02 +02:00

145 lines
7.2 KiB
Swift

/// The validity-aware side of reference handling: which entities a reference
/// may see (the standalone WFC), whether an undeclared reference is fatal
/// (production 68), and the errata E15 content findings. Split from the
/// reader body to keep it under the length caps.
extension PureXML.Parsing.EventReader {
typealias EntityDecoder = PureXML.Parsing.EntityDecoder
typealias Mark = PureXML.Parsing.Mark
/// The entity table visible to references. A standalone document must
/// not reference an entity declared outside its internal subset (WFC:
/// Entity Declared, 2.9), so external declarations are hidden and such
/// a reference reports as undeclared.
var referencableEntities: [String: String] {
guard xmlDeclaration?.standalone == true else { return documentType.entities }
return documentType.entities.filter { documentType.internalEntities.contains($0.key) }
}
/// Whether an undeclared entity reference is fatal (production 68):
/// it is a WFC when the document is standalone or could not have
/// external declarations, and a validity finding otherwise.
var undeclaredEntityIsFatal: Bool {
if xmlDeclaration?.standalone == true { return true }
return documentType.externalSubset == nil && documentType.parameterEntities.isEmpty
}
/// Decodes references in text or an attribute value, with undeclared
/// entities routed per `undeclaredEntityIsFatal`.
mutating func decodeReferences(_ raw: String, at mark: Mark) throws -> String {
guard undeclaredEntityIsFatal else {
var undeclared: [String] = []
let decoded = try EntityDecoder.decodeLenient(
raw,
entities: referencableEntities,
budget: &entityBudget,
at: mark,
undeclared: &undeclared,
)
for name in undeclared {
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding("general entity '&\(name);' is referenced but not declared"))
}
return decoded
}
return try EntityDecoder.decode(raw, entities: referencableEntities, budget: &entityBudget, at: mark)
}
/// Whether the run contains a direct character reference, or references
/// an entity whose stored replacement does (the errata E15h shape: the
/// double-escaped reference surfaces when the replacement is reparsed).
mutating func referencesCharacter(_ raw: String) -> Bool {
if containsCharacterReference(raw) { return true }
let entities = referencableEntities
var index = raw.startIndex
while index < raw.endIndex, let amp = raw[index...].firstIndex(of: "&") {
guard let semicolon = raw[amp...].firstIndex(of: ";") else { return false }
let name = String(raw[raw.index(after: amp) ..< semicolon])
if let replacement = entities[name], containsCharacterReference(replacement) {
return true
}
index = raw.index(after: semicolon)
}
return false
}
/// Whether the text contains a direct character reference (Foundation-
/// free substring test).
func containsCharacterReference(_ raw: String) -> Bool {
var sawAmp = false
for character in raw {
if sawAmp, character == "#" { return true }
sawAmp = character == "&"
}
return false
}
/// Records the errata E15 finding for non-element content inside an
/// element declared EMPTY.
mutating func recordEmptyElementContent(_ what: String) {
guard let current = open.last,
let model = documentType.elementModels[current.localName] ?? documentType.elementModels[current.description],
model == "EMPTY"
else { return }
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding(
"\(what) appears in the EMPTY element <\(current.description)>",
subject: current.description,
))
}
/// The errata E15 validity findings a text run can carry: any reference
/// inside an element declared EMPTY, and reference-derived whitespace
/// (which is character data, not ignorable whitespace) inside element
/// content.
mutating func recordReferenceContentFindings(raw: String, decoded: String) {
// Element-content reference findings exist only against a declared
// content model. With no DTD (the common case) there is nothing to
// check, so return before scanning `raw` for '&' or rendering the
// element's qualified name.
guard !documentType.elementModels.isEmpty else { return }
guard raw.contains("&"), let current = open.last,
let model = documentType.elementModels[current.localName] ?? documentType.elementModels[current.description]
else { return }
if model == "EMPTY" {
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding(
"a reference appears in the EMPTY element <\(current.description)>",
subject: current.description,
))
return
}
// Errata E15: a direct character reference is character data even
// when it names whitespace; an entity reference whose replacement is
// whitespace is permitted in element content.
let isElementContent = model.hasPrefix("(") && !model.hasPrefix("(#PCDATA")
let isReferenceWhitespace = !decoded.isEmpty && decoded.allSatisfy(\.isWhitespace)
if isElementContent, isReferenceWhitespace, referencesCharacter(raw) {
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding(
"character-reference whitespace in the element content of <\(current.description)>",
subject: current.description,
))
}
}
/// Applies the DTD-declared tokenized normalization to namespace
/// declaration values before they bind: an `xmlns:b` declared NMTOKEN in
/// the DTD binds its collapsed value, so ' urn:x ' and 'urn:x' are the
/// same namespace name (eduni ns 1.0/012).
mutating func normalizedBindings(
element: PureXML.Model.QualifiedName,
attributes: [PureXML.Model.Attribute],
) -> [PureXML.Model.Attribute] {
let hasBinding = attributes.contains { $0.name.prefix == "xmlns" || ($0.name.prefix == nil && $0.name.localName == "xmlns") }
guard hasBinding,
let body = documentType.attributeLists[element.description] ?? documentType.attributeLists[element.localName]
else { return attributes }
let declarations = PureXML.Validation.AttributeListParser.parse(body)
return attributes.map { attribute in
let isBinding = attribute.name.prefix == "xmlns" || (attribute.name.prefix == nil && attribute.name.localName == "xmlns")
guard isBinding,
let declaration = declarations.first(where: { $0.name == attribute.name.description })
else { return attribute }
if case .cdata = declaration.type { return attribute }
let collapsed = attribute.value.split(separator: " ").joined(separator: " ")
return PureXML.Model.Attribute(name: attribute.name, value: collapsed)
}
}
}