Skip to content

Make :has() match in find() - #125

Merged
jakejackson1 merged 4 commits into
mainfrom
fix/issue-121
Oct 26, 2026
Merged

jakejackson1 merged 4 commits into
mainfrom
fix/issue-121

Conversation

@gravitypdf-bot

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

Copy link
Copy Markdown
Contributor

:has() never matched in the current engine (CSS\DOMTraverser), which find(), filter() and the factories use. PseudoClass::has() ran its argument in initialized mode against the element itself, so ul:has(li) asked whether the ul is an li.

$xml = '<?xml version="1.0"?><root><ul id="a"><li/><li/></ul><ul id="b"><li/><li/><li/></ul></root>';

qp($xml)->find('ul:has(li)');               // before: (none)   after: a, b
qp($xml)->find('ul:has(li:nth-child(3))');  // before: (none)   after: b
qp($xml)->find('ul:not(:has(li))');         // before: a, b     after: (none)

What changed

  • has() now reads its argument as a CSS 4 relative selector anchored to the element with :scope. It matches a descendant by default, a child for > li, the next sibling for + li, and a later sibling for ~ li. Each group is parsed and anchored once per argument and then cached.
  • :not() and :matches() keep the old "is this element a match?" check, now in is(). isNot() used to call has().
  • applySetFilter() had the same initialized-mode bug for :has() with a positional argument. has is no longer in Util::SELECTOR_ARGUMENT_PSEUDO_CLASSES, so :has() is never a set filter. Its argument is searched separately around each element, as in jQuery, so ul:has(li:eq(2)) matches every ul that has a third li. This matches remove().
  • DOMTraverser::findGroups() runs a selector that is already parsed, and find() now calls it. SimpleSelector::isBlank() is new.

Supported relative forms

li, > li, + li, ~ li, chains such as + ul ~ q and ~ ol > li > span, and comma lists without a leading combinator (x, span). Parser limitations I did not fix:

Behaviour change

Selectors using :has() used to match nothing, and now they match. :not(:has(...)) used to match everything. On self-matching, this engine and remove() now differ: the legacy engine counts the element itself, so remove('*:has(li.k)') includes the li.k. find() follows the spec and does not.

Performance

A descendant search is one traversal per candidate. + checks only the next sibling. ~ searches the later siblings one at a time and stops at the first match. When ~ has no match it costs O(siblings) for each candidate: li:has(~ b) over 2000 siblings takes about 4 s.

Sharing #119's sibling index

#119 (now on main) indexes sibling lists once per query and shares the index with nested traversers through DOMTraverser's 4th constructor argument. is() and findsAny() both pass $this, so every per-element and per-sibling search inside :has() reuses one index. Without that, li:has(~ li:nth-child(1)) over 400 siblings took about 9.2 s. With it, it takes about 0.26 s. PseudoClassTest::testHasQueriesShareTheSiblingIndex pins this for +, ~ and > arguments. Each case indexes exactly one sibling list, and all three fail without $this.

Rebased onto #72, #73 and #119

findsAny() searched each later sibling with an uninitialized find() that used to count the sibling itself. #73 made find() search descendants only, which broke 9 sibling-relative tests. findsAny() now takes a $self flag. A later sibling is tested itself first, and searched below only when the group continues to a child or descendant (~ ol > li > span). relativeSelector() now records whether a group starts at a sibling, rather than returning the anchor combinator.

Tests

  • DOMQueryTest: a data provider of 24 find() cases, plus html5qp(), filter(), agreement with remove() on the issue's selectors, and :not() regression checks.
  • PseudoClassTest: unit checks of has, matches and not.

Every new :has() test fails on main. vendor/bin/phpunit: 630 tests, 1797 assertions, 0 failures (1 skipped). lint and lint:min-php pass.

Fixes #121.

🤖 Generated with Claude Code

@jakejackson1 jakejackson1 added the bug Something isn't working label Oct 26, 2026
@codecov

codecov Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.30508% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.17%. Comparing base (6ae6acf) to head (9fa8255).
⚠️ Report is 13 commits behind head on main.

Files with missing lines Patch % Lines
src/CSS/DOMTraverser/PseudoClass.php 97.95% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main     #125      +/-   ##
============================================
+ Coverage     90.69%   91.17%   +0.48%     
- Complexity     1424     1459      +35     
============================================
  Files            25       26       +1     
  Lines          3170     3231      +61     
============================================
+ Hits           2875     2946      +71     
+ Misses          295      285      -10     

☔ 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 4 commits October 26, 2026 13:14
PseudoClass::has() ran its argument in initialized mode against the
element itself, so ul:has(li) asked whether the ul is an li and never
matched. It now reads the argument as a relative selector anchored to
the element: a descendant by default, or a child, next sibling or later
sibling for a leading >, + or ~. :not() and :matches() keep testing the
element itself.

:has() is no longer a set filter when its argument is positional: the
argument is searched for around each element, as in jQuery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
find() now searches descendants only (#73), so the per-sibling search in
:has(+ ...) and :has(~ ...) no longer saw the sibling it started from.
Test the sibling itself first, and search below it only when the group
goes on to a child or descendant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
has() only ever asked whether the anchor combinator was + or ~, so
relativeSelector() now answers that, and the descendant and sibling
searches become the two arms of one if/else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
findsAny() built each per-element and per-sibling traverser with its own
PseudoClass, so with #119's index every one re-indexed its sibling list:
li:has(~ li:nth-child(1)) over 400 siblings took about 9 s. It now passes
its own handler, as is() does, which also lets a nested :has() reuse the
parsed-argument cache (about 0.26 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jakejackson1
jakejackson1 merged commit 1be874a into main Oct 26, 2026
14 checks passed
@jakejackson1
jakejackson1 deleted the fix/issue-121 branch October 26, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

:has() never matches in find(): qp($xml)->find('ul:has(li)') returns nothing

2 participants