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 @@
+