Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 19 additions & 1 deletion src/CSS/DOMTraverser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)) {
Expand Down
130 changes: 127 additions & 3 deletions src/CSS/DOMTraverser/PseudoClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}

/**
Expand Down
6 changes: 4 additions & 2 deletions src/CSS/DOMTraverser/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/CSS/SimpleSelector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down
43 changes: 43 additions & 0 deletions tests/QueryPath/CSS/PseudoClassTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,27 @@ public function testHas()
$this->assertEquals(1, $i);
}

public function testHasMatchesRelatedElementsNotTheElementItself()
{
$xml = '<?xml version="1.0"?><root><ul><li><b/></li></ul><p/></root>';
[$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();
Expand Down Expand Up @@ -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 = '<?xml version="1.0"?><root>' . str_repeat('<a/><b/>', 50) . '</root>';
[$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 = '<?xml version="1.0"?><root><a/><b/><c/></root>';
Expand Down
Loading
Loading