From 147d776d21068929fe90ba3d5e2047015aac98cb Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 17:21:49 +0200 Subject: [PATCH] feat(relaxng): resolve datatypeLibrary and nsName subtraction Adds a datatypeLibrary-aware datatype resolver to the XML-syntax parser: the in-scope library is inherited from the nearest ancestor, the default (empty) library defines only string and token, and the W3C XML Schema library defines the full built-in set. A type the in-scope library does not define is an unknown datatype, so the / pattern matches nothing rather than silently validating. Adds an nsNameExcept name-class case so nsName with an child (XML syntax) and prefix:* - name subtraction (compact syntax) narrow a namespace wildcard. The compact lexer splits prefix:* into a trailing- colon word plus a star symbol, which the parser now recognizes. Existing RELAX NG facet tests declared XSD types without the library and relied on lenient resolution; they now declare the XSD datatypeLibrary, matching what a conformant processor requires. --- Sources/Schema/RelaxNGCompactHelpers.swift | 29 +++++- Sources/Schema/RelaxNGParser.swift | 83 +++++++++++++--- Sources/Schema/RelaxNGPattern.swift | 2 + Tests/RelaxNGDataParamTests.swift | 2 +- Tests/RelaxNGDatatypeLibraryTests.swift | 110 +++++++++++++++++++++ Tests/RelaxNGTests.swift | 2 +- 6 files changed, 206 insertions(+), 22 deletions(-) create mode 100644 Tests/RelaxNGDatatypeLibraryTests.swift diff --git a/Sources/Schema/RelaxNGCompactHelpers.swift b/Sources/Schema/RelaxNGCompactHelpers.swift index 54cb038..3d9c2e3 100644 --- a/Sources/Schema/RelaxNGCompactHelpers.swift +++ b/Sources/Schema/RelaxNGCompactHelpers.swift @@ -13,20 +13,29 @@ extension RNCParser { } private func parseSimpleNameClass(attribute: Bool) -> NameClass { + let base = parseNameClassPrimary(attribute: attribute) + // `*` and `ns:*` may be narrowed by `- nameClass` subtraction. + guard peek() == .symbol("-") else { return base } + advance() + return subtract(base, parseSimpleNameClass(attribute: attribute)) + } + + private func parseNameClassPrimary(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 + // `prefix:*` lexes as the word `prefix:` (a `:` is a name character) + // followed by a `*` symbol: a namespace wildcard. + case let .word(word) where word.hasSuffix(":") && peek(1) == .symbol("*"): + advance() + advance() + return .nsName(namespaces[String(word.dropLast())] ?? "") case let .word(word): advance() return qualifiedName(word, attribute: attribute) @@ -36,6 +45,16 @@ extension RNCParser { } } + /// Applies name-class subtraction, which RELAX NG defines only for the open + /// classes `anyName` (`*`) and `nsName` (`prefix:*`). + private func subtract(_ base: NameClass, _ except: NameClass) -> NameClass { + switch base { + case .anyName: .anyNameExcept(except) + case let .nsName(namespace): .nsNameExcept(namespace: namespace, except: except) + default: base + } + } + private func qualifiedName(_ word: String, attribute: Bool) -> NameClass { let parts = word.split(separator: ":", maxSplits: 1).map(String.init) if parts.count == 2 { diff --git a/Sources/Schema/RelaxNGParser.swift b/Sources/Schema/RelaxNGParser.swift index f7ee55e..ebbf205 100644 --- a/Sources/Schema/RelaxNGParser.swift +++ b/Sources/Schema/RelaxNGParser.swift @@ -104,8 +104,8 @@ private final class RNGCompiler { case "empty": .empty case "notAllowed": .notAllowed case "text": .text - case "data": .data(dataType(node)) - case "value": .value(RNGNode.text(node)) + case "data": dataPattern(node) + case "value": valuePattern(node) case "ref": .ref(RNGNode.attribute(node, "name") ?? "") case "externalRef": externalRef(node) case "element": element(node) @@ -114,18 +114,61 @@ private final class RNGCompiler { } } - /// Builds the datatype of a `` pattern: its base built-in plus the - /// facets its `value` children constrain it with (the - /// same facet set the XSD datatypes use). - private func dataType(_ node: Tree) -> PureXML.Schema.SimpleType { - let base = PureXML.Schema.BuiltinType(rawValue: RNGNode.strip(RNGNode.attribute(node, "type") ?? "string")) ?? .string + /// The URI of the W3C XML Schema datatype library; the only non-default + /// library RELAX NG processors are required to support. + private static let xsdDatatypeLibrary = "http://www.w3.org/2001/XMLSchema-datatypes" + + /// Builds a `` pattern: its base built-in plus the facets its + /// `value` children constrain it with (the same facet + /// set the XSD datatypes use). A `type` that the in-scope `datatypeLibrary` + /// does not define is an unknown datatype, so the pattern matches nothing. + private func dataPattern(_ node: Tree) -> Pattern { + let typeName = RNGNode.strip(RNGNode.attribute(node, "type") ?? "string") + guard let base = resolvedBuiltin(typeName, library: datatypeLibrary(of: node)) else { return .notAllowed } var facets = PureXML.Schema.Facets() for param in RNGNode.children(node, named: "param") { if let name = RNGNode.attribute(param, "name") { PureXML.Schema.RelaxNGFacets.apply(name, RNGNode.text(param), into: &facets) } } - return PureXML.Schema.SimpleType(base: base, facets: facets) + return .data(PureXML.Schema.SimpleType(base: base, facets: facets)) + } + + /// Builds a `` pattern. An explicit `type` that the in-scope + /// `datatypeLibrary` does not define makes the value unmatchable; otherwise + /// the literal is compared lexically. + private func valuePattern(_ node: Tree) -> Pattern { + if hasUnknownType(node) { return .notAllowed } + return .value(RNGNode.text(node)) + } + + /// Whether `node` declares a `type` the in-scope `datatypeLibrary` does not + /// define (an untyped `` is always known: it compares lexically). + private func hasUnknownType(_ node: Tree) -> Bool { + guard let declared = RNGNode.attribute(node, "type") else { return false } + return resolvedBuiltin(RNGNode.strip(declared), library: datatypeLibrary(of: node)) == nil + } + + /// Resolves a datatype name within a library. The default (empty) library + /// defines only `string` and `token`; the XSD library defines every built-in; + /// any other library is unsupported, so its names are unknown. + private func resolvedBuiltin(_ typeName: String, library: String) -> PureXML.Schema.BuiltinType? { + switch library { + case "": typeName == "string" || typeName == "token" ? PureXML.Schema.BuiltinType(rawValue: typeName) : nil + case Self.xsdDatatypeLibrary: PureXML.Schema.BuiltinType(rawValue: typeName) + default: nil + } + } + + /// The `datatypeLibrary` in scope at `node`: the nearest value on the node or + /// an ancestor, defaulting to the empty (RELAX NG built-in) library. + private func datatypeLibrary(of node: Tree) -> String { + var current: Tree? = node + while let candidate = current { + if let library = RNGNode.attribute(candidate, "datatypeLibrary") { return library } + current = candidate.parent + } + return "" } /// Resolves an `externalRef` to the referenced schema's pattern, merging any @@ -204,7 +247,7 @@ private final class RNGCompiler { case "anyName": anyName(node) case "nsName": - .nsName(RNGNode.attribute(node, "ns") ?? "") + nsName(node) case "choice": nameClassChoice(RNGNode.elementChildren(node)) default: @@ -213,12 +256,22 @@ private final class RNGCompiler { } private func anyName(_ node: Tree) -> NameClass { - guard let except = RNGNode.children(node, named: "except").first, - let inner = RNGNode.elementChildren(except).first - else { - return .anyName - } - return .anyNameExcept(nameClass(inner)) + guard let except = exceptClass(node) else { return .anyName } + return .anyNameExcept(except) + } + + private func nsName(_ node: Tree) -> NameClass { + let namespace = RNGNode.attribute(node, "ns") ?? "" + guard let except = exceptClass(node) else { return .nsName(namespace) } + return .nsNameExcept(namespace: namespace, except: except) + } + + /// The name class of a ``'s `` child, treating multiple + /// children as an implicit choice (`anyName`/`nsName` minus the union). + private func exceptClass(_ node: Tree) -> NameClass? { + guard let except = RNGNode.children(node, named: "except").first else { return nil } + let children = RNGNode.elementChildren(except) + return children.isEmpty ? nil : nameClassChoice(children) } private func nameClassChoice(_ nodes: [Tree]) -> NameClass { diff --git a/Sources/Schema/RelaxNGPattern.swift b/Sources/Schema/RelaxNGPattern.swift index 03839eb..0552eaa 100644 --- a/Sources/Schema/RelaxNGPattern.swift +++ b/Sources/Schema/RelaxNGPattern.swift @@ -5,6 +5,7 @@ public extension PureXML.Schema { case anyNameExcept(NameClass) case name(namespace: String, localName: String) case nsName(String) + case nsNameExcept(namespace: String, except: NameClass) case choice(NameClass, NameClass) /// Whether `name` is in this class. @@ -15,6 +16,7 @@ public extension PureXML.Schema { case let .anyNameExcept(except): return !except.contains(name) case let .name(wantedNamespace, wantedLocal): return namespace == wantedNamespace && name.localName == wantedLocal case let .nsName(wantedNamespace): return namespace == wantedNamespace + case let .nsNameExcept(wantedNamespace, except): return namespace == wantedNamespace && !except.contains(name) case let .choice(left, right): return left.contains(name) || right.contains(name) } } diff --git a/Tests/RelaxNGDataParamTests.swift b/Tests/RelaxNGDataParamTests.swift index 640c343..dc8f15d 100644 --- a/Tests/RelaxNGDataParamTests.swift +++ b/Tests/RelaxNGDataParamTests.swift @@ -3,7 +3,7 @@ import Testing @Suite("RELAX NG data param facets") struct RelaxNGDataParamTests { - private let rngNamespace = "xmlns=\"http://relaxng.org/ns/structure/1.0\"" + private let rngNamespace = "xmlns=\"http://relaxng.org/ns/structure/1.0\" datatypeLibrary=\"http://www.w3.org/2001/XMLSchema-datatypes\"" private func valid(_ rng: String, _ xml: String) throws -> Bool { try PureXML.Schema.RelaxNG(rng).validate(xml) diff --git a/Tests/RelaxNGDatatypeLibraryTests.swift b/Tests/RelaxNGDatatypeLibraryTests.swift new file mode 100644 index 0000000..7bed646 --- /dev/null +++ b/Tests/RelaxNGDatatypeLibraryTests.swift @@ -0,0 +1,110 @@ +@testable import PureXML +import Testing + +@Suite("RELAX NG datatypeLibrary resolution and nsName subtraction") +struct RelaxNGDatatypeLibraryTests { + private let xsd = "http://www.w3.org/2001/XMLSchema-datatypes" + + private func validXML(_ rng: String, _ xml: String) throws -> Bool { + try PureXML.Schema.RelaxNG(rng).validate(xml) + } + + private func validRNC(_ rnc: String, _ xml: String) throws -> Bool { + try PureXML.Schema.RelaxNG(compact: rnc).validate(xml) + } + + @Test("The default library defines only string and token") + func test_defaultLibraryStringToken() throws { + let rng = """ + + + + """ + #expect(try validXML(rng, "anything here")) + } + + @Test("An XSD type without the XSD library is an unknown datatype and matches nothing") + func test_unknownTypeInDefaultLibrary() throws { + let rng = """ + + + + """ + // `integer` is not in the default library, so no value validates. + #expect(try !validXML(rng, "5")) + #expect(try !validXML(rng, "x")) + } + + @Test("Declaring the XSD datatypeLibrary makes xsd types resolve") + func test_xsdLibraryResolves() throws { + let rng = """ + + + + """ + #expect(try validXML(rng, "5")) + #expect(try !validXML(rng, "x")) + } + + @Test("datatypeLibrary is inherited from an ancestor element") + func test_libraryInheritedFromAncestor() throws { + let rng = """ + + + + + + """ + #expect(try validXML(rng, "42")) + #expect(try !validXML(rng, "4.2")) + } + + @Test("A value with an unknown explicit type matches nothing") + func test_valueUnknownType() throws { + let rng = """ + + 5 + + """ + #expect(try !validXML(rng, "5")) + } + + @Test("nsName with except excludes a name in the namespace") + func test_nsNameExceptXML() throws { + let rng = """ + + + skip + + + + """ + // Any element in urn:x except local name "skip" is allowed. + let allowed = "" + let excluded = "" + let wrongNS = "" + #expect(try validXML(rng, allowed)) + #expect(try !validXML(rng, excluded)) + #expect(try !validXML(rng, wrongNS)) + } + + @Test("RNC ns:* wildcard matches any name in the namespace") + func test_rncNsWildcard() throws { + let rnc = """ + namespace p = "urn:x" + element root { element p:* { empty } } + """ + #expect(try validRNC(rnc, "")) + #expect(try !validRNC(rnc, "")) + } + + @Test("RNC ns:* - name subtracts a name from the namespace wildcard") + func test_rncNsWildcardExcept() throws { + let rnc = """ + namespace p = "urn:x" + element root { element p:* - p:skip { empty } } + """ + #expect(try validRNC(rnc, "")) + #expect(try !validRNC(rnc, "")) + } +} diff --git a/Tests/RelaxNGTests.swift b/Tests/RelaxNGTests.swift index 1ffeea6..d2a611d 100644 --- a/Tests/RelaxNGTests.swift +++ b/Tests/RelaxNGTests.swift @@ -7,7 +7,7 @@ struct RelaxNGTests { try PureXML.Schema.RelaxNG(rng).validate(xml) } - private let rngNamespace = "xmlns=\"http://relaxng.org/ns/structure/1.0\"" + private let rngNamespace = "xmlns=\"http://relaxng.org/ns/structure/1.0\" datatypeLibrary=\"http://www.w3.org/2001/XMLSchema-datatypes\"" @Test("An element with text content") func test_elementText() throws {