Skip to content

Add DOMTraverser::filterNodes() for matching the nodes in hand - #128

Merged
jakejackson1 merged 3 commits into
mainfrom
fix/issue-116
Oct 26, 2026
Merged

jakejackson1 merged 3 commits into
mainfrom
fix/issue-116

Conversation

@gravitypdf-bot

@gravitypdf-bot gravitypdf-bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Three places asked "which of these nodes, taken as candidates themselves, match this selector?", and each built an initialized DOMTraverser by hand: NodeMatcher, DOMTraverser::applySetFilter() and PseudoClass::is() (:not()/:matches()). NodeMatcher relied on engine internals: the bare true constructor flag, the default scope node, and re-walking the result because the traverser returns document order.

// before (NodeMatcher, and similar in two other places)
$traverser = new DOMTraverser($nodes, true, null, $this->psHandler);
$matched   = $traverser->find($this->selector)->matches();
// ...then re-walk $nodes to restore their order

// after
DOMTraverser::filterNodes($nodes, $selector, $scopeNode = null, $psHandler = null);

What changed

  • New public 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. :scope defaults to the document element, as in find(). The optional PseudoClass lets callers share Make :nth-* pseudo-classes linear on wide sibling lists #119's sibling index.
  • NodeMatcher, applySetFilter() and PseudoClass::is() all call it. NodeMatcher keeps its public methods and signatures. Its job is now the per-call memo and the shared handler, and filter() is an alias of matching().
  • DOMTraverser no longer modifies the parsed selector it is given. resolveSubject() clones the simple selector before initialMatch() consumes it, which replaces the per-call group copy in PseudoClass::findsAny(). initialMatch()'s docblock now says it must be given a copy. Because parses can now be reused, PseudoClass::parse() caches them per handler, and find() and relativeSelector() (:has()) use it; relativeSelector() copies the anchor it adds :scope to. Per-node queries that share a handler (NodeMatcher, :not()/:matches() candidates) now parse each selector once instead of once per node.
  • It is not on the Traverser interface. Adding a method there breaks every third-party implementer. filterNodes() is also static and specific to DOM and PseudoClass, so it doesn't fit the interface's find()/matches() contract.

The intent is no behaviour change, and the existing suite passes unchanged.

Performance

PHP 8.5, best of 5, 2000-row <table>:

main this PR
qp($xml, 'tr:first-child')->nextUntil(':nth-last-child(1)') 15.9 ms 5.8 ms
qp($xml, 'tr:first-child')->nextUntil('tr.none') 9.9 ms 4.8 ms
qp($xml, 'td')->closest('tr:nth-child(2n)') 28.9 ms 9.9 ms
qp($xml, 'tr:not(:nth-child(2n))') 14.6 ms 5.8 ms
qp($xml, 'tr:matches(td, tr)') 12.6 ms 6.4 ms
qp($xml, 'tr')->filter(':nth-child(2n)') 3.2 ms 3.4 ms
qp($xml, 'tr:not(:first)') 8.6 ms 8.8 ms

Tests

New tests in DOMTraverserTest call filterNodes() directly:

  • input order is preserved
  • only the given nodes are tested, not their descendants
  • combinators and selector lists
  • :first, :eq() and :odd count within the set, and :nth-child still counts siblings
  • :not(:first) and :matches(:last)
  • the document element, :root and :scope, with and without a scope node
  • an empty set
  • a set of all a <ul>'s child nodes, including its text and comment nodes

testQueriesSharingAHandlerReuseTheParsedSelector runs ID, class and positional-ancestor selectors twice on one handler. It fails without the clone in resolveSubject(). vendor/bin/phpunit: 690 tests, 1923 assertions, 0 failures (1 skipped). lint and lint:min-php pass.

Not addressed

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 from applySetFilter(), moved relativeSelector() onto parse(), and folded the empty-set and scope-node tests into the filterNodes() data provider. On 5000 nodes filterNodes('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

@codecov

codecov Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.29%. Comparing base (6ae6acf) to head (c55951c).
⚠️ Report is 15 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

gravitypdf-bot and others added 3 commits October 26, 2026 15:40
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Move "match these nodes against a selector" into DOMTraverser instead of NodeMatcher

2 participants