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
12namespace Symfony\Component\CssSelector\XPath;
13
14use Symfony\Component\CssSelector\Exception\ExpressionErrorException;
15use Symfony\Component\CssSelector\Node\FunctionNode;
16use Symfony\Component\CssSelector\Node\NodeInterface;
17use Symfony\Component\CssSelector\Node\SelectorNode;
18use Symfony\Component\CssSelector\Parser\Parser;
19use Symfony\Component\CssSelector\Parser\ParserInterface;
20
21/**
22 * XPath expression translator interface.
23 *
24 * This component is a port of the Python cssselect library,
25 * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
26 *
27 * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
28 *
29 * @internal
30 */
31class Translator implements TranslatorInterface
32{
33    private $mainParser;
34
35    /**
36     * @var ParserInterface[]
37     */
38    private $shortcutParsers = [];
39
40    /**
41     * @var Extension\ExtensionInterface[]
42     */
43    private $extensions = [];
44
45    private $nodeTranslators = [];
46    private $combinationTranslators = [];
47    private $functionTranslators = [];
48    private $pseudoClassTranslators = [];
49    private $attributeMatchingTranslators = [];
50
51    public function __construct(ParserInterface $parser = null)
52    {
53        $this->mainParser = $parser ?? new Parser();
54
55        $this
56            ->registerExtension(new Extension\NodeExtension())
57            ->registerExtension(new Extension\CombinationExtension())
58            ->registerExtension(new Extension\FunctionExtension())
59            ->registerExtension(new Extension\PseudoClassExtension())
60            ->registerExtension(new Extension\AttributeMatchingExtension())
61        ;
62    }
63
64    public static function getXpathLiteral(string $element): string
65    {
66        if (!str_contains($element, "'")) {
67            return "'".$element."'";
68        }
69
70        if (!str_contains($element, '"')) {
71            return '"'.$element.'"';
72        }
73
74        $string = $element;
75        $parts = [];
76        while (true) {
77            if (false !== $pos = strpos($string, "'")) {
78                $parts[] = sprintf("'%s'", substr($string, 0, $pos));
79                $parts[] = "\"'\"";
80                $string = substr($string, $pos + 1);
81            } else {
82                $parts[] = "'$string'";
83                break;
84            }
85        }
86
87        return sprintf('concat(%s)', implode(', ', $parts));
88    }
89
90    /**
91     * {@inheritdoc}
92     */
93    public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string
94    {
95        $selectors = $this->parseSelectors($cssExpr);
96
97        /** @var SelectorNode $selector */
98        foreach ($selectors as $index => $selector) {
99            if (null !== $selector->getPseudoElement()) {
100                throw new ExpressionErrorException('Pseudo-elements are not supported.');
101            }
102
103            $selectors[$index] = $this->selectorToXPath($selector, $prefix);
104        }
105
106        return implode(' | ', $selectors);
107    }
108
109    /**
110     * {@inheritdoc}
111     */
112    public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string
113    {
114        return ($prefix ?: '').$this->nodeToXPath($selector);
115    }
116
117    /**
118     * @return $this
119     */
120    public function registerExtension(Extension\ExtensionInterface $extension): self
121    {
122        $this->extensions[$extension->getName()] = $extension;
123
124        $this->nodeTranslators = array_merge($this->nodeTranslators, $extension->getNodeTranslators());
125        $this->combinationTranslators = array_merge($this->combinationTranslators, $extension->getCombinationTranslators());
126        $this->functionTranslators = array_merge($this->functionTranslators, $extension->getFunctionTranslators());
127        $this->pseudoClassTranslators = array_merge($this->pseudoClassTranslators, $extension->getPseudoClassTranslators());
128        $this->attributeMatchingTranslators = array_merge($this->attributeMatchingTranslators, $extension->getAttributeMatchingTranslators());
129
130        return $this;
131    }
132
133    /**
134     * @throws ExpressionErrorException
135     */
136    public function getExtension(string $name): Extension\ExtensionInterface
137    {
138        if (!isset($this->extensions[$name])) {
139            throw new ExpressionErrorException(sprintf('Extension "%s" not registered.', $name));
140        }
141
142        return $this->extensions[$name];
143    }
144
145    /**
146     * @return $this
147     */
148    public function registerParserShortcut(ParserInterface $shortcut): self
149    {
150        $this->shortcutParsers[] = $shortcut;
151
152        return $this;
153    }
154
155    /**
156     * @throws ExpressionErrorException
157     */
158    public function nodeToXPath(NodeInterface $node): XPathExpr
159    {
160        if (!isset($this->nodeTranslators[$node->getNodeName()])) {
161            throw new ExpressionErrorException(sprintf('Node "%s" not supported.', $node->getNodeName()));
162        }
163
164        return $this->nodeTranslators[$node->getNodeName()]($node, $this);
165    }
166
167    /**
168     * @throws ExpressionErrorException
169     */
170    public function addCombination(string $combiner, NodeInterface $xpath, NodeInterface $combinedXpath): XPathExpr
171    {
172        if (!isset($this->combinationTranslators[$combiner])) {
173            throw new ExpressionErrorException(sprintf('Combiner "%s" not supported.', $combiner));
174        }
175
176        return $this->combinationTranslators[$combiner]($this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath));
177    }
178
179    /**
180     * @throws ExpressionErrorException
181     */
182    public function addFunction(XPathExpr $xpath, FunctionNode $function): XPathExpr
183    {
184        if (!isset($this->functionTranslators[$function->getName()])) {
185            throw new ExpressionErrorException(sprintf('Function "%s" not supported.', $function->getName()));
186        }
187
188        return $this->functionTranslators[$function->getName()]($xpath, $function);
189    }
190
191    /**
192     * @throws ExpressionErrorException
193     */
194    public function addPseudoClass(XPathExpr $xpath, string $pseudoClass): XPathExpr
195    {
196        if (!isset($this->pseudoClassTranslators[$pseudoClass])) {
197            throw new ExpressionErrorException(sprintf('Pseudo-class "%s" not supported.', $pseudoClass));
198        }
199
200        return $this->pseudoClassTranslators[$pseudoClass]($xpath);
201    }
202
203    /**
204     * @throws ExpressionErrorException
205     */
206    public function addAttributeMatching(XPathExpr $xpath, string $operator, string $attribute, ?string $value): XPathExpr
207    {
208        if (!isset($this->attributeMatchingTranslators[$operator])) {
209            throw new ExpressionErrorException(sprintf('Attribute matcher operator "%s" not supported.', $operator));
210        }
211
212        return $this->attributeMatchingTranslators[$operator]($xpath, $attribute, $value);
213    }
214
215    /**
216     * @return SelectorNode[]
217     */
218    private function parseSelectors(string $css): array
219    {
220        foreach ($this->shortcutParsers as $shortcut) {
221            $tokens = $shortcut->parse($css);
222
223            if (!empty($tokens)) {
224                return $tokens;
225            }
226        }
227
228        return $this->mainParser->parse($css);
229    }
230}
231