Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,97 @@ final class ShoppingCart

<br>

### 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:

<br>

```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:

<br>

## TwigComponent Rules

> [!NOTE]
Expand Down
28 changes: 14 additions & 14 deletions src/NodeAnalyzer/AttributeFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
18 changes: 2 additions & 16 deletions src/Rules/LiveComponent/LivePropHydrationMethodsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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;
}
}
197 changes: 197 additions & 0 deletions src/Rules/LiveComponent/LivePropModifierMethodRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Rules\LiveComponent;

use Kocal\PHPStanSymfonyUX\NodeAnalyzer\AttributeFinder;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\VerbosityLevel;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;

/**
* @implements Rule<Class_>
*/
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;
}
}
Loading