PureXML/Sources/Validation/DTDDeclarationValidity.swift
Mihaela Mihaljevic 89cb299201 feat(validation): DTD declaration-level validity constraints
VC Root Element Type (root matches the DOCTYPE name), VC Unique
Element Type Declaration, VC No Duplicate Types in mixed content, VC
One ID per Element Type, VC Notation Attributes and Notation Declared,
VC Attribute Default Legal plus ID Attribute Default, lexical Name
checks for the ID family, and CDATA as character data in element
content. Strict mode adds VC Attribute Value Type (every attribute
declared; namespace declarations exempt). Declaration findings are
computed once at schema build and reported at the document root. Sun
invalid coverage moves from 43/74 to 62/74; the twelve remaining are
all the standalone='yes' family.
2026-06-10 07:05:14 +02:00

115 lines
5.4 KiB
Swift

extension PureXML.Validation.DTDSchema {
/// The declaration-level validity constraints: violations that exist in the
/// DTD itself, independent of any instance content. Computed once when the
/// schema is built and reported at the document root.
static func declarationFindings(
_ documentType: PureXML.Parsing.DocumentType,
attributes: [String: [PureXML.Validation.AttributeDeclaration]],
notations: Set<String>,
) -> [String] {
var findings: [String] = []
// VC: Unique Element Type Declaration.
for name in documentType.duplicateElements.sorted() {
findings.append("element type '\(name)' is declared more than once")
}
// VC: Notation Declared, an unparsed entity's notation must exist.
for (entity, unparsed) in documentType.unparsedEntities.sorted(by: { $0.key < $1.key }) {
guard !notations.contains(unparsed.notation) else { continue }
findings.append("unparsed entity '\(entity)' names undeclared notation '\(unparsed.notation)'")
}
// VC: No Duplicate Types in mixed content.
for (name, model) in documentType.elementModels.sorted(by: { $0.key < $1.key }) {
if let duplicate = duplicateMixedName(model) {
findings.append("mixed content of '\(name)' repeats '\(duplicate)'")
}
}
for (element, declarations) in attributes.sorted(by: { $0.key < $1.key }) {
findings += attributeFindings(element: element, declarations: declarations, notations: notations)
}
return findings
}
/// One element's attribute-declaration findings: at most one ID attribute,
/// NOTATION lists name declared notations, and defaults are legal.
private static func attributeFindings(
element: String,
declarations: [PureXML.Validation.AttributeDeclaration],
notations: Set<String>,
) -> [String] {
var findings: [String] = []
if declarations.count(where: { $0.type == .id }) > 1 {
findings.append("element '\(element)' declares more than one ID attribute")
}
for declaration in declarations {
if case let .notation(allowed) = declaration.type {
for name in allowed.sorted() where !notations.contains(name) {
findings.append("NOTATION attribute '\(declaration.name)' on '\(element)' lists undeclared notation '\(name)'")
}
}
if let problem = defaultProblem(declaration) {
findings.append("attribute '\(declaration.name)' on '\(element)' \(problem)")
}
}
return findings
}
/// The first repeated name in a `(#PCDATA|a|b)` mixed model, or nil.
private static func duplicateMixedName(_ model: String) -> String? {
let text = model.trimmingXMLWhitespace()
guard text.hasPrefix("(#PCDATA") else { return nil }
let inner = text.dropFirst().prefix { $0 != ")" }
var seen: Set<String> = []
for token in inner.split(separator: "|").dropFirst() {
let name = token.trimmingXMLWhitespace()
if !seen.insert(name).inserted {
return name
}
}
return nil
}
/// Why a declaration's default value is illegal for its type, or nil.
private static func defaultProblem(_ declaration: PureXML.Validation.AttributeDeclaration) -> String? {
let defaultValue: String? = switch declaration.defaultDecl {
case let .fixed(value), let .value(value): value
case .required, .implied: nil
}
// VC: ID Attribute Default, an ID attribute must be #IMPLIED or #REQUIRED.
if declaration.type == .id {
return defaultValue == nil ? nil : "is an ID and must be #IMPLIED or #REQUIRED"
}
guard let value = defaultValue else { return nil }
switch declaration.type {
case let .enumeration(allowed) where !allowed.contains(value):
return "has a default outside its enumeration"
case let .notation(allowed) where !allowed.contains(value):
return "has a default outside its NOTATION list"
default:
return tokenizedDefaultProblem(declaration.type, value: value)
}
}
/// The lexical-form problems of a tokenized type's default value.
private static func tokenizedDefaultProblem(_ type: PureXML.Validation.AttributeType, value: String) -> String? {
let isName = PureXML.Parsing.XMLCharacter.isValidName
let isNmtoken = PureXML.Parsing.XMLCharacter.isNmtoken
let tokens = value.split(whereSeparator: \.isWhitespace).map(String.init)
switch type {
case .idReference where !isName(value):
return "has an IDREF default that is not a Name"
case .idReferences where tokens.isEmpty || !tokens.allSatisfy(isName):
return "has an IDREFS default that is not Names"
case .nmToken where !isNmtoken(value):
return "has an NMTOKEN default that is not a name token"
case .nmTokens where tokens.isEmpty || !tokens.allSatisfy(isNmtoken):
return "has an NMTOKENS default that is not name tokens"
case .entity where !isName(value):
return "has an ENTITY default that is not a Name"
case .entities where tokens.isEmpty || !tokens.allSatisfy(isName):
return "has an ENTITIES default that is not Names"
default:
return nil
}
}
}