Skip to content

Support for Messenger HandleTrait return types #404

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jan 4, 2025
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/tests/tmp
/build-cs
/vendor
/.idea
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this please. This belongs to a global .gitignore.

/composer.lock
.phpunit.result.cache
12 changes: 12 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ services:
-
factory: @symfony.parameterMapFactory::create()

# message map
symfony.messageMapFactory:
class: PHPStan\Symfony\MessageMapFactory
factory: PHPStan\Symfony\MessageMapFactory
-
factory: @symfony.messageMapFactory::create()

# ControllerTrait::get()/has() return type
-
factory: PHPStan\Type\Symfony\ServiceDynamicReturnTypeExtension(Symfony\Component\DependencyInjection\ContainerInterface)
Expand Down Expand Up @@ -203,6 +210,11 @@ services:
factory: PHPStan\Type\Symfony\EnvelopeReturnTypeExtension
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]

# Messenger HandleTrait::handle() return type
-
class: PHPStan\Type\Symfony\MessengerHandleTraitReturnTypeExtension
tags: [phpstan.broker.expressionTypeResolverExtension]

# InputInterface::getArgument() return type
-
factory: PHPStan\Type\Symfony\InputInterfaceGetArgumentDynamicReturnTypeExtension
Expand Down
25 changes: 25 additions & 0 deletions src/Symfony/MessageMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php declare(strict_types = 1);

namespace PHPStan\Symfony;

use PHPStan\Type\Type;

final class MessageMap
{

/** @var array<class-string, Type> */
private $messageMap;

/** @param array<class-string, Type> $messageMap */
public function __construct(array $messageMap)
{
$this->messageMap = $messageMap;
}

/** @param class-string $class */
public function getTypeForClass(string $class): ?Type
{
return $this->messageMap[$class] ?? null;
}

}
143 changes: 143 additions & 0 deletions src/Symfony/MessageMapFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php declare(strict_types = 1);

namespace PHPStan\Symfony;

use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MissingMethodFromReflectionException;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\ShouldNotHappenException;
use Symfony\Component\Messenger\Handler\MessageSubscriberInterface;
use function class_exists;
use function count;
use function is_array;
use function is_int;
use function is_string;

final class MessageMapFactory
{

private const MESSENGER_HANDLER_TAG = 'messenger.message_handler';
private const DEFAULT_HANDLER_METHOD = '__invoke';

/** @var ReflectionProvider */
private $reflectionProvider;

/** @var ServiceMap */
private $serviceMap;

public function __construct(ServiceMap $symfonyServiceMap, ReflectionProvider $reflectionProvider)
{
$this->serviceMap = $symfonyServiceMap;
$this->reflectionProvider = $reflectionProvider;
}

public function create(): MessageMap
{
$returnTypesMap = [];

foreach ($this->serviceMap->getServices() as $service) {
$serviceClass = $service->getClass();

if ($serviceClass === null) {
continue;
}

foreach ($service->getTags() as $tag) {
if ($tag->getName() !== self::MESSENGER_HANDLER_TAG) {
continue;
}

/** @var array{handles?: class-string, method?: string} $tagAttributes */
$tagAttributes = $tag->getAttributes();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a @return stub for this method instead of @var?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shape is dynamic and rely on what tag we're using from SF config. In this case I'm ensuring above that we're handling only messenger.message_handler, so we know what shape it should have.

As far I know stubs are static only, so unfortunately we cannot use them here.

Do you have other idea or it could stay as it is?

$reflectionClass = $this->reflectionProvider->getClass($serviceClass);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unsafe. Ask for hasClass first.


if (isset($tagAttributes['handles'])) {
$handles = [$tagAttributes['handles'] => ['method' => $tagAttributes['method'] ?? self::DEFAULT_HANDLER_METHOD]];
} else {
$handles = $this->guessHandledMessages($reflectionClass);
}

foreach ($handles as $messageClassName => $options) {
$methodReflection = $reflectionClass->getNativeMethod($options['method'] ?? self::DEFAULT_HANDLER_METHOD);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unsafe. Ask for hasNativeMethod first.


foreach ($methodReflection->getVariants() as $variant) {
$returnTypesMap[$messageClassName][] = $variant->getReturnType();
}
}
}
}

$messageMap = [];
foreach ($returnTypesMap as $messageClassName => $returnTypes) {
if (count($returnTypes) !== 1) {
continue;
}

$messageMap[$messageClassName] = $returnTypes[0];
}

return new MessageMap($messageMap);
}

/** @return array<class-string, array<string, string>> */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not return an array. It's a generator.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, changed to iterable :)

private function guessHandledMessages(ClassReflection $reflectionClass): iterable
{
if ($reflectionClass->implementsInterface(MessageSubscriberInterface::class)) {
foreach ($reflectionClass->getName()::getHandledMessages() as $index => $value) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Save the class name into a variable first, then call the method.

if (self::containOptions($index, $value)) {
yield $index => $value;
} else {
yield $value => ['method' => self::DEFAULT_HANDLER_METHOD];
}
}

return;
}

try {
$methodReflection = $reflectionClass->getNativeMethod(self::DEFAULT_HANDLER_METHOD);
} catch (MissingMethodFromReflectionException $e) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ask for hasNativeMethod first, don't catch the exception.

return;
}

