feat(schema): RELAX NG pattern model, parser, and derivative validation

Add the RELAX NG pattern algebra (Pattern, NameClass), a parser for the XML
syntax into a grammar of start plus named defines, and James Clark's
derivative validation algorithm. The pattern is derived through each
start-tag, attribute, text, and end-tag event with simplifying constructors;
the document is valid when the residual pattern is nullable. Supports
element, attribute, text, empty, notAllowed, group, choice, interleave,
optional, zeroOrMore, oneOrMore, mixed, list, ref/define, data, value, and
the name classes; handles interleave and recursive grammars, reusing the XSD
datatype library for data and value.

Closes #11
Closes #12
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 06:49:37 +02:00
parent e845b63ff0
commit b19e48a6eb
6 changed files with 617 additions and 0 deletions

View File

@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- RELAX NG validation by derivatives (toward #2). `PureXML.Schema.RelaxNG`
parses a RELAX NG schema in the XML syntax into a pattern grammar (`element`,
`attribute`, `text`, `empty`, `notAllowed`, `group`, `choice`, `interleave`,
`optional`, `zeroOrMore`, `oneOrMore`, `mixed`, `list`, `ref`/`define`, `data`,
`value`, and the name classes), and `validate(_:)` checks an instance document
by James Clark's derivative algorithm: the pattern is derived through each
start-tag, attribute, text, and end-tag event, and the document is valid when
the residual pattern is nullable. Handles interleave, recursive grammars, and
reuses the XSD datatype library for `data`/`value`.
- XSD schema-document parser, completing the XSD validation pipeline (toward #2).
`PureXML.Schema.Document(xsd)` compiles a schema document into its global
element declarations and named-type table, and `validate(_:)` checks an

View File

@ -0,0 +1,258 @@
extension PureXML.Schema {
/// Validates a document against a RELAX NG grammar by Brzozowski-style
/// derivatives (James Clark's "An algorithm for RELAX NG validation"). The
/// pattern is repeatedly derived with respect to each event of the instance;
/// the document is valid when the residual pattern is nullable.
struct RelaxNGEngine {
let defines: [String: PureXML.Schema.Pattern]
/// Whether `root` matches `start`.
func matches(start: PureXML.Schema.Pattern, root: PureXML.Model.Node) -> Bool {
nullable(childDeriv(start, root))
}
// MARK: Simplifying constructors
private func choice(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
if case .notAllowed = lhs { return rhs }
if case .notAllowed = rhs { return lhs }
return .choice(lhs, rhs)
}
private func group(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
if case .notAllowed = lhs { return .notAllowed }
if case .notAllowed = rhs { return .notAllowed }
if case .empty = lhs { return rhs }
if case .empty = rhs { return lhs }
return .group(lhs, rhs)
}
private func interleave(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
if case .notAllowed = lhs { return .notAllowed }
if case .notAllowed = rhs { return .notAllowed }
if case .empty = lhs { return rhs }
if case .empty = rhs { return lhs }
return .interleave(lhs, rhs)
}
private func after(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
if case .notAllowed = lhs { return .notAllowed }
if case .notAllowed = rhs { return .notAllowed }
return .after(lhs, rhs)
}
private func resolve(_ name: String) -> PureXML.Schema.Pattern {
defines[name] ?? .notAllowed
}
// MARK: Nullability
func nullable(_ pattern: PureXML.Schema.Pattern, visiting: Set<String> = []) -> Bool {
switch pattern {
case .empty, .text: return true
case let .choice(lhs, rhs): return nullable(lhs, visiting: visiting) || nullable(rhs, visiting: visiting)
case let .group(lhs, rhs), let .interleave(lhs, rhs):
return nullable(lhs, visiting: visiting) && nullable(rhs, visiting: visiting)
case let .oneOrMore(inner), let .list(inner): return nullable(inner, visiting: visiting)
case let .ref(name):
guard !visiting.contains(name) else { return false }
return nullable(resolve(name), visiting: visiting.union([name]))
default:
return false
}
}
// MARK: Text and attribute derivatives
private func textDeriv(_ pattern: PureXML.Schema.Pattern, _ string: String) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs): return choice(textDeriv(lhs, string), textDeriv(rhs, string))
case let .interleave(lhs, rhs):
return choice(interleave(textDeriv(lhs, string), rhs), interleave(lhs, textDeriv(rhs, string)))
case let .group(lhs, rhs):
let derived = group(textDeriv(lhs, string), rhs)
return nullable(lhs) ? choice(derived, textDeriv(rhs, string)) : derived
case let .after(lhs, rhs): return after(textDeriv(lhs, string), rhs)
case let .oneOrMore(inner):
return group(textDeriv(inner, string), choice(.oneOrMore(inner), .empty))
case .text: return .text
case let .ref(name): return textDeriv(resolve(name), string)
default: return valueDeriv(pattern, string)
}
}
private func valueDeriv(_ pattern: PureXML.Schema.Pattern, _ string: String) -> PureXML.Schema.Pattern {
switch pattern {
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 .list(inner): listMatches(inner, string) ? .empty : .notAllowed
default: .notAllowed
}
}
private func listMatches(_ pattern: PureXML.Schema.Pattern, _ string: String) -> Bool {
var residual = pattern
for token in string.split(whereSeparator: \.isWhitespace) {
residual = textDeriv(residual, String(token))
}
return nullable(residual)
}
private func attributeDeriv(_ pattern: PureXML.Schema.Pattern, _ attribute: PureXML.Model.Attribute) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs): return choice(attributeDeriv(lhs, attribute), attributeDeriv(rhs, attribute))
case let .group(lhs, rhs):
return choice(group(attributeDeriv(lhs, attribute), rhs), group(lhs, attributeDeriv(rhs, attribute)))
case let .interleave(lhs, rhs):
return choice(interleave(attributeDeriv(lhs, attribute), rhs), interleave(lhs, attributeDeriv(rhs, attribute)))
case let .oneOrMore(inner):
return group(attributeDeriv(inner, attribute), choice(.oneOrMore(inner), .empty))
case let .after(lhs, rhs): return after(attributeDeriv(lhs, attribute), rhs)
case let .attribute(nameClass, content):
guard nameClass.contains(attribute.name), valueMatches(content, attribute.value) else { return .notAllowed }
return .empty
case let .ref(name): return attributeDeriv(resolve(name), attribute)
default: return .notAllowed
}
}
private func valueMatches(_ content: PureXML.Schema.Pattern, _ value: String) -> Bool {
value.isEmpty ? nullable(content) : nullable(textDeriv(content, value))
}
private func oneOrMoreP(_ inner: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
if case .notAllowed = inner { return .notAllowed }
return .oneOrMore(inner)
}
}
}
extension PureXML.Schema.RelaxNGEngine {
// MARK: Element derivatives
func childDeriv(_ pattern: PureXML.Schema.Pattern, _ node: PureXML.Model.Node) -> PureXML.Schema.Pattern {
switch node {
case let .text(string), let .cdata(string):
textDeriv(pattern, string)
case let .element(element):
processElement(pattern, element)
default:
pattern
}
}
private func processElement(_ pattern: PureXML.Schema.Pattern, _ element: PureXML.Model.Element) -> PureXML.Schema.Pattern {
let opened = startTagOpenDeriv(pattern, element.name)
let attributed = element.attributes
.filter { !Self.isNamespaceDeclaration($0) }
.reduce(opened) { attributeDeriv($0, $1) }
let closed = startTagCloseDeriv(attributed)
let withChildren = childrenDeriv(closed, element.children)
return endTagDeriv(withChildren)
}
private func startTagOpenDeriv(_ pattern: PureXML.Schema.Pattern, _ name: PureXML.Model.QualifiedName) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs):
return choice(startTagOpenDeriv(lhs, name), startTagOpenDeriv(rhs, name))
case let .element(nameClass, content):
return nameClass.contains(name) ? after(content, .empty) : .notAllowed
case let .interleave(lhs, rhs):
return choice(
applyAfter({ interleave($0, rhs) }, startTagOpenDeriv(lhs, name)),
applyAfter({ interleave(lhs, $0) }, startTagOpenDeriv(rhs, name)),
)
case let .group(lhs, rhs):
let derived = applyAfter({ group($0, rhs) }, startTagOpenDeriv(lhs, name))
return nullable(lhs) ? choice(derived, startTagOpenDeriv(rhs, name)) : derived
case let .oneOrMore(inner):
return applyAfter({ group($0, choice(oneOrMoreP(inner), .empty)) }, startTagOpenDeriv(inner, name))
case let .after(lhs, rhs):
return applyAfter({ after($0, rhs) }, startTagOpenDeriv(lhs, name))
case let .ref(reference):
return startTagOpenDeriv(resolve(reference), name)
default:
return .notAllowed
}
}
private func applyAfter(_ transform: (PureXML.Schema.Pattern) -> PureXML.Schema.Pattern, _ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
switch pattern {
case let .after(lhs, rhs): after(lhs, transform(rhs))
case let .choice(lhs, rhs): choice(applyAfter(transform, lhs), applyAfter(transform, rhs))
default: .notAllowed
}
}
private func startTagCloseDeriv(_ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs): choice(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs))
case let .group(lhs, rhs): group(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs))
case let .interleave(lhs, rhs): interleave(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs))
case let .oneOrMore(inner): oneOrMoreP(startTagCloseDeriv(inner))
case let .after(lhs, rhs): after(startTagCloseDeriv(lhs), rhs)
case .attribute: .notAllowed
case let .ref(reference): startTagCloseDeriv(resolve(reference))
default: pattern
}
}
private func childrenDeriv(_ pattern: PureXML.Schema.Pattern, _ children: [PureXML.Model.Node]) -> PureXML.Schema.Pattern {
let coalesced = Self.coalesceText(children)
if coalesced.isEmpty { return pattern }
if coalesced.count == 1, case let .text(string) = coalesced[0] {
let derived = textDeriv(pattern, string)
return Self.isWhitespace(string) ? choice(pattern, derived) : derived
}
return coalesced.reduce(pattern) { accumulated, node in
Self.isWhitespaceText(node) ? accumulated : childDeriv(accumulated, node)
}
}
private func endTagDeriv(_ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern {
switch pattern {
case let .choice(lhs, rhs): choice(endTagDeriv(lhs), endTagDeriv(rhs))
case let .after(lhs, rhs): nullable(lhs) ? rhs : .notAllowed
case let .ref(reference): endTagDeriv(resolve(reference))
default: .notAllowed
}
}
// MARK: Helpers
/// Merges adjacent text and CDATA nodes into single text nodes.
private static func coalesceText(_ children: [PureXML.Model.Node]) -> [PureXML.Model.Node] {
var result: [PureXML.Model.Node] = []
var run = ""
for child in children {
switch child {
case let .text(value), let .cdata(value):
run += value
case .comment, .processingInstruction:
continue
default:
if !run.isEmpty { result.append(.text(run))
run = ""
}
result.append(child)
}
}
if !run.isEmpty { result.append(.text(run)) }
return result
}
private static func isWhitespace(_ string: String) -> Bool {
string.allSatisfy(\.isWhitespace)
}
private static func isWhitespaceText(_ node: PureXML.Model.Node) -> Bool {
if case let .text(value) = node { return isWhitespace(value) }
return false
}
private static func isNamespaceDeclaration(_ attribute: PureXML.Model.Attribute) -> Bool {
attribute.name.prefix == "xmlns" || (attribute.name.prefix == nil && attribute.name.localName == "xmlns")
}
}

