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

198 lines
7.7 KiB
Swift

/// The working state of one restrictions pass. Internal to
/// ``RelaxNGRestrictions``; split across files for the length caps.
final class RelaxNGRestrictionChecker {
typealias Pattern = PureXML.Schema.Pattern
typealias NameClass = PureXML.Schema.NameClass
let defines: [String: Pattern]
private var checkedElements = 0
init(defines: [String: Pattern]) {
self.defines = defines
}
func resolve(_ name: String) -> Pattern {
defines[name] ?? .notAllowed
}
/// The 4.20/4.21 normalization: notAllowed and empty operands
/// simplify away before the restrictions are judged, so a branch the
/// schema can never take is not checked.
static func normalized(_ pattern: Pattern) -> Pattern {
switch pattern {
case let .choice(lhs, rhs):
let left = normalized(lhs)
let right = normalized(rhs)
if case .notAllowed = left {
return right
}
if case .notAllowed = right {
return left
}
return .choice(left, right)
case let .group(lhs, rhs):
return combineNormalized(normalized(lhs), normalized(rhs), make: { .group($0, $1) })
case let .interleave(lhs, rhs):
return combineNormalized(normalized(lhs), normalized(rhs), make: { .interleave($0, $1) })
default:
return normalizedUnary(pattern)
}
}
/// The unary cases of 4.20: a wrapper around notAllowed is notAllowed,
/// while elements keep their (normalized) content.
private static func normalizedUnary(_ pattern: Pattern) -> Pattern {
switch pattern {
case let .oneOrMore(inner):
let normalizedInner = normalized(inner)
if case .notAllowed = normalizedInner {
return .notAllowed
}
return .oneOrMore(normalizedInner)
case let .list(inner):
let normalizedInner = normalized(inner)
if case .notAllowed = normalizedInner {
return .notAllowed
}
return .list(normalizedInner)
case let .attribute(nameClass, content):
let normalizedContent = normalized(content)
if case .notAllowed = normalizedContent {
return .notAllowed
}
return .attribute(nameClass, normalizedContent)
case let .element(nameClass, content):
return .element(nameClass, normalized(content))
default:
return pattern
}
}
private static func combineNormalized(_ lhs: Pattern, _ rhs: Pattern, make: (Pattern, Pattern) -> Pattern) -> Pattern {
if case .notAllowed = lhs {
return .notAllowed
}
if case .notAllowed = rhs {
return .notAllowed
}
if case .empty = lhs {
return rhs
}
if case .empty = rhs {
return lhs
}
return make(lhs, rhs)
}
// MARK: 7.1.5 start
/// `start` admits only element, ref, choice, and notAllowed.
func startProblem(_ pattern: Pattern, visiting: Set<String> = []) -> String? {
switch pattern {
case .element, .notAllowed:
return nil
case let .choice(lhs, rhs):
return startProblem(lhs, visiting: visiting) ?? startProblem(rhs, visiting: visiting)
case let .ref(name):
guard !visiting.contains(name) else { return nil }
return startProblem(resolve(name), visiting: visiting.union([name]))
default:
return "start admits only element, ref, choice, and notAllowed (7.1.5)"
}
}
// MARK: 4.19 recursion
/// No define reachable from the start may reach itself through refs
/// without an intervening element (unreachable defines are removed by
/// the 4.19 simplification, so they are exempt).
func recursionProblem(start: Pattern) -> String? {
var reachable: Set<String> = []
collectReachable(start, into: &reachable)
for name in reachable.sorted() where reaches(resolve(name), target: name, visiting: [name]) {
let plain = name.split(separator: "\u{1}").last.map(String.init) ?? name
return "define '\(plain)' recurses without an intervening element (4.19)"
}
return nil
}
private func collectReachable(_ pattern: Pattern, into reachable: inout Set<String>) {
switch pattern {
case let .ref(name):
guard reachable.insert(name).inserted else { return }
collectReachable(resolve(name), into: &reachable)
case let .choice(lhs, rhs), let .group(lhs, rhs), let .interleave(lhs, rhs):
collectReachable(lhs, into: &reachable)
collectReachable(rhs, into: &reachable)
case let .oneOrMore(inner), let .list(inner), let .attribute(_, inner),
let .element(_, inner), let .dataExcept(_, inner):
collectReachable(inner, into: &reachable)
default:
break
}
}
private func reaches(_ pattern: Pattern, target: String, visiting: Set<String>) -> Bool {
switch pattern {
case let .ref(name):
if name == target {
return true
}
guard !visiting.contains(name) else { return false }
return reaches(resolve(name), target: target, visiting: visiting.union([name]))
case let .choice(lhs, rhs), let .group(lhs, rhs), let .interleave(lhs, rhs):
return reaches(lhs, target: target, visiting: visiting) || reaches(rhs, target: target, visiting: visiting)
case let .oneOrMore(inner), let .list(inner), let .attribute(_, inner):
return reaches(inner, target: target, visiting: visiting)
case .element:
return false // An element guards the recursion.
default:
return false
}
}
// MARK: 7.2 content types
func contentType(_ pattern: Pattern, visiting: Set<String>) -> RelaxNGContentType? {
switch pattern {
case .empty, .notAllowed, .attribute:
return .empty
case .text, .element:
return .complex
case .data, .value, .valueQName, .list, .dataExcept:
return .simple
case .choice, .group, .interleave, .oneOrMore:
return combinedContentType(pattern, visiting: visiting)
case let .ref(name):
guard !visiting.contains(name) else { return .complex }
return contentType(resolve(name), visiting: visiting.union([name]))
default:
return .empty
}
}
/// Combinator content types: choice takes the max; group, interleave, and
/// oneOrMore additionally require groupable operands (7.2).
private func combinedContentType(_ pattern: Pattern, visiting: Set<String>) -> RelaxNGContentType? {
switch pattern {
case let .choice(lhs, rhs):
guard let left = contentType(lhs, visiting: visiting), let right = contentType(rhs, visiting: visiting) else { return nil }
return max(left, right)
case let .group(lhs, rhs), let .interleave(lhs, rhs):
guard let left = contentType(lhs, visiting: visiting), let right = contentType(rhs, visiting: visiting),
groupable(left, right)
else { return nil }
return max(left, right)
case let .oneOrMore(inner):
guard let type = contentType(inner, visiting: visiting), groupable(type, type) else { return nil }
return type
default:
return nil
}
}
private func groupable(_ lhs: RelaxNGContentType, _ rhs: RelaxNGContentType) -> Bool {
lhs == .empty || rhs == .empty || (lhs == .complex && rhs == .complex)
}
}