Slice 3 of #92. The DTD validator's imperative walk becomes declared Validation values over a DTDSchema document: - DTDSchema is now just the schema (content models + attribute declarations) and serves as the rules' Document context; its validate/walk methods are gone. - contentModel and attributeDeclarations are Validation<Element, DTDSchema>, so every content-model and attribute violation is now located by coding path ("... at path: r/a"). undeclaredElement is added only in strict mode. - identifierIntegrity is a Validation<Node, DTDSchema> gated to the root, doing the document-wide ID-uniqueness and IDREF-resolution checks in one pass. - DTD.validator(strict:) assembles the rule set; validateAgainstInternalDTD runs it and returns [ValidationError] instead of [Issue]. A new test pins the located result. Schema (XSD) and Schematron validators still use Issue and migrate in the following slices.
150 lines
6.1 KiB
Swift
150 lines
6.1 KiB
Swift
extension PureXML.Validation.DTD {
|
|
// MARK: Content models
|
|
|
|
static func contentViolations(_ element: DTDElement, model: PureXML.Validation.ContentModel, at path: DTDPath) -> [DTDFailure] {
|
|
let name = element.name.description
|
|
let content = childContent(of: element)
|
|
switch model {
|
|
case .empty:
|
|
return content.names.isEmpty && !content.hasText
|
|
? []
|
|
: [DTDFailure(reason: "element <\(name)> is declared EMPTY but has content", at: path)]
|
|
case .any:
|
|
return []
|
|
case .pcdata:
|
|
return content.names.isEmpty
|
|
? []
|
|
: [DTDFailure(reason: "element <\(name)> is declared (#PCDATA) but has child elements", at: path)]
|
|
case let .mixed(allowed):
|
|
return content.names
|
|
.filter { !allowed.contains($0) }
|
|
.map { DTDFailure(reason: "element <\($0)> is not allowed in the mixed content of <\(name)>", at: path) }
|
|
case let .children(particle):
|
|
return childrenViolations(name: name, particle: particle, content: content, at: path)
|
|
}
|
|
}
|
|
|
|
private static func childrenViolations(
|
|
name: String,
|
|
particle: PureXML.Validation.Particle,
|
|
content: (names: [String], hasText: Bool),
|
|
at path: DTDPath,
|
|
) -> [DTDFailure] {
|
|
var result: [DTDFailure] = []
|
|
if content.hasText {
|
|
result.append(DTDFailure(reason: "element <\(name)> has element content but contains character data", at: path))
|
|
}
|
|
if !PureXML.Validation.ContentModelMatcher.matchesChildren(particle, content.names) {
|
|
result.append(DTDFailure(reason: "the children of <\(name)> do not match its content model", at: path))
|
|
}
|
|
return result
|
|
}
|
|
|
|
private static func childContent(of element: DTDElement) -> (names: [String], hasText: Bool) {
|
|
var names: [String] = []
|
|
var hasText = false
|
|
for child in element.children {
|
|
switch child {
|
|
case let .element(inner):
|
|
names.append(inner.name.description)
|
|
case let .text(value), let .cdata(value):
|
|
if value.contains(where: { !$0.isWhitespace }) { hasText = true }
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return (names, hasText)
|
|
}
|
|
|
|
// MARK: Attributes
|
|
|
|
static func attributeViolations(
|
|
_ declaration: PureXML.Validation.AttributeDeclaration,
|
|
on element: DTDElement,
|
|
at path: DTDPath,
|
|
) -> [DTDFailure] {
|
|
let name = element.name.description
|
|
let value = element.attributes.first {
|
|
$0.name.description == declaration.name || $0.name.localName == declaration.name
|
|
}?.value
|
|
guard let value else {
|
|
return declaration.defaultDecl == .required
|
|
? [DTDFailure(reason: "required attribute '\(declaration.name)' is missing on <\(name)>", at: path)]
|
|
: []
|
|
}
|
|
var result: [DTDFailure] = []
|
|
if case let .fixed(fixedValue) = declaration.defaultDecl, value != fixedValue {
|
|
result.append(DTDFailure(reason: "attribute '\(declaration.name)' on <\(name)> is #FIXED and must be \"\(fixedValue)\"", at: path))
|
|
}
|
|
if case let .enumeration(allowed) = declaration.type, !allowed.contains(value) {
|
|
result.append(DTDFailure(reason: "attribute '\(declaration.name)' on <\(name)> has a value outside its enumeration", at: path))
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: ID / IDREF integrity
|
|
|
|
static func identifierErrors(_ node: PureXML.Model.Node, schema: PureXML.Validation.DTDSchema, at path: DTDPath) -> [DTDFailure] {
|
|
var counts: [String: Int] = [:]
|
|
var references: [(value: String, element: String)] = []
|
|
collect(node, schema: schema, counts: &counts, references: &references)
|
|
|
|
var errors: [DTDFailure] = []
|
|
for (value, count) in counts where count > 1 {
|
|
errors.append(DTDFailure(reason: "duplicate ID '\(value)' (declared \(count) times)", at: path))
|
|
}
|
|
for reference in references where counts[reference.value] == nil {
|
|
errors.append(DTDFailure(reason: "IDREF '\(reference.value)' on <\(reference.element)> matches no ID", at: path))
|
|
}
|
|
return errors
|
|
}
|
|
|
|
private static func collect(
|
|
_ node: PureXML.Model.Node,
|
|
schema: PureXML.Validation.DTDSchema,
|
|
counts: inout [String: Int],
|
|
references: inout [(value: String, element: String)],
|
|
) {
|
|
switch node {
|
|
case let .document(children):
|
|
for child in children {
|
|
collect(child, schema: schema, counts: &counts, references: &references)
|
|
}
|
|
case let .element(element):
|
|
collectIdentifiers(element, schema: schema, counts: &counts, references: &references)
|
|
for child in element.children {
|
|
collect(child, schema: schema, counts: &counts, references: &references)
|
|
}
|
|
case .text, .cdata, .comment, .processingInstruction:
|
|
break
|
|
}
|
|
}
|
|
|
|
private static func collectIdentifiers(
|
|
_ element: DTDElement,
|
|
schema: PureXML.Validation.DTDSchema,
|
|
counts: inout [String: Int],
|
|
references: inout [(value: String, element: String)],
|
|
) {
|
|
let name = element.name.description
|
|
guard let declarations = schema.attributes[name] else { return }
|
|
for declaration in declarations {
|
|
guard let value = element.attributes.first(where: {
|
|
$0.name.description == declaration.name || $0.name.localName == declaration.name
|
|
})?.value else { continue }
|
|
switch declaration.type {
|
|
case .id:
|
|
counts[value, default: 0] += 1
|
|
case .idReference:
|
|
references.append((value, name))
|
|
case .idReferences:
|
|
for token in value.split(whereSeparator: { $0.isWhitespace }) {
|
|
references.append((String(token), name))
|
|
}
|
|
case .cdata, .enumeration:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|