Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create attributes AsTwigFilter, AsTwigFunction and AsTwigTest to ease extension development #3916

Open
wants to merge 17 commits into
base: 3.x
Choose a base branch
from

Conversation

GromNaN
Copy link
Contributor

@GromNaN GromNaN commented Nov 26, 2023

One drawback to writing extensions at present is that the declaration of functions/filters/tests is not directly adjacent to the methods. It's worse for runtime extensions because they need to be in 2 different classes. See SerializerExtension and SerializerRuntime as an example.

By using attributes for filters, functions and tests definition, we can make writing extensions more expressive, and use reflection to detect particular options (needs_environment, needs_context, is_variadic).

Example if we implemented the serialize filter:

public function formatDate(Environment $env, $date, ?string $dateFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string
{
return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale);
}

By using the AsTwigFilter attribute, it is not necessary to create the getFilters() method. The needs_environment option is detected from method signature. The name is still required as the method naming convention (camelCase) doesn't match with Twig naming convention (snake_case).

use Twig\Extension\Attribute\AsTwigFilter;
use Twig\Extension\Attribute\AsTwigExtension;

#[AsTwigExtension]
class IntlExtension
{
    #[AsTwigFilter(name: 'format_date')]
    public function formatDate(Environment $env, $date, ?string $dateFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string
    {
        return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale);
    }
}

This approach does not totally replace the current definition of extensions, which is still necessary for advanced needs. It does, however, make for more pleasant reading and writing.

This makes writing lazy-loaded runtime extension the easiest way to create Twig extension in Symfony: symfony/symfony#52748

Related to symfony/symfony#50016

Is there any need to cache the parsing of method attributes? They are only read at compile time, but that can have a performance impact during development or when using dynamic templates.

@GromNaN GromNaN changed the title Allows registration of filters, functions and tests with an attribute Create attributes AsTwigFilter, AsTwigFunction and AsTwigTest to improve extension development Nov 26, 2023
src/Extension/Extension.php Outdated Show resolved Hide resolved
src/Extension/Extension.php Outdated Show resolved Hide resolved
src/Extension/Attribute/AsTwigFilter.php Outdated Show resolved Hide resolved
src/Extension/Attribute/AsTwigFilter.php Outdated Show resolved Hide resolved
src/Extension/Attribute/AsTwigTest.php Outdated Show resolved Hide resolved
@GromNaN GromNaN changed the title Create attributes AsTwigFilter, AsTwigFunction and AsTwigTest to improve extension development Create attributes AsTwigFilter, AsTwigFunction and AsTwigTest to ease extension development Nov 26, 2023
src/Extension/Attribute/AsTwigFilter.php Outdated Show resolved Hide resolved
src/Extension/Extension.php Outdated Show resolved Hide resolved
@GromNaN
Copy link
Contributor Author

GromNaN commented Nov 26, 2023

I'll rework the implementation after reading discussions on symfony/symfony#50016

$parameters = $method->getParameters();
$needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
$firstParam = $needsEnvironment ? 1 : 0;
$needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this special behavior of parameter named $context needs to be documented on the attributes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the doc and example in the attribute class header.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Symfony serialize filter accepts an array $context as 3rd argument. This will not conflict but this means developers could have an unexpected behavior if they don't know about this special argument handling.


namespace Twig\Extension;

interface WithLastModified
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also a new feature required to detect when the class that defines attributes have been updated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we can make passing a class name to the ExtensionSet, that will be scanned by it. This has slightly more impact on existing classes.

Copy link
Contributor Author

@GromNaN GromNaN Dec 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stof I can't replace the modification date by a hash, the Environment::isFresh relies on a date comparison between the ExtensionSet and the cache file mtime.

Twig/src/Environment.php

Lines 416 to 419 in aeeec9a

