From 28cbed0a700cf2ebe8030010e93dcba10f83c2c9 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 16:48:24 +0200 Subject: [PATCH] feat(relaxng): facets under A pattern now carries a SimpleType (base + facets) rather than a bare built-in, and its value children populate the facets (minLength, maxLength, length, pattern, min/max inclusive/exclusive, total/fraction digits), reusing the XSD datatype facet validation. So a constrained datatype like xsd:string with minLength rejects too-short values. datatypeLibrary resolution, typed comparison, and the RNC datatype parameter blocks remain. Part of #79 --- Sources/Schema/RelaxNGCompactParser.swift | 4 +- Sources/Schema/RelaxNGDerivative.swift | 2 +- Sources/Schema/RelaxNGParser.swift | 43 ++++++++++++++++++- Sources/Schema/RelaxNGPattern.swift | 2 +- Tests/RelaxNGDataParamTests.swift | 51 +++++++++++++++++++++++ 5 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 Tests/RelaxNGDataParamTests.swift diff --git a/Sources/Schema/RelaxNGCompactParser.swift b/Sources/Schema/RelaxNGCompactParser.swift index e552381..8ce5528 100644 --- a/Sources/Schema/RelaxNGCompactParser.swift +++ b/Sources/Schema/RelaxNGCompactParser.swift @@ -183,7 +183,7 @@ private final class RNCParser { case "text": .text case "empty": .empty case "notAllowed": .notAllowed - case "string", "token": .data(.string) + case "string", "token": .data(PureXML.Schema.SimpleType(base: .string)) default: nil } } @@ -233,7 +233,7 @@ private final class RNCParser { if case let .string(value) = peek() { advance() return .value(value) } - return .data(type) + return .data(PureXML.Schema.SimpleType(base: type)) } private func parseElement() -> Pattern { diff --git a/Sources/Schema/RelaxNGDerivative.swift b/Sources/Schema/RelaxNGDerivative.swift index 684d1bc..1a3ca92 100644 --- a/Sources/Schema/RelaxNGDerivative.swift +++ b/Sources/Schema/RelaxNGDerivative.swift @@ -86,7 +86,7 @@ extension PureXML.Schema { case let .value(literal): SimpleType(base: .token).validate(string) == nil && SimpleType.process(string, whiteSpace: .collapse) == literal ? .empty : .notAllowed - case let .data(type): SimpleType(base: type).validate(string) == nil ? .empty : .notAllowed + case let .data(type): type.validate(string) == nil ? .empty : .notAllowed case let .list(inner): listMatches(inner, string) ? .empty : .notAllowed default: .notAllowed } diff --git a/Sources/Schema/RelaxNGParser.swift b/Sources/Schema/RelaxNGParser.swift index 7fe4278..eefdbca 100644 --- a/Sources/Schema/RelaxNGParser.swift +++ b/Sources/Schema/RelaxNGParser.swift @@ -104,7 +104,7 @@ private final class RNGCompiler { case "empty": .empty case "notAllowed": .notAllowed case "text": .text - case "data": .data(PureXML.Schema.BuiltinType(rawValue: RNGNode.strip(RNGNode.attribute(node, "type") ?? "string")) ?? .string) + case "data": .data(dataType(node)) case "value": .value(RNGNode.text(node)) case "ref": .ref(RNGNode.attribute(node, "name") ?? "") case "externalRef": externalRef(node) @@ -114,6 +114,47 @@ 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 + var facets = PureXML.Schema.Facets() + for param in RNGNode.children(node, named: "param") { + if let name = RNGNode.attribute(param, "name") { + Self.applyParam(name, RNGNode.text(param), into: &facets) + } + } + return PureXML.Schema.SimpleType(base: base, facets: facets) + } + + private static func applyParam(_ name: String, _ value: String, into facets: inout PureXML.Schema.Facets) { + applyValueParam(name, value, into: &facets) + applyNumericParam(name, Int(value), into: &facets) + } + + private static func applyValueParam(_ name: String, _ value: String, into facets: inout PureXML.Schema.Facets) { + switch name { + case "pattern": facets.patterns.append(value) + case "minInclusive": facets.minInclusive = value + case "maxInclusive": facets.maxInclusive = value + case "minExclusive": facets.minExclusive = value + case "maxExclusive": facets.maxExclusive = value + default: break + } + } + + private static func applyNumericParam(_ name: String, _ number: Int?, into facets: inout PureXML.Schema.Facets) { + switch name { + case "length": facets.length = number + case "minLength": facets.minLength = number + case "maxLength": facets.maxLength = number + case "totalDigits": facets.totalDigits = number + case "fractionDigits": facets.fractionDigits = number + default: break + } + } + /// Resolves an `externalRef` to the referenced schema's pattern, merging any /// grammar `define`s it carries into this compiler's table. private func externalRef(_ node: Tree) -> Pattern { diff --git a/Sources/Schema/RelaxNGPattern.swift b/Sources/Schema/RelaxNGPattern.swift index fae2082..03839eb 100644 --- a/Sources/Schema/RelaxNGPattern.swift +++ b/Sources/Schema/RelaxNGPattern.swift @@ -33,7 +33,7 @@ public extension PureXML.Schema { case oneOrMore(Pattern) case element(NameClass, Pattern) case attribute(NameClass, Pattern) - case data(BuiltinType) + case data(SimpleType) case value(String) case list(Pattern) case ref(String) diff --git a/Tests/RelaxNGDataParamTests.swift b/Tests/RelaxNGDataParamTests.swift new file mode 100644 index 0000000..640c343 --- /dev/null +++ b/Tests/RelaxNGDataParamTests.swift @@ -0,0 +1,51 @@ +@testable import PureXML +import Testing + +@Suite("RELAX NG data param facets") +struct RelaxNGDataParamTests { + 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("A minLength param constrains a data value") + func test_minLength() throws { + let rng = """ + + 3 + + """ + #expect(try valid(rng, "abc")) + #expect(try !valid(rng, "ab")) + } + + @Test("A maxInclusive param constrains a numeric data value") + func test_maxInclusive() throws { + let rng = """ + + 10 + + """ + #expect(try valid(rng, "5")) + #expect(try !valid(rng, "20")) + } + + @Test("A pattern param constrains a data value") + func test_pattern() throws { + let rng = """ + + [A-Z]{3} + + """ + #expect(try valid(rng, "ABC")) + #expect(try !valid(rng, "abc")) + } + + @Test("Data with no params still validates against its base type") + func test_noParams() throws { + let rng = "" + #expect(try valid(rng, "42")) + #expect(try !valid(rng, "x")) + } +}