vendor/twig/twig/src/ExtensionSet.php line 359

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