A content-model violation is now placed at the offending child with a recovery hint naming what was expected, instead of one opaque "content does not match the content model" per element. The sequence/choice automaton is walked child by child via its follow-set to flag the first child the model rejects, or the incomplete content at the end; an xs:all group recovers past each stray child and reports every missing required member. Well-placed children are still validated for their own content, so an editor sees every problem at once. Brings XSD to the same located, recovering standard as the RELAX NG, DTD, and Schematron validators.
345 lines
17 KiB
Swift
345 lines
17 KiB
Swift
/// File-scope aliases for the XSD complex-type validator, kept out of the
|
|
/// namespace to avoid nesting a type two levels deep.
|
|
private typealias XSDFailure = PureXML.Validation.ValidationError
|
|
private typealias XSDPath = [PureXML.Validation.PathKey]
|
|
private typealias XSDGroup = PureXML.Schema.Group
|
|
private typealias XSDTerm = PureXML.Schema.Term
|
|
private typealias XSDTermLabel = PureXML.Schema.TermLabel
|
|
private typealias XSDProcessContents = PureXML.Schema.ProcessContents
|
|
private typealias XSDElementType = PureXML.Schema.ElementType
|
|
private typealias XSDWildcard = PureXML.Schema.Wildcard
|
|
|
|
public extension PureXML.Schema {
|
|
/// Validates an element against a ``ComplexType``: its attribute uses, its
|
|
/// content model, and, recursively, each child element against the type
|
|
/// declared for its name. Every violation is located by the element's coding
|
|
/// path, and the results are ``PureXML/Validation/ValidationError`` values.
|
|
struct ComplexValidator {
|
|
/// The named type table that `ElementType.typeReference` resolves against.
|
|
let types: [String: ElementType]
|
|
/// Local names of elements declared `nillable="true"`.
|
|
private let nillableElements: Set<String>
|
|
/// The `default`/`fixed` value constraint declared on each element name.
|
|
private let elementConstraints: [String: ValueConstraint]
|
|
/// Local names of complex types declared `abstract="true"`.
|
|
let abstractTypes: Set<String>
|
|
/// Derivation methods each named type forbids through `xsi:type`.
|
|
let typeBlock: [String: Set<DerivationMethod>]
|
|
/// Each named complex type's base and derivation method.
|
|
let typeDerivation: [String: TypeDerivation]
|
|
|
|
public init(
|
|
types: [String: ElementType] = [:],
|
|
nillableElements: Set<String> = [],
|
|
elementConstraints: [String: ValueConstraint] = [:],
|
|
abstractTypes: Set<String> = [],
|
|
typeBlock: [String: Set<DerivationMethod>] = [:],
|
|
typeDerivation: [String: TypeDerivation] = [:],
|
|
) {
|
|
self.types = types
|
|
self.nillableElements = nillableElements
|
|
self.elementConstraints = elementConstraints
|
|
self.abstractTypes = abstractTypes
|
|
self.typeBlock = typeBlock
|
|
self.typeDerivation = typeDerivation
|
|
}
|
|
|
|
/// Validates `element` against `type` at `path`, one error per violation.
|
|
public func validate(
|
|
_ element: PureXML.Model.Element,
|
|
against type: ComplexType,
|
|
at path: [PureXML.Validation.PathKey] = [],
|
|
) -> [PureXML.Validation.ValidationError] {
|
|
var errors: [XSDFailure] = []
|
|
validateAttributes(element, type, at: path, into: &errors)
|
|
// An xsi:nil element keeps its attribute obligations but must have no
|
|
// content; its content model is not otherwise checked.
|
|
if let nilErrors = nilErrors(element, at: path) {
|
|
return errors + nilErrors
|
|
}
|
|
validateContent(element, type.content, at: path, into: &errors)
|
|
errors += elementFixedErrors(element, at: path)
|
|
return errors
|
|
}
|
|
|
|
/// The errors from an `xsi:nil="true"` element: rejecting it when the
|
|
/// element is not nillable, or when it carries content. Returns nil when
|
|
/// the element is not nilled.
|
|
private func nilErrors(_ element: PureXML.Model.Element, at path: XSDPath) -> [XSDFailure]? {
|
|
guard Self.isNil(element) else { return nil }
|
|
let name = element.name.localName
|
|
if !nillableElements.contains(name) {
|
|
return [XSDFailure(reason: "element '\(name)' is not nillable but has xsi:nil", at: path)]
|
|
}
|
|
if Self.hasContent(element) {
|
|
return [XSDFailure(reason: "nil element '\(name)' must be empty", at: path)]
|
|
}
|
|
return []
|
|
}
|
|
|
|
/// The error from a `fixed` element value constraint: the element's text
|
|
/// must equal the fixed value.
|
|
private func elementFixedErrors(_ element: PureXML.Model.Element, at path: XSDPath) -> [XSDFailure] {
|
|
guard let fixed = elementConstraints[element.name.localName]?.fixedValue else { return [] }
|
|
let text = Self.textContent(element)
|
|
return text == fixed ? [] : [XSDFailure(reason: "element '\(element.name.localName)' is fixed and must be '\(fixed)'", at: path)]
|
|
}
|
|
|
|
/// Validates `element` against any element type at `path`, resolving a
|
|
/// `typeReference` through the type table.
|
|
public func validate(
|
|
_ element: PureXML.Model.Element,
|
|
as type: ElementType,
|
|
at path: [PureXML.Validation.PathKey] = [],
|
|
) -> [PureXML.Validation.ValidationError] {
|
|
var errors: [XSDFailure] = []
|
|
validateChild(element, against: type, at: path, into: &errors)
|
|
return errors
|
|
}
|
|
|
|
// MARK: Attributes
|
|
|
|
private func validateAttributes(
|
|
_ element: PureXML.Model.Element,
|
|
_ type: ComplexType,
|
|
at path: XSDPath,
|
|
into errors: inout [XSDFailure],
|
|
) {
|
|
let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) && !Self.isSchemaInstanceAttribute($0) }
|
|
for use in type.attributes {
|
|
let match = present.first { Self.sameName($0.name, use.name) }
|
|
if let match {
|
|
if let error = use.type.validate(match.value) {
|
|
errors.append(XSDFailure(reason: "attribute '\(use.name.localName)': \(error)", at: path))
|
|
}
|
|
if let fixed = use.valueConstraint?.fixedValue, match.value != fixed {
|
|
errors.append(XSDFailure(reason: "attribute '\(use.name.localName)' is fixed and must be '\(fixed)'", at: path))
|
|
}
|
|
} else if use.required {
|
|
errors.append(XSDFailure(reason: "missing required attribute '\(use.name.localName)'", at: path))
|
|
}
|
|
}
|
|
// An undeclared attribute is allowed only if an xs:anyAttribute wildcard
|
|
// admits its namespace.
|
|
for attribute in present where !type.attributes.contains(where: { Self.sameName($0.name, attribute.name) }) {
|
|
if type.attributeWildcard?.admits(attribute.name) == true { continue }
|
|
errors.append(XSDFailure(reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
|
|
}
|
|
}
|
|
|
|
// MARK: Content
|
|
|
|
private func validateContent(
|
|
_ element: PureXML.Model.Element,
|
|
_ content: ContentType,
|
|
at path: XSDPath,
|
|
into errors: inout [XSDFailure],
|
|
) {
|
|
let children = element.children.compactMap(\.element)
|
|
switch content {
|
|
case .empty:
|
|
if !children.isEmpty { errors.append(XSDFailure(reason: "element must be empty", at: path)) }
|
|
rejectText(element, at: path, into: &errors)
|
|
case let .simpleContent(type):
|
|
if !children.isEmpty { errors.append(XSDFailure(reason: "element must not have children", at: path)) }
|
|
if let error = type.validate(Self.textContent(element)) {
|
|
errors.append(XSDFailure(reason: "content: \(error)", at: path))
|
|
}
|
|
case let .elementOnly(particle):
|
|
rejectText(element, at: path, into: &errors)
|
|
validateParticle(particle, children: children, at: path, into: &errors)
|
|
case let .mixed(particle):
|
|
validateParticle(particle, children: children, at: path, into: &errors)
|
|
}
|
|
}
|
|
|
|
private func rejectText(_ element: PureXML.Model.Element, at path: XSDPath, into errors: inout [XSDFailure]) {
|
|
guard !Self.textContent(element).isEmpty else { return }
|
|
errors.append(XSDFailure(reason: "element must not contain text", at: path))
|
|
}
|
|
|
|
// MARK: Particles
|
|
|
|
private func validateParticle(
|
|
_ particle: Particle,
|
|
children: [PureXML.Model.Element],
|
|
at path: XSDPath,
|
|
into errors: inout [XSDFailure],
|
|
) {
|
|
// Locate each content-model violation at the offending child (or the
|
|
// missing one), with a recovery hint, rather than one opaque failure,
|
|
// then still validate every well-placed child's own content.
|
|
if case let .group(group) = particle.term, group.compositor == .all {
|
|
allStructureErrors(group, children: children, at: path, into: &errors)
|
|
} else {
|
|
sequenceStructureErrors(particle, children: children, at: path, into: &errors)
|
|
}
|
|
validateChildren(
|
|
children,
|
|
childTypes: Self.elementTypes(in: particle.term),
|
|
wildcard: Self.wildcard(in: particle.term),
|
|
at: path,
|
|
into: &errors,
|
|
)
|
|
}
|
|
|
|
private func validateChildren(
|
|
_ children: [PureXML.Model.Element],
|
|
childTypes: [String: ElementType],
|
|
wildcard: ProcessContents?,
|
|
at path: XSDPath,
|
|
into errors: inout [XSDFailure],
|
|
) {
|
|
let steps = Self.childSteps(children)
|
|
for (child, step) in zip(children, steps) {
|
|
if let declared = childTypes[Self.key(child.name)] {
|
|
validateChild(child, against: declared, at: path + [step], into: &errors)
|
|
} else if let wildcard {
|
|
// The structure already matched, so an undeclared child here was
|
|
// admitted by a wildcard; process its content per the wildcard.
|
|
validateWildcardChild(child, processContents: wildcard, at: path + [step], into: &errors)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validates a wildcard-matched child by its `processContents`: skip does
|
|
/// nothing, lax validates against a global declaration when one exists, and
|
|
/// strict requires one.
|
|
private func validateWildcardChild(
|
|
_ child: PureXML.Model.Element,
|
|
processContents: ProcessContents,
|
|
at path: XSDPath,
|
|
into errors: inout [XSDFailure],
|
|
) {
|
|
switch processContents {
|
|
case .skip:
|
|
return
|
|
case .lax:
|
|
if let declaration = types["element:\(child.name.localName)"] {
|
|
validateChild(child, against: declaration, at: path, into: &errors)
|
|
}
|
|
case .strict:
|
|
guard let declaration = types["element:\(child.name.localName)"] else {
|
|
errors.append(XSDFailure(reason: "no declaration for wildcard-matched element '\(child.name.localName)'", at: path))
|
|
return
|
|
}
|
|
validateChild(child, against: declaration, at: path, into: &errors)
|
|
}
|
|
}
|
|
|
|
private func validateChild(
|
|
_ child: PureXML.Model.Element,
|
|
against declared: ElementType,
|
|
at path: XSDPath,
|
|
into errors: inout [XSDFailure],
|
|
) {
|
|
// An instance `xsi:type` overrides the declared type, provided the named
|
|
// type exists in the schema. Resolve to the type itself rather than a
|
|
// reference so re-entry does not re-read the same xsi:type. A `block` on
|
|
// the declared type can forbid the substitution; an abstract declared
|
|
// type requires one.
|
|
if let blocked = blockedSubstitutionError(declared: declared, child: child, at: path) {
|
|
errors.append(blocked)
|
|
return
|
|
}
|
|
if case let .typeReference(name) = declared, let missing = abstractTypeError(named: name, child: child, at: path) {
|
|
errors.append(missing)
|
|
return
|
|
}
|
|
var declared = declared
|
|
if let overriding = Self.xsiTypeName(child), let resolved = types[overriding] {
|
|
declared = resolved
|
|
}
|
|
switch declared {
|
|
case let .simple(simple):
|
|
if let nilErrors = nilErrors(child, at: path) {
|
|
errors += nilErrors
|
|
return
|
|
}
|
|
if !child.children.compactMap(\.element).isEmpty {
|
|
errors.append(XSDFailure(reason: "'\(child.name.localName)' must not have children", at: path))
|
|
}
|
|
if let error = simple.validate(Self.textContent(child)) {
|
|
errors.append(XSDFailure(reason: "'\(child.name.localName)': \(error)", at: path))
|
|
}
|
|
errors += elementFixedErrors(child, at: path)
|
|
case let .complex(complex):
|
|
errors.append(contentsOf: validate(child, against: complex, at: path))
|
|
case let .typeReference(name):
|
|
guard let resolved = types[name] else {
|
|
errors.append(XSDFailure(reason: "unknown type '\(name)'", at: path))
|
|
return
|
|
}
|
|
validateChild(child, against: resolved, at: path, into: &errors)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Located content-model diagnostics: pinpoint which child breaks the model and
|
|
/// what was expected there, so an editor shows placed errors with recovery hints
|
|
/// rather than one opaque "content does not match" per element.
|
|
private extension PureXML.Schema.ComplexValidator {
|
|
/// Walks the children through the content automaton, flagging the first child
|
|
/// the follow-set rejects, or the missing content when the sequence ends early.
|
|
func sequenceStructureErrors(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFailure]) {
|
|
let nfa = PureXML.Schema.ContentNFABuilder.build(particle)
|
|
let steps = Self.childSteps(children)
|
|
var prefix: [PureXML.Model.QualifiedName] = []
|
|
for (index, child) in children.enumerated() {
|
|
let allowed = nfa.follow(after: prefix).allowed
|
|
if allowed.contains(where: { $0.matches(child.name) }) {
|
|
prefix.append(child.name)
|
|
} else {
|
|
errors.append(XSDFailure(reason: "element '\(child.name.localName)' is not allowed here\(Self.expectation(allowed))", at: path + [steps[index]]))
|
|
return
|
|
}
|
|
}
|
|
let (allowed, complete) = nfa.follow(after: prefix)
|
|
if !complete {
|
|
errors.append(XSDFailure(reason: "content is incomplete\(Self.expectation(allowed))", at: path))
|
|
}
|
|
}
|
|
|
|
/// Locates `all`-group violations: each child that is not an in-bounds member,
|
|
/// recovering past it, then each required member that never appeared.
|
|
func allStructureErrors(_ group: PureXML.Schema.Group, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFailure]) {
|
|
var counts = [Int](repeating: 0, count: group.particles.count)
|
|
let steps = Self.childSteps(children)
|
|
for (index, child) in children.enumerated() {
|
|
guard let position = group.particles.indices.first(where: { slot in
|
|
let member = group.particles[slot]
|
|
let room = member.maxOccurs.map { counts[slot] < $0 } ?? true
|
|
return room && Self.memberMatches(member.term, child.name)
|
|
}) else {
|
|
errors.append(XSDFailure(reason: "element '\(child.name.localName)' is not allowed here", at: path + [steps[index]]))
|
|
continue
|
|
}
|
|
counts[position] += 1
|
|
}
|
|
for (index, member) in group.particles.enumerated() where counts[index] < member.minOccurs {
|
|
if case let .element(name, _) = member.term {
|
|
errors.append(XSDFailure(reason: "element '\(name.localName)' is required but missing", at: path))
|
|
}
|
|
}
|
|
}
|
|
|
|
static func memberMatches(_ term: PureXML.Schema.Term, _ name: PureXML.Model.QualifiedName) -> Bool {
|
|
switch term {
|
|
case let .element(declared, _): declared.localName == name.localName && declared.namespaceURI == name.namespaceURI
|
|
case let .wildcard(wildcard): wildcard.admits(name)
|
|
case .group: false
|
|
}
|
|
}
|
|
|
|
/// A "; expected a, b" hint naming the elements the automaton accepts next.
|
|
static func expectation(_ labels: [PureXML.Schema.TermLabel]) -> String {
|
|
let names = labels.compactMap { label -> String? in
|
|
if case let .name(qualified) = label { return "<\(qualified.localName)>" }
|
|
return nil
|
|
}
|
|
let unique = Set(names).sorted()
|
|
return unique.isEmpty ? "" : "; expected \(unique.joined(separator: ", "))"
|
|
}
|
|
}
|