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;
13
14use Twig\Error\RuntimeError;
15use Twig\Extension\ExtensionInterface;
16use Twig\Extension\GlobalsInterface;
17use Twig\Extension\InitRuntimeInterface;
18use Twig\Extension\StagingExtension;
19use Twig\NodeVisitor\NodeVisitorInterface;
20use Twig\TokenParser\TokenParserInterface;
21
22/**
23 * @author Fabien Potencier <fabien@symfony.com>
24 *
25 * @internal
26 */
27final class ExtensionSet
28{
29    private $extensions;
30    private $initialized = false;
31    private $runtimeInitialized = false;
32    private $staging;
33    private $parsers;
34    private $visitors;
35    private $filters;
36    private $tests;
37    private $functions;
38    private $unaryOperators;
39    private $binaryOperators;
40    private $globals;
41    private $functionCallbacks = [];
42    private $filterCallbacks = [];
43    private $lastModified = 0;
44
45    public function __construct()
46    {
47        $this->staging = new StagingExtension();
48    }
49
50    /**
51     * Initializes the runtime environment.
52     *
53     * @deprecated since Twig 2.7
54     */
55    public function initRuntime(Environment $env)
56    {
57        if ($this->runtimeInitialized) {
58            return;
59        }
60
61        $this->runtimeInitialized = true;
62
63        foreach ($this->extensions as $extension) {
64            if ($extension instanceof InitRuntimeInterface) {
65                $extension->initRuntime($env);
66            }
67        }
68    }
69
70    public function hasExtension(string $class): bool
71    {
72        $class = ltrim($class, '\\');
73        if (!isset($this->extensions[$class]) && class_exists($class, false)) {
74            // For BC/FC with namespaced aliases
75            $class = (new \ReflectionClass($class))->name;
76        }
77
78        return isset($this->extensions[$class]);
79    }
80
81    public function getExtension(string $class): ExtensionInterface
82    {
83        $class = ltrim($class, '\\');
84        if (!isset($this->extensions[$class]) && class_exists($class, false)) {
85            // For BC/FC with namespaced aliases
86            $class = (new \ReflectionClass($class))->name;
87        }
88
89        if (!isset($this->extensions[$class])) {
90            throw new RuntimeError(sprintf('The "%s" extension is not enabled.', $class));
91        }
92
93        return $this->extensions[$class];
94    }
95
96    /**
97     * @param ExtensionInterface[] $extensions
98     */
99    public function setExtensions(array $extensions)
100    {
101        foreach ($extensions as $extension) {
102            $this->addExtension($extension);
103        }
104    }
105
106    /**
107     * @return ExtensionInterface[]
108     */
109    public function getExtensions(): array
110    {
111        return $this->extensions;
112    }
113
114    public function getSignature(): string
115    {
116        return json_encode(array_keys($this->extensions));
117    }
118
119    public function isInitialized(): bool
120    {
121        return $this->initialized || $this->runtimeInitialized;
122    }
123
124    public function getLastModified(): int
125    {
126        if (0 !== $this->lastModified) {
127            return $this->lastModified;
128        }
129
130        foreach ($this->extensions as $extension) {
131            $r = new \ReflectionObject($extension);
132            if (file_exists($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $this->lastModified) {
133                $this->lastModified = $extensionTime;
134            }
135        }
136
137        return $this->lastModified;
138    }
139
140    public function addExtension(ExtensionInterface $extension)
141    {
142        $class = \get_class($extension);
143
144        if ($this->initialized) {
145            throw new \LogicException(sprintf('Unable to register extension "%s" as extensions have already been initialized.', $class));
146        }
147
148        if (isset($this->extensions[$class])) {
149            throw new \LogicException(sprintf('Unable to register extension "%s" as it is already registered.', $class));
150        }
151
152        // For BC/FC with namespaced aliases
153        $class = (new \ReflectionClass($class))->name;
154        $this->extensions[$class] = $extension;
155    }
156
157    public function addFunction(TwigFunction $function)
158    {
159        if ($this->initialized) {
160            throw new \LogicException(sprintf('Unable to add function "%s" as extensions have already been initialized.', $function->getName()));
161        }
162
163        $this->staging->addFunction($function);
164    }
165
166    /**
167     * @return TwigFunction[]
168     */
169    public function getFunctions(): array
170    {
171        if (!$this->initialized) {
172            $this->initExtensions();
173        }
174
175        return $this->functions;
176    }
177
178    /**
179     * @return TwigFunction|false
180     */
181    public function getFunction(string $name)
182    {
183        if (!$this->initialized) {
184            $this->initExtensions();
185        }
186
187        if (isset($this->functions[$name])) {
188            return $this->functions[$name];
189        }
190
191        foreach ($this->functions as $pattern => $function) {
192            $pattern = str_replace('\\*', '(.*?)', preg_quote($pattern, '#'), $count);
193
194            if ($count && preg_match('#^'.$pattern.'$#', $name, $matches)) {
195                array_shift($matches);
196                $function->setArguments($matches);
197
198                return $function;
199            }
200        }
201
202        foreach ($this->functionCallbacks as $callback) {
203            if (false !== $function = $callback($name)) {
204                return $function;
205            }
206        }
207
208        return false;
209    }
210
211    public function registerUndefinedFunctionCallback(callable $callable)
212    {
213        $this->functionCallbacks[] = $callable;
214    }
215
216    public function addFilter(TwigFilter $filter)
217    {
218        if ($this->initialized) {
219            throw new \LogicException(sprintf('Unable to add filter "%s" as extensions have already been initialized.', $filter->getName()));
220        }
221
222        $this->staging->addFilter($filter);
223    }
224
225    /**
226     * @return TwigFilter[]
227     */
228    public function getFilters(): array
229    {
230        if (!$this->initialized) {
231            $this->initExtensions();
232        }
233
234        return $this->filters;
235    }
236
237    /**
238     * @return TwigFilter|false
239     */
240    public function getFilter(string $name)
241    {
242        if (!$this->initialized) {
243            $this->initExtensions();
244        }
245
246        if (isset($this->filters[$name])) {
247            return $this->filters[$name];
248        }
249
250        foreach ($this->filters as $pattern => $filter) {
251            $pattern = str_replace('\\*', '(.*?)', preg_quote($pattern, '#'), $count);
252
253            if ($count && preg_match('#^'.$pattern.'$#', $name, $matches)) {
254                array_shift($matches);
255                $filter->setArguments($matches);
256
257                return $filter;
258            }
259        }
260
261        foreach ($this->filterCallbacks as $callback) {
262            if (false !== $filter = $callback($name)) {
263                return $filter;
264            }
265        }
266
267        return false;
268    }
269
270    public function registerUndefinedFilterCallback(callable $callable)
271    {
272        $this->filterCallbacks[] = $callable;
273    }
274
275    public function addNodeVisitor(NodeVisitorInterface $visitor)
276    {
277        if ($this->initialized) {
278            throw new \LogicException('Unable to add a node visitor as extensions have already been initialized.');
279        }
280
281        $this->staging->addNodeVisitor($visitor);
282    }
283
284    /**
285     * @return NodeVisitorInterface[]
286     */
287    public function getNodeVisitors(): array
288    {
289        if (!$this->initialized) {
290            $this->initExtensions();
291        }
292
293        return $this->visitors;
294    }
295
296    public function addTokenParser(TokenParserInterface $parser)
297    {
298        if ($this->initialized) {
299            throw new \LogicException('Unable to add a token parser as extensions have already been initialized.');
300        }
301
302        $this->staging->addTokenParser($parser);
303    }
304
305    /**
306     * @return TokenParserInterface[]
307     */
308    public function getTokenParsers(): array
309    {
310        if (!$this->initialized) {
311            $this->initExtensions();
312        }
313
314        return $this->parsers;
315    }
316
317    public function getGlobals(): array
318    {
319        if (null !== $this->globals) {
320            return $this->globals;
321        }
322
323        $globals = [];
324        foreach ($this->extensions as $extension) {
325            if (!$extension instanceof GlobalsInterface) {
326                continue;
327            }
328
329            $extGlobals = $extension->getGlobals();
330            if (!\is_array($extGlobals)) {
331                throw new \UnexpectedValueException(sprintf('"%s::getGlobals()" must return an array of globals.', \get_class($extension)));
332            }
333
334            $globals = array_merge($globals, $extGlobals);
335        }
336
337        if ($this->initialized) {
338            $this->globals = $globals;
339        }
340
341        return $globals;
342    }
343
344    public function addTest(TwigTest $test)
345    {
346        if ($this->initialized) {
347            throw new \LogicException(sprintf('Unable to add test "%s" as extensions have already been initialized.', $test->getName()));
348        }
349
350        $this->staging->addTest($test);
351    }
352
353    /**
354     * @return TwigTest[]
355     */
356    public function getTests(): array
357    {
358        if (!$this->initialized) {
359            $this->initExtensions();
360        }
361
362        return $this->tests;
363    }
364
365    /**
366     * @return TwigTest|false
367     */
368    public function getTest(string $name)
369    {
370        if (!$this->initialized) {
371            $this->initExtensions();
372        }
373
374        if (isset($this->tests[$name])) {
375            return $this->tests[$name];
376        }
377
378        foreach ($this->tests as $pattern => $test) {
379            $pattern = str_replace('\\*', '(.*?)', preg_quote($pattern, '#'), $count);
380
381            if ($count) {
382                if (preg_match('#^'.$pattern.'$#', $name, $matches)) {
383                    array_shift($matches);
384                    $test->setArguments($matches);
385
386                    return $test;
387                }
388            }
389        }
390
391        return false;
392    }
393
394    public function getUnaryOperators(): array
395    {
396        if (!$this->initialized) {
397            $this->initExtensions();
398        }
399
400        return $this->unaryOperators;
401    }
402
403    public function getBinaryOperators(): array
404    {
405        if (!$this->initialized) {
406            $this->initExtensions();
407        }
408
409        return $this->binaryOperators;
410    }
411
412    private function initExtensions()
413    {
414        $this->parsers = [];
415        $this->filters = [];
416        $this->functions = [];
417        $this->tests = [];
418        $this->visitors = [];
419        $this->unaryOperators = [];
420        $this->binaryOperators = [];
421
422        foreach ($this->extensions as $extension) {
423            $this->initExtension($extension);
424        }
425        $this->initExtension($this->staging);
426        // Done at the end only, so that an exception during initialization does not mark the environment as initialized when catching the exception
427        $this->initialized = true;
428    }
429
430    private function initExtension(ExtensionInterface $extension)
431    {
432        // filters
433        foreach ($extension->getFilters() as $filter) {
434            $this->filters[$filter->getName()] = $filter;
435        }
436
437        // functions
438        foreach ($extension->getFunctions() as $function) {
439            $this->functions[$function->getName()] = $function;
440        }
441
442        // tests
443        foreach ($extension->getTests() as $test) {
444            $this->tests[$test->getName()] = $test;
445        }
446
447        // token parsers
448        foreach ($extension->getTokenParsers() as $parser) {
449            if (!$parser instanceof TokenParserInterface) {
450                throw new \LogicException('getTokenParsers() must return an array of \Twig\TokenParser\TokenParserInterface.');
451            }
452
453            $this->parsers[] = $parser;
454        }
455
456        // node visitors
457        foreach ($extension->getNodeVisitors() as $visitor) {
458            $this->visitors[] = $visitor;
459        }
460
461        // operators
462        if ($operators = $extension->getOperators()) {
463            if (!\is_array($operators)) {
464                throw new \InvalidArgumentException(sprintf('"%s::getOperators()" must return an array with operators, got "%s".', \get_class($extension), \is_object($operators) ? \get_class($operators) : \gettype($operators).(\is_resource($operators) ? '' : '#'.$operators)));
465            }
466
467            if (2 !== \count($operators)) {
468                throw new \InvalidArgumentException(sprintf('"%s::getOperators()" must return an array of 2 elements, got %d.', \get_class($extension), \count($operators)));
469            }
470
471            $this->unaryOperators = array_merge($this->unaryOperators, $operators[0]);
472            $this->binaryOperators = array_merge($this->binaryOperators, $operators[1]);
473        }
474    }
475}
476
477class_alias('Twig\ExtensionSet', 'Twig_ExtensionSet');
478