Merge DTD validation framework slice 3 (#92): located DTD results
This commit is contained in:
commit
4bcd7e2787
@ -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
|
||||
|
||||
@ -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)] = []
|
||||
}
|
||||
|
||||
149
Sources/Validation/DTDValidationHelpers.swift
Normal file
149
Sources/Validation/DTDValidationHelpers.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Sources/Validation/DTDValidations.swift
Normal file
61
Sources/Validation/DTDValidations.swift
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user