diff --git a/CHANGELOG.md b/CHANGELOG.md
index 527a87d..edb2a12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -58,6 +58,7 @@ 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
+- **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
# 4.2.0
diff --git a/src/Helpers/NodeMatcher.php b/src/Helpers/NodeMatcher.php
index 8df96fd..31fe818 100644
--- a/src/Helpers/NodeMatcher.php
+++ b/src/Helpers/NodeMatcher.php
@@ -107,6 +107,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 ($this->matchesInDocument($node)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* @param \DOMDocument $document
*
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/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'));
+ }
+}