Skip to content

Make find() search descendants only, as jQuery does - #73

Merged
jakejackson1 merged 6 commits into
mainfrom
find-descendant-only
Oct 26, 2026
Merged

jakejackson1 merged 6 commits into
mainfrom
find-descendant-only

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Aug 21, 2026 •

Copy link
Copy Markdown
Member

find() treated the nodes already in the match set as candidates for their own selector.
jQuery's .find() searches descendants; filter() is what asks whether the nodes in hand match.

$xml = '<root><a id="a1"><a id="a2"/><b id="b1"/></a></root>';

qp($xml, '#a1')->find('a');   // before: a1, a2    after: a2
qp($xml, '#a1')->find('*');   // before: a1, a2, b1    after: a2, b1

Based on #72 — merge that first. This is not stylistic sequencing; see below.

The document element stays matchable

qp() seeds the match set with the document element, not the document node, so it stands in
for jQuery's $(document). Without an exception for it, qp($xml)->find('root') could never
match — and several long-standing tests, correctly, expect it to. So the self-match is kept for
exactly that one node:

qp($xml)->find('root');    // still 1
qp($xml)->find(':root');   // still 1

Arguably the deeper fix is to seed with the document itself, which would remove the exception
entirely. That is a much larger change and is not attempted here.

Why this needs #72

Before #72, the selector-filtered traversal methods (next(), siblings(), prevAll(), …) test
a candidate with QueryPath::with($node)->is($selector), and is() ran a find(). They were
relying on the self-match to ask "does this node match?" — the very question find() should not
be answering. Applying this change to main alone breaks nine of them:

testHtml5  testBefore  testSiblings  testHTML  testNext
testPrev   testNextAll  testPrevAll  testPrevUntil

Once #72 routes them through NodeMatcher, they stop caring. On top of #72 this change breaks
exactly two tests, covered below.

The tests changed

DOMQueryTest::testBefore searched for the node it was standing on:

qp($file, 'unary')->before('<test/>')->find(':root > test ~ unary')

It now searches from top(), the same shape the next two assertions in that test already use. Its intent is unchanged and still asserted: before() inserted <test/> as a preceding sibling.

