Skip to content

Commit 95698cb

Browse files
authored
Merge pull request #1 from Kocal/feat/twig-components-forbidden-properties
2 parents ad16a01 + 4f5e97f commit 95698cb

17 files changed

+504
-8
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,12 @@ jobs:
5656
name: Tests
5757
runs-on: ubuntu-latest
5858
strategy:
59+
fail-fast: false
5960
matrix:
6061
php: [ '8.2', '8.3', '8.4', '8.5' ]
6162
composer-dependency-version: ['']
6263
composer-minimum-stability: ['stable']
6364
include:
64-
# Lowest dependencies on minimum supported PHP version
65-
- php: '8.2'
66-
composer-dependency-version: 'lowest'
67-
6865
# Highest dev dependencies
6966
- php: '8.5'
7067
composer-minimum-stability: 'dev'
@@ -83,7 +80,7 @@ jobs:
8380
run: symfony composer config minimum-stability ${{ matrix.composer-minimum-stability }}
8481

8582
- name: Install Composer dependencies
86-
run: symfony composer update --prefer-dist --no-interaction --no-progress ${{ matrix.composer-dependency-version == 'lowest' && '--prefer-lowest' || '' }}
83+
run: symfony composer update --prefer-dist --no-interaction --no-progress
8784

8885
- name: Run PHPUnit tests
8986
run: symfony composer run test

README.md

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,115 @@ 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>
82+
83+
### ForbiddenClassPropertyRule
84+
85+
Forbid the use of the `$class` property in Twig Components, as it is considered a bad practice to manipulate CSS classes directly in components.
86+
Use `{{ attributes }}` or `{{ attributes.defaults({ class: '...' }) }}` in your Twig templates instead.
87+
88+
```yaml
89+
rules:
90+
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenClassPropertyRule
91+
```
92+
93+
```php
94+
// src/Twig/Components/Alert.php
95+
namespace App\Twig\Components;
96+
97+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
98+
99+
#[AsTwigComponent]
100+
final class Alert
101+
{
102+
public $class;
103+
}
104+
```
105+
106+
:x:
107+
108+
<br>
109+
110+
```php
111+
// src/Twig/Components/Alert.php
112+
namespace App\Twig\Components;
113+
114+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
115+
116+
#[AsTwigComponent]
117+
final class Alert
118+
{
119+
}
120+
```
121+
122+
:+1:
123+
124+
<br>

composer.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
}
1111
],
1212
"scripts": {
13+
"qa-fix": [
14+
"@cs-fix",
15+
"@phpstan",
16+
"@test"
17+
],
1318
"phpstan": "vendor/bin/phpstan analyze",
1419
"test": "vendor/bin/phpunit",
1520
"cs": "vendor/bin/ecs check",
@@ -30,7 +35,7 @@
3035
"phpstan/phpstan": "^2.1.13"
3136
},
3237
"require-dev": {
33-
"phpunit/phpunit": "^11.0",
38+
"phpunit/phpunit": "^11.1",
3439
"symfony/ux-twig-component": "^2.0",
3540
"symplify/easy-coding-standard": "^13.0"
3641
},
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\ClassMethod;
12+
use PhpParser\Node\Stmt\Property;
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: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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\Reflection\ReflectionProvider;
12+
use PHPStan\Rules\Rule;
13+
use PHPStan\Rules\RuleErrorBuilder;
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
16+
/**
17+
* @implements Rule<Class_>
18+
*/
19+
final class ForbiddenAttributesPropertyRule implements Rule
20+
{
21+
public function __construct(
22+
private ReflectionProvider $reflectionProvider,
23+
) {
24+
}
25+
26+
public function getNodeType(): string
27+
{
28+
return Class_::class;
29+
}
30+
31+
public function processNode(Node $node, Scope $scope): array
32+
{
33+
if (! $asTwigComponent = AttributeFinder::findAttribute($node, AsTwigComponent::class)) {
34+
return [];
35+
}
36+
37+
if (! $attributesVarName = $this->getAttributesVarName($asTwigComponent)) {
38+
return [];
39+
}
40+
41+
if ($propertyAttributes = $node->getProperty($attributesVarName['name'])) {
42+
return [
43+
RuleErrorBuilder::message(
44+
$attributesVarName['custom']
45+
? 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'])
46+
: sprintf('Using property "%s" in a Twig component is forbidden, it may lead to confusion with the default "attributes" Twig variable.', $attributesVarName['name'])
47+
)
48+
->identifier('SymfonyUX.TwigComponent.forbiddenAttributesProperty')
49+
->line($propertyAttributes->getLine())
50+
->tip('Consider renaming or removing this property to avoid conflicts with the Twig component attributes.')
51+
->build(),
52+
53+
];
54+
}
55+
56+
return [];
57+
}
58+
59+
/**
60+
* @return array{name: string, custom: bool}|null
61+
*/
62+
private function getAttributesVarName(Node\Attribute $attribute): ?array
63+
{
64+
foreach ($attribute->args as $arg) {
65+
if ($arg->name && $arg->name->toString() === 'attributesVar') {
66+
if ($arg->value instanceof Node\Scalar\String_) {
67+
return [
68+
'name' => $arg->value->value,
69+
'custom' => true,
70+
];
71+
}
72+
}
73+
}
74+
75+
$reflAttribute = $this->reflectionProvider->getClass(AsTwigComponent::class);
76+
foreach ($reflAttribute->getConstructor()->getOnlyVariant()->getParameters() as $reflParameter) {
77+
if ($reflParameter->getName() === 'attributesVar' && $reflParameter->getDefaultValue()?->getConstantStrings()) {
78+
return [
79+
'name' => $reflParameter->getDefaultValue()->getConstantStrings()[0]->getValue(),
80+
'custom' => false,
81+
];
82+
}
83+
}
84+
85+
return null;
86+
}
87+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 ForbiddenClassPropertyRule 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 (! AttributeFinder::findAttribute($node, AsTwigComponent::class)) {
28+
return [];
29+
}
30+
31+
if ($propertyClass = $node->getProperty('class')) {
32+
return [
33+
RuleErrorBuilder::message('Using a "class" property in a Twig component is forbidden, it is considered as an anti-pattern.')
34+
->identifier('symfonyUX.twigComponent.forbiddenClassProperty')
35+
->line($propertyClass->getLine())
36+
->tip('Consider using {{ attributes }} to automatically render unknown properties as HTML attributes, such as "class". Learn more at https://symfony.com/bundles/ux-twig-component/current/index.html#component-attributes.')
37+
->build(),
38+
39+
];
40+
}
41+
42+
return [];
43+
}
44+
}
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 string $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 string $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+
}

0 commit comments

Comments
 (0)