PureXML/Sources/XPath/EXSLTFunctions.swift
Mihaela Mihaljevic 4552d24067
fix(xslt): make key(), id(), and EXSLT set functions linear over wide fan-outs (#300)
key(), id(), and EXSLT set:leading/set:trailing sorted their result with
sorted(by: Node.precedes). precedes recomputes both nodes' document-order keys
on every comparison, and each key walks the node to the root taking its index
among each parent's children, a linear scan of a wide parent. Over a wide flat
fan-out the sort was therefore quadratic. key() also deduplicated its matches
with matched.contains(where: ===), a linear membership scan, O(m^2) in the
match count. The EXSLT relative() used the cache-less documentOrder, rescanning
the parent per node.

Sort through sortedByDocumentOrder() (each key computed once behind a shared
sibling-index cache), deduplicate key()'s matches through a Set, and give
relative() a shared cache. The selected nodes, their document order, and the
deduplication are unchanged.

A key()-driven transform over a 20k-element document drops from ~1.15s to
~0.43s (~2.7x). The full suite, the Apache Xalan XSLT gold-output corpus, the
W3C XPath/XSLT/EXSLT tests, and the WASM build stay green; XSLTKeyIdOrderTests
pins key() dedup-and-order and id() document order.
2026-06-25 18:45:08 +02:00

131 lines
6.3 KiB
Swift

extension PureXML.XPath {
/// The EXSLT extension functions (http://exslt.org/...), dispatched by the
/// namespace a function's prefix resolves to: the `math`, `sets`, and the
/// `common` modules. Registered through ``FunctionTable`` so they are
/// available wherever a prefix is bound to an EXSLT namespace.
enum EXSLT {
static let common = "http://exslt.org/common"
static let math = "http://exslt.org/math"
static let sets = "http://exslt.org/sets"
/// The implementation for an EXSLT `local` name in namespace `uri`, or nil.
static func implementation(uri: String, local: String) -> FunctionImplementation? {
switch uri {
case math: mathFunction(local)
case sets: setsFunction(local)
case common: commonFunction(local)
default: nil
}
}
// MARK: common
private static func commonFunction(_ local: String) -> FunctionImplementation? {
guard local == "object-type" else { return nil }
return { arguments, _ in
switch arguments.first {
case .nodeSet: .string("node-set")
case .boolean: .string("boolean")
case .number: .string("number")
case .string, .none: .string("string")
}
}
}
// MARK: math
private static func mathFunction(_ local: String) -> FunctionImplementation? {
switch local {
case "min": { arguments, _ in .number(extremum(arguments.first, isMin: true)) }
case "max": { arguments, _ in .number(extremum(arguments.first, isMin: false)) }
case "highest": { arguments, _ in .nodeSet(extremeNodes(arguments.first, isMin: false)) }
case "lowest": { arguments, _ in .nodeSet(extremeNodes(arguments.first, isMin: true)) }
case "abs": { arguments, _ in .number(abs(arguments.first?.number ?? .nan)) }
case "sqrt": { arguments, _ in .number((arguments.first?.number ?? .nan).squareRoot()) }
default: nil
}
}
/// The minimum or maximum of a node-set's numeric values; NaN for an empty
/// set or any non-numeric node (the EXSLT rule).
private static func extremum(_ value: Value?, isMin: Bool) -> Double {
guard let nodes = value?.nodes, !nodes.isEmpty else { return .nan }
let numbers = nodes.map { PureXML.XPath.Value.parseNumber($0.stringValue) }
if numbers.contains(where: \.isNaN) { return .nan }
return isMin ? numbers.min() ?? .nan : numbers.max() ?? .nan
}
/// The nodes whose numeric value equals the set's maximum (or minimum),
/// in document order; empty when any value is NaN.
private static func extremeNodes(_ value: Value?, isMin: Bool) -> [Node] {
guard let nodes = value?.nodes, !nodes.isEmpty else { return [] }
let target = extremum(value, isMin: isMin)
guard !target.isNaN else { return [] }
return nodes
.filter { PureXML.XPath.Value.parseNumber($0.stringValue) == target }
.sortedByDocumentOrder()
}
// MARK: sets
private static func setsFunction(_ local: String) -> FunctionImplementation? {
switch local {
case "distinct": { arguments, _ in .nodeSet(distinct(arguments.first)) }
case "difference": { arguments, _ in .nodeSet(difference(arguments)) }
case "intersection": { arguments, _ in .nodeSet(intersection(arguments)) }
case "has-same-node": { arguments, _ in .boolean(hasSameNode(arguments)) }
case "leading": { arguments, _ in .nodeSet(relative(arguments, leading: true)) }
case "trailing": { arguments, _ in .nodeSet(relative(arguments, leading: false)) }
default: nil
}
}
private static func distinct(_ value: Value?) -> [Node] {
guard let nodes = value?.nodes else { return [] }
var seen: Set<String> = []
var result: [Node] = []
for node in nodes.sortedByDocumentOrder() where seen.insert(node.stringValue).inserted {
result.append(node)
}
return result
}
private static func difference(_ arguments: [Value]) -> [Node] {
let first = arguments.first?.nodes ?? []
let second = Set(arguments.count > 1 ? arguments[1].nodes ?? [] : [])
return first.filter { !second.contains($0) }.sortedByDocumentOrder()
}
private static func intersection(_ arguments: [Value]) -> [Node] {
let first = arguments.first?.nodes ?? []
let second = Set(arguments.count > 1 ? arguments[1].nodes ?? [] : [])
return first.filter { second.contains($0) }.sortedByDocumentOrder()
}
private static func hasSameNode(_ arguments: [Value]) -> Bool {
let second = Set(arguments.count > 1 ? arguments[1].nodes ?? [] : [])
return (arguments.first?.nodes ?? []).contains { second.contains($0) }
}
/// `set:leading` / `set:trailing`: the nodes of the first set that precede
/// (or follow) the first node, in document order, of the second set.
private static func relative(_ arguments: [Value], leading: Bool) -> [Node] {
let first = arguments.first?.nodes ?? []
guard let pivot = (arguments.count > 1 ? arguments[1].nodes ?? [] : []).firstInDocumentOrder() else {
return []
}
// Decorate once through a shared sibling-index cache: each node's
// order key is computed a single time, and the per-parent index is
// built once rather than rescanned for every node (which made this
// quadratic over a wide fan-out).
let cache = PureXML.XPath.SiblingIndexCache()
let pivotKey = pivot.documentOrder(cache: cache)
return first
.map { (node: $0, key: $0.documentOrder(cache: cache)) }
.filter { leading ? Node.ordered($0.key, before: pivotKey) : Node.ordered(pivotKey, before: $0.key) }
.sorted { Node.ordered($0.key, before: $1.key) }
.map(\.node)
}
}
}