diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b68e2..63489f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ QueryPath Changelog - Fix `not()` keeping only the given nodes, instead of removing them, when passed an `SplObjectStorage` such as `get(null, true)` returns (issue #75) - Fix selectors with two or more levels of nested parentheses, such as `li:not(:not(:nth-child(1)))` or `ul:has(li:not(:nth-child(1)))`, throwing "Recursion error detected" (issue #120). The scanner skipped the character after every nested argument, so the outer argument never saw its closing paren. This also stops `li:not(:nth-child(1)) b` from folding ` b` into the `:not()` argument, and lets quoted arguments contain parentheses, as in `:contains("a)b")` - Fix `QueryPath::VERSION` and `QueryPath::VERSION_MAJOR` still reporting `3.2.2` and `3`. They now report `4.2.0` and `4`, and both are deprecated in favour of `\Composer\InstalledVersions::getPrettyVersion('gravitypdf/querypath')`, which cannot drift from the installed version (issue #83) +- **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 # 4.2.0 diff --git a/src/CSS/DOMTraverser.php b/src/CSS/DOMTraverser.php index df770aa..e6c374a 100644 --- a/src/CSS/DOMTraverser.php +++ b/src/CSS/DOMTraverser.php @@ -168,6 +168,24 @@ public function find($selector): DOMTraverser $handler = new Selector(); $parser = new Parser($selector, $handler); $parser->parse(); + + return $this->findGroups($handler); + } + + /** + * Find the matches of an already-parsed selector. + * + * The traverser modifies the SimpleSelector objects it is given, so they must + * not be reused. + * + * @param iterable $handler + * The SimpleSelector[] groups, as a Selector yields them. + * + * @return DOMTraverser + * @throws ParseException + */ + public function findGroups($handler): DOMTraverser + { $this->selector = $handler; //$selector = $handler->toArray(); @@ -329,7 +347,7 @@ protected function applySetFilter(array $nodes, array $pseudoClass): array return Util::applyPositionalPseudoClass($nodes, $name, $value); } - // :not(), :has() and :matches() whose argument uses a positional filter. + // :not() and :matches() whose argument uses a positional filter. // The argument has to see the whole result set, exactly as jQuery's // :not(:first) does, so run it over this set and keep or drop the result. if (empty($nodes)) { diff --git a/src/CSS/DOMTraverser/PseudoClass.php b/src/CSS/DOMTraverser/PseudoClass.php index 371baca..62fe6b7 100644 --- a/src/CSS/DOMTraverser/PseudoClass.php +++ b/src/CSS/DOMTraverser/PseudoClass.php @@ -18,6 +18,9 @@ use QueryPath\CSS\EventHandler; use QueryPath\CSS\ParseException; use DOMNode; +use QueryPath\CSS\Parser; +use QueryPath\CSS\Selector; +use QueryPath\CSS\SimpleSelector; use SPLObjectStorage; /** @@ -52,6 +55,13 @@ class PseudoClass */ protected $anbRules = []; + /** + * Parsed :has() arguments, keyed by the argument. See relativeSelector(). + * + * @var array + */ + protected $relativeSelectors = []; + /** * Start remembering sibling positions, so the :nth-* pseudo-classes index * each sibling list once rather than walking it for every element. @@ -216,9 +226,9 @@ public function elementMatches($pseudoclass, $node, $scope, $value = null) case 'header': return $this->header($node); case 'has': - case 'matches': return $this->has($node, $value); - break; + case 'matches': + return $this->is($node, $value); case 'not': if (empty($value)) { throw new ParseException(':not() requires a value.'); @@ -392,9 +402,123 @@ protected function containsExactly($node, $value): bool /** * Provides :has pseudoclass. * + * Its argument is a relative selector, so `:has(li)` looks for a descendant + * and `:has(> li)`, `:has(+ li)` and `:has(~ li)` for a child or later sibling. + * * @throws ParseException */ protected function has($node, $selector): bool + { + foreach ($this->relativeSelector($selector) as [$group, $siblings, $nextOnly, $descends]) { + if (! $siblings) { + if ($this->findsAny($node, $node, $group, false)) { + return true; + } + } else { + // Search the later siblings one at a time, so a match near the node ends the search. + for ($next = $node->nextSibling; $next !== null; $next = $next->nextSibling) { + if (! $next instanceof DOMElement) { + continue; + } + + // The sibling can match itself, and its descendants when the group goes on to them (`+ ul li`). + if ($this->findsAny($next, $node, $group, true) + || ($descends && $this->findsAny($next, $node, $group, false)) + ) { + return true; + } + + if ($nextOnly) { + break; + } + } + } + } + + return false; + } + + /** + * Parse the argument of :has(), anchoring each group to :scope. + * + * A group that starts with a combinator (`> li`) has a blank leftmost simple selector, + * which becomes :scope. Any other group (`li`) is a descendant of :scope. + * + * @return array + * One [group, whether it starts at a later sibling (`+`/`~`), whether only the next sibling + * can match, whether it reaches below the sibling it starts from] per group. + * @throws ParseException + */ + protected function relativeSelector($selector): array + { + if (isset($this->relativeSelectors[$selector])) { + return $this->relativeSelectors[$selector]; + } + + $handler = new Selector(); + $parser = new Parser($selector, $handler); + $parser->parse(); + + $sibling = SimpleSelector::ADJACENT | SimpleSelector::SIBLING; + $groups = []; + foreach ($handler as $group) { + // Groups are stored right-to-left, so the last simple selector is the leftmost. + $anchor = end($group); + if (count($group) === 1 || ! $anchor->isBlank()) { + $anchor = new SimpleSelector(); + $anchor->combinator = SimpleSelector::ANY_DESCENDANT; + $group[] = $anchor; + } + $anchor->pseudoClasses[] = ['name' => 'scope', 'value' => null]; + + // `+ li` can only be the next sibling, unless the group goes on to another one (`+ li ~ li`). + $nextOnly = $anchor->combinator === SimpleSelector::ADJACENT; + $descends = false; + foreach (array_slice($group, 0, -1) as $simpleSelector) { + if ($simpleSelector->combinator & $sibling) { + $nextOnly = false; + } elseif ($simpleSelector->combinator) { + $descends = true; + } + } + + $groups[] = [$group, (bool) ($anchor->combinator & $sibling), $nextOnly, $descends]; + } + + return $this->relativeSelectors[$selector] = $groups; + } + + /** + * Whether $group matches $root itself or, when $self is FALSE, any of its descendants. + * + * @param DOMElement $root + * @param DOMElement $scope + * @param SimpleSelector[] $group + * @param bool $self + * + * @throws ParseException + */ + protected function findsAny(DOMElement $root, DOMElement $scope, array $group, bool $self): bool + { + $splos = new SPLObjectStorage(); + $splos->offsetSet($root); + + // The traverser consumes the element and ID selectors as it matches them, so give it a copy. + $copy = []; + foreach ($group as $simpleSelector) { + $copy[] = clone $simpleSelector; + } + $traverser = new DOMTraverser($splos, $self, $scope, $this); + + return count($traverser->findGroups([$copy])->matches()) > 0; + } + + /** + * Provides :matches pseudoclass, which tests the node itself. + * + * @throws ParseException + */ + protected function is($node, $selector): bool { $splos = new SPLObjectStorage(); $splos->offsetSet($node); @@ -411,7 +535,7 @@ protected function has($node, $selector): bool */ protected function isNot($node, $selector): bool { - return ! $this->has($node, $selector); + return ! $this->is($node, $selector); } /** diff --git a/src/CSS/DOMTraverser/Util.php b/src/CSS/DOMTraverser/Util.php index cd2f542..033b14a 100644 --- a/src/CSS/DOMTraverser/Util.php +++ b/src/CSS/DOMTraverser/Util.php @@ -49,10 +49,12 @@ class Util * If that argument contains a positional pseudo-class then the whole * pseudo-class becomes a set filter too, because the argument has to be * evaluated against the result set rather than against a lone node. + * + * :has() is not listed: its argument is searched for around each node, so a + * positional pseudo-class in it indexes that node's own matches, as in jQuery. */ private const SELECTOR_ARGUMENT_PSEUDO_CLASSES = [ 'not', - 'has', 'matches', ]; @@ -84,7 +86,7 @@ public static function isPositionalPseudoClass($name): bool * Check whether a pseudo-class has to be applied to the whole result set. * * That is true of the positional pseudo-classes themselves, and of - * :not()/:has()/:matches() when their argument contains one. + * :not()/:matches() when their argument contains one. * * @param string $name * @param mixed $value diff --git a/src/CSS/SimpleSelector.php b/src/CSS/SimpleSelector.php index f3d87ac..0bae098 100644 --- a/src/CSS/SimpleSelector.php +++ b/src/CSS/SimpleSelector.php @@ -116,7 +116,7 @@ public function __construct() * The jQuery positional pseudo-classes (:eq(), :first, :last, :lt(), :gt(), * :odd, :even) filter an ordered result set rather than testing an * individual node, so a traverser must apply these after it has collected - * its matches. The same is true of :not(), :has() and :matches() when their + * its matches. The same is true of :not() and :matches() when their * argument contains one. * * They are returned in the order they were written, which is the order they @@ -181,6 +181,20 @@ public function hasSetFilterPseudoClasses(): bool return count($this->setFilterPseudoClasses()) > 0; } + /** + * Whether this matches any element: no element, namespace, ID, class, attribute or pseudo. + */ + public function isBlank(): bool + { + return $this->element === null + && empty($this->ns) + && empty($this->id) + && empty($this->classes) + && empty($this->attributes) + && empty($this->pseudoClasses) + && empty($this->pseudoElements); + } + /** * @return bool */ diff --git a/tests/QueryPath/CSS/PseudoClassTest.php b/tests/QueryPath/CSS/PseudoClassTest.php index 6749682..04c9f5b 100644 --- a/tests/QueryPath/CSS/PseudoClassTest.php +++ b/tests/QueryPath/CSS/PseudoClassTest.php @@ -206,6 +206,27 @@ public function testHas() $this->assertEquals(1, $i); } + public function testHasMatchesRelatedElementsNotTheElementItself() + { + $xml = '

'; + [$ele, $root] = $this->doc($xml, 'ul'); + $ps = new PseudoClass(); + + $this->assertTrue($ps->elementMatches('has', $ele, $root, 'li')); + $this->assertTrue($ps->elementMatches('has', $ele, $root, 'b')); + $this->assertTrue($ps->elementMatches('has', $ele, $root, '> li')); + $this->assertFalse($ps->elementMatches('has', $ele, $root, '> b')); + $this->assertTrue($ps->elementMatches('has', $ele, $root, '+ p')); + $this->assertTrue($ps->elementMatches('has', $ele, $root, '~ p')); + $this->assertFalse($ps->elementMatches('has', $ele, $root, 'ul')); + $this->assertFalse($ps->elementMatches('has', $ele, $root, 'p')); + + $this->assertTrue($ps->elementMatches('matches', $ele, $root, 'ul')); + $this->assertFalse($ps->elementMatches('matches', $ele, $root, 'li')); + $this->assertFalse($ps->elementMatches('not', $ele, $root, 'ul')); + $this->assertTrue($ps->elementMatches('not', $ele, $root, 'li')); + } + public function testParent() { $ps = new PseudoClass(); @@ -700,6 +721,28 @@ public function testNestedQueriesShareTheSiblingIndex() $this->assertSame(1, $ps->indexed); } + /** + * :has() runs a query per element, and per later sibling, which must all reuse one index. + * + * @testWith ["a:has(+ b:nth-child(even))", 50] + * ["a:has(~ b:nth-last-child(1))", 50] + * ["root:has(> b:nth-of-type(50))", 1] + */ + public function testHasQueriesShareTheSiblingIndex($selector, $count) + { + $xml = '' . str_repeat('', 50) . ''; + [$ele, $root] = $this->doc($xml, 'root'); + + $splos = new SplObjectStorage(); + $splos->offsetSet($root); + + $ps = $this->countingPseudoClass(); + $traverser = new DOMTraverser($splos, false, null, $ps); + + $this->assertCount($count, $traverser->find($selector)->matches()); + $this->assertSame(1, $ps->indexed); + } + public function testNthChildDoesNotReusePositionsFromAnEarlierQuery() { $xml = ''; diff --git a/tests/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php index f301575..f65c8e4 100644 --- a/tests/QueryPath/DOMQueryTest.php +++ b/tests/QueryPath/DOMQueryTest.php @@ -399,6 +399,106 @@ public function testFind() $this->assertEquals($qp->get(0)->tagName, 'root'); } + protected const HAS_XML = '

' + . '
' + . '

'; + + /** + * @dataProvider hasSelectorProvider + */ + public function testFindHas(string $selector, array $expected) + { + $this->assertSame($expected, $this->ids(qp(self::HAS_XML)->find($selector)), $selector); + } + + public function hasSelectorProvider(): array + { + return [ + ['ul:has(li)', ['a', 'b']], + ['root:has(ul)', ['root']], + ['ul:has(span)', []], + ['ol:has(span)', ['c']], + ['ol:has(x, span)', ['c']], + ['*:has(#k)', ['root', 'c']], + ['ul:has(li:nth-child(3))', ['b']], + ['ul:has(li:first-child)', ['a', 'b']], + ['ul:has(li:eq(1))', ['a', 'b']], + ['ul:has(li:eq(2))', ['b']], + ['ul:has(> li)', ['a', 'b']], + ['ol:has(> span)', []], + ['ol:has(> li > span)', ['c']], + ['x:has(+ li)', ['x']], + ['ul:has(+ ol)', ['b']], + ['ul:has(+ p)', []], + ['ul:has(~ p)', ['a', 'b']], + ['ul:has(~ #q)', ['a', 'b']], + ['ul:has(~ #s)', []], + ['ul:has(~ ol > li > span)', ['a', 'b']], + ['ul:has(+ ul ~ q)', ['a']], + ['ul:not(:has(li))', []], + ['ul:not(:has(span))', ['a', 'b']], + ['*:not(:has(*))', ['a1', 'a2', 'b1', 'b2', 'b3', 'x', 's', 'p', 'q']], + ]; + } + + public function testFindHasHtml5() + { + $html = '

  • 1
  • 2
' + . '
  • 1
  • 2
  • 3
    '; + + $this->assertSame(['body'], $this->ids(html5qp($html)->find('body:has(ul)'))); + $this->assertSame(['a', 'b'], $this->ids(html5qp($html)->find('ul:has(> li)'))); + $this->assertSame(['b'], $this->ids(html5qp($html)->find('ul:has(li:nth-child(3))'))); + $this->assertSame(['b'], $this->ids(html5qp($html)->find('ul:has(+ ol)'))); + } + + public function testFilterHas() + { + $this->assertSame(['a', 'b'], $this->ids(qp(self::HAS_XML, 'ul')->filter(':has(li)'))); + $this->assertSame(['b'], $this->ids(qp(self::HAS_XML, 'ul')->filter('ul:has(li:nth-child(3))'))); + $this->assertSame(['a'], $this->ids(qp(self::HAS_XML, 'ul')->filter(':has(+ ul)'))); + } + + public function testFindHasAgreesWithRemove() + { + $selectors = [ + 'ul:has(li)', + 'root:has(ul)', + 'ul:has(> li)', + 'ul:has(li:nth-child(3))', + 'ul:has(li:first-child)', + 'ul:has(li:eq(1))', + 'ul:has(li:eq(2))', + 'x:has(+ li)', + 'ul:has(~ p)', + ]; + + foreach ($selectors as $selector) { + $this->assertSame( + $this->ids(qp(self::HAS_XML)->remove($selector)), + $this->ids(qp(self::HAS_XML)->find($selector)), + $selector + ); + } + } + + public function testFindNotIsUnaffectedByHas() + { + $this->assertSame(['a1', 'a2', 'b1', 'b2', 'b3'], $this->ids(qp(self::HAS_XML)->find('li:not(#k)'))); + $this->assertSame(['k'], $this->ids(qp(self::HAS_XML)->find('li:not(:empty)'))); + $this->assertSame(['a', 'b'], $this->ids(qp(self::HAS_XML)->find('#root > *:not(ol, p, q)'))); + } + + protected function ids(DOMQuery $qp): array + { + $ids = []; + foreach ($qp->get() as $node) { + $ids[] = $node->getAttribute('id') ?: $node->tagName; + } + + return $ids; + } + public function testFindInPlace() { $file = DATA_FILE;