After #302 cached the document node list, the following and preceding axes still materialized the entire following/preceding span (Array(slice), one Node copy with a slow side-table retain per node) before the step's node test filtered it. For a rare-target query that copy is almost entirely waste. Fuse the node test into the walk: apply the test to the cached document-order slice directly, so a node the test rejects is never copied out of the shared list, only the matches are materialized. The selected nodes and their order are unchanged (the test is the same compiled node test the general axis path used). The 200-context following:: query over a 20k-element document drops from ~9s (post-#302) to ~6s, ~2.6x below the original ~16s. The full suite, the W3C XPath/XSLT and Apache Xalan corpora, and the WASM build stay green; FollowingPrecedingAxisTests gains a fused-name-test case.
335 lines
16 KiB
Swift
335 lines
16 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 = matchedAxisNodes(step, from: contextNode, context)
|
|
return try applyPredicates(step.predicates, to: matched, context)
|
|
}
|
|
|
|
/// The axis nodes that pass the step's node test. For the descendant family
|
|
/// over a tree context the test is fused into the traversal so a node the
|
|
/// test rejects is never wrapped (and so never retained); every other axis
|
|
/// builds the axis node list and filters it.
|
|
private static func matchedAxisNodes(_ step: Step, from contextNode: Node, _ context: EvaluationContext) -> [Node] {
|
|
if case let .tree(tree) = contextNode {
|
|
switch step.axis {
|
|
case .descendant:
|
|
let keep = compiledTreeTest(step.test, on: step.axis, context.namespaces)
|
|
return PureXML.XPath.AxisNavigation.descendants(of: tree, where: keep)
|
|
case .descendantOrSelf:
|
|
let keep = compiledTreeTest(step.test, on: step.axis, context.namespaces)
|
|
return PureXML.XPath.AxisNavigation.descendantsOrSelf(of: tree, where: keep)
|
|
case .attribute:
|
|
let keep = compiledAttributeTest(step.test, context.namespaces)
|
|
return PureXML.XPath.AxisNavigation.attributes(of: tree, where: keep)
|
|
default: break
|
|
}
|
|
}
|
|
let keep = compiledNodeTest(step.test, on: step.axis, context.namespaces)
|
|
let cache = context.environment.documentNavigation
|
|
// The following and preceding axes fuse the node test into the walk so a
|
|
// rejected node is never copied out of the cached document node list; the
|
|
// other axes build their list and filter it.
|
|
switch step.axis {
|
|
case .following:
|
|
return PureXML.XPath.AxisNavigation.following(of: contextNode, cache: cache, where: keep)
|
|
case .preceding:
|
|
return PureXML.XPath.AxisNavigation.preceding(of: contextNode, cache: cache, where: keep)
|
|
default:
|
|
return PureXML.XPath.AxisNavigation.nodes(on: step.axis, from: contextNode, cache: cache).filter(keep)
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
if case let .tree(tree) = node {
|
|
return matchesTree(tree, test, on: axis, namespaces)
|
|
}
|
|
// Attribute and namespace nodes: a name or wildcard test selects them
|
|
// (by the axis's principal kind), `node()` matches anything, and the
|
|
// kind tests (text/comment/processing-instruction) never match a node
|
|
// that is not a tree node.
|
|
switch test {
|
|
case let .name(name): return nameMatches(node, name, axis.principalKind, namespaces)
|
|
case .wildcard: return wildcardMatches(node, axis.principalKind)
|
|
case .node: return true
|
|
case .text, .comment, .processingInstruction: return false
|
|
}
|
|
}
|
|
|
|
/// The node test applied directly to an attribute, the single-node core of
|
|
/// ``matches`` for an `.attribute` node on the attribute axis (whose
|
|
/// principal kind is always attribute). Taking the raw ``Attribute`` lets the
|
|
/// attribute axis test a candidate without wrapping (and so retaining) it, so
|
|
/// an attribute the test rejects is never materialized. A name test matches
|
|
/// by qualified name; a wildcard or `node()` matches any attribute; the kind
|
|
/// tests never match an attribute.
|
|
static func matchesAttribute(_ attribute: PureXML.Model.Attribute, _ test: NodeTest, _ namespaces: [String: String]) -> Bool {
|
|
switch test {
|
|
case let .name(name): qualifiedMatches(attribute.name, name, namespaces)
|
|
case .wildcard, .node: true
|
|
case .text, .comment, .processingInstruction: false
|
|
}
|
|
}
|
|
|
|
/// Compiles a tree-node test into a per-node predicate, resolving everything
|
|
/// that is invariant across a traversal (the axis principal kind, whether
|
|
/// namespace bindings are in effect, the prefix-wildcard shape of the test
|
|
/// name) exactly once instead of on every node. A descendant walk over a
|
|
/// wide subtree applies the returned closure tens of thousands of times, so
|
|
/// hoisting the test's fixed structure out of the per-node path is the bulk
|
|
/// of the saving. The closure is equivalent to ``matchesTree`` for the same
|
|
/// arguments, the one source of truth it specializes.
|
|
static func compiledTreeTest(
|
|
_ test: NodeTest,
|
|
on axis: Axis,
|
|
_ namespaces: [String: String],
|
|
) -> (PureXML.Model.TreeNode) -> Bool {
|
|
switch test {
|
|
case let .name(name):
|
|
guard axis.principalKind == .element else { return { _ in false } }
|
|
// No eval-time bindings and no prefix wildcard: the match is the
|
|
// plain in-document-string comparison, with no per-node namespace or
|
|
// suffix work.
|
|
if namespaces.isEmpty, !name.hasSuffix(":*") {
|
|
return { tree in
|
|
tree.kind == .element && tree.name.map { $0.description == name || $0.localName == name } == true
|
|
}
|
|
}
|
|
return { tree in
|
|
tree.kind == .element && tree.name.map { qualifiedMatches($0, name, namespaces) } == true
|
|
}
|
|
case .wildcard:
|
|
guard axis.principalKind == .element else { return { _ in false } }
|
|
return { $0.kind == .element }
|
|
case .text:
|
|
return { $0.kind == .text || $0.kind == .cdata }
|
|
case .node:
|
|
return { _ in true }
|
|
case .comment:
|
|
return { $0.kind == .comment }
|
|
case let .processingInstruction(target):
|
|
return { tree in
|
|
tree.kind == .processingInstruction && (target == nil || tree.name?.description == target)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compiles an attribute-node test into a per-attribute predicate, hoisting
|
|
/// the same invariants as ``compiledTreeTest`` (the binding state and the
|
|
/// prefix-wildcard shape) out of the per-attribute path. Equivalent to
|
|
/// ``matchesAttribute`` for the same arguments.
|
|
static func compiledAttributeTest(
|
|
_ test: NodeTest,
|
|
_ namespaces: [String: String],
|
|
) -> (PureXML.Model.Attribute) -> Bool {
|
|
switch test {
|
|
case let .name(name):
|
|
if namespaces.isEmpty, !name.hasSuffix(":*") {
|
|
return { $0.name.description == name || $0.name.localName == name }
|
|
}
|
|
return { qualifiedMatches($0.name, name, namespaces) }
|
|
case .wildcard, .node:
|
|
return { _ in true }
|
|
case .text, .comment, .processingInstruction:
|
|
return { _ in false }
|
|
}
|
|
}
|
|
|
|
/// Compiles a general node test (any node kind) into a per-node predicate,
|
|
/// for the axes that build a node list and filter it. A tree node is tested
|
|
/// by the compiled tree test; an attribute or namespace node keeps the
|
|
/// ``matches`` behavior. Equivalent to ``matches`` for the same arguments.
|
|
static func compiledNodeTest(
|
|
_ test: NodeTest,
|
|
on axis: Axis,
|
|
_ namespaces: [String: String],
|
|
) -> (Node) -> Bool {
|
|
let treeTest = compiledTreeTest(test, on: axis, namespaces)
|
|
switch test {
|
|
case let .name(name):
|
|
let kind = axis.principalKind
|
|
return { node in
|
|
if case let .tree(tree) = node { return treeTest(tree) }
|
|
return nameMatches(node, name, kind, namespaces)
|
|
}
|
|
case .wildcard:
|
|
let kind = axis.principalKind
|
|
return { node in
|
|
if case let .tree(tree) = node { return treeTest(tree) }
|
|
return wildcardMatches(node, kind)
|
|
}
|
|
case .node:
|
|
return { _ in true }
|
|
case .text, .comment, .processingInstruction:
|
|
return { node in
|
|
if case let .tree(tree) = node { return treeTest(tree) }
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The node test applied directly to a tree node, the single-node core of
|
|
/// ``matches`` for a `.tree` node. Taking the ``TreeNode`` rather than a
|
|
/// wrapped ``Node`` lets the descendant traversal test a candidate without
|
|
/// first wrapping (and so retaining) it, so a node the test rejects is never
|
|
/// retained. ``matches`` delegates here for every tree node, keeping one
|
|
/// source of truth.
|
|
static func matchesTree(_ tree: PureXML.Model.TreeNode, _ test: NodeTest, on axis: Axis, _ namespaces: [String: String]) -> Bool {
|
|
switch test {
|
|
case let .name(name):
|
|
guard axis.principalKind == .element, tree.kind == .element, let qualified = tree.name else { return false }
|
|
return qualifiedMatches(qualified, name, namespaces)
|
|
case .wildcard:
|
|
return axis.principalKind == .element && tree.kind == .element
|
|
case .text:
|
|
return tree.kind == .text || tree.kind == .cdata
|
|
case .node:
|
|
return true
|
|
case .comment:
|
|
return tree.kind == .comment
|
|
case let .processingInstruction(target):
|
|
guard tree.kind == .processingInstruction else { return false }
|
|
return target == nil || tree.name?.description == 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)
|
|
}
|
|
}
|