diff --git a/CHANGELOG.md b/CHANGELOG.md index c798f49..9cf04f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/Schema/RelaxNGParser.swift b/Sources/Schema/RelaxNGParser.swift index f67d3e7..fc9ccf6 100644 --- a/Sources/Schema/RelaxNGParser.swift +++ b/Sources/Schema/RelaxNGParser.swift @@ -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 = [] + /// 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) } diff --git a/Tests/RelaxNGSpecSuiteTests.swift b/Tests/RelaxNGSpecSuiteTests.swift index 1521897..fc7fd7e 100644 --- a/Tests/RelaxNGSpecSuiteTests.swift +++ b/Tests/RelaxNGSpecSuiteTests.swift @@ -33,13 +33,12 @@ struct RelaxNGSpecSuiteTests { private let knownIncorrectCompiledCount = 213 private let knownCorrectRejected: Set = [] private let knownValidRejected: Set = [ - 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 = [ - 101, 104, 124, 125, 126, 127, 128, 142, 264, 271, + 104, 124, 125, 126, 127, 128, 142, 264, 271, ] private struct SpecCase {