Skip to content

Commit d1d438d

Browse files
committed
[CssSelector] add support for :is() and :where()
1 parent e9870a4 commit d1d438d

File tree

10 files changed

+308
-2
lines changed

10 files changed

+308
-2
lines changed

src/Symfony/Component/CssSelector/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
CHANGELOG
22
=========
33

4+
6.3.0
5+
-----
6+
7+
* Added support for `:is()`
8+
* Added support for `:where()`
9+
410
4.4.0
511
-----
612

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\CssSelector\Node;
13+
14+
/**
15+
* Represents a "<selector>:is(<subSelectorList>)" node.
16+
*
17+
* This component is a port of the Python cssselect library,
18+
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
19+
*
20+
* @author Hubert Lenoir <lenoir.hubert@gmail.com>
21+
*
22+
* @internal
23+
*/
24+
class MatchingNode extends AbstractNode
25+
{
26+
private NodeInterface $selector;
27+
/** @var array<NodeInterface> */
28+
private array $arguments;
29+
30+
public function __construct(NodeInterface $selector, array $arguments = [])
31+
{
32+
$this->selector = $selector;
33+
$this->arguments = $arguments;
34+
}
35+
36+
public function getSelector(): NodeInterface
37+
{
38+
return $this->selector;
39+
}
40+
41+
public function getArguments(): array
42+
{
43+
return $this->arguments;
44+
}
45+
46+
/**
47+
* {@inheritdoc}
48+
*/
49+
public function getSpecificity(): Specificity
50+
{
51+
return array_reduce(
52+
$this->arguments,
53+
static fn (Specificity $c, NodeInterface $n): Specificity => 1 === $n->getSpecificity()->compareTo($c) ? $n->getSpecificity() : $c,
54+
new Specificity(0, 0, 0)
55+
);
56+
}
57+
58+
public function __toString(): string
59+
{
60+
$selectorArguments = array_map(
61+
static fn(NodeInterface $n): string => ltrim((string) $n, '*'),
62+
$this->getArguments()
63+
);
64+
65+
return sprintf('%s[%s:is(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments));
66+
}
67+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\CssSelector\Node;
13+
14+
/**
15+
* Represents a "<selector>:where(<subSelectorList>)" node.
16+
*
17+
* This component is a port of the Python cssselect library,
18+
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
19+
*
20+
* @author Hubert Lenoir <lenoir.hubert@gmail.com>
21+
*
22+
* @internal
23+
*/
24+
class SpecificityAdjustmentNode extends AbstractNode
25+
{
26+
private NodeInterface $selector;
27+
/** @var array<NodeInterface> */
28+
private array $arguments;
29+
30+
public function __construct(NodeInterface $selector, array $arguments = [])
31+
{
32+
$this->selector = $selector;
33+
$this->arguments = $arguments;
34+
}
35+
36+
public function getSelector(): NodeInterface
37+
{
38+
return $this->selector;
39+
}
40+
41+
public function getArguments(): array
42+
{
43+
return $this->arguments;
44+
}
45+
46+
/**
47+
* {@inheritdoc}
48+
*/
49+
public function getSpecificity(): Specificity
50+
{
51+
return new Specificity(0, 0, 0);
52+
}
53+
54+
public function __toString(): string
55+
{
56+
$selectorArguments = array_map(
57+
static fn(NodeInterface $n): string => ltrim((string) $n, '*'),
58+
$this->getArguments()
59+
);
60+
61+
return sprintf('%s[%s:where(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments));
62+
}
63+
}

src/Symfony/Component/CssSelector/Parser/Parser.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
1515
use Symfony\Component\CssSelector\Node;
1616
use Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer;
17+
use Symfony\Component\HttpFoundation\File\Stream;
1718

1819
/**
1920
* CSS selector parser.
@@ -218,6 +219,14 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation =
218219
}
219220

220221
$result = new Node\NegationNode($result, $argument);
222+
} elseif ('is' === strtolower($identifier)) {
223+
$selectors = $this->parseSimpleSelectorArguments($stream);
224+
225+
$result = new Node\MatchingNode($result, $selectors);
226+
} elseif ('where' === strtolower($identifier)) {
227+
$selectors = $this->parseSimpleSelectorArguments($stream);
228+
229+
$result = new Node\SpecificityAdjustmentNode($result, $selectors);
221230
} else {
222231
$arguments = [];
223232
$next = null;
@@ -257,6 +266,32 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation =
257266
return [$result, $pseudoElement];
258267
}
259268

269+
private function parseSimpleSelectorArguments(TokenStream $stream): array
270+
{
271+
$arguments = [];
272+
while(true) {
273+
[$result, $pseudoElement] = $this->parseSimpleSelector($stream, true);
274+
if ($pseudoElement) {
275+
throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'function');
276+
}
277+
$stream->skipWhitespace();
278+
$next = $stream->getNext();
279+
280+
if ($next->isFileEnd() || $next->isDelimiter([','])) {
281+
$stream->getNext();
282+
$stream->skipWhitespace();
283+
$arguments[] = $result;
284+
} elseif ($next->isDelimiter([')'])) {
285+
$arguments[] = $result;
286+
break;
287+
} else {
288+
throw SyntaxErrorException::unexpectedToken('argument', $next);
289+
}
290+
}
291+
292+
return $arguments;
293+
}
294+
260295
private function parseElementNode(TokenStream $stream): Node\ElementNode
261296
{
262297
$peek = $stream->getPeek();
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\CssSelector\Tests\Node;
13+
14+
use Symfony\Component\CssSelector\Node\ClassNode;
15+
use Symfony\Component\CssSelector\Node\ElementNode;
16+
use Symfony\Component\CssSelector\Node\HashNode;
17+
use Symfony\Component\CssSelector\Node\MatchingNode;
18+
19+
class MatchingNodeTest extends AbstractNodeTest
20+
{
21+
public function getToStringConversionTestData()
22+
{
23+
return [
24+
[new MatchingNode(new ElementNode(), [
25+
new ClassNode(new ElementNode(), 'class'),
26+
new HashNode(new ElementNode(), 'id'),
27+
]), 'Matching[Element[*]:is(Class[Element[*].class], Hash[Element[*]#id])]'],
28+
];
29+
}
30+
31+
public function getSpecificityValueTestData()
32+
{
33+
return [
34+
[new MatchingNode(new ElementNode(), [
35+
new ClassNode(new ElementNode(), 'class'),
36+
new HashNode(new ElementNode(), 'id'),
37+
]), 100],
38+
];
39+
}
40+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\CssSelector\Tests\Node;
13+
14+
use Symfony\Component\CssSelector\Node\ClassNode;
15+
use Symfony\Component\CssSelector\Node\ElementNode;
16+
use Symfony\Component\CssSelector\Node\HashNode;
17+
use Symfony\Component\CssSelector\Node\SpecificityAdjustmentNode;
18+
19+
class SpecificityAdjustmentNodeTest extends AbstractNodeTest
20+
{
21+
public function getToStringConversionTestData()
22+
{
23+
return [
24+
[new SpecificityAdjustmentNode(new ElementNode(), [
25+
new ClassNode(new ElementNode(), 'class'),
26+
new HashNode(new ElementNode(), 'id'),
27+
]), 'SpecificityAdjustment[Element[*]:where(Class[Element[*].class], Hash[Element[*]#id])]'],
28+
];
29+
}
30+
31+
public function getSpecificityValueTestData()
32+
{
33+
return [
34+
[new SpecificityAdjustmentNode(new ElementNode(), [
35+
new ClassNode(new ElementNode(), 'class'),
36+
new HashNode(new ElementNode(), 'id'),
37+
]), 0],
38+
];
39+
}
40+
}

src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ public function getParserTestData()
138138
['div:not(div.foo)', ['Negation[Element[div]:not(Class[Element[div].foo])]']],
139139
['td ~ th', ['CombinedSelector[Element[td] ~ Element[th]]']],
140140
['.foo[data-bar][data-baz=0]', ["Attribute[Attribute[Class[Element[*].foo][data-bar]][data-baz = '0']]"]],
141+
['div:is(.foo, #bar)', ['Matching[Element[div]:is(Class[Element[*].foo], Hash[Element[*]#bar])]']],
142+
[':is(:hover, :visited)', ['Matching[Element[*]:is(Pseudo[Element[*]:hover], Pseudo[Element[*]:visited])]']],
143+
['div:where(.foo, #bar)', ['SpecificityAdjustment[Element[div]:where(Class[Element[*].foo], Hash[Element[*]#bar])]']],
144+
[':where(:hover, :visited)', ['SpecificityAdjustment[Element[*]:where(Pseudo[Element[*]:hover], Pseudo[Element[*]:visited])]']],
141145
];
142146
}
143147

@@ -168,6 +172,10 @@ public function getParserExceptionTestData()
168172
[':lang(fr', SyntaxErrorException::unexpectedToken('an argument', new Token(Token::TYPE_FILE_END, '', 8))->getMessage()],
169173
[':contains("foo', SyntaxErrorException::unclosedString(10)->getMessage()],
170174
['foo!', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '!', 3))->getMessage()],
175+
[':is(:before)', SyntaxErrorException::pseudoElementFound('before', 'function')->getMessage()],
176+
[':is(a b)', SyntaxErrorException::unexpectedToken('argument', new Token(Token::TYPE_IDENTIFIER, 'b', 6))->getMessage()],
177+
[':where(:before)', SyntaxErrorException::pseudoElementFound('before', 'function')->getMessage()],
178+
[':where(a b)', SyntaxErrorException::unexpectedToken('argument', new Token(Token::TYPE_IDENTIFIER, 'b', 9))->getMessage()],
171179
];
172180
}
173181

@@ -218,6 +226,10 @@ public function getSpecificityTestData()
218226
['foo::before', 2],
219227
['foo:empty::before', 12],
220228
['#lorem + foo#ipsum:first-child > bar:first-line', 213],
229+
[':is(.foo, #bar)', 100],
230+
[':is(:hover, :visited)', 10],
231+
[':where(.foo, #bar)', 0],
232+
[':where(:hover, :visited)', 0],
221233
];
222234
}
223235

src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ public function getCssToXPathTestData()
219219
['e + f', "e/following-sibling::*[(name() = 'f') and (position() = 1)]"],
220220
['e ~ f', 'e/following-sibling::f'],
221221
['div#container p', "div[@id = 'container']/descendant-or-self::*/p"],
222+
['e:is(section, article) h1', "e[(name() = 'section') or (name() = 'article')]/descendant-or-self::*/h1"],
223+
['e:where(section, article) h1', "e[(name() = 'section') or (name() = 'article')]/descendant-or-self::*/h1"],
222224
];
223225
}
224226

@@ -353,6 +355,12 @@ public function getHtmlIdsTestData()
353355
[':not(*)', []],
354356
['a:not([href])', ['name-anchor']],
355357
['ol :Not(li[class])', ['first-li', 'second-li', 'li-div', 'fifth-li', 'sixth-li', 'seventh-li']],
358+
[':is(#first-li, #second-li)', ['first-li', 'second-li']],
359+
['a:is(#name-anchor, #tag-anchor)', ['name-anchor', 'tag-anchor']],
360+
[':is(.c)', ['first-ol', 'third-li', 'fourth-li']],
361+
[':where(#first-li, #second-li)', ['first-li', 'second-li']],
362+
['a:where(#name-anchor, #tag-anchor)', ['name-anchor', 'tag-anchor']],
363+
[':where(.c)', ['first-ol', 'third-li', 'fourth-li']],
356364
// HTML-specific
357365
[':link', ['link-href', 'tag-anchor', 'nofollow-anchor', 'area-href']],
358366
[':visited', []],
@@ -411,6 +419,8 @@ public function getHtmlShakespearTestData()
411419
['div[class|=dialog]', 50], // ? Seems right
412420
['div[class!=madeup]', 243], // ? Seems right
413421
['div[class~=dialog]', 51], // ? Seems right
422+
['div:is(#speech1, #speech17)', 2],
423+
['div:where(#speech1, #speech17)', 2],
414424
];
415425
}
416426
}

