PureXML/Sources/XPath/Evaluator.swift
Mihaela Mihaljevic 1ae6680d94 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.
2026-06-08 01:32:23 +02:00

144 lines
6.1 KiB
Swift

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