perf(xpath): compile patterns once per run, not per node

XSLT: template selection resolves each match pattern once per transform via
a per-run MatchCache (the pattern's node set is computed once over the source
tree; selection becomes an identity lookup), replacing per-(node, template)
recompilation and full-tree re-walks. XSD identity validation: a per-run query
cache compiles each selector/field XPath once instead of at every visited
element. Schematron was verified to already store compiled queries. Behavior
unchanged (all suites green); a multi-template shape test over 300 nodes
guards the optimized path. MatchCache lives in its own file to keep the
transformer under the length cap.

Closes #112
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 03:24:38 +02:00
parent 38b3f75243
commit 7d3ef26558
6 changed files with 83 additions and 11 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- XPath compilation moved out of the per-node loops (#112): XSLT template selection now resolves each match pattern once per transform through a per-run match cache (the pattern's node set is computed once over the source tree, then membership is an identity lookup), instead of recompiling and re-walking the tree for every (node, template) pair; and the XSD identity validator compiles each selector and field XPath once per run through a query cache instead of once per visited element. Schematron already stored compiled queries. Behavior is unchanged; a multi-template shape test guards the path.
- The HTML document builder's body is now constructed as a live mutable `TreeNode` tree with a single open-elements stack (the HTML5 model), attaching nodes to their parent as they open rather than accumulating children on close (#83, internal). Behavior is unchanged; this is the foundation the adoption agency algorithm needs to reparent already-built subtrees.
- DTD attribute validation is now decomposed into five named, independently composable `Validation` rules (#101): `DTD.requiredAttributes`, `fixedAttributeValues`, `enumeratedAttributeValues`, `tokenizedAttributeTypes`, and `notationAttributes`, replacing the single `attributeDeclarations` rule. Each is removable by identity and isolation-tested one constraint at a time, honoring the validation-framework idiom. Behavior is unchanged. (XSD content validation stays one recursive rule by a documented scope decision, since its constraints are interdependent through type resolution; Schematron already exposes its rules as composable `Validation<Node, Void>` values.)

View File

@ -99,7 +99,7 @@ flowchart TB
PureXML is a working, dependency-free XML library today: parse, emit, validate,
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
currently runs **824 tests in 130 suites** (`swift test`).
currently runs **825 tests in 131 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -1,3 +1,17 @@
/// Compiles each distinct XPath string once per validation run, so a
/// constraint's selector and field queries are not re-parsed at every element
/// the walk visits. A string that fails to compile caches as nil (no match).
private final class XPathQueryCache {
private var queries: [String: PureXML.XPath.Query?] = [:]
func query(_ xpath: String) -> PureXML.XPath.Query? {
if let cached = queries[xpath] { return cached }
let compiled = try? PureXML.XPath.Query(xpath)
queries[xpath] = compiled
return compiled
}
}
extension PureXML.Schema {
/// Validates XSD identity constraints (`unique`, `key`, `keyref`) over an
/// instance document. Each constraint is evaluated at every element whose
@ -8,6 +22,12 @@ extension PureXML.Schema {
struct IdentityValidator {
/// The constraints declared for each element local name.
let constraints: [String: [IdentityConstraint]]
/// Selector and field XPaths compiled once per run, not per element.
private let cache = XPathQueryCache()
init(constraints: [String: [IdentityConstraint]]) {
self.constraints = constraints
}
func validate(
_ root: PureXML.Model.TreeNode,
@ -162,13 +182,13 @@ extension PureXML.Schema {
// MARK: XPath helpers
private func select(_ xpath: String, at node: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {
guard let query = try? PureXML.XPath.Query(xpath) else { return [] }
guard let query = cache.query(xpath) else { return [] }
return query.nodes(over: node)
}
private func fieldTuple(_ fields: [String], at node: PureXML.Model.TreeNode) -> [String?] {
fields.map { field in
guard let query = try? PureXML.XPath.Query(field), let value = try? query.value(at: node) else {
guard let query = cache.query(field), let value = try? query.value(at: node) else {
return nil
}
if let nodes = value.nodes, nodes.isEmpty { return nil }

View File

@ -0,0 +1,26 @@
extension PureXML.XSLT {
/// Caches, per transform, the node set each match pattern selects over the
/// source tree, so a pattern is compiled and evaluated once per document
/// rather than once per (node, template) pair during template selection.
final class MatchCache {
private var matched: [String: Set<ObjectIdentifier>] = [:]
/// The identities of the nodes `pattern` selects over `root`, computed and
/// cached on first use. A branch that fails to compile selects nothing.
func nodes(matching pattern: String, over root: PureXML.Model.TreeNode) -> Set<ObjectIdentifier> {
if let cached = matched[pattern] { return cached }
var result: Set<ObjectIdentifier> = []
for branch in pattern.split(separator: "|") {
let trimmed = branch.trimmingXMLWhitespace()
let path = trimmed.hasPrefix("/") ? trimmed : "//" + trimmed
if let query = try? PureXML.XPath.Query(path) {
for node in query.nodes(over: root) {
result.insert(ObjectIdentifier(node))
}
}
}
matched[pattern] = result
return result
}
}
}

View File

@ -3,6 +3,7 @@
private typealias ResultItem = PureXML.XSLT.ResultItem
private typealias XSLTContext = PureXML.XSLT.XSLTContext
private typealias Termination = PureXML.XSLT.Termination
private typealias MatchCache = PureXML.XSLT.MatchCache
extension PureXML.XSLT {
/// Runs a compiled stylesheet against a source tree, producing a result tree
@ -15,6 +16,7 @@ extension PureXML.XSLT {
let documentLoader: (String) -> String?
private let keyIndex: PureXML.XSLT.KeyIndex
private let termination = Termination()
private let matchCache = MatchCache()
/// The `xsl:message terminate="yes"` text, if one fired during `run()`.
var terminationMessage: String? {
@ -51,14 +53,7 @@ extension PureXML.XSLT {
}
private func matches(_ node: PureXML.Model.TreeNode, _ pattern: String) -> Bool {
for branch in pattern.split(separator: "|") {
let trimmed = branch.trimmingXMLWhitespace()
let path = trimmed.hasPrefix("/") ? trimmed : "//" + trimmed
if let query = try? PureXML.XPath.Query(path), query.nodes(over: root).contains(where: { $0 === node }) {
return true
}
}
return false
matchCache.nodes(matching: pattern, over: root).contains(ObjectIdentifier(node))
}
fileprivate func applyTemplates(

View File

@ -0,0 +1,30 @@
@testable import PureXML
import Testing
@Suite("XSLT match-pattern caching")
struct XSLTMatchCacheTests {
/// Guards the shape #112 optimized: many nodes against several templates,
/// where each match pattern must be compiled and evaluated once per
/// transform (not once per node) and selection must stay correct.
@Test("A multi-template transform over hundreds of nodes selects correctly")
func test_multiTemplateShape() throws {
let rows = (1 ... 300).map { index in
index.isMultiple(of: 2) ? "<even>\(index)</even>" : "<odd>\(index)</odd>"
}.joined()
let source = "<r>\(rows)</r>"
let stylesheet = """
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="text"/>
<xsl:template match="/"><xsl:apply-templates select="/r/*"/></xsl:template>
<xsl:template match="even">E</xsl:template>
<xsl:template match="odd | never">O</xsl:template>
</xsl:stylesheet>
"""
let output = try PureXML.XSLT.transform(stylesheet: stylesheet, source: source)
#expect(output.count == 300)
#expect(output.count(where: { $0 == "E" }) == 150)
#expect(output.count(where: { $0 == "O" }) == 150)
// Alternation is preserved (selection by pattern, not by order accident).
#expect(output.hasPrefix("OEOE"))
}
}