PureXML/Sources/Schema/ParticleRestriction.swift
Mihaela Mihaljevic 87cc83d0ce fix(schema): stop Particle Valid (Restriction) over-rejecting valid schemas
The restriction subset check rejected 72 valid schemas (#148). Three
language-preserving corrections:

- Remove pointless particles (maxOccurs=0) from both the derived and base
  content before mapping, so they no longer consume a base particle and starve
  the particles after them (e.g. (e1 maxOccurs=0, e2) restricting (any)).
- A content-free restriction (an empty sequence, or a group whose members all
  cannot occur) is a valid restriction of an EMPTY base: both accept only the
  empty sequence.
- A derived group that reduces to empty after that removal validly restricts its
  base only when the base is itself emptiable (emptiable(base) accounts for the
  base's own occurrence), closing the vacuous-acceptance gap the removal would
  otherwise open in the MapAndSum and choice paths.

XSTS: valid schemas rejected 72 to 45. invalid schemas accepted 527 to 528 from
addB32, whose only invalidity is a malformed imported document not yet loaded
(#161); the restriction itself is valid, so accepting it is correct.
2026-06-14 15:56:04 +02:00

293 lines
16 KiB
Swift

extension PureXML.Schema {
/// XSD 1.0 "Particle Valid (Restriction)" for the common cases: a complex
/// type derived by restriction must have a content model that accepts a
/// subset of its base's. Covers element-vs-element name matching,
/// element-vs-wildcard admission, wildcard narrowing, occurrence-range
/// subsumption, and the group recursions (sequence/all order-preserving with
/// emptiable skips, choice mapping, sequence-into-choice, sequence-into-all).
/// The spec's effective-total-range arithmetic for MapAndSum is approximated
/// by per-particle checks, a documented simplification.
enum ParticleRestriction {
/// The reason a restriction's content is not a valid subset of its
/// base's, or nil when it is. `types` resolves an element's
/// ``ElementType/typeReference`` so element-vs-element pairs can check that
/// the restricting type derives from the base's (NameAndTypeOK).
static func violation(restricted: ContentType, base: ContentType, types: [String: ElementType], derivation: [String: TypeDerivation]) -> String? {
switch (restricted, base) {
case (.empty, .empty), (.simpleContent, _), (_, .simpleContent):
nil
case let (.empty, .elementOnly(baseParticle)), let (.empty, .mixed(baseParticle)):
emptiable(baseParticle) ? nil : "the base content is required, so the restriction cannot be EMPTY"
case (.mixed, .elementOnly):
"a restriction cannot add mixed content to an element-only base"
case let (.elementOnly(restrictedParticle), .empty):
// A content-free particle (an empty group, or one whose members all
// have maxOccurs=0) accepts only the empty sequence, so it is a valid
// restriction of EMPTY; only a particle that can contribute a child is
// adding content.
contentFree(restrictedParticle) ? nil : "the base content is EMPTY, so the restriction cannot add content"
case (.mixed, .empty):
"the base content is EMPTY, so the restriction cannot add content"
case let (.elementOnly(restrictedParticle), .elementOnly(baseParticle)),
let (.elementOnly(restrictedParticle), .mixed(baseParticle)),
let (.mixed(restrictedParticle), .mixed(baseParticle)):
valid(restrictedParticle, baseParticle, types, derivation)
? nil
: "the restricted content model is not a subset of the base's"
}
}
/// Whether `restricted` accepts a subset of `base` (the pairwise check).
static func valid(_ restricted: Particle, _ base: Particle, _ types: [String: ElementType], _ derivation: [String: TypeDerivation]) -> Bool {
guard rangeSubsumed(restricted, base) else { return false }
switch (restricted.term, base.term) {
case let (.element(restrictedName, _, restrictedTypeName), .element(baseName, _, baseTypeName)):
return sameName(restrictedName, baseName) && elementTypeRestrictionOK(restrictedTypeName, baseTypeName, types, derivation)
case let (.element(name, _, _), .wildcard(wildcard)):
return wildcard.admits(name)
case let (.wildcard(restrictedWildcard), .wildcard(baseWildcard)):
return narrows(restrictedWildcard, baseWildcard)
case let (.element, .group(baseGroup)):
// RecurseAsIfGroup: the lone element, as a one-particle sequence,
// against the base group.
return groupValid(Group(compositor: .sequence, particles: [restricted.withUnitRange()]), baseGroup, types, derivation)
case let (.group(restrictedGroup), .group(baseGroup)):
// If every member of the derived group is a pointless (maxOccurs=0)
// particle, the group accepts only the empty sequence; that is a valid
// restriction iff the base particle is itself emptiable (by structure
// or its own occurrence). `emptiable(base)` accounts for both, which a
// structural check inside `groupValid` could not.
if restrictedGroup.particles.allSatisfy({ $0.maxOccurs == 0 }) {
return emptiable(base)
}
return groupValid(restrictedGroup, baseGroup, types, derivation)
case let (.group(restrictedGroup), .wildcard(wildcard)):
// NSRecurseCheckCardinality, namespace part: every leaf element
// the group can contain must be admitted by the wildcard.
return leafNames(of: restrictedGroup).allSatisfy { wildcard.admits($0) }
default:
return false
}
}
private static func groupValid(_ restricted: Group, _ base: Group, _ types: [String: ElementType], _ derivation: [String: TypeDerivation]) -> Bool {
// A particle that can never occur (maxOccurs=0) contributes nothing to the
// language, so it is removed before mapping (a "pointless particle"). Left
// in, it would wrongly consume a base particle and starve the particles
// after it (e.g. `(e1 maxOccurs=0, e2)` restricting `(any)`).
let restrictedParticles = restricted.particles.filter { $0.maxOccurs != 0 }
let baseParticles = base.particles.filter { $0.maxOccurs != 0 }
switch (restricted.compositor, base.compositor) {
case (.sequence, .sequence), (.all, .all):
return recurse(restrictedParticles, baseParticles, skippedMustBeEmptiable: true, types, derivation)
case (.choice, .choice):
return recurse(restrictedParticles, baseParticles, skippedMustBeEmptiable: false, types, derivation)
case (.sequence, .choice):
// MapAndSum (simplified): each restricted particle fits some branch.
return restrictedParticles.allSatisfy { particle in
baseParticles.contains { valid(particle, $0, types, derivation) }
}
case (.sequence, .all):
// RecurseUnordered: map onto distinct base particles in any order.
return recurseUnordered(restrictedParticles, baseParticles, types, derivation)
default:
return false
}
}
/// Whether a particle can never contribute a child element or wildcard: it
/// has `maxOccurs=0`, or is a group all of whose members are content-free
/// (an empty group included). Its only legal content is the empty sequence.
private static func contentFree(_ particle: Particle) -> Bool {
if particle.maxOccurs == 0 { return true }
switch particle.term {
case .element, .wildcard:
return false
case let .group(group):
return group.particles.allSatisfy(contentFree)
}
}
/// Order-preserving mapping of the restricted particles onto the base's.
/// A skipped base particle must be emptiable for sequence/all (Recurse);
/// for choice (RecurseLax) skipping is free.
private static func recurse(
_ restricted: [Particle],
_ base: [Particle],
skippedMustBeEmptiable: Bool,
_ types: [String: ElementType],
_ derivation: [String: TypeDerivation],
) -> Bool {
var baseIndex = 0
for particle in restricted {
var matched = false
while baseIndex < base.count {
let candidate = base[baseIndex]
baseIndex += 1
if valid(particle, candidate, types, derivation) {
matched = true
break
}
if skippedMustBeEmptiable, !emptiable(candidate) { return false }
}
if !matched { return false }
}
if skippedMustBeEmptiable {
while baseIndex < base.count {
if !emptiable(base[baseIndex]) { return false }
baseIndex += 1
}
}
return true
}
/// Any-order mapping onto distinct base particles; unmapped base
/// particles must be emptiable.
private static func recurseUnordered(_ restricted: [Particle], _ base: [Particle], _ types: [String: ElementType], _ derivation: [String: TypeDerivation]) -> Bool {
var used = [Bool](repeating: false, count: base.count)
for particle in restricted {
guard let index = base.indices.first(where: { !used[$0] && valid(particle, base[$0], types, derivation) }) else {
return false
}
used[index] = true
}
return base.indices.allSatisfy { used[$0] || emptiable(base[$0]) }
}
/// NameAndTypeOK type clause: the restricting element's type must be, or
/// derive by restriction from, the base element's type. Element types carry
/// their derivation identity as a `typeName` (a built-in, a user type, or
/// `anyType` for an absent type; nil for an inline anonymous type). When both
/// names are known the answer is decided by name; an inline anonymous type on
/// either side is permitted (its derivation identity is not recorded), which
/// can only under-reject, so no valid restriction is rejected.
private static func elementTypeRestrictionOK(
_ restrictedTypeName: String?,
_ baseTypeName: String?,
_ types: [String: ElementType],
_ derivation: [String: TypeDerivation],
) -> Bool {
guard let restrictedTypeName, let baseTypeName else { return true }
// A type validly derives from a union by being one of its members, which
// is not a restriction chain over type names, so when the base resolves to
// a union variety the name check stands down (permit) rather than misread
// the membership as unrelated. (A list base stays checked: a valid
// list-typed restriction is the same named list or a restriction of it,
// which the derivation chain does capture.)
if baseIsUnion(baseTypeName, types) { return true }
return typeDerivesOrEqual(restrictedTypeName, baseTypeName, derivation, types)
}
/// Whether the named type resolves (through the type table) to a `union`
/// simple type.
private static func baseIsUnion(_ name: String, _ types: [String: ElementType]) -> Bool {
var current: ElementType? = types[name]
var steps = 0
while let resolved = current, steps <= types.count {
switch resolved {
case let .simple(simple):
if case .union = simple.variety { return true }
return false
case .complex:
return false
case let .typeReference(key):
current = types[key]
steps += 1
}
}
return false
}
private static let urTypeNames: Set<String> = ["anyType", "anySimpleType", "anyAtomicType"]
private static func isUrTypeName(_ name: String, _ types: [String: ElementType]) -> Bool {
urTypeNames.contains(name) && types[name] == nil
}
/// Whether the type named `derived` is, or is validly derived by restriction
/// from, the type named `base`: any type derives from an ur-type base; a name
/// equal to the base matches; the user-declared restriction chain is walked
/// (`derivation`), and where it bottoms out in a built-in the lattice
/// continues it. Names are compared as the compiler resolves them (by local
/// name), so an unrelated pair (`xs:string` restricting a user type, two
/// independent list types) is correctly not derivable.
private static func typeDerivesOrEqual(_ derived: String, _ base: String, _ derivation: [String: TypeDerivation], _ types: [String: ElementType]) -> Bool {
if isUrTypeName(base, types) { return true }
var current = derived
var visited: Set<String> = []
while visited.insert(current).inserted {
if current == base { return true }
guard let step = derivation[current] else { break }
current = step.base
}
if let derivedBuiltin = BuiltinType(rawValue: current), let baseBuiltin = BuiltinType(rawValue: base) {
return derivedBuiltin.derives(from: baseBuiltin)
}
return current == base
}
/// Occurrence-range subsumption: the restriction may not occur less often
/// than the base requires, nor more often than the base allows.
private static func rangeSubsumed(_ restricted: Particle, _ base: Particle) -> Bool {
guard restricted.minOccurs >= base.minOccurs else { return false }
guard let baseMax = base.maxOccurs else { return true }
guard let restrictedMax = restricted.maxOccurs else { return false }
return restrictedMax <= baseMax
}
/// Whether a particle can match no children at all.
static func emptiable(_ particle: Particle) -> Bool {
if particle.minOccurs == 0 { return true }
switch particle.term {
case .element, .wildcard:
return false
case let .group(group):
switch group.compositor {
case .sequence, .all:
return group.particles.allSatisfy(emptiable)
case .choice:
return group.particles.contains(where: emptiable)
}
}
}
/// Whether the restricted wildcard admits no namespace the base refuses.
private static func narrows(_ restricted: Wildcard, _ base: Wildcard) -> Bool {
switch (restricted.namespace, base.namespace) {
case (_, .any):
true
case (.other, .other):
restricted.targetNamespace == base.targetNamespace
case let (.enumerated(narrow), .enumerated(wide)):
narrow.isSubset(of: wide)
case let (.enumerated(narrow), .other):
!narrow.contains("") && !narrow.contains(base.targetNamespace ?? "")
default:
false
}
}
private static func sameName(_ lhs: PureXML.Model.QualifiedName, _ rhs: PureXML.Model.QualifiedName) -> Bool {
lhs.localName == rhs.localName && (lhs.namespaceURI ?? "") == (rhs.namespaceURI ?? "")
}
/// Every element name a group can contain (its alphabet).
private static func leafNames(of group: Group) -> [PureXML.Model.QualifiedName] {
group.particles.flatMap { particle -> [PureXML.Model.QualifiedName] in
switch particle.term {
case let .element(name, _, _): [name]
case let .group(inner): leafNames(of: inner)
case .wildcard: []
}
}
}
}
}
private extension PureXML.Schema.Particle {
/// A copy occurring exactly once, for RecurseAsIfGroup's wrapping.
func withUnitRange() -> PureXML.Schema.Particle {
.init(minOccurs: minOccurs, maxOccurs: maxOccurs, term: term)
}
}