Skip to content

Make is() test the current match set, like jQuery - #67

Closed
jakejackson1 wants to merge 2 commits into
mainfrom
issue-51
Closed

Make is() test the current match set, like jQuery#67
jakejackson1 wants to merge 2 commits into
mainfrom
issue-51

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Aug 21, 2026

Copy link
Copy Markdown
Member

Fixes #51

The problem

is() was implemented as return $this->branch($selector)->count() > 0;. branch() runs a find(), and find() is a descendant-or-self search, so is() returned true whenever anything below an element in the match set matched the selector. As the reporter put it, that made is() useless — it was has() with a boolean return, exactly as noted in the issue discussion.

The fix

QueryPath\Helpers\QueryChecks::is() now filters the elements held in the current match set instead of searching below them:

  • Non-element nodes (text, comment, PI, …) are dropped up front — they can never match a CSS selector.
  • The remaining elements are handed to CSS\DOMTraverser with $initialized = true, which tells the traverser that the match set is the candidate set, so the selector filters those elements rather than seeding an initial descendant match.
  • Returns true when at least one candidate matches, per https://api.jquery.com/is/.

Because the traverser still walks the real document when it evaluates combinators, context-sensitive selectors keep working: html5qp('<div><p/></div>', 'p')->is('div > p') is true, ->is('section p') is false, ->is(':first-child') is true.

The existing overloads are untouched: passing a DOMNode still means "the set is exactly this one node", and passing a Traversable still means "the two sets hold exactly the same nodes". is() still does not accept a callable — jQuery's function overload was out of scope here.

Behaviour changes

Breaking: is($selector) no longer matches on containment.

$q = html5qp('<p><span>foo</span></p>', 'p');
$q->is('p');     // true  (unchanged)
$q->is('span');  // was true, now false

Migration path: has() already provides the old semantics and needs no companion fix. $qp->has($selector)->count() > 0 is equivalent to the old $qp->is($selector). (has() finds matching descendants and then maps them back to the elements of the current set that contain them, so it is true in exactly the cases the old is() was.)

Knock-on fixes. Ten traversal methods filter via QueryPath::with($m, …)->is($selector) and inherited the bug — they matched any candidate that merely contained the selector. All are now correct: parents(), parentsUntil(), next(), nextAll(), nextUntil(), prev(), prevAll(), prevUntil(), closest(), not().

Concretely, with <ul><li id="a">a</li><li id="b"><em class="x">b</em></li><li id="c">c</li></ul>:

call before after
top('#a')->nextAll('.x')->count() 1 0
top('#a')->next('.x')->count() 1 0
top('#c')->prevAll('.x')->count() 1 0
top('#c')->prev('.x')->count() 1 0
top('#a')->nextUntil('.x')->count() 0 2
top('#a')->parents('.x')->count() 2 0
top('#a')->parentsUntil('.x')->count() 0 2
top('#a')->closest('.x')->count() 1 0
top('li')->not('.x')->count() 2 3

Bonus: is() no longer fatals on a non-element node. On main, html5qp('<div>Sample</div>', 'div')->contents()->eq(0)->is(':text') dies with Call to undefined method DOMText::getElementsByTagName(); it now returns false. See the note on #49 below.

Conflict with PR #50 / issue #49

PR #50 (branch issue-49) commits tests/Issues/Issue49Test.php, and one of its assertions depends on the old descendant-matching semantics:

$q = html5qp('<div><input name="text1" type="text" /><input name="text2" /></div>', 'div');
$this->assertTrue($q->is(':text'));   // the collection holds the <div>; only its children are :text

Under this PR that assertion is false and the test fails. That is expected and correct — the <div> is not an input[type=text]. When the two branches are reconciled, that line should become assertFalse($q->is(':text')), or the fixture should put the inputs in the collection (html5qp($html, ':text')). The neighbouring assertCount(2, $q->find(':text')) is unaffected.

