feat(schema): locate compile diagnostics with validation coding paths (#169)

Schema compile findings now carry ValidationError coding paths that resolve to source spans on a ranged tree, enabling IDE underlines for id, structure, include, and facet violations.
This commit is contained in:
Mihaela Mihaljevic 2026-06-16 10:26:11 +02:00
parent 229d7df3d0
commit 489275c6b8
14 changed files with 343 additions and 105 deletions

View File

@ -13,6 +13,8 @@ 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.
- DTD parse-time validity findings are warnings until strict validation promotes them; `validateAgainstInternalDTD` returns full findings (errors and warnings).
- 2026-06-16

View File

@ -21,4 +21,25 @@ public extension PureXML.Model.TreeNode {
func sourceRange(at path: [PureXML.Validation.PathKey]) -> PureXML.Parsing.SourceRange? {
node(at: path)?.sourceRange
}
/// The validation coding path from the document root to this node (a sibling
/// index only when a name repeats), for locating schema compile findings.
func validationCodingPath() -> [PureXML.Validation.PathKey] {
var steps: [PureXML.Validation.PathKey] = []
var current: PureXML.Model.TreeNode? = self
while let element = current, element.kind == .element, let name = element.name?.description {
steps.append(validationPathStep(for: element, named: name))
current = element.parent
}
return steps.reversed()
}
private func validationPathStep(for element: PureXML.Model.TreeNode, named name: String) -> PureXML.Validation.PathKey {
guard let parent = element.parent else { return .element(name) }
let siblings = parent.children.filter { $0.kind == .element && $0.name?.description == name }
guard siblings.count > 1, let index = siblings.firstIndex(where: { $0 === element }) else {
return .element(name)
}
return .element(name, index: index + 1)
}
}

View File

@ -35,6 +35,16 @@ extension PureXML.Schema {
/// A marker subject for compile-time schema checks that run once over the
/// whole schema document rather than per node in an instance tree.
struct SchemaCompileRoot: PureXML.Validation.Validatable {}
/// One schema compile finding with an optional source node for location.
struct SchemaLocatedFinding {
let reason: String
let node: PureXML.Model.TreeNode?
static func unlocated(_ reasons: [String]) -> [SchemaLocatedFinding] {
reasons.map { SchemaLocatedFinding(reason: $0, node: nil) }
}
}
}
extension PureXML.Validation {
@ -47,71 +57,83 @@ extension PureXML.Validation {
static var idAttributesValid: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Schema component id attributes are valid NCNames and unique") { document in
PureXML.Schema.XSDParser.idAttributeErrors(document.schema)
PureXML.Schema.XSDParser.idAttributeFindings(document.schema)
}
}
static var schemaStructureValid: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Schema vocabulary elements follow the schema-for-schemas structure") { document in
PureXML.Schema.XSDParser.structureErrors(document.schema)
PureXML.Schema.XSDParser.structureFindings(document.schema)
}
}
static var componentNamesUnique: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Global schema component names are unique within their symbol spaces") { document in
PureXML.Schema.XSDParser.componentNameErrors(document.schema, document.containers, document.context)
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.componentNameErrors(document.schema, document.containers, document.context),
)
}
}
static var simpleTypeFinalControlsValid: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Simple-type final controls are declared consistently") { document in
PureXML.Schema.XSDParser.simpleTypeFinalErrors(
document.schema,
compositionLoaded: document.context.compositionLoaded,
containers: document.containers,
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.simpleTypeFinalErrors(
document.schema,
compositionLoaded: document.context.compositionLoaded,
containers: document.containers,
),
)
}
}
static var contentModelsDeterministic: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Schema content models are deterministic (UPA)") { document in
PureXML.Schema.ContentModelDeterminism.violations(in: document.schema, context: document.context)
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.ContentModelDeterminism.violations(in: document.schema, context: document.context),
)
}
}
static var typeDerivationAcyclic: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Type derivation chains contain no cycles") { document in
PureXML.Schema.XSDParser.derivationCycleErrors(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.derivationCycleErrors(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
),
)
}
}
static var typeReferencesAcyclic: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Schema type references contain no cycles") { document in
PureXML.Schema.XSDParser.circularReferenceErrors(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.circularReferenceErrors(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
),
)
}
}
static var allGroupReferencesPlaced: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("xs:all group references appear only where permitted") { document in
PureXML.Schema.XSDParser.allGroupReferencePlacementErrors(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.allGroupReferencePlacementErrors(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
),
)
}
}
static var includesCompositionValid: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Included schemas are chameleon or match the includer targetNamespace") { document in
PureXML.Schema.XSDParser.includeCompositionErrors(
PureXML.Schema.XSDParser.includeCompositionFindings(
document.containers,
mainTargetNamespace: document.context.targetNamespace,
compositionLoaded: document.context.compositionLoaded,
@ -125,7 +147,7 @@ extension PureXML.Validation {
static var schemaReferencesResolve: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Every schema reference resolves to a declared component") { document in
guard let globalElements = document.globalElements else { return [] }
return PureXML.Schema.XSDParser.referenceErrors(
return PureXML.Schema.XSDParser.referenceFindings(
document.schema,
in: document.context,
elements: globalElements,
@ -136,25 +158,31 @@ extension PureXML.Validation {
static var attributeUsesValid: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Attribute uses are unique and declare at most one ID attribute") { document in
PureXML.Schema.XSDParser.attributeUseErrors(document.containers, document.context)
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.attributeUseErrors(document.containers, document.context),
)
}
}
static var idValueConstraintsValid: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("ID-typed value constraints are valid") { document in
PureXML.Schema.XSDParser.idValueConstraintErrors(document.schema, document.context)
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.idValueConstraintErrors(document.schema, document.context),
)
}
}
static var substitutionMembersDeriveCorrectly: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
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.XSDParser.substitutionTypeErrors(
document.schema,
document.containers,
derivation,
namedTypes,
document.context,
return PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.substitutionTypeErrors(
document.schema,
document.containers,
derivation,
namedTypes,
document.context,
),
)
}
}
@ -162,39 +190,51 @@ extension PureXML.Validation {
static var userTypeValueConstraintsValid: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Element value constraints are valid against their declared types") { document in
guard let namedTypes = document.namedTypes else { return [] }
return PureXML.Schema.XSDParser.userTypeValueConstraintErrors(document.schema, document.context, namedTypes)
return PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.userTypeValueConstraintErrors(document.schema, document.context, namedTypes),
)
}
}
static var extensionAllGroupsValid: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Complex types extending xs:all groups satisfy XSD placement rules") { document in
guard let namedTypes = document.namedTypes else { return [] }
return PureXML.Schema.XSDParser.extensionAllGroupErrors(document.schema, document.context, namedTypes)
return PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.extensionAllGroupErrors(document.schema, document.context, namedTypes),
)
}
}
static var attributeRestrictionsFaithful: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Attribute restrictions are faithful to their bases") { document in
guard let namedTypes = document.namedTypes else { return [] }
return PureXML.Schema.XSDParser.attributeRestrictionErrors(document.schema, document.context, namedTypes)
return PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.attributeRestrictionErrors(document.schema, document.context, namedTypes),
)
}
}
static var simpleTypeBasesAreSimple: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Simple types do not derive from complex types") { document in
PureXML.Schema.XSDParser.simpleTypeBaseNotComplexErrors(document.schema, in: document.context)
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.simpleTypeBaseNotComplexErrors(document.schema, in: document.context),
)
}
}
static var simpleTypeVarietiesValid: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Simple-type varieties are declared consistently") { document in
PureXML.Schema.XSDParser.simpleTypeVarietyErrors(document.schema, document.context)
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.simpleTypeVarietyErrors(document.schema, document.context),
)
}
}
static var notationsValid: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Notation declarations are valid") { document in
PureXML.Schema.XSDParser.notationValidityErrors(document.containers, document.context)
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.notationValidityErrors(document.containers, document.context),
)
}
}
@ -279,13 +319,20 @@ extension PureXML.Validation {
private static func compileRule(
_ description: String,
_ messages: @escaping (PureXML.Schema.SchemaCompileContext) -> [String],
_ findings: @escaping (PureXML.Schema.SchemaCompileContext) -> [PureXML.Schema.SchemaLocatedFinding],
) -> Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
.init(description: description) { context in
messages(context.document).map {
ValidationError(reason: $0, at: context.codingPath)
findings(context.document).map { finding in
ValidationError(
reason: finding.reason,
at: finding.node?.validationCodingPath() ?? context.codingPath,
)
}
}
}
static func locatedFindings(_ reasons: [String], at node: PureXML.Model.TreeNode?) -> [PureXML.Schema.SchemaLocatedFinding] {
reasons.map { PureXML.Schema.SchemaLocatedFinding(reason: $0, node: node) }
}
}
}

