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