diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index bea5c27..86bfa10 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -56,15 +56,12 @@ jobs:
name: Tests
runs-on: ubuntu-latest
strategy:
+ fail-fast: false
matrix:
php: [ '8.2', '8.3', '8.4', '8.5' ]
composer-dependency-version: ['']
composer-minimum-stability: ['stable']
include:
- # Lowest dependencies on minimum supported PHP version
- - php: '8.2'
- composer-dependency-version: 'lowest'
-
# Highest dev dependencies
- php: '8.5'
composer-minimum-stability: 'dev'
@@ -83,7 +80,7 @@ jobs:
run: symfony composer config minimum-stability ${{ matrix.composer-minimum-stability }}
- name: Install Composer dependencies
- run: symfony composer update --prefer-dist --no-interaction --no-progress ${{ matrix.composer-dependency-version == 'lowest' && '--prefer-lowest' || '' }}
+ run: symfony composer update --prefer-dist --no-interaction --no-progress
- name: Run PHPUnit tests
run: symfony composer run test
diff --git a/README.md b/README.md
index 1ddb5d0..0221114 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,115 @@ To install the PHPStan rules for Symfony UX, you can use Composer:
composer require --dev kocal/phpstan-symfony-ux
```
-## Configuration
+## TwigComponent Rules
-TODO
\ No newline at end of file
+### ForbiddenAttributesPropertyRule
+
+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.
+
+```yaml
+rules:
+ - Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenAttributesPropertyRule
+```
+
+```php
+// src/Twig/Components/Alert.php
+namespace App\Twig\Components;
+
+use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+
+#[AsTwigComponent]
+final class Alert
+{
+ public $attributes;
+}
+```
+
+```php
+// src/Twig/Components/Alert.php
+namespace App\Twig\Components;
+
+use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+
+#[AsTwigComponent(attributesVar: 'customAttributes')]
+final class Alert
+{
+ public $customAttributes;
+}
+```
+
+:x:
+
+
+
+```php
+// src/Twig/Components/Alert.php
+namespace App\Twig\Components;
+
+use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+
+#[AsTwigComponent]
+final class Alert
+{
+}
+```
+
+```php
+// src/Twig/Components/Alert.php
+namespace App\Twig\Components;
+
+use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+
+#[AsTwigComponent]
+final class Alert
+{
+ public $customAttributes;
+}
+```
+
+:+1:
+
+
+
+### ForbiddenClassPropertyRule
+
+Forbid the use of the `$class` property in Twig Components, as it is considered a bad practice to manipulate CSS classes directly in components.
+Use `{{ attributes }}` or `{{ attributes.defaults({ class: '...' }) }}` in your Twig templates instead.
+
+```yaml
+rules:
+ - Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenClassPropertyRule
+```
+
+```php
+// src/Twig/Components/Alert.php
+namespace App\Twig\Components;
+
+use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+
+#[AsTwigComponent]
+final class Alert
+{
+ public $class;
+}
+```
+
+:x:
+
+
+
+```php
+// src/Twig/Components/Alert.php
+namespace App\Twig\Components;
+
+use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+
+#[AsTwigComponent]
+final class Alert
+{
+}
+```
+
+:+1:
+
+
diff --git a/composer.json b/composer.json
index 73c5a38..dca6916 100644
--- a/composer.json
+++ b/composer.json
@@ -10,6 +10,11 @@
}
],
"scripts": {
+ "qa-fix": [
+ "@cs-fix",
+ "@phpstan",
+ "@test"
+ ],
"phpstan": "vendor/bin/phpstan analyze",
"test": "vendor/bin/phpunit",
"cs": "vendor/bin/ecs check",
@@ -30,7 +35,7 @@
"phpstan/phpstan": "^2.1.13"
},
"require-dev": {
- "phpunit/phpunit": "^11.0",
+ "phpunit/phpunit": "^11.1",
"symfony/ux-twig-component": "^2.0",
"symplify/easy-coding-standard": "^13.0"
},
diff --git a/src/NodeAnalyzer/AttributeFinder.php b/src/NodeAnalyzer/AttributeFinder.php
new file mode 100644
index 0000000..3329697
--- /dev/null
+++ b/src/NodeAnalyzer/AttributeFinder.php
@@ -0,0 +1,49 @@
+attrGroups as $attrGroup) {
+ $attributes = array_merge($attributes, $attrGroup->attrs);
+ }
+
+ return $attributes;
+ }
+
+ public static function findAttribute(ClassMethod | Property | ClassLike | Param $node, string $desiredAttributeClass): ?Attribute
+ {
+ $attributes = self::findAttributes($node);
+
+ foreach ($attributes as $attribute) {
+ if (! $attribute->name instanceof FullyQualified) {
+ continue;
+ }
+
+ if ($attribute->name->toString() === $desiredAttributeClass) {
+ return $attribute;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Rules/TwigComponent/ForbiddenAttributesPropertyRule.php b/src/Rules/TwigComponent/ForbiddenAttributesPropertyRule.php
new file mode 100644
index 0000000..25f9a44
--- /dev/null
+++ b/src/Rules/TwigComponent/ForbiddenAttributesPropertyRule.php
@@ -0,0 +1,87 @@
+
+ */
+final class ForbiddenAttributesPropertyRule implements Rule
+{
+ public function __construct(
+ private ReflectionProvider $reflectionProvider,
+ ) {
+ }
+
+ public function getNodeType(): string
+ {
+ return Class_::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (! $asTwigComponent = AttributeFinder::findAttribute($node, AsTwigComponent::class)) {
+ return [];
+ }
+
+ if (! $attributesVarName = $this->getAttributesVarName($asTwigComponent)) {
+ return [];
+ }
+
+ if ($propertyAttributes = $node->getProperty($attributesVarName['name'])) {
+ return [
+ RuleErrorBuilder::message(
+ $attributesVarName['custom']
+ ? 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'])
+ : sprintf('Using property "%s" in a Twig component is forbidden, it may lead to confusion with the default "attributes" Twig variable.', $attributesVarName['name'])
+ )
+ ->identifier('SymfonyUX.TwigComponent.forbiddenAttributesProperty')
+ ->line($propertyAttributes->getLine())
+ ->tip('Consider renaming or removing this property to avoid conflicts with the Twig component attributes.')
+ ->build(),
+
+ ];
+ }
+
+ return [];
+ }
+
+ /**
+ * @return array{name: string, custom: bool}|null
+ */
+ private function getAttributesVarName(Node\Attribute $attribute): ?array
+ {
+ foreach ($attribute->args as $arg) {
+ if ($arg->name && $arg->name->toString() === 'attributesVar') {
+ if ($arg->value instanceof Node\Scalar\String_) {
+ return [
+ 'name' => $arg->value->value,
+ 'custom' => true,
+ ];
+ }
+ }
+ }
+
+ $reflAttribute = $this->reflectionProvider->getClass(AsTwigComponent::class);
+ foreach ($reflAttribute->getConstructor()->getOnlyVariant()->getParameters() as $reflParameter) {
+ if ($reflParameter->getName() === 'attributesVar' && $reflParameter->getDefaultValue()?->getConstantStrings()) {
+ return [
+ 'name' => $reflParameter->getDefaultValue()->getConstantStrings()[0]->getValue(),
+ 'custom' => false,
+ ];
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Rules/TwigComponent/ForbiddenClassPropertyRule.php b/src/Rules/TwigComponent/ForbiddenClassPropertyRule.php
new file mode 100644
index 0000000..810e5ec
--- /dev/null
+++ b/src/Rules/TwigComponent/ForbiddenClassPropertyRule.php
@@ -0,0 +1,44 @@
+
+ */
+final class ForbiddenClassPropertyRule implements Rule
+{
+ public function getNodeType(): string
+ {
+ return Class_::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (! AttributeFinder::findAttribute($node, AsTwigComponent::class)) {
+ return [];
+ }
+
+ if ($propertyClass = $node->getProperty('class')) {
+ return [
+ RuleErrorBuilder::message('Using a "class" property in a Twig component is forbidden, it is considered as an anti-pattern.')
+ ->identifier('symfonyUX.twigComponent.forbiddenClassProperty')
+ ->line($propertyClass->getLine())
+ ->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.')
+ ->build(),
+
+ ];
+ }
+
+ return [];
+ }
+}
diff --git a/tests/Rules/TwigComponent/ForbiddenAttributesPropertyRule/Fixture/ComponentWithAttributesProperty.php b/tests/Rules/TwigComponent/ForbiddenAttributesPropertyRule/Fixture/ComponentWithAttributesProperty.php
new file mode 100644
index 0000000..3fe535f
--- /dev/null
+++ b/tests/Rules/TwigComponent/ForbiddenAttributesPropertyRule/Fixture/ComponentWithAttributesProperty.php
@@ -0,0 +1,13 @@
+
+ */
+ public array $attributes;
+}
diff --git a/tests/Rules/TwigComponent/ForbiddenAttributesPropertyRule/ForbiddenAttributesPropertyRuleTest.php b/tests/Rules/TwigComponent/ForbiddenAttributesPropertyRule/ForbiddenAttributesPropertyRuleTest.php
new file mode 100644
index 0000000..ded3931
--- /dev/null
+++ b/tests/Rules/TwigComponent/ForbiddenAttributesPropertyRule/ForbiddenAttributesPropertyRuleTest.php
@@ -0,0 +1,63 @@
+
+ */
+final class ForbiddenAttributesPropertyRuleTest extends RuleTestCase
+{
+ public function testViolations(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/ComponentWithAttributesProperty.php'],
+ [
+ [
+ 'Using property "attributes" in a Twig component is forbidden, it may lead to confusion with the default "attributes" Twig variable.',
+ 12,
+ 'Consider renaming or removing this property to avoid conflicts with the Twig component attributes.',
+ ],
+ ]
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/ComponentWithCustomAttributesProperty.php'],
+ [
+ [
+ 'Using property "customAttributes" in a Twig component is forbidden, it may lead to confusion with the "customAttributes" attribute defined in #[AsTwigComponent].',
+ 12,
+ 'Consider renaming or removing this property to avoid conflicts with the Twig component attributes.',
+ ],
+ ]
+ );
+ }
+
+ public function testNoViolations(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/NotAComponent.php'],
+ []
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/ComponentWithNoAttributesProperty.php'],
+ []
+ );
+ }
+
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/configured_rule.neon'];
+ }
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(ForbiddenAttributesPropertyRule::class);
+ }
+}
diff --git a/tests/Rules/TwigComponent/ForbiddenAttributesPropertyRule/config/configured_rule.neon b/tests/Rules/TwigComponent/ForbiddenAttributesPropertyRule/config/configured_rule.neon
new file mode 100644
index 0000000..be4a372
--- /dev/null
+++ b/tests/Rules/TwigComponent/ForbiddenAttributesPropertyRule/config/configured_rule.neon
@@ -0,0 +1,2 @@
+rules:
+ - Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenAttributesPropertyRule
diff --git a/tests/Rules/TwigComponent/ForbiddenClassPropertyRule/Fixture/ComponentWithClassProperty.php b/tests/Rules/TwigComponent/ForbiddenClassPropertyRule/Fixture/ComponentWithClassProperty.php
new file mode 100644
index 0000000..59ba5da
--- /dev/null
+++ b/tests/Rules/TwigComponent/ForbiddenClassPropertyRule/Fixture/ComponentWithClassProperty.php
@@ -0,0 +1,13 @@
+
+ */
+final class ForbiddenClassPropertyRuleTest extends RuleTestCase
+{
+ public function testViolations(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/ComponentWithClassProperty.php'],
+ [
+ [
+ 'Using a "class" property in a Twig component is forbidden, it is considered as an anti-pattern.',
+ 12,
+ '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.',
+ ],
+ ]
+ );
+ }
+
+ public function testNoViolations(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/NotAComponent.php'],
+ []
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/ComponentWithNoClassProperty.php'],
+ []
+ );
+ }
+
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/configured_rule.neon'];
+ }
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(ForbiddenClassPropertyRule::class);
+ }
+}
diff --git a/tests/Rules/TwigComponent/ForbiddenClassPropertyRule/config/configured_rule.neon b/tests/Rules/TwigComponent/ForbiddenClassPropertyRule/config/configured_rule.neon
new file mode 100644
index 0000000..b719d97
--- /dev/null
+++ b/tests/Rules/TwigComponent/ForbiddenClassPropertyRule/config/configured_rule.neon
@@ -0,0 +1,2 @@
+rules:
+ - Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenClassPropertyRule