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

240 lines
12 KiB
Swift

private typealias XSDComplexType = PureXML.Schema.ComplexType
private typealias XSDContentType = PureXML.Schema.ContentType
private typealias XSDParticle = PureXML.Schema.Particle
private typealias XSDGroup = PureXML.Schema.Group
private typealias XSDContentNode = PureXML.Schema.XSDNode
extension PureXML.Schema.XSDParser {
/// Attribute uses contributed by a `simpleContent` extension or restriction,
/// including those inherited from a complex base type.
static func simpleContentAttributes(under derivationNode: XSDTree, _ context: PureXML.Schema.XSDContext) -> [PureXML.Schema.AttributeUse] {
let ownAttributes = attributeUses(under: derivationNode, context)
let derivationType = XSDContentNode.localName(derivationNode)
if derivationType == "extension", let base = resolvedBase(derivationNode, context) {
return base.attributes + ownAttributes
}
if derivationType == "restriction" {
let allowedOwnAttributes = ownAttributes.filter { !isProhibited($0.name, under: derivationNode, context) }
if let base = resolvedBase(derivationNode, context) {
var attributes: [PureXML.Schema.AttributeUse] = []
for baseAttr in base.attributes {
if isProhibited(baseAttr.name, under: derivationNode, context) {
continue
}
if let derived = allowedOwnAttributes.first(where: { $0.name == baseAttr.name }) {
attributes.append(derived)
} else {
attributes.append(baseAttr)
}
}
for ownAttr in allowedOwnAttributes where !base.attributes.contains(where: { $0.name == ownAttr.name }) {
attributes.append(ownAttr)
}
return attributes
}
return allowedOwnAttributes
}
return ownAttributes
}
/// Builds the effective complex type of a `complexContent` derivation. An
/// extension composes the base type's attributes and content with its own
/// (the base's content first, then the extension's, in sequence); a
/// restriction states the full restricted model and replaces the base.
///
/// Note: a restriction's model is taken as given. It is NOT verified to be a
/// structurally valid subset of the base ("Particle Valid (Restriction)" in
/// XSD 1.0), which is a much larger algorithm; an unfaithful restriction is
/// accepted.
static func complexContentType(_ derivationNode: XSDTree, mixed: Bool, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.ComplexType {
let ownAttributes = attributeUses(under: derivationNode, context)
let ownWildcard = attributeWildcard(under: derivationNode, context)
let ownParticle = modelGroup(in: derivationNode, context)
let derivationType = XSDContentNode.localName(derivationNode)
if derivationType == "extension", let base = resolvedBase(derivationNode, context) {
// Best-effort wildcard for type building: a not-expressible union makes
// the schema invalid (flagged by SchemaWildcardExpressibility); fall back
// to the base's wildcard so the dead type is still well-formed.
let unionWildcard: PureXML.Schema.Wildcard? = switch PureXML.Schema.Wildcard.union(ownWildcard, base.attributeWildcard) {
case let .constraint(wildcard): wildcard
case .notExpressible: base.attributeWildcard
}
return XSDComplexType(
attributes: base.attributes + ownAttributes,
attributeWildcard: unionWildcard,
content: extendedContent(base: base.content, own: ownParticle, mixed: mixed),
)
} else if derivationType == "restriction" {
let allowedOwnAttributes = ownAttributes.filter { !isProhibited($0.name, under: derivationNode, context) }
let finalAttributes: [PureXML.Schema.AttributeUse]
if let base = resolvedBase(derivationNode, context) {
var attributes: [PureXML.Schema.AttributeUse] = []
for baseAttr in base.attributes {
if isProhibited(baseAttr.name, under: derivationNode, context) {
continue
}
if let derived = allowedOwnAttributes.first(where: { $0.name == baseAttr.name }) {
attributes.append(derived)
} else {
attributes.append(baseAttr)
}
}
for ownAttr in allowedOwnAttributes {
guard !base.attributes.contains(where: { $0.name == ownAttr.name }) else { continue }
attributes.append(ownAttr)
}
finalAttributes = attributes
} else {
finalAttributes = allowedOwnAttributes
}
return XSDComplexType(
attributes: finalAttributes,
attributeWildcard: ownWildcard,
content: content(ownParticle, mixed: mixed),
)
}
return XSDComplexType(
attributes: ownAttributes,
attributeWildcard: ownWildcard,
content: content(ownParticle, mixed: mixed),
)
}
private static func isProhibited(_ name: PureXML.Model.QualifiedName, under node: XSDTree, _ context: PureXML.Schema.XSDContext, visited: Set<String> = []) -> Bool {
for child in XSDContentNode.elementChildren(node) {
switch XSDContentNode.localName(child) {
case "attribute":
if let attrName = attributeName(child, context), attrName == name {
if XSDContentNode.attribute(child, "use") == "prohibited" {
return true
}
}
case "attributeGroup":
guard let ref = XSDContentNode.attribute(child, "ref") else { break }
let refName = XSDContentNode.stripPrefix(ref)
if attributeGroupProhibits(name, refName: refName, under: node, context, visited: visited) {
return true
}
default:
break
}
}
return false
}
private static func attributeGroupProhibits(
_ name: PureXML.Model.QualifiedName,
refName: String,
under _: XSDTree,
_ context: PureXML.Schema.XSDContext,
visited: Set<String>,
) -> Bool {
if visited.contains(refName) {
guard context.redefinedAttributeGroups.contains(refName),
let base = context.baseAttributeGroups[refName] else { return false }
return isProhibited(name, under: base, context, visited: visited)
}
guard let group = context.attributeGroups[refName] else { return false }
return isProhibited(name, under: group, context, visited: visited.union([refName]))
}
private static func attributeName(_ node: XSDTree, _ context: PureXML.Schema.XSDContext) -> PureXML.Model.QualifiedName? {
if let ref = XSDContentNode.attribute(node, "ref") {
let refName = XSDContentNode.stripPrefix(ref)
let namespace = XSDContentNode.referenceNamespace(ref, context.namespaceBindings)
return PureXML.Model.QualifiedName(localName: refName, namespaceURI: namespace)
}
guard let name = XSDContentNode.attribute(node, "name") else { return nil }
let qualified = XSDContentNode.attribute(node, "form") == "qualified"
|| (XSDContentNode.attribute(node, "form") == nil && context.attributeFormQualified)
return PureXML.Model.QualifiedName(localName: name, namespaceURI: qualified ? context.targetNamespace : nil)
}
/// The base complex type a derivation extends, parsed from its definition
/// node, or nil when the base is unknown (a built-in or undeclared type) or
/// would form a derivation cycle.
private static func resolvedBase(_ derivationNode: XSDTree, _ context: PureXML.Schema.XSDContext) -> XSDComplexType? {
guard let baseName = XSDContentNode.attribute(derivationNode, "base").map(XSDContentNode.stripPrefix) else {
return nil
}
guard let baseNode = baseTypeNode(named: baseName, context) else { return nil }
// A `complexContent` derivation chain (`B0` extends `B1` extends ... `B_N`) is, like
// a group-reference chain, flat in the document and so not bounded by the parser's
// `maxDepth`; composing it recurses once per link. Count each link on the shared
// expansion-depth budget (which group/compositor nesting also uses, so a type whose
// base chain and content model are both deep is bounded by their sum) and reject a
// chain past the bound with a located error rather than overflowing the stack.
var context = context
context.expansionDepth += 1
guard context.expansionDepth <= PureXML.Schema.XSDParser.maximumContentModelExpansionDepth else {
context.diagnostics.report(
"type derivation nesting exceeds the maximum content model depth of \(PureXML.Schema.XSDParser.maximumContentModelExpansionDepth)",
at: derivationNode,
)
return nil
}
let scoped = context.scoped(for: XSDContentNode.schemaOwner(baseNode))
return complexType(baseNode, scoped.visitingType(baseName))
}
/// The schema node a complex type derives from: the redefined base when this
/// type is the redefining occurrence of `baseName`, otherwise the ordinary
/// declaration. Returns nil when resolving would form a derivation cycle.
private static func baseTypeNode(named baseName: String, _ context: PureXML.Schema.XSDContext) -> XSDTree? {
if let redefined = redefinedBaseNode(named: baseName, context) {
return redefined
}
guard !context.visitingTypes.contains(baseName) else { return nil }
return context.complexTypeNodes[baseName]
}
private static func redefinedBaseNode(named baseName: String, _ context: PureXML.Schema.XSDContext) -> XSDTree? {
guard context.redefinedComplexTypes.contains(baseName),
context.visitingTypes.contains(baseName),
let baseNode = context.baseComplexTypeNodes[baseName]
else { return nil }
return baseNode
}
/// A particle wrapped as element-only or mixed content, or empty content when
/// there is no particle.
private static func content(_ particle: XSDParticle?, mixed: Bool) -> XSDContentType {
guard let particle else { return mixed ? .mixed(emptyParticle) : .empty }
return mixed ? .mixed(particle) : .elementOnly(particle)
}
/// The content of an extension: the base content's particle followed by the
/// extension's own particle, in a sequence. Mixed if either side is mixed.
private static func extendedContent(base: XSDContentType, own: XSDParticle?, mixed: Bool) -> XSDContentType {
let baseMixed = if case .mixed = base {
true
} else {
false
}
let combined = combine(particle(of: base), own)
return content(combined, mixed: mixed || baseMixed)
}
private static func particle(of content: XSDContentType) -> XSDParticle? {
switch content {
case let .elementOnly(particle), let .mixed(particle): particle
case .empty, .simpleContent: nil
}
}
private static func combine(_ first: XSDParticle?, _ second: XSDParticle?) -> XSDParticle? {
switch (first, second) {
case let (first?, second?):
XSDParticle(term: .group(XSDGroup(compositor: .sequence, particles: [first, second])))
case let (first?, nil): first
case let (nil, second): second
}
}
private static var emptyParticle: XSDParticle {
XSDParticle(minOccurs: 0, maxOccurs: 1, term: .group(XSDGroup(compositor: .sequence, particles: [])))
}
}