Skip to content

Commit 9316af0

Browse files
committed
Add TwigComponent\ForbiddenAttributesPropertyRule
1 parent ad16a01 commit 9316af0

File tree

8 files changed

+300
-2
lines changed

8 files changed

+300
-2
lines changed

README.md

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,72 @@ To install the PHPStan rules for Symfony UX, you can use Composer:
1010
composer require --dev kocal/phpstan-symfony-ux
1111
```
1212

13-
## Configuration
13+
## TwigComponent Rules
1414

15-
TODO
15+
### ForbiddenAttributesPropertyRule
16+
17+
Forbid the use of the `$attributes` property in Twig Components, which can lead to confusion when using `{{ attributes }}` (an instance of `ComponentAttributes` that is automatically injected) in Twig templates.
18+
19+
```yaml
20+
rules:
21+
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenAttributesPropertyRule
22+
```
23+
24+
```php
25+
// src/Twig/Components/Alert.php
26+
namespace App\Twig\Components;
27+
28+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
29+
30+
#[AsTwigComponent]
31+
final class Alert
32+
{
33+
public $attributes;
34+
}
35+
```
36+
37+
```php
38+
// src/Twig/Components/Alert.php
39+
namespace App\Twig\Components;
40+
41+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
42+
43+
#[AsTwigComponent(attributesVar: 'customAttributes')]
44+
final class Alert
45+
{
46+
public $customAttributes;
47+
}
48+
```
49+
50+
:x:
51+
52+
<br>
53+
54+
```php
55+
// src/Twig/Components/Alert.php
56+
namespace App\Twig\Components;
57+
58+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
59+
60+
#[AsTwigComponent]
61+
final class Alert
62+
{
63+
}
64+
```
65+
66+
```php
67+
// src/Twig/Components/Alert.php
68+
namespace App\Twig\Components;
69+
70+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
71+
72+
#[AsTwigComponent]
73+
final class Alert
74+
{
75+
public $customAttributes;
76+
}
77+
```
78+
79+
:+1:
80+
81+
<br>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kocal\PHPStanSymfonyUX\NodeAnalyzer;
6+
7+
use PhpParser\Node\Attribute;
8+
use PhpParser\Node\Name\FullyQualified;
9+
use PhpParser\Node\Param;
10+
use PhpParser\Node\Stmt\ClassLike;
11+
use PhpParser\Node\Stmt\Property;
12+
use PHPStan\Node\ClassMethod;
13+
14+
/**
15+
* Heavily inspired by https://github.com/symplify/phpstan-rules/blob/main/src/NodeAnalyzer/AttributeFinder.php <3
16+
*/
17+
final class AttributeFinder
18+
{
19+
/**
20+
* @return Attribute[]
21+
*/
22+
public static function findAttributes(ClassMethod | Property | ClassLike | Param $node): array
23+
{
24+
$attributes = [];
25+
26+
foreach ($node->attrGroups as $attrGroup) {
27+
$attributes = array_merge($attributes, $attrGroup->attrs);
28+
}
29+
30+
return $attributes;
31+
}
32+
33+
public static function findAttribute(ClassMethod | Property | ClassLike | Param $node, string $desiredAttributeClass): ?Attribute
34+
{
35+
$attributes = self::findAttributes($node);
36+
37+
foreach ($attributes as $attribute) {
38+
if (! $attribute->name instanceof FullyQualified) {
39+
continue;
40+
}
41+
42+
if ($attribute->name->toString() === $desiredAttributeClass) {
43+
return $attribute;
44+
}
45+
}
46+
47+
return null;
48+
}
49+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kocal\PHPStanSymfonyUX\Rules\TwigComponent;
6+
7+
use Kocal\PHPStanSymfonyUX\NodeAnalyzer\AttributeFinder;
8+
use PhpParser\Node;
9+
use PhpParser\Node\Stmt\Class_;
10+
use PHPStan\Analyser\Scope;
11+
use PHPStan\Rules\Rule;
12+
use PHPStan\Rules\RuleErrorBuilder;
13+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
14+
15+
/**
16+
* @implements Rule<Class_>
17+
*/
18+
final class ForbiddenAttributesPropertyRule implements Rule
19+
{
20+
public function getNodeType(): string
21+
{
22+
return Class_::class;
23+
}
24+
25+
public function processNode(Node $node, Scope $scope): array
26+
{
27+
if (! $asTwigComponent = AttributeFinder::findAttribute($node, AsTwigComponent::class)) {
28+
return [];
29+
}
30+
31+
if (! $attributesVarName = $this->getAttributesVarName($asTwigComponent)) {
32+
return [];
33+
}
34+
35+
if ($propertyAttributes = $node->getProperty($attributesVarName['name'])) {
36+
return [
37+
RuleErrorBuilder::message(
38+
$attributesVarName['custom']
39+
? sprintf('Using property "%s" in a Twig component is forbidden, it may lead to confusion with the "%s" attribute defined in #[AsTwigComponent].', $attributesVarName['name'], $attributesVarName['name'])
40+
: sprintf('Using property "%s" in a Twig component is forbidden, it may lead to confusion with the default "attributes" Twig variable.', $attributesVarName['name'])
41+
)
42+
->identifier('SymfonyUX.TwigComponent.forbiddenAttributesProperty')
43+
->line($propertyAttributes->getLine())
44+
->tip('Consider renaming or removing this property to avoid conflicts with the Twig component attributes.')
45+
->build(),
46+
47+
];
48+
}
49+
50+
return [];
51+
}
52+
53+
/**
54+
* @return {name: string, custom: false}|null
55+
*/
56+
private function getAttributesVarName(Node\Attribute $attribute): ?array
57+
{
58+
foreach ($attribute->args as $arg) {
59+
if ($arg->name && $arg->name->toString() === 'attributesVar') {
60+
if ($arg->value instanceof Node\Scalar\String_) {
61+
return [
62+
'name' => $arg->value->value,
63+
'custom' => true,
64+
];
65+
}
66+
}
67+
}
68+
69+
$reflAttribute = new \ReflectionClass(AsTwigComponent::class);
70+
foreach ($reflAttribute->getConstructor()->getParameters() as $reflParameter) {
71+
if ($reflParameter->getName() === 'attributesVar' && $reflParameter->isDefaultValueAvailable()) {
72+
return [
73+
'name' => $reflParameter->getDefaultValue(),
74+
'custom' => false,
75+
];
76+
}
77+
}
78+
79+
return null;
80+
}
81+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ForbiddenAttributesPropertyRule\Fixture;
6+
7+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
8+
9+
#[AsTwigComponent]
10+
final class ComponentWithAttributesProperty
11+
{
12+
public $attributes;
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ForbiddenAttributesPropertyRule\Fixture;
6+
7+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
8+
9+
#[AsTwigComponent(attributesVar: 'customAttributes')]
10+
final class ComponentWithAttributesProperty
11+
{
12+
public $customAttributes;
13+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ForbiddenAttributesPropertyRule\Fixture;
6+
7+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
8+
9+
#[AsTwigComponent]
10+
final class ComponentWithNoAttributesProperty
11+
{
12+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ForbiddenAttributesPropertyRule\Fixture;
6+
7+
final class NotAComponent
8+
{
9+
public $attributes;
10+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ForbiddenAttributesPropertyRule;
6+
7+
use Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenAttributesPropertyRule;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Testing\RuleTestCase;
10+
11+
final class ForbiddenAttributesPropertyRuleTest extends RuleTestCase
12+
{
13+
public function testViolations(): void
14+
{
15+
$this->analyse(
16+
[__DIR__ . '/Fixture/ComponentWithAttributesProperty.php'],
17+
[
18+
[
19+
'Using property "attributes" in a Twig component is forbidden, it may lead to confusion with the default "attributes" Twig variable.',
20+
12,
21+
'Consider renaming or removing this property to avoid conflicts with the Twig component attributes.',
22+
],
23+
]
24+
);
25+
26+
$this->analyse(
27+
[__DIR__ . '/Fixture/ComponentWithCustomAttributesProperty.php'],
28+
[
29+
[
30+
'Using property "customAttributes" in a Twig component is forbidden, it may lead to confusion with the "customAttributes" attribute defined in #[AsTwigComponent].',
31+
12,
32+
'Consider renaming or removing this property to avoid conflicts with the Twig component attributes.',
33+
],
34+
]
35+
);
36+
}
37+
38+
public function testNoViolations(): void
39+
{
40+
$this->analyse(
41+
[__DIR__ . '/Fixture/NotAComponent.php'],
42+
[]
43+
);
44+
$this->analyse(
45+
[__DIR__ . '/Fixture/ComponentWithNoAttributesProperty.php'],
46+
[]
47+
);
48+
}
49+
50+
protected function getRule(): Rule
51+
{
52+
return new ForbiddenAttributesPropertyRule();
53+
}
54+
}

0 commit comments

Comments
 (0)