From 6d3b0979b7320b6274a13df0d218a7994c9c370a Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Sat, 22 Aug 2026 04:32:59 +1000 Subject: [PATCH 1/6] Make find() search descendants only, as jQuery does find() treated the nodes already in the match set as candidates for their own selector, so qp($xml, '#a1')->find('a') returned #a1 itself alongside any nested . jQuery's .find() searches descendants; filter() is what asks whether the nodes in hand match. The document element keeps its self-match. qp() seeds the match set with the document element rather than with the document node, so it stands in for $(document), and without the exception qp($xml)->find('root') could never match. This depends on #72. Before it, the selector-filtered traversal methods reach is(), which reached find(), so they relied on the self-match to test a node against a selector; changing find() on its own breaks nine of them. Once those go through NodeMatcher they no longer care. testBefore searched for the node it was standing on. Rewritten to search from top(), which is the shape the next two assertions in that test already use. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + src/CSS/DOMTraverser.php | 7 ++- tests/QueryPath/DOMQueryTest.php | 4 +- tests/QueryPath/FindDescendantOnlyTest.php | 63 ++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 tests/QueryPath/FindDescendantOnlyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c82319..6354c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ QueryPath Changelog - Apply the same fix to the other selector-filtered traversal methods in `QueryPath\Helpers\QueryFilters`: `parent()`, `parents()`, `parentsUntil()`, `closest()`, `next()`, `nextAll()`, `nextUntil()`, `prev()`, `prevAll()`, `prevUntil()`, `siblings()`, and `not()` (#62) - **Behaviour change:** `parents()` and `parentsUntil()` now return their results in reverse document order with duplicates removed, as jQuery does. Previously a set built from more than one starting element was grouped by starting element (#62) - **Breaking behaviour change:** `DOMQuery::filter()` now narrows the match set to the members that match the selector, as jQuery does, instead of keeping any element that merely *contains* a match. `qp($file, 'inner')->filter('li')` returned both `` elements and now returns none. Use `has()` for the old behaviour +- **Breaking behaviour change:** `find()` now searches descendants only, as jQuery's `.find()` does. A node already in the match set is no longer a candidate for its own selector, so `qp($xml, '#a1')->find('a')` no longer returns `#a1` itself. The document element remains matchable, because `qp()` seeds the match set with it rather than with the document. Use `filter()` to match the nodes in hand - Fix `children($selector)`, which resolved `:scope` against each child rather than the document element, so `children(':scope')` matched every child - `filter()` and `children($selector)` now evaluate the selector against the whole candidate set in a single pass, rather than one node at a time. A per-node pass cannot evaluate a selector describing a position within the set, since each node is the only member of its own one-element set - `nextAll()` and `prevAll()` stop walking at the first sibling already collected from an earlier starting element, rather than re-walking to the end of the list. On a 4,000-item list `nextAll()` fell from about 430 ms to under 10 ms diff --git a/src/CSS/DOMTraverser.php b/src/CSS/DOMTraverser.php index 3839db5..f5795c5 100644 --- a/src/CSS/DOMTraverser.php +++ b/src/CSS/DOMTraverser.php @@ -814,8 +814,11 @@ protected function initialMatchOnElement(SimpleSelector $selector, SplObjectStor continue; } - // Capture the case where the node itself matches the element. - if ($node instanceof DOMElement + // jQuery's find() searches descendants only, so a node in the match set is not + // a candidate for its own selector. The document element is the exception: + // QueryPath seeds the match set with it rather than with the document, so + // without this qp($xml)->find('root') could never match. + if ($node->parentNode instanceof DOMDocument && ($element === '*' || $node->tagName === $element)) { $found->offsetSet($node); } diff --git a/tests/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php index 02b90b4..fbe343c 100644 --- a/tests/QueryPath/DOMQueryTest.php +++ b/tests/QueryPath/DOMQueryTest.php @@ -1132,7 +1132,9 @@ public function testPrependTo() public function testBefore() { $file = DATA_FILE; - $this->assertEquals(1, qp($file, 'unary')->before('')->find(':root > test ~ unary')->count()); + // find() searches descendants, so the search starts from the top of the document — + // the same shape as the top() calls below. + $this->assertEquals(1, qp($file, 'unary')->before('')->top()->find(':root > test ~ unary')->count()); $this->assertEquals(1, qp($file, 'unary')->before('')->top('head ~ test')->count()); $this->assertEquals( 'unary', diff --git a/tests/QueryPath/FindDescendantOnlyTest.php b/tests/QueryPath/FindDescendantOnlyTest.php new file mode 100644 index 0000000..78f842f --- /dev/null +++ b/tests/QueryPath/FindDescendantOnlyTest.php @@ -0,0 +1,63 @@ +'; + + public function testFindDoesNotMatchTheNodesItStartsFrom(): void + { + $a1 = qp(self::XML, '#a1'); + $this->assertSame('a1', $a1->attr('id')); + + // is in the match set, so it is not a candidate: only the nested + // is found. + $found = $a1->find('a'); + $this->assertCount(1, $found); + $this->assertSame('a2', $found->attr('id')); + } + + public function testWildcardDoesNotMatchTheNodesItStartsFrom(): void + { + $ids = []; + foreach (qp(self::XML, '#a1')->find('*') as $node) { + $ids[] = $node->attr('id'); + } + + $this->assertSame(['a2', 'b1'], $ids); + } + + public function testTheDocumentElementIsStillReachable(): void + { + // qp() seeds the match set with the document element, which stands in for the + // document, so a selector naming it has to keep working. + $this->assertCount(1, qp(self::XML)->find('root')); + $this->assertSame('root', qp(self::XML)->find('root')->tag()); + + $this->assertCount(1, qp(self::XML)->find(':root')); + } + + public function testDescendantsOfTheDocumentElementAreStillFound(): void + { + $this->assertCount(2, qp(self::XML)->find('a')); + $this->assertCount(1, qp(self::XML)->find('b')); + } + + /** + * filter() is the jQuery-equivalent way to ask whether the nodes in hand match. + */ + public function testFilterIsTheWayToMatchTheNodesInHand(): void + { + $this->assertCount(1, qp(self::XML, '#a1')->filter('a')); + $this->assertSame('a1', qp(self::XML, '#a1')->filter('a')->attr('id')); + } +} From 7f39c148bda5f173a43f2a906772cbfd87017eb0 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Sat, 22 Aug 2026 04:38:03 +1000 Subject: [PATCH 2/6] Make the ID and class initial matchers descendant-only too Only the element matcher was changed, so find('a') stopped self-matching while find('#a1') and find('.c') still did. Three initial matchers, three separate self-tests, and the selector you happened to write decided the semantics. All three now apply the same rule, with the same document-element exception. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- src/CSS/DOMTraverser.php | 8 ++++++-- tests/QueryPath/FindDescendantOnlyTest.php | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6354c6c..d0351ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ QueryPath Changelog - Apply the same fix to the other selector-filtered traversal methods in `QueryPath\Helpers\QueryFilters`: `parent()`, `parents()`, `parentsUntil()`, `closest()`, `next()`, `nextAll()`, `nextUntil()`, `prev()`, `prevAll()`, `prevUntil()`, `siblings()`, and `not()` (#62) - **Behaviour change:** `parents()` and `parentsUntil()` now return their results in reverse document order with duplicates removed, as jQuery does. Previously a set built from more than one starting element was grouped by starting element (#62) - **Breaking behaviour change:** `DOMQuery::filter()` now narrows the match set to the members that match the selector, as jQuery does, instead of keeping any element that merely *contains* a match. `qp($file, 'inner')->filter('li')` returned both `` elements and now returns none. Use `has()` for the old behaviour -- **Breaking behaviour change:** `find()` now searches descendants only, as jQuery's `.find()` does. A node already in the match set is no longer a candidate for its own selector, so `qp($xml, '#a1')->find('a')` no longer returns `#a1` itself. The document element remains matchable, because `qp()` seeds the match set with it rather than with the document. Use `filter()` to match the nodes in hand +- **Breaking behaviour change:** `find()` now searches descendants only, as jQuery's `.find()` does. A node already in the match set is no longer a candidate for its own selector, so `qp($xml, '#a1')->find('a')`, `find('#a1')` and `find('.c')` no longer return `#a1` itself. The document element remains matchable, because `qp()` seeds the match set with it rather than with the document. Use `filter()` to match the nodes in hand - Fix `children($selector)`, which resolved `:scope` against each child rather than the document element, so `children(':scope')` matched every child - `filter()` and `children($selector)` now evaluate the selector against the whole candidate set in a single pass, rather than one node at a time. A per-node pass cannot evaluate a selector describing a position within the set, since each node is the only member of its own one-element set - `nextAll()` and `prevAll()` stop walking at the first sibling already collected from an earlier starting element, rather than re-walking to the end of the list. On a 4,000-item list `nextAll()` fell from about 430 ms to under 10 ms diff --git a/src/CSS/DOMTraverser.php b/src/CSS/DOMTraverser.php index f5795c5..9ffdacf 100644 --- a/src/CSS/DOMTraverser.php +++ b/src/CSS/DOMTraverser.php @@ -697,7 +697,9 @@ protected function initialMatchOnID(SimpleSelector $selector, SplObjectStorage $ continue; } - if ($node->getAttribute('id') === $id) { + // Descendants only, as in find(). The document element is the exception, + // because the match set is seeded with it rather than with the document. + if ($node->parentNode instanceof DOMDocument && $node->getAttribute('id') === $id) { $found->offsetSet($node); } @@ -745,7 +747,9 @@ protected function initialMatchOnClasses(SimpleSelector $selector, SplObjectStor } // Refactor me! - if ($node->hasAttribute('class')) { + // Descendants only, as in find(). The document element is the exception, + // because the match set is seeded with it rather than with the document. + if ($node->parentNode instanceof DOMDocument && $node->hasAttribute('class')) { $intersect = array_intersect($selector->classes, explode(' ', $node->getAttribute('class'))); if (count($intersect) === count($selector->classes)) { $found->offsetSet($node); diff --git a/tests/QueryPath/FindDescendantOnlyTest.php b/tests/QueryPath/FindDescendantOnlyTest.php index 78f842f..b485c29 100644 --- a/tests/QueryPath/FindDescendantOnlyTest.php +++ b/tests/QueryPath/FindDescendantOnlyTest.php @@ -60,4 +60,22 @@ public function testFilterIsTheWayToMatchTheNodesInHand(): void $this->assertCount(1, qp(self::XML, '#a1')->filter('a')); $this->assertSame('a1', qp(self::XML, '#a1')->filter('a')->attr('id')); } + + /** + * The element, ID and class initial matchers each have their own self-test, so all three + * have to agree — otherwise find('#a1') would match a node that find('a') does not. + */ + public function testIdAndClassSelectorsAreDescendantOnlyToo(): void + { + $xml = ''; + + $this->assertCount(0, qp($xml, '#a1')->find('#a1')); + + $found = qp($xml, '#a1')->find('.c'); + $this->assertCount(1, $found); + $this->assertSame('a2', $found->attr('id')); + + // The document element keeps its exception here as well. + $this->assertCount(1, qp($xml)->find('#a1')); + } } From 638765276c92edf041d482ff64cf980d62f27213 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Sat, 22 Aug 2026 04:50:37 +1000 Subject: [PATCH 3/6] Give the document-element exception one home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule and its four-line explanation were pasted into all three initial matchers. They have to agree — find('#a1') matching a node that find('a') does not is the divergence this PR exists to remove — so they should not be three independent copies. Co-Authored-By: Claude Opus 5 (1M context) --- src/CSS/DOMTraverser.php | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/CSS/DOMTraverser.php b/src/CSS/DOMTraverser.php index 9ffdacf..bb59735 100644 --- a/src/CSS/DOMTraverser.php +++ b/src/CSS/DOMTraverser.php @@ -697,9 +697,7 @@ protected function initialMatchOnID(SimpleSelector $selector, SplObjectStorage $ continue; } - // Descendants only, as in find(). The document element is the exception, - // because the match set is seeded with it rather than with the document. - if ($node->parentNode instanceof DOMDocument && $node->getAttribute('id') === $id) { + if ($this->isDocumentElement($node) && $node->getAttribute('id') === $id) { $found->offsetSet($node); } @@ -747,9 +745,7 @@ protected function initialMatchOnClasses(SimpleSelector $selector, SplObjectStor } // Refactor me! - // Descendants only, as in find(). The document element is the exception, - // because the match set is seeded with it rather than with the document. - if ($node->parentNode instanceof DOMDocument && $node->hasAttribute('class')) { + if ($this->isDocumentElement($node) && $node->hasAttribute('class')) { $intersect = array_intersect($selector->classes, explode(' ', $node->getAttribute('class'))); if (count($intersect) === count($selector->classes)) { $found->offsetSet($node); @@ -798,6 +794,26 @@ private function initialXpathQuery(DOMXPath $xpath, DOMElement $node, string $qu return $xpath->query($query, $node); } + /** + * Is this node the element the match set was seeded with? + * + * jQuery's find() searches descendants only, so a node already in the match set is not + * a candidate for its own selector. The document element is the one exception: QueryPath + * seeds the match set with it rather than with the document, so it stands in for the + * document and qp($xml)->find('root') has to keep matching. + * + * All three initial matchers ask this, and they have to agree — otherwise find('#a1') + * would match a node that find('a') does not. + * + * @param DOMNode $node + * + * @return bool + */ + private function isDocumentElement($node): bool + { + return $node->parentNode instanceof DOMDocument; + } + /** * Shortcut for setting the initial match. * @@ -818,11 +834,7 @@ protected function initialMatchOnElement(SimpleSelector $selector, SplObjectStor continue; } - // jQuery's find() searches descendants only, so a node in the match set is not - // a candidate for its own selector. The document element is the exception: - // QueryPath seeds the match set with it rather than with the document, so - // without this qp($xml)->find('root') could never match. - if ($node->parentNode instanceof DOMDocument + if ($this->isDocumentElement($node) && ($element === '*' || $node->tagName === $element)) { $found->offsetSet($node); } From 030485fc9572113e391f1c4bcdb005d706fd2fae Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 12:23:05 +1100 Subject: [PATCH 4/6] Pin detach($selector) to find()'s descendant-only matching remove() still runs on the legacy engine, which lets a selector match the nodes in hand, so the remove()/detach() agreement test now covers only the selectors that cannot match them. Tracked in #126. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- tests/QueryPath/DOMQueryTest.php | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0351ed..4cdf3be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ QueryPath Changelog - Apply the same fix to the other selector-filtered traversal methods in `QueryPath\Helpers\QueryFilters`: `parent()`, `parents()`, `parentsUntil()`, `closest()`, `next()`, `nextAll()`, `nextUntil()`, `prev()`, `prevAll()`, `prevUntil()`, `siblings()`, and `not()` (#62) - **Behaviour change:** `parents()` and `parentsUntil()` now return their results in reverse document order with duplicates removed, as jQuery does. Previously a set built from more than one starting element was grouped by starting element (#62) - **Breaking behaviour change:** `DOMQuery::filter()` now narrows the match set to the members that match the selector, as jQuery does, instead of keeping any element that merely *contains* a match. `qp($file, 'inner')->filter('li')` returned both `` elements and now returns none. Use `has()` for the old behaviour -- **Breaking behaviour change:** `find()` now searches descendants only, as jQuery's `.find()` does. A node already in the match set is no longer a candidate for its own selector, so `qp($xml, '#a1')->find('a')`, `find('#a1')` and `find('.c')` no longer return `#a1` itself. The document element remains matchable, because `qp()` seeds the match set with it rather than with the document. Use `filter()` to match the nodes in hand +- **Breaking behaviour change:** `find()` now searches descendants only, as jQuery's `.find()` does. A node already in the match set is no longer a candidate for its own selector, so `qp($xml, '#a1')->find('a')`, `find('#a1')` and `find('.c')` no longer return `#a1` itself. The document element remains matchable, because `qp()` seeds the match set with it rather than with the document. Use `filter()` to match the nodes in hand. `detach($selector)`, which uses `find()`, follows suit. `remove($selector)` and `replaceAll($selector)` still use the legacy engine and still match the nodes in hand, so `qp($xml, 'p')->remove('p')` removes the `

