Tokenized (non-CDATA) attribute values normalize before validity checks, so enumerations, NOTATION lists, and the ID/IDREF table see collapsed values. External parsed entities lose their leading text declaration (validated: version optional, encoding required, never standalone) instead of carrying it into replacement text. Character references in PE literals expand at declaration time, making the Appendix D '%zz;' example parse. A PI target inside the DTD subset must be separated from its data. Sun baselines tighten to not-wf 55/56 and valid 26/28; the two remaining valid failures share one cause (replacement text with markup is included as character data, filed separately).
237 lines
11 KiB
Swift
237 lines
11 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))
|
|
}
|
|
guard !PureXML.Validation.ContentModelMatcher.matchesChildren(particle, content.names) else { return result }
|
|
// Name each child outside the model's alphabet; if every child is in the
|
|
// alphabet the fault is order or count, so report it once with the allowed
|
|
// elements as a recovery hint.
|
|
let allowed = PureXML.Validation.ContentModelMatcher.allowedNames(particle)
|
|
let stray = content.names.filter { !allowed.contains($0) }
|
|
if stray.isEmpty {
|
|
let hint = allowed.sorted().map { "<\($0)>" }.joined(separator: ", ")
|
|
result.append(DTDFailure(reason: "the children of <\(name)> do not match its content model; allowed: \(hint)", at: path))
|
|
} else {
|
|
for child in orderedUnique(stray) {
|
|
result.append(DTDFailure(reason: "element <\(child)> is not allowed in <\(name)>", at: path))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// The distinct values of `names`, in first-seen order.
|
|
private static func orderedUnique(_ names: [String]) -> [String] {
|
|
var seen: Set<String> = []
|
|
return names.filter { seen.insert($0).inserted }
|
|
}
|
|
|
|
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
|
|
|
|
/// The value supplied for a declaration's attribute on an element, or nil
|
|
/// when it is absent, normalized per its declared type: for any non-CDATA
|
|
/// type, whitespace runs collapse to a single space and leading/trailing
|
|
/// whitespace is stripped (3.3.3), so ` nonce ` satisfies an enumeration
|
|
/// listing `nonce` and an IDREF resolves regardless of surrounding space.
|
|
static func attributeValue(of declaration: PureXML.Validation.AttributeDeclaration, on element: DTDElement) -> String? {
|
|
let raw = element.attributes.first {
|
|
$0.name.description == declaration.name || $0.name.localName == declaration.name
|
|
}?.value
|
|
return raw.map { normalize($0, for: declaration.type) }
|
|
}
|
|
|
|
/// The 3.3.3 tokenized normalization. CDATA values pass through untouched.
|
|
static func normalize(_ value: String, for type: PureXML.Validation.AttributeType) -> String {
|
|
if case .cdata = type { return value }
|
|
return value.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
|
}
|
|
|
|
/// The error when a `#REQUIRED` attribute is absent.
|
|
static func requiredViolation(_ declaration: PureXML.Validation.AttributeDeclaration, on element: DTDElement, at path: DTDPath) -> DTDFailure? {
|
|
guard declaration.defaultDecl == .required, attributeValue(of: declaration, on: element) == nil else { return nil }
|
|
return DTDFailure(reason: "required attribute '\(declaration.name)' is missing on <\(element.name.description)>", at: path)
|
|
}
|
|
|
|
/// The error when a `#FIXED` attribute is present with a value other than the
|
|
/// fixed one.
|
|
static func fixedViolation(_ declaration: PureXML.Validation.AttributeDeclaration, on element: DTDElement, at path: DTDPath) -> DTDFailure? {
|
|
guard case let .fixed(fixedValue) = declaration.defaultDecl,
|
|
let value = attributeValue(of: declaration, on: element), value != fixedValue else { return nil }
|
|
return DTDFailure(reason: "attribute '\(declaration.name)' on <\(element.name.description)> is #FIXED and must be \"\(fixedValue)\"", at: path)
|
|
}
|
|
|
|
/// The error when an enumerated attribute is present with a value outside its
|
|
/// list.
|
|
static func enumerationViolation(_ declaration: PureXML.Validation.AttributeDeclaration, on element: DTDElement, at path: DTDPath) -> DTDFailure? {
|
|
guard case let .enumeration(allowed) = declaration.type,
|
|
let value = attributeValue(of: declaration, on: element), !allowed.contains(value) else { return nil }
|
|
return DTDFailure(reason: "attribute '\(declaration.name)' on <\(element.name.description)> has a value outside its enumeration", at: path)
|
|
}
|
|
|
|
/// The error when a `NOTATION` attribute value is not one of the names its
|
|
/// declaration lists, or names a notation that is not declared with
|
|
/// `<!NOTATION>`. Returns nil for any other attribute type.
|
|
static func notationError(
|
|
_ declaration: PureXML.Validation.AttributeDeclaration,
|
|
value: String,
|
|
on element: String,
|
|
notations: Set<String>,
|
|
at path: DTDPath,
|
|
) -> DTDFailure? {
|
|
guard case let .notation(allowed) = declaration.type else { return nil }
|
|
if !allowed.contains(value) {
|
|
return DTDFailure(reason: "attribute '\(declaration.name)' on <\(element)> has a value outside its NOTATION list", at: path)
|
|
}
|
|
if !notations.contains(value) {
|
|
return DTDFailure(reason: "attribute '\(declaration.name)' on <\(element)> names undeclared notation '\(value)'", at: path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// The error when a tokenized attribute value does not match its declared
|
|
/// lexical form: `NMTOKEN(S)` must be name token(s), and `ENTITY`/`ENTITIES`
|
|
/// must name declared unparsed (`NDATA`) entities. The ID family is checked
|
|
/// separately for cross-reference integrity.
|
|
static func tokenizedTypeError(
|
|
_ declaration: PureXML.Validation.AttributeDeclaration,
|
|
value: String,
|
|
on element: String,
|
|
entities: Set<String>,
|
|
at path: DTDPath,
|
|
) -> DTDFailure? {
|
|
let isNmtoken = PureXML.Parsing.XMLCharacter.isNmtoken
|
|
let isEntity = { (token: String) in PureXML.Parsing.XMLCharacter.isValidName(token) && entities.contains(token) }
|
|
let tokens = value.split(whereSeparator: \.isWhitespace).map(String.init)
|
|
let valid: Bool
|
|
switch declaration.type {
|
|
case .nmToken: valid = isNmtoken(value)
|
|
case .nmTokens: valid = !tokens.isEmpty && tokens.allSatisfy(isNmtoken)
|
|
case .entity: valid = isEntity(value)
|
|
case .entities: valid = !tokens.isEmpty && tokens.allSatisfy(isEntity)
|
|
case .cdata, .enumeration, .notation, .id, .idReference, .idReferences: return nil
|
|
}
|
|
guard !valid else { return nil }
|
|
return DTDFailure(reason: "attribute '\(declaration.name)' on <\(element)> is not a valid \(typeName(declaration.type))", at: path)
|
|
}
|
|
|
|
private static func typeName(_ type: PureXML.Validation.AttributeType) -> String {
|
|
switch type {
|
|
case .nmToken: "NMTOKEN"
|
|
case .nmTokens: "NMTOKENS"
|
|
case .entity: "ENTITY"
|
|
case .entities: "ENTITIES"
|
|
default: "value"
|
|
}
|
|
}
|
|
|
|
// 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 = attributeValue(of: declaration, on: element) 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, .notation, .nmToken, .nmTokens, .entity, .entities:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|