Skip to content

Commit 7ea6950

Browse files
authored
Support converting all options to twig attributes (#859)
With this change, all potential options from the `TwigFilter` and `TwigFunction` classes will be converted to their counterpart in the attribute, so if you have something like this: ```php class MyClass { public function getFilters(): array { return [ new \Twig\TwigFilter('with_environment', $this->withEnvironment(...), ['needs_environment' => true]), ]; } public function withEnvironment(Environment $env, $value) { // ... } } ``` it will now be converted to the following instead of skipping it: ```php class MyClass { #[\Twig\Attribute\AsTwigFilter('with_environment', needsEnvironment: true)] public function withEnvironment(Environment $env, $value) { // ... } } ```
1 parent 64e807b commit 7ea6950

File tree

5 files changed

+340
-14
lines changed

5 files changed

+340
-14
lines changed
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
namespace Rector\Symfony\Tests\Symfony73\Rector\Class_\GetFiltersToAsTwigFilterAttributeRector\Fixture;
4+
5+
use Twig\DeprecatedCallableInfo;
6+
use Twig\Environment;
7+
use Twig\Node\Node;
8+
use Twig\Extension\AbstractExtension;
9+
10+
final class WithOptionsParameter extends AbstractExtension
11+
{
12+
public function getFilters(): array
13+
{
14+
return [
15+
new \Twig\TwigFilter('with_environment', $this->withEnvironment(...), ['needs_environment' => true]),
16+
new \Twig\TwigFilter('with_context', [$this, 'withContext'], ['needs_context' => true]),
17+
new \Twig\TwigFilter('with_charset', [$this, 'withCharset'], ['needs_charset' => true]),
18+
new \Twig\TwigFilter('with_pre_escape', [$this, 'withPreEscape'], ['pre_escape' => 'html']),
19+
new \Twig\TwigFilter('with_preserves_safety', [$this, 'withPreservesSafety'], ['preserves_safety' => ['html']]),
20+
new \Twig\TwigFilter('with_safe_callback', [$this, 'withSafeCallback'], ['is_safe_callback' => [self::class, 'checkSafeCallback']]),
21+
new \Twig\TwigFilter('with_deprecation_info', [$this, 'withDeprecationInfo'], ['deprecation_info' => new DeprecatedCallableInfo('package', 'version')]),
22+
new \Twig\TwigFilter('with_everything', [$this, 'withEverything'], ['is_safe' => ['html'], 'needs_context' => true, 'needs_charset' => true, 'needs_environment' => true, 'pre_escape' => 'html', 'preserves_safety' => ['html']],),
23+
];
24+
}
25+
26+
public function withEnvironment(Environment $env, $value)
27+
{
28+
return $value;
29+
}
30+
31+
public function withContext(array $context, $value)
32+
{
33+
return $value;
34+
}
35+
36+
public function withCharset(string $charset, $value)
37+
{
38+
return $value;
39+
}
40+
41+
public function withPreEscape($value)
42+
{
43+
return $value;
44+
}
45+
46+
public function withPreservesSafety($value)
47+
{
48+
return $value;
49+
}
50+
51+
public function withSafeCallback($value)
52+
{
53+
return $value;
54+
}
55+
56+
public function withDeprecationInfo($value)
57+
{
58+
return $value;
59+
}
60+
61+
public function withEverything(string $charset, Environment $env, array $context, $value)
62+
{
63+
return $value;
64+
}
65+
66+
public function checkSafeCallback(Node $argsNode): array
67+
{
68+
return [];
69+
}
70+
}
71+
72+
?>
73+
-----
74+
<?php
75+
76+
namespace Rector\Symfony\Tests\Symfony73\Rector\Class_\GetFiltersToAsTwigFilterAttributeRector\Fixture;
77+
78+
use Twig\DeprecatedCallableInfo;
79+
use Twig\Environment;
80+
use Twig\Node\Node;
81+
use Twig\Extension\AbstractExtension;
82+
83+
final class WithOptionsParameter
84+
{
85+
#[\Twig\Attribute\AsTwigFilter('with_environment', needsEnvironment: true)]
86+
public function withEnvironment(Environment $env, $value)
87+
{
88+
return $value;
89+
}
90+
91+
#[\Twig\Attribute\AsTwigFilter('with_context', needsContext: true)]
92+
public function withContext(array $context, $value)
93+
{
94+
return $value;
95+
}
96+
97+
#[\Twig\Attribute\AsTwigFilter('with_charset', needsCharset: true)]
98+
public function withCharset(string $charset, $value)
99+
{
100+
return $value;
101+
}
102+
103+
#[\Twig\Attribute\AsTwigFilter('with_pre_escape', preEscape: 'html')]
104+
public function withPreEscape($value)
105+
{
106+
return $value;
107+
}
108+
109+
#[\Twig\Attribute\AsTwigFilter('with_preserves_safety', preservesSafety: ['html'])]
110+
public function withPreservesSafety($value)
111+
{
112+
return $value;
113+
}
114+
115+
#[\Twig\Attribute\AsTwigFilter('with_safe_callback', isSafeCallback: [self::class, 'checkSafeCallback'])]
116+
public function withSafeCallback($value)
117+
{
118+
return $value;
119+
}
120+
121+
#[\Twig\Attribute\AsTwigFilter('with_deprecation_info', deprecationInfo: new DeprecatedCallableInfo('package', 'version'))]
122+
public function withDeprecationInfo($value)
123+
{
124+
return $value;
125+
}
126+
127+
#[\Twig\Attribute\AsTwigFilter('with_everything', isSafe: ['html'], needsContext: true, needsCharset: true, needsEnvironment: true, preEscape: 'html', preservesSafety: ['html'])]
128+
public function withEverything(string $charset, Environment $env, array $context, $value)
129+
{
130+
return $value;
131+
}
132+
133+
public function checkSafeCallback(Node $argsNode): array
134+
{
135+
return [];
136+
}
137+
}
138+
139+
?>
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
namespace Rector\Symfony\Tests\Symfony73\Rector\Class_\GetFunctionsToAsTwigFunctionAttributeRector\Fixture;
4+
5+
use Twig\DeprecatedCallableInfo;
6+
use Twig\Environment;
7+
use Twig\Node\Node;
8+
use Twig\Extension\AbstractExtension;
9+
10+
final class WithOptionsParameter extends AbstractExtension
11+
{
12+
public function getFunctions(): array
13+
{
14+
return [
15+
new \Twig\TwigFunction('with_environment', $this->withEnvironment(...), ['needs_environment' => true]),
16+
new \Twig\TwigFunction('with_context', [$this, 'withContext'], ['needs_context' => true]),
17+
new \Twig\TwigFunction('with_charset', [$this, 'withCharset'], ['needs_charset' => true]),
18+
new \Twig\TwigFunction('with_safe_callback', [$this, 'withSafeCallback'], ['is_safe_callback' => [self::class, 'checkSafeCallback']]),
19+
new \Twig\TwigFunction('with_deprecation_info', [$this, 'withDeprecationInfo'], ['deprecation_info' => new DeprecatedCallableInfo('package', 'version')]),
20+
new \Twig\TwigFunction('with_everything', [$this, 'withEverything'], ['is_safe' => ['html'], 'needs_context' => true, 'needs_charset' => true, 'needs_environment' => true]),
21+
];
22+
}
23+
24+
public function withEnvironment(Environment $env, $value)
25+
{
26+
return $value;
27+
}
28+
29+
public function withContext(array $context, $value)
30+
{
31+
return $value;
32+
}
33+
34+
public function withCharset(string $charset, $value)
35+
{
36+
return $value;
37+
}
38+
39+
public function withSafeCallback($value)
40+
{
41+
return $value;
42+
}
43+
44+
public function withDeprecationInfo($value)
45+
{
46+
return $value;
47+
}
48+
49+
public function withEverything(string $charset, Environment $env, array $context, $value)
50+
{
51+
return $value;
52+
}
53+
54+
public function checkSafeCallback(Node $argsNode): array
55+
{
56+
return [];
57+
}
58+
}
59+
60+
?>
61+
-----
62+
<?php
63+
64+
namespace Rector\Symfony\Tests\Symfony73\Rector\Class_\GetFunctionsToAsTwigFunctionAttributeRector\Fixture;
65+
66+
use Twig\DeprecatedCallableInfo;
67+
use Twig\Environment;
68+
use Twig\Node\Node;
69+
use Twig\Extension\AbstractExtension;
70+
71+
final class WithOptionsParameter
72+
{
73+
#[\Twig\Attribute\AsTwigFunction('with_environment', needsEnvironment: true)]
74+
public function withEnvironment(Environment $env, $value)
75+
{
76+
return $value;
77+
}
78+
79+
#[\Twig\Attribute\AsTwigFunction('with_context', needsContext: true)]
80+
public function withContext(array $context, $value)
81+
{
82+
return $value;
83+
}
84+
85+
#[\Twig\Attribute\AsTwigFunction('with_charset', needsCharset: true)]
86+
public function withCharset(string $charset, $value)
87+
{
88+
return $value;
89+
}
90+
91+
#[\Twig\Attribute\AsTwigFunction('with_safe_callback', isSafeCallback: [self::class, 'checkSafeCallback'])]
92+
public function withSafeCallback($value)
93+
{
94+
return $value;
95+
}
96+
97+
#[\Twig\Attribute\AsTwigFunction('with_deprecation_info', deprecationInfo: new DeprecatedCallableInfo('package', 'version'))]
98+
public function withDeprecationInfo($value)
99+
{
100+
return $value;
101+
}
102+
103+
#[\Twig\Attribute\AsTwigFunction('with_everything', isSafe: ['html'], needsContext: true, needsCharset: true, needsEnvironment: true)]
104+
public function withEverything(string $charset, Environment $env, array $context, $value)
105+
{
106+
return $value;
107+
}
108+
109+
public function checkSafeCallback(Node $argsNode): array
110+
{
111+
return [];
112+
}
113+
}
114+
115+
?>

rules/Symfony73/GetMethodToAsTwigAttributeTransformer.php

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use PhpParser\Node\Expr\Array_;
1212
use PhpParser\Node\Expr\MethodCall;
1313
use PhpParser\Node\Expr\New_;
14+
use PhpParser\Node\Identifier;
1415
use PhpParser\Node\Name\FullyQualified;
1516
use PhpParser\Node\Scalar\String_;
1617
use PhpParser\Node\Stmt\Class_;
@@ -28,6 +29,15 @@
2829
*/
2930
final readonly class GetMethodToAsTwigAttributeTransformer
3031
{
32+
private const OPTION_TO_NAMED_ARG = [
33+
'is_safe' => 'isSafe',
34+
'needs_environment' => 'needsEnvironment',
35+
'needs_context' => 'needsContext',
36+
'needs_charset' => 'needsCharset',
37+
'is_safe_callback' => 'isSafeCallback',
38+
'deprecation_info' => 'deprecationInfo',
39+
];
40+
3141
public function __construct(
3242
private LocalArrayMethodCallableMatcher $localArrayMethodCallableMatcher,
3343
private ReturnEmptyArrayMethodRemover $returnEmptyArrayMethodRemover,
@@ -36,11 +46,15 @@ public function __construct(
3646
) {
3747
}
3848

49+
/**
50+
* @param array<string, string> $additionalOptionMapping
51+
*/
3952
public function transformClassGetMethodToAttributeMarker(
4053
Class_ $class,
4154
string $methodName,
4255
string $attributeClass,
43-
ObjectType $objectType
56+
ObjectType $objectType,
57+
array $additionalOptionMapping = []
4458
): bool {
4559

4660
// check if attribute even exists
@@ -77,7 +91,8 @@ public function transformClassGetMethodToAttributeMarker(
7791
}
7892

7993
$new = $arrayItem->value;
80-
if (count($new->getArgs()) !== 2) {
94+
$argCount = count($new->getArgs());
95+
if ($argCount > 3 || $argCount < 2) {
8196
continue;
8297
}
8398

@@ -87,6 +102,7 @@ public function transformClassGetMethodToAttributeMarker(
87102
}
88103

89104
$secondArg = $new->getArgs()[1];
105+
$thirdArg = $new->getArgs()[2] ?? null;
90106

91107
if ($this->isLocalCallable($secondArg->value)) {
92108
$localMethodName = $this->localArrayMethodCallableMatcher->match($secondArg->value, $objectType);
@@ -100,7 +116,12 @@ public function transformClassGetMethodToAttributeMarker(
100116
continue;
101117
}
102118

103-
$this->decorateMethodWithAttribute($localMethod, $attributeClass, $nameArg);
119+
$optionArguments = $this->getArgumentsFromOptionArray($thirdArg, $additionalOptionMapping);
120+
if ($optionArguments === null) {
121+
continue;
122+
}
123+
124+
$this->decorateMethodWithAttribute($localMethod, $attributeClass, [$nameArg, ...$optionArguments]);
104125
$this->visibilityManipulator->makePublic($localMethod);
105126

106127
// remove old new function instance
@@ -120,9 +141,12 @@ public function transformClassGetMethodToAttributeMarker(
120141
return $hasChanged;
121142
}
122143

123-
private function decorateMethodWithAttribute(ClassMethod $classMethod, string $attributeClass, Arg $arg): void
144+
/**
145+
* @param Arg[] $args
146+
*/
147+
private function decorateMethodWithAttribute(ClassMethod $classMethod, string $attributeClass, array $args): void
124148
{
125-
$classMethod->attrGroups[] = new AttributeGroup([new Attribute(new FullyQualified($attributeClass), [$arg])]);
149+
$classMethod->attrGroups[] = new AttributeGroup([new Attribute(new FullyQualified($attributeClass), $args)]);
126150
}
127151

128152
private function isLocalCallable(Expr $expr): bool
@@ -133,4 +157,44 @@ private function isLocalCallable(Expr $expr): bool
133157

134158
return $expr instanceof Array_ && count($expr->items) === 2;
135159
}
160+
161+
/**
162+
* @param array<string, string> $additionalOptionMapping
163+
*
164+
* @return Arg[]|null
165+
*/
166+
private function getArgumentsFromOptionArray(?Arg $optionArgument, array $additionalOptionMapping): ?array
167+
{
168+
if (! $optionArgument?->value instanceof Array_) {
169+
return [];
170+
}
171+
172+
$allOptionMappings = [...self::OPTION_TO_NAMED_ARG, ...$additionalOptionMapping];
173+
174+
$args = [];
175+
foreach ($optionArgument->value->items as $item) {
176+
if (! $item->key instanceof String_) {
177+
continue;
178+
}
179+
180+
$mappedName = $allOptionMappings[$item->key->value] ?? null;
181+
if ($mappedName === null) {
182+
continue;
183+
}
184+
185+
if ($mappedName === 'isSafeCallback') {
186+
if ($item->value instanceof MethodCall && $item->value->isFirstClassCallable()) {
187+
continue;
188+
}
189+
}
190+
191+
$arg = new Arg($item->value);
192+
$arg->name = new Identifier($mappedName);
193+
$args[] = $arg;
194+
}
195+
196+
$totalItems = count($optionArgument->value->items);
197+
198+
return count($args) === $totalItems ? $args : null;
199+
}
136200
}

0 commit comments

Comments
 (0)