diff --git a/src/Symfony/Component/CssSelector/CHANGELOG.md b/src/Symfony/Component/CssSelector/CHANGELOG.md index c035d6b3db49e..1c8c6cf76cdce 100644 --- a/src/Symfony/Component/CssSelector/CHANGELOG.md +++ b/src/Symfony/Component/CssSelector/CHANGELOG.md @@ -1,8 +1,14 @@ CHANGELOG ========= +7.1 +--- + +* Add support for `:is()` +* Add support for `:where()` + 6.3 ------ +--- * Add support for `:scope` diff --git a/src/Symfony/Component/CssSelector/Node/MatchingNode.php b/src/Symfony/Component/CssSelector/Node/MatchingNode.php new file mode 100644 index 0000000000000..381ac4585d03a --- /dev/null +++ b/src/Symfony/Component/CssSelector/Node/MatchingNode.php @@ -0,0 +1,55 @@ + + * + * 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 ":is()" 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 + * + * @internal + */ +class MatchingNode extends AbstractNode +{ + /** + * @param array $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)); + } +} diff --git a/src/Symfony/Component/CssSelector/Node/SpecificityAdjustmentNode.php b/src/Symfony/Component/CssSelector/Node/SpecificityAdjustmentNode.php new file mode 100644 index 0000000000000..d49ed4c5f90e6 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Node/SpecificityAdjustmentNode.php @@ -0,0 +1,49 @@ + + * + * 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 ":where()" 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 + * + * @internal + */ +class SpecificityAdjustmentNode extends AbstractNode +{ + /** + * @param array $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)); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Parser.php b/src/Symfony/Component/CssSelector/Parser/Parser.php index 5313d3435ba9c..462714c8b24a7 100644 --- a/src/Symfony/Component/CssSelector/Parser/Parser.php +++ b/src/Symfony/Component/CssSelector/Parser/Parser.php @@ -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(); @@ -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; } @@ -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); } @@ -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(); @@ -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; } @@ -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) { @@ -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; diff --git a/src/Symfony/Component/CssSelector/Tests/Node/MatchingNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/MatchingNodeTest.php new file mode 100644 index 0000000000000..0bc718c4c6d49 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Node/MatchingNodeTest.php @@ -0,0 +1,48 @@ + + * + * 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], + ]; + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Node/SpecificityAdjustmentNodeTest.php b/src/Symfony/Component/CssSelector/Tests/Node/SpecificityAdjustmentNodeTest.php new file mode 100644 index 0000000000000..5c830571e5437 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Tests/Node/SpecificityAdjustmentNodeTest.php @@ -0,0 +1,44 @@ + + * + * 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], + ]; + } +} diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php index a8708ce47282e..509f6e35930e9 100644 --- a/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php +++ b/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php @@ -152,6 +152,10 @@ public static function getParserTestData() [':scope', ['Pseudo[Element[*]:scope]']], ['foo bar, :scope > div', ['CombinedSelector[Element[foo] Element[bar]]', 'CombinedSelector[Pseudo[Element[*]:scope] > Element[div]]']], ['foo bar,:scope > div', ['CombinedSelector[Element[foo] 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]])]']], ]; } @@ -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()], ]; } @@ -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], ]; } diff --git a/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php b/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php index bfb90728bee29..55a2b10a7c3e5 100644 --- a/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php +++ b/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php @@ -221,6 +221,8 @@ public static function getCssToXPathTestData() ['div#container p', "div[@id = 'container']/descendant-or-self::*/p"], [':scope > div[dataimg=""]', "*[1]/div[@dataimg = '']"], [':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"], ]; } @@ -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', []], @@ -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], ]; } } diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php index 49e894adee0df..174d009c77306 100644 --- a/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php +++ b/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php @@ -65,6 +65,8 @@ public function getNodeTranslators(): array 'Selector' => $this->translateSelector(...), 'CombinedSelector' => $this->translateCombinedSelector(...), 'Negation' => $this->translateNegation(...), + 'Matching' => $this->translateMatching(...), + 'SpecificityAdjustment' => $this->translateSpecificityAdjustment(...), 'Function' => $this->translateFunction(...), 'Pseudo' => $this->translatePseudo(...), 'Attribute' => $this->translateAttribute(...), @@ -97,6 +99,36 @@ public function translateNegation(Node\NegationNode $node, Translator $translato return $xpath->addCondition('0'); } + public function translateMatching(Node\MatchingNode $node, Translator $translator): XPathExpr + { + $xpath = $translator->nodeToXPath($node->selector); + + foreach ($node->arguments as $argument) { + $expr = $translator->nodeToXPath($argument); + $expr->addNameTest(); + if ($condition = $expr->getCondition()) { + $xpath->addCondition($condition, 'or'); + } + } + + return $xpath; + } + + public function translateSpecificityAdjustment(Node\SpecificityAdjustmentNode $node, Translator $translator): XPathExpr + { + $xpath = $translator->nodeToXPath($node->selector); + + foreach ($node->arguments as $argument) { + $expr = $translator->nodeToXPath($argument); + $expr->addNameTest(); + if ($condition = $expr->getCondition()) { + $xpath->addCondition($condition, 'or'); + } + } + + return $xpath; + } + public function translateFunction(Node\FunctionNode $node, Translator $translator): XPathExpr { $xpath = $translator->nodeToXPath($node->getSelector()); diff --git a/src/Symfony/Component/CssSelector/XPath/XPathExpr.php b/src/Symfony/Component/CssSelector/XPath/XPathExpr.php index a76e30bec37d2..8cde461dc6ff3 100644 --- a/src/Symfony/Component/CssSelector/XPath/XPathExpr.php +++ b/src/Symfony/Component/CssSelector/XPath/XPathExpr.php @@ -46,9 +46,9 @@ public function getElement(): string /** * @return $this */ - public function addCondition(string $condition): static + public function addCondition(string $condition, string $operator = 'and'): static { - $this->condition = $this->condition ? sprintf('(%s) and (%s)', $this->condition, $condition) : $condition; + $this->condition = $this->condition ? sprintf('(%s) %s (%s)', $this->condition, $operator, $condition) : $condition; return $this; }