diff --git a/CHANGELOG.md b/CHANGELOG.md
index 527a87d..4a82d7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -58,6 +58,19 @@ QueryPath Changelog
- Consolidate the sibling and ancestor walks behind `next()`, `prev()`, `nextAll()`, `prevAll()`, `nextUntil()`, `prevUntil()`, `parent()`, `parents()`, `parentsUntil()` and `closest()` into one helper, so a fix to one no longer has to be repeated in its mirror (issue #115). As a side effect, `parentsUntil()` no longer loops forever on an element that is not attached to the document, and `nextUntil('')` and `prevUntil('')` treat an empty selector as no selector, as the other methods already did, instead of throwing `QueryPath\CSS\ParseException`. `closest('')` returns an empty set, as in jQuery. `nextUntil()`, `prevUntil()` and `parentsUntil()` also stop at an element already collected from an earlier starting element, as `nextAll()` and `prevAll()` do, so `nextUntil()` on each of 2000 siblings falls from about 100 ms to 1 ms
- **Behaviour change.** `prevAll()`, `prevUntil()`, `nextAll()`, `nextUntil()`, `siblings()`, `parent()` and `closest()` now return the results for a multi-element set in jQuery's order, instead of grouped by starting element (issue #117). `prevAll()` and `prevUntil()` return reverse document order, as `parents()` and `parentsUntil()` already did, and the others return document order. `qp($xml, 'c')->add('d')->prevAll()` now returns `c, b, a` rather than `b, a, c`. `next()` and `prev()` keep the order of the starting elements, as in jQuery, and the results for a single starting element are unchanged
- **Behaviour change.** A positional pseudo-class (`:first`, `:eq()`, `:last`, ...) in the stop selector of `nextUntil()`, `prevUntil()` and `parentsUntil()` now counts across the whole document, as jQuery's does, instead of seeing each sibling or ancestor as a one-element set (issue #118). `nextUntil(':first')` used to stop at once and now walks to the end, and `nextUntil(':eq(3)')` stops at the fourth element in the document. `closest()` no longer matches a positional selector at all, as in jQuery, where `closest(':first')` used to return the element itself. `next()`, `prev()` and `parent()` still test each candidate on its own, which agrees with jQuery for a single starting element
+- Fix `siblings()` raising an "Attempt to read property "childNodes" on null" warning and a `foreach()` warning when the match set contains an element with no parent, such as one made with `createElement()` and not yet inserted (issue #139). Such an element has no siblings, so it now adds nothing to the result, as in jQuery
+- **Behaviour change.** `eq()`, `get()` and `slice()` now count a negative index back from the end of the match set, as jQuery does (issue #142). `eq(-1)` and `get(-1)` are the last match, where they used to give an empty set and `NULL`. `slice(-2)` now returns the last two matches instead of the whole set. An index that is still out of range gives an empty set, or `NULL` from `get()`
+- **Breaking behaviour change.** `slice($start, $end)` now takes an exclusive end index as its second argument, as jQuery's `.slice()` does, instead of a length (issue #144). `slice(1, 3)` returns the second and third matches, where it used to return three matches. A negative `$end` counts back from the end, so `slice(1, -1)` drops the first and last matches. Leaving out `$end` still slices to the end of the set, but an explicit `0`, which used to mean "to the end", now gives an empty set. To keep a length, use `slice($start, $start + $length)`
+- **Breaking behaviour change.** `even()` and `odd()` now count from zero, as jQuery's `.even()` and `.odd()` and QueryPath's own `:even` and `:odd` pseudo-classes do (issue #145). `even()` keeps the 1st, 3rd, 5th, ... matches and `odd()` keeps the 2nd, 4th, ..., the reverse of before. Swap the calls to keep the previous result
+- **Behaviour change.** `even()`, `odd()`, `first()` and `last()` now count and return every node in the match set, including text, comment, CDATA and processing-instruction nodes, as jQuery does (issues #143, #146). `even()` and `odd()` used to count those nodes but leave them out of the result, so `contents()->odd()` could be empty, and `first()` and `last()` skipped them, so `first()` could disagree with `eq(0)`. They are now the same as `eq(0)` and `eq(-1)`. To work on elements only, use `children()->first()` or `filter('*')->first()`, and likewise for `last()`, `even()` and `odd()`
+- **Behaviour change.** Fix `filter()`, `not()` and `is()` matching nothing when a positional pseudo-class sits on a compound left of a combinator, as in `ul:first > li` (issue #132). That compound now counts across the whole document, as in jQuery's `.filter()` and in `find()`, so `qp($xml, 'li')->filter('ul:first > li')` returns the items of the document's first `
`. A positional pseudo-class on the subject still counts within the set. `QueryPath\CSS\DOMTraverser::filterNodes()` follows suit
+- **Behaviour change.** `is()` now counts a positional pseudo-class (`:first`, `:last`, `:eq()`, `:lt()`, `:gt()`, `:odd`, `:even`) across the whole document, as jQuery's `.is()` does, instead of within the match set (issue #140). It returns `true` if any element of the set is among the document's matches, so `qp($xml, '#c')->is(':first')` is now `false`. `filter()` still counts within the set. `is()` with a `DOMNode` or `SplObjectStorage` argument, or with a selector that has no positional pseudo-class, is unchanged
+- **Behaviour change.** A positional pseudo-class in the selector of `prevAll()`, `parents()` and `nextAll()` now counts in walk order, before the results are sorted, as jQuery does (issue #134). `qp($xml, '#e')->prevAll(':first')` returns the nearest previous sibling, `d`, instead of the first one, `a`, and `parents(':first')` returns the parent. For a set of more than one element, the walks from each element are joined in match-set order, repeats included, and counted along that list. Add `QueryPath\CSS\DOMTraverser::filterList()`, which filters a list of nodes counting positional pseudo-classes in the order given
+- **Behaviour change.** Fix `:scope` in `find()`, `findInPlace()`, `remove($selector)` and the selector argument of `qp($node, $selector)`, which always referred to the document element, so `qp($xml, 'ul')->find(':scope > li')` matched nothing (issue #133). `:scope` is now the element the search starts from, including inside `:not()` and `:matches()`. When the set holds more than one element and the selector uses `:scope`, the search runs from each element in turn, as jQuery's `find()` does, so positional pseudo-classes count per element, and the results are returned in document order. `filter()` and `is()` are unchanged. `QueryPath\CSS\DOMTraverser\PseudoClass::is()` and `isNot()` take a new optional `$scope` argument, so a subclass overriding either must add it
+- Add `:is()` and `:where()` as aliases of `:matches()` (issue #136). QueryPath doesn't use specificity, so `:where()` matches exactly as `:is()` does, and a positional argument (`:is(:first)`) is applied to the whole set as it is for `:matches()`
+- Deprecate `QueryPath\CSS\SimpleSelector::notEmpty()`, which will be removed in 5.0; use `! isBlank()` instead. It could never return `true`, and is now the inverse of `isBlank()` (issue #138)
+- Fix `replaceAll()` inserting empty copies of the replacement. It copied the replacement node without its children, so only the element and its attributes arrived. It now copies the whole subtree (issue #135)
+- Stop every method that returns a new object (`find()`, `children()`, `filter()`, `eq()`, `next()`, ...) from deep-copying the current matches first (issue #137). A `find()` from the root no longer copies the whole document: on a 50,000-paragraph document, `children()` from the root drops from about 27 ms to 12 ms and `find('#nope')` from about 54 ms to 39 ms. As a result, `end()` now returns the original document nodes instead of detached copies of them. `clone $qp` still deep-copies the matched nodes
# 4.2.0
diff --git a/src/CSS/DOMTraverser.php b/src/CSS/DOMTraverser.php
index 7f443c9..b94fa99 100644
--- a/src/CSS/DOMTraverser.php
+++ b/src/CSS/DOMTraverser.php
@@ -98,6 +98,13 @@ class DOMTraverser implements Traverser
*/
protected $resolvingIndex;
+ /**
+ * The candidates in the order filterList() counts them, or NULL to count in document order.
+ *
+ * @var DOMNode[]|null
+ */
+ protected $candidateList;
+
/**
* Build a new DOMTraverser.
*
@@ -173,8 +180,9 @@ public function find($selector): DOMTraverser
*
* Unlike find(), which searches the descendants of the nodes it starts from, only the given
* nodes can match: for ``, `b` matches the `b` element and not `a`. Combinators are
- * still resolved against the document, so `div > p` works, and positional pseudo-classes
- * (`:first`, `:eq(n)`, ...) count within the given set. Nodes that are not elements never match.
+ * still resolved against the document, so `div > p` works. As in jQuery's .filter(), a positional
+ * pseudo-class (`:first`, `:eq(n)`, ...) counts within the given set when it is on the subject, and
+ * across the document when it is left of a combinator. Nodes that are not elements never match.
*
* @param SplObjectStorage $nodes
* The candidates, all from one document.
@@ -220,6 +228,50 @@ public static function filterNodes(
return $found;
}
+ /**
+ * As filterNodes(), but positional pseudo-classes on the subject count along the list in the
+ * order given, repeats included, as jQuery.filter() counts a traversal's walk.
+ *
+ * @param DOMNode[] $nodes
+ * The candidates, all from one document, in the order to count them.
+ * @param string $selector
+ * A CSS selector.
+ * @param DOMNode|null $scopeNode
+ * @param PseudoClass|null $psHandler
+ *
+ * @return SplObjectStorage
+ * The matching nodes, once each, in the order they first appear in the list.
+ * @throws ParseException
+ */
+ public static function filterList(
+ array $nodes,
+ $selector,
+ $scopeNode = null,
+ ?PseudoClass $psHandler = null
+ ): SplObjectStorage {
+ $unique = new SplObjectStorage();
+ foreach ($nodes as $node) {
+ $unique->offsetSet($node);
+ }
+
+ if (count($unique) === 0) {
+ return $unique;
+ }
+
+ $traverser = new self($unique, true, $scopeNode, $psHandler);
+ $traverser->candidateList = array_values($nodes);
+ $matched = $traverser->find($selector)->matches();
+
+ $found = new SplObjectStorage();
+ foreach ($unique as $node) {
+ if ($matched->offsetExists($node)) {
+ $found->offsetSet($node);
+ }
+ }
+
+ return $found;
+ }
+
/**
* Find the matches of an already-parsed selector.
*
@@ -330,14 +382,18 @@ protected function resolveSubject(array $selectorGroup, int $index): SplObjectSt
// Initialize matches if necessary. A caller-supplied set may hold non-element nodes,
// which can never match a selector.
if ($index === 0 && $this->initialized) {
- $candidates = array_filter(iterator_to_array($this->matches, false), function ($node) {
+ $candidates = array_filter($this->candidateList ?? iterator_to_array($this->matches, false), function ($node) {
return $node instanceof DOMElement;
});
} else {
// initialMatch() consumes the parts of the simple selector it matched on. Give it a copy,
- // so the caller's parsed selector can be reused.
+ // so the caller's parsed selector can be reused. In filterNodes(), a compound left of a
+ // combinator is looked up across the document, so its positional filters count there.
$selectorGroup[$index] = clone $selectorGroup[$index];
- $candidates = $this->initialMatch($selectorGroup[$index], $this->matches);
+ $candidates = $this->initialMatch(
+ $selectorGroup[$index],
+ $this->initialized ? self::documentScope($this->dom) : $this->matches
+ );
}
$setFilters = $selectorGroup[$index]->setFilterPseudoClasses();
@@ -369,7 +425,9 @@ protected function resolveSubject(array $selectorGroup, int $index): SplObjectSt
$this->resolvingIndex = $previous;
- $matched = Util::sortDocumentOrder($matched);
+ if ($index !== 0 || $this->candidateList === null) {
+ $matched = Util::sortDocumentOrder($matched);
+ }
foreach ($setFilters as $pseudoClass) {
$matched = $this->applySetFilter($matched, $pseudoClass);
}
@@ -377,6 +435,23 @@ protected function resolveSubject(array $selectorGroup, int $index): SplObjectSt
return $this->toMatches($matched);
}
+ /**
+ * The document element, standing in for the document as it does in qp($document).
+ *
+ * @param DOMDocument|null $document
+ *
+ * @return SplObjectStorage
+ */
+ public static function documentScope($document): SplObjectStorage
+ {
+ $scope = new SplObjectStorage();
+ if ($document instanceof DOMDocument && $document->documentElement) {
+ $scope->offsetSet($document->documentElement);
+ }
+
+ return $scope;
+ }
+
/**
* Apply a single set-level pseudo-class to an ordered list of nodes.
*
@@ -398,7 +473,7 @@ protected function applySetFilter(array $nodes, array $pseudoClass): array
// :not() 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.
- $inner = self::filterNodes($this->toMatches($nodes), $value, $this->scopeNode, $this->psHandler);
+ $inner = self::filterList($nodes, $value, $this->scopeNode, $this->psHandler);
if ($name !== 'not') {
return iterator_to_array($inner, false);
}
diff --git a/src/CSS/DOMTraverser/PseudoClass.php b/src/CSS/DOMTraverser/PseudoClass.php
index 5815aed..0d1b5cf 100644
--- a/src/CSS/DOMTraverser/PseudoClass.php
+++ b/src/CSS/DOMTraverser/PseudoClass.php
@@ -261,13 +261,15 @@ public function elementMatches($pseudoclass, $node, $scope, $value = null)
case 'has':
return $this->has($node, $value);
case 'matches':
- return $this->is($node, $value);
+ case 'is':
+ case 'where':
+ return $this->is($node, $value, $scope);
case 'not':
if (empty($value)) {
throw new ParseException(':not() requires a value.');
}
- return $this->isNot($node, $value);
+ return $this->isNot($node, $value, $scope);
// Contains == text matches.
// In QP 2.1, this was changed.
case 'contains':
@@ -541,16 +543,16 @@ protected function findsAny(DOMElement $root, DOMElement $scope, array $group, b
}
/**
- * Provides :matches pseudoclass, which tests the node itself.
+ * Provides :matches pseudoclass (and its :is and :where aliases), which tests the node itself.
*
* @throws ParseException
*/
- protected function is($node, $selector): bool
+ protected function is($node, $selector, $scope = null): bool
{
$splos = new SPLObjectStorage();
$splos->offsetSet($node);
- return count(DOMTraverser::filterNodes($splos, $selector, null, $this)) > 0;
+ return count(DOMTraverser::filterNodes($splos, $selector, $scope, $this)) > 0;
}
/**
@@ -558,9 +560,9 @@ protected function is($node, $selector): bool
*
* @throws ParseException
*/
- protected function isNot($node, $selector): bool
+ protected function isNot($node, $selector, $scope = null): bool
{
- return ! $this->is($node, $selector);
+ return ! $this->is($node, $selector, $scope);
}
/**
diff --git a/src/CSS/DOMTraverser/Util.php b/src/CSS/DOMTraverser/Util.php
index 033b14a..304cce8 100644
--- a/src/CSS/DOMTraverser/Util.php
+++ b/src/CSS/DOMTraverser/Util.php
@@ -56,19 +56,28 @@ class Util
private const SELECTOR_ARGUMENT_PSEUDO_CLASSES = [
'not',
'matches',
+ 'is',
+ 'where',
];
+ /**
+ * Cap on each selector cache, so a long-running process cannot grow it forever.
+ */
+ const POSITIONAL_CACHE_LIMIT = 256;
+
/**
* Memoized results for selectorHasPositionalPseudoClass().
*
* @var bool[]
*/
+ private static $positionalSelectorCache = [];
+
/**
- * Cap on the positional-selector cache, so a long-running process cannot grow it forever.
+ * Memoized results for selectorUsesScope().
+ *
+ * @var bool[]
*/
- const POSITIONAL_CACHE_LIMIT = 256;
-
- private static $positionalSelectorCache = [];
+ private static $scopeSelectorCache = [];
/**
* Check whether the given pseudo-class name is a jQuery positional filter.
@@ -86,7 +95,7 @@ public static function isPositionalPseudoClass($name): bool
* 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()/:matches() when their argument contains one.
+ * :not()/:matches()/:is()/:where() when their argument contains one.
*
* @param string $name
* @param mixed $value
@@ -116,39 +125,85 @@ public static function isSetFilterPseudoClass($name, $value = null): bool
* @return bool
*/
public static function selectorHasPositionalPseudoClass($selector): bool
+ {
+ return self::anySimpleSelector(self::$positionalSelectorCache, $selector, function ($simpleSelector) {
+ return $simpleSelector->hasSetFilterPseudoClasses();
+ });
+ }
+
+ /**
+ * Check whether a selector refers to `:scope` (or its `:x-root`/`:x-reset` aliases).
+ *
+ * The arguments of :not(), :matches(), :is() and :where() are searched too. Those of :has() are
+ * not: in there, `:scope` is the element :has() is tested on.
+ *
+ * @param string $selector
+ *
+ * @return bool
+ */
+ public static function selectorUsesScope($selector): bool
+ {
+ return self::anySimpleSelector(self::$scopeSelectorCache, $selector, function ($simpleSelector) {
+ foreach ($simpleSelector->pseudoClasses as $pseudoClass) {
+ $name = strtolower($pseudoClass['name']);
+ if (in_array($name, ['scope', 'x-root', 'x-reset'], true)) {
+ return true;
+ }
+ if (in_array($name, self::SELECTOR_ARGUMENT_PSEUDO_CLASSES, true)
+ && ! empty($pseudoClass['value'])
+ && self::selectorUsesScope($pseudoClass['value'])
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+ }
+
+ /**
+ * Parse a selector and check whether any of its simple selectors passes a test, memoizing the answer.
+ *
+ * A selector that fails to parse answers FALSE; the real traversal reports the error.
+ *
+ * @param bool[] $cache
+ * @param string $selector
+ * @param callable $test
+ *
+ * @return bool
+ */
+ private static function anySimpleSelector(array &$cache, $selector, callable $test): bool
{
$key = (string) $selector;
- if (isset(self::$positionalSelectorCache[$key])) {
- return self::$positionalSelectorCache[$key];
+ if (isset($cache[$key])) {
+ return $cache[$key];
}
$found = false;
$handler = new Selector();
try {
- $parser = new Parser($key, $handler);
- $parser->parse();
+ (new Parser($key, $handler))->parse();
foreach ($handler as $selectorGroup) {
foreach ($selectorGroup as $simpleSelector) {
- if ($simpleSelector->hasSetFilterPseudoClasses()) {
+ if ($test($simpleSelector)) {
$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 = [];
+ if (count($cache) >= self::POSITIONAL_CACHE_LIMIT) {
+ $cache = [];
}
- self::$positionalSelectorCache[$key] = $found;
+ $cache[$key] = $found;
return $found;
}
diff --git a/src/CSS/SimpleSelector.php b/src/CSS/SimpleSelector.php
index 0bae098..55f8cdf 100644
--- a/src/CSS/SimpleSelector.php
+++ b/src/CSS/SimpleSelector.php
@@ -196,17 +196,17 @@ public function isBlank(): bool
}
/**
+ * Whether this restricts the elements it matches. The inverse of isBlank().
+ *
+ * As with isBlank(), the combinator is not counted: it relates this simple selector to the next,
+ * and does not narrow what this one matches.
+ *
* @return bool
+ * @deprecated Use ! isBlank(). This will be removed in 5.0.
*/
public function notEmpty(): bool
{
- return ! empty($this->element)
- && ! empty($this->id)
- && ! empty($this->classes)
- && ! empty($this->combinator)
- && ! empty($this->attributes)
- && ! empty($this->pseudoClasses)
- && ! empty($this->pseudoElements);
+ return ! $this->isBlank();
}
public function __toString()
diff --git a/src/DOM.php b/src/DOM.php
index dbd889b..7391c54 100644
--- a/src/DOM.php
+++ b/src/DOM.php
@@ -10,6 +10,8 @@
use IteratorAggregate;
use Masterminds\HTML5;
use QueryPath\CSS\DOMTraverser;
+use QueryPath\CSS\DOMTraverser\PseudoClass;
+use QueryPath\CSS\DOMTraverser\Util;
use QueryPath\Entities;
use SimpleXMLElement;
use SplObjectStorage;
@@ -168,9 +170,7 @@ public function __construct($document = null, $selector = null, $options = [])
// We don't issue a find because that creates a new DOMQuery.
//$this->find($string);
- $query = new DOMTraverser($this->matches);
- $query->find($selector);
- $this->setMatches($query->matches());
+ $this->setMatches($this->findMatches($selector));
}
}
@@ -371,6 +371,49 @@ public function setMatches($matches)
$this->length = $this->matches->count();
}
+ /**
+ * Search the descendants of the matched nodes for a selector.
+ *
+ * `:scope` is the node the search starts from. When there is more than one and the selector uses
+ * `:scope`, it is run from each node in turn, as jQuery's find() is, and the results are merged in
+ * document order.
+ *
+ * @param string $selector
+ *
+ * @return SplObjectStorage
+ * @throws CSS\ParseException
+ */
+ protected function findMatches($selector): SplObjectStorage
+ {
+ $count = $this->matches->count();
+ if ($count > 1 && ! Util::selectorUsesScope($selector)) {
+ return (new DOMTraverser($this->matches))->find($selector)->matches();
+ }
+
+ $psHandler = new PseudoClass();
+ $psHandler->beginQuery();
+ try {
+ $found = [];
+ foreach ($this->matches as $node) {
+ $start = new SplObjectStorage();
+ $start->offsetSet($node);
+ $scope = $node instanceof DOMElement ? $node : null;
+ foreach ((new DOMTraverser($start, false, $scope, $psHandler))->find($selector)->matches() as $match) {
+ $found[] = $match;
+ }
+ }
+ } finally {
+ $psHandler->endQuery();
+ }
+
+ $sorted = new SplObjectStorage();
+ foreach ($count > 1 ? Util::sortDocumentOrder($found) : $found as $node) {
+ $sorted->offsetSet($node);
+ }
+
+ return $sorted;
+ }
+
/**
* A depth-checking function. Typically, it only needs to be
* invoked with the first parameter. The rest are used for recursion.
@@ -621,14 +664,20 @@ protected function isXMLish($string)
* The internal data structure used in DOMQuery does not have
* strong random access support, so we suppliment it with this method.
*
- * @param $index
+ * @param int $index
+ * Zero-based. A negative index counts back from the end of the matches.
*
- * @return object|void
+ * @return object|null
+ * NULL if the index is out of range.
*/
protected function getNthMatch(int $index)
{
- if ($index < 0 || $index > $this->matches->count()) {
- return;
+ $count = $this->matches->count();
+ if ($index < 0) {
+ $index += $count;
+ }
+ if ($index < 0 || $index >= $count) {
+ return null;
}
$i = 0;
diff --git a/src/DOMQuery.php b/src/DOMQuery.php
index 4c9028b..b6bbdbf 100644
--- a/src/DOMQuery.php
+++ b/src/DOMQuery.php
@@ -16,7 +16,6 @@
use DOMDocument;
use DOMNode;
use DOMXPath;
-use QueryPath\CSS\DOMTraverser;
use Masterminds\HTML5;
use QueryPath\Entities;
use QueryPath\Exception;
@@ -56,6 +55,11 @@ class DOMQuery extends DOM
protected $last = []; // Last set of matches.
private $ext = []; // Extensions array.
+ /**
+ * Set while inst() clones this object, so __clone() skips copying the matches it is about to replace.
+ */
+ private $shallowClone = false;
+
/**
* The number of current matches.
*
@@ -140,10 +144,7 @@ public function top($selector = null): Query
*/
public function find($selector): Query
{
- $query = new DOMTraverser($this->matches);
- $query->find($selector);
-
- return $this->inst($query->matches(), null);
+ return $this->inst($this->findMatches($selector), null);
}
/**
@@ -154,9 +155,7 @@ public function find($selector): Query
*/
public function findInPlace($selector)
{
- $query = new DOMTraverser($this->matches);
- $query->find($selector);
- $this->setMatches($query->matches());
+ $this->setMatches($this->findMatches($selector));
return $this;
}
@@ -266,7 +265,8 @@ public function count(): int
* in QueryPath, see {@link examples/techniques.php}.
*
* @param int|null $index
- * If specified, then only this index value will be returned. If this
+ * If specified, then only this index value will be returned. A negative
+ * index counts back from the end, so -1 is the last match. If this
* index is out of bounds, a NULL will be returned.
* @param boolean $asObject
* If this is TRUE, an SplObjectStorage object will be returned
@@ -281,7 +281,7 @@ public function count(): int
public function get($index = null, $asObject = false)
{
if ($index !== null) {
- return ($this->count() > $index) ? $this->getNthMatch($index) : null;
+ return $this->getNthMatch($index);
}
// Retain support for legacy.
if (! $asObject) {
@@ -1531,7 +1531,12 @@ public function branch($selector = null)
*/
protected function inst($matches, $selector): Query
{
- $dolly = clone $this;
+ $this->shallowClone = true;
+ try {
+ $dolly = clone $this;
+ } finally {
+ $this->shallowClone = false;
+ }
$dolly->setMatches($matches);
if (isset($selector)) {
@@ -1579,6 +1584,12 @@ public function cloneAll(): Query
*/
public function __clone()
{
+ if ($this->shallowClone) {
+ $this->shallowClone = false;
+
+ return;
+ }
+
//XXX: Should we clone the document?
// Make sure we clone the kids.
diff --git a/src/Helpers/NodeMatcher.php b/src/Helpers/NodeMatcher.php
index 8df96fd..cbc0bac 100644
--- a/src/Helpers/NodeMatcher.php
+++ b/src/Helpers/NodeMatcher.php
@@ -2,6 +2,7 @@
namespace QueryPath\Helpers;
+use DOMElement;
use DOMNode;
use QueryPath\CSS\DOMTraverser;
use QueryPath\CSS\DOMTraverser\PseudoClass;
@@ -107,6 +108,31 @@ public function matchesInDocument($node): bool
return $this->inDocument === false ? $this->matches($node) : $this->inDocument->offsetExists($node);
}
+ /**
+ * Test whether at least one of the given nodes matches as jQuery's .is() tests it.
+ *
+ * @see matchesInDocument()
+ *
+ * @param SplObjectStorage $nodes
+ *
+ * @return bool
+ * @throws ParseException
+ */
+ public function matchesAnyInDocument(SplObjectStorage $nodes): bool
+ {
+ if (! Util::selectorHasPositionalPseudoClass($this->selector)) {
+ return $this->matchesAny($nodes);
+ }
+
+ foreach ($nodes as $node) {
+ if ($node instanceof DOMElement && $this->matchesInDocument($node)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* @param \DOMDocument $document
*
@@ -115,13 +141,9 @@ public function matchesInDocument($node): bool
*/
private function documentMatches($document): SplObjectStorage
{
- // As in qp($document), the document element stands in for the document, so it can match.
- $scope = new SplObjectStorage();
- if ($document->documentElement) {
- $scope->offsetSet($document->documentElement);
- }
-
- return (new DOMTraverser($scope, false, null, $this->psHandler))->find($this->selector)->matches();
+ return (new DOMTraverser(DOMTraverser::documentScope($document), false, null, $this->psHandler))
+ ->find($this->selector)
+ ->matches();
}
/**
diff --git a/src/Helpers/QueryChecks.php b/src/Helpers/QueryChecks.php
index 8073a94..723b04f 100644
--- a/src/Helpers/QueryChecks.php
+++ b/src/Helpers/QueryChecks.php
@@ -31,6 +31,10 @@ trait QueryChecks
*
* Unlike jQuery's version, this supports full selectors (not just simple ones).
*
+ * As in jQuery, a positional selector (`:first`, `:eq()`, ...) counts across the whole
+ * document, not within the match set: `is(':first')` is TRUE only if the set holds the
+ * document's first element. filter() counts within the set.
+ *
* Non-element nodes in the match set can never match a CSS selector, and are skipped.
*
* @param string|DOMNode $selector
@@ -73,7 +77,7 @@ public function is($selector): bool
throw new Exception('Cannot compare an object to a DOMQuery.');
}
- return (new NodeMatcher($selector))->matchesAny($this->matches);
+ return (new NodeMatcher($selector))->matchesAnyInDocument($this->matches);
}
/**
diff --git a/src/Helpers/QueryFilters.php b/src/Helpers/QueryFilters.php
index 8b73c7b..beca870 100644
--- a/src/Helpers/QueryFilters.php
+++ b/src/Helpers/QueryFilters.php
@@ -3,6 +3,7 @@
namespace QueryPath\Helpers;
use DOMElement;
+use QueryPath\CSS\DOMTraverser;
use QueryPath\CSS\DOMTraverser\Util;
use QueryPath\CSS\ParseException;
use QueryPath\DOMQuery;
@@ -262,33 +263,37 @@ public function map($callback): Query
/**
* Narrow the items in this object down to only a slice of the starting items.
*
- * @param integer $start
- * Where in the list of matches to begin the slice.
- * @param integer $length
- * The number of items to include in the slice. If nothing is specified, the
- * all remaining matches (from $start onward) will be included in the sliced
- * list.
+ * As in jQuery's .slice(), the slice runs from $start up to, but not
+ * including, $end. Both are zero-based indexes, and a negative index counts
+ * back from the end, so slice(1, -1) drops the first and last items.
+ *
+ * @param integer $start
+ * The index of the first item to include.
+ * @param integer|null $end
+ * The index to stop before. If omitted, the slice runs to the end of the
+ * matches. An $end at or before $start gives an empty set.
*
* @return DOMQuery
* @throws ParseException
- * @see array_slice()
*/
- public function slice($start, $length = 0): Query
+ public function slice($start, $end = null): Query
{
- $end = $length;
- $found = new SplObjectStorage();
- if ($start >= $this->count()) {
- return $this->inst($found, null);
+ $count = $this->count();
+ $start = $start < 0 ? $start + $count : $start;
+ if ($end === null) {
+ $end = $count;
+ } elseif ($end < 0) {
+ $end += $count;
}
- $i = $j = 0;
+ $found = new SplObjectStorage();
+ $i = 0;
foreach ($this->matches as $m) {
+ if ($i >= $end) {
+ break;
+ }
if ($i >= $start) {
- if ($end > 0 && $j >= $end) {
- break;
- }
$found->offsetSet($m);
- ++$j;
}
++$i;
}
@@ -373,82 +378,83 @@ public function eachLambda($lambda): Query
}
/**
- * Get the even elements, so counter-intuitively 1, 3, 5, etc.
+ * Get the matches at even, zero-based positions: 0, 2, 4, etc.
+ *
+ * As in jQuery, and as filter(':even') does, so the first match is kept.
+ * Every node counts and is kept, whatever its type; on a set from
+ * contents(), use filter('*')->even() to count only the elements.
*
* @return DOMQuery
- * A DOMQuery wrapping all of the children.
+ * A DOMQuery wrapping the matches at even positions.
* @throws ParseException
- * @see parent()
- * @see parents()
- * @see next()
- * @see prev()
+ * @see odd()
* @since 2.1
* @author eabrand
- * @see removeChildren()
*/
public function even(): Query
{
- $found = new SplObjectStorage();
- $even = false;
- foreach ($this->matches as $m) {
- if ($even && $m->nodeType === XML_ELEMENT_NODE) {
- $found->offsetSet($m);
- }
- $even = $even ? false : true;
- }
-
- return $this->inst($found, null);
+ return $this->everyOther(0);
}
/**
- * Get the odd elements, so counter-intuitively 0, 2, 4, etc.
+ * Get the matches at odd, zero-based positions: 1, 3, 5, etc.
+ *
+ * As in jQuery, and as filter(':odd') does, so the first match is dropped.
+ * Every node counts and is kept, whatever its type; on a set from
+ * contents(), use filter('*')->odd() to count only the elements.
*
* @return DOMQuery
- * A DOMQuery wrapping all of the children.
+ * A DOMQuery wrapping the matches at odd positions.
* @throws ParseException
- * @see parent()
- * @see parents()
- * @see next()
- * @see prev()
+ * @see even()
* @since 2.1
* @author eabrand
- * @see removeChildren()
*/
public function odd(): Query
+ {
+ return $this->everyOther(1);
+ }
+
+ /**
+ * Keep every second match, starting at the given zero-based position.
+ *
+ * @param int $offset
+ * 0 for the even positions, 1 for the odd.
+ *
+ * @return DOMQuery
+ * @throws ParseException
+ */
+ private function everyOther(int $offset): Query
{
$found = new SplObjectStorage();
- $odd = true;
+ $i = 0;
foreach ($this->matches as $m) {
- if ($odd && $m->nodeType === XML_ELEMENT_NODE) {
+ if ($i++ % 2 === $offset) {
$found->offsetSet($m);
}
- $odd = $odd ? false : true;
}
return $this->inst($found, null);
}
/**
- * Get the first matching element.
+ * Get the first match.
*
+ * As in jQuery, this is the same as eq(0): the first node of any type, so
+ * after contents() it may be a text node. Use children()->first() or
+ * filter('*')->first() for the first element.
*
* @return DOMQuery
- * A DOMQuery wrapping all of the children.
+ * A DOMQuery wrapping the first match, or an empty one.
* @throws ParseException
- * @see prev()
+ * @see last()
+ * @see eq()
* @since 2.1
* @author eabrand
- * @see next()
*/
public function first(): Query
{
- $found = new SplObjectStorage();
- foreach ($this->elements() as $m) {
- $found->offsetSet($m);
- break;
- }
-
- return $this->inst($found, null);
+ return $this->eq(0);
}
/**
@@ -479,29 +485,23 @@ public function firstChild(): Query
}
/**
- * Get the last matching element.
+ * Get the last match.
*
+ * As in jQuery, this is the same as eq(-1): the last node of any type, so
+ * after contents() it may be a text node. Use children()->last() or
+ * filter('*')->last() for the last element.
*
* @return DOMQuery
- * A DOMQuery wrapping all of the children.
+ * A DOMQuery wrapping the last match, or an empty one.
* @throws ParseException
- * @see prev()
+ * @see first()
+ * @see eq()
* @since 2.1
* @author eabrand
- * @see next()
*/
public function last(): Query
{
- $found = new SplObjectStorage();
- $item = null;
- foreach ($this->elements() as $m) {
- $item = $m;
- }
- if ($item) {
- $found->offsetSet($item);
- }
-
- return $this->inst($found, null);
+ return $this->eq(-1);
}
/**
@@ -620,8 +620,9 @@ public function parentsUntil($selector = null): Query
* with {@link end()}.
*
* @param $index
- * The index of the element to keep. The rest will be
- * discarded.
+ * The zero-based index of the item to keep. The rest will be
+ * discarded. A negative index counts back from the end, so -1 keeps
+ * the last item. An index out of range gives an empty set.
*
* @return DOMQuery
* @throws ParseException
@@ -726,7 +727,8 @@ public function parent($selector = null): Query
/**
* Get all ancestors of each element in the DOMQuery.
*
- * If a selector is present, only matching ancestors will be retrieved.
+ * If a selector is present, only matching ancestors will be retrieved. As in jQuery, a
+ * positional selector counts from the nearest ancestor, so `parents(':first')` is the parent.
*
* @param string $selector
* A valid CSS 3 Selector.
@@ -741,7 +743,7 @@ public function parent($selector = null): Query
*/
public function parents($selector = null): Query
{
- return $this->inReverseDocumentOrder($this->walk('parentNode', null, false), $selector);
+ return $this->inReverseDocumentOrder($this->walkAll('parentNode', $selector));
}
/**
@@ -793,7 +795,7 @@ public function next($selector = null): Query
*/
public function nextAll($selector = null): Query
{
- return $this->inDocumentOrder($this->walk('nextSibling', null, false), $selector);
+ return $this->inDocumentOrder($this->walkAll('nextSibling', $selector));
}
/**
@@ -825,7 +827,8 @@ public function prev($selector = null): Query
* Get the previous siblings for each element in the DOMQuery.
*
* For each element in the DOMQuery, get all previous siblings. If a
- * selector is provided, only matching siblings will be retrieved.
+ * selector is provided, only matching siblings will be retrieved. As in jQuery, a
+ * positional selector counts from the nearest sibling, so `prevAll(':first')` is prev().
*
* @param string $selector
* A valid CSS 3 selector.
@@ -842,7 +845,7 @@ public function prev($selector = null): Query
*/
public function prevAll($selector = null): Query
{
- return $this->inReverseDocumentOrder($this->walk('previousSibling', null, false), $selector);
+ return $this->inReverseDocumentOrder($this->walkAll('previousSibling', $selector));
}
/**
@@ -940,8 +943,10 @@ public function siblings($selector = null): Query
{
$found = new SplObjectStorage();
foreach ($this->matches as $m) {
- $parent = $m->parentNode;
- foreach ($parent->childNodes as $n) {
+ if ($m->parentNode === null) {
+ continue;
+ }
+ foreach ($m->parentNode->childNodes as $n) {
if ($n->nodeType === XML_ELEMENT_NODE && $n !== $m) {
$found->offsetSet($n);
}
@@ -1000,6 +1005,38 @@ private function walk(string $axis, $selector, bool $first, bool $inclusive = fa
return $found;
}
+ /**
+ * Walk from each matched element to the end of a DOM axis, keeping the elements that match
+ * an optional selector.
+ *
+ * As in jQuery, a positional selector counts along the walks joined in walk order, so
+ * prevAll(':first') is the nearest previous sibling. An element reached from more than one
+ * starting element is counted once per walk.
+ *
+ * @param string $axis
+ * @param string|null $selector
+ *
+ * @return SplObjectStorage
+ * @throws ParseException
+ */
+ private function walkAll(string $axis, $selector): SplObjectStorage
+ {
+ if (empty($selector) || ! Util::selectorHasPositionalPseudoClass($selector)) {
+ return $this->filterCandidates($this->walk($axis, null, false), $selector);
+ }
+
+ $walked = [];
+ foreach ($this->matches as $m) {
+ for ($n = $m->{$axis}; $n !== null && $n->nodeType !== XML_DOCUMENT_NODE; $n = $n->{$axis}) {
+ if ($n->nodeType === XML_ELEMENT_NODE) {
+ $walked[] = $n;
+ }
+ }
+ }
+
+ return DOMTraverser::filterList($walked, $selector);
+ }
+
/**
* Wrap a traversal's results, optionally filtered by a selector, in document order.
*
diff --git a/src/Helpers/QueryMutators.php b/src/Helpers/QueryMutators.php
index 93ab6b6..d07fb07 100644
--- a/src/Helpers/QueryMutators.php
+++ b/src/Helpers/QueryMutators.php
@@ -795,8 +795,7 @@ public function appendTo(DOMQuery $dest): Query
*/
public function remove($selector = null): Query
{
- // Not find(), which deep-clones the match set via inst().
- $matches = empty($selector) ? $this->matches : (new DOMTraverser($this->matches))->find($selector)->matches();
+ $matches = empty($selector) ? $this->matches : $this->findMatches($selector);
$found = new SplObjectStorage();
foreach ($matches as $item) {
@@ -847,8 +846,7 @@ public function replaceAll($selector, DOMDocument $document): Query
}
foreach ((new DOMTraverser($scope))->find($selector)->matches() as $item) {
- $node = $replacement->cloneNode();
- $node = $document->importNode($node);
+ $node = $document->importNode($replacement, true);
$item->parentNode->replaceChild($node, $item);
}
diff --git a/tests/Issues/Issue118Test.php b/tests/Issues/Issue118Test.php
index d9c9113..1107f3b 100644
--- a/tests/Issues/Issue118Test.php
+++ b/tests/Issues/Issue118Test.php
@@ -71,18 +71,18 @@ public function testNextPrevAndParentTestAPositionalSelectorPerNode()
}
/**
- * The set methods count a positional selector within the set, in document order
- * (issue #66), whatever order the results come back in (issue #117). jQuery counts
- * in walk order, so for prevAll() and parents() its :first is the nearest node.
+ * The set methods count a positional selector along the walk, before the results are
+ * sorted (issue #134), so for prevAll() and parents() :first is the nearest node.
+ * siblings() still counts in document order.
*/
- public function testSetMethodsCountPositionsInDocumentOrder()
+ public function testSetMethodsCountPositionsInWalkOrder()
{
- $this->assertSame('a', $this->idList($this->selectIds(self::SIBLINGS, 'e')->prevAll(':first')));
- $this->assertSame('d', $this->idList($this->selectIds(self::SIBLINGS, 'e')->prevAll(':last')));
- $this->assertSame('b', $this->idList($this->selectIds(self::SIBLINGS, 'c', 'e')->prevAll(':eq(1)')));
- $this->assertSame('b', $this->idList($this->selectIds(self::SIBLINGS, 'c', 'a')->nextAll(':first')));
+ $this->assertSame('d', $this->idList($this->selectIds(self::SIBLINGS, 'e')->prevAll(':first')));
+ $this->assertSame('a', $this->idList($this->selectIds(self::SIBLINGS, 'e')->prevAll(':last')));
+ $this->assertSame('a', $this->idList($this->selectIds(self::SIBLINGS, 'c', 'e')->prevAll(':eq(1)')));
+ $this->assertSame('b', $this->idList($this->selectIds(self::SIBLINGS, 'a', 'c')->nextAll(':first')));
$this->assertSame('a', $this->idList($this->selectIds(self::SIBLINGS, 'd', 'b')->siblings(':first')));
- $this->assertSame('r', $this->idList($this->selectIds(self::ANCESTORS, 'u1', 'u2')->parents(':first')));
- $this->assertSame('t2', $this->idList($this->selectIds(self::ANCESTORS, 'u1', 'u2')->parents(':last')));
+ $this->assertSame('t1', $this->idList($this->selectIds(self::ANCESTORS, 'u1', 'u2')->parents(':first')));
+ $this->assertSame('r', $this->idList($this->selectIds(self::ANCESTORS, 'u1', 'u2')->parents(':last')));
}
}
diff --git a/tests/Issues/Issue132Test.php b/tests/Issues/Issue132Test.php
new file mode 100644
index 0000000..c915774
--- /dev/null
+++ b/tests/Issues/Issue132Test.php
@@ -0,0 +1,61 @@
+';
+
+ /**
+ * @testWith ["ul:first > li", "a,b"]
+ * ["ul:eq(1) > li", "c"]
+ * ["ul:last li", "c"]
+ * ["ul:eq(0) + ul > li", "c"]
+ * ["ul:gt(0) > li:first", "c"]
+ * ["ul:first > li:last", "b"]
+ * ["ul:first > li, ul:last > li", "a,b,c"]
+ */
+ public function testFilterCountsALeftHandPositionalCompoundAcrossTheDocument($selector, $expected)
+ {
+ $this->assertSame($expected, $this->idList(qp(self::XML, 'li')->filter($selector)));
+ }
+
+ public function testTheIssueExamples()
+ {
+ $this->assertSame('a,b', $this->idList(qp(self::XML)->find('ul:first > li')));
+ $this->assertSame('a,b', $this->idList(qp(self::XML, 'li')->filter('ul:first > li')));
+ $this->assertTrue(qp(self::XML, 'li')->is('ul:first > li'));
+ $this->assertSame('c', $this->idList(qp(self::XML, 'li')->filter('ul:eq(1) > li')));
+ }
+
+ public function testTheCompoundIsNotLimitedToTheNodesInHand()
+ {
+ // u1 is not in the set, but is still the document's first .
+ $this->assertSame('b', $this->idList(qp(self::XML, '#b')->filter('ul:first > li')));
+ $this->assertSame('', $this->idList(qp(self::XML, '#c')->filter('ul:first > li')));
+ $this->assertSame('c', $this->idList(qp(self::XML, 'li')->not('ul:first > li')));
+ }
+
+ public function testFilterNodes()
+ {
+ $nodes = new SplObjectStorage();
+ foreach (qp(self::XML, 'li')->get() as $node) {
+ $nodes->offsetSet($node);
+ }
+
+ $this->assertCount(2, DOMTraverser::filterNodes($nodes, 'ul:first > li'));
+ $this->assertCount(1, DOMTraverser::filterNodes($nodes, 'ul:eq(1) > li'));
+ }
+}
diff --git a/tests/Issues/Issue133Test.php b/tests/Issues/Issue133Test.php
new file mode 100644
index 0000000..dc95436
--- /dev/null
+++ b/tests/Issues/Issue133Test.php
@@ -0,0 +1,100 @@
+ li')`. With more than one element in
+ * the set, jQuery runs find() from each of them, so `:scope` is each element in turn.
+ */
+
+namespace QueryPathTests;
+
+use QueryPath\CSS\DOMTraverser\Util;
+
+class Issue133Test extends TestCase
+{
+ public const XML = '';
+
+ public function testScopeIsTheElementFindStartsFrom()
+ {
+ $this->assertSame('a,b', $this->idList(qp(self::XML, '#u1')->find(':scope > li')));
+ $this->assertSame('d', $this->idList(qp(self::XML, '#u1')->find('li:not(:scope > li)')));
+ $this->assertSame('a,b', $this->idList(qp(self::XML, '#u1')->find('li:matches(:scope > li)')));
+ $this->assertSame('a,b', $this->idList(qp(self::XML, '#u1')->find('li:is(:scope > li)')));
+ $this->assertSame('', $this->idList(qp(self::XML, '#u1')->find(':scope')));
+ }
+
+ public function testScopeIsEachElementOfTheSet()
+ {
+ $this->assertSame('a,b,c', $this->idList(qp(self::XML, '#u1, #u2')->find(':scope > li')));
+ $this->assertSame('a,b,d,c', $this->idList(qp(self::XML, 'ul')->find(':scope > li')));
+ $this->assertSame('', $this->idList(qp(self::XML, '#u1, #u2')->find(':scope + ul li')));
+ $this->assertSame('u3', $this->idList(qp(self::XML, '#u1, #u2')->find(':scope > li > ul')));
+ }
+
+ public function testPositionalPseudoClassesCountPerElementWhenScopeIsUsed()
+ {
+ $this->assertSame('a,c', $this->idList(qp(self::XML, '#u1, #u2')->find(':scope > li:first')));
+ $this->assertSame('a', $this->idList(qp(self::XML, '#u1, #u2')->find('li:first')));
+ }
+
+ public function testFindInPlaceAndTheConstructor()
+ {
+ $this->assertSame('a,b', $this->idList(qp(self::XML, '#u1')->findInPlace(':scope > li')));
+
+ $u1 = qp(self::XML, '#u1')->get(0);
+ $this->assertSame('a,b', $this->idList(qp($u1, ':scope > li')));
+ }
+
+ public function testRemoveWithASelector()
+ {
+ $qp = qp(self::XML, '#u1');
+ $this->assertSame('a,b', $this->idList($qp->remove(':scope > li')));
+ $this->assertSame('', $this->idList($qp->find('li')));
+ }
+
+ public function testScopeIsStillTheDocumentElementFromTheTop()
+ {
+ $this->assertSame('u1,u2', $this->idList(qp(self::XML)->find(':scope > ul')));
+ $this->assertSame('root', qp(self::XML)->find(':scope')->tag());
+ $this->assertSame('root', qp(self::XML, 'li')->top()->find(':scope')->tag());
+ }
+
+ public function testHasKeepsItsOwnScope()
+ {
+ $this->assertSame('b', $this->idList(qp(self::XML, '#u1')->find('li:has(> ul)')));
+ $this->assertSame('u3', $this->idList(qp(self::XML, '#u1')->find('ul:has(> li)')));
+ }
+
+ public function testFilterAndIsAreUnchanged()
+ {
+ $this->assertSame('', $this->idList(qp(self::XML, 'ul')->filter(':scope')));
+ $this->assertSame('', $this->idList(qp(self::XML, 'root')->children(':scope')));
+ $this->assertTrue(qp(self::XML, 'root')->is(':scope'));
+ }
+
+ /**
+ * @dataProvider scopeSelectors
+ */
+ public function testSelectorUsesScope($selector, $expected)
+ {
+ $this->assertSame($expected, Util::selectorUsesScope($selector));
+ }
+
+ public function scopeSelectors(): array
+ {
+ return [
+ 'plain' => ['ul > li', false],
+ ':scope' => [':scope > li', true],
+ ':x-root' => [':x-root > li', true],
+ 'second group' => ['ol, :scope li', true],
+ 'inside :not()' => ['li:not(:scope > li)', true],
+ 'inside :is()' => ['li:is(:not(:scope > li))', true],
+ 'inside :has()' => ['li:has(:scope > ul)', false],
+ 'attribute' => ['[name=scope]', false],
+ 'parse error' => [':scope[', false],
+ ];
+ }
+}
diff --git a/tests/Issues/Issue134Test.php b/tests/Issues/Issue134Test.php
new file mode 100644
index 0000000..a45c5f3
--- /dev/null
+++ b/tests/Issues/Issue134Test.php
@@ -0,0 +1,145 @@
+';
+
+ // Document order: r, s1, t1, u1, s2, t2, u2.
+ private const ANCESTORS = '';
+
+ /**
+ * @testWith [":first", "d"]
+ * [":last", "a"]
+ * [":eq(0)", "d"]
+ * [":eq(1)", "c"]
+ * [":eq(-1)", "a"]
+ * [":lt(2)", "d,c"]
+ * [":gt(1)", "b,a"]
+ * [":odd", "c,a"]
+ * [":even", "d,b"]
+ * ["*:not(:first)", "c,b,a"]
+ * [":matches(:last)", "a"]
+ * ["b, :first", "d,b"]
+ */
+ public function testPrevAllCountsFromTheNearestSibling($selector, $expected)
+ {
+ $this->assertSame($expected, $this->idList(qp(self::SIBLINGS, '#e')->prevAll($selector)));
+ }
+
+ /**
+ * The walks from c and e join as b, a, d, c, b, a.
+ *
+ * @testWith [":first", "b"]
+ * [":last", "a"]
+ * [":eq(2)", "d"]
+ * [":eq(4)", "b"]
+ * [":lt(3)", "d,b,a"]
+ * [":gt(3)", "b,a"]
+ * [":odd", "c,a"]
+ */
+ public function testPrevAllOnASetCountsAlongTheJoinedWalks($selector, $expected)
+ {
+ $this->assertSame($expected, $this->idList($this->selectIds(self::SIBLINGS, 'c', 'e')->prevAll($selector)));
+ }
+
+ /**
+ * @testWith ["u2", ":first", "t2"]
+ * ["u2", ":last", "r"]
+ * ["u2", ":eq(1)", "s2"]
+ * ["u2", "s:first", "s2"]
+ * ["u1,u2", ":first", "t1"]
+ * ["u1,u2", ":eq(3)", "t2"]
+ * ["u1,u2", ":lt(2)", "t1,s1"]
+ * ["u1,u2", ":gt(3)", "s2,r"]
+ * ["u1,u2", "s:last", "s2"]
+ */
+ public function testParentsCountsFromTheNearestAncestor($ids, $selector, $expected)
+ {
+ $query = $this->selectIds(self::ANCESTORS, ...explode(',', $ids));
+
+ $this->assertSame($expected, $this->idList($query->parents($selector)));
+ }
+
+ /**
+ * The walks from a and c join as b, c, d, e, d, e.
+ *
+ * @testWith [":first", "b"]
+ * [":eq(1)", "c"]
+ * [":eq(4)", "d"]
+ * [":last", "e"]
+ */
+ public function testNextAllOnASetCountsAlongTheJoinedWalks($selector, $expected)
+ {
+ $this->assertSame($expected, $this->idList($this->selectIds(self::SIBLINGS, 'a', 'c')->nextAll($selector)));
+ }
+
+ /**
+ * The walks are joined in match-set order, as jQuery joins them in its own set's order. add()
+ * keeps the order elements were added in, where jQuery's sorts them into document order.
+ */
+ public function testWalksAreJoinedInMatchSetOrder()
+ {
+ $this->assertSame('d', $this->idList($this->selectIds(self::SIBLINGS, 'c', 'a')->nextAll(':first')));
+ $this->assertSame('d', $this->idList($this->selectIds(self::SIBLINGS, 'e', 'c')->prevAll(':first')));
+ }
+
+ public function testSelectorsWithoutAPositionAreUnchanged()
+ {
+ $this->assertSame('d,c,b,a', $this->idList(qp(self::SIBLINGS, '#e')->prevAll()));
+ $this->assertSame('c,a', $this->idList(qp(self::SIBLINGS, '#e')->prevAll('a, c')));
+ $this->assertSame('t2,s2,t1,s1', $this->idList($this->selectIds(self::ANCESTORS, 'u1', 'u2')->parents('s, t')));
+ $this->assertSame('d,e', $this->idList($this->selectIds(self::SIBLINGS, 'c', 'a')->nextAll('d, e')));
+ }
+
+ /**
+ * The stop selector of prevUntil() and parentsUntil() already counts across the document
+ * (issue #118), so walk order does not affect it.
+ */
+ public function testUntilStopsAreUnaffected()
+ {
+ $this->assertSame('d,c', $this->idList(qp(self::SIBLINGS, '#e')->prevUntil(':eq(2)')));
+ $this->assertSame('t2', $this->idList(qp(self::ANCESTORS, '#u2')->parentsUntil(':eq(4)')));
+ }
+
+ public function testFilterListCountsInTheOrderGiven()
+ {
+ $xpath = new DOMXPath(qp(self::SIBLINGS)->document());
+ $nodes = [];
+ foreach (['d', 'c', 'b', 'c'] as $id) {
+ $nodes[] = $xpath->query("//*[@id='$id']")->item(0);
+ }
+
+ $ids = function ($storage) {
+ $found = [];
+ foreach ($storage as $node) {
+ $found[] = $node->getAttribute('id');
+ }
+
+ return implode(',', $found);
+ };
+
+ $this->assertSame('d', $ids(DOMTraverser::filterList($nodes, ':first')));
+ $this->assertSame('c', $ids(DOMTraverser::filterList($nodes, ':last')));
+ $this->assertSame('c', $ids(DOMTraverser::filterList($nodes, ':eq(3)')));
+ $this->assertSame('d,c,b', $ids(DOMTraverser::filterList($nodes, '*')));
+ $this->assertSame('c,b', $ids(DOMTraverser::filterList($nodes, ':gt(0)')));
+ $this->assertSame('', $ids(DOMTraverser::filterList([], ':first')));
+ }
+}
diff --git a/tests/Issues/Issue135Test.php b/tests/Issues/Issue135Test.php
new file mode 100644
index 0000000..87f051c
--- /dev/null
+++ b/tests/Issues/Issue135Test.php
@@ -0,0 +1,47 @@
+')->get(0)->ownerDocument;
+
+ qp('hi', 'x')->replaceAll('p', $document);
+
+ $copies = qp($document)->children();
+ $this->assertSame(['x', 'x'], array_map(function ($node) {
+ return $node->tagName;
+ }, $copies->get()));
+ foreach ($copies->get() as $copy) {
+ $this->assertSame('1', $copy->getAttribute('a'));
+ $this->assertSame('hi', $copy->textContent);
+ $this->assertSame(1, qp($copy)->children('y')->count());
+ }
+ }
+
+ public function testReplaceAllInsertsSeparateCopies()
+ {
+ $document = qp('')->get(0)->ownerDocument;
+ $source = qp('', 'x');
+
+ $source->replaceAll('p', $document);
+
+ $copies = qp($document)->find('x')->get();
+ $this->assertCount(2, $copies);
+ $this->assertNotSame($copies[0], $copies[1]);
+ $this->assertNotSame($copies[0]->firstChild, $copies[1]->firstChild);
+ $this->assertSame(1, qp($source->get(0)->ownerDocument)->find('x > y')->count());
+ }
+}
diff --git a/tests/Issues/Issue136Test.php b/tests/Issues/Issue136Test.php
new file mode 100644
index 0000000..d35b084
--- /dev/null
+++ b/tests/Issues/Issue136Test.php
@@ -0,0 +1,48 @@
+';
+
+ /**
+ * @dataProvider aliases
+ */
+ public function testAliasesMatchAsMatchesDoes(string $name)
+ {
+ $this->assertSame('b', $this->idList(qp(self::XML)->find("li:$name(#b)")));
+ $this->assertSame('b', $this->idList(qp(self::XML)->find(":$name(#b)")));
+ $this->assertSame('a,b', $this->idList(qp(self::XML)->find("li:$name(#a, #b)")));
+ $this->assertSame('a', $this->idList(qp(self::XML)->find("li:not(:$name(.x))")));
+ $this->assertSame('b,c', $this->idList(qp(self::XML, 'li')->filter(":$name(.x)")));
+ $this->assertTrue(qp(self::XML, '#c')->is(":$name(.x)"));
+ }
+
+ /**
+ * @dataProvider aliases
+ */
+ public function testPositionalArgumentIsAppliedToTheSet(string $name)
+ {
+ $this->assertSame('a', $this->idList(qp(self::XML)->find("li:$name(:first)")));
+ $this->assertSame('b', $this->idList(qp(self::XML)->find("li:$name(.x:first)")));
+ $this->assertSame('a,c', $this->idList(qp(self::XML)->find("li:not(:$name(:eq(1)))")));
+ }
+
+ public function aliases(): array
+ {
+ return [
+ 'is' => ['is'],
+ 'where' => ['where'],
+ 'upper case' => ['IS'],
+ ];
+ }
+}
diff --git a/tests/Issues/Issue137Test.php b/tests/Issues/Issue137Test.php
new file mode 100644
index 0000000..2894ce3
--- /dev/null
+++ b/tests/Issues/Issue137Test.php
@@ -0,0 +1,116 @@
+xy
z
';
+
+ public function chainProvider(): array
+ {
+ return [
+ 'find' => [function ($qp) {
+ return $qp->find('p');
+ }],
+ 'children' => [function ($qp) {
+ return $qp->children();
+ }],
+ 'filter' => [function ($qp) {
+ return $qp->filter('root');
+ }],
+ 'eq' => [function ($qp) {
+ return $qp->eq(0);
+ }],
+ 'not' => [function ($qp) {
+ return $qp->not('p');
+ }],
+ ];
+ }
+
+ /**
+ * @dataProvider chainProvider
+ */
+ public function testEndReturnsTheOriginalNodes(callable $chain)
+ {
+ $qp = qp(self::XML);
+ $root = $qp->get(0);
+
+ $previous = $chain($qp)->end();
+
+ $this->assertSame(1, $previous->count());
+ $this->assertSame($root, $previous->get(0));
+ $this->assertSame($root->ownerDocument->documentElement, $previous->get(0));
+ }
+
+ public function testEndAfterChainedFindsEditsTheDocument()
+ {
+ $qp = qp(self::XML);
+
+ $qp->find('p')->find('i')->end()->attr('class', 'seen');
+
+ $this->assertSame(2, $qp->find('p.seen')->count());
+ }
+
+ public function testResultsAreTheDocumentsNodes()
+ {
+ $qp = qp(self::XML);
+ $document = $qp->get(0)->ownerDocument;
+
+ $found = $qp->find('p')->children('i');
+
+ $this->assertSame(2, $found->count());
+ foreach ($found->get() as $node) {
+ $this->assertSame($document, $node->ownerDocument);
+ $this->assertSame($document->documentElement, $node->parentNode->parentNode);
+ }
+ $this->assertSame($qp->get(0), $qp->find('p')->get(0)->parentNode);
+ }
+
+ public function testCloneStillDeepCopies()
+ {
+ $qp = qp(self::XML, 'p');
+ $qp->find('i');
+
+ $copy = clone $qp;
+
+ $this->assertSame(2, $copy->count());
+ $originals = $qp->get();
+ foreach ($copy->get() as $index => $node) {
+ $this->assertInstanceOf(DOMElement::class, $node);
+ $this->assertNotSame($originals[$index], $node);
+ $this->assertNull($node->parentNode);
+ $this->assertSame($originals[$index]->getAttribute('id'), $node->getAttribute('id'));
+ $this->assertSame($originals[$index]->childNodes->length, $node->childNodes->length);
+ }
+
+ $copy->find('i')->attr('class', 'copy');
+ $this->assertSame(0, $qp->find('i.copy')->count());
+ }
+
+ public function testCloneOfAnInstDeepCopies()
+ {
+ $found = qp(self::XML)->find('p');
+
+ $copy = clone $found;
+
+ $this->assertNotSame($found->get(0), $copy->get(0));
+ $this->assertNull($copy->get(0)->parentNode);
+
+ $again = clone $copy->find('i');
+ $this->assertNotSame($copy->find('i')->get(0), $again->get(0));
+ }
+}
diff --git a/tests/Issues/Issue139Test.php b/tests/Issues/Issue139Test.php
new file mode 100644
index 0000000..2c64b3d
--- /dev/null
+++ b/tests/Issues/Issue139Test.php
@@ -0,0 +1,40 @@
+')->get(0)->ownerDocument;
+ $lone = $doc->createElement('lone');
+
+ $this->assertSame(0, qp($lone)->siblings()->count());
+ $this->assertSame(0, qp($lone)->siblings('*')->count());
+ }
+
+ public function testSiblingsSkipsAParentlessElementInASet()
+ {
+ $qp = qp('', '#b');
+ $lone = $qp->get(0)->ownerDocument->createElement('lone');
+
+ $this->assertSame('a,c', $this->idList($qp->add($lone)->siblings()));
+ }
+
+ public function testSiblingsInADetachedSubtree()
+ {
+ $doc = qp('')->get(0)->ownerDocument;
+ $div = $doc->createElement('div');
+ $a = $div->appendChild($doc->createElement('a'));
+ $b = $div->appendChild($doc->createElement('b'));
+
+ $this->assertSame([$b], qp($a)->siblings()->get());
+ }
+}
diff --git a/tests/Issues/Issue140Test.php b/tests/Issues/Issue140Test.php
new file mode 100644
index 0000000..494ae9b
--- /dev/null
+++ b/tests/Issues/Issue140Test.php
@@ -0,0 +1,83 @@
+';
+
+ public function issueTableProvider(): array
+ {
+ return [
+ '#c :first' => [['c'], ':first', false],
+ '#c i:first' => [['c'], 'i:first', false],
+ '#a i:last' => [['a'], 'i:last', false],
+ '#c, #d i:eq(1)' => [['c', 'd'], 'i:eq(1)', false],
+ '#a :eq(1)' => [['a'], ':eq(1)', true],
+ '#c i:eq(2)' => [['c'], 'i:eq(2)', true],
+ '#c :eq(2)' => [['c'], ':eq(2)', false],
+ '#c, #d i:last' => [['c', 'd'], 'i:last', true],
+ '#a, #b i:gt(1)' => [['a', 'b'], 'i:gt(1)', false],
+ '#a, #d i:odd' => [['a', 'd'], 'i:odd', true],
+ '#b i:not(:first)' => [['b'], 'i:not(:first)', true],
+ '#a i:not(:first)' => [['a'], 'i:not(:first)', false],
+ ];
+ }
+
+ /**
+ * @dataProvider issueTableProvider
+ */
+ public function testIsCountsAPositionalSelectorAcrossTheDocument(array $ids, $selector, $expected)
+ {
+ $this->assertSame($expected, $this->selectIds(self::XML, ...$ids)->is($selector));
+ }
+
+ public function testFilterStillCountsWithinTheSet()
+ {
+ $this->assertSame('c', $this->idList($this->selectIds(self::XML, 'c')->filter('i:first')));
+ $this->assertSame('d', $this->idList($this->selectIds(self::XML, 'c', 'd')->filter('i:eq(1)')));
+ }
+
+ public function testNonPositionalSelectorsAreUnchanged()
+ {
+ $qp = $this->selectIds(self::XML, 'b', 'c');
+
+ $this->assertTrue($qp->is('i'));
+ $this->assertTrue($qp->is('#c'));
+ $this->assertTrue($qp->is('r > i:nth-child(2)'));
+ $this->assertFalse($qp->is('#a'));
+ $this->assertFalse($qp->is('r'));
+ }
+
+ public function testNodeAndSetArgumentsAreUnchanged()
+ {
+ $qp = $this->selectIds(self::XML, 'c');
+ $this->assertTrue($qp->is($qp->get(0)));
+ $this->assertFalse($qp->is(qp(self::XML, '#c')->get(0)));
+
+ $set = new SplObjectStorage();
+ $set->offsetSet($qp->get(0));
+ $this->assertTrue($qp->is($set));
+ }
+
+ public function testNodesThatAreNotElementsAreSkipped()
+ {
+ $xml = 'text';
+
+ $this->assertTrue(qp($xml, 'r')->contents()->is('i:first'));
+ $this->assertFalse(qp($xml, 'r')->contents()->is('i:eq(5)'));
+ $this->assertFalse(qp($xml, 'r')->contents()->eq(0)->is(':first'));
+ }
+}
diff --git a/tests/Issues/Issue142Test.php b/tests/Issues/Issue142Test.php
new file mode 100644
index 0000000..89f07bd
--- /dev/null
+++ b/tests/Issues/Issue142Test.php
@@ -0,0 +1,48 @@
+';
+
+ public function testEqCountsANegativeIndexFromTheEnd()
+ {
+ $qp = qp(self::XML, 'i');
+
+ $this->assertSame('e', $this->idList($qp->eq(-1)));
+ $this->assertSame('d', $this->idList($qp->eq(-2)));
+ $this->assertSame('a', $this->idList($qp->eq(-5)));
+ $this->assertSame('', $this->idList($qp->eq(-6)));
+ $this->assertSame('', $this->idList($qp->eq(5)));
+ $this->assertSame('', $this->idList(qp(self::XML, 'nothing')->eq(-1)));
+ }
+
+ public function testGetCountsANegativeIndexFromTheEnd()
+ {
+ $qp = qp(self::XML, 'i');
+
+ $this->assertSame('e', $qp->get(-1)->getAttribute('id'));
+ $this->assertSame('a', $qp->get(-5)->getAttribute('id'));
+ $this->assertNull($qp->get(-6));
+ $this->assertNull($qp->get(5));
+ $this->assertNull(qp(self::XML, 'nothing')->get(-1));
+ }
+
+ public function testSliceCountsANegativeStartFromTheEnd()
+ {
+ $qp = qp(self::XML, 'i');
+
+ $this->assertSame('d,e', $this->idList($qp->slice(-2)));
+ $this->assertSame('e', $this->idList($qp->slice(-1)));
+ $this->assertSame('a,b,c,d,e', $this->idList($qp->slice(-5)));
+ $this->assertSame('a,b,c,d,e', $this->idList($qp->slice(-9)));
+ }
+}
diff --git a/tests/Issues/Issue143Test.php b/tests/Issues/Issue143Test.php
new file mode 100644
index 0000000..dc410cf
--- /dev/null
+++ b/tests/Issues/Issue143Test.php
@@ -0,0 +1,80 @@
+xyz';
+
+ public function testEvenCountsAndKeepsEveryNode()
+ {
+ $this->assertSame(['x', 'y', 'z'], $this->describe($this->contents()->even()));
+ }
+
+ public function testOddCountsAndKeepsEveryNode()
+ {
+ $this->assertSame(['a', 'b'], $this->describe($this->contents()->odd()));
+ }
+
+ public function testFirstIsTheFirstNodeOfAnyType()
+ {
+ $c = $this->contents();
+
+ $this->assertSame(['x'], $this->describe($c->first()));
+ $this->assertSame($c->eq(0)->get(), $c->first()->get());
+ }
+
+ public function testLastIsTheLastNodeOfAnyType()
+ {
+ $c = $this->contents();
+
+ $this->assertSame(['z'], $this->describe($c->last()));
+ $this->assertSame($c->eq(-1)->get(), $c->last()->get());
+ }
+
+ public function testOnlyElementsAfterFilteringOrChildren()
+ {
+ $c = $this->contents();
+
+ $this->assertSame(['a'], $this->describe($c->filter('*')->first()));
+ $this->assertSame(['b'], $this->describe($c->filter('*')->last()));
+ $this->assertSame(['a'], $this->describe($c->filter('*')->even()));
+ $this->assertSame(['b'], $this->describe($c->filter('*')->odd()));
+ $this->assertSame(['a'], $this->describe(qp(self::XML, 'r')->children()->first()));
+ }
+
+ public function testEmptySet()
+ {
+ $qp = qp(self::XML, 'nothing');
+
+ $this->assertSame(0, $qp->first()->count());
+ $this->assertSame(0, $qp->last()->count());
+ $this->assertSame(0, $qp->even()->count());
+ $this->assertSame(0, $qp->odd()->count());
+ }
+
+ private function contents()
+ {
+ return qp(self::XML, 'r')->contents();
+ }
+
+ /**
+ * Each node's ID if it is an element, or its text otherwise.
+ */
+ private function describe($query): array
+ {
+ return array_map(function ($node) {
+ return $node instanceof \DOMElement ? $node->getAttribute('id') : $node->textContent;
+ }, $query->get());
+ }
+}
diff --git a/tests/Issues/Issue144Test.php b/tests/Issues/Issue144Test.php
new file mode 100644
index 0000000..33ecd9b
--- /dev/null
+++ b/tests/Issues/Issue144Test.php
@@ -0,0 +1,43 @@
+';
+
+ /**
+ * @dataProvider sliceProvider
+ */
+ public function testSliceTakesAnEndIndex(array $args, string $expected)
+ {
+ $this->assertSame($expected, $this->idList(qp(self::XML, 'i')->slice(...$args)));
+ }
+
+ public function sliceProvider(): array
+ {
+ return [
+ 'slice(1, 3)' => [[1, 3], 'b,c'],
+ 'slice(1, -1)' => [[1, -1], 'b,c,d'],
+ 'slice(2, 0)' => [[2, 0], ''],
+ 'slice(0)' => [[0], 'a,b,c,d,e'],
+ 'slice(2)' => [[2], 'c,d,e'],
+ 'slice(1, 9)' => [[1, 9], 'b,c,d,e'],
+ 'slice(3, 2)' => [[3, 2], ''],
+ 'slice(2, 2)' => [[2, 2], ''],
+ 'slice(-3, -1)' => [[-3, -1], 'c,d'],
+ 'slice(-2, 5)' => [[-2, 5], 'd,e'],
+ 'slice(0, -5)' => [[0, -5], ''],
+ 'slice(0, -9)' => [[0, -9], ''],
+ 'slice(-9, 2)' => [[-9, 2], 'a,b'],
+ 'slice(5)' => [[5], ''],
+ ];
+ }
+}
diff --git a/tests/Issues/Issue145Test.php b/tests/Issues/Issue145Test.php
new file mode 100644
index 0000000..f260275
--- /dev/null
+++ b/tests/Issues/Issue145Test.php
@@ -0,0 +1,38 @@
+';
+
+ public function testEvenKeepsTheZeroBasedEvenPositions()
+ {
+ $this->assertSame('a,c,e', $this->idList(qp(self::XML, 'i')->even()));
+ $this->assertSame('a', $this->idList(qp(self::XML, '#a')->even()));
+ $this->assertSame('', $this->idList(qp(self::XML, 'nothing')->even()));
+ }
+
+ public function testOddKeepsTheZeroBasedOddPositions()
+ {
+ $this->assertSame('b,d', $this->idList(qp(self::XML, 'i')->odd()));
+ $this->assertSame('', $this->idList(qp(self::XML, '#a')->odd()));
+ $this->assertSame('', $this->idList(qp(self::XML, 'nothing')->odd()));
+ }
+
+ public function testEvenAndOddAgreeWithThePseudoClasses()
+ {
+ $qp = qp(self::XML, 'i');
+
+ $this->assertSame($this->idList($qp->filter(':even')), $this->idList($qp->even()));
+ $this->assertSame($this->idList($qp->filter(':odd')), $this->idList($qp->odd()));
+ }
+}
diff --git a/tests/QueryPath/CSS/SelectorTest.php b/tests/QueryPath/CSS/SelectorTest.php
index f34c54c..0a580dd 100644
--- a/tests/QueryPath/CSS/SelectorTest.php
+++ b/tests/QueryPath/CSS/SelectorTest.php
@@ -131,4 +131,35 @@ public function testIterator()
$iterator = $selector->getIterator();
$this->assertInstanceOf('\Iterator', $iterator);
}
+
+ /**
+ * @dataProvider notEmptySelectors
+ */
+ public function testNotEmptyIsTheInverseOfIsBlank($selector, $index, $expected)
+ {
+ $simpleSelector = $this->parse($selector)->toArray()[0][$index];
+
+ $this->assertSame($expected, $simpleSelector->notEmpty());
+ $this->assertSame(! $expected, $simpleSelector->isBlank());
+ }
+
+ public function notEmptySelectors(): array
+ {
+ return [
+ 'element' => ['li', 0, true],
+ 'ID' => ['#a', 0, true],
+ 'class' => ['.a', 0, true],
+ 'attribute' => ['[a]', 0, true],
+ 'pseudo-class' => [':first-child', 0, true],
+ 'pseudo-element' => ['::before', 0, true],
+ 'every part' => ['ns|li#a.b[c]:first-child::before', 0, true],
+ 'element with a combinator' => ['ul > li', 1, true],
+ 'blank with a combinator' => ['> li', 1, false],
+ ];
+ }
+
+ public function testNotEmptyOnANewSelector()
+ {
+ $this->assertFalse((new SimpleSelector())->notEmpty());
+ }
}
diff --git a/tests/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php
index 674f925..920d0c3 100644
--- a/tests/QueryPath/DOMQueryTest.php
+++ b/tests/QueryPath/DOMQueryTest.php
@@ -756,8 +756,10 @@ public function testFirstAndLastWithNonElementNodes($xml)
{
[$qp, $a] = $this->contentsWithElement($xml);
- $this->assertSame([$a], $qp->first()->get());
- $this->assertSame([$a], $qp->last()->get());
+ $this->assertSame([$qp->get(0)], $qp->first()->get());
+ $this->assertSame([$qp->get(-1)], $qp->last()->get());
+ $this->assertSame([$a], $qp->filter('*')->first()->get());
+ $this->assertSame([$a], $qp->filter('*')->last()->get());
}
public function testEq()
@@ -849,7 +851,7 @@ public function testFilterEvaluatesTheSelectorAgainstTheWholeSet()
$this->assertEquals('two', qp($file, 'li')->filter(':eq(1)')->attr('id'));
$this->assertCount(2, qp($file, 'li')->filter(':odd'));
- // :scope refers to the document element, exactly as it does in find().
+ // :scope refers to the document element, as it does in is().
$this->assertEquals(0, qp($file, 'inner')->filter(':scope')->count());
$this->assertEquals(1, qp($file, 'root')->filter(':scope')->count());
}
@@ -1011,7 +1013,7 @@ public function testSlice()
$this->assertEquals(0, qp($file, 'li')->slice(9)->count());
// The first item should be #two, the last #three
- $qp = qp($file, 'li')->slice(1, 2);
+ $qp = qp($file, 'li')->slice(1, 3);
$this->assertEquals(2, $qp->count());
$this->assertEquals('two', $qp->attr('id'));
$this->assertEquals('three', $qp->eq(1)->attr('id'));
@@ -2460,14 +2462,14 @@ public function testEven()
{
$file = DATA_FILE;
$this->assertEquals(1, qp($file, 'inner')->even()->count());
- $this->assertEquals(2, qp($file, 'li')->even()->count());
+ $this->assertEquals(3, qp($file, 'li')->even()->count());
}
public function testOdd()
{
$file = DATA_FILE;
$this->assertEquals(1, qp($file, 'inner')->odd()->count());
- $this->assertEquals(3, qp($file, 'li')->odd()->count());
+ $this->assertEquals(2, qp($file, 'li')->odd()->count());
}
public function testFirst()