diff --git a/Sources/API.swift b/Sources/API.swift index a792396..00ea966 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -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 diff --git a/Sources/Validation/DTDSchema.swift b/Sources/Validation/DTDSchema.swift index 8f37f3c..41f3740 100644 --- a/Sources/Validation/DTDSchema.swift +++ b/Sources/Validation/DTDSchema.swift @@ -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)] = [] -} diff --git a/Sources/Validation/DTDValidationHelpers.swift b/Sources/Validation/DTDValidationHelpers.swift new file mode 100644 index 0000000..99a288e --- /dev/null +++ b/Sources/Validation/DTDValidationHelpers.swift @@ -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 + } + } + } +} diff --git a/Sources/Validation/DTDValidations.swift b/Sources/Validation/DTDValidations.swift new file mode 100644 index 0000000..23d9f6b --- /dev/null +++ b/Sources/Validation/DTDValidations.swift @@ -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 { + var validator = Validator.blank + .validating(contentModel, attributeDeclarations) + .validating(identifierIntegrity) + if strict { validator = validator.validating(undeclaredElement) } + return validator + } + + /// Each element's content matches its `` model. + static var contentModel: Validation { + .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 `` declarations + /// (required presence, `#FIXED` value, and enumerations). + static var attributeDeclarations: Validation { + .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 { + .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 { + .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 }, + ) + } + } +} diff --git a/Tests/DTDAttributeTests.swift b/Tests/DTDAttributeTests.swift index f2979c5..d1055a4 100644 --- a/Tests/DTDAttributeTests.swift +++ b/Tests/DTDAttributeTests.swift @@ -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) } diff --git a/Tests/DTDIdentifierTests.swift b/Tests/DTDIdentifierTests.swift index 227189b..13cb9c8 100644 --- a/Tests/DTDIdentifierTests.swift +++ b/Tests/DTDIdentifierTests.swift @@ -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) } diff --git a/Tests/DTDValidationTests.swift b/Tests/DTDValidationTests.swift index 72f36d5..8e2a042 100644 --- a/Tests/DTDValidationTests.swift +++ b/Tests/DTDValidationTests.swift @@ -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("").isEmpty) } + + @Test("A content-model violation is located by coding path") + func test_violationCarriesPath() throws { + let dtd = "]>" + let found = try issues("\(dtd)oops") + #expect(found.count == 1) + #expect(String(describing: found[0]) == "element is declared EMPTY but has content at path: r/a") + } }