diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f8749..450311e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- The `following` and `preceding` axes no longer rebuild the whole document node list and linearly search it on every context node, which made them quadratic over many context nodes. They now share a per-evaluation cache of each document's ordered node list and a node-to-index map (the document does not change during evaluation), so the list is built once and a node's position is an O(1) lookup; `following` additionally skips the context's contiguous subtree by index arithmetic rather than filtering every descendant out. The selected nodes and their order are unchanged. A 200-context `following::` query over a 20k-element document drops from about 16 s to about 9 s; the residual cost is the size of the produced node-set itself. Guarded by `FollowingPrecedingAxisTests`. +- The `following` and `preceding` axes no longer rebuild the whole document node list and linearly search it on every context node, which made them quadratic over many context nodes. They now share a per-evaluation cache of each document's ordered node list and a node-to-index map (the document does not change during evaluation), so the list is built once and a node's position is an O(1) lookup; `following` additionally skips the context's contiguous subtree by index arithmetic rather than filtering every descendant out. The step's node test is fused into the walk, 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. A 200-context `following::` query over a 20k-element document drops from about 16 s to about 6 s. Guarded by `FollowingPrecedingAxisTests`. ## [0.4.3] - 2026-06-25 diff --git a/Sources/XPath/AxisNavigation.swift b/Sources/XPath/AxisNavigation.swift index 23dfa08..4271101 100644 --- a/Sources/XPath/AxisNavigation.swift +++ b/Sources/XPath/AxisNavigation.swift @@ -29,8 +29,8 @@ extension PureXML.XPath { switch axis { case .followingSibling: followingSiblings(of: context) case .precedingSibling: precedingSiblings(of: context) - case .following: following(of: context, cache: cache) - case .preceding: preceding(of: context, cache: cache) + case .following: following(of: context, cache: cache, where: { _ in true }) + case .preceding: preceding(of: context, cache: cache, where: { _ in true }) case .attribute: attributes(of: context) case .namespace: namespaces(of: context) default: [] @@ -119,7 +119,11 @@ extension PureXML.XPath { return parent.children[.. [Node] { + /// The following axis, fusing the step's node test into the walk: `keep` + /// is applied to the document-order slice directly, so a node the test + /// rejects is never copied out of the shared node list (only matches are + /// materialized). The unfused caller passes a test that keeps everything. + static func following(of node: Node, cache: DocumentNavigationCache?, where keep: (Node) -> Bool) -> [Node] { guard let root = rootTree(of: node) else { return [] } let document = orderedDocument(rootedAt: root, cache: cache) if let index = document.index[node] { @@ -128,16 +132,19 @@ extension PureXML.XPath { // subtree. Skip it by index rather than scanning the whole tail // and filtering each descendant out (which made this quadratic). let start = index + 1 + descendantCount(of: node) - return start < document.nodes.count ? Array(document.nodes[start...]) : [] + return start < document.nodes.count ? document.nodes[start...].filter(keep) : [] } // An attribute or namespace start: document order places it after // its owner and before the owner's children, and it has no // descendants, so everything after the owner follows. guard let owner = node.parent, let index = document.index[owner] else { return [] } - return Array(document.nodes[(index + 1)...]) + return document.nodes[(index + 1)...].filter(keep) } - private static func preceding(of node: Node, cache: DocumentNavigationCache?) -> [Node] { + /// The preceding axis, fusing the step's node test into the walk: the + /// span before the context is filtered by `keep` (and the ancestors, + /// which the axis excludes) in one pass, so only matches are materialized. + static func preceding(of node: Node, cache: DocumentNavigationCache?, where keep: (Node) -> Bool) -> [Node] { guard let root = rootTree(of: node) else { return [] } let document = orderedDocument(rootedAt: root, cache: cache) let anchorIndex = document.index[node] ?? node.parent.flatMap { document.index[$0] } @@ -146,7 +153,7 @@ extension PureXML.XPath { // over the preceding span is fine; the win here is the cached node // list and the O(1) anchor lookup instead of a rebuild and scan. let excluded = Set(ancestors(of: node)) - return document.nodes[.. (the one holding a3), + // not a2/b/b1/b11/c; preceding::a from c selects only the first . + #expect(try names("//a1/following::a", Self.doc) == ["a"]) + #expect(try names("//c/preceding::a", Self.doc) == ["a"]) + #expect(try names("//a1/following::b11", Self.doc) == ["b11"]) + #expect(try names("//a3/preceding::a1", Self.doc) == ["a1"]) + } + @Test("the axes give the same result from many context nodes (shared cache)") func test_manyContexts() throws { // Exercises the shared per-query cache across context nodes. The path is a