@@ -426,6 +426,229 @@ public void testTwigExtensionsImplicitUsage() {
426426 assertImplicitUsage (multiAttributeTwigExtensionClass .findOwnMethodByName ("callableMethod" ));
427427 }
428428
429+ public void testClassLevelFrameworkAttributesAreMarkedUsed () {
430+ PsiFile psiFile = myFixture .configureByText (PhpFileType .INSTANCE , "<?php\n " +
431+ "namespace App\\ Usage;\n " +
432+ "\n " +
433+ "use Doctrine\\ Bundle\\ DoctrineBundle\\ Attribute\\ AsDoctrineListener;\n " +
434+ "use Doctrine\\ ORM\\ Events;\n " +
435+ "use Symfony\\ Component\\ Console\\ Attribute\\ AsCommand;\n " +
436+ "use Symfony\\ Component\\ DependencyInjection\\ Attribute\\ AutoconfigureTag;\n " +
437+ "use Symfony\\ Component\\ EventDispatcher\\ Attribute\\ AsEventListener;\n " +
438+ "use Symfony\\ Component\\ HttpKernel\\ KernelEvents;\n " +
439+ "use Symfony\\ Component\\ Messenger\\ Attribute\\ AsMessageHandler;\n " +
440+ "use Symfony\\ Component\\ Scheduler\\ Attribute\\ AsSchedule;\n " +
441+ "use Symfony\\ Component\\ Validator\\ Constraints as Assert;\n " +
442+ "use Symfony\\ Component\\ Workflow\\ Attribute\\ AsAnnounceListener;\n " +
443+ "use Symfony\\ Component\\ Workflow\\ Attribute\\ AsCompletedListener;\n " +
444+ "use Symfony\\ Component\\ Workflow\\ Attribute\\ AsEnterListener;\n " +
445+ "use Symfony\\ Component\\ Workflow\\ Attribute\\ AsEnteredListener;\n " +
446+ "use Symfony\\ Component\\ Workflow\\ Attribute\\ AsGuardListener;\n " +
447+ "use Symfony\\ Component\\ Workflow\\ Attribute\\ AsLeaveListener;\n " +
448+ "use Symfony\\ Component\\ Workflow\\ Attribute\\ AsTransitionListener;\n " +
449+ "use Symfony\\ UX\\ TwigComponent\\ Attribute\\ AsTwigComponent;\n " +
450+ "\n " +
451+ "#[AsCommand(name: 'app:usage:interactive')]\n " +
452+ "#[AsEventListener(event: KernelEvents::EXCEPTION, method: 'onKernelException')]\n " +
453+ "#[AsMessageHandler]\n " +
454+ "#[AsSchedule('usage')]\n " +
455+ "#[AsAnnounceListener]\n " +
456+ "#[AsCompletedListener]\n " +
457+ "#[AsEnterListener]\n " +
458+ "#[AsEnteredListener]\n " +
459+ "#[AsGuardListener]\n " +
460+ "#[AsLeaveListener]\n " +
461+ "#[AsTransitionListener]\n " +
462+ "#[AsTwigComponent('usage_status_badge')]\n " +
463+ "#[Assert\\ Callback('validateShippingWindow')]\n " +
464+ "#[AsDoctrineListener(event: Events::preFlush)]\n " +
465+ "#[AutoconfigureTag('doctrine.event_listener', ['event' => 'postPersist'])]\n " +
466+ "class UsageAttributes\n " +
467+ "{\n " +
468+ " public function validateShippingWindow() {}\n " +
469+ " public function onKernelException() {}\n " +
470+ " public function unrelated() {}\n " +
471+ "}"
472+ );
473+
474+ PhpClass phpClass = PhpElementsUtil .getFirstClassFromFile ((PhpFile ) psiFile .getContainingFile ());
475+
476+ assertImplicitUsage (phpClass );
477+ assertImplicitUsage (phpClass .findOwnMethodByName ("validateShippingWindow" ));
478+ assertImplicitUsage (phpClass .findOwnMethodByName ("onKernelException" ));
479+ assertNotImplicitUsage (phpClass .findOwnMethodByName ("unrelated" ));
480+ }
481+
482+ public void testMcpCapabilitiesAreMarkedUsed () {
483+ PsiFile invokableCapabilityPsiFile = myFixture .configureByText (PhpFileType .INSTANCE , "<?php\n " +
484+ "namespace App\\ Mcp;\n " +
485+ "\n " +
486+ "use Mcp\\ Capability\\ Attribute\\ McpTool;\n " +
487+ "\n " +
488+ "#[McpTool(name: 'current-time')]\n " +
489+ "final class CurrentTimeTool\n " +
490+ "{\n " +
491+ " public function __invoke(string $format = 'Y-m-d H:i:s'): string\n " +
492+ " {\n " +
493+ " return 'now';\n " +
494+ " }\n " +
495+ "\n " +
496+ " public function unused(): string\n " +
497+ " {\n " +
498+ " return 'unused';\n " +
499+ " }\n " +
500+ "}"
501+ );
502+
503+ PhpClass invokableCapabilityClass = PhpElementsUtil .getFirstClassFromFile ((PhpFile ) invokableCapabilityPsiFile .getContainingFile ());
504+ assertImplicitUsage (invokableCapabilityClass );
505+ assertImplicitUsage (invokableCapabilityClass .findOwnMethodByName ("__invoke" ));
506+ assertNotImplicitUsage (invokableCapabilityClass .findOwnMethodByName ("unused" ));
507+
508+ PsiFile methodCapabilityPsiFile = myFixture .configureByText (PhpFileType .INSTANCE , "<?php\n " +
509+ "namespace App\\ Mcp;\n " +
510+ "\n " +
511+ "use Mcp\\ Capability\\ Attribute\\ McpPrompt;\n " +
512+ "use Mcp\\ Capability\\ Attribute\\ McpResource;\n " +
513+ "use Mcp\\ Capability\\ Attribute\\ McpResourceTemplate;\n " +
514+ "\n " +
515+ "final class TimeCapabilities\n " +
516+ "{\n " +
517+ " #[McpPrompt(name: 'time-analysis')]\n " +
518+ " public function timeAnalysis(): array\n " +
519+ " {\n " +
520+ " return [];\n " +
521+ " }\n " +
522+ "\n " +
523+ " #[McpResource(uri: 'time://current', name: 'current-time')]\n " +
524+ " public function currentTime(): array\n " +
525+ " {\n " +
526+ " return [];\n " +
527+ " }\n " +
528+ "\n " +
529+ " #[McpResourceTemplate(uriTemplate: 'time://{timezone}', name: 'time-by-timezone')]\n " +
530+ " public function timeByTimezone(string $timezone): array\n " +
531+ " {\n " +
532+ " return [];\n " +
533+ " }\n " +
534+ "\n " +
535+ " public function unused(): array\n " +
536+ " {\n " +
537+ " return [];\n " +
538+ " }\n " +
539+ "}"
540+ );
541+
542+ PhpClass methodCapabilityClass = PhpElementsUtil .getFirstClassFromFile ((PhpFile ) methodCapabilityPsiFile .getContainingFile ());
543+ assertNotImplicitUsage (methodCapabilityClass );
544+ assertImplicitUsage (methodCapabilityClass .findOwnMethodByName ("timeAnalysis" ));
545+ assertImplicitUsage (methodCapabilityClass .findOwnMethodByName ("currentTime" ));
546+ assertImplicitUsage (methodCapabilityClass .findOwnMethodByName ("timeByTimezone" ));
547+ assertNotImplicitUsage (methodCapabilityClass .findOwnMethodByName ("unused" ));
548+ }
549+
550+ public void testAutoconfigureTagIsMarkedUsedForNamedAndDefaultFqcnForms () {
551+ PsiFile psiFile = myFixture .configureByText (PhpFileType .INSTANCE , "<?php\n " +
552+ "namespace App\\ Usage;\n " +
553+ "\n " +
554+ "use Symfony\\ Component\\ DependencyInjection\\ Attribute\\ AutoconfigureTag;\n " +
555+ "\n " +
556+ "#[AutoconfigureTag('app.other_tag')]\n " +
557+ "class NamedAutoconfigureTagUsage\n " +
558+ "{\n " +
559+ "}"
560+ );
561+
562+ PhpClass phpClass = PhpElementsUtil .getFirstClassFromFile ((PhpFile ) psiFile .getContainingFile ());
563+ assertImplicitUsage (phpClass );
564+
565+ PsiFile defaultPsiFile = myFixture .configureByText (PhpFileType .INSTANCE , "<?php\n " +
566+ "namespace App\\ Usage;\n " +
567+ "\n " +
568+ "use Symfony\\ Component\\ DependencyInjection\\ Attribute\\ AutoconfigureTag;\n " +
569+ "\n " +
570+ "#[AutoconfigureTag]\n " +
571+ "interface DefaultAutoconfigureTagUsage\n " +
572+ "{\n " +
573+ "}"
574+ );
575+
576+ PhpClass defaultPhpClass = PhpElementsUtil .getFirstClassFromFile ((PhpFile ) defaultPsiFile .getContainingFile ());
577+ assertImplicitUsage (defaultPhpClass );
578+ }
579+
580+ public void testDoctrineLifecycleMethodsAreMarkedUsedOnEntities () {
581+ PsiFile psiFile = myFixture .configureByText (PhpFileType .INSTANCE , "<?php\n " +
582+ "namespace App\\ Entity;\n " +
583+ "\n " +
584+ "use Doctrine\\ ORM\\ Event\\ PostLoadEventArgs;\n " +
585+ "use Doctrine\\ ORM\\ Event\\ PreFlushEventArgs;\n " +
586+ "use Doctrine\\ ORM\\ Event\\ PrePersistEventArgs;\n " +
587+ "use Doctrine\\ ORM\\ Mapping as ORM;\n " +
588+ "\n " +
589+ "#[ORM\\ Entity]\n " +
590+ "class LifecycleEntity\n " +
591+ "{\n " +
592+ " #[ORM\\ PrePersist]\n " +
593+ " public function beforePersist(PrePersistEventArgs $event): void\n " +
594+ " {\n " +
595+ " }\n " +
596+ "\n " +
597+ " #[ORM\\ PreFlush]\n " +
598+ " public function beforeFlush(PreFlushEventArgs $event): void\n " +
599+ " {\n " +
600+ " }\n " +
601+ "\n " +
602+ " #[ORM\\ PostLoad]\n " +
603+ " public function afterLoad(PostLoadEventArgs $event): void\n " +
604+ " {\n " +
605+ " }\n " +
606+ "\n " +
607+ " public function unrelated(): void\n " +
608+ " {\n " +
609+ " }\n " +
610+ "}"
611+ );
612+
613+ PhpClass phpClass = PhpElementsUtil .getFirstClassFromFile ((PhpFile ) psiFile .getContainingFile ());
614+
615+ assertImplicitUsage (phpClass .findOwnMethodByName ("beforePersist" ));
616+ assertImplicitUsage (phpClass .findOwnMethodByName ("beforeFlush" ));
617+ assertImplicitUsage (phpClass .findOwnMethodByName ("afterLoad" ));
618+ assertNotImplicitUsage (phpClass .findOwnMethodByName ("unrelated" ));
619+ }
620+
621+ public void testDoctrineAsEntityListenerMethodIsMarkedUsed () {
622+ PsiFile psiFile = myFixture .configureByText (PhpFileType .INSTANCE , "<?php\n " +
623+ "declare(strict_types=1);\n " +
624+ "\n " +
625+ "namespace App\\ Usage\\ Doctrine;\n " +
626+ "\n " +
627+ "use Doctrine\\ Bundle\\ DoctrineBundle\\ Attribute\\ AsEntityListener;\n " +
628+ "use Doctrine\\ ORM\\ Event\\ PostPersistEventArgs;\n " +
629+ "use Doctrine\\ ORM\\ Events;\n " +
630+ "use App\\ Entity\\ FooEntity;\n " +
631+ "\n " +
632+ "#[AsEntityListener(event: Events::postPersist, method: 'whenPostPersist', entity: FooEntity::class)]\n " +
633+ "final class AsEntityListenerFixture\n " +
634+ "{\n " +
635+ " public function whenPostPersist(FooEntity $entity, PostPersistEventArgs $event): void\n " +
636+ " {\n " +
637+ " }\n " +
638+ "\n " +
639+ " public function unrelated(): void\n " +
640+ " {\n " +
641+ " }\n " +
642+ "}"
643+ );
644+
645+ PhpClass phpClass = PhpElementsUtil .getFirstClassFromFile ((PhpFile ) psiFile .getContainingFile ());
646+
647+ assertImplicitUsage (phpClass );
648+ assertImplicitUsage (phpClass .findOwnMethodByName ("whenPostPersist" ));
649+ assertNotImplicitUsage (phpClass .findOwnMethodByName ("unrelated" ));
650+ }
651+
429652 private PhpClass createPhpControllerClassWithRouteContent (@ NotNull String content ) {
430653 return createPhpControllerClassWithRouteContent ("\\ App\\ Controller\\ FooController" , content );
431654 }
0 commit comments