PureXML/Sources/XPath/EvaluatorSteps.swift
Mihaela Mihaljevic 2eb3741848
perf(xpath): skip node-set de-duplication where no duplicate can arise (#280)
Each location step accumulated its context nodes' axis results into a
Set<Node> to drop nodes reachable from more than one context, hashing and
copying every node. The wrapped TreeNode is reference-counted, so each copy
is ARC traffic, and the profile showed the Set insert, hashing, and Node
copies dominating evaluation.

De-duplication is needed only when two distinct context nodes can reach the
same node. Skip it on a single context and on the axes whose results are
disjoint per context (child, attribute, namespace, self): those produce the
identical node sequence the Set would, since every insertion succeeds, with
no hashing. Keep the Set on the axes that genuinely overlap (descendant,
ancestor, parent, the siblings, following/preceding). Predicates stay applied
per context and the path result still passes through the final
order-and-unique pass, so results are identical.

Measured xpath ~13x to ~10x slower than libxml2 on the 20k-item generated
corpus via a same-state A/B, ~28% faster, same result. XPathNodeSetDedupTests
pins that a node shared by many contexts through ancestor/parent/descendant
is still counted once, the disjoint axes return every distinct result, and an
overlapping union still de-duplicates. The full XPath suite and the Apache
Xalan XSLT gold-output corpus stay green.
2026-06-25 01:33:50 +02:00

166 lines
7.4 KiB
Swift

extension PureXML.XPath.Evaluator {
typealias Node = PureXML.XPath.Node
typealias Step = PureXML.XPath.Step
typealias NodeTest = PureXML.XPath.NodeTest
typealias Axis = PureXML.XPath.Axis
typealias Expression = PureXML.XPath.Expression
typealias EvaluationContext = PureXML.XPath.EvaluationContext
typealias Value = PureXML.XPath.Value
typealias PrincipalKind = PureXML.XPath.PrincipalKind
/// Walks the steps from a starting node-set, threading each step's result into
/// the next. Predicates run with proximity position within each context node's
/// axis result.
static func evaluateSteps(_ steps: [Step], from start: [Node], _ context: EvaluationContext) throws -> [Node] {
var current = start
for step in steps {
var result: [Node] = []
// Cross-context de-duplication is needed only when two distinct
// context nodes can reach the same node. With at most one context, or
// on an axis whose results are disjoint per context (child, attribute,
// namespace, self), no duplicate can arise, so accumulate directly:
// the `Set` path below produces the identical sequence (every insert
// succeeds) at the cost of hashing and copying every node.
if current.count <= 1 || step.axis.yieldsDisjointResults {
for contextNode in current {
try result.append(contentsOf: stepNodes(step, from: contextNode, context))
try context.checkBudget(result.count)
}
} else {
var seen: Set<Node> = []
for contextNode in current {
for node in try stepNodes(step, from: contextNode, context) where seen.insert(node).inserted {
result.append(node)
}
try context.checkBudget(result.count)
}
}
current = result
}
return current
}
/// The nodes a single `step` selects from one `contextNode`: the axis nodes
/// that pass the node test, then the step's predicates (applied per context,
/// as XPath proximity position requires).
private static func stepNodes(_ step: Step, from contextNode: Node, _ context: EvaluationContext) throws -> [Node] {
let matched = PureXML.XPath.AxisNavigation.nodes(on: step.axis, from: contextNode)
.filter { matches($0, step.test, on: step.axis, namespaces: context.namespaces) }
return try applyPredicates(step.predicates, to: matched, context)
}
/// Filters nodes through each predicate in turn. A numeric predicate is a
/// position test; any other is taken as a boolean.
static func applyPredicates(
_ predicates: [Expression],
to nodes: [Node],
_ context: EvaluationContext,
) throws -> [Node] {
var current = nodes
for predicate in predicates {
var kept: [Node] = []
let size = current.count
for (offset, node) in current.enumerated() {
let focused = context.focused(on: node, position: offset + 1, size: size)
if try keeps(predicate, focused, position: offset + 1) {
kept.append(node)
}
}
current = kept
}
return current
}
private static func keeps(_ predicate: Expression, _ context: EvaluationContext, position: Int) throws -> Bool {
let value = try eval(predicate, context)
if case let .number(number) = value {
return Int(number) == position
}
return value.boolean
}
// MARK: Node tests
static func matches(_ node: Node, _ test: NodeTest, on axis: Axis, namespaces: [String: String] = [:]) -> Bool {
switch test {
case let .name(name):
nameMatches(node, name, axis.principalKind, namespaces)
case .wildcard:
wildcardMatches(node, axis.principalKind)
case .text:
isTreeKind(node, in: [.text, .cdata])
case .node:
true
case .comment:
isTreeKind(node, in: [.comment])
case let .processingInstruction(target):
processingInstructionMatches(node, target)
}
}
private static func nameMatches(_ node: Node, _ name: String, _ kind: PrincipalKind, _ namespaces: [String: String]) -> Bool {
switch kind {
case .element:
guard case let .tree(tree) = node, tree.kind == .element, let qualified = tree.name else {
return false
}
return qualifiedMatches(qualified, name, namespaces)
case .attribute:
guard case let .attribute(_, attribute) = node else { return false }
return qualifiedMatches(attribute.name, name, namespaces)
case .namespace:
guard case let .namespace(_, prefix, _) = node else { return false }
return prefix == name
}
}
/// Matches a node's qualified name against a test name. When an eval-time
/// prefix binding covers the test's prefix, the match is by namespace URI and
/// local name; otherwise it falls back to the in-document prefix string (the
/// behavior when no bindings are supplied).
private static func qualifiedMatches(_ qualified: PureXML.Model.QualifiedName, _ name: String, _ namespaces: [String: String]) -> Bool {
if !namespaces.isEmpty {
if let colon = name.firstIndex(of: ":") {
let prefix = String(name[..<colon])
if let uri = namespaces[prefix] {
let local = String(name[name.index(after: colon)...])
// The NCName:* form matches every name in the namespace.
if local == "*" { return (qualified.namespaceURI ?? "") == uri }
return qualified.localName == local && (qualified.namespaceURI ?? "") == uri
}
} else {
// With bindings supplied, the XPath 1.0 rule applies exactly:
// an unprefixed name test selects the null namespace.
return qualified.localName == name && (qualified.namespaceURI ?? "").isEmpty
}
}
if name.hasSuffix(":*"), let colon = name.firstIndex(of: ":") {
// Without bindings the prefix-wildcard falls back to the
// in-document prefix string, like plain prefixed tests.
return qualified.prefix == String(name[..<colon])
}
return qualified.description == name || qualified.localName == name
}
private static func wildcardMatches(_ node: Node, _ kind: PrincipalKind) -> Bool {
switch kind {
case .element: return isTreeKind(node, in: [.element])
case .attribute: if case .attribute = node { return true }
return false
case .namespace: if case .namespace = node { return true }
return false
}
}
private static func processingInstructionMatches(_ node: Node, _ target: String?) -> Bool {
guard case let .tree(tree) = node, tree.kind == .processingInstruction else { return false }
guard let target else { return true }
return tree.name?.description == target
}
private static func isTreeKind(_ node: Node, in kinds: [PureXML.Model.TreeNodeKind]) -> Bool {
guard case let .tree(tree) = node else { return false }
return kinds.contains(tree.kind)
}
}