PureXML/Sources/Parsing/DTDAttListGrammar.swift
Mihaela Mihaljevic 2f1b936e36 feat(parsing): scalar-level lexing, xmlconf archive fully conformant
The reader buffers one Unicode scalar per element, splitting
multi-scalar graphemes from any source, so combining marks cannot merge
with delimiters and the Fifth Edition's scalar-defined name productions
classify correctly; the replacement-text, content-model, and ATTLIST
sub-scanners and the entity-literal validator follow, while grapheme
clusters reassemble wherever scanned text accumulates. Clears the last
eduni baseline, grows the asserted IBM pre-5e class to 300, and with
the xmltest manifest's own EDITION exclusions for 140/141 empties the
xmltest baseline: every xmlconf case applicable to a Fifth Edition,
namespace-aware processor passes.

Closes #135
2026-06-10 08:40:26 +02:00

173 lines
6.2 KiB
Swift

extension PureXML.Parsing {
/// Validates an `<!ATTLIST>` declaration body (everything after the element
/// name) against the XML 1.0 grammar (productions 52-60): each definition is
/// `S Name S AttType S DefaultDecl`, where the type is `CDATA`, a tokenized
/// type, `NOTATION` with a name group, or an enumeration, and the default is
/// `#REQUIRED`, `#IMPLIED`, or an optionally-`#FIXED` quoted value. The
/// lenient `AttributeListParser` still drives behavior; this strict check
/// runs at DTD scan time.
enum DTDAttListGrammar {
private static let tokenizedTypes: Set<String> = [
"CDATA", "ID", "IDREF", "IDREFS", "ENTITY", "ENTITIES", "NMTOKEN", "NMTOKENS",
]
static func isValid(_ body: String) -> Bool {
defaultValues(body) != nil
}
/// The quoted default values of a valid body, or nil when the body does
/// not match the grammar. The values feed the entity-reference checks
/// the spec puts on attribute defaults.
static func defaultValues(_ body: String) -> [String]? {
var defaults: [String] = []
var scanner = AttListScanner(body)
while !scanner.isAtEnd {
// Each definition starts with required whitespace (the body begins
// right after the element name).
guard scanner.requiredSpace() else { return nil }
if scanner.isAtEnd { return defaults } // trailing whitespace
guard scanner.name() else { return nil }
guard scanner.requiredSpace() else { return nil }
guard attType(&scanner) else { return nil }
guard scanner.requiredSpace() else { return nil }
guard let value = defaultDecl(&scanner) else { return nil }
if let value { defaults.append(value) }
}
return defaults
}
private static func attType(_ scanner: inout AttListScanner) -> Bool {
if scanner.peek() == "(" {
return scanner.nameGroup(nmtokens: true)
}
guard let keyword = scanner.keyword() else { return false }
if tokenizedTypes.contains(keyword) { return true }
if keyword == "NOTATION" {
guard scanner.requiredSpace() else { return false }
return scanner.nameGroup(nmtokens: false)
}
return false
}
/// nil = malformed; .some(nil) = #REQUIRED/#IMPLIED; .some(value) = a
/// (possibly #FIXED) quoted default.
private static func defaultDecl(_ scanner: inout AttListScanner) -> String?? {
if scanner.consumeLiteral("#REQUIRED") || scanner.consumeLiteral("#IMPLIED") {
return .some(nil)
}
if scanner.consumeLiteral("#FIXED") {
guard scanner.requiredSpace() else { return nil }
}
guard let value = scanner.quotedValue() else { return nil }
return .some(value)
}
}
}
/// A tiny scanner over one attribute-list body.
private struct AttListScanner {
private let characters: [Character]
private var index = 0
init(_ text: String) {
// Scalar-level: a combining mark must not merge with a delimiter.
characters = text.unicodeScalars.map(Character.init)
}
var isAtEnd: Bool {
index >= characters.count
}
func peek() -> Character? {
index < characters.count ? characters[index] : nil
}
/// Consumes at least one whitespace character.
mutating func requiredSpace() -> Bool {
let start = index
while let character = peek(), character.isWhitespace {
index += 1
}
return index > start
}
mutating func name() -> Bool {
guard let first = peek(), first.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isNameStart) else {
return false
}
index += 1
while let next = peek(), next.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isNameChar) {
index += 1
}
return true
}
/// An NMTOKEN: one or more name characters (the start may be any name char).
private mutating func nmtoken() -> Bool {
let start = index
while let next = peek(), next.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isNameChar) {
index += 1
}
return index > start
}
/// An uppercase keyword run (`CDATA`, `NOTATION`, ...).
mutating func keyword() -> String? {
let start = index
while let next = peek(), next.isLetter {
index += 1
}
guard index > start else { return nil }
return String(characters[start ..< index])
}
/// `'(' S? token (S? '|' S? token)* S? ')'` with names or nmtokens.
mutating func nameGroup(nmtokens: Bool) -> Bool {
guard consume("(") else { return false }
skipSpace()
guard nmtokens ? nmtoken() : name() else { return false }
skipSpace()
while !consume(")") {
guard consume("|") else { return false }
skipSpace()
guard nmtokens ? nmtoken() : name() else { return false }
skipSpace()
}
return true
}
mutating func quotedValue() -> String? {
guard let quote = peek(), quote == "\"" || quote == "'" else { return nil }
index += 1
var value = ""
while let next = peek(), next != quote {
value.append(next)
index += 1
}
return consume(quote) ? value : nil
}
mutating func consumeLiteral(_ literal: String) -> Bool {
let count = literal.count
guard index + count <= characters.count,
String(characters[index ..< index + count]) == literal
else { return false }
index += count
return true
}
private mutating func consume(_ character: Character) -> Bool {
if peek() == character {
index += 1
return true
}
return false
}
private mutating func skipSpace() {
while let character = peek(), character.isWhitespace {
index += 1
}
}
}