Skip to content

Commit 8e8616b

Browse files
Jean-Berufabpot
authored andcommitted
[CssSelector] add support for :is() and :where()
1 parent 883d961 commit 8e8616b

10 files changed

+303
-12
lines changed

CHANGELOG.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
CHANGELOG
22
=========
33

4+
7.1
5+
---
6+
7+
* Add support for `:is()`
8+
* Add support for `:where()`
9+
410
6.3
5-
-----
11+
---
612

713
* Add support for `:scope`
814

Node/MatchingNode.php

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
/**
27+
* @param array<NodeInterface> $arguments
28+
*/
29+
public function __construct(
30+
public readonly NodeInterface $selector,
31+
public readonly array $arguments = [],
32+
) {
33+
}
34+
35+
public function getSpecificity(): Specificity
36+
{
37+
$argumentsSpecificity = array_reduce(
38+
$this->arguments,
39+
fn ($c, $n) => 1 === $n->getSpecificity()->compareTo($c) ? $n->getSpecificity() : $c,
40+
new Specificity(0, 0, 0),
41+
);
42+
43+
return $this->selector->getSpecificity()->plus($argumentsSpecificity);
44+
}
45+
46+
public function __toString(): string
47+
{
48+
$selectorArguments = array_map(
49+
fn ($n): string => ltrim((string) $n, '*'),
50+
$this->arguments,
51+
);
52+
53+
return sprintf('%s[%s:is(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments));
54+
}
55+
}

Node/SpecificityAdjustmentNode.php

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
/**
27+
* @param array<NodeInterface> $arguments
28+
*/
29+
public function __construct(
30+
public readonly NodeInterface $selector,
31+
public readonly array $arguments = [],
32+
) {
33+
}
34+
35+
public function getSpecificity(): Specificity
36+
{
37+
return $this->selector->getSpecificity();
38+
}
39+
40+
public function __toString(): string
41+
{
42+
$selectorArguments = array_map(
43+
fn ($n) => ltrim((string) $n, '*'),
44+
$this->arguments,
45+
);
46+
47+
return sprintf('%s[%s:where(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments));
48+
}
49+
}

Parser/Parser.php

+35-9
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,17 @@ public static function parseSeries(array $tokens): array
8787
];
8888
}
8989

90-
private function parseSelectorList(TokenStream $stream): array
90+
private function parseSelectorList(TokenStream $stream, bool $isArgument = false): array
9191
{
9292
$stream->skipWhitespace();
9393
$selectors = [];
9494

9595
while (true) {
96-
$selectors[] = $this->parserSelectorNode($stream);
96+
if ($isArgument && $stream->getPeek()->isDelimiter([')'])) {
97+
break;
98+
}
99+
100+
$selectors[] = $this->parserSelectorNode($stream, $isArgument);
97101

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

109-
private function parserSelectorNode(TokenStream $stream): Node\SelectorNode
113+
private function parserSelectorNode(TokenStream $stream, bool $isArgument = false): Node\SelectorNode
110114
{
111-
[$result, $pseudoElement] = $this->parseSimpleSelector($stream);
115+
[$result, $pseudoElement] = $this->parseSimpleSelector($stream, false, $isArgument);
112116

113117
while (true) {
114118
$stream->skipWhitespace();
115119
$peek = $stream->getPeek();
116120

117-
if ($peek->isFileEnd() || $peek->isDelimiter([','])) {
121+
if (
122+
$peek->isFileEnd()
123+
|| $peek->isDelimiter([','])
124+
|| ($isArgument && $peek->isDelimiter([')']))
125+
) {
118126
break;
119127
}
120128

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

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

@@ -141,7 +149,7 @@ private function parserSelectorNode(TokenStream $stream): Node\SelectorNode
141149
*
142150
* @throws SyntaxErrorException
143151
*/
144-
private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false): array
152+
private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false, bool $isArgument = false): array
145153
{
146154
$stream->skipWhitespace();
147155

@@ -154,7 +162,7 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation =
154162
if ($peek->isWhitespace()
155163
|| $peek->isFileEnd()
156164
|| $peek->isDelimiter([',', '+', '>', '~'])
157-
|| ($insideNegation && $peek->isDelimiter([')']))
165+
|| ($isArgument && $peek->isDelimiter([')']))
158166
) {
159167
break;
160168
}
@@ -215,7 +223,7 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation =
215223
throw SyntaxErrorException::nestedNot();
216224
}
217225

218-
[$argument, $argumentPseudoElement] = $this->parseSimpleSelector($stream, true);
226+
[$argument, $argumentPseudoElement] = $this->parseSimpleSelector($stream, true, true);
219227
$next = $stream->getNext();
220228

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

229237
$result = new Node\NegationNode($result, $argument);
238+
} elseif ('is' === strtolower($identifier)) {
239+
$selectors = $this->parseSelectorList($stream, true);
240+
241+
$next = $stream->getNext();
242+
if (!$next->isDelimiter([')'])) {
243+
throw SyntaxErrorException::unexpectedToken('")"', $next);
244+
}
245+
246+
$result = new Node\MatchingNode($result, $selectors);
247+
} elseif ('where' === strtolower($identifier)) {
248+
$selectors = $this->parseSelectorList($stream, true);
249+
250+
$next = $stream->getNext();
251+
if (!$next->isDelimiter([')'])) {
252+
throw SyntaxErrorException::unexpectedToken('")"', $next);
253+
}
254+
255+
$result = new Node\SpecificityAdjustmentNode($result, $selectors);
230256
} else {
231257
$arguments = [];
232258
$next = null;

Tests/Node/MatchingNodeTest.php

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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 AbstractNodeTestCase
20+
{
21+
public static 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 static 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+
[new MatchingNode(new ClassNode(new ElementNode(), 'class'), [
39+
new ClassNode(new ElementNode(), 'class'),
40+
new HashNode(new ElementNode(), 'id'),
41+
]), 110],
42+
[new MatchingNode(new HashNode(new ElementNode(), 'id'), [
43+
new ClassNode(new ElementNode(), 'class'),
44+
new HashNode(new ElementNode(), 'id'),
45+
]), 200],
46+
];
47+
}
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 AbstractNodeTestCase
20+
{
21+
public static 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 static 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+
[new SpecificityAdjustmentNode(new ClassNode(new ElementNode(), 'class'), [
39+
new ClassNode(new ElementNode(), 'class'),
40+
new HashNode(new ElementNode(), 'id'),
41+
]), 10],
42+
];
43+
}
44+
}

