From 148d3e079da90cf970e014a91354da96560a0d2d Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 11 Aug 2024 14:21:57 +0200 Subject: [PATCH] Make various optimization for dynamic Twig callables --- src/AbstractTwigCallable.php | 23 ++++++- src/ExpressionParser.php | 63 +++++++++---------- src/ExtensionSet.php | 55 +++++++++------- src/Node/Expression/CallExpression.php | 2 +- src/Node/Expression/FilterExpression.php | 1 + src/Node/Expression/FunctionExpression.php | 1 + src/Node/Expression/TestExpression.php | 1 + tests/ExpressionParserTest.php | 49 +++++++++++++++ tests/Fixtures/filters/dynamic_filter.test | 2 + .../Fixtures/functions/dynamic_function.test | 2 + 10 files changed, 138 insertions(+), 61 deletions(-) diff --git a/src/AbstractTwigCallable.php b/src/AbstractTwigCallable.php index b4ff59565..f5739529b 100644 --- a/src/AbstractTwigCallable.php +++ b/src/AbstractTwigCallable.php @@ -19,12 +19,13 @@ abstract class AbstractTwigCallable implements TwigCallableInterface protected $options; private $name; + private $dynamicName; private $callable; private $arguments; public function __construct(string $name, $callable = null, array $options = []) { - $this->name = $name; + $this->name = $this->dynamicName = $name; $this->callable = $callable; $this->arguments = []; $this->options = array_merge([ @@ -43,6 +44,11 @@ abstract class AbstractTwigCallable implements TwigCallableInterface return $this->name; } + public function getDynamicName(): string + { + return $this->dynamicName; + } + public function getCallable() { return $this->callable; @@ -68,8 +74,23 @@ abstract class AbstractTwigCallable implements TwigCallableInterface return $this->options['needs_context']; } + public function withDynamicArguments(string $name, string $dynamicName, array $arguments): self + { + $new = clone $this; + $new->name = $name; + $new->dynamicName = $dynamicName; + $new->arguments = $arguments; + + return $new; + } + + /** + * @deprecated since Twig 3.12, use withDynamicArguments() instead + */ public function setArguments(array $arguments): void { + trigger_deprecation('twig/twig', '3.12', 'The "%s::setArguments()" method is deprecated, use "%s::withDynamicArguments()" instead.', static::class, static::class); + $this->arguments = $arguments; } diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php index 56a749efe..95f4e0f57 100644 --- a/src/ExpressionParser.php +++ b/src/ExpressionParser.php @@ -494,9 +494,9 @@ class ExpressionParser } $args = $this->parseArguments(true); - $class = $this->getFunctionNodeClass($name, $line); + $function = $this->getFunction($name, $line); - return new $class($name, $args, $line); + return new ($function->getNodeClass())($function->getName(), $args, $line); } } @@ -559,9 +559,9 @@ class ExpressionParser $length = $this->parseExpression(); } - $class = $this->getFilterNodeClass('slice', $token->getLine()); + $filter = $this->getFilter('slice', $token->getLine()); $arguments = new Node([$arg, $length]); - $filter = new $class($node, new ConstantExpression('slice', $token->getLine()), $arguments, $token->getLine()); + $filter = new ($filter->getNodeClass())($node, new ConstantExpression('slice', $token->getLine()), $arguments, $token->getLine()); $stream->expect(Token::PUNCTUATION_TYPE, ']'); @@ -586,16 +586,15 @@ class ExpressionParser while (true) { $token = $this->parser->getStream()->expect(Token::NAME_TYPE); - $name = new ConstantExpression($token->getValue(), $token->getLine()); if (!$this->parser->getStream()->test(Token::PUNCTUATION_TYPE, '(')) { $arguments = new Node(); } else { $arguments = $this->parseArguments(true, false, true); } - $class = $this->getFilterNodeClass($name->getAttribute('value'), $token->getLine()); - - $node = new $class($node, $name, $arguments, $token->getLine(), $tag); + $filter = $this->getFilter($token->getValue(), $token->getLine()); + $name = new ConstantExpression($filter->getName(), $token->getLine()); + $node = new ($filter->getNodeClass())($node, $name, $arguments, $token->getLine(), $tag); if (!$this->parser->getStream()->test(Token::PUNCTUATION_TYPE, '|')) { break; @@ -724,9 +723,8 @@ class ExpressionParser private function parseTestExpression(Node $node): TestExpression { $stream = $this->parser->getStream(); - [$name, $test] = $this->getTest($node->getTemplateLine()); + $test = $this->getTest($node->getTemplateLine()); - $class = $this->getTestNodeClass($test); $arguments = null; if ($stream->test(Token::PUNCTUATION_TYPE, '(')) { $arguments = $this->parseArguments(true); @@ -734,42 +732,37 @@ class ExpressionParser $arguments = new Node([0 => $this->parsePrimaryExpression()]); } - if ('defined' === $name && $node instanceof NameExpression && null !== $alias = $this->parser->getImportedSymbol('function', $node->getAttribute('name'))) { + if ('defined' === $test->getName() && $node instanceof NameExpression && null !== $alias = $this->parser->getImportedSymbol('function', $node->getAttribute('name'))) { $node = new MethodCallExpression($alias['node'], $alias['name'], new ArrayExpression([], $node->getTemplateLine()), $node->getTemplateLine()); $node->setAttribute('safe', true); } - return new $class($node, $name, $arguments, $this->parser->getCurrentToken()->getLine()); + return new ($test->getNodeClass())($node, $test->getName(), $arguments, $this->parser->getCurrentToken()->getLine()); } - private function getTest(int $line): array + private function getTest(int $line): TwigTest { $stream = $this->parser->getStream(); $name = $stream->expect(Token::NAME_TYPE)->getValue(); - if ($test = $this->env->getTest($name)) { - return [$name, $test]; - } + if (!$test = $this->env->getTest($name)) { + if ($stream->test(/* Token::NAME_TYPE */ 5)) { + // try 2-words tests + $name = $name.' '.$this->parser->getCurrentToken()->getValue(); - if ($stream->test(Token::NAME_TYPE)) { - // try 2-words tests - $name = $name.' '.$this->parser->getCurrentToken()->getValue(); - - if ($test = $this->env->getTest($name)) { - $stream->next(); - - return [$name, $test]; + if ($test = $this->env->getTest($name)) { + $stream->next(); + } } } - $e = new SyntaxError(\sprintf('Unknown "%s" test.', $name), $line, $stream->getSourceContext()); - $e->addSuggestions($name, array_keys($this->env->getTests())); + if (!$test) { + $e = new SyntaxError(\sprintf('Unknown "%s" test.', $name), $line, $stream->getSourceContext()); + $e->addSuggestions($name, array_keys($this->env->getTests())); - throw $e; - } + throw $e; + } - private function getTestNodeClass(TwigTest $test): string - { if ($test->isDeprecated()) { $stream = $this->parser->getStream(); $message = \sprintf('Twig Test "%s" is deprecated', $test->getName()); @@ -783,10 +776,10 @@ class ExpressionParser trigger_deprecation($test->getDeprecatingPackage(), $test->getDeprecatedVersion(), $message); } - return $test->getNodeClass(); + return $test; } - private function getFunctionNodeClass(string $name, int $line): string + private function getFunction(string $name, int $line): TwigFunction { if (!$function = $this->env->getFunction($name)) { $e = new SyntaxError(\sprintf('Unknown "%s" function.', $name), $line, $this->parser->getStream()->getSourceContext()); @@ -806,10 +799,10 @@ class ExpressionParser trigger_deprecation($function->getDeprecatingPackage(), $function->getDeprecatedVersion(), $message); } - return $function->getNodeClass(); + return $function; } - private function getFilterNodeClass(string $name, int $line): string + private function getFilter(string $name, int $line): TwigFilter { if (!$filter = $this->env->getFilter($name)) { $e = new SyntaxError(\sprintf('Unknown "%s" filter.', $name), $line, $this->parser->getStream()->getSourceContext()); @@ -829,7 +822,7 @@ class ExpressionParser trigger_deprecation($filter->getDeprecatingPackage(), $filter->getDeprecatedVersion(), $message); } - return $filter->getNodeClass(); + return $filter; } // checks that the node only contains "constant" elements diff --git a/src/ExtensionSet.php b/src/ExtensionSet.php index 8b59a13e1..32377b0fc 100644 --- a/src/ExtensionSet.php +++ b/src/ExtensionSet.php @@ -36,10 +36,16 @@ final class ExtensionSet private $visitors; /** @var array */ private $filters; + /** @var array */ + private $dynamicFilters; /** @var array */ private $tests; + /** @var array */ + private $dynamicTests; /** @var array */ private $functions; + /** @var array */ + private $dynamicFunctions; /** @var array}> */ private $unaryOperators; /** @var array, associativity: ExpressionParser::OPERATOR_*}> */ @@ -167,14 +173,11 @@ final class ExtensionSet return $this->functions[$name]; } - foreach ($this->functions as $pattern => $function) { - $pattern = str_replace('\\*', '(.*?)', preg_quote($pattern, '#'), $count); - - if ($count && preg_match('#^'.$pattern.'$#', $name, $matches)) { + foreach ($this->dynamicFunctions as $pattern => $function) { + if (preg_match($pattern, $name, $matches)) { array_shift($matches); - $function->setArguments($matches); - return $function; + return $function->withDynamicArguments($name, $function->getName(), $matches); } } @@ -223,14 +226,11 @@ final class ExtensionSet return $this->filters[$name]; } - foreach ($this->filters as $pattern => $filter) { - $pattern = str_replace('\\*', '(.*?)', preg_quote($pattern, '#'), $count); - - if ($count && preg_match('#^'.$pattern.'$#', $name, $matches)) { + foreach ($this->dynamicFilters as $pattern => $filter) { + if (preg_match($pattern, $name, $matches)) { array_shift($matches); - $filter->setArguments($matches); - return $filter; + return $filter->withDynamicArguments($name, $filter->getName(), $matches); } } @@ -375,16 +375,11 @@ final class ExtensionSet return $this->tests[$name]; } - foreach ($this->tests as $pattern => $test) { - $pattern = str_replace('\\*', '(.*?)', preg_quote($pattern, '#'), $count); + foreach ($this->dynamicTests as $pattern => $test) { + if (preg_match($pattern, $name, $matches)) { + array_shift($matches); - if ($count) { - if (preg_match('#^'.$pattern.'$#', $name, $matches)) { - array_shift($matches); - $test->setArguments($matches); - - return $test; - } + return $test->withDynamicArguments($name, $test->getName(), $matches); } } @@ -421,6 +416,9 @@ final class ExtensionSet $this->filters = []; $this->functions = []; $this->tests = []; + $this->dynamicFilters = []; + $this->dynamicFunctions = []; + $this->dynamicTests = []; $this->visitors = []; $this->unaryOperators = []; $this->binaryOperators = []; @@ -437,17 +435,26 @@ final class ExtensionSet { // filters foreach ($extension->getFilters() as $filter) { - $this->filters[$filter->getName()] = $filter; + $this->filters[$name = $filter->getName()] = $filter; + if (str_contains($name, '*')) { + $this->dynamicFilters['#^'.str_replace('\\*', '(.*?)', preg_quote($name, '#')).'$#'] = $filter; + } } // functions foreach ($extension->getFunctions() as $function) { - $this->functions[$function->getName()] = $function; + $this->functions[$name = $function->getName()] = $function; + if (str_contains($name, '*')) { + $this->dynamicFunctions['#^'.str_replace('\\*', '(.*?)', preg_quote($name, '#')).'$#'] = $function; + } } // tests foreach ($extension->getTests() as $test) { - $this->tests[$test->getName()] = $test; + $this->tests[$name = $test->getName()] = $test; + if (str_contains($name, '*')) { + $this->dynamicTests['#^'.str_replace('\\*', '(.*?)', preg_quote($name, '#')).'$#'] = $test; + } } // token parsers diff --git a/src/Node/Expression/CallExpression.php b/src/Node/Expression/CallExpression.php index cd81df47a..1ef11cea8 100644 --- a/src/Node/Expression/CallExpression.php +++ b/src/Node/Expression/CallExpression.php @@ -51,7 +51,7 @@ abstract class CallExpression extends AbstractExpression $compiler->raw(\sprintf('->%s', $callable[1])); } else { - $compiler->raw(\sprintf('$this->env->get%s(\'%s\')->getCallable()', ucfirst($this->getAttribute('type')), $this->getAttribute('name'))); + $compiler->raw(\sprintf('$this->env->get%s(\'%s\')->getCallable()', ucfirst($this->getAttribute('type')), $this->getAttribute('dynamic_name'))); } } diff --git a/src/Node/Expression/FilterExpression.php b/src/Node/Expression/FilterExpression.php index 251870ae5..d410ede53 100644 --- a/src/Node/Expression/FilterExpression.php +++ b/src/Node/Expression/FilterExpression.php @@ -44,6 +44,7 @@ class FilterExpression extends CallExpression $this->setAttribute('arguments', $filter->getArguments()); $this->setAttribute('callable', $filter->getCallable()); $this->setAttribute('is_variadic', $filter->isVariadic()); + $this->setAttribute('dynamic_name', $filter->getDynamicName()); $this->compileCallable($compiler); } diff --git a/src/Node/Expression/FunctionExpression.php b/src/Node/Expression/FunctionExpression.php index ef99c401a..b7136f6f7 100644 --- a/src/Node/Expression/FunctionExpression.php +++ b/src/Node/Expression/FunctionExpression.php @@ -37,6 +37,7 @@ class FunctionExpression extends CallExpression } $this->setAttribute('callable', $callable); $this->setAttribute('is_variadic', $function->isVariadic()); + $this->setAttribute('dynamic_name', $function->getDynamicName()); $this->compileCallable($compiler); } diff --git a/src/Node/Expression/TestExpression.php b/src/Node/Expression/TestExpression.php index 29c5a522c..6bf6c691b 100644 --- a/src/Node/Expression/TestExpression.php +++ b/src/Node/Expression/TestExpression.php @@ -33,6 +33,7 @@ class TestExpression extends CallExpression $this->setAttribute('arguments', $test->getArguments()); $this->setAttribute('callable', $test->getCallable()); $this->setAttribute('is_variadic', $test->isVariadic()); + $this->setAttribute('dynamic_name', $test->getDynamicName()); $this->compileCallable($compiler); } diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php index b1e3d5017..17524b245 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -14,6 +14,7 @@ namespace Twig\Tests; use PHPUnit\Framework\TestCase; use Twig\Environment; use Twig\Error\SyntaxError; +use Twig\Extension\AbstractExtension; use Twig\Loader\ArrayLoader; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\Binary\ConcatBinary; @@ -21,6 +22,9 @@ use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\NameExpression; use Twig\Parser; use Twig\Source; +use Twig\TwigFilter; +use Twig\TwigFunction; +use Twig\TwigTest; class ExpressionParserTest extends TestCase { @@ -411,6 +415,51 @@ class ExpressionParserTest extends TestCase $parser->parse($env->tokenize(new Source('{{ 1 is foobar }}', 'index'))); } + public function testCompiledCodeForDynamicTest() + { + $env = new Environment(new ArrayLoader(['index' => '{{ "a" is foo_foo_bar_bar }}']), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new class() extends AbstractExtension { + public function getTests() + { + return [ + new TwigTest('*_foo_*_bar', function ($foo, $bar, $a) {}), + ]; + } + }); + + $this->assertStringContainsString('$this->env->getTest(\'*_foo_*_bar\')->getCallable()("foo", "bar", "a")', $env->compile($env->parse($env->tokenize(new Source($env->getLoader()->getSourceContext('index')->getCode(), 'index'))))); + } + + public function testCompiledCodeForDynamicFunction() + { + $env = new Environment(new ArrayLoader(['index' => '{{ foo_foo_bar_bar("a") }}']), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new class() extends AbstractExtension { + public function getFunctions() + { + return [ + new TwigFunction('*_foo_*_bar', function ($foo, $bar, $a) {}), + ]; + } + }); + + $this->assertStringContainsString('$this->env->getFunction(\'*_foo_*_bar\')->getCallable()("foo", "bar", "a")', $env->compile($env->parse($env->tokenize(new Source($env->getLoader()->getSourceContext('index')->getCode(), 'index'))))); + } + + public function testCompiledCodeForDynamicFilter() + { + $env = new Environment(new ArrayLoader(['index' => '{{ "a"|foo_foo_bar_bar }}']), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new class() extends AbstractExtension { + public function getFilters() + { + return [ + new TwigFilter('*_foo_*_bar', function ($foo, $bar, $a) {}), + ]; + } + }); + + $this->assertStringContainsString('$this->env->getFilter(\'*_foo_*_bar\')->getCallable()("foo", "bar", "a")', $env->compile($env->parse($env->tokenize(new Source($env->getLoader()->getSourceContext('index')->getCode(), 'index'))))); + } + private function createNameExpression(string $name, array $attributes) { $expression = new NameExpression($name, 1); diff --git a/tests/Fixtures/filters/dynamic_filter.test b/tests/Fixtures/filters/dynamic_filter.test index 27dc8784c..15c47814d 100644 --- a/tests/Fixtures/filters/dynamic_filter.test +++ b/tests/Fixtures/filters/dynamic_filter.test @@ -2,9 +2,11 @@ dynamic filter --TEMPLATE-- {{ 'bar'|foo_path }} +{{ 'bar'|bar_path }} {{ 'bar'|a_foo_b_bar }} --DATA-- return [] --EXPECT-- foo/bar +bar/bar a/b/bar diff --git a/tests/Fixtures/functions/dynamic_function.test b/tests/Fixtures/functions/dynamic_function.test index c7b3539c4..ea851b6dc 100644 --- a/tests/Fixtures/functions/dynamic_function.test +++ b/tests/Fixtures/functions/dynamic_function.test @@ -2,9 +2,11 @@ dynamic function --TEMPLATE-- {{ foo_path('bar') }} +{{ bar_path('bar') }} {{ a_foo_b_bar('bar') }} --DATA-- return [] --EXPECT-- foo/bar +bar/bar a/b/bar