PureXML/Sources/Schema/ComplexValidator.swift
Mihaela Mihaljevic 4765af0624 refactor(validation): migrate XSD validation onto the framework
Slice 4 of #92. The XSD complex-type and identity-constraint validators move
onto the validation framework, producing located results.

- ComplexValidator threads a coding path through its (inherently recursive)
  descent, so every content-model, attribute, and simple-type violation is now
  a ValidationError located by path ("... at path: order/qty"). Child steps
  carry a sibling index only when a name repeats.
- IdentityValidator returns ValidationError instead of Issue.
- A new XSD rules enum wraps both as Validation<Node, XSDContext> values gated to
  the document root, combined by Validator<XSDContext>. XSDContext carries the
  type table, the identity constraints, and the root declaration.
- Schema.Document.validate builds the context and runs the validator, returning
  [ValidationError].

XSD content-model validation is recursive by nature (a child's type depends on
its position), so the rules delegate to the recursion while contributing to one
error collection. Schematron is the last validator on Issue and migrates next.
2026-06-08 12:43:20 +02:00

263 lines
12 KiB
Swift

/// File-scope aliases for the XSD complex-type validator, kept out of the
/// namespace to avoid nesting a type two levels deep.
private typealias XSDFailure = PureXML.Validation.ValidationError
private typealias XSDPath = [PureXML.Validation.PathKey]
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. Every violation is located by the element's coding
/// path, and the results are ``PureXML/Validation/ValidationError`` values.
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` at `path`, one error per violation.
public func validate(
_ element: PureXML.Model.Element,
against type: ComplexType,
at path: [PureXML.Validation.PathKey] = [],
) -> [PureXML.Validation.ValidationError] {
var errors: [XSDFailure] = []
validateAttributes(element, type, at: path, into: &errors)
validateContent(element, type.content, at: path, into: &errors)
return errors
}
/// Validates `element` against any element type at `path`, resolving a
/// `typeReference` through the type table.
public func validate(
_ element: PureXML.Model.Element,
as type: ElementType,
at path: [PureXML.Validation.PathKey] = [],
) -> [PureXML.Validation.ValidationError] {
var errors: [XSDFailure] = []
validateChild(element, against: type, at: path, into: &errors)
return errors
}
// MARK: Attributes
private func validateAttributes(
_ element: PureXML.Model.Element,
_ type: ComplexType,
at path: XSDPath,
into errors: inout [XSDFailure],
) {
let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) && !Self.isSchemaInstanceAttribute($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) {
errors.append(XSDFailure(reason: "attribute '\(use.name.localName)': \(error)", at: path))
}
} else if use.required {
errors.append(XSDFailure(reason: "missing required attribute '\(use.name.localName)'", at: path))
}
}
guard !type.allowsOtherAttributes else { return }
for attribute in present where !type.attributes.contains(where: { $0.name.localName == attribute.name.localName }) {
errors.append(XSDFailure(reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
}
}
// MARK: Content
private func validateContent(
_ element: PureXML.Model.Element,
_ content: ContentType,
at path: XSDPath,
into errors: inout [XSDFailure],
) {
let children = element.children.compactMap(\.element)
switch content {
case .empty:
if !children.isEmpty { errors.append(XSDFailure(reason: "element must be empty", at: path)) }
rejectText(element, at: path, into: &errors)
case let .simpleContent(type):
if !children.isEmpty { errors.append(XSDFailure(reason: "element must not have children", at: path)) }
if let error = type.validate(Self.textContent(element)) {
errors.append(XSDFailure(reason: "content: \(error)", at: path))
}
case let .elementOnly(particle):
rejectText(element, at: path, into: &errors)
validateParticle(particle, children: children, at: path, into: &errors)
case let .mixed(particle):
validateParticle(particle, children: children, at: path, into: &errors)
}
}
private func rejectText(_ element: PureXML.Model.Element, at path: XSDPath, into errors: inout [XSDFailure]) {
guard !Self.textContent(element).isEmpty else { return }
errors.append(XSDFailure(reason: "element must not contain text", at: path))
}
// MARK: Particles
private func validateParticle(
_ particle: Particle,
children: [PureXML.Model.Element],
at path: XSDPath,
into errors: inout [XSDFailure],
) {
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 {
errors.append(XSDFailure(reason: "content does not match the content model", at: path))
return
}
validateChildren(children, childTypes: Self.elementTypes(in: particle.term), at: path, into: &errors)
}
private func validateChildren(
_ children: [PureXML.Model.Element],
childTypes: [String: ElementType],
at path: XSDPath,
into errors: inout [XSDFailure],
) {
let steps = Self.childSteps(children)
for (child, step) in zip(children, steps) {
guard let declared = childTypes[Self.key(child.name)] else { continue }
validateChild(child, against: declared, at: path + [step], into: &errors)
}
}
private func validateChild(
_ child: PureXML.Model.Element,
against declared: ElementType,
at path: XSDPath,
into errors: inout [XSDFailure],
) {
// An instance `xsi:type` overrides the declared type, provided the named
// type exists in the schema. Resolve to the type itself rather than a
// reference so re-entry does not re-read the same xsi:type.
var declared = declared
if let overriding = Self.xsiTypeName(child), let resolved = types[overriding] {
declared = resolved
}
switch declared {
case let .simple(simple):
if !child.children.compactMap(\.element).isEmpty {
errors.append(XSDFailure(reason: "'\(child.name.localName)' must not have children", at: path))
}
if let error = simple.validate(Self.textContent(child)) {
errors.append(XSDFailure(reason: "'\(child.name.localName)': \(error)", at: path))
}
case let .complex(complex):
errors.append(contentsOf: validate(child, against: complex, at: path))
case let .typeReference(name):
guard let resolved = types[name] else {
errors.append(XSDFailure(reason: "unknown type '\(name)'", at: path))
return
}
validateChild(child, against: resolved, at: path, into: &errors)
}
}
// MARK: Helpers
/// The coding-path step for each child: its name, with a sibling index only
/// when more than one child shares that name.
private static func childSteps(_ children: [PureXML.Model.Element]) -> [PureXML.Validation.PathKey] {
var totals: [String: Int] = [:]
for child in children {
totals[child.name.description, default: 0] += 1
}
var seen: [String: Int] = [:]
return children.map { child in
let name = child.name.description
let index = (seen[name] ?? 0) + 1
seen[name] = index
return (totals[name] ?? 0) > 1 ? .element(name, index: index) : .element(name)
}
}
/// 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")
}
/// Whether an attribute belongs to the XML Schema instance namespace
/// (`xsi:type`, `xsi:nil`, and the schema-location hints), which are
/// processing directives rather than declared attributes.
private static func isSchemaInstanceAttribute(_ attribute: PureXML.Model.Attribute) -> Bool {
attribute.name.namespaceURI == "http://www.w3.org/2001/XMLSchema-instance" || attribute.name.prefix == "xsi"
}
/// The local name of an element's `xsi:type` attribute value, or nil when it
/// carries none. Recognizes the attribute by the XML Schema instance
/// namespace or, failing namespace resolution, the conventional `xsi` prefix.
private static func xsiTypeName(_ element: PureXML.Model.Element) -> String? {
let schemaInstance = "http://www.w3.org/2001/XMLSchema-instance"
let match = element.attributes.first { attribute in
attribute.name.localName == "type"
&& (attribute.name.namespaceURI == schemaInstance || attribute.name.prefix == "xsi")
}
return match.map { $0.value.split(separator: ":").last.map(String.init) ?? $0.value }
}
}
}