diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e390a2..6c97f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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()` diff --git a/src/CSS/DOMTraverser.php b/src/CSS/DOMTraverser.php index 8b8483f..cc827c0 100644 --- a/src/CSS/DOMTraverser.php +++ b/src/CSS/DOMTraverser.php @@ -7,6 +7,7 @@ use DOMDocument; use DOMElement; +use DOMNode; use DOMNodeList; use DOMXPath; use QueryPath\CSS\DOMTraverser\Util; @@ -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); } @@ -196,7 +197,7 @@ 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 * @@ -204,8 +205,16 @@ public function matchesSelector(DOMElement $node, $selector) * 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. @@ -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)); @@ -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); } @@ -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'))); @@ -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); diff --git a/src/CSS/DOMTraverser/PseudoClass.php b/src/CSS/DOMTraverser/PseudoClass.php index 8a4d4b4..2812c9b 100644 --- a/src/CSS/DOMTraverser/PseudoClass.php +++ b/src/CSS/DOMTraverser/PseudoClass.php @@ -12,6 +12,7 @@ namespace QueryPath\CSS\DOMTraverser; +use DOMElement; use QueryPath\CSS\DOMTraverser; use QueryPath\CSS\NotImplementedException; use QueryPath\CSS\EventHandler; @@ -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) { @@ -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': @@ -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'. * diff --git a/src/CSS/DOMTraverser/Util.php b/src/CSS/DOMTraverser/Util.php index a7206b2..ba4cf00 100644 --- a/src/CSS/DOMTraverser/Util.php +++ b/src/CSS/DOMTraverser/Util.php @@ -7,6 +7,7 @@ namespace QueryPath\CSS\DOMTraverser; +use DOMElement; use QueryPath\CSS\EventHandler; /** @@ -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; } @@ -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; } @@ -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'; + } } diff --git a/src/CSS/QueryPathEventHandler.php b/src/CSS/QueryPathEventHandler.php index c75778d..b8d13d7 100644 --- a/src/CSS/QueryPathEventHandler.php +++ b/src/CSS/QueryPathEventHandler.php @@ -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. * @@ -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': diff --git a/tests/Issues/Issue49Test.php b/tests/Issues/Issue49Test.php new file mode 100644 index 0000000..fcc5750 --- /dev/null +++ b/tests/Issues/Issue49Test.php @@ -0,0 +1,208 @@ +' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + + /** + * Get the ID of every element in the match set. + * + * @param \QueryPath\DOMQuery $query + * + * @return array + */ + protected function ids($query): array + { + $ids = []; + foreach ($query as $item) { + $ids[] = $item->attr('id'); + } + sort($ids); + + return $ids; + } + + public function testCheckingForMatchingTextInputs(): void + { + $q = html5qp('
', 'div'); + + /* + * The collection holds the
. It is not itself a text input, but it contains two, + * so the containment question is asked with has() and the matches with find(). + */ + $this->assertCount(1, $q->has(':text')); + $this->assertCount(2, $q->find(':text')); + + /* The inputs themselves match: an explicit type="text", and an with no type */ + $this->assertTrue($q->find('input')->is(':text')); + $this->assertTrue($q->find('[name="text1"]')->is(':text')); + $this->assertTrue($q->find('[name="text2"]')->is(':text')); + + /* contents() here holds the two elements, not text nodes */ + $firstInput = $q->contents()->eq(0); + $this->assertTrue($firstInput->is(':text')); + } + + public function testCheckingForEmptyTextInputs(): void + { + $q = html5qp('
Sample
', 'div'); + + /* Check if the DOMNode or its children matches */ + $this->assertFalse($q->is(':text')); + $this->assertCount(0, $q->find(':text')); + + /* check if a text node matches */ + $textNode = $q->find('div')->contents()->eq(0); + $this->assertFalse($textNode->is(':text')); + } + + /** + * As in jQuery, ':text' matches an `input` whose type is absent or 'text' + * (case-insensitively), and nothing else. + * + * @see https://api.jquery.com/text-selector/ + */ + public function testTextSelectorOnlyMatchesTextInputs(): void + { + $q = html5qp(self::INPUT_HTML, 'div'); + + $this->assertSame(['a', 'b', 'c'], $this->ids($q->find(':text'))); + } + + public function testTextSelectorMatchesTheInputItself(): void + { + $this->assertTrue(html5qp('
', 'input')->is(':text')); + $this->assertTrue(html5qp('
', 'input')->is(':text')); + $this->assertTrue(html5qp('
', 'input')->is(':text')); + + $this->assertFalse(html5qp('
', 'input')->is(':text')); + $this->assertFalse(html5qp('
', 'input')->is(':text')); + $this->assertFalse(html5qp('
', 'textarea')->is(':text')); + $this->assertFalse(html5qp('
', 'button')->is(':text')); + } + + /** + * remove() runs the selector through the legacy CSS engine, which must agree + * with find(). + */ + public function testTextSelectorInTheLegacyEngine(): void + { + $q = html5qp(self::INPUT_HTML, 'div'); + + $this->assertSame(['a', 'b', 'c'], $this->ids($q->remove(':text'))); + $this->assertCount(0, $q->find(':text')); + } + + /** + * Any selector run against a match set holding a text node must return a + * sane result rather than fataling on the element-only DOM API. + */ + public function testSelectorsAgainstATextNodeDoNotThrow(): void + { + $textNode = html5qp('
SampleChild
', 'div') + ->contents() + ->eq(0); + + $this->assertInstanceOf(DOMText::class, $textNode->get(0)); + + $this->assertFalse($textNode->is('*')); + $this->assertFalse($textNode->is('span')); + $this->assertFalse($textNode->is('.wrap')); + $this->assertFalse($textNode->is('#wrap')); + $this->assertFalse($textNode->is('[class]')); + $this->assertFalse($textNode->is('[class="wrap"]')); + $this->assertFalse($textNode->is(':first-child')); + $this->assertFalse($textNode->is('div span')); + + $this->assertCount(0, $textNode->find('*')); + $this->assertCount(0, $textNode->find('span')); + $this->assertCount(0, $textNode->find('.wrap')); + $this->assertCount(0, $textNode->find('#wrap')); + $this->assertCount(0, $textNode->find('[class]')); + $this->assertCount(0, $textNode->filter('*')); + } + + public function testSelectorsAgainstACommentNodeDoNotThrow(): void + { + $comment = html5qp('
Child
', 'div') + ->contents() + ->eq(0); + + $this->assertInstanceOf(DOMComment::class, $comment->get(0)); + + $this->assertFalse($comment->is('*')); + $this->assertFalse($comment->is('span')); + $this->assertFalse($comment->is('.wrap')); + $this->assertFalse($comment->is('#wrap')); + $this->assertFalse($comment->is('[class]')); + $this->assertFalse($comment->is(':text')); + + $this->assertCount(0, $comment->find('*')); + $this->assertCount(0, $comment->find('span')); + $this->assertCount(0, $comment->find('[class]')); + } + + public function testSelectorsAgainstCdataAndProcessingInstructionNodesDoNotThrow(): void + { + $contents = qp( + 'Text', + 'root' + )->contents(); + + $cdata = $contents->eq(0); + $pi = $contents->eq(1); + + $this->assertInstanceOf(DOMCdataSection::class, $cdata->get(0)); + $this->assertInstanceOf(DOMProcessingInstruction::class, $pi->get(0)); + + foreach ([$cdata, $pi] as $node) { + $this->assertFalse($node->is('*')); + $this->assertFalse($node->is('child')); + $this->assertFalse($node->is('.c')); + $this->assertFalse($node->is('#i')); + $this->assertFalse($node->is('[class]')); + + $this->assertCount(0, $node->find('*')); + $this->assertCount(0, $node->find('child')); + } + } + + /** + * A match set mixing elements with non-element nodes must still match the + * elements it holds. + */ + public function testMixedNodeMatchSetStillMatchesItsElements(): void + { + $contents = html5qp('
SampleChild
', 'div') + ->contents(); + + /* The set holds a text node and an element; neither may cause a fatal. */ + $this->assertCount(2, $contents); + + /* find() reaches the descendants of the elements in the set */ + $this->assertSame(['e'], $this->ids($contents->find('em'))); + $this->assertSame(['e'], $this->ids($contents->find('#e'))); + + /* filter() and is() ask about the elements in the set itself */ + $this->assertSame(['s'], $this->ids($contents->filter('span'))); + $this->assertSame(['s'], $this->ids($contents->filter('.x'))); + $this->assertSame(['s'], $this->ids($contents->filter('#s'))); + $this->assertTrue($contents->is('.x')); + } +}