diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index 3cc6fa1..992a419 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -1,7 +1,13 @@ +/// File-scope aliases for the XSD complex-type validator, kept out of the +/// namespace to avoid nesting a type two levels deep. +private typealias XSDFailure = PureXML.Validation.ValidationError +private typealias XSDPath = [PureXML.Validation.PathKey] + public extension PureXML.Schema { /// Validates an element against a ``ComplexType``: its attribute uses, its /// content model, and, recursively, each child element against the type - /// declared for its name. + /// declared for its name. Every violation is located by the element's coding + /// path, and the results are ``PureXML/Validation/ValidationError`` values. struct ComplexValidator { /// The named type table that `ElementType.typeReference` resolves against. private let types: [String: ElementType] @@ -10,26 +16,28 @@ public extension PureXML.Schema { self.types = types } - /// Validates `element` against `type`, returning one issue per violation. + /// Validates `element` against `type` at `path`, one error per violation. public func validate( _ element: PureXML.Model.Element, against type: ComplexType, - ) -> [PureXML.Validation.Issue] { - var issues: [PureXML.Validation.Issue] = [] - validateAttributes(element, type, into: &issues) - validateContent(element, type.content, into: &issues) - return issues + at path: [PureXML.Validation.PathKey] = [], + ) -> [PureXML.Validation.ValidationError] { + var errors: [XSDFailure] = [] + validateAttributes(element, type, at: path, into: &errors) + validateContent(element, type.content, at: path, into: &errors) + return errors } - /// Validates `element` against any element type, resolving a + /// Validates `element` against any element type at `path`, resolving a /// `typeReference` through the type table. public func validate( _ element: PureXML.Model.Element, as type: ElementType, - ) -> [PureXML.Validation.Issue] { - var issues: [PureXML.Validation.Issue] = [] - validateChild(element, against: type, into: &issues) - return issues + at path: [PureXML.Validation.PathKey] = [], + ) -> [PureXML.Validation.ValidationError] { + var errors: [XSDFailure] = [] + validateChild(element, against: type, at: path, into: &errors) + return errors } // MARK: Attributes @@ -37,22 +45,23 @@ public extension PureXML.Schema { private func validateAttributes( _ element: PureXML.Model.Element, _ type: ComplexType, - into issues: inout [PureXML.Validation.Issue], + at path: XSDPath, + into errors: inout [XSDFailure], ) { let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) && !Self.isSchemaInstanceAttribute($0) } for use in type.attributes { let match = present.first { $0.name.localName == use.name.localName } if let match { if let error = use.type.validate(match.value) { - issues.append(.init(severity: .error, message: "attribute '\(use.name.localName)': \(error)")) + errors.append(XSDFailure(reason: "attribute '\(use.name.localName)': \(error)", at: path)) } } else if use.required { - issues.append(.init(severity: .error, message: "missing required attribute '\(use.name.localName)'")) + errors.append(XSDFailure(reason: "missing required attribute '\(use.name.localName)'", at: path)) } } guard !type.allowsOtherAttributes else { return } for attribute in present where !type.attributes.contains(where: { $0.name.localName == attribute.name.localName }) { - issues.append(.init(severity: .error, message: "undeclared attribute '\(attribute.name.localName)'")) + errors.append(XSDFailure(reason: "undeclared attribute '\(attribute.name.localName)'", at: path)) } } @@ -61,33 +70,30 @@ public extension PureXML.Schema { private func validateContent( _ element: PureXML.Model.Element, _ content: ContentType, - into issues: inout [PureXML.Validation.Issue], + at path: XSDPath, + into errors: inout [XSDFailure], ) { let children = element.children.compactMap(\.element) switch content { case .empty: - if !children.isEmpty { issues.append(.init(severity: .error, message: "element must be empty")) } - rejectText(element, allowed: false, into: &issues) + if !children.isEmpty { errors.append(XSDFailure(reason: "element must be empty", at: path)) } + rejectText(element, at: path, into: &errors) case let .simpleContent(type): - if !children.isEmpty { issues.append(.init(severity: .error, message: "element must not have children")) } + if !children.isEmpty { errors.append(XSDFailure(reason: "element must not have children", at: path)) } if let error = type.validate(Self.textContent(element)) { - issues.append(.init(severity: .error, message: "content: \(error)")) + errors.append(XSDFailure(reason: "content: \(error)", at: path)) } case let .elementOnly(particle): - rejectText(element, allowed: false, into: &issues) - validateParticle(particle, children: children, into: &issues) + rejectText(element, at: path, into: &errors) + validateParticle(particle, children: children, at: path, into: &errors) case let .mixed(particle): - validateParticle(particle, children: children, into: &issues) + validateParticle(particle, children: children, at: path, into: &errors) } } - private func rejectText( - _ element: PureXML.Model.Element, - allowed: Bool, - into issues: inout [PureXML.Validation.Issue], - ) { - guard !allowed, !Self.textContent(element).isEmpty else { return } - issues.append(.init(severity: .error, message: "element must not contain text")) + private func rejectText(_ element: PureXML.Model.Element, at path: XSDPath, into errors: inout [XSDFailure]) { + guard !Self.textContent(element).isEmpty else { return } + errors.append(XSDFailure(reason: "element must not contain text", at: path)) } // MARK: Particles @@ -95,7 +101,8 @@ public extension PureXML.Schema { private func validateParticle( _ particle: Particle, children: [PureXML.Model.Element], - into issues: inout [PureXML.Validation.Issue], + at path: XSDPath, + into errors: inout [XSDFailure], ) { let names = children.map(\.name) let structureValid: Bool = if case let .group(group) = particle.term, group.compositor == .all { @@ -104,31 +111,34 @@ public extension PureXML.Schema { ContentNFABuilder.build(particle).matchesWhole(names) } if !structureValid { - issues.append(.init(severity: .error, message: "content does not match the content model")) + errors.append(XSDFailure(reason: "content does not match the content model", at: path)) return } - validateChildren(children, childTypes: Self.elementTypes(in: particle.term), into: &issues) + validateChildren(children, childTypes: Self.elementTypes(in: particle.term), at: path, into: &errors) } private func validateChildren( _ children: [PureXML.Model.Element], childTypes: [String: ElementType], - into issues: inout [PureXML.Validation.Issue], + at path: XSDPath, + into errors: inout [XSDFailure], ) { - for child in children { + let steps = Self.childSteps(children) + for (child, step) in zip(children, steps) { guard let declared = childTypes[Self.key(child.name)] else { continue } - validateChild(child, against: declared, into: &issues) + validateChild(child, against: declared, at: path + [step], into: &errors) } } private func validateChild( _ child: PureXML.Model.Element, against declared: ElementType, - into issues: inout [PureXML.Validation.Issue], + at path: XSDPath, + into errors: inout [XSDFailure], ) { - // An instance `xsi:type` overrides the declared type, provided the - // named type exists in the schema. Resolve to the type itself rather - // than a reference so re-entry does not re-read the same xsi:type. + // 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. var declared = declared if let overriding = Self.xsiTypeName(child), let resolved = types[overriding] { declared = resolved @@ -136,24 +146,40 @@ public extension PureXML.Schema { 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")) + errors.append(XSDFailure(reason: "'\(child.name.localName)' must not have children", at: path)) } if let error = simple.validate(Self.textContent(child)) { - issues.append(.init(severity: .error, message: "'\(child.name.localName)': \(error)")) + errors.append(XSDFailure(reason: "'\(child.name.localName)': \(error)", at: path)) } case let .complex(complex): - issues.append(contentsOf: validate(child, against: complex)) + errors.append(contentsOf: validate(child, against: complex, at: path)) case let .typeReference(name): guard let resolved = types[name] else { - issues.append(.init(severity: .error, message: "unknown type '\(name)'")) + errors.append(XSDFailure(reason: "unknown type '\(name)'", at: path)) return } - validateChild(child, against: resolved, into: &issues) + validateChild(child, against: resolved, at: path, into: &errors) } } // MARK: Helpers + /// The coding-path step for each child: its name, with a sibling index only + /// when more than one child shares that name. + private 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. private static func matchesAll(_ group: Group, names: [PureXML.Model.QualifiedName]) -> Bool { @@ -221,10 +247,9 @@ public extension PureXML.Schema { 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. + /// 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. private static func xsiTypeName(_ element: PureXML.Model.Element) -> String? { let schemaInstance = "http://www.w3.org/2001/XMLSchema-instance" let match = element.attributes.first { attribute in diff --git a/Sources/Schema/IdentityValidator.swift b/Sources/Schema/IdentityValidator.swift index 5554aa4..65169b7 100644 --- a/Sources/Schema/IdentityValidator.swift +++ b/Sources/Schema/IdentityValidator.swift @@ -9,9 +9,9 @@ extension PureXML.Schema { /// The constraints declared for each element local name. let constraints: [String: [IdentityConstraint]] - func validate(_ root: PureXML.Model.TreeNode) -> [PureXML.Validation.Issue] { + func validate(_ root: PureXML.Model.TreeNode) -> [PureXML.Validation.ValidationError] { guard !constraints.isEmpty else { return [] } - var issues: [PureXML.Validation.Issue] = [] + var issues: [PureXML.Validation.ValidationError] = [] walk(root, scopes: [], into: &issues) return issues } @@ -19,7 +19,7 @@ extension PureXML.Schema { private func walk( _ node: PureXML.Model.TreeNode, scopes: [[String: [[String?]]]], - into issues: inout [PureXML.Validation.Issue], + into issues: inout [PureXML.Validation.ValidationError], ) { var frame: [String: [[String?]]] = [:] let declared = node.name.flatMap { constraints[$0.localName] } ?? [] @@ -48,7 +48,7 @@ extension PureXML.Schema { _ constraint: IdentityConstraint, at node: PureXML.Model.TreeNode, frame: inout [String: [[String?]]], - into issues: inout [PureXML.Validation.Issue], + into issues: inout [PureXML.Validation.ValidationError], ) { guard isNonRef(constraint) else { return } var tuples: [[String?]] = [] @@ -56,12 +56,12 @@ extension PureXML.Schema { for target in select(constraint.selector, at: node) { let tuple = fieldTuple(constraint.fields, at: target) if constraint.kind == .key, tuple.contains(where: { $0 == nil }) { - issues.append(.init(severity: .error, message: "key '\(constraint.name)': a field is missing")) + issues.append(.init(reason: "key '\(constraint.name)': a field is missing", at: [])) continue } if tuple.contains(where: { $0 == nil }) { continue } if seen.contains(where: { $0 == tuple }) { - issues.append(.init(severity: .error, message: "\(label(constraint)) '\(constraint.name)': duplicate value")) + issues.append(.init(reason: "\(label(constraint)) '\(constraint.name)': duplicate value", at: [])) } else { seen.append(tuple) } @@ -75,14 +75,14 @@ extension PureXML.Schema { refer: String, at node: PureXML.Model.TreeNode, scopes: [[String: [[String?]]]], - into issues: inout [PureXML.Validation.Issue], + into issues: inout [PureXML.Validation.ValidationError], ) { let keyTuples = scopes.reversed().compactMap { $0[refer] }.first ?? [] for target in select(constraint.selector, at: node) { let tuple = fieldTuple(constraint.fields, at: target) if tuple.contains(where: { $0 == nil }) { continue } if !keyTuples.contains(where: { $0 == tuple }) { - issues.append(.init(severity: .error, message: "keyref '\(constraint.name)': no matching key '\(refer)'")) + issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: [])) } } } diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift index df2c4bb..132e32d 100644 --- a/Sources/Schema/SchemaDocument.swift +++ b/Sources/Schema/SchemaDocument.swift @@ -29,21 +29,20 @@ public extension PureXML.Schema { constraints = compiled.constraints } - /// 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] { + /// Validates an instance document against the schema, returning one located + /// ``PureXML/Validation/ValidationError`` per violation. Reports an error + /// when the root element has no global declaration. + public func validate(_ xml: String) throws -> [PureXML.Validation.ValidationError] { 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")] + return [.init(reason: "the document has no root element", at: [])] } guard let declaration = elements[root.name.localName] else { - return [.init(severity: .error, message: "no element declaration for '\(root.name.localName)'")] + return [.init(reason: "no element declaration for '\(root.name.localName)'", at: [])] } - var issues = ComplexValidator(types: types).validate(root, as: declaration) - issues += IdentityValidator(constraints: constraints).validate(PureXML.Model.TreeNode(.element(root))) - return issues + let context = PureXML.Validation.XSDContext(types: types, constraints: constraints, rootDeclaration: declaration) + return PureXML.Validation.XSD.validator().errors(for: .element(root), in: context) } } } diff --git a/Sources/Schema/XSDValidations.swift b/Sources/Schema/XSDValidations.swift new file mode 100644 index 0000000..27ba99c --- /dev/null +++ b/Sources/Schema/XSDValidations.swift @@ -0,0 +1,59 @@ +public extension PureXML.Validation { + /// The compiled-schema context the XSD validations read: the named-type table + /// a content model resolves against, the identity constraints, and the root + /// element's declared type. + struct XSDContext { + public let types: [String: PureXML.Schema.ElementType] + public let constraints: [String: [PureXML.Schema.IdentityConstraint]] + public let rootDeclaration: PureXML.Schema.ElementType? + + public init( + types: [String: PureXML.Schema.ElementType], + constraints: [String: [PureXML.Schema.IdentityConstraint]], + rootDeclaration: PureXML.Schema.ElementType?, + ) { + self.types = types + self.constraints = constraints + self.rootDeclaration = rootDeclaration + } + } + + /// XSD validation expressed as composable ``Validation`` values over an + /// ``XSDContext``. The content-model check is inherently recursive (a child's + /// type depends on its position), so each rule delegates to the recursive + /// validators while contributing located ``ValidationError`` results to one + /// collection. + enum XSD { + /// The document element is valid against its declared XSD type. + static var contentValidity: Validation { + .init( + description: "The document element is valid against its XSD type", + check: { context in + guard case let .element(root) = context.subject, let declaration = context.document.rootDeclaration else { + return [] + } + return PureXML.Schema.ComplexValidator(types: context.document.types) + .validate(root, as: declaration, at: [.element(root.name.description)]) + }, + when: { $0.codingPath.isEmpty }, + ) + } + + /// The document's XSD identity constraints (`unique`, `key`, `keyref`) hold. + static var identityConstraints: Validation { + .init( + description: "XSD identity constraints hold", + check: { context in + PureXML.Schema.IdentityValidator(constraints: context.document.constraints) + .validate(PureXML.Model.TreeNode(context.subject)) + }, + when: { $0.codingPath.isEmpty }, + ) + } + + /// A validator combining the content-model and identity-constraint rules. + static func validator() -> Validator { + Validator.blank.validating(contentValidity, identityConstraints) + } + } +} diff --git a/Tests/XSDComplexTypeTests.swift b/Tests/XSDComplexTypeTests.swift index 74590af..6c52d27 100644 --- a/Tests/XSDComplexTypeTests.swift +++ b/Tests/XSDComplexTypeTests.swift @@ -18,7 +18,7 @@ struct XSDComplexTypeTests { private enum TestFailure: Error { case noRoot } - private func validate(_ xml: String, _ type: Schema.ComplexType) throws -> [PureXML.Validation.Issue] { + private func validate(_ xml: String, _ type: Schema.ComplexType) throws -> [PureXML.Validation.ValidationError] { try Schema.ComplexValidator().validate(parse(xml), against: type) } diff --git a/Tests/XSDCompositionTests.swift b/Tests/XSDCompositionTests.swift index cd8c747..f943634 100644 --- a/Tests/XSDCompositionTests.swift +++ b/Tests/XSDCompositionTests.swift @@ -3,7 +3,7 @@ import Testing @Suite("XSD list, union, groups, and references") struct XSDCompositionTests { - private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.Issue] { + private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] { try PureXML.Schema.Document(xsd).validate(xml) } diff --git a/Tests/XSDIdentityTests.swift b/Tests/XSDIdentityTests.swift index e84df74..90b4c93 100644 --- a/Tests/XSDIdentityTests.swift +++ b/Tests/XSDIdentityTests.swift @@ -3,7 +3,7 @@ import Testing @Suite("XSD identity constraints") struct XSDIdentityTests { - private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.Issue] { + private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] { try PureXML.Schema.Document(xsd).validate(xml) } diff --git a/Tests/XSDImportTests.swift b/Tests/XSDImportTests.swift index 855151a..b85856e 100644 --- a/Tests/XSDImportTests.swift +++ b/Tests/XSDImportTests.swift @@ -7,7 +7,7 @@ struct XSDImportTests { _ xsd: String, _ xml: String, loader: @escaping (String) -> String? = { _ in nil }, - ) throws -> [PureXML.Validation.Issue] { + ) throws -> [PureXML.Validation.ValidationError] { try PureXML.Schema.Document(xsd, schemaLoader: loader).validate(xml) } diff --git a/Tests/XSDSchemaTests.swift b/Tests/XSDSchemaTests.swift index 54df2ec..77a21d2 100644 --- a/Tests/XSDSchemaTests.swift +++ b/Tests/XSDSchemaTests.swift @@ -3,10 +3,26 @@ import Testing @Suite("XSD schema documents") struct XSDSchemaTests { - private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.Issue] { + private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] { try PureXML.Schema.Document(xsd).validate(xml) } + @Test("A nested type violation is located by coding path") + func test_violationCarriesPath() throws { + let xsd = """ + + + + + + + + """ + let found = try validate(xsd, "lots") + #expect(found.count == 1) + #expect(String(describing: found[0]).hasSuffix("at path: order/qty")) + } + @Test("A simple element with a built-in type") func test_simpleElement() throws { let xsd = """