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:
parent
229d7df3d0
commit
489275c6b8
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
107
Tests/SchemaLocatedDiagnosticsTests.swift
Normal file
107
Tests/SchemaLocatedDiagnosticsTests.swift
Normal 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") })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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") })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user