PureXML/Sources/Parsing/DTDContentModelGrammar.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

154 lines
5.2 KiB
Swift

extension PureXML.Parsing {
/// Validates an `<!ELEMENT>` content specification against the XML 1.0
/// grammar (productions 46-51): `EMPTY`, `ANY`, `Mixed`, or `children`.
/// The lenient `ContentModelParser` still drives validation behavior; this
/// strict check runs at DTD scan time so a malformed model (unbalanced
/// parentheses, `#PCDATA` misplacement, mixed connectors, a quantifier
/// detached by whitespace, SGML leftovers) is rejected as not well-formed.
enum DTDContentModelGrammar {
static func isValid(_ model: String) -> Bool {
let text = model.trimmingXMLWhitespace()
if text == "EMPTY" || text == "ANY" { return true }
var scanner = ModelScanner(text)
guard scanner.peek() == "(" else { return false }
// Mixed and children both start with '('; Mixed is identified by
// '#PCDATA' as the first token inside.
let isMixed = scanner.looksMixed()
let parsed = isMixed ? scanner.mixed() : scanner.children()
return parsed && scanner.isAtEnd
}
}
}
/// A tiny recursive-descent scanner over one content-model expression.
private struct ModelScanner {
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(_ ahead: Int = 0) -> Character? {
index + ahead < characters.count ? characters[index + ahead] : nil
}
private mutating func advance() -> Character? {
guard index < characters.count else { return nil }
defer { index += 1 }
return characters[index]
}
private mutating func skipSpace() {
while let character = peek(), character.isWhitespace {
index += 1
}
}
private mutating func consume(_ character: Character) -> Bool {
if peek() == character {
index += 1
return true
}
return false
}
/// Whether the expression opens as `( S? #PCDATA`.
func looksMixed() -> Bool {
var probe = self
guard probe.consume("(") else { return false }
probe.skipSpace()
return probe.peek() == "#"
}
/// `Mixed ::= '(' S? '#PCDATA' (S? '|' S? Name)* S? ')*' | '(' S? '#PCDATA' S? ')'`
mutating func mixed() -> Bool {
guard consume("(") else { return false }
skipSpace()
guard consumeLiteral("#PCDATA") else { return false }
var sawAlternative = false
while true {
skipSpace()
if consume(")") { break }
guard consume("|") else { return false }
sawAlternative = true
skipSpace()
guard name() else { return false }
}
// With alternatives the trailing '*' is required; without, optional none.
if sawAlternative {
return consume("*")
}
return peek() != "*" || consume("*")
}
/// `children ::= (choice | seq) ('?' | '*' | '+')?` with
/// `cp ::= (Name | choice | seq) ('?' | '*' | '+')?`. A group commits to one
/// connector (`|` or `,`); a quantifier must follow its particle directly.
mutating func children() -> Bool {
guard group() else { return false }
_ = quantifier()
return true
}
private mutating func group() -> Bool {
guard consume("(") else { return false }
skipSpace()
guard particle() else { return false }
skipSpace()
var connector: Character?
while !consume(")") {
guard let next = advance(), next == "|" || next == "," else { return false }
if let connector, connector != next { return false }
connector = next
skipSpace()
guard particle() else { return false }
skipSpace()
}
return true
}
private mutating func particle() -> Bool {
if peek() == "(" {
guard group() else { return false }
} else {
guard name() else { return false }
}
_ = quantifier()
return true
}
/// A quantifier binds directly to its particle: no whitespace before it,
/// and at most one.
private mutating func quantifier() -> Bool {
guard let next = peek(), next == "?" || next == "*" || next == "+" else { return false }
index += 1
return true
}
private 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
}
private 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
}
}