From ee5c46929c7d8b414bd8d1302b8ab1ff2af16dec Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sun, 21 Jun 2026 16:35:59 +0200 Subject: [PATCH] feat(schema): locate derivation/cycle-pass schema diagnostics Convert the three derivation/cycle-pass compile checkers to located findings so each error attaches to its declaring node instead of the schema root (#169): an all-group referenced inside a sequence/choice/all locates on the offending group reference (allGroupReferencePlacementFindings); a type derived from itself locates on its complexType/simpleType (derivationCycleFindings); and an element that is a member of its own substitution group, or a model group or attribute group that references itself, locates on its declaration (circularReferenceFindings). The cycle checkers report a cycle by component name over a name-keyed graph, so each now records the declaring node in a parallel nodeOf map built in the same loop as the graph; a name that yields a finding always has a node, so a mutual cycle underlines each member on its own declaration. Every predicate, key ordering, and reason string is unchanged; only the location each finding carries differs. The three checkers had exactly one caller each, so each is renamed in place and the .unlocated wrap dropped. Adds a located-diagnostics test per checker family, covering both substitution and group-reference cycles. --- CHANGELOG.md | 2 +- Sources/Schema/SchemaAllGroupPlacement.swift | 23 +++-- Sources/Schema/SchemaCompileValidation.swift | 30 +++--- Sources/Schema/XSDDerivation.swift | 33 +++++-- ...emaDerivationLocatedDiagnosticsTests.swift | 95 +++++++++++++++++++ 5 files changed, 147 insertions(+), 36 deletions(-) create mode 100644 Tests/SchemaDerivationLocatedDiagnosticsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a912bc5..90b9363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ 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. Value-constraint errors now locate to their declaring element or attribute: a `default`/`fixed` on an ID-derived type, a value that is invalid for its built-in, named user, or inline type, and an illegal combination of value constraints each underline the offending declaration rather than the schema root. The structure-pass checkers are likewise located: a facet inapplicable to a list or union variety, a derivation by restriction of `xs:anySimpleType` and a constraining facet on `anySimpleType`-based simple content, a top-level definition missing its name or carrying a local-only attribute, a nested named definition, an empty `targetNamespace` or `import` namespace, and an all-group reference with an illegal `minOccurs`/`maxOccurs` each underline their own node instead of the schema root. The simple-type-pass checkers join them: a list whose item type is itself a list or a union containing a list, a list declaring both or neither of `itemType`/inline `simpleType`, an empty union, a simpleType restriction whose base resolves to a complex type, an `itemType`/`memberType` referencing a type that is `final` for list/union derivation, and an attribute typed by a complex type each underline the offending `list`, `union`, `restriction`, or `attribute` node rather than the schema root. The attribute-pass checkers follow: an attribute declaration that lands in the XSI namespace underlines the `attribute`; a duplicate attribute use or a second ID-typed attribute underlines its declaring `complexType` or `attributeGroup`; an attribute reference that contradicts a global `fixed` value underlines the reference; and a complex-type restriction that relaxes a base attribute use (making a required attribute optional, narrowing its type unfaithfully, dropping a fixed value, or widening the attribute wildcard) underlines the `restriction`. +- 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. Value-constraint errors now locate to their declaring element or attribute: a `default`/`fixed` on an ID-derived type, a value that is invalid for its built-in, named user, or inline type, and an illegal combination of value constraints each underline the offending declaration rather than the schema root. The structure-pass checkers are likewise located: a facet inapplicable to a list or union variety, a derivation by restriction of `xs:anySimpleType` and a constraining facet on `anySimpleType`-based simple content, a top-level definition missing its name or carrying a local-only attribute, a nested named definition, an empty `targetNamespace` or `import` namespace, and an all-group reference with an illegal `minOccurs`/`maxOccurs` each underline their own node instead of the schema root. The simple-type-pass checkers join them: a list whose item type is itself a list or a union containing a list, a list declaring both or neither of `itemType`/inline `simpleType`, an empty union, a simpleType restriction whose base resolves to a complex type, an `itemType`/`memberType` referencing a type that is `final` for list/union derivation, and an attribute typed by a complex type each underline the offending `list`, `union`, `restriction`, or `attribute` node rather than the schema root. The attribute-pass checkers follow: an attribute declaration that lands in the XSI namespace underlines the `attribute`; a duplicate attribute use or a second ID-typed attribute underlines its declaring `complexType` or `attributeGroup`; an attribute reference that contradicts a global `fixed` value underlines the reference; and a complex-type restriction that relaxes a base attribute use (making a required attribute optional, narrowing its type unfaithfully, dropping a fixed value, or widening the attribute wildcard) underlines the `restriction`. The derivation/cycle-pass checkers, which report a cycle by component name over a name-keyed graph, now record the declaring node alongside that graph and underline it: a type derived from itself underlines its `complexType`/`simpleType`, an element that is a member of its own substitution group underlines its `element`, a model group or attribute group that references itself underlines its `group`/`attributeGroup` declaration, and an all-group referenced inside a `sequence`/`choice`/`all` underlines the offending `group` reference. - DTD parse-time validity findings are warnings until strict validation promotes them; `validateAgainstInternalDTD` returns full findings (errors and warnings). diff --git a/Sources/Schema/SchemaAllGroupPlacement.swift b/Sources/Schema/SchemaAllGroupPlacement.swift index 98aa34c..2b60c86 100644 --- a/Sources/Schema/SchemaAllGroupPlacement.swift +++ b/Sources/Schema/SchemaAllGroupPlacement.swift @@ -8,7 +8,7 @@ extension PureXML.Schema.XSDParser { /// where that reference appears inside a compositor (so the `all` is not the /// whole content). The reference is followed only when it resolves to this /// schema's own target namespace. - static func allGroupReferencePlacementErrors(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [String] { + static func allGroupReferencePlacementFindings(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [PureXML.Schema.SchemaLocatedFinding] { // A redefined group's effective content is the redefinition's, not the // original loaded definition, so a redefined group name is exempt: its // original `all` content is overridden (and the suite treats a reference to @@ -41,21 +41,30 @@ extension PureXML.Schema.XSDParser { } } guard !allGroups.isEmpty else { return [] } - var errors: [String] = [] + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] for container in containers { - collectAllGroupRefPlacement(container, allGroups, bindings, target, into: &errors) + collectAllGroupRefPlacement(container, allGroups, bindings, target, into: &findings) } - return errors + return findings } - private static func collectAllGroupRefPlacement(_ node: XSDTree, _ allGroups: Set, _ bindings: [String: String], _ target: String?, into errors: inout [String]) { + private static func collectAllGroupRefPlacement( + _ node: XSDTree, + _ allGroups: Set, + _ bindings: [String: String], + _ target: String?, + into findings: inout [PureXML.Schema.SchemaLocatedFinding], + ) { let kind = AllNode.localName(node) let insideCompositor = kind == "sequence" || kind == "choice" || kind == "all" for child in AllNode.elementChildren(node) { if insideCompositor, let referenced = allGroupReference(child, allGroups, bindings, target) { - errors.append("the all-group '\(referenced)' may not be referenced inside a '\(kind ?? "")'; an all group must be the whole content model") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "the all-group '\(referenced)' may not be referenced inside a '\(kind ?? "")'; an all group must be the whole content model", + node: child, + )) } - collectAllGroupRefPlacement(child, allGroups, bindings, target, into: &errors) + collectAllGroupRefPlacement(child, allGroups, bindings, target, into: &findings) } } diff --git a/Sources/Schema/SchemaCompileValidation.swift b/Sources/Schema/SchemaCompileValidation.swift index d9288cc..500d613 100644 --- a/Sources/Schema/SchemaCompileValidation.swift +++ b/Sources/Schema/SchemaCompileValidation.swift @@ -95,36 +95,30 @@ extension PureXML.Validation { static var typeDerivationAcyclic: Validation { compileRule("Type derivation chains contain no cycles") { document in - PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.derivationCycleErrors( - document.containers, - document.context.namespaceBindings, - document.context.targetNamespace, - ), + PureXML.Schema.XSDParser.derivationCycleFindings( + document.containers, + document.context.namespaceBindings, + document.context.targetNamespace, ) } } static var typeReferencesAcyclic: Validation { compileRule("Schema type references contain no cycles") { document in - PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.circularReferenceErrors( - document.containers, - document.context.namespaceBindings, - document.context.targetNamespace, - ), + PureXML.Schema.XSDParser.circularReferenceFindings( + 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.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.allGroupReferencePlacementErrors( - document.containers, - document.context.namespaceBindings, - document.context.targetNamespace, - ), + PureXML.Schema.XSDParser.allGroupReferencePlacementFindings( + document.containers, + document.context.namespaceBindings, + document.context.targetNamespace, ) } } diff --git a/Sources/Schema/XSDDerivation.swift b/Sources/Schema/XSDDerivation.swift index 1faf0e7..31b6d71 100644 --- a/Sources/Schema/XSDDerivation.swift +++ b/Sources/Schema/XSDDerivation.swift @@ -107,9 +107,10 @@ extension PureXML.Schema.XSDParser { /// redefinition derives from its former self), so redefined types are treated as /// chain ends rather than self-cycles, matching the suite's "circular ref is /// allowed if parent is redefine". - static func derivationCycleErrors(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [String] { + static func derivationCycleFindings(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [PureXML.Schema.SchemaLocatedFinding] { let redefined = redefinedTypeNames(containers) var graph: [String: String] = [:] + var nodeOf: [String: XSDTree] = [:] for container in containers { for kind in ["complexType", "simpleType"] { for type in descendants(container, named: kind) { @@ -120,12 +121,13 @@ extension PureXML.Schema.XSDParser { // local names collide (a `local:X` extending an imported `other:X`). guard XSDDerivNode.referenceNamespace(base, bindings) == target else { continue } graph[name] = XSDDerivNode.stripPrefix(base) + nodeOf[name] = type } } } return graph.keys.sorted().compactMap { name in guard !redefined.contains(name), derivesFromItself(name, graph, redefined) else { return nil } - return "type '\(name)' must not be derived from itself" + return PureXML.Schema.SchemaLocatedFinding(reason: "type '\(name)' must not be derived from itself", node: nodeOf[name]) } } @@ -189,36 +191,47 @@ extension PureXML.Schema.XSDParser { /// chain may not loop. A reference is followed only when it resolves to this /// schema's own target namespace, and a redefined group/attribute group is a /// chain end (its single self-reference is the legal redefinition). - static func circularReferenceErrors(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [String] { - substitutionCycleErrors(containers, bindings, target) - + referenceCycleErrors(containers, "group", "model group", bindings, target) - + referenceCycleErrors(containers, "attributeGroup", "attribute group", bindings, target) + static func circularReferenceFindings(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [PureXML.Schema.SchemaLocatedFinding] { + substitutionCycleFindings(containers, bindings, target) + + referenceCycleFindings(containers, "group", "model group", bindings, target) + + referenceCycleFindings(containers, "attributeGroup", "attribute group", bindings, target) } - private static func substitutionCycleErrors(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [String] { + private static func substitutionCycleFindings(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [PureXML.Schema.SchemaLocatedFinding] { var graph: [String: String] = [:] + var nodeOf: [String: XSDTree] = [:] for container in containers { for element in descendants(container, named: "element") { guard let name = XSDDerivNode.attribute(element, "name"), let head = XSDDerivNode.attribute(element, "substitutionGroup"), XSDDerivNode.referenceNamespace(head, bindings) == target else { continue } graph[name] = XSDDerivNode.stripPrefix(head) + nodeOf[name] = element } } return graph.keys.sorted().compactMap { name in - derivesFromItself(name, graph, []) ? "element '\(name)' must not be a member of its own substitution group" : nil + guard derivesFromItself(name, graph, []) else { return nil } + return PureXML.Schema.SchemaLocatedFinding(reason: "element '\(name)' must not be a member of its own substitution group", node: nodeOf[name]) } } /// A named-definition reference cycle: a `kind` definition (`group` / /// `attributeGroup`) whose `kind`-references reach itself. Multi-edge, since a /// definition may reference several others. - private static func referenceCycleErrors(_ containers: [XSDTree], _ kind: String, _ label: String, _ bindings: [String: String], _ target: String?) -> [String] { + private static func referenceCycleFindings( + _ containers: [XSDTree], + _ kind: String, + _ label: String, + _ bindings: [String: String], + _ target: String?, + ) -> [PureXML.Schema.SchemaLocatedFinding] { let redefined = redefinedNames(containers, kind) var adjacency: [String: [String]] = [:] + var nodeOf: [String: XSDTree] = [:] for container in containers { for definition in descendants(container, named: kind) { guard let name = XSDDerivNode.attribute(definition, "name") else { continue } + if nodeOf[name] == nil { nodeOf[name] = definition } for ref in boundedReferences(definition, kind) where XSDDerivNode.referenceNamespace(ref, bindings) == target { adjacency[name, default: []].append(XSDDerivNode.stripPrefix(ref)) } @@ -226,7 +239,7 @@ extension PureXML.Schema.XSDParser { } return adjacency.keys.sorted().compactMap { name in guard !redefined.contains(name), reachesItself(name, adjacency, redefined) else { return nil } - return "\(label) '\(name)' must not reference itself" + return PureXML.Schema.SchemaLocatedFinding(reason: "\(label) '\(name)' must not reference itself", node: nodeOf[name]) } } diff --git a/Tests/SchemaDerivationLocatedDiagnosticsTests.swift b/Tests/SchemaDerivationLocatedDiagnosticsTests.swift new file mode 100644 index 0000000..4cb9eaf --- /dev/null +++ b/Tests/SchemaDerivationLocatedDiagnosticsTests.swift @@ -0,0 +1,95 @@ +import Testing +@testable import PureXML + +/// Derivation/cycle-pass schema compile findings (a type derived from itself, an +/// element in its own substitution group, a model group referencing itself, and +/// an all-group referenced inside a compositor) carry validation coding paths that +/// resolve to source spans, the IDE underline flow for #169. The cycle checkers +/// report by component name over a name-keyed graph, so each finding now locates +/// on the declaring node recorded alongside that graph. Split from the other +/// located-diagnostics suites to keep each suite within the type-body length budget. +@Suite("Schema derivation/cycle-pass located compile diagnostics") +struct SchemaDerivationLocatedDiagnosticsTests { + 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 type derived from itself is located on the type declaration") + func test_derivationCycleLocated() throws { + let xsd = """ + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("type 'a' must not be derived from itself") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 2) + } + + @Test("An element in its own substitution group is located on the element") + func test_substitutionCycleLocated() throws { + let xsd = """ + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("element 'a' must not be a member of its own substitution group") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:element"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 2) + } + + @Test("A model group referencing itself is located on the group declaration") + func test_groupReferenceCycleLocated() throws { + let xsd = """ + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("model group 'g' must not reference itself") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:group"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 2) + } + + @Test("An all-group referenced inside a compositor is located on the reference") + func test_allGroupReferencePlacementLocated() throws { + let xsd = """ + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("may not be referenced inside a 'sequence'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:sequence", "xs:group"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 4) + } +}