From 356e87581c55e9bdfc3e86c37eeca310d2f766b9 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sun, 21 Jun 2026 15:33:35 +0200 Subject: [PATCH] feat(schema): locate simple-type-pass schema diagnostics Convert the four simple-type-pass compile checkers to located findings so each error attaches to its own declaring node instead of the schema root (#169): a list whose item type is itself a list or a union containing a list and a list declaring both or neither of itemType/inline simpleType (simpleTypeVarietyFindings), an empty union, a simpleType restriction whose base resolves to a complex type (simpleTypeBaseNotComplexFindings), an itemType/memberType referencing a type final for list/union derivation (simpleTypeFinalFindings), and an attribute typed by a complex type (attributeTypeMustBeSimpleFindings). Each predicate, traversal order, and reason string is unchanged; only the location each finding carries differs, from the schema root to the offending list, union, restriction, or attribute node. The four checkers had exactly one caller each (their compile-rule dispatch), so each is renamed in place and the .unlocated wrap dropped, leaving no dead wrapper. Adds a located-diagnostics test per checker in a new suite kept within the type-body budget. --- CHANGELOG.md | 2 +- .../Schema/SchemaAttributeUniqueness.swift | 20 +++- Sources/Schema/SchemaCompileValidation.swift | 22 ++--- Sources/Schema/SchemaReferences.swift | 33 ++++--- Sources/Schema/SchemaSimpleTypeFinal.swift | 38 +++++--- Sources/Schema/SchemaSimpleTypeValidity.swift | 21 +++-- ...emaSimpleTypeLocatedDiagnosticsTests.swift | 91 +++++++++++++++++++ 7 files changed, 177 insertions(+), 50 deletions(-) create mode 100644 Tests/SchemaSimpleTypeLocatedDiagnosticsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e59c5f..b313309 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. +- 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. - DTD parse-time validity findings are warnings until strict validation promotes them; `validateAgainstInternalDTD` returns full findings (errors and warnings). diff --git a/Sources/Schema/SchemaAttributeUniqueness.swift b/Sources/Schema/SchemaAttributeUniqueness.swift index d16c64d..848ca71 100644 --- a/Sources/Schema/SchemaAttributeUniqueness.swift +++ b/Sources/Schema/SchemaAttributeUniqueness.swift @@ -19,21 +19,31 @@ extension PureXML.Schema.XSDParser { /// `types` first. Self-contained schema; a foreign or unresolved type is left /// alone. Strictly zero false positive: an attribute can never carry a complex /// type, so no valid schema is affected. - static func attributeTypeMustBeSimpleErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] { + static func attributeTypeMustBeSimpleFindings( + _ schema: XSDTree, + _ context: PureXML.Schema.XSDContext, + _ types: [String: PureXML.Schema.ElementType], + ) -> [PureXML.Schema.SchemaLocatedFinding] { guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } let bindings = context.namespaceBindings - var errors: [String] = [] + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] for attribute in descendants(schema, named: "attribute") where attribute.name?.namespaceURI == xsdNamespace { guard let typeName = AttrNode.attribute(attribute, "type") else { continue } let namespace = AttrNode.referenceNamespace(typeName, bindings) let local = AttrNode.stripPrefix(typeName) if namespace == context.targetNamespace, case .complex? = types[local] { - errors.append("an attribute's type must be a simple type, not the complex type '\(local)'") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "an attribute's type must be a simple type, not the complex type '\(local)'", + node: attribute, + )) } else if namespace == xsdNamespace, types[local] == nil, local == "anyType" { - errors.append("an attribute's type must be a simple type, not 'anyType'") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "an attribute's type must be a simple type, not 'anyType'", + node: attribute, + )) } } - return errors + return findings } /// Schema-validity findings for attribute-use uniqueness (XSD 1.0 diff --git a/Sources/Schema/SchemaCompileValidation.swift b/Sources/Schema/SchemaCompileValidation.swift index 985d310..7263410 100644 --- a/Sources/Schema/SchemaCompileValidation.swift +++ b/Sources/Schema/SchemaCompileValidation.swift @@ -77,12 +77,10 @@ extension PureXML.Validation { static var simpleTypeFinalControlsValid: Validation { compileRule("Simple-type final controls are declared consistently") { document in - PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.simpleTypeFinalErrors( - document.schema, - compositionLoaded: document.context.compositionLoaded, - containers: document.containers, - ), + PureXML.Schema.XSDParser.simpleTypeFinalFindings( + document.schema, + compositionLoaded: document.context.compositionLoaded, + containers: document.containers, ) } } @@ -213,17 +211,13 @@ extension PureXML.Validation { static var simpleTypeBasesAreSimple: Validation { compileRule("Simple types do not derive from complex types") { document in - PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.simpleTypeBaseNotComplexErrors(document.schema, in: document.context), - ) + PureXML.Schema.XSDParser.simpleTypeBaseNotComplexFindings(document.schema, in: document.context) } } static var simpleTypeVarietiesValid: Validation { compileRule("Simple-type varieties are declared consistently") { document in - PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.simpleTypeVarietyErrors(document.schema, document.context), - ) + PureXML.Schema.XSDParser.simpleTypeVarietyFindings(document.schema, document.context) } } @@ -351,9 +345,7 @@ extension PureXML.Validation.SchemaCompile { static var attributeTypesSimple: PureXML.Validation.Validation { compileRule("An attribute's type is a simple type") { document in guard let namedTypes = document.namedTypes else { return [] } - return PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.attributeTypeMustBeSimpleErrors(document.schema, document.context, namedTypes), - ) + return PureXML.Schema.XSDParser.attributeTypeMustBeSimpleFindings(document.schema, document.context, namedTypes) } } diff --git a/Sources/Schema/SchemaReferences.swift b/Sources/Schema/SchemaReferences.swift index 168f765..9ef73c5 100644 --- a/Sources/Schema/SchemaReferences.swift +++ b/Sources/Schema/SchemaReferences.swift @@ -232,8 +232,8 @@ extension PureXML.Schema.XSDParser { return errors } - static func simpleTypeBaseNotComplexErrors(_ schema: XSDTree, in context: PureXML.Schema.XSDContext) -> [String] { - var errors: [String] = [] + static func simpleTypeBaseNotComplexFindings(_ schema: XSDTree, in context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema) let targetNamespace = context.targetNamespace @@ -262,11 +262,11 @@ extension PureXML.Schema.XSDParser { if node.name?.namespaceURI == xsdNamespace { switch local { case "restriction": - Self.checkRestrictionUnderSimpleType(node, isComplex: isComplex, errors: &errors) + Self.checkRestrictionUnderSimpleType(node, isComplex: isComplex, findings: &findings) case "list": - Self.checkList(node, isComplex: isComplex, errors: &errors) + Self.checkList(node, isComplex: isComplex, findings: &findings) case "union": - Self.checkUnion(node, isComplex: isComplex, errors: &errors) + Self.checkUnion(node, isComplex: isComplex, findings: &findings) default: break } @@ -277,26 +277,35 @@ extension PureXML.Schema.XSDParser { } } check(schema) - return errors + return findings } - private static func checkRestrictionUnderSimpleType(_ node: XSDTree, isComplex: (String) -> Bool, errors: inout [String]) { + private static func checkRestrictionUnderSimpleType(_ node: XSDTree, isComplex: (String) -> Bool, findings: inout [PureXML.Schema.SchemaLocatedFinding]) { guard let parent = node.parent, parent.name?.namespaceURI == xsdNamespace, PureXML.Schema.XSDNode.localName(parent) == "simpleType" else { return } if let base = PureXML.Schema.XSDNode.attribute(node, "base"), isComplex(base) { - errors.append("base type '\(base)' of simpleType restriction must be a simple type, not a complex type") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "base type '\(base)' of simpleType restriction must be a simple type, not a complex type", + node: node, + )) } } - private static func checkList(_ node: XSDTree, isComplex: (String) -> Bool, errors: inout [String]) { + private static func checkList(_ node: XSDTree, isComplex: (String) -> Bool, findings: inout [PureXML.Schema.SchemaLocatedFinding]) { if let itemType = PureXML.Schema.XSDNode.attribute(node, "itemType"), isComplex(itemType) { - errors.append("itemType '\(itemType)' of list must be a simple type, not a complex type") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "itemType '\(itemType)' of list must be a simple type, not a complex type", + node: node, + )) } } - private static func checkUnion(_ node: XSDTree, isComplex: (String) -> Bool, errors: inout [String]) { + private static func checkUnion(_ node: XSDTree, isComplex: (String) -> Bool, findings: inout [PureXML.Schema.SchemaLocatedFinding]) { if let members = PureXML.Schema.XSDNode.attribute(node, "memberTypes") { for token in members.split(whereSeparator: \.isWhitespace) where isComplex(String(token)) { - errors.append("memberType '\(token)' of union must be a simple type, not a complex type") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "memberType '\(token)' of union must be a simple type, not a complex type", + node: node, + )) } } } diff --git a/Sources/Schema/SchemaSimpleTypeFinal.swift b/Sources/Schema/SchemaSimpleTypeFinal.swift index f4ce552..9be6d8e 100644 --- a/Sources/Schema/SchemaSimpleTypeFinal.swift +++ b/Sources/Schema/SchemaSimpleTypeFinal.swift @@ -14,7 +14,7 @@ extension PureXML.Schema.XSDParser { /// where the single target namespace makes the names resolved by local name /// unambiguous; with external definitions the check stands down (a disclosed /// under-rejection). - static func simpleTypeFinalErrors(_ schema: XSDTree, compositionLoaded: Bool, containers: [XSDTree]) -> [String] { + static func simpleTypeFinalFindings(_ schema: XSDTree, compositionLoaded: Bool, containers: [XSDTree]) -> [PureXML.Schema.SchemaLocatedFinding] { guard !skipsCrossDocumentRules(schema, compositionLoaded: compositionLoaded) else { return [] } let finalOf = compositionLoaded ? mergedSimpleTypeFinalMap(containers) : simpleTypeFinalMap(schema) guard finalOf.values.contains(where: { !$0.isEmpty }) else { return [] } @@ -24,8 +24,8 @@ extension PureXML.Schema.XSDParser { // confused with a user type of the same local name. let bindings = FinalNode.namespaceBindings(of: schema) let target = FinalNode.attribute(schema, "targetNamespace") - return listItemFinalErrors(schema, finalOf, bindings, target) - + unionMemberFinalErrors(schema, finalOf, bindings, target) + return listItemFinalFindings(schema, finalOf, bindings, target) + + unionMemberFinalFindings(schema, finalOf, bindings, target) } private static func mergedSimpleTypeFinalMap(_ containers: [XSDTree]) -> [String: Set] { @@ -57,33 +57,49 @@ extension PureXML.Schema.XSDParser { return finalOf } - private static func listItemFinalErrors(_ schema: XSDTree, _ finalOf: [String: Set], _ bindings: [String: String], _ target: String?) -> [String] { - var errors: [String] = [] + private static func listItemFinalFindings( + _ schema: XSDTree, + _ finalOf: [String: Set], + _ bindings: [String: String], + _ target: String?, + ) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] for list in descendants(schema, named: "list") { guard let item = FinalNode.attribute(list, "itemType"), FinalNode.referenceNamespace(item, bindings) == target else { continue } let name = FinalNode.stripPrefix(item) if finalOf[name]?.contains("list") == true { - errors.append("simple type '\(name)' is final for 'list' and may not be a list item type") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "simple type '\(name)' is final for 'list' and may not be a list item type", + node: list, + )) } } - return errors + return findings } - private static func unionMemberFinalErrors(_ schema: XSDTree, _ finalOf: [String: Set], _ bindings: [String: String], _ target: String?) -> [String] { - var errors: [String] = [] + private static func unionMemberFinalFindings( + _ schema: XSDTree, + _ finalOf: [String: Set], + _ bindings: [String: String], + _ target: String?, + ) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] for union in descendants(schema, named: "union") { guard let members = FinalNode.attribute(union, "memberTypes") else { continue } for token in members.split(whereSeparator: \.isWhitespace) { guard FinalNode.referenceNamespace(String(token), bindings) == target else { continue } let name = FinalNode.stripPrefix(String(token)) if finalOf[name]?.contains("union") == true { - errors.append("simple type '\(name)' is final for 'union' and may not be a union member type") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "simple type '\(name)' is final for 'union' and may not be a union member type", + node: union, + )) } } } - return errors + return findings } /// The derivation tokens a `final` value forbids; `#all` forbids every simple-type diff --git a/Sources/Schema/SchemaSimpleTypeValidity.swift b/Sources/Schema/SchemaSimpleTypeValidity.swift index 68d92c4..fc0b2c1 100644 --- a/Sources/Schema/SchemaSimpleTypeValidity.swift +++ b/Sources/Schema/SchemaSimpleTypeValidity.swift @@ -3,9 +3,9 @@ extension PureXML.Schema.XSDParser { /// the itemType of a list must be atomic or union (where all member types are atomic). /// That is, a list's itemType cannot be a list or a union containing a list, and /// cannot resolve to the simple ur-type 'anySimpleType'. - static func simpleTypeVarietyErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext) -> [String] { + static func simpleTypeVarietyFindings(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] { let targetNamespace = context.targetNamespace - var errors: [String] = [] + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] func check(_ node: XSDTree) { let local = PureXML.Schema.XSDNode.localName(node) @@ -31,13 +31,19 @@ extension PureXML.Schema.XSDParser { } if !isForeign, isListOrUnionContainingList(item) { - errors.append("the item type of a list must be atomic or union of atomic, not a list or union containing list") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "the item type of a list must be atomic or union of atomic, not a list or union containing list", + node: node, + )) } // src-simple-type.2: a list has its item type from EITHER the // itemType attribute OR an inline simpleType child, never both and // never neither. if hasItemTypeAttribute(node) == hasSimpleTypeChild(node) { - errors.append("a list must have either an 'itemType' attribute or an inline simpleType child, but not both") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a list must have either an 'itemType' attribute or an inline simpleType child, but not both", + node: node, + )) } } @@ -45,7 +51,10 @@ extension PureXML.Schema.XSDParser { // attribute, inline simpleType children, or both, so it must declare at // least one of them (an empty union has no member type definitions). if isEmptyUnion(node, local) { - errors.append("a union must declare at least one member type, through 'memberTypes' or an inline simpleType child") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a union must declare at least one member type, through 'memberTypes' or an inline simpleType child", + node: node, + )) } for child in PureXML.Schema.XSDNode.elementChildren(node) { @@ -54,7 +63,7 @@ extension PureXML.Schema.XSDParser { } check(schema) - return errors + return findings } /// A `union` element in the schema namespace that declares no member types at diff --git a/Tests/SchemaSimpleTypeLocatedDiagnosticsTests.swift b/Tests/SchemaSimpleTypeLocatedDiagnosticsTests.swift new file mode 100644 index 0000000..29832c8 --- /dev/null +++ b/Tests/SchemaSimpleTypeLocatedDiagnosticsTests.swift @@ -0,0 +1,91 @@ +import Testing +@testable import PureXML + +/// Simple-type-pass schema compile findings (variety constraints, a complex base +/// under a simpleType restriction, `final` enforcement for list/union derivation, +/// and an attribute typed by a complex type) carry validation coding paths that +/// resolve to source spans, the IDE underline flow for #169. Split from the other +/// located-diagnostics suites to keep each suite within the type-body length budget. +@Suite("Schema simple-type-pass located compile diagnostics") +struct SchemaSimpleTypeLocatedDiagnosticsTests { + 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("An empty union is located on the union element") + func test_emptyUnionLocated() throws { + let xsd = """ + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("a union must declare at least one member type") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:simpleType", "xs:union"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 2) + } + + @Test("A simpleType restriction of a complex base is located on the restriction") + func test_simpleTypeBaseComplexLocated() throws { + let xsd = """ + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("of simpleType restriction must be a simple type") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:simpleType", "xs:restriction"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 3) + } + + @Test("A list item type that is final for list is located on the list element") + func test_listItemFinalLocated() throws { + let xsd = """ + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("is final for 'list' and may not be a list item type") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:simpleType", "xs:list"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 3) + } + + @Test("An attribute typed by a complex type is located on the attribute") + func test_attributeTypeComplexLocated() throws { + let xsd = """ + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("an attribute's type must be a simple type, not the complex type") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:attribute"]) + + let (tree, _) = PureXML.readTree(xsd) + let range = tree.sourceRange(at: located.codingPath) + #expect(range?.start.line == 4) + #expect(range?.start.column == 5) + } +}