Unique Particle Attribution missed overlapping xs:any wildcards involving ##other: wildcardsOverlap returned false for any pair touching .other, so two competing wildcards whose namespaces actually intersect were not flagged as non-deterministic and the schema was wrongly accepted. Compute the overlap correctly: ##other against ##other always overlaps (both admit every namespace but their target and the absent one, infinite sets that always intersect), and ##other against an enumerated set overlaps iff the set names a namespace that is neither absent (##local) nor the target. The new predicate matches Wildcard.admits exactly, so disjoint cases (##other vs ##local, or vs the target namespace) stay deterministic and no valid schema is newly rejected. Verified against the full W3C XSTS 2006-11-06 suite: invalid-schemas-accepted 35 -> 31 (wildI009/wildI013/wildI014 and one more), valid-schemas-rejected held at 0 (no other bucket moved). Adds UPA unit tests for both rejection shapes and a ##other-vs-##local determinism guard; updates the pinned XSTS count 35 -> 31.
352 lines
18 KiB
Swift
352 lines
18 KiB
Swift
public extension PureXML.Schema {
|
|
/// Unique Particle Attribution (cos-nonambig, XSD 1.0 3.8.6): a content model
|
|
/// must be deterministic, so each element in an instance is attributed to one
|
|
/// particle without lookahead. Checked on the Glushkov position automaton: the
|
|
/// model is ambiguous iff some decision set, the start `firstpos` or any
|
|
/// `followpos(p)`, holds two distinct positions whose labels can match the same
|
|
/// element. No DFA subset construction is needed.
|
|
///
|
|
/// This works on a LITERAL view of the raw schema tree, distinct from the
|
|
/// compiled (instance) content model: a `ref` is one position carrying its
|
|
/// resolved QName (prefix bindings, not the blanket target namespace), and
|
|
/// substitution groups are NOT expanded. The overlap test is QName-only (not
|
|
/// substitution-group-aware), so it can only under-report substitution-group
|
|
/// ambiguities, never over-report. Those two choices remove the false overlaps
|
|
/// (cross-namespace refs; expanded substitution members) that a check over the
|
|
/// compiled model produced.
|
|
enum ContentModelDeterminism {
|
|
private static let modelGroupNames: Set<String> = ["sequence", "choice", "all"]
|
|
private static let particleNames: Set<String> = ["element", "sequence", "choice", "all", "group", "any"]
|
|
|
|
/// Every UPA-violation finding across the schema's complex-type content
|
|
/// models, deduplicated by message in first-seen order. Each finding is
|
|
/// located on the content-model node (`sequence`/`choice`/`all`/`group`)
|
|
/// whose particles are ambiguous; a message shared by two models locates on
|
|
/// the first, preserving the deduplicated count.
|
|
static func violationFindings(in schema: XSDTree, context: XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] {
|
|
let bindings = namespaceBindings(schema)
|
|
var findings: [PureXML.Schema.SchemaLocatedFinding] = []
|
|
var seen: Set<String> = []
|
|
forEachComplexType(schema) { complexType in
|
|
guard let model = contentModelNode(complexType),
|
|
let conflict = violation(model, bindings, context)
|
|
else { return }
|
|
if seen.insert(conflict).inserted {
|
|
findings.append(PureXML.Schema.SchemaLocatedFinding(reason: conflict, node: model))
|
|
}
|
|
}
|
|
return findings
|
|
}
|
|
|
|
/// The prefix-to-namespace bindings declared on the schema element (its
|
|
/// `xmlns:prefix` attributes; the default namespace under the empty key).
|
|
private static func namespaceBindings(_ schema: XSDTree) -> [String: String] {
|
|
var bindings: [String: String] = [:]
|
|
for attribute in schema.attributes {
|
|
if attribute.name.prefix == "xmlns" {
|
|
bindings[attribute.name.localName] = attribute.value
|
|
} else if attribute.name.prefix == nil, attribute.name.localName == "xmlns" {
|
|
bindings[""] = attribute.value
|
|
}
|
|
}
|
|
return bindings
|
|
}
|
|
|
|
/// Visits every `complexType` element in the schema document, skipping
|
|
/// foreign annotation content.
|
|
private static func forEachComplexType(_ node: XSDTree, _ visit: (XSDTree) -> Void) {
|
|
let local = PureXML.Schema.XSDNode.localName(node)
|
|
if local == "appinfo" || local == "documentation" { return }
|
|
if local == "complexType" { visit(node) }
|
|
for child in PureXML.Schema.XSDNode.elementChildren(node) {
|
|
forEachComplexType(child, visit)
|
|
}
|
|
}
|
|
|
|
/// The model-group node (`sequence`/`choice`/`all`/`group`) that holds a
|
|
/// complex type's content model, reached through `complexContent` and its
|
|
/// `restriction`/`extension`, or nil for simple or empty content.
|
|
private static func contentModelNode(_ complexType: XSDTree) -> XSDTree? {
|
|
if let direct = modelGroupChild(complexType) { return direct }
|
|
guard let complexContent = PureXML.Schema.XSDNode.firstChild(complexType, named: "complexContent") else { return nil }
|
|
for derivation in PureXML.Schema.XSDNode.elementChildren(complexContent) {
|
|
if let model = modelGroupChild(derivation) { return model }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func modelGroupChild(_ node: XSDTree) -> XSDTree? {
|
|
PureXML.Schema.XSDNode.elementChildren(node).first {
|
|
["sequence", "choice", "all", "group"].contains(PureXML.Schema.XSDNode.localName($0) ?? "")
|
|
}
|
|
}
|
|
|
|
/// A UPA-violation message for the content model rooted at `model`, or nil.
|
|
private static func violation(_ model: XSDTree, _ bindings: [String: String], _ context: XSDContext) -> String? {
|
|
var automaton = PureXML.Schema.PositionAutomaton()
|
|
let root = build(model, &automaton, bindings, context, [])
|
|
// A model that hit the position cap was only partially built; skip it
|
|
// rather than risk a result from an incomplete automaton.
|
|
if automaton.labels.count > positionCap { return nil }
|
|
var decisionSets = automaton.followpos
|
|
decisionSets.append(root.first)
|
|
for set in decisionSets {
|
|
if let conflict = overlap(in: set, automaton: automaton) {
|
|
return "ambiguous content model: '\(conflict)' can be matched by more than one particle"
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// A ceiling on positions per content model. A pathological schema is bounded
|
|
/// here and its UPA check skipped (an under-rejection, never an
|
|
/// over-rejection). Real content models are far below this.
|
|
private static let positionCap = 4096
|
|
|
|
private static func build(
|
|
_ node: XSDTree,
|
|
_ automaton: inout PureXML.Schema.PositionAutomaton,
|
|
_ bindings: [String: String],
|
|
_ context: XSDContext,
|
|
_ visiting: Set<String>,
|
|
) -> PureXML.Schema.Positions {
|
|
if automaton.labels.count > positionCap { return .empty }
|
|
let (minimum, maximum) = PureXML.Schema.XSDNode.occurrence(node)
|
|
// A `maxOccurs="0"` particle can never occur, and a negative maxOccurs is
|
|
// malformed (flagged by occurrence-order validation): either contributes
|
|
// nothing here, and this guards the copy count below against a bad bound.
|
|
if let maximum, maximum <= 0 { return .empty }
|
|
// A fixed count {n,n} is n copies in sequence with no repetition loop, so
|
|
// `b{2,2}, b` stays the deterministic `b, b, b`. The exact count above one
|
|
// does not change the determinism question, so it is clamped to two.
|
|
if let maximum, minimum == maximum {
|
|
let copies = (0 ..< Swift.min(maximum, 2)).map { _ in buildInner(node, &automaton, bindings, context, visiting) }
|
|
return sequence(copies, &automaton)
|
|
}
|
|
// Here min < max. Build one copy. A self-loop (last -> first) is added
|
|
// only when the particle can truly repeat (max unbounded or above one);
|
|
// unrolling a repetition into separate copies would put one element in two
|
|
// positions of a decision set and read as ambiguity, when a repetition of
|
|
// a deterministic body is itself deterministic. A {0,1} is merely optional,
|
|
// not a repetition, so it gets no loop. The floor sets nullability.
|
|
let inner = buildInner(node, &automaton, bindings, context, visiting)
|
|
if maximum == nil || (maximum ?? 0) > 1 {
|
|
for position in inner.last {
|
|
automaton.followpos[position].formUnion(inner.first)
|
|
}
|
|
}
|
|
return PureXML.Schema.Positions(nullable: minimum == 0 || inner.nullable, first: inner.first, last: inner.last)
|
|
}
|
|
|
|
private static func buildInner(
|
|
_ node: XSDTree,
|
|
_ automaton: inout PureXML.Schema.PositionAutomaton,
|
|
_ bindings: [String: String],
|
|
_ context: XSDContext,
|
|
_ visiting: Set<String>,
|
|
) -> PureXML.Schema.Positions {
|
|
switch PureXML.Schema.XSDNode.localName(node) {
|
|
case "element":
|
|
let position = automaton.position(.name(elementQName(node, bindings, context)), node)
|
|
return PureXML.Schema.Positions(nullable: false, first: [position], last: [position])
|
|
case "any":
|
|
let position = automaton.position(.wildcard(PureXML.Schema.XSDParser.wildcard(node, context)), node)
|
|
return PureXML.Schema.Positions(nullable: false, first: [position], last: [position])
|
|
case "sequence":
|
|
return sequence(childSets(node, &automaton, bindings, context, visiting), &automaton)
|
|
case "choice":
|
|
return choice(childSets(node, &automaton, bindings, context, visiting))
|
|
case "all":
|
|
return all(childSets(node, &automaton, bindings, context, visiting), &automaton)
|
|
case "group":
|
|
return groupReference(node, &automaton, bindings, context, visiting)
|
|
default:
|
|
return .empty
|
|
}
|
|
}
|
|
|
|
private static func childSets(
|
|
_ node: XSDTree,
|
|
_ automaton: inout PureXML.Schema.PositionAutomaton,
|
|
_ bindings: [String: String],
|
|
_ context: XSDContext,
|
|
_ visiting: Set<String>,
|
|
) -> [PureXML.Schema.Positions] {
|
|
PureXML.Schema.XSDNode.elementChildren(node)
|
|
.filter { particleNames.contains(PureXML.Schema.XSDNode.localName($0) ?? "") }
|
|
.map { build($0, &automaton, bindings, context, visiting) }
|
|
}
|
|
|
|
/// Inlines a `group` reference's model group (its sole `all`/`choice`/
|
|
/// `sequence`), guarding against a cyclic reference.
|
|
private static func groupReference(
|
|
_ node: XSDTree,
|
|
_ automaton: inout PureXML.Schema.PositionAutomaton,
|
|
_ bindings: [String: String],
|
|
_ context: XSDContext,
|
|
_ visiting: Set<String>,
|
|
) -> PureXML.Schema.Positions {
|
|
guard let ref = PureXML.Schema.XSDNode.attribute(node, "ref") else { return .empty }
|
|
let name = PureXML.Schema.XSDNode.stripPrefix(ref)
|
|
guard !visiting.contains(name), let definition = context.groups[name],
|
|
let model = PureXML.Schema.XSDNode.elementChildren(definition).first(where: { modelGroupNames.contains(PureXML.Schema.XSDNode.localName($0) ?? "") })
|
|
else { return .empty }
|
|
return build(model, &automaton, bindings, context, visiting.union([name]))
|
|
}
|
|
|
|
private static func elementQName(_ node: XSDTree, _ bindings: [String: String], _ context: XSDContext) -> PureXML.Model.QualifiedName {
|
|
if let ref = PureXML.Schema.XSDNode.attribute(node, "ref") {
|
|
return resolveQName(ref, bindings, context)
|
|
}
|
|
let name = PureXML.Schema.XSDNode.attribute(node, "name") ?? ""
|
|
let form = PureXML.Schema.XSDNode.attribute(node, "form")
|
|
let qualified = form == "qualified" || (form == nil && context.elementFormQualified)
|
|
return PureXML.Model.QualifiedName(localName: name, namespaceURI: qualified ? context.targetNamespace : nil)
|
|
}
|
|
|
|
/// Resolves a QName reference to (localName, namespaceURI): a prefix maps
|
|
/// through the schema's bindings (or stands for itself if unbound, keeping
|
|
/// distinct prefixes distinct); an unprefixed name takes the default
|
|
/// namespace binding.
|
|
private static func resolveQName(_ qname: String, _ bindings: [String: String], _: XSDContext) -> PureXML.Model.QualifiedName {
|
|
let parts = qname.split(separator: ":", maxSplits: 1).map(String.init)
|
|
if parts.count == 2 {
|
|
return PureXML.Model.QualifiedName(localName: parts[1], namespaceURI: bindings[parts[0]] ?? parts[0])
|
|
}
|
|
return PureXML.Model.QualifiedName(localName: qname, namespaceURI: bindings[""])
|
|
}
|
|
|
|
private static func sequence(_ children: [PureXML.Schema.Positions], _ automaton: inout PureXML.Schema.PositionAutomaton) -> PureXML.Schema.Positions {
|
|
var first: Set<Int> = []
|
|
var prefixNullable = true
|
|
var preceding: Set<Int> = []
|
|
for child in children {
|
|
if prefixNullable { first.formUnion(child.first) }
|
|
for position in preceding {
|
|
automaton.followpos[position].formUnion(child.first)
|
|
}
|
|
preceding = child.nullable ? preceding.union(child.last) : child.last
|
|
if !child.nullable { prefixNullable = false }
|
|
}
|
|
var last: Set<Int> = []
|
|
var suffixNullable = true
|
|
for child in children.reversed() {
|
|
if suffixNullable { last.formUnion(child.last) }
|
|
if !child.nullable { suffixNullable = false }
|
|
}
|
|
return PureXML.Schema.Positions(nullable: children.allSatisfy(\.nullable), first: first, last: last)
|
|
}
|
|
|
|
private static func choice(_ children: [PureXML.Schema.Positions]) -> PureXML.Schema.Positions {
|
|
PureXML.Schema.Positions(
|
|
nullable: children.isEmpty || children.contains(where: \.nullable),
|
|
first: children.reduce(into: Set<Int>()) { $0.formUnion($1.first) },
|
|
last: children.reduce(into: Set<Int>()) { $0.formUnion($1.last) },
|
|
)
|
|
}
|
|
|
|
/// `all`: members are order-independent, so each may follow every other and
|
|
/// the members form one decision set; two same-named members then overlap.
|
|
private static func all(_ children: [PureXML.Schema.Positions], _ automaton: inout PureXML.Schema.PositionAutomaton) -> PureXML.Schema.Positions {
|
|
for source in children {
|
|
for target in children where target.first != source.first {
|
|
for position in source.last {
|
|
automaton.followpos[position].formUnion(target.first)
|
|
}
|
|
}
|
|
}
|
|
return choice(children)
|
|
}
|
|
|
|
private static func overlap(in set: Set<Int>, automaton: PureXML.Schema.PositionAutomaton) -> String? {
|
|
let positions = set.sorted()
|
|
for outer in 0 ..< positions.count {
|
|
let outerPosition = positions[outer]
|
|
for inner in (outer + 1) ..< positions.count {
|
|
let innerPosition = positions[inner]
|
|
// Two positions conflict only if they are distinct particles whose
|
|
// labels can match the same element; one particle's own repetition
|
|
// (same source) is deterministic by construction.
|
|
guard automaton.particles[outerPosition] != automaton.particles[innerPosition],
|
|
labelsOverlap(automaton.labels[outerPosition], automaton.labels[innerPosition])
|
|
else { continue }
|
|
return describe(automaton.labels[outerPosition])
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func labelsOverlap(_ lhs: TermLabel, _ rhs: TermLabel) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case let (.name(lhsName), .name(rhsName)):
|
|
lhsName.localName == rhsName.localName && lhsName.namespaceURI == rhsName.namespaceURI
|
|
case let (.name(name), .wildcard(wildcard)), let (.wildcard(wildcard), .name(name)):
|
|
wildcard.admits(name)
|
|
case let (.wildcard(lhsCard), .wildcard(rhsCard)):
|
|
wildcardsOverlap(lhsCard, rhsCard)
|
|
}
|
|
}
|
|
|
|
private static func wildcardsOverlap(_ lhs: Wildcard, _ rhs: Wildcard) -> Bool {
|
|
switch (lhs.namespace, rhs.namespace) {
|
|
case (.any, _), (_, .any):
|
|
// `##any` admits every name, so it shares names with any other constraint.
|
|
true
|
|
case (.other, .other):
|
|
// Two `##other` constraints each admit every namespace but their own
|
|
// target (and the absent namespace); those infinite sets always
|
|
// intersect, so they overlap (XSTS wildI* non-deterministic choices).
|
|
true
|
|
case let (.enumerated(lhsSet), .enumerated(rhsSet)):
|
|
!lhsSet.isDisjoint(with: rhsSet)
|
|
case let (.other, .enumerated(set)):
|
|
otherOverlapsEnumerated(set, target: lhs.targetNamespace)
|
|
case let (.enumerated(set), .other):
|
|
otherOverlapsEnumerated(set, target: rhs.targetNamespace)
|
|
}
|
|
}
|
|
|
|
/// Whether `##other` (any namespace but the target and the absent namespace)
|
|
/// shares a name with an enumerated set: it does iff the set names some
|
|
/// namespace that is neither absent (the empty string, `##local`) nor the
|
|
/// target namespace `##other` excludes.
|
|
private static func otherOverlapsEnumerated(_ set: Set<String>, target: String?) -> Bool {
|
|
set.contains { !$0.isEmpty && $0 != (target ?? "") }
|
|
}
|
|
|
|
private static func describe(_ label: TermLabel) -> String {
|
|
switch label {
|
|
case let .name(name): name.localName
|
|
case .wildcard: "any"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The firstpos/lastpos/nullable summary of a sub-model, used while building the
|
|
/// position automaton.
|
|
private struct Positions {
|
|
let nullable: Bool
|
|
let first: Set<Int>
|
|
let last: Set<Int>
|
|
|
|
static let empty = Positions(nullable: true, first: [], last: [])
|
|
}
|
|
|
|
/// The Glushkov position automaton under construction: one entry per position
|
|
/// for its transition label, its `followpos` set, and the source particle it
|
|
/// came from (Unique Particle Attribution is per particle, so a particle never
|
|
/// conflicts with a repetition of itself).
|
|
private struct PositionAutomaton {
|
|
var labels: [TermLabel] = []
|
|
var followpos: [Set<Int>] = []
|
|
var particles: [ObjectIdentifier] = []
|
|
|
|
mutating func position(_ label: TermLabel, _ source: XSDTree) -> Int {
|
|
labels.append(label)
|
|
followpos.append([])
|
|
particles.append(ObjectIdentifier(source))
|
|
return labels.count - 1
|
|
}
|
|
}
|
|
}
|