feat(xpath): add an XPath 1.0 location-path subset
Add PureXML.XPath: a compiled location-path query engine reimplemented from the XPath 1.0 specification; no upstream code copied. - Compiler parses a path into steps; Evaluator walks the tree returning a Selection node-set (node or attribute) in document order. - Forward axes only (child, descendant //, self ., attribute @), since the value model has no parent pointers. Node tests: name, *, text(), node(), comment(). Predicates: [n], [@a], [@a='v'], [child], [child='v']. - Public surface: XPath.Query (init compiles, evaluate/elements/strings), the Selection enum (element / stringValue), QueryError, and the one-shot PureXML.xpath(_:over:). Upward/sibling axes and the full expression language are explicitly out of scope. - 12 XPath tests; 61 total.
This commit is contained in:
parent
01a35cc6e6
commit
1ae6680d94
@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- XPath query support (a practical subset). `PureXML.XPath.Query` compiles a
|
||||
location path and evaluates it over a node; `PureXML.xpath(_:over:)` is the
|
||||
one-shot convenience. Supports the forward axes (child, descendant `//`, self
|
||||
`.`, attribute `@`), the node tests (name, `*`, `text()`, `node()`,
|
||||
`comment()`), and the predicates `[n]`, `[@a]`, `[@a='v']`, `[child]`,
|
||||
`[child='v']`. Returns a `Selection` node-set with `element` and `stringValue`
|
||||
accessors. Upward/sibling axes and the full expression language are out of
|
||||
scope (the model has no parent pointers).
|
||||
- Optional DTD with internal general entities, off by default. With
|
||||
`Limits(allowDoctype: true)` the parser reads a `<!DOCTYPE>` internal subset,
|
||||
honors internal `<!ENTITY name "value">` declarations, and expands them in text
|
||||
|
||||
@ -57,7 +57,7 @@ flowchart TB
|
||||
ByteStream["Streaming byte decode"]:::done
|
||||
DTD["Optional DTD + entities"]:::done
|
||||
Validation["Schema validation"]:::todo
|
||||
XPath["XPath 1.0"]:::todo
|
||||
XPath["XPath 1.0 subset"]:::done
|
||||
Model --> Stream
|
||||
Stream --> Escape
|
||||
Escape --> Limits
|
||||
|
||||
@ -64,6 +64,12 @@ public extension PureXML {
|
||||
return Parsing.EventReader(pulling: { decoder.next() }, limits: limits)
|
||||
}
|
||||
|
||||
/// Compiles and evaluates an XPath query over a node, returning the selected
|
||||
/// node-set. Compile once with ``XPath/Query`` to reuse a query.
|
||||
static func xpath(_ path: String, over node: Model.Node) throws -> [XPath.Selection] {
|
||||
try XPath.Query(path).evaluate(over: node)
|
||||
}
|
||||
|
||||
/// Serializes a ``Model/Node`` tree into XML with the selected options.
|
||||
static func serialize(
|
||||
_ node: Model.Node,
|
||||
|
||||
@ -20,4 +20,7 @@ public enum PureXML {
|
||||
|
||||
/// XML stream types.
|
||||
public enum Stream {}
|
||||
|
||||
/// XPath query types.
|
||||
public enum XPath {}
|
||||
}
|
||||
|
||||
15
Sources/XPath/Axis.swift
Normal file
15
Sources/XPath/Axis.swift
Normal file
@ -0,0 +1,15 @@
|
||||
extension PureXML.XPath {
|
||||
/// The supported XPath axes. The model is a value tree without parent
|
||||
/// pointers, so only the forward axes are available; upward axes (parent,
|
||||
/// ancestor) and sibling axes are intentionally out of scope.
|
||||
enum Axis: Equatable {
|
||||
/// Immediate children (the default axis).
|
||||
case child
|
||||
/// All descendants at any depth (reached with `//`).
|
||||
case descendant
|
||||
/// The context node itself (`.`).
|
||||
case selfNode
|
||||
/// Attributes of the context element (`@`).
|
||||
case attribute
|
||||
}
|
||||
}
|
||||
192
Sources/XPath/Compiler.swift
Normal file
192
Sources/XPath/Compiler.swift
Normal file
@ -0,0 +1,192 @@
|
||||
extension PureXML.XPath {
|
||||
/// Compiles an XPath location-path string (the supported subset) into a list
|
||||
/// of steps. A small recursive-descent parser over the characters.
|
||||
struct Compiler {
|
||||
private let chars: [Character]
|
||||
private var index = 0
|
||||
|
||||
private init(_ path: String) {
|
||||
chars = Array(path)
|
||||
}
|
||||
|
||||
static func compile(_ path: String) throws -> (absolute: Bool, steps: [Step]) {
|
||||
var compiler = Compiler(path)
|
||||
return try compiler.parse()
|
||||
}
|
||||
|
||||
private mutating func parse() throws -> (absolute: Bool, steps: [Step]) {
|
||||
skipSpace()
|
||||
guard !isAtEnd else { throw QueryError.empty }
|
||||
var absolute = false
|
||||
var steps: [Step] = []
|
||||
|
||||
if peek() == "/" {
|
||||
absolute = true
|
||||
advance()
|
||||
if peek() == "/" {
|
||||
advance()
|
||||
try steps.append(parseStep(axisOverride: .descendant))
|
||||
} else if isAtEnd {
|
||||
return (true, [])
|
||||
} else {
|
||||
try steps.append(parseStep(axisOverride: nil))
|
||||
}
|
||||
} else {
|
||||
try steps.append(parseStep(axisOverride: nil))
|
||||
}
|
||||
|
||||
while true {
|
||||
skipSpace()
|
||||
guard peek() == "/" else {
|
||||
if isAtEnd { break }
|
||||
throw QueryError.unexpectedToken(String(peek() ?? " "))
|
||||
}
|
||||
advance()
|
||||
if peek() == "/" {
|
||||
advance()
|
||||
try steps.append(parseStep(axisOverride: .descendant))
|
||||
} else {
|
||||
try steps.append(parseStep(axisOverride: nil))
|
||||
}
|
||||
}
|
||||
return (absolute, steps)
|
||||
}
|
||||
|
||||
private mutating func parseStep(axisOverride: Axis?) throws -> Step {
|
||||
skipSpace()
|
||||
if peek() == "." {
|
||||
advance()
|
||||
if peek() == "." {
|
||||
throw QueryError.unsupportedAxis("..")
|
||||
}
|
||||
return try Step(axis: .selfNode, test: .node, predicates: parsePredicates())
|
||||
}
|
||||
var axis = axisOverride ?? .child
|
||||
if peek() == "@" {
|
||||
advance()
|
||||
axis = .attribute
|
||||
}
|
||||
let test = try parseNodeTest()
|
||||
return try Step(axis: axis, test: test, predicates: parsePredicates())
|
||||
}
|
||||
|
||||
private mutating func parseNodeTest() throws -> NodeTest {
|
||||
if peek() == "*" {
|
||||
advance()
|
||||
return .wildcard
|
||||
}
|
||||
let name = parseName()
|
||||
guard !name.isEmpty else { throw QueryError.expectedNodeTest }
|
||||
if peek() == "(" {
|
||||
advance()
|
||||
skipSpace()
|
||||
guard peek() == ")" else { throw QueryError.unexpectedToken(String(peek() ?? " ")) }
|
||||
advance()
|
||||
switch name {
|
||||
case "text": return .text
|
||||
case "node": return .node
|
||||
case "comment": return .comment
|
||||
default: throw QueryError.unexpectedToken("\(name)()")
|
||||
}
|
||||
}
|
||||
return .name(name)
|
||||
}
|
||||
|
||||
private mutating func parsePredicates() throws -> [Predicate] {
|
||||
var predicates: [Predicate] = []
|
||||
while peek() == "[" {
|
||||
advance()
|
||||
try predicates.append(parsePredicate())
|
||||
}
|
||||
return predicates
|
||||
}
|
||||
|
||||
private mutating func parsePredicate() throws -> Predicate {
|
||||
skipSpace()
|
||||
let predicate: Predicate
|
||||
if let digit = peek(), digit.isNumber {
|
||||
predicate = .position(parseNumber())
|
||||
} else if peek() == "@" {
|
||||
advance()
|
||||
let name = parseName()
|
||||
predicate = try parseEquality(
|
||||
makeEquals: { .attributeEquals(name: name, value: $0) },
|
||||
makeExists: { .hasAttribute(name) },
|
||||
)
|
||||
} else {
|
||||
let name = parseName()
|
||||
guard !name.isEmpty else { throw QueryError.unsupportedPredicate("empty") }
|
||||
predicate = try parseEquality(
|
||||
makeEquals: { .childEquals(name: name, value: $0) },
|
||||
makeExists: { .hasChild(name) },
|
||||
)
|
||||
}
|
||||
skipSpace()
|
||||
guard peek() == "]" else { throw QueryError.unterminatedPredicate }
|
||||
advance()
|
||||
return predicate
|
||||
}
|
||||
|
||||
private mutating func parseEquality(
|
||||
makeEquals: (String) -> Predicate,
|
||||
makeExists: () -> Predicate,
|
||||
) throws -> Predicate {
|
||||
skipSpace()
|
||||
guard peek() == "=" else { return makeExists() }
|
||||
advance()
|
||||
skipSpace()
|
||||
return try makeEquals(parseLiteral())
|
||||
}
|
||||
|
||||
private mutating func parseLiteral() throws -> String {
|
||||
guard let quote = peek(), quote == "\"" || quote == "'" else {
|
||||
throw QueryError.unsupportedPredicate("expected a quoted value")
|
||||
}
|
||||
advance()
|
||||
var value = ""
|
||||
while let character = peek(), character != quote {
|
||||
value.append(character)
|
||||
advance()
|
||||
}
|
||||
guard peek() == quote else { throw QueryError.unsupportedPredicate("unterminated value") }
|
||||
advance()
|
||||
return value
|
||||
}
|
||||
|
||||
private mutating func parseName() -> String {
|
||||
var name = ""
|
||||
while let character = peek(), character.isXMLNameContinuation {
|
||||
name.append(character)
|
||||
advance()
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
private mutating func parseNumber() -> Int {
|
||||
var digits = ""
|
||||
while let character = peek(), character.isNumber {
|
||||
digits.append(character)
|
||||
advance()
|
||||
}
|
||||
return Int(digits) ?? 0
|
||||
}
|
||||
|
||||
private var isAtEnd: Bool {
|
||||
index >= chars.count
|
||||
}
|
||||
|
||||
private func peek() -> Character? {
|
||||
index < chars.count ? chars[index] : nil
|
||||
}
|
||||
|
||||
private mutating func advance() {
|
||||
if index < chars.count { index += 1 }
|
||||
}
|
||||
|
||||
private mutating func skipSpace() {
|
||||
while let character = peek(), character == " " || character == "\t" {
|
||||
advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
Sources/XPath/Evaluator.swift
Normal file
143
Sources/XPath/Evaluator.swift
Normal file
@ -0,0 +1,143 @@
|
||||
extension PureXML.XPath {
|
||||
/// Evaluates compiled steps over a node, returning the selected node-set in
|
||||
/// document order. The provided node is treated as the starting context (and,
|
||||
/// for an absolute path, as the root).
|
||||
enum Evaluator {
|
||||
static func evaluate(
|
||||
steps: [Step],
|
||||
over node: PureXML.Model.Node,
|
||||
) throws -> [Selection] {
|
||||
guard !steps.isEmpty else { return [.node(node)] }
|
||||
var context: [PureXML.Model.Node] = [node]
|
||||
for (offset, step) in steps.enumerated() {
|
||||
if step.axis == .attribute {
|
||||
guard offset == steps.count - 1 else { throw QueryError.attributeStepNotLast }
|
||||
return attributeSelections(step, context: context)
|
||||
}
|
||||
var next: [PureXML.Model.Node] = []
|
||||
for contextNode in context {
|
||||
let candidates = candidateNodes(step, contextNode)
|
||||
next.append(contentsOf: applyPredicates(step.predicates, to: candidates))
|
||||
}
|
||||
context = next
|
||||
}
|
||||
return context.map { .node($0) }
|
||||
}
|
||||
|
||||
private static func candidateNodes(_ step: Step, _ contextNode: PureXML.Model.Node) -> [PureXML.Model.Node] {
|
||||
let pool: [PureXML.Model.Node] = switch step.axis {
|
||||
case .child: children(of: contextNode)
|
||||
case .descendant: subtree(of: contextNode)
|
||||
case .selfNode: [contextNode]
|
||||
case .attribute: []
|
||||
}
|
||||
return pool.filter { matches($0, step.test) }
|
||||
}
|
||||
|
||||
private static func attributeSelections(_ step: Step, context: [PureXML.Model.Node]) -> [Selection] {
|
||||
var result: [Selection] = []
|
||||
for contextNode in context {
|
||||
guard case let .element(element) = contextNode else { continue }
|
||||
var attributes = element.attributes.filter { attributeMatches($0, step.test) }
|
||||
for predicate in step.predicates {
|
||||
if case let .position(index) = predicate {
|
||||
attributes = (index >= 1 && index <= attributes.count) ? [attributes[index - 1]] : []
|
||||
}
|
||||
}
|
||||
result.append(contentsOf: attributes.map { .attribute($0) })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func applyPredicates(
|
||||
_ predicates: [Predicate],
|
||||
to nodes: [PureXML.Model.Node],
|
||||
) -> [PureXML.Model.Node] {
|
||||
var result = nodes
|
||||
for predicate in predicates {
|
||||
switch predicate {
|
||||
case let .position(index):
|
||||
result = (index >= 1 && index <= result.count) ? [result[index - 1]] : []
|
||||
case let .hasAttribute(name):
|
||||
result = result.filter { attributeValue($0, name) != nil }
|
||||
case let .attributeEquals(name, value):
|
||||
result = result.filter { attributeValue($0, name) == value }
|
||||
case let .hasChild(name):
|
||||
result = result.filter { childElement($0, name) != nil }
|
||||
case let .childEquals(name, value):
|
||||
result = result.filter { childElementText($0, name) == value }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func children(of node: PureXML.Model.Node) -> [PureXML.Model.Node] {
|
||||
switch node {
|
||||
case let .element(element): element.children
|
||||
case let .document(nodes): nodes
|
||||
default: []
|
||||
}
|
||||
}
|
||||
|
||||
private static func subtree(of node: PureXML.Model.Node) -> [PureXML.Model.Node] {
|
||||
var result = [node]
|
||||
for child in children(of: node) {
|
||||
result.append(contentsOf: subtree(of: child))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func matches(_ node: PureXML.Model.Node, _ test: NodeTest) -> Bool {
|
||||
switch test {
|
||||
case let .name(name):
|
||||
guard case let .element(element) = node else { return false }
|
||||
return element.name.description == name || element.name.localName == name
|
||||
case .wildcard:
|
||||
if case .element = node { return true }
|
||||
return false
|
||||
case .text:
|
||||
if case .text = node { return true }
|
||||
if case .cdata = node { return true }
|
||||
return false
|
||||
case .node:
|
||||
return true
|
||||
case .comment:
|
||||
if case .comment = node { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func attributeMatches(_ attribute: PureXML.Model.Attribute, _ test: NodeTest) -> Bool {
|
||||
switch test {
|
||||
case let .name(name): attribute.name.description == name || attribute.name.localName == name
|
||||
case .wildcard: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
private static func attributeValue(_ node: PureXML.Model.Node, _ name: String) -> String? {
|
||||
guard case let .element(element) = node else { return nil }
|
||||
return element.attributes.first { $0.name.description == name || $0.name.localName == name }?.value
|
||||
}
|
||||
|
||||
private static func childElementNode(_ node: PureXML.Model.Node, _ name: String) -> PureXML.Model.Node? {
|
||||
guard case let .element(element) = node else { return nil }
|
||||
return element.children.first { child in
|
||||
if case let .element(inner) = child {
|
||||
return inner.name.description == name || inner.name.localName == name
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func childElement(_ node: PureXML.Model.Node, _ name: String) -> PureXML.Model.Element? {
|
||||
guard case let .element(found) = childElementNode(node, name) else { return nil }
|
||||
return found
|
||||
}
|
||||
|
||||
private static func childElementText(_ node: PureXML.Model.Node, _ name: String) -> String? {
|
||||
guard let child = childElementNode(node, name) else { return nil }
|
||||
return Selection.node(child).stringValue
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Sources/XPath/NodeTest.swift
Normal file
15
Sources/XPath/NodeTest.swift
Normal file
@ -0,0 +1,15 @@
|
||||
extension PureXML.XPath {
|
||||
/// The node test of a location step: which nodes on the axis qualify.
|
||||
enum NodeTest: Equatable {
|
||||
/// A local-name match (`book`).
|
||||
case name(String)
|
||||
/// The `*` wildcard (any element, or any attribute on the attribute axis).
|
||||
case wildcard
|
||||
/// `text()`: character-data and CDATA nodes.
|
||||
case text
|
||||
/// `node()`: any node.
|
||||
case node
|
||||
/// `comment()`: comment nodes.
|
||||
case comment
|
||||
}
|
||||
}
|
||||
17
Sources/XPath/Predicate.swift
Normal file
17
Sources/XPath/Predicate.swift
Normal file
@ -0,0 +1,17 @@
|
||||
extension PureXML.XPath {
|
||||
/// A step predicate (`[...]`). A supported, practical subset: a position, an
|
||||
/// attribute existence or equality test, or a child-element existence or
|
||||
/// equality test.
|
||||
enum Predicate: Equatable {
|
||||
/// A one-based position, as in `book[1]`.
|
||||
case position(Int)
|
||||
/// `[@id]`: the context element has the named attribute.
|
||||
case hasAttribute(String)
|
||||
/// `[@id='x']`: the named attribute equals the value.
|
||||
case attributeEquals(name: String, value: String)
|
||||
/// `[title]`: the context element has a child element with the name.
|
||||
case hasChild(String)
|
||||
/// `[title='x']`: a child element with the name has the string value.
|
||||
case childEquals(name: String, value: String)
|
||||
}
|
||||
}
|
||||
35
Sources/XPath/Query.swift
Normal file
35
Sources/XPath/Query.swift
Normal file
@ -0,0 +1,35 @@
|
||||
public extension PureXML.XPath {
|
||||
/// A compiled XPath location-path query over the supported subset: the
|
||||
/// forward axes (child, descendant `//`, self `.`, attribute `@`), the node
|
||||
/// tests (name, `*`, `text()`, `node()`, `comment()`), and the predicates
|
||||
/// `[n]`, `[@a]`, `[@a='v']`, `[child]`, `[child='v']`.
|
||||
///
|
||||
/// Upward and sibling axes, functions, and the full expression language are
|
||||
/// intentionally out of scope. Reimplemented from the XPath 1.0 specification.
|
||||
struct Query: Sendable {
|
||||
let absolute: Bool
|
||||
let steps: [Step]
|
||||
|
||||
/// Compiles an XPath location path, throwing ``QueryError`` on a path
|
||||
/// outside the supported subset.
|
||||
public init(_ path: String) throws {
|
||||
(absolute, steps) = try Compiler.compile(path)
|
||||
}
|
||||
|
||||
/// Evaluates the query over a node, returning the selected node-set in
|
||||
/// document order. The node is the starting context.
|
||||
public func evaluate(over node: PureXML.Model.Node) throws -> [Selection] {
|
||||
try Evaluator.evaluate(steps: steps, over: node)
|
||||
}
|
||||
|
||||
/// Evaluates the query and returns the selected elements only.
|
||||
public func elements(over node: PureXML.Model.Node) throws -> [PureXML.Model.Element] {
|
||||
try evaluate(over: node).compactMap(\.element)
|
||||
}
|
||||
|
||||
/// Evaluates the query and returns the string-value of each selection.
|
||||
public func strings(over node: PureXML.Model.Node) throws -> [String] {
|
||||
try evaluate(over: node).map(\.stringValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Sources/XPath/QueryError.swift
Normal file
26
Sources/XPath/QueryError.swift
Normal file
@ -0,0 +1,26 @@
|
||||
public extension PureXML.XPath {
|
||||
/// An error compiling or evaluating an XPath query in the supported subset.
|
||||
enum QueryError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
case empty
|
||||
case unexpectedToken(String)
|
||||
case expectedNodeTest
|
||||
case unterminatedPredicate
|
||||
case unsupportedPredicate(String)
|
||||
/// An upward or sibling axis was used; only forward axes are supported.
|
||||
case unsupportedAxis(String)
|
||||
/// An attribute step appeared before the end of the path.
|
||||
case attributeStepNotLast
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .empty: "the XPath expression is empty"
|
||||
case let .unexpectedToken(token): "unexpected token '\(token)'"
|
||||
case .expectedNodeTest: "expected a node test"
|
||||
case .unterminatedPredicate: "unterminated predicate"
|
||||
case let .unsupportedPredicate(detail): "unsupported predicate: \(detail)"
|
||||
case let .unsupportedAxis(axis): "unsupported axis: \(axis)"
|
||||
case .attributeStepNotLast: "an attribute step must be the last step"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Sources/XPath/Selection.swift
Normal file
41
Sources/XPath/Selection.swift
Normal file
@ -0,0 +1,41 @@
|
||||
public extension PureXML.XPath {
|
||||
/// A node selected by an XPath query: either a tree node or an attribute
|
||||
/// (attributes are not ``PureXML/Model/Node`` values, so they are surfaced
|
||||
/// here alongside nodes).
|
||||
enum Selection: Equatable, Sendable {
|
||||
case node(PureXML.Model.Node)
|
||||
case attribute(PureXML.Model.Attribute)
|
||||
|
||||
/// The selected element, when this selection is an element node.
|
||||
public var element: PureXML.Model.Element? {
|
||||
guard case let .node(node) = self, case let .element(element) = node else {
|
||||
return nil
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
/// The XPath string-value: an attribute's value, or the concatenation of
|
||||
/// all descendant character data for a node.
|
||||
public var stringValue: String {
|
||||
switch self {
|
||||
case let .attribute(attribute):
|
||||
attribute.value
|
||||
case let .node(node):
|
||||
Self.stringValue(of: node)
|
||||
}
|
||||
}
|
||||
|
||||
private static func stringValue(of node: PureXML.Model.Node) -> String {
|
||||
switch node {
|
||||
case let .text(value), let .cdata(value), let .comment(value):
|
||||
value
|
||||
case let .processingInstruction(_, data):
|
||||
data
|
||||
case let .element(element):
|
||||
element.children.reduce(into: "") { $0 += stringValue(of: $1) }
|
||||
case let .document(children):
|
||||
children.reduce(into: "") { $0 += stringValue(of: $1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Sources/XPath/Step.swift
Normal file
9
Sources/XPath/Step.swift
Normal file
@ -0,0 +1,9 @@
|
||||
extension PureXML.XPath {
|
||||
/// One compiled location step: an axis, a node test, and zero or more
|
||||
/// predicates applied in order.
|
||||
struct Step: Equatable {
|
||||
let axis: Axis
|
||||
let test: NodeTest
|
||||
let predicates: [Predicate]
|
||||
}
|
||||
}
|
||||
95
Tests/XPathTests.swift
Normal file
95
Tests/XPathTests.swift
Normal file
@ -0,0 +1,95 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("XPath")
|
||||
struct XPathTests {
|
||||
private func catalog() throws -> PureXML.Model.Node {
|
||||
try PureXML.parse(
|
||||
"<catalog>"
|
||||
+ "<book id=\"bk101\" lang=\"en\"><title>XML Guide</title><author>Drobnik</author></book>"
|
||||
+ "<book id=\"bk102\"><title>YAML Guide</title></book>"
|
||||
+ "<magazine><title>Wired</title></magazine>"
|
||||
+ "</catalog>",
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Absolute child path selects matching elements")
|
||||
func test_childPath() throws {
|
||||
let books = try PureXML.XPath.Query("/catalog/book").elements(over: catalog())
|
||||
#expect(books.count == 2)
|
||||
#expect(books.first?.attributes.first?.value == "bk101")
|
||||
}
|
||||
|
||||
@Test("Descendant path finds nodes at any depth")
|
||||
func test_descendantPath() throws {
|
||||
let titles = try PureXML.XPath.Query("//title").strings(over: catalog())
|
||||
#expect(titles == ["XML Guide", "YAML Guide", "Wired"])
|
||||
}
|
||||
|
||||
@Test("Positional predicate selects within the parent")
|
||||
func test_positionPredicate() throws {
|
||||
let titles = try PureXML.XPath.Query("/catalog/book[1]/title").strings(over: catalog())
|
||||
#expect(titles == ["XML Guide"])
|
||||
}
|
||||
|
||||
@Test("Attribute-equality predicate filters elements")
|
||||
func test_attributeEqualityPredicate() throws {
|
||||
let titles = try PureXML.XPath.Query("//book[@id='bk102']/title").strings(over: catalog())
|
||||
#expect(titles == ["YAML Guide"])
|
||||
}
|
||||
|
||||
@Test("Attribute step selects attributes")
|
||||
func test_attributeStep() throws {
|
||||
let selections = try PureXML.XPath.Query("/catalog/book/@id").evaluate(over: catalog())
|
||||
#expect(selections.map(\.stringValue) == ["bk101", "bk102"])
|
||||
if case .attribute = selections.first {} else {
|
||||
Issue.record("expected an attribute selection")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Attribute-existence predicate filters elements")
|
||||
func test_attributeExistencePredicate() throws {
|
||||
let books = try PureXML.XPath.Query("//book[@lang]").elements(over: catalog())
|
||||
#expect(books.count == 1)
|
||||
#expect(books.first?.attributes.first?.value == "bk101")
|
||||
}
|
||||
|
||||
@Test("Wildcard selects all child elements")
|
||||
func test_wildcard() throws {
|
||||
let children = try PureXML.XPath.Query("/catalog/*").elements(over: catalog())
|
||||
#expect(children.count == 3)
|
||||
}
|
||||
|
||||
@Test("Child-equality predicate filters by child string value")
|
||||
func test_childEqualityPredicate() throws {
|
||||
let books = try PureXML.XPath.Query("//book[title='YAML Guide']").elements(over: catalog())
|
||||
#expect(books.count == 1)
|
||||
#expect(books.first?.attributes.first?.value == "bk102")
|
||||
}
|
||||
|
||||
@Test("text() selects character data")
|
||||
func test_textNodeTest() throws {
|
||||
let strings = try PureXML.XPath.Query("//author/text()").strings(over: catalog())
|
||||
#expect(strings == ["Drobnik"])
|
||||
}
|
||||
|
||||
@Test("The top-level convenience evaluates a path")
|
||||
func test_topLevelConvenience() throws {
|
||||
let selections = try PureXML.xpath("//magazine/title", over: catalog())
|
||||
#expect(selections.map(\.stringValue) == ["Wired"])
|
||||
}
|
||||
|
||||
@Test("Unsupported upward axis is rejected at compile time")
|
||||
func test_unsupportedAxis() {
|
||||
#expect(throws: PureXML.XPath.QueryError.self) {
|
||||
_ = try PureXML.XPath.Query("/catalog/..")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("An empty path is rejected")
|
||||
func test_emptyPath() {
|
||||
#expect(throws: PureXML.XPath.QueryError.empty) {
|
||||
_ = try PureXML.XPath.Query(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user