public function isTemplateFresh(string $name, int $time): bool
{
return $this->extensionSet->getLastModified() <= $time && $this->getLoader()->isFresh($name, $time);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LastModifiedAwareInterface? What's the existing convention in Twig?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to ModificationAwareInterface. The convention if to suffix with Interface.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the last change, I removed this interface. ExtensionSet is now aware of the extension classes that use attributes, and check their modification date as well.

* Identifies a class that uses PHP attributes to define filters, functions, or tests.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class AsTwigExtension
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this attribute ? I don't see (yet) how it is / will be used

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's similar to Twig\Extension\RuntimeExtensionInterface, it's a marker for classes that provide extension features, to be automatically tagged by Symfony dependency injection.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that extensions must implement the ExtensionInterface anyway, there is no need for a marker attribute. The Symfony dependency injection component is also able to perform auto-configuration based on implemented interfaces (and TwigBundle already does it for the Twig ExtensionInterface

Copy link
Contributor Author

@GromNaN GromNaN Dec 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's more a question of DX. It's more meaningful to use an attribute than an interface as a marker as the developer will use attributes in the class. Implementing an interface without method is a deviation from the interface system.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made this attribute mandatory to prevent any object from being passed by mistake. In practice, it will be mandatory anyway for Symfony autoconfiguration.

@GromNaN GromNaN force-pushed the attribute branch 4 times, most recently from c627a38 to 4c1adc1 Compare December 10, 2023 21:38
Copy link
Contributor Author

@GromNaN GromNaN left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ready for review @fabpot.

doc/advanced.rst Outdated

.. note::

The ``\Twig\Extension\AttributeExtension`` can be added only once to an environment.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an exception if we try to register the extension a second time.

@GromNaN
Copy link
Contributor Author

GromNaN commented Jan 3, 2024

In all honesty, I'm not totally convinced by this implementation. The fact that extensions have to be registered in 2 places (as a runtime extension and for definition in AttributeExtension) makes them difficult to use with Twig standalone, this is totally hidden for Symfony users with the TwigBundle.

I would like to add a new Environment::add(object $extension) method that would accept any class with attributes. Invocable classes could be used to define filters and functions.

@nicolas-grekas
Copy link
Contributor

What is a bit strange is having one single instance of AttributeExtension be responsible for registering many runtimes.
Maybe we should instead have one AttributeExtension per runtime?
Then it might be easier to use this standalone : addExtension(new AttributeExtension(new AttributeBasedRuntime))) (and such runtimes could have a static factory to make this even easier to create).
Note that this suggestion might be wrong as I didn't think of how runtimes should be made lazy-instantiated.

@kbond
Copy link
Contributor

kbond commented Jan 3, 2024

For reference, this is my attempt/experiment to solve the problem of apps having to create a bunch of twig extensions for things: https://github.com/zenstruck/twig-service-bundle

Copy link
Contributor Author

@GromNaN GromNaN left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a bit strange is having one single instance of AttributeExtension be responsible for registering many runtimes. Maybe we should instead have one AttributeExtension per runtime? Then it might be easier to use this standalone : addExtension(new AttributeExtension(new AttributeBasedRuntime))) (and such runtimes could have a static factory to make this even easier to create). Note that this suggestion might be wrong as I didn't think of how runtimes should be made lazy-instantiated.

In the ExtensionSet, extensions are identified by their class name. The same extension class cannot be registered multiple times with distinct configuration.

I updated this PR so that extension can be registered directly using any class name or object that have the #[AsTwigExtension] attribute (the attribute becomes required).

    $twig = new \Twig\Environment($loader);
-    $twig->addExtension(new \Twig\Extension\AttributeExtension([
-        ProjectExtension::class,
-    ]));
+    $twig->addExtension(ProjectExtension::class);
    $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([
        ProjectExtension::class => function () use ($lipsumProvider) {
            return new ProjectExtension($lipsumProvider);
        },
    ]));

To use standalone (non-lazy-loaded):

$twig->addExtension(new ProjectExtension($lipsumProvider));

/**
* @param ExtensionInterface|object|class-string $extension
*/
public function addExtension(object|string $extension)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change.

}

// Assign all at the end to avoid inconsistent state in case of exception
$this->filters = $filters;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should do array_values on those arrays instead of returning names as keys

*
* If the first argument of the method has Twig\Environment type-hint, the test will receive the current environment.
* If the next argument of the method is named $context and has array type-hint, the test will receive the current context.
* The last argument of the method is the value to be tested, if any.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is wrong. It is not the last argument. It is the following argument. A test can have as many arguments as you want after the value being tested, which become arguments in the Twig signature.

public ?array $preservesSafety = null,

/**
* Set to true if the filter is deprecated.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the string type about then ?

public ?string $preEscape = null,

/**
* Preserves the safety of the value that the filter is applied to.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this misses the doc for the type of array values

throw new \LogicException(sprintf('Extension class "%s" must have the attribute "%s" in order to use attributes', is_string($objectOrClass) ? $objectOrClass : get_debug_type($objectOrClass), AsTwigExtension::class));
}

foreach ($reflectionClass->getMethods() as $method) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking about that, I'm wondering whether those asTwigFilter, AsTwigFunction and AsTwigTest attributes should be read by that extension at all.

An alternative solution would be to have an extension in which you inject this list of test, filter and function metadata, and letting TwigBundle perform all this attribute reading at build-time instead.
This would even removed the need for the AsTwigExtension attribute entirely (which would be quite confusing as the class having this attribute is not a twig extension but a twig runtime) because the autoconfiguration system is able to apply autoconfiguration based on method-level attributes already.

if ($extension instanceof ExtensionInterface) {
$this->initExtension($extension);
} else {
$classes[] = $extension;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

making $this->extensions contain a mix of extensions and of runtimes (but not all of them) looks weird to me

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this is a way to solve the case of getLastModified, I'd rather add a way to make ExtensionSet aware of a separate list of runtimes needing to be checked (which would allow solving the case where we don't invalidate the cache today if you change the argument names in a runtime while it actually impacts the template compilation if you use named parameters in Twig, by making TwigBundle inject the name of all runtimes in the list).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

6 participants