From 9251b49ff41d1d2bb68959df79d0e0a04d1b551c Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 17:02:00 +1100 Subject: [PATCH 1/5] Count and keep every node in even(), odd(), first() and last() even() and odd() counted text, comment, CDATA and processing-instruction nodes but dropped them from the result, so contents()->odd() could be empty. first() and last() skipped them altogether, so first() disagreed with eq(0). As in jQuery, all four now treat every node in the set alike, and first() and last() are eq(0) and eq(-1). Fixes #143. Fixes #146. Co-Authored-By: Claude Opus 5.5 --- src/Helpers/QueryFilters.php | 51 +++++++++----------- tests/Issues/Issue143Test.php | 80 ++++++++++++++++++++++++++++++++ tests/QueryPath/DOMQueryTest.php | 6 ++- 3 files changed, 105 insertions(+), 32 deletions(-) create mode 100644 tests/Issues/Issue143Test.php diff --git a/src/Helpers/QueryFilters.php b/src/Helpers/QueryFilters.php index 31a69fc..09d4b06 100644 --- a/src/Helpers/QueryFilters.php +++ b/src/Helpers/QueryFilters.php @@ -374,7 +374,8 @@ public function eachLambda($lambda): Query /** * Get the matches at zero-based even positions (0, 2, 4, ...), as jQuery's .even() and - * the :even pseudo-class do. + * the :even pseudo-class do. Every node counts, whatever its type; use filter('*')->even() + * to count only the elements. * * @return DOMQuery * A DOMQuery wrapping the matches at even positions. @@ -390,7 +391,8 @@ public function even(): Query /** * Get the matches at zero-based odd positions (1, 3, 5, ...), as jQuery's .odd() and - * the :odd pseudo-class do. + * the :odd pseudo-class do. Every node counts, whatever its type; use filter('*')->odd() + * to count only the elements. * * @return DOMQuery * A DOMQuery wrapping the matches at odd positions. @@ -416,35 +418,30 @@ private function positional(string $name): Query { $found = new SplObjectStorage(); foreach (Util::applyPositionalPseudoClass(iterator_to_array($this->matches, false), $name) as $m) { - if ($m->nodeType === XML_ELEMENT_NODE) { - $found->offsetSet($m); - } + $found->offsetSet($m); } 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); } /** @@ -475,29 +472,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); } /** 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/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php index dba8df4..07d0e64 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() From 7c48048655c8b925d097bc5c5747d605ec6d7bbc Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 17:17:21 +1100 Subject: [PATCH 2/5] Add a CHANGELOG entry Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abdcec..eb6e042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ QueryPath Changelog - **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 - **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()` # 4.2.0 From f0befde0d48ce58c08469eb065c2a7f6c039f182 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 17:18:07 +1100 Subject: [PATCH 3/5] Build last() on the match count, so it does not depend on negative indexes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/Helpers/QueryFilters.php | 6 +++--- tests/Issues/Issue143Test.php | 2 +- tests/QueryPath/DOMQueryTest.php | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6e042..300ca10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,7 @@ QueryPath Changelog - **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 - **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.** `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)`. `first()` is now the same as `eq(0)`, and `last()` returns the last node of any type. To work on elements only, use `children()->first()` or `filter('*')->first()`, and likewise for `last()`, `even()` and `odd()` # 4.2.0 diff --git a/src/Helpers/QueryFilters.php b/src/Helpers/QueryFilters.php index 09d4b06..d9ff747 100644 --- a/src/Helpers/QueryFilters.php +++ b/src/Helpers/QueryFilters.php @@ -474,8 +474,8 @@ public function firstChild(): Query /** * 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 + * As in jQuery, this is 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 @@ -488,7 +488,7 @@ public function firstChild(): Query */ public function last(): Query { - return $this->eq(-1); + return $this->eq($this->count() - 1); } /** diff --git a/tests/Issues/Issue143Test.php b/tests/Issues/Issue143Test.php index dc410cf..875c91f 100644 --- a/tests/Issues/Issue143Test.php +++ b/tests/Issues/Issue143Test.php @@ -39,7 +39,7 @@ public function testLastIsTheLastNodeOfAnyType() $c = $this->contents(); $this->assertSame(['z'], $this->describe($c->last())); - $this->assertSame($c->eq(-1)->get(), $c->last()->get()); + $this->assertSame($c->eq($c->count() - 1)->get(), $c->last()->get()); } public function testOnlyElementsAfterFilteringOrChildren() diff --git a/tests/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php index 07d0e64..886a4e5 100644 --- a/tests/QueryPath/DOMQueryTest.php +++ b/tests/QueryPath/DOMQueryTest.php @@ -757,7 +757,7 @@ public function testFirstAndLastWithNonElementNodes($xml) [$qp, $a] = $this->contentsWithElement($xml); $this->assertSame([$qp->get(0)], $qp->first()->get()); - $this->assertSame([$qp->get(-1)], $qp->last()->get()); + $this->assertSame([$qp->get($qp->count() - 1)], $qp->last()->get()); $this->assertSame([$a], $qp->filter('*')->first()->get()); $this->assertSame([$a], $qp->filter('*')->last()->get()); } From 128fca535383f11743904f0a329d10af7d1cfc8b Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 17:24:40 +1100 Subject: [PATCH 4/5] Trim assertions that restate first() and last() Co-Authored-By: Claude Opus 5.5 --- tests/Issues/Issue143Test.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/Issues/Issue143Test.php b/tests/Issues/Issue143Test.php index 875c91f..9956c80 100644 --- a/tests/Issues/Issue143Test.php +++ b/tests/Issues/Issue143Test.php @@ -31,7 +31,6 @@ 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() @@ -39,7 +38,6 @@ public function testLastIsTheLastNodeOfAnyType() $c = $this->contents(); $this->assertSame(['z'], $this->describe($c->last())); - $this->assertSame($c->eq($c->count() - 1)->get(), $c->last()->get()); } public function testOnlyElementsAfterFilteringOrChildren() From 73d64cc500957f97d47bdb3285b04f4969e2df63 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 17:26:26 +1100 Subject: [PATCH 5/5] Tidy comments Co-Authored-By: Claude Opus 5.5 --- src/Helpers/QueryFilters.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Helpers/QueryFilters.php b/src/Helpers/QueryFilters.php index d9ff747..dbf467c 100644 --- a/src/Helpers/QueryFilters.php +++ b/src/Helpers/QueryFilters.php @@ -475,8 +475,8 @@ public function firstChild(): Query * Get the last match. * * As in jQuery, this is 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. + * may be a text node. Use children()->last() or filter('*')->last() for + * the last element. * * @return DOMQuery * A DOMQuery wrapping the last match, or an empty one.