View File

@ -0,0 +1,170 @@
private typealias Tree = PureXML.Model.TreeNode
/// Tree helpers for the RELAX NG parser. File-scope and private.
private enum RNGNode {
static func localName(_ node: Tree) -> String? {
node.name?.localName
}
static func attribute(_ node: Tree, _ name: String) -> String? {
node.attributes.first { $0.name.localName == name }?.value
}
static func elementChildren(_ node: Tree) -> [Tree] {
node.children.filter { $0.kind == .element }
}
static func children(_ node: Tree, named name: String) -> [Tree] {
elementChildren(node).filter { localName($0) == name }
}
static func text(_ node: Tree) -> String {
node.stringValue.trimmingXMLWhitespace()
}
static func strip(_ qualified: String) -> String {
qualified.split(separator: ":").last.map(String.init) ?? qualified
}
}
extension PureXML.Schema {
/// Parses a RELAX NG schema in the XML syntax into a grammar: a start pattern
/// and the named `define` patterns it refers to. The vocabulary is matched by
/// local name. Supports `element`, `attribute`, `text`, `empty`, `notAllowed`,
/// `group`, `choice`, `interleave`, `optional`, `zeroOrMore`, `oneOrMore`,
/// `mixed`, `list`, `ref`, `data`, `value`, and the name classes.
enum RelaxNGParser {
static func parse(_ rng: String) throws -> (start: Pattern, defines: [String: Pattern]) {
let root = try PureXML.parseTree(rng)
guard let top = RNGNode.elementChildren(root).first else {
throw PureXML.Schema.SchemaError.notASchema
}
if RNGNode.localName(top) == "grammar" {
return grammar(top)
}
return (pattern(top), [:])
}
private static func grammar(_ node: Tree) -> (start: Pattern, defines: [String: Pattern]) {
var defines: [String: Pattern] = [:]
for define in RNGNode.children(node, named: "define") {
if let name = RNGNode.attribute(define, "name") {
defines[name] = combined(RNGNode.elementChildren(define), .sequence)
}
}
let start = RNGNode.children(node, named: "start").first
return (start.map { combined(RNGNode.elementChildren($0), .sequence) } ?? .notAllowed, defines)
}
// MARK: Patterns
private static func pattern(_ node: Tree) -> Pattern {
leafPattern(node) ?? compositePattern(node)
}
/// The terminal and element/attribute patterns; nil for a compositor.
private static func leafPattern(_ node: Tree) -> Pattern? {
switch RNGNode.localName(node) {
case "empty": .empty
case "notAllowed": .notAllowed
case "text": .text
case "data": .data(BuiltinType(rawValue: RNGNode.strip(RNGNode.attribute(node, "type") ?? "string")) ?? .string)
case "value": .value(RNGNode.text(node))
case "ref": .ref(RNGNode.attribute(node, "name") ?? "")
case "element": element(node)
case "attribute": attribute(node)
default: nil
}
}
/// The compositor and quantifier patterns.
private static func compositePattern(_ node: Tree) -> Pattern {
switch RNGNode.localName(node) {
case "group": combined(RNGNode.elementChildren(node), .sequence)
case "choice": combined(RNGNode.elementChildren(node), .choice)
case "interleave": combined(RNGNode.elementChildren(node), .all)
case "optional": .choice(group(node), .empty)
case "zeroOrMore": .choice(.oneOrMore(group(node)), .empty)
case "oneOrMore": .oneOrMore(group(node))
case "mixed": .interleave(group(node), .text)
case "list": .list(group(node))
default: .notAllowed
}
}
private static func group(_ node: Tree) -> Pattern {
combined(RNGNode.elementChildren(node), .sequence)
}
private static func element(_ node: Tree) -> Pattern {
let (nameClass, contentNodes) = nameClassAndContent(node)
return .element(nameClass, combined(contentNodes, .sequence))
}
private static func attribute(_ node: Tree) -> Pattern {
let (nameClass, contentNodes) = nameClassAndContent(node)
let content = contentNodes.isEmpty ? Pattern.text : combined(contentNodes, .sequence)
return .attribute(nameClass, content)
}
/// Splits an `element`/`attribute` node into its name class and its pattern
/// children: a `name` attribute gives the class and all children are
/// content; otherwise the first child is the name class.
private static func nameClassAndContent(_ node: Tree) -> (NameClass, [Tree]) {
let children = RNGNode.elementChildren(node)
if let name = RNGNode.attribute(node, "name") {
let namespace = RNGNode.attribute(node, "ns") ?? ""
return (.name(namespace: namespace, localName: name), children)
}
guard let first = children.first else { return (.anyName, []) }
return (nameClass(first), Array(children.dropFirst()))
}
// MARK: Combinators and name classes
private static func combined(_ nodes: [Tree], _ compositor: Compositor) -> Pattern {
let patterns = nodes.map(pattern)
let identity: Pattern = compositor == .choice ? .notAllowed : .empty
guard let first = patterns.first else { return identity }
return patterns.dropFirst().reduce(first) { combine($0, $1, compositor) }
}
private static func combine(_ lhs: Pattern, _ rhs: Pattern, _ compositor: Compositor) -> Pattern {
switch compositor {
case .sequence: .group(lhs, rhs)
case .choice: .choice(lhs, rhs)
case .all: .interleave(lhs, rhs)
}
}
private static func nameClass(_ node: Tree) -> NameClass {
switch RNGNode.localName(node) {
case "name":
.name(namespace: RNGNode.attribute(node, "ns") ?? "", localName: RNGNode.text(node))
case "anyName":
anyName(node)
case "nsName":
.nsName(RNGNode.attribute(node, "ns") ?? "")
case "choice":
nameClassChoice(RNGNode.elementChildren(node))
default:
.anyName
}
}
private static 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))
}
private static func nameClassChoice(_ nodes: [Tree]) -> NameClass {
let classes = nodes.map(nameClass)
guard let first = classes.first else { return .anyName }
return classes.dropFirst().reduce(first) { .choice($0, $1) }
}
}
}