Tests/Parser/ParserTest.php

+17
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ public static function getParserTestData()
152152
[':scope', ['Pseudo[Element[*]:scope]']],
153153
['foo bar, :scope > div', ['CombinedSelector[Element[foo] <followed> Element[bar]]', 'CombinedSelector[Pseudo[Element[*]:scope] > Element[div]]']],
154154
['foo bar,:scope > div', ['CombinedSelector[Element[foo] <followed> Element[bar]]', 'CombinedSelector[Pseudo[Element[*]:scope] > Element[div]]']],
155+
['div:is(.foo, #bar)', ['Matching[Element[div]:is(Selector[Class[Element[*].foo]], Selector[Hash[Element[*]#bar]])]']],
156+
[':is(:hover, :visited)', ['Matching[Element[*]:is(Selector[Pseudo[Element[*]:hover]], Selector[Pseudo[Element[*]:visited]])]']],
157+
['div:where(.foo, #bar)', ['SpecificityAdjustment[Element[div]:where(Selector[Class[Element[*].foo]], Selector[Hash[Element[*]#bar]])]']],
158+
[':where(:hover, :visited)', ['SpecificityAdjustment[Element[*]:where(Selector[Pseudo[Element[*]:hover]], Selector[Pseudo[Element[*]:visited]])]']],
155159
];
156160
}
157161

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

@@ -233,6 +238,18 @@ public static function getSpecificityTestData()
233238
['foo::before', 2],
234239
['foo:empty::before', 12],
235240
['#lorem + foo#ipsum:first-child > bar:first-line', 213],
241+
[':is(*)', 0],
242+
[':is(foo)', 1],
243+
[':is(.foo)', 10],
244+
[':is(#foo)', 100],
245+
[':is(#foo, :empty, foo)', 100],
246+
['#foo:is(#bar:empty)', 210],
247+
[':where(*)', 0],
248+
[':where(foo)', 0],
249+
[':where(.foo)', 0],
250+
[':where(#foo)', 0],
251+
[':where(#foo, :empty, foo)', 0],
252+
['#foo:where(#bar:empty)', 100],
236253
];
237254
}
238255

Tests/XPath/TranslatorTest.php

+14
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ public static function getCssToXPathTestData()
221221
['div#container p', "div[@id = 'container']/descendant-or-self::*/p"],
222222
[':scope > div[dataimg="<testmessage>"]', "*[1]/div[@dataimg = '<testmessage>']"],
223223
[':scope', '*[1]'],
224+
['e:is(section, article) h1', "e[(name() = 'section') or (name() = 'article')]/descendant-or-self::*/h1"],
225+
['e:where(section, article) h1', "e[(name() = 'section') or (name() = 'article')]/descendant-or-self::*/h1"],
224226
];
225227
}
226228

@@ -355,6 +357,17 @@ public static function getHtmlIdsTestData()
355357
[':not(*)', []],
356358
['a:not([href])', ['name-anchor']],
357359
['ol :Not(li[class])', ['first-li', 'second-li', 'li-div', 'fifth-li', 'sixth-li', 'seventh-li']],
360+
[':is(#first-li, #second-li)', ['first-li', 'second-li']],
361+
['a:is(#name-anchor, #tag-anchor)', ['name-anchor', 'tag-anchor']],
362+
[':is(.c)', ['first-ol', 'third-li', 'fourth-li']],
363+
['a:is(:not(#name-anchor))', ['tag-anchor', 'nofollow-anchor']],
364+
['a:not(:is(#name-anchor))', ['tag-anchor', 'nofollow-anchor']],
365+
[':where(#first-li, #second-li)', ['first-li', 'second-li']],
366+
['a:where(#name-anchor, #tag-anchor)', ['name-anchor', 'tag-anchor']],
367+
[':where(.c)', ['first-ol', 'third-li', 'fourth-li']],
368+
['a:where(:not(#name-anchor))', ['tag-anchor', 'nofollow-anchor']],
369+
['a:not(:where(#name-anchor))', ['tag-anchor', 'nofollow-anchor']],
370+
['a:where(:is(#name-anchor), :where(#tag-anchor))', ['name-anchor', 'tag-anchor']],
358371
// HTML-specific
359372
[':link', ['link-href', 'tag-anchor', 'nofollow-anchor', 'area-href']],
360373
[':visited', []],
@@ -416,6 +429,7 @@ public static function getHtmlShakespearTestData()
416429
[':scope > div', 1],
417430
[':scope > div > div[class=dialog]', 1],
418431
[':scope > div div', 242],
432+
['div:is(div#test .dialog) .direction', 4],
419433
];
420434
}
421435
}

0 commit comments

Comments
 (0)