Merge epic/10-xsd-parser: XSD schema-document parser and end-to-end validation

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 06:40:04 +02:00
commit e845b63ff0
6 changed files with 486 additions and 15 deletions

View File

@ -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

View File

@ -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.

View File

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

View File

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

View File

@ -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
}
}
}
}

124
Tests/XSDSchemaTests.swift Normal file
View File

@ -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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="age" type="xs:nonNegativeInteger"/>
</xs:schema>
"""
#expect(try validate(xsd, "<age>30</age>").isEmpty)
#expect(try !validate(xsd, "<age>-5</age>").isEmpty)
#expect(try !validate(xsd, "<age>x</age>").isEmpty)
}
@Test("A named simpleType with facets")
func test_namedSimpleType() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:simpleType name="sku">
<xs:restriction base="xs:string">
<xs:pattern value="[A-Z]{3}-\\d{4}"/>
</xs:restriction>
</xs:simpleType>
<xs:element name="code" type="sku"/>
</xs:schema>
"""
#expect(try validate(xsd, "<code>ABC-1234</code>").isEmpty)
#expect(try !validate(xsd, "<code>abc-1234</code>").isEmpty)
}
@Test("A complex type with a sequence and attributes")
func test_complexType() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="book">
<xs:complexType>
<xs:sequence>
<xs:element name="title" type="xs:string"/>
<xs:element name="year" type="xs:gYear" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="isbn" type="xs:string" use="required"/>
</xs:complexType>
</xs:element>
</xs:schema>
"""
#expect(try validate(xsd, "<book isbn=\"1\"><title>T</title><year>2026</year></book>").isEmpty)
#expect(try validate(xsd, "<book isbn=\"1\"><title>T</title></book>").isEmpty)
#expect(try !validate(xsd, "<book><title>T</title></book>").isEmpty) // missing isbn
#expect(try !validate(xsd, "<book isbn=\"1\"><year>2026</year></book>").isEmpty) // missing title
#expect(try !validate(xsd, "<book isbn=\"1\"><title>T</title><year>nope</year></book>").isEmpty)
}
@Test("A named complex type referenced by elements")
func test_namedComplexType() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:complexType name="point">
<xs:sequence>
<xs:element name="x" type="xs:decimal"/>
<xs:element name="y" type="xs:decimal"/>
</xs:sequence>
</xs:complexType>
<xs:element name="location" type="point"/>
</xs:schema>
"""
#expect(try validate(xsd, "<location><x>1.5</x><y>2.5</y></location>").isEmpty)
#expect(try !validate(xsd, "<location><x>1.5</x></location>").isEmpty)
}
@Test("Recursive types validate to any depth")
func test_recursiveType() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:complexType name="node">
<xs:sequence>
<xs:element name="value" type="xs:int"/>
<xs:element name="child" type="node" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:element name="tree" type="node"/>
</xs:schema>
"""
let nested = "<tree><value>1</value><child><value>2</value><child><value>3</value></child></child></tree>"
#expect(try validate(xsd, nested).isEmpty)
let bad = "<tree><value>1</value><child><value>x</value></child></tree>"
#expect(try !validate(xsd, bad).isEmpty)
}
@Test("Simple content restricts the text and keeps attributes")
func test_simpleContent() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="price">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:decimal">
<xs:attribute name="currency" type="xs:string"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
</xs:schema>
"""
#expect(try validate(xsd, "<price currency=\"USD\">9.99</price>").isEmpty)
#expect(try !validate(xsd, "<price>nope</price>").isEmpty)
}
@Test("An unknown root element is reported")
func test_unknownRoot() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="known" type="xs:string"/>
</xs:schema>
"""
#expect(try !validate(xsd, "<unknown/>").isEmpty)
}
}