Skip to content

Commit 9e2d22a

Browse files
authored
Merge pull request #18 from Kocal/LivePropModifierMethodRule
2 parents d40ab0f + 69ab393 commit 9e2d22a

17 files changed

+605
-34
lines changed

README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,97 @@ final class ShoppingCart
295295

296296
<br>
297297

298+
### LivePropModifierMethodRule
299+
300+
Enforces that when a `#[LiveProp]` attribute specifies a `modifier` parameter:
301+
- The method must exist in the component class and be declared as public
302+
- The method must have 1 or 2 parameters:
303+
- First parameter: must be of type `LiveProp`
304+
- Second parameter (optional): must be of type `string`
305+
- The method must return a `LiveProp` instance
306+
307+
This ensures that property modifiers are correctly implemented and can safely transform LiveProp configurations at runtime.
308+
309+
```yaml
310+
rules:
311+
- Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropModifierMethodRule
312+
```
313+
314+
```php
315+
// src/Twig/Components/SearchComponent.php
316+
namespace App\Twig\Components;
317+
318+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
319+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
320+
321+
#[AsLiveComponent]
322+
final class SearchComponent
323+
{
324+
#[LiveProp(modifier: 'modifyQueryProp')]
325+
public string $query;
326+
327+
// Error: Method is not public
328+
private function modifyQueryProp(LiveProp $liveProp): LiveProp
329+
{
330+
return $liveProp;
331+
}
332+
333+
// Error: Wrong return type
334+
public function modifyOtherProp(LiveProp $liveProp): string
335+
{
336+
return 'test';
337+
}
338+
339+
// Error: Wrong first parameter type
340+
public function modifyAnotherProp(string $value): LiveProp
341+
{
342+
return new LiveProp();
343+
}
344+
}
345+
```
346+
347+
:x:
348+
349+
<br>
350+
351+
```php
352+
// src/Twig/Components/SearchComponent.php
353+
namespace App\Twig\Components;
354+
355+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
356+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
357+
use Symfony\UX\LiveComponent\Mapping\UrlMapping;
358+
359+
#[AsLiveComponent]
360+
final class SearchComponent
361+
{
362+
#[LiveProp(modifier: 'modifyQueryProp')]
363+
public string $query;
364+
365+
#[LiveProp]
366+
public ?string $alias = null;
367+
368+
// Valid: with two parameters
369+
public function modifyQueryProp(LiveProp $liveProp, string $name): LiveProp
370+
{
371+
if ($this->alias) {
372+
$liveProp = $liveProp->withUrl(new UrlMapping(as: $this->alias));
373+
}
374+
return $liveProp;
375+
}
376+
377+
// Valid: with one parameter
378+
public function modifyOtherProp(LiveProp $liveProp): LiveProp
379+
{
380+
return $liveProp->writable();
381+
}
382+
}
383+
```
384+
385+
:+1:
386+
387+
<br>
388+
298389
## TwigComponent Rules
299390

300391
> [!NOTE]

