diff --git a/README.md b/README.md index 0b41cec..5e8ffc3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,63 @@ Each rule can be enabled individually by adding it to your `phpstan.dist.neon` c > [!NOTE] > All these rules also apply to LiveComponents (classes annotated with `#[AsLiveComponent]`). +### ClassMustBeFinalRule + +Enforces that all Twig Component classes must be declared as `final`. +This prevents inheritance and promotes composition via traits, ensuring better code maintainability and avoiding tight coupling between components. + +```yaml +rules: + - Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ClassMustBeFinalRule +``` + +```php +// src/Twig/Components/Alert.php +namespace App\Twig\Components; + +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; + +#[AsTwigComponent] +class Alert +{ + public string $message; +} +``` + +```php +// src/Twig/Components/Alert.php +namespace App\Twig\Components; + +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; + +#[AsTwigComponent] +abstract class Alert +{ + public string $message; +} +``` + +:x: + +
+ +```php +// src/Twig/Components/Alert.php +namespace App\Twig\Components; + +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; + +#[AsTwigComponent] +final class Alert +{ + public string $message; +} +``` + +:+1: + +
+ ### ClassNameShouldNotEndWithComponentRule Forbid Twig Component class names from ending with "Component" suffix, as it creates redundancy since the class is already identified as a component through the `#[AsTwigComponent]` attribute. @@ -230,76 +287,6 @@ final class Alert
-### ForbiddenInheritanceRule - -Forbids the use of class inheritance in Twig Components. Composition via traits should be used instead. -This promotes better code reusability and avoids tight coupling between components. - -> [!TIP] -> Another alternative is to use [Class Variant Authority](https://symfony.com/bundles/ux-twig-component/current/index.html#component-with-complex-variants-cva) to create variations of a base component without inheritance or traits, -> for example `` instead of ``. - -```yaml -rules: - - Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenInheritanceRule -``` - -```php -// src/Twig/Components/Alert.php -namespace App\Twig\Components; - -use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; - -abstract class BaseComponent -{ - public string $name; -} - -#[AsTwigComponent] -class Alert extends BaseComponent -{ -} -``` - -:x: - -
- -```php -// src/Twig/Components/Alert.php -namespace App\Twig\Components; - -use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; - -trait CommonComponentTrait -{ - public string $name; -} - -#[AsTwigComponent] -final class Alert -{ - use CommonComponentTrait; -} -``` - -```php -// src/Twig/Components/Alert.php -namespace App\Twig\Components; - -use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; - -#[AsTwigComponent] -final class Alert -{ - public string $name; -} -``` - -:+1: - -
- ### MethodsShouldBePublicOrPrivateRule Enforces that all methods in Twig Components are either public or private, but not protected. diff --git a/src/Rules/TwigComponent/ForbiddenInheritanceRule.php b/src/Rules/TwigComponent/ClassMustBeFinalRule.php similarity index 53% rename from src/Rules/TwigComponent/ForbiddenInheritanceRule.php rename to src/Rules/TwigComponent/ClassMustBeFinalRule.php index c3529d3..ad89630 100644 --- a/src/Rules/TwigComponent/ForbiddenInheritanceRule.php +++ b/src/Rules/TwigComponent/ClassMustBeFinalRule.php @@ -16,7 +16,7 @@ /** * @implements Rule */ -final class ForbiddenInheritanceRule implements Rule +final class ClassMustBeFinalRule implements Rule { public function getNodeType(): string { @@ -29,12 +29,20 @@ public function processNode(Node $node, Scope $scope): array return []; } - if ($node->extends !== null) { + if ($node->isAbstract()) { return [ - RuleErrorBuilder::message('Using class inheritance in a Twig component is forbidden, use traits for composition instead.') - ->identifier('symfonyUX.twigComponent.forbiddenClassInheritance') - ->line($node->extends->getLine()) - ->tip('Consider using traits to share common functionality between Twig components.') + RuleErrorBuilder::message('Twig component class must be final, not abstract.') + ->identifier('symfonyUX.twigComponent.classMustBeFinal') + ->tip('Make the class final and use traits for composition instead of inheritance.') + ->build(), + ]; + } + + if (! $node->isFinal()) { + return [ + RuleErrorBuilder::message('Twig component class must be final.') + ->identifier('symfonyUX.twigComponent.classMustBeFinal') + ->tip('Add the "final" keyword to the class declaration to prevent inheritance.') ->build(), ]; } diff --git a/tests/Rules/TwigComponent/ClassMustBeFinalRule/ClassMustBeFinalRuleTest.php b/tests/Rules/TwigComponent/ClassMustBeFinalRule/ClassMustBeFinalRuleTest.php new file mode 100644 index 0000000..fe7821f --- /dev/null +++ b/tests/Rules/TwigComponent/ClassMustBeFinalRule/ClassMustBeFinalRuleTest.php @@ -0,0 +1,90 @@ + + */ +final class ClassMustBeFinalRuleTest extends RuleTestCase +{ + public function testViolations(): void + { + $this->analyse( + [__DIR__ . '/Fixture/InvalidNonFinalTwigComponent.php'], + [ + [ + 'Twig component class must be final.', + 9, + 'Add the "final" keyword to the class declaration to prevent inheritance.', + ], + ] + ); + + $this->analyse( + [__DIR__ . '/Fixture/InvalidNonFinalLiveComponent.php'], + [ + [ + 'Twig component class must be final.', + 9, + 'Add the "final" keyword to the class declaration to prevent inheritance.', + ], + ] + ); + + $this->analyse( + [__DIR__ . '/Fixture/InvalidAbstractTwigComponent.php'], + [ + [ + 'Twig component class must be final, not abstract.', + 9, + 'Make the class final and use traits for composition instead of inheritance.', + ], + ] + ); + + $this->analyse( + [__DIR__ . '/Fixture/InvalidAbstractLiveComponent.php'], + [ + [ + 'Twig component class must be final, not abstract.', + 9, + 'Make the class final and use traits for composition instead of inheritance.', + ], + ] + ); + } + + public function testNoViolations(): void + { + $this->analyse( + [__DIR__ . '/Fixture/NotAComponent.php'], + [] + ); + + $this->analyse( + [__DIR__ . '/Fixture/ValidTwigComponent.php'], + [] + ); + + $this->analyse( + [__DIR__ . '/Fixture/ValidLiveComponent.php'], + [] + ); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/config/configured_rule.neon']; + } + + protected function getRule(): Rule + { + return self::getContainer()->getByType(ClassMustBeFinalRule::class); + } +} diff --git a/tests/Rules/TwigComponent/ClassMustBeFinalRule/Fixture/InvalidAbstractLiveComponent.php b/tests/Rules/TwigComponent/ClassMustBeFinalRule/Fixture/InvalidAbstractLiveComponent.php new file mode 100644 index 0000000..4c0c6fe --- /dev/null +++ b/tests/Rules/TwigComponent/ClassMustBeFinalRule/Fixture/InvalidAbstractLiveComponent.php @@ -0,0 +1,12 @@ + - */ -final class ForbiddenInheritanceRuleTest extends RuleTestCase -{ - public function testViolations(): void - { - $this->analyse( - [__DIR__ . '/Fixture/ComponentWithInheritance.php'], - [ - [ - 'Using class inheritance in a Twig component is forbidden, use traits for composition instead.', - 15, - 'Consider using traits to share common functionality between Twig components.', - ], - ] - ); - - $this->analyse( - [__DIR__ . '/Fixture/LiveComponentWithInheritance.php'], - [ - [ - 'Using class inheritance in a Twig component is forbidden, use traits for composition instead.', - 15, - 'Consider using traits to share common functionality between Twig components.', - ], - ] - ); - } - - public function testNoViolations(): void - { - $this->analyse( - [__DIR__ . '/Fixture/NotAComponent.php'], - [] - ); - - $this->analyse( - [__DIR__ . '/Fixture/ComponentWithoutInheritance.php'], - [] - ); - - $this->analyse( - [__DIR__ . '/Fixture/ComponentUsingTrait.php'], - [] - ); - - $this->analyse( - [__DIR__ . '/Fixture/LiveComponentWithoutInheritance.php'], - [] - ); - } - - public static function getAdditionalConfigFiles(): array - { - return [__DIR__ . '/config/configured_rule.neon']; - } - - protected function getRule(): Rule - { - return self::getContainer()->getByType(ForbiddenInheritanceRule::class); - } -} diff --git a/tests/Rules/TwigComponent/ForbiddenInheritanceRule/config/configured_rule.neon b/tests/Rules/TwigComponent/ForbiddenInheritanceRule/config/configured_rule.neon deleted file mode 100644 index f43631d..0000000 --- a/tests/Rules/TwigComponent/ForbiddenInheritanceRule/config/configured_rule.neon +++ /dev/null @@ -1,2 +0,0 @@ -rules: - - Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenInheritanceRule