feat(relaxng): handle RNC annotations and div grouping
The compact-syntax parser now skips [ ... ] documentation/grammar
annotations (balanced over nesting) wherever they may appear, before a
define or before a pattern, and treats div { ... } as transparent
grammar grouping that contributes its defines and start directly. A
define literally named div still parses when assigned. Splits the
name-class and token helpers into a companion file to stay within the
type and file length budget.
This commit is contained in:
parent
434d0d8882
commit
e24004dd2a
90
Sources/Schema/RelaxNGCompactHelpers.swift
Normal file
90
Sources/Schema/RelaxNGCompactHelpers.swift
Normal file
@ -0,0 +1,90 @@
|
||||
/// Name-class and token-stream helpers for ``RNCParser``. Kept in a separate
|
||||
/// file so the parser proper stays within the type/file length budget.
|
||||
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()
|
||||
// `* - nameClass` is any name except the subtracted class.
|
||||
if peek() == .symbol("-") {
|
||||
advance()
|
||||
return .anyNameExcept(parseSimpleNameClass(attribute: attribute))
|
||||
}
|
||||
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
|
||||
|
||||
func peek(_ offset: Int = 0) -> PureXML.Schema.RNCToken? {
|
||||
let index = position + offset
|
||||
return index < tokens.count ? tokens[index] : nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func advance() -> PureXML.Schema.RNCToken? {
|
||||
defer { position += 1 }
|
||||
return peek()
|
||||
}
|
||||
|
||||
func wordValue() -> String {
|
||||
if case let .word(word) = peek() { advance()
|
||||
return word
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func expectAssignment() -> String {
|
||||
if case let .symbol(symbol) = peek(), symbol == "=" || symbol == "|=" || symbol == "&=" {
|
||||
advance()
|
||||
return symbol
|
||||
}
|
||||
return "="
|
||||
}
|
||||
|
||||
func expectSymbol(_ symbol: String) {
|
||||
if peek() == .symbol(symbol) { advance() }
|
||||
}
|
||||
|
||||
func expectString() -> String {
|
||||
if case let .string(value) = peek() { advance()
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func strip(_ qualified: String) -> String {
|
||||
qualified.split(separator: ":").last.map(String.init) ?? qualified
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,20 @@
|
||||
private typealias RNCToken = PureXML.Schema.RNCToken
|
||||
private typealias RNCLexer = PureXML.Schema.RNCLexer
|
||||
typealias RNCToken = PureXML.Schema.RNCToken
|
||||
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 {
|
||||
/// Module-internal; the only entry point is ``PureXML/Schema/RelaxNGCompactParser``.
|
||||
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?
|
||||
let tokens: [RNCToken]
|
||||
var position = 0
|
||||
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>
|
||||
var startPattern: Pattern?
|
||||
var defaultNamespace = ""
|
||||
var namespaces: [String: String] = [:]
|
||||
var visited: Set<String>
|
||||
|
||||
init(_ tokens: [RNCToken], loader: @escaping (String) -> String?, visited: Set<String> = []) {
|
||||
self.tokens = tokens
|
||||
@ -30,11 +30,15 @@ private final class RNCParser {
|
||||
}
|
||||
return (parsePattern(), defines)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Declarations
|
||||
// MARK: Declarations, grammar, and patterns
|
||||
|
||||
extension RNCParser {
|
||||
private func skipDeclarations() {
|
||||
while case let .word(keyword) = peek() {
|
||||
while true {
|
||||
skipAnnotation()
|
||||
guard case let .word(keyword) = peek() else { return }
|
||||
switch keyword {
|
||||
case "namespace": parseNamespace(isDefault: false)
|
||||
case "default": parseDefault()
|
||||
@ -80,10 +84,41 @@ private final class RNCParser {
|
||||
|
||||
private func parseGrammarBody() {
|
||||
while position < tokens.count, peek() != .symbol("}") {
|
||||
if case .word("include") = peek() {
|
||||
parseInclude()
|
||||
} else {
|
||||
parseDefine()
|
||||
skipAnnotation()
|
||||
guard position < tokens.count, peek() != .symbol("}") else { break }
|
||||
parseGrammarItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func parseGrammarItem() {
|
||||
if case .word("include") = peek() {
|
||||
parseInclude()
|
||||
} else if case .word("div") = peek(), peek(1) == .symbol("{") {
|
||||
parseDiv()
|
||||
} else {
|
||||
parseDefine()
|
||||
}
|
||||
}
|
||||
|
||||
/// A `div { ... }` grouping is transparent: it scopes annotations in the
|
||||
/// full syntax but contributes its defines and `start` directly here.
|
||||
private func parseDiv() {
|
||||
advance() // div
|
||||
expectSymbol("{")
|
||||
parseGrammarBody()
|
||||
expectSymbol("}")
|
||||
}
|
||||
|
||||
/// Skips a leading `[ ... ]` documentation/grammar annotation (balanced over
|
||||
/// nested brackets). Annotations carry no schema semantics, so they are
|
||||
/// dropped wherever they may appear: before a define, or before a pattern.
|
||||
private func skipAnnotation() {
|
||||
while peek() == .symbol("[") {
|
||||
advance()
|
||||
var depth = 1
|
||||
while position < tokens.count, depth > 0 {
|
||||
let token = advance()
|
||||
if token == .symbol("[") { depth += 1 } else if token == .symbol("]") { depth -= 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -169,6 +204,7 @@ private final class RNCParser {
|
||||
}
|
||||
|
||||
private func parsePrimary() -> Pattern {
|
||||
skipAnnotation()
|
||||
guard case let .word(word) = peek() else { return parseNonWordPrimary() }
|
||||
if let leaf = leafKeyword(word) {
|
||||
advance()
|
||||
@ -291,96 +327,6 @@ private final class RNCParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
// `* - nameClass` is any name except the subtracted class.
|
||||
if peek() == .symbol("-") {
|
||||
advance()
|
||||
return .anyNameExcept(parseSimpleNameClass(attribute: attribute))
|
||||
}
|
||||
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 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.
|
||||
|
||||
62
Tests/RelaxNGCompactAnnotationTests.swift
Normal file
62
Tests/RelaxNGCompactAnnotationTests.swift
Normal file
@ -0,0 +1,62 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("RELAX NG compact annotations and div grouping")
|
||||
struct RelaxNGCompactAnnotationTests {
|
||||
private func valid(_ rnc: String, _ xml: String) throws -> Bool {
|
||||
try PureXML.Schema.RelaxNG(compact: rnc).validate(xml)
|
||||
}
|
||||
|
||||
@Test("A leading annotation on a define is ignored")
|
||||
func test_annotationOnDefine() throws {
|
||||
let rnc = """
|
||||
[ a:documentation [ "the root element" ] ]
|
||||
start = element root { text }
|
||||
"""
|
||||
#expect(try valid(rnc, "<root>hello</root>"))
|
||||
#expect(try !valid(rnc, "<other>hello</other>"))
|
||||
}
|
||||
|
||||
@Test("An annotation before a pattern is ignored")
|
||||
func test_annotationOnPattern() throws {
|
||||
let rnc = "element root { [ a:doc \"a child\" ] element child { text } }"
|
||||
#expect(try valid(rnc, "<root><child>x</child></root>"))
|
||||
#expect(try !valid(rnc, "<root><wrong>x</wrong></root>"))
|
||||
}
|
||||
|
||||
@Test("A div grouping contributes its defines transparently")
|
||||
func test_divGrouping() throws {
|
||||
let rnc = """
|
||||
start = element root { ref-a, ref-b }
|
||||
div {
|
||||
ref-a = element a { empty }
|
||||
ref-b = element b { empty }
|
||||
}
|
||||
"""
|
||||
#expect(try valid(rnc, "<root><a/><b/></root>"))
|
||||
#expect(try !valid(rnc, "<root><a/></root>"))
|
||||
}
|
||||
|
||||
@Test("A div may carry its own annotation and nest")
|
||||
func test_divAnnotatedNested() throws {
|
||||
let rnc = """
|
||||
start = element root { ref-a }
|
||||
[ a:doc "a section" ]
|
||||
div {
|
||||
div {
|
||||
ref-a = element a { empty }
|
||||
}
|
||||
}
|
||||
"""
|
||||
#expect(try valid(rnc, "<root><a/></root>"))
|
||||
}
|
||||
|
||||
@Test("A define literally named div still works when assigned")
|
||||
func test_defineNamedDiv() throws {
|
||||
let rnc = """
|
||||
start = element root { div }
|
||||
div = element section { empty }
|
||||
"""
|
||||
#expect(try valid(rnc, "<root><section/></root>"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user