src/NodeAnalyzer/AttributeFinder.php

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,6 @@
1616
*/
1717
final class AttributeFinder
1818
{
19-
/**
20-
* @return Attribute[]
21-
*/
22-
public static function findAttributes(ClassMethod | Property | ClassLike | Param $node): array
23-
{
24-
$attributes = [];
25-
26-
foreach ($node->attrGroups as $attrGroup) {
27-
$attributes = array_merge($attributes, $attrGroup->attrs);
28-
}
29-
30-
return $attributes;
31-
}
32-
3319
public static function findAttribute(ClassMethod | Property | ClassLike | Param $node, string $desiredAttributeClass): ?Attribute
3420
{
3521
$attributes = self::findAttributes($node);
@@ -68,4 +54,18 @@ public static function findAnyAttribute(ClassMethod | Property | ClassLike | Par
6854

6955
return null;
7056
}
57+
58+
/**
59+
* @return Attribute[]
60+
*/
61+
private static function findAttributes(ClassMethod | Property | ClassLike | Param $node): array
62+
{
63+
$attributes = [];
64+
65+
foreach ($node->attrGroups as $attrGroup) {
66+
$attributes = array_merge($attributes, $attrGroup->attrs);
67+
}
68+
69+
return $attributes;
70+
}
7171
}

src/Rules/LiveComponent/LiveActionMethodsShouldBePublicRule.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ public function getNodeType(): string
2525

2626
public function processNode(Node $node, Scope $scope): array
2727
{
28-
if (! AttributeFinder::findAnyAttribute($node, [AsLiveComponent::class])) {
28+
if (! AttributeFinder::findAttribute($node, AsLiveComponent::class)) {
2929
return [];
3030
}
3131

3232
$errors = [];
3333

3434
foreach ($node->getMethods() as $method) {
35-
if (! AttributeFinder::findAnyAttribute($method, [LiveAction::class])) {
35+
if (! AttributeFinder::findAttribute($method, LiveAction::class)) {
3636
continue;
3737
}
3838

src/Rules/LiveComponent/LiveListenerMethodsShouldBePublicRule.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ public function getNodeType(): string
2525

2626
public function processNode(Node $node, Scope $scope): array
2727
{
28-
if (! AttributeFinder::findAnyAttribute($node, [AsLiveComponent::class])) {
28+
if (! AttributeFinder::findAttribute($node, AsLiveComponent::class)) {
2929
return [];
3030
}
3131

3232
$errors = [];
3333

3434
foreach ($node->getMethods() as $method) {
35-
if (! AttributeFinder::findAnyAttribute($method, [LiveListener::class])) {
35+
if (! AttributeFinder::findAttribute($method, LiveListener::class)) {
3636
continue;
3737
}
3838

src/Rules/LiveComponent/LivePropHydrationMethodsRule.php

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ public function processNode(Node $node, Scope $scope): array
140140
$dehydrateMethodRefl = $reflClass->getMethod($dehydrateWith, $scope);
141141

142142
// Get AST nodes for line numbers
143-
$hydrateMethodNode = $this->findMethod($node, $hydrateWith);
144-
$dehydrateMethodNode = $this->findMethod($node, $dehydrateWith);
143+
$hydrateMethodNode = $node->getMethod($hydrateWith);
144+
$dehydrateMethodNode = $node->getMethod($dehydrateWith);
145145

146146
// Check that methods are public
147147
if (! $hydrateMethodRefl->isPublic()) {
@@ -298,18 +298,4 @@ private function getArgumentValue(Node\Attribute $attribute, string $argumentNam
298298

299299
return null;
300300
}
301-
302-
/**
303-
* Finds a method AST node by name in the given class (used for line numbers).
304-
*/
305-
private function findMethod(Class_ $classNode, string $methodName): ?Node\Stmt\ClassMethod
306-
{
307-
foreach ($classNode->getMethods() as $method) {
308-
if ($method->name->toString() === $methodName) {
309-
return $method;
310-
}
311-
}
312-
313-
return null;
314-
}
315301
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Kocal\PHPStanSymfonyUX\Rules\LiveComponent;
6+
7+
use Kocal\PHPStanSymfonyUX\NodeAnalyzer\AttributeFinder;
8+
use PhpParser\Node;
9+
use PhpParser\Node\Stmt\Class_;
10+
use PHPStan\Analyser\Scope;
11+
use PHPStan\Reflection\ReflectionProvider;
12+
use PHPStan\Rules\Rule;
13+
use PHPStan\Rules\RuleErrorBuilder;
14+
use PHPStan\Type\ObjectType;
15+
use PHPStan\Type\StringType;
16+
use PHPStan\Type\VerbosityLevel;
17+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
18+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
19+
20+
/**
21+
* @implements Rule<Class_>
22+
*/
23+
final class LivePropModifierMethodRule implements Rule
24+
{
25+
public function __construct(
26+
private ReflectionProvider $reflectionProvider,
27+
) {
28+
}
29+
30+
public function getNodeType(): string
31+
{
32+
return Class_::class;
33+
}
34+
35+
public function processNode(Node $node, Scope $scope): array
36+
{
37+
if (! AttributeFinder::findAttribute($node, AsLiveComponent::class)) {
38+
return [];
39+
}
40+
41+
if ($node->namespacedName === null) {
42+
return [];
43+
}
44+
45+
$errors = [];
46+
$reflClass = $this->reflectionProvider->getClass($node->namespacedName->toString());
47+
48+
foreach ($node->getProperties() as $property) {
49+
$livePropAttribute = AttributeFinder::findAttribute($property, LiveProp::class);
50+
if (! $livePropAttribute) {
51+
continue;
52+
}
53+
54+
// Extract modifier method name from the attribute
55+
$modifier = $this->getArgumentValue($livePropAttribute, 'modifier');
56+
57+
// Skip if modifier argument is not defined
58+
if ($modifier === null) {
59+
continue;
60+
}
61+
62+
$propertyName = $property->props[0]->name->toString();
63+
64+
// Check if the modifier method exists
65+
if (! $reflClass->hasMethod($modifier)) {
66+
$errors[] = RuleErrorBuilder::message(
67+
sprintf(
68+
'Property "%s" references non-existent modifier method "%s()".',
69+
$propertyName,
70+
$modifier
71+
)
72+
)
73+
->identifier('symfonyUX.liveComponent.livePropModifierMethodMustExist')
74+
->line($property->getLine())
75+
->tip(sprintf('Create the public method "%s()" in the component class.', $modifier))
76+
->build();
77+
78+
continue;
79+
}
80+
81+
// Get method reflection
82+
$modifierMethodRefl = $reflClass->getMethod($modifier, $scope);
83+
84+
// Get AST node for line number
85+
$modifierMethodNode = $node->getMethod($modifier);
86+
87+
// Check that method is public
88+
if (! $modifierMethodRefl->isPublic()) {
89+
$errors[] = RuleErrorBuilder::message(
90+
sprintf(
91+
'Modifier method "%s()" referenced in property "%s" must be public.',
92+
$modifier,
93+
$propertyName
94+
)
95+
)
96+
->identifier('symfonyUX.liveComponent.livePropModifierMethodMustBePublic')
97+
->line($modifierMethodNode?->getLine() ?? $property->getLine())
98+
->tip(sprintf('Make the method "%s()" public.', $modifier))
99+
->build();
100+
}
101+
102+
// Get method signature
103+
$modifierVariant = $modifierMethodRefl->getOnlyVariant();
104+
$modifierParams = $modifierVariant->getParameters();
105+
$modifierReturnType = $modifierVariant->getReturnType();
106+
107+
// Check that modifier method has 1 or 2 parameters
108+
$paramCount = count($modifierParams);
109+
if ($paramCount < 1 || $paramCount > 2) {
110+
$errors[] = RuleErrorBuilder::message(
111+
sprintf(
112+
'Modifier method "%s()" must have 1 or 2 parameters (LiveProp and optionally string).',
113+
$modifier
114+
)
115+
)
116+
->identifier('symfonyUX.liveComponent.livePropModifierMethodParameterCount')
117+
->line($modifierMethodNode?->getLine() ?? $property->getLine())
118+
->tip('The modifier method should have a LiveProp parameter and optionally a string parameter.')
119+
->build();
120+
} else {
121+
// Check first parameter is LiveProp
122+
$firstParamType = $modifierParams[0]->getType();
123+
$expectedLivePropType = new ObjectType(LiveProp::class);
124+
125+
if (! $expectedLivePropType->isSuperTypeOf($firstParamType)->yes()) {
126+
$errors[] = RuleErrorBuilder::message(
127+
sprintf(
128+
'Modifier method "%s()" first parameter must be of type "%s", got "%s".',
129+
$modifier,
130+
LiveProp::class,
131+
$firstParamType->describe(VerbosityLevel::typeOnly())
132+
)
133+
)
134+
->identifier('symfonyUX.liveComponent.livePropModifierMethodFirstParameterType')
135+
->line($modifierMethodNode?->getLine() ?? $property->getLine())
136+
->tip(sprintf('Change the first parameter type to "%s".', LiveProp::class))
137+
->build();
138+
}
139+
140+
// Check second parameter is string if it exists
141+
if ($paramCount === 2) {
142+
$secondParamType = $modifierParams[1]->getType();
143+
$expectedStringType = new StringType();
144+
145+
if (! $expectedStringType->isSuperTypeOf($secondParamType)->yes()) {
146+
$errors[] = RuleErrorBuilder::message(
147+
sprintf(
148+
'Modifier method "%s()" second parameter must be of type "string", got "%s".',
149+
$modifier,
150+
$secondParamType->describe(VerbosityLevel::typeOnly())
151+
)
152+
)
153+
->identifier('symfonyUX.liveComponent.livePropModifierMethodSecondParameterType')
154+
->line($modifierMethodNode?->getLine() ?? $property->getLine())
155+
->tip('Change the second parameter type to "string".')
156+
->build();
157+
}
158+
}
159+
}
160+
161+
// Check that modifier method returns LiveProp
162+
$expectedLivePropType = new ObjectType(LiveProp::class);
163+
if (! $expectedLivePropType->isSuperTypeOf($modifierReturnType)->yes()) {
164+
$errors[] = RuleErrorBuilder::message(
165+
sprintf(
166+
'Modifier method "%s()" must return "%s", got "%s".',
167+
$modifier,
168+
LiveProp::class,
169+
$modifierReturnType->describe(VerbosityLevel::typeOnly())
170+
)
171+
)
172+
->identifier('symfonyUX.liveComponent.livePropModifierMethodReturnType')
173+
->line($modifierMethodNode?->getLine() ?? $property->getLine())
174+
->tip(sprintf('Change the return type to ": %s".', LiveProp::class))
175+
->build();
176+
}
177+
}
178+
179+
return $errors;
180+
}
181+
182+
/**
183+
* Extracts the value of a named argument from a LiveProp attribute.
184+
*/
185+
private function getArgumentValue(Node\Attribute $attribute, string $argumentName): ?string
186+
{
187+
foreach ($attribute->args as $arg) {
188+
if ($arg->name && $arg->name->toString() === $argumentName) {
189+
if ($arg->value instanceof Node\Scalar\String_) {
190+
return $arg->value->value;
191+
}
192+
}
193+
}
194+
195+
return null;
196+
}
197+
}

0 commit comments

Comments
 (0)