From 627c035c2a2a8c3370f55b4fd2cfbe4acd79f2ac Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 05:52:23 +0200 Subject: [PATCH] feat(xpointer): element() and xpointer() schemes Add the PureXML.XPointer namespace resolving the shorthand bare-name form (id), the element() scheme (child-element positions from an id or the document root), and the xpointer() scheme (a full XPath expression). Scheme parts chain with the XPointer fallback rule: the first non-empty selection wins. element() data is translated to the equivalent XPath and evaluated by PureXML.XPath, so no separate evaluator is needed. Closes #46 Closes #47 Closes #23 --- CHANGELOG.md | 7 ++ README.md | 2 +- Sources/PureXML.swift | 4 + Sources/XPointer/Pointer.swift | 158 +++++++++++++++++++++++++++ Sources/XPointer/XPointerError.swift | 16 +++ Tests/XPointerTests.swift | 64 +++++++++++ 6 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 Sources/XPointer/Pointer.swift create mode 100644 Sources/XPointer/XPointerError.swift create mode 100644 Tests/XPointerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ffa7a..0220ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- XPointer (#23, the libxml2 `xpointer.h` schemes). The new `PureXML.XPointer` + namespace resolves the shorthand bare-name form (`name` = `id('name')`), the + `element()` scheme (child-element positions from an id or the document root, + e.g. `element(intro/2)`, `element(/1/2)`), and the `xpointer()` scheme (a full + XPath expression). A pointer may chain scheme parts; they are tried in order + and the first non-empty selection wins (the fallback rule). Built on + `PureXML.XPath`. - Streaming pattern matching (#22, the libxml2 `pattern.h` model). The new `PureXML.Pattern` namespace compiles the streamable XPath subset (element names, `*`, `prefix:*`, `/`, `//`, a leading absolute `/`, and a trailing diff --git a/README.md b/README.md index b7133d0..aa10ac7 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ flowchart TB classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827 XPathFull["#21 Full XPath 1.0"]:::done Pattern["#22 xmlPattern streaming match"]:::done - XPointer["#23 XPointer"]:::epic + XPointer["#23 XPointer"]:::done XPathFull --> Pattern XPathFull --> XPointer ``` diff --git a/Sources/PureXML.swift b/Sources/PureXML.swift index 28da66a..06bd6e3 100644 --- a/Sources/PureXML.swift +++ b/Sources/PureXML.swift @@ -26,4 +26,8 @@ public enum PureXML { /// Streaming pattern types (the libxml2 `pattern.h` XPath subset). public enum Pattern {} + + /// XPointer fragment-identifier types (the `element()` and `xpointer()` + /// schemes over XPath). + public enum XPointer {} } diff --git a/Sources/XPointer/Pointer.swift b/Sources/XPointer/Pointer.swift new file mode 100644 index 0000000..e724a1f --- /dev/null +++ b/Sources/XPointer/Pointer.swift @@ -0,0 +1,158 @@ +/// Scans an XPointer into its scheme parts, each already translated to an XPath +/// expression. File-scope and private: an internal detail of +/// ``PureXML/XPointer/Pointer``. +private struct PointerScanner { + typealias XPointerError = PureXML.XPointer.XPointerError + + private let chars: [Character] + private var index = 0 + + private init(_ pointer: String) { + chars = Array(pointer) + } + + static func parts(of pointer: String) throws -> [String] { + let trimmed = pointer.trimmingXMLWhitespace() + guard !trimmed.isEmpty else { throw XPointerError.empty } + // A bare name with no scheme parentheses is a shorthand pointer: id(name). + if !trimmed.contains("(") { + return ["id('\(trimmed)')"] + } + var scanner = PointerScanner(trimmed) + return try scanner.scanSchemes() + } + + private mutating func scanSchemes() throws -> [String] { + var parts: [String] = [] + while true { + skipSpace() + guard !isAtEnd else { return parts } + let scheme = scanName() + guard !scheme.isEmpty, consume("(") else { throw XPointerError.malformed } + let data = try scanData() + try parts.append(translate(scheme: scheme, data: data)) + } + } + + /// Reads the balanced parenthesized data after `(`, honoring quotes, and + /// consumes the closing `)`. + private mutating func scanData() throws -> String { + var depth = 1 + var data = "" + var quote: Character? + while let character = peek() { + advance() + if let active = quote { + if character == active { quote = nil } + data.append(character) + continue + } + switch character { + case "'", "\"": + quote = character + case "(": + depth += 1 + case ")": + depth -= 1 + if depth == 0 { return data } + default: + break + } + data.append(character) + } + throw XPointerError.malformed + } + + private func translate(scheme: String, data: String) throws -> String { + switch scheme { + case "xpointer": data + case "element": Self.elementToXPath(data) + default: throw XPointerError.unknownScheme(scheme) + } + } + + /// Translates an `element()` scheme datum (`id/1/2`, `/1/2`, `id`) into the + /// equivalent XPath: an id() start or the document root, then child-element + /// positions. + private static func elementToXPath(_ data: String) -> String { + let segments = data.split(separator: "/", omittingEmptySubsequences: false) + var result = "" + for (offset, segment) in segments.enumerated() { + if offset == 0 { + result = segment.isEmpty ? "" : "id('\(segment)')" + } else { + result += "/*[\(segment)]" + } + } + return result.isEmpty ? "/" : result + } + + private var isAtEnd: Bool { + index >= chars.count + } + + private func peek() -> Character? { + index < chars.count ? chars[index] : nil + } + + private mutating func scanName() -> String { + var name = "" + while let character = peek(), character.isXMLNameContinuation { + name.append(character) + advance() + } + return name + } + + @discardableResult + private mutating func consume(_ character: Character) -> Bool { + guard peek() == character else { return false } + advance() + return true + } + + private mutating func advance() { + if index < chars.count { index += 1 } + } + + private mutating func skipSpace() { + while let character = peek(), character == " " || character == "\t" || character == "\n" || character == "\r" { + advance() + } + } +} + +public extension PureXML.XPointer { + /// A compiled XPointer over the `element()` and `xpointer()` schemes, plus the + /// shorthand bare-name form (`name`, equivalent to `id('name')`). A pointer + /// may chain several scheme parts; they are tried in order and the first that + /// selects a non-empty node-set wins (the XPointer fallback rule). + /// + /// `element(id/2/3)` navigates by child-element position from an id (or the + /// document root for a leading `/`); `xpointer(expr)` evaluates a full XPath + /// expression. Reimplemented over ``PureXML/XPath``. + struct Pointer: Sendable { + private let parts: [String] + + /// Compiles an XPointer string. + public init(_ pointer: String) throws { + parts = try PointerScanner.parts(of: pointer) + } + + /// Evaluates the pointer over a node, returning the first scheme part's + /// non-empty selection in document order, or an empty list. + public func evaluate(over node: PureXML.Model.Node) -> [PureXML.XPath.Selection] { + for part in parts { + guard let query = try? PureXML.XPath.Query(part) else { continue } + let result = query.evaluate(over: node) + if !result.isEmpty { return result } + } + return [] + } + } + + /// Compiles and evaluates an XPointer over a node in one step. + static func evaluate(_ pointer: String, over node: PureXML.Model.Node) throws -> [PureXML.XPath.Selection] { + try Pointer(pointer).evaluate(over: node) + } +} diff --git a/Sources/XPointer/XPointerError.swift b/Sources/XPointer/XPointerError.swift new file mode 100644 index 0000000..5e6587d --- /dev/null +++ b/Sources/XPointer/XPointerError.swift @@ -0,0 +1,16 @@ +public extension PureXML.XPointer { + /// An error parsing an XPointer. + enum XPointerError: Swift.Error, Equatable, Sendable, CustomStringConvertible { + case empty + case malformed + case unknownScheme(String) + + public var description: String { + switch self { + case .empty: "the XPointer is empty" + case .malformed: "the XPointer is malformed" + case let .unknownScheme(name): "unknown XPointer scheme '\(name)'" + } + } + } +} diff --git a/Tests/XPointerTests.swift b/Tests/XPointerTests.swift new file mode 100644 index 0000000..240b31c --- /dev/null +++ b/Tests/XPointerTests.swift @@ -0,0 +1,64 @@ +@testable import PureXML +import Testing + +@Suite("XPointer") +struct XPointerTests { + private func doc() throws -> PureXML.Model.Node { + try PureXML.parse( + "" + + "firstsecond" + + "third" + + "", + ) + } + + private func strings(_ pointer: String) throws -> [String] { + try PureXML.XPointer.evaluate(pointer, over: doc()).map(\.stringValue) + } + + @Test("A shorthand pointer resolves an id") + func test_shorthand() throws { + #expect(try strings("intro") == ["firstsecond"]) + } + + @Test("element() navigates child positions from an id") + func test_elementFromId() throws { + #expect(try strings("element(intro/2)") == ["second"]) + #expect(try strings("element(intro/1)") == ["first"]) + } + + @Test("element() navigates from the document root") + func test_elementFromRoot() throws { + #expect(try strings("element(/1/2)") == ["third"]) + #expect(try strings("element(/1)") == ["firstsecondthird"]) + } + + @Test("xpointer() evaluates a full XPath expression") + func test_xpointerScheme() throws { + #expect(try strings("xpointer(//para[2])") == ["second"]) + #expect(try strings("xpointer(//chapter[@id='main']/para)") == ["third"]) + } + + @Test("Scheme parts fall back to the first non-empty result") + func test_fallback() throws { + // The first part selects nothing (no such id), so the second part wins. + #expect(try strings("element(nope/1)element(main/1)") == ["third"]) + } + + @Test("A malformed pointer is rejected") + func test_malformed() { + #expect(throws: PureXML.XPointer.XPointerError.self) { + _ = try PureXML.XPointer.Pointer("xpointer(") + } + #expect(throws: PureXML.XPointer.XPointerError.self) { + _ = try PureXML.XPointer.Pointer("bogus(/1)") + } + } + + @Test("An empty pointer is rejected") + func test_empty() { + #expect(throws: PureXML.XPointer.XPointerError.empty) { + _ = try PureXML.XPointer.Pointer(" ") + } + } +}