DOMQueryTest::testDetachWithSelectorAgreesWithRemove (from #108) asserted that detach($sel) and remove($sel) take the same nodes. detach() uses find() and is now descendants-only. remove() and replaceAll() still use the legacy QueryPathEventHandler, which matches the nodes in hand:

qp($xml, 'p')->remove('p')->count();   // 2
qp($xml, 'p')->detach('p')->count();   // 0

The agreement loop now covers the selectors that can't match the nodes in hand (i, p > i, noSuchElement). Two assertions pin detach('p') and detach('*'). The CHANGELOG says so, and #126 tracks making the legacy engine agree. Once that's fixed, the skipped selectors can go back into the loop.

Merged work

Rebased onto the current #72, which is itself on current main. #50 (:text) is merged. Its broadened wildcard self-match in initialMatchOnElement() was resolved toward this branch. Issue49Test passes unchanged. main had also added a guard that skips non-element nodes to the element and ID matchers; it is kept.

Where the document-element exception lives

Only initialMatchOnElement() needs it, because getElementsByTagName() never returns the node it is called on. The ID and class matchers run an XPath query, and initialXpathQuery() already runs that query from the document when the context is the document element, so the element itself is included. Their self-match branches only ever fired for the document element, so they were redundant and are removed. The element matcher's check now uses the same isSameNode($this->dom->documentElement) test as initialXpathQuery().

Verification

  • vendor/bin/phpunit: 564 tests, 1684 assertions, 0 failures (1 skipped)
  • composer run lint and composer run lint:min-php: clean, PHP 7.1+
  • tests/QueryPath/FindDescendantOnlyTest.php pins the behaviour, the document-element exception, filter() as the replacement, mixed text/comment/element sets, leaf and empty sets, and malformed selectors

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files
@@             Coverage Diff              @@
##               main      #73      +/-   ##
============================================
+ Coverage     90.69%   91.02%   +0.33%     
- Complexity     1424     1430       +6     
============================================
  Files            25       26       +1     
  Lines          3170     3166       -4     
============================================
+ Hits           2875     2882       +7     
+ Misses          295      284      -11     

☔ 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.

jakejackson1 added a commit that referenced this pull request Aug 21, 2026
Two assertions reached find() for a node that was already in their own match set,
which only worked because find() self-matched. #73 makes find() search
descendants only, as jQuery does.

Rewritten to ask each question of the method that answers it: find() of a real
descendant, filter()/is() of the elements in the set. The mixed-node fixture gains
a nested <em> so find() still has something to reach, which keeps the point of the
test — that a set holding a text node does not cause a fatal — intact on both
sides of the selector.

Passes with and without #72/#73.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jakejackson1 added a commit that referenced this pull request Aug 21, 2026
The rule — an input whose type is absent or case-insensitively "text" — was
spelled out once per engine. The two are meant to agree, which is why this PR
has a test asserting they do; sharing the definition is what actually keeps them
agreeing. Util is the established home for this: 4.1.0 moved parseAnB() there
for the same reason.

Also in this commit, none of it behavioural:

- Drop .phpunit.result.cache, which was committed despite being in .gitignore.
- Restore the six blank lines the diff had stripped from released CHANGELOG
  sections. All five open PRs edit that file, so unrelated whitespace churn in it
  buys four conflicts for nothing.
- Record the find('*') self-match change in the CHANGELOG. It was needed to make
  is(':text') work on an element under the current is(), but it is a behaviour
  change that was going in unmentioned, and #73 supersedes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jakejackson1 added a commit that referenced this pull request Aug 24, 2026
Two assertions reached find() for a node that was already in their own match set,
which only worked because find() self-matched. #73 makes find() search
descendants only, as jQuery does.

Rewritten to ask each question of the method that answers it: find() of a real
descendant, filter()/is() of the elements in the set. The mixed-node fixture gains
a nested <em> so find() still has something to reach, which keeps the point of the
test — that a set holding a text node does not cause a fatal — intact on both
sides of the selector.

Passes with and without #72/#73.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jakejackson1 added a commit that referenced this pull request Aug 24, 2026
The rule — an input whose type is absent or case-insensitively "text" — was
spelled out once per engine. The two are meant to agree, which is why this PR
has a test asserting they do; sharing the definition is what actually keeps them
agreeing. Util is the established home for this: 4.1.0 moved parseAnB() there
for the same reason.

Also in this commit, none of it behavioural:

- Drop .phpunit.result.cache, which was committed despite being in .gitignore.
- Restore the six blank lines the diff had stripped from released CHANGELOG
  sections. All five open PRs edit that file, so unrelated whitespace churn in it
  buys four conflicts for nothing.
- Record the find('*') self-match change in the CHANGELOG. It was needed to make
  is(':text') work on an element under the current is(), but it is a behaviour
  change that was going in unmentioned, and #73 supersedes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 added this pull request to stack #114 October 26, 2026 00:27
@jakejackson1 jakejackson1 added the bug Something isn't working label Oct 26, 2026
@gravitypdf-bot
gravitypdf-bot force-pushed the find-descendant-only branch 3 times, most recently from b17a788 to 39fe095 Compare October 26, 2026 01:40
@gravitypdf-bot
gravitypdf-bot marked this pull request as ready for review October 26, 2026 01:43
Base automatically changed from fix-selector-matching to main October 26, 2026 01:49
jakejackson1 and others added 6 commits October 26, 2026 12:49
find() treated the nodes already in the match set as candidates for their own
selector, so qp($xml, '#a1')->find('a') returned #a1 itself alongside any nested
<a>. jQuery's .find() searches descendants; filter() is what asks whether the
nodes in hand match.

The document element keeps its self-match. qp() seeds the match set with the
document element rather than with the document node, so it stands in for
$(document), and without the exception qp($xml)->find('root') could never match.

This depends on #72. Before it, the selector-filtered traversal methods reach
is(), which reached find(), so they relied on the self-match to test a node
against a selector; changing find() on its own breaks nine of them. Once those
go through NodeMatcher they no longer care.

testBefore searched for the node it was standing on. Rewritten to search from
top(), which is the shape the next two assertions in that test already use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the element matcher was changed, so find('a') stopped self-matching while
find('#a1') and find('.c') still did. Three initial matchers, three separate
self-tests, and the selector you happened to write decided the semantics.

All three now apply the same rule, with the same document-element exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule and its four-line explanation were pasted into all three initial
matchers. They have to agree — find('#a1') matching a node that find('a') does
not is the divergence this PR exists to remove — so they should not be three
independent copies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
remove() still runs on the legacy engine, which lets a selector match the
nodes in hand, so the remove()/detach() agreement test now covers only the
selectors that cannot match them. Tracked in #126.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ID and class matchers' self-match branches only ever fired for the
document element, which initialXpathQuery() already matches by running their
query from the document. Removing them leaves one caller for
isDocumentElement(), so its check is inlined there, using the same
isSameNode() test as initialXpathQuery().

testBefore() now passes its selector straight to top().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tors

The element, ID and class matchers each skip text and comment nodes in the
match set, exclude the elements in it, and still find their descendants.
Finding from a leaf or an empty set returns nothing, and starting below the
document element still reports a malformed selector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jakejackson1
jakejackson1 merged commit 355753f into main Oct 26, 2026
14 checks passed
@jakejackson1
jakejackson1 deleted the find-descendant-only branch October 26, 2026 01:50
gravitypdf-bot added a commit that referenced this pull request Oct 26, 2026
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>
gravitypdf-bot added a commit that referenced this pull request Oct 26, 2026
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>
jakejackson1 pushed a commit that referenced this pull request Oct 26, 2026
* Make :has() match related elements in DOMTraverser

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>

* Test a later sibling itself against a sibling-relative :has()

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>

* Return whether a :has() group starts at a sibling, not its combinator

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>

* Share the sibling index with the queries :has() runs

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>

---------

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

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants