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

384 lines
18 KiB
Swift

extension PureXML.Schema.XSDParser {
/// Schema-validity findings for component-name uniqueness. Within a schema,
/// global type names (simpleType and complexType share one symbol space),
/// global element names, global attribute names, named model-group names,
/// named attribute-group names, and notation names must each be unique;
/// identity-constraint names
/// (unique/key/keyref) must be unique across the whole schema. A duplicate was
/// accepted, with a later definition silently overwriting the earlier one.
///
/// Only the document's own globals are examined (`xs:redefine` children and
/// included documents are not direct globals here), so a redefinition is not
/// mistaken for a clash.
static func componentNameFindings(_ schema: XSDTree, _ containers: [XSDTree], _ context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] {
let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: context.targetNamespace)
let uniqueIndices = findUniqueIndices(
in: containers,
containerLocations: context.containerLocations,
namespaceMap: namespaceMap,
mainTargetNamespace: context.targetNamespace,
)
var findings = checkDuplicateGlobalFindings(
in: containers,
indices: uniqueIndices,
namespaceMap: namespaceMap,
mainTargetNamespace: context.targetNamespace,
)
findings += identityConstraintNameFindings(
containers,
uniqueIndices,
namespaceMap: namespaceMap,
mainTargetNamespace: context.targetNamespace,
)
findings += keyrefReferFindings(schema, containers, context)
return findings
}
private static func findUniqueIndices(
in containers: [XSDTree],
containerLocations: [ObjectIdentifier: String?],
namespaceMap: [Int: String?],
mainTargetNamespace: String?,
) -> [Int] {
var uniqueIndices: [Int] = []
for index in containers.indices {
let container = containers[index]
let namespaceURI = namespaceMap[index] ?? mainTargetNamespace
let location = containerLocations[ObjectIdentifier(container)] ?? nil
let alreadyExists = uniqueIndices.contains { prevIndex in
let prevNS = namespaceMap[prevIndex] ?? mainTargetNamespace
let prevLocation = containerLocations[ObjectIdentifier(containers[prevIndex])] ?? nil
guard prevNS == namespaceURI,
isStructurallyEqual(containers[prevIndex], container)
else {
return false
}
if prevLocation == location {
return true
}
// A circular import reloads the root schema under a schemaLocation while
// the compile root is appended last; treat that as one document, not a clash.
let rootIndex = containers.count - 1
return index == rootIndex || prevIndex == rootIndex
}
if !alreadyExists {
uniqueIndices.append(index)
}
}
return uniqueIndices
}
private struct ComponentKey: Hashable {
let kind: String
let targetNamespace: String?
let name: String
}
private static func checkDuplicateGlobalFindings(
in containers: [XSDTree],
indices: [Int],
namespaceMap: [Int: String?],
mainTargetNamespace: String?,
) -> [PureXML.Schema.SchemaLocatedFinding] {
var findings: [PureXML.Schema.SchemaLocatedFinding] = []
var seenComponents: Set<ComponentKey> = []
for index in indices {
let container = containers[index]
guard PureXML.Schema.XSDNode.localName(container) != "redefine" else { continue }
let namespaceURI = namespaceMap[index] ?? mainTargetNamespace
let globals = PureXML.Schema.XSDNode.elementChildren(container).filter {
$0.name?.namespaceURI == xsdNamespace
}
for global in globals {
let localName = PureXML.Schema.XSDNode.localName(global) ?? ""
guard let kind = componentKind(for: localName),
let name = PureXML.Schema.XSDNode.attribute(global, "name")?.trimmingXMLWhitespace()
else {
continue
}
let key = ComponentKey(kind: kind, targetNamespace: namespaceURI, name: name)
if !seenComponents.insert(key).inserted {
let nsStr = namespaceURI.map { " in namespace '\($0)'" } ?? " in no namespace"
let kindLabel = kind == "group" ? "model group" : (kind == "attributeGroup" ? "attribute group" : kind)
findings.append(PureXML.Schema.SchemaLocatedFinding(
reason: "duplicate \(kindLabel) name '\(name)'\(nsStr)",
node: global,
))
}
}
}
return findings
}
private static func componentKind(for localName: String) -> String? {
switch localName {
case "simpleType", "complexType": "type"
case "element": "element"
case "attribute": "attribute"
case "group": "group"
case "attributeGroup": "attributeGroup"
case "notation": "notation"
default: nil
}
}
private struct KeyrefInfo {
let name: String
let refer: String
let arity: Int
let node: XSDTree
}
/// Findings for a `keyref` whose `refer` does not name a `key` or `unique` in
/// the document, or whose field arity does not match the referenced key/unique's.
/// Skipped when the document pulls in external definitions
/// (`import`/`include`/`redefine`), which the default compile does not load,
/// so a `refer` into them is never flagged.
private static func keyrefReferFindings(_ schema: XSDTree, _ containers: [XSDTree], _ context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] {
if skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) {
return []
}
var keyArities: [String: Int] = [:]
var keyrefs: [KeyrefInfo] = []
let sources = context.compositionLoaded ? containers : [schema]
for source in sources where PureXML.Schema.XSDNode.localName(source) != "redefine" {
collectKeysAndRefers(source, keyArities: &keyArities, keyrefs: &keyrefs)
}
var findings: [PureXML.Schema.SchemaLocatedFinding] = []
for keyref in keyrefs {
if let keyArity = keyArities[keyref.refer] {
if keyArity != keyref.arity {
findings.append(PureXML.Schema.SchemaLocatedFinding(
reason: "keyref '\(keyref.name)' and its referenced key/unique '\(keyref.refer)' must have the same number of fields",
node: keyref.node,
))
}
} else {
findings.append(PureXML.Schema.SchemaLocatedFinding(
reason: "keyref refers to undeclared key or unique '\(keyref.refer)'",
node: keyref.node,
))
}
}
return findings
}
private static func collectKeysAndRefers(
_ node: XSDTree,
keyArities: inout [String: Int],
keyrefs: inout [KeyrefInfo],
) {
if let local = PureXML.Schema.XSDNode.localName(node), local == "appinfo" || local == "documentation" {
return
}
if node.name?.namespaceURI == xsdNamespace, let local = PureXML.Schema.XSDNode.localName(node) {
if local == "key" || local == "unique", let name = PureXML.Schema.XSDNode.attribute(node, "name")?.trimmingXMLWhitespace() {
let arity = PureXML.Schema.XSDNode.elementChildren(node)
.count(where: { $0.name?.namespaceURI == xsdNamespace && PureXML.Schema.XSDNode.localName($0) == "field" })
keyArities[name] = arity
}
if local == "keyref" {
let name = PureXML.Schema.XSDNode.attribute(node, "name")?.trimmingXMLWhitespace()
let refer = PureXML.Schema.XSDNode.attribute(node, "refer")?.trimmingXMLWhitespace()
if let name, let refer {
let arity = PureXML.Schema.XSDNode.elementChildren(node)
.count(where: { $0.name?.namespaceURI == xsdNamespace && PureXML.Schema.XSDNode.localName($0) == "field" })
keyrefs.append(KeyrefInfo(name: name, refer: PureXML.Schema.XSDNode.stripPrefix(refer), arity: arity, node: node))
}
}
}
for child in PureXML.Schema.XSDNode.elementChildren(node) {
collectKeysAndRefers(child, keyArities: &keyArities, keyrefs: &keyrefs)
}
}
private static func identityConstraintNameFindings(
_ containers: [XSDTree],
_ uniqueIndices: [Int],
namespaceMap: [Int: String?],
mainTargetNamespace: String?,
) -> [PureXML.Schema.SchemaLocatedFinding] {
struct KeyrefNameKey: Hashable {
let targetNamespace: String?
let name: String
}
var seen: Set<KeyrefNameKey> = []
var findings: [PureXML.Schema.SchemaLocatedFinding] = []
func walk(_ node: XSDTree, namespaceURI: String?) {
let local = PureXML.Schema.XSDNode.localName(node)
if local == "appinfo" || local == "documentation" {
return
}
if let name = identityConstraintName(node) {
let key = KeyrefNameKey(targetNamespace: namespaceURI, name: name)
if !seen.insert(key).inserted {
let nsStr = namespaceURI.map { " in namespace '\($0)'" } ?? " in no namespace"
findings.append(PureXML.Schema.SchemaLocatedFinding(
reason: "duplicate identity constraint name '\(name)'\(nsStr)",
node: node,
))
}
}
for child in PureXML.Schema.XSDNode.elementChildren(node) {
walk(child, namespaceURI: namespaceURI)
}
}
for index in uniqueIndices {
let container = containers[index]
guard PureXML.Schema.XSDNode.localName(container) != "redefine" else { continue }
let namespaceURI = namespaceMap[index] ?? mainTargetNamespace
walk(container, namespaceURI: namespaceURI)
}
return findings
}
/// The `name` of `node` when it is an identity constraint (`unique`/`key`/
/// `keyref`) in the XSD namespace, or nil.
private static func identityConstraintName(_ node: XSDTree) -> String? {
guard node.name?.namespaceURI == xsdNamespace,
let local = PureXML.Schema.XSDNode.localName(node),
["unique", "key", "keyref"].contains(local)
else { return nil }
return PureXML.Schema.XSDNode.attribute(node, "name")?.trimmingXMLWhitespace()
}
private static func isStructurallyEqual(_ lhs: XSDTree, _ rhs: XSDTree) -> Bool {
if lhs.name != rhs.name {
return false
}
if lhs.attributes.count != rhs.attributes.count {
return false
}
for attr in lhs.attributes {
guard let rhsVal = rhs.attributes.first(where: { $0.name == attr.name })?.value,
rhsVal == attr.value
else { return false }
}
let lhsChildren = PureXML.Schema.XSDNode.elementChildren(lhs)
let rhsChildren = PureXML.Schema.XSDNode.elementChildren(rhs)
if lhsChildren.count != rhsChildren.count {
return false
}
guard !zip(lhsChildren, rhsChildren).contains(where: { !isStructurallyEqual($0, $1) }) else { return false }
return true
}
static func resolveContainerNamespaces(_ containers: [XSDTree], mainTargetNamespace: String?) -> [Int: String?] {
var resolved: [Int: String?] = [:]
var resolvedIndices = Set<Int>()
guard !containers.isEmpty else { return resolved }
let mainIndex = containers.count - 1
resolved[mainIndex] = mainTargetNamespace
resolvedIndices.insert(mainIndex)
for parentIndex in containers.indices.reversed() {
guard resolvedIndices.contains(parentIndex) else { continue }
let parentNamespace = resolvedNamespace(at: parentIndex, in: resolved, fallback: mainTargetNamespace)
let parent = containers[parentIndex]
for child in PureXML.Schema.XSDNode.elementChildren(parent) {
let kind = PureXML.Schema.XSDNode.localName(child)
guard kind == "include" || kind == "import" || kind == "redefine",
let childKind = kind
else {
continue
}
let targetNamespaceURI: String? = if childKind == "import" {
PureXML.Schema.XSDNode.attribute(child, "namespace")
} else {
parentNamespace
}
let expectedAttrNamespace: String? = childKind == "import" ? PureXML.Schema.XSDNode.attribute(child, "namespace") : parentNamespace
let foundIndex = findMatchingContainerIndex(
in: containers,
before: parentIndex,
kind: childKind,
namespaces: (parent: parentNamespace, expected: expectedAttrNamespace),
resolvedIndices: resolvedIndices,
)
if let foundIndex {
resolved[foundIndex] = targetNamespaceURI
resolvedIndices.insert(foundIndex)
}
}
}
for index in containers.indices where !resolvedIndices.contains(index) {
let container = containers[index]
if let attrNS = PureXML.Schema.XSDNode.attribute(container, "targetNamespace") {
resolved[index] = attrNS
} else {
resolved[index] = mainTargetNamespace
}
resolvedIndices.insert(index)
}
return resolved
}
/// Schema-validity findings for `xs:include` when external schemas load: the
/// included document must be chameleon (no `targetNamespace`) or declare the
/// same target namespace as the includer (XSD Structures §4.2.3).
static func includeCompositionFindings(
_ containers: [XSDTree],
mainTargetNamespace: String?,
compositionLoaded: Bool,
containerLocations: [ObjectIdentifier: String?],
) -> [PureXML.Schema.SchemaLocatedFinding] {
guard compositionLoaded else { return [] }
let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: mainTargetNamespace)
var findings: [PureXML.Schema.SchemaLocatedFinding] = []
for (parentIndex, parent) in containers.enumerated() {
guard PureXML.Schema.XSDNode.localName(parent) == "schema" else { continue }
let parentNamespace = resolvedNamespace(at: parentIndex, in: namespaceMap, fallback: mainTargetNamespace)
for child in PureXML.Schema.XSDNode.elementChildren(parent) {
let kind = PureXML.Schema.XSDNode.localName(child)
guard kind == "include" || kind == "redefine",
let location = PureXML.Schema.XSDNode.attribute(child, "schemaLocation")
else { continue }
guard let includedIndex = containers.firstIndex(where: {
containerLocations[ObjectIdentifier($0)] == location && PureXML.Schema.XSDNode.localName($0) == "schema"
}) else { continue }
let includedNamespace = PureXML.Schema.XSDNode.attribute(containers[includedIndex], "targetNamespace")
if let includedNamespace, includedNamespace != parentNamespace {
let parentLabel = parentNamespace ?? "no namespace"
let verb = kind == "redefine" ? "redefined" : "included"
findings.append(PureXML.Schema.SchemaLocatedFinding(
reason: "\(verb) schema targetNamespace '\(includedNamespace)' must match the '\(parentLabel)' targetNamespace or be chameleon (no targetNamespace)",
node: child,
))
}
}
}
return findings
}
private static func resolvedNamespace(at index: Int, in map: [Int: String?], fallback: String?) -> String? {
switch map[index] {
case nil: fallback
case let .some(namespace): namespace
}
}
private static func findMatchingContainerIndex(
in containers: [XSDTree],
before parentIndex: Int,
kind: String,
namespaces: (parent: String?, expected: String?),
resolvedIndices: Set<Int>,
) -> Int? {
for index in (0 ..< parentIndex).reversed() where !resolvedIndices.contains(index) {
let container = containers[index]
let attrNS = PureXML.Schema.XSDNode.attribute(container, "targetNamespace")
let isMatch: Bool = if kind == "import" {
attrNS == namespaces.expected
} else {
attrNS == nil || attrNS == namespaces.parent
}
if isMatch {
return index
}
}
return nil
}
}