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

179 lines
6.3 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
}
}
}