diff --git a/README.md b/README.md index 3725c54..981eee1 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,97 @@ final class ShoppingCart
+### LivePropModifierMethodRule + +Enforces that when a `#[LiveProp]` attribute specifies a `modifier` parameter: +- The method must exist in the component class and be declared as public +- The method must have 1 or 2 parameters: + - First parameter: must be of type `LiveProp` + - Second parameter (optional): must be of type `string` +- The method must return a `LiveProp` instance + +This ensures that property modifiers are correctly implemented and can safely transform LiveProp configurations at runtime. + +```yaml +rules: + - Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropModifierMethodRule +``` + +```php +// src/Twig/Components/SearchComponent.php +namespace App\Twig\Components; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveProp; + +#[AsLiveComponent] +final class SearchComponent +{ + #[LiveProp(modifier: 'modifyQueryProp')] + public string $query; + + // Error: Method is not public + private function modifyQueryProp(LiveProp $liveProp): LiveProp + { + return $liveProp; + } + + // Error: Wrong return type + public function modifyOtherProp(LiveProp $liveProp): string + { + return 'test'; + } + + // Error: Wrong first parameter type + public function modifyAnotherProp(string $value): LiveProp + { + return new LiveProp(); + } +} +``` + +:x: + +
+ +```php +// src/Twig/Components/SearchComponent.php +namespace App\Twig\Components; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Mapping\UrlMapping; + +#[AsLiveComponent] +final class SearchComponent +{ + #[LiveProp(modifier: 'modifyQueryProp')] + public string $query; + + #[LiveProp] + public ?string $alias = null; + + // Valid: with two parameters + public function modifyQueryProp(LiveProp $liveProp, string $name): LiveProp + { + if ($this->alias) { + $liveProp = $liveProp->withUrl(new UrlMapping(as: $this->alias)); + } + return $liveProp; + } + + // Valid: with one parameter + public function modifyOtherProp(LiveProp $liveProp): LiveProp + { + return $liveProp->writable(); + } +} +``` + +:+1: + +
+ ## TwigComponent Rules > [!NOTE] diff --git a/src/NodeAnalyzer/AttributeFinder.php b/src/NodeAnalyzer/AttributeFinder.php index 8614ea7..cdb187c 100644 --- a/src/NodeAnalyzer/AttributeFinder.php +++ b/src/NodeAnalyzer/AttributeFinder.php @@ -16,20 +16,6 @@ */ final class AttributeFinder { - /** - * @return Attribute[] - */ - public static function findAttributes(ClassMethod | Property | ClassLike | Param $node): array - { - $attributes = []; - - foreach ($node->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); @@ -68,4 +54,18 @@ public static function findAnyAttribute(ClassMethod | Property | ClassLike | Par return null; } + + /** + * @return Attribute[] + */ + private static function findAttributes(ClassMethod | Property | ClassLike | Param $node): array + { + $attributes = []; + + foreach ($node->attrGroups as $attrGroup) { + $attributes = array_merge($attributes, $attrGroup->attrs); + } + + return $attributes; + } } diff --git a/src/Rules/LiveComponent/LiveActionMethodsShouldBePublicRule.php b/src/Rules/LiveComponent/LiveActionMethodsShouldBePublicRule.php index 6637b8a..be2e5c4 100644 --- a/src/Rules/LiveComponent/LiveActionMethodsShouldBePublicRule.php +++ b/src/Rules/LiveComponent/LiveActionMethodsShouldBePublicRule.php @@ -25,14 +25,14 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - if (! AttributeFinder::findAnyAttribute($node, [AsLiveComponent::class])) { + if (! AttributeFinder::findAttribute($node, AsLiveComponent::class)) { return []; } $errors = []; foreach ($node->getMethods() as $method) { - if (! AttributeFinder::findAnyAttribute($method, [LiveAction::class])) { + if (! AttributeFinder::findAttribute($method, LiveAction::class)) { continue; } diff --git a/src/Rules/LiveComponent/LiveListenerMethodsShouldBePublicRule.php b/src/Rules/LiveComponent/LiveListenerMethodsShouldBePublicRule.php index f545c7b..d5cdffd 100644 --- a/src/Rules/LiveComponent/LiveListenerMethodsShouldBePublicRule.php +++ b/src/Rules/LiveComponent/LiveListenerMethodsShouldBePublicRule.php @@ -25,14 +25,14 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - if (! AttributeFinder::findAnyAttribute($node, [AsLiveComponent::class])) { + if (! AttributeFinder::findAttribute($node, AsLiveComponent::class)) { return []; } $errors = []; foreach ($node->getMethods() as $method) { - if (! AttributeFinder::findAnyAttribute($method, [LiveListener::class])) { + if (! AttributeFinder::findAttribute($method, LiveListener::class)) { continue; } diff --git a/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php b/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php index 161cdad..10d8d6f 100644 --- a/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php +++ b/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php @@ -140,8 +140,8 @@ public function processNode(Node $node, Scope $scope): array $dehydrateMethodRefl = $reflClass->getMethod($dehydrateWith, $scope); // Get AST nodes for line numbers - $hydrateMethodNode = $this->findMethod($node, $hydrateWith); - $dehydrateMethodNode = $this->findMethod($node, $dehydrateWith); + $hydrateMethodNode = $node->getMethod($hydrateWith); + $dehydrateMethodNode = $node->getMethod($dehydrateWith); // Check that methods are public if (! $hydrateMethodRefl->isPublic()) { @@ -298,18 +298,4 @@ private function getArgumentValue(Node\Attribute $attribute, string $argumentNam return null; } - - /** - * Finds a method AST node by name in the given class (used for line numbers). - */ - private function findMethod(Class_ $classNode, string $methodName): ?Node\Stmt\ClassMethod - { - foreach ($classNode->getMethods() as $method) { - if ($method->name->toString() === $methodName) { - return $method; - } - } - - return null; - } } diff --git a/src/Rules/LiveComponent/LivePropModifierMethodRule.php b/src/Rules/LiveComponent/LivePropModifierMethodRule.php new file mode 100644 index 0000000..211a5cd --- /dev/null +++ b/src/Rules/LiveComponent/LivePropModifierMethodRule.php @@ -0,0 +1,197 @@ + + */ +final class LivePropModifierMethodRule implements Rule +{ + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + + public function getNodeType(): string + { + return Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (! AttributeFinder::findAttribute($node, AsLiveComponent::class)) { + return []; + } + + if ($node->namespacedName === null) { + return []; + } + + $errors = []; + $reflClass = $this->reflectionProvider->getClass($node->namespacedName->toString()); + + foreach ($node->getProperties() as $property) { + $livePropAttribute = AttributeFinder::findAttribute($property, LiveProp::class); + if (! $livePropAttribute) { + continue; + } + + // Extract modifier method name from the attribute + $modifier = $this->getArgumentValue($livePropAttribute, 'modifier'); + + // Skip if modifier argument is not defined + if ($modifier === null) { + continue; + } + + $propertyName = $property->props[0]->name->toString(); + + // Check if the modifier method exists + if (! $reflClass->hasMethod($modifier)) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Property "%s" references non-existent modifier method "%s()".', + $propertyName, + $modifier + ) + ) + ->identifier('symfonyUX.liveComponent.livePropModifierMethodMustExist') + ->line($property->getLine()) + ->tip(sprintf('Create the public method "%s()" in the component class.', $modifier)) + ->build(); + + continue; + } + + // Get method reflection + $modifierMethodRefl = $reflClass->getMethod($modifier, $scope); + + // Get AST node for line number + $modifierMethodNode = $node->getMethod($modifier); + + // Check that method is public + if (! $modifierMethodRefl->isPublic()) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Modifier method "%s()" referenced in property "%s" must be public.', + $modifier, + $propertyName + ) + ) + ->identifier('symfonyUX.liveComponent.livePropModifierMethodMustBePublic') + ->line($modifierMethodNode?->getLine() ?? $property->getLine()) + ->tip(sprintf('Make the method "%s()" public.', $modifier)) + ->build(); + } + + // Get method signature + $modifierVariant = $modifierMethodRefl->getOnlyVariant(); + $modifierParams = $modifierVariant->getParameters(); + $modifierReturnType = $modifierVariant->getReturnType(); + + // Check that modifier method has 1 or 2 parameters + $paramCount = count($modifierParams); + if ($paramCount < 1 || $paramCount > 2) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Modifier method "%s()" must have 1 or 2 parameters (LiveProp and optionally string).', + $modifier + ) + ) + ->identifier('symfonyUX.liveComponent.livePropModifierMethodParameterCount') + ->line($modifierMethodNode?->getLine() ?? $property->getLine()) + ->tip('The modifier method should have a LiveProp parameter and optionally a string parameter.') + ->build(); + } else { + // Check first parameter is LiveProp + $firstParamType = $modifierParams[0]->getType(); + $expectedLivePropType = new ObjectType(LiveProp::class); + + if (! $expectedLivePropType->isSuperTypeOf($firstParamType)->yes()) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Modifier method "%s()" first parameter must be of type "%s", got "%s".', + $modifier, + LiveProp::class, + $firstParamType->describe(VerbosityLevel::typeOnly()) + ) + ) + ->identifier('symfonyUX.liveComponent.livePropModifierMethodFirstParameterType') + ->line($modifierMethodNode?->getLine() ?? $property->getLine()) + ->tip(sprintf('Change the first parameter type to "%s".', LiveProp::class)) + ->build(); + } + + // Check second parameter is string if it exists + if ($paramCount === 2) { + $secondParamType = $modifierParams[1]->getType(); + $expectedStringType = new StringType(); + + if (! $expectedStringType->isSuperTypeOf($secondParamType)->yes()) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Modifier method "%s()" second parameter must be of type "string", got "%s".', + $modifier, + $secondParamType->describe(VerbosityLevel::typeOnly()) + ) + ) + ->identifier('symfonyUX.liveComponent.livePropModifierMethodSecondParameterType') + ->line($modifierMethodNode?->getLine() ?? $property->getLine()) + ->tip('Change the second parameter type to "string".') + ->build(); + } + } + } + + // Check that modifier method returns LiveProp + $expectedLivePropType = new ObjectType(LiveProp::class); + if (! $expectedLivePropType->isSuperTypeOf($modifierReturnType)->yes()) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Modifier method "%s()" must return "%s", got "%s".', + $modifier, + LiveProp::class, + $modifierReturnType->describe(VerbosityLevel::typeOnly()) + ) + ) + ->identifier('symfonyUX.liveComponent.livePropModifierMethodReturnType') + ->line($modifierMethodNode?->getLine() ?? $property->getLine()) + ->tip(sprintf('Change the return type to ": %s".', LiveProp::class)) + ->build(); + } + } + + return $errors; + } + + /** + * Extracts the value of a named argument from a LiveProp attribute. + */ + private function getArgumentValue(Node\Attribute $attribute, string $argumentName): ?string + { + foreach ($attribute->args as $arg) { + if ($arg->name && $arg->name->toString() === $argumentName) { + if ($arg->value instanceof Node\Scalar\String_) { + return $arg->value->value; + } + } + } + + return null; + } +} diff --git a/tests/Rules/LiveComponent/LivePropModifierMethodRule/Fixture/ModifierMethodNotFound.php b/tests/Rules/LiveComponent/LivePropModifierMethodRule/Fixture/ModifierMethodNotFound.php new file mode 100644 index 0000000..393a1a5 --- /dev/null +++ b/tests/Rules/LiveComponent/LivePropModifierMethodRule/Fixture/ModifierMethodNotFound.php @@ -0,0 +1,15 @@ + + */ +final class LivePropModifierMethodRuleTest extends RuleTestCase +{ + public function testModifierMethodNotFound(): void + { + $this->analyse( + [__DIR__ . '/Fixture/ModifierMethodNotFound.php'], + [ + [ + 'Property "query" references non-existent modifier method "nonExistentMethod()".', + 13, + 'Create the public method "nonExistentMethod()" in the component class.', + ], + ] + ); + } + + public function testModifierMethodNotPublic(): void + { + $this->analyse( + [__DIR__ . '/Fixture/ModifierMethodNotPublic.php'], + [ + [ + 'Modifier method "modifyQueryProp()" referenced in property "query" must be public.', + 16, + 'Make the method "modifyQueryProp()" public.', + ], + ] + ); + } + + public function testModifierMethodWrongParameterCount(): void + { + $this->analyse( + [__DIR__ . '/Fixture/ModifierMethodWrongParameterCount.php'], + [ + [ + 'Modifier method "modifyQueryProp()" must have 1 or 2 parameters (LiveProp and optionally string).', + 16, + 'The modifier method should have a LiveProp parameter and optionally a string parameter.', + ], + ] + ); + } + + public function testModifierMethodWrongFirstParameterType(): void + { + $this->analyse( + [__DIR__ . '/Fixture/ModifierMethodWrongFirstParameterType.php'], + [ + [ + 'Modifier method "modifyQueryProp()" first parameter must be of type "Symfony\UX\LiveComponent\Attribute\LiveProp", got "string".', + 16, + 'Change the first parameter type to "Symfony\UX\LiveComponent\Attribute\LiveProp".', + ], + ] + ); + } + + public function testModifierMethodWrongSecondParameterType(): void + { + $this->analyse( + [__DIR__ . '/Fixture/ModifierMethodWrongSecondParameterType.php'], + [ + [ + 'Modifier method "modifyQueryProp()" second parameter must be of type "string", got "int".', + 16, + 'Change the second parameter type to "string".', + ], + ] + ); + } + + public function testModifierMethodWrongReturnType(): void + { + $this->analyse( + [__DIR__ . '/Fixture/ModifierMethodWrongReturnType.php'], + [ + [ + 'Modifier method "modifyQueryProp()" must return "Symfony\UX\LiveComponent\Attribute\LiveProp", got "string".', + 16, + 'Change the return type to ": Symfony\UX\LiveComponent\Attribute\LiveProp".', + ], + ] + ); + } + + public function testNoViolations(): void + { + $this->analyse( + [__DIR__ . '/Fixture/NotAComponent.php'], + [] + ); + + $this->analyse( + [__DIR__ . '/Fixture/ValidModifier.php'], + [] + ); + + $this->analyse( + [__DIR__ . '/Fixture/ValidModifierWithOneParameter.php'], + [] + ); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/config/configured_rule.neon']; + } + + protected function getRule(): Rule + { + return self::getContainer()->getByType(LivePropModifierMethodRule::class); + } +} diff --git a/tests/Rules/LiveComponent/LivePropModifierMethodRule/config/configured_rule.neon b/tests/Rules/LiveComponent/LivePropModifierMethodRule/config/configured_rule.neon new file mode 100644 index 0000000..7899306 --- /dev/null +++ b/tests/Rules/LiveComponent/LivePropModifierMethodRule/config/configured_rule.neon @@ -0,0 +1,2 @@ +rules: + - Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropModifierMethodRule