PureXML/Sources/Schema/SchemaDocument.swift
Mihaela Mihaljevic 3415ff3d9a feat(relaxng): schema-correctness validation, spec suite complete
A RELAX NG schema document is validated before pattern interpretation
in three layers: the section-3 grammar check (vocabulary, attributes,
arity, text, QNames, datatypeLibrary URIs, hrefs, 4.16 excepts, xmlns
prohibitions), compiler checks (4.17 combine rules, unresolved refs,
parentRef scope, startless grammars, include/externalRef cycles and
overrides, grammar-checked loaded documents, unknown datatypes,
built-in-library params), and the 4.19/7.1-7.4 restrictions over the
compiled algebra after 4.20/4.21 normalization (prohibited paths,
content types, duplicate attributes via representative-name overlap,
infinite-class attributes outside oneOrMore, interleave constraints),
with recursion detection deliberately before normalization. Incorrect
schemas: 213 compiling down to 7, all the pre-Fifth-Edition name-class
class (count asserted). Two leniency tests updated to spec behavior.

Closes #131
2026-06-10 11:38:45 +02:00

171 lines
9.2 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([String])
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.joined(separator: "; ")
}
}
}
/// 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 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 typeDerivation: [String: TypeDerivation]
private let targetNamespace: String?
/// 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.
public init(_ xsd: String, schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
let compiled = try XSDParser.parse(xsd, loader: schemaLoader)
elements = compiled.elements
types = compiled.types
constraints = compiled.constraints
nillableElements = compiled.nillableElements
elementConstraints = compiled.elementConstraints
abstractTypes = compiled.abstractTypes
abstractElements = compiled.abstractElements
typeBlock = compiled.typeBlock
typeDerivation = compiled.typeDerivation
targetNamespace = compiled.targetNamespace
// 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.
let findings = PureXML.Validation.XSDSchema.consistencyErrors(
types: compiled.types,
typeDerivation: compiled.typeDerivation,
typeFinal: compiled.typeFinal,
)
if !findings.isEmpty {
throw SchemaError.inconsistent(findings.map { String(describing: $0) })
}
}
/// 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` 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; document-scoped identity constraints (which need the
/// whole tree) stay on ``validate(_:)-(String)``.
public func validate(streaming xml: String, limits: PureXML.Parsing.Limits = .default) throws -> [PureXML.Validation.ValidationError] {
let validator = PureXML.Schema.ComplexValidator(
types: types,
nillableElements: nillableElements,
elementConstraints: elementConstraints,
abstractTypes: abstractTypes,
typeBlock: typeBlock,
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)
}
return driver.finish()
}
/// 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 = elements[root.name.localName] 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.
if let target = targetNamespace, !target.isEmpty, root.name.namespaceURI != target {
return [.init(reason: "root element '\(root.name.localName)' is not in the schema target namespace '\(target)'", 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,
typeDerivation: typeDerivation,
)
return PureXML.Validation.XSD.validator().errors(for: .element(root), in: context)
}
/// 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)
}
}
}