From 03eaa7cf78beb46889fc32a6c4104ea96f453145 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Fri, 21 Aug 2026 14:46:39 +1000 Subject: [PATCH] Make jQuery positional pseudo-classes index the matched set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :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) Use SplObjectStorage::offsetExists() instead of the deprecated contains() 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) Decide per selector what can only be decided per selector 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) --- CHANGELOG.md | 5 + src/CSS/DOMTraverser.php | 235 +++++++++++- src/CSS/DOMTraverser/PseudoClass.php | 51 ++- src/CSS/DOMTraverser/Util.php | 339 ++++++++++++++++++ src/CSS/QueryPathEventHandler.php | 99 ++--- src/CSS/SimpleSelector.php | 83 +++++ src/documentation.php | 24 +- tests/Issues/Issue66Test.php | 206 +++++++++++ tests/QueryPath/CSS/PseudoClassTest.php | 177 ++------- .../CSS/QueryPathEventHandlerTest.php | 27 +- 10 files changed, 963 insertions(+), 283 deletions(-) create mode 100644 tests/Issues/Issue66Test.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e390a22..59eb2920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ QueryPath Changelog # Unreleased changes +- **Behaviour change.** Fix the jQuery positional pseudo-classes (`:eq()`, `:first`, `:last`, `:lt()`, `:gt()`, `:odd`, `:even`, and the `:nth()` alias of `:eq()`), which indexed an element's siblings instead of the matched set (issue #66). They are now applied, zero-indexed and in document order, to the set the selector matched, as in jQuery. Most visibly, `:eq(0)` now matches the first result instead of never matching. `:lt()`, `:gt()`, `:eq()` and `:nth()` are zero-indexed where they were previously one-indexed, `:odd`/`:even` now index the matched set rather than sibling position, and a negative index counts back from the end of the set (`:eq(-1)` is the last match). The CSS structural pseudo-classes (`:nth-child()`, `:nth-of-type()`, `:first-child`, `:last-child`, ...) are unchanged and still count siblings, one-indexed +- Make `QueryPath\CSS\QueryPathEventHandler`, the legacy engine still used by `remove()` and `replaceAll()`, agree with `QueryPath\CSS\DOMTraverser` on all of the positional pseudo-classes. `remove('li:first')` and `find('li:first')` now select the same element +- Support positional pseudo-classes on any simple selector in a chain, not just the last one: `ul:first li` selects the list items of the first `ul`, and each comma-separated group is filtered independently before the union is returned in document order +- Support positional pseudo-classes as the argument to `:not()`, `:has()` and `:matches()`, so the common `li:not(:first)` idiom now excludes the first match rather than every match +- `QueryPath\CSS\DOMTraverser\PseudoClass::elementMatches()` now throws `QueryPath\CSS\NotImplementedException` for the positional pseudo-classes. They cannot be answered for a node in isolation; use a `Traverser`, which applies them after traversal - Reorganise, modernise, and repair the `examples/` directory. Each example now lives in its own subdirectory with an `index.php`, and the full set is indexed in `examples/quickstart-guide.md` - Convert the remaining legacy examples: `simple_example.php`, `techniques.php`, `svg.php`, `rss.php`, `odt.php`, `parse_php.php`, and `sparql.php` - Fix examples that no longer ran: send a `User-Agent` where remote hosts now require one, resolve paths relative to the example rather than the working directory, and stop relying on the removed `qp.php` autoloader and the PHP 8 incompatible `eachLambda()` diff --git a/src/CSS/DOMTraverser.php b/src/CSS/DOMTraverser.php index 8b8483f9..7c6f09ca 100644 --- a/src/CSS/DOMTraverser.php +++ b/src/CSS/DOMTraverser.php @@ -57,6 +57,15 @@ * - `#myElement` does not get expanded * - `#myElement .class` \i may be expanded to `*#myElement *.class` * (which will obviously not perform well). + * + * \b Positional pseudo-classes + * + * The jQuery positional pseudo-classes (`:eq()`, `:first`, `:last`, `:lt()`, + * `:gt()`, `:odd`, `:even`, and the `:nth()` alias of `:eq()`) are not node + * predicates: they index the ordered set the selector matched. They are + * therefore stripped out of the per-node match and applied afterwards, to the + * document-ordered result set of the simple selector they were written on. See + * resolveGroup(). */ class DOMTraverser implements Traverser { @@ -67,6 +76,27 @@ class DOMTraverser implements Traverser protected $psHandler; protected $scopeNode; + /** + * Pre-resolved match sets for simple selectors that carry a set-level + * pseudo-class but are not the subject of the selector. + * + * Keyed by the simple selector's index within the current selector group. + * + * @var SplObjectStorage[] + */ + protected $setFilterMatches = []; + + /** + * The index of the simple selector currently being resolved. + * + * While resolving index N, the set-level pseudo-classes on index N must + * not be looked up in $setFilterMatches -- that is precisely what is being + * computed. + * + * @var int|null + */ + protected $resolvingIndex; + /** * Build a new DOMTraverser. * @@ -135,27 +165,195 @@ public function find($selector): DOMTraverser $this->selector = $handler; //$selector = $handler->toArray(); - $found = $this->newMatches(); + $found = $this->newMatches(); + $isFiltered = false; foreach ($handler as $selectorGroup) { - // Initialize matches if necessary. - if ($this->initialized) { - $candidates = $this->matches; - } else { - $candidates = $this->initialMatch($selectorGroup[0], $this->matches); + if ($this->groupIsSetFiltered($selectorGroup)) { + $isFiltered = true; + } + + foreach ($this->resolveGroup($selectorGroup) as $candidate) { + // $this->debug('Attaching ' . $candidate->nodeName); + $found->offsetSet($candidate); + } + } + + // Comma-separated groups are filtered independently (as in jQuery), so the + // union of the groups is not necessarily in document order. Restore it. + if ($isFiltered) { + $found = $this->toMatches(Util::sortDocumentOrder(iterator_to_array($found, false))); + } + + $this->setMatches($found); + + return $this; + } + + /** + * Whether any simple selector in the group carries a set-level pseudo-class. + * + * @param SimpleSelector[] $selectorGroup + * + * @return bool + */ + protected function groupIsSetFiltered(array $selectorGroup): bool + { + foreach ($selectorGroup as $simpleSelector) { + if ($simpleSelector->hasSetFilterPseudoClasses()) { + return true; } + } + + return false; + } + + /** + * Resolve a single (comma-delimited) selector group to its matches. + * + * A selector group is stored right-to-left, so index 0 is the subject of + * the selector and higher indexes are its ancestors/siblings. + * + * Positional pseudo-classes filter an ordered set, so any simple selector + * that carries one has to be resolved -- and filtered -- in full before the + * selectors to its right can be evaluated against it. Those sets are + * resolved leftmost-first and cached; matchesSimpleSelector() then answers + * from the cache instead of re-testing the node. + * + * @param SimpleSelector[] $selectorGroup + * + * @return SplObjectStorage + * @throws NotImplementedException + * @throws ParseException + */ + protected function resolveGroup(array $selectorGroup): SplObjectStorage + { + $previousFilterMatches = $this->setFilterMatches; + $this->setFilterMatches = []; + + for ($index = count($selectorGroup) - 1; $index > 0; $index--) { + if ($selectorGroup[$index]->hasSetFilterPseudoClasses()) { + $this->setFilterMatches[$index] = $this->resolveSubject($selectorGroup, $index); + } + } + + $matches = $this->resolveSubject($selectorGroup, 0); + $this->setFilterMatches = $previousFilterMatches; + + return $matches; + } + /** + * Resolve the set of nodes matching the sub-selector whose subject is $index. + * + * @param SimpleSelector[] $selectorGroup + * @param int $index + * + * @return SplObjectStorage + * @throws NotImplementedException + * @throws ParseException + */ + protected function resolveSubject(array $selectorGroup, int $index): SplObjectStorage + { + // Initialize matches if necessary. + if ($index === 0 && $this->initialized) { + $candidates = $this->matches; + } else { + $candidates = $this->initialMatch($selectorGroup[$index], $this->matches); + } + + $setFilters = $selectorGroup[$index]->setFilterPseudoClasses(); + $previous = $this->resolvingIndex; + $this->resolvingIndex = $index; + + // Without a set-level filter nothing downstream depends on the order or the size of + // the result, so it is collected straight into the match storage rather than into an + // array that then has to be copied into one. + if (empty($setFilters)) { + $found = $this->newMatches(); /** @var DOMElement $candidate */ foreach ($candidates as $candidate) { - // fprintf(STDOUT, "Testing %s against %s.\n", $candidate->tagName, $selectorGroup[0]); - if ($this->matchesSelector($candidate, $selectorGroup)) { - // $this->debug('Attaching ' . $candidate->nodeName); + if ($this->matchesSimpleSelector($candidate, $selectorGroup, $index)) { $found->offsetSet($candidate); } } + + $this->resolvingIndex = $previous; + + return $found; } - $this->setMatches($found); - return $this; + $matched = []; + /** @var DOMElement $candidate */ + foreach ($candidates as $candidate) { + if ($this->matchesSimpleSelector($candidate, $selectorGroup, $index)) { + $matched[] = $candidate; + } + } + + $this->resolvingIndex = $previous; + + $matched = Util::sortDocumentOrder($matched); + foreach ($setFilters as $pseudoClass) { + $matched = $this->applySetFilter($matched, $pseudoClass); + } + + return $this->toMatches($matched); + } + + /** + * Apply a single set-level pseudo-class to an ordered list of nodes. + * + * @param array $nodes + * @param array $pseudoClass + * + * @return array + * @throws ParseException + */ + protected function applySetFilter(array $nodes, array $pseudoClass): array + { + $name = strtolower($pseudoClass['name']); + $value = isset($pseudoClass['value']) ? $pseudoClass['value'] : null; + + if (Util::isPositionalPseudoClass($name)) { + return Util::applyPositionalPseudoClass($nodes, $name, $value); + } + + // :not(), :has() and :matches() whose argument uses a positional filter. + // The argument has to see the whole result set, exactly as jQuery's + // :not(:first) does, so run it over this set and keep or drop the result. + if (empty($nodes)) { + return []; + } + + $traverser = new self($this->toMatches($nodes), true, $this->scopeNode); + $inner = $traverser->find($value)->matches(); + + $filtered = []; + foreach ($nodes as $node) { + $isMatch = $inner->offsetExists($node); + if ($name === 'not' ? ! $isMatch : $isMatch) { + $filtered[] = $node; + } + } + + return $filtered; + } + + /** + * Convert a list of nodes into an SplObjectStorage of matches. + * + * @param array $nodes + * + * @return SplObjectStorage + */ + protected function toMatches(array $nodes): SplObjectStorage + { + $matches = $this->newMatches(); + foreach ($nodes as $node) { + $matches->offsetSet($node); + } + + return $matches; } public function matches() @@ -207,13 +405,23 @@ public function matchesSelector(DOMElement $node, $selector) public function matchesSimpleSelector(DOMElement $node, $selectors, $index) { $selector = $selectors[$index]; + + // A set-level pseudo-class on a non-subject simple selector has already + // been resolved against the whole ordered set (see resolveGroup()). The + // cached set accounts for every selector to the left of $index too, so + // there is nothing further to combine. + if ($index !== $this->resolvingIndex && isset($this->setFilterMatches[$index])) { + return $this->setFilterMatches[$index]->offsetExists($node); + } + // Note that this will short circuit as soon as one of these // returns FALSE. $result = $this->matchElement($node, $selector->element, $selector->ns) && $this->matchAttributes($node, $selector->attributes) && $this->matchId($node, $selector->id) && $this->matchClasses($node, $selector->classes) - && $this->matchPseudoClasses($node, $selector->pseudoClasses) + && (empty($selector->pseudoClasses) + || $this->matchPseudoClasses($node, $selector->nodePseudoClasses())) && $this->matchPseudoElements($node, $selector->pseudoElements); $isNextRule = isset($selectors[++$index]); @@ -815,7 +1023,8 @@ protected function matchPseudoClasses(DOMElement $node, $pseudoClasses): bool $name = $pseudoClass['name']; // Avoid E_STRICT violation. $value = $pseudoClass['value'] ?? null; - $ret &= $this->psHandler->elementMatches($name, $node, $this->scopeNode, $value); + + $ret &= $this->psHandler->elementMatches($name, $node, $this->scopeNode, $value); } return $ret; diff --git a/src/CSS/DOMTraverser/PseudoClass.php b/src/CSS/DOMTraverser/PseudoClass.php index 8a4d4b46..a36d3461 100644 --- a/src/CSS/DOMTraverser/PseudoClass.php +++ b/src/CSS/DOMTraverser/PseudoClass.php @@ -104,12 +104,25 @@ public function elementMatches($pseudoclass, $node, $scope, $value = null) case 'x-reset': case 'scope': return $node->isSameNode($scope); - // NON-STANDARD extensions for simple support of even and odd. These - // are supported by jQuery, FF, and other user agents. + // The jQuery positional pseudo-classes index the *matched set*, not a + // node's siblings, so they cannot be answered for a node in isolation. + // QueryPath\CSS\DOMTraverser applies them to its ordered result set + // once traversal has finished. See Util::applyPositionalPseudoClass(). case 'even': - return $this->isNthChild($node, 'even'); case 'odd': - return $this->isNthChild($node, 'odd'); + case 'lt': + case 'gt': + case 'nth': + case 'eq': + case 'first': + case 'last': + throw new NotImplementedException( + sprintf( + ':%s filters an ordered result set and cannot be evaluated against a single node. ' + . 'Use a Traverser, which applies it after traversal.', + $name + ) + ); case 'nth-child': return $this->isNthChild($node, $value); case 'nth-last-child': @@ -125,27 +138,8 @@ public function elementMatches($pseudoclass, $node, $scope, $value = null) case 'only-of-type': return $this->isFirstOfType($node) && $this->isLastOfType($node); - // Additional pseudo-classes defined in jQuery: - case 'lt': - // I'm treating this as "less than or equal to". - $rule = sprintf('-n + %d', (int) $value); - - // $rule = '-n+15'; - return $this->isNthChild($node, $rule); - case 'gt': - // I'm treating this as "greater than" - // return $this->nodePositionFromEnd($node) > (int) $value; - 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); case 'first-child': return $this->isFirst($node); - case 'last': case 'last-child': return $this->isLast($node); case 'only-child': @@ -415,17 +409,16 @@ protected function nodePositionFromEnd($node, $byType = false): int * Provides nth-child and also the functionality required for: * *- nth-last-child - *- even - *- odd - *- first - *- last - *- eq - *- nth *- nth-of-type *- first-of-type *- last-of-type *- nth-last-of-type * + * Note that the jQuery positional pseudo-classes (:even, :odd, :first, + * :last, :eq(), :nth(), :lt(), :gt()) are NOT handled here. They index the + * matched set rather than an element's siblings, and are applied to the + * result set by the traverser. + * * See also QueryPath::CSS::DOMTraverser::Util::parseAnB(). * * @param $node diff --git a/src/CSS/DOMTraverser/Util.php b/src/CSS/DOMTraverser/Util.php index a7206b23..6a3ddac3 100644 --- a/src/CSS/DOMTraverser/Util.php +++ b/src/CSS/DOMTraverser/Util.php @@ -7,13 +7,352 @@ namespace QueryPath\CSS\DOMTraverser; +use DOMNode; use QueryPath\CSS\EventHandler; +use QueryPath\CSS\ParseException; +use QueryPath\CSS\Parser; +use QueryPath\CSS\Selector; +use SplObjectStorage; /** * Utilities for DOM Traversal. */ class Util { + /** + * The jQuery positional pseudo-classes. + * + * These are NOT CSS selectors. Unlike the structural pseudo-classes + * (:nth-child(), :first-child, :nth-of-type(), ...) which describe a + * node's position among its siblings, these describe a node's position + * within the ordered set of nodes matched by the selector. They must + * therefore be applied as a filter over the result set once traversal + * has completed, and cannot be evaluated one node at a time. + * + * All of them are zero-indexed, as in jQuery. + */ + public const POSITIONAL_PSEUDO_CLASSES = [ + 'eq', + 'nth', + 'first', + 'last', + 'lt', + 'gt', + 'even', + 'odd', + ]; + + /** + * Pseudo-classes that take a selector as their argument. + * + * If that argument contains a positional pseudo-class then the whole + * pseudo-class becomes a set filter too, because the argument has to be + * evaluated against the result set rather than against a lone node. + */ + private const SELECTOR_ARGUMENT_PSEUDO_CLASSES = [ + 'not', + 'has', + 'matches', + ]; + + /** + * Memoized results for selectorHasPositionalPseudoClass(). + * + * @var bool[] + */ + /** + * Cap on the positional-selector cache, so a long-running process cannot grow it forever. + */ + const POSITIONAL_CACHE_LIMIT = 256; + + private static $positionalSelectorCache = []; + + /** + * Check whether the given pseudo-class name is a jQuery positional filter. + * + * @param string $name + * + * @return bool + */ + public static function isPositionalPseudoClass($name): bool + { + return in_array(strtolower((string) $name), self::POSITIONAL_PSEUDO_CLASSES, true); + } + + /** + * Check whether a pseudo-class has to be applied to the whole result set. + * + * That is true of the positional pseudo-classes themselves, and of + * :not()/:has()/:matches() when their argument contains one. + * + * @param string $name + * @param mixed $value + * + * @return bool + */ + public static function isSetFilterPseudoClass($name, $value = null): bool + { + if (self::isPositionalPseudoClass($name)) { + return true; + } + + if (! in_array(strtolower((string) $name), self::SELECTOR_ARGUMENT_PSEUDO_CLASSES, true)) { + return false; + } + + return ! empty($value) && self::selectorHasPositionalPseudoClass($value); + } + + /** + * Check whether a selector string uses a positional pseudo-class anywhere. + * + * Results are memoized: this is called once per node during traversal. + * + * @param string $selector + * + * @return bool + */ + public static function selectorHasPositionalPseudoClass($selector): bool + { + $key = (string) $selector; + if (isset(self::$positionalSelectorCache[$key])) { + return self::$positionalSelectorCache[$key]; + } + + $found = false; + $handler = new Selector(); + try { + $parser = new Parser($key, $handler); + $parser->parse(); + + foreach ($handler as $selectorGroup) { + foreach ($selectorGroup as $simpleSelector) { + if ($simpleSelector->hasSetFilterPseudoClasses()) { + $found = true; + break 2; + } + } + } + } catch (ParseException $e) { + // Let the real traversal report the parse error. + $found = false; + } + + // The cache is keyed by selector string, which in a long-running process can be + // unbounded user input. Selectors repeat heavily within one query but rarely across + // unrelated ones, so the table is dropped wholesale rather than evicted entry by entry. + if (count(self::$positionalSelectorCache) >= self::POSITIONAL_CACHE_LIMIT) { + self::$positionalSelectorCache = []; + } + + self::$positionalSelectorCache[$key] = $found; + + return $found; + } + + /** + * Apply a jQuery positional pseudo-class to an ordered list of nodes. + * + * The list is expected to already be in document order. See + * {@see Util::sortDocumentOrder()}. + * + * As in jQuery, indexes are zero-based, and a negative index counts back + * from the end of the set. + * + * @param array $nodes An ordered, zero-indexed list of nodes. + * @param string $name The pseudo-class name. + * @param mixed $value The optional value supplied to the pseudo-class. + * + * @return array The filtered (and re-indexed) list of nodes. + */ + public static function applyPositionalPseudoClass(array $nodes, $name, $value = null): array + { + $nodes = array_values($nodes); + $count = count($nodes); + + if ($count === 0) { + return []; + } + + switch (strtolower((string) $name)) { + case 'first': + return [$nodes[0]]; + case 'last': + return [$nodes[$count - 1]]; + case 'eq': + case 'nth': + $index = self::normalizePositionalIndex($value, $count); + + return isset($nodes[$index]) ? [$nodes[$index]] : []; + case 'lt': + $index = self::normalizePositionalIndex($value, $count); + + return $index > 0 ? array_slice($nodes, 0, $index) : []; + case 'gt': + $index = self::normalizePositionalIndex($value, $count); + + return array_slice($nodes, max(0, $index + 1)); + case 'even': + return self::everyOther($nodes, 0); + case 'odd': + return self::everyOther($nodes, 1); + } + + return $nodes; + } + + /** + * Sort a list of DOM nodes into document order. + * + * PHP's DOM extension does not expose compareDocumentPosition(), so the + * position of each node is derived by walking up the tree and recording + * the offset of each ancestor within its parent. Comparing those paths + * lexicographically yields document order. + * + * @param array $nodes + * + * @return array + */ + public static function sortDocumentOrder(array $nodes): array + { + $nodes = array_values($nodes); + if (count($nodes) < 2) { + return $nodes; + } + + $siblingOffsets = new SplObjectStorage(); + + $decorated = []; + foreach ($nodes as $offset => $node) { + $decorated[] = [self::documentOrderPath($node, $siblingOffsets), $offset, $node]; + } + + usort($decorated, function ($a, $b) { + $comparison = self::comparePaths($a[0], $b[0]); + + // Fall back to the original offset so the sort is stable on PHP 7. + return $comparison !== 0 ? $comparison : $a[1] - $b[1]; + }); + + $sorted = []; + foreach ($decorated as $item) { + $sorted[] = $item[2]; + } + + return $sorted; + } + + /** + * Resolve a (possibly negative) positional index against a set size. + * + * @param mixed $value + * @param int $count + * + * @return int + */ + private static function normalizePositionalIndex($value, int $count): int + { + $index = (int) $value; + + return $index < 0 ? $index + $count : $index; + } + + /** + * Return every other item from the list, beginning at $start. + * + * @param array $nodes + * @param int $start + * + * @return array + */ + private static function everyOther(array $nodes, int $start): array + { + $found = []; + $count = count($nodes); + for ($i = $start; $i < $count; $i += 2) { + $found[] = $nodes[$i]; + } + + return $found; + } + + /** + * Build a comparable path describing a node's position in its document. + * + * @param DOMNode $node + * @param SplObjectStorage $siblingOffsets Memo shared across one sort. + * + * @return int[] + */ + private static function documentOrderPath($node, SplObjectStorage $siblingOffsets): array + { + $path = []; + while ($node instanceof DOMNode && $node->parentNode !== null) { + $path[] = self::siblingOffset($node, $siblingOffsets); + $node = $node->parentNode; + } + + return array_reverse($path); + } + + /** + * Get a node's offset within its parent, indexing the whole sibling list + * the first time any one of them is asked for. + * + * Walking previousSibling per node is quadratic on wide documents (a table + * with thousands of rows, say), and a positional selector can easily ask + * for every row. + * + * @param DOMNode $node + * @param SplObjectStorage $siblingOffsets + * + * @return int + */ + private static function siblingOffset(DOMNode $node, SplObjectStorage $siblingOffsets): int + { + if ($siblingOffsets->offsetExists($node)) { + return $siblingOffsets[$node]; + } + + $offset = 0; + foreach ($node->parentNode->childNodes as $sibling) { + $siblingOffsets[$sibling] = $offset++; + } + + // The node has to be one of its parent's children, but do not assume the + // DOM handed back the same PHP object we were given. + if ($siblingOffsets->offsetExists($node)) { + return $siblingOffsets[$node]; + } + + $offset = 0; + for ($sibling = $node->previousSibling; $sibling !== null; $sibling = $sibling->previousSibling) { + ++$offset; + } + + return $offset; + } + + /** + * Lexicographically compare two document order paths. + * + * @param int[] $a + * @param int[] $b + * + * @return int + */ + private static function comparePaths(array $a, array $b): int + { + $shared = min(count($a), count($b)); + for ($i = 0; $i < $shared; $i++) { + if ($a[$i] !== $b[$i]) { + return $a[$i] < $b[$i] ? -1 : 1; + } + } + + return count($a) - count($b); + } + /** * Check whether the given DOMElement has the given attribute. * diff --git a/src/CSS/QueryPathEventHandler.php b/src/CSS/QueryPathEventHandler.php index c75778d9..13e6b431 100644 --- a/src/CSS/QueryPathEventHandler.php +++ b/src/CSS/QueryPathEventHandler.php @@ -25,6 +25,10 @@ * - pseudo-class filters of the form -an+b do not function as described in the * specification. However, they do behave the same way here as they do in * jQuery. + * - The jQuery positional pseudo-classes (:eq(), :first, :last, :lt(), :gt(), + * :odd, :even) are zero-indexed and filter the matched set, exactly as they + * do in QueryPath\CSS\DOMTraverser. They are not the CSS structural + * pseudo-classes, which count siblings and are one-indexed. * - This library DOES provide XML namespace aware tools. Selectors can use * namespaces to increase specificity. * - This library does nothing with the CSS 3 Selector specificity rating. Of @@ -476,15 +480,6 @@ public function pseudoClass($name, $value = null) $this->matches->offsetSet($this->dom); break; - // NON-STANDARD extensions for simple support of even and odd. These - // are supported by jQuery, FF, and other user agents. - case 'even': - $this->nthChild(2, 0); - break; - case 'odd': - $this->nthChild(2, 1); - break; - // Standard child-checking items. case 'nth-child': [$aVal, $bVal] = Util::parseAnB($value); @@ -529,15 +524,16 @@ public function pseudoClass($name, $value = null) } $this->not($value); break; - // Additional pseudo-classes defined in jQuery: + // Additional (NON-STANDARD) positional pseudo-classes defined by jQuery. + // These index the matched set, not a node's siblings. case 'lt': case 'gt': case 'nth': case 'eq': case 'first': case 'last': - //case 'even': - //case 'odd': + case 'even': + case 'odd': $this->getByPosition($name, $value); break; case 'parent': @@ -632,76 +628,31 @@ private function removeQuotes($str) } /** - * Pseudo-class handler for a variety of jQuery pseudo-classes. - * Handles lt, gt, eq, nth, first, last pseudo-classes. + * Pseudo-class handler for the jQuery positional pseudo-classes. + * + * Handles :lt(), :gt(), :eq(), :nth(), :first, :last, :even and :odd. + * + * These filter the ordered set of currently matched elements. They are NOT + * the CSS structural pseudo-classes, which describe a node's position among + * its siblings; see nthChild() for those. All indexes are zero-based, as in + * jQuery. + * + * @param string $operator + * @param mixed $pos */ private function getByPosition($operator, $pos) { $matches = $this->candidateList(); - $found = new SplObjectStorage(); - if ($matches->count() == 0) { + if ($matches->count() === 0) { return; } - switch ($operator) { - case 'nth': - case 'eq': - if ($matches->count() >= $pos) { - //$found[] = $matches[$pos -1]; - foreach ($matches as $match) { - // CSS is 1-based, so we pre-increment. - if ($matches->key() + 1 == $pos) { - $found->offsetSet($match); - break; - } - } - } - break; - case 'first': - if ($matches->count() > 0) { - $matches->rewind(); // This is necessary to init. - $found->offsetSet($matches->current()); - } - break; - case 'last': - if ($matches->count() > 0) { - // Spin through iterator. - foreach ($matches as $item) { - } + $nodes = Util::sortDocumentOrder(iterator_to_array($matches, false)); + $nodes = Util::applyPositionalPseudoClass($nodes, $operator, $pos); - $found->offsetSet($item); - } - break; - // case 'even': - // for ($i = 1; $i <= count($matches); ++$i) { - // if ($i % 2 == 0) { - // $found[] = $matches[$i]; - // } - // } - // break; - // case 'odd': - // for ($i = 1; $i <= count($matches); ++$i) { - // if ($i % 2 == 0) { - // $found[] = $matches[$i]; - // } - // } - // break; - case 'lt': - $i = 0; - foreach ($matches as $item) { - if (++$i < $pos) { - $found->offsetSet($item); - } - } - break; - case 'gt': - $i = 0; - foreach ($matches as $item) { - if (++$i > $pos) { - $found->offsetSet($item); - } - } - break; + $found = new SplObjectStorage(); + foreach ($nodes as $node) { + $found->offsetSet($node); } $this->matches = $found; diff --git a/src/CSS/SimpleSelector.php b/src/CSS/SimpleSelector.php index 1230eb61..f3d87acc 100644 --- a/src/CSS/SimpleSelector.php +++ b/src/CSS/SimpleSelector.php @@ -8,6 +8,7 @@ namespace QueryPath\CSS; use Exception; +use QueryPath\CSS\DOMTraverser\Util; /** * Models a simple selector. @@ -47,6 +48,17 @@ class SimpleSelector public $pseudoElements = []; public $combinator; + /** + * The pseudo-classes split into set-level filters and per-node tests. + * + * Which side a pseudo-class falls on is a property of the parsed selector, not of any + * node, so it is worked out once. Deciding it per node cost two strtolower() and two + * in_array() calls for every node the selector was tested against. + * + * @var array|null + */ + private $pseudoClassSplit; + /** * @param $code * @@ -98,6 +110,77 @@ public function __construct() { } + /** + * Get the pseudo-classes that have to be applied to a whole result set. + * + * The jQuery positional pseudo-classes (:eq(), :first, :last, :lt(), :gt(), + * :odd, :even) filter an ordered result set rather than testing an + * individual node, so a traverser must apply these after it has collected + * its matches. The same is true of :not(), :has() and :matches() when their + * argument contains one. + * + * They are returned in the order they were written, which is the order they + * must be applied in. + * + * @return array + * @see \QueryPath\CSS\DOMTraverser\Util::POSITIONAL_PSEUDO_CLASSES + */ + public function setFilterPseudoClasses(): array + { + $split = $this->splitPseudoClasses(); + + return $split['set']; + } + + /** + * The pseudo-classes that are tested against an individual node. + * + * The complement of setFilterPseudoClasses(): everything that is not a filter over the + * ordered result set. + * + * @return array + */ + public function nodePseudoClasses(): array + { + $split = $this->splitPseudoClasses(); + + return $split['node']; + } + + /** + * Split the pseudo-classes into set-level filters and per-node tests, once. + * + * @return array + */ + private function splitPseudoClasses(): array + { + if ($this->pseudoClassSplit !== null) { + return $this->pseudoClassSplit; + } + + $split = ['set' => [], 'node' => []]; + foreach ($this->pseudoClasses as $pseudoClass) { + $value = isset($pseudoClass['value']) ? $pseudoClass['value'] : null; + $key = Util::isSetFilterPseudoClass($pseudoClass['name'], $value) ? 'set' : 'node'; + + $split[$key][] = $pseudoClass; + } + + $this->pseudoClassSplit = $split; + + return $split; + } + + /** + * Whether this simple selector carries any set-level pseudo-class. + * + * @return bool + */ + public function hasSetFilterPseudoClasses(): bool + { + return count($this->setFilterPseudoClasses()) > 0; + } + /** * @return bool */ diff --git a/src/documentation.php b/src/documentation.php index 3e66569b..3de03e4b 100644 --- a/src/documentation.php +++ b/src/documentation.php @@ -184,8 +184,8 @@ * - root: The root element of the document * - x-root: The root element that was passed into QueryPath's constructor * - x-reset: Same as above. - * - even: All even elements in a set. First element is odd. - * - odd: Odd elements in a set. First element is odd. + * - even: Matched elements at an even index (0, 2, 4, ...) of the matched set. + * - odd: Matched elements at an odd index (1, 3, 5, ...) of the matched set. * - nth-child: Every nth child in a set. * - nth-last-child: Every nth child in a set, counting from the end. * - nth-of-type: Every nth tag in a set. @@ -198,12 +198,20 @@ * - only-of-type: Matches only if it is the only child of the given tag in a set. * - empty: Selects only empty elements. * - not: The negation operator, takes a CSS3 selector, e.g. :not(strong>a). - * - lt: Items in a set whose index is less than the given integer, e.g. lt(3) - * - gt: Items in a set whose index is greater than the given integer, e.g. gt(3) - * - nth: The nth item in a set, e.g. nth(3) - * - eq: The nth item in a set, e.g. eq(3) - * - first: The first item in a set. - * - last: The last item in a set. + * - lt: Items in the matched set whose index is less than the given integer, e.g. lt(3) + * - gt: Items in the matched set whose index is greater than the given integer, e.g. gt(3) + * - nth: The item at the given index of the matched set. An alias of eq. + * - eq: The item at the given index of the matched set, e.g. eq(3) is the fourth match. + * - first: The first item in the matched set. + * - last: The last item in the matched set. + * + * The eight pseudo-classes above (even, odd, lt, gt, nth, eq, first, last) are + * the jQuery positional filters. As in jQuery, they are ZERO-INDEXED and they + * index the *matched set* -- the ordered list of elements the selector found -- + * not an element's position among its siblings. A negative index counts back + * from the end of the set. To select by sibling position, use the CSS + * structural pseudo-classes (:nth-child(), :first-child, :nth-of-type(), ...), + * which are one-indexed. * - parent: Matches if the item is a parent of child elements. * - enabled: Matches (form) items that are enabled * - disabled: Matches form items that are disabled diff --git a/tests/Issues/Issue66Test.php b/tests/Issues/Issue66Test.php new file mode 100644 index 00000000..d5615d34 --- /dev/null +++ b/tests/Issues/Issue66Test.php @@ -0,0 +1,206 @@ +
  • a1
  • a2
  • a3
  • b1
  • b2
