diff --git a/CHANGELOG b/CHANGELOG index 86fdf7caa..0b841015e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ * 1.26.0 (2016-XX-XX) + * allowed filters/functions/tests implementation to use a different class than the extension they belong to * deprecated Twig_ExtensionInterface::getName() * 1.25.0 (2016-09-21) diff --git a/doc/advanced.rst b/doc/advanced.rst index 6c7c2d1d1..441450b01 100644 --- a/doc/advanced.rst +++ b/doc/advanced.rst @@ -136,7 +136,13 @@ Creating a filter is as simple as associating a name with a PHP callable:: // or a simple PHP function $filter = new Twig_SimpleFilter('rot13', 'str_rot13'); + // or a class static method + $filter = new Twig_SimpleFilter('rot13', array('SomeClass', 'rot13Filter')); + $filter = new Twig_SimpleFilter('rot13', 'SomeClass::rot13Filter'); + // or a class method + $filter = new Twig_SimpleFilter('rot13', array($this, 'rot13Filter')); + // the one below needs a runtime implementation (see below for more information) $filter = new Twig_SimpleFilter('rot13', array('SomeClass', 'rot13Filter')); The first argument passed to the ``Twig_SimpleFilter`` constructor is the name @@ -525,10 +531,6 @@ reusable class like adding support for internationalization. An extension can define tags, filters, tests, operators, global variables, functions, and node visitors. -Creating an extension also makes for a better separation of code that is -executed at compilation time and code needed at runtime. As such, it makes -your code faster. - Most of the time, it is useful to create a single extension for your project, to host all the specific tags and filters you want to add to Twig. @@ -646,7 +648,7 @@ main ``Environment`` object:: .. tip:: - The bundled extensions are great examples of how extensions work. + The Twig core extensions are great examples of how extensions work. Globals ~~~~~~~ @@ -765,6 +767,101 @@ The ``getTests()`` method lets you add new test functions:: // ... } +Definition vs Runtime +~~~~~~~~~~~~~~~~~~~~~ + +Twig filters, functions, and tests runtime implementations can be defined as +any valid PHP callable: + +* **functions/static methods**: Simple to implement and fast (used by all Twig + core extensions); but it is hard for the runtime to depend on external + objects; + +* **closures**: Simple to implement; + +* **object methods**: More flexible and required if your runtime code depends + on external objects. + +The simplest way to use methods is to define them on the extension itself:: + + class Project_Twig_Extension extends Twig_Extension + { + private $rot13Provider; + + public function __construct($rot13Provider) + { + $this->rot13Provider = $rot13Provider; + } + + public function getFunctions() + { + return array( + new Twig_SimpleFunction('rot13', array($this, 'rot13')), + ); + } + + public function rot13($value) + { + return $rot13Provider->rot13($value); + } + } + +This is very convenient but not recommended as it makes template compilation +depend on runtime dependencies even if they are not needed (think for instance +as a dependency that connects to a database engine). + +As of Twig 1.26, you can easily decouple the extension definitions from their +runtime implementations by registering a ``Twig_RuntimeLoaderInterface`` +instance on the environment that knows how to instantiate such runtime classes +(runtime classes must be autoload-able):: + + class RuntimeLoader implements Twig_RuntimeLoaderInterface + { + public function load($class) + { + // implement the logic to create an instance of $class + // and inject its dependencies + // most of the time, it means using your dependency injection container + if ('Project_Twig_RuntimeExtension' === $class) { + return new $class(new Rot13Provider()); + } else { + // ... + } + } + } + + $twig->addRuntimeLoader(new RuntimeLoader()); + +It is now possible to move the runtime logic to a new +``Project_Twig_RuntimeExtension`` class and use it directly in the extension:: + + class Project_Twig_RuntimeExtension extends Twig_Extension + { + private $rot13Provider; + + public function __construct($rot13Provider) + { + $this->rot13Provider = $rot13Provider; + } + + public function rot13($value) + { + return $rot13Provider->rot13($value); + } + } + + class Project_Twig_Extension extends Twig_Extension + { + public function getFunctions() + { + return array( + new Twig_SimpleFunction('rot13', array('Project_Twig_RuntimeExtension', 'rot13')), + // or + new Twig_SimpleFunction('rot13', 'Project_Twig_RuntimeExtension::rot13'), + ); + } + } + Overloading ----------- @@ -792,7 +889,7 @@ possible** (order matters):: Here, we have overloaded the built-in ``date`` filter with a custom one. -If you do the same on the Twig_Environment itself, beware that it takes +If you do the same on the ``Twig_Environment`` itself, beware that it takes precedence over any other registered extensions:: $twig = new Twig_Environment($loader); diff --git a/lib/Twig/Environment.php b/lib/Twig/Environment.php index 4f169481a..9a4b4e2f8 100644 --- a/lib/Twig/Environment.php +++ b/lib/Twig/Environment.php @@ -49,7 +49,8 @@ class Twig_Environment private $bcWriteCacheFile = false; private $bcGetCacheFilename = false; private $lastModifiedExtension = 0; - private $legacyExtensionNames = array(); + private $runtimeLoaders = array(); + private $runtimes = array(); /** * Constructor. @@ -786,6 +787,14 @@ class Twig_Environment return isset($this->extensions[ltrim($class, '\\')]); } + /** + * Adds a runtime loader. + */ + public function addRuntimeLoader(Twig_RuntimeLoaderInterface $loader) + { + $this->runtimeLoaders[] = $loader; + } + /** * Gets an extension by class name. * @@ -809,6 +818,28 @@ class Twig_Environment return $this->extensions[$class]; } + /** + * Returns the runtime implementation of a Twig element (filter/function/test). + * + * @param string $class A runtime class name + * + * @return object The runtime implementation + */ + public function getRuntime($class) + { + if (isset($this->runtimes[$class])) { + return $this->runtimes[$class]; + } + + foreach ($this->runtimeLoaders as $loader) { + if (null !== $runtime = $loader->load($class)) { + return $this->runtimes[$class] = $runtime; + } + } + + throw new \Exception(sprintf('Unable to load the "%s" runtime.', $class)); + } + /** * Registers an extension. * diff --git a/lib/Twig/Node/Expression/Call.php b/lib/Twig/Node/Expression/Call.php index ef58f5e11..224c22dc4 100644 --- a/lib/Twig/Node/Expression/Call.php +++ b/lib/Twig/Node/Expression/Call.php @@ -10,18 +10,29 @@ */ abstract class Twig_Node_Expression_Call extends Twig_Node_Expression { + private $reflector; + protected function compileCallable(Twig_Compiler $compiler) { $closingParenthesis = false; if ($this->hasAttribute('callable') && $callable = $this->getAttribute('callable')) { - if (is_string($callable)) { + if (is_string($callable) && false === strpos($callable, '::')) { $compiler->raw($callable); - } elseif (is_array($callable) && $callable[0] instanceof Twig_ExtensionInterface) { - $compiler->raw(sprintf('$this->env->getExtension(\'%s\')->%s', get_class($callable[0]), $callable[1])); } else { - $type = ucfirst($this->getAttribute('type')); - $compiler->raw(sprintf('call_user_func_array($this->env->get%s(\'%s\')->getCallable(), array', $type, $this->getAttribute('name'))); - $closingParenthesis = true; + list($r, $callable) = $this->reflectCallable($callable); + if ($r instanceof ReflectionMethod && is_string($callable[0])) { + if ($r->isStatic()) { + $compiler->raw(sprintf('%s::%s', $callable[0], $callable[1])); + } else { + $compiler->raw(sprintf('$this->env->getRuntime(\'%s\')->%s', $callable[0], $callable[1])); + } + } elseif ($r instanceof ReflectionMethod && $callable[0] instanceof Twig_ExtensionInterface) { + $compiler->raw(sprintf('$this->env->getExtension(\'%s\')->%s', get_class($callable[0]), $callable[1])); + } else { + $type = ucfirst($this->getAttribute('type')); + $compiler->raw(sprintf('call_user_func_array($this->env->get%s(\'%s\')->getCallable(), array', $type, $this->getAttribute('name'))); + $closingParenthesis = true; + } } } else { $compiler->raw($this->getAttribute('thing')->compile()); @@ -121,7 +132,6 @@ abstract class Twig_Node_Expression_Call extends Twig_Node_Expression throw new LogicException($message); } - // manage named arguments $callableParameters = $this->getCallableParameters($callable, $isVariadic); $arguments = array(); $names = array(); @@ -208,16 +218,7 @@ abstract class Twig_Node_Expression_Call extends Twig_Node_Expression private function getCallableParameters($callable, $isVariadic) { - if (is_array($callable)) { - $r = new ReflectionMethod($callable[0], $callable[1]); - } elseif (is_object($callable) && !$callable instanceof Closure) { - $r = new ReflectionObject($callable); - $r = $r->getMethod('__invoke'); - } elseif (is_string($callable) && false !== strpos($callable, '::')) { - $r = new ReflectionMethod($callable); - } else { - $r = new ReflectionFunction($callable); - } + list($r, $_) = $this->reflectCallable($callable); $parameters = $r->getParameters(); if ($this->hasNode('node')) { @@ -250,4 +251,26 @@ abstract class Twig_Node_Expression_Call extends Twig_Node_Expression return $parameters; } + + private function reflectCallable($callable) + { + if (null !== $this->reflector) { + return $this->reflector; + } + + if (is_array($callable)) { + $r = new ReflectionMethod($callable[0], $callable[1]); + } elseif (is_object($callable) && !$callable instanceof Closure) { + $r = new ReflectionObject($callable); + $r = $r->getMethod('__invoke'); + $callable = array($callable, '__invoke'); + } elseif (is_string($callable) && false !== $pos = strpos($callable, '::')) { + $r = new ReflectionMethod($callable); + $callable = array(substr($callable, 0, $pos), substr($callable, $pos + 2)); + } else { + $r = new ReflectionFunction($callable); + } + + return $this->reflector = array($r, $callable); + } } diff --git a/lib/Twig/RuntimeLoaderInterface.php b/lib/Twig/RuntimeLoaderInterface.php new file mode 100644 index 000000000..c41f44af9 --- /dev/null +++ b/lib/Twig/RuntimeLoaderInterface.php @@ -0,0 +1,27 @@ + + */ +interface Twig_RuntimeLoaderInterface +{ + /** + * Creates the runtime implementation of a Twig element (filter/function/test). + * + * @param string $class A runtime class + * + * @return object|null The runtime instance or null if the loader does not know how to create the runtime for this class + */ + public function load($class); +} diff --git a/test/Twig/Tests/EnvironmentTest.php b/test/Twig/Tests/EnvironmentTest.php index 67cd5c297..ce33fcf99 100644 --- a/test/Twig/Tests/EnvironmentTest.php +++ b/test/Twig/Tests/EnvironmentTest.php @@ -396,6 +396,32 @@ EOF restore_error_handler(); } + public function testAddRuntimeLoader() + { + $runtimeLoader = $this->getMockBuilder('Twig_RuntimeLoaderInterface')->getMock(); + $runtimeLoader->expects($this->any())->method('load')->will($this->returnValue(new Twig_Tests_EnvironmentTest_Runtime())); + + $loader = new Twig_Loader_Array(array( + 'func_array' => '{{ from_runtime_array("foo") }}', + 'func_array_default' => '{{ from_runtime_array() }}', + 'func_array_named_args' => '{{ from_runtime_array(name="foo") }}', + 'func_string' => '{{ from_runtime_string("foo") }}', + 'func_string_default' => '{{ from_runtime_string() }}', + 'func_string_named_args' => '{{ from_runtime_string(name="foo") }}', + )); + + $twig = new Twig_Environment($loader); + $twig->addExtension(new Twig_Tests_EnvironmentTest_ExtensionWithoutRuntime()); + $twig->addRuntimeLoader($runtimeLoader); + + $this->assertEquals('foo', $twig->render('func_array')); + $this->assertEquals('bar', $twig->render('func_array_default')); + $this->assertEquals('foo', $twig->render('func_array_named_args')); + $this->assertEquals('foo', $twig->render('func_string')); + $this->assertEquals('bar', $twig->render('func_string_default')); + $this->assertEquals('foo', $twig->render('func_string_named_args')); + } + protected function getMockLoader($templateName, $templateContent) { $loader = $this->getMockBuilder('Twig_LoaderInterface')->getMock(); @@ -526,3 +552,27 @@ class Twig_Tests_EnvironmentTest_ExtensionWithoutDeprecationInitRuntime extends { } } + +class Twig_Tests_EnvironmentTest_ExtensionWithoutRuntime extends Twig_Extension +{ + public function getFunctions() + { + return array( + new Twig_SimpleFunction('from_runtime_array', array('Twig_Tests_EnvironmentTest_Runtime', 'fromRuntime')), + new Twig_SimpleFunction('from_runtime_string', 'Twig_Tests_EnvironmentTest_Runtime::fromRuntime'), + ); + } + + public function getName() + { + return 'from_runtime'; + } +} + +class Twig_Tests_EnvironmentTest_Runtime +{ + public function fromRuntime($name = 'bar') + { + return $name; + } +}