feat(validation,schema): composable validation framework and cross-document composition
Ship the OpenAPIKit-style validation layer (builtin rules, warnings channel, Validator tiers, coverage gates) and wire XSD compile checks through it. Advance #161 with chameleon include validation, multi-container reference resolution, and include targetNamespace composition rules so merged schema sets compile correctly when a schemaLoader is provided.
This commit is contained in:
parent
5f6170a57a
commit
229d7df3d0
@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-06-16
|
||||
### Added
|
||||
|
||||
- OpenAPIKit-style validation framework completion: `BuiltinValidation` registry (23 rules), exhaustive fail/succeed isolation tests, configuration pins, public-type and field coverage gates (`scripts/check-validation-coverage.sh`, `scripts/check-validation-fields.sh`), warnings channel with `strict:` promotion, two-tier `Validator` defaults, XSD schema compile rules as composable validations (`SchemaCompileValidation`), and `ValidationOutcome` non-throwing accessor.
|
||||
|
||||
### Changed
|
||||
|
||||
- DTD parse-time validity findings are warnings until strict validation promotes them; `validateAgainstInternalDTD` returns full findings (errors and warnings).
|
||||
- 2026-06-16
|
||||
|
||||
Second pre-release. Continues the W3C XSTS schema-validity campaign: **invalid-schemas-accepted 394 → 266** (settled expectations), **valid-instances-rejected 180 → 171**, **invalid-instances-accepted 158 → 155**, **valid-schemas-rejected held at 1** (`particlesZ001`, spec-ambiguous). Not yet 1.0: see `docs/production-readiness.md`.
|
||||
|
||||
|
||||
@ -100,7 +100,7 @@ flowchart TB
|
||||
EncodingMismatch["#137 encoding-declaration mismatches: complete"]:::done
|
||||
BaseURI["#138 per-entity base URIs: complete"]:::done
|
||||
Epic122["#122 conformance suite maintenance: complete (#130 survivor)"]:::epic
|
||||
XSTS["#129 XSTS runner shipped; gates #145-#148 at 1/266/171/155 (v0.2.0)"]:::review
|
||||
XSTS["#129 XSTS runner shipped; gates #145-#148 at 1/264/173/155 (v0.2.0)"]:::review
|
||||
XSLTTS["#130 XSLT 1.0 suite (xalan-test): ~126 baseline failures"]:::review
|
||||
Parity100["Behavioral parity closed: #141 top-level params, #142 public streaming, #143 XPath budget, #144 WASI runtime proof, pl/ru sort collation"]:::done
|
||||
Epic139["#139 faster than libxml2, measured + streaming (benchmarks in docs/benchmarks.md; quadratics killed; unsafe authorized)"]:::epic
|
||||
@ -139,7 +139,7 @@ flowchart TB
|
||||
|
||||
## Status
|
||||
|
||||
**Current focus:** production readiness for IDE use ([#167](https://github.com/mihaelamj/PureXML/issues/167)). XSTS settled baselines (v0.2.0): **1 / 266 / 171 / 155**. See [`docs/roadmap.md`](docs/roadmap.md) for milestones and open tasks.
|
||||
**Current focus:** production readiness for IDE use ([#167](https://github.com/mihaelamj/PureXML/issues/167)). XSTS settled baselines (v0.2.0): **1 / 264 / 173 / 155**. See [`docs/roadmap.md`](docs/roadmap.md) for milestones and open tasks.
|
||||
|
||||
PureXML is a working, dependency-free XML library today: parse, emit, validate,
|
||||
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
|
||||
|
||||
@ -86,7 +86,7 @@ public extension PureXML {
|
||||
) throws -> [Validation.ValidationError] {
|
||||
let parsed = try Parsing.Parser().parseWithDocumentType(xml, limits: limits, resolver: resolver)
|
||||
let schema = Validation.DTDSchema(parsed.documentType, standalone: parsed.declaration?.standalone == true)
|
||||
return Validation.DTD.validator(strict: strict).errors(for: parsed.node, in: schema)
|
||||
return Validation.DTD.validator(strict: strict).findings(for: parsed.node, in: schema)
|
||||
}
|
||||
|
||||
/// The XML declaration (`version`, `encoding`, `standalone`) at the start of
|
||||
@ -210,4 +210,21 @@ public extension PureXML {
|
||||
) throws {
|
||||
try validator.validate(node)
|
||||
}
|
||||
|
||||
/// The HTML5 content-model validation errors for a parsed node; empty when the
|
||||
/// tree satisfies the intrinsic rules (void elements, required parents, unique
|
||||
/// `id` values). Built on ``Validation/HTML``.
|
||||
static func validateHTML(_ node: Model.Node) -> [Validation.ValidationError] {
|
||||
HTML.validationErrors(in: node)
|
||||
}
|
||||
|
||||
/// Lints a possibly-invalid document against an XSD schema, merging parse
|
||||
/// diagnostics and schema validation findings into source-ranged
|
||||
/// ``LintDiagnostic``s. See ``lint(_:validate:)``.
|
||||
static func lint(
|
||||
_ xml: String,
|
||||
schema: Schema.Document,
|
||||
) -> [LintDiagnostic] {
|
||||
lint(xml, validate: schema.validate)
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,19 +43,72 @@ public extension PureXML.Schema.ComplexValidator {
|
||||
return type
|
||||
}
|
||||
|
||||
/// The declared element type of a child `name` in `parent`'s content model, or
|
||||
/// nil when the parent has no element content or no such child (an undeclared
|
||||
/// child is flagged by the parent's own structure check).
|
||||
/// The declared element type of a child `name` in `parent`'s content model: a
|
||||
/// named particle member, or a global declaration reached through a matching
|
||||
/// wildcard's `processContents`. Returns nil when the parent has no element
|
||||
/// content, when `processContents="skip"`, or when the child is not declared
|
||||
/// and no wildcard admits it (the parent's structure check reports that).
|
||||
func childType(of parent: PureXML.Schema.ElementType, child name: PureXML.Model.QualifiedName) -> PureXML.Schema.ElementType? {
|
||||
guard case let .complex(complex) = parent else { return nil }
|
||||
switch complex.content {
|
||||
case let .elementOnly(particle), let .mixed(particle):
|
||||
return Self.elementTypes(in: particle.term)[Self.key(name)]
|
||||
if let type = Self.elementTypes(in: particle.term)[Self.key(name)] {
|
||||
return type
|
||||
}
|
||||
return wildcardChildType(for: name, in: particle.term)
|
||||
case .empty, .simpleContent:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The error when a strict wildcard admits a child but no global declaration
|
||||
/// exists for it, matching the tree validator's ``validateWildcardChild``.
|
||||
func strictWildcardError(
|
||||
for child: PureXML.Model.QualifiedName,
|
||||
in parent: PureXML.Schema.ElementType,
|
||||
at path: [PureXML.Validation.PathKey],
|
||||
) -> PureXML.Validation.ValidationError? {
|
||||
guard case let .complex(complex) = parent else { return nil }
|
||||
switch complex.content {
|
||||
case let .elementOnly(particle), let .mixed(particle):
|
||||
if Self.elementTypes(in: particle.term)[Self.key(child)] != nil { return nil }
|
||||
guard Self.wildcardMatch(for: child, in: particle.term) == .strict,
|
||||
types["element:\(child.localName)"] == nil
|
||||
else { return nil }
|
||||
return PureXML.Validation.ValidationError(
|
||||
reason: "no declaration for wildcard-matched element '\(child.localName)'",
|
||||
at: path,
|
||||
)
|
||||
case .empty, .simpleContent:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The `processContents` of a wildcard in `term` that admits `name`, if any.
|
||||
static func wildcardMatch(for name: PureXML.Model.QualifiedName, in term: PureXML.Schema.Term) -> PureXML.Schema.ProcessContents? {
|
||||
switch term {
|
||||
case let .wildcard(wildcard):
|
||||
return wildcard.admits(name) ? wildcard.processContents : nil
|
||||
case .element:
|
||||
return nil
|
||||
case let .group(group):
|
||||
for member in group.particles {
|
||||
if let match = wildcardMatch(for: name, in: member.term) { return match }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func wildcardChildType(for name: PureXML.Model.QualifiedName, in term: PureXML.Schema.Term) -> PureXML.Schema.ElementType? {
|
||||
guard let process = Self.wildcardMatch(for: name, in: term) else { return nil }
|
||||
switch process {
|
||||
case .skip:
|
||||
return nil
|
||||
case .lax, .strict:
|
||||
return types["element:\(name.localName)"]
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates one element's own attributes and content-model structure (child
|
||||
/// names, order, occurrence) and simple content, but not its children's own
|
||||
/// validity, which the streaming driver checks as each child closes. `type`
|
||||
@ -97,9 +150,11 @@ public extension PureXML.Schema.ComplexValidator {
|
||||
if !element.children.compactMap(\.element).isEmpty {
|
||||
errors.append(XSDFailure(reason: "'\(element.name.localName)' must not have children", at: path))
|
||||
}
|
||||
if let error = simple.validate(Self.textContent(element)) {
|
||||
let text = Self.rawTextContent(element)
|
||||
if let error = simple.validate(text) {
|
||||
errors.append(XSDFailure(reason: "'\(element.name.localName)': \(error)", at: path))
|
||||
}
|
||||
recordIDs(simple, value: text, at: path)
|
||||
errors += elementFixedErrors(element, at: path)
|
||||
}
|
||||
|
||||
@ -111,9 +166,11 @@ public extension PureXML.Schema.ComplexValidator {
|
||||
rejectText(element, at: path, into: &errors)
|
||||
case let .simpleContent(type):
|
||||
if !children.isEmpty { errors.append(XSDFailure(reason: "element must not have children", at: path)) }
|
||||
if let error = type.validate(Self.textContent(element)) {
|
||||
let text = Self.rawTextContent(element)
|
||||
if let error = type.validate(text) {
|
||||
errors.append(XSDFailure(reason: "content: \(error)", at: path))
|
||||
}
|
||||
recordIDs(type, value: text, at: path)
|
||||
case let .elementOnly(particle):
|
||||
rejectText(element, at: path, into: &errors)
|
||||
shallowStructure(particle, children: children, at: path, into: &errors)
|
||||
|
||||
@ -42,6 +42,14 @@ public extension PureXML.Schema {
|
||||
return engine.streamingValid(state)
|
||||
}
|
||||
|
||||
/// Located validation errors for a streaming run. When the stream verdict
|
||||
/// is invalid, falls back to the tree ``errors(in:)`` pass so diagnostics
|
||||
/// stay located rather than a bare false.
|
||||
public func errors(streaming xml: String, limits: PureXML.Parsing.Limits = .default) throws -> [PureXML.Validation.ValidationError] {
|
||||
if try validate(streaming: xml, limits: limits) { return [] }
|
||||
return try errors(in: xml)
|
||||
}
|
||||
|
||||
/// Every way `xml` fails the schema, as located errors with recovery hints,
|
||||
/// so an editor can show all of a faulty document's problems at once rather
|
||||
/// than only the first. An empty array means the document is valid.
|
||||
|
||||
@ -20,7 +20,7 @@ extension PureXML.Schema.XSDParser {
|
||||
/// lexically (e.g. a list type's `"1 2 3"` and `"1 2 3"` are the same value),
|
||||
/// which a string comparison gets wrong; that clause is left for a later change.
|
||||
static func attributeRestrictionErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] {
|
||||
guard !hasExternalReference(schema) else { return [] }
|
||||
guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] }
|
||||
let bindings = context.namespaceBindings
|
||||
var errors: [String] = []
|
||||
for content in descendants(schema, named: "complexContent") {
|
||||
|
||||
291
Sources/Schema/SchemaCompileValidation.swift
Normal file
291
Sources/Schema/SchemaCompileValidation.swift
Normal file
@ -0,0 +1,291 @@
|
||||
extension PureXML.Schema {
|
||||
/// The cross-cutting context for XSD schema compile-time validation rules.
|
||||
struct SchemaCompileContext {
|
||||
let schema: PureXML.Model.TreeNode
|
||||
let context: PureXML.Schema.XSDContext
|
||||
let containers: [PureXML.Model.TreeNode]
|
||||
let derivation: PureXML.Schema.XSDParser.DerivationTables?
|
||||
let globalElements: [String: PureXML.Schema.ElementType]?
|
||||
let namedTypes: [String: PureXML.Schema.ElementType]?
|
||||
|
||||
init(
|
||||
schema: PureXML.Model.TreeNode,
|
||||
context: PureXML.Schema.XSDContext,
|
||||
containers: [PureXML.Model.TreeNode],
|
||||
derivation: PureXML.Schema.XSDParser.DerivationTables? = nil,
|
||||
globalElements: [String: PureXML.Schema.ElementType]? = nil,
|
||||
namedTypes: [String: PureXML.Schema.ElementType]? = nil,
|
||||
) {
|
||||
self.schema = schema
|
||||
self.context = context
|
||||
self.containers = containers
|
||||
self.derivation = derivation
|
||||
self.globalElements = globalElements
|
||||
self.namedTypes = namedTypes
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved named types and global elements produced during schema compilation.
|
||||
struct SchemaCompileNamedTypes {
|
||||
let derivation: PureXML.Schema.XSDParser.DerivationTables
|
||||
let globalElements: [String: PureXML.Schema.ElementType]
|
||||
let types: [String: PureXML.Schema.ElementType]
|
||||
}
|
||||
|
||||
/// 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 {}
|
||||
}
|
||||
|
||||
extension PureXML.Validation {
|
||||
/// XSD schema compile-time consistency, expressed as composable ``Validation``
|
||||
/// values over a ``PureXML/Schema/SchemaCompileRoot`` subject. Each rule wraps
|
||||
/// the existing compile-time check so findings stay identical while the
|
||||
/// orchestration follows the OpenAPIKit idiom.
|
||||
enum SchemaCompile {
|
||||
// MARK: Pre named-type rules
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
document.containers,
|
||||
mainTargetNamespace: document.context.targetNamespace,
|
||||
compositionLoaded: document.context.compositionLoaded,
|
||||
containerLocations: document.context.containerLocations,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Post named-type rules
|
||||
|
||||
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(
|
||||
document.schema,
|
||||
in: document.context,
|
||||
elements: globalElements,
|
||||
containers: document.containers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// The default validator for checks that run before named types resolve.
|
||||
static func preCompileValidator() -> Validator<PureXML.Schema.SchemaCompileContext> {
|
||||
Validator<PureXML.Schema.SchemaCompileContext>.defaults(
|
||||
nonReference: [
|
||||
AnyValidation(idAttributesValid),
|
||||
AnyValidation(schemaStructureValid),
|
||||
AnyValidation(componentNamesUnique),
|
||||
AnyValidation(simpleTypeFinalControlsValid),
|
||||
AnyValidation(contentModelsDeterministic),
|
||||
],
|
||||
reference: [
|
||||
AnyValidation(typeDerivationAcyclic),
|
||||
AnyValidation(typeReferencesAcyclic),
|
||||
AnyValidation(allGroupReferencesPlaced),
|
||||
AnyValidation(includesCompositionValid),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// The default validator for checks that require resolved named types.
|
||||
static func postCompileValidator() -> Validator<PureXML.Schema.SchemaCompileContext> {
|
||||
Validator<PureXML.Schema.SchemaCompileContext>.defaults(
|
||||
nonReference: [
|
||||
AnyValidation(attributeUsesValid),
|
||||
AnyValidation(idValueConstraintsValid),
|
||||
AnyValidation(userTypeValueConstraintsValid),
|
||||
AnyValidation(extensionAllGroupsValid),
|
||||
AnyValidation(attributeRestrictionsFaithful),
|
||||
AnyValidation(simpleTypeBasesAreSimple),
|
||||
AnyValidation(simpleTypeVarietiesValid),
|
||||
AnyValidation(notationsValid),
|
||||
],
|
||||
reference: [
|
||||
AnyValidation(schemaReferencesResolve),
|
||||
AnyValidation(substitutionMembersDeriveCorrectly),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Every pre-compile finding for a schema document.
|
||||
static func preCompileErrors(
|
||||
schema: PureXML.Model.TreeNode,
|
||||
context: PureXML.Schema.XSDContext,
|
||||
containers: [PureXML.Model.TreeNode],
|
||||
) -> [ValidationError] {
|
||||
let document = PureXML.Schema.SchemaCompileContext(schema: schema, context: context, containers: containers)
|
||||
return preCompileValidator().errors(
|
||||
for: PureXML.Schema.SchemaCompileRoot(),
|
||||
at: [.element("schema")],
|
||||
in: document,
|
||||
)
|
||||
}
|
||||
|
||||
/// Every post-compile finding for a schema document.
|
||||
static func postCompileErrors(in document: PureXML.Schema.SchemaCompileContext) -> [ValidationError] {
|
||||
postCompileValidator().errors(
|
||||
for: PureXML.Schema.SchemaCompileRoot(),
|
||||
at: [.element("schema")],
|
||||
in: document,
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a post-compile context and returns every finding.
|
||||
static func postCompileErrors(
|
||||
schema: PureXML.Model.TreeNode,
|
||||
context: PureXML.Schema.XSDContext,
|
||||
containers: [PureXML.Model.TreeNode],
|
||||
namedTypes: PureXML.Schema.SchemaCompileNamedTypes,
|
||||
) -> [ValidationError] {
|
||||
postCompileErrors(in: PureXML.Schema.SchemaCompileContext(
|
||||
schema: schema,
|
||||
context: context,
|
||||
containers: containers,
|
||||
derivation: namedTypes.derivation,
|
||||
globalElements: namedTypes.globalElements,
|
||||
namedTypes: namedTypes.types,
|
||||
))
|
||||
}
|
||||
|
||||
private static func compileRule(
|
||||
_ description: String,
|
||||
_ messages: @escaping (PureXML.Schema.SchemaCompileContext) -> [String],
|
||||
) -> Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
|
||||
.init(description: description) { context in
|
||||
messages(context.document).map {
|
||||
ValidationError(reason: $0, at: context.codingPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -149,6 +149,13 @@ public extension PureXML.Schema {
|
||||
/// 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),
|
||||
@ -156,7 +163,7 @@ public extension PureXML.Schema {
|
||||
else { continue }
|
||||
combined = combined.merging(other)
|
||||
}
|
||||
return combined.validate(node)
|
||||
return combined
|
||||
}
|
||||
|
||||
/// The schema-document locations an instance points at through
|
||||
@ -191,9 +198,32 @@ public extension PureXML.Schema {
|
||||
/// 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)``.
|
||||
/// 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,
|
||||
nillableElements: nillableElements,
|
||||
@ -213,7 +243,21 @@ public extension PureXML.Schema {
|
||||
while let event = try reader.next() {
|
||||
driver.consume(event)
|
||||
}
|
||||
return driver.finish()
|
||||
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)
|
||||
.validate(PureXML.Model.TreeNode(.element(root)), at: [.element(root.name.description)])
|
||||
}
|
||||
|
||||
/// Validates an already-parsed node tree, so a caller can validate the same
|
||||
|
||||
@ -13,7 +13,7 @@ extension PureXML.Schema.XSDParser {
|
||||
/// makes the `all` the whole content, which is valid), a simple-content base, or
|
||||
/// an unresolved/foreign base is left alone. Checked for a self-contained schema.
|
||||
static func extensionAllGroupErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] {
|
||||
guard !hasExternalReference(schema) else { return [] }
|
||||
guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] }
|
||||
let bindings = context.namespaceBindings
|
||||
var errors: [String] = []
|
||||
for content in descendants(schema, named: "complexContent") {
|
||||
|
||||
@ -39,27 +39,12 @@ extension PureXML.Schema.XSDParser {
|
||||
node.attributes.first { $0.name.prefix == nil && $0.name.localName == "id" }?.value
|
||||
}
|
||||
|
||||
/// All compile-time schema-consistency findings, collected together so they are
|
||||
/// reported in one pass: `id` validity, schema-for-schemas structure, global
|
||||
/// component-name uniqueness, content-model determinism (UPA), and circular
|
||||
/// type derivation. The explicit return type keeps the type-checker from
|
||||
/// inferring a long concatenation.
|
||||
/// All compile-time schema-consistency findings before named types resolve.
|
||||
static func consistencyErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ containers: [XSDTree]) -> [String] {
|
||||
let structural = idAttributeErrors(schema) + structureErrors(schema)
|
||||
+ componentNameErrors(schema, containers, context)
|
||||
+ simpleTypeFinalErrors(schema)
|
||||
let determinism = PureXML.Schema.ContentModelDeterminism.violations(in: schema, context: context)
|
||||
let cycles = derivationCycleErrors(containers, context.namespaceBindings, context.targetNamespace)
|
||||
+ circularReferenceErrors(containers, context.namespaceBindings, context.targetNamespace)
|
||||
let placement = allGroupReferencePlacementErrors(containers, context.namespaceBindings, context.targetNamespace)
|
||||
return structural + determinism + cycles + placement
|
||||
PureXML.Validation.SchemaCompile.preCompileErrors(schema: schema, context: context, containers: containers).map(\.reason)
|
||||
}
|
||||
|
||||
/// Consistency findings that depend on the resolved named types, collected after
|
||||
/// `namedTypes` has populated them: unresolved references, attribute-use
|
||||
/// uniqueness and single-ID, ID-typed value constraints, substitution-group
|
||||
/// member type derivation (`e-props-correct.4`), and value constraints valid
|
||||
/// against a user type (`e-props-correct.2` / `a-props-correct.2`).
|
||||
/// Consistency findings that depend on the resolved named types.
|
||||
static func postNamedTypeErrors(
|
||||
_ schema: XSDTree,
|
||||
_ context: PureXML.Schema.XSDContext,
|
||||
@ -67,15 +52,17 @@ extension PureXML.Schema.XSDParser {
|
||||
_ derivation: DerivationTables,
|
||||
typeMaps: (global: [String: PureXML.Schema.ElementType], named: [String: PureXML.Schema.ElementType]),
|
||||
) -> [String] {
|
||||
referenceErrors(schema, in: context, elements: typeMaps.global)
|
||||
+ attributeUseErrors(containers, context)
|
||||
+ idValueConstraintErrors(schema, context)
|
||||
+ substitutionTypeErrors(schema, derivation, typeMaps.named)
|
||||
+ userTypeValueConstraintErrors(schema, context, typeMaps.named)
|
||||
+ extensionAllGroupErrors(schema, context, typeMaps.named)
|
||||
+ attributeRestrictionErrors(schema, context, typeMaps.named)
|
||||
+ simpleTypeBaseNotComplexErrors(schema, in: context)
|
||||
+ simpleTypeVarietyErrors(schema, context)
|
||||
+ notationValidityErrors(containers, context)
|
||||
PureXML.Validation.SchemaCompile.postCompileErrors(
|
||||
schema: schema,
|
||||
context: context,
|
||||
containers: containers,
|
||||
namedTypes: PureXML.Schema.SchemaCompileNamedTypes(
|
||||
derivation: derivation,
|
||||
globalElements: typeMaps.global,
|
||||
types: typeMaps.named,
|
||||
),
|
||||
).map(\.reason)
|
||||
}
|
||||
|
||||
// Legacy helpers retained for direct unit tests of individual checks.
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ extension PureXML.Schema.XSDParser {
|
||||
namespaceMap: namespaceMap,
|
||||
mainTargetNamespace: context.targetNamespace,
|
||||
)
|
||||
errors += keyrefReferErrors(schema)
|
||||
errors += keyrefReferErrors(schema, containers, context)
|
||||
return errors
|
||||
}
|
||||
|
||||
@ -48,7 +48,16 @@ extension PureXML.Schema.XSDParser {
|
||||
let alreadyExists = uniqueIndices.contains { prevIndex in
|
||||
let prevNS = namespaceMap[prevIndex] ?? mainTargetNamespace
|
||||
let prevLocation = containerLocations[ObjectIdentifier(containers[prevIndex])] ?? nil
|
||||
return prevNS == namespaceURI && prevLocation == location && isStructurallyEqual(containers[prevIndex], container)
|
||||
guard prevNS == namespaceURI,
|
||||
isStructurallyEqual(containers[prevIndex], container)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
if prevLocation == location { return true }
|
||||
// A circular import reloads the root schema under a schemaLocation while
|
||||
// the compile root is appended last; treat that as one document, not a clash.
|
||||
let rootIndex = containers.count - 1
|
||||
return index == rootIndex || prevIndex == rootIndex
|
||||
}
|
||||
if !alreadyExists {
|
||||
uniqueIndices.append(index)
|
||||
@ -118,11 +127,14 @@ extension PureXML.Schema.XSDParser {
|
||||
/// Skipped when the document pulls in external definitions
|
||||
/// (`import`/`include`/`redefine`), which the default compile does not load,
|
||||
/// so a `refer` into them is never flagged.
|
||||
private static func keyrefReferErrors(_ schema: XSDTree) -> [String] {
|
||||
if hasExternalReference(schema) { return [] }
|
||||
private static func keyrefReferErrors(_ schema: XSDTree, _ containers: [XSDTree], _ context: PureXML.Schema.XSDContext) -> [String] {
|
||||
if skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) { return [] }
|
||||
var keyArities: [String: Int] = [:]
|
||||
var keyrefs: [KeyrefInfo] = []
|
||||
collectKeysAndRefers(schema, keyArities: &keyArities, keyrefs: &keyrefs)
|
||||
let sources = context.compositionLoaded ? containers : [schema]
|
||||
for source in sources where PureXML.Schema.XSDNode.localName(source) != "redefine" {
|
||||
collectKeysAndRefers(source, keyArities: &keyArities, keyrefs: &keyrefs)
|
||||
}
|
||||
|
||||
var errors: [String] = []
|
||||
for keyref in keyrefs {
|
||||
@ -227,11 +239,14 @@ extension PureXML.Schema.XSDParser {
|
||||
|
||||
static func resolveContainerNamespaces(_ containers: [XSDTree], mainTargetNamespace: String?) -> [Int: String?] {
|
||||
var resolved: [Int: String?] = [:]
|
||||
var resolvedIndices = Set<Int>()
|
||||
guard !containers.isEmpty else { return resolved }
|
||||
let mainIndex = containers.count - 1
|
||||
resolved[mainIndex] = mainTargetNamespace
|
||||
resolvedIndices.insert(mainIndex)
|
||||
for parentIndex in containers.indices.reversed() {
|
||||
guard let parentNamespace = resolved[parentIndex] else { continue }
|
||||
guard resolvedIndices.contains(parentIndex) else { continue }
|
||||
let parentNamespace = resolvedNamespace(at: parentIndex, in: resolved, fallback: mainTargetNamespace)
|
||||
let parent = containers[parentIndex]
|
||||
for child in PureXML.Schema.XSDNode.elementChildren(parent) {
|
||||
let kind = PureXML.Schema.XSDNode.localName(child)
|
||||
@ -251,32 +266,75 @@ extension PureXML.Schema.XSDParser {
|
||||
before: parentIndex,
|
||||
kind: childKind,
|
||||
namespaces: (parent: parentNamespace, expected: expectedAttrNamespace),
|
||||
resolved: resolved,
|
||||
resolvedIndices: resolvedIndices,
|
||||
)
|
||||
if let foundIndex {
|
||||
resolved[foundIndex] = targetNamespaceURI
|
||||
resolvedIndices.insert(foundIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
for index in containers.indices where resolved[index] == nil {
|
||||
for index in containers.indices where !resolvedIndices.contains(index) {
|
||||
let container = containers[index]
|
||||
if let attrNS = PureXML.Schema.XSDNode.attribute(container, "targetNamespace") {
|
||||
resolved[index] = attrNS
|
||||
} else {
|
||||
resolved[index] = mainTargetNamespace
|
||||
}
|
||||
resolvedIndices.insert(index)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
/// Schema-validity findings for `xs:include` when external schemas load: the
|
||||
/// included document must be chameleon (no `targetNamespace`) or declare the
|
||||
/// same target namespace as the includer (XSD Structures §4.2.3).
|
||||
static func includeCompositionErrors(
|
||||
_ containers: [XSDTree],
|
||||
mainTargetNamespace: String?,
|
||||
compositionLoaded: Bool,
|
||||
containerLocations: [ObjectIdentifier: String?],
|
||||
) -> [String] {
|
||||
guard compositionLoaded else { return [] }
|
||||
let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: mainTargetNamespace)
|
||||
var errors: [String] = []
|
||||
for (parentIndex, parent) in containers.enumerated() {
|
||||
guard PureXML.Schema.XSDNode.localName(parent) == "schema" else { continue }
|
||||
let parentNamespace = resolvedNamespace(at: parentIndex, in: namespaceMap, fallback: mainTargetNamespace)
|
||||
for child in PureXML.Schema.XSDNode.elementChildren(parent) {
|
||||
guard PureXML.Schema.XSDNode.localName(child) == "include",
|
||||
let location = PureXML.Schema.XSDNode.attribute(child, "schemaLocation")
|
||||
else { continue }
|
||||
guard let includedIndex = containers.firstIndex(where: {
|
||||
containerLocations[ObjectIdentifier($0)] == location
|
||||
}) else { continue }
|
||||
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)",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
private static func resolvedNamespace(at index: Int, in map: [Int: String?], fallback: String?) -> String? {
|
||||
switch map[index] {
|
||||
case nil: fallback
|
||||
case let .some(namespace): namespace
|
||||
}
|
||||
}
|
||||
|
||||
private static func findMatchingContainerIndex(
|
||||
in containers: [XSDTree],
|
||||
before parentIndex: Int,
|
||||
kind: String,
|
||||
namespaces: (parent: String?, expected: String?),
|
||||
resolved: [Int: String?],
|
||||
resolvedIndices: Set<Int>,
|
||||
) -> Int? {
|
||||
for index in (0 ..< parentIndex).reversed() where resolved[index] == nil {
|
||||
for index in (0 ..< parentIndex).reversed() where !resolvedIndices.contains(index) {
|
||||
let container = containers[index]
|
||||
let attrNS = PureXML.Schema.XSDNode.attribute(container, "targetNamespace")
|
||||
let isMatch: Bool = if kind == "import" {
|
||||
|
||||
66
Sources/Schema/SchemaReferenceForeignPools.swift
Normal file
66
Sources/Schema/SchemaReferenceForeignPools.swift
Normal file
@ -0,0 +1,66 @@
|
||||
extension PureXML.Schema.XSDParser {
|
||||
static func mergedNamespaceBindings(on node: XSDTree, inherited: [String: String]) -> [String: String] {
|
||||
var bindings = inherited
|
||||
for attribute in node.attributes {
|
||||
if attribute.name.prefix == "xmlns" {
|
||||
bindings[attribute.name.localName] = attribute.value
|
||||
} else if attribute.name.prefix == nil, attribute.name.localName == "xmlns" {
|
||||
bindings[""] = attribute.value
|
||||
}
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
/// Global component names in loaded containers outside the main target namespace.
|
||||
static func foreignComponentPools(
|
||||
_ containers: [XSDTree],
|
||||
mainTargetNamespace: String?,
|
||||
) -> [String?: [String: Set<String>]] {
|
||||
let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: mainTargetNamespace)
|
||||
var result: [String?: [String: Set<String>]] = [:]
|
||||
for index in containers.indices {
|
||||
let container = containers[index]
|
||||
guard PureXML.Schema.XSDNode.localName(container) != "redefine" else { continue }
|
||||
let namespaceURI = namespaceMap[index] ?? mainTargetNamespace
|
||||
if namespacesMatch(namespaceURI, mainTargetNamespace) { continue }
|
||||
var pools = result[namespaceURI, default: [:]]
|
||||
insertNamedGlobals(from: container, into: &pools)
|
||||
result[namespaceURI] = pools
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func insertNamedGlobals(from container: XSDTree, into pools: inout [String: Set<String>]) {
|
||||
insertGlobalElements(from: container, into: &pools)
|
||||
insertGlobalNames(child: "attribute", pool: "attribute", from: container, into: &pools)
|
||||
insertGlobalNames(child: "group", pool: "group", from: container, into: &pools)
|
||||
insertGlobalNames(child: "attributeGroup", pool: "attributeGroup", from: container, into: &pools)
|
||||
insertGlobalNames(child: "simpleType", pool: "type", from: container, into: &pools)
|
||||
insertGlobalNames(child: "complexType", pool: "type", from: container, into: &pools)
|
||||
}
|
||||
|
||||
private static func insertGlobalElements(from container: XSDTree, into pools: inout [String: Set<String>]) {
|
||||
for element in PureXML.Schema.XSDNode.children(container, named: "element") {
|
||||
guard PureXML.Schema.XSDNode.attribute(element, "ref") == nil,
|
||||
let name = PureXML.Schema.XSDNode.attribute(element, "name") else { continue }
|
||||
pools["element", default: []].insert(name)
|
||||
}
|
||||
}
|
||||
|
||||
private static func insertGlobalNames(
|
||||
child: String,
|
||||
pool: String,
|
||||
from container: XSDTree,
|
||||
into pools: inout [String: Set<String>],
|
||||
) {
|
||||
for node in PureXML.Schema.XSDNode.children(container, named: child) {
|
||||
if let name = PureXML.Schema.XSDNode.attribute(node, "name") {
|
||||
pools[pool, default: []].insert(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func namespacesMatch(_ lhs: String?, _ rhs: String?) -> Bool {
|
||||
lhs == rhs || ((lhs == nil || lhs == "") && (rhs == nil || rhs == ""))
|
||||
}
|
||||
}
|
||||
90
Sources/Schema/SchemaReferenceResolution.swift
Normal file
90
Sources/Schema/SchemaReferenceResolution.swift
Normal file
@ -0,0 +1,90 @@
|
||||
struct ReferenceCheckContext {
|
||||
let types: Set<String>
|
||||
let pools: [String: Set<String>]
|
||||
let bindings: [String: String]
|
||||
let targetNamespace: String?
|
||||
let foreignPools: [String?: [String: Set<String>]]
|
||||
/// A no-`targetNamespace` included schema whose globals adopt the includer's namespace.
|
||||
let chameleonNamespace: Bool
|
||||
}
|
||||
|
||||
extension PureXML.Schema.XSDParser {
|
||||
/// The built-in type names a reference may name without a declaration: the
|
||||
/// XSD Part 2 datatypes, the built-in list types, and the ur-types.
|
||||
static let referenceBuiltins: Set<String> = {
|
||||
var names = Set(PureXML.Schema.BuiltinType.allCases.map(\.rawValue))
|
||||
names.formUnion(["anyType", "anySimpleType", "anyAtomicType", "NOTATION", "IDREFS", "ENTITIES", "NMTOKENS"])
|
||||
return names
|
||||
}()
|
||||
|
||||
/// Namespaces commonly referenced without a loadable schema document (`xml:`,
|
||||
/// and similar). References into them stand down rather than being rejected.
|
||||
static let unloadedReferenceNamespaces: Set<String> = [
|
||||
"http://www.w3.org/XML/1998/namespace",
|
||||
]
|
||||
|
||||
static func referenceURI(for qname: String, bindings: [String: String]) -> String? {
|
||||
let prefix = PureXML.Schema.XSDNode.prefix(qname)
|
||||
var uri = prefix.flatMap { bindings[$0] } ?? bindings[""]
|
||||
if uri == nil, prefix == "xml" {
|
||||
uri = "http://www.w3.org/XML/1998/namespace"
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
static func isUndeclaredReferenceType(_ qname: String, in context: ReferenceCheckContext) -> Bool {
|
||||
let uri = referenceURI(for: qname, bindings: context.bindings)
|
||||
let localPart = referenceLocalName(qname)
|
||||
if uri == xsdNamespace {
|
||||
if context.targetNamespace == xsdNamespace {
|
||||
return !referenceBuiltins.contains(localPart) && !context.types.contains(localPart)
|
||||
} else {
|
||||
return !referenceBuiltins.contains(localPart)
|
||||
}
|
||||
}
|
||||
if let uri, unloadedReferenceNamespaces.contains(uri) {
|
||||
return false
|
||||
}
|
||||
let inTargetNamespace = uri == context.targetNamespace
|
||||
|| ((uri == nil || uri == "") && (context.targetNamespace == nil || context.targetNamespace == ""))
|
||||
if inTargetNamespace {
|
||||
return !context.types.contains(localPart)
|
||||
}
|
||||
if let foreignTypes = context.foreignPools[uri]?["type"] {
|
||||
return !foreignTypes.contains(localPart)
|
||||
}
|
||||
if uri == nil || uri == "" {
|
||||
return !context.types.contains(localPart)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func isUndeclaredReferenceRef(_ qname: String, poolName: String, in context: ReferenceCheckContext) -> Bool {
|
||||
let uri = referenceURI(for: qname, bindings: context.bindings)
|
||||
let localPart = referenceLocalName(qname)
|
||||
let inTargetNamespace = uri == context.targetNamespace
|
||||
|| ((uri == nil || uri == "") && (context.targetNamespace == nil || context.targetNamespace == ""))
|
||||
if inTargetNamespace {
|
||||
return context.pools[poolName]?.contains(localPart) != true
|
||||
}
|
||||
if let uri, unloadedReferenceNamespaces.contains(uri) {
|
||||
return false
|
||||
}
|
||||
if let foreign = context.foreignPools[uri]?[poolName] {
|
||||
return !foreign.contains(localPart)
|
||||
}
|
||||
if uri == nil || uri == "" {
|
||||
if context.chameleonNamespace, context.pools[poolName]?.contains(localPart) == true { return false }
|
||||
if !(context.targetNamespace == nil || context.targetNamespace == "") { return true }
|
||||
return context.pools[poolName]?.contains(localPart) != true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// The local part of a QName reference, after the whitespace normalization a
|
||||
/// `whiteSpace="collapse"` QName attribute receives (a value may be written
|
||||
/// with surrounding or, harmlessly, interior whitespace).
|
||||
static func referenceLocalName(_ qname: String) -> String {
|
||||
PureXML.Schema.XSDNode.stripPrefix(qname.trimmingXMLWhitespace())
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,24 @@
|
||||
extension PureXML.Schema.XSDParser {
|
||||
/// The built-in type names a reference may name without a declaration: the
|
||||
/// XSD Part 2 datatypes, the built-in list types, and the ur-types.
|
||||
private static let referenceBuiltins: Set<String> = {
|
||||
var names = Set(PureXML.Schema.BuiltinType.allCases.map(\.rawValue))
|
||||
names.formUnion(["anyType", "anySimpleType", "anyAtomicType", "NOTATION", "IDREFS", "ENTITIES", "NMTOKENS"])
|
||||
return names
|
||||
}()
|
||||
|
||||
private struct ResolutionContext {
|
||||
let types: Set<String>
|
||||
let pools: [String: Set<String>]
|
||||
let bindings: [String: String]
|
||||
let targetNamespace: String?
|
||||
let check: ReferenceCheckContext
|
||||
|
||||
init(
|
||||
types: Set<String>,
|
||||
pools: [String: Set<String>],
|
||||
bindings: [String: String],
|
||||
targetNamespace: String?,
|
||||
foreignPools: [String?: [String: Set<String>]],
|
||||
chameleonNamespace: Bool,
|
||||
) {
|
||||
check = ReferenceCheckContext(
|
||||
types: types,
|
||||
pools: pools,
|
||||
bindings: bindings,
|
||||
targetNamespace: targetNamespace,
|
||||
foreignPools: foreignPools,
|
||||
chameleonNamespace: chameleonNamespace,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema-validity findings for unresolvable references: every QName a schema
|
||||
@ -23,10 +30,17 @@ extension PureXML.Schema.XSDParser {
|
||||
/// Skipped when the document pulls in external definitions through
|
||||
/// `import`/`include`/`redefine`: the default compile does not load them, so
|
||||
/// the pools would be incomplete and a reference into them must not be flagged.
|
||||
static func referenceErrors(_ schema: XSDTree, in context: PureXML.Schema.XSDContext, elements: [String: PureXML.Schema.ElementType]) -> [String] {
|
||||
static func referenceErrors(
|
||||
_ 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)
|
||||
if hasExternalReference(schema) { return xsdErrors + simpleContentErrors }
|
||||
if skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) {
|
||||
return xsdErrors + simpleContentErrors
|
||||
}
|
||||
let types = referenceBuiltins.union(context.simpleTypes.keys).union(context.complexTypeNodes.keys)
|
||||
let pools: [String: Set<String>] = [
|
||||
"element": Set(elements.keys),
|
||||
@ -34,14 +48,46 @@ extension PureXML.Schema.XSDParser {
|
||||
"group": Set(context.groups.keys),
|
||||
"attributeGroup": Set(context.attributeGroups.keys),
|
||||
]
|
||||
let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema)
|
||||
let targetNamespace = context.targetNamespace
|
||||
let foreignPools = context.compositionLoaded
|
||||
? foreignComponentPools(containers, mainTargetNamespace: targetNamespace)
|
||||
: [:]
|
||||
var errors: [String] = []
|
||||
let resolutionContext = ResolutionContext(types: types, pools: pools, bindings: bindings, targetNamespace: targetNamespace)
|
||||
collectReferenceErrors(schema, in: resolutionContext, into: &errors)
|
||||
let referenceSources: [(XSDTree, String?)]
|
||||
if context.compositionLoaded {
|
||||
let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: targetNamespace)
|
||||
referenceSources = containers.compactMap { container -> (XSDTree, String?)? in
|
||||
guard PureXML.Schema.XSDNode.localName(container) == "schema" else { return nil }
|
||||
let index = containers.firstIndex(where: { $0 === container }) ?? containers.count - 1
|
||||
let effectiveNamespace = namespaceMap[index] ?? targetNamespace
|
||||
return (container, effectiveNamespace)
|
||||
}
|
||||
} else {
|
||||
referenceSources = [(schema, targetNamespace)]
|
||||
}
|
||||
for (source, effectiveNamespace) in referenceSources {
|
||||
let sourceBindings = PureXML.Schema.XSDNode.namespaceBindings(of: source)
|
||||
let chameleonNamespace = PureXML.Schema.XSDNode.attribute(source, "targetNamespace") == nil
|
||||
let resolutionContext = ResolutionContext(
|
||||
types: types,
|
||||
pools: pools,
|
||||
bindings: sourceBindings,
|
||||
targetNamespace: effectiveNamespace,
|
||||
foreignPools: foreignPools,
|
||||
chameleonNamespace: chameleonNamespace,
|
||||
)
|
||||
collectReferenceErrors(source, in: resolutionContext, inheritedBindings: sourceBindings, into: &errors)
|
||||
}
|
||||
return xsdErrors + simpleContentErrors + errors
|
||||
}
|
||||
|
||||
/// Whether cross-document schema-validity rules should stand down: an external
|
||||
/// reference is declared but no external document was loaded through a
|
||||
/// `schemaLoader`, so the merged component set is incomplete.
|
||||
static func skipsCrossDocumentRules(_ schema: XSDTree, compositionLoaded: Bool) -> Bool {
|
||||
hasExternalReference(schema) && !compositionLoaded
|
||||
}
|
||||
|
||||
/// Whether the document declares an `import`, `include`, or `redefine`, so its
|
||||
/// component pools may be completed by an external document the default compile
|
||||
/// does not load.
|
||||
@ -72,15 +118,25 @@ extension PureXML.Schema.XSDParser {
|
||||
private static func collectReferenceErrors(
|
||||
_ node: XSDTree,
|
||||
in resolutionContext: ResolutionContext,
|
||||
inheritedBindings: [String: String],
|
||||
into errors: inout [String],
|
||||
) {
|
||||
let local = PureXML.Schema.XSDNode.localName(node)
|
||||
if local == "appinfo" || local == "documentation" { return }
|
||||
let bindings = mergedNamespaceBindings(on: node, inherited: inheritedBindings)
|
||||
let nodeContext = ResolutionContext(
|
||||
types: resolutionContext.check.types,
|
||||
pools: resolutionContext.check.pools,
|
||||
bindings: bindings,
|
||||
targetNamespace: resolutionContext.check.targetNamespace,
|
||||
foreignPools: resolutionContext.check.foreignPools,
|
||||
chameleonNamespace: resolutionContext.check.chameleonNamespace,
|
||||
)
|
||||
if node.name?.namespaceURI == xsdNamespace, let local {
|
||||
errors += referenceErrors(at: node, local: local, in: resolutionContext)
|
||||
errors += referenceErrors(at: node, local: local, in: nodeContext)
|
||||
}
|
||||
for child in PureXML.Schema.XSDNode.elementChildren(node) {
|
||||
collectReferenceErrors(child, in: resolutionContext, into: &errors)
|
||||
collectReferenceErrors(child, in: resolutionContext, inheritedBindings: bindings, into: &errors)
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,67 +146,34 @@ extension PureXML.Schema.XSDParser {
|
||||
in resolutionContext: ResolutionContext,
|
||||
) -> [String] {
|
||||
var errors: [String] = []
|
||||
|
||||
func isUndeclaredType(_ qname: String) -> Bool {
|
||||
let prefix = PureXML.Schema.XSDNode.prefix(qname)
|
||||
let uri = prefix.flatMap { resolutionContext.bindings[$0] } ?? resolutionContext.bindings[""]
|
||||
let localPart = localName(qname)
|
||||
if uri == xsdNamespace {
|
||||
if resolutionContext.targetNamespace == xsdNamespace {
|
||||
return !referenceBuiltins.contains(localPart) && !resolutionContext.types.contains(localPart)
|
||||
} else {
|
||||
return !referenceBuiltins.contains(localPart)
|
||||
}
|
||||
}
|
||||
let target = resolutionContext.targetNamespace
|
||||
if uri == target || ((uri == nil || uri == "") && (target == nil || target == "")) {
|
||||
return !resolutionContext.types.contains(localPart)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isUndeclaredRef(_ qname: String, _ poolName: String) -> Bool {
|
||||
let prefix = PureXML.Schema.XSDNode.prefix(qname)
|
||||
let uri = prefix.flatMap { resolutionContext.bindings[$0] } ?? resolutionContext.bindings[""]
|
||||
let localPart = localName(qname)
|
||||
let target = resolutionContext.targetNamespace
|
||||
if uri == target || ((uri == nil || uri == "") && (target == nil || target == "")) {
|
||||
return resolutionContext.pools[poolName]?.contains(localPart) != true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
let context = resolutionContext.check
|
||||
for attribute in ["type", "base", "itemType"] {
|
||||
if let qname = PureXML.Schema.XSDNode.attribute(node, attribute), isUndeclaredType(qname) {
|
||||
guard let qname = PureXML.Schema.XSDNode.attribute(node, attribute) else { continue }
|
||||
if isUndeclaredReferenceType(qname, in: context) {
|
||||
errors.append("\(attribute) references undeclared type '\(qname)'")
|
||||
}
|
||||
}
|
||||
if let members = PureXML.Schema.XSDNode.attribute(node, "memberTypes") {
|
||||
for token in members.split(whereSeparator: \.isWhitespace) {
|
||||
let qname = String(token)
|
||||
if isUndeclaredType(qname) {
|
||||
if isUndeclaredReferenceType(qname, in: context) {
|
||||
errors.append("memberTypes references undeclared type '\(qname)'")
|
||||
}
|
||||
}
|
||||
}
|
||||
if let head = PureXML.Schema.XSDNode.attribute(node, "substitutionGroup"), isUndeclaredRef(head, "element") {
|
||||
errors.append("substitutionGroup references undeclared element '\(head)'")
|
||||
if let head = PureXML.Schema.XSDNode.attribute(node, "substitutionGroup") {
|
||||
if isUndeclaredReferenceRef(head, poolName: "element", in: context) {
|
||||
errors.append("substitutionGroup references undeclared element '\(head)'")
|
||||
}
|
||||
}
|
||||
if let reference = PureXML.Schema.XSDNode.attribute(node, "ref"), resolutionContext.pools[local] != nil {
|
||||
if isUndeclaredRef(reference, local) {
|
||||
if let reference = PureXML.Schema.XSDNode.attribute(node, "ref"), context.pools[local] != nil {
|
||||
if isUndeclaredReferenceRef(reference, poolName: local, in: context) {
|
||||
errors.append("\(local) ref references undeclared '\(reference)'")
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/// The local part of a QName reference, after the whitespace normalization a
|
||||
/// `whiteSpace="collapse"` QName attribute receives (a value may be written
|
||||
/// with surrounding or, harmlessly, interior whitespace).
|
||||
private static func localName(_ qname: String) -> String {
|
||||
PureXML.Schema.XSDNode.stripPrefix(qname.trimmingXMLWhitespace())
|
||||
}
|
||||
|
||||
static func simpleTypeBaseNotComplexErrors(_ schema: XSDTree, in context: PureXML.Schema.XSDContext) -> [String] {
|
||||
var errors: [String] = []
|
||||
let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema)
|
||||
|
||||
@ -14,9 +14,9 @@ extension PureXML.Schema.XSDParser {
|
||||
/// where the single target namespace makes the names resolved by local name
|
||||
/// unambiguous; with external definitions the check stands down (a disclosed
|
||||
/// under-rejection).
|
||||
static func simpleTypeFinalErrors(_ schema: XSDTree) -> [String] {
|
||||
guard !hasExternalReference(schema) else { return [] }
|
||||
let finalOf = simpleTypeFinalMap(schema)
|
||||
static func simpleTypeFinalErrors(_ schema: XSDTree, compositionLoaded: Bool, containers: [XSDTree]) -> [String] {
|
||||
guard !skipsCrossDocumentRules(schema, compositionLoaded: compositionLoaded) else { return [] }
|
||||
let finalOf = compositionLoaded ? mergedSimpleTypeFinalMap(containers) : simpleTypeFinalMap(schema)
|
||||
guard finalOf.values.contains(where: { !$0.isEmpty }) else { return [] }
|
||||
// The `final` map is keyed by local name; an `itemType`/`memberTypes`
|
||||
// reference must resolve to this schema's own target namespace before it is
|
||||
@ -28,6 +28,16 @@ extension PureXML.Schema.XSDParser {
|
||||
+ unionMemberFinalErrors(schema, finalOf, bindings, target)
|
||||
}
|
||||
|
||||
private static func mergedSimpleTypeFinalMap(_ containers: [XSDTree]) -> [String: Set<String>] {
|
||||
var finalOf: [String: Set<String>] = [:]
|
||||
for container in containers where FinalNode.localName(container) != "redefine" {
|
||||
for (name, tokens) in simpleTypeFinalMap(container) {
|
||||
finalOf[name] = tokens
|
||||
}
|
||||
}
|
||||
return finalOf
|
||||
}
|
||||
|
||||
/// Each named simple type mapped to the derivation tokens its `final` forbids.
|
||||
private static func simpleTypeFinalMap(_ schema: XSDTree) -> [String: Set<String>] {
|
||||
var finalOf: [String: Set<String>] = [:]
|
||||
|
||||
@ -21,25 +21,52 @@ extension PureXML.Schema.XSDParser {
|
||||
/// 2.3 and 2.4: a type derives from a union if it derives from one of the
|
||||
/// union's member types). When either type is a list or union the check stands
|
||||
/// down rather than reject a valid member, a disclosed under-rejection.
|
||||
static func substitutionTypeErrors(_ schema: XSDTree, _ tables: DerivationTables, _ types: [String: PureXML.Schema.ElementType]) -> [String] {
|
||||
guard !hasExternalDocuments(schema) else { return [] }
|
||||
let globals = SubTypeNode.elementChildren(schema).filter {
|
||||
SubTypeNode.localName($0) == "element" && $0.name?.namespaceURI == xsdNamespace
|
||||
}
|
||||
var globalElementType: [String: String] = [:]
|
||||
for global in globals {
|
||||
if let name = SubTypeNode.attribute(global, "name"), let type = SubTypeNode.attribute(global, "type") {
|
||||
globalElementType[name] = SubTypeNode.stripPrefix(type)
|
||||
static func substitutionTypeErrors(
|
||||
_ schema: XSDTree,
|
||||
_ containers: [XSDTree],
|
||||
_ tables: DerivationTables,
|
||||
_ types: [String: PureXML.Schema.ElementType],
|
||||
_ context: PureXML.Schema.XSDContext,
|
||||
) -> [String] {
|
||||
guard !skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) else { return [] }
|
||||
let sources = context.compositionLoaded ? containers : [schema]
|
||||
let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: context.targetNamespace)
|
||||
var globalElementType: [GlobalElementKey: String] = [:]
|
||||
for index in sources.indices {
|
||||
let source = sources[index]
|
||||
guard SubTypeNode.localName(source) != "redefine" else { continue }
|
||||
let namespaceURI = context.compositionLoaded
|
||||
? (namespaceMap[index] ?? context.targetNamespace)
|
||||
: context.targetNamespace
|
||||
for global in SubTypeNode.elementChildren(source).filter({
|
||||
SubTypeNode.localName($0) == "element" && $0.name?.namespaceURI == xsdNamespace
|
||||
}) {
|
||||
if let name = SubTypeNode.attribute(global, "name"), let type = SubTypeNode.attribute(global, "type") {
|
||||
globalElementType[GlobalElementKey(namespaceURI: namespaceURI, name: name)] = SubTypeNode.stripPrefix(type)
|
||||
}
|
||||
}
|
||||
}
|
||||
return globals.flatMap { element in
|
||||
checkSingleElement(element, globalElementType, tables, types)
|
||||
return sources.indices.flatMap { index -> [String] in
|
||||
let source = sources[index]
|
||||
guard SubTypeNode.localName(source) != "redefine" else { return [] }
|
||||
let bindings = SubTypeNode.namespaceBindings(of: source)
|
||||
return SubTypeNode.elementChildren(source).filter {
|
||||
SubTypeNode.localName($0) == "element" && $0.name?.namespaceURI == xsdNamespace
|
||||
}.flatMap { element in
|
||||
checkSingleElement(element, bindings, globalElementType, tables, types)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GlobalElementKey: Hashable {
|
||||
let namespaceURI: String?
|
||||
let name: String
|
||||
}
|
||||
|
||||
private static func checkSingleElement(
|
||||
_ element: XSDTree,
|
||||
_ globalElementType: [String: String],
|
||||
_ bindings: [String: String],
|
||||
_ globalElementType: [GlobalElementKey: String],
|
||||
_ tables: DerivationTables,
|
||||
_ types: [String: PureXML.Schema.ElementType],
|
||||
) -> [String] {
|
||||
@ -47,7 +74,9 @@ extension PureXML.Schema.XSDParser {
|
||||
let headReference = SubTypeNode.attribute(element, "substitutionGroup")
|
||||
else { return [] }
|
||||
let head = SubTypeNode.stripPrefix(headReference)
|
||||
guard let headType = globalElementType[head] else { return [] }
|
||||
let headNamespace = SubTypeNode.referenceNamespace(headReference, bindings)
|
||||
let headKey = GlobalElementKey(namespaceURI: headNamespace, name: head)
|
||||
guard let headType = globalElementType[headKey] else { return [] }
|
||||
if isInlineListOrUnion(element) { return [] }
|
||||
let inlineDeriv = inlineTypeDerivation(element)
|
||||
let isDerived: Bool
|
||||
@ -194,13 +223,4 @@ extension PureXML.Schema.XSDParser {
|
||||
case .list, .union: return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the schema document composes other documents, so a referenced
|
||||
/// definition may live outside the loaded derivation table.
|
||||
private static func hasExternalDocuments(_ schema: XSDTree) -> Bool {
|
||||
SubTypeNode.elementChildren(schema).contains { child in
|
||||
let kind = SubTypeNode.localName(child)
|
||||
return kind == "import" || kind == "include" || kind == "redefine"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,8 +8,9 @@ public extension PureXML.Validation {
|
||||
/// validity is checked when that child closes.
|
||||
///
|
||||
/// Type resolution covers the global root declaration, the parent content
|
||||
/// model, `xsi:type` overrides, and `typeReference` chains. Wildcard
|
||||
/// `processContents` and substitution groups stay on the tree validator.
|
||||
/// model, wildcard `processContents`, `xsi:type` overrides, and
|
||||
/// `typeReference` chains. Document-scoped identity constraints are checked
|
||||
/// at the end of a streaming run when the caller supplies the parsed tree.
|
||||
struct StreamingXSDValidator {
|
||||
private let validator: PureXML.Schema.ComplexValidator
|
||||
private let rootElements: [String: PureXML.Schema.ElementType]
|
||||
@ -90,7 +91,14 @@ public extension PureXML.Validation {
|
||||
/// element has no type to validate against.
|
||||
private mutating func resolveDeclared(_ name: PureXML.Model.QualifiedName, path: [PathKey]) -> PureXML.Schema.ElementType? {
|
||||
guard stack.isEmpty else {
|
||||
return stack.last?.effective.flatMap { validator.childType(of: $0, child: name) }
|
||||
guard let parent = stack.last?.effective else { return nil }
|
||||
if let declared = validator.childType(of: parent, child: name) {
|
||||
return declared
|
||||
}
|
||||
if let error = validator.strictWildcardError(for: name, in: parent, at: path) {
|
||||
collected.append(error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
sawRoot = true
|
||||
guard let declaration = rootElements[name.localName] else {
|
||||
|
||||
@ -20,6 +20,24 @@ extension PureXML.Schema.XSDParser {
|
||||
return uses
|
||||
}
|
||||
|
||||
/// Prefix bindings in effect on `node`: the enclosing schema's `xmlns` declarations
|
||||
/// merged along the ancestor path (used for `ref` QNames inside included documents).
|
||||
static func namespaceBindingsInScope(of node: XSDTree, defaultBindings: [String: String]) -> [String: String] {
|
||||
var path: [XSDTree] = []
|
||||
var current: XSDTree? = node
|
||||
while let currentNode = current {
|
||||
path.append(currentNode)
|
||||
current = currentNode.parent
|
||||
}
|
||||
path.reverse()
|
||||
let schemaRoot = path.first { PureXML.Schema.XSDNode.localName($0) == "schema" }
|
||||
var bindings = schemaRoot.map { PureXML.Schema.XSDNode.namespaceBindings(of: $0) } ?? defaultBindings
|
||||
for ancestor in path {
|
||||
bindings = mergedNamespaceBindings(on: ancestor, inherited: bindings)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
static func attributeUse(_ node: XSDTree, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.AttributeUse? {
|
||||
// An `<attribute ref="...">` references a global attribute declaration:
|
||||
// take its name (always target-namespace qualified) and type from the
|
||||
@ -28,7 +46,8 @@ extension PureXML.Schema.XSDParser {
|
||||
let refName = PureXML.Schema.XSDNode.stripPrefix(ref)
|
||||
guard let declaration = context.globalAttributes[refName],
|
||||
var use = attributeUse(declaration, context) else { return nil }
|
||||
let namespace = PureXML.Schema.XSDNode.referenceNamespace(ref, context.namespaceBindings)
|
||||
let bindings = namespaceBindingsInScope(of: node, defaultBindings: context.namespaceBindings)
|
||||
let namespace = PureXML.Schema.XSDNode.referenceNamespace(ref, bindings)
|
||||
use.name = PureXML.Model.QualifiedName(localName: refName, namespaceURI: namespace)
|
||||
if PureXML.Schema.XSDNode.attribute(node, "use") == "required" { use.required = true }
|
||||
if let constraint = valueConstraint(of: node) { use.valueConstraint = constraint }
|
||||
|
||||
@ -100,6 +100,14 @@ extension PureXML.Schema {
|
||||
return containers
|
||||
}
|
||||
|
||||
/// True when at least one external schema document was loaded (not merely
|
||||
/// referenced or a `redefine` wrapper without its included target).
|
||||
static func compositionLoaded(from containerTuples: [(location: String?, tree: XSDTree)]) -> Bool {
|
||||
containerTuples.contains { tuple in
|
||||
tuple.location != nil && localName(tuple.tree) == "schema"
|
||||
}
|
||||
}
|
||||
|
||||
/// The transitive substitution-group membership across all definition
|
||||
/// containers: each head element maps to every element that may substitute
|
||||
/// for it, directly or through a chain of heads.
|
||||
@ -231,6 +239,10 @@ extension PureXML.Schema {
|
||||
/// Named complex types being resolved up the current `complexContent`
|
||||
/// derivation chain, a guard against a cyclic base reference.
|
||||
var visitingTypes: Set<String> = []
|
||||
/// Whether `include`/`import`/`redefine` targets were loaded through a
|
||||
/// `schemaLoader`, so cross-document rules may run over the merged component
|
||||
/// set instead of standing down.
|
||||
var compositionLoaded: Bool = false
|
||||
/// The location of each container schema document.
|
||||
var containerLocations: [ObjectIdentifier: String?] = [:]
|
||||
/// The shared schema-validity finding collector. A reference type, so the
|
||||
|
||||
@ -41,24 +41,30 @@ extension PureXML.Schema {
|
||||
let derivation = derivationTables(containers)
|
||||
try checkRedefine(containers)
|
||||
try checkAllGroups(containers)
|
||||
var containerLocations: [ObjectIdentifier: String?] = [:]
|
||||
for (loc, tree) in containerTuples {
|
||||
let actualLoc = loc ?? rootLocation
|
||||
containerLocations[ObjectIdentifier(tree)] = actualLoc
|
||||
}
|
||||
let containerLocations = containerLocationMap(containerTuples, rootLocation: rootLocation)
|
||||
let compositionLoaded = XSDNode.compositionLoaded(from: containerTuples)
|
||||
var context = createContext(
|
||||
schema: schema,
|
||||
containers: containers,
|
||||
derivation: derivation,
|
||||
containerLocations: containerLocations,
|
||||
compositionLoaded: compositionLoaded,
|
||||
)
|
||||
for error in consistencyErrors(schema, context, containers) {
|
||||
context.diagnostics.report(error)
|
||||
for error in PureXML.Validation.SchemaCompile.preCompileErrors(schema: schema, context: context, containers: containers) {
|
||||
context.diagnostics.report(error.reason)
|
||||
}
|
||||
var types = namedTypes(containers, into: &context)
|
||||
let elements = globalElements(containers, context, into: &types)
|
||||
for error in postNamedTypeErrors(schema, context, containers, derivation, typeMaps: (elements, types)) {
|
||||
context.diagnostics.report(error)
|
||||
let postCompileDocument = PureXML.Schema.SchemaCompileContext(
|
||||
schema: schema,
|
||||
context: context,
|
||||
containers: containers,
|
||||
derivation: derivation,
|
||||
globalElements: elements,
|
||||
namedTypes: types,
|
||||
)
|
||||
for error in PureXML.Validation.SchemaCompile.postCompileErrors(in: postCompileDocument) {
|
||||
context.diagnostics.report(error.reason)
|
||||
}
|
||||
return buildCompiled(
|
||||
elements: elements,
|
||||
@ -72,6 +78,17 @@ extension PureXML.Schema {
|
||||
}
|
||||
|
||||
extension PureXML.Schema.XSDParser {
|
||||
private static func containerLocationMap(
|
||||
_ containerTuples: [(location: String?, tree: XSDTree)],
|
||||
rootLocation: String?,
|
||||
) -> [ObjectIdentifier: String?] {
|
||||
var containerLocations: [ObjectIdentifier: String?] = [:]
|
||||
for (loc, tree) in containerTuples {
|
||||
containerLocations[ObjectIdentifier(tree)] = loc ?? rootLocation
|
||||
}
|
||||
return containerLocations
|
||||
}
|
||||
|
||||
private static func buildCompiled(
|
||||
elements: [String: ElementType],
|
||||
types: [String: ElementType],
|
||||
@ -102,6 +119,7 @@ extension PureXML.Schema.XSDParser {
|
||||
containers: [XSDTree],
|
||||
derivation: DerivationTables,
|
||||
containerLocations: [ObjectIdentifier: String?],
|
||||
compositionLoaded: Bool,
|
||||
) -> XSDContext {
|
||||
var context = XSDContext(
|
||||
simpleTypes: [:],
|
||||
@ -110,6 +128,7 @@ extension PureXML.Schema.XSDParser {
|
||||
targetNamespace: XSDNode.attribute(schema, "targetNamespace"),
|
||||
substitutions: filterSubstitutions(XSDNode.substitutionMembers(containers), derivation),
|
||||
abstractElements: derivation.abstractElements,
|
||||
compositionLoaded: compositionLoaded,
|
||||
containerLocations: containerLocations,
|
||||
)
|
||||
context.elementFormQualified = XSDNode.attribute(schema, "elementFormDefault") == "qualified"
|
||||
|
||||
@ -71,10 +71,22 @@ public extension PureXML.Validation {
|
||||
typeFinal: [String: Set<PureXML.Schema.DerivationMethod>],
|
||||
) -> [ValidationError] {
|
||||
let facts = PureXML.Schema.CompiledSchemaFacts(types: types, typeDerivation: typeDerivation, typeFinal: typeFinal)
|
||||
let rules = [finalRespected, restrictionsAreSubsets]
|
||||
return typeDerivation.keys.sorted().flatMap { name -> [ValidationError] in
|
||||
let fact = PureXML.Schema.SchemaTypeFact(name: name, derivation: typeDerivation[name])
|
||||
return rules.flatMap { $0.apply(to: fact, at: [.element(name)], in: facts) }
|
||||
return namedTypeErrors(in: facts)
|
||||
}
|
||||
|
||||
/// The default schema-consistency validator over named types.
|
||||
static func validator() -> Validator<PureXML.Schema.CompiledSchemaFacts> {
|
||||
Validator<PureXML.Schema.CompiledSchemaFacts>.defaults(
|
||||
nonReference: [AnyValidation(finalRespected)],
|
||||
reference: [AnyValidation(restrictionsAreSubsets)],
|
||||
)
|
||||
}
|
||||
|
||||
/// Applies every named-type consistency rule to `facts`.
|
||||
static func namedTypeErrors(in facts: PureXML.Schema.CompiledSchemaFacts) -> [ValidationError] {
|
||||
facts.typeDerivation.keys.sorted().flatMap { name -> [ValidationError] in
|
||||
let fact = PureXML.Schema.SchemaTypeFact(name: name, derivation: facts.typeDerivation[name])
|
||||
return validator().errors(for: fact, at: [.element(name)], in: facts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -97,7 +97,10 @@ public extension PureXML.Validation {
|
||||
|
||||
/// A validator combining the content-model and identity-constraint rules.
|
||||
static func validator() -> Validator<XSDContext> {
|
||||
Validator<XSDContext>.blank.validating(contentValidity, identityConstraints)
|
||||
Validator<XSDContext>.defaults(
|
||||
nonReference: [AnyValidation(contentValidity)],
|
||||
reference: [AnyValidation(identityConstraints)],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
174
Sources/Validation/BuiltinValidation.swift
Normal file
174
Sources/Validation/BuiltinValidation.swift
Normal file
@ -0,0 +1,174 @@
|
||||
public extension PureXML.Validation {
|
||||
/// The shipped builtin validation rules, keyed by stable description strings.
|
||||
/// Reference rules through KeyPath at `BuiltinValidation.Type` so they can be
|
||||
/// added or removed by identity (`withoutValidating`).
|
||||
enum BuiltinValidation {
|
||||
// MARK: Structural
|
||||
|
||||
public static var uniqueAttributes: Validation<PureXML.Model.Element, Void> {
|
||||
Structural.uniqueAttributes
|
||||
}
|
||||
|
||||
// MARK: DTD
|
||||
|
||||
public static var dtdContentModel: Validation<PureXML.Model.Element, DTDSchema> {
|
||||
DTD.contentModel
|
||||
}
|
||||
|
||||
public static var dtdRequiredAttributes: Validation<PureXML.Model.Element, DTDSchema> {
|
||||
DTD.requiredAttributes
|
||||
}
|
||||
|
||||
public static var dtdFixedAttributeValues: Validation<PureXML.Model.Element, DTDSchema> {
|
||||
DTD.fixedAttributeValues
|
||||
}
|
||||
|
||||
public static var dtdEnumeratedAttributeValues: Validation<PureXML.Model.Element, DTDSchema> {
|
||||
DTD.enumeratedAttributeValues
|
||||
}
|
||||
|
||||
public static var dtdTokenizedAttributeTypes: Validation<PureXML.Model.Element, DTDSchema> {
|
||||
DTD.tokenizedAttributeTypes
|
||||
}
|
||||
|
||||
public static var dtdNotationAttributes: Validation<PureXML.Model.Element, DTDSchema> {
|
||||
DTD.notationAttributes
|
||||
}
|
||||
|
||||
public static var dtdUndeclaredElement: Validation<PureXML.Model.Element, DTDSchema> {
|
||||
DTD.undeclaredElement
|
||||
}
|
||||
|
||||
public static var dtdUndeclaredAttributes: Validation<PureXML.Model.Element, DTDSchema> {
|
||||
DTD.undeclaredAttributes
|
||||
}
|
||||
|
||||
public static var dtdDeclarationValidity: Validation<PureXML.Model.Node, DTDSchema> {
|
||||
DTD.declarationValidity
|
||||
}
|
||||
|
||||
public static var dtdRootElementType: Validation<PureXML.Model.Node, DTDSchema> {
|
||||
DTD.rootElementType
|
||||
}
|
||||
|
||||
public static var dtdIdentifierIntegrity: Validation<PureXML.Model.Node, DTDSchema> {
|
||||
DTD.identifierIntegrity
|
||||
}
|
||||
|
||||
public static var dtdStandaloneAttributes: Validation<PureXML.Model.Element, DTDSchema> {
|
||||
DTD.standaloneAttributes
|
||||
}
|
||||
|
||||
public static var dtdStandaloneElementWhitespace: Validation<PureXML.Model.Element, DTDSchema> {
|
||||
DTD.standaloneElementWhitespace
|
||||
}
|
||||
|
||||
public static var dtdParseAdvisories: Validation<PureXML.Model.Node, DTDSchema> {
|
||||
DTD.parseAdvisories
|
||||
}
|
||||
|
||||
// MARK: HTML
|
||||
|
||||
public static var htmlVoidElementsAreEmpty: Validation<PureXML.Model.Element, Void> {
|
||||
HTML.voidElementsAreEmpty
|
||||
}
|
||||
|
||||
public static var htmlRequiredParent: Validation<PureXML.Model.Element, Void> {
|
||||
HTML.requiredParent
|
||||
}
|
||||
|
||||
public static var htmlUniqueIdentifiers: Validation<PureXML.Model.Node, Void> {
|
||||
HTML.uniqueIdentifiers
|
||||
}
|
||||
|
||||
// MARK: XSD instance
|
||||
|
||||
public static var xsdContentValidity: Validation<PureXML.Model.Node, XSDContext> {
|
||||
XSD.contentValidity
|
||||
}
|
||||
|
||||
public static var xsdIdentityConstraints: Validation<PureXML.Model.Node, XSDContext> {
|
||||
XSD.identityConstraints
|
||||
}
|
||||
|
||||
// MARK: XSD schema consistency
|
||||
|
||||
public static var xsdFinalRespected: Validation<PureXML.Schema.SchemaTypeFact, PureXML.Schema.CompiledSchemaFacts> {
|
||||
PureXML.Validation.XSDSchema.finalRespected
|
||||
}
|
||||
|
||||
public static var xsdRestrictionsAreSubsets: Validation<PureXML.Schema.SchemaTypeFact, PureXML.Schema.CompiledSchemaFacts> {
|
||||
PureXML.Validation.XSDSchema.restrictionsAreSubsets
|
||||
}
|
||||
|
||||
// MARK: Conformance
|
||||
|
||||
public static var conformanceMatchesExpected: Validation<ConformanceCase, Void> {
|
||||
Conformance.matchesExpected
|
||||
}
|
||||
|
||||
// MARK: XSD streaming
|
||||
|
||||
public static var xsdStreamingShallowValidity: Validation<PureXML.Schema.ResolvedElement, PureXML.Schema.ComplexValidator> {
|
||||
PureXML.Schema.ComplexValidator.shallowValidity
|
||||
}
|
||||
|
||||
/// Stable ids for each shipped builtin rule (matches ``BuiltinValidation`` properties).
|
||||
public static let allRuleIDs: [String] = [
|
||||
"uniqueAttributes",
|
||||
"dtdContentModel",
|
||||
"dtdRequiredAttributes",
|
||||
"dtdFixedAttributeValues",
|
||||
"dtdEnumeratedAttributeValues",
|
||||
"dtdTokenizedAttributeTypes",
|
||||
"dtdNotationAttributes",
|
||||
"dtdUndeclaredElement",
|
||||
"dtdUndeclaredAttributes",
|
||||
"dtdDeclarationValidity",
|
||||
"dtdRootElementType",
|
||||
"dtdIdentifierIntegrity",
|
||||
"dtdStandaloneAttributes",
|
||||
"dtdStandaloneElementWhitespace",
|
||||
"dtdParseAdvisories",
|
||||
"htmlVoidElementsAreEmpty",
|
||||
"htmlRequiredParent",
|
||||
"htmlUniqueIdentifiers",
|
||||
"xsdContentValidity",
|
||||
"xsdIdentityConstraints",
|
||||
"xsdFinalRespected",
|
||||
"xsdRestrictionsAreSubsets",
|
||||
"conformanceMatchesExpected",
|
||||
"xsdStreamingShallowValidity",
|
||||
]
|
||||
|
||||
/// Every builtin rule description, in deterministic order, for configuration-pin tests.
|
||||
public static var allDescriptions: [String] {
|
||||
[
|
||||
uniqueAttributes.description,
|
||||
dtdContentModel.description,
|
||||
dtdRequiredAttributes.description,
|
||||
dtdFixedAttributeValues.description,
|
||||
dtdEnumeratedAttributeValues.description,
|
||||
dtdTokenizedAttributeTypes.description,
|
||||
dtdNotationAttributes.description,
|
||||
dtdUndeclaredElement.description,
|
||||
dtdUndeclaredAttributes.description,
|
||||
dtdDeclarationValidity.description,
|
||||
dtdRootElementType.description,
|
||||
dtdIdentifierIntegrity.description,
|
||||
dtdStandaloneAttributes.description,
|
||||
dtdStandaloneElementWhitespace.description,
|
||||
dtdParseAdvisories.description,
|
||||
htmlVoidElementsAreEmpty.description,
|
||||
htmlRequiredParent.description,
|
||||
htmlUniqueIdentifiers.description,
|
||||
xsdContentValidity.description,
|
||||
xsdIdentityConstraints.description,
|
||||
xsdFinalRespected.description,
|
||||
xsdRestrictionsAreSubsets.description,
|
||||
conformanceMatchesExpected.description,
|
||||
xsdStreamingShallowValidity.description,
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,9 @@ public extension PureXML.Validation {
|
||||
/// The `<!DOCTYPE name ...>` name the root element must match
|
||||
/// (VC: Root Element Type).
|
||||
let doctypeName: String?
|
||||
/// Parse-time DTD validity findings (entity/PE references, group nesting),
|
||||
/// collected as advisories separate from declaration hard errors.
|
||||
let parseAdvisories: [PureXML.Validation.ValidationError]
|
||||
/// Validity findings about the declarations themselves (duplicate
|
||||
/// element types, repeated mixed-content names, multiple ID attributes,
|
||||
/// undeclared notations in NOTATION lists, illegal attribute defaults),
|
||||
@ -43,13 +46,15 @@ public extension PureXML.Validation {
|
||||
parsedAttributes[name] = AttributeListParser.parse(body)
|
||||
}
|
||||
attributes = parsedAttributes
|
||||
let scanned = documentType.validityFindings.map { finding in
|
||||
let advisories = documentType.validityFindings.map { finding in
|
||||
PureXML.Validation.ValidationError(
|
||||
reason: finding.reason,
|
||||
at: finding.subject.map { [PureXML.Validation.PathKey.element($0)] } ?? [],
|
||||
severity: .warning,
|
||||
)
|
||||
}
|
||||
declarationErrors = scanned + Self.declarationFindings(documentType, attributes: parsedAttributes, notations: notations)
|
||||
parseAdvisories = advisories
|
||||
declarationErrors = Self.declarationFindings(documentType, attributes: parsedAttributes, notations: notations)
|
||||
self.standalone = standalone
|
||||
externalElementModels = Set(documentType.elementModels.keys).subtracting(documentType.internalElementModels)
|
||||
var external: [String: [AttributeDeclaration]] = [:]
|
||||
|
||||
@ -10,18 +10,39 @@ public extension PureXML.Validation {
|
||||
/// and ID/IDREF integrity is checked once over the whole tree. Replaces the
|
||||
/// imperative walk with declared ``Validation`` values.
|
||||
enum DTD {
|
||||
/// Parse-time DTD advisories are surfaced for inspection. Under strict
|
||||
/// validation they promote to errors; otherwise they remain warnings.
|
||||
static var parseAdvisories: Validation<PureXML.Model.Node, DTDSchema> {
|
||||
.init(
|
||||
description: "Parse-time DTD advisories are reviewed",
|
||||
check: { context in context.document.parseAdvisories },
|
||||
when: { $0.codingPath.isEmpty },
|
||||
)
|
||||
}
|
||||
|
||||
/// A validator for a ``DTDSchema``. In strict mode every element must also
|
||||
/// be declared.
|
||||
static func validator(strict: Bool) -> Validator<DTDSchema> {
|
||||
var validator = Validator<DTDSchema>.blank
|
||||
.validating(contentModel)
|
||||
.validating(requiredAttributes, fixedAttributeValues, enumeratedAttributeValues)
|
||||
.validating(tokenizedAttributeTypes, notationAttributes)
|
||||
.validating(identifierIntegrity)
|
||||
.validating(declarationValidity, rootElementType)
|
||||
.validating(standaloneAttributes, standaloneElementWhitespace)
|
||||
if strict { validator = validator.validating(undeclaredElement, undeclaredAttributes) }
|
||||
return validator
|
||||
let nonReference: [AnyValidation<DTDSchema>] = [
|
||||
AnyValidation(contentModel),
|
||||
AnyValidation(requiredAttributes),
|
||||
AnyValidation(fixedAttributeValues),
|
||||
AnyValidation(enumeratedAttributeValues),
|
||||
AnyValidation(tokenizedAttributeTypes),
|
||||
AnyValidation(notationAttributes),
|
||||
AnyValidation(declarationValidity),
|
||||
AnyValidation(rootElementType),
|
||||
AnyValidation(standaloneAttributes),
|
||||
AnyValidation(standaloneElementWhitespace),
|
||||
AnyValidation(parseAdvisories),
|
||||
]
|
||||
var reference: [AnyValidation<DTDSchema>] = [
|
||||
AnyValidation(identifierIntegrity),
|
||||
]
|
||||
if strict {
|
||||
reference += [AnyValidation(undeclaredElement), AnyValidation(undeclaredAttributes)]
|
||||
}
|
||||
return Validator<DTDSchema>.defaults(nonReference: nonReference, reference: reference)
|
||||
}
|
||||
|
||||
/// Each element's content matches its `<!ELEMENT>` model.
|
||||
|
||||
@ -49,9 +49,10 @@ public extension PureXML.Validation {
|
||||
|
||||
/// A validator combining the HTML conformance rules.
|
||||
static func validator() -> Validator<Void> {
|
||||
Validator<Void>.blank
|
||||
.validating(voidElementsAreEmpty, requiredParent)
|
||||
.validating(uniqueIdentifiers)
|
||||
Validator<Void>.defaults(
|
||||
nonReference: [AnyValidation(voidElementsAreEmpty), AnyValidation(requiredParent)],
|
||||
reference: [AnyValidation(uniqueIdentifiers)],
|
||||
)
|
||||
}
|
||||
|
||||
/// The allowed direct parents for each element that requires one (the
|
||||
|
||||
11
Sources/Validation/HasWarnings.swift
Normal file
11
Sources/Validation/HasWarnings.swift
Normal file
@ -0,0 +1,11 @@
|
||||
public extension PureXML.Validation {
|
||||
/// Parse-time or advisory findings attached to a validated value, collected
|
||||
/// during traversal and returned separately from rule failures unless
|
||||
/// ``Validator/validate(_:in:strict:)`` runs with `strict: true` (the default),
|
||||
/// which promotes warnings into the thrown error collection.
|
||||
protocol HasWarnings {
|
||||
/// Advisory findings for this value at `path`. Each entry should use
|
||||
/// ``ValidationError/severity`` `.warning`.
|
||||
func validationWarnings(at path: [PathKey]) -> [ValidationError]
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,7 @@ public extension PureXML.Validation {
|
||||
public func validate(_ xml: String, phase: String? = nil, documentLoader: @escaping (String) -> String? = { _ in nil }) throws -> [ValidationError] {
|
||||
let node = try PureXML.parse(xml)
|
||||
let validation = Self.rule(activePatterns(phase: phase), schemaLets: schema.lets, diagnostics: schema.diagnostics, keys: schema.keys, loader: documentLoader)
|
||||
return Validator<Void>.blank.validating(validation).errors(for: node, in: ())
|
||||
return Validator<Void>.blank.validating(validation).findings(for: node, in: ())
|
||||
}
|
||||
|
||||
/// The patterns active in the requested phase, falling back to the schema's
|
||||
|
||||
@ -28,20 +28,25 @@ public extension PureXML.Validation {
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureXML.Validation.Validator where Document == Void {
|
||||
/// The default structural validator: schema-independent well-formedness rules.
|
||||
init() {
|
||||
self.init(validations: PureXML.Validation.Structural.defaults.map { PureXML.Validation.AnyValidation($0) })
|
||||
}
|
||||
|
||||
extension PureXML.Validation.Validator where Document == Void {
|
||||
/// Validates a node with the structural rules, throwing a
|
||||
/// ``PureXML/Validation/ValidationErrorCollection`` on any failure.
|
||||
func validate(_ node: PureXML.Model.Node) throws {
|
||||
try validate(node, in: ())
|
||||
func validate(_ node: PureXML.Model.Node, strict: Bool = true) throws {
|
||||
try validate(node, in: (), strict: strict)
|
||||
}
|
||||
|
||||
/// The structural errors for a node, in document order; empty when valid.
|
||||
func errors(for node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] {
|
||||
errors(for: node, in: ())
|
||||
}
|
||||
|
||||
/// The structural warnings for a node, in document order.
|
||||
func warnings(for node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] {
|
||||
warnings(for: node, in: ())
|
||||
}
|
||||
|
||||
/// Every structural finding for a node, errors and warnings together.
|
||||
func findings(for node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] {
|
||||
findings(for: node, in: ())
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,3 +110,19 @@ func unwrap<T: Validatable, U: Validatable, D>(
|
||||
func all<T: Validatable, D>(_ validations: VRule<T, D>...) -> (VContext<T, D>) -> [VError] {
|
||||
{ context in validations.flatMap { $0.apply(to: context.subject, at: context.codingPath, in: context.document) } }
|
||||
}
|
||||
|
||||
/// Resolve a named value from the document, error if missing, else validate the resolved value.
|
||||
func lookup<T: Validatable, U: Validatable, D>(
|
||||
_ path: KeyPath<D, [String: U]>,
|
||||
name: KeyPath<T, String>,
|
||||
missing: @escaping (String) -> String,
|
||||
into validations: VRule<U, D>...,
|
||||
) -> (VContext<T, D>) -> [VError] {
|
||||
{ context in
|
||||
let key = context.subject[keyPath: name]
|
||||
guard let resolved = context.document[keyPath: path][key] else {
|
||||
return [VError(reason: missing(key), at: context.codingPath)]
|
||||
}
|
||||
return validations.flatMap { $0.apply(to: resolved, at: context.codingPath, in: context.document) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,6 +106,19 @@ public extension PureXML.Validation {
|
||||
values.map(\.description).joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits findings into error- and warning-severity buckets.
|
||||
static func splitFindings(_ findings: [ValidationError]) -> (errors: [ValidationError], warnings: [ValidationError]) {
|
||||
var errors: [ValidationError] = []
|
||||
var warnings: [ValidationError] = []
|
||||
for finding in findings {
|
||||
switch finding.severity {
|
||||
case .error: errors.append(finding)
|
||||
case .warning: warnings.append(finding)
|
||||
}
|
||||
}
|
||||
return (errors, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Model.Node: PureXML.Validation.Validatable {}
|
||||
|
||||
36
Sources/Validation/ValidationOutcome.swift
Normal file
36
Sources/Validation/ValidationOutcome.swift
Normal file
@ -0,0 +1,36 @@
|
||||
public extension PureXML.Validation {
|
||||
/// The outcome of validating a node: errors, optional warnings, and a validity
|
||||
/// flag. Non-throwing recovery accessor beside ``Validator/validate(_:in:strict:)``.
|
||||
struct ValidationOutcome: Equatable, Sendable {
|
||||
public let errors: [ValidationError]
|
||||
public let warnings: [ValidationError]
|
||||
|
||||
/// True when there are no errors and no warnings.
|
||||
public var isValid: Bool {
|
||||
errors.isEmpty && warnings.isEmpty
|
||||
}
|
||||
|
||||
public init(errors: [ValidationError], warnings: [ValidationError] = []) {
|
||||
self.errors = errors
|
||||
self.warnings = warnings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureXML.Validation.Validator {
|
||||
/// Validates `node` in `document`, returning a ``ValidationOutcome`` instead
|
||||
/// of throwing. When `strict` is true, warnings are merged into `errors`
|
||||
/// (matching the throwing entry point's default).
|
||||
func outcome(for node: PureXML.Model.Node, in document: Document, strict: Bool = false) -> PureXML.Validation.ValidationOutcome {
|
||||
let split = PureXML.Validation.splitFindings(findings(for: node, in: document))
|
||||
if strict {
|
||||
return PureXML.Validation.ValidationOutcome(errors: split.errors + split.warnings)
|
||||
}
|
||||
return PureXML.Validation.ValidationOutcome(errors: split.errors, warnings: split.warnings)
|
||||
}
|
||||
|
||||
/// Validates `node` in `document`, returning a ``ValidationOutcome`` instead of throwing.
|
||||
func outcome(for node: PureXML.Model.Node, in document: Document) -> PureXML.Validation.ValidationOutcome {
|
||||
outcome(for: node, in: document, strict: false)
|
||||
}
|
||||
}
|
||||
@ -1,23 +1,31 @@
|
||||
public extension PureXML.Validation {
|
||||
/// Applies a set of ``Validation`` values across a parsed node tree, gathering
|
||||
/// every error with its coding path and throwing one ``ValidationErrorCollection``
|
||||
/// at the end. Holds an ordered list of erased validations (a layer's default
|
||||
/// set plus any custom additions). Build one with ``blank`` and the fluent
|
||||
/// ``validating(_:)``/``withoutValidating(_:)`` methods, or take a layer's
|
||||
/// at the end. Holds default rules in two tiers (non-reference, then reference)
|
||||
/// plus any custom additions, in that order. Build one with ``blank`` and the
|
||||
/// fluent ``validating(_:)``/``withoutValidating(_:)`` methods, or take a layer's
|
||||
/// default factory.
|
||||
///
|
||||
/// Parameterized over `Document`, the cross-cutting context the rules consult
|
||||
/// (a schema, a scope, or `Void` for rules that need only their subject).
|
||||
struct Validator<Document> {
|
||||
var validations: [AnyValidation<Document>]
|
||||
final class Validator<Document>: @unchecked Sendable {
|
||||
fileprivate let nonReferenceDefaultValidations: [AnyValidation<Document>]
|
||||
fileprivate let referenceDefaultValidations: [AnyValidation<Document>]
|
||||
fileprivate let customValidations: [AnyValidation<Document>]
|
||||
|
||||
init(validations: [AnyValidation<Document>]) {
|
||||
self.validations = validations
|
||||
init(
|
||||
nonReferenceDefaultValidations: [AnyValidation<Document>],
|
||||
referenceDefaultValidations: [AnyValidation<Document>],
|
||||
customValidations: [AnyValidation<Document>],
|
||||
) {
|
||||
self.nonReferenceDefaultValidations = nonReferenceDefaultValidations
|
||||
self.referenceDefaultValidations = referenceDefaultValidations
|
||||
self.customValidations = customValidations
|
||||
}
|
||||
|
||||
/// A validator with no rules; add your own with ``validating(_:)``.
|
||||
public static var blank: Validator {
|
||||
Validator(validations: [])
|
||||
Validator(nonReferenceDefaultValidations: [], referenceDefaultValidations: [], customValidations: [])
|
||||
}
|
||||
|
||||
/// The descriptions of the active rules, in application order.
|
||||
@ -25,12 +33,57 @@ public extension PureXML.Validation {
|
||||
validations.map(\.description)
|
||||
}
|
||||
|
||||
/// Returns a validator with `additions` appended after the current rules.
|
||||
public func validating<Subject>(_ additions: Validation<Subject, Document>...) -> Validator {
|
||||
Validator(validations: validations + additions.map { AnyValidation($0) })
|
||||
/// Descriptions of the non-reference default tier, in order.
|
||||
public var nonReferenceValidationDescriptions: [String] {
|
||||
nonReferenceDefaultValidations.map(\.description)
|
||||
}
|
||||
|
||||
/// Returns a validator with a single-error Bool rule appended.
|
||||
/// Descriptions of the reference default tier, in order.
|
||||
public var referenceValidationDescriptions: [String] {
|
||||
referenceDefaultValidations.map(\.description)
|
||||
}
|
||||
|
||||
/// Descriptions of the custom tier, in order.
|
||||
public var customValidationDescriptions: [String] {
|
||||
customValidations.map(\.description)
|
||||
}
|
||||
|
||||
fileprivate var validations: [AnyValidation<Document>] {
|
||||
nonReferenceDefaultValidations + referenceDefaultValidations + customValidations
|
||||
}
|
||||
|
||||
/// Returns a validator with `additions` appended to the custom tier.
|
||||
@discardableResult
|
||||
public func validating<Subject>(_ additions: Validation<Subject, Document>...) -> Validator {
|
||||
Validator(
|
||||
nonReferenceDefaultValidations: nonReferenceDefaultValidations,
|
||||
referenceDefaultValidations: referenceDefaultValidations,
|
||||
customValidations: customValidations + additions.map { AnyValidation($0) },
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a validator with one named builtin rule appended to the custom tier.
|
||||
@discardableResult
|
||||
public func validating(
|
||||
_ rule: KeyPath<BuiltinValidation.Type, Validation<some Any, Document>>,
|
||||
) -> Validator {
|
||||
validating(BuiltinValidation.self[keyPath: rule])
|
||||
}
|
||||
|
||||
/// Returns a validator with several named builtin rules appended (mixed subject types).
|
||||
@discardableResult
|
||||
public func validating<each Subject: Validatable>(
|
||||
_ rules: repeat KeyPath<BuiltinValidation.Type, Validation<each Subject, Document>>,
|
||||
) -> Validator {
|
||||
var result = self
|
||||
for rule in repeat each rules {
|
||||
result = result.validating(BuiltinValidation.self[keyPath: rule])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Returns a validator with a single-error Bool rule appended to the custom tier.
|
||||
@discardableResult
|
||||
public func validating<Subject>(
|
||||
_ description: String,
|
||||
check: @escaping (ValidationContext<Subject, Document>) -> Bool,
|
||||
@ -39,51 +92,84 @@ public extension PureXML.Validation {
|
||||
validating(Validation(description: description, check: check, when: predicate))
|
||||
}
|
||||
|
||||
/// Returns a validator with the rules matching `descriptions` removed.
|
||||
/// Returns a validator with the rules matching `descriptions` removed from
|
||||
/// every tier.
|
||||
@discardableResult
|
||||
public func withoutValidating(_ descriptions: String...) -> Validator {
|
||||
Validator(validations: validations.filter { !descriptions.contains($0.description) })
|
||||
let drop = Set(descriptions)
|
||||
func filter(_ tier: [AnyValidation<Document>]) -> [AnyValidation<Document>] {
|
||||
tier.filter { !drop.contains($0.description) }
|
||||
}
|
||||
return Validator(
|
||||
nonReferenceDefaultValidations: filter(nonReferenceDefaultValidations),
|
||||
referenceDefaultValidations: filter(referenceDefaultValidations),
|
||||
customValidations: filter(customValidations),
|
||||
)
|
||||
}
|
||||
|
||||
/// Applies every active rule to `subject` at `path` in `document`, without
|
||||
/// walking a node tree. Used for compile-time schema checks and other
|
||||
/// single-subject validation passes.
|
||||
public func errors(
|
||||
for subject: some Validatable,
|
||||
at path: [PathKey] = [],
|
||||
in document: Document,
|
||||
) -> [ValidationError] {
|
||||
var findings: [ValidationError] = []
|
||||
apply(subject, at: path, in: document, into: &findings)
|
||||
return PureXML.Validation.splitFindings(findings).errors
|
||||
}
|
||||
|
||||
// MARK: Running
|
||||
|
||||
/// Gathers every error from validating `node` in `document`, in document
|
||||
/// order. Returns an empty array when the tree is valid.
|
||||
/// Every validation finding for `node` in `document`, including advisory
|
||||
/// warnings from rules and from ``HasWarnings`` values.
|
||||
public func findings(for node: PureXML.Model.Node, in document: Document) -> [ValidationError] {
|
||||
var findings: [ValidationError] = []
|
||||
walk(node, path: [], document, &findings)
|
||||
return findings
|
||||
}
|
||||
|
||||
/// Gathers every error-severity finding from validating `node` in `document`.
|
||||
public func errors(for node: PureXML.Model.Node, in document: Document) -> [ValidationError] {
|
||||
var errors: [ValidationError] = []
|
||||
walk(node, path: [], document, &errors)
|
||||
return errors
|
||||
PureXML.Validation.splitFindings(findings(for: node, in: document)).errors
|
||||
}
|
||||
|
||||
/// Gathers every warning-severity finding from validating `node` in `document`.
|
||||
public func warnings(for node: PureXML.Model.Node, in document: Document) -> [ValidationError] {
|
||||
PureXML.Validation.splitFindings(findings(for: node, in: document)).warnings
|
||||
}
|
||||
|
||||
/// Validates `node` in `document`, throwing a ``ValidationErrorCollection``
|
||||
/// if any rule fails.
|
||||
public func validate(_ node: PureXML.Model.Node, in document: Document) throws {
|
||||
let found = errors(for: node, in: document)
|
||||
if !found.isEmpty { throw ValidationErrorCollection(values: found) }
|
||||
/// when any error-severity finding occurs, and also when any warning occurs
|
||||
/// and `strict` is true (the default).
|
||||
public func validate(_ node: PureXML.Model.Node, in document: Document, strict: Bool = true) throws {
|
||||
let split = PureXML.Validation.splitFindings(findings(for: node, in: document))
|
||||
let toThrow = strict ? split.errors + split.warnings : split.errors
|
||||
if !toThrow.isEmpty { throw ValidationErrorCollection(values: toThrow) }
|
||||
}
|
||||
|
||||
private func walk(_ node: PureXML.Model.Node, path: [PathKey], _ document: Document, _ errors: inout [ValidationError]) {
|
||||
apply(node, at: path, in: document, into: &errors)
|
||||
private func walk(_ node: PureXML.Model.Node, path: [PathKey], _ document: Document, _ findings: inout [ValidationError]) {
|
||||
apply(node, at: path, in: document, into: &findings)
|
||||
switch node {
|
||||
case let .document(children):
|
||||
walkChildren(children, path: path, document, &errors)
|
||||
walkChildren(children, path: path, document, &findings)
|
||||
case let .element(element):
|
||||
apply(element, at: path, in: document, into: &errors)
|
||||
apply(element, at: path, in: document, into: &findings)
|
||||
for attribute in element.attributes {
|
||||
apply(attribute, at: path + [.attribute(attribute.name.localName)], in: document, into: &errors)
|
||||
apply(attribute, at: path + [.attribute(attribute.name.localName)], in: document, into: &findings)
|
||||
}
|
||||
walkChildren(element.children, path: path, document, &errors)
|
||||
walkChildren(element.children, path: path, document, &findings)
|
||||
case .text, .cdata, .comment, .processingInstruction:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Recurses element children, extending the path by `name` (and a sibling
|
||||
/// index only when more than one child shares that name).
|
||||
private func walkChildren(
|
||||
_ children: [PureXML.Model.Node],
|
||||
path: [PathKey],
|
||||
_ document: Document,
|
||||
_ errors: inout [ValidationError],
|
||||
_ findings: inout [ValidationError],
|
||||
) {
|
||||
let elementNames = children.compactMap { child -> String? in
|
||||
guard case let .element(element) = child else { return nil }
|
||||
@ -92,18 +178,46 @@ public extension PureXML.Validation {
|
||||
var steps = PathKey.steps(forChildNames: elementNames).makeIterator()
|
||||
for child in children {
|
||||
guard case .element = child else {
|
||||
walk(child, path: path, document, &errors)
|
||||
walk(child, path: path, document, &findings)
|
||||
continue
|
||||
}
|
||||
let step = steps.next() ?? .element("")
|
||||
walk(child, path: path + [step], document, &errors)
|
||||
walk(child, path: path + [step], document, &findings)
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(_ value: some Validatable, at path: [PathKey], in document: Document, into errors: inout [ValidationError]) {
|
||||
private func apply(_ value: some Validatable, at path: [PathKey], in document: Document, into findings: inout [ValidationError]) {
|
||||
for validation in validations {
|
||||
errors += validation.apply(to: value, at: path, in: document)
|
||||
findings += validation.apply(to: value, at: path, in: document)
|
||||
}
|
||||
if let warningSource = value as? HasWarnings {
|
||||
findings += warningSource.validationWarnings(at: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureXML.Validation.Validator where Document == Void {
|
||||
/// The default structural validator: schema-independent well-formedness rules.
|
||||
convenience init() {
|
||||
self.init(
|
||||
nonReferenceDefaultValidations: PureXML.Validation.Structural.defaults.map { PureXML.Validation.AnyValidation($0) },
|
||||
referenceDefaultValidations: [],
|
||||
customValidations: [],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Validation.Validator {
|
||||
/// Builds a validator whose default tiers are populated explicitly.
|
||||
static func defaults(
|
||||
nonReference: [PureXML.Validation.AnyValidation<Document>] = [],
|
||||
reference: [PureXML.Validation.AnyValidation<Document>] = [],
|
||||
) -> PureXML.Validation.Validator<Document> {
|
||||
PureXML.Validation.Validator(
|
||||
nonReferenceDefaultValidations: nonReference,
|
||||
referenceDefaultValidations: reference,
|
||||
customValidations: [],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
231
Tests/Fixtures/schema-differential-xsts-baseline.txt
Normal file
231
Tests/Fixtures/schema-differential-xsts-baseline.txt
Normal file
@ -0,0 +1,231 @@
|
||||
IDREFS_minLength006_402:../msData/datatypes/Facets/Schemas/IDREFS_minLength006.xsd
|
||||
IDREF_minLength006_532:../msData/datatypes/Facets/Schemas/IDREF_minLength006.xsd
|
||||
ID_minLength006_510:../msData/datatypes/Facets/Schemas/ID_minLength006.xsd
|
||||
NCName_minLength006_488:../msData/datatypes/Facets/Schemas/NCName_minLength006.xsd
|
||||
NMTOKENS_minLength006_445:../msData/datatypes/Facets/Schemas/NMTOKENS_minLength006.xsd
|
||||
NMTOKEN_minLength006_423:../msData/datatypes/Facets/Schemas/NMTOKEN_minLength006.xsd
|
||||
NOTATION_minLength006_312:../msData/datatypes/Facets/Schemas/NOTATION_minLength006.xsd
|
||||
Name_minLength006_466:../msData/datatypes/Facets/Schemas/Name_minLength006.xsd
|
||||
QName_minLength006_290:../msData/datatypes/Facets/Schemas/QName_minLength006.xsd
|
||||
addB009:../msData/additional/test66059.xsd
|
||||
addB033:../msData/additional/test71774.xsd
|
||||
addB036:../msData/additional/test71847.xsd
|
||||
addB053:../msData/additional/test73457.xsd
|
||||
addB064:../msData/additional/test73722.xsd
|
||||
addB071:../msData/additional/test65026.xsd
|
||||
addB082:../msData/additional/addB082.xsd
|
||||
addB089:../msData/additional/addB089.xsd
|
||||
addB099:../msData/additional/addB099.xsd
|
||||
addB100:../msData/additional/addB100.xsd
|
||||
addB106:../msData/additional/addB106.xsd
|
||||
addB113:../msData/additional/addB113.xsd
|
||||
addB118:../msData/additional/test74966.xsd
|
||||
addB121:../msData/additional/addB121.xsd
|
||||
addB141:../msData/additional/test82919.xsd
|
||||
addB153:../msData/additional/test86490_a.xsd
|
||||
addB178:../msData/additional/test102850_3.xsd
|
||||
addB194:../msData/additional/addB194.xsd
|
||||
addB32:../msData/additional/test67764.xsd
|
||||
addC001:../msData/additional/adhocAddC001.xsd
|
||||
addC002:../msData/additional/adhocAddC002.xsd
|
||||
addD003:../msData/additional/xsd.xsd
|
||||
annotA014:../msData/annotations/annotA014.xsd
|
||||
anyURI_a002_1337:../msData/datatypes/Facets/anyURI/anyURI_a002.xsd
|
||||
anyURI_a004_1339:../msData/datatypes/Facets/anyURI/anyURI_a004.xsd
|
||||
anyURI_a006_1341:../msData/datatypes/Facets/anyURI/anyURI_a006.xsd
|
||||
anyURI_a009_1344:../msData/datatypes/Facets/anyURI/anyURI_a009.xsd
|
||||
anyURI_minLength006_269:../msData/datatypes/Facets/Schemas/anyURI_minLength006.xsd
|
||||
attD002:../msData/attribute/attD002.xsd
|
||||
attKa015:../msData/attribute/attKa015.xsd
|
||||
attKb012:../msData/attribute/attKb012.xsd
|
||||
attKb015:../msData/attribute/attKb015.xsd
|
||||
attKb018a:../msData/attribute/attKb018a.xsd
|
||||
attKc012:../msData/attribute/attKc012.xsd
|
||||
attKc015:../msData/attribute/attKc015.xsd
|
||||
attQ011:../msData/attribute/attQ011.xsd
|
||||
attQ016:../msData/attribute/attQ016.xsd
|
||||
attQ017:../msData/attribute/attQ017.xsd
|
||||
attQ018:../msData/attribute/attQ018.xsd
|
||||
attZ008_e:../msData/attribute/attZ008_e.xsd
|
||||
attZ008_f:../msData/attribute/attZ008_f.xsd
|
||||
attZ008_h:../msData/attribute/attZ008_h.xsd
|
||||
attZ013b:../msData/attribute/attZ013b.xsd
|
||||
attgC028:../msData/attributeGroup/attgC028.xsd
|
||||
attgC044:../msData/attributeGroup/attgC044.xsd
|
||||
attgD037:../msData/attributeGroup/attgD037.xsd
|
||||
attgD038:../msData/attributeGroup/attgD038.xsd
|
||||
attgD039:../msData/attributeGroup/attgD039.xsd
|
||||
attgZ002:../msData/attributeGroup/attgZ002.xsd
|
||||
attgZ003:../msData/attributeGroup/attgZ003.xsd
|
||||
base64Binary_enumeration003_1321:../msData/datatypes/Facets/base64Binary/base64Binary_enumeration003.xsd
|
||||
base64Binary_minLength006_248:../msData/datatypes/Facets/Schemas/base64Binary_minLength006.xsd
|
||||
byte_fractionDigits004_806:../msData/datatypes/Facets/Schemas/byte_fractionDigits004.xsd
|
||||
byte_fractionDigits007_809:../msData/datatypes/Facets/Schemas/byte_fractionDigits007.xsd
|
||||
ctA042:../msData/complexType/ctA042.xsd
|
||||
ctC009:../msData/complexType/ctC009.xsd
|
||||
ctD001:../msData/complexType/ctD001.xsd
|
||||
ctD004:../msData/complexType/ctD004.xsd
|
||||
ctE003:../msData/complexType/ctE003.xsd
|
||||
ctE004:../msData/complexType/ctE004.xsd
|
||||
ctF008:../msData/complexType/ctF008.xsd
|
||||
ctF009:../msData/complexType/ctF009.xsd
|
||||
ctF012:../msData/complexType/ctF012.xsd
|
||||
ctF015:../msData/complexType/ctF015.xsd
|
||||
ctI016:../msData/complexType/ctI016.xsd
|
||||
ctI017:../msData/complexType/ctI017.xsd
|
||||
ctI020:../msData/complexType/ctI020.xsd
|
||||
ctI024:../msData/complexType/ctI024.xsd
|
||||
ctJ002:../msData/complexType/ctJ002.xsd
|
||||
ctJ003:../msData/complexType/ctJ003.xsd
|
||||
ctK002:../msData/complexType/ctK002.xsd
|
||||
ctM001:../msData/complexType/ctM001.xsd
|
||||
ctO004:../msData/complexType/ctO004.xsd
|
||||
ctO005:../msData/complexType/ctO005.xsd
|
||||
ctO007:../msData/complexType/ctO007.xsd
|
||||
ctZ010a:../msData/complexType/ctZ010a.xsd
|
||||
ctZ010d:../msData/complexType/ctZ010d.xsd
|
||||
ctZ010e:../msData/complexType/ctZ010e.xsd
|
||||
elemD004:../msData/element/elemD004.xsd
|
||||
elemE002:../msData/element/elemE002.xsd
|
||||
elemE003:../msData/element/elemE003.xsd
|
||||
elemE004:../msData/element/elemE004.xsd
|
||||
elemE005:../msData/element/elemE005.xsd
|
||||
elemG003:../msData/element/elemG003.xsd
|
||||
elemG004:../msData/element/elemG004.xsd
|
||||
elemP005:../msData/element/elemP005.xsd
|
||||
elemZ004:../msData/element/elemZ004.xsd
|
||||
elemZ012:../msData/element/elemZ012.xsd
|
||||
elemZ028b:../msData/element/elemZ028b.xsd
|
||||
elemZ028e:../msData/element/elemZ028e.xsd
|
||||
elemZ032b:../msData/element/elemZ032b.xsd
|
||||
errC005:../msData/errata10/errC005.xsd
|
||||
errC006:../msData/errata10/errC006.xsd
|
||||
errC009:../msData/errata10/errC009.xsd
|
||||
gMonth_enumeration001_1275:../msData/datatypes/Facets/gMonth/gMonth_enumeration001.xsd
|
||||
gMonth_enumeration002_1276:../msData/datatypes/Facets/gMonth/gMonth_enumeration002.xsd
|
||||
gMonth_enumeration002_203:../msData/datatypes/Facets/Schemas/gMonth_enumeration002.xsd
|
||||
gMonth_enumeration003_1277:../msData/datatypes/Facets/gMonth/gMonth_enumeration003.xsd
|
||||
gMonth_enumeration004_1278:../msData/datatypes/Facets/gMonth/gMonth_enumeration004.xsd
|
||||
gMonth_maxExclusive001_1282:../msData/datatypes/Facets/gMonth/gMonth_maxExclusive001.xsd
|
||||
gMonth_maxExclusive002_1283:../msData/datatypes/Facets/gMonth/gMonth_maxExclusive002.xsd
|
||||
gMonth_maxExclusive002_208:../msData/datatypes/Facets/Schemas/gMonth_maxExclusive002.xsd
|
||||
gMonth_maxExclusive003_1284:../msData/datatypes/Facets/gMonth/gMonth_maxExclusive003.xsd
|
||||
gMonth_maxInclusive001_1279:../msData/datatypes/Facets/gMonth/gMonth_maxInclusive001.xsd
|
||||
gMonth_maxInclusive002_1280:../msData/datatypes/Facets/gMonth/gMonth_maxInclusive002.xsd
|
||||
gMonth_maxInclusive002_205:../msData/datatypes/Facets/Schemas/gMonth_maxInclusive002.xsd
|
||||
gMonth_maxInclusive003_1281:../msData/datatypes/Facets/gMonth/gMonth_maxInclusive003.xsd
|
||||
gMonth_minExclusive001_1290:../msData/datatypes/Facets/gMonth/gMonth_minExclusive001.xsd
|
||||
gMonth_minExclusive002_1291:../msData/datatypes/Facets/gMonth/gMonth_minExclusive002.xsd
|
||||
gMonth_minExclusive002_213:../msData/datatypes/Facets/Schemas/gMonth_minExclusive002.xsd
|
||||
gMonth_minExclusive003_1292:../msData/datatypes/Facets/gMonth/gMonth_minExclusive003.xsd
|
||||
gMonth_minExclusive004_1293:../msData/datatypes/Facets/gMonth/gMonth_minExclusive004.xsd
|
||||
gMonth_minExclusive005_1294:../msData/datatypes/Facets/gMonth/gMonth_minExclusive005.xsd
|
||||
gMonth_minInclusive001_1285:../msData/datatypes/Facets/gMonth/gMonth_minInclusive001.xsd
|
||||
gMonth_minInclusive002_1286:../msData/datatypes/Facets/gMonth/gMonth_minInclusive002.xsd
|
||||
gMonth_minInclusive002_210:../msData/datatypes/Facets/Schemas/gMonth_minInclusive002.xsd
|
||||
gMonth_minInclusive003_1287:../msData/datatypes/Facets/gMonth/gMonth_minInclusive003.xsd
|
||||
gMonth_minInclusive004_1288:../msData/datatypes/Facets/gMonth/gMonth_minInclusive004.xsd
|
||||
gMonth_minInclusive005_1289:../msData/datatypes/Facets/gMonth/gMonth_minInclusive005.xsd
|
||||
groupC003:../msData/group/groupC003.xsd
|
||||
groupE002:../msData/group/groupE002.xsd
|
||||
groupF009v:../msData/group/groupF009.xsd
|
||||
groupG002:../msData/group/groupG002.xsd
|
||||
groupH007v:../msData/group/groupH007.xsd
|
||||
groupH009v:../msData/group/groupH009.xsd
|
||||
groupI002:../msData/group/groupI002.xsd
|
||||
groupJ009v:../msData/group/groupJ009.xsd
|
||||
groupK002:../msData/group/groupK002.xsd
|
||||
groupL009v:../msData/group/groupL009.xsd
|
||||
groupM002:../msData/group/groupM002.xsd
|
||||
groupN009v:../msData/group/groupN009.xsd
|
||||
hexBinary_minLength006_227:../msData/datatypes/Facets/Schemas/hexBinary_minLength006.xsd
|
||||
idA044:../msData/identityConstraint/idA044.xsd
|
||||
idA046:../msData/identityConstraint/idA046.xsd
|
||||
idB044:../msData/identityConstraint/idB044.xsd
|
||||
idB046:../msData/identityConstraint/idB046.xsd
|
||||
idC019:../msData/identityConstraint/idC019.xsd
|
||||
idC047:../msData/identityConstraint/idC047.xsd
|
||||
idC049:../msData/identityConstraint/idC049.xsd
|
||||
idI010:../msData/identityConstraint/idI010.xsd
|
||||
idJ011:../msData/identityConstraint/idJ011.xsd
|
||||
int_fractionDigits004_734:../msData/datatypes/Facets/Schemas/int_fractionDigits004.xsd
|
||||
int_fractionDigits007_737:../msData/datatypes/Facets/Schemas/int_fractionDigits007.xsd
|
||||
integer_fractionDigits004_567:../msData/datatypes/Facets/Schemas/integer_fractionDigits004.xsd
|
||||
integer_fractionDigits007_570:../msData/datatypes/Facets/Schemas/integer_fractionDigits007.xsd
|
||||
isDefault078:../msData/additional/isdefault078.xsd
|
||||
language_minLength006_380:../msData/datatypes/Facets/Schemas/language_minLength006.xsd
|
||||
long_fractionDigits004_667:../msData/datatypes/Facets/Schemas/long_fractionDigits004.xsd
|
||||
long_fractionDigits007_670:../msData/datatypes/Facets/Schemas/long_fractionDigits007.xsd
|
||||
mgAa004:../msData/modelGroups/mgAa004.xsd
|
||||
mgAa006:../msData/modelGroups/mgAa006.xsd
|
||||
mgAb003:../msData/modelGroups/mgAb003.xsd
|
||||
mgC008:../msData/modelGroups/mgC008.xsd
|
||||
mgC009:../msData/modelGroups/mgC009.xsd
|
||||
mgE006:../msData/modelGroups/mgE006.xsd
|
||||
mgEa005:../msData/modelGroups/mgEa005.xsd
|
||||
mgG002:../msData/modelGroups/mgG002.xsd
|
||||
mgHa005:../msData/modelGroups/mgHa005.xsd
|
||||
mgJ002:../msData/modelGroups/mgJ002.xsd
|
||||
mgO001:../msData/modelGroups/mgO001.xsd
|
||||
mgO018:../msData/modelGroups/mgO018.xsd
|
||||
mgO019:../msData/modelGroups/mgO019.xsd
|
||||
mgO020:../msData/modelGroups/mgO020.xsd
|
||||
mgO027:../msData/modelGroups/mgO027.xsd
|
||||
mgQ021:../msData/modelGroups/mgQ021.xsd
|
||||
mgR002:../msData/modelGroups/mgR002.xsd
|
||||
mgR003:../msData/modelGroups/mgR003.xsd
|
||||
mgR006:../msData/modelGroups/mgR006.xsd
|
||||
mgR007:../msData/modelGroups/mgR007.xsd
|
||||
mgR009:../msData/modelGroups/mgR009.xsd
|
||||
mgR014:../msData/modelGroups/mgR014.xsd
|
||||
mgR015:../msData/modelGroups/mgR015.xsd
|
||||
mgR016:../msData/modelGroups/mgR016.xsd
|
||||
mgR017:../msData/modelGroups/mgR017.xsd
|
||||
mgR018:../msData/modelGroups/mgR018.xsd
|
||||
mgR022:../msData/modelGroups/mgR022.xsd
|
||||
name00101m1:../sunData/Notation/name/name00101m/name00101m1.xsd
|
||||
negativeInteger_fractionDigits004_631:../msData/datatypes/Facets/Schemas/negativeInteger_fractionDigits004.xsd
|
||||
negativeInteger_fractionDigits007_634:../msData/datatypes/Facets/Schemas/negativeInteger_fractionDigits007.xsd
|
||||
nonNegativeInteger_fractionDigits004_838:../msData/datatypes/Facets/Schemas/nonNegativeInteger_fractionDigits004.xsd
|
||||
nonNegativeInteger_fractionDigits007_841:../msData/datatypes/Facets/Schemas/nonNegativeInteger_fractionDigits007.xsd
|
||||
nonPositiveInteger_fractionDigits004_599:../msData/datatypes/Facets/Schemas/nonPositiveInteger_fractionDigits004.xsd
|
||||
nonPositiveInteger_fractionDigits007_602:../msData/datatypes/Facets/Schemas/nonPositiveInteger_fractionDigits007.xsd
|
||||
normalizedString_minLength006_334:../msData/datatypes/Facets/Schemas/normalizedString_minLength006.xsd
|
||||
normalizedString_whitespace001_344:../msData/datatypes/Facets/Schemas/normalizedString_whitespace001.xsd
|
||||
notatB001:../msData/notations/notatB001.xsd
|
||||
notatB008:../msData/notations/notatB008.xsd
|
||||
notatB009:../msData/notations/notatB009.xsd
|
||||
notatB010:../msData/notations/notatB010.xsd
|
||||
notatB011:../msData/notations/notatB011.xsd
|
||||
notatB012:../msData/notations/notatB012.xsd
|
||||
notatB013:../msData/notations/notatB013.xsd
|
||||
notatE003:../msData/notations/notatE003.xsd
|
||||
notatG001:../msData/notations/notatG001.xsd
|
||||
notatG002:../msData/notations/notatG002.xsd
|
||||
notatG003:../msData/notations/notatG003.xsd
|
||||
particlesDa011:../msData/particles/particlesDa011.xsd
|
||||
particlesEa023:../msData/particles/particlesEa023.xsd
|
||||
particlesEa025:../msData/particles/particlesEa025.xsd
|
||||
particlesEb015:../msData/particles/particlesEb015.xsd
|
||||
particlesEc009:../msData/particles/particlesEc009.xsd
|
||||
positiveInteger_fractionDigits004_1014:../msData/datatypes/Facets/Schemas/positiveInteger_fractionDigits004.xsd
|
||||
positiveInteger_fractionDigits007_1017:../msData/datatypes/Facets/Schemas/positiveInteger_fractionDigits007.xsd
|
||||
publicid00201m1:../sunData/Notation/publicId/publicId00201m/publicId00201m1.xsd
|
||||
short_fractionDigits004_770:../msData/datatypes/Facets/Schemas/short_fractionDigits004.xsd
|
||||
short_fractionDigits007_773:../msData/datatypes/Facets/Schemas/short_fractionDigits007.xsd
|
||||
st_facets00119m:../sunData/SType/ST_facets/ST_facets00119m/ST_facets00119m.xsd
|
||||
string_minLength006_12:../msData/datatypes/Facets/Schemas/string_minLength006.xsd
|
||||
token_minLength006_357:../msData/datatypes/Facets/Schemas/token_minLength006.xsd
|
||||
token_whitespace001_367:../msData/datatypes/Facets/Schemas/token_whitespace001.xsd
|
||||
unsignedByte_fractionDigits004_982:../msData/datatypes/Facets/Schemas/unsignedByte_fractionDigits004.xsd
|
||||
unsignedByte_fractionDigits007_985:../msData/datatypes/Facets/Schemas/unsignedByte_fractionDigits007.xsd
|
||||
unsignedInt_fractionDigits004_910:../msData/datatypes/Facets/Schemas/unsignedInt_fractionDigits004.xsd
|
||||
unsignedInt_fractionDigits007_913:../msData/datatypes/Facets/Schemas/unsignedInt_fractionDigits007.xsd
|
||||
unsignedLong_fractionDigits004_874:../msData/datatypes/Facets/Schemas/unsignedLong_fractionDigits004.xsd
|
||||
unsignedLong_fractionDigits007_877:../msData/datatypes/Facets/Schemas/unsignedLong_fractionDigits007.xsd
|
||||
unsignedShort_fractionDigits004_946:../msData/datatypes/Facets/Schemas/unsignedShort_fractionDigits004.xsd
|
||||
unsignedShort_fractionDigits007_949:../msData/datatypes/Facets/Schemas/unsignedShort_fractionDigits007.xsd
|
||||
valueconstraint01001m5:../sunData/ElemDecl/valueConstraint/valueConstraint01001m/valueConstraint01001m5.xsd
|
||||
valueconstraint01001m6:../sunData/ElemDecl/valueConstraint/valueConstraint01001m/valueConstraint01001m6.xsd
|
||||
xsd013.e:../sunData/combined/xsd013/xsd013.e.xsd
|
||||
xsd014.e:../sunData/combined/xsd014/xsd014.e.xsd
|
||||
65
Tests/Fixtures/validation-field-registry.txt
Normal file
65
Tests/Fixtures/validation-field-registry.txt
Normal file
@ -0,0 +1,65 @@
|
||||
# validation-rules.md field coverage registry.
|
||||
# Each MODEL names a validation document or subject type. FIELD lines map stored
|
||||
# properties (or Node enum cases) to rule ids or layer rule names. IGNORE lines
|
||||
# document computed or out-of-scope constructs excluded from the coverage law.
|
||||
# The meta-test in ValidationFieldCoverageTests must stay in sync with this file.
|
||||
|
||||
MODEL PureXML.Validation.DTDSchema
|
||||
FIELD models dtdContentModel,dtdUndeclaredElement,dtdStandaloneElementWhitespace
|
||||
FIELD attributes dtdRequiredAttributes,dtdFixedAttributeValues,dtdEnumeratedAttributeValues,dtdTokenizedAttributeTypes,dtdNotationAttributes,dtdUndeclaredAttributes,dtdStandaloneAttributes
|
||||
FIELD notations dtdNotationAttributes,dtdDeclarationValidity
|
||||
FIELD unparsedEntities dtdTokenizedAttributeTypes
|
||||
FIELD doctypeName dtdRootElementType
|
||||
FIELD declarationErrors dtdDeclarationValidity
|
||||
FIELD standalone dtdStandaloneAttributes,dtdStandaloneElementWhitespace
|
||||
FIELD externalElementModels dtdStandaloneElementWhitespace
|
||||
FIELD externalAttributes dtdStandaloneAttributes
|
||||
FIELD parseAdvisories dtdParseAdvisories
|
||||
IGNORE isEmpty derived summary; no independent constraint
|
||||
|
||||
MODEL PureXML.Validation.XSDContext
|
||||
FIELD types xsdContentValidity,xsdStreamingShallowValidity
|
||||
FIELD constraints xsdIdentityConstraints
|
||||
FIELD rootDeclaration xsdContentValidity
|
||||
FIELD nillableElements xsdContentValidity
|
||||
FIELD elementConstraints xsdContentValidity
|
||||
FIELD abstractTypes xsdContentValidity
|
||||
FIELD typeBlock xsdContentValidity
|
||||
FIELD elementBlock xsdContentValidity
|
||||
FIELD typeDerivation xsdContentValidity
|
||||
|
||||
MODEL PureXML.Model.Element
|
||||
FIELD name dtdContentModel,dtdUndeclaredElement,dtdUndeclaredAttributes,htmlRequiredParent,htmlVoidElementsAreEmpty,xsdContentValidity
|
||||
FIELD attributes uniqueAttributes,dtdRequiredAttributes,dtdFixedAttributeValues,dtdEnumeratedAttributeValues,dtdTokenizedAttributeTypes,dtdNotationAttributes,dtdUndeclaredAttributes,dtdStandaloneAttributes
|
||||
FIELD children dtdContentModel,htmlVoidElementsAreEmpty,htmlRequiredParent,xsdContentValidity
|
||||
IGNORE text derived from direct text and CDATA children
|
||||
|
||||
MODEL PureXML.Model.Attribute
|
||||
FIELD name uniqueAttributes,dtdUndeclaredAttributes,dtdRequiredAttributes,dtdFixedAttributeValues,dtdEnumeratedAttributeValues,dtdTokenizedAttributeTypes,dtdNotationAttributes
|
||||
FIELD value dtdFixedAttributeValues,dtdEnumeratedAttributeValues,dtdTokenizedAttributeTypes,dtdNotationAttributes,dtdIdentifierIntegrity
|
||||
|
||||
MODEL PureXML.Model.Node
|
||||
CASE document dtdDeclarationValidity,dtdRootElementType,dtdIdentifierIntegrity,htmlUniqueIdentifiers,xsdContentValidity,xsdIdentityConstraints
|
||||
CASE element uniqueAttributes,dtdContentModel,dtdRequiredAttributes,dtdFixedAttributeValues,dtdEnumeratedAttributeValues,dtdTokenizedAttributeTypes,dtdNotationAttributes,dtdUndeclaredElement,dtdUndeclaredAttributes,dtdStandaloneAttributes,dtdStandaloneElementWhitespace,htmlVoidElementsAreEmpty,htmlRequiredParent,xsdContentValidity
|
||||
CASE text dtdContentModel
|
||||
CASE cdata dtdContentModel
|
||||
IGNORE comment no DTD or XSD constraints on comments
|
||||
IGNORE processingInstruction no DTD or XSD constraints on processing instructions
|
||||
|
||||
MODEL PureXML.Validation.ConformanceCase
|
||||
FIELD name conformanceMatchesExpected
|
||||
FIELD actual conformanceMatchesExpected
|
||||
FIELD expected conformanceMatchesExpected
|
||||
|
||||
MODEL PureXML.Schema.SchemaTypeFact
|
||||
FIELD name xsdFinalRespected,xsdRestrictionsAreSubsets
|
||||
FIELD derivation xsdFinalRespected,xsdRestrictionsAreSubsets
|
||||
|
||||
MODEL PureXML.Schema.CompiledSchemaFacts
|
||||
FIELD types xsdFinalRespected,xsdRestrictionsAreSubsets
|
||||
FIELD typeDerivation xsdFinalRespected,xsdRestrictionsAreSubsets
|
||||
FIELD typeFinal xsdFinalRespected
|
||||
|
||||
MODEL PureXML.Schema.ResolvedElement
|
||||
FIELD element xsdStreamingShallowValidity
|
||||
FIELD type xsdStreamingShallowValidity
|
||||
@ -60,4 +60,14 @@ struct RelaxNGStreamingTests {
|
||||
// A disallowed child element fails even in mixed content.
|
||||
try check(mixedGrammar, "<note>text <i>no</i></note>", expected: false)
|
||||
}
|
||||
|
||||
@Test("Streaming errors fall back to located tree diagnostics when invalid")
|
||||
func test_streamingErrors() throws {
|
||||
let schema = try PureXML.Schema.RelaxNG(compact: grammar)
|
||||
let bad = "<library><book id=\"1\"><author>A</author></book></library>"
|
||||
#expect(try schema.validate(streaming: bad) == false)
|
||||
let errors = try schema.errors(streaming: bad)
|
||||
#expect(!errors.isEmpty)
|
||||
#expect(try schema.errors(streaming: "<library><book id=\"1\"><title>T</title><author>A</author></book></library>").isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
187
Tests/SchemaDifferentialHarness.swift
Normal file
187
Tests/SchemaDifferentialHarness.swift
Normal file
@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
@testable import PureXML
|
||||
|
||||
/// Whether PureXML accepts or rejects compiling a schema document.
|
||||
enum SchemaCompileVerdict: Equatable, Sendable {
|
||||
case accepts
|
||||
case rejects
|
||||
}
|
||||
|
||||
/// Opt-in differential harness (#171): compare PureXML's schema compile verdict
|
||||
/// to libxml2's `xmllint --schema` (schema compilation phase).
|
||||
enum SchemaDifferential {
|
||||
static func pureXMLVerdict(source: String, loader: @escaping (String) -> String? = { _ in nil }) -> SchemaCompileVerdict {
|
||||
(try? PureXML.Schema.Document(source, schemaLoader: loader)) == nil ? .rejects : .accepts
|
||||
}
|
||||
|
||||
static func rootElementName(in source: String) -> String? {
|
||||
guard let root = try? PureXML.parseTree(source),
|
||||
let schema = root.elementChildren.first(where: {
|
||||
$0.name?.localName == "schema" && $0.name?.namespaceURI == "http://www.w3.org/2001/XMLSchema"
|
||||
})
|
||||
else { return "root" }
|
||||
for child in schema.elementChildren {
|
||||
guard child.name?.localName == "element",
|
||||
child.name?.namespaceURI == "http://www.w3.org/2001/XMLSchema",
|
||||
child.attributes.first(where: { $0.name.localName == "ref" }) == nil,
|
||||
let name = child.attributes.first(where: { $0.name.localName == "name" })?.value
|
||||
else { continue }
|
||||
return name
|
||||
}
|
||||
return "root"
|
||||
}
|
||||
|
||||
static func libxml2Verdict(
|
||||
schemaPath: String,
|
||||
xmllint: String,
|
||||
rootElement: String? = nil,
|
||||
) -> SchemaCompileVerdict? {
|
||||
#if os(WASI)
|
||||
return nil
|
||||
#else
|
||||
let directory = (schemaPath as NSString).deletingLastPathComponent
|
||||
let fileName = (schemaPath as NSString).lastPathComponent
|
||||
let instanceName = ".purexml-schema-diff-instance.xml"
|
||||
let instancePath = directory + "/" + instanceName
|
||||
let element = rootElement ?? "root"
|
||||
let instance = "<\(element)/>"
|
||||
do {
|
||||
try instance.write(toFile: instancePath, atomically: true, encoding: .utf8)
|
||||
defer { try? FileManager.default.removeItem(atPath: instancePath) }
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: xmllint)
|
||||
process.arguments = ["--noout", "--schema", fileName, instanceName]
|
||||
process.currentDirectoryURL = URL(fileURLWithPath: directory.isEmpty ? "." : directory)
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(decoding: data, as: UTF8.self)
|
||||
return libxml2CompiledSchema(output: output, exitCode: process.terminationStatus, schemaFile: fileName)
|
||||
? .accepts
|
||||
: .rejects
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func libxml2CompiledSchema(output: String, exitCode: Int32, schemaFile: String) -> Bool {
|
||||
if output.contains("WXS schema \(schemaFile) failed to compile") { return false }
|
||||
if output.contains("Schemas parser error"), output.contains(schemaFile) { return false }
|
||||
if output.contains("Failed to parse the XML resource '\(schemaFile)'") { return false }
|
||||
if exitCode == 5 { return false }
|
||||
if exitCode == 1, output.localizedCaseInsensitiveContains("parser error"), output.contains(schemaFile) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
enum SchemaDifferentialXSTS {
|
||||
static func loadKnownDisagreements() -> Set<String> {
|
||||
let path = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures/schema-differential-xsts-baseline.txt")
|
||||
guard let text = try? String(contentsOf: path, encoding: .utf8) else { return [] }
|
||||
return Set(text.split(whereSeparator: \.isNewline).map(String.init).filter { !$0.isEmpty })
|
||||
}
|
||||
|
||||
static func parsedManifest(at path: String) throws -> PureXML.Model.Node {
|
||||
try PureXML.parse(String(contentsOfFile: path, encoding: .utf8), limits: .init(allowDoctype: true))
|
||||
}
|
||||
|
||||
static func manifestElements(named name: String, under node: PureXML.Model.Node) -> [PureXML.Model.Element] {
|
||||
var found: [PureXML.Model.Element] = []
|
||||
switch node {
|
||||
case let .document(children):
|
||||
for child in children {
|
||||
found += manifestElements(named: name, under: child)
|
||||
}
|
||||
case let .element(element):
|
||||
if element.name.localName == name { found.append(element) }
|
||||
for child in element.children {
|
||||
found += manifestElements(named: name, under: child)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
static func manifestReferences(in node: PureXML.Model.Node, named name: String) -> [String] {
|
||||
manifestElements(named: name, under: node).compactMap { element in
|
||||
element.attributes.first { $0.name.localName == "href" }?.value
|
||||
}
|
||||
}
|
||||
|
||||
static func manifestAttribute(_ name: String, of element: PureXML.Model.Element) -> String? {
|
||||
element.attributes.first { $0.name.localName == name }?.value
|
||||
}
|
||||
|
||||
static func schemaTestAccepted(_ test: PureXML.Model.Element) -> Bool {
|
||||
guard let current = manifestElements(named: "current", under: .element(test)).first else { return true }
|
||||
return (manifestAttribute("status", of: current) ?? "accepted") == "accepted"
|
||||
}
|
||||
|
||||
static func directory(of path: String) -> String {
|
||||
guard let slash = path.lastIndex(of: "/") else { return "." }
|
||||
return String(path[..<slash])
|
||||
}
|
||||
|
||||
static func resolve(_ href: String, against base: String) -> String {
|
||||
var parts = base.split(separator: "/", omittingEmptySubsequences: false).map(String.init)
|
||||
for segment in href.split(separator: "/") {
|
||||
if segment == ".." {
|
||||
if !parts.isEmpty { parts.removeLast() }
|
||||
} else if segment != "." {
|
||||
parts.append(String(segment))
|
||||
}
|
||||
}
|
||||
return parts.joined(separator: "/")
|
||||
}
|
||||
}
|
||||
|
||||
struct SchemaFuzzSeedGenerator {
|
||||
private var rng: DifferentialSeededRNG
|
||||
|
||||
init(seed: UInt64) {
|
||||
rng = DifferentialSeededRNG(seed: seed == 0 ? 1 : seed)
|
||||
}
|
||||
|
||||
private static let leafTypes = ["xs:string", "xs:int", "xs:boolean"]
|
||||
|
||||
private mutating func pick<T>(_ options: [T]) -> T {
|
||||
options[Int.random(in: 0 ..< options.count, using: &rng)]
|
||||
}
|
||||
|
||||
private mutating func toss() -> Bool {
|
||||
Bool.random(using: &rng)
|
||||
}
|
||||
|
||||
mutating func schema() -> String {
|
||||
"""
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"\
|
||||
\(toss() ? " targetNamespace=\"urn:t\"" : "")>
|
||||
<xs:element name="root" type="\(pick(Self.leafTypes))"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
private struct DifferentialSeededRNG: RandomNumberGenerator {
|
||||
private var state: UInt64
|
||||
|
||||
init(seed: UInt64) {
|
||||
state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed
|
||||
}
|
||||
|
||||
mutating func next() -> UInt64 {
|
||||
state ^= state >> 12
|
||||
state ^= state << 25
|
||||
state ^= state >> 27
|
||||
return state &* 0x2545_F491_4F6C_DD1D
|
||||
}
|
||||
}
|
||||
173
Tests/SchemaDifferentialTests.swift
Normal file
173
Tests/SchemaDifferentialTests.swift
Normal file
@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
private struct CuratedSchema {
|
||||
let label: String
|
||||
let source: String
|
||||
}
|
||||
|
||||
@Suite("Schema differential (opt-in via SCHEMA_DIFF_ORACLE)")
|
||||
struct SchemaDifferentialTests {
|
||||
private var enabled: Bool {
|
||||
ProcessInfo.processInfo.environment["SCHEMA_DIFF_ORACLE"] != nil
|
||||
}
|
||||
|
||||
private var xmllint: String? {
|
||||
if let path = ProcessInfo.processInfo.environment["XMLLINT"], !path.isEmpty {
|
||||
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
|
||||
}
|
||||
let defaultPath = "/usr/bin/xmllint"
|
||||
return FileManager.default.isExecutableFile(atPath: defaultPath) ? defaultPath : nil
|
||||
}
|
||||
|
||||
private var xstsRoot: String? {
|
||||
ProcessInfo.processInfo.environment["XSTS_ROOT"]
|
||||
}
|
||||
|
||||
private let curated: [CuratedSchema] = [
|
||||
CuratedSchema(
|
||||
label: "valid-simple",
|
||||
source: """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="root" type="xs:string"/>
|
||||
</xs:schema>
|
||||
""",
|
||||
),
|
||||
CuratedSchema(
|
||||
label: "invalid-duplicate-attribute",
|
||||
source: """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="root" type="xs:string" type="xs:int"/>
|
||||
</xs:schema>
|
||||
""",
|
||||
),
|
||||
CuratedSchema(
|
||||
label: "invalid-duplicate-global-element",
|
||||
source: """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="a" type="xs:string"/>
|
||||
<xs:element name="a" type="xs:int"/>
|
||||
</xs:schema>
|
||||
""",
|
||||
),
|
||||
CuratedSchema(
|
||||
label: "invalid-simpleType-two-derivations",
|
||||
source: """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:simpleType name="T">
|
||||
<xs:restriction base="xs:string"/>
|
||||
<xs:union memberTypes="xs:int"/>
|
||||
</xs:simpleType>
|
||||
</xs:schema>
|
||||
""",
|
||||
),
|
||||
]
|
||||
|
||||
private let knownFuzzDisagreements: Set<UInt64> = []
|
||||
|
||||
private let knownXSTSDisagreements = SchemaDifferentialXSTS.loadKnownDisagreements()
|
||||
|
||||
@Test("Curated schemas agree with libxml2 on compile verdict")
|
||||
func test_curatedCorpus() throws {
|
||||
guard enabled, let xmllint else { return }
|
||||
var disagreements: [String] = []
|
||||
for item in curated {
|
||||
let pure = SchemaDifferential.pureXMLVerdict(source: item.source)
|
||||
let oracle = try compareInMemory(source: item.source, label: item.label, pure: pure, xmllint: xmllint)
|
||||
if pure != oracle {
|
||||
disagreements.append("\(item.label): purexml=\(pure) libxml2=\(oracle)")
|
||||
}
|
||||
}
|
||||
try writeReport(disagreements)
|
||||
#expect(disagreements.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Generative fuzz schemas: libxml2 verdict baseline")
|
||||
func test_fuzzCorpus() throws {
|
||||
guard enabled, let xmllint else { return }
|
||||
var disagreements: [String] = []
|
||||
for seed in UInt64(1) ... 32 {
|
||||
var generator = SchemaFuzzSeedGenerator(seed: seed)
|
||||
let source = generator.schema()
|
||||
let pure = SchemaDifferential.pureXMLVerdict(source: source)
|
||||
let oracle = try compareInMemory(source: source, label: "fuzz-\(seed)", pure: pure, xmllint: xmllint)
|
||||
if pure != oracle {
|
||||
disagreements.append("fuzz-\(seed)")
|
||||
}
|
||||
}
|
||||
try writeReport(disagreements.map { "\($0): verdict mismatch" })
|
||||
let unexpected = Set(disagreements.compactMap { line -> UInt64? in
|
||||
guard line.hasPrefix("fuzz-"), let value = UInt64(line.dropFirst(5)) else { return nil }
|
||||
return value
|
||||
}).subtracting(knownFuzzDisagreements)
|
||||
#expect(unexpected.isEmpty)
|
||||
}
|
||||
|
||||
@Test("XSTS schema documents agree with libxml2 on compile verdict")
|
||||
func test_xstsSchemaDocuments() throws {
|
||||
guard enabled, let xmllint, let root = xstsRoot else { return }
|
||||
var disagreements: [String] = []
|
||||
let suite = try SchemaDifferentialXSTS.parsedManifest(at: root + "/suite.xml")
|
||||
for setHref in SchemaDifferentialXSTS.manifestReferences(in: suite, named: "testSetRef") {
|
||||
let setPath = root + "/" + setHref
|
||||
let setDirectory = SchemaDifferentialXSTS.directory(of: setPath)
|
||||
guard let testSet = try? SchemaDifferentialXSTS.parsedManifest(at: setPath) else { continue }
|
||||
for group in SchemaDifferentialXSTS.manifestElements(named: "testGroup", under: testSet) {
|
||||
let groupName = SchemaDifferentialXSTS.manifestAttribute("name", of: group) ?? "?"
|
||||
for schemaTest in SchemaDifferentialXSTS.manifestElements(named: "schemaTest", under: .element(group)) {
|
||||
guard SchemaDifferentialXSTS.schemaTestAccepted(schemaTest) else { continue }
|
||||
guard let href = SchemaDifferentialXSTS.manifestReferences(in: .element(schemaTest), named: "schemaDocument").first else {
|
||||
continue
|
||||
}
|
||||
let schemaPath = SchemaDifferentialXSTS.resolve(href, against: setDirectory)
|
||||
let schemaDirectory = SchemaDifferentialXSTS.directory(of: schemaPath)
|
||||
let loader: (String) -> String? = { location in
|
||||
try? String(contentsOfFile: SchemaDifferentialXSTS.resolve(location, against: schemaDirectory), encoding: .utf8)
|
||||
}
|
||||
guard let source = try? String(contentsOfFile: schemaPath, encoding: .utf8) else { continue }
|
||||
let key = "\(groupName):\(href)"
|
||||
let pure = SchemaDifferential.pureXMLVerdict(source: source, loader: loader)
|
||||
guard let oracle = SchemaDifferential.libxml2Verdict(
|
||||
schemaPath: schemaPath,
|
||||
xmllint: xmllint,
|
||||
rootElement: SchemaDifferential.rootElementName(in: source),
|
||||
) else { continue }
|
||||
if pure != oracle {
|
||||
disagreements.append(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try writeReport(disagreements.map { "\($0): verdict mismatch" })
|
||||
let unexpected = Set(disagreements).subtracting(knownXSTSDisagreements)
|
||||
#expect(unexpected.isEmpty)
|
||||
}
|
||||
|
||||
private func compareInMemory(
|
||||
source: String,
|
||||
label: String,
|
||||
pure: SchemaCompileVerdict,
|
||||
xmllint: String,
|
||||
) throws -> SchemaCompileVerdict {
|
||||
let directory = NSTemporaryDirectory() + "purexml-schema-diff-\(label)-\(UUID().uuidString)"
|
||||
try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(atPath: directory) }
|
||||
let schemaPath = directory + "/schema.xsd"
|
||||
try source.write(toFile: schemaPath, atomically: true, encoding: .utf8)
|
||||
guard let oracle = SchemaDifferential.libxml2Verdict(
|
||||
schemaPath: schemaPath,
|
||||
xmllint: xmllint,
|
||||
rootElement: SchemaDifferential.rootElementName(in: source),
|
||||
) else {
|
||||
return pure
|
||||
}
|
||||
return oracle
|
||||
}
|
||||
|
||||
private func writeReport(_ lines: [String]) throws {
|
||||
guard !lines.isEmpty else { return }
|
||||
let body = lines.joined(separator: "\n") + "\n"
|
||||
try body.write(toFile: "/tmp/schema-differential.txt", atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
109
Tests/SchemaIncludeTests.swift
Normal file
109
Tests/SchemaIncludeTests.swift
Normal file
@ -0,0 +1,109 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("XSD include composition (#161)")
|
||||
struct SchemaIncludeTests {
|
||||
@Test("chameleon include: no-namespace library merges into a target-namespace main schema")
|
||||
func test_chameleonIncludeWithTargetNamespace() throws {
|
||||
let library = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:simpleType name="sku">
|
||||
<xs:restriction base="xs:string"><xs:pattern value="[A-Z]{3}"/></xs:restriction>
|
||||
</xs:simpleType>
|
||||
</xs:schema>
|
||||
"""
|
||||
let main = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
targetNamespace="urn:main" xmlns="urn:main">
|
||||
<xs:include schemaLocation="lib.xsd"/>
|
||||
<xs:element name="code" type="sku"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
let doc = try PureXML.Schema.Document(main, schemaLoader: { $0 == "lib.xsd" ? library : nil })
|
||||
#expect(try doc.validate("<code xmlns=\"urn:main\">ABC</code>").isEmpty)
|
||||
#expect(try !doc.validate("<code xmlns=\"urn:main\">abc</code>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("xs:include with a mismatched targetNamespace is rejected")
|
||||
func test_invalidIncludeDifferentTargetNamespace() {
|
||||
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>
|
||||
"""
|
||||
#expect(throws: Error.self) {
|
||||
_ = try PureXML.Schema.Document(main, schemaLoader: { $0 == "other.xsd" ? included : nil })
|
||||
}
|
||||
}
|
||||
|
||||
@Test("xs:include with matching targetNamespace is accepted")
|
||||
func test_includeMatchingTargetNamespace() throws {
|
||||
let included = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
targetNamespace="urn:main" xmlns="urn:main">
|
||||
<xs:simpleType name="sku">
|
||||
<xs:restriction base="xs:string"><xs:pattern value="[A-Z]{3}"/></xs:restriction>
|
||||
</xs:simpleType>
|
||||
</xs:schema>
|
||||
"""
|
||||
let main = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
targetNamespace="urn:main" xmlns="urn:main">
|
||||
<xs:include schemaLocation="lib.xsd"/>
|
||||
<xs:element name="code" type="sku"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
let doc = try PureXML.Schema.Document(main, schemaLoader: { $0 == "lib.xsd" ? included : nil })
|
||||
#expect(try doc.validate("<code xmlns=\"urn:main\">ABC</code>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("chameleon include: attribute refs inside the included schema resolve after merge")
|
||||
func test_chameleonIncludeAttributeGroupRef() throws {
|
||||
let library = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:attribute name="a1" type="xs:string"/>
|
||||
<xs:attributeGroup name="g">
|
||||
<xs:attribute ref="a1"/>
|
||||
</xs:attributeGroup>
|
||||
</xs:schema>
|
||||
"""
|
||||
let main = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
targetNamespace="urn:main" xmlns="urn:main">
|
||||
<xs:include schemaLocation="lib.xsd"/>
|
||||
<xs:element name="doc">
|
||||
<xs:complexType>
|
||||
<xs:attributeGroup ref="g"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
let doc = try PureXML.Schema.Document(main, schemaLoader: { $0 == "lib.xsd" ? library : nil })
|
||||
#expect(try doc.validate("<doc xmlns=\"urn:main\" a1=\"x\"/>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("undeclared refs inside an included chameleon schema are rejected at compile time")
|
||||
func test_invalidReferenceInsideIncludedSchema() {
|
||||
let library = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:attributeGroup name="g">
|
||||
<xs:attribute ref="missing"/>
|
||||
</xs:attributeGroup>
|
||||
</xs:schema>
|
||||
"""
|
||||
let main = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:main">
|
||||
<xs:include schemaLocation="lib.xsd"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
#expect(throws: Error.self) {
|
||||
_ = try PureXML.Schema.Document(main, schemaLoader: { $0 == "lib.xsd" ? library : nil })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -51,4 +51,20 @@ struct SchemaMultiDocumentTests {
|
||||
let doc = try PureXML.Schema.Document(mainXSD)
|
||||
#expect(try !doc.validate(instance).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Streaming validation honors xsi:schemaLocation the same as the tree path")
|
||||
func test_streamingResolvesAcrossDocuments() throws {
|
||||
let doc = try PureXML.Schema.Document(mainXSD)
|
||||
let loader: (String) -> String? = { [otherXSD] in $0 == "other.xsd" ? otherXSD : nil }
|
||||
let tree = try doc.validate(instance, schemaLoader: loader).map(\.reason).sorted()
|
||||
let streamed = try doc.validate(streaming: instance, schemaLoader: loader).map(\.reason).sorted()
|
||||
#expect(tree == streamed)
|
||||
#expect(tree.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Streaming strict wildcard fails without the loader")
|
||||
func test_streamingStrictUnresolvedWithoutLoader() throws {
|
||||
let doc = try PureXML.Schema.Document(mainXSD)
|
||||
#expect(try !doc.validate(streaming: instance).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,17 +73,24 @@ struct SchemaSubstitutionTypeTests {
|
||||
))
|
||||
}
|
||||
|
||||
/// A self-contained schema is required: with an import the head's type may live
|
||||
/// in an unloaded document, so the check stands down and a genuinely-unrelated
|
||||
/// member is not flagged here (a disclosed under-rejection).
|
||||
@Test("With an import present the check stands down")
|
||||
func test_importStandsDown() {
|
||||
/// With an import but no loader the check stands down; once the import is
|
||||
/// loaded through a `schemaLoader` the member type is checked against the head.
|
||||
@Test("With an unloaded import the check stands down")
|
||||
func test_importWithoutLoaderStandsDown() {
|
||||
let main = "<xs:schema \(xsd) xmlns:imp=\"urn:imp\">"
|
||||
+ "<xs:import namespace=\"urn:imp\" schemaLocation=\"imp.xsd\"/>"
|
||||
+ "<xs:element name=\"member\" substitutionGroup=\"imp:head\" type=\"xs:int\"/></xs:schema>"
|
||||
#expect((try? PureXML.Schema.Document(main)) != nil)
|
||||
}
|
||||
|
||||
@Test("With a loaded import an unrelated member type is rejected")
|
||||
func test_loadedImportChecksMemberType() {
|
||||
let imported = "<xs:schema \(xsd) targetNamespace=\"urn:imp\">"
|
||||
+ "<xs:element name=\"head\" type=\"xs:string\"/></xs:schema>"
|
||||
let main = "<xs:schema \(xsd) xmlns:imp=\"urn:imp\">"
|
||||
+ "<xs:import namespace=\"urn:imp\" schemaLocation=\"imp.xsd\"/>"
|
||||
+ "<xs:element name=\"member\" substitutionGroup=\"imp:head\" type=\"xs:int\"/></xs:schema>"
|
||||
#expect((try? PureXML.Schema.Document(main, schemaLoader: { $0 == "imp.xsd" ? imported : nil })) != nil)
|
||||
#expect((try? PureXML.Schema.Document(main, schemaLoader: { $0 == "imp.xsd" ? imported : nil })) == nil)
|
||||
}
|
||||
|
||||
@Test("Substitution group member is blocked if type derivation method is final on the head element")
|
||||
|
||||
118
Tests/ValidationConfigurationTests.swift
Normal file
118
Tests/ValidationConfigurationTests.swift
Normal file
@ -0,0 +1,118 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
/// Configuration-pin tests: every validator variant lists exactly its active rule
|
||||
/// descriptions, so adding or rewording a rule fails a test before it is accidental.
|
||||
@Suite("Validation configuration pins")
|
||||
struct ValidationConfigurationTests {
|
||||
@Test("The structural default validator lists its rules")
|
||||
func test_structuralDefault() {
|
||||
#expect(PureXML.Validation.Validator<Void>().validationDescriptions == [
|
||||
"Element attribute names are unique",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("The blank validator carries no rules")
|
||||
func test_blank() {
|
||||
#expect(PureXML.Validation.Validator<Void>.blank.validationDescriptions.isEmpty)
|
||||
#expect(PureXML.Validation.Validator<PureXML.Validation.DTDSchema>.blank.validationDescriptions.isEmpty)
|
||||
}
|
||||
|
||||
@Test("The lenient DTD validator lists its non-strict rules in order")
|
||||
func test_dtdLenient() {
|
||||
let validator = PureXML.Validation.DTD.validator(strict: false)
|
||||
#expect(validator.nonReferenceValidationDescriptions == [
|
||||
"Element content matches its DTD content model",
|
||||
"Required DTD attributes are present",
|
||||
"#FIXED DTD attributes hold their fixed value",
|
||||
"Enumerated DTD attributes hold a listed value",
|
||||
"Tokenized DTD attributes match their declared type",
|
||||
"NOTATION DTD attributes name a declared notation",
|
||||
"The DTD declarations satisfy their validity constraints",
|
||||
"The root element matches the DOCTYPE name",
|
||||
"Standalone documents do not depend on external attribute declarations",
|
||||
"Standalone documents have no whitespace in externally-declared element content",
|
||||
"Parse-time DTD advisories are reviewed",
|
||||
])
|
||||
#expect(validator.referenceValidationDescriptions == [
|
||||
"DTD ID values are unique and IDREFs resolve",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("The strict DTD validator appends undeclared element and attribute rules to the reference tier")
|
||||
func test_dtdStrict() {
|
||||
let validator = PureXML.Validation.DTD.validator(strict: true)
|
||||
#expect(validator.referenceValidationDescriptions.suffix(2) == [
|
||||
"Element is declared in the DTD",
|
||||
"Every attribute is declared in the DTD",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("The HTML validator splits local and document-scoped rules across tiers")
|
||||
func test_html() {
|
||||
let validator = PureXML.Validation.HTML.validator()
|
||||
#expect(validator.nonReferenceValidationDescriptions == [
|
||||
"Void HTML elements have no content",
|
||||
"HTML elements appear inside their required parent",
|
||||
])
|
||||
#expect(validator.referenceValidationDescriptions == [
|
||||
"HTML id attributes are unique",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("The XSD instance validator splits content and identity rules across tiers")
|
||||
func test_xsdInstance() {
|
||||
let validator = PureXML.Validation.XSD.validator()
|
||||
#expect(validator.nonReferenceValidationDescriptions == [
|
||||
"The document element is valid against its XSD type",
|
||||
])
|
||||
#expect(validator.referenceValidationDescriptions == [
|
||||
"XSD identity constraints hold",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("The XSD schema-consistency validator lists named-type rules in tier order")
|
||||
func test_xsdSchemaConsistency() {
|
||||
let validator = PureXML.Validation.XSDSchema.validator()
|
||||
#expect(validator.nonReferenceValidationDescriptions == [
|
||||
"A type derives from its base only by methods the base permits",
|
||||
])
|
||||
#expect(validator.referenceValidationDescriptions == [
|
||||
"A restriction's content model accepts a subset of its base's",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("The schema compile validators pin their pre and post rule sets")
|
||||
func test_schemaCompile() {
|
||||
#expect(PureXML.Validation.SchemaCompile.preCompileValidator().validationDescriptions == [
|
||||
"Schema component id attributes are valid NCNames and unique",
|
||||
"Schema vocabulary elements follow the schema-for-schemas structure",
|
||||
"Global schema component names are unique within their symbol spaces",
|
||||
"Simple-type final controls are declared consistently",
|
||||
"Schema content models are deterministic (UPA)",
|
||||
"Type derivation chains contain no cycles",
|
||||
"Schema type references contain no cycles",
|
||||
"xs:all group references appear only where permitted",
|
||||
"Included schemas are chameleon or match the includer targetNamespace",
|
||||
])
|
||||
#expect(PureXML.Validation.SchemaCompile.postCompileValidator().validationDescriptions == [
|
||||
"Attribute uses are unique and declare at most one ID attribute",
|
||||
"ID-typed value constraints are valid",
|
||||
"Element value constraints are valid against their declared types",
|
||||
"Complex types extending xs:all groups satisfy XSD placement rules",
|
||||
"Attribute restrictions are faithful to their bases",
|
||||
"Simple types do not derive from complex types",
|
||||
"Simple-type varieties are declared consistently",
|
||||
"Notation declarations are valid",
|
||||
"Every schema reference resolves to a declared component",
|
||||
"Substitution-group members derive correctly from their head",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Every builtin description is unique and pinned")
|
||||
func test_builtinRegistry() {
|
||||
let descriptions = PureXML.Validation.BuiltinValidation.allDescriptions
|
||||
#expect(descriptions.count == Set(descriptions).count)
|
||||
#expect(descriptions.count == 24)
|
||||
}
|
||||
}
|
||||
164
Tests/ValidationFieldCoverageTests.swift
Normal file
164
Tests/ValidationFieldCoverageTests.swift
Normal file
@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
/// Meta-tests for validation-rules.md full field coverage: every stored property
|
||||
/// (or ``Model/Node`` case) on validation document/subject types must map to a rule
|
||||
/// id in ``docs/validation-field-registry.txt`` or an explicit IGNORE entry.
|
||||
@Suite("Validation field coverage")
|
||||
struct ValidationFieldCoverageTests {
|
||||
private struct Registry {
|
||||
var fields: [String: [String: String]] = [:]
|
||||
var cases: [String: [String: String]] = [:]
|
||||
var ignored: [String: Set<String>] = [:]
|
||||
}
|
||||
|
||||
private static let canonicalStoredProperties: [String: Set<String>] = [
|
||||
"PureXML.Validation.DTDSchema": [
|
||||
"models", "attributes", "notations", "unparsedEntities", "doctypeName",
|
||||
"parseAdvisories", "declarationErrors", "standalone", "externalElementModels", "externalAttributes", "isEmpty",
|
||||
],
|
||||
"PureXML.Validation.XSDContext": [
|
||||
"types", "constraints", "rootDeclaration", "nillableElements", "elementConstraints",
|
||||
"abstractTypes", "typeBlock", "elementBlock", "typeDerivation",
|
||||
],
|
||||
"PureXML.Model.Element": ["name", "attributes", "children", "text"],
|
||||
"PureXML.Model.Attribute": ["name", "value"],
|
||||
"PureXML.Validation.ConformanceCase": ["name", "actual", "expected"],
|
||||
"PureXML.Schema.SchemaTypeFact": ["name", "derivation"],
|
||||
"PureXML.Schema.CompiledSchemaFacts": ["types", "typeDerivation", "typeFinal"],
|
||||
"PureXML.Schema.ResolvedElement": ["element", "type"],
|
||||
]
|
||||
|
||||
private static let canonicalNodeCases: Set<String> = [
|
||||
"document", "element", "text", "cdata", "comment", "processingInstruction",
|
||||
]
|
||||
|
||||
private static let knownRuleTokens: Set<String> = {
|
||||
var tokens = Set(PureXML.Validation.BuiltinValidation.allRuleIDs)
|
||||
tokens.formUnion([
|
||||
"uniqueAttributes", "dtdContentModel", "dtdRequiredAttributes", "dtdFixedAttributeValues",
|
||||
"dtdEnumeratedAttributeValues", "dtdTokenizedAttributeTypes", "dtdNotationAttributes",
|
||||
"dtdUndeclaredElement", "dtdUndeclaredAttributes", "dtdDeclarationValidity",
|
||||
"dtdRootElementType", "dtdIdentifierIntegrity", "dtdStandaloneAttributes",
|
||||
"dtdStandaloneElementWhitespace", "dtdParseAdvisories", "htmlVoidElementsAreEmpty", "htmlRequiredParent",
|
||||
"htmlUniqueIdentifiers", "xsdContentValidity", "xsdIdentityConstraints",
|
||||
"xsdFinalRespected", "xsdRestrictionsAreSubsets", "conformanceMatchesExpected",
|
||||
"xsdStreamingShallowValidity",
|
||||
])
|
||||
return tokens
|
||||
}()
|
||||
|
||||
private func applyRegistryLine(_ keyword: String, parts: [String], model: inout String?, registry: inout Registry) {
|
||||
switch keyword {
|
||||
case "MODEL":
|
||||
model = parts[1]
|
||||
case "FIELD":
|
||||
guard let model else { return }
|
||||
registry.fields[model, default: [:]][parts[1]] = parts[2]
|
||||
case "CASE":
|
||||
guard let model else { return }
|
||||
registry.cases[model, default: [:]][parts[1]] = parts[2]
|
||||
case "IGNORE":
|
||||
guard let model else { return }
|
||||
registry.ignored[model, default: []].insert(parts[1])
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private static let fixtureRelativePath = "Tests/Fixtures/validation-field-registry.txt"
|
||||
|
||||
private func loadRegistry() throws -> Registry {
|
||||
let text = try Self.registryText()
|
||||
var registry = Registry()
|
||||
var currentModel: String?
|
||||
for line in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
let parts = trimmed.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
guard let keyword = parts.first else { continue }
|
||||
applyRegistryLine(keyword, parts: parts, model: ¤tModel, registry: ®istry)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
private static func registryText() throws -> String {
|
||||
#if os(WASI)
|
||||
let path = FileManager.default.currentDirectoryPath + "/" + fixtureRelativePath
|
||||
return try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
|
||||
#else
|
||||
if let url = Bundle.module.url(
|
||||
forResource: "validation-field-registry",
|
||||
withExtension: "txt",
|
||||
subdirectory: "Fixtures",
|
||||
) {
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
}
|
||||
let docsPath = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("docs/validation-field-registry.txt")
|
||||
return try String(contentsOf: docsPath, encoding: .utf8)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func ruleTokens(from listing: String) -> [String] {
|
||||
listing.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
}
|
||||
|
||||
@Test("Every stored property is classified in the field registry")
|
||||
func test_storedPropertiesCovered() throws {
|
||||
let registry = try loadRegistry()
|
||||
for (model, properties) in Self.canonicalStoredProperties {
|
||||
let covered = Set(registry.fields[model, default: [:]].keys)
|
||||
let ignored = registry.ignored[model, default: []]
|
||||
#expect(
|
||||
covered.union(ignored) == properties,
|
||||
"model \(model): missing \(properties.subtracting(covered.union(ignored)).sorted()), extra \(covered.union(ignored).subtracting(properties).sorted())",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Every Model.Node case is classified in the field registry")
|
||||
func test_nodeCasesCovered() throws {
|
||||
let registry = try loadRegistry()
|
||||
let covered = Set(registry.cases["PureXML.Model.Node", default: [:]].keys)
|
||||
let ignored = registry.ignored["PureXML.Model.Node", default: []]
|
||||
#expect(
|
||||
covered.union(ignored) == Self.canonicalNodeCases,
|
||||
"PureXML.Model.Node: missing \(Self.canonicalNodeCases.subtracting(covered.union(ignored)).sorted())",
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Registry rule tokens reference known validation rules")
|
||||
func test_ruleTokensKnown() throws {
|
||||
let registry = try loadRegistry()
|
||||
var unknown: [String] = []
|
||||
for (_, fields) in registry.fields {
|
||||
for (_, listing) in fields {
|
||||
for token in ruleTokens(from: listing) where !Self.knownRuleTokens.contains(token) {
|
||||
unknown.append(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, cases) in registry.cases {
|
||||
for (_, listing) in cases {
|
||||
for token in ruleTokens(from: listing) where !Self.knownRuleTokens.contains(token) {
|
||||
unknown.append(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(unknown.isEmpty, "unknown rule tokens: \(Set(unknown).sorted())")
|
||||
}
|
||||
|
||||
@Test("A planted registry gap is detected")
|
||||
func test_plantedGapFails() throws {
|
||||
var registry = try loadRegistry()
|
||||
registry.fields["PureXML.Validation.DTDSchema", default: [:]]["__planted__"] = "dtdContentModel"
|
||||
let properties = Self.canonicalStoredProperties["PureXML.Validation.DTDSchema"] ?? []
|
||||
let covered = Set(registry.fields["PureXML.Validation.DTDSchema", default: [:]].keys)
|
||||
let ignored = registry.ignored["PureXML.Validation.DTDSchema", default: []]
|
||||
#expect(covered.union(ignored) != properties)
|
||||
}
|
||||
}
|
||||
@ -157,11 +157,38 @@ struct ValidationFrameworkTests {
|
||||
#expect((atA + atB).count == 2)
|
||||
}
|
||||
|
||||
@Test("Error rendering: at-path, at-root, and a stripped trailing period")
|
||||
func test_errorRendering() {
|
||||
let rooted = Error(reason: "Bad.", at: [])
|
||||
#expect(String(describing: rooted) == "Bad at root of document")
|
||||
let located = Error(reason: "Bad value", at: [.element("box"), .element("id")])
|
||||
#expect(String(describing: located) == "Bad value at path: box/id")
|
||||
@Test("lookup resolves against the document store")
|
||||
func test_lookup() {
|
||||
struct Item: PureXML.Validation.Validatable { var key: String }
|
||||
struct Store { var items: [String: Item] = [:] }
|
||||
typealias Context = PureXML.Validation.ValidationContext<Item, Store>
|
||||
let rule: (Context) -> [PureXML.Validation.ValidationError] = lookup(
|
||||
\Store.items,
|
||||
name: \Item.key,
|
||||
missing: { "missing \($0)" },
|
||||
into: PureXML.Validation.Validation(description: "present", check: { _ in true }),
|
||||
)
|
||||
let store = Store(items: ["a": Item(key: "a")])
|
||||
let context = Context(document: store, subject: Item(key: "a"), codingPath: [])
|
||||
#expect(rule(context).isEmpty)
|
||||
let missing = Context(document: store, subject: Item(key: "z"), codingPath: [])
|
||||
#expect(rule(missing).count == 1)
|
||||
}
|
||||
|
||||
@Test("Parameter-pack KeyPath validating mixes void-document builtins")
|
||||
func test_keyPathParameterPack() {
|
||||
let validator = PureXML.Validation.Validator<Void>.blank
|
||||
.validating(\.uniqueAttributes, \.htmlVoidElementsAreEmpty)
|
||||
#expect(validator.validationDescriptions == [
|
||||
"Element attribute names are unique",
|
||||
"Void HTML elements have no content",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Validator.outcome returns valid when no rules fail")
|
||||
func test_outcomeValid() {
|
||||
let node = PureXML.Model.Node.document([.element(.init("a"))])
|
||||
let outcome = PureXML.Validation.Validator<Void>().outcome(for: node, in: ())
|
||||
#expect(outcome.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
254
Tests/ValidationRuleDTDTests.swift
Normal file
254
Tests/ValidationRuleDTDTests.swift
Normal file
@ -0,0 +1,254 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
/// Per-rule isolation tests (Validation rules DTD).
|
||||
@Suite("Validation rules DTD")
|
||||
struct ValidationRuleDTDTests {
|
||||
@Test("DTD.contentModel reports an EMPTY element with content at the element path")
|
||||
func test_contentModel() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY>]><r><c/></r>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.contentModel)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "element <r> is declared EMPTY but has content")
|
||||
#expect(ValidationRuleTestSupport.path(errors[0]) == ["r"])
|
||||
}
|
||||
|
||||
@Test("DTD.contentModel accepts an EMPTY element with no content")
|
||||
func test_dtdContentModel_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY>]><r/>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.contentModel)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.requiredAttributes reports a missing required attribute in isolation")
|
||||
func test_requiredAttributes() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r id CDATA #REQUIRED>]><r/>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.requiredAttributes)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "required attribute 'id' is missing on <r>")
|
||||
#expect(ValidationRuleTestSupport.path(errors[0]) == ["r"])
|
||||
}
|
||||
|
||||
@Test("DTD.fixedAttributeValues isolates the #FIXED constraint")
|
||||
func test_fixedAttributeValues() throws {
|
||||
// A missing required attribute is invisible to the fixed-value rule alone.
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r v CDATA #FIXED \"1\" id CDATA #REQUIRED>]><r v=\"2\"/>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.fixedAttributeValues)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "attribute 'v' on <r> is #FIXED and must be \"1\"")
|
||||
}
|
||||
|
||||
@Test("DTD.requiredAttributes accepts a present required attribute")
|
||||
func test_dtdRequiredAttributes_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r id CDATA #REQUIRED>]><r id=\"1\"/>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.requiredAttributes).errors(for: node, in: schema).isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.fixedAttributeValues accepts the fixed value")
|
||||
func test_dtdFixedAttributeValues_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r v CDATA #FIXED \"1\">]><r v=\"1\"/>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.fixedAttributeValues).errors(for: node, in: schema)
|
||||
.isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.enumeratedAttributeValues accepts a listed value")
|
||||
func test_dtdEnumeratedAttributeValues_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r k (a|b) #IMPLIED>]><r k=\"a\"/>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.enumeratedAttributeValues).errors(for: node, in: schema)
|
||||
.isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.tokenizedAttributeTypes accepts a valid NMTOKEN")
|
||||
func test_dtdTokenizedAttributeTypes_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r t NMTOKEN #IMPLIED>]><r t=\"abc\"/>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.tokenizedAttributeTypes).errors(for: node, in: schema)
|
||||
.isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.notationAttributes accepts a declared notation")
|
||||
func test_dtdNotationAttributes_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport
|
||||
.dtd("<!DOCTYPE r [<!NOTATION bmp SYSTEM \"b\"><!ELEMENT r EMPTY><!ATTLIST r kind NOTATION (bmp) #IMPLIED>]><r kind=\"bmp\"/>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.notationAttributes).errors(for: node, in: schema).isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.enumeratedAttributeValues isolates the enumeration constraint")
|
||||
func test_enumeratedAttributeValues() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r k (a|b) #IMPLIED>]><r k=\"z\"/>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.enumeratedAttributeValues)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "attribute 'k' on <r> has a value outside its enumeration")
|
||||
}
|
||||
|
||||
@Test("DTD.tokenizedAttributeTypes isolates the NMTOKEN constraint")
|
||||
func test_tokenizedAttributeTypes() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r t NMTOKEN #IMPLIED>]><r t=\"a b\"/>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.tokenizedAttributeTypes)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason.contains("NMTOKEN") == true)
|
||||
}
|
||||
|
||||
@Test("DTD.notationAttributes isolates the NOTATION constraint")
|
||||
func test_notationAttributes() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r kind NOTATION (bmp) #IMPLIED>]><r kind=\"bmp\"/>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.notationAttributes)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason.contains("undeclared notation") == true)
|
||||
}
|
||||
|
||||
@Test("DTD.undeclaredElement uses the single-error Bool form (Failed to satisfy)")
|
||||
func test_undeclaredElement() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT a EMPTY>]><r/>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.undeclaredElement)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "Failed to satisfy: Element is declared in the DTD")
|
||||
#expect(ValidationRuleTestSupport.path(errors[0]) == ["r"])
|
||||
}
|
||||
|
||||
@Test("DTD.identifierIntegrity reports a duplicate ID at the document root")
|
||||
func test_identifierIntegrity() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ATTLIST a id ID #IMPLIED>]><r><a id=\"x\"/><a id=\"x\"/></r>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.identifierIntegrity)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "duplicate ID 'x' (declared 2 times)")
|
||||
// Runs once over the whole tree, so it is located at the document root.
|
||||
#expect(ValidationRuleTestSupport.path(errors[0]).isEmpty)
|
||||
#expect(String(describing: errors[0]).hasSuffix("at root of document"))
|
||||
}
|
||||
|
||||
@Test("DTD.identifierIntegrity accepts unique IDs and resolved IDREFs")
|
||||
func test_dtdIdentifierIntegrity_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ATTLIST a id ID #IMPLIED ref IDREF #IMPLIED>]><r><a id=\"y\"/><a ref=\"y\"/></r>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.identifierIntegrity).errors(for: node, in: schema)
|
||||
.isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.undeclaredElement accepts a declared root element")
|
||||
func test_dtdUndeclaredElement_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY>]><r/>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.undeclaredElement).errors(for: node, in: schema).isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.undeclaredAttributes reports an attribute not declared in the DTD")
|
||||
func test_dtdUndeclaredAttributes() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY>]><r extra=\"1\"/>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.undeclaredAttributes).errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason.contains("extra") == true)
|
||||
}
|
||||
|
||||
@Test("DTD.undeclaredAttributes accepts only declared attributes")
|
||||
func test_dtdUndeclaredAttributes_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r id CDATA #IMPLIED>]><r id=\"1\"/>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.undeclaredAttributes).errors(for: node, in: schema)
|
||||
.isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.declarationValidity reports duplicate element declarations")
|
||||
func test_dtdDeclarationValidity() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ELEMENT x (#PCDATA)><!ELEMENT x (#PCDATA)>]><r/>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.declarationValidity).errors(for: node, in: schema)
|
||||
#expect(errors.contains { $0.reason.contains("declared more than once") })
|
||||
}
|
||||
|
||||
@Test("DTD.declarationValidity accepts a well-formed declaration set")
|
||||
func test_dtdDeclarationValidity_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r id CDATA #IMPLIED>]><r/>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.declarationValidity).errors(for: node, in: schema)
|
||||
.isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.rootElementType requires the root to match the DOCTYPE name")
|
||||
func test_dtdRootElementType() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE expected [<!ELEMENT expected EMPTY><!ELEMENT other EMPTY>]>\n<other/>")
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.rootElementType).errors(for: node, in: schema)
|
||||
#expect(errors.contains { $0.reason.contains("does not match the DOCTYPE name") })
|
||||
}
|
||||
|
||||
@Test("DTD.rootElementType accepts a matching root element name")
|
||||
func test_dtdRootElementType_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE expected [<!ELEMENT expected EMPTY>]>\n<expected/>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.rootElementType).errors(for: node, in: schema).isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.standaloneAttributes rejects an externally-declared default in standalone documents")
|
||||
func test_dtdStandaloneAttributes() throws {
|
||||
let xml = "<?xml version='1.0' standalone='yes'?>\n<!DOCTYPE root SYSTEM \"x.dtd\">\n<root/>"
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd(xml, resolver: ValidationRuleTestSupport.standaloneResolver())
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.standaloneAttributes).errors(for: node, in: schema)
|
||||
#expect(errors.contains { $0.reason.contains("externally-declared default") })
|
||||
}
|
||||
|
||||
@Test("DTD.standaloneAttributes accepts internally-declared attributes in standalone documents")
|
||||
func test_dtdStandaloneAttributes_succeeds() throws {
|
||||
let xml = """
|
||||
<?xml version='1.0' standalone='yes'?>
|
||||
<!DOCTYPE root SYSTEM "x.dtd" [<!ATTLIST root token (a|b|c) "a">]>
|
||||
<root token="b" id="ok"/>
|
||||
"""
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd(xml, resolver: ValidationRuleTestSupport.standaloneResolver())
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.standaloneAttributes).errors(for: node, in: schema)
|
||||
.isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.standaloneElementWhitespace rejects whitespace in externally-declared mixed content")
|
||||
func test_dtdStandaloneElementWhitespace() throws {
|
||||
let xml = "<?xml version='1.0' standalone='yes'?>\n<!DOCTYPE root SYSTEM \"x.dtd\">\n<root token=\"b\" id=\"ok\">\n <child>x</child>\n</root>"
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd(xml, resolver: ValidationRuleTestSupport.standaloneResolver())
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.standaloneElementWhitespace).errors(
|
||||
for: node,
|
||||
in: schema,
|
||||
)
|
||||
#expect(errors.contains { $0.reason.contains("whitespace in the externally-declared element content") })
|
||||
}
|
||||
|
||||
@Test("DTD.standaloneElementWhitespace accepts compact content in standalone documents")
|
||||
func test_dtdStandaloneElementWhitespace_succeeds() throws {
|
||||
let xml = "<?xml version='1.0' standalone='yes'?>\n<!DOCTYPE root SYSTEM \"x.dtd\">\n<root token=\"b\" id=\"ok\"><child>x</child></root>"
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd(xml, resolver: ValidationRuleTestSupport.standaloneResolver())
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.standaloneElementWhitespace).errors(for: node, in: schema)
|
||||
.isEmpty)
|
||||
}
|
||||
|
||||
@Test("DTD.parseAdvisories surfaces parse-time advisories at the document root")
|
||||
func test_dtdParseAdvisories() throws {
|
||||
let loose = """
|
||||
<!DOCTYPE root [
|
||||
<!ELEMENT root (#PCDATA)>
|
||||
<!ENTITY % outside SYSTEM "x.ent">
|
||||
%outside;
|
||||
%undeclared;
|
||||
]>
|
||||
<root/>
|
||||
"""
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd(loose, resolver: ValidationRuleTestSupport.standaloneResolver())
|
||||
let warnings = PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.parseAdvisories)
|
||||
.warnings(for: node, in: schema)
|
||||
#expect(warnings.contains { $0.reason.contains("'%undeclared;' is referenced but not declared") })
|
||||
}
|
||||
|
||||
@Test("DTD.parseAdvisories stays quiet when there are no advisories")
|
||||
func test_dtdParseAdvisories_succeeds() throws {
|
||||
let (node, schema) = try ValidationRuleTestSupport.dtd("<!DOCTYPE r [<!ELEMENT r EMPTY>]><r/>")
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.DTDSchema>.blank.validating(PureXML.Validation.DTD.parseAdvisories).warnings(for: node, in: schema).isEmpty)
|
||||
}
|
||||
}
|
||||
140
Tests/ValidationRuleMiscTests.swift
Normal file
140
Tests/ValidationRuleMiscTests.swift
Normal file
@ -0,0 +1,140 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
/// Per-rule isolation tests (Validation rules misc).
|
||||
@Suite("Validation rules misc")
|
||||
struct ValidationRuleMiscTests {
|
||||
// MARK: HTML
|
||||
|
||||
@Test("HTML.voidElementsAreEmpty accepts an empty void element")
|
||||
func test_htmlVoidElementsAreEmpty_succeeds() {
|
||||
let node = PureXML.Model.Node.document([.element(.init("br"))])
|
||||
let errors = PureXML.Validation.Validator<Void>.blank
|
||||
.validating(PureXML.Validation.HTML.voidElementsAreEmpty)
|
||||
.errors(for: node, in: ())
|
||||
#expect(errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test("HTML.requiredParent accepts li inside ul")
|
||||
func test_htmlRequiredParent_succeeds() {
|
||||
let node = PureXML.Model.Node.document([
|
||||
.element(.init("ul", children: [.element(.init("li"))])),
|
||||
])
|
||||
let errors = PureXML.Validation.Validator<Void>.blank
|
||||
.validating(PureXML.Validation.HTML.requiredParent)
|
||||
.errors(for: node, in: ())
|
||||
#expect(errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test("HTML.uniqueIdentifiers accepts distinct ids")
|
||||
func test_htmlUniqueIdentifiers_succeeds() {
|
||||
let node = PureXML.Model.Node.document([
|
||||
.element(.init("a", attributes: [.init("id", "one")])),
|
||||
.element(.init("b", attributes: [.init("id", "two")])),
|
||||
])
|
||||
let errors = PureXML.Validation.Validator<Void>.blank
|
||||
.validating(PureXML.Validation.HTML.uniqueIdentifiers)
|
||||
.errors(for: node, in: ())
|
||||
#expect(errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test("HTML.voidElementsAreEmpty rejects content inside a void element")
|
||||
func test_htmlVoidElementsAreEmpty() {
|
||||
let node = PureXML.Model.Node.document([.element(.init("br", children: [.text("no")]))])
|
||||
#expect(!PureXML.Validation.Validator<Void>.blank.validating(PureXML.Validation.HTML.voidElementsAreEmpty).errors(for: node, in: ()).isEmpty)
|
||||
}
|
||||
|
||||
@Test("HTML.requiredParent rejects li outside ul/ol/menu")
|
||||
func test_htmlRequiredParent() {
|
||||
let node = PureXML.Model.Node.document([.element(.init("li"))])
|
||||
#expect(!PureXML.Validation.Validator<Void>.blank.validating(PureXML.Validation.HTML.requiredParent).errors(for: node, in: ()).isEmpty)
|
||||
}
|
||||
|
||||
@Test("HTML.uniqueIdentifiers rejects duplicate id values")
|
||||
func test_htmlUniqueIdentifiers() {
|
||||
let node = PureXML.Model.Node.document([
|
||||
.element(.init("a", attributes: [.init("id", "dup")])),
|
||||
.element(.init("b", attributes: [.init("id", "dup")])),
|
||||
])
|
||||
#expect(!PureXML.Validation.Validator<Void>.blank.validating(PureXML.Validation.HTML.uniqueIdentifiers).errors(for: node, in: ()).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Conformance
|
||||
|
||||
@Test("Conformance.matchesExpected reports a divergent actual value")
|
||||
func test_conformanceMatchesExpected() {
|
||||
let testCase = PureXML.Validation.ConformanceCase(name: "sample", actual: "bad", expected: "good")
|
||||
let errors = PureXML.Validation.BuiltinValidation.conformanceMatchesExpected.apply(to: testCase, at: [.element("sample")], in: ())
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test("Conformance.matchesExpected accepts a matching actual value")
|
||||
func test_conformanceMatchesExpected_succeeds() {
|
||||
let testCase = PureXML.Validation.ConformanceCase(name: "sample", actual: "good", expected: "good")
|
||||
#expect(PureXML.Validation.BuiltinValidation.conformanceMatchesExpected.apply(to: testCase, at: [.element("sample")], in: ()).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: XSD streaming
|
||||
|
||||
@Test("ComplexValidator.shallowValidity rejects empty content that carries a child")
|
||||
func test_xsdStreamingShallowValidity() {
|
||||
let validator = PureXML.Schema.ComplexValidator()
|
||||
let bad = PureXML.Schema.ResolvedElement(
|
||||
element: PureXML.Model.Element("v", children: [.element(.init("child"))]),
|
||||
type: .complex(PureXML.Schema.ComplexType(content: .empty)),
|
||||
)
|
||||
#expect(!PureXML.Validation.BuiltinValidation.xsdStreamingShallowValidity.apply(to: bad, at: [.element("v")], in: validator).isEmpty)
|
||||
}
|
||||
|
||||
@Test("ComplexValidator.shallowValidity accepts an empty element against an empty type")
|
||||
func test_xsdStreamingShallowValidity_succeeds() {
|
||||
let validator = PureXML.Schema.ComplexValidator()
|
||||
let good = PureXML.Schema.ResolvedElement(
|
||||
element: PureXML.Model.Element("v"),
|
||||
type: .complex(PureXML.Schema.ComplexType(content: .empty)),
|
||||
)
|
||||
#expect(PureXML.Validation.BuiltinValidation.xsdStreamingShallowValidity.apply(to: good, at: [.element("v")], in: validator).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Machinery
|
||||
|
||||
private struct Named: PureXML.Validation.Validatable {
|
||||
var name: String
|
||||
}
|
||||
|
||||
private struct NamedDocument {
|
||||
var items: [String: Named] = [:]
|
||||
}
|
||||
|
||||
@Test("lookup resolves a document entry and validates it")
|
||||
func test_lookupCombinator() {
|
||||
var document = NamedDocument(items: ["a": Named(name: "ok")])
|
||||
let rule = PureXML.Validation.Validation<Named, NamedDocument>(
|
||||
description: "Name is ok",
|
||||
check: \Named.name == "ok",
|
||||
)
|
||||
let lookupRule = PureXML.Validation.Validation<Named, NamedDocument>(
|
||||
description: "Named item resolves",
|
||||
check: lookup(\.items, name: \Named.name, missing: { "missing \($0)" }, into: rule),
|
||||
)
|
||||
let hit = lookupRule.apply(to: Named(name: "a"), at: [.element("a")], in: document)
|
||||
#expect(hit.isEmpty)
|
||||
let miss = lookupRule.apply(to: Named(name: "z"), at: [.element("z")], in: document)
|
||||
#expect(miss.count == 1)
|
||||
#expect(miss[0].reason == "missing z")
|
||||
document.items["bad"] = Named(name: "nope")
|
||||
let bad = lookupRule.apply(to: Named(name: "bad"), at: [.element("bad")], in: document)
|
||||
#expect(bad.first?.reason == "Failed to satisfy: Name is ok")
|
||||
}
|
||||
|
||||
@Test("Validator.outcome collects errors without throwing")
|
||||
func test_validationOutcome() {
|
||||
let element = PureXML.Model.Element("a", attributes: [.init("x", "1"), .init("x", "2")])
|
||||
let node = PureXML.Model.Node.document([.element(element)])
|
||||
let outcome = PureXML.Validation.Validator<Void>.blank
|
||||
.validating(PureXML.Validation.Structural.uniqueAttributes)
|
||||
.outcome(for: node, in: ())
|
||||
#expect(!outcome.isValid)
|
||||
#expect(outcome.errors.count == 1)
|
||||
}
|
||||
}
|
||||
52
Tests/ValidationRuleRegistryTests.swift
Normal file
52
Tests/ValidationRuleRegistryTests.swift
Normal file
@ -0,0 +1,52 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
/// Meta-tests for validation-rules.md exhaustive coverage: every shipped builtin
|
||||
/// rule must have a fail and a near-miss succeed isolation test in
|
||||
/// ``ValidationRule*Tests`` suites (update ``pairs`` when adding a rule or test).
|
||||
@Suite("Validation rule registry")
|
||||
struct ValidationRuleRegistryTests {
|
||||
private typealias RuleTestPair = (fail: String, succeed: String)
|
||||
|
||||
/// Maps each ``BuiltinValidation/allRuleIDs`` entry to its isolation test names.
|
||||
private static let pairs: [String: RuleTestPair] = [
|
||||
"uniqueAttributes": ("test_uniqueAttributes", "test_uniqueAttributes_succeeds"),
|
||||
"dtdContentModel": ("test_contentModel", "test_dtdContentModel_succeeds"),
|
||||
"dtdRequiredAttributes": ("test_requiredAttributes", "test_dtdRequiredAttributes_succeeds"),
|
||||
"dtdFixedAttributeValues": ("test_fixedAttributeValues", "test_dtdFixedAttributeValues_succeeds"),
|
||||
"dtdEnumeratedAttributeValues": ("test_enumeratedAttributeValues", "test_dtdEnumeratedAttributeValues_succeeds"),
|
||||
"dtdTokenizedAttributeTypes": ("test_tokenizedAttributeTypes", "test_dtdTokenizedAttributeTypes_succeeds"),
|
||||
"dtdNotationAttributes": ("test_notationAttributes", "test_dtdNotationAttributes_succeeds"),
|
||||
"dtdUndeclaredElement": ("test_undeclaredElement", "test_dtdUndeclaredElement_succeeds"),
|
||||
"dtdUndeclaredAttributes": ("test_dtdUndeclaredAttributes", "test_dtdUndeclaredAttributes_succeeds"),
|
||||
"dtdDeclarationValidity": ("test_dtdDeclarationValidity", "test_dtdDeclarationValidity_succeeds"),
|
||||
"dtdRootElementType": ("test_dtdRootElementType", "test_dtdRootElementType_succeeds"),
|
||||
"dtdIdentifierIntegrity": ("test_identifierIntegrity", "test_dtdIdentifierIntegrity_succeeds"),
|
||||
"dtdStandaloneAttributes": ("test_dtdStandaloneAttributes", "test_dtdStandaloneAttributes_succeeds"),
|
||||
"dtdStandaloneElementWhitespace": ("test_dtdStandaloneElementWhitespace", "test_dtdStandaloneElementWhitespace_succeeds"),
|
||||
"dtdParseAdvisories": ("test_dtdParseAdvisories", "test_dtdParseAdvisories_succeeds"),
|
||||
"htmlVoidElementsAreEmpty": ("test_htmlVoidElementsAreEmpty", "test_htmlVoidElementsAreEmpty_succeeds"),
|
||||
"htmlRequiredParent": ("test_htmlRequiredParent", "test_htmlRequiredParent_succeeds"),
|
||||
"htmlUniqueIdentifiers": ("test_htmlUniqueIdentifiers", "test_htmlUniqueIdentifiers_succeeds"),
|
||||
"xsdContentValidity": ("test_xsdContentValidity", "test_xsdContentValidity_succeeds"),
|
||||
"xsdIdentityConstraints": ("test_xsdIdentityConstraints", "test_xsdIdentityConstraints_succeeds"),
|
||||
"xsdFinalRespected": ("test_xsdFinalRespected", "test_xsdFinalRespected_succeeds"),
|
||||
"xsdRestrictionsAreSubsets": ("test_xsdRestrictionsAreSubsets", "test_xsdRestrictionsAreSubsets_succeeds"),
|
||||
"conformanceMatchesExpected": ("test_conformanceMatchesExpected", "test_conformanceMatchesExpected_succeeds"),
|
||||
"xsdStreamingShallowValidity": ("test_xsdStreamingShallowValidity", "test_xsdStreamingShallowValidity_succeeds"),
|
||||
]
|
||||
|
||||
@Test("Every builtin rule id has a fail and succeed test registered")
|
||||
func test_allRulesPaired() {
|
||||
let ids = PureXML.Validation.BuiltinValidation.allRuleIDs
|
||||
#expect(Set(Self.pairs.keys) == Set(ids), "pairs keys must match allRuleIDs")
|
||||
#expect(ids.count == 24)
|
||||
}
|
||||
|
||||
@Test("Builtin descriptions stay unique")
|
||||
func test_descriptionsUnique() {
|
||||
let all = PureXML.Validation.BuiltinValidation.allDescriptions
|
||||
#expect(all.count == Set(all).count)
|
||||
#expect(all.count == PureXML.Validation.BuiltinValidation.allRuleIDs.count)
|
||||
}
|
||||
}
|
||||
27
Tests/ValidationRuleStructuralTests.swift
Normal file
27
Tests/ValidationRuleStructuralTests.swift
Normal file
@ -0,0 +1,27 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
/// Per-rule isolation tests (Validation rules structural).
|
||||
@Suite("Validation rules structural")
|
||||
struct ValidationRuleStructuralTests {
|
||||
@Test("Structural.uniqueAttributes reports each duplicate at the element path")
|
||||
func test_uniqueAttributes() {
|
||||
let element = PureXML.Model.Element("a", attributes: [.init("x", "1"), .init("x", "2")])
|
||||
let node = PureXML.Model.Node.document([.element(element)])
|
||||
let errors = PureXML.Validation.Validator<Void>.blank
|
||||
.validating(PureXML.Validation.Structural.uniqueAttributes)
|
||||
.errors(for: node, in: ())
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "Duplicate attribute 'x' on <a>")
|
||||
#expect(ValidationRuleTestSupport.path(errors[0]) == ["a"])
|
||||
}
|
||||
|
||||
@Test("Structural.uniqueAttributes accepts distinct attribute names")
|
||||
func test_uniqueAttributes_succeeds() {
|
||||
let node = PureXML.Model.Node.document([.element(.init("a", attributes: [.init("x", "1"), .init("y", "2")]))])
|
||||
let errors = PureXML.Validation.Validator<Void>.blank
|
||||
.validating(PureXML.Validation.Structural.uniqueAttributes)
|
||||
.errors(for: node, in: ())
|
||||
#expect(errors.isEmpty)
|
||||
}
|
||||
}
|
||||
30
Tests/ValidationRuleTestSupport.swift
Normal file
30
Tests/ValidationRuleTestSupport.swift
Normal file
@ -0,0 +1,30 @@
|
||||
@testable import PureXML
|
||||
|
||||
enum ValidationRuleTestSupport {
|
||||
typealias DTDSchema = PureXML.Validation.DTDSchema
|
||||
typealias XSDContext = PureXML.Validation.XSDContext
|
||||
|
||||
static func dtd(_ xml: String, resolver: PureXML.Parsing.EntityResolver = .refusing) throws -> (PureXML.Model.Node, DTDSchema) {
|
||||
let parsed = try PureXML.Parsing.Parser().parseWithDocumentType(xml, limits: .init(allowDoctype: true), resolver: resolver)
|
||||
let standalone = parsed.declaration?.standalone == true
|
||||
return (parsed.node, DTDSchema(parsed.documentType, standalone: standalone))
|
||||
}
|
||||
|
||||
static let standaloneExternalDTD = """
|
||||
<!ELEMENT root (child)*>
|
||||
<!ELEMENT child (#PCDATA)>
|
||||
<!ATTLIST root token (a|b|c) "a" id ID #IMPLIED>
|
||||
"""
|
||||
|
||||
static func standaloneResolver() -> PureXML.Parsing.EntityResolver {
|
||||
let dtd = standaloneExternalDTD
|
||||
return PureXML.Parsing.EntityResolver(
|
||||
resolveEntity: { _, _ in nil },
|
||||
resolveExternalSubset: { _ in dtd },
|
||||
)
|
||||
}
|
||||
|
||||
static func path(_ error: PureXML.Validation.ValidationError) -> [String] {
|
||||
error.codingPath.map(\.stringValue)
|
||||
}
|
||||
}
|
||||
@ -1,152 +0,0 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
/// Per-rule isolation tests in the OpenAPIKit style: each builtin validation is
|
||||
/// installed alone on a `blank` validator, run against a seeded subject, and the
|
||||
/// resulting errors are asserted by count, reason, and coding path.
|
||||
@Suite("Validation rules in isolation")
|
||||
struct ValidationRuleTests {
|
||||
private typealias DTDSchema = PureXML.Validation.DTDSchema
|
||||
private typealias XSDContext = PureXML.Validation.XSDContext
|
||||
|
||||
private func dtd(_ xml: String) throws -> (PureXML.Model.Node, DTDSchema) {
|
||||
let parsed = try PureXML.Parsing.Parser().parseWithDocumentType(xml, limits: .init(allowDoctype: true))
|
||||
return (parsed.node, DTDSchema(parsed.documentType))
|
||||
}
|
||||
|
||||
private func path(_ error: PureXML.Validation.ValidationError) -> [String] {
|
||||
error.codingPath.map(\.stringValue)
|
||||
}
|
||||
|
||||
// MARK: Structural
|
||||
|
||||
@Test("Structural.uniqueAttributes reports each duplicate at the element path")
|
||||
func test_uniqueAttributes() {
|
||||
let element = PureXML.Model.Element("a", attributes: [.init("x", "1"), .init("x", "2")])
|
||||
let node = PureXML.Model.Node.document([.element(element)])
|
||||
let errors = PureXML.Validation.Validator<Void>.blank
|
||||
.validating(PureXML.Validation.Structural.uniqueAttributes)
|
||||
.errors(for: node, in: ())
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "Duplicate attribute 'x' on <a>")
|
||||
#expect(path(errors[0]) == ["a"])
|
||||
}
|
||||
|
||||
// MARK: DTD
|
||||
|
||||
@Test("DTD.contentModel reports an EMPTY element with content at the element path")
|
||||
func test_contentModel() throws {
|
||||
let (node, schema) = try dtd("<!DOCTYPE r [<!ELEMENT r EMPTY>]><r><c/></r>")
|
||||
let errors = PureXML.Validation.Validator<DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.contentModel)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "element <r> is declared EMPTY but has content")
|
||||
#expect(path(errors[0]) == ["r"])
|
||||
}
|
||||
|
||||
@Test("DTD.requiredAttributes reports a missing required attribute in isolation")
|
||||
func test_requiredAttributes() throws {
|
||||
let (node, schema) = try dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r id CDATA #REQUIRED>]><r/>")
|
||||
let errors = PureXML.Validation.Validator<DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.requiredAttributes)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "required attribute 'id' is missing on <r>")
|
||||
#expect(path(errors[0]) == ["r"])
|
||||
}
|
||||
|
||||
@Test("DTD.fixedAttributeValues isolates the #FIXED constraint")
|
||||
func test_fixedAttributeValues() throws {
|
||||
// A missing required attribute is invisible to the fixed-value rule alone.
|
||||
let (node, schema) = try dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r v CDATA #FIXED \"1\" id CDATA #REQUIRED>]><r v=\"2\"/>")
|
||||
let errors = PureXML.Validation.Validator<DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.fixedAttributeValues)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "attribute 'v' on <r> is #FIXED and must be \"1\"")
|
||||
}
|
||||
|
||||
@Test("DTD.enumeratedAttributeValues isolates the enumeration constraint")
|
||||
func test_enumeratedAttributeValues() throws {
|
||||
let (node, schema) = try dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r k (a|b) #IMPLIED>]><r k=\"z\"/>")
|
||||
let errors = PureXML.Validation.Validator<DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.enumeratedAttributeValues)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "attribute 'k' on <r> has a value outside its enumeration")
|
||||
}
|
||||
|
||||
@Test("DTD.tokenizedAttributeTypes isolates the NMTOKEN constraint")
|
||||
func test_tokenizedAttributeTypes() throws {
|
||||
let (node, schema) = try dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r t NMTOKEN #IMPLIED>]><r t=\"a b\"/>")
|
||||
let errors = PureXML.Validation.Validator<DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.tokenizedAttributeTypes)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason.contains("NMTOKEN") == true)
|
||||
}
|
||||
|
||||
@Test("DTD.notationAttributes isolates the NOTATION constraint")
|
||||
func test_notationAttributes() throws {
|
||||
let (node, schema) = try dtd("<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r kind NOTATION (bmp) #IMPLIED>]><r kind=\"bmp\"/>")
|
||||
let errors = PureXML.Validation.Validator<DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.notationAttributes)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason.contains("undeclared notation") == true)
|
||||
}
|
||||
|
||||
@Test("DTD.undeclaredElement uses the single-error Bool form (Failed to satisfy)")
|
||||
func test_undeclaredElement() throws {
|
||||
let (node, schema) = try dtd("<!DOCTYPE r [<!ELEMENT a EMPTY>]><r/>")
|
||||
let errors = PureXML.Validation.Validator<DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.undeclaredElement)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "Failed to satisfy: Element is declared in the DTD")
|
||||
#expect(path(errors[0]) == ["r"])
|
||||
}
|
||||
|
||||
@Test("DTD.identifierIntegrity reports a duplicate ID at the document root")
|
||||
func test_identifierIntegrity() throws {
|
||||
let (node, schema) = try dtd("<!DOCTYPE r [<!ATTLIST a id ID #IMPLIED>]><r><a id=\"x\"/><a id=\"x\"/></r>")
|
||||
let errors = PureXML.Validation.Validator<DTDSchema>.blank
|
||||
.validating(PureXML.Validation.DTD.identifierIntegrity)
|
||||
.errors(for: node, in: schema)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "duplicate ID 'x' (declared 2 times)")
|
||||
// Runs once over the whole tree, so it is located at the document root.
|
||||
#expect(path(errors[0]).isEmpty)
|
||||
#expect(String(describing: errors[0]).hasSuffix("at root of document"))
|
||||
}
|
||||
|
||||
// MARK: XSD
|
||||
|
||||
@Test("XSD.contentValidity locates a simple-type violation at the root element")
|
||||
func test_xsdContentValidity() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="n" type="xs:integer"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
let compiled = try PureXML.Schema.XSDParser.parse(xsd)
|
||||
let node = try PureXML.parse("<n>x</n>")
|
||||
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
|
||||
Issue.record("no root element")
|
||||
return
|
||||
}
|
||||
let context = XSDContext(
|
||||
types: compiled.types,
|
||||
constraints: compiled.constraints,
|
||||
rootDeclaration: compiled.elements["n"],
|
||||
)
|
||||
// The validator is rooted at the document element, as Schema.Document does.
|
||||
let errors = PureXML.Validation.Validator<XSDContext>.blank
|
||||
.validating(PureXML.Validation.XSD.contentValidity)
|
||||
.errors(for: .element(root), in: context)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason.contains("n") == true)
|
||||
#expect(errors.first.map(path) == ["n"])
|
||||
}
|
||||
}
|
||||
175
Tests/ValidationRuleXSDTests.swift
Normal file
175
Tests/ValidationRuleXSDTests.swift
Normal file
@ -0,0 +1,175 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
/// Per-rule isolation tests (Validation rules XSD).
|
||||
@Suite("Validation rules XSD")
|
||||
struct ValidationRuleXSDTests {
|
||||
@Test("XSD.contentValidity locates a simple-type violation at the root element")
|
||||
func test_xsdContentValidity() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="n" type="xs:integer"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
let compiled = try PureXML.Schema.XSDParser.parse(xsd)
|
||||
let node = try PureXML.parse("<n>x</n>")
|
||||
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
|
||||
Issue.record("no root element")
|
||||
return
|
||||
}
|
||||
let context = ValidationRuleTestSupport.XSDContext(
|
||||
types: compiled.types,
|
||||
constraints: compiled.constraints,
|
||||
rootDeclaration: compiled.elements["n"],
|
||||
)
|
||||
// The validator is rooted at the document element, as Schema.Document does.
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.XSDContext>.blank
|
||||
.validating(PureXML.Validation.XSD.contentValidity)
|
||||
.errors(for: .element(root), in: context)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason.contains("n") == true)
|
||||
#expect(errors.first.map(ValidationRuleTestSupport.path) == ["n"])
|
||||
}
|
||||
|
||||
@Test("XSD.contentValidity accepts a valid integer at the boundary")
|
||||
func test_xsdContentValidity_succeeds() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="n" type="xs:integer"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
let compiled = try PureXML.Schema.XSDParser.parse(xsd)
|
||||
let node = try PureXML.parse("<n>0</n>")
|
||||
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
|
||||
Issue.record("no root element")
|
||||
return
|
||||
}
|
||||
let context = ValidationRuleTestSupport.XSDContext(types: compiled.types, constraints: compiled.constraints, rootDeclaration: compiled.elements["n"])
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.XSDContext>.blank
|
||||
.validating(PureXML.Validation.XSD.contentValidity)
|
||||
.errors(for: .element(root), in: context)
|
||||
#expect(errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test("XSD.identityConstraints reports a duplicate key field")
|
||||
func test_xsdIdentityConstraints() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="list">
|
||||
<xs:complexType>
|
||||
<xs:sequence><xs:element name="item" maxOccurs="unbounded"><xs:complexType><xs:attribute name="id" type="xs:string"/></xs:complexType></xs:element></xs:sequence>
|
||||
</xs:complexType>
|
||||
<xs:unique name="byId"><xs:selector xpath="item"/><xs:field xpath="@id"/></xs:unique>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
let compiled = try PureXML.Schema.XSDParser.parse(xsd)
|
||||
let node = try PureXML.parse("<list><item id=\"1\"/><item id=\"1\"/></list>")
|
||||
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
|
||||
Issue.record("no root element")
|
||||
return
|
||||
}
|
||||
let context = ValidationRuleTestSupport.XSDContext(types: compiled.types, constraints: compiled.constraints, rootDeclaration: compiled.elements["list"])
|
||||
let errors = PureXML.Validation.Validator<ValidationRuleTestSupport.XSDContext>.blank
|
||||
.validating(PureXML.Validation.XSD.identityConstraints)
|
||||
.errors(for: .element(root), in: context)
|
||||
#expect(!errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test("XSD.identityConstraints accepts distinct field values")
|
||||
func test_xsdIdentityConstraints_succeeds() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="list">
|
||||
<xs:complexType>
|
||||
<xs:sequence><xs:element name="item" maxOccurs="unbounded"><xs:complexType><xs:attribute name="id" type="xs:string"/></xs:complexType></xs:element></xs:sequence>
|
||||
</xs:complexType>
|
||||
<xs:unique name="byId"><xs:selector xpath="item"/><xs:field xpath="@id"/></xs:unique>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
let compiled = try PureXML.Schema.XSDParser.parse(xsd)
|
||||
let node = try PureXML.parse("<list><item id=\"1\"/><item id=\"2\"/></list>")
|
||||
guard case let .document(children) = node, let root = children.compactMap(\.element).first else { return }
|
||||
let context = ValidationRuleTestSupport.XSDContext(types: compiled.types, constraints: compiled.constraints, rootDeclaration: compiled.elements["list"])
|
||||
#expect(PureXML.Validation.Validator<ValidationRuleTestSupport.XSDContext>.blank.validating(PureXML.Validation.XSD.identityConstraints).errors(
|
||||
for: .element(root),
|
||||
in: context,
|
||||
).isEmpty)
|
||||
}
|
||||
|
||||
@Test("XSDSchema.finalRespected rejects a derivation forbidden by final")
|
||||
func test_xsdFinalRespected() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:complexType name="ShapeT" final="extension">
|
||||
<xs:sequence><xs:element name="name" type="xs:string"/></xs:sequence>
|
||||
</xs:complexType>
|
||||
<xs:complexType name="CircleT">
|
||||
<xs:complexContent><xs:extension base="ShapeT"><xs:sequence><xs:element name="radius" type="xs:integer"/></xs:sequence></xs:extension></xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:schema>
|
||||
"""
|
||||
let compiled = try PureXML.Schema.XSDParser.parse(xsd)
|
||||
let facts = PureXML.Schema.CompiledSchemaFacts(types: compiled.types, typeDerivation: compiled.typeDerivation, typeFinal: compiled.typeFinal)
|
||||
let fact = PureXML.Schema.SchemaTypeFact(name: "CircleT", derivation: compiled.typeDerivation["CircleT"])
|
||||
let errors = PureXML.Validation.BuiltinValidation.xsdFinalRespected.apply(to: fact, at: [.element("CircleT")], in: facts)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test("XSDSchema.finalRespected accepts a derivation the base permits")
|
||||
func test_xsdFinalRespected_succeeds() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:complexType name="ShapeT"><xs:sequence><xs:element name="name" type="xs:string"/></xs:sequence></xs:complexType>
|
||||
</xs:schema>
|
||||
"""
|
||||
let compiled = try PureXML.Schema.XSDParser.parse(xsd)
|
||||
let facts = PureXML.Schema.CompiledSchemaFacts(types: compiled.types, typeDerivation: compiled.typeDerivation, typeFinal: compiled.typeFinal)
|
||||
let fact = PureXML.Schema.SchemaTypeFact(name: "ShapeT", derivation: nil)
|
||||
#expect(PureXML.Validation.BuiltinValidation.xsdFinalRespected.apply(to: fact, at: [.element("ShapeT")], in: facts).isEmpty)
|
||||
}
|
||||
|
||||
@Test("XSDSchema.restrictionsAreSubsets rejects an unfaithful restriction")
|
||||
func test_xsdRestrictionsAreSubsets() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:complexType name="B"><xs:sequence><xs:element name="a" type="xs:string"/></xs:sequence></xs:complexType>
|
||||
<xs:complexType name="R">
|
||||
<xs:complexContent>
|
||||
<xs:restriction base="B">
|
||||
<xs:sequence>
|
||||
<xs:element name="a" type="xs:string"/>
|
||||
<xs:element name="b" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:restriction>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:schema>
|
||||
"""
|
||||
let compiled = try PureXML.Schema.XSDParser.parse(xsd)
|
||||
let facts = PureXML.Schema.CompiledSchemaFacts(types: compiled.types, typeDerivation: compiled.typeDerivation, typeFinal: compiled.typeFinal)
|
||||
let fact = PureXML.Schema.SchemaTypeFact(name: "R", derivation: compiled.typeDerivation["R"])
|
||||
#expect(!PureXML.Validation.BuiltinValidation.xsdRestrictionsAreSubsets.apply(to: fact, at: [.element("R")], in: facts).isEmpty)
|
||||
}
|
||||
|
||||
@Test("XSDSchema.restrictionsAreSubsets accepts a faithful restriction")
|
||||
func test_xsdRestrictionsAreSubsets_succeeds() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:complexType name="B"><xs:sequence><xs:element name="a" type="xs:string"/></xs:sequence></xs:complexType>
|
||||
<xs:complexType name="R">
|
||||
<xs:complexContent>
|
||||
<xs:restriction base="B">
|
||||
<xs:sequence><xs:element name="a" type="xs:string"/></xs:sequence>
|
||||
</xs:restriction>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:schema>
|
||||
"""
|
||||
let compiled = try PureXML.Schema.XSDParser.parse(xsd)
|
||||
let facts = PureXML.Schema.CompiledSchemaFacts(types: compiled.types, typeDerivation: compiled.typeDerivation, typeFinal: compiled.typeFinal)
|
||||
let fact = PureXML.Schema.SchemaTypeFact(name: "R", derivation: compiled.typeDerivation["R"])
|
||||
#expect(PureXML.Validation.BuiltinValidation.xsdRestrictionsAreSubsets.apply(to: fact, at: [.element("R")], in: facts).isEmpty)
|
||||
}
|
||||
}
|
||||
118
Tests/ValidationWarningsTests.swift
Normal file
118
Tests/ValidationWarningsTests.swift
Normal file
@ -0,0 +1,118 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
/// Tests for the validation-rules.md warnings channel: advisory findings are
|
||||
/// collected separately from errors and promoted only under `strict: true`.
|
||||
@Suite("Validation warnings channel")
|
||||
struct ValidationWarningsTests {
|
||||
private struct WarningLeaf: PureXML.Validation.Validatable, PureXML.Validation.HasWarnings {
|
||||
let message: String
|
||||
|
||||
func validationWarnings(at path: [PureXML.Validation.PathKey]) -> [PureXML.Validation.ValidationError] {
|
||||
[PureXML.Validation.ValidationError(reason: message, at: path, severity: .warning)]
|
||||
}
|
||||
}
|
||||
|
||||
private func advisoryRule() -> PureXML.Validation.Validation<PureXML.Model.Element, Void> {
|
||||
PureXML.Validation.Validation(
|
||||
description: "advisory only",
|
||||
check: { context in
|
||||
[PureXML.Validation.ValidationError(reason: "note", at: context.codingPath, severity: .warning)]
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test("HasWarnings supplies advisory findings at the coding path")
|
||||
func test_hasWarningsAtPath() {
|
||||
let leaf = WarningLeaf(message: "advisory")
|
||||
let warnings = leaf.validationWarnings(at: [.element("a")])
|
||||
#expect(warnings.count == 1)
|
||||
#expect(warnings.first?.reason == "advisory")
|
||||
#expect(warnings.first?.codingPath.map(\.stringValue) == ["a"])
|
||||
}
|
||||
|
||||
@Test("strict validation throws warnings; lenient validation returns them")
|
||||
func test_strictPromotesWarnings() throws {
|
||||
let node = PureXML.Model.Node.document([.element(.init("a"))])
|
||||
let validator = PureXML.Validation.Validator<Void>.blank.validating(advisoryRule())
|
||||
|
||||
#expect(throws: PureXML.Validation.ValidationErrorCollection.self) {
|
||||
try validator.validate(node, in: (), strict: true)
|
||||
}
|
||||
#expect(throws: Never.self) {
|
||||
try validator.validate(node, in: (), strict: false)
|
||||
}
|
||||
|
||||
let lenient = validator.outcome(for: node, in: (), strict: false)
|
||||
#expect(lenient.errors.isEmpty)
|
||||
#expect(lenient.warnings.count == 1)
|
||||
#expect(!lenient.isValid)
|
||||
|
||||
let promoted = validator.outcome(for: node, in: (), strict: true)
|
||||
#expect(promoted.errors.count == 1)
|
||||
#expect(promoted.warnings.isEmpty)
|
||||
#expect(!promoted.isValid)
|
||||
}
|
||||
|
||||
@Test("Rule-produced warning severity is split from errors")
|
||||
func test_ruleSeveritySplit() {
|
||||
let node = PureXML.Model.Node.document([.element(.init("a"))])
|
||||
let validator = PureXML.Validation.Validator<Void>.blank.validating(advisoryRule())
|
||||
#expect(validator.errors(for: node, in: ()).isEmpty)
|
||||
#expect(validator.warnings(for: node, in: ()).count == 1)
|
||||
#expect(validator.findings(for: node, in: ()).count == 1)
|
||||
}
|
||||
|
||||
@Test("DTD parse advisories promote under strict validation")
|
||||
func test_dtdParseAdvisoriesStrict() throws {
|
||||
let xml = "<!DOCTYPE r SYSTEM \"x.dtd\" [<!ELEMENT r ANY>]>\n<r>&mystery;</r>"
|
||||
let parsed = try PureXML.Parsing.Parser().parseWithDocumentType(
|
||||
xml,
|
||||
limits: .init(allowDoctype: true),
|
||||
resolver: .refusing,
|
||||
)
|
||||
let schema = PureXML.Validation.DTDSchema(parsed.documentType)
|
||||
let validator = PureXML.Validation.DTD.validator(strict: false)
|
||||
#expect(validator.warnings(for: parsed.node, in: schema).contains { $0.reason.contains("'&mystery;' is referenced but not declared") })
|
||||
#expect(throws: PureXML.Validation.ValidationErrorCollection.self) {
|
||||
try validator.validate(parsed.node, in: schema, strict: true)
|
||||
}
|
||||
#expect(throws: Never.self) {
|
||||
try validator.validate(parsed.node, in: schema, strict: false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Schematron reports stay warnings until strict validation")
|
||||
func test_schematronWarningsSplit() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule context="book">
|
||||
<report test="count(author) > 2">more than two authors</report>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
let xml = """
|
||||
<book>
|
||||
<author>A</author><author>B</author><author>C</author>
|
||||
</book>
|
||||
"""
|
||||
let node = try PureXML.parse(xml)
|
||||
let parsed = try PureXML.Validation.SchematronParser.parse(schema)
|
||||
let validation = PureXML.Validation.Schematron.rule(
|
||||
parsed.patterns,
|
||||
schemaLets: parsed.lets,
|
||||
diagnostics: parsed.diagnostics,
|
||||
)
|
||||
let validator = PureXML.Validation.Validator<Void>.blank.validating(validation)
|
||||
#expect(validator.errors(for: node, in: ()).isEmpty)
|
||||
#expect(validator.warnings(for: node, in: ()).count == 1)
|
||||
#expect(throws: PureXML.Validation.ValidationErrorCollection.self) {
|
||||
try validator.validate(node, in: (), strict: true)
|
||||
}
|
||||
#expect(throws: Never.self) {
|
||||
try validator.validate(node, in: (), strict: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,29 @@ struct XSDIdentityTests {
|
||||
#expect(try validate(xsd, "<list><item/><item/></list>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("Streaming validation reports identity constraints like the tree path")
|
||||
func test_streamingIdentity() throws {
|
||||
let xsd = """
|
||||
\(head)
|
||||
<xs:element name="list">
|
||||
<xs:complexType>
|
||||
<xs:sequence><xs:element name="item" maxOccurs="unbounded"><xs:complexType><xs:attribute name="id" type="xs:string"/></xs:complexType></xs:element></xs:sequence>
|
||||
</xs:complexType>
|
||||
<xs:unique name="byId">
|
||||
<xs:selector xpath="item"/>
|
||||
<xs:field xpath="@id"/>
|
||||
</xs:unique>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
let xml = "<list><item id=\"1\"/><item id=\"1\"/></list>"
|
||||
let document = try PureXML.Schema.Document(xsd)
|
||||
let tree = try document.validate(xml).map(\.reason).sorted()
|
||||
let streamed = try document.validate(streaming: xml).map(\.reason).sorted()
|
||||
#expect(tree == streamed)
|
||||
#expect(!streamed.isEmpty)
|
||||
}
|
||||
|
||||
@Test("xs:key requires every field to be present and distinct")
|
||||
func test_key() throws {
|
||||
let xsd = """
|
||||
|
||||
@ -29,8 +29,8 @@ struct XSTSSuiteTests {
|
||||
/// XSTS` (debug is far slower and the corpus is large). Per-case deviations
|
||||
/// are written to /tmp/xsts-failures.txt for the burn-down.
|
||||
private let knownSchemaValidRejected = 1
|
||||
private let knownSchemaInvalidAccepted = 266
|
||||
private let knownInstanceValidRejected = 171
|
||||
private let knownSchemaInvalidAccepted = 264
|
||||
private let knownInstanceValidRejected = 173
|
||||
private let knownInstanceInvalidAccepted = 155
|
||||
|
||||
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
|
||||
|
||||
@ -4,21 +4,22 @@ Living index of open work. **Pin [#167](https://github.com/mihaelamj/PureXML/iss
|
||||
|
||||
## Current focus (priority order)
|
||||
|
||||
1. **#171**: schema differential harness (PureXML vs Xerces/libxml2). Unblocks safe burn-down.
|
||||
2. **#161**: cross-document schema composition (`include`/`import`/`redefine`). Largest single XSTS blocker.
|
||||
3. **#158 / #159**: Particle Valid Restriction/Extension completion.
|
||||
4. **#169**: located schema diagnostics (line/column).
|
||||
5. **#172–#174**: instance-validation cluster tasks.
|
||||
1. **#161**: cross-document schema composition (`include`/`import`/`redefine`): chameleon include + cross-container reference checks landed; attg/schZ XSTS burn-down still open.
|
||||
2. **#158 / #159**: Particle Valid Restriction/Extension completion.
|
||||
3. **#169**: located schema diagnostics (line/column).
|
||||
4. **#172–#174**: instance-validation cluster tasks.
|
||||
|
||||
Parallel: **#130** (XSLT suite), **#139** (performance).
|
||||
Parallel: **#130** (XSLT suite), **#139** (performance epic; tasks [#175](https://github.com/mihaelamj/PureXML/issues/175)–[#178](https://github.com/mihaelamj/PureXML/issues/178)).
|
||||
|
||||
**M1 done:** #171 schema differential harness (closed; opt-in; 231 XSTS oracle disagreements pinned) + generative fuzz + validation framework (#92).
|
||||
|
||||
## XSTS gates (v0.2.0 baselines)
|
||||
|
||||
| Gate | Now | Issue |
|
||||
|---|---|---|
|
||||
| Valid schemas rejected | 1 | [#148](https://github.com/mihaelamj/PureXML/issues/148) |
|
||||
| Invalid schemas accepted | 266 | [#145](https://github.com/mihaelamj/PureXML/issues/145) |
|
||||
| Valid instances rejected | 171 | [#146](https://github.com/mihaelamj/PureXML/issues/146) |
|
||||
| Invalid schemas accepted | 264 | [#145](https://github.com/mihaelamj/PureXML/issues/145) |
|
||||
| Valid instances rejected | 173 | [#146](https://github.com/mihaelamj/PureXML/issues/146) |
|
||||
| Invalid instances accepted | 155 | [#147](https://github.com/mihaelamj/PureXML/issues/147) |
|
||||
|
||||
Gates are **metric trackers**, not implementation tasks. Pick up **task** issues; ratchet baselines in `Tests/XSTSSuiteTests.swift` when counts fall.
|
||||
@ -27,11 +28,11 @@ Gates are **metric trackers**, not implementation tasks. Pick up **task** issues
|
||||
|
||||
| Milestone | Exit criteria | Key issues |
|
||||
|---|---|---|
|
||||
| M1: Trust the oracle | Reference differential + fuzz | #171 |
|
||||
| M1: Trust the oracle | Reference differential + fuzz | ~~#171~~ (closed), fuzz |
|
||||
| M2: Schema compiles correctly | #145 to 0 | #161, #158, #159 |
|
||||
| M3: Instances validate correctly | #146, #147 to 0 | #172, #173, #174 |
|
||||
| M4: IDE-ready diagnostics | Located errors, proven bounds | #169 |
|
||||
| M5: 1.0 | All gates at 0 | #148, #130 |
|
||||
| M5: 1.0 | All gates at 0 | #148 (closed at floor), #130, #139 |
|
||||
|
||||
## Labels
|
||||
|
||||
|
||||
@ -18,6 +18,13 @@ Project-specific overrides:
|
||||
- `Package.swift` must keep `dependencies: []`.
|
||||
- Public API must live under the `PureXML` namespace tree.
|
||||
- The package must build on macOS, Linux, Windows, and WASI.
|
||||
- Validation coverage gates: `docs/validation-coverage-registry.txt` (public types),
|
||||
`docs/validation-field-registry.txt` (document/subject fields), enforced by
|
||||
`scripts/check-validation-coverage.sh` and `scripts/check-validation-fields.sh`.
|
||||
|
||||
The broader rule files are retained for detailed guidance. When they include
|
||||
generic examples, apply the PureXML-specific overrides above.
|
||||
|
||||
Canonical Swift rules live at `/Volumes/Code/DeveloperExt/private/rules-swift`
|
||||
(especially `validation-rules.md` and `parsing-rules.md`). Prefer that source
|
||||
when `docs/rules/` copies drift.
|
||||
|
||||
@ -10,8 +10,8 @@ irreducible tail. Do not deviate to new features while this is open.
|
||||
| Bucket | Count | Reading |
|
||||
|---|---|---|
|
||||
| valid-schemas-rejected | **72 -> 4** | production target is 0: rejecting a correct schema is the most user-hostile defect (#148). Particle-restriction over-rejection fixed: pointless `maxOccurs=0` particles removed, content-free restriction of an EMPTY base, empty-derived emptiable-base, and (#163) the pairwise check over the normalized content model with occurrence subsumption applied only to same-kind pairings (cross-kind element/group, group/element, group/wildcard match occurrence inside, by effective total range / member). #164 resolved element-ref namespaces by prefix binding (the `particlesJj/Jk/Jm/Jn/Js`, `particlesQ/R` cluster, -28). Content-free (`maxOccurs=0`) derived groups and substitution-group declaration order closed `particlesW006`/`elemZ027_a`; the W3C MapAndSum rule (Sequence:Choice, #165) closed `particlesV003`. Remaining 1 (`particlesZ001`): a documented spec ambiguity (RecurseAsIfGroup; the reference rejects it), not a defect. |
|
||||
| **invalid-schemas-accepted** | **2461 -> 266** | we rarely catch a bad one (facet, default/fixed value, particle-restriction name+type, id, structural content-model, UPA determinism, content order, identity-constraint XPath-subset, wildcard-namespace, final/block, simpleType content, group/wildcard and group/element cardinality, element-ref namespace, sequence/choice MapAndSum, circular type derivation, circular group/attributeGroup/substitution references, attribute-use uniqueness and single-ID, type-excludes-inline-type, ID-typed value-constraint exclusion, all-group reference placement, substitution-member type derivation, default/fixed mutual exclusion, elementTypeRestrictionOK NameAndTypeOK union checks, prohibited attribute type-check bypass, keyref arity verification, top-level group/attributeGroup local-attribute checks, simpleType restriction structure and prefix binding checks, namespace-aware reference resolution checks, simpleType variety constraints on list itemType and union memberTypes, targetNamespace scope matching on local schema references, substitution group exclusions based on element final and schema finalDefault attributes, cross-container component-name and identity-constraint uniqueness checks, notation declarations and notation enumeration restrictions, xml:lang and xml:space on documentation/appinfo elements, duplicate schema container validation across separate files with identical content, schema element order, import constraints validation, facet applicability per base type, fractionDigits constraints on integer types, ordered duration range bounds, and built-in list non-emptiness constraints landed). |
|
||||
| valid-instances-rejected | **233 -> 171** | (instance side; #164's element-ref namespace fix is shared with instance validation, complexContent restriction attributes inheritance and namespace fixes) |
|
||||
| **invalid-schemas-accepted** | **2461 -> 264** | we rarely catch a bad one (facet, default/fixed value, particle-restriction name+type, id, structural content-model, UPA determinism, content order, identity-constraint XPath-subset, wildcard-namespace, final/block, simpleType content, group/wildcard and group/element cardinality, element-ref namespace, sequence/choice MapAndSum, circular type derivation, circular group/attributeGroup/substitution references, attribute-use uniqueness and single-ID, type-excludes-inline-type, ID-typed value-constraint exclusion, all-group reference placement, substitution-member type derivation, default/fixed mutual exclusion, elementTypeRestrictionOK NameAndTypeOK union checks, prohibited attribute type-check bypass, keyref arity verification, top-level group/attributeGroup local-attribute checks, simpleType restriction structure and prefix binding checks, namespace-aware reference resolution checks, simpleType variety constraints on list itemType and union memberTypes, targetNamespace scope matching on local schema references, substitution group exclusions based on element final and schema finalDefault attributes, cross-container component-name and identity-constraint uniqueness checks, notation declarations and notation enumeration restrictions, xml:lang and xml:space on documentation/appinfo elements, duplicate schema container validation across separate files with identical content, schema element order, import constraints validation, facet applicability per base type, fractionDigits constraints on integer types, ordered duration range bounds, and built-in list non-emptiness constraints landed). |
|
||||
| valid-instances-rejected | **233 -> 173** | (instance side; #164's element-ref namespace fix is shared with instance validation, complexContent restriction attributes inheritance and namespace fixes) |
|
||||
| invalid-instances-accepted | **160 -> 155** | (instance side; resolving inline `<simpleType>` restriction base, complexContent restriction attributes prohibition) |
|
||||
|
||||
One-sentence diagnosis: *the validator runs its instance-validity rules but not
|
||||
|
||||
35
docs/validation-coverage-registry.txt
Normal file
35
docs/validation-coverage-registry.txt
Normal file
@ -0,0 +1,35 @@
|
||||
# PureXML validation coverage registry (validation-rules.md).
|
||||
# Each public type is either a SUBJECT (validated by named rule sets) or EXCLUDE
|
||||
# (with reason). Namespace prefixes EXCLUDE_NS auto-exclude infrastructure types.
|
||||
# WATCH_NS requires every newly discovered type in that namespace be listed here.
|
||||
|
||||
SUBJECT PureXML.Model.Node structural,dtd,xsd,html,schematron,relaxng
|
||||
SUBJECT PureXML.Model.Element structural,dtd,xsd,html
|
||||
SUBJECT PureXML.Model.Attribute structural,dtd,xsd
|
||||
SUBJECT PureXML.Validation.ConformanceCase conformance
|
||||
SUBJECT PureXML.Schema.SchemaTypeFact xsd-schema-consistency
|
||||
SUBJECT PureXML.Schema.ResolvedElement xsd-streaming-shallow
|
||||
|
||||
EXCLUDE PureXML.Model.QualifiedName value type; lexical invariants at construction
|
||||
EXCLUDE PureXML.Model.TreeNode mutable editor view of Node; validated via Node
|
||||
EXCLUDE PureXML.Model.TreeNodeKind tag enum; no invariants
|
||||
EXCLUDE PureXML.LintDiagnostic editor output; not user input
|
||||
|
||||
EXCLUDE_NS PureXML.Parsing parser and scanner; Model.Node is the validation subject
|
||||
EXCLUDE_NS PureXML.Emitting serializer; output not validated as input model
|
||||
EXCLUDE_NS PureXML.Decoding typed decoding helpers; not document models
|
||||
EXCLUDE_NS PureXML.Encoding encoding helpers; not document models
|
||||
EXCLUDE_NS PureXML.Stream streaming adapters; events validate via schemas
|
||||
EXCLUDE_NS PureXML.Validation validation framework and compiled schemas; Node is the subject
|
||||
EXCLUDE_NS PureXML.Schema schema compiler and internal engine; Document validates Node
|
||||
EXCLUDE_NS PureXML.XPath query engine; not a document model
|
||||
EXCLUDE_NS PureXML.Pattern regex/pattern engine; not a document model
|
||||
EXCLUDE_NS PureXML.XPointer pointer engine; not a document model
|
||||
EXCLUDE_NS PureXML.Catalog catalog resolver; not a document model
|
||||
EXCLUDE_NS PureXML.XInclude inclusion processor; not a document model
|
||||
EXCLUDE_NS PureXML.Canonical canonicalization; not a document model
|
||||
EXCLUDE_NS PureXML.Regex regex engine; not a document model
|
||||
EXCLUDE_NS PureXML.XSLT transform engine; not a document model
|
||||
EXCLUDE_NS PureXML.HTML tokenizer/builder; Node/HTML rules validate output trees
|
||||
|
||||
WATCH_NS PureXML.Model
|
||||
65
docs/validation-field-registry.txt
Normal file
65
docs/validation-field-registry.txt
Normal file
@ -0,0 +1,65 @@
|
||||
# validation-rules.md field coverage registry.
|
||||
# Each MODEL names a validation document or subject type. FIELD lines map stored
|
||||
# properties (or Node enum cases) to rule ids or layer rule names. IGNORE lines
|
||||
# document computed or out-of-scope constructs excluded from the coverage law.
|
||||
# The meta-test in ValidationFieldCoverageTests must stay in sync with this file.
|
||||
|
||||
MODEL PureXML.Validation.DTDSchema
|
||||
FIELD models dtdContentModel,dtdUndeclaredElement,dtdStandaloneElementWhitespace
|
||||
FIELD attributes dtdRequiredAttributes,dtdFixedAttributeValues,dtdEnumeratedAttributeValues,dtdTokenizedAttributeTypes,dtdNotationAttributes,dtdUndeclaredAttributes,dtdStandaloneAttributes
|
||||
FIELD notations dtdNotationAttributes,dtdDeclarationValidity
|
||||
FIELD unparsedEntities dtdTokenizedAttributeTypes
|
||||
FIELD doctypeName dtdRootElementType
|
||||
FIELD declarationErrors dtdDeclarationValidity
|
||||
FIELD standalone dtdStandaloneAttributes,dtdStandaloneElementWhitespace
|
||||
FIELD externalElementModels dtdStandaloneElementWhitespace
|
||||
FIELD externalAttributes dtdStandaloneAttributes
|
||||
FIELD parseAdvisories dtdParseAdvisories
|
||||
IGNORE isEmpty derived summary; no independent constraint
|
||||
|
||||
MODEL PureXML.Validation.XSDContext
|
||||
FIELD types xsdContentValidity,xsdStreamingShallowValidity
|
||||
FIELD constraints xsdIdentityConstraints
|
||||
FIELD rootDeclaration xsdContentValidity
|
||||
FIELD nillableElements xsdContentValidity
|
||||
FIELD elementConstraints xsdContentValidity
|
||||
FIELD abstractTypes xsdContentValidity
|
||||
FIELD typeBlock xsdContentValidity
|
||||
FIELD elementBlock xsdContentValidity
|
||||
FIELD typeDerivation xsdContentValidity
|
||||
|
||||
MODEL PureXML.Model.Element
|
||||
FIELD name dtdContentModel,dtdUndeclaredElement,dtdUndeclaredAttributes,htmlRequiredParent,htmlVoidElementsAreEmpty,xsdContentValidity
|
||||
FIELD attributes uniqueAttributes,dtdRequiredAttributes,dtdFixedAttributeValues,dtdEnumeratedAttributeValues,dtdTokenizedAttributeTypes,dtdNotationAttributes,dtdUndeclaredAttributes,dtdStandaloneAttributes
|
||||
FIELD children dtdContentModel,htmlVoidElementsAreEmpty,htmlRequiredParent,xsdContentValidity
|
||||
IGNORE text derived from direct text and CDATA children
|
||||
|
||||
MODEL PureXML.Model.Attribute
|
||||
FIELD name uniqueAttributes,dtdUndeclaredAttributes,dtdRequiredAttributes,dtdFixedAttributeValues,dtdEnumeratedAttributeValues,dtdTokenizedAttributeTypes,dtdNotationAttributes
|
||||
FIELD value dtdFixedAttributeValues,dtdEnumeratedAttributeValues,dtdTokenizedAttributeTypes,dtdNotationAttributes,dtdIdentifierIntegrity
|
||||
|
||||
MODEL PureXML.Model.Node
|
||||
CASE document dtdDeclarationValidity,dtdRootElementType,dtdIdentifierIntegrity,htmlUniqueIdentifiers,xsdContentValidity,xsdIdentityConstraints
|
||||
CASE element uniqueAttributes,dtdContentModel,dtdRequiredAttributes,dtdFixedAttributeValues,dtdEnumeratedAttributeValues,dtdTokenizedAttributeTypes,dtdNotationAttributes,dtdUndeclaredElement,dtdUndeclaredAttributes,dtdStandaloneAttributes,dtdStandaloneElementWhitespace,htmlVoidElementsAreEmpty,htmlRequiredParent,xsdContentValidity
|
||||
CASE text dtdContentModel
|
||||
CASE cdata dtdContentModel
|
||||
IGNORE comment no DTD or XSD constraints on comments
|
||||
IGNORE processingInstruction no DTD or XSD constraints on processing instructions
|
||||
|
||||
MODEL PureXML.Validation.ConformanceCase
|
||||
FIELD name conformanceMatchesExpected
|
||||
FIELD actual conformanceMatchesExpected
|
||||
FIELD expected conformanceMatchesExpected
|
||||
|
||||
MODEL PureXML.Schema.SchemaTypeFact
|
||||
FIELD name xsdFinalRespected,xsdRestrictionsAreSubsets
|
||||
FIELD derivation xsdFinalRespected,xsdRestrictionsAreSubsets
|
||||
|
||||
MODEL PureXML.Schema.CompiledSchemaFacts
|
||||
FIELD types xsdFinalRespected,xsdRestrictionsAreSubsets
|
||||
FIELD typeDerivation xsdFinalRespected,xsdRestrictionsAreSubsets
|
||||
FIELD typeFinal xsdFinalRespected
|
||||
|
||||
MODEL PureXML.Schema.ResolvedElement
|
||||
FIELD element xsdStreamingShallowValidity
|
||||
FIELD type xsdStreamingShallowValidity
|
||||
@ -34,8 +34,8 @@ entries removed about 70 apparent deviations that were never bugs.
|
||||
| Kind | First measurement | Now |
|
||||
|---|---|---|
|
||||
| valid schema rejected | 75 | 1 |
|
||||
| invalid schema accepted | 2467 | 266 |
|
||||
| valid instance rejected | 604 | 171 |
|
||||
| invalid schema accepted | 2467 | 264 |
|
||||
| valid instance rejected | 604 | 173 |
|
||||
| invalid instance accepted | 582 | 155 |
|
||||
|
||||
Counts are against *settled* W3C expectations (queried/disputed entries excluded).
|
||||
@ -65,7 +65,7 @@ validity (`min`/`maxInclusive`, `min`/`maxExclusive`, `enumeration` values valid
|
||||
in the base space; inclusive/exclusive exclusions; bound ordering) · `gMonth`
|
||||
`--MM--` lexical form · schema `id` attribute validity (`xs:ID`: NCName and
|
||||
unique within the document). The schema-validity work brought invalid schemas
|
||||
accepted 2461 to 266 (schema-document structural validity: content model + complexType content shape, attribute values, name/reference + pattern-regex lexical validity, and reference resolution, against the schema-for-schemas, plus simpleType variety constraints on list itemType and union memberTypes, plus targetNamespace scope matching on local schema references, plus substitution group exclusions based on element final and schema finalDefault attributes, duplicate schema container validation across separate files with identical content, schema element order, and import constraints validation); see `schema-validity-burndown.md`.
|
||||
accepted 2461 to 264 (schema-document structural validity: content model + complexType content shape, attribute values, name/reference + pattern-regex lexical validity, and reference resolution, against the schema-for-schemas, plus simpleType variety constraints on list itemType and union memberTypes, plus targetNamespace scope matching on local schema references, plus substitution group exclusions based on element final and schema finalDefault attributes, duplicate schema container validation across separate files with identical content, schema element order, and import constraints validation); see `schema-validity-burndown.md`.
|
||||
Plus the measurement fix (exclude W3C-disputed `status="queried"` entries).
|
||||
|
||||
### Remaining, root-caused (the hard tail)
|
||||
|
||||
@ -18,6 +18,8 @@ run_gate() {
|
||||
run_gate "style" bash scripts/check-style.sh
|
||||
run_gate "namespacing" bash scripts/check-namespacing.sh
|
||||
run_gate "forbidden patterns" bash scripts/check-forbidden-patterns.sh
|
||||
run_gate "validation coverage" bash scripts/check-validation-coverage.sh
|
||||
run_gate "validation field coverage" bash scripts/check-validation-fields.sh
|
||||
run_gate "changelog" bash scripts/check-changelog-touched.sh
|
||||
run_gate "swiftformat" swiftformat . --config .swiftformat --lint
|
||||
run_gate "swiftlint" swiftlint --config .swiftlint.yml --strict
|
||||
|
||||
56
scripts/check-validation-coverage.sh
Executable file
56
scripts/check-validation-coverage.sh
Executable file
@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# validation-rules.md gate: every public type under a WATCH_NS namespace is either
|
||||
# a SUBJECT, an EXCLUDE entry, or covered by EXCLUDE_NS. Fails naming any gap.
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
registry="$root/docs/validation-coverage-registry.txt"
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
subjects="$scratch/subjects"
|
||||
excludes="$scratch/excludes"
|
||||
exclude_ns="$scratch/exclude_ns"
|
||||
watch_ns="$scratch/watch_ns"
|
||||
discovered="$scratch/discovered"
|
||||
|
||||
grep -E '^SUBJECT ' "$registry" | awk '{print $2}' | sort -u >"$subjects"
|
||||
grep -E '^EXCLUDE ' "$registry" | awk '{print $2}' | sort -u >"$excludes"
|
||||
grep -E '^EXCLUDE_NS ' "$registry" | awk '{print $2}' | sort -u >"$exclude_ns"
|
||||
grep -E '^WATCH_NS ' "$registry" | awk '{print $2}' | sort -u >"$watch_ns"
|
||||
|
||||
: >"$discovered"
|
||||
for dir in "$root"/Sources/*/; do
|
||||
folder="$(basename "$dir")"
|
||||
case "$folder" in
|
||||
Model|Parsing|Emitting|Decoding|Encoding|Validation|Stream|XPath|Pattern|XPointer|Catalog|XInclude|Canonical|Regex|Schema|XSLT|HTML)
|
||||
prefix="PureXML.$folder"
|
||||
;;
|
||||
*) continue ;;
|
||||
esac
|
||||
rg -N '^\s+(?:public\s+)?(?:(?:final|indirect)\s+)*(struct|enum|class|actor|protocol)\s+([A-Za-z_]\w*)' "$dir" \
|
||||
-r "$prefix.\$2" -o --no-filename 2>/dev/null >>"$discovered" || true
|
||||
done
|
||||
sort -u "$discovered" -o "$discovered"
|
||||
|
||||
missing=0
|
||||
while IFS= read -r qualified; do
|
||||
[[ -z "$qualified" ]] && continue
|
||||
if grep -qxF "$qualified" "$subjects" || grep -qxF "$qualified" "$excludes"; then
|
||||
continue
|
||||
fi
|
||||
ns="${qualified%.*}"
|
||||
if grep -qxF "$ns" "$exclude_ns"; then
|
||||
continue
|
||||
fi
|
||||
if grep -qxF "$ns" "$watch_ns"; then
|
||||
echo "validation-coverage: unclassified public type $qualified (add SUBJECT or EXCLUDE to docs/validation-coverage-registry.txt)" >&2
|
||||
missing=1
|
||||
fi
|
||||
done <"$discovered"
|
||||
|
||||
if [[ "$missing" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "validation-coverage: OK ($(wc -l <"$discovered" | tr -d ' ') public types scanned)"
|
||||
47
scripts/check-validation-fields.sh
Executable file
47
scripts/check-validation-fields.sh
Executable file
@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# validation-rules.md field-coverage gate: docs/validation-field-registry.txt must
|
||||
# classify every stored property on validation document/subject types.
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
registry="$root/docs/validation-field-registry.txt"
|
||||
fixture="$root/Tests/Fixtures/validation-field-registry.txt"
|
||||
|
||||
if [[ ! -f "$registry" ]]; then
|
||||
echo "validation-field-coverage: missing $registry" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$fixture" ]]; then
|
||||
echo "validation-field-coverage: missing $fixture (copy docs registry for WASI tests)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! diff -q "$registry" "$fixture" >/dev/null 2>&1; then
|
||||
echo "validation-field-coverage: docs and Tests/Fixtures registries differ" >&2
|
||||
diff -u "$registry" "$fixture" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Lightweight structural check before the Swift meta-test runs in the suite.
|
||||
models="$(grep -E '^MODEL ' "$registry" | awk '{print $2}' | sort -u)"
|
||||
if [[ -z "$models" ]]; then
|
||||
echo "validation-field-coverage: registry contains no MODEL entries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r model; do
|
||||
[[ -z "$model" ]] && continue
|
||||
fields="$(awk -v model="$model" '
|
||||
$1 == "MODEL" && $2 == model { active=1; next }
|
||||
$1 == "MODEL" { active=0 }
|
||||
active && ($1 == "FIELD" || $1 == "CASE" || $1 == "IGNORE") { count++ }
|
||||
END { print count+0 }
|
||||
' "$registry")"
|
||||
if [[ "$fields" -eq 0 ]]; then
|
||||
echo "validation-field-coverage: $model has no FIELD/CASE/IGNORE entries" >&2
|
||||
exit 1
|
||||
fi
|
||||
done <<<"$models"
|
||||
|
||||
echo "validation-field-coverage: OK ($(wc -l <"$registry" | tr -d ' ') registry lines, $(echo "$models" | wc -l | tr -d ' ') models)"
|
||||
Loading…
Reference in New Issue
Block a user