PureXML/Sources/XPath/CompilerDescendantFusion.swift
Mihaela Mihaljevic b35f4a17ef
perf(xpath): fuse // into a descendant step for non-positional predicates (#281)
`//X` compiles to `descendant-or-self::node()/child::X`, which materializes
every node of the subtree as an intermediate context before selecting the X
children of each. Fuse that pair into a single `descendant::X` step at compile
time: the two select the same node-set, so evaluation skips the intermediate.

The fusion is transparent except for a positional predicate: `//x[1]` is the
first x child of EACH parent, not the first x in document order, so it must
not become `descendant::x[1]`. Apply the rewrite only when every predicate is
provably non-positional: its value is a boolean, node-set, or string (never a
number, which XPath reads as position() = value) and it nowhere references
position() or last(). Anything not provably both is left unfused, so a result
can never change.

Measured xpath ~10x to ~7x slower than libxml2 on the 20k-item generated
corpus via a same-state A/B, identical results. XPathDescendantFusionTests
pins the positional forms keeping their per-parent meaning, the non-positional
forms selecting across the subtree, and composition with trailing and nested
steps. An adversarial differential review (fused versus a spec-literal unfused
reference over nine attack suites) found no query whose result changes. The
boolean-function allowlist carries a documented invariant: every entry must be
position- and size-independent, since the position scan is syntactic.
2026-06-25 03:21:03 +02:00

113 lines
5.6 KiB
Swift

extension PureXML.XPath.Compiler {
/// Fuses each `descendant-or-self::node()` step immediately followed by a
/// `child::X` step into a single `descendant::X` step. This is the standard
/// `//` rewrite: `descendant-or-self::node()/child::X` selects exactly the
/// `X` descendants of the context, the same node-set as `descendant::X`, but
/// without materializing every node of the subtree as an intermediate
/// context. The compiled step list is rewritten once, so every evaluation of
/// the query benefits.
///
/// The rewrite is applied only when the child step's predicates are
/// non-positional. A positional predicate filters relative to each parent's
/// own child list (`//x[1]` is the first `x` child of EACH element, not the
/// first `x` in document order), which the `descendant` axis cannot
/// reproduce, so such a pair is left exactly as the grammar produced it.
static func fuseDescendantSteps(_ steps: [Step]) -> [Step] {
guard steps.count >= 2 else { return steps }
var result: [Step] = []
result.reserveCapacity(steps.count)
var index = 0
while index < steps.count {
let step = steps[index]
if index + 1 < steps.count, isDescendantOrSelfNode(step) {
let child = steps[index + 1]
if child.axis == .child, child.predicates.allSatisfy(isNonPositionalPredicate) {
result.append(Step(axis: .descendant, test: child.test, predicates: child.predicates))
index += 2
continue
}
}
result.append(step)
index += 1
}
return result
}
/// The bare `descendant-or-self::node()` step that `//` compiles to.
private static func isDescendantOrSelfNode(_ step: Step) -> Bool {
step.axis == .descendantOrSelf && step.test == .node && step.predicates.isEmpty
}
/// Whether `predicate` is safe to carry from a `child` axis onto a
/// `descendant` axis. Safe exactly when it is not a positional filter: its
/// value is provably a boolean, node-set, or string (never a number, which
/// XPath reads as `position() = value`), AND it nowhere references
/// `position()` or `last()` (so `[position() < 3]`, a boolean, is still
/// excluded). Anything not provably both is treated as positional and left
/// unfused, so the optimization can never change a result.
private static func isNonPositionalPredicate(_ predicate: Expression) -> Bool {
isBooleanValued(predicate) && !referencesPosition(predicate)
}
/// Whether the expression's value is provably a boolean, node-set, or string
/// (never a number). A relational or logical operator and the known
/// boolean-returning functions yield booleans; a path, filter, or union
/// yields a node-set; a literal string yields a string. A number, a
/// variable (type unknown at compile time), a unary minus, an arithmetic
/// operator, and an unrecognized function are not provably non-numeric.
private static func isBooleanValued(_ expression: Expression) -> Bool {
switch expression {
case let .binary(binaryOperator, _, _):
switch binaryOperator {
case .logicalOr, .logicalAnd, .equal, .notEqual, .lessThan, .lessOrEqual, .greaterThan, .greaterOrEqual:
true
case .add, .subtract, .multiply, .divide, .modulo:
false
}
case .union, .path, .filter, .string:
true
case let .function(name, _):
booleanReturningFunctions.contains(name)
case .number, .variable, .negate:
false
}
}
/// The built-in functions that return a boolean (so a predicate calling one
/// is a boolean test, never a position test). INVARIANT: every name here must
/// be position- and size-independent. `referencesPosition` scans only for the
/// `position()`/`last()` syntax, so a function that reads the context position
/// or size INTERNALLY (without taking it as a visible argument) would defeat
/// the guard and let a positional predicate ride the descendant axis. Do not
/// add such a function to this set.
private static let booleanReturningFunctions: Set<String> = [
"boolean", "not", "true", "false", "lang", "contains", "starts-with",
]
/// Whether the expression references `position()` or `last()` anywhere,
/// scanned conservatively through every sub-expression (including nested
/// path and filter predicates, whose own context the functions actually
/// bind to). Over-inclusive on purpose: a false positive only declines a
/// fusion, never produces a wrong result.
private static func referencesPosition(_ expression: Expression) -> Bool {
switch expression {
case let .function(name, arguments):
name == "position" || name == "last" || arguments.contains(where: referencesPosition)
case let .binary(_, lhs, rhs):
referencesPosition(lhs) || referencesPosition(rhs)
case let .negate(operand):
referencesPosition(operand)
case let .union(lhs, rhs):
referencesPosition(lhs) || referencesPosition(rhs)
case let .path(_, steps):
steps.contains { $0.predicates.contains(where: referencesPosition) }
case let .filter(primary, predicates, steps):
referencesPosition(primary)
|| predicates.contains(where: referencesPosition)
|| steps.contains { $0.predicates.contains(where: referencesPosition) }
case .number, .string, .variable:
false
}
}
}