diff --git a/CHANGELOG.md b/CHANGELOG.md index 713f8c7..6c6cda6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- XSD schema-document parser, completing the XSD validation pipeline (toward #2). + `PureXML.Schema.Document(xsd)` compiles a schema document into its global + element declarations and named-type table, and `validate(_:)` checks an + instance document end to end. The parser matches the schema vocabulary by local + name and supports global and local element declarations, named and inline + simple and complex types, `sequence`/`choice`/`all` model groups with + occurrence, attribute uses, simple content (restriction/extension), the full + facet set, and recursive types (via named type references resolved during + validation). - XSD complex types and particles (toward #2 schema validation). `PureXML.Schema.ComplexValidator` validates an element against a `ComplexType`: its attribute uses (required and typed, with unknown-attribute diff --git a/Sources/Schema/ComplexType.swift b/Sources/Schema/ComplexType.swift index 36ffb01..8777d9c 100644 --- a/Sources/Schema/ComplexType.swift +++ b/Sources/Schema/ComplexType.swift @@ -48,10 +48,13 @@ public extension PureXML.Schema { } } - /// The declared type of an element: a simple type (text only) or a complex one. + /// The declared type of an element: a simple type (text only), a complex one, + /// or a named reference resolved against the schema's type table (which lets + /// recursive schemas be represented without an infinite value). indirect enum ElementType: Sendable { case simple(SimpleType) case complex(ComplexType) + case typeReference(String) } /// An attribute use on a complex type. diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index 35f9016..5abc914 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -3,7 +3,12 @@ public extension PureXML.Schema { /// content model, and, recursively, each child element against the type /// declared for its name. struct ComplexValidator { - public init() {} + /// The named type table that `ElementType.typeReference` resolves against. + private let types: [String: ElementType] + + public init(types: [String: ElementType] = [:]) { + self.types = types + } /// Validates `element` against `type`, returning one issue per violation. public func validate( @@ -16,6 +21,17 @@ public extension PureXML.Schema { return issues } + /// Validates `element` against any element type, resolving a + /// `typeReference` through the type table. + public func validate( + _ element: PureXML.Model.Element, + as type: ElementType, + ) -> [PureXML.Validation.Issue] { + var issues: [PureXML.Validation.Issue] = [] + validateChild(element, against: type, into: &issues) + return issues + } + // MARK: Attributes private func validateAttributes( @@ -91,27 +107,41 @@ public extension PureXML.Schema { issues.append(.init(severity: .error, message: "content does not match the content model")) return } - validateChildren(children, types: Self.elementTypes(in: particle.term), into: &issues) + validateChildren(children, childTypes: Self.elementTypes(in: particle.term), into: &issues) } private func validateChildren( _ children: [PureXML.Model.Element], - types: [String: ElementType], + childTypes: [String: ElementType], into issues: inout [PureXML.Validation.Issue], ) { for child in children { - guard let type = types[Self.key(child.name)] else { continue } - switch type { - case let .simple(simple): - if !child.children.compactMap(\.element).isEmpty { - issues.append(.init(severity: .error, message: "'\(child.name.localName)' must not have children")) - } - if let error = simple.validate(Self.textContent(child)) { - issues.append(.init(severity: .error, message: "'\(child.name.localName)': \(error)")) - } - case let .complex(complex): - issues.append(contentsOf: validate(child, against: complex)) + guard let declared = childTypes[Self.key(child.name)] else { continue } + validateChild(child, against: declared, into: &issues) + } + } + + private func validateChild( + _ child: PureXML.Model.Element, + against declared: ElementType, + into issues: inout [PureXML.Validation.Issue], + ) { + switch declared { + case let .simple(simple): + if !child.children.compactMap(\.element).isEmpty { + issues.append(.init(severity: .error, message: "'\(child.name.localName)' must not have children")) } + if let error = simple.validate(Self.textContent(child)) { + issues.append(.init(severity: .error, message: "'\(child.name.localName)': \(error)")) + } + case let .complex(complex): + issues.append(contentsOf: validate(child, against: complex)) + case let .typeReference(name): + guard let resolved = types[name] else { + issues.append(.init(severity: .error, message: "unknown type '\(name)'")) + return + } + validateChild(child, against: resolved, into: &issues) } } diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift new file mode 100644 index 0000000..dd3274e --- /dev/null +++ b/Sources/Schema/SchemaDocument.swift @@ -0,0 +1,39 @@ +public extension PureXML.Schema { + /// An error compiling or applying a schema. + enum SchemaError: Swift.Error, Equatable, Sendable, CustomStringConvertible { + case notASchema + + public var description: String { + switch self { + case .notASchema: "the document is not an xs:schema" + } + } + } + + /// A compiled XSD schema: its global element declarations and named-type + /// table, parsed from a schema document, used to validate instance documents. + struct Document: Sendable { + private let elements: [String: ElementType] + private let types: [String: ElementType] + + /// Compiles a schema document. + public init(_ xsd: String) throws { + (elements, types) = try XSDParser.parse(xsd) + } + + /// Validates an instance document against the schema, returning one issue + /// per violation. Reports an issue when the root element has no global + /// declaration. + public func validate(_ xml: String) throws -> [PureXML.Validation.Issue] { + guard case let .document(children) = try PureXML.parse(xml), + let root = children.compactMap(\.element).first + else { + return [.init(severity: .error, message: "the document has no root element")] + } + guard let declaration = elements[root.name.localName] else { + return [.init(severity: .error, message: "no element declaration for '\(root.name.localName)'")] + } + return ComplexValidator(types: types).validate(root, as: declaration) + } + } +} diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift new file mode 100644 index 0000000..b2942fa --- /dev/null +++ b/Sources/Schema/XSDParser.swift @@ -0,0 +1,266 @@ +private typealias Tree = PureXML.Model.TreeNode + +/// Tree-access helpers shared by the schema parser. File-scope and private. +private enum XSDNode { + static func localName(_ node: Tree) -> String? { + node.name?.localName + } + + static func attribute(_ node: Tree, _ name: String) -> String? { + node.attributes.first { $0.name.localName == name }?.value + } + + static func elementChildren(_ node: Tree) -> [Tree] { + node.children.filter { $0.kind == .element } + } + + static func children(_ node: Tree, named name: String) -> [Tree] { + elementChildren(node).filter { localName($0) == name } + } + + static func firstChild(_ node: Tree, named name: String) -> Tree? { + children(node, named: name).first + } + + static func stripPrefix(_ qualified: String) -> String { + qualified.split(separator: ":").last.map(String.init) ?? qualified + } + + static func occurrence(_ node: Tree) -> (min: Int, max: Int?) { + let minimum = attribute(node, "minOccurs").flatMap(Int.init) ?? 1 + let maximumText = attribute(node, "maxOccurs") + let maximum: Int? = maximumText == "unbounded" ? nil : (maximumText.flatMap(Int.init) ?? 1) + return (minimum, maximum) + } +} + +extension PureXML.Schema { + /// Parses an XSD schema document into its global element declarations and its + /// named-type table. Matches the schema vocabulary by local name, so the XML + /// Schema namespace prefix may be anything. Supports global and local element + /// declarations, named and inline simple and complex types, `sequence`, + /// `choice`, and `all` model groups with occurrence, attribute uses, simple + /// content, and the full facet set. + enum XSDParser { + static func parse( + _ xsd: String, + ) throws -> (elements: [String: ElementType], types: [String: ElementType]) { + let root = try PureXML.parseTree(xsd) + guard let schema = XSDNode.elementChildren(root).first(where: { XSDNode.localName($0) == "schema" }) else { + throw PureXML.Schema.SchemaError.notASchema + } + var simpleTypes: [String: SimpleType] = [:] + for node in XSDNode.children(schema, named: "simpleType") { + if let name = XSDNode.attribute(node, "name") { + simpleTypes[name] = simpleType(node, simpleTypes) + } + } + var types: [String: ElementType] = simpleTypes.mapValues(ElementType.simple) + for node in XSDNode.children(schema, named: "complexType") { + if let name = XSDNode.attribute(node, "name") { + types[name] = .complex(complexType(node, simpleTypes)) + } + } + var elements: [String: ElementType] = [:] + for node in XSDNode.children(schema, named: "element") { + if let name = XSDNode.attribute(node, "name") { + elements[name] = elementType(node, simpleTypes) + } + } + return (elements, types) + } + + // MARK: Element and type references + + private static func elementType(_ node: Tree, _ simpleTypes: [String: SimpleType]) -> ElementType { + if let typeName = XSDNode.attribute(node, "type") { + return typeReference(typeName) + } + if let inline = XSDNode.firstChild(node, named: "simpleType") { + return .simple(simpleType(inline, simpleTypes)) + } + if let inline = XSDNode.firstChild(node, named: "complexType") { + return .complex(complexType(inline, simpleTypes)) + } + return .complex(anyType) + } + + private static func typeReference(_ typeName: String) -> ElementType { + let local = XSDNode.stripPrefix(typeName) + if let builtin = BuiltinType(rawValue: local) { + return .simple(SimpleType(base: builtin)) + } + return .typeReference(local) + } + + private static func simpleTypeReference(_ typeName: String, _ simpleTypes: [String: SimpleType]) -> SimpleType { + let local = XSDNode.stripPrefix(typeName) + if let builtin = BuiltinType(rawValue: local) { return SimpleType(base: builtin) } + return simpleTypes[local] ?? SimpleType(base: .string) + } + + private static var anyType: ComplexType { + ComplexType( + allowsOtherAttributes: true, + content: .mixed(Particle(minOccurs: 0, maxOccurs: nil, term: .wildcard(Wildcard()))), + ) + } + + // MARK: Simple types and facets + + private static func simpleType(_ node: Tree, _ simpleTypes: [String: SimpleType]) -> SimpleType { + guard let restriction = XSDNode.firstChild(node, named: "restriction") else { + return SimpleType(base: .string) + } + let baseName = XSDNode.stripPrefix(XSDNode.attribute(restriction, "base") ?? "string") + var base = BuiltinType.string + var facets = Facets() + if let builtin = BuiltinType(rawValue: baseName) { + base = builtin + } else if let parent = simpleTypes[baseName] { + base = parent.base + facets = parent.facets + } + applyFacets(restriction, into: &facets) + return SimpleType(base: base, facets: facets) + } + + private static func applyFacets(_ restriction: Tree, into facets: inout Facets) { + for facet in XSDNode.elementChildren(restriction) { + let value = XSDNode.attribute(facet, "value") + applyStringFacet(XSDNode.localName(facet), value, into: &facets) + applyNumericFacet(XSDNode.localName(facet), value, into: &facets) + } + } + + private static func applyStringFacet(_ name: String?, _ value: String?, into facets: inout Facets) { + switch name { + case "pattern": if let value { facets.patterns.append(value) } + case "enumeration": if let value { facets.enumeration = (facets.enumeration ?? []) + [value] } + case "minInclusive": facets.minInclusive = value + case "maxInclusive": facets.maxInclusive = value + case "minExclusive": facets.minExclusive = value + case "maxExclusive": facets.maxExclusive = value + case "whiteSpace": facets.whiteSpace = whiteSpace(value) + default: break + } + } + + private static func applyNumericFacet(_ name: String?, _ value: String?, into facets: inout Facets) { + let number = value.flatMap(Int.init) + switch name { + case "length": facets.length = number + case "minLength": facets.minLength = number + case "maxLength": facets.maxLength = number + case "totalDigits": facets.totalDigits = number + case "fractionDigits": facets.fractionDigits = number + default: break + } + } + + private static func whiteSpace(_ value: String?) -> WhiteSpace? { + switch value { + case "preserve": .preserve + case "replace": .replace + case "collapse": .collapse + default: nil + } + } + + // MARK: Complex types + + private static func complexType(_ node: Tree, _ simpleTypes: [String: SimpleType]) -> ComplexType { + let mixed = XSDNode.attribute(node, "mixed") == "true" + var attributes = attributeUses(under: node, simpleTypes) + if let simpleContent = XSDNode.firstChild(node, named: "simpleContent") { + let inner = derivation(simpleContent) + attributes += inner.map { attributeUses(under: $0, simpleTypes) } ?? [] + return ComplexType(attributes: attributes, content: .simpleContent(simpleContentType(simpleContent, simpleTypes))) + } + let container = XSDNode.firstChild(node, named: "complexContent").flatMap(derivation) ?? node + attributes += container === node ? [] : attributeUses(under: container, simpleTypes) + guard let particle = modelGroup(in: container, simpleTypes) else { + return ComplexType(attributes: attributes, content: .empty) + } + return ComplexType(attributes: attributes, content: mixed ? .mixed(particle) : .elementOnly(particle)) + } + + private static func derivation(_ node: Tree) -> Tree? { + XSDNode.firstChild(node, named: "restriction") ?? XSDNode.firstChild(node, named: "extension") + } + + private static func simpleContentType(_ node: Tree, _ simpleTypes: [String: SimpleType]) -> SimpleType { + guard let inner = derivation(node) else { return SimpleType(base: .string) } + let baseName = XSDNode.stripPrefix(XSDNode.attribute(inner, "base") ?? "string") + let base = BuiltinType(rawValue: baseName) ?? simpleTypes[baseName]?.base ?? .string + var facets = simpleTypes[baseName]?.facets ?? Facets() + applyFacets(inner, into: &facets) + return SimpleType(base: base, facets: facets) + } + + private static func attributeUses(under node: Tree, _ simpleTypes: [String: SimpleType]) -> [AttributeUse] { + XSDNode.children(node, named: "attribute").compactMap { attributeUse($0, simpleTypes) } + } + + private static func attributeUse(_ node: Tree, _ simpleTypes: [String: SimpleType]) -> AttributeUse? { + guard let name = XSDNode.attribute(node, "name") else { return nil } + let required = XSDNode.attribute(node, "use") == "required" + let type: SimpleType = if let typeName = XSDNode.attribute(node, "type") { + simpleTypeReference(typeName, simpleTypes) + } else if let inline = XSDNode.firstChild(node, named: "simpleType") { + simpleType(inline, simpleTypes) + } else { + SimpleType(base: .string) + } + return AttributeUse(name: PureXML.Model.QualifiedName(name), type: type, required: required) + } + + // MARK: Model groups + + private static func modelGroup(in node: Tree, _ simpleTypes: [String: SimpleType]) -> Particle? { + for (name, compositor) in [("sequence", Compositor.sequence), ("choice", .choice), ("all", .all)] { + if let group = XSDNode.firstChild(node, named: name) { + return groupParticle(group, compositor, simpleTypes) + } + } + return nil + } + + private static func groupParticle( + _ node: Tree, + _ compositor: Compositor, + _ simpleTypes: [String: SimpleType], + ) -> Particle { + var particles: [Particle] = [] + for child in XSDNode.elementChildren(node) { + if let member = particle(child, simpleTypes) { particles.append(member) } + } + let (minimum, maximum) = XSDNode.occurrence(node) + return Particle( + minOccurs: minimum, + maxOccurs: maximum, + term: .group(Group(compositor: compositor, particles: particles)), + ) + } + + private static func particle(_ node: Tree, _ simpleTypes: [String: SimpleType]) -> Particle? { + let (minimum, maximum) = XSDNode.occurrence(node) + switch XSDNode.localName(node) { + case "element": + let name = XSDNode.attribute(node, "name") ?? XSDNode.stripPrefix(XSDNode.attribute(node, "ref") ?? "") + let type = XSDNode.attribute(node, "name") != nil ? elementType(node, simpleTypes) : ElementType.complex(anyType) + return Particle(minOccurs: minimum, maxOccurs: maximum, term: .element(name: PureXML.Model.QualifiedName(name), type: type)) + case "sequence": + return groupParticle(node, .sequence, simpleTypes) + case "choice": + return groupParticle(node, .choice, simpleTypes) + case "all": + return groupParticle(node, .all, simpleTypes) + case "any": + return Particle(minOccurs: minimum, maxOccurs: maximum, term: .wildcard(Wildcard())) + default: + return nil + } + } + } +} diff --git a/Tests/XSDSchemaTests.swift b/Tests/XSDSchemaTests.swift new file mode 100644 index 0000000..54df2ec --- /dev/null +++ b/Tests/XSDSchemaTests.swift @@ -0,0 +1,124 @@ +@testable import PureXML +import Testing + +@Suite("XSD schema documents") +struct XSDSchemaTests { + private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.Issue] { + try PureXML.Schema.Document(xsd).validate(xml) + } + + @Test("A simple element with a built-in type") + func test_simpleElement() throws { + let xsd = """ + + + + """ + #expect(try validate(xsd, "30").isEmpty) + #expect(try !validate(xsd, "-5").isEmpty) + #expect(try !validate(xsd, "x").isEmpty) + } + + @Test("A named simpleType with facets") + func test_namedSimpleType() throws { + let xsd = """ + + + + + + + + + """ + #expect(try validate(xsd, "ABC-1234").isEmpty) + #expect(try !validate(xsd, "abc-1234").isEmpty) + } + + @Test("A complex type with a sequence and attributes") + func test_complexType() throws { + let xsd = """ + + + + + + + + + + + + """ + #expect(try validate(xsd, "T2026").isEmpty) + #expect(try validate(xsd, "T").isEmpty) + #expect(try !validate(xsd, "T").isEmpty) // missing isbn + #expect(try !validate(xsd, "2026").isEmpty) // missing title + #expect(try !validate(xsd, "Tnope").isEmpty) + } + + @Test("A named complex type referenced by elements") + func test_namedComplexType() throws { + let xsd = """ + + + + + + + + + + """ + #expect(try validate(xsd, "1.52.5").isEmpty) + #expect(try !validate(xsd, "1.5").isEmpty) + } + + @Test("Recursive types validate to any depth") + func test_recursiveType() throws { + let xsd = """ + + + + + + + + + + """ + let nested = "123" + #expect(try validate(xsd, nested).isEmpty) + let bad = "1x" + #expect(try !validate(xsd, bad).isEmpty) + } + + @Test("Simple content restricts the text and keeps attributes") + func test_simpleContent() throws { + let xsd = """ + + + + + + + + + + + + """ + #expect(try validate(xsd, "9.99").isEmpty) + #expect(try !validate(xsd, "nope").isEmpty) + } + + @Test("An unknown root element is reported") + func test_unknownRoot() throws { + let xsd = """ + + + + """ + #expect(try !validate(xsd, "").isEmpty) + } +}