From 9845e1cd9df09640a99ff3f42cf0539c6f21d1eb Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 17:38:58 +0200 Subject: [PATCH] feat(relaxng): fold RNC ~ string concatenation A string literal split with ~ ("a" ~ "b") is the single literal "ab", the way long values and namespace URIs are wrapped across lines. The fold applies wherever a string literal is read: value literals and the URIs of namespace/datatypes/include/external declarations. --- Sources/Schema/RelaxNGCompactHelpers.swift | 15 +++++++++- Sources/Schema/RelaxNGCompactParser.swift | 4 +-- Tests/RelaxNGCompactConcatTests.swift | 33 ++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 Tests/RelaxNGCompactConcatTests.swift diff --git a/Sources/Schema/RelaxNGCompactHelpers.swift b/Sources/Schema/RelaxNGCompactHelpers.swift index 3d9c2e3..878d820 100644 --- a/Sources/Schema/RelaxNGCompactHelpers.swift +++ b/Sources/Schema/RelaxNGCompactHelpers.swift @@ -98,11 +98,24 @@ extension RNCParser { func expectString() -> String { if case let .string(value) = peek() { advance() - return value + return concatenated(value) } return "" } + /// Folds RNC string concatenation: `"a" ~ "b"` is the single literal `"ab"`, + /// the way long strings are split across lines. The leading string has been + /// consumed; this appends each following `~ "…"`. + func concatenated(_ value: String) -> String { + var result = value + while peek() == .symbol("~"), case let .string(next)? = peek(1) { + advance() + advance() + result += next + } + return result + } + 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 0bd1f61..6d66d52 100644 --- a/Sources/Schema/RelaxNGCompactParser.swift +++ b/Sources/Schema/RelaxNGCompactParser.swift @@ -250,7 +250,7 @@ extension RNCParser { switch peek() { case let .string(value): advance() // A bare string literal is shorthand for a `token`-typed value. - return .value(PureXML.Schema.SimpleType(base: .token), value) + return .value(PureXML.Schema.SimpleType(base: .token), concatenated(value)) case .symbol("("): advance() let pattern = parsePattern() expectSymbol(")") @@ -269,7 +269,7 @@ extension RNCParser { var facets = PureXML.Schema.Facets() if peek() == .symbol("{") { parseParams(into: &facets) } if case let .string(value) = peek() { advance() - return .value(PureXML.Schema.SimpleType(base: type), value) + return .value(PureXML.Schema.SimpleType(base: type), concatenated(value)) } return .data(PureXML.Schema.SimpleType(base: type, facets: facets)) } diff --git a/Tests/RelaxNGCompactConcatTests.swift b/Tests/RelaxNGCompactConcatTests.swift new file mode 100644 index 0000000..b2e78f6 --- /dev/null +++ b/Tests/RelaxNGCompactConcatTests.swift @@ -0,0 +1,33 @@ +@testable import PureXML +import Testing + +@Suite("RELAX NG compact string concatenation") +struct RelaxNGCompactConcatTests { + private func validRNC(_ rnc: String, _ xml: String) throws -> Bool { + try PureXML.Schema.RelaxNG(compact: rnc).validate(xml) + } + + @Test("A value literal split with ~ is concatenated") + func test_valueConcatenation() throws { + let rnc = "element greeting { \"hello \" ~ \"world\" }" + #expect(try validRNC(rnc, "hello world")) + #expect(try !validRNC(rnc, "hello")) + } + + @Test("More than two segments concatenate left to right") + func test_multiSegment() throws { + let rnc = "element n { \"a\" ~ \"b\" ~ \"c\" }" + #expect(try validRNC(rnc, "abc")) + #expect(try !validRNC(rnc, "ab")) + } + + @Test("A namespace URI may be assembled with ~") + func test_namespaceConcatenation() throws { + let rnc = """ + namespace p = "urn:" ~ "example" + element root { element p:item { empty } } + """ + #expect(try validRNC(rnc, "")) + #expect(try !validRNC(rnc, "")) + } +}