View File

@ -0,0 +1,42 @@
public extension PureXML.Schema {
/// A RELAX NG name class: which element or attribute names a pattern matches.
indirect enum NameClass: Sendable {
case anyName
case anyNameExcept(NameClass)
case name(namespace: String, localName: String)
case nsName(String)
case choice(NameClass, NameClass)
/// Whether `name` is in this class.
func contains(_ name: PureXML.Model.QualifiedName) -> Bool {
let namespace = name.namespaceURI ?? ""
switch self {
case .anyName: return true
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 .choice(left, right): return left.contains(name) || right.contains(name)
}
}
}
/// A RELAX NG pattern (the simplified algebra used by the derivative
/// algorithm). `optional`, `zeroOrMore`, and `mixed` are expressed through
/// these constructors; `after` is internal to derivation.
indirect enum Pattern: Sendable {
case empty
case notAllowed
case text
case choice(Pattern, Pattern)
case interleave(Pattern, Pattern)
case group(Pattern, Pattern)
case oneOrMore(Pattern)
case element(NameClass, Pattern)
case attribute(NameClass, Pattern)
case data(BuiltinType)
case value(String)
case list(Pattern)
case ref(String)
case after(Pattern, Pattern)
}
}

View File

@ -0,0 +1,23 @@
public extension PureXML.Schema {
/// A compiled RELAX NG schema (XML syntax). `validate(_:)` checks an instance
/// document by the derivative algorithm.
struct RelaxNG: Sendable {
private let start: Pattern
private let defines: [String: Pattern]
/// Compiles a RELAX NG schema document.
public init(_ rng: String) throws {
(start, defines) = try RelaxNGParser.parse(rng)
}
/// Whether `xml` is valid against the schema.
public func validate(_ xml: String) throws -> Bool {
guard case let .document(children) = try PureXML.parse(xml),
let root = children.compactMap(\.element).first
else {
return false
}
return RelaxNGEngine(defines: defines).matches(start: start, root: .element(root))
}
}
}

