PureXML/Sources/Schema/SchemaDocument.swift
Mihaela Mihaljevic d3a0b60d67 refactor(schema): express the src-resolve check as a Validation, not a pipeline throw (#161)
Per the OpenAPIKit validation idiom (Rules/universal/validation-rules.md), every
schema-validity check should run through the Validator framework as a described,
composable Validation rather than an ad-hoc throw. The src-resolve check (a
resolved include/import/redefine schemaLocation must be a well-formed schema)
was a parse-pipeline throw; it is now the pre-compile Validation
referencedSchemasResolveToSchemas.

failedSchemaReferences (formerly the throwing checkSchemaLocations) returns the
resolved-but-not-a-schema locations; the pipeline records them on XSDContext and
the validation reports one finding each. Same rejection behaviour (the finding
makes the schema invalid), now located in the framework, pinned in the
configuration test. Removed the now-unused SchemaError.invalidSchemaReference
case (added earlier this branch, never released).

No bucket change (invalid-schemas-accepted stays 81, valid-schemas-rejected 0);
full suite and fuzz pass (report-and-continue does not crash downstream).
2026-06-17 23:34:33 +02:00

394 lines
21 KiB
Swift

public extension PureXML.Schema {
/// An error compiling or applying a schema.
enum SchemaError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
case notASchema
/// A type inside `xs:redefine` does not redefine itself: its restriction or
/// extension base must name the type it redefines.
case redefineIncompatible(type: String)
/// An `xs:all` group violates its XSD 1.0 constraints: every member must be
/// an element with `maxOccurs` at most 1, and the group itself may occur at
/// most once.
case invalidAllGroup(reason: String)
/// A RELAX NG schema document does not match the RELAX NG grammar or
/// its restrictions (sections 3, 4.16-4.18).
case invalidRelaxNG(reason: String)
/// 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([PureXML.Validation.ValidationError])
public var description: String {
switch self {
case .notASchema:
"the document is not an xs:schema"
case let .redefineIncompatible(type):
"redefined type '\(type)' must derive from itself"
case let .invalidAllGroup(reason):
"invalid xs:all group: \(reason)"
case let .invalidRelaxNG(reason):
"invalid RELAX NG schema: \(reason)"
case let .inconsistent(findings):
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
/// table, parsed from a schema document, used to validate instance documents.
struct Document: Sendable {
private let elements: [String: ElementType]
private let types: [String: ElementType]
private let constraints: [String: [IdentityConstraint]]
private let identityFieldTypes: [String: SimpleType]
private let nillableElements: Set<String>
private let elementConstraints: [String: ValueConstraint]
private let abstractTypes: Set<String>
private let abstractElements: Set<String>
private let typeBlock: [String: Set<DerivationMethod>]
private let elementBlock: [String: Set<DerivationMethod>]
private let typeDerivation: [String: TypeDerivation]
private let targetNamespace: String?
private let globalAttributes: [String: AttributeUse]
/// Combines already-compiled declaration tables, for merging the schemas
/// an instance references through `xsi:schemaLocation`. No consistency
/// check runs: each component schema was validated when first compiled.
private init(
elements: [String: ElementType],
types: [String: ElementType],
constraints: [String: [IdentityConstraint]],
identityFieldTypes: [String: SimpleType] = [:],
nillableElements: Set<String>,
elementConstraints: [String: ValueConstraint],
abstractTypes: Set<String>,
abstractElements: Set<String>,
typeBlock: [String: Set<DerivationMethod>],
elementBlock: [String: Set<DerivationMethod>],
typeDerivation: [String: TypeDerivation],
targetNamespace: String?,
globalAttributes: [String: AttributeUse],
) {
self.elements = elements
self.types = types
self.constraints = constraints
self.identityFieldTypes = identityFieldTypes
self.nillableElements = nillableElements
self.elementConstraints = elementConstraints
self.abstractTypes = abstractTypes
self.abstractElements = abstractElements
self.typeBlock = typeBlock
self.elementBlock = elementBlock
self.typeDerivation = typeDerivation
self.targetNamespace = targetNamespace
self.globalAttributes = globalAttributes
}
/// This schema with `other`'s global declarations merged in; this schema's
/// own declarations win on any key conflict. Used to fold in the schemas an
/// instance points at, so a strict/lax wildcard can resolve elements
/// declared in another document.
public func merged(with other: Document) -> Document {
merging(other)
}
private func merging(_ other: Document) -> Document {
func mineFirst<V>(_ mine: [String: V], _ theirs: [String: V]) -> [String: V] {
mine.merging(theirs) { current, _ in current }
}
return Document(
elements: mineFirst(elements, other.elements),
types: mineFirst(types, other.types),
constraints: mineFirst(constraints, other.constraints),
identityFieldTypes: mineFirst(identityFieldTypes, other.identityFieldTypes),
nillableElements: nillableElements.union(other.nillableElements),
elementConstraints: mineFirst(elementConstraints, other.elementConstraints),
abstractTypes: abstractTypes.union(other.abstractTypes),
abstractElements: abstractElements.union(other.abstractElements),
typeBlock: mineFirst(typeBlock, other.typeBlock),
elementBlock: mineFirst(elementBlock, other.elementBlock),
typeDerivation: mineFirst(typeDerivation, other.typeDerivation),
targetNamespace: targetNamespace,
globalAttributes: mineFirst(globalAttributes, other.globalAttributes),
)
}
/// Compiles a schema document. `schemaLoader` resolves the
/// `schemaLocation` of `xs:include`, `xs:import`, and `xs:redefine` to
/// schema source; it returns nil (the default) when external schemas are
/// not available, which keeps compilation from reaching the filesystem or
/// network by default.
private init(compiled: XSDCompiled) throws {
elements = compiled.elements
types = compiled.types
constraints = compiled.constraints
identityFieldTypes = compiled.identityFieldTypes
nillableElements = compiled.nillableElements
elementConstraints = compiled.elementConstraints
abstractTypes = compiled.abstractTypes
abstractElements = compiled.abstractElements
typeBlock = compiled.typeBlock
elementBlock = compiled.elementBlock
typeDerivation = compiled.typeDerivation
targetNamespace = compiled.targetNamespace
globalAttributes = compiled.globalAttributes
// Schema consistency through the validation framework: every named
// type is checked by the composable rules (final respected, Particle
// Valid (Restriction)), and ALL findings are reported together, joined
// with the schema-validity findings gathered while parsing (malformed
// facet definitions and the like).
let consistencyFindings = PureXML.Validation.XSDSchema.consistencyErrors(
types: compiled.types,
typeDerivation: compiled.typeDerivation,
typeFinal: compiled.typeFinal,
)
let allFindings = compiled.schemaErrors + consistencyFindings
if !allFindings.isEmpty {
throw SchemaError.inconsistent(allFindings)
}
}
/// Validates an instance document against the schema, returning one located
/// ``PureXML/Validation/ValidationError`` per violation. Reports an error
/// when the root element has no global declaration.
public func validate(_ xml: String) throws -> [PureXML.Validation.ValidationError] {
try validate(PureXML.parse(xml))
}
/// Validates `xml`, additionally honoring the instance's
/// `xsi:schemaLocation` and `xsi:noNamespaceSchemaLocation` hints: each
/// referenced schema document is loaded through `schemaLoader`, compiled,
/// and its global declarations merged in (this schema's own declarations
/// win), so a strict or lax wildcard can resolve elements declared in
/// another document. With no hints this is `validate(_:)`.
public func validate(_ xml: String, schemaLoader: @escaping (String) -> String?) throws -> [PureXML.Validation.ValidationError] {
let node = try PureXML.parse(xml)
return merged(with: node, schemaLoader: schemaLoader).validate(node)
}
/// Merges global declarations from every schema document the instance
/// references through `xsi:schemaLocation` or
/// `xsi:noNamespaceSchemaLocation`.
private func merged(with node: PureXML.Model.Node, schemaLoader: @escaping (String) -> String?) -> Document {
var combined = self
for location in Self.hintedSchemaLocations(in: node) {
guard let source = schemaLoader(location),
let other = try? Document(source, schemaLoader: schemaLoader)
else { continue }
combined = combined.merging(other)
}
return combined
}
/// The schema-document locations an instance points at through
/// `xsi:schemaLocation` (the second token of each namespace/location pair)
/// and `xsi:noNamespaceSchemaLocation` (a single location).
private static func hintedSchemaLocations(in node: PureXML.Model.Node) -> [String] {
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
return []
}
var locations: [String] = []
for attribute in root.attributes {
let isInstance = attribute.name.namespaceURI == "http://www.w3.org/2001/XMLSchema-instance"
|| attribute.name.prefix == "xsi"
guard isInstance else { continue }
switch attribute.name.localName {
case "schemaLocation":
let tokens = attribute.value.split(whereSeparator: \.isWhitespace).map(String.init)
var index = 1
while index < tokens.count {
locations.append(tokens[index])
index += 2
}
case "noNamespaceSchemaLocation":
locations.append(attribute.value)
default:
break
}
}
return locations
}
/// Validates `xml` against the schema while it is pulled event by event (the
/// libxml2 `xmlTextReader` model) rather than over a built tree. Memory is
/// bounded to the open-element stack. Content models, attributes, and simple
/// content are checked incrementally; document-scoped identity constraints
/// are checked once at the end from a parsed tree (the instance is parsed
/// again only when constraints are present).
public func validate(streaming xml: String, limits: PureXML.Parsing.Limits = .default) throws -> [PureXML.Validation.ValidationError] {
let identityNode = constraints.isEmpty ? nil : try PureXML.parse(xml)
return try validateStreaming(xml, limits: limits, identityNode: identityNode)
}
/// Validates `xml` while it is pulled event by event, additionally honoring
/// the instance's `xsi:schemaLocation` and
/// `xsi:noNamespaceSchemaLocation` hints through `schemaLoader`. See
/// ``validate(_:schemaLoader:)-(String)``.
public func validate(
streaming xml: String,
schemaLoader: @escaping (String) -> String?,
limits: PureXML.Parsing.Limits = .default,
) throws -> [PureXML.Validation.ValidationError] {
let node = try PureXML.parse(xml)
return try merged(with: node, schemaLoader: schemaLoader).validateStreaming(xml, limits: limits, identityNode: node)
}
private func validateStreaming(
_ xml: String,
limits: PureXML.Parsing.Limits,
identityNode: PureXML.Model.Node?,
) throws -> [PureXML.Validation.ValidationError] {
let validator = PureXML.Schema.ComplexValidator(
types: types,
globalAttributes: globalAttributes,
nillableElements: nillableElements,
elementConstraints: elementConstraints,
abstractTypes: abstractTypes,
typeBlock: typeBlock,
elementBlock: elementBlock,
typeDerivation: typeDerivation,
)
var driver = PureXML.Validation.StreamingXSDValidator(
validator: validator,
rootElements: elements,
abstractElements: abstractElements,
targetNamespace: targetNamespace,
)
var reader = PureXML.Parsing.EventReader(xml, limits: limits)
while let event = try reader.next() {
driver.consume(event)
}
var errors = driver.finish()
errors += validator.idErrors()
if !constraints.isEmpty, let identityNode {
errors += identityErrors(in: identityNode)
}
return errors
}
/// Identity-constraint findings for an already-parsed instance tree.
private func identityErrors(in node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] {
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
return []
}
return PureXML.Schema.IdentityValidator(
constraints: constraints,
fieldTypes: identityFieldTypes,
types: types,
)
.validate(PureXML.Model.TreeNode(.element(root)), at: [.element(root.name.description)])
}
/// Validates an already-parsed node tree, so a caller can validate the same
/// (possibly recovered) tree it is editing. See ``validate(_:)-(String)``.
public func validate(_ node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] {
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
return [.init(reason: "the document has no root element", at: [])]
}
guard let declaration = globalElementDeclaration(for: root.name) ?? rootTypeOverride(for: root) else {
return [.init(reason: "no element declaration for '\(root.name.localName)'", at: [.element(root.name.description)])]
}
// A schema with a target namespace declares its global elements in that
// namespace; the root must be in it unless a merged schema declares it
// in another namespace, or xsi:type names the validating type.
if rootTypeOverride(for: root) == nil, rootNamespaceMismatch(root.name) {
let reason = "root element '\(root.name.localName)' is not in the schema target namespace '\(targetNamespace ?? "")'"
return [.init(reason: reason, at: [.element(root.name.description)])]
}
// An abstract element may not appear in an instance directly; only a
// concrete substitution-group member may stand in its place.
if abstractElements.contains(root.name.localName) {
return [.init(reason: "abstract element '\(root.name.localName)' must not appear in an instance", at: [.element(root.name.description)])]
}
let context = PureXML.Validation.XSDContext(
types: types,
constraints: constraints,
rootDeclaration: declaration,
nillableElements: nillableElements,
elementConstraints: elementConstraints,
abstractTypes: abstractTypes,
typeBlock: typeBlock,
elementBlock: elementBlock,
typeDerivation: typeDerivation,
globalAttributes: globalAttributes,
identityFieldTypes: identityFieldTypes,
)
return PureXML.Validation.XSD.validator().errors(for: .element(root), in: context)
}
/// The global element declaration for a qualified instance name.
private func globalElementDeclaration(for name: PureXML.Model.QualifiedName) -> ElementType? {
if let declaration = types[PureXML.Schema.XSDParser.elementDeclarationKey(name)] {
return declaration
}
if name.namespaceURI == nil || name.namespaceURI?.isEmpty == true {
return elements[name.localName]
}
return nil
}
private func rootNamespaceMismatch(_ name: PureXML.Model.QualifiedName) -> Bool {
guard let target = targetNamespace, !target.isEmpty, name.namespaceURI != target else { return false }
return types[PureXML.Schema.XSDParser.elementDeclarationKey(name)] == nil
}
/// When the root has no global element declaration, an `xsi:type` attribute
/// can still name the type to validate against (Sun target-NS tests).
private func rootTypeOverride(for root: PureXML.Model.Element) -> ElementType? {
guard PureXML.Schema.ComplexValidator.xsiTypeAttributeValue(root) != nil else { return nil }
let bindings = PureXML.Schema.ComplexValidator.namespaceBindings(for: root)
guard let reference = PureXML.Schema.ComplexValidator.xsiTypeReference(root, namespaceBindings: bindings),
let type = PureXML.Schema.ComplexValidator.resolveNamedType(reference, in: types)
else { return nil }
return type
}
/// What the schema allows at the element a coding `path` addresses in
/// `tree`: the next child elements (the content-model follow-set), whether
/// the content may end there, and the declared attributes with their
/// required/present status. For editor completions and "what's missing".
/// Returns nil when the path does not address a declared element.
public func completions(at path: [PureXML.Validation.PathKey], in tree: PureXML.Model.TreeNode) -> Completions? {
guard let node = tree.node(at: path), case let .element(element) = node.node,
let type = CompletionEngine.elementType(at: path, elements: elements, types: types)
else {
return nil
}
return CompletionEngine.completions(for: element, type: type, types: types)
}
/// The quick-fixes the schema offers at the element a coding `path`
/// addresses in `tree`: add a required-but-absent attribute, or insert a
/// still-expected required child. Derived from the structured
/// ``completions(at:in:)``, with precise placement from the node's content
/// span, so the edits are exact, not guessed from a message.
public func quickFixes(at path: [PureXML.Validation.PathKey], in tree: PureXML.Model.TreeNode) -> [PureXML.QuickFix] {
guard let completions = completions(at: path, in: tree), let element = tree.node(at: path) else { return [] }
return PureXML.QuickFixEngine.fixes(from: completions, element: element)
}
}
}
public extension PureXML.Schema.Document {
/// Compiles a schema document. `schemaLoader` resolves the `schemaLocation` of
/// `xs:include`, `xs:import`, and `xs:redefine` to schema source; it returns
/// nil (the default) when external schemas are not available, which keeps
/// compilation from reaching the filesystem or network by default.
init(_ xsd: String, schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
try self.init(compiled: PureXML.Schema.XSDParser.parse(xsd, loader: schemaLoader))
}
/// Compiles several schema documents that jointly form one schema as a single
/// union (#161), so cross-document substitution-group membership is resolved.
/// Use this rather than compiling each document and merging the results, which
/// freezes each document's content models before the others are known.
init(composing sources: [String], schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
try self.init(compiled: PureXML.Schema.XSDParser.parse(union: sources, loader: schemaLoader))
}
}