diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e390a2..e274e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ QueryPath Changelog # Unreleased changes +- **Breaking behaviour change:** `DOMQuery::is()` now behaves like jQuery's `.is()`. It tests the elements held in the current match set and returns `true` when at least one of them matches the selector. Previously it ran a descendant search, so `html5qp('
foo
', 'p')->is('span')` returned `true`. Use `has()` if you need the old "does the set contain something matching this selector" behaviour (#51) +- `DOMQuery::is()` no longer raises a fatal error when the match set contains non-element nodes (text nodes, comments, processing instructions). Those nodes cannot match a CSS selector, so they are skipped - Reorganise, modernise, and repair the `examples/` directory. Each example now lives in its own subdirectory with an `index.php`, and the full set is indexed in `examples/quickstart-guide.md` - Convert the remaining legacy examples: `simple_example.php`, `techniques.php`, `svg.php`, `rss.php`, `odt.php`, `parse_php.php`, and `sparql.php` - Fix examples that no longer ran: send a `User-Agent` where remote hosts now require one, resolve paths relative to the example rather than the working directory, and stop relying on the removed `qp.php` autoloader and the PHP 8 incompatible `eachLambda()` diff --git a/src/Helpers/NodeMatcher.php b/src/Helpers/NodeMatcher.php new file mode 100644 index 0000000..e66996c --- /dev/null +++ b/src/Helpers/NodeMatcher.php @@ -0,0 +1,99 @@ +descendants of the nodes it is given, so for `` it would report + * that the `a` element "matches" the selector `b`. Here the supplied nodes are + * themselves the only candidates, so `b` matches the `b` element and nothing else. + * + * The DOMTraverser is built in "initialized" mode, which tells it to treat the + * supplied set as the candidate set rather than seeding it with a descendant + * search. Combinators are still resolved against the real document by walking up + * from each candidate, so full selectors (e.g. `div > p`) keep working. + * + * The scope node is deliberately left at its default (the document element) so that + * `:scope` means the same thing here as it does in find(). Passing a candidate as the + * scope node would make every candidate match `:scope`. + */ +final class NodeMatcher +{ + + /** + * Reduce a set of nodes to those that match a selector. + * + * Nodes that are not elements can never match a CSS selector, and are skipped + * rather than raising an error. + * + * @param SplObjectStorage $nodes + * The candidate nodes. + * @param string $selector + * A valid CSS selector. + * + * @return SplObjectStorage + * The subset of $nodes that match the selector. + * @throws ParseException + */ + public static function filter(SplObjectStorage $nodes, $selector): SplObjectStorage + { + $candidates = new SplObjectStorage(); + foreach ($nodes as $node) { + if ($node instanceof DOMElement) { + $candidates->offsetSet($node); + } + } + + if (count($candidates) === 0) { + return $candidates; + } + + $traverser = new DOMTraverser($candidates, true); + $traverser->find($selector); + + return $traverser->matches(); + } + + /** + * Test whether at least one of the given nodes matches a selector. + * + * @param SplObjectStorage $nodes + * @param string $selector + * + * @return bool + * @throws ParseException + */ + public static function matchesAny(SplObjectStorage $nodes, $selector): bool + { + return count(self::filter($nodes, $selector)) > 0; + } + + /** + * Test whether a single node, taken as an element, matches a selector. + * + * @param mixed $node + * The node to test. Anything that is not an element returns FALSE. + * @param string $selector + * + * @return bool + * @throws ParseException + */ + public static function matchesNode($node, $selector): bool + { + if (! $node instanceof DOMElement) { + return false; + } + + $nodes = new SplObjectStorage(); + $nodes->offsetSet($node); + + return self::matchesAny($nodes, $selector); + } +} diff --git a/src/Helpers/QueryChecks.php b/src/Helpers/QueryChecks.php index cdb4b6c..68a5232 100644 --- a/src/Helpers/QueryChecks.php +++ b/src/Helpers/QueryChecks.php @@ -21,20 +21,33 @@ trait QueryChecks { /** - * Given a selector, this checks to see if the current set has one or more matches. + * Check the current set of elements against a selector, and return TRUE if at least + * one of them matches. + * + * This behaves like jQuery's is(): only the elements in the current match set are + * tested. Neither the descendants nor the ancestors of those elements are considered. + * Use has() if you need to know whether the current set contains something that + * matches a selector. * * Unlike jQuery's version, this supports full selectors (not just simple ones). * - * @param string|DOMNode $selector - * The selector to search for. As of QueryPath 2.1.1, this also supports passing a - * DOMNode object. + * Non-element nodes in the match set (text nodes, comments, processing instructions, and + * so on) can never match a CSS selector, and are simply skipped. + * + * @param string|DOMNode|Traversable $selector + * The selector to test the current match set against. As of QueryPath 2.1.1, this also + * supports passing a DOMNode object, in which case the current match set must consist of + * exactly that one node, or a Traversable (e.g. another DOMQuery's match set), in which + * case the two sets must contain exactly the same nodes. * * @return boolean * TRUE if one or more elements match. FALSE if no match is found. * @throws Exception - * @throws Exception + * @throws ParseException * @see get() * @see eq() + * @see has() + * @see filter() */ public function is($selector): bool { @@ -63,7 +76,9 @@ public function is($selector): bool throw new Exception('Cannot compare an object to a DOMQuery.'); } - return $this->branch($selector)->count() > 0; + // Only the elements in the match set are candidates: a descendant that matches + // the selector must not make is() true. See NodeMatcher for the details. + return NodeMatcher::matchesAny($this->matches, $selector); } /** diff --git a/tests/Issues/Issue51Test.php b/tests/Issues/Issue51Test.php new file mode 100644 index 0000000..930f0c2 --- /dev/null +++ b/tests/Issues/Issue51Test.php @@ -0,0 +1,154 @@ +foo', 'p'); + + self::assertTrue($dom->is('p'), 'Should match an element held directly in the collection'); + self::assertFalse($dom->is('span'), 'Should not match a descendant of an element held in the collection'); + } + + /** + * The same document, but with the collection left at the document element. Neither the + * nor the is in the collection, so neither may match.
+ */
+ public function testIsDoesNotMatchDeeperDescendants(): void
+ {
+ $dom = html5qp(' foo foo foo foo foo foo
', 'li');
+
+ self::assertCount(3, $dom);
+ self::assertTrue($dom->is('li'));
+ self::assertTrue($dom->is('#one'), 'The first element matches');
+ self::assertTrue($dom->is('.two'), 'The second element matches');
+ self::assertTrue($dom->is('li, dt'), 'Selector groups are supported');
+ self::assertFalse($dom->is('.missing'));
+ self::assertFalse($dom->is('ul'), 'Should not match the parent of the elements in the collection');
+ }
+
+ /**
+ * The elements are still tested in the context of their own document, so combinators and
+ * positional pseudo-classes continue to work.
+ */
+ public function testIsSupportsFullSelectors(): void
+ {
+ $dom = html5qp('
');
+ $one = $dom->top('#one');
+ $node = $one->get(0);
+
+ self::assertTrue($one->is($node));
+ self::assertFalse($dom->top('#two')->is($node));
+ self::assertFalse($dom->top('li')->is($node), 'A single node cannot equal a set of two');
+ }
+
+ public function testIsStillAcceptsATraversable(): void
+ {
+ $dom = html5qp('
');
+
+ $list = new SplDoublyLinkedList();
+ $list->push($dom->top('#one')->get(0));
+ $list->push($dom->top('#two')->get(0));
+
+ self::assertTrue($dom->top('#one,#two')->is($list));
+ self::assertFalse($dom->top('#one')->is($list));
+ }
+
+ /**
+ * has() is the migration path for anyone who relied on the old containment behaviour.
+ */
+ public function testHasProvidesTheOldContainmentBehaviour(): void
+ {
+ $dom = html5qp('