diff --git a/CHANGELOG.md b/CHANGELOG.md index a832f6b..d92bdbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Schema-consistency checks now run through the validation framework (#117 follow-up): the derivation `final` check and Particle Valid (Restriction) are composable `Validation` rules (`finalRespected`, `restrictionsAreSubsets`) applied per named type, so a schema with several problems reports them ALL at once. `SchemaError.finalViolation` and `.invalidRestriction` are replaced by `.inconsistent([String])` carrying every finding with its type's coding path; the finding texts are unchanged. `checkRedefine`, `checkAllGroups`, and `notASchema` stay fail-fast throws because they examine the raw schema source, the compilation analog of well-formedness. - Document-order sorts no longer recompute root paths per comparison (#113): node-set ordering (the evaluator's order-and-dedup, EXSLT's set functions, string-value coercion's first-node lookup) goes through decorate-sort-undecorate helpers (`sortedByDocumentOrder()`, `firstInDocumentOrder()`) that compute each node's order key once, turning O(n log n x depth) sorts into O(n log n). `set:leading`/`set:trailing` also reuse one precomputed pivot key. Behavior is unchanged. - XPath compilation moved out of the per-node loops (#112): XSLT template selection now resolves each match pattern once per transform through a per-run match cache (the pattern's node set is computed once over the source tree, then membership is an identity lookup), instead of recompiling and re-walking the tree for every (node, template) pair; and the XSD identity validator compiles each selector and field XPath once per run through a query cache instead of once per visited element. Schematron already stored compiled queries. Behavior is unchanged; a multi-template shape test guards the path. - The HTML document builder's body is now constructed as a live mutable `TreeNode` tree with a single open-elements stack (the HTML5 model), attaching nodes to their parent as they open rather than accumulating children on close (#83, internal). Behavior is unchanged; this is the foundation the adoption agency algorithm needs to reparent already-built subtrees. diff --git a/README.md b/README.md index 43d15be..6b93fc2 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ flowchart TB 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 -currently runs **846 tests in 141 suites** (`swift test`). +currently runs **850 tests in 142 suites** (`swift test`). ### Shipped (libxml2-aligned surface) diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift index 0aa8629..32504b4 100644 --- a/Sources/Schema/SchemaDocument.swift +++ b/Sources/Schema/SchemaDocument.swift @@ -2,8 +2,6 @@ public extension PureXML.Schema { /// An error compiling or applying a schema. enum SchemaError: Swift.Error, Equatable, Sendable, CustomStringConvertible { case notASchema - /// A type derives from a base that declares that derivation method `final`. - case finalViolation(type: String, base: String, method: String) /// A type inside `xs:redefine` does not redefine itself: its restriction or /// extension base must name the type it redefines. case redefineIncompatible(type: String) @@ -11,22 +9,21 @@ public extension PureXML.Schema { /// an element with `maxOccurs` at most 1, and the group itself may occur at /// most once. case invalidAllGroup(reason: String) - /// A complex-type restriction's content model is not a structurally valid - /// subset of its base's ("Particle Valid (Restriction)" in XSD 1.0). - case invalidRestriction(type: String, base: String, reason: String) + /// The compiled schema fails one or more consistency rules (a `final` + /// violation, an unfaithful restriction); every finding is carried, so a + /// schema with several problems reports them all at once. + case inconsistent([String]) public var description: String { switch self { case .notASchema: "the document is not an xs:schema" - case let .finalViolation(type, base, method): - "type '\(type)' derives from '\(base)' by \(method), which '\(base)' declares final" case let .redefineIncompatible(type): "redefined type '\(type)' must derive from itself" case let .invalidAllGroup(reason): "invalid xs:all group: \(reason)" - case let .invalidRestriction(type, base, reason): - "type '\(type)' is not a valid restriction of '\(base)': \(reason)" + case let .inconsistent(findings): + findings.joined(separator: "; ") } } } @@ -62,17 +59,16 @@ public extension PureXML.Schema { typeBlock = compiled.typeBlock typeDerivation = compiled.typeDerivation targetNamespace = compiled.targetNamespace - // Particle Valid (Restriction): a complex type derived by restriction - // must accept a subset of its base (deterministic order for stable - // error reporting). - for name in typeDerivation.keys.sorted() { - guard let derivation = typeDerivation[name], derivation.method == .restriction, - case let .complex(restrictedType)? = types[name], - case let .complex(baseType)? = types[derivation.base] - else { continue } - if let reason = ParticleRestriction.violation(restricted: restrictedType.content, base: baseType.content) { - throw SchemaError.invalidRestriction(type: name, base: derivation.base, reason: reason) - } + // Schema consistency through the validation framework: every named + // type is checked by the composable rules (final respected, Particle + // Valid (Restriction)), and ALL findings are reported together. + let findings = PureXML.Validation.XSDSchema.consistencyErrors( + types: compiled.types, + typeDerivation: compiled.typeDerivation, + typeFinal: compiled.typeFinal, + ) + if !findings.isEmpty { + throw SchemaError.inconsistent(findings.map { String(describing: $0) }) } } diff --git a/Sources/Schema/XSDContext.swift b/Sources/Schema/XSDContext.swift index 86b73f1..ef02b85 100644 --- a/Sources/Schema/XSDContext.swift +++ b/Sources/Schema/XSDContext.swift @@ -116,6 +116,9 @@ extension PureXML.Schema { /// Each named complex type's base type and derivation method, the backbone /// the `block` check walks from an `xsi:type` to its declared type. var typeDerivation: [String: TypeDerivation] = [:] + /// Derivation methods each named type declares `final`, for the + /// schema-consistency rules. + var typeFinal: [String: Set] = [:] /// The schema's target namespace, so the root element's namespace can be /// checked against it. var targetNamespace: String? diff --git a/Sources/Schema/XSDDerivation.swift b/Sources/Schema/XSDDerivation.swift index 07962df..bba3def 100644 --- a/Sources/Schema/XSDDerivation.swift +++ b/Sources/Schema/XSDDerivation.swift @@ -104,15 +104,6 @@ extension PureXML.Schema.XSDParser { return methods } - /// Throws when any type derives from a base by a method that base declares - /// `final`. - static func checkFinal(_ tables: DerivationTables) throws { - for (name, derivation) in tables.typeDerivation { - guard tables.typeFinal[derivation.base]?.contains(derivation.method) == true else { continue } - throw SchemaFault.finalViolation(type: name, base: derivation.base, method: methodName(derivation.method)) - } - } - /// Throws when a type inside an `xs:redefine` does not derive from itself: a /// redefinition's `base` must name the type being redefined. static func checkRedefine(_ containers: [XSDTree]) throws { diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift index e6e4ee7..e6779af 100644 --- a/Sources/Schema/XSDParser.swift +++ b/Sources/Schema/XSDParser.swift @@ -15,7 +15,10 @@ extension PureXML.Schema { var visited: Set = [] let containers = XSDNode.collectContainers(schema, loader, &visited) let derivation = derivationTables(containers) - try checkFinal(derivation) + // checkRedefine and checkAllGroups examine the raw schema source, the + // schema-compilation analog of well-formedness, so they stay throws; + // the model-level consistency checks (final, restriction subsets) are + // Validation rules collected by Schema.Document. try checkRedefine(containers) try checkAllGroups(containers) var context = XSDContext( @@ -58,6 +61,7 @@ extension PureXML.Schema { abstractElements: derivation.abstractElements, typeBlock: derivation.typeBlock, typeDerivation: derivation.typeDerivation, + typeFinal: derivation.typeFinal, targetNamespace: context.targetNamespace, ) } diff --git a/Sources/Schema/XSDSchemaValidations.swift b/Sources/Schema/XSDSchemaValidations.swift new file mode 100644 index 0000000..d348d72 --- /dev/null +++ b/Sources/Schema/XSDSchemaValidations.swift @@ -0,0 +1,82 @@ +public extension PureXML.Schema { + /// One named complex type's compile-time derivation facts: the subject of + /// the schema-consistency rules. + struct SchemaTypeFact: PureXML.Validation.Validatable { + public let name: String + public let derivation: TypeDerivation? + + public init(name: String, derivation: TypeDerivation?) { + self.name = name + self.derivation = derivation + } + } + + /// The compiled context the schema-consistency rules read: the named-type + /// table and each type's `final` controls. + struct CompiledSchemaFacts { + public let types: [String: ElementType] + public let typeFinal: [String: Set] + + public init(types: [String: ElementType], typeFinal: [String: Set]) { + self.types = types + self.typeFinal = typeFinal + } + } +} + +public extension PureXML.Validation { + /// Schema-consistency validation, expressed in the validation framework: + /// each named type is a ``PureXML/Schema/SchemaTypeFact`` subject checked by + /// composable rules, so a schema with several problems reports them all at + /// once instead of failing on the first. + enum XSDSchema { + /// A type derives from its base only by methods the base permits. + static var finalRespected: Validation { + .init(description: "A type derives from its base only by methods the base permits") { context in + guard let derivation = context.subject.derivation, + context.document.typeFinal[derivation.base]?.contains(derivation.method) == true + else { return [] } + let method = methodName(derivation.method) + let reason = "type '\(context.subject.name)' derives from '\(derivation.base)' by \(method), which '\(derivation.base)' declares final" + return [ValidationError(reason: reason, at: context.codingPath)] + } + } + + /// A complex type derived by restriction accepts a subset of its base + /// ("Particle Valid (Restriction)", XSD 1.0). + static var restrictionsAreSubsets: Validation { + .init(description: "A restriction's content model accepts a subset of its base's") { context in + guard let derivation = context.subject.derivation, derivation.method == .restriction, + case let .complex(restricted)? = context.document.types[context.subject.name], + case let .complex(base)? = context.document.types[derivation.base], + let reason = PureXML.Schema.ParticleRestriction.violation(restricted: restricted.content, base: base.content) + else { return [] } + let text = "type '\(context.subject.name)' is not a valid restriction of '\(derivation.base)': \(reason)" + return [ValidationError(reason: text, at: context.codingPath)] + } + } + + /// Every consistency finding across the schema's named types, each + /// located at its type, in deterministic order. + static func consistencyErrors( + types: [String: PureXML.Schema.ElementType], + typeDerivation: [String: PureXML.Schema.TypeDerivation], + typeFinal: [String: Set], + ) -> [ValidationError] { + let facts = PureXML.Schema.CompiledSchemaFacts(types: types, 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) } + } + } + + private static func methodName(_ method: PureXML.Schema.DerivationMethod) -> String { + switch method { + case .extension: "extension" + case .restriction: "restriction" + case .substitution: "substitution" + } + } + } +} diff --git a/Tests/XSDSchemaValidationTests.swift b/Tests/XSDSchemaValidationTests.swift new file mode 100644 index 0000000..4542ce6 --- /dev/null +++ b/Tests/XSDSchemaValidationTests.swift @@ -0,0 +1,163 @@ +@testable import PureXML +import Testing + +/// Unit tests for the schema-consistency validation rules (final respected, +/// Particle Valid (Restriction)) exercised directly per the framework idiom, +/// plus the collected-findings behavior: a schema with several problems +/// reports them all at once instead of failing on the first. +@Suite("XSD schema-consistency validation") +struct XSDSchemaValidationTests { + private typealias Fact = PureXML.Schema.SchemaTypeFact + private typealias Facts = PureXML.Schema.CompiledSchemaFacts + + /// Compiles raw XSD source into the parser's tables, bypassing + /// `Schema.Document`'s consistency throw, so the rules can be applied + /// directly to known-inconsistent schemas. + private func compile(_ xsd: String) throws -> PureXML.Schema.XSDCompiled { + try PureXML.Schema.XSDParser.parse(xsd) { _ in nil } + } + + private let finalViolationSchema = """ + + + + + + + + + + + + + + """ + + private let badRestrictionSchema = """ + + + + + + + + + + + + + + + + + """ + + @Test("finalRespected reports a derivation the base declares final, and only that") + func test_finalRespectedRule() throws { + let compiled = try compile(finalViolationSchema) + let facts = Facts(types: compiled.types, typeFinal: compiled.typeFinal) + let rule = PureXML.Validation.XSDSchema.finalRespected + let bad = Fact(name: "CircleT", derivation: compiled.typeDerivation["CircleT"]) + let errors = rule.apply(to: bad, at: [.element("CircleT")], in: facts) + #expect(errors.count == 1) + #expect(errors.first?.reason == "type 'CircleT' derives from 'ShapeT' by extension, which 'ShapeT' declares final") + // A type with no derivation at all is untouched by the rule. + let underived = Fact(name: "ShapeT", derivation: nil) + #expect(rule.apply(to: underived, at: [.element("ShapeT")], in: facts).isEmpty) + } + + @Test("restrictionsAreSubsets reports an unfaithful restriction with the violation reason") + func test_restrictionRule() throws { + let compiled = try compile(badRestrictionSchema) + let facts = Facts(types: compiled.types, typeFinal: compiled.typeFinal) + let rule = PureXML.Validation.XSDSchema.restrictionsAreSubsets + let bad = Fact(name: "R", derivation: compiled.typeDerivation["R"]) + let errors = rule.apply(to: bad, at: [.element("R")], in: facts) + #expect(errors.count == 1) + #expect(errors.first?.reason.contains("type 'R' is not a valid restriction of 'B'") == true) + // The same rule passes a faithful restriction (identical content model). + let faithful = """ + + + + + + + + + + + + + + """ + let good = try compile(faithful) + let goodFacts = Facts(types: good.types, typeFinal: good.typeFinal) + let goodFact = Fact(name: "R", derivation: good.typeDerivation["R"]) + #expect(rule.apply(to: goodFact, at: [.element("R")], in: goodFacts).isEmpty) + } + + @Test("A schema with several consistency problems reports them all at once") + func test_multiProblemSchemaReportsAll() throws { + let xsd = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + do { + _ = try PureXML.Schema.Document(xsd) + Issue.record("compilation should have thrown") + } catch let error as PureXML.Schema.SchemaError { + guard case let .inconsistent(findings) = error else { + Issue.record("expected .inconsistent, got \(error)") + return + } + #expect(findings.count == 2) + #expect(findings.contains { $0.contains("declares final") }) + #expect(findings.contains { $0.contains("not a valid restriction") }) + } + } + + @Test("consistencyErrors walks every derived type in deterministic name order") + func test_consistencyErrorsOrder() throws { + let compiledFinal = try compile(finalViolationSchema) + let compiledRestriction = try compile(badRestrictionSchema) + let finalErrors = PureXML.Validation.XSDSchema.consistencyErrors( + types: compiledFinal.types, + typeDerivation: compiledFinal.typeDerivation, + typeFinal: compiledFinal.typeFinal, + ) + #expect(finalErrors.count == 1) + #expect(finalErrors.first?.codingPath == [.element("CircleT")]) + let restrictionErrors = PureXML.Validation.XSDSchema.consistencyErrors( + types: compiledRestriction.types, + typeDerivation: compiledRestriction.typeDerivation, + typeFinal: compiledRestriction.typeFinal, + ) + #expect(restrictionErrors.count == 1) + #expect(restrictionErrors.first?.codingPath == [.element("R")]) + } +}