Convert the three names/UPA/notation-pass compile checkers to located findings so each error attaches to its declaring node instead of the schema root (#169): component-name uniqueness underlines the second duplicate global, identity-constraint name uniqueness underlines the second constraint, a keyref whose refer names no key/unique (or whose arity differs) underlines the keyref (KeyrefInfo gains the keyref node), a Unique Particle Attribution violation underlines the ambiguous content-model group, and notation validity underlines the notation or enumeration. Every predicate, aggregation order, reason string, and the message-dedup of the UPA pass are unchanged; only the location each finding carries differs, verified conformance-neutral by the full XSTS suite (14383 groups, pinned counts unchanged). The findUniqueIndices circular-import (rootIndex) workaround is left byte-identical, since it governs which containers are examined, not where findings locate. The three checkers had exactly one caller each (their dispatch), so each is renamed in place and the .unlocated wrap dropped. Adds a located-diagnostics test per sub-check.
336 lines
17 KiB
Swift
336 lines
17 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):
|
|
true
|
|
case let (.enumerated(lhsSet), .enumerated(rhsSet)):
|
|
!lhsSet.isDisjoint(with: rhsSet)
|
|
default:
|
|
false
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|