Merge epic/68-relaxng-compact: RNC compact syntax, include, externalRef

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 09:13:10 +02:00
commit 019ce94d03
5 changed files with 739 additions and 131 deletions

View File

@ -0,0 +1,76 @@
extension PureXML.Schema {
/// One lexical token of the RELAX NG compact syntax.
enum RNCToken: Equatable {
case word(String)
case string(String)
case symbol(String)
}
/// Tokenizes RELAX NG compact-syntax source: NCName words (which may carry a
/// single `:` prefix), quoted string literals, and punctuation, with `#`
/// line comments skipped.
enum RNCLexer {
static func tokens(_ source: String) -> [RNCToken] {
var tokens: [RNCToken] = []
let characters = Array(source)
var index = 0
while index < characters.count {
let character = characters[index]
if character == "#" {
while index < characters.count, characters[index] != "\n" {
index += 1
}
} else if character.isWhitespace {
index += 1
} else if character == "\"" || character == "'" {
index = readString(characters, index, quote: character, into: &tokens)
} else if isNameStart(character) {
index = readWord(characters, index, into: &tokens)
} else {
index = readSymbol(characters, index, into: &tokens)
}
}
return tokens
}
private static func readString(_ characters: [Character], _ start: Int, quote: Character, into tokens: inout [RNCToken]) -> Int {
var index = start + 1
var value = ""
while index < characters.count, characters[index] != quote {
value.append(characters[index])
index += 1
}
tokens.append(.string(value))
return index + 1
}
private static func readWord(_ characters: [Character], _ start: Int, into tokens: inout [RNCToken]) -> Int {
var index = start
var value = ""
while index < characters.count, isNameChar(characters[index]) {
value.append(characters[index])
index += 1
}
tokens.append(.word(value))
return index
}
private static func readSymbol(_ characters: [Character], _ start: Int, into tokens: inout [RNCToken]) -> Int {
let two = start + 1 < characters.count ? String(characters[start ... start + 1]) : ""
if two == "|=" || two == "&=" {
tokens.append(.symbol(two))
return start + 2
}
tokens.append(.symbol(String(characters[start])))
return start + 1
}
private static func isNameStart(_ character: Character) -> Bool {
character.isLetter || character == "_"
}
private static func isNameChar(_ character: Character) -> Bool {
character.isLetter || character.isNumber || character == "_" || character == "." || character == "-" || character == ":"
}
}
}

View File

