PureXML/Sources/Schema/ComplexValidatorAttributes.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

147 lines
7.2 KiB
Swift

extension PureXML.Schema.ComplexValidator {
func validateAttributes(
_ element: PureXML.Model.Element,
_ type: PureXML.Schema.ComplexType,
at path: [PureXML.Validation.PathKey],
into errors: inout [XSDFinding],
) {
let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) && !Self.isSchemaInstanceAttribute($0) }
for use in type.attributes where !use.prohibited {
let match = present.first { Self.attributeMatches($0.name, declared: use.name, use: use, on: element.name) }
if let match {
if let error = use.type.validate(match.value) {
errors.append(XSDFinding(.attributeTypeValid, reason: "attribute '\(use.name.localName)': \(error)", at: path))
}
if let fixed = use.valueConstraint?.fixedValue, !use.type.valueMatches(match.value, literal: fixed) {
errors.append(XSDFinding(.attributeFixedMatches, reason: "attribute '\(use.name.localName)' is fixed and must be '\(fixed)'", at: path))
}
recordIDs(use.type, value: match.value, at: path)
} else if use.required {
errors.append(XSDFinding(.attributeRequiredPresent, reason: "missing required attribute '\(use.name.localName)'", at: path))
} else if let constraint = use.valueConstraint {
// An absent attribute takes its `default`/`fixed` value, so a
// defaulted or fixed IDREF/IDREFS must still resolve to a matching
// ID (cvc-id): the supplied value participates in document-scoped
// ID/IDREF checking exactly as a present value would.
recordIDs(use.type, value: constraint.value, at: path)
}
}
validateWildcardAttributes(present, element: element, type: type, at: path, into: &errors)
if idTypedAttributeCount(present, type, on: element) > 1 {
errors.append(XSDFinding(
.attributeSingleID,
reason: "an element may carry at most one attribute of type ID",
at: path,
))
}
}
/// The number of `present` attributes whose effective type is `xs:ID` (an
/// element may carry at most one; XSD 1.0 §3.4.6, cvc-complex-type). An
/// attribute's type is its declared use's type when it matches one, otherwise
/// the global declaration a matching wildcard admits.
private func idTypedAttributeCount(
_ present: [PureXML.Model.Attribute],
_ type: PureXML.Schema.ComplexType,
on element: PureXML.Model.Element,
) -> Int {
present.reduce(into: 0) { count, attribute in
if isIDTyped(attribute, type, on: element) {
count += 1
}
}
}
/// Whether `attribute`'s effective type is `xs:ID`: the type of the declared
/// use it matches, or the global declaration a matching wildcard admits.
private func isIDTyped(
_ attribute: PureXML.Model.Attribute,
_ type: PureXML.Schema.ComplexType,
on element: PureXML.Model.Element,
) -> Bool {
if let use = type.attributes.first(where: { !$0.prohibited && Self.attributeMatches(attribute.name, declared: $0.name, use: $0, on: element.name) }) {
return use.type.isID
}
guard let wildcard = type.attributeWildcard, wildcard.admits(attribute.name),
let use = globalAttributeUse(for: attribute.name)
else { return false }
return use.type.isID
}
/// Whether an instance attribute satisfies a declared use. Chameleon-included
/// global attributes may appear unprefixed on an element in the effective TN.
static func attributeMatches(
_ instance: PureXML.Model.QualifiedName,
declared: PureXML.Model.QualifiedName,
use: PureXML.Schema.AttributeUse,
on elementName: PureXML.Model.QualifiedName,
) -> Bool {
if sameName(instance, declared) {
return true
}
guard use.chameleonUnprefixed,
instance.namespaceURI == nil || instance.namespaceURI?.isEmpty == true,
let declaredNamespace = declared.namespaceURI, !declaredNamespace.isEmpty
else { return false }
return instance.localName == declared.localName && declaredNamespace == elementName.namespaceURI
}
private func validateWildcardAttributes(
_ present: [PureXML.Model.Attribute],
element: PureXML.Model.Element,
type: PureXML.Schema.ComplexType,
at path: [PureXML.Validation.PathKey],
into errors: inout [XSDFinding],
) {
// A `use="prohibited"` use is excluded from the effective {attribute uses},
// so a prohibited attribute is treated as undeclared and left to the
// attribute wildcard (which may still admit it) or reported as undeclared.
let declared = type.attributes.filter { !$0.prohibited }
let elementName = element.name
let wildcard = type.attributeWildcard
for attribute in present where !declared.contains(where: { Self.attributeMatches(attribute.name, declared: $0.name, use: $0, on: elementName) }) {
guard let wildcard, wildcard.admits(attribute.name) else {
errors.append(XSDFinding(.attributeDeclaredOrPermitted, reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
continue
}
switch wildcard.processContents {
case .skip:
continue
case .lax:
if let use = globalAttributeUse(for: attribute.name) {
validateWildcardAttribute(attribute, use: use, at: path, into: &errors)
}
case .strict:
guard let use = globalAttributeUse(for: attribute.name) else {
errors.append(XSDFinding(
.wildcardAttributeUndeclared,
reason: "no declaration for wildcard-matched attribute '\(attribute.name.localName)'",
at: path,
))
continue
}
validateWildcardAttribute(attribute, use: use, at: path, into: &errors)
}
}
}
private func globalAttributeUse(for name: PureXML.Model.QualifiedName) -> PureXML.Schema.AttributeUse? {
globalAttributes[PureXML.Schema.XSDParser.attributeDeclarationKey(name)]
}
private func validateWildcardAttribute(
_ attribute: PureXML.Model.Attribute,
use: PureXML.Schema.AttributeUse,
at path: [PureXML.Validation.PathKey],
into errors: inout [XSDFinding],
) {
if let error = use.type.validate(attribute.value) {
errors.append(XSDFinding(.wildcardAttributeTypeValid, reason: "attribute '\(attribute.name.localName)': \(error)", at: path))
}
if let fixed = use.valueConstraint?.fixedValue, !use.type.valueMatches(attribute.value, literal: fixed) {
errors.append(XSDFinding(.wildcardAttributeFixedMatches, reason: "attribute '\(attribute.name.localName)' is fixed and must be '\(fixed)'", at: path))
}
recordIDs(use.type, value: attribute.value, at: path)
}
}