Merge epic/68-relaxng-compact: RNC compact syntax, include, externalRef
This commit is contained in:
commit
019ce94d03
76
Sources/Schema/RelaxNGCompactLexer.swift
Normal file
76
Sources/Schema/RelaxNGCompactLexer.swift
Normal 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 == ":"
|
||||
}
|
||||
}
|
||||
}
|
||||
389
Sources/Schema/RelaxNGCompactParser.swift
Normal file
389
Sources/Schema/RelaxNGCompactParser.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
78
Tests/RelaxNGCompactTests.swift
Normal file
78
Tests/RelaxNGCompactTests.swift
Normal 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>"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user