diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d9f74..7b63e4a 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`. 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. The content-derivation-pass checkers (the `cos-ct-extends`/`src-ct` base-kind family) follow: a complexContent extension adding element content over a simpleContent base, a simpleContent restriction whose base is a built-in or user simple type, an unfaithful inline type in a simpleContent restriction, a complexContent base that is a simple type, a simpleContent extension whose base is `xs:anyType`, and a mismatched `mixed` setting on a complexContent extension each underline their `extension`/`restriction` derivation node, and an element carrying a `default`/`fixed` value over element-only or empty content underlines the `element`. The reference-resolution rule is now genuinely located: `referenceFindings` previously wrapped its whole aggregate in `.unlocated`, so every undeclared-reference error collapsed onto the schema root despite the rule being dispatched as located; each of its six sub-collectors (the core type/base/itemType/memberTypes/ref/substitutionGroup walk, the `xs:`-namespace builtin check, the simpleContent base-kind check, the un-imported-namespace check, the redefine-component-existence check, and the import-namespace-agreement check) now attaches to its referencing node, `import`, or `redefine` component. +- 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. The content-derivation-pass checkers (the `cos-ct-extends`/`src-ct` base-kind family) follow: a complexContent extension adding element content over a simpleContent base, a simpleContent restriction whose base is a built-in or user simple type, an unfaithful inline type in a simpleContent restriction, a complexContent base that is a simple type, a simpleContent extension whose base is `xs:anyType`, and a mismatched `mixed` setting on a complexContent extension each underline their `extension`/`restriction` derivation node, and an element carrying a `default`/`fixed` value over element-only or empty content underlines the `element`. The reference-resolution rule is now genuinely located: `referenceFindings` previously wrapped its whole aggregate in `.unlocated`, so every undeclared-reference error collapsed onto the schema root despite the rule being dispatched as located; each of its six sub-collectors (the core type/base/itemType/memberTypes/ref/substitutionGroup walk, the `xs:`-namespace builtin check, the simpleContent base-kind check, the un-imported-namespace check, the redefine-component-existence check, and the import-namespace-agreement check) now attaches to its referencing node, `import`, or `redefine` component. The extension/substitution-pass checkers follow: an `all` group reached through an extension over a base with content underlines the `extension`, an anonymous complex-type restriction that is not a subset of its base underlines the `restriction`, and a substitution-group member whose type is not validly derived from its head underlines the member `element`. - DTD parse-time validity findings are warnings until strict validation promotes them; `validateAgainstInternalDTD` returns full findings (errors and warnings). diff --git a/Sources/Schema/SchemaCompileValidation.swift b/Sources/Schema/SchemaCompileValidation.swift index 64eece7..f58be9b 100644 --- a/Sources/Schema/SchemaCompileValidation.swift +++ b/Sources/Schema/SchemaCompileValidation.swift @@ -163,14 +163,12 @@ extension PureXML.Validation { 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.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.substitutionTypeErrors( - document.schema, - document.containers, - derivation, - namedTypes, - document.context, - ), + return PureXML.Schema.XSDParser.substitutionTypeFindings( + document.schema, + document.containers, + derivation, + namedTypes, + document.context, ) } } @@ -186,9 +184,7 @@ extension PureXML.Validation { 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.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.extensionAllGroupErrors(document.schema, document.context, namedTypes), - ) + return PureXML.Schema.XSDParser.extensionAllGroupFindings(document.schema, document.context, namedTypes) } } @@ -338,13 +334,11 @@ extension PureXML.Validation.SchemaCompile { static var anonymousRestrictionsValid: PureXML.Validation.Validation { compileRule("Anonymous complex-type restrictions accept a subset of their base's") { document in guard let namedTypes = document.namedTypes else { return [] } - return PureXML.Schema.SchemaLocatedFinding.unlocated( - PureXML.Schema.XSDParser.anonymousRestrictionErrors( - document.schema, - document.context, - namedTypes, - document.derivation?.typeDerivation ?? [:], - ), + return PureXML.Schema.XSDParser.anonymousRestrictionFindings( + document.schema, + document.context, + namedTypes, + document.derivation?.typeDerivation ?? [:], ) } } diff --git a/Sources/Schema/SchemaExtensionAllGroup.swift b/Sources/Schema/SchemaExtensionAllGroup.swift index 1a5fd28..90a3962 100644 --- a/Sources/Schema/SchemaExtensionAllGroup.swift +++ b/Sources/Schema/SchemaExtensionAllGroup.swift @@ -12,10 +12,14 @@ extension PureXML.Schema.XSDParser { /// content is element-only or mixed. An empty base (extending it with an `all` /// makes the `all` the whole content, which is valid), a simple-content base, or /// an unresolved/foreign base is left alone. Checked for a self-contained schema. - static func extensionAllGroupErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] { + static func extensionAllGroupFindings( + _ 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 ext = ExtAllNode.firstChild(content, named: "extension"), let base = ExtAllNode.attribute(ext, "base"), @@ -26,17 +30,23 @@ extension PureXML.Schema.XSDParser { // The extension itself adds an `all` group over a base that already has // element content: the all would be nested in the extension sequence. if extensionHasAllGroup(ext), baseHasElementContent(complex.content) { - errors.append("an 'all' group may not extend the type '\(baseName)', which has its own content; an all group must be the whole content model") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "an 'all' group may not extend the type '\(baseName)', which has its own content; an all group must be the whole content model", + node: ext, + )) continue } // The base's own content IS an `all` group and the extension adds // element content: the base's all is then nested in the sequence that // joins base and extension content, which is forbidden (cos-all-limited). if contentIsAllGroup(complex.content), extensionAddsElementContent(ext) { - errors.append("the type '\(baseName)' has an 'all' group as its whole content and may not be extended with element content") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "the type '\(baseName)' has an 'all' group as its whole content and may not be extended with element content", + node: ext, + )) } } - return errors + return findings } /// Particle Valid (Restriction) for ANONYMOUS complex types: the named-type @@ -47,15 +57,15 @@ extension PureXML.Schema.XSDParser { /// in this schema's own target namespace, and runs the same subset check. Only a /// self-contained schema is examined; the same `ParticleRestriction.violation` /// used for named types keeps the false-positive profile identical. - static func anonymousRestrictionErrors( + static func anonymousRestrictionFindings( _ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType], _ derivation: [String: PureXML.Schema.TypeDerivation], - ) -> [String] { + ) -> [PureXML.Schema.SchemaLocatedFinding] { guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } let bindings = context.namespaceBindings - var errors: [String] = [] + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] for complexType in descendants(schema, named: "complexType") where ExtAllNode.attribute(complexType, "name") == nil { guard let content = ExtAllNode.firstChild(complexType, named: "complexContent"), let restriction = ExtAllNode.firstChild(content, named: "restriction"), @@ -70,10 +80,13 @@ extension PureXML.Schema.XSDParser { types: types, derivation: derivation, ) { - errors.append("an anonymous complex type is not a valid restriction of '\(ExtAllNode.stripPrefix(baseRef))': \(reason)") + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "an anonymous complex type is not a valid restriction of '\(ExtAllNode.stripPrefix(baseRef))': \(reason)", + node: restriction, + )) } } - return errors + return findings } /// Whether a content type's whole content model is a NON-EMPTY `all` group. An diff --git a/Sources/Schema/SchemaSubstitutionType.swift b/Sources/Schema/SchemaSubstitutionType.swift index 750661c..4f09cd3 100644 --- a/Sources/Schema/SchemaSubstitutionType.swift +++ b/Sources/Schema/SchemaSubstitutionType.swift @@ -21,13 +21,13 @@ extension PureXML.Schema.XSDParser { /// 2.3 and 2.4: a type derives from a union if it derives from one of the /// union's member types). When either type is a list or union the check stands /// down rather than reject a valid member, a disclosed under-rejection. - static func substitutionTypeErrors( + static func substitutionTypeFindings( _ schema: XSDTree, _ containers: [XSDTree], _ tables: DerivationTables, _ types: [String: PureXML.Schema.ElementType], _ context: PureXML.Schema.XSDContext, - ) -> [String] { + ) -> [PureXML.Schema.SchemaLocatedFinding] { guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] } let sources = context.compositionLoaded ? containers : [schema] let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: context.targetNamespace) @@ -46,7 +46,7 @@ extension PureXML.Schema.XSDParser { } } } - return sources.indices.flatMap { index -> [String] in + return sources.indices.flatMap { index -> [PureXML.Schema.SchemaLocatedFinding] in let source = sources[index] guard SubTypeNode.localName(source) != "redefine" else { return [] } let bindings = SubTypeNode.namespaceBindings(of: source) @@ -69,7 +69,7 @@ extension PureXML.Schema.XSDParser { _ globalElementType: [GlobalElementKey: String], _ tables: DerivationTables, _ types: [String: PureXML.Schema.ElementType], - ) -> [String] { + ) -> [PureXML.Schema.SchemaLocatedFinding] { guard let member = SubTypeNode.attribute(element, "name"), let headReference = SubTypeNode.attribute(element, "substitutionGroup") else { return [] } @@ -100,11 +100,21 @@ extension PureXML.Schema.XSDParser { // as a recorded restriction of the head's type, both of which `isDerived` // covers). Affiliating it to any other head type is not a valid derivation. if isListOrUnion(memberType, types), !isDerived, headType != "anySimpleType", headType != "anyType" { - return ["element '\(member)' may not be in the substitution group of '\(head)': its list or union type is not derived from '\(headType)'"] + return [ + PureXML.Schema.SchemaLocatedFinding( + reason: "element '\(member)' may not be in the substitution group of '\(head)': its list or union type is not derived from '\(headType)'", + node: element, + ), + ] } guard !isListOrUnion(memberType, types), !isListOrUnion(headType, types) else { return [] } if !isDerived { - return ["element '\(member)' may not be in the substitution group of '\(head)': its type is not derived from '\(headType)'"] + return [ + PureXML.Schema.SchemaLocatedFinding( + reason: "element '\(member)' may not be in the substitution group of '\(head)': its type is not derived from '\(headType)'", + node: element, + ), + ] } return checkExclusions( member: member, @@ -112,7 +122,7 @@ extension PureXML.Schema.XSDParser { inlineDeriv: inlineDeriv, headInfo: (head, headType), tables: tables, - ) + ).map { PureXML.Schema.SchemaLocatedFinding(reason: $0, node: element) } } private static func checkExclusions( diff --git a/Sources/Schema/XSDDerivation.swift b/Sources/Schema/XSDDerivation.swift index 31b6d71..723e944 100644 --- a/Sources/Schema/XSDDerivation.swift +++ b/Sources/Schema/XSDDerivation.swift @@ -16,7 +16,7 @@ extension PureXML.Schema.XSDParser { /// /// The original fields (`typeDerivation`, `typeBlock`, etc.) are keyed by BARE /// local name and feed the schema-validity machinery (``ParticleRestriction``, - /// ``CompiledSchemaFacts``, ``substitutionTypeErrors(_:_:_:_:_:)``), which + /// ``CompiledSchemaFacts``, ``substitutionTypeFindings(_:_:_:_:_:)``), which /// resolves component references to local names and is the single-namespace /// consistent identity those rules compare by. /// diff --git a/Tests/SchemaExtensionSubstitutionLocatedTests.swift b/Tests/SchemaExtensionSubstitutionLocatedTests.swift new file mode 100644 index 0000000..2afbd2b --- /dev/null +++ b/Tests/SchemaExtensionSubstitutionLocatedTests.swift @@ -0,0 +1,91 @@ +import Testing +@testable import PureXML + +/// Extension/substitution-pass schema compile findings (an `all` group reached +/// through extension, an anonymous complex-type restriction that is not a subset of +/// its base, and a substitution-group member whose type is not derived from its +/// head) 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 extension/substitution-pass located compile diagnostics") +struct SchemaExtensionSubstitutionLocatedTests { + 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 all-group extending a base with content is located on the extension") + func test_extensionAllGroupLocated() throws { + let xsd = """ + + + + + + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("an 'all' group may not extend the type 'base'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:complexContent", "xs:extension"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 7) + } + + @Test("An invalid anonymous restriction is located on the restriction") + func test_anonymousRestrictionLocated() throws { + let xsd = """ + + + + + + + + + + + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("an anonymous complex type is not a valid restriction of 'base'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:element", "xs:complexType", "xs:complexContent", "xs:restriction"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 8) + } + + @Test("A substitution member with an underived type is located on the member element") + func test_substitutionTypeLocated() throws { + let xsd = """ + + + + + """ + let findings = try inconsistentFindings(in: xsd) + let located = try #require(findings.first { $0.reason.contains("element 'member' may not be in the substitution group of 'head'") }) + #expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:element"]) + + let (tree, _) = PureXML.readTree(xsd) + #expect(tree.sourceRange(at: located.codingPath)?.start.line == 3) + } +}