PureXML/Sources/Parsing/DoctypeScannerConditional.swift
Mihaela Mihaljevic c084d01d7c feat(parsing): IBM section clean, entity-declared WFC/VC split, declaration recursion
The IBM section (1094 cases) runs clean to two documented classes:
279 pre-Fifth-Edition character-class cases (P85-89, count asserted
exactly) and 2 PE-in-internal-subset feature cases. Real gaps closed:
a document needs a root element (production 1); entity references in
literals must be Names or character references; WFC: No Recursion
checked at declaration over the general/parameter reference graph;
undeclared PE references split into WFC (standalone, or no external
declarations possible) versus deferred validity finding, also for
attribute-default references; refused external PEs are neither; and
groups split across PE replacements report VC: Proper Group/PE
Nesting. Entity machinery split to DoctypeScannerEntities for caps.

Closes #125
2026-06-10 07:26:50 +02:00

143 lines
6.5 KiB
Swift

/// Conditional sections of a DTD subset (`<![INCLUDE[ ... ]]>` /
/// `<![IGNORE[ ... ]]>`, productions 61-65), split from the scanner body to
/// keep it under the length caps.
extension DTDScanner {
/// Processes a conditional section `<![INCLUDE[ … ]]>` or `<![IGNORE[ … ]]>`.
/// The keyword may be a parameter-entity reference, so it is parameter-expanded
/// first; after expansion it must be exactly `INCLUDE` or `IGNORE` (case-
/// sensitive, production 61). An INCLUDE section's declarations are scanned;
/// an IGNORE section's body is discarded. Nested sections are handled by
/// re-scanning the body, and an unterminated section is not well-formed.
mutating func scanConditionalSection(_ reader: inout Reader, depth: Int, at mark: Mark) throws {
reader.consume("<![")
var keywordRaw = ""
while let character = reader.peek(), character != "[" {
keywordRaw.append(character)
reader.advance()
}
guard reader.consume("[") else {
throw ParseError.malformedDeclaration(mark)
}
let keyword = expandParameterReferences(keywordRaw).trimmingXMLWhitespace()
guard keyword == "INCLUDE" || keyword == "IGNORE" else {
throw ParseError.malformedDeclaration(mark)
}
guard let body = readConditionalBody(&reader) else {
throw ParseError.malformedDeclaration(mark)
}
if keyword == "INCLUDE", depth < maxDepth {
var sub = Reader(body)
try scanDeclarations(&sub, depth: depth + 1, terminatedByBracket: false, at: mark)
}
}
/// Reads the body of a conditional section up to its matching `]]>`, keeping
/// any nested `<![ … ]]>` sections intact so an INCLUDE can re-scan them.
/// Returns nil when the input ends before the section is balanced.
mutating func readConditionalBody(_ reader: inout Reader) -> String? {
var body = ""
var nesting = 1
while reader.peek() != nil {
if reader.matches("<![") {
nesting += 1
reader.consume("<![")
body += "<!["
} else if reader.matches("]]>") {
reader.consume("]]>")
nesting -= 1
if nesting == 0 { return body }
body += "]]>"
} else if let character = reader.peek() {
body.append(character)
reader.advance()
}
}
return nil
}
/// VC: Proper Group/PE Nesting. A parameter entity used in a content model
/// must contain balanced parentheses, so a group cannot open in one
/// replacement text and close in another. Recorded as a validity finding.
mutating func checkGroupNesting(_ rawModel: String, element: String) {
guard rawModel.contains("%") else { return }
var index = rawModel.startIndex
while index < rawModel.endIndex, let percent = rawModel[index...].firstIndex(of: "%") {
guard let semicolon = rawModel[percent...].firstIndex(of: ";") else { return }
let name = String(rawModel[rawModel.index(after: percent) ..< semicolon])
if let replacement = doctype.parameterEntities[name] {
let opens = replacement.count(where: { $0 == "(" })
let closes = replacement.count(where: { $0 == ")" })
if opens != closes {
doctype.validityFindings.append(
"the content model of '\(element)' uses parameter entity '%\(name);' with improper group/PE nesting",
)
}
}
index = rawModel.index(after: semicolon)
}
}
/// WFC: No Recursion, checked at declaration: no parsed entity (general or
/// parameter) may reference itself directly or indirectly through the
/// stored replacement texts.
func checkEntityRecursion(at mark: Mark) throws {
var visiting: Set<String> = []
var cleared: Set<String> = []
for key in doctype.entities.keys.sorted() {
try walkEntityGraph("ge:" + key, visiting: &visiting, cleared: &cleared, at: mark)
}
for key in doctype.parameterEntities.keys.sorted() {
try walkEntityGraph("pe:" + key, visiting: &visiting, cleared: &cleared, at: mark)
}
}
private func walkEntityGraph(_ node: String, visiting: inout Set<String>, cleared: inout Set<String>, at mark: Mark) throws {
guard !cleared.contains(node) else { return }
guard visiting.insert(node).inserted else {
throw ParseError.recursiveEntity(name: String(node.dropFirst(3)), mark)
}
defer {
visiting.remove(node)
cleared.insert(node)
}
let name = String(node.dropFirst(3))
let value = node.hasPrefix("ge:") ? doctype.entities[name] : doctype.parameterEntities[name]
guard let value else { return }
for reference in entityReferences(in: value) {
try walkEntityGraph(reference, visiting: &visiting, cleared: &cleared, at: mark)
}
}
/// The `ge:`/`pe:`-tagged references in a replacement text, with CDATA
/// sections shielded and predefined entities skipped.
private func entityReferences(in text: String) -> [String] {
let predefined: Set = ["amp", "lt", "gt", "quot", "apos"]
var references: [String] = []
var index = text.startIndex
while index < text.endIndex {
if text[index...].hasPrefix("<![CDATA[") {
var cursor = text.index(index, offsetBy: 9)
while cursor < text.endIndex, !text[cursor...].hasPrefix("]]>") {
cursor = text.index(after: cursor)
}
index = cursor < text.endIndex ? text.index(cursor, offsetBy: 3) : text.endIndex
continue
}
let character = text[index]
guard character == "&" || character == "%" else {
index = text.index(after: index)
continue
}
guard let semicolon = text[index...].firstIndex(of: ";") else { return references }
let body = String(text[text.index(after: index) ..< semicolon])
if character == "&", !body.hasPrefix("#"), !predefined.contains(body) {
references.append("ge:" + body)
} else if character == "%", !body.isEmpty {
references.append("pe:" + body)
}
index = text.index(after: semicolon)
}
return references
}
}