` elements while `detach('p')` detaches nothing (#126) - Fix `children($selector)`, which resolved `:scope` against each child rather than the document element, so `children(':scope')` matched every child - `filter()` and `children($selector)` now evaluate the selector against the whole candidate set in a single pass, rather than one node at a time. A per-node pass cannot evaluate a selector describing a position within the set, since each node is the only member of its own one-element set - `nextAll()` and `prevAll()` stop walking at the first sibling already collected from an earlier starting element, rather than re-walking to the end of the list. On a 4,000-item list `nextAll()` fell from about 430 ms to under 10 ms diff --git a/tests/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php index fbe343c..f7223a8 100644 --- a/tests/QueryPath/DOMQueryTest.php +++ b/tests/QueryPath/DOMQueryTest.php @@ -2277,7 +2277,9 @@ public function testDetachWithSelectorAgreesWithRemove() { $xml = '

ax

b

c'; - foreach (['i', 'p > i', 'p', '*', 'noSuchElement'] as $selector) { + // remove() still lets a selector match the nodes in hand, while detach() searches + // descendants only, as find() does, so selectors such as 'p' and '*' disagree (#126). + foreach (['i', 'p > i', 'noSuchElement'] as $selector) { $removeQp = qp($xml, 'p'); $removed = $removeQp->remove($selector); $detachQp = qp($xml, 'p'); @@ -2286,6 +2288,9 @@ public function testDetachWithSelectorAgreesWithRemove() $this->assertSame($removed->count(), $detached->count(), $selector); $this->assertSame($removeQp->top()->xml(), $detachQp->top()->xml(), $selector); } + + $this->assertSame(0, qp($xml, 'p')->detach('p')->count()); + $this->assertSame(3, qp($xml, 'p')->detach('*')->count()); } public function testAttachAfterDetachWithSelector() From 8c27287a0a718c19ea31fb495eaedc4bf790c62b Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 12:37:24 +1100 Subject: [PATCH 5/6] Leave the document-element exception to the element matcher alone The ID and class matchers' self-match branches only ever fired for the document element, which initialXpathQuery() already matches by running their query from the document. Removing them leaves one caller for isDocumentElement(), so its check is inlined there, using the same isSameNode() test as initialXpathQuery(). testBefore() now passes its selector straight to top(). Co-Authored-By: Claude Opus 5.5 --- src/CSS/DOMTraverser.php | 42 ++++------------------ tests/QueryPath/DOMQueryTest.php | 4 +-- tests/QueryPath/FindDescendantOnlyTest.php | 4 +-- 3 files changed, 10 insertions(+), 40 deletions(-) diff --git a/src/CSS/DOMTraverser.php b/src/CSS/DOMTraverser.php index bb59735..28164d2 100644 --- a/src/CSS/DOMTraverser.php +++ b/src/CSS/DOMTraverser.php @@ -697,10 +697,6 @@ protected function initialMatchOnID(SimpleSelector $selector, SplObjectStorage $ continue; } - if ($this->isDocumentElement($node) && $node->getAttribute('id') === $id) { - $found->offsetSet($node); - } - $nl = $this->initialXpathQuery($xpath, $node, $baseQuery); if (! empty($nl) && $nl instanceof DOMNodeList) { $this->attachNodeList($nl, $found); @@ -744,14 +740,6 @@ protected function initialMatchOnClasses(SimpleSelector $selector, SplObjectStor continue; } - // Refactor me! - if ($this->isDocumentElement($node) && $node->hasAttribute('class')) { - $intersect = array_intersect($selector->classes, explode(' ', $node->getAttribute('class'))); - if (count($intersect) === count($selector->classes)) { - $found->offsetSet($node); - } - } - $nl = $this->initialXpathQuery($xpath, $node, $baseQuery); /** @var DOMElement $subNode */ foreach ($nl as $subNode) { @@ -785,8 +773,8 @@ protected function initialMatchOnClasses(SimpleSelector $selector, SplObjectStor */ private function initialXpathQuery(DOMXPath $xpath, DOMElement $node, string $query): DOMNodeList { - // This works around a bug in which the document element - // does not correctly search with the $baseQuery. + // From the document element, search the whole document, so that the element itself can + // match: it stands in for the document (see initialMatchOnElement()). if ($node->isSameNode($this->dom->documentElement)) { $query = mb_substr($query, 1); } @@ -794,26 +782,6 @@ private function initialXpathQuery(DOMXPath $xpath, DOMElement $node, string $qu return $xpath->query($query, $node); } - /** - * Is this node the element the match set was seeded with? - * - * jQuery's find() searches descendants only, so a node already in the match set is not - * a candidate for its own selector. The document element is the one exception: QueryPath - * seeds the match set with it rather than with the document, so it stands in for the - * document and qp($xml)->find('root') has to keep matching. - * - * All three initial matchers ask this, and they have to agree — otherwise find('#a1') - * would match a node that find('a') does not. - * - * @param DOMNode $node - * - * @return bool - */ - private function isDocumentElement($node): bool - { - return $node->parentNode instanceof DOMDocument; - } - /** * Shortcut for setting the initial match. * @@ -834,7 +802,11 @@ protected function initialMatchOnElement(SimpleSelector $selector, SplObjectStor continue; } - if ($this->isDocumentElement($node) + // jQuery's find() searches descendants only, so a node in the match set is not a + // candidate for its own selector. The document element is the exception: qp() seeds + // the match set with it rather than with the document, so it stands in for the + // document. The ID and class matchers get the same exception from initialXpathQuery(). + if ($node->isSameNode($this->dom->documentElement) && ($element === '*' || $node->tagName === $element)) { $found->offsetSet($node); } diff --git a/tests/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php index f7223a8..f301575 100644 --- a/tests/QueryPath/DOMQueryTest.php +++ b/tests/QueryPath/DOMQueryTest.php @@ -1132,9 +1132,7 @@ public function testPrependTo() public function testBefore() { $file = DATA_FILE; - // find() searches descendants, so the search starts from the top of the document — - // the same shape as the top() calls below. - $this->assertEquals(1, qp($file, 'unary')->before('')->top()->find(':root > test ~ unary')->count()); + $this->assertEquals(1, qp($file, 'unary')->before('')->top(':root > test ~ unary')->count()); $this->assertEquals(1, qp($file, 'unary')->before('')->top('head ~ test')->count()); $this->assertEquals( 'unary', diff --git a/tests/QueryPath/FindDescendantOnlyTest.php b/tests/QueryPath/FindDescendantOnlyTest.php index b485c29..d74aa8a 100644 --- a/tests/QueryPath/FindDescendantOnlyTest.php +++ b/tests/QueryPath/FindDescendantOnlyTest.php @@ -62,8 +62,8 @@ public function testFilterIsTheWayToMatchTheNodesInHand(): void } /** - * The element, ID and class initial matchers each have their own self-test, so all three - * have to agree — otherwise find('#a1') would match a node that find('a') does not. + * The ID and class selectors take a different path from the element selector, and must + * agree with it: find('#a1') may not match a node that find('a') does not. */ public function testIdAndClassSelectorsAreDescendantOnlyToo(): void { From f443f90dab2c0b5241c9bf61a6654a6ae9c2b6c9 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 12:40:41 +1100 Subject: [PATCH 6/6] Test find() from mixed, leaf and empty sets, and with malformed selectors The element, ID and class matchers each skip text and comment nodes in the match set, exclude the elements in it, and still find their descendants. Finding from a leaf or an empty set returns nothing, and starting below the document element still reports a malformed selector. Co-Authored-By: Claude Opus 5.5 --- tests/QueryPath/FindDescendantOnlyTest.php | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/QueryPath/FindDescendantOnlyTest.php b/tests/QueryPath/FindDescendantOnlyTest.php index d74aa8a..ffe68a0 100644 --- a/tests/QueryPath/FindDescendantOnlyTest.php +++ b/tests/QueryPath/FindDescendantOnlyTest.php @@ -78,4 +78,48 @@ public function testIdAndClassSelectorsAreDescendantOnlyToo(): void // The document element keeps its exception here as well. $this->assertCount(1, qp($xml)->find('#a1')); } + + /** + * Each initial matcher (element, ID, class) skips the text and comment nodes in the set, + * and still excludes the element in the set while finding its descendants. + * + * @testWith ["*", "c1"] + * ["c", "c1"] + * ["#c1", "c1"] + * [".k", "c1"] + * ["#b1", ""] + * ["b", ""] + * ["b > c", "c1"] + */ + public function testFindFromAMixedNodeSet($selector, $expected): void + { + $xml = 'text'; + + $contents = qp($xml, '#a1')->contents(); + $this->assertCount(3, $contents); + + $ids = []; + foreach ($contents->find($selector)->get() as $node) { + $ids[] = $node->getAttribute('id'); + } + $this->assertSame($expected, implode(',', $ids)); + } + + public function testFindFromALeafOrAnEmptySetFindsNothing(): void + { + $this->assertCount(0, qp(self::XML, '#b1')->find('*')); + $this->assertCount(0, qp(self::XML, 'nope')->find('*')); + } + + /** + * Starting below the document element must not bypass selector parsing. + * + * @testWith ["[foo"] + * [""] + */ + public function testFindRejectsAMalformedSelector($selector): void + { + $this->expectException(\QueryPath\CSS\ParseException::class); + qp(self::XML, '#a1')->find($selector); + } }