diff --git a/README.md b/README.md index 2b9b031b..b895d6b4 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ $ vendor/bin/phpstan - constructors, calls, factory methods - [`phpstan/phpstan-symfony`](https://github.com/phpstan/phpstan-symfony) with `containerXmlPath` must be used - `#[AsEventListener]` attribute +- `#[AsMessageHandler]` attribute - `#[AsController]` attribute - `#[AsCommand]` attribute - `#[Required]` attribute diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index 7c622504..5d14ca59 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -363,6 +363,10 @@ protected function shouldMarkAsUsed(ReflectionMethod $method): ?string return 'Event listener method via #[AsEventListener] attribute'; } + if ($this->isMessageHandlerMethodWithAsMessageHandlerAttribute($method)) { + return 'Message handler method via #[AsMessageHandler] attribute'; + } + if ($this->isWorkflowEventListenerMethod($method)) { return 'Workflow event listener method via workflow attribute'; } @@ -500,6 +504,49 @@ protected function isEventListenerMethodWithAsEventListenerAttribute(ReflectionM || $this->hasAttribute($method, 'Symfony\Component\EventDispatcher\Attribute\AsEventListener'); } + protected function isMessageHandlerMethodWithAsMessageHandlerAttribute(ReflectionMethod $method): bool + { + $class = $method->getDeclaringClass(); + $methodName = $method->getName(); + + // Check if this method has the attribute directly (fallback to method name itself if no target specified) + foreach ($method->getAttributes('Symfony\Component\Messenger\Attribute\AsMessageHandler') as $attribute) { + $arguments = $attribute->getArguments(); + $targetMethod = $arguments['method'] ?? $arguments[3] ?? $methodName; + + if ($targetMethod === $methodName) { + return true; + } + } + + // Check class-level attributes (fallback to __invoke if no target specified) + foreach ($class->getAttributes('Symfony\Component\Messenger\Attribute\AsMessageHandler') as $attribute) { + $arguments = $attribute->getArguments(); + $targetMethod = $arguments['method'] ?? $arguments[3] ?? '__invoke'; + + if ($targetMethod === $methodName) { + return true; + } + } + + // Check if any other method points to this method (only if explicitly specified) + foreach ($class->getMethods() as $otherMethod) { + if ($otherMethod->getName() === $methodName) { + continue; + } + + foreach ($otherMethod->getAttributes('Symfony\Component\Messenger\Attribute\AsMessageHandler') as $attribute) { + $arguments = $attribute->getArguments(); + $targetMethod = $arguments['method'] ?? $arguments[3] ?? null; + if ($methodName === $targetMethod) { + return true; + } + } + } + + return false; + } + protected function isWorkflowEventListenerMethod(ReflectionMethod $method): bool { return $this->hasAttribute($method, 'Symfony\Component\Workflow\Attribute\AsAnnounceListener') diff --git a/tests/Rule/data/providers/symfony-gte71.php b/tests/Rule/data/providers/symfony-gte71.php index 41997727..21b7ed40 100644 --- a/tests/Rule/data/providers/symfony-gte71.php +++ b/tests/Rule/data/providers/symfony-gte71.php @@ -4,6 +4,7 @@ use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Workflow\Attribute\AsAnnounceListener; use Symfony\Component\Workflow\Attribute\AsCompletedListener; @@ -100,3 +101,76 @@ public function deadMethod(): void // error: Unused Symfony\WorkflowEventListene { } } + +// Test AsMessageHandler with default __invoke method +#[AsMessageHandler] +class MessageHandlerWithInvoke +{ + public function __invoke(): void + { + } + + public function deadMethod(): void // error: Unused Symfony\MessageHandlerWithInvoke::deadMethod + { + } +} + +// Test AsMessageHandler with custom method +#[AsMessageHandler(method: 'handleMessage')] +class MessageHandlerWithCustomMethod +{ + public function handleMessage(): void + { + } + + public function deadMethod(): void // error: Unused Symfony\MessageHandlerWithCustomMethod::deadMethod + { + } +} + +// Test AsMessageHandler on method level without parameters +class MessageHandlerWithMethodAttribute +{ + #[AsMessageHandler] + public function handleDirectly(): void + { + } + + public function deadMethod(): void // error: Unused Symfony\MessageHandlerWithMethodAttribute::deadMethod + { + } +} + +// Test AsMessageHandler on method level with method parameter (edge case) +class MessageHandlerWithMethodLevelRedirect +{ + #[AsMessageHandler(null, null, null, 'actualHandler')] + public function annotatedMethod(): void // error: Unused Symfony\MessageHandlerWithMethodLevelRedirect::annotatedMethod + { + } + + public function actualHandler(): void + { + } + + public function deadMethod(): void // error: Unused Symfony\MessageHandlerWithMethodLevelRedirect::deadMethod + { + } +} + +// Test AsMessageHandler on method level with named parameter (edge case) +class MessageHandlerWithMethodLevelNamedRedirect +{ + #[AsMessageHandler(method: 'realHandler')] + public function annotatedMethod(): void // error: Unused Symfony\MessageHandlerWithMethodLevelNamedRedirect::annotatedMethod + { + } + + public function realHandler(): void + { + } + + public function deadMethod(): void // error: Unused Symfony\MessageHandlerWithMethodLevelNamedRedirect::deadMethod + { + } +}