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
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 09:13:09 +02:00
parent f2a1e44e5e
commit f02ba8b8cf
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>"))
}
}