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
This commit is contained in:
parent
ab7d924901
commit
627c035c2a
@ -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
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
158
Sources/XPointer/Pointer.swift
Normal file
158
Sources/XPointer/Pointer.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
16
Sources/XPointer/XPointerError.swift
Normal file
16
Sources/XPointer/XPointerError.swift
Normal file
@ -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)'"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
Tests/XPointerTests.swift
Normal file
64
Tests/XPointerTests.swift
Normal file
@ -0,0 +1,64 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("XPointer")
|
||||
struct XPointerTests {
|
||||
private func doc() throws -> PureXML.Model.Node {
|
||||
try PureXML.parse(
|
||||
"<book>"
|
||||
+ "<chapter id=\"intro\"><para>first</para><para>second</para></chapter>"
|
||||
+ "<chapter id=\"main\"><para>third</para></chapter>"
|
||||
+ "</book>",
|
||||
)
|
||||
}
|
||||
|
||||
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(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user