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

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