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