Merge epic/24-xinclude: XInclude, URI resolution, and xml:base

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 06:08:32 +02:00
commit caa5c9004c
7 changed files with 407 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@ -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[..<slash])
} else {
output = ""
}
}
private static func moveSegment(_ input: inout Substring, _ output: inout String) {
var end = input.index(after: input.startIndex)
while end < input.endIndex, input[end] != "/" {
end = input.index(after: end)
}
output += input[input.startIndex ..< end]
input = input[end...]
}
private func recomposed() -> 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[..<hash]
}
if let question = rest.firstIndex(of: "?") {
reference.query = String(rest[rest.index(after: question)...])
rest = rest[..<question]
}
reference.scheme = scanScheme(&rest)
if rest.hasPrefix("//") {
rest = rest.dropFirst(2)
let end = rest.firstIndex { $0 == "/" } ?? rest.endIndex
reference.authority = String(rest[..<end])
rest = rest[end...]
}
reference.path = String(rest)
return reference
}
private static func scanScheme(_ rest: inout Substring) -> String? {
guard let colon = rest.firstIndex(of: ":") else { return nil }
let candidate = rest[..<colon]
guard let first = candidate.first, first.isLetter,
candidate.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "+" || $0 == "-" || $0 == "." }),
!candidate.contains("/")
else {
return nil
}
rest = rest[rest.index(after: colon)...]
return String(candidate)
}
}
}

View File

@ -0,0 +1,19 @@
public extension PureXML.XInclude {
/// An error during XInclude processing.
enum XIncludeError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
/// An `xi:include` had no `href` and no usable fallback.
case missingHref
/// The resource could not be loaded and there was no `xi:fallback`.
case unresolved(String)
/// Includes nested past the recursion limit (a likely include loop).
case toodeep
public var description: String {
switch self {
case .missingHref: "an xi:include has no href and no fallback"
case let .unresolved(href): "could not resolve xi:include href '\(href)' and no fallback"
case .toodeep: "xi:include nesting is too deep (possible loop)"
}
}
}
}

View File

@ -0,0 +1,133 @@
/// One XInclude pass over a value tree. File-scope and private: an internal
/// detail of ``PureXML/XInclude``. Operates on the immutable ``PureXML/Model/Node``
/// tree, returning a new tree with `xi:include` elements replaced.
private struct XIncludeRun {
typealias Node = PureXML.Model.Node
typealias Element = PureXML.Model.Element
let load: (String) -> 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)
}
}

95
Tests/XIncludeTests.swift Normal file
View File

@ -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 = "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\"><xi:include href=\"frag.xml\"/></doc>"
let result = try process(xml) { uri in
uri == "http://example.com/frag.xml" ? "<inserted>hi</inserted>" : nil
}
#expect(result == "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\"><inserted>hi</inserted></doc>")
}
@Test("xi:include parse=text substitutes raw text")
func test_includeText() throws {
let xml = "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\">"
+ "<xi:include href=\"note.txt\" parse=\"text\"/></doc>"
let result = try process(xml) { _ in "a < b" }
#expect(result == "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\">a &lt; b</doc>")
}
@Test("xi:include with an xpointer selects a fragment")
func test_includeWithXPointer() throws {
let xml = "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\">"
+ "<xi:include href=\"frag.xml\" xpointer=\"xpointer(//item[2])\"/></doc>"
let result = try process(xml) { _ in "<list><item>one</item><item>two</item></list>" }
#expect(result == "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\"><item>two</item></doc>")
}
@Test("href resolves against an xml:base on an ancestor")
func test_xmlBase() throws {
let xml = "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\" xml:base=\"sub/\">"
+ "<xi:include href=\"frag.xml\"/></doc>"
var requested = ""
let result = try process(xml) { uri in
requested = uri
return "<ok/>"
}
#expect(requested == "http://example.com/sub/frag.xml")
#expect(result == "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\" xml:base=\"sub/\"><ok/></doc>")
}
@Test("A failed include uses its xi:fallback")
func test_fallback() throws {
let xml = "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\">"
+ "<xi:include href=\"missing.xml\"><xi:fallback><backup/></xi:fallback></xi:include></doc>"
let result = try process(xml) { _ in nil }
#expect(result == "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\"><backup/></doc>")
}
@Test("A failed include with no fallback throws")
func test_unresolved() {
let xml = "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\"><xi:include href=\"missing.xml\"/></doc>"
#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 = "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\"><xi:include href=\"a.xml\"/></doc>"
let result = try process(xml) { uri in
switch uri {
case "http://example.com/a.xml":
"<a xmlns:xi=\"http://www.w3.org/2001/XInclude\"><xi:include href=\"b.xml\"/></a>"
case "http://example.com/b.xml":
"<b/>"
default:
nil
}
}
#expect(result == "<doc xmlns:xi=\"http://www.w3.org/2001/XInclude\"><a xmlns:xi=\"http://www.w3.org/2001/XInclude\"><b/></a></doc>")
}
}