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

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