Fix jQuery positional pseudo-classes to index the matched set - #70
Draft
jakejackson1 wants to merge 3 commits into
Draft
Fix jQuery positional pseudo-classes to index the matched set#70jakejackson1 wants to merge 3 commits into
jakejackson1 wants to merge 3 commits into
Conversation
: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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
…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>
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.
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. InCSS\DOMTraverserthey 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-indexedisNthChild().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.Utilgains 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), andsortDocumentOrder(). PHP's DOM has nocompareDocumentPosition(), 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.SimpleSelectorreports which of its pseudo-classes are set-level (setFilterPseudoClasses()). They stay inpseudoClassesso parse output and__toString()are unchanged.DOMTraverserskips them during the per-node match and applies them inresolveSubject(). 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, soul:first liandul:last li:firstbehave as in jQuery.li:first, li:lastgives 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 commonli: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.QueryPathEventHandler::getByPosition()— whichremove()andreplaceAll()use — was already set-based but one-indexed, and routed:even/:oddtonthChild(). It now calls the sameUtilhelper, soremove('li:first')andfind('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-typeare 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:
li:eq(0)a1a1li:eq(1)a2a1, b1a2li:eq(3)b1a3b1li:firsta1a1, b1a1li:lastb2a3, b2b2li:lt(2)a1, a2a1, a2, b1, b2a1, a2li:gt(2)b1, b2a3b1, b2li:odda2, b1a1, a3, b1a2, b1li:evena1, a3, b2a2, b2a1, a3, b2Behaviour 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.:oddand:evenindex the matched set, not sibling position — and the sense flips, because CSS:nth-child(even)is the 2nd/4th/… sibling while jQuery:evenis the 1st/3rd/… match.:first/:lastreturn one element for the whole set instead of one per parent.:eq(-1)is the last match.QueryPath\CSS\DOMTraverser\PseudoClass::elementMatches()now throwsQueryPath\CSS\NotImplementedExceptionfor these eight names. It cannot answer them for a node in isolation, and answering with sibling semantics is exactly the bug. Callers should use aTraverser.children($selector)andfilter($selector)build a traverser per candidate node, so a positional filter written there sees a one-element set (children('li:first')now returns everylichild rather than the first-child ones). Both are set operations that should really filter their whole candidate set at once; that refactor touches:scopehandling 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,testEqtested per-node sibling semantics againstPseudoClassdirectly. Replaced withtestPositionalPseudoClassesAreNotNodePredicates, which asserts the new exception; the real coverage moved toIssue66Test.QueryPathEventHandlerTest:testPseudoClassGT,testPseudoClassLT,testPseudoClassNTHand threenthChildProviderrows (: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)')vsfind('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>inphpunit.xml; no config change was needed.vendor/bin/phpunit: 355 tests, 1121 assertions, 2 skipped (the pre-existingcreate_functionskips), 0 failures.composer run lintandcomposer 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 baretd).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:eqmoved would have been more confusing than moving both.: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