Skip to content

Commit bb432f4

Browse files
committed
Add more implicit usage detection for PHP 8 attributes of Symfony components and Doctrine
1 parent d14163d commit bb432f4

File tree

3 files changed

+492
-2
lines changed

3 files changed

+492
-2
lines changed

src/main/java/fr/adrienbrault/idea/symfony2plugin/codeInsight/SymfonyImplicitUsageProvider.java

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88
import com.jetbrains.php.lang.psi.stubs.indexes.expectedArguments.PhpExpectedFunctionScalarArgument;
99
import de.espend.idea.php.annotation.dict.PhpDocCommentAnnotation;
1010
import de.espend.idea.php.annotation.util.AnnotationUtil;
11+
import fr.adrienbrault.idea.symfony2plugin.completion.PhpAttributeScopeValidator;
1112
import fr.adrienbrault.idea.symfony2plugin.doctrine.metadata.util.DoctrineMetadataUtil;
1213
import fr.adrienbrault.idea.symfony2plugin.routing.RouteHelper;
1314
import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil;
15+
import fr.adrienbrault.idea.symfony2plugin.util.PhpPsiAttributesUtil;
1416
import fr.adrienbrault.idea.symfony2plugin.util.PsiElementUtils;
1517
import fr.adrienbrault.idea.symfony2plugin.util.dict.ServiceUtil;
1618
import org.apache.commons.lang3.StringUtils;
1719
import org.jetbrains.annotations.NotNull;
20+
import org.jetbrains.annotations.Nullable;
1821

