Skip to content

Commit 25380ac

Browse files
committed
Support @reqired annotation and #[Required] attribute for properties.
Prevent PropertyNotSetInConstructor when ContainerAwareTrait used.
1 parent a56f44a commit 25380ac

File tree

4 files changed

+155
-0
lines changed

4 files changed

+155
-0
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace Psalm\SymfonyPsalmPlugin\Handler;
4+
5+
use PhpParser\Node\Stmt\ClassLike;
6+
use Psalm\Codebase;
7+
use Psalm\FileSource;
8+
use Psalm\Plugin\Hook\AfterClassLikeVisitInterface;
9+
use Psalm\Storage\ClassLikeStorage;
10+
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
11+
12+
class ContainerAwareTraitHandler implements AfterClassLikeVisitInterface
13+
{
14+
public static function afterClassLikeVisit(
15+
ClassLike $stmt,
16+
ClassLikeStorage $storage,
17+
FileSource $statements_source,
18+
Codebase $codebase,
19+
array &$file_replacements = []
20+
) {
21+
if (ContainerAwareTrait::class === $storage->name) {
22+
$storage->initialized_properties['container'] = true;
23+
}
24+
}
25+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Psalm\SymfonyPsalmPlugin\Handler;
4+
5+
use PhpParser\Node\Stmt\Class_;
6+
use PhpParser\Node\Stmt\ClassLike;
7+
use Psalm\Codebase;
8+
use Psalm\FileSource;
9+
use Psalm\Plugin\Hook\AfterClassLikeVisitInterface;
10+
use Psalm\Storage\ClassLikeStorage;
11+
use Symfony\Contracts\Service\Attribute\Required;
12+
13+
class RequiredPropertyHandler implements AfterClassLikeVisitInterface
14+
{
15+
public static function afterClassLikeVisit(
16+
ClassLike $stmt,
17+
ClassLikeStorage $storage,
18+
FileSource $statements_source,
19+
Codebase $codebase,
20+
array &$file_replacements = []
21+
) {
22+
if (!$stmt instanceof Class_) {
23+
return;
24+
}
25+
$reflection = null;
26+
foreach ($storage->properties as $name => $property) {
27+
if (!empty($storage->initialized_properties[$name])) {
28+
continue;
29+
}
30+
foreach ($property->attributes as $attribute) {
31+
if (Required::class === $attribute->fq_class_name) {
32+
$storage->initialized_properties[$name] = true;
33+
continue 2;
34+
}
35+
}
36+
/** @psalm-var class-string $class */
37+
$class = $storage->name;
38+
if (!class_exists($class)) {
39+
require_once $statements_source->getRootFilePath();
40+
}
41+
$reflection = $reflection ?? new \ReflectionClass($class);
42+
if ($reflection->hasProperty($name)) {
43+
$reflectionProperty = $reflection->getProperty($name);
44+
$docCommend = $reflectionProperty->getDocComment();
45+
if ($docCommend && false !== strpos(strtoupper($docCommend), '@REQUIRED')) {
46+
$storage->initialized_properties[$name] = true;
47+
}
48+
}
49+
}
50+
}
51+
}

src/Plugin.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
use Psalm\Plugin\RegistrationInterface;
1010
use Psalm\SymfonyPsalmPlugin\Handler\AnnotationHandler;
1111
use Psalm\SymfonyPsalmPlugin\Handler\ConsoleHandler;
12+
use Psalm\SymfonyPsalmPlugin\Handler\ContainerAwareTraitHandler;
1213
use Psalm\SymfonyPsalmPlugin\Handler\ContainerDependencyHandler;
1314
use Psalm\SymfonyPsalmPlugin\Handler\ContainerHandler;
1415
use Psalm\SymfonyPsalmPlugin\Handler\DoctrineQueryBuilderHandler;
1516
use Psalm\SymfonyPsalmPlugin\Handler\DoctrineRepositoryHandler;
1617
use Psalm\SymfonyPsalmPlugin\Handler\HeaderBagHandler;
18+
use Psalm\SymfonyPsalmPlugin\Handler\RequiredPropertyHandler;
1719
use Psalm\SymfonyPsalmPlugin\Handler\RequiredSetterHandler;
1820
use Psalm\SymfonyPsalmPlugin\Symfony\ContainerMeta;
1921
use Psalm\SymfonyPsalmPlugin\Twig\AnalyzedTemplatesTainter;
@@ -56,13 +58,17 @@ public function __invoke(RegistrationInterface $api, SimpleXMLElement $config =
5658
require_once __DIR__.'/Handler/HeaderBagHandler.php';
5759
require_once __DIR__.'/Handler/ContainerHandler.php';
5860
require_once __DIR__.'/Handler/ConsoleHandler.php';
61+
require_once __DIR__.'/Handler/ContainerAwareTraitHandler.php';
5962
require_once __DIR__.'/Handler/ContainerDependencyHandler.php';
63+
require_once __DIR__.'/Handler/RequiredPropertyHandler.php';
6064
require_once __DIR__.'/Handler/RequiredSetterHandler.php';
6165
require_once __DIR__.'/Handler/DoctrineQueryBuilderHandler.php';
6266

6367
$api->registerHooksFromClass(HeaderBagHandler::class);
6468
$api->registerHooksFromClass(ConsoleHandler::class);
69+
$api->registerHooksFromClass(ContainerAwareTraitHandler::class);
6570
$api->registerHooksFromClass(ContainerDependencyHandler::class);
71+
$api->registerHooksFromClass(RequiredPropertyHandler::class);
6672
$api->registerHooksFromClass(RequiredSetterHandler::class);
6773

6874
if (class_exists(\Doctrine\ORM\QueryBuilder::class)) {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
@symfony-common
2+
Feature: RequiredAttribute
3+
4+
Background:
5+
Given I have the following config
6+
"""
7+
<?xml version="1.0"?>
8+
<psalm errorLevel="1">
9+
<projectFiles>
10+
<directory name="."/>
11+
<ignoreFiles> <directory name="../../vendor"/> </ignoreFiles>
12+
</projectFiles>
13+
14+
<plugins>
15+
<pluginClass class="Psalm\SymfonyPsalmPlugin\Plugin">
16+
<containerXml>../../tests/acceptance/container.xml</containerXml>
17+
</pluginClass>
18+
</plugins>
19+
</psalm>
20+
"""
21+
22+
Scenario: PropertyNotSetInConstructor error is not raised when the @required annotation is present.
23+
Given I have the following code
24+
"""
25+
<?php
26+
final class MyServiceA {
27+
}
28+
29+
final class MyServiceB {
30+
/** @required */
31+
public MyServiceA $a;
32+
public function __construct(){}
33+
}
34+
"""
35+
When I run Psalm
36+
Then I see no errors
37+
38+
Scenario: PropertyNotSetInConstructor error is not raised when the @required annotation is present.
39+
Given I have the following code
40+
"""
41+
<?php
42+
use Symfony\Contracts\Service\Attribute\Required;
43+
44+
final class MyServiceA {
45+
}
46+
47+
final class MyServiceB {
48+
#[Required]
49+
public MyServiceA $a;
50+
public function __construct(){}
51+
}
52+
"""
53+
When I run Psalm
54+
Then I see no errors
55+
56+
Scenario: PropertyNotSetInConstructor error is raised when the @required annotation is not present.
57+
Given I have the following code
58+
"""
59+
<?php
60+
final class MyServiceA {
61+
}
62+
63+
final class MyServiceB {
64+
public MyServiceA $a;
65+
public function __construct(){}
66+
67+
}
68+
"""
69+
When I run Psalm
70+
Then I see these errors
71+
| Type | Message |
72+
| PropertyNotSetInConstructor | Property MyServiceB::$a is not defined in constructor of MyServiceB and in any methods called in the constructor |
73+
And I see no other errors

0 commit comments

Comments
 (0)