镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

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

Draft
jakejackson1 wants to merge 4 commits into
fix-selector-matchingfrom
find-descendant-only
Draft

jakejackson1 wants to merge 4 commits into
fix-selector-matchingfrom
find-descendant-only

Conversation

@jakejackson1

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 one test.

The one test changed

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

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

Rewritten to search from top(), which is the shape the next two assertions in that same test
already use. Its intent — that before() inserted <test/> as a preceding sibling — is unchanged
and still asserted.

Relationship to #50

#50 broadened the wildcard self-match from "the root element" to "any node", to make is() work
on an element itself under the old is(). #72 replaces that is(), and this PR removes the
self-match generally, so that hunk becomes unnecessary. Expect a conflict in
DOMTraverser::initialMatchOnElement() between #50 and this branch; resolve toward this one.
Two assertions in Issue49Test reach find() for a node in their own set and will need the same
treatment as testBefore.

Verification

  • vendor/bin/phpunit — 351 tests, 1175 assertions, 0 failures (2 pre-existing create_function skips)
  • composer run lint and composer run lint:min-php — clean
  • New tests/QueryPath/FindDescendantOnlyTest.php (5 tests) pinning the behaviour, the document-element exception, and filter() as the replacement

🤖 Generated with Claude Code

jakejackson1 and others added 2 commits August 22, 2026 04:13
is(), filter(), and the selector-filtered traversal methods all asked "does this
node contain a match?" where jQuery asks "is this node a match?". They ran a
descendant search against each candidate, so a selector that matched anything
below a candidate kept that candidate.

    html5qp('<p><span>foo</span></p>', 'p')->is('span');        // true, expected false
    qp($xml, 'Demographics > Age > Name')->parents('Demographics');
                                        // <Demographics>, <AmplifyReturn>, <ns1:AmplifyResponse>
    qp($file, 'inner')->filter('li')->count();                  // 2, expected 0

has() does what these used to do, and is unchanged: it remains the migration path
for callers that want the containment behaviour.

The single-node test is extracted into QueryPath\Helpers\NodeMatcher so the three
call paths cannot drift apart. It builds the traverser in "initialized" mode,
which treats the supplied nodes as the candidates rather than seeding a
descendant search, and leaves the scope node at its default so :scope resolves
against the document element exactly as it does in find(). Passing a candidate as
the scope node made every candidate match :scope, which is why parents(':scope')
returned every ancestor and children(':scope') returned every child.

filter() and children() filter their candidates as one set rather than one node
at a time. A per-node pass cannot evaluate a selector describing a position
within the set, because each node is the only member of its own one-element set.

parents() and parentsUntil() now return reverse document order with duplicates
removed, as jQuery does. Previously a set built from more than one starting
element was grouped by starting element.

DOMQueryTest::testFilter asserted the containment result and is rebaselined. It
is the test the original author's "fails unit tests" comment on filter() referred
to; it was the only one.

Fixes #51
Fixes #62

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
@codecov

codecov Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.83721% with 1 line in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (fix-selector-matching@e23fbb0). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/Helpers/NodeMatcher.php 94.44% 1 Missing ⚠️
Additional details and impacted files
@@                   Coverage Diff                    @@
##             fix-selector-matching      #73   +/-   ##
========================================================
  Coverage                         ?   89.69%           
  Complexity                       ?     1366           
========================================================
  Files                            ?       27           
  Lines                            ?     3066           
  Branches                         ?        0           
========================================================
  Hits                             ?     2750           
  Misses                           ?      316           
  Partials                         ?        0           

☔ 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 and others added 2 commits August 22, 2026 04:38
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>
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
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.

1 participant