PureXML/Sources/Validation/ContentModelParser.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

142 lines
4.5 KiB
Swift

extension PureXML.Validation {
/// Parses a DTD content-model string (the text after `<!ELEMENT name`) into a
/// ``ContentModel``. Tolerant: a malformed model falls back to `ANY` rather
/// than failing, so a damaged DTD never blocks parsing the document itself.
struct ContentModelParser {
private let chars: [Character]
private var index = 0
private init(_ model: String) {
chars = Array(model)
}
static func parse(_ model: String) -> ContentModel {
var parser = ContentModelParser(model)
return parser.parseModel()
}
private mutating func parseModel() -> ContentModel {
skipSpace()
if matchKeyword("EMPTY") {
return .empty
}
if matchKeyword("ANY") {
return .any
}
guard peek() == "(" else { return .any }
// Look inside for the #PCDATA marker to distinguish mixed/PCDATA from
// element content, without consuming the group.
let saved = index
advance()
skipSpace()
if matchKeyword("#PCDATA") {
return parseMixedTail()
}
index = saved
return .children(parseParticle())
}
private mutating func parseMixedTail() -> ContentModel {
var names: [String] = []
while true {
skipSpace()
if peek() == ")" {
advance()
break
}
if peek() == "|" {
advance()
skipSpace()
let name = parseName()
if !name.isEmpty {
names.append(name)
}
} else {
advance()
}
}
_ = consumeOccurrence()
return names.isEmpty ? .pcdata : .mixed(names)
}
private mutating func parseParticle() -> Particle {
skipSpace()
if peek() == "(" {
advance()
var items: [Particle] = [parseParticle()]
var separator: Character?
while true {
skipSpace()
guard let next = peek(), next == "," || next == "|" else { break }
separator = separator ?? next
advance()
items.append(parseParticle())
}
skipSpace()
if peek() == ")" {
advance()
}
let occurrence = consumeOccurrence()
return separator == "|" ? .choice(items, occurrence) : .sequence(items, occurrence)
}
let name = parseName()
return .name(name, consumeOccurrence())
}
private mutating func consumeOccurrence() -> Occurrence {
switch peek() {
case "?": advance()
return .optional
case "*": advance()
return .zeroOrMore
case "+": advance()
return .oneOrMore
default: return .once
}
}
private mutating func parseName() -> String {
var name = ""
while let character = peek(), !isDelimiter(character) {
name.append(character)
advance()
}
return name
}
private func isDelimiter(_ character: Character) -> Bool {
switch character {
case ",", "|", "(", ")", "?", "*", "+", " ", "\t", "\n", "\r": true
default: false
}
}
private mutating func matchKeyword(_ keyword: String) -> Bool {
let target = Array(keyword)
guard index + target.count <= chars.count else { return false }
for (offset, expected) in target.enumerated() where chars[index + offset] != expected {
return false
}
index += target.count
return true
}
private func peek() -> Character? {
index < chars.count ? chars[index] : nil
}
private mutating func advance() {
if index < chars.count {
index += 1
}
}
private mutating func skipSpace() {
while let character = peek(), character == " " || character == "\t" || character == "\n" || character == "\r" {
advance()
}
}
}
}