PureXML/Sources/Schema/ComplexValidatorStreaming.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

287 lines
14 KiB
Swift

public extension PureXML.Schema {
/// An element paired with its already-resolved effective type: the subject of
/// the streaming XSD content rule, so the contextual type resolution that a
/// node-tree walk cannot express is carried on the subject instead.
struct ResolvedElement: PureXML.Validation.Validatable {
public let element: PureXML.Model.Element
public let type: ElementType
/// The matched particle's `default`/`fixed` value constraint, when the element
/// is a content-model child, so an empty element validates that value.
public let valueConstraint: ValueConstraint?
public init(element: PureXML.Model.Element, type: ElementType, valueConstraint: ValueConstraint? = nil) {
self.element = element
self.type = type
self.valueConstraint = valueConstraint
}
}
}
extension PureXML.Schema.ComplexValidator: PureXML.Validation.Validatable {}
public extension PureXML.Schema.ComplexValidator {
// MARK: Streaming support
/// The streaming XSD content check as a composable ``PureXML/Validation`` value
/// (the OpenAPIKit idiom): an element is valid against its resolved type. The
/// validator is the document, the cross-cutting context that holds the type
/// tables, so the streaming path is rule-driven like every other validator
/// rather than a bare method call.
static var shallowValidity: PureXML.Validation.Validation<PureXML.Schema.ResolvedElement, PureXML.Schema.ComplexValidator> {
.init(description: "Each streamed element is valid against its declared XSD type") { context in
context.document.validateShallow(
context.subject.element,
as: context.subject.type,
valueConstraint: context.subject.valueConstraint,
at: context.codingPath,
)
}
}
/// The streaming path's document-scoped ID/IDREF check as a composable
/// ``PureXML/Validation`` value, reusing ``idErrors()`` (the same accumulator
/// the tree path's `idIdrefConsistent` rule reads) rather than the streaming
/// driver calling it imperatively outside the framework. The validator is its
/// own subject: by the time this runs (after the whole document has streamed
/// through ``shallowValidity``), its `idTracker` already holds every recorded
/// ID/IDREF.
static var idConsistency: PureXML.Validation.Validation<PureXML.Schema.ComplexValidator, Void> {
.init(description: "Every streamed xs:ID is unique and every streamed xs:IDREF resolves to one") { context in
context.subject.idErrors()
}
}
/// The matched particle's `default`/`fixed` value constraint for a child of
/// `parent`'s content model, keyed by namespaced identity (so same-local-name
/// elements in different types do not collide as in the flat `elementConstraints`).
func childValueConstraint(of parent: PureXML.Schema.ElementType, child name: PureXML.Model.QualifiedName) -> PureXML.Schema.ValueConstraint? {
guard case let .complex(complex) = parent else { return nil }
switch complex.content {
case let .elementOnly(particle), let .mixed(particle):
return Self.elementValueConstraints(in: particle.term)[Self.key(name)]
case .empty, .simpleContent:
return nil
}
}
/// The effective element type after an `xsi:type` override and following any
/// `typeReference` chain, so the streaming driver resolves a type once per
/// element rather than re-entering the tree walk. The `xsi:type` is resolved
/// through its in-scope prefix bindings to a namespaced type key (as the tree
/// path does), so two imported types sharing a local name do not collide.
func effectiveType(
_ declared: PureXML.Schema.ElementType,
of element: PureXML.Model.Element,
namespaceBindings: [String: String] = [:],
) -> PureXML.Schema.ElementType {
var type = declared
if let resolved = resolvedXsiType(element, namespaceBindings: namespaceBindings) {
type = resolved
}
// An unknown or circular chain stays a typeReference, which
// validateShallow reports as a located error.
if case let .resolved(resolved) = resolveReference(type) {
type = resolved
}
return type
}
/// The type an element's `xsi:type` resolves to through its in-scope prefix
/// bindings, or nil when the named type is undeclared. Used by the streaming
/// driver to report an unknown `xsi:type` the way the tree path does.
func resolvedXsiType(_ element: PureXML.Model.Element, namespaceBindings: [String: String]) -> PureXML.Schema.ElementType? {
guard let reference = Self.xsiTypeReference(element, namespaceBindings: namespaceBindings) ?? Self.xsiTypeName(element) else {
return nil
}
return Self.resolveNamedType(reference, in: types)
}
/// The declared element type of a child `name` in `parent`'s content model: a
/// named particle member, or a global declaration reached through a matching
/// wildcard's `processContents`. Returns nil when the parent has no element
/// content, when `processContents="skip"`, or when the child is not declared
/// and no wildcard admits it (the parent's structure check reports that).
func childType(of parent: PureXML.Schema.ElementType, child name: PureXML.Model.QualifiedName) -> PureXML.Schema.ElementType? {
guard case let .complex(complex) = parent else { return nil }
switch complex.content {
case let .elementOnly(particle), let .mixed(particle):
if let type = Self.elementTypes(in: particle.term)[Self.key(name)] {
return type
}
return wildcardChildType(for: name, in: particle.term)
case .empty, .simpleContent:
return nil
}
}
/// The error when a strict wildcard admits a child but no global declaration
/// exists for it, matching the tree validator's ``validateWildcardChild``.
func strictWildcardError(
for child: PureXML.Model.QualifiedName,
in parent: PureXML.Schema.ElementType,
at path: [PureXML.Validation.PathKey],
) -> PureXML.Validation.ValidationError? {
guard case let .complex(complex) = parent else { return nil }
switch complex.content {
case let .elementOnly(particle), let .mixed(particle):
if Self.elementTypes(in: particle.term)[Self.key(child)] != nil {
return nil
}
guard Self.wildcardMatch(for: child, in: particle.term) == .strict,
types[PureXML.Schema.XSDParser.elementDeclarationKey(child)] == nil
else { return nil }
return PureXML.Validation.ValidationError(
reason: "no declaration for wildcard-matched element '\(child.localName)'",
at: path,
)
case .empty, .simpleContent:
return nil
}
}
/// The `processContents` of a wildcard in `term` that admits `name`, if any.
static func wildcardMatch(for name: PureXML.Model.QualifiedName, in term: PureXML.Schema.Term) -> PureXML.Schema.ProcessContents? {
switch term {
case let .wildcard(wildcard):
return wildcard.admits(name) ? wildcard.processContents : nil
case .element:
return nil
case let .group(group):
for member in group.particles {
if let match = wildcardMatch(for: name, in: member.term) {
return match
}
}
return nil
}
}
private func wildcardChildType(for name: PureXML.Model.QualifiedName, in term: PureXML.Schema.Term) -> PureXML.Schema.ElementType? {
guard let process = Self.wildcardMatch(for: name, in: term) else { return nil }
switch process {
case .skip:
return nil
case .lax, .strict:
// Match the global declaration by full qualified name only; a global is
// always in the target namespace, so the namespaced key already covers a
// no-namespace global without conflating distinct namespaces.
return types[PureXML.Schema.XSDParser.elementDeclarationKey(name)]
}
}
/// Validates one element's own attributes and content-model structure (child
/// names, order, occurrence) and simple content, but not its children's own
/// validity, which the streaming driver checks as each child closes. `type`
/// must already be effective (see ``effectiveType(_:of:)``).
func validateShallow(
_ element: PureXML.Model.Element,
as type: PureXML.Schema.ElementType,
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
at path: [PureXML.Validation.PathKey] = [],
) -> [PureXML.Validation.ValidationError] {
validateShallowTagged(element, as: type, valueConstraint: valueConstraint, at: path).map(\.error)
}
/// The tagged form of ``validateShallow(_:as:valueConstraint:at:)``. See
/// ``PureXML/Schema/ComplexValidator/validateTagged(_:against:at:namespaceBindings:)``.
private func validateShallowTagged(
_ element: PureXML.Model.Element,
as type: PureXML.Schema.ElementType,
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
at path: [PureXML.Validation.PathKey] = [],
) -> [XSDFinding] {
var errors: [XSDFinding] = []
switch type {
case let .simple(simple):
validateSimpleElement(element, simple, valueConstraint: valueConstraint, at: path, into: &errors)
case let .complex(complex):
validateAttributes(element, complex, at: path, into: &errors)
if let nilErrors = nilErrors(element, at: path) {
return errors + nilErrors
}
shallowContent(element, complex.content, at: path, into: &errors)
// A simpleContent fixed value is compared in its simple type's value space
// (cvc-elt.5.2.2.1), matching the tree path's validate(_:against:).
let simpleContentType: PureXML.Schema.SimpleType? = if case let .simpleContent(simple) = complex.content {
simple
} else {
nil
}
errors += elementFixedErrors(element, valueType: simpleContentType, at: path)
case .typeReference:
// The shared resolver also guards a circular chain, which would
// otherwise recurse here without terminating.
switch resolveReference(type) {
case let .unknown(name):
return [XSDFinding(.typeReferenceResolves, reason: "unknown type '\(name)'", at: path)]
case let .circular(name):
return [XSDFinding(.typeReferenceResolves, reason: "circular type reference '\(name)'", at: path)]
case let .resolved(resolved):
return validateShallowTagged(element, as: resolved, valueConstraint: valueConstraint, at: path)
}
}
return errors
}
private func validateSimpleElement(
_ element: PureXML.Model.Element,
_ simple: PureXML.Schema.SimpleType,
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
at path: XSDPath,
into errors: inout [XSDFinding],
) {
if let nilErrors = nilErrors(element, fixedValue: valueConstraint?.fixedValue, at: path) {
errors += nilErrors
return
}
if !element.children.compactMap(\.element).isEmpty {
errors.append(XSDFinding(.simpleTypeForbidsChildren, reason: "'\(element.name.localName)' must not have children", at: path))
}
// An empty element takes its `default`/`fixed` value; that value (not the
// empty string) is what must be valid against the type, matching the tree
// path's validateChild.
let text = Self.rawTextContent(element)
if let error = simple.validate(effectiveSimpleText(for: element, particleConstraint: valueConstraint)) {
errors.append(XSDFinding(.simpleTypeValueValid, reason: "'\(element.name.localName)': \(error)", at: path))
}
recordIDs(simple, value: text, at: path)
// Compare a present fixed value in the type's value space (e.g. "05" == "5"
// for xs:int) and against the matched particle's fixed value, as the tree
// path's validateChild does, not a raw string compare.
errors += elementFixedErrors(element, valueType: simple, particleFixed: valueConstraint?.fixedValue, at: path)
}
private func shallowContent(_ element: PureXML.Model.Element, _ content: PureXML.Schema.ContentType, at path: XSDPath, into errors: inout [XSDFinding]) {
let children = element.children.compactMap(\.element)
switch content {
case .empty:
if !children.isEmpty {
errors.append(XSDFinding(.emptyContentForbidsChildren, reason: "element must be empty", at: path))
}
rejectText(element, at: path, into: &errors)
case let .simpleContent(type):
if !children.isEmpty {
errors.append(XSDFinding(.simpleContentForbidsChildren, reason: "element must not have children", at: path))
}
let text = Self.rawTextContent(element)
if let error = type.validate(text) {
errors.append(XSDFinding(.simpleContentTypeValid, reason: "content: \(error)", at: path))
}
recordIDs(type, value: text, at: path)
case let .elementOnly(particle):
rejectText(element, at: path, into: &errors)
shallowStructure(particle, children: children, at: path, into: &errors)
case let .mixed(particle):
shallowStructure(particle, children: children, at: path, into: &errors)
}
}
private func shallowStructure(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFinding]) {
if case let .group(group) = particle.term, group.compositor == .all {
allStructureErrors(group, children: children, groupOptional: particle.occurrenceRange.minimum.isZero, at: path, into: &errors)
} else {
sequenceStructureErrors(particle, children: children, at: path, into: &errors)
}
}
}