Repository navigation
Add DOMTraverser::filterNodes() for matching the nodes in hand - #128
Merged
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #128 +/- ##
============================================
+ Coverage 90.69% 91.29% +0.60%
- Complexity 1424 1463 +39
============================================
Files 25 26 +1
Lines 3170 3229 +59
============================================
+ Hits 2875 2948 +73
+ Misses 295 281 -14 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Oct 26, 2026
NodeMatcher, applySetFilter() and PseudoClass::is() each built an initialized DOMTraverser by hand and relied on its internals. They now share one entry point that returns the matching nodes in input order. DOMTraverser no longer consumes the parsed selector it is given, so PseudoClass caches parses and queries sharing a handler parse once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A single selector group without a set filter is matched in the order the nodes were given, so is(), not() and the other set callers no longer pay for a second pass. :matches() with a positional argument returns the filtered set directly, relativeSelector() reuses parse() and copies the anchor it adds :scope to, and initialMatch() documents that it must be given a copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gravitypdf-bot
force-pushed
the
fix/issue-116
branch
from
October 26, 2026 04:45
6561738 to
c55951c
Compare
This was referenced Oct 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three places asked "which of these nodes, taken as candidates themselves, match this selector?", and each built an initialized
DOMTraverserby hand:NodeMatcher,DOMTraverser::applySetFilter()andPseudoClass::is()(:not()/:matches()).NodeMatcherrelied on engine internals: the baretrueconstructor flag, the default scope node, and re-walking the result because the traverser returns document order.What changed
DOMTraverser::filterNodes(). It returns the matching nodes in the order they were given. A single selector group without a set filter already comes back in that order, so only selector lists and set-filtered selectors pay for a reordering pass. Non-elements never match, and positional pseudo-classes count within the given set.:scopedefaults to the document element, as infind(). The optionalPseudoClasslets callers share Make :nth-* pseudo-classes linear on wide sibling lists #119's sibling index.NodeMatcher,applySetFilter()andPseudoClass::is()all call it.NodeMatcherkeeps its public methods and signatures. Its job is now the per-call memo and the shared handler, andfilter()is an alias ofmatching().DOMTraverserno longer modifies the parsed selector it is given.resolveSubject()clones the simple selector beforeinitialMatch()consumes it, which replaces the per-call group copy inPseudoClass::findsAny().initialMatch()'s docblock now says it must be given a copy. Because parses can now be reused,PseudoClass::parse()caches them per handler, andfind()andrelativeSelector()(:has()) use it;relativeSelector()copies the anchor it adds:scopeto. Per-node queries that share a handler (NodeMatcher,:not()/:matches()candidates) now parse each selector once instead of once per node.Traverserinterface. Adding a method there breaks every third-party implementer.filterNodes()is also static and specific to DOM andPseudoClass, so it doesn't fit the interface'sfind()/matches()contract.The intent is no behaviour change, and the existing suite passes unchanged.
Performance
PHP 8.5, best of 5, 2000-row
<table>:qp($xml, 'tr:first-child')->nextUntil(':nth-last-child(1)')qp($xml, 'tr:first-child')->nextUntil('tr.none')qp($xml, 'td')->closest('tr:nth-child(2n)')qp($xml, 'tr:not(:nth-child(2n))')qp($xml, 'tr:matches(td, tr)')qp($xml, 'tr')->filter(':nth-child(2n)')qp($xml, 'tr:not(:first)')Tests
New tests in
DOMTraverserTestcallfilterNodes()directly::first,:eq()and:oddcount within the set, and:nth-childstill counts siblings:not(:first)and:matches(:last):rootand:scope, with and without a scope node<ul>'s child nodes, including its text and comment nodestestQueriesSharingAHandlerReuseTheParsedSelectorruns ID, class and positional-ancestor selectors twice on one handler. It fails without the clone inresolveSubject().vendor/bin/phpunit: 690 tests, 1923 assertions, 0 failures (1 skipped).lintandlint:min-phppass.Not addressed
filter('ul:first > li')(andfilterNodes()with that selector) matches nothing. This already happens onmain; tracked in filter() and is() match nothing for a positional pseudo-class left of a combinator (ul:first > li) #132.:scopeinside:not()/:matches(), and infind()generally, is the document element rather than the query's scope, as before; tracked in:scopealways refers to the document element, sofind(':scope > li')matches nothing #133.Util::selectorHasPositionalPseudoClass()still parses:not()/:matches()arguments through its own static cache.PseudoClassserves as the shared context, and its docblock now says so.Rebased onto #127
The only conflict was the CHANGELOG; both entries are kept. A /simplify pass then added the in-order shortcut in
filterNodes(), returned:matches()' set-filter result directly fromapplySetFilter(), movedrelativeSelector()ontoparse(), and folded the empty-set and scope-node tests into thefilterNodes()data provider. On 5000 nodesfilterNodes('li')now costs the same as a bare traverser (1.84 ms each; it was about 0.5 ms more).Fixes #116.
🤖 Generated with Claude Code