Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[CssSelector] add support for :is() and :where() #48803

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 3, 2024
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
8 changes: 7 additions & 1 deletion src/Symfony/Component/CssSelector/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
CHANGELOG
=========

7.1
---

* Add support for `:is()`
* Add support for `:where()`

6.3
-----
---

* Add support for `:scope`

Expand Down
55 changes: 55 additions & 0 deletions src/Symfony/Component/CssSelector/Node/MatchingNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\CssSelector\Node;

/**
* Represents a "<selector>:is(<subSelectorList>)" node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Hubert Lenoir <[email protected]>
*
* @internal
*/
class MatchingNode extends AbstractNode
{
/**
* @param array<NodeInterface> $arguments
*/
public function __construct(
public readonly NodeInterface $selector,
public readonly array $arguments = [],
) {
}

public function getSpecificity(): Specificity
{
$argumentsSpecificity = array_reduce(
$this->arguments,
fn ($c, $n) => 1 === $n->getSpecificity()->compareTo($c) ? $n->getSpecificity() : $c,
new Specificity(0, 0, 0),
);

return $this->selector->getSpecificity()->plus($argumentsSpecificity);
}

public function __toString(): string
{
$selectorArguments = array_map(
fn ($n): string => ltrim((string) $n, '*'),
$this->arguments,
);

return sprintf('%s[%s:is(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\CssSelector\Node;

/**
* Represents a "<selector>:where(<subSelectorList>)" node.
*
* This component is a port of the Python cssselect library,
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
*
* @author Hubert Lenoir <[email protected]>
*
* @internal
*/
class SpecificityAdjustmentNode extends AbstractNode
{
/**
* @param array<NodeInterface> $arguments
*/
public function __construct(
public readonly NodeInterface $selector,
public readonly array $arguments = [],
) {
}

public function getSpecificity(): Specificity
{
return $this->selector->getSpecificity();
}

public function __toString(): string
{
$selectorArguments = array_map(
fn ($n) => ltrim((string) $n, '*'),
$this->arguments,
);

return sprintf('%s[%s:where(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments));
}
}
44 changes: 35 additions & 9 deletions src/Symfony/Component/CssSelector/Parser/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,17 @@ public static function parseSeries(array $tokens): array
];
}

private function parseSelectorList(TokenStream $stream): array
private function parseSelectorList(TokenStream $stream, bool $isArgument = false): array
{
$stream->skipWhitespace();
$selectors = [];

while (true) {
$selectors[] = $this->parserSelectorNode($stream);
if ($isArgument && $stream->getPeek()->isDelimiter([')'])) {
break;
}

$selectors[] = $this->parserSelectorNode($stream, $isArgument);

if ($stream->getPeek()->isDelimiter([','])) {
$stream->getNext();
Expand All @@ -106,15 +110,19 @@ private function parseSelectorList(TokenStream $stream): array
return $selectors;
}

private function parserSelectorNode(TokenStream $stream): Node\SelectorNode
private function parserSelectorNode(TokenStream $stream, bool $isArgument = false): Node\SelectorNode
{
[$result, $pseudoElement] = $this->parseSimpleSelector($stream);
[$result, $pseudoElement] = $this->parseSimpleSelector($stream, false, $isArgument);

while (true) {
$stream->skipWhitespace();
$peek = $stream->getPeek();

if ($peek->isFileEnd() || $peek->isDelimiter([','])) {
if (
$peek->isFileEnd()
|| $peek->isDelimiter([','])
|| ($isArgument && $peek->isDelimiter([')']))
) {
break;
}

Expand All @@ -129,7 +137,7 @@ private function parserSelectorNode(TokenStream $stream): Node\SelectorNode
$combinator = ' ';
}

[$nextSelector, $pseudoElement] = $this->parseSimpleSelector($stream);
[$nextSelector, $pseudoElement] = $this->parseSimpleSelector($stream, false, $isArgument);
$result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector);
}

Expand All @@ -141,7 +149,7 @@ private function parserSelectorNode(TokenStream $stream): Node\SelectorNode
*
* @throws SyntaxErrorException
*/
private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false): array
private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false, bool $isArgument = false): array
{
$stream->skipWhitespace();

Expand All @@ -154,7 +162,7 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation =
if ($peek->isWhitespace()
|| $peek->isFileEnd()
|| $peek->isDelimiter([',', '+', '>', '~'])
|| ($insideNegation && $peek->isDelimiter([')']))
|| ($isArgument && $peek->isDelimiter([')']))
) {
break;
}
Expand Down Expand Up @@ -215,7 +223,7 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation =
throw SyntaxErrorException::nestedNot();
}

[$argument, $argumentPseudoElement] = $this->parseSimpleSelector($stream, true);
[$argument, $argumentPseudoElement] = $this->parseSimpleSelector($stream, true, true);
$next = $stream->getNext();

if (null !== $argumentPseudoElement) {
Expand All @@ -227,6 +235,24 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation =
}

$result = new Node\NegationNode($result, $argument);
} elseif ('is' === strtolower($identifier)) {
$selectors = $this->parseSelectorList($stream, true);

$next = $stream->getNext();
if (!$next->isDelimiter([')'])) {
throw SyntaxErrorException::unexpectedToken('")"', $next);
}

$result = new Node\MatchingNode($result, $selectors);
} elseif ('where' === strtolower($identifier)) {
$selectors = $this->parseSelectorList($stream, true);

$next = $stream->getNext();
if (!$next->isDelimiter([')'])) {
throw SyntaxErrorException::unexpectedToken('")"', $next);
}

$result = new Node\SpecificityAdjustmentNode($result, $selectors);
} else {
$arguments = [];
$next = null;
Expand Down
48 changes: 48 additions & 0 deletions src/Symfony/Component/CssSelector/Tests/Node/MatchingNodeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\CssSelector\Tests\Node;

use Symfony\Component\CssSelector\Node\ClassNode;
use Symfony\Component\CssSelector\Node\ElementNode;
use Symfony\Component\CssSelector\Node\HashNode;
use Symfony\Component\CssSelector\Node\MatchingNode;

class MatchingNodeTest extends AbstractNodeTestCase
{
public static function getToStringConversionTestData()
{
return [
[new MatchingNode(new ElementNode(), [
new ClassNode(new ElementNode(), 'class'),
new HashNode(new ElementNode(), 'id'),
]), 'Matching[Element[*]:is(Class[Element[*].class], Hash[Element[*]#id])]'],
];
}

public static function getSpecificityValueTestData()
{
return [
[new MatchingNode(new ElementNode(), [
new ClassNode(new ElementNode(), 'class'),
new HashNode(new ElementNode(), 'id'),
]), 100],
[new MatchingNode(new ClassNode(new ElementNode(), 'class'), [
new ClassNode(new ElementNode(), 'class'),
new HashNode(new ElementNode(), 'id'),
]), 110],
[new MatchingNode(new HashNode(new ElementNode(), 'id'), [
new ClassNode(new ElementNode(), 'class'),
new HashNode(new ElementNode(), 'id'),
]), 200],
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\CssSelector\Tests\Node;

use Symfony\Component\CssSelector\Node\ClassNode;
use Symfony\Component\CssSelector\Node\ElementNode;
use Symfony\Component\CssSelector\Node\HashNode;
use Symfony\Component\CssSelector\Node\SpecificityAdjustmentNode;

class SpecificityAdjustmentNodeTest extends AbstractNodeTestCase
{
public static function getToStringConversionTestData()
{
return [
[new SpecificityAdjustmentNode(new ElementNode(), [
new ClassNode(new ElementNode(), 'class'),
new HashNode(new ElementNode(), 'id'),
]), 'SpecificityAdjustment[Element[*]:where(Class[Element[*].class], Hash[Element[*]#id])]'],
];
}

public static function getSpecificityValueTestData()
{
return [
[new SpecificityAdjustmentNode(new ElementNode(), [
new ClassNode(new ElementNode(), 'class'),
new HashNode(new ElementNode(), 'id'),
]), 0],
[new SpecificityAdjustmentNode(new ClassNode(new ElementNode(), 'class'), [
new ClassNode(new ElementNode(), 'class'),
new HashNode(new ElementNode(), 'id'),
]), 10],
];
}
}
17 changes: 17 additions & 0 deletions src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ public static function getParserTestData()
[':scope', ['Pseudo[Element[*]:scope]']],
['foo bar, :scope > div', ['CombinedSelector[Element[foo] <followed> Element[bar]]', 'CombinedSelector[Pseudo[Element[*]:scope] > Element[div]]']],
['foo bar,:scope > div', ['CombinedSelector[Element[foo] <followed> Element[bar]]', 'CombinedSelector[Pseudo[Element[*]:scope] > Element[div]]']],
['div:is(.foo, #bar)', ['Matching[Element[div]:is(Selector[Class[Element[*].foo]], Selector[Hash[Element[*]#bar]])]']],
[':is(:hover, :visited)', ['Matching[Element[*]:is(Selector[Pseudo[Element[*]:hover]], Selector[Pseudo[Element[*]:visited]])]']],
['div:where(.foo, #bar)', ['SpecificityAdjustment[Element[div]:where(Selector[Class[Element[*].foo]], Selector[Hash[Element[*]#bar]])]']],
[':where(:hover, :visited)', ['SpecificityAdjustment[Element[*]:where(Selector[Pseudo[Element[*]:hover]], Selector[Pseudo[Element[*]:visited]])]']],
];
}

Expand Down Expand Up @@ -183,6 +187,7 @@ public static function getParserExceptionTestData()
[':contains("foo', SyntaxErrorException::unclosedString(10)->getMessage()],
['foo!', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '!', 3))->getMessage()],
[':scope > div :scope header', SyntaxErrorException::notAtTheStartOfASelector('scope')->getMessage()],
[':not(:not(a))', SyntaxErrorException::nestedNot()->getMessage()],
];
}