src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ public function getNodeTranslators(): array
6565
'Selector' => $this->translateSelector(...),
6666
'CombinedSelector' => $this->translateCombinedSelector(...),
6767
'Negation' => $this->translateNegation(...),
68+
'Matching' => $this->translateMatching(...),
69+
'SpecificityAdjustment' => $this->translateSpecificityAdjustment(...),
6870
'Function' => $this->translateFunction(...),
6971
'Pseudo' => $this->translatePseudo(...),
7072
'Attribute' => $this->translateAttribute(...),
@@ -97,6 +99,36 @@ public function translateNegation(Node\NegationNode $node, Translator $translato
9799
return $xpath->addCondition('0');
98100
}
99101

102+
public function translateMatching(Node\MatchingNode $node, Translator $translator): XPathExpr
103+
{
104+
$xpath = $translator->nodeToXPath($node->getSelector());
105+
106+
foreach ($node->getArguments() as $argument) {
107+
$expr = $translator->nodeToXPath($argument);
108+
$expr->addNameTest();
109+
if ($condition = $expr->getCondition()) {
110+
$xpath->addCondition($condition, 'or');
111+
}
112+
}
113+
114+
return $xpath;
115+
}
116+
117+
public function translateSpecificityAdjustment(Node\SpecificityAdjustmentNode $node, Translator $translator): XPathExpr
118+
{
119+
$xpath = $translator->nodeToXPath($node->getSelector());
120+
121+
foreach ($node->getArguments() as $argument) {
122+
$expr = $translator->nodeToXPath($argument);
123+
$expr->addNameTest();
124+
if ($condition = $expr->getCondition()) {
125+
$xpath->addCondition($condition, 'or');
126+
}
127+
}
128+
129+
return $xpath;
130+
}
131+
100132
public function translateFunction(Node\FunctionNode $node, Translator $translator): XPathExpr
101133
{
102134
$xpath = $translator->nodeToXPath($node->getSelector());

0 commit comments

Comments
 (0)