8000 [CssSelector] add support for :is() and :where() · symfony/symfony@27ba278 · GitHub
[go: up one dir, main page]

Skip to content
8000

Commit 27ba278

Browse files
committed
[CssSelector] add support for :is() and :where()
1 parent 5782fe6 commit 27ba278

File tree

10 files changed

+300
-2
lines changed

10 files changed

+300
-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 10000 +
*
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ public function getParserTestData()
148148
// unicode escape: \20 == (space)
149149
['*[aval="\'\20 \'"]', ['Attribute[Element[*][aval = \'\' \'\']]']],
150150
["*[aval=\"'\\20\r\n '\"]", ['Attribute[Element[*][aval = \'\' \'\']]']],
151+
['div:is(.foo, #bar)', ['Matching[Element[div]:is(Class[Element[*].foo], Hash[Element[*]#bar])]']],
152+
[':is(:hover, :visited)', ['Matching[Element[*]:is(Pseudo[Element[*]:hover], Pseudo[Element[*]:visited])]']],
153+
['div:where(.foo, #bar)', ['SpecificityAdjustment[Element[div]:where(Class[Element[*].foo], Hash[Element[*]#bar])]']],
154+
[':where(:hover, :visited)', ['SpecificityAdjustment[Element[*]:where(Pseudo[Element[*]:hover], Pseudo[Element[*]:visited])]']],
151155
];
152156
}
153157

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());

src/Symfony/Component/CssSelector/XPath/XPathExpr.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ public function getElement(): string
4646
/**
4747
* @return $this
4848
*/
49-
public function addCondition(string $condition): static
49+
public function addCondition(string $condition/*, string $conjuction = 'and'*/): static
5050
{
51-
$this->condition = $this->condition ? sprintf('(%s) and (%s)', $this->condition, $condition) : $condition;
51+
$conjuction = 2 <= \func_num_args() ? func_get_arg(1) : 'and';
52+
$this->condition = $this->condition ? sprintf('(%s) %s (%s)', $this->condition, $conjuction, $condition) : $condition;
5253

5354
return $this;
5455
}

0 commit comments

Comments
 (0)
0