Merge DTD validation framework slice 3 (#92): located DTD results

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 12:36:57 +02:00
commit 4bcd7e2787
7 changed files with 223 additions and 158 deletions

View File

@ -83,10 +83,10 @@ public extension PureXML {
limits: Parsing.Limits = .init(allowDoctype: true),
strict: Bool = false,
resolver: Parsing.EntityResolver = .refusing,
) throws -> [Validation.Issue] {
) throws -> [Validation.ValidationError] {
let parsed = try Parsing.Parser().parseWithDocumentType(xml, limits: limits, resolver: resolver)
let schema = Validation.DTDSchema(parsed.documentType)
return schema.validate(parsed.node, strict: strict)
return Validation.DTD.validator(strict: strict).errors(for: parsed.node, in: schema)
}
/// Compiles and evaluates an XPath query over a node, returning the selected

View File

@ -24,158 +24,5 @@ public extension PureXML.Validation {
public var isEmpty: Bool {
models.isEmpty && attributes.isEmpty
}
/// Validates a node tree against the declared content models and attribute
/// rules. Element structure and attribute presence/value are checked in one
/// walk; ID uniqueness and IDREF resolution are checked afterward, since a
/// reference may point forward to an ID later in the document. In strict
/// mode an element with no declaration is itself an error.
public func validate(_ node: PureXML.Model.Node, strict: Bool = false) -> [Issue] {
var state = DTDState()
walk(node, strict: strict, into: &state)
for (value, count) in state.idCounts where count > 1 {
state.issues.append(error("duplicate ID '\(value)' (declared \(count) times)"))
}
for reference in state.references where state.idCounts[reference.value] == nil {
state.issues.append(error("IDREF '\(reference.value)' on <\(reference.element)> matches no ID"))
}
return state.issues
}
private func walk(_ node: PureXML.Model.Node, strict: Bool, into state: inout DTDState) {
switch node {
case let .document(children):
for child in children {
walk(child, strict: strict, into: &state)
}
case let .element(element):
validateElement(element, strict: strict, into: &state)
for child in element.children {
walk(child, strict: strict, into: &state)
}
case .text, .cdata, .comment, .processingInstruction:
break
}
}
private func validateElement(_ element: PureXML.Model.Element, strict: Bool, into state: inout DTDState) {
let name = element.name.description
if let model = models[name] {
let content = childContent(of: element)
state.issues.append(contentsOf: violations(name: name, model: model, content: content))
} else if strict {
state.issues.append(error("element <\(name)> is not declared in the DTD"))
}
guard let declarations = attributes[name] else { return }
for declaration in declarations {
let value = element.attributes.first {
$0.name.description == declaration.name || $0.name.localName == declaration.name
}?.value
state.issues.append(contentsOf: checkAttribute(declaration, value: value, on: name))
if let value {
collectIdentifiers(declaration, value: value, element: name, into: &state)
}
}
}
private func collectIdentifiers(
_ declaration: AttributeDeclaration,
value: String,
element: String,
into state: inout DTDState,
) {
switch declaration.type {
case .id:
state.idCounts[value, default: 0] += 1
case .idReference:
state.references.append((value, element))
case .idReferences:
for token in value.split(whereSeparator: { $0.isWhitespace }) {
state.references.append((String(token), element))
}
case .cdata, .enumeration:
break
}
}
private func checkAttribute(_ declaration: AttributeDeclaration, value: String?, on element: String) -> [Issue] {
guard let value else {
return declaration.defaultDecl == .required
? [error("required attribute '\(declaration.name)' is missing on <\(element)>")]
: []
}
var result: [Issue] = []
if case let .fixed(fixedValue) = declaration.defaultDecl, value != fixedValue {
result.append(error("attribute '\(declaration.name)' on <\(element)> is #FIXED and must be \"\(fixedValue)\""))
}
if case let .enumeration(allowed) = declaration.type, !allowed.contains(value) {
result.append(error("attribute '\(declaration.name)' on <\(element)> has a value outside its enumeration"))
}
return result
}
private func childContent(of element: PureXML.Model.Element) -> (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)
}
private func violations(name: String, model: ContentModel, content: (names: [String], hasText: Bool)) -> [Issue] {
switch model {
case .empty:
content.names.isEmpty && !content.hasText
? []
: [error("element <\(name)> is declared EMPTY but has content")]
case .any:
[]
case .pcdata:
content.names.isEmpty
? []
: [error("element <\(name)> is declared (#PCDATA) but has child elements")]
case let .mixed(allowed):
content.names
.filter { !allowed.contains($0) }
.map { error("element <\($0)> is not allowed in the mixed content of <\(name)>") }
case let .children(particle):
childrenViolations(name: name, particle: particle, content: content)
}
}
private func childrenViolations(
name: String,
particle: Particle,
content: (names: [String], hasText: Bool),
) -> [Issue] {
var result: [Issue] = []
if content.hasText {
result.append(error("element <\(name)> has element content but contains character data"))
}
if !ContentModelMatcher.matchesChildren(particle, content.names) {
result.append(error("the children of <\(name)> do not match its content model"))
}
return result
}
private func error(_ message: String) -> Issue {
Issue(severity: .error, message: message)
}
}
}
/// Mutable state carried through one DTD validation walk: the issues found, the
/// count of each declared ID, and the IDREF references to resolve afterward.
private struct DTDState {
var issues: [PureXML.Validation.Issue] = []
var idCounts: [String: Int] = [:]
var references: [(value: String, element: String)] = []
}

