From 489275c6b818f52abe70301e7aa6f74eb028cb6d Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 16 Jun 2026 10:26:11 +0200 Subject: [PATCH] feat(schema): locate compile diagnostics with validation coding paths (#169) Schema compile findings now carry ValidationError coding paths that resolve to source spans on a ranged tree, enabling IDE underlines for id, structure, include, and facet violations. --- CHANGELOG.md | 2 + Sources/Model/TreeNodeSpan.swift | 21 ++++ Sources/Schema/SchemaCompileValidation.swift | 125 +++++++++++++------ Sources/Schema/SchemaDocument.swift | 14 ++- Sources/Schema/SchemaIDAttribute.swift | 30 +++-- Sources/Schema/SchemaNameUniqueness.swift | 25 +++- Sources/Schema/SchemaReferences.swift | 20 +++ Sources/Schema/SchemaStructure.swift | 48 ++++--- Sources/Schema/XSDContext.swift | 22 +++- Sources/Schema/XSDParser.swift | 4 +- Sources/Schema/XSDSimpleParser.swift | 2 +- Sources/Validation/Schematron.swift | 24 +--- Tests/SchemaLocatedDiagnosticsTests.swift | 107 ++++++++++++++++ Tests/XSDSchemaValidationTests.swift | 4 +- 14 files changed, 343 insertions(+), 105 deletions(-) create mode 100644 Tests/SchemaLocatedDiagnosticsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 731af1f..c7093df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Schema compile diagnostics are now located: `SchemaError.inconsistent` carries `[ValidationError]` with coding paths that resolve to `TreeNode.sourceRange` on a ranged schema tree (#169). Parse-time facet and structure findings attach to their declaring nodes where possible; `SchemaError.inconsistentFindings` exposes the full list. Schematron reuses the shared `validationCodingPath()` helper. + - DTD parse-time validity findings are warnings until strict validation promotes them; `validateAgainstInternalDTD` returns full findings (errors and warnings). - 2026-06-16 diff --git a/Sources/Model/TreeNodeSpan.swift b/Sources/Model/TreeNodeSpan.swift index ab4ee98..112e171 100644 --- a/Sources/Model/TreeNodeSpan.swift +++ b/Sources/Model/TreeNodeSpan.swift @@ -21,4 +21,25 @@ public extension PureXML.Model.TreeNode { func sourceRange(at path: [PureXML.Validation.PathKey]) -> PureXML.Parsing.SourceRange? { node(at: path)?.sourceRange } + + /// The validation coding path from the document root to this node (a sibling + /// index only when a name repeats), for locating schema compile findings. + func validationCodingPath() -> [PureXML.Validation.PathKey] { + var steps: [PureXML.Validation.PathKey] = [] + var current: PureXML.Model.TreeNode? = self + while let element = current, element.kind == .element, let name = element.name?.description { + steps.append(validationPathStep(for: element, named: name)) + current = element.parent + } + return steps.reversed() + } + + private func validationPathStep(for element: PureXML.Model.TreeNode, named name: String) -> PureXML.Validation.PathKey { + guard let parent = element.parent else { return .element(name) } + let siblings = parent.children.filter { $0.kind == .element && $0.name?.description == name } + guard siblings.count > 1, let index = siblings.firstIndex(where: { $0 === element }) else { + return .element(name) + } + return .element(name, index: index + 1) + } } diff --git a/Sources/Schema/SchemaCompileValidation.swift b/Sources/Schema/SchemaCompileValidation.swift index f839db2..fe55ecc 100644 --- a/Sources/Schema/SchemaCompileValidation.swift +++ b/Sources/Schema/SchemaCompileValidation.swift @@ -35,6 +35,16 @@ extension PureXML.Schema { /// A marker subject for compile-time schema checks that run once over the /// whole schema document rather than per node in an instance tree. struct SchemaCompileRoot: PureXML.Validation.Validatable {} + + /// One schema compile finding with an optional source node for location. + struct SchemaLocatedFinding { + let reason: String + let node: PureXML.Model.TreeNode? + + static func unlocated(_ reasons: [String]) -> [SchemaLocatedFinding] { + reasons.map { SchemaLocatedFinding(reason: $0, node: nil) } + } + } } extension PureXML.Validation { @@ -47,71 +57,83 @@ extension PureXML.Validation { static var idAttributesValid: Validation { compileRule("Schema component id attributes are valid NCNames and unique") { document in - PureXML.Schema.XSDParser.idAttributeErrors(document.schema) + PureXML.Schema.XSDParser.idAttributeFindings(document.schema) } } static var schemaStructureValid: Validation { compileRule("Schema vocabulary elements follow the schema-for-schemas structure") { document in - PureXML.Schema.XSDParser.structureErrors(document.schema) + PureXML.Schema.XSDParser.structureFindings(document.schema) } } static var componentNamesUnique: Validation { compileRule("Global schema component names are unique within their symbol spaces") { document in - PureXML.Schema.XSDParser.componentNameErrors(document.schema, document.containers, document.context) + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.componentNameErrors(document.schema, document.containers, document.context), + ) } } static var simpleTypeFinalControlsValid: Validation { compileRule("Simple-type final controls are declared consistently") { document in - PureXML.Schema.XSDParser.simpleTypeFinalErrors( - document.schema, - compositionLoaded: document.context.compositionLoaded, - containers: document.containers, + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.simpleTypeFinalErrors( + document.schema, + compositionLoaded: document.context.compositionLoaded, + containers: document.containers, + ), ) } } static var contentModelsDeterministic: Validation { compileRule("Schema content models are deterministic (UPA)") { document in - PureXML.Schema.ContentModelDeterminism.violations(in: document.schema, context: document.context) + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.ContentModelDeterminism.violations(in: document.schema, context: document.context), + ) } } static var typeDerivationAcyclic: Validation { compileRule("Type derivation chains contain no cycles") { document in - PureXML.Schema.XSDParser.derivationCycleErrors( - document.containers, - document.context.namespaceBindings, - document.context.targetNamespace, + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.derivationCycleErrors( + document.containers, + document.context.namespaceBindings, + document.context.targetNamespace, + ), ) } } static var typeReferencesAcyclic: Validation { compileRule("Schema type references contain no cycles") { document in - PureXML.Schema.XSDParser.circularReferenceErrors( - document.containers, - document.context.namespaceBindings, - document.context.targetNamespace, + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.circularReferenceErrors( + document.containers, + document.context.namespaceBindings, + document.context.targetNamespace, + ), ) } } static var allGroupReferencesPlaced: Validation { compileRule("xs:all group references appear only where permitted") { document in - PureXML.Schema.XSDParser.allGroupReferencePlacementErrors( - document.containers, - document.context.namespaceBindings, - document.context.targetNamespace, + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.allGroupReferencePlacementErrors( + document.containers, + document.context.namespaceBindings, + document.context.targetNamespace, + ), ) } } static var includesCompositionValid: Validation { compileRule("Included schemas are chameleon or match the includer targetNamespace") { document in - PureXML.Schema.XSDParser.includeCompositionErrors( + PureXML.Schema.XSDParser.includeCompositionFindings( document.containers, mainTargetNamespace: document.context.targetNamespace, compositionLoaded: document.context.compositionLoaded, @@ -125,7 +147,7 @@ extension PureXML.Validation { static var schemaReferencesResolve: Validation { compileRule("Every schema reference resolves to a declared component") { document in guard let globalElements = document.globalElements else { return [] } - return PureXML.Schema.XSDParser.referenceErrors( + return PureXML.Schema.XSDParser.referenceFindings( document.schema, in: document.context, elements: globalElements, @@ -136,25 +158,31 @@ extension PureXML.Validation { static var attributeUsesValid: Validation { compileRule("Attribute uses are unique and declare at most one ID attribute") { document in - PureXML.Schema.XSDParser.attributeUseErrors(document.containers, document.context) + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.attributeUseErrors(document.containers, document.context), + ) } } static var idValueConstraintsValid: Validation { compileRule("ID-typed value constraints are valid") { document in - PureXML.Schema.XSDParser.idValueConstraintErrors(document.schema, document.context) + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.idValueConstraintErrors(document.schema, document.context), + ) } } static var substitutionMembersDeriveCorrectly: Validation { compileRule("Substitution-group members derive correctly from their head") { document in guard let derivation = document.derivation, let namedTypes = document.namedTypes else { return [] } - return PureXML.Schema.XSDParser.substitutionTypeErrors( - document.schema, - document.containers, - derivation, - namedTypes, - document.context, + return PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.substitutionTypeErrors( + document.schema, + document.containers, + derivation, + namedTypes, + document.context, + ), ) } } @@ -162,39 +190,51 @@ extension PureXML.Validation { static var userTypeValueConstraintsValid: Validation { compileRule("Element value constraints are valid against their declared types") { document in guard let namedTypes = document.namedTypes else { return [] } - return PureXML.Schema.XSDParser.userTypeValueConstraintErrors(document.schema, document.context, namedTypes) + return PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.userTypeValueConstraintErrors(document.schema, document.context, namedTypes), + ) } } static var extensionAllGroupsValid: Validation { compileRule("Complex types extending xs:all groups satisfy XSD placement rules") { document in guard let namedTypes = document.namedTypes else { return [] } - return PureXML.Schema.XSDParser.extensionAllGroupErrors(document.schema, document.context, namedTypes) + return PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.extensionAllGroupErrors(document.schema, document.context, namedTypes), + ) } } static var attributeRestrictionsFaithful: Validation { compileRule("Attribute restrictions are faithful to their bases") { document in guard let namedTypes = document.namedTypes else { return [] } - return PureXML.Schema.XSDParser.attributeRestrictionErrors(document.schema, document.context, namedTypes) + return PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.attributeRestrictionErrors(document.schema, document.context, namedTypes), + ) } } static var simpleTypeBasesAreSimple: Validation { compileRule("Simple types do not derive from complex types") { document in - PureXML.Schema.XSDParser.simpleTypeBaseNotComplexErrors(document.schema, in: document.context) + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.simpleTypeBaseNotComplexErrors(document.schema, in: document.context), + ) } } static var simpleTypeVarietiesValid: Validation { compileRule("Simple-type varieties are declared consistently") { document in - PureXML.Schema.XSDParser.simpleTypeVarietyErrors(document.schema, document.context) + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.simpleTypeVarietyErrors(document.schema, document.context), + ) } } static var notationsValid: Validation { compileRule("Notation declarations are valid") { document in - PureXML.Schema.XSDParser.notationValidityErrors(document.containers, document.context) + PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.notationValidityErrors(document.containers, document.context), + ) } } @@ -279,13 +319,20 @@ extension PureXML.Validation { private static func compileRule( _ description: String, - _ messages: @escaping (PureXML.Schema.SchemaCompileContext) -> [String], + _ findings: @escaping (PureXML.Schema.SchemaCompileContext) -> [PureXML.Schema.SchemaLocatedFinding], ) -> Validation { .init(description: description) { context in - messages(context.document).map { - ValidationError(reason: $0, at: context.codingPath) + findings(context.document).map { finding in + ValidationError( + reason: finding.reason, + at: finding.node?.validationCodingPath() ?? context.codingPath, + ) } } } + + static func locatedFindings(_ reasons: [String], at node: PureXML.Model.TreeNode?) -> [PureXML.Schema.SchemaLocatedFinding] { + reasons.map { PureXML.Schema.SchemaLocatedFinding(reason: $0, node: node) } + } } } diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift index e2ec585..1c8b7c4 100644 --- a/Sources/Schema/SchemaDocument.swift +++ b/Sources/Schema/SchemaDocument.swift @@ -15,7 +15,7 @@ public extension PureXML.Schema { /// The compiled schema fails one or more consistency rules (a `final` /// violation, an unfaithful restriction); every finding is carried, so a /// schema with several problems reports them all at once. - case inconsistent([String]) + case inconsistent([PureXML.Validation.ValidationError]) public var description: String { switch self { @@ -28,9 +28,15 @@ public extension PureXML.Schema { case let .invalidRelaxNG(reason): "invalid RELAX NG schema: \(reason)" case let .inconsistent(findings): - findings.joined(separator: "; ") + findings.map(\.description).joined(separator: "; ") } } + + /// Every compile-time finding when the schema is inconsistent. + public var inconsistentFindings: [PureXML.Validation.ValidationError]? { + if case let .inconsistent(findings) = self { return findings } + return nil + } } /// A compiled XSD schema: its global element declarations and named-type @@ -123,12 +129,12 @@ public extension PureXML.Schema { // Valid (Restriction)), and ALL findings are reported together, joined // with the schema-validity findings gathered while parsing (malformed // facet definitions and the like). - let findings = PureXML.Validation.XSDSchema.consistencyErrors( + let consistencyFindings = PureXML.Validation.XSDSchema.consistencyErrors( types: compiled.types, typeDerivation: compiled.typeDerivation, typeFinal: compiled.typeFinal, ) - let allFindings = compiled.schemaErrors + findings.map { String(describing: $0) } + let allFindings = compiled.schemaErrors + consistencyFindings if !allFindings.isEmpty { throw SchemaError.inconsistent(allFindings) } diff --git a/Sources/Schema/SchemaIDAttribute.swift b/Sources/Schema/SchemaIDAttribute.swift index e266072..9d3ac7a 100644 --- a/Sources/Schema/SchemaIDAttribute.swift +++ b/Sources/Schema/SchemaIDAttribute.swift @@ -9,26 +9,40 @@ extension PureXML.Schema.XSDParser { /// Walks the one document rooted at `schema`; an included or imported document /// keeps its own `id` scope, so cross-document collisions are not flagged here. static func idAttributeErrors(_ schema: XSDTree) -> [String] { - var errors: [String] = [] - var seen: Set = [] - collectIDErrors(schema, into: &errors, seen: &seen) - return errors + idAttributeFindings(schema).map(\.reason) } - private static func collectIDErrors(_ node: XSDTree, into errors: inout [String], seen: inout Set) { + static func idAttributeFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + var seen: Set = [] + collectIDFindings(schema, into: &findings, seen: &seen) + return findings + } + + private static func collectIDFindings( + _ node: XSDTree, + into findings: inout [PureXML.Schema.SchemaLocatedFinding], + seen: inout Set, + ) { // `appinfo` and `documentation` hold arbitrary foreign content whose own // `id` attributes are not xs:ID; do not descend into them. let local = PureXML.Schema.XSDNode.localName(node) if local == "appinfo" || local == "documentation" { return } if let id = unprefixedID(node) { if !PureXML.Schema.Lexical.isNCName(id) { - errors.append("id attribute value '\(id)' is not a valid NCName") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "id attribute value '\(id)' is not a valid NCName", + node: node, + )) } else if !seen.insert(id).inserted { - errors.append("duplicate id attribute value '\(id)' in the schema document") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "duplicate id attribute value '\(id)' in the schema document", + node: node, + )) } } for child in PureXML.Schema.XSDNode.elementChildren(node) { - collectIDErrors(child, into: &errors, seen: &seen) + collectIDFindings(child, into: &findings, seen: &seen) } } diff --git a/Sources/Schema/SchemaNameUniqueness.swift b/Sources/Schema/SchemaNameUniqueness.swift index c361085..7e16946 100644 --- a/Sources/Schema/SchemaNameUniqueness.swift +++ b/Sources/Schema/SchemaNameUniqueness.swift @@ -295,9 +295,23 @@ extension PureXML.Schema.XSDParser { compositionLoaded: Bool, containerLocations: [ObjectIdentifier: String?], ) -> [String] { + includeCompositionFindings( + containers, + mainTargetNamespace: mainTargetNamespace, + compositionLoaded: compositionLoaded, + containerLocations: containerLocations, + ).map(\.reason) + } + + static func includeCompositionFindings( + _ containers: [XSDTree], + mainTargetNamespace: String?, + compositionLoaded: Bool, + containerLocations: [ObjectIdentifier: String?], + ) -> [PureXML.Schema.SchemaLocatedFinding] { guard compositionLoaded else { return [] } let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: mainTargetNamespace) - var errors: [String] = [] + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] for (parentIndex, parent) in containers.enumerated() { guard PureXML.Schema.XSDNode.localName(parent) == "schema" else { continue } let parentNamespace = resolvedNamespace(at: parentIndex, in: namespaceMap, fallback: mainTargetNamespace) @@ -311,13 +325,14 @@ extension PureXML.Schema.XSDParser { let includedNamespace = PureXML.Schema.XSDNode.attribute(containers[includedIndex], "targetNamespace") if let includedNamespace, includedNamespace != parentNamespace { let parentLabel = parentNamespace ?? "no namespace" - errors.append( - "included schema targetNamespace '\(includedNamespace)' must match includer targetNamespace '\(parentLabel)' or be chameleon (no targetNamespace)", - ) + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "included schema targetNamespace '\(includedNamespace)' must match includer targetNamespace '\(parentLabel)' or be chameleon (no targetNamespace)", + node: child, + )) } } } - return errors + return findings } private static func resolvedNamespace(at index: Int, in map: [Int: String?], fallback: String?) -> String? { diff --git a/Sources/Schema/SchemaReferences.swift b/Sources/Schema/SchemaReferences.swift index 3ccbdc3..60b7a0e 100644 --- a/Sources/Schema/SchemaReferences.swift +++ b/Sources/Schema/SchemaReferences.swift @@ -35,6 +35,26 @@ extension PureXML.Schema.XSDParser { in context: PureXML.Schema.XSDContext, elements: [String: PureXML.Schema.ElementType], containers: [XSDTree], + ) -> [String] { + collectReferenceErrors(schema, in: context, elements: elements, containers: containers) + } + + static func referenceFindings( + _ schema: XSDTree, + in context: PureXML.Schema.XSDContext, + elements: [String: PureXML.Schema.ElementType], + containers: [XSDTree], + ) -> [PureXML.Schema.SchemaLocatedFinding] { + PureXML.Schema.SchemaLocatedFinding.unlocated( + collectReferenceErrors(schema, in: context, elements: elements, containers: containers), + ) + } + + private static func collectReferenceErrors( + _ schema: XSDTree, + in context: PureXML.Schema.XSDContext, + elements: [String: PureXML.Schema.ElementType], + containers: [XSDTree], ) -> [String] { let xsdErrors = xsdNamespaceReferenceErrors(schema) let simpleContentErrors = simpleContentBaseErrors(schema, in: context) diff --git a/Sources/Schema/SchemaStructure.swift b/Sources/Schema/SchemaStructure.swift index ddcad3b..c420b0a 100644 --- a/Sources/Schema/SchemaStructure.swift +++ b/Sources/Schema/SchemaStructure.swift @@ -54,18 +54,36 @@ extension PureXML.Schema.XSDParser { /// once) must be first, and an identity constraint needs a selector and field. /// Walks the one document rooted at `schema`, skipping foreign content. static func structureErrors(_ schema: XSDTree) -> [String] { - var errors: [String] = [] - let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema) - collectStructure(schema, bindings: bindings, into: &errors) - errors += simpleTypeVarietyFacetErrors(schema) - errors += valueConstraintErrors(schema) - errors += topLevelDeclarationErrors(schema) - errors += nestedNamedDefinitionErrors(schema) - errors += anySimpleTypeRestrictionErrors(schema) - return errors + structureFindings(schema).map(\.reason) } - private static func collectStructure(_ node: XSDTree, bindings: [String: String], into errors: inout [String]) { + static func structureFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema) + collectStructure(schema, bindings: bindings, into: &findings) + append(simpleTypeVarietyFacetErrors(schema), at: schema, into: &findings) + append(valueConstraintErrors(schema), at: schema, into: &findings) + append(topLevelDeclarationErrors(schema), at: schema, into: &findings) + append(nestedNamedDefinitionErrors(schema), at: schema, into: &findings) + append(anySimpleTypeRestrictionErrors(schema), at: schema, into: &findings) + return findings + } + + private static func append( + _ messages: [String], + at node: XSDTree, + into findings: inout [PureXML.Schema.SchemaLocatedFinding], + ) { + for message in messages { + findings.append(PureXML.Schema.SchemaLocatedFinding(reason: message, node: node)) + } + } + + private static func collectStructure( + _ node: XSDTree, + bindings: [String: String], + into findings: inout [PureXML.Schema.SchemaLocatedFinding], + ) { let local = PureXML.Schema.XSDNode.localName(node) let children = PureXML.Schema.XSDNode.elementChildren(node) var currentBindings = bindings @@ -77,15 +95,15 @@ extension PureXML.Schema.XSDParser { } } if node.name?.namespaceURI == xsdNamespace, let local { - errors += attributeValueErrors(node, bindings: currentBindings) - errors += occurrenceOrderErrors(node) - errors += attributeApplicabilityErrors(node, local: local) + append(attributeValueErrors(node, bindings: currentBindings), at: node, into: &findings) + append(occurrenceOrderErrors(node), at: node, into: &findings) + append(attributeApplicabilityErrors(node, local: local), at: node, into: &findings) let names = children.filter { $0.name?.namespaceURI == xsdNamespace }.compactMap(PureXML.Schema.XSDNode.localName) - errors += componentSpecificErrors(node, local: local, names: names) + append(componentSpecificErrors(node, local: local, names: names), at: node, into: &findings) } if local == "appinfo" || local == "documentation" { return } for child in children { - collectStructure(child, bindings: currentBindings, into: &errors) + collectStructure(child, bindings: currentBindings, into: &findings) } } diff --git a/Sources/Schema/XSDContext.swift b/Sources/Schema/XSDContext.swift index 3e0725a..2e10b6c 100644 --- a/Sources/Schema/XSDContext.swift +++ b/Sources/Schema/XSDContext.swift @@ -178,26 +178,36 @@ extension PureXML.Schema { /// Schema-validity findings gathered while parsing (malformed facet /// definitions and the like), reported together with the model-level /// consistency findings when the schema is compiled. - var schemaErrors: [String] = [] + var schemaErrors: [PureXML.Validation.ValidationError] = [] } /// Collects schema-validity findings during parsing. A reference type so the /// findings survive the value-type ``XSDContext``'s copies (`visiting(_:)`): /// every copy shares the one collector, and the parser harvests it at the end. final class SchemaDiagnostics { - private(set) var errors: [String] = [] + private(set) var errors: [PureXML.Validation.ValidationError] = [] - func report(_ message: String) { - errors.append(message) + func report(_ error: PureXML.Validation.ValidationError) { + errors.append(error) + } + + func report(_ reason: String, at node: PureXML.Model.TreeNode?) { + report(PureXML.Validation.ValidationError( + reason: reason, + at: node?.validationCodingPath() ?? [.element("schema")], + )) } /// The findings with exact-duplicate messages removed, first occurrence /// kept. The same malformed definition is visited more than once (named /// simple types resolve over repeated passes), so dedup keeps one finding /// per distinct problem. - var deduplicated: [String] { + var deduplicated: [PureXML.Validation.ValidationError] { var seen: Set = [] - return errors.filter { seen.insert($0).inserted } + return errors.filter { + let key = "\(PureXML.Validation.PathKey.render($0.codingPath))|\($0.reason)" + return seen.insert(key).inserted + } } } diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift index a65e888..6ad97e5 100644 --- a/Sources/Schema/XSDParser.swift +++ b/Sources/Schema/XSDParser.swift @@ -51,7 +51,7 @@ extension PureXML.Schema { compositionLoaded: compositionLoaded, ) for error in PureXML.Validation.SchemaCompile.preCompileErrors(schema: schema, context: context, containers: containers) { - context.diagnostics.report(error.reason) + context.diagnostics.report(error) } var types = namedTypes(containers, into: &context) let elements = globalElements(containers, context, into: &types) @@ -64,7 +64,7 @@ extension PureXML.Schema { namedTypes: types, ) for error in PureXML.Validation.SchemaCompile.postCompileErrors(in: postCompileDocument) { - context.diagnostics.report(error.reason) + context.diagnostics.report(error) } return buildCompiled( elements: elements, diff --git a/Sources/Schema/XSDSimpleParser.swift b/Sources/Schema/XSDSimpleParser.swift index ded4ba6..16b060a 100644 --- a/Sources/Schema/XSDSimpleParser.swift +++ b/Sources/Schema/XSDSimpleParser.swift @@ -43,7 +43,7 @@ extension PureXML.Schema { } applyFacets(restriction, into: &facets) for error in facetDefinitionErrors(restriction, base: baseType) + patternErrors(restriction) { - context.diagnostics.report(error) + context.diagnostics.report(error, at: restriction) } return SimpleType(base: baseType.base, facets: facets, variety: baseType.variety, isBuiltinList: baseType.isBuiltinList) } diff --git a/Sources/Validation/Schematron.swift b/Sources/Validation/Schematron.swift index 9d91ba9..e7ea29c 100644 --- a/Sources/Validation/Schematron.swift +++ b/Sources/Validation/Schematron.swift @@ -157,7 +157,7 @@ public extension PureXML.Validation { diagnostics: [String: [SchematronMessagePart]], _ functions: Functions, ) -> [ValidationError] { - let path = codingPath(of: node) + let path = node.validationCodingPath() var errors: [ValidationError] = [] for assertion in assertions { let holds = (try? assertion.test.value(at: node, position: 1, size: 1, variables: variables, functions: functions(node)).boolean) ?? false @@ -206,27 +206,5 @@ public extension PureXML.Validation { }.joined() return joined.split(whereSeparator: \.isWhitespace).joined(separator: " ") } - - /// The coding path of a context node, built from the element chain to the - /// root (a sibling index only when a name repeats), so each finding is - /// located at the node its rule fired on rather than the document root. - private static func codingPath(of node: PureXML.Model.TreeNode) -> [PathKey] { - var steps: [PathKey] = [] - var current: PureXML.Model.TreeNode? = node - while let element = current, element.kind == .element, let name = element.name?.description { - steps.append(step(for: element, named: name)) - current = element.parent - } - return steps.reversed() - } - - private static func step(for element: PureXML.Model.TreeNode, named name: String) -> PathKey { - guard let parent = element.parent else { return .element(name) } - let siblings = parent.children.filter { $0.kind == .element && $0.name?.description == name } - guard siblings.count > 1, let index = siblings.firstIndex(where: { $0 === element }) else { - return .element(name) - } - return .element(name, index: index + 1) - } } } diff --git a/Tests/SchemaLocatedDiagnosticsTests.swift b/Tests/SchemaLocatedDiagnosticsTests.swift new file mode 100644 index 0000000..d0e9216 --- /dev/null +++ b/Tests/SchemaLocatedDiagnosticsTests.swift @@ -0,0 +1,107 @@ +@testable import PureXML +import Testing + +/// Schema compile findings carry validation coding paths that resolve to source +/// spans on a ranged schema tree, the IDE underline flow for #169. +@Suite("Schema located compile diagnostics") +struct SchemaLocatedDiagnosticsTests { + private func inconsistentFindings(in xsd: String) throws -> [PureXML.Validation.ValidationError] { + do { + _ = try PureXML.Schema.Document(xsd) + Issue.record("expected schema compilation to fail") + return [] + } catch let error as PureXML.Schema.SchemaError { + guard case let .inconsistent(findings) = error else { + Issue.record("expected .inconsistent, got \(error)") + return [] + } + return findings + } + } + + @Test("A duplicate xs:ID is located on the offending component") + func test_duplicateIdLocated() throws { + let xsd = """ + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let duplicate = try #require(findings.first { $0.reason.contains("duplicate id") }) + #expect(duplicate.codingPath.map(\.stringValue) == ["xs:schema", "xs:element"]) + #expect(duplicate.codingPath.last?.intValue == 2) + + let (tree, _) = PureXML.readTree(xsd) + let range = tree.sourceRange(at: duplicate.codingPath) + #expect(range?.start.line == 3) + #expect(range?.start.column == 3) + } + + @Test("An invalid xs:ID value is located on its declaring component") + func test_invalidIdLocated() throws { + let xsd = """ + + + + """ + let findings = try inconsistentFindings(in: xsd) + let invalid = try #require(findings.first { $0.reason.contains("not a valid NCName") }) + #expect(invalid.codingPath.map(\.stringValue) == ["xs:schema", "xs:element"]) + + let (tree, _) = PureXML.readTree(xsd) + let range = tree.sourceRange(at: invalid.codingPath) + #expect(range?.start.line == 2) + #expect(range?.start.column == 3) + } + + @Test("An xs:include targetNamespace mismatch is located on the include directive") + func test_includeMismatchLocated() throws { + let included = """ + + + + """ + let main = """ + + + + + """ + let findings: [PureXML.Validation.ValidationError] + do { + _ = try PureXML.Schema.Document(main, schemaLoader: { $0 == "other.xsd" ? included : nil }) + Issue.record("expected include mismatch to be rejected") + return + } catch let error as PureXML.Schema.SchemaError { + guard case let .inconsistent(found) = error else { + Issue.record("expected .inconsistent, got \(error)") + return + } + findings = found + } + let mismatch = try #require(findings.first { $0.reason.contains("included schema targetNamespace") }) + #expect(mismatch.codingPath.map(\.stringValue) == ["xs:schema", "xs:include"]) + + let (tree, _) = PureXML.readTree(main) + let range = tree.sourceRange(at: mismatch.codingPath) + #expect(range?.start.line == 2) + #expect(range?.start.column == 3) + } + + @Test("SchemaError.inconsistentFindings exposes the validation errors") + func test_inconsistentFindingsAccessor() throws { + let xsd = """ + + + + """ + do { + _ = try PureXML.Schema.Document(xsd) + Issue.record("expected schema compilation to fail") + } catch let error as PureXML.Schema.SchemaError { + let findings = try #require(error.inconsistentFindings) + #expect(findings.contains { $0.reason.contains("not a valid NCName") }) + } + } +} diff --git a/Tests/XSDSchemaValidationTests.swift b/Tests/XSDSchemaValidationTests.swift index 0bd1ed6..5b768ae 100644 --- a/Tests/XSDSchemaValidationTests.swift +++ b/Tests/XSDSchemaValidationTests.swift @@ -136,8 +136,8 @@ struct XSDSchemaValidationTests { return } #expect(findings.count == 2) - #expect(findings.contains { $0.contains("declares final") }) - #expect(findings.contains { $0.contains("not a valid restriction") }) + #expect(findings.contains { $0.reason.contains("declares final") }) + #expect(findings.contains { $0.reason.contains("not a valid restriction") }) } }