View File

@ -15,7 +15,7 @@ public extension PureXML.Schema {
/// The compiled schema fails one or more consistency rules (a `final`
/// violation, an unfaithful restriction); every finding is carried, so a
/// schema with several problems reports them all at once.
case inconsistent([String])
case inconsistent([PureXML.Validation.ValidationError])
public var description: String {
switch self {
@ -28,9 +28,15 @@ public extension PureXML.Schema {
case let .invalidRelaxNG(reason):
"invalid RELAX NG schema: \(reason)"
case let .inconsistent(findings):
findings.joined(separator: "; ")
findings.map(\.description).joined(separator: "; ")
}
}
/// Every compile-time finding when the schema is inconsistent.
public var inconsistentFindings: [PureXML.Validation.ValidationError]? {
if case let .inconsistent(findings) = self { return findings }
return nil
}
}
/// A compiled XSD schema: its global element declarations and named-type
@ -123,12 +129,12 @@ public extension PureXML.Schema {
// Valid (Restriction)), and ALL findings are reported together, joined
// with the schema-validity findings gathered while parsing (malformed
// facet definitions and the like).
let findings = PureXML.Validation.XSDSchema.consistencyErrors(
let consistencyFindings = PureXML.Validation.XSDSchema.consistencyErrors(
types: compiled.types,
typeDerivation: compiled.typeDerivation,
typeFinal: compiled.typeFinal,
)
let allFindings = compiled.schemaErrors + findings.map { String(describing: $0) }
let allFindings = compiled.schemaErrors + consistencyFindings
if !allFindings.isEmpty {
throw SchemaError.inconsistent(allFindings)
}

View File

@ -9,26 +9,40 @@ extension PureXML.Schema.XSDParser {
/// Walks the one document rooted at `schema`; an included or imported document
/// keeps its own `id` scope, so cross-document collisions are not flagged here.
static func idAttributeErrors(_ schema: XSDTree) -> [String] {
var errors: [String] = []
var seen: Set<String> = []
collectIDErrors(schema, into: &errors, seen: &seen)
return errors
idAttributeFindings(schema).map(\.reason)
}
private static func collectIDErrors(_ node: XSDTree, into errors: inout [String], seen: inout Set<String>) {
static func idAttributeFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] {
var findings: [PureXML.Schema.SchemaLocatedFinding] = []
var seen: Set<String> = []
collectIDFindings(schema, into: &findings, seen: &seen)
return findings
}
private static func collectIDFindings(
_ node: XSDTree,
into findings: inout [PureXML.Schema.SchemaLocatedFinding],
seen: inout Set<String>,
) {
// `appinfo` and `documentation` hold arbitrary foreign content whose own
// `id` attributes are not xs:ID; do not descend into them.
let local = PureXML.Schema.XSDNode.localName(node)
if local == "appinfo" || local == "documentation" { return }
if let id = unprefixedID(node) {
if !PureXML.Schema.Lexical.isNCName(id) {
errors.append("id attribute value '\(id)' is not a valid NCName")
findings.append(PureXML.Schema.SchemaLocatedFinding(
reason: "id attribute value '\(id)' is not a valid NCName",
node: node,
))
} else if !seen.insert(id).inserted {
errors.append("duplicate id attribute value '\(id)' in the schema document")
findings.append(PureXML.Schema.SchemaLocatedFinding(
reason: "duplicate id attribute value '\(id)' in the schema document",
node: node,
))
}
}
for child in PureXML.Schema.XSDNode.elementChildren(node) {
collectIDErrors(child, into: &errors, seen: &seen)
collectIDFindings(child, into: &findings, seen: &seen)
}
}

View File

@ -295,9 +295,23 @@ extension PureXML.Schema.XSDParser {
compositionLoaded: Bool,
containerLocations: [ObjectIdentifier: String?],
) -> [String] {
includeCompositionFindings(
containers,
mainTargetNamespace: mainTargetNamespace,
compositionLoaded: compositionLoaded,
containerLocations: containerLocations,
).map(\.reason)
}
static func includeCompositionFindings(
_ containers: [XSDTree],
mainTargetNamespace: String?,
compositionLoaded: Bool,
containerLocations: [ObjectIdentifier: String?],
) -> [PureXML.Schema.SchemaLocatedFinding] {
guard compositionLoaded else { return [] }
let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: mainTargetNamespace)
var errors: [String] = []
var findings: [PureXML.Schema.SchemaLocatedFinding] = []
for (parentIndex, parent) in containers.enumerated() {
guard PureXML.Schema.XSDNode.localName(parent) == "schema" else { continue }
let parentNamespace = resolvedNamespace(at: parentIndex, in: namespaceMap, fallback: mainTargetNamespace)
@ -311,13 +325,14 @@ extension PureXML.Schema.XSDParser {
let includedNamespace = PureXML.Schema.XSDNode.attribute(containers[includedIndex], "targetNamespace")
if let includedNamespace, includedNamespace != parentNamespace {
let parentLabel = parentNamespace ?? "no namespace"
errors.append(
"included schema targetNamespace '\(includedNamespace)' must match includer targetNamespace '\(parentLabel)' or be chameleon (no targetNamespace)",
)
findings.append(PureXML.Schema.SchemaLocatedFinding(
reason: "included schema targetNamespace '\(includedNamespace)' must match includer targetNamespace '\(parentLabel)' or be chameleon (no targetNamespace)",
node: child,
))
}
}
}
return errors
return findings
}
private static func resolvedNamespace(at index: Int, in map: [Int: String?], fallback: String?) -> String? {

View File

@ -35,6 +35,26 @@ extension PureXML.Schema.XSDParser {
in context: PureXML.Schema.XSDContext,
elements: [String: PureXML.Schema.ElementType],
containers: [XSDTree],
) -> [String] {
collectReferenceErrors(schema, in: context, elements: elements, containers: containers)
}
static func referenceFindings(
_ schema: XSDTree,
in context: PureXML.Schema.XSDContext,
elements: [String: PureXML.Schema.ElementType],
containers: [XSDTree],
) -> [PureXML.Schema.SchemaLocatedFinding] {
PureXML.Schema.SchemaLocatedFinding.unlocated(
collectReferenceErrors(schema, in: context, elements: elements, containers: containers),
)
}
private static func collectReferenceErrors(
_ schema: XSDTree,
in context: PureXML.Schema.XSDContext,
elements: [String: PureXML.Schema.ElementType],
containers: [XSDTree],
) -> [String] {
let xsdErrors = xsdNamespaceReferenceErrors(schema)
let simpleContentErrors = simpleContentBaseErrors(schema, in: context)

View File

@ -54,18 +54,36 @@ extension PureXML.Schema.XSDParser {
/// once) must be first, and an identity constraint needs a selector and field.
/// Walks the one document rooted at `schema`, skipping foreign content.
static func structureErrors(_ schema: XSDTree) -> [String] {
var errors: [String] = []
let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema)
collectStructure(schema, bindings: bindings, into: &errors)
errors += simpleTypeVarietyFacetErrors(schema)
errors += valueConstraintErrors(schema)
errors += topLevelDeclarationErrors(schema)
errors += nestedNamedDefinitionErrors(schema)
errors += anySimpleTypeRestrictionErrors(schema)
return errors
structureFindings(schema).map(\.reason)
}
private static func collectStructure(_ node: XSDTree, bindings: [String: String], into errors: inout [String]) {
static func structureFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] {
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)
append(valueConstraintErrors(schema), at: schema, into: &findings)
append(topLevelDeclarationErrors(schema), at: schema, into: &findings)
append(nestedNamedDefinitionErrors(schema), at: schema, into: &findings)
append(anySimpleTypeRestrictionErrors(schema), at: schema, into: &findings)
return findings
}
private static func append(
_ messages: [String],
at node: XSDTree,
into findings: inout [PureXML.Schema.SchemaLocatedFinding],
) {
for message in messages {
findings.append(PureXML.Schema.SchemaLocatedFinding(reason: message, node: node))
}
}
private static func collectStructure(
_ node: XSDTree,
bindings: [String: String],
into findings: inout [PureXML.Schema.SchemaLocatedFinding],
) {
let local = PureXML.Schema.XSDNode.localName(node)
let children = PureXML.Schema.XSDNode.elementChildren(node)
var currentBindings = bindings
@ -77,15 +95,15 @@ extension PureXML.Schema.XSDParser {
}
}
if node.name?.namespaceURI == xsdNamespace, let local {
errors += attributeValueErrors(node, bindings: currentBindings)
errors += occurrenceOrderErrors(node)
errors += attributeApplicabilityErrors(node, local: local)
append(attributeValueErrors(node, bindings: currentBindings), at: node, into: &findings)
append(occurrenceOrderErrors(node), at: node, into: &findings)
append(attributeApplicabilityErrors(node, local: local), at: node, into: &findings)
let names = children.filter { $0.name?.namespaceURI == xsdNamespace }.compactMap(PureXML.Schema.XSDNode.localName)
errors += componentSpecificErrors(node, local: local, names: names)
append(componentSpecificErrors(node, local: local, names: names), at: node, into: &findings)
}
if local == "appinfo" || local == "documentation" { return }
for child in children {
collectStructure(child, bindings: currentBindings, into: &errors)
collectStructure(child, bindings: currentBindings, into: &findings)
}
}

View File

@ -178,26 +178,36 @@ extension PureXML.Schema {
/// Schema-validity findings gathered while parsing (malformed facet
/// definitions and the like), reported together with the model-level
/// consistency findings when the schema is compiled.
var schemaErrors: [String] = []
var schemaErrors: [PureXML.Validation.ValidationError] = []
}
/// Collects schema-validity findings during parsing. A reference type so the
/// findings survive the value-type ``XSDContext``'s copies (`visiting(_:)`):
/// every copy shares the one collector, and the parser harvests it at the end.
final class SchemaDiagnostics {
private(set) var errors: [String] = []
private(set) var errors: [PureXML.Validation.ValidationError] = []
func report(_ message: String) {
errors.append(message)
func report(_ error: PureXML.Validation.ValidationError) {
errors.append(error)
}
func report(_ reason: String, at node: PureXML.Model.TreeNode?) {
report(PureXML.Validation.ValidationError(
reason: reason,
at: node?.validationCodingPath() ?? [.element("schema")],
))
}
/// The findings with exact-duplicate messages removed, first occurrence
/// kept. The same malformed definition is visited more than once (named
/// simple types resolve over repeated passes), so dedup keeps one finding
/// per distinct problem.
var deduplicated: [String] {
var deduplicated: [PureXML.Validation.ValidationError] {
var seen: Set<String> = []
return errors.filter { seen.insert($0).inserted }
return errors.filter {
let key = "\(PureXML.Validation.PathKey.render($0.codingPath))|\($0.reason)"
return seen.insert(key).inserted
}
}
}

View File

@ -51,7 +51,7 @@ extension PureXML.Schema {
compositionLoaded: compositionLoaded,
)
for error in PureXML.Validation.SchemaCompile.preCompileErrors(schema: schema, context: context, containers: containers) {
context.diagnostics.report(error.reason)
context.diagnostics.report(error)
}
var types = namedTypes(containers, into: &context)
let elements = globalElements(containers, context, into: &types)
@ -64,7 +64,7 @@ extension PureXML.Schema {
namedTypes: types,
)
for error in PureXML.Validation.SchemaCompile.postCompileErrors(in: postCompileDocument) {
context.diagnostics.report(error.reason)
context.diagnostics.report(error)
}
return buildCompiled(
elements: elements,

View File

@ -43,7 +43,7 @@ extension PureXML.Schema {
}
applyFacets(restriction, into: &facets)
for error in facetDefinitionErrors(restriction, base: baseType) + patternErrors(restriction) {
context.diagnostics.report(error)
context.diagnostics.report(error, at: restriction)
}
return SimpleType(base: baseType.base, facets: facets, variety: baseType.variety, isBuiltinList: baseType.isBuiltinList)
}

View File

@ -157,7 +157,7 @@ public extension PureXML.Validation {
diagnostics: [String: [SchematronMessagePart]],
_ functions: Functions,
) -> [ValidationError] {
let path = codingPath(of: node)
let path = node.validationCodingPath()
var errors: [ValidationError] = []
for assertion in assertions {
let holds = (try? assertion.test.value(at: node, position: 1, size: 1, variables: variables, functions: functions(node)).boolean) ?? false
@ -206,27 +206,5 @@ public extension PureXML.Validation {
}.joined()
return joined.split(whereSeparator: \.isWhitespace).joined(separator: " ")
}
/// The coding path of a context node, built from the element chain to the
/// root (a sibling index only when a name repeats), so each finding is
/// located at the node its rule fired on rather than the document root.
private static func codingPath(of node: PureXML.Model.TreeNode) -> [PathKey] {
var steps: [PathKey] = []
var current: PureXML.Model.TreeNode? = node
while let element = current, element.kind == .element, let name = element.name?.description {
steps.append(step(for: element, named: name))
current = element.parent
}
return steps.reversed()
}
private static func step(for element: PureXML.Model.TreeNode, named name: String) -> PathKey {
guard let parent = element.parent else { return .element(name) }
let siblings = parent.children.filter { $0.kind == .element && $0.name?.description == name }
guard siblings.count > 1, let index = siblings.firstIndex(where: { $0 === element }) else {
return .element(name)
}
return .element(name, index: index + 1)
}
}
}

View File

@ -0,0 +1,107 @@
@testable import PureXML
import Testing
/// Schema compile findings carry validation coding paths that resolve to source
/// spans on a ranged schema tree, the IDE underline flow for #169.
@Suite("Schema located compile diagnostics")
struct SchemaLocatedDiagnosticsTests {
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 duplicate xs:ID is located on the offending component")
func test_duplicateIdLocated() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="a" id="dup"/>
<xs:element name="b" id="dup"/>
</xs:schema>
"""
let findings = try inconsistentFindings(in: xsd)
let duplicate = try #require(findings.first { $0.reason.contains("duplicate id") })
#expect(duplicate.codingPath.map(\.stringValue) == ["xs:schema", "xs:element"])
#expect(duplicate.codingPath.last?.intValue == 2)
let (tree, _) = PureXML.readTree(xsd)
let range = tree.sourceRange(at: duplicate.codingPath)
#expect(range?.start.line == 3)
#expect(range?.start.column == 3)
}
@Test("An invalid xs:ID value is located on its declaring component")
func test_invalidIdLocated() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="a" id="123"/>
</xs:schema>
"""
let findings = try inconsistentFindings(in: xsd)
let invalid = try #require(findings.first { $0.reason.contains("not a valid NCName") })
#expect(invalid.codingPath.map(\.stringValue) == ["xs:schema", "xs:element"])
let (tree, _) = PureXML.readTree(xsd)
let range = tree.sourceRange(at: invalid.codingPath)
#expect(range?.start.line == 2)
#expect(range?.start.column == 3)
}
@Test("An xs:include targetNamespace mismatch is located on the include directive")
func test_includeMismatchLocated() throws {
let included = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:other">
<xs:element name="e" type="xs:string"/>
</xs:schema>
"""
let main = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:main">
<xs:include schemaLocation="other.xsd"/>
<xs:element name="root" type="xs:string"/>
</xs:schema>
"""
let findings: [PureXML.Validation.ValidationError]
do {
_ = try PureXML.Schema.Document(main, schemaLoader: { $0 == "other.xsd" ? included : nil })
Issue.record("expected include mismatch to be rejected")
return
} catch let error as PureXML.Schema.SchemaError {
guard case let .inconsistent(found) = error else {
Issue.record("expected .inconsistent, got \(error)")
return
}
findings = found
}
let mismatch = try #require(findings.first { $0.reason.contains("included schema targetNamespace") })
#expect(mismatch.codingPath.map(\.stringValue) == ["xs:schema", "xs:include"])
let (tree, _) = PureXML.readTree(main)
let range = tree.sourceRange(at: mismatch.codingPath)
#expect(range?.start.line == 2)
#expect(range?.start.column == 3)
}
@Test("SchemaError.inconsistentFindings exposes the validation errors")
func test_inconsistentFindingsAccessor() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="a" id="123"/>
</xs:schema>
"""
do {
_ = try PureXML.Schema.Document(xsd)
Issue.record("expected schema compilation to fail")
} catch let error as PureXML.Schema.SchemaError {
let findings = try #require(error.inconsistentFindings)
#expect(findings.contains { $0.reason.contains("not a valid NCName") })
}
}
}

View File

@ -136,8 +136,8 @@ struct XSDSchemaValidationTests {
return
}
#expect(findings.count == 2)
#expect(findings.contains { $0.contains("declares final") })
#expect(findings.contains { $0.contains("not a valid restriction") })
#expect(findings.contains { $0.reason.contains("declares final") })
#expect(findings.contains { $0.reason.contains("not a valid restriction") })
}
}