Description of the bug
QueryPath advertises support for the jQuery positional pseudo-classes (:eq, :first, :last, :lt, :gt, :odd, :even). In jQuery these index into the matched set. In CSS\DOMTraverser they are all implemented as position among siblings instead, so they return the wrong elements whenever the match set spans more than one parent.
The most visible symptom is that :eq(0) never matches anything, because :eq(n) is routed through isNthChild(), which is 1-indexed while jQuery's :eq() is 0-indexed.
src/CSS/DOMTraverser/PseudoClass.php:129-146:
case 'lt':
$rule = sprintf('-n + %d', (int) $value);
return $this->isNthChild($node, $rule);
case 'gt':
return $this->nodePositionFromStart($node) > (int) $value;
case 'nth':
case 'eq':
$rule = (int) $value;
return $this->isNthChild($node, $rule);
case 'first':
return $this->isNthChild($node, 1);
Divergence table
Document: <div><ul><li>a1</li><li>a2</li><li>a3</li></ul><ul><li>b1</li><li>b2</li></ul></div>
find('li') matches five elements in document order: a1, a2, a3, b1, b2.
| Selector |
jQuery |
QueryPath |
li:eq(0) |
a1 |
(no match) |
li:eq(1) |
a2 |
a1, b1 |
li:eq(3) |
b1 |
a3 |
li:first |
a1 |
a1, b1 |
li:last |
b2 |
a3, b2 |
li:lt(2) |
a1, a2 |
a1, a2, b1, b2 |
li:gt(2) |
b1, b2 |
a3 |
li:odd |
a2, b1 |
a1, a3, b1 |
li:even |
a1, a3, b2 |
a2, b2 |
Every one of them is wrong. They only coincide with jQuery when the whole match set happens to share a single parent, which is why this has gone unnoticed.
The equivalent methods are correct, because they operate on the match set as jQuery does:
html5qp($html)->find('li')->eq(0)->text(); // 'a1' correct
html5qp($html)->find('li:eq(0)')->text(); // '' wrong
The legacy engine disagrees too
QueryMutators::remove() and QueryMutators::replaceAll() still route through the legacy CSS\QueryPathEventHandler, which implements these pseudo-classes differently again — so the same selector gives three different answers depending on which method you call:
$q = html5qp($html);
$q->remove('li:first');
// removes a1 only — neither jQuery's answer nor DOMTraverser's
Suggested fix
:eq, :first, :last, :lt, :gt, :odd, and :even are filters over an ordered result set, not node predicates, so they cannot be evaluated correctly by a per-node matchesPseudoClass() callback. Fixing them properly means applying them after the traversal has collected its matches, rather than during it.
If that is too large a change, the minimum worth doing is making :eq(n) 0-indexed so :eq(0) stops silently matching nothing, and documenting the sibling-position semantics of the rest so users are not misled by the jQuery names. Whichever way it goes, the two engines should be made to agree.
QueryPath version
4.1.0 (main, 3036f97)
PHP Version and environment
PHP 8.3.16 (cli), macOS. Not version-specific.
Minimal reproducible PHP+HTML snippet to replicate bug
<?php
require 'vendor/autoload.php';
$html = '<div><ul><li>a1</li><li>a2</li><li>a3</li></ul><ul><li>b1</li><li>b2</li></ul></div>';
echo html5qp($html)->find('li')->textImplode(','), "\n";
// a1,a2,a3,b1,b2 — five matches
echo '[', html5qp($html)->find('li:eq(0)')->textImplode(','), "]\n";
// actual: [] <-- :eq(0) never matches
// expected: [a1]
echo '[', html5qp($html)->find('li:first')->textImplode(','), "]\n";
// actual: [a1,b1] <-- first child of each <ul>
// expected: [a1]
echo '[', html5qp($html)->find('li:lt(2)')->textImplode(','), "]\n";
// actual: [a1,a2,b1,b2]
// expected: [a1,a2]
Description of the bug
QueryPath advertises support for the jQuery positional pseudo-classes (
:eq,:first,:last,:lt,:gt,:odd,:even). In jQuery these index into the matched set. InCSS\DOMTraverserthey are all implemented as position among siblings instead, so they return the wrong elements whenever the match set spans more than one parent.The most visible symptom is that
:eq(0)never matches anything, because:eq(n)is routed throughisNthChild(), which is 1-indexed while jQuery's:eq()is 0-indexed.src/CSS/DOMTraverser/PseudoClass.php:129-146:Divergence table
Document:
<div><ul><li>a1</li><li>a2</li><li>a3</li></ul><ul><li>b1</li><li>b2</li></ul></div>find('li')matches five elements in document order:a1, a2, a3, b1, b2.li:eq(0)a1li:eq(1)a2a1, b1li:eq(3)b1a3li:firsta1a1, b1li:lastb2a3, b2li:lt(2)a1, a2a1, a2, b1, b2li:gt(2)b1, b2a3li:odda2, b1a1, a3, b1li:evena1, a3, b2a2, b2Every one of them is wrong. They only coincide with jQuery when the whole match set happens to share a single parent, which is why this has gone unnoticed.
The equivalent methods are correct, because they operate on the match set as jQuery does:
The legacy engine disagrees too
QueryMutators::remove()andQueryMutators::replaceAll()still route through the legacyCSS\QueryPathEventHandler, which implements these pseudo-classes differently again — so the same selector gives three different answers depending on which method you call:Suggested fix
:eq,:first,:last,:lt,:gt,:odd, and:evenare filters over an ordered result set, not node predicates, so they cannot be evaluated correctly by a per-nodematchesPseudoClass()callback. Fixing them properly means applying them after the traversal has collected its matches, rather than during it.If that is too large a change, the minimum worth doing is making
:eq(n)0-indexed so:eq(0)stops silently matching nothing, and documenting the sibling-position semantics of the rest so users are not misled by the jQuery names. Whichever way it goes, the two engines should be made to agree.QueryPath version
4.1.0 (
main, 3036f97)PHP Version and environment
PHP 8.3.16 (cli), macOS. Not version-specific.
Minimal reproducible PHP+HTML snippet to replicate bug