diff --git a/CHANGELOG.md b/CHANGELOG.md index b313309..a912bc5 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. +- 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`. - DTD parse-time validity findings are warnings until strict validation promotes them; `validateAgainstInternalDTD` returns full findings (errors and warnings). diff --git a/Sources/Schema/SchemaAttributeRestriction.swift b/Sources/Schema/SchemaAttributeRestriction.swift index daba66a..bc35c7b 100644 --- a/Sources/Schema/SchemaAttributeRestriction.swift +++ b/Sources/Schema/SchemaAttributeRestriction.swift @@ -19,19 +19,26 @@ extension PureXML.Schema.XSDParser { /// comparing the two fixed values in the attribute type's value space, not /// lexically (e.g. a list type's `"1 2 3"` and `"1 2 3"` are the same value), /// which a string comparison gets wrong; that clause is left for a later change. - static func attributeRestrictionErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] { + static func attributeRestrictionFindings( + _ 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 content in descendants(schema, named: "complexContent") { guard let restriction = AttrRestrictNode.firstChild(content, named: "restriction"), let base = AttrRestrictNode.attribute(restriction, "base"), AttrRestrictNode.referenceNamespace(base, bindings) == context.targetNamespace, case let .complex(complex)? = types[AttrRestrictNode.stripPrefix(base)] else { continue } + // An attribute use resolves to a model value with no source node, so a + // faithful-restriction violation locates on the restriction performing + // the illegal narrowing, the node a developer would correct. for derived in attributeUses(under: restriction, context) { if let message = attributeRestrictionViolation(derived, complex.attributes, complex.attributeWildcard, restriction, context) { - errors.append(message) + findings.append(PureXML.Schema.SchemaLocatedFinding(reason: message, node: restriction)) } } // cos-ct-restricts.4: a restriction's own attribute wildcard must be a @@ -41,14 +48,20 @@ extension PureXML.Schema.XSDParser { if let derivedWildcard = attributeWildcard(under: restriction, context) { if let baseWildcard = complex.attributeWildcard { if !PureXML.Schema.ParticleRestriction.narrows(derivedWildcard, baseWildcard) { - errors.append("the restriction's attribute wildcard is not a subset of the base type's attribute wildcard") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "the restriction's attribute wildcard is not a subset of the base type's attribute wildcard", + node: restriction, + )) } } else { - errors.append("a restriction may not introduce an attribute wildcard that the base type does not have") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "a restriction may not introduce an attribute wildcard that the base type does not have", + node: restriction, + )) } } } - return errors + return findings } /// The way `derived` (an attribute the restriction redeclares) illegally relaxes diff --git a/Sources/Schema/SchemaAttributeUniqueness.swift b/Sources/Schema/SchemaAttributeUniqueness.swift index 848ca71..7c3c308 100644 --- a/Sources/Schema/SchemaAttributeUniqueness.swift +++ b/Sources/Schema/SchemaAttributeUniqueness.swift @@ -59,20 +59,24 @@ extension PureXML.Schema.XSDParser { /// carry their resolved namespace, so a qualified and an unqualified attribute /// of the same local name do not collide. The own-declared set is checked, not /// the set inherited through extension (a disclosed under-rejection). - static func attributeUseErrors(_ containers: [XSDTree], _ context: PureXML.Schema.XSDContext) -> [String] { - var errors: [String] = [] + static func attributeUseFindings(_ containers: [XSDTree], _ context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] for container in containers { for group in descendants(container, named: "attributeGroup") { guard let name = AttrNode.attribute(group, "name") else { continue } - errors += attributeSetErrors(attributeDeclarations(under: group, context), "attribute group '\(name)'") + // A uniqueness/single-ID clash is a property of the whole declared + // attribute set, so it locates on the declaring group. + findings += attributeSetErrors(attributeDeclarations(under: group, context), "attribute group '\(name)'") + .map { PureXML.Schema.SchemaLocatedFinding(reason: $0, node: group) } } for type in descendants(container, named: "complexType") { let label = AttrNode.attribute(type, "name").map { "complex type '\($0)'" } ?? "an anonymous complex type" - errors += attributeSetErrors(attributeDeclarations(under: attributeContainer(of: type), context), label) + findings += attributeSetErrors(attributeDeclarations(under: attributeContainer(of: type), context), label) + .map { PureXML.Schema.SchemaLocatedFinding(reason: $0, node: type) } } - errors += attributeRefValueConstraintErrors(container, context) + findings += attributeRefValueConstraintFindings(container, context) } - return errors + return findings } /// XSD 1.0 `au-props-correct.2`: when an attribute use references a global @@ -88,8 +92,8 @@ extension PureXML.Schema.XSDParser { /// rejects a same-local-name collision across namespaces (an imported `imp:a` /// versus a local `a`), where the local-name pool could otherwise surface the /// wrong global and contradict a valid reference. - private static func attributeRefValueConstraintErrors(_ container: XSDTree, _ context: PureXML.Schema.XSDContext) -> [String] { - var errors: [String] = [] + private static func attributeRefValueConstraintFindings(_ container: XSDTree, _ context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] for use in descendants(container, named: "attribute") { guard let ref = AttrNode.attribute(use, "ref"), let declaration = context.globalAttributes[AttrNode.stripPrefix(ref)], @@ -100,13 +104,19 @@ extension PureXML.Schema.XSDParser { if let useFixed = AttrNode.attribute(use, "fixed") { let equal = resolved.type.valueMatches(useFixed, literal: globalFixed) || useFixed == globalFixed if !equal { - errors.append("attribute reference to '\(AttrNode.stripPrefix(ref))' must use its fixed value '\(globalFixed)', not '\(useFixed)'") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "attribute reference to '\(AttrNode.stripPrefix(ref))' must use its fixed value '\(globalFixed)', not '\(useFixed)'", + node: use, + )) } } else if AttrNode.attribute(use, "default") != nil { - errors.append("attribute reference to '\(AttrNode.stripPrefix(ref))' has a fixed value and may not specify a default") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "attribute reference to '\(AttrNode.stripPrefix(ref))' has a fixed value and may not specify a default", + node: use, + )) } } - return errors + return findings } /// The node whose direct children declare a complex type's attributes: the diff --git a/Sources/Schema/SchemaCompileValidation.swift b/Sources/Schema/SchemaCompileValidation.swift index 7263410..d9288cc 100644 --- a/Sources/Schema/SchemaCompileValidation.swift +++ b/Sources/Schema/SchemaCompileValidation.swift @@ -156,9 +156,7 @@ extension PureXML.Validation { static var attributeUsesValid: Validation { compileRule("Attribute uses are unique and declare at most one ID attribute") { document in - PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.attributeUseErrors(document.containers, document.context), - ) + PureXML.Schema.XSDParser.attributeUseFindings(document.containers, document.context) } } @@ -203,9 +201,7 @@ extension PureXML.Validation { static var attributeRestrictionsFaithful: Validation { compileRule("Attribute restrictions are faithful to their bases") { document in guard let namedTypes = document.namedTypes else { return [] } - return PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.attributeRestrictionErrors(document.schema, document.context, namedTypes), - ) + return PureXML.Schema.XSDParser.attributeRestrictionFindings(document.schema, document.context, namedTypes) } } @@ -332,12 +328,10 @@ extension PureXML.Validation { extension PureXML.Validation.SchemaCompile { /// xsi: Not Allowed (XSD 1.0 SCC ยง3.2.6): no attribute declaration lands in the - /// XSI namespace. See ``PureXML/Schema/XSDParser/xsiNamespaceAttributeErrors``. + /// XSI namespace. See ``PureXML/Schema/XSDParser/xsiNamespaceAttributeFindings``. static var attributeDeclarationsNotInXSI: PureXML.Validation.Validation { compileRule("Attribute declarations are not in the XSI namespace") { document in - PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.xsiNamespaceAttributeErrors(document.schema, document.context), - ) + PureXML.Schema.XSDParser.xsiNamespaceAttributeFindings(document.schema, document.context) } } diff --git a/Sources/Schema/SchemaXSIAttribute.swift b/Sources/Schema/SchemaXSIAttribute.swift index 76c082c..cdf6d5a 100644 --- a/Sources/Schema/SchemaXSIAttribute.swift +++ b/Sources/Schema/SchemaXSIAttribute.swift @@ -10,19 +10,19 @@ extension PureXML.Schema.XSDParser { /// XSI it is forbidden (corpus attKb018a: `attributeFormDefault="qualified"` puts /// its attribute-group attributes into XSI). The check is scoped to schemas whose /// target namespace is XSI, the only documents that can place an attribute there. - static func xsiNamespaceAttributeErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext) -> [String] { + static func xsiNamespaceAttributeFindings(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] { guard context.targetNamespace == xsiInstanceNamespace else { return [] } let attributeFormDefault = PureXML.Schema.XSDNode.attribute(schema, "attributeFormDefault") - var errors: [String] = [] - collectXSIAttributeErrors(schema, parentIsSchema: false, attributeFormDefault: attributeFormDefault, into: &errors) - return errors + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + collectXSIAttributeFindings(schema, parentIsSchema: false, attributeFormDefault: attributeFormDefault, into: &findings) + return findings } - private static func collectXSIAttributeErrors( + private static func collectXSIAttributeFindings( _ node: XSDTree, parentIsSchema: Bool, attributeFormDefault: String?, - into errors: inout [String], + into findings: inout [PureXML.Schema.SchemaLocatedFinding], ) { let local = PureXML.Schema.XSDNode.localName(node) if local == "appinfo" || local == "documentation" { return } @@ -32,11 +32,14 @@ extension PureXML.Schema.XSDParser { let form = PureXML.Schema.XSDNode.attribute(node, "form") let qualified = parentIsSchema || form == "qualified" || (form == nil && attributeFormDefault == "qualified") if qualified { - errors.append("attribute declaration '\(name)' is in the XSI namespace, which is not allowed (xsi: Not Allowed)") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "attribute declaration '\(name)' is in the XSI namespace, which is not allowed (xsi: Not Allowed)", + node: node, + )) } } for child in PureXML.Schema.XSDNode.elementChildren(node) { - collectXSIAttributeErrors(child, parentIsSchema: isSchema, attributeFormDefault: attributeFormDefault, into: &errors) + collectXSIAttributeFindings(child, parentIsSchema: isSchema, attributeFormDefault: attributeFormDefault, into: &findings) } } } diff --git a/Tests/SchemaAttributeLocatedDiagnosticsTests.swift b/Tests/SchemaAttributeLocatedDiagnosticsTests.swift new file mode 100644 index 0000000..b039090 --- /dev/null +++ b/Tests/SchemaAttributeLocatedDiagnosticsTests.swift @@ -0,0 +1,101 @@ +import Testing +@testable import PureXML + +/// Attribute-pass schema compile findings (an attribute declared in the XSI +/// namespace, a duplicate attribute use within a complex type, an attribute +/// reference contradicting a global `fixed` value, and a restriction that relaxes +/// a base attribute) 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 attribute-pass located compile diagnostics") +struct SchemaAttributeLocatedDiagnosticsTests { + 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 attribute declared in the XSI namespace is located on the attribute") + func test_xsiNamespaceAttributeLocated() throws { + let xsd = """ + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("is in the XSI namespace, which is not allowed") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:attribute"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 3) + } + + @Test("A duplicate attribute use is located on its declaring complex type") + func test_duplicateAttributeUseLocated() throws { + let xsd = """ + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("has more than one attribute named 'a'") }) + #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 attribute reference contradicting a fixed value is located on the reference") + func test_attributeRefFixedValueLocated() throws { + let xsd = """ + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("must use its fixed value 'x', not 'y'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:attribute"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 4) + } + + @Test("A restriction relaxing a required base attribute is located on the restriction") + func test_attributeRestrictionLocated() throws { + let xsd = """ + + + + + + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("is required in the base type and a restriction may not make it optional") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:complexContent", "xs:restriction"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 7) + } +}