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:
Mihaela Mihaljevic 2026-06-08 01:32:23 +02:00
parent 01a35cc6e6
commit 1ae6680d94
14 changed files with 606 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View 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()
}
}
}
}

View 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
}
}
}

View 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
}
}

View 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
View 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)
}
}
}

View 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"
}
}
}
}

View 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
View 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
View 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(" ")
}
}
}