PureXML/Sources/Schema/ComplexValidator.swift
Mihaela Mihaljevic 8ae3c79f42 feat(schema): XSD schema-document parser and end-to-end validation
Add Schema.Document compiling an xs:schema into global element declarations
and a named-type table, and validate(_:) checking instance documents end to
end. The parser supports global and local element declarations, named and
inline simple and complex types, sequence/choice/all groups with occurrence,
attribute uses, simple content (restriction/extension), the full facet set,
and recursive types via named references resolved during validation. Extends
ComplexType with a typeReference case and ComplexValidator with a type table
so recursive schemas are representable.

Closes #10
2026-06-08 06:40:03 +02:00

211 lines
8.9 KiB
Swift

public extension PureXML.Schema {
/// Validates an element against a ``ComplexType``: its attribute uses, its
/// content model, and, recursively, each child element against the type
/// declared for its name.
struct ComplexValidator {
/// The named type table that `ElementType.typeReference` resolves against.
private let types: [String: ElementType]
public init(types: [String: ElementType] = [:]) {
self.types = types
}
/// Validates `element` against `type`, returning one issue per violation.
public func validate(
_ element: PureXML.Model.Element,
against type: ComplexType,
) -> [PureXML.Validation.Issue] {
var issues: [PureXML.Validation.Issue] = []
validateAttributes(element, type, into: &issues)
validateContent(element, type.content, into: &issues)
return issues
}
/// Validates `element` against any element type, resolving a
/// `typeReference` through the type table.
public func validate(
_ element: PureXML.Model.Element,
as type: ElementType,
) -> [PureXML.Validation.Issue] {
var issues: [PureXML.Validation.Issue] = []
validateChild(element, against: type, into: &issues)
return issues
}
// MARK: Attributes
private func validateAttributes(
_ element: PureXML.Model.Element,
_ type: ComplexType,
into issues: inout [PureXML.Validation.Issue],
) {
let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) }
for use in type.attributes {
let match = present.first { $0.name.localName == use.name.localName }
if let match {
if let error = use.type.validate(match.value) {
issues.append(.init(severity: .error, message: "attribute '\(use.name.localName)': \(error)"))
}
} else if use.required {
issues.append(.init(severity: .error, message: "missing required attribute '\(use.name.localName)'"))
}
}
guard !type.allowsOtherAttributes else { return }
for attribute in present where !type.attributes.contains(where: { $0.name.localName == attribute.name.localName }) {
issues.append(.init(severity: .error, message: "undeclared attribute '\(attribute.name.localName)'"))
}
}
// MARK: Content
private func validateContent(
_ element: PureXML.Model.Element,
_ content: ContentType,
into issues: inout [PureXML.Validation.Issue],
) {
let children = element.children.compactMap(\.element)
switch content {
case .empty:
if !children.isEmpty { issues.append(.init(severity: .error, message: "element must be empty")) }
rejectText(element, allowed: false, into: &issues)
case let .simpleContent(type):
if !children.isEmpty { issues.append(.init(severity: .error, message: "element must not have children")) }
if let error = type.validate(Self.textContent(element)) {
issues.append(.init(severity: .error, message: "content: \(error)"))
}
case let .elementOnly(particle):
rejectText(element, allowed: false, into: &issues)
validateParticle(particle, children: children, into: &issues)
case let .mixed(particle):
validateParticle(particle, children: children, into: &issues)
}
}
private func rejectText(
_ element: PureXML.Model.Element,
allowed: Bool,
into issues: inout [PureXML.Validation.Issue],
) {
guard !allowed, !Self.textContent(element).isEmpty else { return }
issues.append(.init(severity: .error, message: "element must not contain text"))
}
// MARK: Particles
private func validateParticle(
_ particle: Particle,
children: [PureXML.Model.Element],
into issues: inout [PureXML.Validation.Issue],
) {
let names = children.map(\.name)
let structureValid: Bool = if case let .group(group) = particle.term, group.compositor == .all {
Self.matchesAll(group, names: names)
} else {
ContentNFABuilder.build(particle).matchesWhole(names)
}
if !structureValid {
issues.append(.init(severity: .error, message: "content does not match the content model"))
return
}
validateChildren(children, childTypes: Self.elementTypes(in: particle.term), into: &issues)
}
private func validateChildren(
_ children: [PureXML.Model.Element],
childTypes: [String: ElementType],
into issues: inout [PureXML.Validation.Issue],
) {
for child in children {
guard let declared = childTypes[Self.key(child.name)] else { continue }
validateChild(child, against: declared, into: &issues)
}
}
private func validateChild(
_ child: PureXML.Model.Element,
against declared: ElementType,
into issues: inout [PureXML.Validation.Issue],
) {
switch declared {
case let .simple(simple):
if !child.children.compactMap(\.element).isEmpty {
issues.append(.init(severity: .error, message: "'\(child.name.localName)' must not have children"))
}
if let error = simple.validate(Self.textContent(child)) {
issues.append(.init(severity: .error, message: "'\(child.name.localName)': \(error)"))
}
case let .complex(complex):
issues.append(contentsOf: validate(child, against: complex))
case let .typeReference(name):
guard let resolved = types[name] else {
issues.append(.init(severity: .error, message: "unknown type '\(name)'"))
return
}
validateChild(child, against: resolved, into: &issues)
}
}
// MARK: Helpers
/// Validates an `all` group order-independently: every child matches a
/// member within its occurrence bounds, and each member meets its minimum.
private static func matchesAll(_ group: Group, names: [PureXML.Model.QualifiedName]) -> Bool {
var counts = [Int](repeating: 0, count: group.particles.count)
for name in names {
guard let index = group.particles.indices.first(where: { position in
let member = group.particles[position]
let room = member.maxOccurs.map { counts[position] < $0 } ?? true
return room && label(of: member.term).matches(name)
}) else { return false }
counts[index] += 1
}
for (index, member) in group.particles.enumerated() where counts[index] < member.minOccurs {
return false
}
return true
}
private static func label(of term: Term) -> TermLabel {
if case let .element(name, _) = term { return .name(name) }
return .any
}
private static func elementTypes(in term: Term) -> [String: ElementType] {
var result: [String: ElementType] = [:]
collectTypes(term, into: &result)
return result
}
private static func collectTypes(_ term: Term, into result: inout [String: ElementType]) {
switch term {
case let .element(name, type):
if let type { result[key(name)] = type }
case let .group(group):
for member in group.particles {
collectTypes(member.term, into: &result)
}
case .wildcard:
break
}
}
private static func key(_ name: PureXML.Model.QualifiedName) -> String {
"{\(name.namespaceURI ?? "")}\(name.localName)"
}
private static func textContent(_ element: PureXML.Model.Element) -> String {
let text = element.children.reduce(into: "") { result, child in
switch child {
case let .text(value), let .cdata(value): result += value
default: break
}
}
return text.trimmingXMLWhitespace()
}
private static func isNamespaceDeclaration(_ attribute: PureXML.Model.Attribute) -> Bool {
attribute.name.prefix == "xmlns" || (attribute.name.prefix == nil && attribute.name.localName == "xmlns")
}
}
}