Two other observations for #49, verified against both branches:

  • $q->find('div')->contents()->eq(0) in that test is the first <input> element, not a text node. ->is(':text') on it was false on main and is true here — this PR fixes that assertion.
  • The genuine DOMText case (html5qp('<div>Sample</div>', 'div')->find('div')->contents()->eq(0)->is(':text')) throws on main and returns false here. If $singleTextNode->is(':text') throws #49 wants a DOMText to satisfy :text, that is a deliberate extension of :text and belongs in Psuedo-class selector :text producing errors and incorrect results #50 — this PR only guarantees it no longer fatals.

I have not touched the issue-49 branch.

Tests

New: tests/Issues/Issue51Test.php (11 tests, 37 assertions) covering descendants, ancestors, "any element in the set matches", selector groups, combinators and pseudo-classes, empty collections, non-element nodes, both existing overloads, the has() migration path, and the parents() knock-on fix. PHPUnit already recurses into tests/, so no phpunit.xml change was needed (matching the convention tests/Issues/Issue49Test.php introduces).

No existing test needed to be altered. The full suite is green: 330 tests, 1110 assertions, 2 pre-existing skips (create_function removed in PHP 8). composer run lint and composer run lint:min-php are both clean.

Notably DOMQueryTest::testIs still passes unchanged — its assertions (find('#one')->is('#one'), find('li')->is('#one')) hold under both semantics.

Deliberately out of scope

filter($selector) has the same containment bug (it runs DOMTraverser with $initialized = false, i.e. a descendant-or-self search on each element), which is why siblings('.x') still returns a sibling that merely contains .x. There is a comment in QueryFilters::filter() from the original authors saying the true variant "fails unit tests". Fixing that is the same one-line change applied to a different method, but it moves more behaviour than #51 asks for, so I have left it — happy to open a follow-up issue.

🤖 Generated with Claude Code


Reconciliation with #69

#69 (issue #62) fixes the same containment defect one layer up, in the selector-filtered
traversal methods. Both PRs originally carried their own copy of the "test this node against
a selector" logic, so that has been extracted into QueryPath\Helpers\NodeMatcher, added here
and consumed by both. #69 is now stacked on this branch and should be merged after it.

NodeMatcher leaves the traverser's scope node at its default, so :scope resolves against the
document element exactly as it does in find().

Note that once #69 lands, QueryFilters no longer calls is() at all, so the traversal methods
are fixed directly there rather than transitively through this change. The CHANGELOG entry that
claimed the transitive fix has been dropped from this PR; #69 describes those methods accurately.

Verified together: 344 tests, 1151 assertions, 0 failures; phpcs and lint:min-php clean.

is() ran a descendant search (`branch($selector)->count() > 0`), so it returned
TRUE whenever anything *below* an element in the match set matched the selector.
That made `html5qp('<p><span>foo</span></p>', 'p')->is('span')` return TRUE and
`is()` largely useless as a predicate.

It now filters the elements held in the match set instead, by handing them to
CSS\DOMTraverser with `$initialized = true`, and returns TRUE when at least one
of them matches. Ancestors and descendants no longer cause a match, while
combinators and positional pseudo-classes are still evaluated against the full
document.

Non-element nodes are skipped rather than passed to the traverser, which also
stops `is()` fataling on a DOMText.

The DOMNode and Traversable overloads are unchanged.

Fixes #51

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 68.42105% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.44%. Comparing base (296d828) to head (f0cb252).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/Helpers/NodeMatcher.php 66.66% 6 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main      #67      +/-   ##
============================================
- Coverage     89.44%   89.44%   -0.01%     
- Complexity     1342     1349       +7     
============================================
  Files            26       27       +1     
  Lines          3023     3041      +18     
============================================
+ Hits           2704     2720      +16     
- Misses          319      321       +2     

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

…cher

is() built its DOMTraverser inline. The selector-filtered traversal methods in
QueryFilters need the same "is this node a match?" test, so the logic is moved
into a small helper both can share rather than being written twice and left to
drift apart.

NodeMatcher deliberately leaves the traverser's scope node at its default, so
:scope resolves against the document element exactly as it does in find().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1

Copy link
Copy Markdown
Member Author

Collapsed into #72, which carries all of this work unchanged — same tree, one branch. Closing to keep the review in one place; reopen if the split turns out to be preferable.

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.

html5qp('<p><span>foo</span></p>')->is('span') returns true

1 participant