115
Tests/RelaxNGTests.swift Normal file
View File

@ -0,0 +1,115 @@
@testable import PureXML
import Testing
@Suite("RELAX NG")
struct RelaxNGTests {
private func valid(_ rng: String, _ xml: String) throws -> Bool {
try PureXML.Schema.RelaxNG(rng).validate(xml)
}
private let rngNamespace = "xmlns=\"http://relaxng.org/ns/structure/1.0\""
@Test("An element with text content")
func test_elementText() throws {
let rng = "<element name=\"greeting\" \(rngNamespace)><text/></element>"
#expect(try valid(rng, "<greeting>hello</greeting>"))
#expect(try !valid(rng, "<other>hello</other>"))
#expect(try !valid(rng, "<greeting><child/></greeting>"))
}
@Test("A sequence of child elements in order")
func test_sequence() throws {
let rng = """
<element name="book" \(rngNamespace)>
<element name="title"><text/></element>
<element name="author"><text/></element>
</element>
"""
#expect(try valid(rng, "<book><title>T</title><author>A</author></book>"))
#expect(try !valid(rng, "<book><author>A</author><title>T</title></book>"))
#expect(try !valid(rng, "<book><title>T</title></book>"))
}
@Test("choice, optional, and zeroOrMore")
func test_combinators() throws {
let rng = """
<element name="r" \(rngNamespace)>
<optional><element name="a"><text/></element></optional>
<zeroOrMore><element name="b"><empty/></element></zeroOrMore>
<choice><element name="x"><empty/></element><element name="y"><empty/></element></choice>
</element>
"""
#expect(try valid(rng, "<r><a>1</a><b/><b/><x/></r>"))
#expect(try valid(rng, "<r><y/></r>"))
#expect(try !valid(rng, "<r><x/><y/></r>"))
}
@Test("oneOrMore requires at least one")
func test_oneOrMore() throws {
let rng = """
<element name="list" \(rngNamespace)>
<oneOrMore><element name="item"><text/></element></oneOrMore>
</element>
"""
#expect(try valid(rng, "<list><item>a</item><item>b</item></list>"))
#expect(try !valid(rng, "<list></list>"))
}
@Test("Attributes are validated and required")
func test_attributes() throws {
let rng = """
<element name="e" \(rngNamespace)>
<attribute name="id"><text/></attribute>
<empty/>
</element>
"""
#expect(try valid(rng, "<e id=\"1\"/>"))
#expect(try !valid(rng, "<e/>"))
}
@Test("interleave allows children in any order")
func test_interleave() throws {
let rng = """
<element name="r" \(rngNamespace)>
<interleave>
<element name="a"><empty/></element>
<element name="b"><empty/></element>
</interleave>
</element>
"""
#expect(try valid(rng, "<r><a/><b/></r>"))
#expect(try valid(rng, "<r><b/><a/></r>"))
#expect(try !valid(rng, "<r><a/></r>"))
}
@Test("data types validate text content")
func test_data() throws {
let rng = "<element name=\"age\" \(rngNamespace)><data type=\"int\"/></element>"
#expect(try valid(rng, "<age>42</age>"))
#expect(try !valid(rng, "<age>x</age>"))
}
@Test("value matches a literal")
func test_value() throws {
let rng = "<element name=\"flag\" \(rngNamespace)><value>yes</value></element>"
#expect(try valid(rng, "<flag>yes</flag>"))
#expect(try !valid(rng, "<flag>no</flag>"))
}
@Test("A grammar with define and ref validates recursive structures")
func test_recursiveGrammar() throws {
let rng = """
<grammar \(rngNamespace)>
<start><ref name="node"/></start>
<define name="node">
<element name="node">
<optional><ref name="node"/></optional>
</element>
</define>
</grammar>
"""
#expect(try valid(rng, "<node/>"))
#expect(try valid(rng, "<node><node><node/></node></node>"))
#expect(try !valid(rng, "<node><other/></node>"))
}
}