$variants = $methodReflection->getVariants();
if (count($variants) !== 1) {
return;
}

$parameters = $variants[0]->getParameters();

if (count($parameters) !== 1) {
return;
}

/** @var class-string[] $classNames */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't add this @var, this assumption might be wrong.

$classNames = $parameters[0]->getType()->getObjectClassNames();

if (count($classNames) !== 1) {
return;
}

yield $classNames[0] => ['method' => self::DEFAULT_HANDLER_METHOD];
}

/**
* @param mixed $index
* @param mixed $value
* @phpstan-assert-if-true class-string $index
* @phpstan-assert-if-true array<string, mixed> $value
* @phpstan-assert-if-false int $index
* @phpstan-assert-if-false class-string $value
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*/
private static function containOptions($index, $value): bool
{
if (is_string($index) && class_exists($index) && is_array($value)) {
return true;
} elseif (is_int($index) && is_string($value) && class_exists($value)) {
return false;
}

throw new ShouldNotHappenException();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say this can happen very easily. Wrong getHandledMessages implementation should not crash PHPStan.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, so now I'm catching that case and skipping when wrongly implemented method.

}

}
13 changes: 12 additions & 1 deletion src/Symfony/Service.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,25 @@ final class Service implements ServiceDefinition
/** @var string|null */
private $alias;

/** @var ServiceTag[] */
private $tags;

/** @param ServiceTag[] $tags */
public function __construct(
string $id,
?string $class,
bool $public,
bool $synthetic,
?string $alias
?string $alias,
array $tags = []
)
{
$this->id = $id;
$this->class = $class;
$this->public = $public;
$this->synthetic = $synthetic;
$this->alias = $alias;
$this->tags = $tags;
}

public function getId(): string
Expand Down Expand Up @@ -60,4 +66,9 @@ public function getAlias(): ?string
return $this->alias;
}

public function getTags(): array
{
return $this->tags;
}

}
3 changes: 3 additions & 0 deletions src/Symfony/ServiceDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ public function isSynthetic(): bool;

public function getAlias(): ?string;

/** @return ServiceTag[] */
public function getTags(): array;

}
31 changes: 31 additions & 0 deletions src/Symfony/ServiceTag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php declare(strict_types = 1);

namespace PHPStan\Symfony;

final class ServiceTag implements ServiceTagDefinition
{

/** @var string */
private $name;

/** @var array<string, string> */
private $attributes;

/** @param array<string, string> $attributes */
public function __construct(string $name, array $attributes = [])
{
$this->name = $name;
$this->attributes = $attributes;
}

public function getName(): string
{
return $this->name;
}

public function getAttributes(): array
{
return $this->attributes;
}

}
13 changes: 13 additions & 0 deletions src/Symfony/ServiceTagDefinition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace PHPStan\Symfony;

interface ServiceTagDefinition
{

public function getName(): string;

/** @return array<string, string> */
public function getAttributes(): array;

}
12 changes: 11 additions & 1 deletion src/Symfony/XmlServiceMapFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,22 @@ public function create(): ServiceMap
continue;
}

$serviceTags = [];
foreach ($def->tag as $tag) {
$tagAttrs = ((array) $tag->attributes())['@attributes'] ?? [];
$tagName = $tagAttrs['name'];
unset($tagAttrs['name']);

$serviceTags[] = new ServiceTag($tagName, $tagAttrs);
}

$service = new Service(
$this->cleanServiceId((string) $attrs->id),
isset($attrs->class) ? (string) $attrs->class : null,
isset($attrs->public) && (string) $attrs->public === 'true',
isset($attrs->synthetic) && (string) $attrs->synthetic === 'true',
isset($attrs->alias) ? $this->cleanServiceId((string) $attrs->alias) : null
isset($attrs->alias) ? $this->cleanServiceId((string) $attrs->alias) : null,
$serviceTags
);

if ($service->getAlias() !== null) {
Expand Down
Loading