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