PureXML/Sources/Schema/RelaxNGParser.swift
Mihaela Mihaljevic f02ba8b8cf feat(relaxng): add compact syntax (RNC) and include/externalRef
Compile RELAX NG from both syntaxes and across documents:

- A compact-syntax parser (lexer plus recursive descent) produces the same
  pattern algebra the XML syntax compiles to. It covers element and attribute
  declarations with name classes and namespace resolution, the , | & combinators
  and ? * + cardinality, text/empty/notAllowed, list and mixed, datatypes and
  value literals, grammars with start and named definitions (= |= &=), nested
  grammars, and include/external references.
- The XML-syntax parser gains include (merging another grammar's definitions,
  with the include's own nested definitions overriding) and externalRef
  (resolving to an external schema's pattern), both through an injected loader.

Both syntaxes reach the loader through a new schemaLoader parameter that
returns nil by default, so compilation never touches the filesystem or network
unless a loader is supplied.

Closes #68
2026-06-08 09:13:09 +02:00

228 lines
8.7 KiB
Swift

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
}
}
/// Compiles a RELAX NG XML-syntax document into the pattern algebra, resolving
/// `include` and `externalRef` through a loader. Holds the accumulating `define`
/// table so external grammars merge into it. File-scope and private.
private final class RNGCompiler {
typealias Pattern = PureXML.Schema.Pattern
typealias NameClass = PureXML.Schema.NameClass
typealias Compositor = PureXML.Schema.Compositor
private(set) var defines: [String: Pattern] = [:]
private let loader: (String) -> String?
private var visited: Set<String> = []
init(loader: @escaping (String) -> String?) {
self.loader = loader
}
func topLevel(_ node: Tree) -> Pattern {
RNGNode.localName(node) == "grammar" ? grammar(node) : pattern(node)
}
private func grammar(_ node: Tree) -> Pattern {
for include in RNGNode.children(node, named: "include") {
mergeInclude(include)
}
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
}
/// Merges an `include`d grammar: its `define`s first, then the `include`'s own
/// nested `define`s, which override the included ones.
private func mergeInclude(_ node: Tree) {
guard let href = RNGNode.attribute(node, "href"), !visited.contains(href),
let text = loader(href), let root = try? PureXML.parseTree(text),
let grammar = RNGNode.elementChildren(root).first
else {
return
}
visited.insert(href)
for define in RNGNode.children(grammar, named: "define") {
if let name = RNGNode.attribute(define, "name") {
defines[name] = combined(RNGNode.elementChildren(define), .sequence)
}
}
for define in RNGNode.children(node, named: "define") {
if let name = RNGNode.attribute(define, "name") {
defines[name] = combined(RNGNode.elementChildren(define), .sequence)
}
}
}
// MARK: Patterns
private func pattern(_ node: Tree) -> Pattern {
leafPattern(node) ?? compositePattern(node)
}
private func leafPattern(_ node: Tree) -> Pattern? {
switch RNGNode.localName(node) {
case "empty": .empty
case "notAllowed": .notAllowed
case "text": .text
case "data": .data(PureXML.Schema.BuiltinType(rawValue: RNGNode.strip(RNGNode.attribute(node, "type") ?? "string")) ?? .string)
case "value": .value(RNGNode.text(node))
case "ref": .ref(RNGNode.attribute(node, "name") ?? "")
case "externalRef": externalRef(node)
case "element": element(node)
case "attribute": attribute(node)
default: nil
}
}
/// Resolves an `externalRef` to the referenced schema's pattern, merging any
/// grammar `define`s it carries into this compiler's table.
private func externalRef(_ node: Tree) -> Pattern {
guard let href = RNGNode.attribute(node, "href"), !visited.contains(href),
let text = loader(href), let root = try? PureXML.parseTree(text),
let top = RNGNode.elementChildren(root).first
else {
return .notAllowed
}
visited.insert(href)
return topLevel(top)
}
private 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 func group(_ node: Tree) -> Pattern {
combined(RNGNode.elementChildren(node), .sequence)
}
private func element(_ node: Tree) -> Pattern {
let (nameClass, contentNodes) = nameClassAndContent(node)
return .element(nameClass, combined(contentNodes, .sequence))
}
private func attribute(_ node: Tree) -> Pattern {
let (nameClass, contentNodes) = nameClassAndContent(node)
let content = contentNodes.isEmpty ? Pattern.text : combined(contentNodes, .sequence)
return .attribute(nameClass, content)
}
private 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 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 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 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 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 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) }
}
}
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`, the name classes, and `include`
/// and `externalRef` resolved through `loader`.
enum RelaxNGParser {
static func parse(
_ rng: String,
loader: @escaping (String) -> String? = { _ in nil },
) 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
}
let compiler = RNGCompiler(loader: loader)
let start = compiler.topLevel(top)
return (start, compiler.defines)
}
}
}