From 6959d172e09430154994dfe0d4053c2dae34c9ae Mon Sep 17 00:00:00 2001 From: Daniel Espendiller Date: Wed, 17 Dec 2025 18:04:59 +0100 Subject: [PATCH] Add Twig attribute completions (`#[AsTwigFilter]`, `#[AsTwigFunction]`, `#[AsTwigTest]`) for TwigExtension classes. --- .../PhpAttributeCompletionContributor.java | 89 +++++++++++++++ ...PhpAttributeCompletionContributorTest.java | 107 ++++++++++++++++++ .../tests/completion/fixtures/classes.php | 20 ++++ 3 files changed, 216 insertions(+) diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java index aa5af24df..5cf228e8b 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java @@ -37,6 +37,10 @@ public class PhpAttributeCompletionContributor extends CompletionContributor { private static final String ROUTE_ATTRIBUTE_FQN = "\\Symfony\\Component\\Routing\\Attribute\\Route"; private static final String IS_GRANTED_ATTRIBUTE_FQN = "\\Symfony\\Component\\Security\\Http\\Attribute\\IsGranted"; private static final String CACHE_ATTRIBUTE_FQN = "\\Symfony\\Component\\HttpKernel\\Attribute\\Cache"; + private static final String AS_TWIG_FILTER_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigFilter"; + private static final String AS_TWIG_FUNCTION_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigFunction"; + private static final String AS_TWIG_TEST_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigTest"; + private static final String TWIG_EXTENSION_FQN = "\\Twig\\Extension\\AbstractExtension"; public PhpAttributeCompletionContributor() { // Match any element in PHP files - we'll do more specific checking in the provider @@ -76,6 +80,10 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull lookupElements.addAll(getControllerCompletions(project)); } + if (containingClass != null && isTwigExtensionClass(containingClass)) { + lookupElements.addAll(getTwigExtensionCompletions(project)); + } + // Stop here - don't show other completions when typing "#" for attributes if (!lookupElements.isEmpty()) { result.addAllElements(lookupElements); @@ -125,6 +133,87 @@ private Collection getControllerCompletions(@NotNull Project proj return lookupElements; } + private Collection getTwigExtensionCompletions(@NotNull Project project) { + Collection lookupElements = new ArrayList<>(); + + // Add AsTwigFilter attribute completion + if (PhpElementsUtil.getClassInterface(project, AS_TWIG_FILTER_ATTRIBUTE_FQN) != null) { + LookupElement lookupElement = LookupElementBuilder + .create("#[AsTwigFilter]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(AS_TWIG_FILTER_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_FILTER_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES)) + .bold(); + + lookupElements.add(lookupElement); + } + + // Add AsTwigFunction attribute completion + if (PhpElementsUtil.getClassInterface(project, AS_TWIG_FUNCTION_ATTRIBUTE_FQN) != null) { + LookupElement lookupElement = LookupElementBuilder + .create("#[AsTwigFunction]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(AS_TWIG_FUNCTION_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_FUNCTION_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES)) + .bold(); + + lookupElements.add(lookupElement); + } + + // Add AsTwigTest attribute completion + if (PhpElementsUtil.getClassInterface(project, AS_TWIG_TEST_ATTRIBUTE_FQN) != null) { + LookupElement lookupElement = LookupElementBuilder + .create("#[AsTwigTest]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(AS_TWIG_TEST_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_TEST_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES)) + .bold(); + + lookupElements.add(lookupElement); + } + + return lookupElements; + } + + /** + * Check if the class is a TwigExtension class. + * A class is considered a TwigExtension if: + * - Its name ends with "TwigExtension", OR + * - It extends AbstractExtension or implements ExtensionInterface, OR + * - Any other public method in the class already has an AsTwig* attribute + */ + private boolean isTwigExtensionClass(@NotNull PhpClass phpClass) { + // Check if the class name ends with "TwigExtension" + if (phpClass.getName().endsWith("TwigExtension")) { + return true; + } + + // Check if the class extends AbstractExtension + if (PhpElementsUtil.isInstanceOf(phpClass, TWIG_EXTENSION_FQN)) { + return true; + } + + // Check if any other public method in the class has an AsTwig* attribute + for (Method ownMethod : phpClass.getOwnMethods()) { + if (!ownMethod.getAccess().isPublic() || ownMethod.isStatic()) { + continue; + } + + // Collect attributes once and check for any AsTwig* attribute + Collection attributes = ownMethod.getAttributes(); + for (PhpAttribute attribute : attributes) { + String fqn = attribute.getFQN(); + if (AS_TWIG_FILTER_ATTRIBUTE_FQN.equals(fqn) || + AS_TWIG_FUNCTION_ATTRIBUTE_FQN.equals(fqn) || + AS_TWIG_TEST_ATTRIBUTE_FQN.equals(fqn)) { + return true; + } + } + } + + return false; + } + /** * Check if we're in a context where typing "#" for attributes makes sense * (i.e., after "#" character with whitespace before it) diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java index c12881bad..f25d3f96e 100644 --- a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java @@ -101,4 +101,111 @@ public void testCacheAttributeInsertionWithoutQuotes() { assertTrue("Result should contain Cache use statement", result.contains("use Symfony\\Component\\HttpKernel\\Attribute\\Cache;")); assertTrue("Result should contain empty parentheses", result.contains("#[Cache()]")); } + + public void testAsTwigFilterAttributeCompletionInClassEndingWithTwigExtension() { + // Test that AsTwigFilter attribute appears when class name ends with "TwigExtension" + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function myFilter() { }\n}", + "#[AsTwigFilter]" + ); + } + + public void testAsTwigFunctionAttributeCompletionInClassExtendingAbstractExtension() { + // Test that AsTwigFunction attribute appears when class extends AbstractExtension + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function myFunction() { }\n}", + "#[AsTwigFunction]" + ); + } + + public void testAsTwigTestAttributeCompletionInClassWithExistingAsTwigAttribute() { + // Test that AsTwigTest attribute appears when another method already has an AsTwig* attribute + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function myTest() { }\n}", + "#[AsTwigTest]" + ); + } + + public void testAllAsTwigAttributesAvailableInTwigExtension() { + // Test that all three AsTwig* attributes are available in a TwigExtension class + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function myMethod() { }\n}", + "#[AsTwigFilter]", "#[AsTwigFunction]", "#[AsTwigTest]" + ); + } + + public void testNoAsTwigAttributesInNonTwigExtensionClass() { + // Test that AsTwig* attributes don't appear in classes that don't match TwigExtension criteria + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n public function myMethod() { }\n}", + "#[AsTwigFilter]", "#[AsTwigFunction]", "#[AsTwigTest]" + ); + } + + public void testAsTwigFilterAttributeInsertionWithNamespaceAddsUseStatement() { + // Test AsTwigFilter attribute insertion with namespace - should add use import + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + " public function myFilter() { }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + var filterItem = java.util.Arrays.stream(items) + .filter(l -> "#[AsTwigFilter]".equals(l.getLookupString())) + .findFirst() + .orElse(null); + + if (filterItem != null) { + myFixture.getLookup().setCurrentItem(filterItem); + myFixture.type('\n'); + + String result = myFixture.getFile().getText(); + + assertTrue("Result should contain use statement", result.contains("use Twig\\Attribute\\AsTwigFilter;")); + assertTrue("Result should contain quotes for filter name", result.contains("#[AsTwigFilter(\"\")]")); + } + } + + public void testAsTwigFunctionAttributeInsertionWithQuotes() { + // Test that AsTwigFunction attribute insertion includes quotes (for the Twig function name) + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + " public function myFunction() { }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + var functionItem = java.util.Arrays.stream(items) + .filter(l -> "#[AsTwigFunction]".equals(l.getLookupString())) + .findFirst() + .orElse(null); + + if (functionItem != null) { + myFixture.getLookup().setCurrentItem(functionItem); + myFixture.type('\n'); + + String result = myFixture.getFile().getText(); + + assertTrue("Result should contain use statement", result.contains("use Twig\\Attribute\\AsTwigFunction;")); + assertTrue("Result should contain quotes for function name", result.contains("#[AsTwigFunction(\"\")]")); + } + } + + public void testNoAsTwigAttributesOutsidePublicMethod() { + // Test that AsTwig* attributes are not suggested outside of a method + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n private function privateMethod() { }\n}", + "#[AsTwigFilter]", "#[AsTwigFunction]", "#[AsTwigTest]" + ); + } } \ No newline at end of file diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php index 26e9529ff..a8cf2126c 100644 --- a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php @@ -62,4 +62,24 @@ class IsGranted class Cache { } +} + +namespace Twig\Extension { + class AbstractExtension + { + } +} + +namespace Twig\Attribute { + class AsTwigFilter + { + } + + class AsTwigFunction + { + } + + class AsTwigTest + { + } } \ No newline at end of file