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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ QueryPath Changelog

# Unreleased changes

- **Behaviour change.** Fix the jQuery positional pseudo-classes (`:eq()`, `:first`, `:last`, `:lt()`, `:gt()`, `:odd`, `:even`, and the `:nth()` alias of `:eq()`), which indexed an element's siblings instead of the matched set (issue #66). They are now applied, zero-indexed and in document order, to the set the selector matched, as in jQuery. Most visibly, `:eq(0)` now matches the first result instead of never matching. `:lt()`, `:gt()`, `:eq()` and `:nth()` are zero-indexed where they were previously one-indexed, `:odd`/`:even` now index the matched set rather than sibling position, and a negative index counts back from the end of the set (`:eq(-1)` is the last match). The CSS structural pseudo-classes (`:nth-child()`, `:nth-of-type()`, `:first-child`, `:last-child`, ...) are unchanged and still count siblings, one-indexed
- Make `QueryPath\CSS\QueryPathEventHandler`, the legacy engine still used by `remove()` and `replaceAll()`, agree with `QueryPath\CSS\DOMTraverser` on all of the positional pseudo-classes. `remove('li:first')` and `find('li:first')` now select the same element
- Support positional pseudo-classes on any simple selector in a chain, not just the last one: `ul:first li` selects the list items of the first `ul`, and each comma-separated group is filtered independently before the union is returned in document order
- Support positional pseudo-classes as the argument to `:not()`, `:has()` and `:matches()`, so the common `li:not(:first)` idiom now excludes the first match rather than every match
- `QueryPath\CSS\DOMTraverser\PseudoClass::elementMatches()` now throws `QueryPath\CSS\NotImplementedException` for the positional pseudo-classes. They cannot be answered for a node in isolation; use a `Traverser`, which applies them after traversal
- 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()`
Expand Down
235 changes: 222 additions & 13 deletions src/CSS/DOMTraverser.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@
* - `#myElement` does not get expanded
* - `#myElement .class` \i may be expanded to `*#myElement *.class`
* (which will obviously not perform well).
*
* \b Positional pseudo-classes
*
* The jQuery positional pseudo-classes (`:eq()`, `:first`, `:last`, `:lt()`,
* `:gt()`, `:odd`, `:even`, and the `:nth()` alias of `:eq()`) are not node
* predicates: they index the ordered set the selector matched. They are
* therefore stripped out of the per-node match and applied afterwards, to the
* document-ordered result set of the simple selector they were written on. See
* resolveGroup().
*/
class DOMTraverser implements Traverser
{
Expand All @@ -67,6 +76,27 @@ class DOMTraverser implements Traverser
protected $psHandler;
protected $scopeNode;

/**
* Pre-resolved match sets for simple selectors that carry a set-level
* pseudo-class but are not the subject of the selector.
*
* Keyed by the simple selector's index within the current selector group.
*
* @var SplObjectStorage[]
*/
protected $setFilterMatches = [];

/**
* The index of the simple selector currently being resolved.
*
* While resolving index N, the set-level pseudo-classes on index N must
* not be looked up in $setFilterMatches -- that is precisely what is being
* computed.
*
* @var int|null
*/
protected $resolvingIndex;

/**
* Build a new DOMTraverser.
*
Expand Down Expand Up @@ -135,27 +165,195 @@ public function find($selector): DOMTraverser
$this->selector = $handler;

//$selector = $handler->toArray();
$found = $this->newMatches();
$found = $this->newMatches();
$isFiltered = false;
foreach ($handler as $selectorGroup) {
// Initialize matches if necessary.
if ($this->initialized) {
$candidates = $this->matches;
} else {
$candidates = $this->initialMatch($selectorGroup[0], $this->matches);
if ($this->groupIsSetFiltered($selectorGroup)) {
$isFiltered = true;
}

foreach ($this->resolveGroup($selectorGroup) as $candidate) {
// $this->debug('Attaching ' . $candidate->nodeName);
$found->offsetSet($candidate);
}
}

// Comma-separated groups are filtered independently (as in jQuery), so the
// union of the groups is not necessarily in document order. Restore it.
if ($isFiltered) {
$found = $this->toMatches(Util::sortDocumentOrder(iterator_to_array($found, false)));
}

$this->setMatches($found);

return $this;
}

/**
* Whether any simple selector in the group carries a set-level pseudo-class.
*
* @param SimpleSelector[] $selectorGroup
*
* @return bool
*/
protected function groupIsSetFiltered(array $selectorGroup): bool
{
foreach ($selectorGroup as $simpleSelector) {
if ($simpleSelector->hasSetFilterPseudoClasses()) {
return true;
}
}

return false;
}

/**
* Resolve a single (comma-delimited) selector group to its matches.
*
* A selector group is stored right-to-left, so index 0 is the subject of
* the selector and higher indexes are its ancestors/siblings.
*
* Positional pseudo-classes filter an ordered set, so any simple selector
* that carries one has to be resolved -- and filtered -- in full before the
* selectors to its right can be evaluated against it. Those sets are
* resolved leftmost-first and cached; matchesSimpleSelector() then answers
* from the cache instead of re-testing the node.
*
* @param SimpleSelector[] $selectorGroup
*
* @return SplObjectStorage
* @throws NotImplementedException
* @throws ParseException
*/
protected function resolveGroup(array $selectorGroup): SplObjectStorage
{
$previousFilterMatches = $this->setFilterMatches;
$this->setFilterMatches = [];

for ($index = count($selectorGroup) - 1; $index > 0; $index--) {
if ($selectorGroup[$index]->hasSetFilterPseudoClasses()) {
$this->setFilterMatches[$index] = $this->resolveSubject($selectorGroup, $index);
}
}

$matches = $this->resolveSubject($selectorGroup, 0);
$this->setFilterMatches = $previousFilterMatches;

return $matches;
}

/**
* Resolve the set of nodes matching the sub-selector whose subject is $index.
*
* @param SimpleSelector[] $selectorGroup
* @param int $index
*
* @return SplObjectStorage
* @throws NotImplementedException
* @throws ParseException
*/
protected function resolveSubject(array $selectorGroup, int $index): SplObjectStorage
{
// Initialize matches if necessary.
if ($index === 0 && $this->initialized) {
$candidates = $this->matches;
} else {
$candidates = $this->initialMatch($selectorGroup[$index], $this->matches);
}

$setFilters = $selectorGroup[$index]->setFilterPseudoClasses();
$previous = $this->resolvingIndex;
$this->resolvingIndex = $index;

// Without a set-level filter nothing downstream depends on the order or the size of
// the result, so it is collected straight into the match storage rather than into an
// array that then has to be copied into one.
if (empty($setFilters)) {
$found = $this->newMatches();
/** @var DOMElement $candidate */
foreach ($candidates as $candidate) {
// fprintf(STDOUT, "Testing %s against %s.\n", $candidate->tagName, $selectorGroup[0]);
if ($this->matchesSelector($candidate, $selectorGroup)) {
// $this->debug('Attaching ' . $candidate->nodeName);
if ($this->matchesSimpleSelector($candidate, $selectorGroup, $index)) {
$found->offsetSet($candidate);
}
}

$this->resolvingIndex = $previous;

return $found;
}
$this->setMatches($found);

return $this;
$matched = [];
/** @var DOMElement $candidate */
foreach ($candidates as $candidate) {
if ($this->matchesSimpleSelector($candidate, $selectorGroup, $index)) {
$matched[] = $candidate;
}
}

$this->resolvingIndex = $previous;

$matched = Util::sortDocumentOrder($matched);
foreach ($setFilters as $pseudoClass) {
$matched = $this->applySetFilter($matched, $pseudoClass);
}

return $this->toMatches($matched);
}

/**
* Apply a single set-level pseudo-class to an ordered list of nodes.
*
* @param array $nodes
* @param array $pseudoClass
*
* @return array
* @throws ParseException
*/
protected function applySetFilter(array $nodes, array $pseudoClass): array
{
$name = strtolower($pseudoClass['name']);
$value = isset($pseudoClass['value']) ? $pseudoClass['value'] : null;

if (Util::isPositionalPseudoClass($name)) {
return Util::applyPositionalPseudoClass($nodes, $name, $value);
}

// :not(), :has() 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)) {
return [];
}

$traverser = new self($this->toMatches($nodes), true, $this->scopeNode);
$inner = $traverser->find($value)->matches();

$filtered = [];
foreach ($nodes as $node) {
$isMatch = $inner->offsetExists($node);
if ($name === 'not' ? ! $isMatch : $isMatch) {
$filtered[] = $node;
}
}

return $filtered;
}

/**
* Convert a list of nodes into an SplObjectStorage of matches.
*
* @param array $nodes
*
* @return SplObjectStorage
*/
protected function toMatches(array $nodes): SplObjectStorage
{
$matches = $this->newMatches();
foreach ($nodes as $node) {
$matches->offsetSet($node);
}

return $matches;
}

public function matches()
Expand Down Expand Up @@ -207,13 +405,23 @@ public function matchesSelector(DOMElement $node, $selector)
public function matchesSimpleSelector(DOMElement $node, $selectors, $index)
{
$selector = $selectors[$index];

// A set-level pseudo-class on a non-subject simple selector has already
// been resolved against the whole ordered set (see resolveGroup()). The
// cached set accounts for every selector to the left of $index too, so
// there is nothing further to combine.
if ($index !== $this->resolvingIndex && isset($this->setFilterMatches[$index])) {
return $this->setFilterMatches[$index]->offsetExists($node);
}

// Note that this will short circuit as soon as one of these
// returns FALSE.
$result = $this->matchElement($node, $selector->element, $selector->ns)
&& $this->matchAttributes($node, $selector->attributes)
&& $this->matchId($node, $selector->id)
&& $this->matchClasses($node, $selector->classes)
&& $this->matchPseudoClasses($node, $selector->pseudoClasses)
&& (empty($selector->pseudoClasses)
|| $this->matchPseudoClasses($node, $selector->nodePseudoClasses()))
&& $this->matchPseudoElements($node, $selector->pseudoElements);

$isNextRule = isset($selectors[++$index]);
Expand Down Expand Up @@ -815,7 +1023,8 @@ protected function matchPseudoClasses(DOMElement $node, $pseudoClasses): bool
$name = $pseudoClass['name'];
// Avoid E_STRICT violation.
$value = $pseudoClass['value'] ?? null;
$ret &= $this->psHandler->elementMatches($name, $node, $this->scopeNode, $value);

$ret &= $this->psHandler->elementMatches($name, $node, $this->scopeNode, $value);
}

return $ret;
Expand Down
51 changes: 22 additions & 29 deletions src/CSS/DOMTraverser/PseudoClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,25 @@ public function elementMatches($pseudoclass, $node, $scope, $value = null)
case 'x-reset':
case 'scope':
return $node->isSameNode($scope);
// NON-STANDARD extensions for simple support of even and odd. These
// are supported by jQuery, FF, and other user agents.
// The jQuery positional pseudo-classes index the *matched set*, not a
// node's siblings, so they cannot be answered for a node in isolation.
// QueryPath\CSS\DOMTraverser applies them to its ordered result set
// once traversal has finished. See Util::applyPositionalPseudoClass().
case 'even':
return $this->isNthChild($node, 'even');
case 'odd':
return $this->isNthChild($node, 'odd');
case 'lt':
case 'gt':
case 'nth':
case 'eq':
case 'first':
case 'last':
throw new NotImplementedException(
sprintf(
':%s filters an ordered result set and cannot be evaluated against a single node. '
. 'Use a Traverser, which applies it after traversal.',
$name
)
);
case 'nth-child':
return $this->isNthChild($node, $value);
case 'nth-last-child':
Expand All @@ -125,27 +138,8 @@ public function elementMatches($pseudoclass, $node, $scope, $value = null)
case 'only-of-type':
return $this->isFirstOfType($node) && $this->isLastOfType($node);

// Additional pseudo-classes defined in jQuery:
case 'lt':
// I'm treating this as "less than or equal to".
$rule = sprintf('-n + %d', (int) $value);

// $rule = '-n+15';
return $this->isNthChild($node, $rule);
case 'gt':
// I'm treating this as "greater than"
// return $this->nodePositionFromEnd($node) > (int) $value;
return $this->nodePositionFromStart($node) > (int) $value;
case 'nth':
case 'eq':
$rule = (int) $value;

return $this->isNthChild($node, $rule);
case 'first':
return $this->isNthChild($node, 1);
case 'first-child':
return $this->isFirst($node);
case 'last':
case 'last-child':
return $this->isLast($node);
case 'only-child':
Expand Down Expand Up @@ -415,17 +409,16 @@ protected function nodePositionFromEnd($node, $byType = false): int
* Provides nth-child and also the functionality required for:
*
*- nth-last-child
*- even
*- odd
*- first
*- last
*- eq
*- nth
*- nth-of-type
*- first-of-type
*- last-of-type
*- nth-last-of-type
*
* Note that the jQuery positional pseudo-classes (:even, :odd, :first,
* :last, :eq(), :nth(), :lt(), :gt()) are NOT handled here. They index the
* matched set rather than an element's siblings, and are applied to the
* result set by the traverser.
*
* See also QueryPath::CSS::DOMTraverser::Util::parseAnB().
*
* @param $node
Expand Down
Loading
Loading