Skip to content

Commit c786fdc

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

File tree

6 files changed

+147
-3
lines changed

6 files changed

+147
-3
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+
12+
class RequiredPropertyHandler 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 (!$stmt instanceof Class_) {
22+
return;
23+
}
24+
$reflection = null;
25+
foreach ($storage->properties as $name => $property) {
26+
if (!empty($storage->initialized_properties[$name])) {
27+
continue;
28+
}
29+
foreach ($property->attributes as $attribute) {
30+
if ('Symfony\Contracts\Service\Attribute\Required' === $attribute->fq_class_name) {
31+
$storage->initialized_properties[$name] = true;
32+
continue 2;
33+
}
34+
}
35+
$class = $storage->name;
36+
if (!class_exists($class)) {
37+
/** @psalm-suppress UnresolvableInclude */
38+
require_once $statements_source->getRootFilePath();
39+
}
40+
/** @psalm-suppress ArgumentTypeCoercion */
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;
@@ -60,13 +62,17 @@ public function __invoke(RegistrationInterface $api, SimpleXMLElement $config =
6062
require_once __DIR__.'/Handler/HeaderBagHandler.php';
6163
require_once __DIR__.'/Handler/ContainerHandler.php';
6264
require_once __DIR__.'/Handler/ConsoleHandler.php';
65+
require_once __DIR__.'/Handler/ContainerAwareTraitHandler.php';
6366
require_once __DIR__.'/Handler/ContainerDependencyHandler.php';
67+
require_once __DIR__.'/Handler/RequiredPropertyHandler.php';
6468
require_once __DIR__.'/Handler/RequiredSetterHandler.php';
6569
require_once __DIR__.'/Handler/DoctrineQueryBuilderHandler.php';
6670

6771
$api->registerHooksFromClass(HeaderBagHandler::class);
6872
$api->registerHooksFromClass(ConsoleHandler::class);
73+
$api->registerHooksFromClass(ContainerAwareTraitHandler::class);
6974
$api->registerHooksFromClass(ContainerDependencyHandler::class);
75+
$api->registerHooksFromClass(RequiredPropertyHandler::class);
7076
$api->registerHooksFromClass(RequiredSetterHandler::class);
7177

7278
if (class_exists(\Doctrine\ORM\QueryBuilder::class)) {

tests/acceptance/acceptance/PropertyAccessorInterface.feature

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ Feature: PropertyAccessorInterface
4141
"""
4242
class Company
4343
{
44-
public string $name = 'Acme';
44+
/**
45+
* @var string
46+
*/
47+
public $name = 'Acme';
4548
}
4649
$company = new Company();
4750
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
27+
class MyServiceA {
28+
/**
29+
* @required
30+
* @var string
31+
*/
32+
public $a;
33+
public function __construct(){}
34+
}
35+
"""
36+
When I run Psalm
37+
Then I see no errors
38+
39+
Scenario: PropertyNotSetInConstructor error is raised when the @required annotation is not present.
40+
Given I have the following code
41+
"""
42+
<?php
43+
44+
class MyServiceC {
45+
/**
46+
* @var string
47+
*/
48+
public $a;
49+
public function __construct(){}
50+
51+
}
52+
"""
53+
When I run Psalm
54+
Then I see these errors
55+
| Type | Message |
56+
| PropertyNotSetInConstructor | Property MyServiceC::$a is not defined in constructor of MyServiceC and in any methods called in the constructor |
57+
And I see no other errors

tests/acceptance/acceptance/RequiredSetter.feature

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ Feature: Annotation class
2727
}
2828
2929
final class MyServiceB {
30-
private MyServiceA $a;
30+
/** @var MyServiceA */
31+
private $a;
3132
public function __construct(){}
3233
3334
/** @required */
@@ -45,7 +46,8 @@ Feature: Annotation class
4546
}
4647
4748
final class MyServiceB {
48-
private MyServiceA $a;
49+
/** @var MyServiceA */
50+
private $a;
4951
public function __construct(){}
5052
5153
private function setMyServiceA(MyServiceA $a): void { $this->a = $a; }

0 commit comments

Comments
 (0)