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)
+ }
+}