Skip to content

Commit e750732

Browse files
authored
[container] support service subscriber & locator (#101)
* [container] support service subscriber * no message
1 parent 0397c58 commit e750732

File tree

4 files changed

+108
-7
lines changed

4 files changed

+108
-7
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vendor/bin/psalm-plugin enable psalm/plugin-symfony
1212
### Features
1313

1414
- Detect `ContainerInterface::get()` result type. Works better if you [configure](#configuration) compiled container XML file.
15+
- Support [Service Subscribers](https://github.com/psalm/psalm-plugin-symfony/issues/20).
1516
- Detect return type of console arguments (`InputInterface::getArgument()`) and options (`InputInterface::getOption()`). Enforces
1617
to use InputArgument and InputOption constants as a part of best practise.
1718
- Detects correct Doctrine repository class if entities are configured with annotations.

src/Handler/ContainerHandler.php

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
use PhpParser\Node\Expr;
66
use PhpParser\Node\Expr\ClassConstFetch;
77
use PhpParser\Node\Scalar\String_;
8+
use PhpParser\Node\Stmt\Class_;
89
use PhpParser\Node\Stmt\ClassLike;
10+
use PhpParser\Node\Stmt\ClassMethod;
11+
use PhpParser\Node\Stmt\Return_;
912
use Psalm\Codebase;
1013
use Psalm\CodeLocation;
1114
use Psalm\Context;
@@ -19,6 +22,7 @@
1922
use Psalm\SymfonyPsalmPlugin\Issue\PrivateService;
2023
use Psalm\SymfonyPsalmPlugin\Issue\ServiceNotFound;
2124
use Psalm\SymfonyPsalmPlugin\Symfony\ContainerMeta;
25+
use Psalm\SymfonyPsalmPlugin\Symfony\Service;
2226
use Psalm\Type\Atomic\TNamedObject;
2327
use Psalm\Type\Union;
2428

@@ -58,7 +62,7 @@ public static function afterMethodCallAnalysis(
5862
if (!self::isContainerMethod($declaring_method_id, 'get')) {
5963
if (self::isContainerMethod($declaring_method_id, 'getparameter')) {
6064
$argument = $expr->args[0]->value;
61-
if ($argument instanceof String_ && !self::followsNamingConvention($argument->value)) {
65+
if ($argument instanceof String_ && !self::followsNamingConvention($argument->value) && false === strpos($argument->value, '\\')) {
6266
IssueBuffer::accepts(
6367
new NamingConventionViolation(new CodeLocation($statements_source, $argument)),
6468
$statements_source->getSuppressedIssues()
@@ -90,7 +94,7 @@ public static function afterMethodCallAnalysis(
9094

9195
$service = self::$containerMeta->get($serviceId);
9296
if ($service) {
93-
if (!self::followsNamingConvention($serviceId) && !class_exists($service->getClassName())) {
97+
if (!self::followsNamingConvention($serviceId) && false === strpos($serviceId, '\\')) {
9498
IssueBuffer::accepts(
9599
new NamingConventionViolation(new CodeLocation($statements_source, $expr->args[0]->value)),
96100
$statements_source->getSuppressedIssues()
@@ -130,14 +134,46 @@ public static function afterClassLikeVisit(
130134
Codebase $codebase,
131135
array &$file_replacements = []
132136
) {
137+
$fileStorage = $codebase->file_storage_provider->get($statements_source->getFilePath());
138+
133139
if (\in_array($storage->name, ContainerHandler::GET_CLASSLIKES)) {
134140
if (self::$containerMeta) {
135-
$file_path = $statements_source->getFilePath();
136-
$file_storage = $codebase->file_storage_provider->get($file_path);
137-
138141
foreach (self::$containerMeta->getClassNames() as $className) {
139142
$codebase->queueClassLikeForScanning($className);
140-
$file_storage->referenced_classlikes[strtolower($className)] = $className;
143+
$fileStorage->referenced_classlikes[strtolower($className)] = $className;
144+
}
145+
}
146+
}
147+
148+
// see https://symfony.com/doc/current/service_container/service_subscribers_locators.html
149+
if (self::$containerMeta && $stmt instanceof Class_ && isset($storage->class_implements['symfony\contracts\service\servicesubscriberinterface'])) {
150+
foreach ($stmt->stmts as $classStmt) {
151+
if ($classStmt instanceof ClassMethod && 'getSubscribedServices' === $classStmt->name->name && $classStmt->stmts) {
152+
foreach ($classStmt->stmts as $methodStmt) {
153+
if ($methodStmt instanceof Return_ && ($return = $methodStmt->expr) && $return instanceof Expr\Array_) {
154+
foreach ($return->items as $arrayItem) {
155+
if ($arrayItem instanceof Expr\ArrayItem) {
156+
$value = $arrayItem->value;
157+
if (!$value instanceof Expr\ClassConstFetch) {
158+
continue;
159+
}
160+
161+
/** @var string $className */
162+
$className = $value->class->getAttribute('resolvedName');
163+
164+
$key = $arrayItem->key;
165+
$serviceId = $key instanceof String_ ? $key->value : $className;
166+
167+
$service = new Service($serviceId, $className);
168+
$service->setIsPublic(true);
169+
self::$containerMeta->add($service);
170+
171+
$codebase->queueClassLikeForScanning($className);
172+
$fileStorage->referenced_classlikes[strtolower($className)] = $className;
173+
}
174+
}
175+
}
176+
}
141177
}
142178
}
143179
}

src/Symfony/ContainerMeta.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public function get(string $id): ?Service
3333
return null;
3434
}
3535

36-
private function add(Service $service): void
36+
public function add(Service $service): void
3737
{
3838
if (($alias = $service->getAlias()) && isset($this->services[$alias])) {
3939
$aliasedService = $this->services[$alias];
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
@symfony-4 @symfony-5
2+
Feature: Service Subscriber
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: Asserting psalm recognizes return type of services defined in getSubscribedServices
23+
Given I have the following code
24+
"""
25+
<?php
26+
27+
use Doctrine\ORM\EntityManagerInterface;
28+
use Psr\Container\ContainerInterface;
29+
use Symfony\Contracts\Service\ServiceSubscriberInterface;
30+
use Symfony\Component\Validator\Validator\ValidatorInterface;
31+
32+
class SomeController implements ServiceSubscriberInterface
33+
{
34+
private $container;
35+
36+
public function __construct(ContainerInterface $container)
37+
{
38+
$this->container = $container;
39+
}
40+
41+
public function __invoke()
42+
{
43+
/** @psalm-trace $entityManager */
44+
$entityManager = $this->container->get('em');
45+
46+
/** @psalm-trace $validator */
47+
$validator = $this->container->get(ValidatorInterface::class);
48+
}
49+
50+
public static function getSubscribedServices()
51+
{
52+
return [
53+
'em' => EntityManagerInterface::class, // with key
54+
ValidatorInterface::class, // without key
55+
];
56+
}
57+
}
58+
"""
59+
When I run Psalm
60+
Then I see these errors
61+
| Type | Message |
62+
| Trace | $entityManager: Doctrine\ORM\EntityManagerInterface |
63+
| Trace | $validator: Symfony\Component\Validator\Validator\ValidatorInterface |
64+
And I see no other errors

0 commit comments

Comments
 (0)