@ -0,0 +1,389 @@
private typealias RNCToken = PureXML.Schema.RNCToken
private typealias RNCLexer = PureXML.Schema.RNCLexer
/// A recursive-descent parser from the token stream to the pattern algebra.
/// File-scope and private.
private final class RNCParser {
typealias Pattern = PureXML.Schema.Pattern
typealias NameClass = PureXML.Schema.NameClass
private let tokens: [RNCToken]
private var position = 0
private let loader: (String) -> String?
private(set) var defines: [String: Pattern] = [:]
private var startPattern: Pattern?
private var defaultNamespace = ""
private var namespaces: [String: String] = [:]
private var visited: Set<String>
init(_ tokens: [RNCToken], loader: @escaping (String) -> String?, visited: Set<String> = []) {
self.tokens = tokens
self.loader = loader
self.visited = visited
}
func parse() -> (start: Pattern, defines: [String: Pattern]) {
skipDeclarations()
if looksLikeGrammar() {
parseGrammarBody()
return (startPattern ?? .notAllowed, defines)
}
return (parsePattern(), defines)
}
// MARK: Declarations
private func skipDeclarations() {
while case let .word(keyword) = peek() {
switch keyword {
case "namespace": parseNamespace(isDefault: false)
case "default": parseDefault()
case "datatypes": parseDatatypes()
default: return
}
}
}
private func parseDefault() {
advance() // default
if case .word("namespace") = peek() { parseNamespace(isDefault: true) }
}
private func parseNamespace(isDefault: Bool) {
advance() // namespace
var prefix = ""
if case let .word(name) = peek(), name != "=" { prefix = name
advance()
}
expectSymbol("=")
let uri = expectString()
if isDefault { defaultNamespace = uri }
if !prefix.isEmpty { namespaces[prefix] = uri }
}
private func parseDatatypes() {
advance() // datatypes
if case .word = peek() { advance() }
expectSymbol("=")
_ = expectString()
}
// MARK: Grammar body
private func looksLikeGrammar() -> Bool {
guard case let .word(word) = peek() else { return false }
if word == "include" || word == "start" { return true }
// A define is a word followed by one of =, |=, &=.
if case let .symbol(symbol) = peek(1), symbol == "=" || symbol == "|=" || symbol == "&=" { return true }
return false
}
private func parseGrammarBody() {
while position < tokens.count, peek() != .symbol("}") {
if case .word("include") = peek() {
parseInclude()
} else {
parseDefine()
}
}
}
private func parseDefine() {
guard case let .word(name) = peek() else { advance()
return
}
advance()
let combinator = expectAssignment()
let pattern = parsePattern()
if name == "start" {
startPattern = combine(startPattern, pattern, combinator)
} else {
defines[name] = combine(defines[name], pattern, combinator)
}
}
private func parseInclude() {
advance() // include
let href = expectString()
if let text = loader(href), !visited.contains(href) {
visited.insert(href)
let sub = RNCParser(RNCLexer.tokens(text), loader: loader, visited: visited)
sub.skipDeclarations()
sub.parseGrammarBody()
for (name, pattern) in sub.defines {
defines[name] = pattern
}
if startPattern == nil { startPattern = sub.startPattern }
visited = sub.visited
}
// An override block re-defines names after the include, overriding them.
if peek() == .symbol("{") {
advance()
parseGrammarBody()
expectSymbol("}")
}
}
private func combine(_ existing: Pattern?, _ new: Pattern, _ combinator: String) -> Pattern {
guard let existing else { return new }
switch combinator {
case "|=": return .choice(existing, new)
case "&=": return .interleave(existing, new)
default: return new
}
}
// MARK: Patterns
private func parsePattern() -> Pattern {
var patterns = [parseParticle()]
guard case let .symbol(operatorSymbol) = peek(), operatorSymbol == "," || operatorSymbol == "|" || operatorSymbol == "&" else {
return patterns[0]
}
while peek() == .symbol(operatorSymbol) {
advance()
patterns.append(parseParticle())
}
return patterns.dropFirst().reduce(patterns[0]) { combinePatterns($0, $1, operatorSymbol) }
}
private func combinePatterns(_ lhs: Pattern, _ rhs: Pattern, _ operatorSymbol: String) -> Pattern {
switch operatorSymbol {
case "|": .choice(lhs, rhs)
case "&": .interleave(lhs, rhs)
default: .group(lhs, rhs)
}
}
private func parseParticle() -> Pattern {
let primary = parsePrimary()
switch peek() {
case .symbol("?"): advance()
return .choice(primary, .empty)
case .symbol("*"): advance()
return .choice(.oneOrMore(primary), .empty)
case .symbol("+"): advance()
return .oneOrMore(primary)
default: return primary
}
}
private func parsePrimary() -> Pattern {
guard case let .word(word) = peek() else { return parseNonWordPrimary() }
if let leaf = leafKeyword(word) {
advance()
return leaf
}
return blockKeyword(word)
}
/// The keyword patterns that take no block and map to a constant.
private func leafKeyword(_ word: String) -> Pattern? {
switch word {
case "text": .text
case "empty": .empty
case "notAllowed": .notAllowed
case "string", "token": .data(.string)
default: nil
}
}
/// The keyword patterns that introduce a block, reference, or external
/// schema; a bare word falls through to a datatype or define reference.
private func blockKeyword(_ word: String) -> Pattern {
switch word {
case "element": advance()
return parseElement()
case "attribute": advance()
return parseAttribute()
case "list": advance()
return .list(parseBraced())
case "mixed": advance()
return .interleave(parseBraced(), .text)
case "external": advance()
return externalPattern(expectString())
case "grammar": advance()
return nestedGrammar()
case "parent": advance()
return .ref(wordValue())
default: return wordPrimary(word)
}
}
private func parseNonWordPrimary() -> Pattern {
switch peek() {
case let .string(value): advance()
return .value(value)
case .symbol("("): advance()
let pattern = parsePattern()
expectSymbol(")")
return pattern
default: advance()
return .notAllowed
}
}
/// A `:`-qualified word is a datatype (optionally with a parameter block or a
/// value literal); a bare word is a reference to a define.
private func wordPrimary(_ word: String) -> Pattern {
advance()
guard word.contains(":") else { return .ref(word) }
let type = PureXML.Schema.BuiltinType(rawValue: strip(word)) ?? .string
if peek() == .symbol("{") { skipBraces() }
if case let .string(value) = peek() { advance()
return .value(value)
}
return .data(type)
}
private func parseElement() -> Pattern {
let nameClass = parseNameClass(attribute: false)
return .element(nameClass, parseBraced())
}
private func parseAttribute() -> Pattern {
let nameClass = parseNameClass(attribute: true)
return .attribute(nameClass, parseBraced())
}
private func parseBraced() -> Pattern {
expectSymbol("{")
let pattern = parsePattern()
expectSymbol("}")
return pattern
}
private func nestedGrammar() -> Pattern {
expectSymbol("{")
parseGrammarBody()
expectSymbol("}")
return startPattern ?? .notAllowed
}
private func externalPattern(_ href: String) -> Pattern {
guard let text = loader(href), !visited.contains(href) else { return .notAllowed }
visited.insert(href)
let sub = RNCParser(RNCLexer.tokens(text), loader: loader, visited: visited)
let (start, subDefines) = sub.parse()
for (name, pattern) in subDefines {
defines[name] = pattern
}
visited = sub.visited
return start
}
}
/// Name-class and token-stream helpers for ``RNCParser``.
private extension RNCParser {
// MARK: Name classes
func parseNameClass(attribute: Bool) -> NameClass {
var nameClass = parseSimpleNameClass(attribute: attribute)
while peek() == .symbol("|") {
advance()
nameClass = .choice(nameClass, parseSimpleNameClass(attribute: attribute))
}
return nameClass
}
private func parseSimpleNameClass(attribute: Bool) -> NameClass {
switch peek() {
case .symbol("*"):
advance()
return .anyName
case .symbol("("):
advance()
let nameClass = parseNameClass(attribute: attribute)
expectSymbol(")")
return nameClass
case let .word(word):
advance()
return qualifiedName(word, attribute: attribute)
default:
advance()
return .anyName
}
}
private func qualifiedName(_ word: String, attribute: Bool) -> NameClass {
let parts = word.split(separator: ":", maxSplits: 1).map(String.init)
if parts.count == 2 {
if parts[1] == "*" { return .nsName(namespaces[parts[0]] ?? "") }
return .name(namespace: namespaces[parts[0]] ?? "", localName: parts[1])
}
return .name(namespace: attribute ? "" : defaultNamespace, localName: word)
}
// MARK: Token helpers
private func peek(_ offset: Int = 0) -> RNCToken? {
let index = position + offset
return index < tokens.count ? tokens[index] : nil
}
@discardableResult
private func advance() -> RNCToken? {
defer { position += 1 }
return peek()
}
private func wordValue() -> String {
if case let .word(word) = peek() { advance()
return word
}
return ""
}
private func expectAssignment() -> String {
if case let .symbol(symbol) = peek(), symbol == "=" || symbol == "|=" || symbol == "&=" {
advance()
return symbol
}
return "="
}
private func expectSymbol(_ symbol: String) {
if peek() == .symbol(symbol) { advance() }
}
private func expectString() -> String {
if case let .string(value) = peek() { advance()
return value
}
return ""
}
private func skipBraces() {
guard peek() == .symbol("{") else { return }
var depth = 0
repeat {
switch advance() {
case .symbol("{"): depth += 1
case .symbol("}"): depth -= 1
default: break
}
} while depth > 0 && position < tokens.count
}
private func strip(_ qualified: String) -> String {
qualified.split(separator: ":").last.map(String.init) ?? qualified
}
}
extension PureXML.Schema {
/// Parses a RELAX NG schema in the compact syntax (RNC) into a start pattern
/// and named `define` patterns, the same algebra the XML syntax compiles to.
/// Supports element/attribute declarations with name classes and namespaces,
/// the `,` `|` `&` combinators, `?` `*` `+` cardinality, `text`, `empty`,
/// `notAllowed`, `list`, `mixed`, datatypes and value literals, grammars with
/// `start` and named definitions (`=`, `|=`, `&=`), nested grammars, and
/// `include` and `external` resolved through `loader`.
enum RelaxNGCompactParser {
static func parse(
_ rnc: String,
loader: @escaping (String) -> String? = { _ in nil },
) throws -> (start: Pattern, defines: [String: Pattern]) {
RNCParser(RNCLexer.tokens(rnc), loader: loader).parse()
}
}
}

