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

160 lines
5.3 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
}
}