Merge epic/24-xinclude: XInclude, URI resolution, and xml:base
This commit is contained in:
commit
caa5c9004c
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
147
Sources/XInclude/URIReference.swift
Normal file
147
Sources/XInclude/URIReference.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
Sources/XInclude/XIncludeError.swift
Normal file
19
Sources/XInclude/XIncludeError.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
Sources/XInclude/XIncludeProcessor.swift
Normal file
133
Sources/XInclude/XIncludeProcessor.swift
Normal 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
95
Tests/XIncludeTests.swift
Normal 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 < 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>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user