Skip to content

Match selectors against the nodes in hand, not their descendants - #72

Draft
jakejackson1 wants to merge 2 commits into
mainfrom
fix-selector-matching
Draft

Match selectors against the nodes in hand, not their descendants#72
jakejackson1 wants to merge 2 commits into
mainfrom
fix-selector-matching

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Collapses #67, #69 and #71 into one branch. They were three PRs because there was one agent per
issue, but they are one defect at three layers, and reviewing them stacked meant reading the same
fix three times. Those PRs are closed in favour of this one.

Fixes #51. Fixes #62. Also fixes the filter() bug that had no issue.

The defect

is(), filter() and the selector-filtered traversal methods asked "does this node contain a
match?"
where jQuery asks "is this node a match?". Each ran a descendant search against the
candidate, so anything matching below it kept it:

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

has() does exactly what these used to do and is unchanged — it is the migration path for anyone
depending on the old behaviour.

The fix

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 — treating the supplied nodes as
the candidate set rather than seeding a descendant search — which is machinery the engine already
had; nothing under src/CSS/ changed.

It also leaves the scope node at its default. Passing a candidate as the scope node makes every
candidate match :scope, which is why the selector silently stopped filtering:

qp($xml, 'c')->parents(':scope')     // before: b, a, root   after: root
find('div')->children(':scope')      // before: 3            after: 0

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()/parentsUntil() now return reverse document order with duplicates removed, per the
jQuery spec quoted in #62.

Breaking changes

Method Before After
is($sel) true if a descendant matched true if a set member matches
filter($sel) kept elements containing a match keeps elements that match
parents(), parentsUntil(), closest(), parent(), next*(), prev*(), siblings(), not() selector matched containment selector matches the element
parents(), parentsUntil() ordering grouped by starting element reverse document order, deduplicated

is() also no longer fatals on a match set holding non-element nodes — DOMText raised
Call to undefined method DOMText::getElementsByTagName().

The author's comment on filter()

filter() carried this since 2009:

// Seems like this should be right... but it fails unit
// tests. Need to compare to jQuery.
// $query = new \QueryPath\CSS\DOMTraverser($tmp, TRUE, $m);

It fails exactly one test — DOMQueryTest::testFilter, whose two meaningful assertions both pin
the containment result. Rebaselined here, with two tests added: one pinning the filter()/has()
difference so the migration path stays covered, one for set-level evaluation. No other existing
test encoded the old behaviour.

Interaction with #66

#66 documents a known limitation: children() and filter() built a traverser per node, so a
positional pseudo-class saw a one-element set and children('li:first') returned every li child.
Set-level filtering resolves it. Verified by merging #66 into this branch locally — 382 tests pass,
and filter(':first')a1, filter(':eq(3)')b1, filter(':odd')a2,b1,
children('li:first')x1, all matching jQuery. #66 needs no change; this removes the
reason for its caveat.

#50 (issue #49) conflicts in one assertion: its committed assertTrue($q->is(':text')) holds $q
as the <div> while only its children are :text, and becomes false here. It needs flipping to
assertFalse at merge.

Verification

🤖 Generated with Claude Code

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>
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.33962% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.57%. Comparing base (296d828) to head (c54f283).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/CSS/DOMTraverser/Util.php 86.84% 5 Missing ⚠️
src/Helpers/NodeMatcher.php 95.23% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main      #72      +/-   ##
============================================
+ Coverage     89.44%   89.57%   +0.12%     
- Complexity     1342     1366      +24     
============================================
  Files            26       27       +1     
  Lines          3023     3070      +47     
============================================
+ Hits           2704     2750      +46     
- Misses          319      320       +1     

☔ 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
The assertion held the <div> and expected is(':text') to be true, which only
worked because is() ran a descendant search. #72 makes is() test the elements in
the match set, as jQuery does, so that assertion would flip to false.

Rewritten so it does not depend on which semantics are in force: the containment
question is asked with has(), which is what it always meant, and is() is asked of
the inputs themselves. It passes both with and without #72.

Also renamed $textNode to $firstInput in this test. contents()->eq(0) here is the
first <input> element, not a text node — the name is accurate in the sibling test
below, where the fixture really does hold text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Two problems, both in the machinery this PR introduced.

The selector was still evaluated per node in 12 places. The changelog states the
rule — a per-node pass cannot answer a selector describing a position within the
set, because each node is the only member of its own one-element set — and then
filter() and children() honour it while every other method routes through
matchesNodeSelector(). It does not show up yet, because :first and friends are
still sibling-positional on main. Once #70 lands, not(':first') returns the empty
set, siblings(':first') returns every sibling, and nextAll(':first') returns all
of them. Verified against #70 merged locally.

not(), siblings(), nextAll(), prevAll() and the parents() path now collect their
candidates and filter the set once. That also drops a selector parse and four
SplObjectStorage allocations per candidate: parents('div') on a 20-deep tree of
100 leaves parsed 'div' 2000 times for one call.

matchesNodeSelector() stays for the loops that genuinely need a per-node answer —
nextUntil(), prevUntil(), parentsUntil(), closest(), next(), prev() and parent(),
which stop at the first match rather than collecting a set.

The document-order sort was quadratic on document width. documentOrderPath()
walked previousSibling per node, so sorting n siblings cost n^2/2 pointer hops,
and array_unshift() per level made it quadratic on depth too. Sorting is now
Util::sortDocumentOrder(), which indexes each child list once and memoizes for
the duration of the call. parents() over a 4000-wide document: 253ms -> 33ms,
linear again.

The sorter is named and shaped to match the one #70 adds to the same file, so
merging the two is a single "defined twice, keep one" rather than a reconcile.

NodeMatcher::filter() now returns its result in the caller's order, so the
order-restoring walk filter() and children() each carried is gone, and
matchesAny() folds into matchesNode().

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

parents() doesn't match jQuery functionality when using selector html5qp('<p><span>foo</span></p>')->is('span') returns true

1 participant