diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6cda6..c11975b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- RELAX NG validation by derivatives (toward #2). `PureXML.Schema.RelaxNG` + parses a RELAX NG schema in the XML syntax into a pattern grammar (`element`, + `attribute`, `text`, `empty`, `notAllowed`, `group`, `choice`, `interleave`, + `optional`, `zeroOrMore`, `oneOrMore`, `mixed`, `list`, `ref`/`define`, `data`, + `value`, and the name classes), and `validate(_:)` checks an instance document + by James Clark's derivative algorithm: the pattern is derived through each + start-tag, attribute, text, and end-tag event, and the document is valid when + the residual pattern is nullable. Handles interleave, recursive grammars, and + reuses the XSD datatype library for `data`/`value`. - XSD schema-document parser, completing the XSD validation pipeline (toward #2). `PureXML.Schema.Document(xsd)` compiles a schema document into its global element declarations and named-type table, and `validate(_:)` checks an diff --git a/Sources/Schema/RelaxNGDerivative.swift b/Sources/Schema/RelaxNGDerivative.swift new file mode 100644 index 0000000..684d1bc --- /dev/null +++ b/Sources/Schema/RelaxNGDerivative.swift @@ -0,0 +1,258 @@ +extension PureXML.Schema { + /// Validates a document against a RELAX NG grammar by Brzozowski-style + /// derivatives (James Clark's "An algorithm for RELAX NG validation"). The + /// pattern is repeatedly derived with respect to each event of the instance; + /// the document is valid when the residual pattern is nullable. + struct RelaxNGEngine { + let defines: [String: PureXML.Schema.Pattern] + + /// Whether `root` matches `start`. + func matches(start: PureXML.Schema.Pattern, root: PureXML.Model.Node) -> Bool { + nullable(childDeriv(start, root)) + } + + // MARK: Simplifying constructors + + private func choice(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + if case .notAllowed = lhs { return rhs } + if case .notAllowed = rhs { return lhs } + return .choice(lhs, rhs) + } + + private func group(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + if case .notAllowed = lhs { return .notAllowed } + if case .notAllowed = rhs { return .notAllowed } + if case .empty = lhs { return rhs } + if case .empty = rhs { return lhs } + return .group(lhs, rhs) + } + + private func interleave(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + if case .notAllowed = lhs { return .notAllowed } + if case .notAllowed = rhs { return .notAllowed } + if case .empty = lhs { return rhs } + if case .empty = rhs { return lhs } + return .interleave(lhs, rhs) + } + + private func after(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + if case .notAllowed = lhs { return .notAllowed } + if case .notAllowed = rhs { return .notAllowed } + return .after(lhs, rhs) + } + + private func resolve(_ name: String) -> PureXML.Schema.Pattern { + defines[name] ?? .notAllowed + } + + // MARK: Nullability + + func nullable(_ pattern: PureXML.Schema.Pattern, visiting: Set = []) -> Bool { + switch pattern { + case .empty, .text: return true + case let .choice(lhs, rhs): return nullable(lhs, visiting: visiting) || nullable(rhs, visiting: visiting) + case let .group(lhs, rhs), let .interleave(lhs, rhs): + return nullable(lhs, visiting: visiting) && nullable(rhs, visiting: visiting) + case let .oneOrMore(inner), let .list(inner): return nullable(inner, visiting: visiting) + case let .ref(name): + guard !visiting.contains(name) else { return false } + return nullable(resolve(name), visiting: visiting.union([name])) + default: + return false + } + } + + // MARK: Text and attribute derivatives + + private func textDeriv(_ pattern: PureXML.Schema.Pattern, _ string: String) -> PureXML.Schema.Pattern { + switch pattern { + case let .choice(lhs, rhs): return choice(textDeriv(lhs, string), textDeriv(rhs, string)) + case let .interleave(lhs, rhs): + return choice(interleave(textDeriv(lhs, string), rhs), interleave(lhs, textDeriv(rhs, string))) + case let .group(lhs, rhs): + let derived = group(textDeriv(lhs, string), rhs) + return nullable(lhs) ? choice(derived, textDeriv(rhs, string)) : derived + case let .after(lhs, rhs): return after(textDeriv(lhs, string), rhs) + case let .oneOrMore(inner): + return group(textDeriv(inner, string), choice(.oneOrMore(inner), .empty)) + case .text: return .text + case let .ref(name): return textDeriv(resolve(name), string) + default: return valueDeriv(pattern, string) + } + } + + private func valueDeriv(_ pattern: PureXML.Schema.Pattern, _ string: String) -> PureXML.Schema.Pattern { + switch pattern { + case let .value(literal): + SimpleType(base: .token).validate(string) == nil + && SimpleType.process(string, whiteSpace: .collapse) == literal ? .empty : .notAllowed + case let .data(type): SimpleType(base: type).validate(string) == nil ? .empty : .notAllowed + case let .list(inner): listMatches(inner, string) ? .empty : .notAllowed + default: .notAllowed + } + } + + private func listMatches(_ pattern: PureXML.Schema.Pattern, _ string: String) -> Bool { + var residual = pattern + for token in string.split(whereSeparator: \.isWhitespace) { + residual = textDeriv(residual, String(token)) + } + return nullable(residual) + } + + private func attributeDeriv(_ pattern: PureXML.Schema.Pattern, _ attribute: PureXML.Model.Attribute) -> PureXML.Schema.Pattern { + switch pattern { + case let .choice(lhs, rhs): return choice(attributeDeriv(lhs, attribute), attributeDeriv(rhs, attribute)) + case let .group(lhs, rhs): + return choice(group(attributeDeriv(lhs, attribute), rhs), group(lhs, attributeDeriv(rhs, attribute))) + case let .interleave(lhs, rhs): + return choice(interleave(attributeDeriv(lhs, attribute), rhs), interleave(lhs, attributeDeriv(rhs, attribute))) + case let .oneOrMore(inner): + return group(attributeDeriv(inner, attribute), choice(.oneOrMore(inner), .empty)) + case let .after(lhs, rhs): return after(attributeDeriv(lhs, attribute), rhs) + case let .attribute(nameClass, content): + guard nameClass.contains(attribute.name), valueMatches(content, attribute.value) else { return .notAllowed } + return .empty + case let .ref(name): return attributeDeriv(resolve(name), attribute) + default: return .notAllowed + } + } + + private func valueMatches(_ content: PureXML.Schema.Pattern, _ value: String) -> Bool { + value.isEmpty ? nullable(content) : nullable(textDeriv(content, value)) + } + + private func oneOrMoreP(_ inner: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + if case .notAllowed = inner { return .notAllowed } + return .oneOrMore(inner) + } + } +} + +extension PureXML.Schema.RelaxNGEngine { + // MARK: Element derivatives + + func childDeriv(_ pattern: PureXML.Schema.Pattern, _ node: PureXML.Model.Node) -> PureXML.Schema.Pattern { + switch node { + case let .text(string), let .cdata(string): + textDeriv(pattern, string) + case let .element(element): + processElement(pattern, element) + default: + pattern + } + } + + private func processElement(_ pattern: PureXML.Schema.Pattern, _ element: PureXML.Model.Element) -> PureXML.Schema.Pattern { + let opened = startTagOpenDeriv(pattern, element.name) + let attributed = element.attributes + .filter { !Self.isNamespaceDeclaration($0) } + .reduce(opened) { attributeDeriv($0, $1) } + let closed = startTagCloseDeriv(attributed) + let withChildren = childrenDeriv(closed, element.children) + return endTagDeriv(withChildren) + } + + private func startTagOpenDeriv(_ pattern: PureXML.Schema.Pattern, _ name: PureXML.Model.QualifiedName) -> PureXML.Schema.Pattern { + switch pattern { + case let .choice(lhs, rhs): + return choice(startTagOpenDeriv(lhs, name), startTagOpenDeriv(rhs, name)) + case let .element(nameClass, content): + return nameClass.contains(name) ? after(content, .empty) : .notAllowed + case let .interleave(lhs, rhs): + return choice( + applyAfter({ interleave($0, rhs) }, startTagOpenDeriv(lhs, name)), + applyAfter({ interleave(lhs, $0) }, startTagOpenDeriv(rhs, name)), + ) + case let .group(lhs, rhs): + let derived = applyAfter({ group($0, rhs) }, startTagOpenDeriv(lhs, name)) + return nullable(lhs) ? choice(derived, startTagOpenDeriv(rhs, name)) : derived + case let .oneOrMore(inner): + return applyAfter({ group($0, choice(oneOrMoreP(inner), .empty)) }, startTagOpenDeriv(inner, name)) + case let .after(lhs, rhs): + return applyAfter({ after($0, rhs) }, startTagOpenDeriv(lhs, name)) + case let .ref(reference): + return startTagOpenDeriv(resolve(reference), name) + default: + return .notAllowed + } + } + + private func applyAfter(_ transform: (PureXML.Schema.Pattern) -> PureXML.Schema.Pattern, _ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + switch pattern { + case let .after(lhs, rhs): after(lhs, transform(rhs)) + case let .choice(lhs, rhs): choice(applyAfter(transform, lhs), applyAfter(transform, rhs)) + default: .notAllowed + } + } + + private func startTagCloseDeriv(_ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + switch pattern { + case let .choice(lhs, rhs): choice(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs)) + case let .group(lhs, rhs): group(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs)) + case let .interleave(lhs, rhs): interleave(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs)) + case let .oneOrMore(inner): oneOrMoreP(startTagCloseDeriv(inner)) + case let .after(lhs, rhs): after(startTagCloseDeriv(lhs), rhs) + case .attribute: .notAllowed + case let .ref(reference): startTagCloseDeriv(resolve(reference)) + default: pattern + } + } + + private func childrenDeriv(_ pattern: PureXML.Schema.Pattern, _ children: [PureXML.Model.Node]) -> PureXML.Schema.Pattern { + let coalesced = Self.coalesceText(children) + if coalesced.isEmpty { return pattern } + if coalesced.count == 1, case let .text(string) = coalesced[0] { + let derived = textDeriv(pattern, string) + return Self.isWhitespace(string) ? choice(pattern, derived) : derived + } + return coalesced.reduce(pattern) { accumulated, node in + Self.isWhitespaceText(node) ? accumulated : childDeriv(accumulated, node) + } + } + + private func endTagDeriv(_ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + switch pattern { + case let .choice(lhs, rhs): choice(endTagDeriv(lhs), endTagDeriv(rhs)) + case let .after(lhs, rhs): nullable(lhs) ? rhs : .notAllowed + case let .ref(reference): endTagDeriv(resolve(reference)) + default: .notAllowed + } + } + + // MARK: Helpers + + /// Merges adjacent text and CDATA nodes into single text nodes. + private static func coalesceText(_ children: [PureXML.Model.Node]) -> [PureXML.Model.Node] { + var result: [PureXML.Model.Node] = [] + var run = "" + for child in children { + switch child { + case let .text(value), let .cdata(value): + run += value + case .comment, .processingInstruction: + continue + default: + if !run.isEmpty { result.append(.text(run)) + run = "" + } + result.append(child) + } + } + if !run.isEmpty { result.append(.text(run)) } + return result + } + + private static func isWhitespace(_ string: String) -> Bool { + string.allSatisfy(\.isWhitespace) + } + + private static func isWhitespaceText(_ node: PureXML.Model.Node) -> Bool { + if case let .text(value) = node { return isWhitespace(value) } + return false + } + + private static func isNamespaceDeclaration(_ attribute: PureXML.Model.Attribute) -> Bool { + attribute.name.prefix == "xmlns" || (attribute.name.prefix == nil && attribute.name.localName == "xmlns") + } +} diff --git a/Sources/Schema/RelaxNGParser.swift b/Sources/Schema/RelaxNGParser.swift new file mode 100644 index 0000000..577c44e --- /dev/null +++ b/Sources/Schema/RelaxNGParser.swift @@ -0,0 +1,170 @@ +private typealias Tree = PureXML.Model.TreeNode + +/// Tree helpers for the RELAX NG parser. File-scope and private. +private enum RNGNode { + static func localName(_ node: Tree) -> String? { + node.name?.localName + } + + static func attribute(_ node: Tree, _ name: String) -> String? { + node.attributes.first { $0.name.localName == name }?.value + } + + static func elementChildren(_ node: Tree) -> [Tree] { + node.children.filter { $0.kind == .element } + } + + static func children(_ node: Tree, named name: String) -> [Tree] { + elementChildren(node).filter { localName($0) == name } + } + + static func text(_ node: Tree) -> String { + node.stringValue.trimmingXMLWhitespace() + } + + static func strip(_ qualified: String) -> String { + qualified.split(separator: ":").last.map(String.init) ?? qualified + } +} + +extension PureXML.Schema { + /// Parses a RELAX NG schema in the XML syntax into a grammar: a start pattern + /// and the named `define` patterns it refers to. The vocabulary is matched by + /// local name. Supports `element`, `attribute`, `text`, `empty`, `notAllowed`, + /// `group`, `choice`, `interleave`, `optional`, `zeroOrMore`, `oneOrMore`, + /// `mixed`, `list`, `ref`, `data`, `value`, and the name classes. + enum RelaxNGParser { + static func parse(_ rng: String) throws -> (start: Pattern, defines: [String: Pattern]) { + let root = try PureXML.parseTree(rng) + guard let top = RNGNode.elementChildren(root).first else { + throw PureXML.Schema.SchemaError.notASchema + } + if RNGNode.localName(top) == "grammar" { + return grammar(top) + } + return (pattern(top), [:]) + } + + private static func grammar(_ node: Tree) -> (start: Pattern, defines: [String: Pattern]) { + var defines: [String: Pattern] = [:] + for define in RNGNode.children(node, named: "define") { + if let name = RNGNode.attribute(define, "name") { + defines[name] = combined(RNGNode.elementChildren(define), .sequence) + } + } + let start = RNGNode.children(node, named: "start").first + return (start.map { combined(RNGNode.elementChildren($0), .sequence) } ?? .notAllowed, defines) + } + + // MARK: Patterns + + private static func pattern(_ node: Tree) -> Pattern { + leafPattern(node) ?? compositePattern(node) + } + + /// The terminal and element/attribute patterns; nil for a compositor. + private static func leafPattern(_ node: Tree) -> Pattern? { + switch RNGNode.localName(node) { + case "empty": .empty + case "notAllowed": .notAllowed + case "text": .text + case "data": .data(BuiltinType(rawValue: RNGNode.strip(RNGNode.attribute(node, "type") ?? "string")) ?? .string) + case "value": .value(RNGNode.text(node)) + case "ref": .ref(RNGNode.attribute(node, "name") ?? "") + case "element": element(node) + case "attribute": attribute(node) + default: nil + } + } + + /// The compositor and quantifier patterns. + private static func compositePattern(_ node: Tree) -> Pattern { + switch RNGNode.localName(node) { + case "group": combined(RNGNode.elementChildren(node), .sequence) + case "choice": combined(RNGNode.elementChildren(node), .choice) + case "interleave": combined(RNGNode.elementChildren(node), .all) + case "optional": .choice(group(node), .empty) + case "zeroOrMore": .choice(.oneOrMore(group(node)), .empty) + case "oneOrMore": .oneOrMore(group(node)) + case "mixed": .interleave(group(node), .text) + case "list": .list(group(node)) + default: .notAllowed + } + } + + private static func group(_ node: Tree) -> Pattern { + combined(RNGNode.elementChildren(node), .sequence) + } + + private static func element(_ node: Tree) -> Pattern { + let (nameClass, contentNodes) = nameClassAndContent(node) + return .element(nameClass, combined(contentNodes, .sequence)) + } + + private static func attribute(_ node: Tree) -> Pattern { + let (nameClass, contentNodes) = nameClassAndContent(node) + let content = contentNodes.isEmpty ? Pattern.text : combined(contentNodes, .sequence) + return .attribute(nameClass, content) + } + + /// Splits an `element`/`attribute` node into its name class and its pattern + /// children: a `name` attribute gives the class and all children are + /// content; otherwise the first child is the name class. + private static func nameClassAndContent(_ node: Tree) -> (NameClass, [Tree]) { + let children = RNGNode.elementChildren(node) + if let name = RNGNode.attribute(node, "name") { + let namespace = RNGNode.attribute(node, "ns") ?? "" + return (.name(namespace: namespace, localName: name), children) + } + guard let first = children.first else { return (.anyName, []) } + return (nameClass(first), Array(children.dropFirst())) + } + + // MARK: Combinators and name classes + + private static func combined(_ nodes: [Tree], _ compositor: Compositor) -> Pattern { + let patterns = nodes.map(pattern) + let identity: Pattern = compositor == .choice ? .notAllowed : .empty + guard let first = patterns.first else { return identity } + return patterns.dropFirst().reduce(first) { combine($0, $1, compositor) } + } + + private static func combine(_ lhs: Pattern, _ rhs: Pattern, _ compositor: Compositor) -> Pattern { + switch compositor { + case .sequence: .group(lhs, rhs) + case .choice: .choice(lhs, rhs) + case .all: .interleave(lhs, rhs) + } + } + + private static func nameClass(_ node: Tree) -> NameClass { + switch RNGNode.localName(node) { + case "name": + .name(namespace: RNGNode.attribute(node, "ns") ?? "", localName: RNGNode.text(node)) + case "anyName": + anyName(node) + case "nsName": + .nsName(RNGNode.attribute(node, "ns") ?? "") + case "choice": + nameClassChoice(RNGNode.elementChildren(node)) + default: + .anyName + } + } + + private static func anyName(_ node: Tree) -> NameClass { + guard let except = RNGNode.children(node, named: "except").first, + let inner = RNGNode.elementChildren(except).first + else { + return .anyName + } + return .anyNameExcept(nameClass(inner)) + } + + private static func nameClassChoice(_ nodes: [Tree]) -> NameClass { + let classes = nodes.map(nameClass) + guard let first = classes.first else { return .anyName } + return classes.dropFirst().reduce(first) { .choice($0, $1) } + } + } +} diff --git a/Sources/Schema/RelaxNGPattern.swift b/Sources/Schema/RelaxNGPattern.swift new file mode 100644 index 0000000..fae2082 --- /dev/null +++ b/Sources/Schema/RelaxNGPattern.swift @@ -0,0 +1,42 @@ +public extension PureXML.Schema { + /// A RELAX NG name class: which element or attribute names a pattern matches. + indirect enum NameClass: Sendable { + case anyName + case anyNameExcept(NameClass) + case name(namespace: String, localName: String) + case nsName(String) + case choice(NameClass, NameClass) + + /// Whether `name` is in this class. + func contains(_ name: PureXML.Model.QualifiedName) -> Bool { + let namespace = name.namespaceURI ?? "" + switch self { + case .anyName: return true + case let .anyNameExcept(except): return !except.contains(name) + case let .name(wantedNamespace, wantedLocal): return namespace == wantedNamespace && name.localName == wantedLocal + case let .nsName(wantedNamespace): return namespace == wantedNamespace + case let .choice(left, right): return left.contains(name) || right.contains(name) + } + } + } + + /// A RELAX NG pattern (the simplified algebra used by the derivative + /// algorithm). `optional`, `zeroOrMore`, and `mixed` are expressed through + /// these constructors; `after` is internal to derivation. + indirect enum Pattern: Sendable { + case empty + case notAllowed + case text + case choice(Pattern, Pattern) + case interleave(Pattern, Pattern) + case group(Pattern, Pattern) + case oneOrMore(Pattern) + case element(NameClass, Pattern) + case attribute(NameClass, Pattern) + case data(BuiltinType) + case value(String) + case list(Pattern) + case ref(String) + case after(Pattern, Pattern) + } +} diff --git a/Sources/Schema/RelaxNGSchema.swift b/Sources/Schema/RelaxNGSchema.swift new file mode 100644 index 0000000..6c41603 --- /dev/null +++ b/Sources/Schema/RelaxNGSchema.swift @@ -0,0 +1,23 @@ +public extension PureXML.Schema { + /// A compiled RELAX NG schema (XML syntax). `validate(_:)` checks an instance + /// document by the derivative algorithm. + struct RelaxNG: Sendable { + private let start: Pattern + private let defines: [String: Pattern] + + /// Compiles a RELAX NG schema document. + public init(_ rng: String) throws { + (start, defines) = try RelaxNGParser.parse(rng) + } + + /// Whether `xml` is valid against the schema. + public func validate(_ xml: String) throws -> Bool { + guard case let .document(children) = try PureXML.parse(xml), + let root = children.compactMap(\.element).first + else { + return false + } + return RelaxNGEngine(defines: defines).matches(start: start, root: .element(root)) + } + } +} diff --git a/Tests/RelaxNGTests.swift b/Tests/RelaxNGTests.swift new file mode 100644 index 0000000..1ffeea6 --- /dev/null +++ b/Tests/RelaxNGTests.swift @@ -0,0 +1,115 @@ +@testable import PureXML +import Testing + +@Suite("RELAX NG") +struct RelaxNGTests { + private func valid(_ rng: String, _ xml: String) throws -> Bool { + try PureXML.Schema.RelaxNG(rng).validate(xml) + } + + private let rngNamespace = "xmlns=\"http://relaxng.org/ns/structure/1.0\"" + + @Test("An element with text content") + func test_elementText() throws { + let rng = "" + #expect(try valid(rng, "hello")) + #expect(try !valid(rng, "hello")) + #expect(try !valid(rng, "")) + } + + @Test("A sequence of child elements in order") + func test_sequence() throws { + let rng = """ + + + + + """ + #expect(try valid(rng, "TA")) + #expect(try !valid(rng, "AT")) + #expect(try !valid(rng, "T")) + } + + @Test("choice, optional, and zeroOrMore") + func test_combinators() throws { + let rng = """ + + + + + + """ + #expect(try valid(rng, "1")) + #expect(try valid(rng, "")) + #expect(try !valid(rng, "")) + } + + @Test("oneOrMore requires at least one") + func test_oneOrMore() throws { + let rng = """ + + + + """ + #expect(try valid(rng, "ab")) + #expect(try !valid(rng, "")) + } + + @Test("Attributes are validated and required") + func test_attributes() throws { + let rng = """ + + + + + """ + #expect(try valid(rng, "")) + #expect(try !valid(rng, "")) + } + + @Test("interleave allows children in any order") + func test_interleave() throws { + let rng = """ + + + + + + + """ + #expect(try valid(rng, "")) + #expect(try valid(rng, "")) + #expect(try !valid(rng, "")) + } + + @Test("data types validate text content") + func test_data() throws { + let rng = "" + #expect(try valid(rng, "42")) + #expect(try !valid(rng, "x")) + } + + @Test("value matches a literal") + func test_value() throws { + let rng = "yes" + #expect(try valid(rng, "yes")) + #expect(try !valid(rng, "no")) + } + + @Test("A grammar with define and ref validates recursive structures") + func test_recursiveGrammar() throws { + let rng = """ + + + + + + + + + """ + #expect(try valid(rng, "")) + #expect(try valid(rng, "")) + #expect(try !valid(rng, "")) + } +}