1<?php
2
3/*
4 * This file is part of Twig.
5 *
6 * (c) Fabien Potencier
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 Twig\Node\Expression;
13
14use Twig\Compiler;
15use Twig\Error\SyntaxError;
16use Twig\Extension\ExtensionInterface;
17use Twig\Node\Node;
18use Twig\TwigCallableInterface;
19use Twig\TwigFilter;
20use Twig\TwigFunction;
21use Twig\TwigTest;
22use Twig\Util\CallableArgumentsExtractor;
23use Twig\Util\ReflectionCallable;
24
25abstract class CallExpression extends AbstractExpression
26{
27    private $reflector;
28
29    /**
30     * @return void
31     */
32    protected function compileCallable(Compiler $compiler)
33    {
34        $twigCallable = $this->getTwigCallable();
35        $callable = $twigCallable->getCallable();
36
37        if (\is_string($callable) && !str_contains($callable, '::')) {
38            $compiler->raw($callable);
39        } else {
40            $rc = $this->reflectCallable($twigCallable);
41            $r = $rc->getReflector();
42            $callable = $rc->getCallable();
43
44            if (\is_string($callable)) {
45                $compiler->raw($callable);
46            } elseif (\is_array($callable) && \is_string($callable[0])) {
47                if (!$r instanceof \ReflectionMethod || $r->isStatic()) {
48                    $compiler->raw(\sprintf('%s::%s', $callable[0], $callable[1]));
49                } else {
50                    $compiler->raw(\sprintf('$this->env->getRuntime(\'%s\')->%s', $callable[0], $callable[1]));
51                }
52            } elseif (\is_array($callable) && $callable[0] instanceof ExtensionInterface) {
53                $class = \get_class($callable[0]);
54                if (!$compiler->getEnvironment()->hasExtension($class)) {
55                    // Compile a non-optimized call to trigger a \Twig\Error\RuntimeError, which cannot be a compile-time error
56                    $compiler->raw(\sprintf('$this->env->getExtension(\'%s\')', $class));
57                } else {
58                    $compiler->raw(\sprintf('$this->extensions[\'%s\']', ltrim($class, '\\')));
59                }
60
61                $compiler->raw(\sprintf('->%s', $callable[1]));
62            } else {
63                $compiler->raw(\sprintf('$this->env->get%s(\'%s\')->getCallable()', ucfirst($this->getAttribute('type')), $twigCallable->getDynamicName()));
64            }
65        }
66
67        $this->compileArguments($compiler);
68    }
69
70    protected function compileArguments(Compiler $compiler, $isArray = false): void
71    {
72        if (\func_num_args() >= 2) {
73            trigger_deprecation('twig/twig', '3.11', 'Passing a second argument to "%s()" is deprecated.', __METHOD__);
74        }
75
76        $compiler->raw($isArray ? '[' : '(');
77
78        $first = true;
79
80        $twigCallable = $this->getAttribute('twig_callable');
81
82        if ($twigCallable->needsCharset()) {
83            $compiler->raw('$this->env->getCharset()');
84            $first = false;
85        }
86
87        if ($twigCallable->needsEnvironment()) {
88            if (!$first) {
89                $compiler->raw(', ');
90            }
91            $compiler->raw('$this->env');
92            $first = false;
93        }
94
95        if ($twigCallable->needsContext()) {
96            if (!$first) {
97                $compiler->raw(', ');
98            }
99            $compiler->raw('$context');
100            $first = false;
101        }
102
103        if (self::needsIsSandboxed($twigCallable)) {
104            if (!$first) {
105                $compiler->raw(', ');
106            }
107            $compiler->raw('$this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source)');
108            $first = false;
109        }
110
111        foreach ($twigCallable->getArguments() as $argument) {
112            if (!$first) {
113                $compiler->raw(', ');
114            }
115            $compiler->string($argument);
116            $first = false;
117        }
118
119        if ($this->hasNode('node')) {
120            if (!$first) {
121                $compiler->raw(', ');
122            }
123            $compiler->subcompile($this->getNode('node'));
124            $first = false;
125        }
126
127        if ($this->hasNode('arguments')) {
128            $arguments = (new CallableArgumentsExtractor($this, $this->getTwigCallable()))->extractArguments($this->getNode('arguments'));
129            foreach ($arguments as $node) {
130                if (!$first) {
131                    $compiler->raw(', ');
132                }
133                $compiler->subcompile($node);
134                $first = false;
135            }
136        }
137
138        $compiler->raw($isArray ? ']' : ')');
139    }
140
141    /**
142     * @deprecated since Twig 3.12, use Twig\Util\CallableArgumentsExtractor::getArguments() instead
143     */
144    protected function getArguments($callable, $arguments)
145    {
146        trigger_deprecation('twig/twig', '3.12', 'The "%s()" method is deprecated, use Twig\Util\CallableArgumentsExtractor::getArguments() instead.', __METHOD__);
147
148        $callType = $this->getAttribute('type');
149        $callName = $this->getAttribute('name');
150
151        $parameters = [];
152        $named = false;
153        foreach ($arguments as $name => $node) {
154            if (!\is_int($name)) {
155                $named = true;
156                $name = $this->normalizeName($name);
157            } elseif ($named) {
158                throw new SyntaxError(\sprintf('Positional arguments cannot be used after named arguments for %s "%s".', $callType, $callName), $this->getTemplateLine(), $this->getSourceContext());
159            }
160
161            $parameters[$name] = $node;
162        }
163
164        $isVariadic = $this->getAttribute('twig_callable')->isVariadic();
165        if (!$named && !$isVariadic) {
166            return $parameters;
167        }
168
169        if (!$callable) {
170            if ($named) {
171                $message = \sprintf('Named arguments are not supported for %s "%s".', $callType, $callName);
172            } else {
173                $message = \sprintf('Arbitrary positional arguments are not supported for %s "%s".', $callType, $callName);
174            }
175
176            throw new \LogicException($message);
177        }
178
179        [$callableParameters, $isPhpVariadic] = $this->getCallableParameters($callable, $isVariadic);
180        $arguments = [];
181        $names = [];
182        $missingArguments = [];
183        $optionalArguments = [];
184        $pos = 0;
185        foreach ($callableParameters as $callableParameter) {
186            $name = $this->normalizeName($callableParameter->name);
187            if (\PHP_VERSION_ID >= 80000 && 'range' === $callable) {
188                if ('start' === $name) {
189                    $name = 'low';
190                } elseif ('end' === $name) {
191                    $name = 'high';
192                }
193            }
194
195            $names[] = $name;
196
197            if (\array_key_exists($name, $parameters)) {
198                if (\array_key_exists($pos, $parameters)) {
199                    throw new SyntaxError(\sprintf('Argument "%s" is defined twice for %s "%s".', $name, $callType, $callName), $this->getTemplateLine(), $this->getSourceContext());
200                }
201
202                if (\count($missingArguments)) {
203                    throw new SyntaxError(\sprintf(
204                        'Argument "%s" could not be assigned for %s "%s(%s)" because it is mapped to an internal PHP function which cannot determine default value for optional argument%s "%s".',
205                        $name, $callType, $callName, implode(', ', $names), \count($missingArguments) > 1 ? 's' : '', implode('", "', $missingArguments)
206                    ), $this->getTemplateLine(), $this->getSourceContext());
207                }
208
209                $arguments = array_merge($arguments, $optionalArguments);
210                $arguments[] = $parameters[$name];
211                unset($parameters[$name]);
212                $optionalArguments = [];
213            } elseif (\array_key_exists($pos, $parameters)) {
214                $arguments = array_merge($arguments, $optionalArguments);
215                $arguments[] = $parameters[$pos];
216                unset($parameters[$pos]);
217                $optionalArguments = [];
218                ++$pos;
219            } elseif ($callableParameter->isDefaultValueAvailable()) {
220                $optionalArguments[] = new ConstantExpression($callableParameter->getDefaultValue(), -1);
221            } elseif ($callableParameter->isOptional()) {
222                if (!$parameters) {
223                    break;
224                }
225                $missingArguments[] = $name;
226            } else {
227                throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $name, $callType, $callName), $this->getTemplateLine(), $this->getSourceContext());
228            }
229        }
230
231        if ($isVariadic) {
232            $arbitraryArguments = $isPhpVariadic ? new VariadicExpression([], -1) : new ArrayExpression([], -1);
233            foreach ($parameters as $key => $value) {
234                if (\is_int($key)) {
235                    $arbitraryArguments->addElement($value);
236                } else {
237                    $arbitraryArguments->addElement($value, new ConstantExpression($key, -1));
238                }
239                unset($parameters[$key]);
240            }
241
242            if ($arbitraryArguments->count()) {
243                $arguments = array_merge($arguments, $optionalArguments);
244                $arguments[] = $arbitraryArguments;
245            }
246        }
247
248        if ($parameters) {
249            $unknownParameter = null;
250            foreach ($parameters as $parameter) {
251                if ($parameter instanceof Node) {
252                    $unknownParameter = $parameter;
253                    break;
254                }
255            }
256
257            throw new SyntaxError(
258                \sprintf(
259                    'Unknown argument%s "%s" for %s "%s(%s)".',
260                    \count($parameters) > 1 ? 's' : '', implode('", "', array_keys($parameters)), $callType, $callName, implode(', ', $names)
261                ),
262                $unknownParameter ? $unknownParameter->getTemplateLine() : $this->getTemplateLine(),
263                $unknownParameter ? $unknownParameter->getSourceContext() : $this->getSourceContext()
264            );
265        }
266
267        return $arguments;
268    }
269
270    /**
271     * @deprecated since Twig 3.12
272     */
273    protected function normalizeName(string $name): string
274    {
275        trigger_deprecation('twig/twig', '3.12', 'The "%s()" method is deprecated.', __METHOD__);
276
277        return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], ['\\1_\\2', '\\1_\\2'], $name));
278    }
279
280    // To be removed in 4.0
281    private function getCallableParameters($callable, bool $isVariadic): array
282    {
283        $twigCallable = $this->getAttribute('twig_callable');
284        $rc = $this->reflectCallable($twigCallable);
285        $r = $rc->getReflector();
286        $callableName = $rc->getName();
287
288        $parameters = $r->getParameters();
289        if ($this->hasNode('node')) {
290            array_shift($parameters);
291        }
292        if ($twigCallable->needsCharset()) {
293            array_shift($parameters);
294        }
295        if ($twigCallable->needsEnvironment()) {
296            array_shift($parameters);
297        }
298        if ($twigCallable->needsContext()) {
299            array_shift($parameters);
300        }
301        if (self::needsIsSandboxed($twigCallable)) {
302            array_shift($parameters);
303        }
304        foreach ($twigCallable->getArguments() as $argument) {
305            array_shift($parameters);
306        }
307
308        $isPhpVariadic = false;
309        if ($isVariadic) {
310            $argument = end($parameters);
311            $isArray = $argument && $argument->hasType() && $argument->getType() instanceof \ReflectionNamedType && 'array' === $argument->getType()->getName();
312            if ($isArray && $argument->isDefaultValueAvailable() && [] === $argument->getDefaultValue()) {
313                array_pop($parameters);
314            } elseif ($argument && $argument->isVariadic()) {
315                array_pop($parameters);
316                $isPhpVariadic = true;
317            } else {
318                throw new \LogicException(\sprintf('The last parameter of "%s" for %s "%s" must be an array with default value, eg. "array $arg = []".', $callableName, $this->getAttribute('type'), $twigCallable->getName()));
319            }
320        }
321
322        return [$parameters, $isPhpVariadic];
323    }
324
325    private function reflectCallable(TwigCallableInterface $callable): ReflectionCallable
326    {
327        if (!$this->reflector) {
328            $this->reflector = new ReflectionCallable($callable);
329        }
330
331        return $this->reflector;
332    }
333
334    /**
335     * @internal
336     *
337     * To be removed in 4.0 and replaced by $twigCallable->needsIsSandboxed().
338     */
339    public static function needsIsSandboxed(TwigCallableInterface $twigCallable): bool
340    {
341        if (method_exists($twigCallable, 'needsIsSandboxed')) {
342            return $twigCallable->needsIsSandboxed();
343        }
344
345        trigger_deprecation('twig/twig', '3.25', 'Not implementing the "needsIsSandboxed()" method in "%s" is deprecated. This method will be part of the "%s" interface in 4.0.', $twigCallable::class, TwigCallableInterface::class);
346
347        return false;
348    }
349
350    /**
351     * Overrides the Twig callable based on attributes (as potentially, attributes changed between the creation and the compilation of the node).
352     *
353     * To be removed in 4.0 and replace by $this->getAttribute('twig_callable').
354     */
355    private function getTwigCallable(): TwigCallableInterface
356    {
357        $current = $this->getAttribute('twig_callable');
358
359        $this->setAttribute('twig_callable', match ($this->getAttribute('type')) {
360            'test' => (new TwigTest(
361                $this->getAttribute('name'),
362                $this->hasAttribute('callable') ? $this->getAttribute('callable') : $current->getCallable(),
363                [
364                    'needs_is_sandboxed' => $this->hasAttribute('needs_is_sandboxed') ? $this->getAttribute('needs_is_sandboxed') : self::needsIsSandboxed($current),
365                    'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(),
366                ],
367            ))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()),
368            'function' => (new TwigFunction(
369                $this->hasAttribute('name') ? $this->getAttribute('name') : $current->getName(),
370                $this->hasAttribute('callable') ? $this->getAttribute('callable') : $current->getCallable(),
371                [
372                    'needs_environment' => $this->hasAttribute('needs_environment') ? $this->getAttribute('needs_environment') : $current->needsEnvironment(),
373                    'needs_context' => $this->hasAttribute('needs_context') ? $this->getAttribute('needs_context') : $current->needsContext(),
374                    'needs_charset' => $this->hasAttribute('needs_charset') ? $this->getAttribute('needs_charset') : $current->needsCharset(),
375                    'needs_is_sandboxed' => $this->hasAttribute('needs_is_sandboxed') ? $this->getAttribute('needs_is_sandboxed') : self::needsIsSandboxed($current),
376                    'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(),
377                ],
378            ))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()),
379            'filter' => (new TwigFilter(
380                $this->getAttribute('name'),
381                $this->hasAttribute('callable') ? $this->getAttribute('callable') : $current->getCallable(),
382                [
383                    'needs_environment' => $this->hasAttribute('needs_environment') ? $this->getAttribute('needs_environment') : $current->needsEnvironment(),
384                    'needs_context' => $this->hasAttribute('needs_context') ? $this->getAttribute('needs_context') : $current->needsContext(),
385                    'needs_charset' => $this->hasAttribute('needs_charset') ? $this->getAttribute('needs_charset') : $current->needsCharset(),
386                    'needs_is_sandboxed' => $this->hasAttribute('needs_is_sandboxed') ? $this->getAttribute('needs_is_sandboxed') : self::needsIsSandboxed($current),
387                    'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(),
388                ],
389            ))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()),
390        });
391
392        return $this->getAttribute('twig_callable');
393    }
394}
395