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:
Mihaela Mihaljevic 2026-06-08 17:12:35 +02:00
parent 434d0d8882
commit e24004dd2a
3 changed files with 205 additions and 107 deletions

View 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
}
}

View File

@ -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.

View 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>"))
}
}