diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index ca9eba2..cfdf203 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -16,20 +16,32 @@ public extension PureXML.Schema { /// path, and the results are ``PureXML/Validation/ValidationError`` values. struct ComplexValidator { /// The named type table that `ElementType.typeReference` resolves against. - private let types: [String: ElementType] + let types: [String: ElementType] /// Local names of elements declared `nillable="true"`. private let nillableElements: Set /// 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 + /// Derivation methods each named type forbids through `xsi:type`. + let typeBlock: [String: Set] + /// Each named complex type's base and derivation method. + let typeDerivation: [String: TypeDerivation] public init( types: [String: ElementType] = [:], nillableElements: Set = [], elementConstraints: [String: ValueConstraint] = [:], + abstractTypes: Set = [], + typeBlock: [String: Set] = [:], + 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. @@ -225,7 +237,17 @@ public extension PureXML.Schema { ) { // 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. + // 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 @@ -255,137 +277,3 @@ public extension PureXML.Schema { } } } - -extension PureXML.Schema.ComplexValidator { - // MARK: Helpers - - /// The coding-path step for each child: its name, with a sibling index only - /// when more than one child shares that name. - static func childSteps(_ children: [PureXML.Model.Element]) -> [PureXML.Validation.PathKey] { - var totals: [String: Int] = [:] - for child in children { - totals[child.name.description, default: 0] += 1 - } - var seen: [String: Int] = [:] - return children.map { child in - let name = child.name.description - let index = (seen[name] ?? 0) + 1 - seen[name] = index - return (totals[name] ?? 0) > 1 ? .element(name, index: index) : .element(name) - } - } - - /// Validates an `all` group order-independently: every child matches a - /// member within its occurrence bounds, and each member meets its minimum. - fileprivate static func matchesAll(_ group: XSDGroup, names: [PureXML.Model.QualifiedName]) -> Bool { - var counts = [Int](repeating: 0, count: group.particles.count) - for name in names { - guard let index = group.particles.indices.first(where: { position in - let member = group.particles[position] - let room = member.maxOccurs.map { counts[position] < $0 } ?? true - return room && label(of: member.term).matches(name) - }) else { return false } - counts[index] += 1 - } - for (index, member) in group.particles.enumerated() where counts[index] < member.minOccurs { - return false - } - return true - } - - fileprivate static func label(of term: XSDTerm) -> XSDTermLabel { - switch term { - case let .element(name, _): .name(name) - case let .wildcard(wildcard): .wildcard(wildcard) - case .group: .wildcard(XSDWildcard()) - } - } - - /// The `processContents` of a wildcard reachable in `term`, if the content - /// model contains one. - fileprivate static func wildcard(in term: XSDTerm) -> XSDProcessContents? { - switch term { - case let .wildcard(wildcard): - return wildcard.processContents - case let .group(group): - for member in group.particles { - if let found = wildcard(in: member.term) { return found } - } - return nil - case .element: - return nil - } - } - - fileprivate static func elementTypes(in term: XSDTerm) -> [String: XSDElementType] { - var result: [String: XSDElementType] = [:] - collectTypes(term, into: &result) - return result - } - - fileprivate static func collectTypes(_ term: XSDTerm, into result: inout [String: XSDElementType]) { - switch term { - case let .element(name, type): - if let type { result[key(name)] = type } - case let .group(group): - for member in group.particles { - collectTypes(member.term, into: &result) - } - case .wildcard: - break - } - } - - static func key(_ name: PureXML.Model.QualifiedName) -> String { - "{\(name.namespaceURI ?? "")}\(name.localName)" - } - - static func textContent(_ element: PureXML.Model.Element) -> String { - let text = element.children.reduce(into: "") { result, child in - switch child { - case let .text(value), let .cdata(value): result += value - default: break - } - } - return text.trimmingXMLWhitespace() - } - - static func isNamespaceDeclaration(_ attribute: PureXML.Model.Attribute) -> Bool { - attribute.name.prefix == "xmlns" || (attribute.name.prefix == nil && attribute.name.localName == "xmlns") - } - - /// Whether an attribute belongs to the XML Schema instance namespace - /// (`xsi:type`, `xsi:nil`, and the schema-location hints), which are - /// processing directives rather than declared attributes. - static func isSchemaInstanceAttribute(_ attribute: PureXML.Model.Attribute) -> Bool { - attribute.name.namespaceURI == "http://www.w3.org/2001/XMLSchema-instance" || attribute.name.prefix == "xsi" - } - - /// The local name of an element's `xsi:type` attribute value, or nil when it - /// carries none. Recognizes the attribute by the XML Schema instance - /// namespace or, failing namespace resolution, the conventional `xsi` prefix. - static func xsiTypeName(_ element: PureXML.Model.Element) -> String? { - let schemaInstance = "http://www.w3.org/2001/XMLSchema-instance" - let match = element.attributes.first { attribute in - attribute.name.localName == "type" - && (attribute.name.namespaceURI == schemaInstance || attribute.name.prefix == "xsi") - } - return match.map { $0.value.split(separator: ":").last.map(String.init) ?? $0.value } - } -} - -extension PureXML.Schema.ComplexValidator { - /// Whether the element carries `xsi:nil="true"`. - static func isNil(_ element: PureXML.Model.Element) -> Bool { - element.attributes.contains { attribute in - attribute.name.localName == "nil" - && (attribute.name.namespaceURI == "http://www.w3.org/2001/XMLSchema-instance" || attribute.name.prefix == "xsi") - && attribute.value == "true" - } - } - - /// Whether the element has any child element or non-whitespace text. - static func hasContent(_ element: PureXML.Model.Element) -> Bool { - !textContent(element).isEmpty || element.children.contains { if case .element = $0 { true } else { false } } - } -} diff --git a/Sources/Schema/ComplexValidatorDerivation.swift b/Sources/Schema/ComplexValidatorDerivation.swift new file mode 100644 index 0000000..64e5550 --- /dev/null +++ b/Sources/Schema/ComplexValidatorDerivation.swift @@ -0,0 +1,39 @@ +extension PureXML.Schema.ComplexValidator { + /// The error when an element's declared type is an abstract complex type and + /// the element supplies no `xsi:type` to name a concrete derived type. An + /// abstract type cannot itself be the type of an instance element. + func abstractTypeError( + named name: String, + child: PureXML.Model.Element, + at path: [PureXML.Validation.PathKey], + ) -> PureXML.Validation.ValidationError? { + guard abstractTypes.contains(name), Self.xsiTypeName(child) == nil else { return nil } + return PureXML.Validation.ValidationError( + reason: "element '\(child.name.localName)' has abstract type '\(name)' and requires an xsi:type naming a concrete derived type", + at: path, + ) + } + + /// The error when an `xsi:type` substitution is forbidden by `block` on the + /// declared type: the substituted type reaches the declared type by a method + /// the declared type lists in `block`. Returns nil when there is no named + /// declared type, no `block`, or the substitution is permitted. + func blockedSubstitutionError( + declared: PureXML.Schema.ElementType, + child: PureXML.Model.Element, + at path: [PureXML.Validation.PathKey], + ) -> PureXML.Validation.ValidationError? { + guard case let .typeReference(declaredName) = declared, + let substitute = Self.xsiTypeName(child), types[substitute] != nil, + let blocked = typeBlock[declaredName], + let methods = PureXML.Schema.XSDParser.derivationMethods(from: substitute, to: declaredName, typeDerivation), + !methods.isDisjoint(with: blocked) + else { + return nil + } + return PureXML.Validation.ValidationError( + reason: "xsi:type '\(substitute)' is blocked: type '\(declaredName)' disallows substitution by this derivation", + at: path, + ) + } +} diff --git a/Sources/Schema/ComplexValidatorHelpers.swift b/Sources/Schema/ComplexValidatorHelpers.swift new file mode 100644 index 0000000..06698e9 --- /dev/null +++ b/Sources/Schema/ComplexValidatorHelpers.swift @@ -0,0 +1,140 @@ +/// File-scope aliases for the complex-type validator's helpers, kept out of the +/// namespace to avoid nesting a type two levels deep in a dotted extension. +private typealias XSDTerm = PureXML.Schema.Term +private typealias XSDTermLabel = PureXML.Schema.TermLabel +private typealias XSDElementType = PureXML.Schema.ElementType +private typealias XSDWildcard = PureXML.Schema.Wildcard + +extension PureXML.Schema.ComplexValidator { + // MARK: Helpers + + /// The coding-path step for each child: its name, with a sibling index only + /// when more than one child shares that name. + static func childSteps(_ children: [PureXML.Model.Element]) -> [PureXML.Validation.PathKey] { + var totals: [String: Int] = [:] + for child in children { + totals[child.name.description, default: 0] += 1 + } + var seen: [String: Int] = [:] + return children.map { child in + let name = child.name.description + let index = (seen[name] ?? 0) + 1 + seen[name] = index + return (totals[name] ?? 0) > 1 ? .element(name, index: index) : .element(name) + } + } + + /// Validates an `all` group order-independently: every child matches a + /// member within its occurrence bounds, and each member meets its minimum. + static func matchesAll(_ group: PureXML.Schema.Group, names: [PureXML.Model.QualifiedName]) -> Bool { + var counts = [Int](repeating: 0, count: group.particles.count) + for name in names { + guard let index = group.particles.indices.first(where: { position in + let member = group.particles[position] + let room = member.maxOccurs.map { counts[position] < $0 } ?? true + return room && label(of: member.term).matches(name) + }) else { return false } + counts[index] += 1 + } + for (index, member) in group.particles.enumerated() where counts[index] < member.minOccurs { + return false + } + return true + } + + fileprivate static func label(of term: XSDTerm) -> XSDTermLabel { + switch term { + case let .element(name, _): .name(name) + case let .wildcard(wildcard): .wildcard(wildcard) + case .group: .wildcard(XSDWildcard()) + } + } + + /// The `processContents` of a wildcard reachable in `term`, if the content + /// model contains one. + static func wildcard(in term: PureXML.Schema.Term) -> PureXML.Schema.ProcessContents? { + switch term { + case let .wildcard(wildcard): + return wildcard.processContents + case let .group(group): + for member in group.particles { + if let found = wildcard(in: member.term) { return found } + } + return nil + case .element: + return nil + } + } + + static func elementTypes(in term: PureXML.Schema.Term) -> [String: PureXML.Schema.ElementType] { + var result: [String: XSDElementType] = [:] + collectTypes(term, into: &result) + return result + } + + fileprivate static func collectTypes(_ term: XSDTerm, into result: inout [String: XSDElementType]) { + switch term { + case let .element(name, type): + if let type { result[key(name)] = type } + case let .group(group): + for member in group.particles { + collectTypes(member.term, into: &result) + } + case .wildcard: + break + } + } + + static func key(_ name: PureXML.Model.QualifiedName) -> String { + "{\(name.namespaceURI ?? "")}\(name.localName)" + } + + static func textContent(_ element: PureXML.Model.Element) -> String { + let text = element.children.reduce(into: "") { result, child in + switch child { + case let .text(value), let .cdata(value): result += value + default: break + } + } + return text.trimmingXMLWhitespace() + } + + static func isNamespaceDeclaration(_ attribute: PureXML.Model.Attribute) -> Bool { + attribute.name.prefix == "xmlns" || (attribute.name.prefix == nil && attribute.name.localName == "xmlns") + } + + /// Whether an attribute belongs to the XML Schema instance namespace + /// (`xsi:type`, `xsi:nil`, and the schema-location hints), which are + /// processing directives rather than declared attributes. + static func isSchemaInstanceAttribute(_ attribute: PureXML.Model.Attribute) -> Bool { + attribute.name.namespaceURI == "http://www.w3.org/2001/XMLSchema-instance" || attribute.name.prefix == "xsi" + } + + /// The local name of an element's `xsi:type` attribute value, or nil when it + /// carries none. Recognizes the attribute by the XML Schema instance + /// namespace or, failing namespace resolution, the conventional `xsi` prefix. + static func xsiTypeName(_ element: PureXML.Model.Element) -> String? { + let schemaInstance = "http://www.w3.org/2001/XMLSchema-instance" + let match = element.attributes.first { attribute in + attribute.name.localName == "type" + && (attribute.name.namespaceURI == schemaInstance || attribute.name.prefix == "xsi") + } + return match.map { $0.value.split(separator: ":").last.map(String.init) ?? $0.value } + } +} + +extension PureXML.Schema.ComplexValidator { + /// Whether the element carries `xsi:nil="true"`. + static func isNil(_ element: PureXML.Model.Element) -> Bool { + element.attributes.contains { attribute in + attribute.name.localName == "nil" + && (attribute.name.namespaceURI == "http://www.w3.org/2001/XMLSchema-instance" || attribute.name.prefix == "xsi") + && attribute.value == "true" + } + } + + /// Whether the element has any child element or non-whitespace text. + static func hasContent(_ element: PureXML.Model.Element) -> Bool { + !textContent(element).isEmpty || element.children.contains { if case .element = $0 { true } else { false } } + } +} diff --git a/Sources/Schema/DerivationMethod.swift b/Sources/Schema/DerivationMethod.swift new file mode 100644 index 0000000..09847f6 --- /dev/null +++ b/Sources/Schema/DerivationMethod.swift @@ -0,0 +1,10 @@ +public extension PureXML.Schema { + /// A way one type is derived from another, or one element substitutes for + /// another: by extension, by restriction, or (for elements) by substitution + /// group membership. Used by the `block` and `final` derivation controls. + enum DerivationMethod: Sendable, Equatable, Hashable { + case `extension` + case restriction + case substitution + } +} diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift index 931aae2..bb03acc 100644 --- a/Sources/Schema/SchemaDocument.swift +++ b/Sources/Schema/SchemaDocument.swift @@ -2,10 +2,20 @@ public extension PureXML.Schema { /// An error compiling or applying a schema. enum SchemaError: Swift.Error, Equatable, Sendable, CustomStringConvertible { case notASchema + /// A type derives from a base that declares that derivation method `final`. + case finalViolation(type: String, base: String, method: String) + /// A type inside `xs:redefine` does not redefine itself: its restriction or + /// extension base must name the type it redefines. + case redefineIncompatible(type: String) public var description: String { switch self { - case .notASchema: "the document is not an xs:schema" + case .notASchema: + "the document is not an xs:schema" + case let .finalViolation(type, base, method): + "type '\(type)' derives from '\(base)' by \(method), which '\(base)' declares final" + case let .redefineIncompatible(type): + "redefined type '\(type)' must derive from itself" } } } @@ -18,6 +28,10 @@ public extension PureXML.Schema { private let constraints: [String: [IdentityConstraint]] private let nillableElements: Set private let elementConstraints: [String: ValueConstraint] + private let abstractTypes: Set + private let abstractElements: Set + private let typeBlock: [String: Set] + private let typeDerivation: [String: TypeDerivation] /// Compiles a schema document. `schemaLoader` resolves the /// `schemaLocation` of `xs:include`, `xs:import`, and `xs:redefine` to @@ -31,6 +45,10 @@ public extension PureXML.Schema { constraints = compiled.constraints nillableElements = compiled.nillableElements elementConstraints = compiled.elementConstraints + abstractTypes = compiled.abstractTypes + abstractElements = compiled.abstractElements + typeBlock = compiled.typeBlock + typeDerivation = compiled.typeDerivation } /// Validates an instance document against the schema, returning one located @@ -49,12 +67,20 @@ public extension PureXML.Schema { guard let declaration = elements[root.name.localName] else { return [.init(reason: "no element declaration for '\(root.name.localName)'", at: [])] } + // An abstract element may not appear in an instance directly; only a + // concrete substitution-group member may stand in its place. + if abstractElements.contains(root.name.localName) { + return [.init(reason: "abstract element '\(root.name.localName)' must not appear in an instance", at: [.element(root.name.description)])] + } let context = PureXML.Validation.XSDContext( types: types, constraints: constraints, rootDeclaration: declaration, nillableElements: nillableElements, elementConstraints: elementConstraints, + abstractTypes: abstractTypes, + typeBlock: typeBlock, + typeDerivation: typeDerivation, ) return PureXML.Validation.XSD.validator().errors(for: .element(root), in: context) } diff --git a/Sources/Schema/TypeDerivation.swift b/Sources/Schema/TypeDerivation.swift new file mode 100644 index 0000000..f5bb89c --- /dev/null +++ b/Sources/Schema/TypeDerivation.swift @@ -0,0 +1,17 @@ +public extension PureXML.Schema { + /// How a named complex type is derived from its base: the base type's local + /// name and whether the derivation is an extension or a restriction. This is + /// the backbone the `block`, `final`, and `xsi:type`-substitution checks walk + /// to decide whether a derivation is permitted. + struct TypeDerivation: Sendable, Equatable { + /// The local name of the base type this type derives from. + public var base: String + /// Whether this type extends or restricts its base. + public var method: DerivationMethod + + public init(base: String, method: DerivationMethod) { + self.base = base + self.method = method + } + } +} diff --git a/Sources/Schema/XSDComplexContent.swift b/Sources/Schema/XSDComplexContent.swift new file mode 100644 index 0000000..03e514e --- /dev/null +++ b/Sources/Schema/XSDComplexContent.swift @@ -0,0 +1,77 @@ +private typealias XSDComplexType = PureXML.Schema.ComplexType +private typealias XSDContentType = PureXML.Schema.ContentType +private typealias XSDParticle = PureXML.Schema.Particle +private typealias XSDGroup = PureXML.Schema.Group +private typealias XSDContentNode = PureXML.Schema.XSDNode + +extension PureXML.Schema.XSDParser { + /// Builds the effective complex type of a `complexContent` derivation. An + /// extension composes the base type's attributes and content with its own + /// (the base's content first, then the extension's, in sequence); a + /// restriction states the full restricted model and replaces the base. + /// + /// Note: a restriction's model is taken as given. It is NOT verified to be a + /// structurally valid subset of the base ("Particle Valid (Restriction)" in + /// XSD 1.0), which is a much larger algorithm; an unfaithful restriction is + /// accepted. + static func complexContentType(_ derivationNode: XSDTree, mixed: Bool, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.ComplexType { + let ownAttributes = attributeUses(under: derivationNode, context) + let ownWildcard = attributeWildcard(under: derivationNode, context) + let ownParticle = modelGroup(in: derivationNode, context) + guard XSDContentNode.localName(derivationNode) == "extension", let base = resolvedBase(derivationNode, context) else { + return XSDComplexType(attributes: ownAttributes, attributeWildcard: ownWildcard, content: content(ownParticle, mixed: mixed)) + } + return XSDComplexType( + attributes: base.attributes + ownAttributes, + attributeWildcard: ownWildcard ?? base.attributeWildcard, + content: extendedContent(base: base.content, own: ownParticle, mixed: mixed), + ) + } + + /// The base complex type a derivation extends, parsed from its definition + /// node, or nil when the base is unknown (a built-in or undeclared type) or + /// would form a derivation cycle. + private static func resolvedBase(_ derivationNode: XSDTree, _ context: PureXML.Schema.XSDContext) -> XSDComplexType? { + guard let baseName = XSDContentNode.attribute(derivationNode, "base").map(XSDContentNode.stripPrefix), + !context.visitingTypes.contains(baseName), let baseNode = context.complexTypeNodes[baseName] + else { + return nil + } + return complexType(baseNode, context.visitingType(baseName)) + } + + /// A particle wrapped as element-only or mixed content, or empty content when + /// there is no particle. + private static func content(_ particle: XSDParticle?, mixed: Bool) -> XSDContentType { + guard let particle else { return mixed ? .mixed(emptyParticle) : .empty } + return mixed ? .mixed(particle) : .elementOnly(particle) + } + + /// The content of an extension: the base content's particle followed by the + /// extension's own particle, in a sequence. Mixed if either side is mixed. + private static func extendedContent(base: XSDContentType, own: XSDParticle?, mixed: Bool) -> XSDContentType { + let baseMixed = if case .mixed = base { true } else { false } + let combined = combine(particle(of: base), own) + return content(combined, mixed: mixed || baseMixed) + } + + private static func particle(of content: XSDContentType) -> XSDParticle? { + switch content { + case let .elementOnly(particle), let .mixed(particle): particle + case .empty, .simpleContent: nil + } + } + + private static func combine(_ first: XSDParticle?, _ second: XSDParticle?) -> XSDParticle? { + switch (first, second) { + case let (first?, second?): + XSDParticle(term: .group(XSDGroup(compositor: .sequence, particles: [first, second]))) + case let (first?, nil): first + case let (nil, second): second + } + } + + private static var emptyParticle: XSDParticle { + XSDParticle(minOccurs: 0, maxOccurs: 1, term: .group(XSDGroup(compositor: .sequence, particles: []))) + } +} diff --git a/Sources/Schema/XSDContext.swift b/Sources/Schema/XSDContext.swift index 41d6bf8..045bc7b 100644 --- a/Sources/Schema/XSDContext.swift +++ b/Sources/Schema/XSDContext.swift @@ -105,6 +105,17 @@ extension PureXML.Schema { var nillableElements: Set = [] /// The `default`/`fixed` value constraint declared on each element name. var elementConstraints: [String: ValueConstraint] = [:] + /// Local names of complex types declared `abstract="true"`: an element of + /// such a type must supply an `xsi:type` naming a concrete derived type. + var abstractTypes: Set = [] + /// Local names of element declarations declared `abstract="true"`: they may + /// not appear in an instance directly, only through a substitution member. + var abstractElements: Set = [] + /// Derivation methods the named type forbids when used through `xsi:type`. + var typeBlock: [String: Set] = [:] + /// Each named complex type's base type and derivation method, the backbone + /// the `block` check walks from an `xsi:type` to its declared type. + var typeDerivation: [String: TypeDerivation] = [:] } /// The parsing context: the named simple types resolved so far, plus the @@ -114,18 +125,33 @@ extension PureXML.Schema { var simpleTypes: [String: SimpleType] var attributeGroups: [String: XSDTree] var groups: [String: XSDTree] + /// Named complex-type definition nodes, so a `complexContent` derivation can + /// resolve and compose its base type's content model and attributes. + var complexTypeNodes: [String: XSDTree] = [:] /// The schema's target namespace, for resolving `##other`/`##targetNamespace` /// in wildcard constraints. var targetNamespace: String? /// Each substitution-group head maps to its transitive member element /// names, so an `xs:element ref` to a head also admits its members. var substitutions: [String: [String]] = [:] + /// Local names of element declarations declared `abstract="true"`, so a + /// reference to an abstract head expands to its members but not the head. + var abstractElements: Set = [] var visitingGroups: Set = [] + /// Named complex types being resolved up the current `complexContent` + /// derivation chain, a guard against a cyclic base reference. + var visitingTypes: Set = [] func visiting(_ group: String) -> XSDContext { var copy = self copy.visitingGroups.insert(group) return copy } + + func visitingType(_ type: String) -> XSDContext { + var copy = self + copy.visitingTypes.insert(type) + return copy + } } } diff --git a/Sources/Schema/XSDDerivation.swift b/Sources/Schema/XSDDerivation.swift new file mode 100644 index 0000000..9f0918b --- /dev/null +++ b/Sources/Schema/XSDDerivation.swift @@ -0,0 +1,168 @@ +private typealias SchemaFault = PureXML.Schema.SchemaError +private typealias XSDDerivNode = PureXML.Schema.XSDNode + +extension PureXML.Schema.XSDParser { + /// The derivation-control facts gathered from a schema's definition + /// containers: which types and elements are abstract, the `block`/`final` + /// derivation methods each names, every named complex type's base and + /// derivation method, and each element's declared type name (needed to test a + /// substitution member's derivation against its head's `block`). + /// + /// Note: this records the derivation *backbone* (base + method) and enforces + /// `block`, `final`, and abstract usage. It does NOT verify that a complex-type + /// restriction is a structurally valid subset of its base ("Particle Valid + /// (Restriction)" in XSD 1.0), which is a much larger algorithm; a structurally + /// unfaithful restriction is accepted here. + struct DerivationTables { + var typeDerivation: [String: PureXML.Schema.TypeDerivation] = [:] + var abstractTypes: Set = [] + var typeBlock: [String: Set] = [:] + var typeFinal: [String: Set] = [:] + var abstractElements: Set = [] + var elementBlock: [String: Set] = [:] + var elementTypeNames: [String: String] = [:] + } + + static func derivationTables(_ containers: [XSDTree]) -> DerivationTables { + var tables = DerivationTables() + for container in containers { + for type in descendants(container, named: "complexType") { + gatherType(type, into: &tables) + } + for element in descendants(container, named: "element") { + gatherElement(element, into: &tables) + } + } + return tables + } + + private static func gatherType(_ type: XSDTree, into tables: inout DerivationTables) { + guard let name = XSDDerivNode.attribute(type, "name") else { return } + if XSDDerivNode.attribute(type, "abstract") == "true" { tables.abstractTypes.insert(name) } + let block = methodSet(XSDDerivNode.attribute(type, "block")) + if !block.isEmpty { tables.typeBlock[name] = block } + let finalSet = methodSet(XSDDerivNode.attribute(type, "final")) + if !finalSet.isEmpty { tables.typeFinal[name] = finalSet } + if let derivation = derivationInfo(type) { tables.typeDerivation[name] = derivation } + } + + private static func gatherElement(_ element: XSDTree, into tables: inout DerivationTables) { + guard let name = XSDDerivNode.attribute(element, "name") else { return } + if XSDDerivNode.attribute(element, "abstract") == "true" { tables.abstractElements.insert(name) } + let block = methodSet(XSDDerivNode.attribute(element, "block")) + if !block.isEmpty { tables.elementBlock[name] = block } + if let type = XSDDerivNode.attribute(element, "type") { + tables.elementTypeNames[name] = XSDDerivNode.stripPrefix(type) + } + } + + /// Parses a `block`/`final`/`substitutionGroup`-style value: `#all` for every + /// method, or a whitespace-separated subset of `extension`/`restriction`/ + /// `substitution`. + static func methodSet(_ raw: String?) -> Set { + guard let raw, !raw.isEmpty else { return [] } + var set: Set = [] + for token in raw.split(whereSeparator: \.isWhitespace) { + switch token { + case "#all": return [.extension, .restriction, .substitution] + case "extension": set.insert(.extension) + case "restriction": set.insert(.restriction) + case "substitution": set.insert(.substitution) + default: break + } + } + return set + } + + /// The base type and derivation method a named complex type declares through + /// its `complexContent`/`simpleContent` `extension` or `restriction`. + static func derivationInfo(_ type: XSDTree) -> PureXML.Schema.TypeDerivation? { + let container = XSDDerivNode.firstChild(type, named: "complexContent") + ?? XSDDerivNode.firstChild(type, named: "simpleContent") + guard let container else { return nil } + if let ext = XSDDerivNode.firstChild(container, named: "extension"), let base = XSDDerivNode.attribute(ext, "base") { + return PureXML.Schema.TypeDerivation(base: XSDDerivNode.stripPrefix(base), method: .extension) + } + if let res = XSDDerivNode.firstChild(container, named: "restriction"), let base = XSDDerivNode.attribute(res, "base") { + return PureXML.Schema.TypeDerivation(base: XSDDerivNode.stripPrefix(base), method: .restriction) + } + return nil + } + + /// The derivation methods along the chain from `derived` up to `base`, or nil + /// when `derived` does not derive from `base`. An empty set means they are the + /// same type. Cycle-guarded so a malformed schema cannot loop. + static func derivationMethods(from derived: String, to base: String, _ table: [String: PureXML.Schema.TypeDerivation]) -> Set? { + var methods: Set = [] + var current = derived + var visited: Set = [derived] + while current != base { + guard let step = table[current] else { return nil } + methods.insert(step.method) + guard visited.insert(step.base).inserted else { return nil } + current = step.base + } + return methods + } + + /// Throws when any type derives from a base by a method that base declares + /// `final`. + static func checkFinal(_ tables: DerivationTables) throws { + for (name, derivation) in tables.typeDerivation { + guard tables.typeFinal[derivation.base]?.contains(derivation.method) == true else { continue } + throw SchemaFault.finalViolation(type: name, base: derivation.base, method: methodName(derivation.method)) + } + } + + /// Throws when a type inside an `xs:redefine` does not derive from itself: a + /// redefinition's `base` must name the type being redefined. + static func checkRedefine(_ containers: [XSDTree]) throws { + for container in containers where XSDDerivNode.localName(container) == "redefine" { + for type in XSDDerivNode.children(container, named: "complexType") { + guard let name = XSDDerivNode.attribute(type, "name") else { continue } + if derivationInfo(type)?.base != name { throw SchemaFault.redefineIncompatible(type: name) } + } + for type in XSDDerivNode.children(container, named: "simpleType") { + try checkRedefinedSimpleType(type) + } + } + } + + private static func checkRedefinedSimpleType(_ type: XSDTree) throws { + guard let name = XSDDerivNode.attribute(type, "name") else { return } + let base = XSDDerivNode.firstChild(type, named: "restriction") + .flatMap { XSDDerivNode.attribute($0, "base") } + .map(XSDDerivNode.stripPrefix) + if base != name { throw SchemaFault.redefineIncompatible(type: name) } + } + + /// Drops from each substitution group the members a head's `block` forbids: a + /// member whose type derives from the head's type by a blocked method may not + /// substitute. + static func filterSubstitutions(_ subs: [String: [String]], _ tables: DerivationTables) -> [String: [String]] { + var result: [String: [String]] = [:] + for (head, members) in subs { + guard let blocked = tables.elementBlock[head], let headType = tables.elementTypeNames[head] else { + result[head] = members + continue + } + result[head] = members.filter { member in + guard let memberType = tables.elementTypeNames[member], + let methods = derivationMethods(from: memberType, to: headType, tables.typeDerivation) + else { + return true + } + return methods.isDisjoint(with: blocked) + } + } + return result + } + + private static func methodName(_ method: PureXML.Schema.DerivationMethod) -> String { + switch method { + case .extension: "extension" + case .restriction: "restriction" + case .substitution: "substitution" + } + } +} diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift index 6b6cb6f..911988c 100644 --- a/Sources/Schema/XSDParser.swift +++ b/Sources/Schema/XSDParser.swift @@ -14,25 +14,19 @@ extension PureXML.Schema { } var visited: Set = [] let containers = XSDNode.collectContainers(schema, loader, &visited) + let derivation = derivationTables(containers) + try checkFinal(derivation) + try checkRedefine(containers) var context = XSDContext( simpleTypes: [:], attributeGroups: indexByName(allChildren(containers, named: "attributeGroup")), groups: indexByName(allChildren(containers, named: "group")), targetNamespace: XSDNode.attribute(schema, "targetNamespace"), - substitutions: XSDNode.substitutionMembers(containers), + substitutions: filterSubstitutions(XSDNode.substitutionMembers(containers), derivation), + abstractElements: derivation.abstractElements, ) - // Named simple types may restrict or list one another regardless of - // document order, so resolve them over repeated passes: each pass sees - // the types resolved by the previous one. A chain of length n settles - // in at most n passes. - let simpleNodes = allChildren(containers, named: "simpleType").filter { XSDNode.attribute($0, "name") != nil } - for _ in simpleNodes.indices { - for node in simpleNodes { - if let name = XSDNode.attribute(node, "name") { - context.simpleTypes[name] = XSDSimpleParser.simpleType(node, context) - } - } - } + context.complexTypeNodes = indexByName(allChildren(containers, named: "complexType")) + resolveSimpleTypes(allChildren(containers, named: "simpleType"), into: &context) var types: [String: ElementType] = context.simpleTypes.mapValues(ElementType.simple) for node in allChildren(containers, named: "complexType") { if let name = XSDNode.attribute(node, "name") { @@ -40,16 +34,15 @@ extension PureXML.Schema { } } var elements: [String: ElementType] = [:] - for node in allChildren(containers, named: "element") { - if let name = XSDNode.attribute(node, "name") { - let type = elementType(node, context) - elements[name] = type - // An `xs:element ref="name"` resolves to this declaration's type - // through a reserved key (a colon keeps it out of the NCName-only - // type namespace), letting forward and recursive refs resolve at - // validation time. - types[elementKey(name)] = type - } + for node in allChildren(containers, named: "element") where XSDNode.attribute(node, "name") != nil { + let name = XSDNode.attribute(node, "name") ?? "" + let type = elementType(node, context) + elements[name] = type + // An `xs:element ref="name"` resolves to this declaration's type + // through a reserved key (a colon keeps it out of the NCName-only + // type namespace), letting forward and recursive refs resolve at + // validation time. + types[elementKey(name)] = type } let (nillable, elementConstraints) = elementMetadata(containers) return XSDCompiled( @@ -58,6 +51,10 @@ extension PureXML.Schema { constraints: identityConstraints(containers), nillableElements: nillable, elementConstraints: elementConstraints, + abstractTypes: derivation.abstractTypes, + abstractElements: derivation.abstractElements, + typeBlock: derivation.typeBlock, + typeDerivation: derivation.typeDerivation, ) } @@ -65,6 +62,21 @@ extension PureXML.Schema { containers.flatMap { XSDNode.children($0, named: name) } } + /// Resolves the named simple types into `context` over repeated passes: + /// named simple types may restrict or list one another regardless of + /// document order, so each pass sees the types the previous one resolved. A + /// dependency chain of length n settles in at most n passes. + private static func resolveSimpleTypes(_ nodes: [XSDTree], into context: inout XSDContext) { + let named = nodes.filter { XSDNode.attribute($0, "name") != nil } + for _ in named.indices { + for node in named { + if let name = XSDNode.attribute(node, "name") { + context.simpleTypes[name] = XSDSimpleParser.simpleType(node, context) + } + } + } + } + static func elementKey(_ name: String) -> String { "element:\(name)" } @@ -112,19 +124,20 @@ extension PureXML.Schema { // MARK: Complex types - private static func complexType(_ node: XSDTree, _ context: XSDContext) -> ComplexType { + static func complexType(_ node: XSDTree, _ context: XSDContext) -> ComplexType { let mixed = XSDNode.attribute(node, "mixed") == "true" - var attributes = attributeUses(under: node, context) + let attributes = attributeUses(under: node, context) if let simpleContent = XSDNode.firstChild(node, named: "simpleContent") { let inner = derivation(simpleContent) - attributes += inner.map { attributeUses(under: $0, context) } ?? [] let wildcard = inner.flatMap { attributeWildcard(under: $0, context) } - return ComplexType(attributes: attributes, attributeWildcard: wildcard, content: .simpleContent(simpleContentType(simpleContent, context))) + let extra = inner.map { attributeUses(under: $0, context) } ?? [] + return ComplexType(attributes: attributes + extra, attributeWildcard: wildcard, content: .simpleContent(simpleContentType(simpleContent, context))) } - let container = XSDNode.firstChild(node, named: "complexContent").flatMap(derivation) ?? node - attributes += container === node ? [] : attributeUses(under: container, context) - let wildcard = attributeWildcard(under: container, context) - guard let particle = modelGroup(in: container, context) else { + if let complexContent = XSDNode.firstChild(node, named: "complexContent"), let inner = derivation(complexContent) { + return complexContentType(inner, mixed: mixed || XSDNode.attribute(complexContent, "mixed") == "true", context) + } + let wildcard = attributeWildcard(under: node, context) + guard let particle = modelGroup(in: node, context) else { return ComplexType(attributes: attributes, attributeWildcard: wildcard, content: .empty) } return ComplexType(attributes: attributes, attributeWildcard: wildcard, content: mixed ? .mixed(particle) : .elementOnly(particle)) @@ -145,7 +158,7 @@ extension PureXML.Schema { // MARK: Attribute uses and groups - private static func attributeUses(under node: XSDTree, _ context: XSDContext, visited: Set = []) -> [AttributeUse] { + static func attributeUses(under node: XSDTree, _ context: XSDContext, visited: Set = []) -> [AttributeUse] { var uses: [AttributeUse] = [] for child in XSDNode.elementChildren(node) { switch XSDNode.localName(child) { @@ -191,7 +204,7 @@ extension PureXML.Schema { // MARK: Model groups - private static func modelGroup(in node: XSDTree, _ context: XSDContext) -> Particle? { + static func modelGroup(in node: XSDTree, _ context: XSDContext) -> Particle? { for (name, compositor) in [("sequence", Compositor.sequence), ("choice", .choice), ("all", .all)] { if let group = XSDNode.firstChild(node, named: name) { return groupParticle(group, compositor, context) @@ -243,9 +256,12 @@ extension PureXML.Schema { private static func elementParticle(_ node: XSDTree, _ minimum: Int, _ maximum: Int?, _ context: XSDContext) -> Particle { if let ref = XSDNode.attribute(node, "ref") { let name = XSDNode.stripPrefix(ref) - let alternatives = [name] + (context.substitutions[name] ?? []) + // An abstract head may not appear itself, only its members; a + // concrete head appears alongside them. + let head = context.abstractElements.contains(name) ? [] : [name] + let alternatives = head + (context.substitutions[name] ?? []) if alternatives.count == 1 { - return Particle(minOccurs: minimum, maxOccurs: maximum, term: elementReferenceTerm(name)) + return Particle(minOccurs: minimum, maxOccurs: maximum, term: elementReferenceTerm(alternatives[0])) } // The reference admits its substitution-group members, so expand it // to a choice over the head and every member, each carrying its own @@ -321,7 +337,7 @@ extension PureXML.Schema.XSDParser { return PureXML.Schema.IdentityConstraint(name: PureXML.Schema.XSDNode.attribute(node, "name") ?? "", kind: kind, selector: selector, fields: fields) } - private static func descendants(_ node: XSDTree, named name: String) -> [XSDTree] { + static func descendants(_ node: XSDTree, named name: String) -> [XSDTree] { var result: [XSDTree] = [] for child in PureXML.Schema.XSDNode.elementChildren(node) { if PureXML.Schema.XSDNode.localName(child) == name { result.append(child) } @@ -330,37 +346,3 @@ extension PureXML.Schema.XSDParser { return result } } - -extension PureXML.Schema.XSDParser { - /// The `xs:anyAttribute` wildcard declared directly under `node`, if any. - static func attributeWildcard(under node: XSDTree, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.Wildcard? { - PureXML.Schema.XSDNode.firstChild(node, named: "anyAttribute").map { wildcard($0, context) } - } - - /// Parses a wildcard's `namespace` and `processContents` constraints. - static func wildcard(_ node: XSDTree, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.Wildcard { - let processContents: PureXML.Schema.ProcessContents = switch PureXML.Schema.XSDNode.attribute(node, "processContents") { - case "skip": .skip - case "lax": .lax - default: .strict - } - let namespace = wildcardNamespace(PureXML.Schema.XSDNode.attribute(node, "namespace"), context) - return PureXML.Schema.Wildcard(namespace: namespace, processContents: processContents, targetNamespace: context.targetNamespace) - } - - private static func wildcardNamespace(_ value: String?, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.WildcardNamespace { - switch value { - case nil, "##any": return .any - case "##other": return .other - default: - let uris = (value ?? "").split(whereSeparator: \.isWhitespace).map { token -> String in - switch token { - case "##targetNamespace": context.targetNamespace ?? "" - case "##local": "" - default: String(token) - } - } - return .enumerated(Set(uris)) - } - } -} diff --git a/Sources/Schema/XSDValidations.swift b/Sources/Schema/XSDValidations.swift index 1bffc54..d03e871 100644 --- a/Sources/Schema/XSDValidations.swift +++ b/Sources/Schema/XSDValidations.swift @@ -8,6 +8,9 @@ public extension PureXML.Validation { public let rootDeclaration: PureXML.Schema.ElementType? public let nillableElements: Set public let elementConstraints: [String: PureXML.Schema.ValueConstraint] + public let abstractTypes: Set + public let typeBlock: [String: Set] + public let typeDerivation: [String: PureXML.Schema.TypeDerivation] public init( types: [String: PureXML.Schema.ElementType], @@ -15,12 +18,18 @@ public extension PureXML.Validation { rootDeclaration: PureXML.Schema.ElementType?, nillableElements: Set = [], elementConstraints: [String: PureXML.Schema.ValueConstraint] = [:], + abstractTypes: Set = [], + typeBlock: [String: Set] = [:], + typeDerivation: [String: PureXML.Schema.TypeDerivation] = [:], ) { self.types = types self.constraints = constraints self.rootDeclaration = rootDeclaration self.nillableElements = nillableElements self.elementConstraints = elementConstraints + self.abstractTypes = abstractTypes + self.typeBlock = typeBlock + self.typeDerivation = typeDerivation } } @@ -42,6 +51,9 @@ public extension PureXML.Validation { types: context.document.types, nillableElements: context.document.nillableElements, elementConstraints: context.document.elementConstraints, + abstractTypes: context.document.abstractTypes, + typeBlock: context.document.typeBlock, + typeDerivation: context.document.typeDerivation, ) .validate(root, as: declaration, at: [.element(root.name.description)]) }, diff --git a/Sources/Schema/XSDWildcardParser.swift b/Sources/Schema/XSDWildcardParser.swift new file mode 100644 index 0000000..cc75665 --- /dev/null +++ b/Sources/Schema/XSDWildcardParser.swift @@ -0,0 +1,33 @@ +extension PureXML.Schema.XSDParser { + /// The `xs:anyAttribute` wildcard declared directly under `node`, if any. + static func attributeWildcard(under node: XSDTree, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.Wildcard? { + PureXML.Schema.XSDNode.firstChild(node, named: "anyAttribute").map { wildcard($0, context) } + } + + /// Parses a wildcard's `namespace` and `processContents` constraints. + static func wildcard(_ node: XSDTree, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.Wildcard { + let processContents: PureXML.Schema.ProcessContents = switch PureXML.Schema.XSDNode.attribute(node, "processContents") { + case "skip": .skip + case "lax": .lax + default: .strict + } + let namespace = wildcardNamespace(PureXML.Schema.XSDNode.attribute(node, "namespace"), context) + return PureXML.Schema.Wildcard(namespace: namespace, processContents: processContents, targetNamespace: context.targetNamespace) + } + + private static func wildcardNamespace(_ value: String?, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.WildcardNamespace { + switch value { + case nil, "##any": return .any + case "##other": return .other + default: + let uris = (value ?? "").split(whereSeparator: \.isWhitespace).map { token -> String in + switch token { + case "##targetNamespace": context.targetNamespace ?? "" + case "##local": "" + default: String(token) + } + } + return .enumerated(Set(uris)) + } + } +} diff --git a/Tests/XSDDerivationControlTests.swift b/Tests/XSDDerivationControlTests.swift new file mode 100644 index 0000000..08a8588 --- /dev/null +++ b/Tests/XSDDerivationControlTests.swift @@ -0,0 +1,210 @@ +@testable import PureXML +import Testing + +@Suite("XSD derivation control") +struct XSDDerivationControlTests { + private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] { + try PureXML.Schema.Document(xsd).validate(xml) + } + + /// A schema with an abstract `shape` head, a concrete `circle` member, and a + /// `figure` element whose content references the head. + private let substitution = """ + + + + + + + + + + + + """ + + @Test("An abstract element may not appear at the document root") + func test_abstractRootRejected() throws { + let xsd = """ + + + + """ + #expect(try !validate(xsd, "x").isEmpty) + } + + @Test("An abstract head is not admitted in content, only its members") + func test_abstractHeadInContent() throws { + #expect(try !validate(substitution, "
x
").isEmpty) + #expect(try validate(substitution, "
x
").isEmpty) + } + + @Test("An element of abstract type requires xsi:type naming a concrete type") + func test_abstractTypeNeedsXsiType() throws { + let xsd = """ + + + + + + + + + + + + + + """ + #expect(try !validate(xsd, "a").isEmpty) + #expect(try validate(xsd, "a1").isEmpty) + } + + @Test("block=extension on a type forbids xsi:type substitution by extension") + func test_blockExtensionXsiType() throws { + let xsd = """ + + + + + + + + + + + + + + + + + + + + + """ + // Substitution by extension is blocked. + #expect(try !validate(xsd, "a1").isEmpty) + // Substitution by restriction is still permitted. + #expect(try validate(xsd, "a").isEmpty) + } + + @Test("final=extension forbids deriving a new type by extension") + func test_finalExtension() throws { + let xsd = """ + + + + + + + + + + + + + + """ + #expect(throws: PureXML.Schema.SchemaError.self) { + _ = try PureXML.Schema.Document(xsd) + } + } + + @Test("final=#all forbids both extension and restriction") + func test_finalAll() throws { + let xsd = """ + + + + + + + + + + + + + + """ + #expect(throws: PureXML.Schema.SchemaError.self) { + _ = try PureXML.Schema.Document(xsd) + } + } + + @Test("xs:redefine requires the redefined type to derive from itself") + func test_redefineSelfReference() throws { + let base = """ + + + + + + """ + let bad = """ + + + + + + + + + + + + + """ + #expect(throws: PureXML.Schema.SchemaError.self) { + _ = try PureXML.Schema.Document(bad, schemaLoader: { _ in base }) + } + } + + @Test("xs:redefine compiles when the type derives from itself") + func test_redefineCompiles() throws { + let base = """ + + + + + + """ + let good = """ + + + + + + + + + + + + + """ + _ = try PureXML.Schema.Document(good, schemaLoader: { _ in base }) + } + + @Test("A concrete element with a non-blocked xsi:type still validates") + func test_xsiTypeStillWorks() throws { + let xsd = """ + + + + + + + + + + + + + + """ + #expect(try validate(xsd, "a1").isEmpty) + } +} diff --git a/Tests/XSDImportTests.swift b/Tests/XSDImportTests.swift index b85856e..ba7f7eb 100644 --- a/Tests/XSDImportTests.swift +++ b/Tests/XSDImportTests.swift @@ -44,7 +44,7 @@ struct XSDImportTests { - +