diff --git a/Sources/Schema/RelaxNGCompactLexer.swift b/Sources/Schema/RelaxNGCompactLexer.swift new file mode 100644 index 0000000..54b80f9 --- /dev/null +++ b/Sources/Schema/RelaxNGCompactLexer.swift @@ -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 == ":" + } + } +} diff --git a/Sources/Schema/RelaxNGCompactParser.swift b/Sources/Schema/RelaxNGCompactParser.swift new file mode 100644 index 0000000..e552381 --- /dev/null +++ b/Sources/Schema/RelaxNGCompactParser.swift @@ -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 + + init(_ tokens: [RNCToken], loader: @escaping (String) -> String?, visited: Set = []) { + 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() + } + } +} diff --git a/Sources/Schema/RelaxNGParser.swift b/Sources/Schema/RelaxNGParser.swift index 577c44e..4c35bd7 100644 --- a/Sources/Schema/RelaxNGParser.swift +++ b/Sources/Schema/RelaxNGParser.swift @@ -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 = [] + + 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) } } } diff --git a/Sources/Schema/RelaxNGSchema.swift b/Sources/Schema/RelaxNGSchema.swift index 6c41603..3a9facd 100644 --- a/Sources/Schema/RelaxNGSchema.swift +++ b/Sources/Schema/RelaxNGSchema.swift @@ -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. diff --git a/Tests/RelaxNGCompactTests.swift b/Tests/RelaxNGCompactTests.swift new file mode 100644 index 0000000..bcfb0c7 --- /dev/null +++ b/Tests/RelaxNGCompactTests.swift @@ -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, "hello")) + #expect(try !validCompact(rnc, "hello")) + #expect(try !validCompact(rnc, "")) + } + + @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, "TA")) + #expect(try !validCompact(rnc, "AT")) + } + + @Test("Compact choice and cardinality") + func test_choiceAndCardinality() throws { + let rnc = """ + element list { + (element a { text } | element b { text })* + } + """ + #expect(try validCompact(rnc, "123")) + #expect(try validCompact(rnc, "")) + #expect(try !validCompact(rnc, "x")) + } + + @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, "ab")) + #expect(try !validCompact(rnc, "a")) + #expect(try !validCompact(rnc, "")) + } + + @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, "x", loader: loader)) + #expect(try !validCompact(main, "x", 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 = "" + let main = "" + let schema = try PureXML.Schema.RelaxNG(main, schemaLoader: { $0 == "note.rng" ? external : nil }) + #expect(try schema.validate("hi")) + #expect(try !schema.validate("hi")) + } +}