Skip to content

Commit 1be874a

Browse files
Make :has() match in find() (#125)
* Make :has() match related elements in DOMTraverser PseudoClass::has() ran its argument in initialized mode against the element itself, so ul:has(li) asked whether the ul is an li and never matched. It now reads the argument as a relative selector anchored to the element: a descendant by default, or a child, next sibling or later sibling for a leading >, + or ~. :not() and :matches() keep testing the element itself. :has() is no longer a set filter when its argument is positional: the argument is searched for around each element, as in jQuery. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test a later sibling itself against a sibling-relative :has() find() now searches descendants only (#73), so the per-sibling search in :has(+ ...) and :has(~ ...) no longer saw the sibling it started from. Test the sibling itself first, and search below it only when the group goes on to a child or descendant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Return whether a :has() group starts at a sibling, not its combinator has() only ever asked whether the anchor combinator was + or ~, so relativeSelector() now answers that, and the descendant and sibling searches become the two arms of one if/else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Share the sibling index with the queries :has() runs findsAny() built each per-element and per-sibling traverser with its own PseudoClass, so with #119's index every one re-indexed its sibling list: li:has(~ li:nth-child(1)) over 400 siblings took about 9 s. It now passes its own handler, as is() does, which also lets a nested :has() reuse the parsed-argument cache (about 0.26 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1767909 commit 1be874a

7 files changed

Lines changed: 309 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ QueryPath Changelog
5252
- Fix `not()` keeping only the given nodes, instead of removing them, when passed an `SplObjectStorage` such as `get(null, true)` returns (issue #75)
5353
- 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")`
5454
- 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)
55+
- **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
5556

5657
# 4.2.0
5758

‎src/CSS/DOMTraverser.php‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,24 @@ public function find($selector): DOMTraverser
168168
$handler = new Selector();
169169
$parser = new Parser($selector, $handler);
170170
$parser->parse();
171+
172+
return $this->findGroups($handler);
173+
}
174+
175+
/**
176+
* Find the matches of an already-parsed selector.
177+
*
178+
* The traverser modifies the SimpleSelector objects it is given, so they must
179+
* not be reused.
180+
*
181+
* @param iterable $handler
182+
* The SimpleSelector[] groups, as a Selector yields them.
183+
*
184+
* @return DOMTraverser
185+
* @throws ParseException
186+
*/
187+
public function findGroups($handler): DOMTraverser
188+
{
171189
$this->selector = $handler;
172190

173191
//$selector = $handler->toArray();
@@ -329,7 +347,7 @@ protected function applySetFilter(array $nodes, array $pseudoClass): array
329347
return Util::applyPositionalPseudoClass($nodes, $name, $value);
330348
}
331349

332-
// :not(), :has() and :matches() whose argument uses a positional filter.
350+
// :not() and :matches() whose argument uses a positional filter.
333351
// The argument has to see the whole result set, exactly as jQuery's
334352
// :not(:first) does, so run it over this set and keep or drop the result.
335353
if (empty($nodes)) {

‎src/CSS/DOMTraverser/PseudoClass.php‎

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
use QueryPath\CSS\EventHandler;
1919
use QueryPath\CSS\ParseException;
2020
use DOMNode;
21+
use QueryPath\CSS\Parser;
22+
use QueryPath\CSS\Selector;
23+
use QueryPath\CSS\SimpleSelector;
2124
use SPLObjectStorage;
2225

2326
/**
@@ -52,6 +55,13 @@ class PseudoClass
5255
*/
5356
protected $anbRules = [];
5457

58+
/**
59+
* Parsed :has() arguments, keyed by the argument. See relativeSelector().
60+
*
61+
* @var array
62+
*/
63+
protected $relativeSelectors = [];
64+
5565
/**
5666
* Start remembering sibling positions, so the :nth-* pseudo-classes index
5767
* each sibling list once rather than walking it for every element.
@@ -216,9 +226,9 @@ public function elementMatches($pseudoclass, $node, $scope, $value = null)
216226
case 'header':
217227
return $this->header($node);
218228
case 'has':
219-
case 'matches':
220229
return $this->has($node, $value);
221-
break;
230+
case 'matches':
231+
return $this->is($node, $value);
222232
case 'not':
223233
if (empty($value)) {
224234
throw new ParseException(':not() requires a value.');
@@ -392,9 +402,123 @@ protected function containsExactly($node, $value): bool
392402
/**
393403
* Provides :has pseudoclass.
394404
*
405+
* Its argument is a relative selector, so `:has(li)` looks for a descendant
406+
* and `:has(> li)`, `:has(+ li)` and `:has(~ li)` for a child or later sibling.
407+
*
395408
* @throws ParseException
396409
*/
397410
protected function has($node, $selector): bool
411+
{
412+
foreach ($this->relativeSelector($selector) as [$group, $siblings, $nextOnly, $descends]) {
413+
if (! $siblings) {
414+
if ($this->findsAny($node, $node, $group, false)) {
415+
return true;
416+
}
417+
} else {
418+
// Search the later siblings one at a time, so a match near the node ends the search.
419+
for ($next = $node->nextSibling; $next !== null; $next = $next->nextSibling) {
420+
if (! $next instanceof DOMElement) {
421+
continue;
422+
}
423+
424+
// The sibling can match itself, and its descendants when the group goes on to them (`+ ul li`).
425+
if ($this->findsAny($next, $node, $group, true)
426+
|| ($descends && $this->findsAny($next, $node, $group, false))
427+
) {
428+
return true;
429+
}
430+
431+
if ($nextOnly) {
432+
break;
433+
}
434+
}
435+
}
436+
}
437+
438+
return false;
439+
}
440+
441+
/**
442+
* Parse the argument of :has(), anchoring each group to :scope.
443+
*
444+
* A group that starts with a combinator (`> li`) has a blank leftmost simple selector,
445+
* which becomes :scope. Any other group (`li`) is a descendant of :scope.
446+
*
447+
* @return array
448+
* One [group, whether it starts at a later sibling (`+`/`~`), whether only the next sibling
449+
* can match, whether it reaches below the sibling it starts from] per group.
450+
* @throws ParseException
451+
*/
452+
protected function relativeSelector($selector): array
453+
{
454+
if (isset($this->relativeSelectors[$selector])) {
455+
return $this->relativeSelectors[$selector];
456+
}
457+
458+
$handler = new Selector();
459+
$parser = new Parser($selector, $handler);
460+
$parser->parse();
461+
462+
$sibling = SimpleSelector::ADJACENT | SimpleSelector::SIBLING;
463+
$groups = [];
464+
foreach ($handler as $group) {
465+
// Groups are stored right-to-left, so the last simple selector is the leftmost.
466+
$anchor = end($group);
467+
if (count($group) === 1 || ! $anchor->isBlank()) {
468+
$anchor = new SimpleSelector();
469+
$anchor->combinator = SimpleSelector::ANY_DESCENDANT;
470+
$group[] = $anchor;
471+
}
472+
$anchor->pseudoClasses[] = ['name' => 'scope', 'value' => null];
473+
474+
// `+ li` can only be the next sibling, unless the group goes on to another one (`+ li ~ li`).
475+
$nextOnly = $anchor->combinator === SimpleSelector::ADJACENT;
476+
$descends = false;
477+
foreach (array_slice($group, 0, -1) as $simpleSelector) {
478+
if ($simpleSelector->combinator & $sibling) {
479+
$nextOnly = false;
480+
} elseif ($simpleSelector->combinator) {
481+
$descends = true;
482+
}
483+
}
484+
485+
$groups[] = [$group, (bool) ($anchor->combinator & $sibling), $nextOnly, $descends];
486+
}
487+
488+
return $this->relativeSelectors[$selector] = $groups;
489+
}
490+
491+
/**
492+
* Whether $group matches $root itself or, when $self is FALSE, any of its descendants.
493+
*
494+
* @param DOMElement $root
495+
* @param DOMElement $scope
496+
* @param SimpleSelector[] $group
497+
* @param bool $self
498+
*
499+
* @throws ParseException
500+
*/
501+
protected function findsAny(DOMElement $root, DOMElement $scope, array $group, bool $self): bool
502+
{
503+
$splos = new SPLObjectStorage();
504+
$splos->offsetSet($root);
505+
506+
// The traverser consumes the element and ID selectors as it matches them, so give it a copy.
507+
$copy = [];
508+
foreach ($group as $simpleSelector) {
509+
$copy[] = clone $simpleSelector;
510+
}
511+
$traverser = new DOMTraverser($splos, $self, $scope, $this);
512+
513+
return count($traverser->findGroups([$copy])->matches()) > 0;
514+
}
515+
516+
/**
517+
* Provides :matches pseudoclass, which tests the node itself.
518+
*
519+
* @throws ParseException
520+
*/
521+
protected function is($node, $selector): bool
398522
{
399523
$splos = new SPLObjectStorage();
400524
$splos->offsetSet($node);
@@ -411,7 +535,7 @@ protected function has($node, $selector): bool
411535
*/
412536
protected function isNot($node, $selector): bool
413537
{
414-
return ! $this->has($node, $selector);
538+
return ! $this->is($node, $selector);
415539
}
416540

417541
/**

‎src/CSS/DOMTraverser/Util.php‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,12 @@ class Util
4949
* If that argument contains a positional pseudo-class then the whole
5050
* pseudo-class becomes a set filter too, because the argument has to be
5151
* evaluated against the result set rather than against a lone node.
52+
*
53+
* :has() is not listed: its argument is searched for around each node, so a
54+
* positional pseudo-class in it indexes that node's own matches, as in jQuery.
5255
*/
5356
private const SELECTOR_ARGUMENT_PSEUDO_CLASSES = [
5457
'not',
55-
'has',
5658
'matches',
5759
];
5860

@@ -84,7 +86,7 @@ public static function isPositionalPseudoClass($name): bool
8486
* Check whether a pseudo-class has to be applied to the whole result set.
8587
*
8688
* That is true of the positional pseudo-classes themselves, and of
87-
* :not()/:has()/:matches() when their argument contains one.
89+
* :not()/:matches() when their argument contains one.
8890
*
8991
* @param string $name
9092
* @param mixed $value

‎src/CSS/SimpleSelector.php‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ public function __construct()
116116
* The jQuery positional pseudo-classes (:eq(), :first, :last, :lt(), :gt(),
117117
* :odd, :even) filter an ordered result set rather than testing an
118118
* individual node, so a traverser must apply these after it has collected
119-
* its matches. The same is true of :not(), :has() and :matches() when their
119+
* its matches. The same is true of :not() and :matches() when their
120120
* argument contains one.
121121
*
122122
* They are returned in the order they were written, which is the order they
@@ -181,6 +181,20 @@ public function hasSetFilterPseudoClasses(): bool
181181
return count($this->setFilterPseudoClasses()) > 0;
182182
}
183183

184+
/**
185+
* Whether this matches any element: no element, namespace, ID, class, attribute or pseudo.
186+
*/
187+
public function isBlank(): bool
188+
{
189+
return $this->element === null
190+
&& empty($this->ns)
191+
&& empty($this->id)
192+
&& empty($this->classes)
193+
&& empty($this->attributes)
194+
&& empty($this->pseudoClasses)
195+
&& empty($this->pseudoElements);
196+
}
197+
184198
/**
185199
* @return bool
186200
*/

‎tests/QueryPath/CSS/PseudoClassTest.php‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,27 @@ public function testHas()
206206
$this->assertEquals(1, $i);
207207
}
208208

209+
public function testHasMatchesRelatedElementsNotTheElementItself()
210+
{
211+
$xml = '<?xml version="1.0"?><root><ul><li><b/></li></ul><p/></root>';
212+
[$ele, $root] = $this->doc($xml, 'ul');
213+
$ps = new PseudoClass();
214+
215+
$this->assertTrue($ps->elementMatches('has', $ele, $root, 'li'));
216+
$this->assertTrue($ps->elementMatches('has', $ele, $root, 'b'));
217+
$this->assertTrue($ps->elementMatches('has', $ele, $root, '> li'));
218+
$this->assertFalse($ps->elementMatches('has', $ele, $root, '> b'));
219+
$this->assertTrue($ps->elementMatches('has', $ele, $root, '+ p'));
220+
$this->assertTrue($ps->elementMatches('has', $ele, $root, '~ p'));
221+
$this->assertFalse($ps->elementMatches('has', $ele, $root, 'ul'));
222+
$this->assertFalse($ps->elementMatches('has', $ele, $root, 'p'));
223+
224+
$this->assertTrue($ps->elementMatches('matches', $ele, $root, 'ul'));
225+
$this->assertFalse($ps->elementMatches('matches', $ele, $root, 'li'));
226+
$this->assertFalse($ps->elementMatches('not', $ele, $root, 'ul'));
227+
$this->assertTrue($ps->elementMatches('not', $ele, $root, 'li'));
228+
}
229+
209230
public function testParent()
210231
{
211232
$ps = new PseudoClass();
@@ -700,6 +721,28 @@ public function testNestedQueriesShareTheSiblingIndex()
700721
$this->assertSame(1, $ps->indexed);
701722
}
702723

724+
/**
725+
* :has() runs a query per element, and per later sibling, which must all reuse one index.
726+
*
727+
* @testWith ["a:has(+ b:nth-child(even))", 50]
728+
* ["a:has(~ b:nth-last-child(1))", 50]
729+
* ["root:has(> b:nth-of-type(50))", 1]
730+
*/
731+
public function testHasQueriesShareTheSiblingIndex($selector, $count)
732+
{
733+
$xml = '<?xml version="1.0"?><root>' . str_repeat('<a/><b/>', 50) . '</root>';
734+
[$ele, $root] = $this->doc($xml, 'root');
735+
736+
$splos = new SplObjectStorage();
737+
$splos->offsetSet($root);
738+
739+
$ps = $this->countingPseudoClass();
740+
$traverser = new DOMTraverser($splos, false, null, $ps);
741+
742+
$this->assertCount($count, $traverser->find($selector)->matches());
743+
$this->assertSame(1, $ps->indexed);
744+
}
745+
703746
public function testNthChildDoesNotReusePositionsFromAnEarlierQuery()
704747
{
705748
$xml = '<?xml version="1.0"?><root><a/><b/><c/></root>';

0 commit comments

Comments
 (0)