feat(relaxng): simplification 4.1, 4.2, and xml:base href resolution

Foreign-namespace elements are stripped before pattern interpretation
(namespace-less elements count only inside fully unqualified schemas),
name/type/combine values are whitespace-trimmed, and include and
externalRef hrefs resolve against in-scope xml:base attributes and the
loaded document's URI, threaded through nested loads with visited
tracking on resolved hrefs. Spec-suite baselines tighten to valid
242/289 and invalid 278/291.
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 09:18:34 +02:00
parent b6981baf19
commit 2eab91bf2b
3 changed files with 70 additions and 9 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- RELAX NG simplification, first burn-down (#131): foreign-namespace elements are stripped before pattern interpretation (4.1: only RELAX NG-namespace elements are schema content; a namespace-less element counts only inside a fully unqualified schema), name/type/combine attribute values are whitespace-trimmed (4.2), and include/externalRef hrefs resolve against the xml:base attributes in scope and the loaded document's own URI (4.5, reusing the RFC 3986 resolver), with the document base threaded through nested loads and visited-tracking keyed on resolved hrefs. Spec-suite baselines tighten: valid 242/289 accepted (from 233), invalid 278/291 rejected.
- RELAX NG spec test suite runner (#122, #131): James Clark's spectest.xml (385 cases, distributed with jing-trang) runs via an opt-in RNG_TS_ROOT runner, never vendored; the manifest is parsed with PureXML itself, nested testSuite scopes thread their resource/dir definitions into the compiler's loader, and each case asserts compile/reject for the schema plus accept/reject per instance. First measurement, carried as exact baselines: every correct schema compiles and most instances behave (valid 233/289 accepted, invalid 277/291 rejected), while schema-correctness validation is absent (all 213 incorrect schemas compile, count asserted exactly). The burn-down classes are recorded in the runner: schema validation against the RELAX NG grammar and the 4.16-4.18 restrictions, foreign-namespace stripping (4.1), xml:base-aware include/externalRef resolution (4.5), and the section 6 compatibility datatypes.
- Canonical XML 1.0 spec-example vectors (#122, #132): the worked examples of the C14N 1.0 Recommendation's section 3 run as input/expected-output tests (cited per example; used under the W3C Software and Document License), all six passing: top-level PIs/comments with and without comments mode, whitespace preservation, start/end tags with attribute sorting and DTD defaults, character modifications and references, entity expansion, and UTF-8 output from legacy-encoded input. The vectors exposed three real conformance fixes: top-level siblings in the canonical form are now separated by single line feeds; an empty default-namespace declaration renders only when it undeclares a rendered non-empty default (superfluous `xmlns=""` is dropped, inclusive mode); and the text/attribute escape loops iterate Unicode scalars, so a carriage return that grapheme-clusters with a following line feed still escapes to `
` (the #135 class, surfacing in output rather than lexing).
- Scalar-level lexing (#135): the reader now buffers exactly one Unicode scalar per element (multi-scalar graphemes from any source are split, string input iterates scalars directly), so a combining mark directly after an ASCII delimiter can no longer merge with it into one Swift grapheme cluster and XML's scalar-defined name productions classify correctly: U+06D6 lexes as a Fifth Edition PI-target NameStartChar, ZWNJ/ZWJ attribute names parse, and U+309A markup from entity expansion forms a real element. The replacement-text, content-model, and ATTLIST sub-scanners and the entity-literal validator go scalar-level the same way, while grapheme clusters reassemble naturally wherever scanned text is appended to a String (asserted by test). This clears the last eduni baseline (now empty), grows the IBM pre-5e class from 279 to 300 exactly as the Fifth Edition prescribes, and, combined with the EDITION="1 2 3 4" exclusions the xmltest manifest itself carries for cases 140/141, empties the xmltest deviation baseline entirely: every xmlconf case applicable to a Fifth Edition, namespace-aware processor now passes.

View File

@ -7,11 +7,29 @@ private enum RNGNode {
}
static func attribute(_ node: Tree, _ name: String) -> String? {
node.attributes.first { $0.name.localName == name }?.value
let raw = node.attributes.first { $0.name.localName == name }?.value
// Simplification 4.2: the value of name, type, and combine attributes
// is whitespace-trimmed.
guard let raw, ["name", "type", "combine"].contains(name) else { return raw }
return raw.trimmingXMLWhitespace()
}
/// The RELAX NG namespace; simplification 4.1 removes foreign-namespace
/// elements (annotations such as documentation or comments) before any
/// pattern interpretation.
static let relaxNGNamespace = "http://relaxng.org/ns/structure/1.0"
static func elementChildren(_ node: Tree) -> [Tree] {
node.children.filter { $0.kind == .element }
node.children.filter { child in
guard child.kind == .element else { return false }
// Simplification 4.1: only RELAX NG-namespace elements are schema
// content. An element with no namespace counts as schema content
// only when the whole document is unqualified (leniency for
// namespace-less schemas); under a qualified parent it is foreign.
let uri = child.name?.namespaceURI
if uri == relaxNGNamespace { return true }
return uri == nil && node.name?.namespaceURI == nil
}
}
static func children(_ node: Tree, named name: String) -> [Tree] {
@ -25,6 +43,39 @@ private enum RNGNode {
static func strip(_ qualified: String) -> String {
qualified.split(separator: ":").last.map(String.init) ?? qualified
}
/// Resolves `reference` against an optional base URI (RFC 3986), keeping a
/// relative base's merged form relative (the same adjustment external
/// identifiers use).
static func resolveRelative(_ reference: String, against base: String?) -> String {
guard let base, !base.isEmpty else { return reference }
let merged = PureXML.Canonical.Canonicalizer.resolveURI(reference, against: base)
let baseHasScheme = base.split(separator: "/", maxSplits: 1)[0].hasSuffix(":")
if !base.hasPrefix("/"), !baseHasScheme, !reference.hasPrefix("/"), merged.hasPrefix("/") {
return String(merged.dropFirst())
}
return merged
}
/// The `href` of an include/externalRef, resolved against the document's
/// base and the `xml:base` attributes in scope at the node (4.5).
static func resolvedHref(_ node: Tree, documentBase: String?) -> String? {
guard let href = attribute(node, "href") else { return nil }
var bases: [String] = []
var cursor: Tree? = node
while let current = cursor {
if let base = current.attributes.first(where: { $0.name.prefix == "xml" && $0.name.localName == "base" })?.value {
bases.append(base)
}
cursor = current.parent
}
if let documentBase { bases.append(documentBase) }
var resolved = href
for base in bases {
resolved = resolveRelative(resolved, against: base)
}
return resolved
}
}
/// Compiles a RELAX NG XML-syntax document into the pattern algebra, resolving
@ -38,6 +89,9 @@ private final class RNGCompiler {
private(set) var defines: [String: Pattern] = [:]
private let loader: (String) -> String?
private var visited: Set<String> = []
/// The URI of the document currently being compiled (nil for the top
/// level): hrefs inside a loaded document resolve against it.
private var documentBase: String?
init(loader: @escaping (String) -> String?) {
self.loader = loader
@ -78,16 +132,20 @@ private final class RNGCompiler {
/// Merges an `include`d grammar: its `define`s first, then the `include`'s own
/// nested `define`s, which override or (with `combine`) merge with them.
private func mergeInclude(_ node: Tree) {
guard let href = RNGNode.attribute(node, "href"), !visited.contains(href),
guard let href = RNGNode.resolvedHref(node, documentBase: documentBase), !visited.contains(href),
let text = loader(href), let root = try? PureXML.parseTree(text),
let grammar = RNGNode.elementChildren(root).first
else {
return
}
visited.insert(href)
let outerBase = documentBase
documentBase = href
defer { documentBase = outerBase }
for define in RNGNode.children(grammar, named: "define") {
addDefine(define)
}
documentBase = outerBase
for define in RNGNode.children(node, named: "define") {
addDefine(define)
}
@ -180,13 +238,16 @@ private final class RNGCompiler {
/// 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 {
guard let href = RNGNode.attribute(node, "href"), !visited.contains(href),
guard let href = RNGNode.resolvedHref(node, documentBase: documentBase), !visited.contains(href),
let text = loader(href), let root = try? PureXML.parseTree(text),
let top = RNGNode.elementChildren(root).first
else {
return .notAllowed
}
visited.insert(href)
let outerBase = documentBase
documentBase = href
defer { documentBase = outerBase }
return topLevel(top)
}

View File

@ -33,13 +33,12 @@ struct RelaxNGSpecSuiteTests {
private let knownIncorrectCompiledCount = 213
private let knownCorrectRejected: Set<Int> = []
private let knownValidRejected: Set<Int> = [
90, 92, 93, 94, 95, 96, 98, 100, 101, 104, 108, 109, 110, 111, 115,
119, 120, 124, 125, 126, 127, 128, 130, 131, 132, 133, 142, 190, 191,
194, 195, 208, 209, 210, 236, 256, 258, 264, 265, 266, 268, 269, 271,
328, 372, 374, 378, 379, 380,
98, 104, 109, 110, 111, 115, 119, 120, 124, 125, 126, 127, 128, 130,
131, 132, 133, 142, 190, 191, 194, 195, 208, 209, 210, 236, 256, 258,
265, 266, 268, 269, 271, 328, 372, 374, 378, 379, 380,
]
private let knownInvalidAccepted: Set<Int> = [
101, 104, 124, 125, 126, 127, 128, 142, 264, 271,
104, 124, 125, 126, 127, 128, 142, 264, 271,
]
private struct SpecCase {