Expand Down Expand Up @@ -233,6 +238,18 @@ public static function getSpecificityTestData()
['foo::before', 2],
['foo:empty::before', 12],
['#lorem + foo#ipsum:first-child > bar:first-line', 213],
[':is(*)', 0],
[':is(foo)', 1],
[':is(.foo)', 10],
[':is(#foo)', 100],
[':is(#foo, :empty, foo)', 100],
['#foo:is(#bar:empty)', 210],
[':where(*)', 0],
[':where(foo)', 0],
[':where(.foo)', 0],
[':where(#foo)', 0],
[':where(#foo, :empty, foo)', 0],
['#foo:where(#bar:empty)', 100],
];
}

Expand Down
14 changes: 14 additions & 0 deletions src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ public static function getCssToXPathTestData()
['div#container p', "div[@id = 'container']/descendant-or-self::*/p"],
[':scope > div[dataimg="<testmessage>"]', "*[1]/div[@dataimg = '<testmessage>']"],
[':scope', '*[1]'],
['e:is(section, article) h1', "e[(name() = 'section') or (name() = 'article')]/descendant-or-self::*/h1"],
['e:where(section, article) h1', "e[(name() = 'section') or (name() = 'article')]/descendant-or-self::*/h1"],
];
}

Expand Down Expand Up @@ -355,6 +357,17 @@ public static function getHtmlIdsTestData()
[':not(*)', []],
['a:not([href])', ['name-anchor']],
['ol :Not(li[class])', ['first-li', 'second-li', 'li-div', 'fifth-li', 'sixth-li', 'seventh-li']],
[':is(#first-li, #second-li)', ['first-li', 'second-li']],
['a:is(#name-anchor, #tag-anchor)', ['name-anchor', 'tag-anchor']],
[':is(.c)', ['first-ol', 'third-li', 'fourth-li']],
['a:is(:not(#name-anchor))', ['tag-anchor', 'nofollow-anchor']],
['a:not(:is(#name-anchor))', ['tag-anchor', 'nofollow-anchor']],
[':where(#first-li, #second-li)', ['first-li', 'second-li']],
['a:where(#name-anchor, #tag-anchor)', ['name-anchor', 'tag-anchor']],
[':where(.c)', ['first-ol', 'third-li', 'fourth-li']],
['a:where(:not(#name-anchor))', ['tag-anchor', 'nofollow-anchor']],
['a:not(:where(#name-anchor))', ['tag-anchor', 'nofollow-anchor']],
['a:where(:is(#name-anchor), :where(#tag-anchor))', ['name-anchor', 'tag-anchor']],
// HTML-specific
[':link', ['link-href', 'tag-anchor', 'nofollow-anchor', 'area-href']],
[':visited', []],
Expand Down Expand Up @@ -416,6 +429,7 @@ public static function getHtmlShakespearTestData()
[':scope > div', 1],
[':scope > div > div[class=dialog]', 1],
[':scope > div div', 242],
['div:is(div#test .dialog) .direction', 4],
];
}
}
Loading