diff --git a/src/Symfony/Component/CssSelector/CHANGELOG.md b/src/Symfony/Component/CssSelector/CHANGELOG.md index d2b7fb1d62acf..2e28394fdf7b9 100644 --- a/src/Symfony/Component/CssSelector/CHANGELOG.md +++ b/src/Symfony/Component/CssSelector/CHANGELOG.md @@ -11,6 +11,7 @@ CHANGELOG --- * Add support for `:scope` + * Add support for `*:has` 4.4.0 ----- diff --git a/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php b/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php index 52d8259b86789..d54f82ebeb5f3 100644 --- a/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php +++ b/src/Symfony/Component/CssSelector/Exception/SyntaxErrorException.php @@ -17,7 +17,7 @@ * ParseException is thrown when a CSS selector syntax is not valid. * * This component is a port of the Python cssselect library, - * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> */ diff --git a/src/Symfony/Component/CssSelector/Node/RelationNode.php b/src/Symfony/Component/CssSelector/Node/RelationNode.php new file mode 100644 index 0000000000000..7f50501d17472 --- /dev/null +++ b/src/Symfony/Component/CssSelector/Node/RelationNode.php @@ -0,0 +1,61 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * 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 "<selector>:has(<subselector>)" node. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. + * + * @author Franck Ranaivo-Harisoa <franckranaivo@gmail.com> + * + * @internal + */ +class RelationNode extends AbstractNode +{ + private NodeInterface $selector; + private NodeInterface $subSelector; + private string $combinator; + + public function __construct(NodeInterface $selector, string $combinator, NodeInterface $subSelector) + { + $this->selector = $selector; + $this->combinator = $combinator; + $this->subSelector = $subSelector; + } + + public function getSelector(): NodeInterface + { + return $this->selector; + } + + public function getCombinator(): string + { + return $this->combinator; + } + + public function getSubSelector(): NodeInterface + { + return $this->subSelector; + } + + public function getSpecificity(): Specificity + { + return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity()); + } + + public function __toString(): string + { + return sprintf('%s[%s:has(%s)]', $this->getNodeName(), $this->selector, $this->subSelector); + } +} diff --git a/src/Symfony/Component/CssSelector/Parser/Parser.php b/src/Symfony/Component/CssSelector/Parser/Parser.php index f7eea2f828fc3..7c4eff8c5f0c4 100644 --- a/src/Symfony/Component/CssSelector/Parser/Parser.php +++ b/src/Symfony/Component/CssSelector/Parser/Parser.php @@ -11,6 +11,7 @@ namespace Symfony\Component\CssSelector\Parser; +use Symfony\Component\CssSelector\Exception\InternalErrorException; use Symfony\Component\CssSelector\Exception\SyntaxErrorException; use Symfony\Component\CssSelector\Node; use Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer; @@ -144,10 +145,42 @@ private function parserSelectorNode(TokenStream $stream, bool $isArgument = fals return new Node\SelectorNode($result, $pseudoElement); } + /** + * @throws InternalErrorException + * @throws SyntaxErrorException + */ + function parseRelativeSelector(TokenStream $stream): array + { + $stream->skipWhitespace(); + $subSelector = ''; + $next = $stream->getNext(); + + if ($next->isDelimiter(['-', '+', '>', '~'])) { + $combinator = $next->getValue(); + $stream->skipWhitespace(); + $next = $stream->getNext(); + } else { + $combinator = new Token(Token::TYPE_DELIMITER, ' ', 0); + } + + while(true){ + if ($next->isString() || $next->isIdentifier() || $next->isNumber() + || $next->isDelimiter(['.', '*'])) { + $subSelector .= $next->getValue(); + } elseif ($next->isDelimiter([')'])) { + $result = $this->parse($subSelector); + return [$combinator, $result[0]]; + } else { + throw SyntaxErrorException::unexpectedToken('an argument', $next); + } + $next = $stream->getNext(); + } + } + /** * Parses next simple node (hash, class, pseudo, negation). * - * @throws SyntaxErrorException + * @throws SyntaxErrorException|InternalErrorException */ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false, bool $isArgument = false): array { @@ -253,6 +286,9 @@ private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = } $result = new Node\SpecificityAdjustmentNode($result, $selectors); + } elseif('has' === strtolower($identifier)) { + [$combinator, $arguments] = $this->parseRelativeSelector($stream); + $result = new Node\RelationNode($result, $combinator ,$arguments); } else { $arguments = []; $next = null; diff --git a/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php b/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php index 82de5ab6b8562..c309484b15115 100644 --- a/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php +++ b/src/Symfony/Component/CssSelector/Tests/Parser/ParserTest.php @@ -134,6 +134,7 @@ public static function getParserTestData() ['div:contains("foo")', ["Function[Element[div]:contains(['foo'])]"]], ['div#foobar', ['Hash[Element[div]#foobar]']], ['div:not(div.foo)', ['Negation[Element[div]:not(Class[Element[div].foo])]']], + ['div:has(div.foo)', ['Relation[Element[div]:has(Selector[Class[Element[div].foo]])]']], ['td ~ th', ['CombinedSelector[Element[td] ~ Element[th]]']], ['.foo[data-bar][data-baz=0]', ["Attribute[Attribute[Class[Element[*].foo][data-bar]][data-baz = '0']]"]], ['div#foo\.bar', ['Hash[Element[div]#foo.bar]']], diff --git a/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php b/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php index f521a94708423..8b255db90a205 100644 --- a/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php +++ b/src/Symfony/Component/CssSelector/Tests/XPath/TranslatorTest.php @@ -235,6 +235,10 @@ public static function getCssToXPathTestData() [':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"], + ['div:has(> .foo)', "div[./*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"], + ['div:has(~ .foo)', "div[following-sibling::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]]"], + ['div:has(+ .foo)', "div[following-sibling::*[(@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')) and (position() = 1)]]"], + ['div:has(+ .foo)', "div[following-sibling::*[(@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')) and (position() = 1)]]"], ]; } diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php index 495f882910d5a..30eedb8921eb5 100644 --- a/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php +++ b/src/Symfony/Component/CssSelector/XPath/Extension/AbstractExtension.php @@ -15,7 +15,7 @@ * XPath expression translator abstract extension. * * This component is a port of the Python cssselect library, - * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> * @@ -47,4 +47,9 @@ public function getAttributeMatchingTranslators(): array { return []; } + + public function getRelativeCombinationTranslators(): array + { + return []; + } } diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php b/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php index 1a74b90acc6c3..1d53eba527f23 100644 --- a/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php +++ b/src/Symfony/Component/CssSelector/XPath/Extension/ExtensionInterface.php @@ -15,10 +15,12 @@ * XPath expression translator extension interface. * * This component is a port of the Python cssselect library, - * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> * + * @method array<string, callable(XPathExpr, XPathExpr): XPathExpr> getRelativeCombinationTranslators() Returns combination translators found inside ":has()" relation. + * * @internal */ interface ExtensionInterface diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php index 4cd46fa1fc783..4f6f961cad9c0 100644 --- a/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php +++ b/src/Symfony/Component/CssSelector/XPath/Extension/NodeExtension.php @@ -19,7 +19,7 @@ * XPath expression translator node extension. * * This component is a port of the Python cssselect library, - * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect. + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. * * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com> * @@ -71,6 +71,7 @@ public function getNodeTranslators(): array 'Class' => $this->translateClass(...), 'Hash' => $this->translateHash(...), 'Element' => $this->translateElement(...), + 'Relation' => $this->translateRelation(...), ]; } @@ -209,6 +210,13 @@ public function translateElement(Node\ElementNode $node): XPathExpr return $xpath; } + public function translateRelation(Node\RelationNode $node, Translator $translator): XPathExpr + { + $combinator = $node->getCombinator(); + + return $translator->addRelativeCombination($combinator, $node->getSelector(), $node->getSubSelector()); + } + public function getName(): string { return 'node'; diff --git a/src/Symfony/Component/CssSelector/XPath/Extension/RelationExtension.php b/src/Symfony/Component/CssSelector/XPath/Extension/RelationExtension.php new file mode 100644 index 0000000000000..d85ec21dfa069 --- /dev/null +++ b/src/Symfony/Component/CssSelector/XPath/Extension/RelationExtension.php @@ -0,0 +1,67 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CssSelector\XPath\Extension; + +use Symfony\Component\CssSelector\XPath\XPathExpr; + +/** + * XPath expression translator combination extension. + * + * This component is a port of the Python cssselect library, + * which is copyright Ian Bicking, @see https://github.com/scrapy/cssselect. + * + * @author Franck Ranaivo-Harisoa <franckranaivo@gmail.com> + * + * @internal + */ +class RelationExtension extends AbstractExtension +{ + public function getRelativeCombinationTranslators(): array + { + return [ + ' ' => $this->translateRelationDescendant(...), + '>' => $this->translateRelationChild(...), + '+' => $this->translateRelationDirectAdjacent(...), + '~' => $this->translateRelationIndirectAdjacent(...), + ]; + } + + public function translateRelationDescendant(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + return $xpath->join('[descendant-or-self::', $combinedXpath, ']', true); + } + + public function translateRelationChild(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + return $xpath->join('[./', $combinedXpath, ']', true); + } + + public function translateRelationDirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + $combinedXpath + ->addNameTest() + ->addCondition('position() = 1'); + + return $xpath + ->join('[following-sibling::', $combinedXpath, ']', true); + } + + public function translateRelationIndirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr + { + return $xpath->join('[following-sibling::', $combinedXpath, ']', true); + } + + public function getName(): string + { + return 'relation'; + } +} diff --git a/src/Symfony/Component/CssSelector/XPath/Translator.php b/src/Symfony/Component/CssSelector/XPath/Translator.php index b2623e5067ed4..d2670a35c7589 100644 --- a/src/Symfony/Component/CssSelector/XPath/Translator.php +++ b/src/Symfony/Component/CssSelector/XPath/Translator.php @@ -44,6 +44,7 @@ class Translator implements TranslatorInterface private array $nodeTranslators = []; private array $combinationTranslators = []; + private array $relativeCombinationTranslators = []; private array $functionTranslators = []; private array $pseudoClassTranslators = []; private array $attributeMatchingTranslators = []; @@ -58,6 +59,7 @@ public function __construct(?ParserInterface $parser = null) ->registerExtension(new Extension\FunctionExtension()) ->registerExtension(new Extension\PseudoClassExtension()) ->registerExtension(new Extension\AttributeMatchingExtension()) + ->registerExtension(new Extension\RelationExtension()) ; } @@ -120,6 +122,7 @@ public function registerExtension(Extension\ExtensionInterface $extension): stat $this->functionTranslators = array_merge($this->functionTranslators, $extension->getFunctionTranslators()); $this->pseudoClassTranslators = array_merge($this->pseudoClassTranslators, $extension->getPseudoClassTranslators()); $this->attributeMatchingTranslators = array_merge($this->attributeMatchingTranslators, $extension->getAttributeMatchingTranslators()); + $this->relativeCombinationTranslators = array_merge($this->relativeCombinationTranslators, $extension->getRelativeCombinationTranslators()); return $this; } @@ -170,6 +173,18 @@ public function addCombination(string $combiner, NodeInterface $xpath, NodeInter return $this->combinationTranslators[$combiner]($this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath)); } + /** + * @throws ExpressionErrorException + */ + public function addRelativeCombination(string $combiner, NodeInterface $xpath, NodeInterface $combinedXpath): XPathExpr + { + if (!isset($this->relativeCombinationTranslators[$combiner])) { + throw new ExpressionErrorException(sprintf('Combiner "%s" not supported.', $combiner)); + } + + return $this->relativeCombinationTranslators[$combiner]($this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath)); + } + /** * @throws ExpressionErrorException */ diff --git a/src/Symfony/Component/CssSelector/XPath/XPathExpr.php b/src/Symfony/Component/CssSelector/XPath/XPathExpr.php index a148febc53e0d..c3144b3f98ebd 100644 --- a/src/Symfony/Component/CssSelector/XPath/XPathExpr.php +++ b/src/Symfony/Component/CssSelector/XPath/XPathExpr.php @@ -82,7 +82,7 @@ public function addStarPrefix(): static * * @return $this */ - public function join(string $combiner, self $expr): static + public function join(string $combiner, self $expr, string $closingCombiner = null, bool $hasInnerConditions = false): static { $path = $this->__toString().$combiner; @@ -91,8 +91,19 @@ public function join(string $combiner, self $expr): static } $this->path = $path; - $this->element = $expr->element; - $this->condition = $expr->condition; + + if (!$hasInnerConditions) { + $this->element = $expr->element.($closingCombiner ?? ''); + $this->condition = $expr->condition; + } else { + $this->element = $expr->element; + if ($expr->condition) { + $this->element .= '['.$expr->condition.']'; + } + if ($closingCombiner) { + $this->element .= $closingCombiner; + } + } return $this; }