View File

@ -27,144 +27,201 @@ private enum RNGNode {
}
}
/// 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`, and the name classes.
/// `mixed`, `list`, `ref`, `data`, `value`, the name classes, and `include`
/// and `externalRef` resolved through `loader`.
enum RelaxNGParser {
static func parse(_ rng: String) throws -> (start: Pattern, defines: [String: Pattern]) {
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
}
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) }
let compiler = RNGCompiler(loader: loader)
let start = compiler.topLevel(top)
return (start, compiler.defines)
}
}
}

View File

@ -5,9 +5,17 @@ public extension PureXML.Schema {
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)
/// Compiles a RELAX NG schema document (XML syntax). `schemaLoader`
/// resolves the `href` of `include` and `externalRef` to schema source;
/// it returns nil (the default) when external schemas are not available.
public init(_ rng: String, schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
(start, defines) = try RelaxNGParser.parse(rng, loader: schemaLoader)
}
/// Compiles a RELAX NG schema in the compact syntax (RNC). `schemaLoader`
/// resolves `include` and `external` references to compact-syntax source.
public init(compact rnc: String, schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
(start, defines) = try RelaxNGCompactParser.parse(rnc, loader: schemaLoader)
}
/// Whether `xml` is valid against the schema.

View File

@ -0,0 +1,78 @@
@testable import PureXML
import Testing
@Suite("RELAX NG compact syntax and references")
struct RelaxNGCompactTests {
private func validCompact(
_ rnc: String,
_ xml: String,
loader: @escaping (String) -> String? = { _ in nil },
) throws -> Bool {
try PureXML.Schema.RelaxNG(compact: rnc, schemaLoader: loader).validate(xml)
}
@Test("A compact element with text content")
func test_elementText() throws {
let rnc = "element greeting { text }"
#expect(try validCompact(rnc, "<greeting>hello</greeting>"))
#expect(try !validCompact(rnc, "<other>hello</other>"))
#expect(try !validCompact(rnc, "<greeting><child/></greeting>"))
}
@Test("A compact sequence of children in order")
func test_sequence() throws {
let rnc = """
element book {
element title { text },
element author { text }
}
"""
#expect(try validCompact(rnc, "<book><title>T</title><author>A</author></book>"))
#expect(try !validCompact(rnc, "<book><author>A</author><title>T</title></book>"))
}
@Test("Compact choice and cardinality")
func test_choiceAndCardinality() throws {
let rnc = """
element list {
(element a { text } | element b { text })*
}
"""
#expect(try validCompact(rnc, "<list><a>1</a><b>2</b><a>3</a></list>"))
#expect(try validCompact(rnc, "<list></list>"))
#expect(try !validCompact(rnc, "<list><c>x</c></list>"))
}
@Test("Compact attributes and grammar definitions")
func test_grammarAndAttributes() throws {
let rnc = """
start = element root { item+ }
item = element item { attribute id { text }, text }
"""
#expect(try validCompact(rnc, "<root><item id=\"1\">a</item><item id=\"2\">b</item></root>"))
#expect(try !validCompact(rnc, "<root><item>a</item></root>"))
#expect(try !validCompact(rnc, "<root></root>"))
}
@Test("Compact include merges an external grammar's definitions")
func test_include() throws {
let library = "shared = element shared { text }"
let main = """
include "lib.rnc"
start = element root { shared }
"""
let loader: (String) -> String? = { $0 == "lib.rnc" ? library : nil }
#expect(try validCompact(main, "<root><shared>x</shared></root>", loader: loader))
#expect(try !validCompact(main, "<root><other>x</other></root>", loader: loader))
}
@Test("XML-syntax externalRef pulls in an external pattern")
func test_externalRef() throws {
let rng = "xmlns=\"http://relaxng.org/ns/structure/1.0\""
let external = "<element name=\"note\" \(rng)><text/></element>"
let main = "<externalRef href=\"note.rng\" \(rng)/>"
let schema = try PureXML.Schema.RelaxNG(main, schemaLoader: { $0 == "note.rng" ? external : nil })
#expect(try schema.validate("<note>hi</note>"))
#expect(try !schema.validate("<other>hi</other>"))
}
}