DocumentType.validityFindings carries ValidityFinding values (reason plus subject) instead of strings, DTDSchema.declarationErrors holds ready ValidationErrors, and every declaration-level finding renders at its declaration's coding path (x, r/@a) instead of at the document root, matching the XSD consistency-errors precedent. Texts unchanged.
144 lines
6.8 KiB
Swift
144 lines
6.8 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.
|
|
typealias Finding = PureXML.Validation.ValidationError
|
|
typealias Key = PureXML.Validation.PathKey
|
|
|
|
static func declarationFindings(
|
|
_ documentType: PureXML.Parsing.DocumentType,
|
|
attributes: [String: [PureXML.Validation.AttributeDeclaration]],
|
|
notations: Set<String>,
|
|
) -> [Finding] {
|
|
var findings: [Finding] = []
|
|
// VC: Unique Element Type Declaration.
|
|
for name in documentType.duplicateElements.sorted() {
|
|
findings.append(Finding(reason: "element type '\(name)' is declared more than once", at: [.element(name)]))
|
|
}
|
|
// 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(Finding(
|
|
reason: "unparsed entity '\(entity)' names undeclared notation '\(unparsed.notation)'",
|
|
at: [.element(entity)],
|
|
))
|
|
}
|
|
// 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(Finding(reason: "mixed content of '\(name)' repeats '\(duplicate)'", at: [.element(name)]))
|
|
}
|
|
}
|
|
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>,
|
|
) -> [Finding] {
|
|
var findings: [Finding] = []
|
|
if declarations.count(where: { $0.type == .id }) > 1 {
|
|
findings.append(Finding(reason: "element '\(element)' declares more than one ID attribute", at: [.element(element)]))
|
|
}
|
|
for declaration in declarations {
|
|
let path: [Key] = [.element(element), .attribute(declaration.name)]
|
|
if case let .notation(allowed) = declaration.type {
|
|
for name in allowed.sorted() where !notations.contains(name) {
|
|
findings.append(Finding(
|
|
reason: "NOTATION attribute '\(declaration.name)' on '\(element)' lists undeclared notation '\(name)'",
|
|
at: path,
|
|
))
|
|
}
|
|
}
|
|
// Errata E2: tokens in an enumerated or NOTATION list must be
|
|
// distinct.
|
|
if let duplicate = duplicateToken(declaration.type) {
|
|
findings.append(Finding(reason: "attribute '\(declaration.name)' on '\(element)' repeats the token '\(duplicate)'", at: path))
|
|
}
|
|
if let problem = defaultProblem(declaration) {
|
|
findings.append(Finding(reason: "attribute '\(declaration.name)' on '\(element)' \(problem)", at: path))
|
|
}
|
|
}
|
|
return findings
|
|
}
|
|
|
|
/// The first repeated token of an enumerated or NOTATION type, or nil.
|
|
private static func duplicateToken(_ type: PureXML.Validation.AttributeType) -> String? {
|
|
let tokens: [String]
|
|
switch type {
|
|
case let .enumeration(allowed), let .notation(allowed): tokens = allowed
|
|
default: return nil
|
|
}
|
|
var seen: Set<String> = []
|
|
for token in tokens where !seen.insert(token).inserted {
|
|
return token
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
}
|