Skip to content

Fix jQuery positional pseudo-classes to index the matched set - #70

Draft
jakejackson1 wants to merge 3 commits into
mainfrom
issue-66
Draft

Fix jQuery positional pseudo-classes to index the matched set#70
jakejackson1 wants to merge 3 commits into
mainfrom
issue-66

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Fixes #66

The problem

QueryPath advertises the jQuery positional pseudo-classes (:eq(), :first, :last, :lt(), :gt(), :odd, :even). In jQuery they index the matched set. In CSS\DOMTraverser they were all implemented as position among siblings, so they returned the wrong elements whenever the match set spanned more than one parent — and :eq(0) never matched anything, because :eq(n) was routed through the one-indexed isNthChild().

The approach

These are filters over an ordered result set, not node predicates, so they cannot be answered by a per-node matchesPseudoClass() callback. They are now applied after traversal, to the document-ordered set the selector matched.

  • Util gains the shared pieces both engines use: the list of positional pseudo-classes, applyPositionalPseudoClass() (zero-indexed, negative indexes count back from the end, as in jQuery), and sortDocumentOrder(). PHP's DOM has no compareDocumentPosition(), so ordering is derived by walking up the tree and comparing sibling offsets; sibling offsets are memoized per sort, because the naive version is quadratic on wide documents.
  • SimpleSelector reports which of its pseudo-classes are set-level (setFilterPseudoClasses()). They stay in pseudoClasses so parse output and __toString() are unchanged.
  • DOMTraverser skips them during the per-node match and applies them in resolveSubject(). A simple selector that carries one is resolved in full — combinators and all — into a document-ordered set, filtered, and cached; a selector to its right then answers from that cache instead of re-testing the node. Non-subject selectors are resolved leftmost-first, so ul:first li and ul:last li:first behave as in jQuery.
  • Each comma-separated group is filtered independently (jQuery's behaviour: li:first, li:last gives two elements), and the union is re-sorted into document order.
  • :not(), :has() and :matches() defer to the set level when their argument contains a positional filter, so the common li:not(:first) idiom excludes the first match. Without this the fix would have silently returned an empty set for it, since every node is "first" in its own one-element set.
  • The legacy engine agrees. QueryPathEventHandler::getByPosition() — which remove() and replaceAll() use — was already set-based but one-indexed, and routed :even/:odd to nthChild(). It now calls the same Util helper, so remove('li:first') and find('li:first') select the same element. No routing change or documented divergence was needed.
  • :nth-child(), :nth-of-type(), :first-child, :last-child, :first-of-type, :last-of-type are untouched and still count siblings, one-indexed. Only the jQuery set moved.

Verified against the issue's divergence table

All twelve rows now match jQuery, on both engines:

Selector jQuery before after
li:eq(0) a1 (no match) a1
li:eq(1) a2 a1, b1 a2
li:eq(3) b1 a3 b1
li:first a1 a1, b1 a1
li:last b2 a3, b2 b2
li:lt(2) a1, a2 a1, a2, b1, b2 a1, a2
li:gt(2) b1, b2 a3 b1, b2
li:odd a2, b1 a1, a3, b1 a2, b1
li:even a1, a3, b2 a2, b2 a1, a3, b2

Behaviour changes

These are the point of the PR, but they are breaking for anyone relying on the old semantics:

  • :eq(), :nth(), :lt() and :gt() are zero-indexed where they were one-indexed. :eq(2) is now the third match, not the second.
  • :odd and :even index the matched set, not sibling position — and the sense flips, because CSS :nth-child(even) is the 2nd/4th/… sibling while jQuery :even is the 1st/3rd/… match.
  • :first/:last return one element for the whole set instead of one per parent.
  • A negative index counts back from the end: :eq(-1) is the last match.
  • QueryPath\CSS\DOMTraverser\PseudoClass::elementMatches() now throws QueryPath\CSS\NotImplementedException for these eight names. It cannot answer them for a node in isolation, and answering with sibling semantics is exactly the bug. Callers should use a Traverser.
  • children($selector) and filter($selector) build a traverser per candidate node, so a positional filter written there sees a one-element set (children('li:first') now returns every li child rather than the first-child ones). Both are set operations that should really filter their whole candidate set at once; that refactor touches :scope handling and is out of scope here.

Existing tests updated

Nine assertions across two files encoded the old semantics, all of them direct tests of the pseudo-classes being fixed:

  • PseudoClassTest: testFirst, testLast, testEven, testOdd, testLt, testGt, testEq tested per-node sibling semantics against PseudoClass directly. Replaced with testPositionalPseudoClassesAreNotNodePredicates, which asserts the new exception; the real coverage moved to Issue66Test.
  • QueryPathEventHandlerTest: testPseudoClassGT, testPseudoClassLT, testPseudoClassNTH and three nthChildProvider rows (:root :even, i:even, i:odd) re-baselined to the jQuery answers. The :nth-child() rows in the same provider are unchanged, which is the point.

Tests

New tests/Issues/Issue66Test.php (35 tests) covers the divergence table on both engines, selector/method equivalence (find('li:eq(0)') vs find('li')->eq(0)), combinators, comma groups, chained filters, :not(), XML documents, and a guard that the CSS structural pseudo-classes still count siblings. tests/Issues/ is picked up by the existing recursive <directory>./tests/</directory> in phpunit.xml; no config change was needed.

  • vendor/bin/phpunit: 355 tests, 1121 assertions, 2 skipped (the pre-existing create_function skips), 0 failures.
  • composer run lint and composer run lint:min-php: clean.
  • php ./tests/run-examples.php --all: all 17 examples pass.

Positional selectors are no slower than a plain find() after the sibling-offset memoization (4000-cell table: td:eq(2500) 0.12s, vs 0.52s without the memo and 0.10s for a bare td).

Judgement calls

  • :nth() is now an alias of :eq() and zero-indexed. It is a QueryPath-only name, not a jQuery one, and the old code already aliased it to :eq. Leaving it one-indexed while :eq moved would have been more confusing than moving both.
  • Positional filters apply per comma group, matching jQuery/Sizzle, rather than to the union of all groups.
  • Positional filters inside :not() use the enclosing set, which is what jQuery does. Nested :not(:not(:first)) works because the inner traverser takes the same path.

🤖 Generated with Claude Code

:eq(), :first, :last, :lt(), :gt(), :odd and :even were implemented in
CSS\DOMTraverser as sibling-position tests, so they returned the wrong
elements whenever the match set spanned more than one parent, and :eq(0)
never matched anything because it was routed through the one-indexed
isNthChild().

These are filters over an ordered result set, not node predicates, so
they are now applied after the traversal has collected its matches:

- SimpleSelector reports which of its pseudo-classes are set-level.
- DOMTraverser resolves each simple selector that carries one to a
  document-ordered set, filters it, and caches it. A selector to the
  right then answers from that cache, so `ul:first li` and
  `ul:last li:first` work as they do in jQuery.
- Each comma-separated group is filtered on its own; the union is
  returned in document order.
- :not(), :has() and :matches() defer to the set level when their
  argument uses a positional filter, so `li:not(:first)` excludes the
  first match rather than every match.
- The legacy QueryPathEventHandler engine behind remove() and
  replaceAll() shares the same filter helper, so both engines agree.

PseudoClass::elementMatches() now throws NotImplementedException for
these names instead of answering with sibling semantics.

Fixes #66

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

Files with missing lines Patch % Lines
src/CSS/DOMTraverser/Util.php 91.08% 9 Missing ⚠️
src/CSS/DOMTraverser.php 98.57% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main      #70      +/-   ##
============================================
+ Coverage     89.44%   89.74%   +0.30%     
- Complexity     1342     1407      +65     
============================================
  Files            26       26              
  Lines          3023     3170     +147     
============================================
+ Hits           2704     2845     +141     
- Misses          319      325       +6     

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

…ns()

SplObjectStorage::contains() is deprecated as of PHP 8.5. CI runs with
error_reporting=E_ALL, so the sibling-offset memoization added for
sortDocumentOrder() failed the 8.5 job while passing everywhere else.

offsetExists() is the documented replacement and has been available since
PHP 5.3, so it is safe on the 7.1 floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jakejackson1 added a commit that referenced this pull request Aug 21, 2026
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>
Whether a pseudo-class filters the ordered set or tests a node is a property of
the parsed selector, but it was being recomputed for every node the selector was
tested against — two strtolower() and two in_array() calls each time. And
setFilterPseudoClasses() rebuilt its list on every call, from three call sites
per traversal.

SimpleSelector now splits its pseudo-classes once and caches the result, exposing
both halves. The per-node loop iterates the per-node half, so the question is not
asked again. Selectors with no pseudo-classes short-circuit before the call.

resolveSubject() also collected into an array and copied it into an
SplObjectStorage on every traversal, to support a reordering step that only runs
when a set-level filter is present. The check is hoisted above the candidate
loop, so the common path writes straight into the storage.

20 000 rows: `tr` 36.6ms -> 35.0ms, `tr.r` 49.1ms -> 47.3ms, `tr:empty`
46.6ms -> 43.0ms.

Also bounds the positional-selector cache, which is keyed by selector string and
so was unbounded user input in a long-running process, and folds the `gt` index
clamp into max().

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.

jQuery positional pseudo-classes (:eq, :first, :lt, :gt, :odd, :even) index siblings instead of the match set; :eq(0) never matches

1 participant