Skip to content

Commit 8e8616b

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

File tree

10 files changed

+303
-12
lines changed

10 files changed

+303
-12
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
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

Lines changed: 55 additions & 0 deletions
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

Lines changed: 49 additions & 0 deletions
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

Lines changed: 35 additions & 9 deletions
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

Lines changed: 48 additions & 0 deletions
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+
}
Lines changed: 44 additions & 0 deletions
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

Lines changed: 17 additions & 0 deletions
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

Lines changed: 14 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy