diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14907c4..cbb6125 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,7 @@ QueryPath Changelog
- **Behaviour change.** Fix `:has()` never matching in `find()`, `filter()` and the other methods that use `QueryPath\CSS\DOMTraverser`, so that `ul:has(li)` selected nothing and `:not(:has(li))` selected everything (issue #121). `:has()` now matches an element with a descendant matching its argument, and supports relative selectors: `ul:has(> li)` (a child), `h2:has(+ p)` (the next sibling) and `h2:has(~ p)` (a later sibling). A positional pseudo-class in the argument indexes each element's own matches, so `ul:has(li:eq(2))` selects every list with at least three items
- **Behaviour change.** `remove($selector)` and `replaceAll($selector)` now select with `QueryPath\CSS\DOMTraverser`, the engine behind `find()`, instead of the legacy `QueryPath\CSS\QueryPathEventHandler` (issues #126, #123, #124). `remove($selector)` searches the descendants of the match set, as `find()` and `detach($selector)` do, so `qp($xml, 'p')->remove('p')` no longer removes the `
` elements themselves. `replaceAll($selector, $document)` still searches the whole of `$document`, document element included. Both now accept `:matches()`, which threw "Unknown Pseudo-Class: matches", and apply a positional `:not()` argument to the whole set, so `remove('li:not(:first)')` keeps only the first `
` instead of removing nothing. `QueryPathEventHandler` is no longer used and is deprecated
- Add `QueryPath\CSS\DOMTraverser::filterNodes($nodes, $selector)`, which returns the given nodes that themselves match a selector, in the order given (issue #116). `filter()`, `is()`, the selector-filtered traversal methods, and `:not()`/`:matches()` all use it. `DOMTraverser` no longer modifies a parsed selector, so queries sharing a `PseudoClass` handler parse each selector once: `nextUntil()` over 2000 siblings drops from about 16 ms to 6 ms, and `tr:not(:nth-child(2n))` on a 2000-row table from 15 ms to 6 ms
+- 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
# 4.2.0
diff --git a/src/Helpers/QueryFilters.php b/src/Helpers/QueryFilters.php
index ea3a176..df379e1 100644
--- a/src/Helpers/QueryFilters.php
+++ b/src/Helpers/QueryFilters.php
@@ -557,21 +557,7 @@ public function lastChild(): Query
*/
public function nextUntil($selector = null): Query
{
- $found = new SplObjectStorage();
- $matcher = new NodeMatcher($selector);
- foreach ($this->matches as $m) {
- while (isset($m->nextSibling)) {
- $m = $m->nextSibling;
- if ($m->nodeType === XML_ELEMENT_NODE) {
- if (null !== $selector && $matcher->matches($m)) {
- break;
- }
- $found->offsetSet($m);
- }
- }
- }
-
- return $this->inst($found, null);
+ return $this->inst($this->walk('nextSibling', $selector, false), null);
}
/**
@@ -597,22 +583,7 @@ public function nextUntil($selector = null): Query
*/
public function prevUntil($selector = null): Query
{
- $found = new SplObjectStorage();
- $matcher = new NodeMatcher($selector);
- foreach ($this->matches as $m) {
- while (isset($m->previousSibling)) {
- $m = $m->previousSibling;
- if ($m->nodeType === XML_ELEMENT_NODE) {
- if (null !== $selector && $matcher->matches($m)) {
- break;
- }
-
- $found->offsetSet($m);
- }
- }
- }
-
- return $this->inst($found, null);
+ return $this->inst($this->walk('previousSibling', $selector, false), null);
}
/**
@@ -634,21 +605,7 @@ public function prevUntil($selector = null): Query
*/
public function parentsUntil($selector = null): Query
{
- $found = new SplObjectStorage();
- $matcher = new NodeMatcher($selector);
- foreach ($this->matches as $m) {
- while ($m->parentNode->nodeType !== XML_DOCUMENT_NODE) {
- $m = $m->parentNode;
- if ($m->nodeType === XML_ELEMENT_NODE) {
- if (! empty($selector) && $matcher->matches($m)) {
- break;
- }
- $found->offsetSet($m);
- }
- }
- }
-
- return $this->inst($this->sortReverseDocumentOrder($found), null);
+ return $this->inst($this->sortReverseDocumentOrder($this->walk('parentNode', $selector, false)), null);
}
/**
@@ -732,19 +689,12 @@ public function not($selector): Query
*/
public function closest($selector): Query
{
- $found = new SplObjectStorage();
- $matcher = new NodeMatcher($selector);
- foreach ($this->matches as $m) {
- for (; $m !== null && $m->nodeType !== XML_DOCUMENT_NODE; $m = $m->parentNode) {
- if ($matcher->matches($m)) {
- $found->offsetSet($m);
- break;
- }
- }
+ // As in jQuery, nothing is closest to an empty selector.
+ if (empty($selector)) {
+ return $this->inst(new SplObjectStorage(), null);
}
- // XXX: Should this be an in-place modification?
- return $this->inst($found, null);
+ return $this->inst($this->walk('parentNode', $selector, true, true), null);
}
/**
@@ -765,7 +715,7 @@ public function closest($selector): Query
*/
public function parent($selector = null): Query
{
- return $this->getParentElements($selector, true);
+ return $this->inst($this->walk('parentNode', $selector, true), null);
}
/**
@@ -786,53 +736,11 @@ public function parent($selector = null): Query
*/
public function parents($selector = null): Query
{
- return $this->getParentElements($selector, false);
- }
-
- /**
- * Get ancestor(s) of each element in the DOMQuery.
- *
- * If a selector is present, only matching ancestors will be retrieved.
- *
- * @param string|null $selector
- * A valid CSS 3 Selector.
- * @param bool $immediate
- * If function should return only the immediate parent
- *
- * @return DOMQuery
- * A DOMNode object containing the matching ancestors.
- * @throws ParseException
- * @throws Exception
- */
- private function getParentElements(?string $selector, bool $immediate): Query
- {
- $found = new SplObjectStorage();
- $matcher = new NodeMatcher($selector);
- foreach ($this->matches as $m) {
- while ($m->parentNode && $m->parentNode->nodeType !== XML_DOCUMENT_NODE) {
- $m = $m->parentNode;
- if ($m->nodeType !== XML_ELEMENT_NODE) {
- continue;
- }
- // parent() stops at the nearest matching ancestor of each element, so it tests
- // the walk as it goes; parents() collects every ancestor and filters below.
- if (! $immediate) {
- $found->offsetSet($m);
- } elseif (empty($selector) || $matcher->matches($m)) {
- $found->offsetSet($m);
- break;
- }
- }
- }
-
- if ($immediate) {
- return $this->inst($found, null);
- }
-
// jQuery returns the ancestors of a multi-element set in reverse document order, with
- // duplicates removed. The selector is applied after that ordering, so a positional
- // selector counts from the closest ancestor.
- return $this->inst($this->filterCandidates($this->sortReverseDocumentOrder($found), $selector), null);
+ // duplicates removed. The selector is applied after that ordering.
+ $ancestors = $this->sortReverseDocumentOrder($this->walk('parentNode', null, false));
+
+ return $this->inst($this->filterCandidates($ancestors, $selector), null);
}
/**
@@ -856,19 +764,7 @@ private function getParentElements(?string $selector, bool $immediate): Query
*/
public function next($selector = null): Query
{
- $found = new SplObjectStorage();
- $matcher = new NodeMatcher($selector);
- foreach ($this->matches as $m) {
- while (isset($m->nextSibling)) {
- $m = $m->nextSibling;
- if ($m->nodeType === XML_ELEMENT_NODE && (empty($selector) || $matcher->matches($m))) {
- $found->offsetSet($m);
- break;
- }
- }
- }
-
- return $this->inst($found, null);
+ return $this->inst($this->walk('nextSibling', $selector, true), null);
}
/**
@@ -892,21 +788,7 @@ public function next($selector = null): Query
*/
public function nextAll($selector = null): Query
{
- $found = new SplObjectStorage();
- foreach ($this->matches as $m) {
- while (isset($m->nextSibling)) {
- $m = $m->nextSibling;
- // Every sibling beyond one already collected was collected with it.
- if ($found->offsetExists($m)) {
- break;
- }
- if ($m->nodeType === XML_ELEMENT_NODE) {
- $found->offsetSet($m);
- }
- }
- }
-
- return $this->inst($this->filterCandidates($found, $selector), null);
+ return $this->inst($this->filterCandidates($this->walk('nextSibling', null, false), $selector), null);
}
/**
@@ -931,19 +813,7 @@ public function nextAll($selector = null): Query
*/
public function prev($selector = null): Query
{
- $found = new SplObjectStorage();
- $matcher = new NodeMatcher($selector);
- foreach ($this->matches as $m) {
- while (isset($m->previousSibling)) {
- $m = $m->previousSibling;
- if ($m->nodeType === XML_ELEMENT_NODE && (empty($selector) || $matcher->matches($m))) {
- $found->offsetSet($m);
- break;
- }
- }
- }
-
- return $this->inst($found, null);
+ return $this->inst($this->walk('previousSibling', $selector, true), null);
}
/**
@@ -967,21 +837,7 @@ public function prev($selector = null): Query
*/
public function prevAll($selector = null): Query
{
- $found = new SplObjectStorage();
- foreach ($this->matches as $m) {
- while (isset($m->previousSibling)) {
- $m = $m->previousSibling;
- // Every sibling beyond one already collected was collected with it.
- if ($found->offsetExists($m)) {
- break;
- }
- if ($m->nodeType === XML_ELEMENT_NODE) {
- $found->offsetSet($m);
- }
- }
- }
-
- return $this->inst($this->filterCandidates($found, $selector), null);
+ return $this->inst($this->filterCandidates($this->walk('previousSibling', null, false), $selector), null);
}
/**
@@ -1090,6 +946,53 @@ public function siblings($selector = null): Query
return $this->inst($this->filterCandidates($found, $selector), null);
}
+ /**
+ * Walk from each matched element along a DOM axis, collecting the elements passed.
+ *
+ * @param string $axis
+ * The property to follow: 'nextSibling', 'previousSibling' or 'parentNode'.
+ * @param string|null $selector
+ * An optional CSS selector. With $first, the element to collect; otherwise, the element
+ * to stop before.
+ * @param bool $first
+ * Collect only the first element matching the selector (the first element, if there is
+ * none), rather than every element up to the first match.
+ * @param bool $inclusive
+ * Start the walk at the matched element itself rather than at its neighbour.
+ *
+ * @return SplObjectStorage
+ * @throws ParseException
+ */
+ private function walk(string $axis, $selector, bool $first, bool $inclusive = false): SplObjectStorage
+ {
+ $found = new SplObjectStorage();
+ $matcher = empty($selector) ? null : new NodeMatcher($selector);
+ foreach ($this->matches as $m) {
+ $n = $inclusive ? $m : $m->{$axis};
+ for (; $n !== null && $n->nodeType !== XML_DOCUMENT_NODE; $n = $n->{$axis}) {
+ if ($n->nodeType !== XML_ELEMENT_NODE) {
+ continue;
+ }
+
+ if ($first) {
+ if ($matcher === null || $matcher->matches($n)) {
+ $found->offsetSet($n);
+ break;
+ }
+ continue;
+ }
+
+ // Every element beyond one already collected was collected with it, up to the same stop.
+ if ($found->offsetExists($n) || ($matcher !== null && $matcher->matches($n))) {
+ break;
+ }
+ $found->offsetSet($n);
+ }
+ }
+
+ return $found;
+ }
+
/**
* Sort a set of nodes into reverse document order.
*
diff --git a/tests/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php
index 2e78b58..01deac5 100644
--- a/tests/QueryPath/DOMQueryTest.php
+++ b/tests/QueryPath/DOMQueryTest.php
@@ -2516,6 +2516,48 @@ public function testParentsUntil()
$this->assertEquals(2, qp($file, 'li')->parentsUntil('root')->count());
}
+ public function testParentsUntilOutsideTheDocument()
+ {
+ $doc = qp('')->get(0)->ownerDocument;
+ $parent = $doc->createElement('parent');
+ $child = $parent->appendChild($doc->createElement('child'));
+
+ $this->assertSame('parent', qp($child)->parentsUntil('root')->tag());
+ $this->assertSame('parent', qp($child)->parentsUntil()->tag());
+ }
+
+ public function testUntilWalksFromElementsOnEachOthersPath()
+ {
+ $xml = '';
+
+ $tagsOf = function ($qp) {
+ $tags = [];
+ foreach ($qp->get() as $node) {
+ $tags[] = $node->tagName;
+ }
+ sort($tags);
+
+ return $tags;
+ };
+
+ $this->assertSame(['b', 'c', 'f'], $tagsOf(qp($xml, 'a, b, e')->nextUntil('stop')));
+ $this->assertSame(['a', 'b', 'e'], $tagsOf(qp($xml, 'c, f')->prevUntil('stop, x')));
+ $this->assertSame(['x', 'y'], $tagsOf(qp($xml, 'z, y')->parentsUntil('r')));
+ }
+
+ public function testTraversalTreatsAnEmptySelectorAsNone()
+ {
+ $xml = '';
+
+ $this->assertSame('c', qp($xml, 'b')->nextUntil('')->tag());
+ $this->assertSame('a', qp($xml, 'b')->prevUntil('')->tag());
+ $this->assertSame('r', qp($xml, 'b')->parentsUntil('')->tag());
+ $this->assertSame('c', qp($xml, 'b')->next('')->tag());
+ $this->assertSame('a', qp($xml, 'b')->prev('')->tag());
+ $this->assertSame('r', qp($xml, 'b')->parent('')->tag());
+ $this->assertCount(0, qp($xml, 'b')->closest(''));
+ }
+
public function testSort()
{
$xml = '1521';