PureXML/Sources/Parsing/EventReaderValidity.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

153 lines
7.3 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)
}
}
}