From e24004dd2a9ec5ca5b0f4688f80820db99f80fa5 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 17:12:35 +0200 Subject: [PATCH] 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. --- Sources/Schema/RelaxNGCompactHelpers.swift | 90 ++++++++++++ Sources/Schema/RelaxNGCompactParser.swift | 160 +++++++-------------- Tests/RelaxNGCompactAnnotationTests.swift | 62 ++++++++ 3 files changed, 205 insertions(+), 107 deletions(-) create mode 100644 Sources/Schema/RelaxNGCompactHelpers.swift create mode 100644 Tests/RelaxNGCompactAnnotationTests.swift diff --git a/Sources/Schema/RelaxNGCompactHelpers.swift b/Sources/Schema/RelaxNGCompactHelpers.swift new file mode 100644 index 0000000..54cb038 --- /dev/null +++ b/Sources/Schema/RelaxNGCompactHelpers.swift @@ -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 + } +} diff --git a/Sources/Schema/RelaxNGCompactParser.swift b/Sources/Schema/RelaxNGCompactParser.swift index d5d90ea..04efb7c 100644 --- a/Sources/Schema/RelaxNGCompactParser.swift +++ b/Sources/Schema/RelaxNGCompactParser.swift @@ -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 + var startPattern: Pattern? + var defaultNamespace = "" + var namespaces: [String: String] = [:] + var visited: Set init(_ tokens: [RNCToken], loader: @escaping (String) -> String?, visited: Set = []) { 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. diff --git a/Tests/RelaxNGCompactAnnotationTests.swift b/Tests/RelaxNGCompactAnnotationTests.swift new file mode 100644 index 0000000..de63e89 --- /dev/null +++ b/Tests/RelaxNGCompactAnnotationTests.swift @@ -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, "hello")) + #expect(try !valid(rnc, "hello")) + } + + @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, "x")) + #expect(try !valid(rnc, "x")) + } + + @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, "")) + #expect(try !valid(rnc, "")) + } + + @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, "")) + } + + @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, "
")) + } +}