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 = ''
+ . '
'
+ . '