From 20604f13e78c5a17c4ef6428e83988d48a1ca005 Mon Sep 17 00:00:00 2001 From: Farhad Safarov Date: Sun, 22 Aug 2021 08:27:44 +0300 Subject: [PATCH] v3.0 - improve container handler & refactor (#202) --- .github/workflows/integrate.yaml | 1 - src/Handler/ContainerHandler.php | 77 +----- src/Handler/ParameterBagHandler.php | 10 +- src/Symfony/ContainerMeta.php | 222 +++++++++--------- src/Symfony/Service.php | 64 ----- .../acceptance/ServiceSubscriber.feature | 96 ++------ tests/acceptance/container.xml | 99 +++++--- tests/unit/Symfony/ContainerMetaTest.php | 132 +++++------ 8 files changed, 259 insertions(+), 442 deletions(-) delete mode 100644 src/Symfony/Service.php diff --git a/.github/workflows/integrate.yaml b/.github/workflows/integrate.yaml index d15d5c4..a482797 100644 --- a/.github/workflows/integrate.yaml +++ b/.github/workflows/integrate.yaml @@ -73,7 +73,6 @@ jobs: - 8.0 symfony-version: - - 3 - 4 - 5 diff --git a/src/Handler/ContainerHandler.php b/src/Handler/ContainerHandler.php index 97096c1..eed4e7a 100644 --- a/src/Handler/ContainerHandler.php +++ b/src/Handler/ContainerHandler.php @@ -2,28 +2,21 @@ namespace Psalm\SymfonyPsalmPlugin\Handler; -use PhpParser\Node\Expr; use PhpParser\Node\Expr\ClassConstFetch; -use PhpParser\Node\Name; use PhpParser\Node\Scalar\String_; -use PhpParser\Node\Stmt\Class_; -use PhpParser\Node\Stmt\ClassMethod; -use PhpParser\Node\Stmt\Return_; -use Psalm\Codebase; use Psalm\CodeLocation; use Psalm\IssueBuffer; use Psalm\Plugin\EventHandler\AfterClassLikeVisitInterface; use Psalm\Plugin\EventHandler\AfterMethodCallAnalysisInterface; use Psalm\Plugin\EventHandler\Event\AfterClassLikeVisitEvent; use Psalm\Plugin\EventHandler\Event\AfterMethodCallAnalysisEvent; -use Psalm\Storage\FileStorage; use Psalm\SymfonyPsalmPlugin\Issue\NamingConventionViolation; use Psalm\SymfonyPsalmPlugin\Issue\PrivateService; use Psalm\SymfonyPsalmPlugin\Issue\ServiceNotFound; use Psalm\SymfonyPsalmPlugin\Symfony\ContainerMeta; -use Psalm\SymfonyPsalmPlugin\Symfony\Service; use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Union; +use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; class ContainerHandler implements AfterMethodCallAnalysisInterface, AfterClassLikeVisitInterface { @@ -88,8 +81,9 @@ class ContainerHandler implements AfterMethodCallAnalysisInterface, AfterClassLi return; } - $service = self::$containerMeta->get($serviceId); - if ($service) { + try { + $service = self::$containerMeta->get($serviceId, $context->self); + if (!self::followsNamingConvention($serviceId) && false === strpos($serviceId, '\\')) { IssueBuffer::accepts( new NamingConventionViolation(new CodeLocation($statements_source, $expr->args[0]->value)), @@ -97,7 +91,7 @@ class ContainerHandler implements AfterMethodCallAnalysisInterface, AfterClassLi ); } - $class = $service->getClassName(); + $class = $service->getClass(); if ($class) { $codebase->classlikes->addFullyQualifiedClassName($class); $event->setReturnTypeCandidate(new Union([new TNamedObject($class)])); @@ -112,7 +106,7 @@ class ContainerHandler implements AfterMethodCallAnalysisInterface, AfterClassLi ); } } - } else { + } catch (ServiceNotFoundException $e) { IssueBuffer::accepts( new ServiceNotFound($serviceId, new CodeLocation($statements_source, $expr->args[0]->value)), $statements_source->getSuppressedIssues() @@ -128,7 +122,6 @@ class ContainerHandler implements AfterMethodCallAnalysisInterface, AfterClassLi $codebase = $event->getCodebase(); $statements_source = $event->getStatementsSource(); $storage = $event->getStorage(); - $stmt = $event->getStmt(); $fileStorage = $codebase->file_storage_provider->get($statements_source->getFilePath()); @@ -140,64 +133,6 @@ class ContainerHandler implements AfterMethodCallAnalysisInterface, AfterClassLi } } } - - // see https://symfony.com/doc/current/service_container/service_subscribers_locators.html - if (self::$containerMeta && $stmt instanceof Class_ && in_array('getsubscribedservices', array_keys($storage->methods))) { - foreach ($stmt->stmts as $classStmt) { - if ($classStmt instanceof ClassMethod && 'getSubscribedServices' === $classStmt->name->name && $classStmt->stmts) { - foreach ($classStmt->stmts as $methodStmt) { - if (!$methodStmt instanceof Return_) { - continue; - } - - $return = $methodStmt->expr; - if ($return instanceof Expr\Array_) { - self::addSubscribedServicesArray($return, $codebase, $fileStorage); - } elseif ($return instanceof Expr\FuncCall) { - $funcName = $return->name; - if ($funcName instanceof Name && in_array('array_merge', $funcName->parts)) { - foreach ($return->args as $arg) { - if ($arg->value instanceof Expr\Array_) { - self::addSubscribedServicesArray($arg->value, $codebase, $fileStorage); - } - } - } - } - } - } - } - } - } - - private static function addSubscribedServicesArray(Expr\Array_ $array, Codebase $codebase, FileStorage $fileStorage): void - { - if (!self::$containerMeta) { - return; - } - - foreach ($array->items as $arrayItem) { - if ($arrayItem instanceof Expr\ArrayItem) { - $value = $arrayItem->value; - if (!$value instanceof Expr\ClassConstFetch) { - continue; - } - - /** @var string $className */ - $className = $value->class->getAttribute('resolvedName'); - - $key = $arrayItem->key; - $serviceId = $key instanceof String_ ? $key->value : $className; - - if (null === self::$containerMeta->get($className)) { - $service = new Service($serviceId, $className); - $service->setIsPublic(true); - self::$containerMeta->add($service); - } - - $codebase->queueClassLikeForScanning($className); - $fileStorage->referenced_classlikes[strtolower($className)] = $className; - } - } } private static function isContainerMethod(string $declaringMethodId, string $methodName): bool diff --git a/src/Handler/ParameterBagHandler.php b/src/Handler/ParameterBagHandler.php index 64fd29f..eec91c2 100644 --- a/src/Handler/ParameterBagHandler.php +++ b/src/Handler/ParameterBagHandler.php @@ -8,6 +8,7 @@ use Psalm\Plugin\EventHandler\Event\AfterMethodCallAnalysisEvent; use Psalm\SymfonyPsalmPlugin\Symfony\ContainerMeta; use Psalm\Type\Atomic; use Psalm\Type\Union; +use Symfony\Component\DependencyInjection\Exception\ParameterNotFoundException; class ParameterBagHandler implements AfterMethodCallAnalysisInterface { @@ -36,8 +37,15 @@ class ParameterBagHandler implements AfterMethodCallAnalysisInterface $argument = $expr->args[0]->value->value; + try { + $parameter = self::$containerMeta->getParameter($argument); + } catch (ParameterNotFoundException $e) { + // maybe emit ParameterNotFound issue + return; + } + // @todo find a better way to calculate return type - switch (gettype(self::$containerMeta->getParameter($argument))) { + switch (gettype($parameter)) { case 'string': $event->setReturnTypeCandidate(new Union([Atomic::create('string')])); break; diff --git a/src/Symfony/ContainerMeta.php b/src/Symfony/ContainerMeta.php index e313336..4b1095c 100644 --- a/src/Symfony/ContainerMeta.php +++ b/src/Symfony/ContainerMeta.php @@ -5,47 +5,66 @@ declare(strict_types=1); namespace Psalm\SymfonyPsalmPlugin\Symfony; use Psalm\Exception\ConfigException; -use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; +use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; +use Symfony\Component\DependencyInjection\Reference; class ContainerMeta { - /** - * @psalm-var array - */ - private $services = []; - /** * @var array */ private $classNames = []; /** - * @var array + * @var array */ - private $parameters = []; + private $classLocators = []; + + /** + * @var array> + */ + private $serviceLocators = []; + + /** + * @var ContainerBuilder + */ + private $container; public function __construct(array $containerXmlPaths) { $this->init($containerXmlPaths); } - public function get(string $id): ?Service + /** + * @throws ServiceNotFoundException + */ + public function get(string $id, ?string $contextClass = null): Definition { - if (isset($this->services[$id])) { - return $this->services[$id]; + if ($contextClass && isset($this->classLocators[$contextClass]) && isset($this->serviceLocators[$this->classLocators[$contextClass]]) && isset($this->serviceLocators[$this->classLocators[$contextClass]][$id])) { + $id = $this->serviceLocators[$this->classLocators[$contextClass]][$id]; + + try { + $definition = $this->getDefinition($id); + } catch (ServiceNotFoundException $e) { + if (!class_exists($id)) { + throw $e; + } + + $definition = new Definition($id); + } + + $definition->setPublic(true); + } else { + $definition = $this->getDefinition($id); } - return null; - } - - public function add(Service $service): void - { - if (($alias = $service->getAlias()) && isset($this->services[$alias])) { - $aliasedService = $this->services[$alias]; - $service->setClassName($aliasedService->getClassName()); - } - - $this->services[$service->getId()] = $service; + return $definition; } /** @@ -53,11 +72,7 @@ class ContainerMeta */ public function getParameter(string $key) { - if (isset($this->parameters[$key])) { - return $this->parameters[$key]; - } - - return null; + return $this->container->getParameter($key); } /** @@ -70,103 +85,82 @@ class ContainerMeta private function init(array $containerXmlPaths): void { - /** @var string $containerXmlPath */ - foreach ($containerXmlPaths as $containerXmlPath) { - $xmlPath = realpath($containerXmlPath); - if (!$xmlPath || !file_exists($xmlPath)) { - continue; + $this->container = new ContainerBuilder(); + $xml = new XmlFileLoader($this->container, new FileLocator()); + + $containerXmlPath = null; + foreach ($containerXmlPaths as $filePath) { + $containerXmlPath = realpath((string) $filePath); + if ($containerXmlPath) { + break; } - - $xml = simplexml_load_file($xmlPath); - if (!$xml->services instanceof \SimpleXMLElement) { - throw new ConfigException($xmlPath.' is not a valid container xml file'); - } - - /** @psalm-var \SimpleXMLElement $serviceXml */ - foreach ($xml->services->service as $serviceXml) { - /** @psalm-var \SimpleXMLElement $serviceAttributes */ - $serviceAttributes = $serviceXml->attributes(); - - $className = (string) $serviceAttributes->class; - - if ($className) { - $this->classNames[] = $className; - } - - $service = new Service((string) $serviceAttributes->id, $className); - if (isset($serviceAttributes->alias)) { - $service->setAlias((string) $serviceAttributes->alias); - } - - if (3 < Kernel::MAJOR_VERSION) { - $service->setIsPublic('true' === (string) $serviceAttributes->public); - } else { - $service->setIsPublic('false' !== (string) $serviceAttributes->public); - } - - $this->add($service); - } - - /** @var \SimpleXMLElement $parameter */ - foreach ($xml->parameters->parameter as $parameter) { - $value = $this->getXmlParameterValue($parameter); - - $attributes = $parameter->attributes(); - if (!isset($attributes->key)) { - continue; - } - - $this->parameters[(string) $attributes->key] = $value; - } - - return; } - throw new ConfigException('Container xml file(s) not found at '); + if (!$containerXmlPath) { + throw new ConfigException('Container xml file(s) not found!'); + } + + $xml->load($containerXmlPath); + + foreach ($this->container->getDefinitions() as $definition) { + $definitionFactory = $definition->getFactory(); + if ($definition->hasTag('container.service_locator_context') && is_array($definitionFactory)) { + /** @var Reference $reference */ + $reference = $definitionFactory[0]; + $this->classLocators[$definition->getTag('container.service_locator_context')[0]['id']] = (string) $reference; + } elseif ($definition->hasTag('container.service_locator')) { + continue; + } elseif ($className = $definition->getClass()) { + $this->classNames[] = $className; + } + } + + foreach ($this->container->findTaggedServiceIds('container.service_locator') as $key => $_) { + $definition = $this->container->getDefinition($key); + foreach ($definition->getArgument(0) as $id => $argument) { + if ($argument instanceof Reference) { + $this->addServiceLocator($key, $id, $argument); + } elseif ($argument instanceof ServiceClosureArgument) { + foreach ($argument->getValues() as $value) { + $this->addServiceLocator($key, $id, $value); + } + } + } + } + } + + private function addServiceLocator(string $key, string $id, Reference $reference): void + { + $this->serviceLocators[$key][$id] = (string) $reference; + + try { + $definition = $this->getDefinition((string) $reference); + $className = $definition->getClass(); + if ($className) { + $this->classNames[] = $className; + } + } catch (ServiceNotFoundException $e) { + } } /** - * @return mixed + * @throws ServiceNotFoundException */ - private function getXmlParameterValue(\SimpleXMLElement $parameter) + private function getDefinition(string $id): Definition { - $value = null; - $attributes = $parameter->attributes(); - if (isset($attributes->type)) { - switch ((string) $attributes->type) { - case 'binary': - $value = base64_decode((string) $parameter, true); - break; - case 'collection': - foreach ($parameter->children() as $child) { - $childAttributes = $child->attributes(); - if (isset($childAttributes->key)) { - $value[(string) $childAttributes->key] = $this->getXmlParameterValue($child); - } else { - $value[] = $this->getXmlParameterValue($child); - } - } - break; - case 'string': - default: - $value = (string) $parameter; - break; - } - } else { - $value = (string) $parameter; - if ('true' === $value || 'false' === $value) { - $value = (bool) $value; - } elseif ('null' === $value) { - $value = null; - } elseif (is_numeric($value)) { - if (false === strpos($value, '.')) { - $value = (int) $value; - } else { - $value = (float) $value; - } + try { + $definition = $this->container->getDefinition($id); + } catch (ServiceNotFoundException $serviceNotFoundException) { + try { + $alias = $this->container->getAlias($id); + } catch (InvalidArgumentException $e) { + throw $serviceNotFoundException; } + + $definition = $this->container->getDefinition((string) $alias); + $definition->setPublic($alias->isPublic()); } - return $value; + return $definition; } } diff --git a/src/Symfony/Service.php b/src/Symfony/Service.php deleted file mode 100644 index 5a5e7dc..0000000 --- a/src/Symfony/Service.php +++ /dev/null @@ -1,64 +0,0 @@ -id = $id; - $this->className = $className; - } - - public function isPublic(): bool - { - return $this->isPublic; - } - - public function setIsPublic(bool $isPublic): void - { - $this->isPublic = $isPublic; - } - - public function getId(): string - { - return $this->id; - } - - public function getClassName(): string - { - return $this->className; - } - - public function getAlias(): ?string - { - return $this->alias; - } - - public function setAlias(string $alias): void - { - $this->alias = $alias; - } - - public function setClassName(string $className): void - { - $this->className = $className; - } -} diff --git a/tests/acceptance/acceptance/ServiceSubscriber.feature b/tests/acceptance/acceptance/ServiceSubscriber.feature index ce2945b..d48039d 100644 --- a/tests/acceptance/acceptance/ServiceSubscriber.feature +++ b/tests/acceptance/acceptance/ServiceSubscriber.feature @@ -6,18 +6,18 @@ Feature: Service Subscriber """ ../../tests/acceptance/container.xml """ - - Scenario: Asserting psalm recognizes return type of services defined in getSubscribedServices - Given I have the following code + And I have the following code preamble """ container = $container; } - public function __invoke() - { - /** @psalm-trace $entityManager */ - $entityManager = $this->container->get('em'); - - /** @psalm-trace $validator */ - $validator = $this->container->get(ValidatorInterface::class); - } - public static function getSubscribedServices() { return [ - 'em' => EntityManagerInterface::class, // with key - ValidatorInterface::class, // without key + // takes container.xml into account ]; } - } """ - When I run Psalm - Then I see these errors - | Type | Message | - | Trace | $entityManager: Doctrine\ORM\EntityManagerInterface | - | Trace | $validator: Symfony\Component\Validator\Validator\ValidatorInterface | - And I see no other errors - - Scenario: Asserting psalm recognizes return type of services defined in getSubscribedServices using array_merge + Scenario: Asserting psalm recognizes return type of services defined in getSubscribedServices Given I have the following code """ - container->get('custom_service'); - } + /** @psalm-trace $service1 */ + $service1 = $this->container->get('dummy_service_with_locator'); - public static function getSubscribedServices(): array - { - return array_merge([ - 'custom_service' => EntityManagerInterface::class, - ], parent::getSubscribedServices()); + /** @psalm-trace $service2 */ + $service2 = $this->container->get('dummy_service_with_locator2'); + + /** @psalm-trace $service3 */ + $service3 = $this->container->get('dummy_service_with_locator3'); } } """ When I run Psalm Then I see these errors - | Type | Message | - | Trace | $entityManager: Doctrine\ORM\EntityManagerInterface | - And I see no other errors - - Scenario: Asserting psalm recognizes return type of services defined in getSubscribedServices, already defined as an alias in containerXml - Given I have the following code - """ - container = $container; - } - - public function __invoke() - { - /** @psalm-trace $kernel */ - $kernel = $this->container->get('http_kernel'); - } - - public static function getSubscribedServices(): array - { - return [ - 'http_kernel' => HttpKernelInterface::class, - ]; - } - } - """ - When I run Psalm - Then I see these errors - | Type | Message | - | Trace | $kernel: Symfony\Component\HttpKernel\HttpKernel | + | Type | Message | + | Trace | $service1: Psalm\SymfonyPsalmPlugin\Tests\Fixture\DummyPrivateService | + | Trace | $service2: Psalm\SymfonyPsalmPlugin\Tests\Fixture\DummyPrivateService | + | Trace | $service3: Psalm\SymfonyPsalmPlugin\Tests\Fixture\DummyPrivateService | And I see no other errors diff --git a/tests/acceptance/container.xml b/tests/acceptance/container.xml index ff0b677..fd096ac 100644 --- a/tests/acceptance/container.xml +++ b/tests/acceptance/container.xml @@ -1,42 +1,65 @@ - - dev - true - false - 1 - 1 - 3.14 - - val1 - val2 + + dev + true + false + 1 + 1 + 3.14 + + val1 + val2 + + + val + + true + 2.18 + + something - - val - - true - 2.18 - - something - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + App\Controller\DummyController + + + + diff --git a/tests/unit/Symfony/ContainerMetaTest.php b/tests/unit/Symfony/ContainerMetaTest.php index af18ff0..1e310d7 100644 --- a/tests/unit/Symfony/ContainerMetaTest.php +++ b/tests/unit/Symfony/ContainerMetaTest.php @@ -5,7 +5,9 @@ namespace Psalm\SymfonyPsalmPlugin\Tests\Symfony; use PHPUnit\Framework\TestCase; use Psalm\Exception\ConfigException; use Psalm\SymfonyPsalmPlugin\Symfony\ContainerMeta; -use Psalm\SymfonyPsalmPlugin\Symfony\Service; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\ParameterNotFoundException; +use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\HttpKernel\Kernel; /** @@ -29,86 +31,38 @@ class ContainerMetaTest extends TestCase } /** - * @testdox service attributes for > Symfony 3 + * @testdox service attributes * @dataProvider publicServices */ public function testServices($id, string $className, bool $isPublic) { - if (3 === Kernel::MAJOR_VERSION) { - $this->markTestSkipped('Should run for > Symfony 3'); - } - - $service = $this->containerMeta->get($id); - $this->assertInstanceOf(Service::class, $service); - $this->assertSame($className, $service->getClassName()); - $this->assertSame($isPublic, $service->isPublic()); + $serviceDefinition = $this->containerMeta->get($id); + $this->assertInstanceOf(Definition::class, $serviceDefinition); + $this->assertSame($className, $serviceDefinition->getClass()); + $this->assertSame($isPublic, $serviceDefinition->isPublic()); } - public function publicServices() + public function publicServices(): iterable { - return [ - [ - 'id' => 'service_container', - 'className' => 'Symfony\Component\DependencyInjection\ContainerInterface', - 'isPublic' => true, - ], - [ - 'id' => 'Foo\Bar', - 'className' => 'Foo\Bar', - 'isPublic' => false, - ], - [ - 'id' => 'Symfony\Component\HttpKernel\HttpKernelInterface', - 'className' => 'Symfony\Component\HttpKernel\HttpKernel', - 'isPublic' => true, - ], - [ - 'id' => 'public_service_wo_public_attr', - 'className' => 'Foo\Bar', - 'isPublic' => false, - ], + yield [ + 'id' => 'service_container', + 'className' => 'Symfony\Component\DependencyInjection\ContainerInterface', + 'isPublic' => true, ]; - } - - /** - * @testdox service attributes for Symfony 3 - * @dataProvider publicServices3 - */ - public function testServices3($id, string $className, bool $isPublic) - { - if (Kernel::MAJOR_VERSION > 3) { - $this->markTestSkipped('Should run for Symfony 3'); - } - - $service = $this->containerMeta->get($id); - $this->assertInstanceOf(Service::class, $service); - $this->assertSame($className, $service->getClassName()); - $this->assertSame($isPublic, $service->isPublic()); - } - - public function publicServices3() - { - return [ - [ - 'id' => 'service_container', - 'className' => 'Symfony\Component\DependencyInjection\ContainerInterface', - 'isPublic' => true, - ], - [ - 'id' => 'Foo\Bar', - 'className' => 'Foo\Bar', - 'isPublic' => false, - ], - [ - 'id' => 'Symfony\Component\HttpKernel\HttpKernelInterface', - 'className' => 'Symfony\Component\HttpKernel\HttpKernel', - 'isPublic' => true, - ], - [ - 'id' => 'public_service_wo_public_attr', - 'className' => 'Foo\Bar', - 'isPublic' => true, - ], + yield [ + 'id' => 'Foo\Bar', + 'className' => 'Foo\Bar', + 'isPublic' => false, + ]; + yield [ + 'id' => 'public_service_wo_public_attr', + 'className' => 'Foo\Bar', + 'isPublic' => Kernel::MAJOR_VERSION < 5, + ]; + yield [ + 'id' => 'doctrine.orm.entity_manager', + 'className' => 'Doctrine\ORM\EntityManager', + 'isPublic' => true, ]; } @@ -126,6 +80,7 @@ class ContainerMetaTest extends TestCase */ public function testNonExistentService() { + $this->expectException(ServiceNotFoundException::class); $this->assertNull($this->containerMeta->get('non-existent-service')); } @@ -136,7 +91,7 @@ class ContainerMetaTest extends TestCase { $containerMeta = new ContainerMeta(['non-existent-file.xml', __DIR__.'/../../acceptance/container.xml']); $service = $containerMeta->get('service_container'); - $this->assertSame('Symfony\Component\DependencyInjection\ContainerInterface', $service->getClassName()); + $this->assertSame('Symfony\Component\DependencyInjection\ContainerInterface', $service->getClass()); } public function testGetParameter(): void @@ -161,4 +116,33 @@ class ContainerMetaTest extends TestCase ] ], $this->containerMeta->getParameter('nested_collection')); } + + public function testGetParameterP(): void + { + $this->expectException(ParameterNotFoundException::class); + $this->containerMeta->getParameter('non_existent'); + } + + /** + * @dataProvider serviceLocatorProvider + */ + public function testGetServiceWithContext(string $id, string $contextClass, string $expectedClass): void + { + $serviceDefinition = $this->containerMeta->get($id, $contextClass); + $this->assertSame($expectedClass, $serviceDefinition->getClass()); + } + + public function serviceLocatorProvider(): iterable + { + yield [ + 'dummy_service_with_locator2', + 'App\Controller\DummyController', + 'Psalm\SymfonyPsalmPlugin\Tests\Fixture\DummyPrivateService' + ]; + yield [ + 'dummy_service_with_locator3', + 'App\Controller\DummyController', + 'Psalm\SymfonyPsalmPlugin\Tests\Fixture\DummyPrivateService' + ]; + } }