View File

@ -0,0 +1,149 @@
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
}
}
}
}

View File

@ -0,0 +1,61 @@
/// File-scope aliases for the DTD validation rules and helpers, kept out of the
/// `DTD` enum to avoid nesting a type two levels deep.
typealias DTDElement = PureXML.Model.Element
typealias DTDPath = [PureXML.Validation.PathKey]
typealias DTDFailure = PureXML.Validation.ValidationError
public extension PureXML.Validation {
/// DTD validation as composable rules over a ``DTDSchema`` document: every
/// element is checked against its content model and attribute declarations,
/// and ID/IDREF integrity is checked once over the whole tree. Replaces the
/// imperative walk with declared ``Validation`` values.
enum DTD {
/// A validator for a ``DTDSchema``. In strict mode every element must also
/// be declared.
static func validator(strict: Bool) -> Validator<DTDSchema> {
var validator = Validator<DTDSchema>.blank
.validating(contentModel, attributeDeclarations)
.validating(identifierIntegrity)
if strict { validator = validator.validating(undeclaredElement) }
return validator
}
/// Each element's content matches its `<!ELEMENT>` model.
static var contentModel: Validation<DTDElement, DTDSchema> {
.init(description: "Element content matches its DTD content model") { context in
let element = context.subject
guard let model = context.document.models[element.name.description] else { return [] }
return contentViolations(element, model: model, at: context.codingPath)
}
}
/// Each element's attributes satisfy their `<!ATTLIST>` declarations
/// (required presence, `#FIXED` value, and enumerations).
static var attributeDeclarations: Validation<DTDElement, DTDSchema> {
.init(description: "Element attributes satisfy their DTD declarations") { context in
let element = context.subject
guard let declarations = context.document.attributes[element.name.description] else { return [] }
return declarations.flatMap { declaration in
attributeViolations(declaration, on: element, at: context.codingPath)
}
}
}
/// In strict mode, every element is declared in the DTD.
static var undeclaredElement: Validation<DTDElement, DTDSchema> {
.init(description: "Element is declared in the DTD") { context in
context.document.models[context.subject.name.description] != nil
}
}
/// ID values are unique and every IDREF resolves. Runs once over the whole
/// tree (a reference may point forward), so it is gated to the root.
static var identifierIntegrity: Validation<PureXML.Model.Node, DTDSchema> {
.init(
description: "DTD ID values are unique and IDREFs resolve",
check: { context in identifierErrors(context.subject, schema: context.document, at: context.codingPath) },
when: { $0.codingPath.isEmpty },
)
}
}
}

View File

@ -3,7 +3,7 @@ import Testing
@Suite("DTD attribute validation")
struct DTDAttributeTests {
private func issues(_ xml: String, strict: Bool = false) throws -> [PureXML.Validation.Issue] {
private func issues(_ xml: String, strict: Bool = false) throws -> [PureXML.Validation.ValidationError] {
try PureXML.validateAgainstInternalDTD(xml, strict: strict)
}

View File

@ -3,7 +3,7 @@ import Testing
@Suite("DTD ID and IDREF validation")
struct DTDIdentifierTests {
private func issues(_ xml: String) throws -> [PureXML.Validation.Issue] {
private func issues(_ xml: String) throws -> [PureXML.Validation.ValidationError] {
try PureXML.validateAgainstInternalDTD(xml)
}

View File

@ -3,7 +3,7 @@ import Testing
@Suite("DTD validation")
struct DTDValidationTests {
private func issues(_ xml: String, strict: Bool = false) throws -> [PureXML.Validation.Issue] {
private func issues(_ xml: String, strict: Bool = false) throws -> [PureXML.Validation.ValidationError] {
try PureXML.validateAgainstInternalDTD(xml, strict: strict)
}
@ -71,4 +71,12 @@ struct DTDValidationTests {
func test_noDTD() throws {
try #expect(issues("<r><a/></r>").isEmpty)
}
@Test("A content-model violation is located by coding path")
func test_violationCarriesPath() throws {
let dtd = "<!DOCTYPE r [<!ELEMENT r (a)><!ELEMENT a EMPTY>]>"
let found = try issues("\(dtd)<r><a>oops</a></r>")
#expect(found.count == 1)
#expect(String(describing: found[0]) == "element <a> is declared EMPTY but has content at path: r/a")
}
}