'; + + /** + * The divergence table from issue #66. Every expectation here is what + * jQuery returns for the same selector against the same markup. + * + * @return array + */ + public function positionalProvider(): array + { + return [ + 'eq(0) used to match nothing' => ['li:eq(0)', ['a1']], + 'eq is zero indexed' => ['li:eq(1)', ['a2']], + 'eq spans parents' => ['li:eq(3)', ['b1']], + 'eq past the end' => ['li:eq(9)', []], + 'eq counts back when negative' => ['li:eq(-1)', ['b2']], + 'nth is an alias of eq' => ['li:nth(0)', ['a1']], + 'first' => ['li:first', ['a1']], + 'last' => ['li:last', ['b2']], + 'lt' => ['li:lt(2)', ['a1', 'a2']], + 'lt(0)' => ['li:lt(0)', []], + 'gt' => ['li:gt(2)', ['b1', 'b2']], + 'odd' => ['li:odd', ['a2', 'b1']], + 'even' => ['li:even', ['a1', 'a3', 'b2']], + ]; + } + + /** + * @dataProvider positionalProvider + * + * @param string $selector + * @param array $expected + */ + public function testPositionalPseudoClassesIndexTheMatchedSet($selector, array $expected) + { + $this->assertSame($expected, $this->textOf(html5qp(self::HTML)->find($selector))); + } + + /** + * remove() and replaceAll() use the legacy QueryPathEventHandler engine. + * It has to give the same answer as find(). + * + * @dataProvider positionalProvider + * + * @param string $selector + * @param array $expected + */ + public function testLegacyEngineAgreesWithTheTraverser($selector, array $expected) + { + $removed = $this->textOf(html5qp(self::HTML)->remove($selector)); + sort($removed); + + $sorted = $expected; + sort($sorted); + + $this->assertSame($sorted, $removed); + } + + /** + * The selector and the equivalent method have to agree. + */ + public function testSelectorMatchesTheEquivalentMethod() + { + $this->assertSame('a1', html5qp(self::HTML)->find('li')->eq(0)->text()); + $this->assertSame('a1', html5qp(self::HTML)->find('li:eq(0)')->text()); + + $this->assertSame('b2', html5qp(self::HTML)->find('li')->last()->text()); + $this->assertSame('b2', html5qp(self::HTML)->find('li:last')->text()); + } + + /** + * Positional filters compose with combinators. A filter written on a + * non-subject simple selector applies to that selector's own result set. + */ + public function testPositionalPseudoClassesComposeWithCombinators() + { + $this->assertSame(['a1', 'a2', 'a3'], $this->textOf(html5qp(self::HTML)->find('ul:first li'))); + $this->assertSame(['b1'], $this->textOf(html5qp(self::HTML)->find('ul:last li:first'))); + $this->assertSame(['a2'], $this->textOf(html5qp(self::HTML)->find('ul:eq(0) li:eq(1)'))); + $this->assertSame(['b1'], $this->textOf(html5qp(self::HTML)->find('ul > li:eq(3)'))); + $this->assertSame(['b1', 'b2'], $this->textOf(html5qp(self::HTML)->find('ul:not(:first) li'))); + } + + /** + * Each comma-separated group is filtered on its own, as in jQuery, and the + * union is returned in document order. + */ + public function testEachSelectorGroupIsFilteredIndependently() + { + $this->assertSame(['a1', 'b2'], $this->textOf(html5qp(self::HTML)->find('li:first, li:last'))); + $this->assertSame(['a1', 'a2'], $this->textOf(html5qp(self::HTML)->find('li:eq(1), li:first'))); + } + + /** + * Chained filters are applied left to right over the running set. + */ + public function testPositionalPseudoClassesChain() + { + $this->assertSame(['a2', 'a3'], $this->textOf(html5qp(self::HTML)->find('li:gt(0):lt(2)'))); + } + + /** + * :not(:first) is the common jQuery idiom: the argument sees the whole + * result set, not the node under test. + */ + public function testPositionalPseudoClassesInsideNot() + { + $this->assertSame(['a2', 'a3', 'b1', 'b2'], $this->textOf(html5qp(self::HTML)->find('li:not(:first)'))); + $this->assertSame(['a1', 'a3', 'b1', 'b2'], $this->textOf(html5qp(self::HTML)->find('li:not(:eq(1))'))); + $this->assertSame(['a1', 'a3', 'b2'], $this->textOf(html5qp(self::HTML)->find('li:not(:odd)'))); + $this->assertSame(['a2'], $this->textOf(html5qp(self::HTML)->find('li:not(:first):first'))); + $this->assertSame(['a1'], $this->textOf(html5qp(self::HTML)->find('li:matches(:first)'))); + } + + /** + * The CSS structural pseudo-classes still count siblings. Only the jQuery + * positional set moved to result-set semantics. + */ + public function testStructuralPseudoClassesStillCountSiblings() + { + $this->assertSame(['a1', 'b1'], $this->textOf(html5qp(self::HTML)->find('li:first-child'))); + $this->assertSame(['a3', 'b2'], $this->textOf(html5qp(self::HTML)->find('li:last-child'))); + + // :nth-child() is one-based and counts siblings. + $this->assertSame(['a1', 'b1'], $this->textOf(html5qp(self::HTML)->find('li:nth-child(1)'))); + $this->assertSame(['a2', 'b2'], $this->textOf(html5qp(self::HTML)->find('li:nth-child(even)'))); + $this->assertSame(['a1', 'a3', 'b1'], $this->textOf(html5qp(self::HTML)->find('li:nth-child(odd)'))); + $this->assertSame(['a1', 'b1'], $this->textOf(html5qp(self::HTML)->find('li:nth-of-type(1)'))); + } + + /** + * XML documents go through the same code path. + */ + public function testPositionalPseudoClassesOnXml() + { + $xml = '123'; + + $this->assertSame('1', qp($xml, 'i:eq(0)')->text()); + $this->assertSame('3', qp($xml, 'i:eq(2)')->text()); + $this->assertSame('3', qp($xml, 'i:last')->text()); + $this->assertSame(2, qp($xml, 'i:even')->count()); + } + + /** + * The filter helper itself, which both engines share. + */ + public function testApplyPositionalPseudoClassOnAnEmptySet() + { + $this->assertSame([], Util::applyPositionalPseudoClass([], 'first')); + $this->assertSame([], Util::applyPositionalPseudoClass([], 'eq', 0)); + } + + public function testUtilRecognisesThePositionalSet() + { + foreach (['eq', 'nth', 'first', 'last', 'lt', 'gt', 'odd', 'even', 'EQ', 'First'] as $name) { + $this->assertTrue(Util::isPositionalPseudoClass($name), $name . ' should be positional'); + } + + foreach (['nth-child', 'nth-of-type', 'first-child', 'last-child', 'first-of-type', 'root'] as $name) { + $this->assertFalse(Util::isPositionalPseudoClass($name), $name . ' should not be positional'); + } + } + + /** + * Collect the text of every match, in match order. + * + * @param \QueryPath\DOMQuery $query + * + * @return string[] + */ + private function textOf($query): array + { + $text = []; + foreach ($query as $item) { + $text[] = $item->text(); + } + + return $text; + } +} diff --git a/tests/QueryPath/CSS/PseudoClassTest.php b/tests/QueryPath/CSS/PseudoClassTest.php index 7893d906..ab7be432 100644 --- a/tests/QueryPath/CSS/PseudoClassTest.php +++ b/tests/QueryPath/CSS/PseudoClassTest.php @@ -4,6 +4,7 @@ use DOMDocument; use QueryPath\CSS\DOMTraverser\PseudoClass; +use QueryPath\CSS\NotImplementedException; use QueryPath\CSS\ParseException; use QueryPathTests\TestCase; @@ -227,40 +228,39 @@ public function testParent() $this->assertTrue($ret); } - public function testFirst() + /** + * The jQuery positional pseudo-classes index the matched set, not a node's + * siblings, so PseudoClass cannot answer them for a node in isolation. + * + * @dataProvider positionalPseudoClassProvider + * + * @param string $name + * @param mixed $value + */ + public function testPositionalPseudoClassesAreNotNodePredicates($name, $value) { - $ps = new PseudoClass(); + $this->expectException(NotImplementedException::class); + $xml = '

'; [$ele, $root] = $this->doc($xml, 'q'); - $ret = $ps->elementMatches('first', $ele, $root); - $this->assertTrue($ret); - - [$ele, $root] = $this->doc($xml, 'p'); - $ret = $ps->elementMatches('first', $ele, $root); - $this->assertTrue($ret); + $ps = new PseudoClass(); - [$ele, $root] = $this->doc($xml, 'b'); - $ret = $ps->elementMatches('first', $ele, $root); - $this->assertFalse($ret); + $ps->elementMatches($name, $ele, $root, $value); } - public function testLast() + public function positionalPseudoClassProvider(): array { - $ps = new PseudoClass(); - $xml = '

'; - - [$ele, $root] = $this->doc($xml, 'q'); - $ret = $ps->elementMatches('last', $ele, $root); - $this->assertTrue($ret); - - [$ele, $root] = $this->doc($xml, 'p'); - $ret = $ps->elementMatches('last', $ele, $root); - $this->assertFalse($ret); - - [$ele, $root] = $this->doc($xml, 'b'); - $ret = $ps->elementMatches('last', $ele, $root); - $this->assertTrue($ret); + return [ + ['first', null], + ['last', null], + ['even', null], + ['odd', null], + ['eq', '1'], + ['nth', '1'], + ['lt', '2'], + ['gt', '2'], + ]; } public function testNot() @@ -481,54 +481,6 @@ public function testNthChild($pattern, $matchesCount, $matchTag) $this->assertEquals($matchesCount, $i, 'Invalid matches count'); } - public function testEven() - { - $xml = ''; - $xml .= str_repeat('', 5); - $xml .= ''; - - $ps = new PseudoClass(); - [$ele, $root] = $this->doc($xml, 'root'); - $nl = $root->childNodes; - - $i = 0; - $expects = ['b', 'd']; - foreach ($nl as $n) { - $res = $ps->elementMatches('even', $n, $root); - if ($res) { - ++$i; - $name = $n->tagName; - $this->assertContains($name, $expects, 'Expected a or c, got ' . $name); - } - } - $this->assertEquals(10, $i, ' even is ten items.'); - } - - public function testOdd() - { - $xml = ''; - $xml .= str_repeat('', 5); - $xml .= ''; - - $ps = new PseudoClass(); - [$ele, $root] = $this->doc($xml, 'root'); - $nl = $root->childNodes; - - // Odd - $i = 0; - $expects = ['a', 'c']; - $j = 0; - foreach ($nl as $n) { - $res = $ps->elementMatches('odd', $n, $root); - if ($res) { - ++$i; - $name = $n->tagName; - $this->assertContains($name, $expects, sprintf('Expected b or d, got %s in slot %s', $name, ++$j)); - } - } - $this->assertEquals(10, $i, 'Ten odds.'); - } - public function testNthOfTypeChild() { $xml = ''; @@ -640,83 +592,6 @@ public function testXRoot() { public function testXReset() { } */ - public function testLt() - { - $xml = ''; - $xml .= str_repeat('', 5); - $xml .= ''; - - $ps = new PseudoClass(); - [$ele, $root] = $this->doc($xml, 'root'); - $nl = $root->childNodes; - - // Odd - $i = 0; - foreach ($nl as $n) { - $res = $ps->elementMatches('lt', $n, $root, '15'); - if ($res) { - ++$i; - $name = $n->tagName; - } - } - $this->assertEquals(15, $i, 'Less than or equal to 15.'); - } - - public function testGt() - { - $xml = ''; - $xml .= str_repeat('', 5); - $xml .= ''; - - $ps = new PseudoClass(); - [$ele, $root] = $this->doc($xml, 'root'); - $nl = $root->childNodes; - - // Odd - $i = 0; - foreach ($nl as $n) { - $res = $ps->elementMatches('gt', $n, $root, '15'); - if ($res) { - ++$i; - $name = $n->tagName; - } - } - $this->assertEquals(5, $i, 'Greater than the 15th element.'); - } - - public function testEq() - { - $xml = ''; - $xml .= str_repeat('', 5); - $xml .= ''; - - $ps = new PseudoClass(); - [$ele, $root] = $this->doc($xml, 'root'); - $nl = $root->childNodes; - - $i = 0; - foreach ($nl as $n) { - $res = $ps->elementMatches('eq', $n, $root, '15'); - if ($res) { - ++$i; - $name = $n->tagName; - $this->assertEquals('c', $name); - } - } - $this->assertEquals(1, $i, 'The 15th element.'); - - $i = 0; - foreach ($nl as $n) { - $res = $ps->elementMatches('nth', $n, $root, '15'); - if ($res) { - ++$i; - $name = $n->tagName; - $this->assertEquals('c', $name); - } - } - $this->assertEquals(1, $i, 'The 15th element.'); - } - public function testAnyLink() { $ps = new PseudoClass(); diff --git a/tests/QueryPath/CSS/QueryPathEventHandlerTest.php b/tests/QueryPath/CSS/QueryPathEventHandlerTest.php index 5443aead..ef4bb2dd 100644 --- a/tests/QueryPath/CSS/QueryPathEventHandlerTest.php +++ b/tests/QueryPath/CSS/QueryPathEventHandlerTest.php @@ -618,9 +618,12 @@ public function testChildAtIndex() { public function nthChildProvider(): array { return [ - [':root :even', 3, 'four' ], // full list - ['i:even', 2, 'four' ], // restricted to specific element - ['i:odd', 3, 'three' ], // restricted to specific element, odd this time + // :even and :odd are the jQuery positional filters: they index the + // zero-based matched set, not the sibling position. Contrast with + // :nth-child(even) / :nth-child(odd) below, which are the CSS ones. + [':root :even', 3, 'three' ], // full list + ['i:even', 3, 'three' ], // restricted to specific element + ['i:odd', 2, 'four' ], // restricted to specific element, odd this time ['i:nth-child(odd)', 3, 'three' ], // odd ['i:nth-child(2n+1)', 3, 'three' ], // odd, equiv to 2n + 1 ['i:nth-child(2n-1)', 3, 'three' ], // odd, equiv to 2n + 1 @@ -1045,8 +1048,8 @@ public function testPseudoClassGT() $handler = new QueryPathEventHandler($doc); $handler->find('i:gt(1)'); $matches = $handler->getMatches(); - $this->assertEquals(2, $matches->count()); - $this->assertEquals('two', $this->firstMatch($matches)->getAttribute('id')); + $this->assertEquals(1, $matches->count()); + $this->assertEquals('three', $this->firstMatch($matches)->getAttribute('id')); } public function testPseudoClassLT() @@ -1064,9 +1067,10 @@ public function testPseudoClassLT() $handler = new QueryPathEventHandler($doc); $handler->find('i:lt(3)'); $matches = $handler->getMatches(); - $this->assertEquals(2, $matches->count()); + $this->assertEquals(3, $matches->count()); $this->assertEquals('one', $this->nthMatch($matches, 0)->getAttribute('id')); $this->assertEquals('two', $this->nthMatch($matches, 1)->getAttribute('id')); + $this->assertEquals('three', $this->nthMatch($matches, 2)->getAttribute('id')); } public function testPseudoClassNTH() @@ -1081,16 +1085,23 @@ public function testPseudoClassNTH() $doc->loadXML($xml); $handler = new QueryPathEventHandler($doc); + // :nth() is an alias of :eq(), and both are zero-indexed, as in jQuery. $handler->find('i:nth(2)'); $matches = $handler->getMatches(); $this->assertEquals(1, $matches->count()); - $this->assertEquals('two', $this->firstMatch($matches)->getAttribute('id')); + $this->assertEquals('three', $this->firstMatch($matches)->getAttribute('id')); $handler = new QueryPathEventHandler($doc); $handler->find('i:eq(2)'); $matches = $handler->getMatches(); $this->assertEquals(1, $matches->count()); - $this->assertEquals('two', $this->firstMatch($matches)->getAttribute('id')); + $this->assertEquals('three', $this->firstMatch($matches)->getAttribute('id')); + + $handler = new QueryPathEventHandler($doc); + $handler->find('i:eq(0)'); + $matches = $handler->getMatches(); + $this->assertEquals(1, $matches->count()); + $this->assertEquals('one', $this->firstMatch($matches)->getAttribute('id')); } public function testPseudoClassNthOfType()