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

# Unreleased changes

- Fix fatal error when running a CSS selector against a match set that contains non-element nodes (text, comment, CDATA
or processing instruction). Those nodes now simply do not match, instead of calling element-only DOM methods on them.
- Fix the `:text` pseudo-class so it matches jQuery: it selects `input` elements whose `type` attribute is absent or is
`text` (case-insensitively). It never indicated, and still does not indicate, whether a node is a text node.
- `find('*')` now matches the nodes in the match set as well as their descendants, so that a selector can be tested
against an element already in hand. Note that #73 replaces this with jQuery's descendant-only `find()`; this entry
is provisional and should be dropped if that lands first.
- 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
44 changes: 35 additions & 9 deletions src/CSS/DOMTraverser.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use DOMDocument;
use DOMElement;
use DOMNode;
use DOMNodeList;
use DOMXPath;
use QueryPath\CSS\DOMTraverser\Util;
Expand Down Expand Up @@ -177,14 +178,14 @@ public function matches()
* absolutely huge selectors or for versions of PHP tuned to
* strictly limit recursion depth.
*
* @param DOMElement $node
* @param DOMNode $node
* The DOMNode to check.
* @param $selector
*
* @return boolean
* A boolean TRUE if the node matches, false otherwise.
*/
public function matchesSelector(DOMElement $node, $selector)
public function matchesSelector(DOMNode $node, $selector)
{
return $this->matchesSimpleSelector($node, $selector, 0);
}
Expand All @@ -196,16 +197,24 @@ public function matchesSelector(DOMElement $node, $selector)
* this checks only a simple selector (plus an optional
* combinator).
*
* @param DOMElement $node
* @param DOMNode $node
* @param $selectors
* @param $index
*
* @return boolean
* A boolean TRUE if the node matches, false otherwise.
* @throws NotImplementedException
*/
public function matchesSimpleSelector(DOMElement $node, $selectors, $index)
public function matchesSimpleSelector(DOMNode $node, $selectors, $index)
{
// Selectors only ever match elements. A match set may legitimately
// contain text, comment, CDATA or processing instruction nodes (e.g.
// from contents()), and those simply do not match -- rather than
// blowing up on the element-only DOM API used below.
if (! $node instanceof DOMElement) {
return false;
}

$selector = $selectors[$index];
// Note that this will short circuit as soon as one of these
// returns FALSE.
Expand Down Expand Up @@ -257,7 +266,7 @@ public function matchesSimpleSelector(DOMElement $node, $selectors, $index)
* @return boolean
* TRUE if the next selector(s) match.
*/
public function combine(DOMElement $node, $selectors, $index)
public function combine(DOMNode $node, $selectors, $index)
{
$selector = $selectors[$index];
//$this->debug(implode(' ', $selectors));
Expand Down Expand Up @@ -476,6 +485,11 @@ protected function initialMatchOnID(SimpleSelector $selector, SplObjectStorage $
// Now we try to find any matching IDs.
/** @var DOMElement $node */
foreach ($matches as $node) {
// Non-element nodes have neither attributes nor element children.
if (! $node instanceof DOMElement) {
continue;
}

if ($node->getAttribute('id') === $id) {
$found->offsetSet($node);
}
Expand Down Expand Up @@ -520,6 +534,11 @@ protected function initialMatchOnClasses(SimpleSelector $selector, SplObjectStor
// Now we try to find any matching IDs.
/** @var DOMElement $node */
foreach ($matches as $node) {
// Non-element nodes have neither attributes nor element children.
if (! $node instanceof DOMElement) {
continue;
}

// Refactor me!
if ($node->hasAttribute('class')) {
$intersect = array_intersect($selector->classes, explode(' ', $node->getAttribute('class')));
Expand Down Expand Up @@ -585,11 +604,18 @@ protected function initialMatchOnElement(SimpleSelector $selector, SplObjectStor
$element = '*';
}
$found = $this->newMatches();
/** @var DOMDocument $node */
/** @var DOMDocument|DOMElement $node */
foreach ($matches as $node) {
// Capture the case where the initial element is the root element.
if ($node->tagName === $element
|| ($element === '*' && $node->parentNode instanceof DOMDocument)) {
// Only elements and documents can contain elements. Text, comment,
// CDATA and processing instruction nodes never match, and do not
// support the element-only API used below.
if (! $node instanceof DOMElement && ! $node instanceof DOMDocument) {
continue;
}

// Capture the case where the node itself matches the element.
if ($node instanceof DOMElement
&& ($element === '*' || $node->tagName === $element)) {
$found->offsetSet($node);
}
$nl = $node->getElementsByTagName($element);
Expand Down
29 changes: 29 additions & 0 deletions src/CSS/DOMTraverser/PseudoClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

namespace QueryPath\CSS\DOMTraverser;

use DOMElement;
use QueryPath\CSS\DOMTraverser;
use QueryPath\CSS\NotImplementedException;
use QueryPath\CSS\EventHandler;
Expand Down Expand Up @@ -46,6 +47,13 @@ class PseudoClass
*/
public function elementMatches($pseudoclass, $node, $scope, $value = null)
{
// Pseudo-classes are only ever satisfied by elements. Text, comment,
// CDATA and processing instruction nodes have no tag name, attributes
// or element children, so they can never match.
if (! $node instanceof DOMElement) {
return false;
}

$name = strtolower($pseudoclass);
// Need to handle known pseudoclasses.
switch ($name) {
Expand Down Expand Up @@ -160,6 +168,8 @@ public function elementMatches($pseudoclass, $node, $scope, $value = null)
case 'checked':
return Util::matchesAttribute($node, $name);
case 'text':
return $this->isTextInput($node);

case 'radio':
case 'checkbox':
case 'file':
Expand Down Expand Up @@ -226,6 +236,25 @@ protected function lang($node, $value)
return false;
}

/**
* Provides jQuery pseudoclass ':text'.
*
* This mirrors jQuery, where `:text` selects `input` elements of type text
* -- that is, an `input` whose `type` attribute is either absent (`text` is
* the default type of an `input`) or is `text`, matched case-insensitively.
*
* It does NOT indicate whether the node is a text node.
*
* @param DOMElement $node
*
* @return bool
* @see https://api.jquery.com/text-selector/
*/
protected function isTextInput($node): bool
{
return Util::isTextInput($node);
}

/**
* Provides jQuery pseudoclass ':header'.
*
Expand Down
35 changes: 35 additions & 0 deletions src/CSS/DOMTraverser/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace QueryPath\CSS\DOMTraverser;

use DOMElement;
use QueryPath\CSS\EventHandler;

/**
Expand All @@ -26,6 +27,12 @@ class Util
*/
public static function matchesAttribute($node, $name, $value = null, $operation = EventHandler::IS_EXACTLY): bool
{
// Only elements have attributes. Text, comment, CDATA and processing
// instruction nodes can never match an attribute selector.
if (! $node instanceof DOMElement) {
return false;
}

if (! $node->hasAttribute($name)) {
return false;
}
Expand All @@ -47,6 +54,11 @@ public static function matchesAttributeNS(
$value = null,
$operation = EventHandler::IS_EXACTLY
) {
// Only elements have attributes.
if (! $node instanceof DOMElement) {
return false;
}

if (! $node->hasAttributeNS($nsuri, $name)) {
return false;
}
Expand Down Expand Up @@ -165,4 +177,27 @@ public static function parseAnB($rule): array

return [$aVal, $bVal];
}

/**
* Does this node match jQuery's :text pseudo-class?
*
* jQuery's :text selects input elements whose type attribute is absent, or is "text"
* regardless of case. It says nothing about whether a node is a text node.
*
* Both selector engines ask this question, so they share one answer — they are meant to
* agree, and two copies of the rule would be free to drift apart.
*
* @param mixed $node
*
* @return bool
*/
public static function isTextInput($node): bool
{
if (! $node instanceof DOMElement || strtolower($node->localName) !== 'input') {
return false;
}

// An input with no type attribute defaults to a text input.
return ! $node->hasAttribute('type') || strtolower($node->getAttribute('type')) === 'text';
}
}
26 changes: 26 additions & 0 deletions src/CSS/QueryPathEventHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,30 @@ public function attribute($name, $value = null, $operation = EventHandler::IS_EX
$this->findAnyElement = false;
}

/**
* Helper function for the jQuery ':text' pseudo-class.
*
* As in jQuery, ':text' selects `input` elements of type text -- that is, an
* `input` whose `type` attribute is either absent (`text` is the default
* type of an `input`) or is `text`, matched case-insensitively. It does NOT
* indicate whether the node is a text node.
*
* @see https://api.jquery.com/text-selector/
*/
protected function textInput()
{
$found = new SplObjectStorage();
$matches = $this->candidateList();
foreach ($matches as $item) {
if (Util::isTextInput($item)) {
$found->offsetSet($item);
}
}

$this->matches = $found;
$this->findAnyElement = false;
}

/**
* Helper function to find all elements with exact matches.
*
Expand Down Expand Up @@ -557,6 +581,8 @@ public function pseudoClass($name, $value = null)
$this->attribute($name);
break;
case 'text':
$this->textInput();
break;
case 'radio':
case 'checkbox':
case 'file':
Expand Down
Loading
Loading