diff --git a/README.md b/README.md index 444a612..3725c54 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,136 @@ final class Notification
+### LivePropHydrationMethodsRule + +Enforces that when a `#[LiveProp]` attribute specifies `hydrateWith` and `dehydrateWith` parameters: +- Both parameters must be specified together +- Both methods must exist in the component class and be declared as public +- The types must be compatible throughout the hydration/dehydration cycle: + - The property must have a type declaration + - The hydrate method must return the same type as the property + - The dehydrate method must accept the same type as the property as its first parameter + - The dehydrate method's return type must match the hydrate method's parameter type + +This ensures data flows correctly between frontend and backend representations. + +```yaml +rules: + - Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropHydrationMethodsRule +``` + +```php +// src/Twig/Components/ProductList.php +namespace App\Twig\Components; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveProp; + +#[AsLiveComponent] +final class ProductList +{ + // Error: Missing dehydrateWith parameter + #[LiveProp(hydrateWith: 'hydrateFilters')] + public array $filters; +} +``` + +```php +// src/Twig/Components/ProductList.php +namespace App\Twig\Components; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveProp; + +#[AsLiveComponent] +final class ProductList +{ + #[LiveProp(hydrateWith: 'hydrateFilters', dehydrateWith: 'dehydrateFilters')] + public array $filters; + + // Error: Methods are private/protected instead of public + private function hydrateFilters(array $data): array + { + return $data; + } + + protected function dehydrateFilters(array $data): array + { + return $data; + } +} +``` + +```php +// src/Twig/Components/ShoppingCart.php +namespace App\Twig\Components; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveProp; + +class Product +{ + public function __construct(public string $name, public float $price) {} +} + +#[AsLiveComponent] +final class ShoppingCart +{ + #[LiveProp(hydrateWith: 'hydrateProduct', dehydrateWith: 'dehydrateProduct')] + public Product $product; + + // Error: Return type doesn't match property type + public function hydrateProduct(array $data): array + { + return $data; + } + + // Error: Parameter type doesn't match property type + public function dehydrateProduct(string $product): array + { + return []; + } +} +``` + +:x: + +
+ +```php +// src/Twig/Components/ShoppingCart.php +namespace App\Twig\Components; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveProp; + +class Product +{ + public function __construct(public string $name, public float $price) {} +} + +#[AsLiveComponent] +final class ShoppingCart +{ + #[LiveProp(hydrateWith: 'hydrateProduct', dehydrateWith: 'dehydrateProduct')] + public Product $product; + + public function hydrateProduct(array $data): Product + { + return new Product($data['name'], $data['price']); + } + + public function dehydrateProduct(Product $product): array + { + return ['name' => $product->name, 'price' => $product->price]; + } +} +``` + +:+1: + +
+ ## TwigComponent Rules > [!NOTE] diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 053d645..3e7842e 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -10,6 +10,8 @@ parameters: paths: - tests/**/Fixture/* identifiers: + - argument.type - method.unused - missingType.iterableValue + - missingType.property - property.unused diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 6aa26c0..aa0f6ad 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -13,6 +13,7 @@ + diff --git a/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php b/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php new file mode 100644 index 0000000..161cdad --- /dev/null +++ b/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php @@ -0,0 +1,315 @@ + + */ +final class LivePropHydrationMethodsRule 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 hydration method names from the attribute + $hydrateWith = $this->getArgumentValue($livePropAttribute, 'hydrateWith'); + $dehydrateWith = $this->getArgumentValue($livePropAttribute, 'dehydrateWith'); + + // Skip if both arguments are not defined + if ($hydrateWith === null && $dehydrateWith === null) { + continue; + } + + // TODO: Is there a best way to do this? + $propertyName = $property->props[0]->name->toString(); + + // Ensure that "dehydrateWith" is specified when "hydrateWith" is specified + if ($hydrateWith !== null && $dehydrateWith === null) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Property "%s" has a #[LiveProp] attribute with "hydrateWith" but is missing "dehydrateWith".', + $propertyName + ) + ) + ->identifier('symfonyUX.liveComponent.livePropHydrationMethodsMustBothExist') + ->line($property->getLine()) + ->tip('Both "hydrateWith" and "dehydrateWith" must be specified together in the #[LiveProp] attribute.') + ->build(); + + continue; + } + + // Ensure that "hydrateWith" is specified when "dehydrateWith" is specified + if ($dehydrateWith !== null && $hydrateWith === null) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Property "%s" has a #[LiveProp] attribute with "dehydrateWith" but is missing "hydrateWith".', + $propertyName + ) + ) + ->identifier('symfonyUX.liveComponent.livePropHydrationMethodsMustBothExist') + ->line($property->getLine()) + ->tip('Both "hydrateWith" and "dehydrateWith" must be specified together in the #[LiveProp] attribute.') + ->build(); + + continue; + } + + // At this point, both $hydrateWith and $dehydrateWith are guaranteed to be non-null (validated above) + assert($hydrateWith !== null && $dehydrateWith !== null); + + // Get method reflections using PHPStan's reflection system + $hydrateMethodExists = $reflClass->hasMethod($hydrateWith); + $dehydrateMethodExists = $reflClass->hasMethod($dehydrateWith); + + // Validate that hydrate method exists + if (! $hydrateMethodExists) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Property "%s" references non-existent hydrate method "%s()".', + $propertyName, + $hydrateWith + ) + ) + ->identifier('symfonyUX.liveComponent.livePropHydrationMethodMustExist') + ->line($property->getLine()) + ->tip(sprintf('Create the public method "%s()" in the component class.', $hydrateWith)) + ->build(); + } + + // Validate that dehydrate method exists + if (! $dehydrateMethodExists) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Property "%s" references non-existent dehydrate method "%s()".', + $propertyName, + $dehydrateWith + ) + ) + ->identifier('symfonyUX.liveComponent.livePropDehydrationMethodMustExist') + ->line($property->getLine()) + ->tip(sprintf('Create the public method "%s()" in the component class.', $dehydrateWith)) + ->build(); + } + + // Skip further validation if methods don't exist + if (! $hydrateMethodExists || ! $dehydrateMethodExists) { + continue; + } + + // Get reflection of both methods + $hydrateMethodRefl = $reflClass->getMethod($hydrateWith, $scope); + $dehydrateMethodRefl = $reflClass->getMethod($dehydrateWith, $scope); + + // Get AST nodes for line numbers + $hydrateMethodNode = $this->findMethod($node, $hydrateWith); + $dehydrateMethodNode = $this->findMethod($node, $dehydrateWith); + + // Check that methods are public + if (! $hydrateMethodRefl->isPublic()) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Hydrate method "%s()" referenced in property "%s" must be public.', + $hydrateWith, + $propertyName + ) + ) + ->identifier('symfonyUX.liveComponent.livePropHydrationMethodMustBePublic') + ->line($hydrateMethodNode?->getLine() ?? $property->getLine()) + ->tip(sprintf('Make the method "%s()" public.', $hydrateWith)) + ->build(); + } + + if (! $dehydrateMethodRefl->isPublic()) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Dehydrate method "%s()" referenced in property "%s" must be public.', + $dehydrateWith, + $propertyName + ) + ) + ->identifier('symfonyUX.liveComponent.livePropDehydrationMethodMustBePublic') + ->line($dehydrateMethodNode?->getLine() ?? $property->getLine()) + ->tip(sprintf('Make the method "%s()" public.', $dehydrateWith)) + ->build(); + } + + // Check that methods have compatible types using PHPStan's type system + // Get the property type from reflection + if (! $reflClass->hasProperty($propertyName)) { + continue; + } + + $propertyRefl = $reflClass->getProperty($propertyName, $scope); + $propertyType = $propertyRefl->getReadableType(); + + // Get method signatures + $hydrateVariant = $hydrateMethodRefl->getOnlyVariant(); + $dehydrateVariant = $dehydrateMethodRefl->getOnlyVariant(); + + $hydrateParams = $hydrateVariant->getParameters(); + $hydrateReturnType = $hydrateVariant->getReturnType(); + + $dehydrateParams = $dehydrateVariant->getParameters(); + $dehydrateReturnType = $dehydrateVariant->getReturnType(); + + // Check that hydrate method has one parameter + if (count($hydrateParams) !== 1) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Hydrate method "%s()" must have one parameter.', + $hydrateWith + ) + ) + ->identifier('symfonyUX.liveComponent.livePropHydrateMethodMustHaveParameter') + ->line($hydrateMethodNode?->getLine() ?? $property->getLine()) + ->tip('Add a parameter to the hydrate method.') + ->build(); + } + + // Check that hydrate method return type matches property type + if (! $hydrateReturnType->equals($propertyType)) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Hydrate method "%s()" return type must match property "%s" type. Expected "%s", got "%s".', + $hydrateWith, + $propertyName, + $propertyType->describe(VerbosityLevel::typeOnly()), + $hydrateReturnType->describe(VerbosityLevel::typeOnly()) + ) + ) + ->identifier('symfonyUX.liveComponent.livePropHydrateMethodReturnTypeMismatch') + ->line($hydrateMethodNode?->getLine() ?? $property->getLine()) + ->tip(sprintf('Change the return type to ": %s".', $propertyType->describe(VerbosityLevel::typeOnly()))) + ->build(); + } + + // Check that dehydrate method has one parameter + if (count($dehydrateParams) !== 1) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Dehydrate method "%s()" must have one parameter that matches property "%s" type.', + $dehydrateWith, + $propertyName + ) + ) + ->identifier('symfonyUX.liveComponent.livePropDehydrateMethodMustHaveParameter') + ->line($dehydrateMethodNode?->getLine() ?? $property->getLine()) + ->tip('Add a parameter to the dehydrate method that matches the property type.') + ->build(); + } else { + // Check that dehydrate method first parameter type matches property type + $dehydrateParamType = $dehydrateParams[0]->getType(); + if (! $dehydrateParamType->equals($propertyType)) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Dehydrate method "%s()" first parameter type must match property "%s" type. Expected "%s", got "%s".', + $dehydrateWith, + $propertyName, + $propertyType->describe(VerbosityLevel::typeOnly()), + $dehydrateParamType->describe(VerbosityLevel::typeOnly()) + ) + ) + ->identifier('symfonyUX.liveComponent.livePropDehydrateMethodParameterTypeMismatch') + ->line($dehydrateMethodNode?->getLine() ?? $property->getLine()) + ->tip(sprintf('Change the parameter type to "%s".', $propertyType->describe(VerbosityLevel::typeOnly()))) + ->build(); + } + } + + // Check that hydration and dehydration methods are cross-compatible + // The dehydrate method's return type should match the hydrate method's parameter type + if (count($hydrateParams) > 0) { + $hydrateParamType = $hydrateParams[0]->getType(); + if (! $dehydrateReturnType->equals($hydrateParamType)) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Dehydrate method "%s()" return type must match hydrate method "%s()" first parameter type. Expected "%s", got "%s".', + $dehydrateWith, + $hydrateWith, + $hydrateParamType->describe(VerbosityLevel::typeOnly()), + $dehydrateReturnType->describe(VerbosityLevel::typeOnly()) + ) + ) + ->identifier('symfonyUX.liveComponent.livePropHydrationMethodsTypeMismatch') + ->line($dehydrateMethodNode?->getLine() ?? $property->getLine()) + ->tip(sprintf( + 'The dehydrate method should return the same type that the hydrate method accepts as its first parameter: "%s".', + $hydrateParamType->describe(VerbosityLevel::typeOnly()) + )) + ->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; + } + + /** + * 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/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/DehydrateMethodParameterTypeMismatch.php b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/DehydrateMethodParameterTypeMismatch.php new file mode 100644 index 0000000..0edce0e --- /dev/null +++ b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/DehydrateMethodParameterTypeMismatch.php @@ -0,0 +1,25 @@ + $data->value, + ]; + } +} diff --git a/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/ValidHydrationMethods.php b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/ValidHydrationMethods.php new file mode 100644 index 0000000..212bd89 --- /dev/null +++ b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/ValidHydrationMethods.php @@ -0,0 +1,25 @@ + + */ +final class LivePropHydrationMethodsRuleTest extends RuleTestCase +{ + public function testMissingBothMethods(): void + { + $this->analyse( + [__DIR__ . '/Fixture/MissingBothMethods.php'], + [ + [ + 'Property "data" references non-existent hydrate method "hydrateData()".', + 13, + 'Create the public method "hydrateData()" in the component class.', + ], + [ + 'Property "data" references non-existent dehydrate method "dehydrateData()".', + 13, + 'Create the public method "dehydrateData()" in the component class.', + ], + ] + ); + } + + public function testMissingHydrateWith(): void + { + $this->analyse( + [__DIR__ . '/Fixture/MissingHydrateWith.php'], + [ + [ + 'Property "data" has a #[LiveProp] attribute with "dehydrateWith" but is missing "hydrateWith".', + 13, + 'Both "hydrateWith" and "dehydrateWith" must be specified together in the #[LiveProp] attribute.', + ], + ] + ); + } + + public function testMissingDehydrateWith(): void + { + $this->analyse( + [__DIR__ . '/Fixture/MissingDehydrateWith.php'], + [ + [ + 'Property "data" has a #[LiveProp] attribute with "hydrateWith" but is missing "dehydrateWith".', + 13, + 'Both "hydrateWith" and "dehydrateWith" must be specified together in the #[LiveProp] attribute.', + ], + ] + ); + } + + public function testPrivateHydrateMethod(): void + { + $this->analyse( + [__DIR__ . '/Fixture/PrivateHydrateMethod.php'], + [ + [ + 'Hydrate method "hydrateData()" referenced in property "data" must be public.', + 21, + 'Make the method "hydrateData()" public.', + ], + ] + ); + } + + public function testProtectedDehydrateMethod(): void + { + $this->analyse( + [__DIR__ . '/Fixture/ProtectedDehydrateMethod.php'], + [ + [ + 'Dehydrate method "dehydrateData()" referenced in property "data" must be public.', + 21, + 'Make the method "dehydrateData()" public.', + ], + ] + ); + } + + public function testHydrateMethodReturnTypeMismatch(): void + { + $this->analyse( + [__DIR__ . '/Fixture/HydrateMethodReturnTypeMismatch.php'], + [ + [ + 'Hydrate method "hydrateData()" return type must match property "data" type. Expected "array", got "string".', + 16, + 'Change the return type to ": array".', + ], + ] + ); + } + + public function testDehydrateMethodParameterTypeMismatch(): void + { + $this->analyse( + [__DIR__ . '/Fixture/DehydrateMethodParameterTypeMismatch.php'], + [ + [ + 'Dehydrate method "dehydrateData()" first parameter type must match property "data" type. Expected "array", got "string".', + 21, + 'Change the parameter type to "array".', + ], + ] + ); + } + + public function testHydrateMethodWithoutParameter(): void + { + $this->analyse( + [__DIR__ . '/Fixture/HydrateMethodWithoutParameter.php'], + [ + [ + 'Hydrate method "hydrateData()" must have one parameter.', + 16, + 'Add a parameter to the hydrate method.', + ], + ] + ); + } + + public function testDehydrateMethodWithoutParameter(): void + { + $this->analyse( + [__DIR__ . '/Fixture/DehydrateMethodWithoutParameter.php'], + [ + [ + 'Dehydrate method "dehydrateData()" must have one parameter that matches property "data" type.', + 21, + 'Add a parameter to the dehydrate method that matches the property type.', + ], + ] + ); + } + + public function testHydrationMethodsTypeMismatch(): void + { + $this->analyse( + [__DIR__ . '/Fixture/HydrationMethodsTypeMismatch.php'], + [ + [ + 'Dehydrate method "dehydrateData()" return type must match hydrate method "hydrateData()" first parameter type. Expected "array", got "string".', + 21, + 'The dehydrate method should return the same type that the hydrate method accepts as its first parameter: "array".', + ], + ] + ); + } + + public function testNoViolations(): void + { + $this->analyse( + [__DIR__ . '/Fixture/NotAComponent.php'], + [] + ); + + $this->analyse( + [__DIR__ . '/Fixture/ValidHydrationMethods.php'], + [] + ); + + $this->analyse( + [__DIR__ . '/Fixture/NoHydrationMethods.php'], + [] + ); + + $this->analyse( + [__DIR__ . '/Fixture/ValidTypes.php'], + [] + ); + + $this->analyse( + [__DIR__ . '/Fixture/ValidComplexTypes.php'], + [] + ); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/config/configured_rule.neon']; + } + + protected function getRule(): Rule + { + return self::getContainer()->getByType(LivePropHydrationMethodsRule::class); + } +} diff --git a/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/config/configured_rule.neon b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/config/configured_rule.neon new file mode 100644 index 0000000..e607037 --- /dev/null +++ b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/config/configured_rule.neon @@ -0,0 +1,2 @@ +rules: + - Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropHydrationMethodsRule