feat(relaxng): <param> facets under <data>

A <data type=> pattern now carries a SimpleType (base + facets) rather
than a bare built-in, and its <param name=>value</param> 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 <value> comparison, and the RNC
datatype parameter blocks remain.

Part of #79
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 16:48:24 +02:00
parent c29bf69f07
commit 28cbed0a70
5 changed files with 97 additions and 5 deletions

View File

@ -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 {

View File

@ -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
}

View File

@ -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 `<data type=>` pattern: its base built-in plus the
/// facets its `<param name= >value</param>` 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 {

View File

@ -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)

View File

@ -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 = """
<element name="code" \(rngNamespace)>
<data type="string"><param name="minLength">3</param></data>
</element>
"""
#expect(try valid(rng, "<code>abc</code>"))
#expect(try !valid(rng, "<code>ab</code>"))
}
@Test("A maxInclusive param constrains a numeric data value")
func test_maxInclusive() throws {
let rng = """
<element name="n" \(rngNamespace)>
<data type="integer"><param name="maxInclusive">10</param></data>
</element>
"""
#expect(try valid(rng, "<n>5</n>"))
#expect(try !valid(rng, "<n>20</n>"))
}
@Test("A pattern param constrains a data value")
func test_pattern() throws {
let rng = """
<element name="sku" \(rngNamespace)>
<data type="string"><param name="pattern">[A-Z]{3}</param></data>
</element>
"""
#expect(try valid(rng, "<sku>ABC</sku>"))
#expect(try !valid(rng, "<sku>abc</sku>"))
}
@Test("Data with no params still validates against its base type")
func test_noParams() throws {
let rng = "<element name=\"n\" \(rngNamespace)><data type=\"integer\"/></element>"
#expect(try valid(rng, "<n>42</n>"))
#expect(try !valid(rng, "<n>x</n>"))
}
}