From 3a56d9f40042c4de84914e9d063454003a55d28a Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sun, 21 Jun 2026 14:55:21 +0200 Subject: [PATCH] feat(schema): locate structure-pass schema diagnostics Convert the seven structure-pass compile checkers to located findings so each error attaches to its own declaring node instead of the schema root (#169): a facet inapplicable to a list/union variety, a 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. The rewire in structureFindings preserves the prior finding order and count; each error predicate and reason string is unchanged, only the location each finding carries differs. Adds a located-diagnostics test per checker family in a new suite kept within the type-body budget. --- CHANGELOG.md | 2 +- .../Schema/SchemaAttributeApplicability.swift | 22 ++- Sources/Schema/SchemaFacetApplicability.swift | 33 ++-- Sources/Schema/SchemaNestedDefinition.swift | 17 +- Sources/Schema/SchemaStructure.swift | 10 +- Sources/Schema/SchemaStructureHelpers.swift | 32 ++-- ...hemaStructureLocatedDiagnosticsTests.swift | 146 ++++++++++++++++++ 7 files changed, 219 insertions(+), 43 deletions(-) create mode 100644 Tests/SchemaStructureLocatedDiagnosticsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e87cc90..5e59c5f 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. +- 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. - DTD parse-time validity findings are warnings until strict validation promotes them; `validateAgainstInternalDTD` returns full findings (errors and warnings). diff --git a/Sources/Schema/SchemaAttributeApplicability.swift b/Sources/Schema/SchemaAttributeApplicability.swift index 95773ab..1bb3a90 100644 --- a/Sources/Schema/SchemaAttributeApplicability.swift +++ b/Sources/Schema/SchemaAttributeApplicability.swift @@ -121,14 +121,14 @@ extension PureXML.Schema.XSDParser { /// reference). The per-component applicability table is the permissive /// global/local union and cannot make this distinction; this scan supplies the /// global-only constraint. - static func topLevelDeclarationErrors(_ schema: XSDTree) -> [String] { - var errors: [String] = [] - collectTopLevelDeclarationErrors(in: schema, into: &errors) + static func topLevelDeclarationFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + collectTopLevelDeclarationFindings(in: schema, into: &findings) for redefine in PureXML.Schema.XSDNode.elementChildren(schema) { guard PureXML.Schema.XSDNode.localName(redefine) == "redefine" else { continue } - collectTopLevelDeclarationErrors(in: redefine, into: &errors) + collectTopLevelDeclarationFindings(in: redefine, into: &findings) } - return errors + return findings } private static let topLevelForbidden: [String: [String]] = [ @@ -142,17 +142,23 @@ extension PureXML.Schema.XSDParser { "simpleType", "complexType", "group", "attributeGroup", "element", "attribute", "notation", ] - private static func collectTopLevelDeclarationErrors(in container: XSDTree, into errors: inout [String]) { + private static func collectTopLevelDeclarationFindings(in container: XSDTree, into findings: inout [PureXML.Schema.SchemaLocatedFinding]) { for child in PureXML.Schema.XSDNode.elementChildren(container) { guard child.name?.namespaceURI == xsdNamespace, let kind = PureXML.Schema.XSDNode.localName(child) else { continue } if namedTopLevelKinds.contains(kind), !hasUnprefixed(child, "name") { - errors.append("a top-level '\(kind)' definition must have a 'name' attribute") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a top-level '\(kind)' definition must have a 'name' attribute", + node: child, + )) } if let forbidden = topLevelForbidden[kind] { for attribute in forbidden where hasUnprefixed(child, attribute) { - errors.append("a top-level '\(kind)' declaration may not specify '\(attribute)'") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a top-level '\(kind)' declaration may not specify '\(attribute)'", + node: child, + )) } } } diff --git a/Sources/Schema/SchemaFacetApplicability.swift b/Sources/Schema/SchemaFacetApplicability.swift index 25cff9e..e374183 100644 --- a/Sources/Schema/SchemaFacetApplicability.swift +++ b/Sources/Schema/SchemaFacetApplicability.swift @@ -9,10 +9,10 @@ extension PureXML.Schema.XSDParser { /// The base's variety is resolved from the raw tree, since this runs before the /// types are compiled; an atomic, unresolvable, or external base is skipped, so /// no valid restriction is rejected. - static func simpleTypeVarietyFacetErrors(_ schema: XSDTree) -> [String] { + static func simpleTypeVarietyFacetFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { let bindings = namespaceBindings(schema) let target = PureXML.Schema.XSDNode.attribute(schema, "targetNamespace") - var errors: [String] = [] + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] forEachSimpleType(schema) { simpleType in guard let restriction = PureXML.Schema.XSDNode.firstChild(simpleType, named: "restriction"), let variety = restrictionBaseVariety(restriction, schema, bindings, target, []) @@ -22,10 +22,13 @@ extension PureXML.Schema.XSDParser { .compactMap(PureXML.Schema.XSDNode.localName) .filter { facetNames.contains($0) } for facet in used where !allowed.contains(facet) { - errors.append("the facet '\(facet)' does not apply to a \(variety) type") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "the facet '\(facet)' does not apply to a \(variety) type", + node: simpleType, + )) } } - return errors + return findings } /// A `default`/`fixed` value on an `element` or `attribute` must be a valid value @@ -289,22 +292,25 @@ extension PureXML.Schema.XSDParser { /// Reject any restriction (in the XSD namespace) whose `base` /// attribute resolves to `xs:anySimpleType` in the XSD namespace when defined directly under a simpleType. - static func anySimpleTypeRestrictionErrors(_ schema: XSDTree) -> [String] { + static func anySimpleTypeRestrictionFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { let targetNamespace = schema.attributes.first { $0.name.prefix == nil && $0.name.localName == "targetNamespace" }?.value ?? "" if targetNamespace == xsdNamespace { return [] } let bindings = namespaceBindings(schema) - var errors: [String] = [] + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] forEachRestrictionInSimpleType(schema) { node in guard let base = PureXML.Schema.XSDNode.attribute(node, "base") else { return } let (prefix, local) = splitQName(base) let uri = prefix.map { bindings[$0] } ?? bindings[""] if uri == xsdNamespace, local == "anySimpleType" { - errors.append("derivation by restriction of 'xs:anySimpleType' is not allowed") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "derivation by restriction of 'xs:anySimpleType' is not allowed", + node: node, + )) } } - return errors + return findings } private static func forEachRestrictionInSimpleType(_ node: XSDTree, _ visit: (XSDTree) -> Void) { @@ -330,19 +336,22 @@ extension PureXML.Schema.XSDParser { /// built-in other than anySimpleType, a simple-type, or external/unresolved is left /// alone, so no valid facet is rejected. A restriction supplying its own nested /// `simpleType` base is also skipped (the facet applies to that, not anySimpleType). - static func anySimpleTypeFacetErrors(_ schema: XSDTree) -> [String] { + static func anySimpleTypeFacetFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { let bindings = namespaceBindings(schema) let target = PureXML.Schema.XSDNode.attribute(schema, "targetNamespace") - var errors: [String] = [] + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] forEachSimpleContentRestriction(schema) { restriction in guard PureXML.Schema.XSDNode.firstChild(restriction, named: "simpleType") == nil, let base = PureXML.Schema.XSDNode.attribute(restriction, "base"), hasConstrainingFacet(restriction), simpleContentBaseIsAnySimpleType(base, schema, bindings, target, []) else { return } - errors.append("a constraining facet may not be applied to anySimpleType-based simpleContent") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a constraining facet may not be applied to anySimpleType-based simpleContent", + node: restriction, + )) } - return errors + return findings } private static func hasConstrainingFacet(_ restriction: XSDTree) -> Bool { diff --git a/Sources/Schema/SchemaNestedDefinition.swift b/Sources/Schema/SchemaNestedDefinition.swift index 2b0eb4d..5ecabc7 100644 --- a/Sources/Schema/SchemaNestedDefinition.swift +++ b/Sources/Schema/SchemaNestedDefinition.swift @@ -11,23 +11,26 @@ extension PureXML.Schema.XSDParser { /// /// "Top-level" means a direct child of `schema` or of a `redefine`; everything /// deeper is nested. - static func nestedNamedDefinitionErrors(_ schema: XSDTree) -> [String] { - var errors: [String] = [] - collectNestedNamed(schema, parentIsTopLevel: false, into: &errors) - return errors + static func nestedNamedDefinitionFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + collectNestedNamed(schema, parentIsTopLevel: false, into: &findings) + return findings } - private static func collectNestedNamed(_ node: XSDTree, parentIsTopLevel: Bool, into errors: inout [String]) { + private static func collectNestedNamed(_ node: XSDTree, parentIsTopLevel: Bool, into findings: inout [PureXML.Schema.SchemaLocatedFinding]) { let kind = NestedNode.localName(node) // Foreign content inside an annotation is not the schema's own and is not // structurally checked. if kind == "appinfo" || kind == "documentation" { return } if isNestedNamedDefinition(node, kind, parentIsTopLevel: parentIsTopLevel) { - errors.append("a nested '\(kind ?? "")' definition may not have a 'name'; only a top-level definition is named") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a nested '\(kind ?? "")' definition may not have a 'name'; only a top-level definition is named", + node: node, + )) } let childrenAreTopLevel = kind == "schema" || kind == "redefine" for child in NestedNode.elementChildren(node) { - collectNestedNamed(child, parentIsTopLevel: childrenAreTopLevel, into: &errors) + collectNestedNamed(child, parentIsTopLevel: childrenAreTopLevel, into: &findings) } } diff --git a/Sources/Schema/SchemaStructure.swift b/Sources/Schema/SchemaStructure.swift index f1be940..3fce8b7 100644 --- a/Sources/Schema/SchemaStructure.swift +++ b/Sources/Schema/SchemaStructure.swift @@ -61,12 +61,12 @@ extension PureXML.Schema.XSDParser { 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) + findings += simpleTypeVarietyFacetFindings(schema) findings += valueConstraintFindings(schema) - append(topLevelDeclarationErrors(schema), at: schema, into: &findings) - append(nestedNamedDefinitionErrors(schema), at: schema, into: &findings) - append(anySimpleTypeRestrictionErrors(schema) + anySimpleTypeFacetErrors(schema), at: schema, into: &findings) - append(emptyNamespaceErrors(schema) + allGroupReferenceMaxOccursErrors(schema), at: schema, into: &findings) + findings += topLevelDeclarationFindings(schema) + findings += nestedNamedDefinitionFindings(schema) + findings += anySimpleTypeRestrictionFindings(schema) + anySimpleTypeFacetFindings(schema) + findings += emptyNamespaceFindings(schema) + allGroupReferenceMaxOccursFindings(schema) return findings } diff --git a/Sources/Schema/SchemaStructureHelpers.swift b/Sources/Schema/SchemaStructureHelpers.swift index 7e73a0e..5fbb32f 100644 --- a/Sources/Schema/SchemaStructureHelpers.swift +++ b/Sources/Schema/SchemaStructureHelpers.swift @@ -80,7 +80,7 @@ extension PureXML.Schema.XSDParser { /// is itself an all-group particle, so its `maxOccurs` must be 1 and its /// `minOccurs` 0 or 1. ``allGroupLimitedErrors`` covers the direct `` element; /// this covers a `` resolving to an all group (corpus particlesEa025). - static func allGroupReferenceMaxOccursErrors(_ schema: XSDTree) -> [String] { + static func allGroupReferenceMaxOccursFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { let xsd = PureXML.Schema.XSDParser.xsdNamespace var allGroupNames: Set = [] for group in descendants(schema, named: "group") where group.name?.namespaceURI == xsd { @@ -91,21 +91,27 @@ extension PureXML.Schema.XSDParser { if isAll { allGroupNames.insert(name) } } guard !allGroupNames.isEmpty else { return [] } - var errors: [String] = [] + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] for group in descendants(schema, named: "group") where group.name?.namespaceURI == xsd { guard let ref = PureXML.Schema.XSDNode.attribute(group, "ref"), allGroupNames.contains(PureXML.Schema.XSDNode.stripPrefix(ref)) else { continue } let maxOccurs = PureXML.Schema.XSDNode.attribute(group, "maxOccurs")?.trimmingXMLWhitespace() if let maxOccurs, maxOccurs == "unbounded" || (isNonNegativeInteger(maxOccurs) && canonicalMagnitude(maxOccurs) != "1") { - errors.append("the maxOccurs of a reference to an all group must be 1") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "the maxOccurs of a reference to an all group must be 1", + node: group, + )) } let minOccurs = PureXML.Schema.XSDNode.attribute(group, "minOccurs")?.trimmingXMLWhitespace() if let minOccurs, isNonNegativeInteger(minOccurs), !["0", "1"].contains(canonicalMagnitude(minOccurs)) { - errors.append("the minOccurs of a reference to an all group must be 0 or 1") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "the minOccurs of a reference to an all group must be 0 or 1", + node: group, + )) } } - return errors + return findings } static func exceeds(_ lhs: String, _ rhs: String) -> Bool { @@ -315,14 +321,20 @@ extension PureXML.Schema.XSDParser { /// is not a legal namespace name. So `` and /// `` are invalid, while omitting the attribute (no target /// namespace, a no-namespace import) stays valid. - static func emptyNamespaceErrors(_ schema: XSDTree) -> [String] { - var errors: [String] = [] + static func emptyNamespaceFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] if PureXML.Schema.XSDNode.attribute(schema, "targetNamespace") == "" { - errors.append("the 'targetNamespace' attribute may not be the empty string") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "the 'targetNamespace' attribute may not be the empty string", + node: schema, + )) } for importNode in descendants(schema, named: "import") where PureXML.Schema.XSDNode.attribute(importNode, "namespace") == "" { - errors.append("an 'import' namespace may not be the empty string") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "an 'import' namespace may not be the empty string", + node: importNode, + )) } - return errors + return findings } } diff --git a/Tests/SchemaStructureLocatedDiagnosticsTests.swift b/Tests/SchemaStructureLocatedDiagnosticsTests.swift new file mode 100644 index 0000000..3fabb74 --- /dev/null +++ b/Tests/SchemaStructureLocatedDiagnosticsTests.swift @@ -0,0 +1,146 @@ +import Testing +@testable import PureXML + +/// Structure-pass schema compile findings (facet applicability, top-level and +/// nested declaration rules, empty namespaces, all-group references) carry +/// validation coding paths that resolve to source spans, the IDE underline flow +/// for #169. Split from `SchemaLocatedDiagnosticsTests` to keep each suite within +/// the type-body length budget. +@Suite("Schema structure-pass located compile diagnostics") +struct SchemaStructureLocatedDiagnosticsTests { + 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 facet that does not apply to a list variety is located on its declaring simpleType") + func test_varietyFacetLocated() throws { + let xsd = """ + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("'maxInclusive' does not apply to a list type") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:simpleType"]) + + let (tree, _) = PureXML.readTree(xsd) + let range = tree.sourceRange(at: located.codingPath) + #expect(range?.start.line == 3) + } + + @Test("A top-level declaration without a name is located on the declaration") + func test_topLevelMissingNameLocated() throws { + let xsd = """ + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("top-level 'element' definition must have a 'name'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:element"]) + + let (tree, _) = PureXML.readTree(xsd) + let range = tree.sourceRange(at: located.codingPath) + #expect(range?.start.line == 2) + #expect(range?.start.column == 3) + } + + @Test("A nested named definition is located on the offending nested component") + func test_nestedNamedDefinitionLocated() throws { + let xsd = """ + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("nested 'simpleType' definition may not have a 'name'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:element", "xs:simpleType"]) + + let (tree, _) = PureXML.readTree(xsd) + let range = tree.sourceRange(at: located.codingPath) + #expect(range?.start.line == 3) + } + + @Test("A restriction of xs:anySimpleType is located on the restriction") + func test_anySimpleTypeRestrictionLocated() throws { + let xsd = """ + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("restriction of 'xs:anySimpleType' is not allowed") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:simpleType", "xs:restriction"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 2) + } + + @Test("A facet on anySimpleType-based simpleContent is located on the restriction") + func test_anySimpleTypeFacetLocated() throws { + let xsd = """ + + + + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("constraining facet may not be applied to anySimpleType") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:simpleContent", "xs:restriction"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 7) + } + + @Test("An empty targetNamespace is located on the schema element") + func test_emptyTargetNamespaceLocated() throws { + let xsd = """ + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("'targetNamespace' attribute may not be the empty string") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 1) + } + + @Test("An all-group reference with an illegal maxOccurs is located on the reference") + func test_allGroupReferenceMaxOccursLocated() throws { + let xsd = """ + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("maxOccurs of a reference to an all group must be 1") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:group"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 3) + } +}