1922
import java.util.Arrays;
2023
import java.util.Collection;
@@ -27,12 +30,51 @@
2730
* @author Daniel Espendiller <daniel@espendiller.net>
2831
*/
2932
public class SymfonyImplicitUsageProvider implements ImplicitUsageProvider {
33+
private static final Set<String> CLASS_LEVEL_IMPLICIT_USAGE_ATTRIBUTES = Set.of(
34+
"\\Symfony\\Component\\Console\\Attribute\\AsCommand",
35+
"\\Symfony\\Component\\EventDispatcher\\Attribute\\AsEventListener",
36+
"\\Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler",
37+
"\\Symfony\\Component\\Scheduler\\Attribute\\AsSchedule",
38+
"\\Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent",
39+
"\\Doctrine\\Bundle\\DoctrineBundle\\Attribute\\AsDoctrineListener",
40+
"\\Doctrine\\Bundle\\DoctrineBundle\\Attribute\\AsEntityListener",
41+
"\\Symfony\\Component\\DependencyInjection\\Attribute\\AutoconfigureTag",
42+
"\\Symfony\\Component\\Validator\\Constraints\\Callback",
43+
"\\Symfony\\Component\\Workflow\\Attribute\\AsAnnounceListener",
44+
"\\Symfony\\Component\\Workflow\\Attribute\\AsCompletedListener",
45+
"\\Symfony\\Component\\Workflow\\Attribute\\AsEnterListener",
46+
"\\Symfony\\Component\\Workflow\\Attribute\\AsEnteredListener",
47+
"\\Symfony\\Component\\Workflow\\Attribute\\AsGuardListener",
48+
"\\Symfony\\Component\\Workflow\\Attribute\\AsLeaveListener",
49+
"\\Symfony\\Component\\Workflow\\Attribute\\AsTransitionListener"
50+
);
51+
private static final Set<String> DOCTRINE_LIFECYCLE_METHOD_ATTRIBUTES = Set.of(
52+
"\\Doctrine\\ORM\\Mapping\\PrePersist",
53+
"\\Doctrine\\ORM\\Mapping\\PostPersist",
54+
"\\Doctrine\\ORM\\Mapping\\PreUpdate",
55+
"\\Doctrine\\ORM\\Mapping\\PostUpdate",
56+
"\\Doctrine\\ORM\\Mapping\\PreRemove",
57+
"\\Doctrine\\ORM\\Mapping\\PostRemove",
58+
"\\Doctrine\\ORM\\Mapping\\PostLoad",
59+
"\\Doctrine\\ORM\\Mapping\\PreFlush"
60+
);
61+
private static final Set<String> MCP_CAPABILITY_ATTRIBUTES = Set.of(
62+
"\\Mcp\\Capability\\Attribute\\McpTool",
63+
"\\Mcp\\Capability\\Attribute\\McpPrompt",
64+
"\\Mcp\\Capability\\Attribute\\McpResource",
65+
"\\Mcp\\Capability\\Attribute\\McpResourceTemplate"
66+
);
67+
3068
@Override
3169
public boolean isImplicitUsage(@NotNull PsiElement element) {
3270
if (element instanceof Method method && method.getAccess() == PhpModifier.Access.PUBLIC) {
3371
return isMethodARoute(method)
3472
|| isSubscribedEvent(method)
3573
|| isAsEventListenerMethodPhpAttribute(method)
74+
|| isAsEntityListenerMethodPhpAttribute(method)
75+
|| isAssertCallbackMethod(method)
76+
|| isDoctrineLifecycleCallbackMethod(method)
77+
|| isMcpCapabilityMethod(method)
3678
|| hasTwigAttribute(method);
3779
} else if (element instanceof PhpClass phpClass) {
3880
return isRouteClass(phpClass)
@@ -42,12 +84,48 @@ public boolean isImplicitUsage(@NotNull PsiElement element) {
4284
|| isTwigExtension(phpClass)
4385
|| isEntityRepository(phpClass)
4486
|| isConstraint(phpClass)
45-
|| isKernelEventListener(phpClass);
87+
|| isKernelEventListener(phpClass)
88+
|| isMcpCapabilityClass(phpClass)
89+
|| hasClassLevelImplicitUsageAttribute(phpClass);
4690
}
4791

4892
return false;
4993
}
5094

95+
private boolean isDoctrineLifecycleCallbackMethod(@NotNull Method method) {
96+
PhpClass containingClass = method.getContainingClass();
97+
if (containingClass == null || !isDoctrineEntity(containingClass)) {
98+
return false;
99+
}
100+
101+
return DOCTRINE_LIFECYCLE_METHOD_ATTRIBUTES.stream().anyMatch(attribute -> !method.getAttributes(attribute).isEmpty());
102+
}
103+
104+
private boolean isDoctrineEntity(@NotNull PhpClass phpClass) {
105+
return PhpAttributeScopeValidator.isDoctrineEntityClass(phpClass);
106+
}
107+
108+
private boolean isMcpCapabilityClass(@NotNull PhpClass phpClass) {
109+
return hasMcpCapabilityAttribute(phpClass);
110+
}
111+
112+
private boolean isMcpCapabilityMethod(@NotNull Method method) {
113+
return hasOwnMcpCapabilityAttribute(method)
114+
|| "__invoke".equals(method.getName()) && hasMcpCapabilityAttribute(method.getContainingClass());
115+
}
116+
117+
private boolean hasMcpCapabilityAttribute(@Nullable PhpClass phpClass) {
118+
return phpClass != null && MCP_CAPABILITY_ATTRIBUTES.stream().anyMatch(attribute -> !phpClass.getAttributes(attribute).isEmpty());
119+
}
120+
121+
private boolean hasOwnMcpCapabilityAttribute(@NotNull Method method) {
122+
return MCP_CAPABILITY_ATTRIBUTES.stream().anyMatch(attribute -> !method.getAttributes(attribute).isEmpty());
123+
}
124+
125+
private boolean hasClassLevelImplicitUsageAttribute(@NotNull PhpClass phpClass) {
126+
return CLASS_LEVEL_IMPLICIT_USAGE_ATTRIBUTES.stream().anyMatch(attribute -> !phpClass.getAttributes(attribute).isEmpty());
127+
}
128+
51129
private boolean isKernelEventListener(@NotNull PhpClass phpClass) {
52130
if (!ServiceUtil.isPhpClassAService(phpClass)) {
53131
return false;
@@ -231,6 +309,38 @@ private boolean isAsEventListenerMethodPhpAttribute(@NotNull Method method) {
231309
return false;
232310
}
233311

312+
private boolean isAssertCallbackMethod(@NotNull Method method) {
313+
PhpClass containingClass = method.getContainingClass();
314+
if (containingClass == null) {
315+
return false;
316+
}
317+
318+
for (PhpAttribute attribute : containingClass.getAttributes("\\Symfony\\Component\\Validator\\Constraints\\Callback")) {
319+
String callback = PhpPsiAttributesUtil.getAttributeValueByNameAsStringWithDefaultParameterFallback(attribute, "callback");
320+
if (method.getName().equals(callback)) {
321+
return true;
322+
}
323+
}
324+
325+
return false;
326+
}
327+
328+
private boolean isAsEntityListenerMethodPhpAttribute(@NotNull Method method) {
329+
PhpClass containingClass = method.getContainingClass();
330+
if (containingClass == null) {
331+
return false;
332+
}
333+
334+
for (PhpAttribute attribute : containingClass.getAttributes("\\Doctrine\\Bundle\\DoctrineBundle\\Attribute\\AsEntityListener")) {
335+
String callback = PhpPsiAttributesUtil.getAttributeValueByNameAsString(attribute, "method");
336+
if (method.getName().equals(callback)) {
337+
return true;
338+
}
339+
}
340+
341+
return false;
342+
}
343+
234344
private boolean hasTwigAttribute(@NotNull Method method) {
235345
return !method.getAttributes("\\Twig\\Attribute\\AsTwigFilter").isEmpty()
236346
|| !method.getAttributes("\\Twig\\Attribute\\AsTwigFunction").isEmpty()

src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/codeInsight/SymfonyImplicitUsageProviderTest.java

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)