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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-21 14:55:21 +02:00
parent d53cdaad1c
commit 3a56d9f400
7 changed files with 219 additions and 43 deletions

View File

@ -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).

View File

@ -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,
))
}
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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 `<all>` element;
/// this covers a `<group ref>` 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<String> = []
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 `<schema targetNamespace="">` and
/// `<import namespace="">` 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
}
}

View File

@ -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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:simpleType name="ints"><xs:list itemType="xs:integer"/></xs:simpleType>
<xs:simpleType name="t">
<xs:restriction base="ints"><xs:maxInclusive value="5"/></xs:restriction>
</xs:simpleType>
</xs:schema>
"""
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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element type="xs:string"/>
</xs:schema>
"""
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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="a">
<xs:simpleType name="bad"><xs:restriction base="xs:string"/></xs:simpleType>
</xs:element>
</xs:schema>
"""
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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t">
<xs:simpleType name="t"><xs:restriction base="xs:anySimpleType"/></xs:simpleType>
</xs:schema>
"""
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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:complexType name="base">
<xs:simpleContent><xs:extension base="xs:anySimpleType"/></xs:simpleContent>
</xs:complexType>
<xs:complexType name="t">
<xs:simpleContent>
<xs:restriction base="base"><xs:minLength value="1"/></xs:restriction>
</xs:simpleContent>
</xs:complexType>
</xs:schema>
"""
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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="">
<xs:element name="a" type="xs:string"/>
</xs:schema>
"""
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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:group name="g"><xs:all><xs:element name="a" type="xs:string"/></xs:all></xs:group>
<xs:complexType name="t"><xs:group ref="g" maxOccurs="2"/></xs:complexType>
</xs:schema>
"""
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)
}
}