Repository navigation
Make find() search descendants only, as jQuery does - #73
Merged
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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>
This was referenced Aug 24, 2026
gravitypdf-bot
force-pushed
the
find-descendant-only
branch
3 times, most recently
from
October 26, 2026 01:40
b17a788 to
39fe095
Compare
gravitypdf-bot
marked this pull request as ready for review
October 26, 2026 01:43
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
force-pushed
the
find-descendant-only
branch
from
October 26, 2026 01:49
39fe095 to
f443f90
Compare
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>
This was referenced Oct 26, 2026
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>
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.
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.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 infor jQuery's
$(document). Without an exception for it,qp($xml)->find('root')could nevermatch — and several long-standing tests, correctly, expect it to. So the self-match is kept for
exactly that one node:
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(), …) testa candidate with
QueryPath::with($node)->is($selector), andis()ran afind(). They wererelying on the self-match to ask "does this node match?" — the very question
find()should notbe answering. Applying this change to
mainalone breaks nine of them:Once #72 routes them through
NodeMatcher, they stop caring. On top of #72 this change breaksexactly two tests, covered below.
The tests changed
DOMQueryTest::testBeforesearched for the node it was standing on: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 thatdetach($sel)andremove($sel)take the same nodes.detach()usesfind()and is now descendants-only.remove()andreplaceAll()still use the legacyQueryPathEventHandler, which matches the nodes in hand:The agreement loop now covers the selectors that can't match the nodes in hand (
i,p > i,noSuchElement). Two assertions pindetach('p')anddetach('*'). 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 ininitialMatchOnElement()was resolved toward this branch.Issue49Testpasses unchanged.mainhad 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, becausegetElementsByTagName()never returns the node it is called on. The ID and class matchers run an XPath query, andinitialXpathQuery()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 sameisSameNode($this->dom->documentElement)test asinitialXpathQuery().Verification
vendor/bin/phpunit: 564 tests, 1684 assertions, 0 failures (1 skipped)composer run lintandcomposer run lint:min-php: clean, PHP 7.1+tests/QueryPath/FindDescendantOnlyTest.phppins 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