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

Merged
merged 1 commit into from
Mar 24, 2025

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 formatDate 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;

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
@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
@GromNaN
Copy link
Contributor Author

GromNaN commented Nov 26, 2023

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

@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.

Copy link
Contributor

@fabpot fabpot left a comment

Choose a reason for hiding this comment

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

Not an extensive review, just things I've spotted while reading the code quickly.

fabpot added a commit that referenced this pull request Jan 3, 2025
This PR was merged into the 3.x branch.

Discussion
----------

Add `getLastModified()` to extensions

Give to extensions the ability to set a last modification date for cache invalidation.

### Runtime

Currently, the cache is not invalidated when the signature of a runtime method is modified. This is an issue for templates that use named arguments, as argument names have an impact on the generated class.

With this change, extensions using runtime classes can compute a modification date by including the files on which they depend.

By default, the `AbstractExtension` checks if there is a file for the runtime class with the same name of the
This is the convention applied in [Symfony](https://github.com/symfony/symfony/tree/7.3/src/Symfony/Bridge/Twig/Extension) and Twig Extra: `MarkdownExtension` has `MarkdownRuntime`.

### Attribute

Contributing to #3916.

The extension class that will get the configuration from attributes will be able to track the classes having attributes to find the last modification date of all this classes.

### ~BC break~

~In Twig 4.0, the method `getLastModified` will be added to `ExtensionInterface`. It is extremely rare to implement this interface without extending `AbstractExtension`. So adding this method to the interface shouldn't be a problem as the base class has an implementation.~

Commits
-------

d8fe3bd Add `LastModifiedExtensionInterface` and implementation in `AbstractExtension` to track modification of runtime classes
@GromNaN GromNaN force-pushed the attribute branch 7 times, most recently from e87c1e6 to 60c96db Compare January 3, 2025 18:56
@GromNaN
Copy link
Contributor Author

GromNaN commented Jan 3, 2025

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.

I refactored once again to get 1 instance of AttributeExtension for each class. This allows to list the encapsulated class in the ExtensionSet::getSignature to invalidate the cache when an extension class is removed.

Also, I removed the ability to pass an instance of an object to AttributeExtension, this would require proxying calls to ExtensionSet::getExtension('MyClass')->functionCall() to the wrapped object. This can be implemented in a follow-up PR.

The doc is up-to-date on how to use it standalone. The Symfony implementation need to be updated.

@nicolas-grekas
Copy link
Contributor

git rebase FTW ;)

@GromNaN GromNaN force-pushed the attribute branch 2 times, most recently from eb7fd52 to daa12f0 Compare March 3, 2025 08:40
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.

The state of my reflection is that we would want a different implementation in Symfony TwigBundle that reads the attributes during container compilation and inject a pre-compiled instance of AttributeExtension.

This can be done in 2 ways:

  • by serializing the AttributeExtension (I'm not sure for the performances),
  • or by exposing an API to convert attributes to TwigCallable. Maybe creating a public method on the attribute classes AsTwigFilter::getTwigCallable(\ReflectionFunctionAbstract $reflection): TwigFilter

Comment on lines +146 to +150
if ($extension instanceof AttributeExtension) {
$class = $extension->getClass();
} else {
$class = $extension::class;
}
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 for cache invalidation when an extension is added/removed.

Instead of relying on this class check, I can introduce an interface: DelegatingExtensionInterface with the getClass method.

Copy link
Contributor

@kbond kbond left a comment

Choose a reason for hiding this comment

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

Love this!

}

If you want to access the current environment instance in your filter or function,
add the ``Twig\Environment`` type to the first argument of the method::
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this automatically add the needsEnvironment option? What about needsContext? Is adding array $context enough?

Copy link
Contributor Author

@GromNaN GromNaN Mar 6, 2025

Choose a reason for hiding this comment

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

The detection of the context is less obvious (no specific type).
And it's very rarely used: dump is the only that uses it in all the Twig extra, Symfony and UX packages. Leaving it explicit seems to me to be more safe.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed, just auto-detecting needsEnvironment seems best for these reasons.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm confused. needsEnvironment is auto-detected, right?

Copy link
Contributor

Choose a reason for hiding this comment

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

needsEnvironment is auto-detected - needsContext is not.

*
* @author Jérôme Tamarelle <[email protected]>
*/
final class AttributeExtension extends AbstractExtension
Copy link
Contributor Author

@GromNaN GromNaN Mar 12, 2025

Choose a reason for hiding this comment

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

It would be possible to extend this class to create an extension without runtime dependency. This is interesting to simplify standalone usage only, as Symfony will abstract the runtime declaration part.

class CustomExtension extends AttributeExtension
{
    public function __construct()
    {
        // This can be set automatically for child classes
        parent::__construct(self::class);
    }

    #[AsTwigFunction('foo')]
    public function foo(): string
    {
        return 'foo';
    }
}

Reflection for a subsequent PR.

@fabpot
Copy link
Contributor

fabpot commented Mar 24, 2025

Thank you @GromNaN.

@fabpot fabpot merged commit 8a7d912 into twigphp:3.x Mar 24, 2025
7 of 8 checks passed
@GromNaN GromNaN mentioned this pull request Mar 25, 2025
@GromNaN GromNaN deleted the attribute branch March 26, 2025 06:14
fabpot added a commit to symfony/symfony that referenced this pull request Mar 26, 2025
…on]` and `#[AsTwigTest]` attributes to configure runtime extensions (GromNaN)

This PR was squashed before being merged into the 7.3 branch.

Discussion
----------

[TwigBundle] Enable `#[AsTwigFilter]`, `#[AsTwigFunction]` and `#[AsTwigTest]` attributes to configure runtime extensions

| Q             | A
| ------------- | ---
| Branch?       | 7.3
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | Fix #50016
| License       | MIT

Integration for new PHP attributes introduced by twigphp/Twig#3916.

~We use the existing empty interface `Twig\Extension\RuntimeExtensionInterface` to identify services that are registered as Twig runtime and could define filters/functions/tests using the attributes.~ Using attribute autoconfiguration of methods.

~There is still an issue with cache invalidation when the runtime class is modified.~ Fixed by twigphp/Twig#3916

Commits
-------

c8780d1 [TwigBundle] Enable `#[AsTwigFilter]`, `#[AsTwigFunction]` and `#[AsTwigTest]` attributes to configure runtime extensions
@GromNaN
Copy link
Contributor Author

GromNaN commented Mar 26, 2025

Documentation: Using PHP Attributes to define Extensions

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.

7 participants