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

136 lines
6.3 KiB
Swift

/// The attribute and interleave restrictions (7.3, 7.4) with the
/// representative-name overlap test for name classes.
extension RelaxNGRestrictionChecker {
// MARK: 7.3 duplicate attributes
func attributeSets(_ pattern: Pattern, visiting: Set<String>) -> (classes: [NameClass], problem: String?) {
switch pattern {
case let .attribute(nameClass, _):
([nameClass], nil)
case let .choice(lhs, rhs):
combinedAttributeSets(lhs, rhs, crossCheck: false, visiting: visiting)
case let .group(lhs, rhs), let .interleave(lhs, rhs):
combinedAttributeSets(lhs, rhs, crossCheck: true, visiting: visiting)
case let .oneOrMore(inner), let .list(inner):
attributeSets(inner, visiting: visiting)
case let .ref(name):
visiting.contains(name)
? ([], nil)
: attributeSets(resolve(name), visiting: visiting.union([name]))
default:
([], nil)
}
}
/// Merges two operands' attribute classes; group and interleave require
/// the operands' classes to be pairwise disjoint (7.3).
private func combinedAttributeSets(_ lhs: Pattern, _ rhs: Pattern, crossCheck: Bool, visiting: Set<String>) -> (classes: [NameClass], problem: String?) {
let left = attributeSets(lhs, visiting: visiting)
if left.problem != nil {
return left
}
let right = attributeSets(rhs, visiting: visiting)
if right.problem != nil {
return right
}
if crossCheck {
for first in left.classes {
for second in right.classes where nameClassesOverlap(first, second) {
return ([], "the same attribute may occur twice in one element (7.3)")
}
}
}
return (left.classes + right.classes, nil)
}
// MARK: 7.4 interleave
func interleaveProblem(_ pattern: Pattern, visiting: Set<String>) -> String? {
switch pattern {
case let .interleave(lhs, rhs):
if hasText(lhs, visiting: visiting), hasText(rhs, visiting: visiting) {
return "text may appear in only one operand of an interleave (7.4)"
}
for first in elementClasses(lhs, visiting: visiting) {
for second in elementClasses(rhs, visiting: visiting) where nameClassesOverlap(first, second) {
return "interleave operands share an element name (7.4)"
}
}
return interleaveProblem(lhs, visiting: visiting) ?? interleaveProblem(rhs, visiting: visiting)
case let .choice(lhs, rhs), let .group(lhs, rhs):
return interleaveProblem(lhs, visiting: visiting) ?? interleaveProblem(rhs, visiting: visiting)
case let .oneOrMore(inner), let .list(inner), let .attribute(_, inner), let .dataExcept(_, inner):
return interleaveProblem(inner, visiting: visiting)
case let .ref(name):
guard !visiting.contains(name) else { return nil }
return interleaveProblem(resolve(name), visiting: visiting.union([name]))
default:
return nil
}
}
private func hasText(_ pattern: Pattern, visiting: Set<String>) -> Bool {
switch pattern {
case .text:
return true
case let .choice(lhs, rhs), let .group(lhs, rhs), let .interleave(lhs, rhs):
return hasText(lhs, visiting: visiting) || hasText(rhs, visiting: visiting)
case let .oneOrMore(inner):
return hasText(inner, visiting: visiting)
case let .ref(name):
guard !visiting.contains(name) else { return false }
return hasText(resolve(name), visiting: visiting.union([name]))
default:
return false
}
}
private func elementClasses(_ pattern: Pattern, visiting: Set<String>) -> [NameClass] {
switch pattern {
case let .element(nameClass, _):
return [nameClass]
case let .choice(lhs, rhs), let .group(lhs, rhs), let .interleave(lhs, rhs):
return elementClasses(lhs, visiting: visiting) + elementClasses(rhs, visiting: visiting)
case let .oneOrMore(inner):
return elementClasses(inner, visiting: visiting)
case let .ref(name):
guard !visiting.contains(name) else { return [] }
return elementClasses(resolve(name), visiting: visiting.union([name]))
default:
return []
}
}
// MARK: Name-class overlap
/// Overlap by representative names: every concrete name either class
/// mentions, plus an illustrative name per nsName namespace and one in
/// a namespace neither could mention, tested against both classes.
private func namesClassRepresentatives(_ nameClass: NameClass, into set: inout [PureXML.Model.QualifiedName]) {
switch nameClass {
case let .name(namespace, localName):
set.append(PureXML.Model.QualifiedName(prefix: nil, localName: localName, namespaceURI: namespace.isEmpty ? nil : namespace))
case let .nsName(namespace):
set.append(PureXML.Model.QualifiedName(prefix: nil, localName: "\u{1}illustrative", namespaceURI: namespace.isEmpty ? nil : namespace))
case let .nsNameExcept(namespace, except):
set.append(PureXML.Model.QualifiedName(prefix: nil, localName: "\u{1}illustrative", namespaceURI: namespace.isEmpty ? nil : namespace))
namesClassRepresentatives(except, into: &set)
case .anyName:
set.append(PureXML.Model.QualifiedName(prefix: nil, localName: "\u{1}illustrative", namespaceURI: "\u{1}nowhere"))
case let .anyNameExcept(except):
set.append(PureXML.Model.QualifiedName(prefix: nil, localName: "\u{1}illustrative", namespaceURI: "\u{1}nowhere"))
namesClassRepresentatives(except, into: &set)
case let .choice(lhs, rhs):
namesClassRepresentatives(lhs, into: &set)
namesClassRepresentatives(rhs, into: &set)
}
}
func nameClassesOverlap(_ first: NameClass, _ second: NameClass) -> Bool {
var candidates: [PureXML.Model.QualifiedName] = []
namesClassRepresentatives(first, into: &candidates)
namesClassRepresentatives(second, into: &candidates)
return candidates.contains { first.contains($0) && second.contains($0) }
}
}