Skip to content

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

Description

@jakejackson1

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]

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions