From 85f2ad2ebd3c3b401eb85714c39ce6f243afd185 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 06:08:31 +0200 Subject: [PATCH] feat(xinclude): XInclude, URI resolution, and xml:base Add the PureXML.XInclude namespace: RFC 3986 reference resolution (URIReference with dot-segment removal) and an xi:include processor over the value tree. href resolves against the base and in-scope xml:base; parse=xml includes the document element or an xpointer() fragment (via PureXML.XPointer), parse=text includes raw text, and a failed load uses xi:fallback or errors. Includes nest. No I/O is done internally: fetching is only through the injected loadingURI closure, so the default fetches nothing. Closes #48 Closes #49 Closes #50 Closes #51 Closes #24 --- CHANGELOG.md | 9 ++ README.md | 2 +- Sources/PureXML.swift | 3 + Sources/XInclude/URIReference.swift | 147 +++++++++++++++++++++++ Sources/XInclude/XIncludeError.swift | 19 +++ Sources/XInclude/XIncludeProcessor.swift | 133 ++++++++++++++++++++ Tests/XIncludeTests.swift | 95 +++++++++++++++ 7 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 Sources/XInclude/URIReference.swift create mode 100644 Sources/XInclude/XIncludeError.swift create mode 100644 Sources/XInclude/XIncludeProcessor.swift create mode 100644 Tests/XIncludeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ded8a8c..2e03018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- XInclude, URI resolution, and `xml:base` (#24, the libxml2 `xinclude.h`/`uri.h` + model). The new `PureXML.XInclude` namespace adds RFC 3986 reference + resolution (`URIReference.resolve(_:against:)`, with dot-segment removal) and + `process(_:base:loadingURI:)`, which replaces `xi:include` elements with their + targets. `href`s resolve against the supplied base and the in-scope `xml:base`, + `parse="xml"` includes the document element (or an `xpointer()` fragment via + `PureXML.XPointer`), `parse="text"` includes raw text, and a failed load uses + `xi:fallback` or errors. Includes nest. PureXML does no I/O itself: fetching is + only through the injected `loadingURI` closure, so the default fetches nothing. - XML Catalog resolution (#27, the libxml2 `catalog.h` model). The new `PureXML.Catalog` namespace parses an OASIS XML catalog (matching `public`, `system`, `uri`, `rewriteSystem`, `rewriteURI`, and `group`/`catalog` by local diff --git a/README.md b/README.md index 814a7be..c20f8fd 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ flowchart TB classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827 classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827 Xslt["#3 XSLT 1.0"]:::epic - XInclude["#24 XInclude, URI, xml:base"]:::epic + XInclude["#24 XInclude, URI, xml:base"]:::done C14n["#26 Canonical XML"]:::epic Catalog["#27 XML Catalog"]:::done Xslt --> C14n diff --git a/Sources/PureXML.swift b/Sources/PureXML.swift index dff917f..e13e530 100644 --- a/Sources/PureXML.swift +++ b/Sources/PureXML.swift @@ -33,4 +33,7 @@ public enum PureXML { /// OASIS XML Catalog types (resolving public/system identifiers to URIs). public enum Catalog {} + + /// XInclude processing, URI reference resolution, and `xml:base`. + public enum XInclude {} } diff --git a/Sources/XInclude/URIReference.swift b/Sources/XInclude/URIReference.swift new file mode 100644 index 0000000..09e6426 --- /dev/null +++ b/Sources/XInclude/URIReference.swift @@ -0,0 +1,147 @@ +extension PureXML.XInclude { + /// A parsed URI reference (scheme, authority, path, query, fragment) and the + /// RFC 3986 reference-resolution algorithm. No I/O: it only manipulates the + /// identifier strings so a relative `href` can be resolved against a base. + struct URIReference: Equatable { + var scheme: String? + var authority: String? + var path: String + var query: String? + var fragment: String? + + /// Resolves `reference` against `base`, returning the resolved URI string. + static func resolve(_ reference: String, against base: String) -> String { + let ref = parse(reference) + let baseRef = parse(base) + return ref.resolved(against: baseRef).recomposed() + } + + /// The RFC 3986 §5.2.2 transform. + func resolved(against base: URIReference) -> URIReference { + if scheme != nil { + return URIReference( + scheme: scheme, + authority: authority, + path: Self.removeDotSegments(path), + query: query, + fragment: fragment, + ) + } + var result = URIReference(scheme: base.scheme, authority: nil, path: "", query: nil, fragment: fragment) + if let authority { + result.authority = authority + result.path = Self.removeDotSegments(path) + result.query = query + return result + } + result.authority = base.authority + resolvePath(base: base, into: &result) + return result + } + + private func resolvePath(base: URIReference, into result: inout URIReference) { + if path.isEmpty { + result.path = base.path + result.query = query ?? base.query + } else { + let merged = path.hasPrefix("/") ? path : Self.merge(base: base, reference: path) + result.path = Self.removeDotSegments(merged) + result.query = query + } + } + + private static func merge(base: URIReference, reference: String) -> String { + if base.authority != nil, base.path.isEmpty { + return "/" + reference + } + guard let slash = base.path.lastIndex(of: "/") else { return reference } + return base.path[...slash] + reference + } + + /// The RFC 3986 §5.2.4 dot-segment removal. + static func removeDotSegments(_ path: String) -> String { + var input = Substring(path) + var output = "" + while !input.isEmpty { + if input.hasPrefix("../") { input = input.dropFirst(3) } else if input.hasPrefix("./") { + input = input.dropFirst(2) + } else if input.hasPrefix("/./") { input = "/" + input.dropFirst(3) } else if input == "/." { + input = "/" + } else if input.hasPrefix("/../") { + input = "/" + input.dropFirst(4) + dropLastSegment(&output) + } else if input == "/.." { + input = "/" + dropLastSegment(&output) + } else if input == "." || input == ".." { + input = "" + } else { + moveSegment(&input, &output) + } + } + return output + } + + private static func dropLastSegment(_ output: inout String) { + if let slash = output.lastIndex(of: "/") { + output = String(output[.. String { + var result = "" + if let scheme { result += scheme + ":" } + if let authority { result += "//" + authority } + result += path + if let query { result += "?" + query } + if let fragment { result += "#" + fragment } + return result + } + + static func parse(_ string: String) -> URIReference { + var rest = Substring(string) + var reference = URIReference(scheme: nil, authority: nil, path: "", query: nil, fragment: nil) + if let hash = rest.firstIndex(of: "#") { + reference.fragment = String(rest[rest.index(after: hash)...]) + rest = rest[.. String? { + guard let colon = rest.firstIndex(of: ":") else { return nil } + let candidate = rest[.. String? + let maxDepth = 30 + let namespace = "http://www.w3.org/2001/XInclude" + + func process(_ node: Node, base: String, depth: Int) throws -> [Node] { + switch node { + case let .document(children): + try [.document(children.flatMap { try process($0, base: base, depth: depth) })] + case let .element(element): + try processElement(element, base: base, depth: depth) + default: + [node] + } + } + + private func processElement(_ element: Element, base: String, depth: Int) throws -> [Node] { + if isInclude(element) { + return try resolveInclude(element, base: base, depth: depth) + } + let elementBase = baseURI(of: element, base: base) + let children = try element.children.flatMap { try process($0, base: elementBase, depth: depth) } + return [.element(Element(name: element.name, attributes: element.attributes, children: children))] + } + + private func resolveInclude(_ element: Element, base: String, depth: Int) throws -> [Node] { + guard depth < maxDepth else { throw PureXML.XInclude.XIncludeError.toodeep } + let elementBase = baseURI(of: element, base: base) + guard let href = attribute(element, "href") else { + return try fallback(element, base: base, depth: depth, error: .missingHref) + } + let resolved = PureXML.XInclude.URIReference.resolve(href, against: elementBase) + guard let content = load(resolved) else { + return try fallback(element, base: base, depth: depth, error: .unresolved(href)) + } + if attribute(element, "parse") == "text" { + return [.text(content)] + } + guard let parsed = try? PureXML.parse(content) else { + return try fallback(element, base: base, depth: depth, error: .unresolved(href)) + } + let selected = selectedNodes(from: parsed, xpointer: attribute(element, "xpointer")) + return try selected.flatMap { try process($0, base: resolved, depth: depth + 1) } + } + + private func selectedNodes(from parsed: Node, xpointer: String?) -> [Node] { + if let xpointer { + let selections = (try? PureXML.XPointer.evaluate(xpointer, over: parsed)) ?? [] + return selections.map(Self.node) + } + return [documentElement(of: parsed) ?? parsed] + } + + private func documentElement(of parsed: Node) -> Node? { + guard case let .document(children) = parsed else { return nil } + return children.first { if case .element = $0 { true } else { false } } + } + + private static func node(_ selection: PureXML.XPath.Selection) -> Node { + switch selection { + case let .node(node): node + case let .attribute(attribute): .text(attribute.value) + } + } + + private func fallback( + _ element: Element, + base: String, + depth: Int, + error: PureXML.XInclude.XIncludeError, + ) throws -> [Node] { + guard let fallback = element.children.first(where: isFallback), + case let .element(fallbackElement) = fallback + else { + throw error + } + return try fallbackElement.children.flatMap { try process($0, base: base, depth: depth) } + } + + private func baseURI(of element: Element, base: String) -> String { + guard let declared = attribute(element, "xml:base") else { return base } + return PureXML.XInclude.URIReference.resolve(declared, against: base) + } + + private func isInclude(_ element: Element) -> Bool { + element.name.localName == "include" && inXIncludeNamespace(element) + } + + private func isFallback(_ node: Node) -> Bool { + guard case let .element(element) = node else { return false } + return element.name.localName == "fallback" && inXIncludeNamespace(element) + } + + private func inXIncludeNamespace(_ element: Element) -> Bool { + element.name.namespaceURI == namespace || element.name.prefix == "xi" + } + + private func attribute(_ element: Element, _ name: String) -> String? { + element.attributes.first { $0.name.description == name }?.value + } +} + +public extension PureXML.XInclude { + /// Processes the `xi:include` elements in a parsed tree, returning a new tree + /// with each include replaced by its target's content. `href`s are resolved + /// against `base` and the in-scope `xml:base`, then loaded through + /// `loadingURI`. PureXML performs no I/O itself: a reference whose loader + /// returns nil falls back to the include's `xi:fallback`, or errors if there + /// is none. With a loader that always returns nil nothing external is fetched. + static func process( + _ node: PureXML.Model.Node, + base: String = "", + loadingURI: @escaping (_ uri: String) -> String?, + ) throws -> PureXML.Model.Node { + let run = XIncludeRun(load: loadingURI) + return try run.process(node, base: base, depth: 0).first ?? node + } + + /// Parses `xml` and processes its `xi:include` elements. + static func process( + _ xml: String, + base: String = "", + loadingURI: @escaping (_ uri: String) -> String?, + ) throws -> PureXML.Model.Node { + try process(PureXML.parse(xml), base: base, loadingURI: loadingURI) + } +} diff --git a/Tests/XIncludeTests.swift b/Tests/XIncludeTests.swift new file mode 100644 index 0000000..f6ba0e1 --- /dev/null +++ b/Tests/XIncludeTests.swift @@ -0,0 +1,95 @@ +@testable import PureXML +import Testing + +@Suite("XInclude") +struct XIncludeTests { + // MARK: URI resolution + + @Test("Relative references resolve against a base per RFC 3986") + func test_uriResolution() { + let resolve = PureXML.XInclude.URIReference.resolve + #expect(resolve("g", "http://a/b/c/d") == "http://a/b/c/g") + #expect(resolve("../g", "http://a/b/c/d") == "http://a/b/g") + #expect(resolve("../../g", "http://a/b/c/d") == "http://a/g") + #expect(resolve("/g", "http://a/b/c/d") == "http://a/g") + #expect(resolve("http://x/y", "http://a/b/c/d") == "http://x/y") + #expect(resolve("./g", "http://a/b/c/d") == "http://a/b/c/g") + } + + // MARK: Processing + + private func process(_ xml: String, _ load: @escaping (String) -> String?) throws -> String { + let node = try PureXML.XInclude.process(xml, base: "http://example.com/doc.xml", loadingURI: load) + return PureXML.serialize(node, options: .compact) + } + + @Test("xi:include parse=xml substitutes the document element") + func test_includeXML() throws { + let xml = "" + let result = try process(xml) { uri in + uri == "http://example.com/frag.xml" ? "hi" : nil + } + #expect(result == "hi") + } + + @Test("xi:include parse=text substitutes raw text") + func test_includeText() throws { + let xml = "" + + "" + let result = try process(xml) { _ in "a < b" } + #expect(result == "a < b") + } + + @Test("xi:include with an xpointer selects a fragment") + func test_includeWithXPointer() throws { + let xml = "" + + "" + let result = try process(xml) { _ in "onetwo" } + #expect(result == "two") + } + + @Test("href resolves against an xml:base on an ancestor") + func test_xmlBase() throws { + let xml = "" + + "" + var requested = "" + let result = try process(xml) { uri in + requested = uri + return "" + } + #expect(requested == "http://example.com/sub/frag.xml") + #expect(result == "") + } + + @Test("A failed include uses its xi:fallback") + func test_fallback() throws { + let xml = "" + + "" + let result = try process(xml) { _ in nil } + #expect(result == "") + } + + @Test("A failed include with no fallback throws") + func test_unresolved() { + let xml = "" + #expect(throws: PureXML.XInclude.XIncludeError.self) { + _ = try PureXML.XInclude.process(xml) { _ in nil } + } + } + + @Test("Includes nest: an included document's own includes resolve") + func test_nestedInclude() throws { + let xml = "" + let result = try process(xml) { uri in + switch uri { + case "http://example.com/a.xml": + "" + case "http://example.com/b.xml": + "" + default: + nil + } + } + #expect(result == "") + } +}