diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e390a2..59eb292 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 8b8483f..7c6f09c 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 8a4d4b4..a36d346 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 a7206b2..6a3ddac 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 c75778d..13e6b43 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 1230eb6..f3d87ac 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 3e66569..3de03e4 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 0000000..d5615d3 --- /dev/null +++ b/tests/Issues/Issue66Test.php @@ -0,0 +1,206 @@ +'; + + /** + * 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 7893d90..ab7be43 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 5443aea..ef4bb2d 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()