From 24a862e3a0fbca2ec63406177e3cea3361cfb31d Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 16:03:39 +0200 Subject: [PATCH] feat(relaxng): combine on define (choice/interleave merging) A define repeated with combine="choice" or combine="interleave" now merges with the existing definition (as a choice or interleave pattern) instead of the last one silently replacing it. A plain redefinition with no combine still replaces. Applied consistently in a grammar and across include merges. facets under , datatypeLibrary resolution, typed comparison, and the RNC-syntax gaps remain. Part of #79 --- Sources/Schema/RelaxNGParser.swift | 31 +++++++++++------ Tests/RelaxNGCombineTests.swift | 55 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 Tests/RelaxNGCombineTests.swift diff --git a/Sources/Schema/RelaxNGParser.swift b/Sources/Schema/RelaxNGParser.swift index 4c35bd7..7fe4278 100644 --- a/Sources/Schema/RelaxNGParser.swift +++ b/Sources/Schema/RelaxNGParser.swift @@ -52,16 +52,31 @@ private final class RNGCompiler { mergeInclude(include) } for define in RNGNode.children(node, named: "define") { - if let name = RNGNode.attribute(define, "name") { - defines[name] = combined(RNGNode.elementChildren(define), .sequence) - } + addDefine(define) } let start = RNGNode.children(node, named: "start").first return start.map { combined(RNGNode.elementChildren($0), .sequence) } ?? .notAllowed } + /// Adds a `define`, honoring `combine`: a second definition of the same name + /// with `combine="choice"`/`"interleave"` merges with the existing one rather + /// than replacing it (a plain redefinition still replaces). + private func addDefine(_ define: Tree) { + guard let name = RNGNode.attribute(define, "name") else { return } + let pattern = combined(RNGNode.elementChildren(define), .sequence) + guard let existing = defines[name] else { + defines[name] = pattern + return + } + switch RNGNode.attribute(define, "combine") { + case "choice": defines[name] = .choice(existing, pattern) + case "interleave": defines[name] = .interleave(existing, pattern) + default: defines[name] = pattern + } + } + /// Merges an `include`d grammar: its `define`s first, then the `include`'s own - /// nested `define`s, which override the included ones. + /// nested `define`s, which override or (with `combine`) merge with them. 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), @@ -71,14 +86,10 @@ private final class RNGCompiler { } 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) - } + addDefine(define) } for define in RNGNode.children(node, named: "define") { - if let name = RNGNode.attribute(define, "name") { - defines[name] = combined(RNGNode.elementChildren(define), .sequence) - } + addDefine(define) } } diff --git a/Tests/RelaxNGCombineTests.swift b/Tests/RelaxNGCombineTests.swift new file mode 100644 index 0000000..2ffd121 --- /dev/null +++ b/Tests/RelaxNGCombineTests.swift @@ -0,0 +1,55 @@ +@testable import PureXML +import Testing + +@Suite("RELAX NG combine on define") +struct RelaxNGCombineTests { + private let rngNamespace = "xmlns=\"http://relaxng.org/ns/structure/1.0\"" + + private func valid(_ rng: String, _ xml: String) throws -> Bool { + try PureXML.Schema.RelaxNG(rng).validate(xml) + } + + @Test("combine=choice merges two definitions of the same name as a choice") + func test_combineChoice() throws { + let rng = """ + + + + + + """ + // Both alternatives validate against the merged choice. + #expect(try valid(rng, "")) + #expect(try valid(rng, "")) + #expect(try !valid(rng, "")) + } + + @Test("combine=interleave merges two definitions as an interleave") + func test_combineInterleave() throws { + let rng = """ + + + + + + """ + // Interleave admits the two children in either order. + #expect(try valid(rng, "")) + #expect(try valid(rng, "")) + // Both are required by the interleave, so one alone fails. + #expect(try !valid(rng, "")) + } + + @Test("A plain redefinition without combine still replaces") + func test_plainRedefinitionReplaces() throws { + let rng = """ + + + + + + """ + #expect(try !valid(rng, "")) + #expect(try valid(rng, "")) + } +}