Skip to content

Commit f451ece

Browse files
committed
Add Twig attribute completions (#[AsTwigFilter], #[AsTwigFunction], #[AsTwigTest]) for TwigExtension classes.
1 parent 41e2cc8 commit f451ece

File tree

3 files changed

+216
-0
lines changed

3 files changed

+216
-0
lines changed

src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ public class PhpAttributeCompletionContributor extends CompletionContributor {
3737
private static final String ROUTE_ATTRIBUTE_FQN = "\\Symfony\\Component\\Routing\\Attribute\\Route";
3838
private static final String IS_GRANTED_ATTRIBUTE_FQN = "\\Symfony\\Component\\Security\\Http\\Attribute\\IsGranted";
3939
private static final String CACHE_ATTRIBUTE_FQN = "\\Symfony\\Component\\HttpKernel\\Attribute\\Cache";
40+
private static final String AS_TWIG_FILTER_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigFilter";
41+
private static final String AS_TWIG_FUNCTION_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigFunction";
42+
private static final String AS_TWIG_TEST_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigTest";
43+
private static final String TWIG_EXTENSION_FQN = "\\Twig\\Extension\\AbstractExtension";
4044

4145
public PhpAttributeCompletionContributor() {
4246
// 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
7680
lookupElements.addAll(getControllerCompletions(project));
7781
}
7882

83+
if (containingClass != null && isTwigExtensionClass(containingClass)) {
84+
lookupElements.addAll(getTwigExtensionCompletions(project));
85+
}
86+
7987
// Stop here - don't show other completions when typing "#" for attributes
8088
if (!lookupElements.isEmpty()) {
8189
result.addAllElements(lookupElements);
@@ -125,6 +133,87 @@ private Collection<LookupElement> getControllerCompletions(@NotNull Project proj
125133
return lookupElements;
126134
}
127135

136+
private Collection<LookupElement> getTwigExtensionCompletions(@NotNull Project project) {
137+
Collection<LookupElement> lookupElements = new ArrayList<>();
138+
139+
// Add AsTwigFilter attribute completion
140+
if (PhpElementsUtil.getClassInterface(project, AS_TWIG_FILTER_ATTRIBUTE_FQN) != null) {
141+
LookupElement lookupElement = LookupElementBuilder
142+
.create("#[AsTwigFilter]")
143+
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
144+
.withTypeText(StringUtils.stripStart(AS_TWIG_FILTER_ATTRIBUTE_FQN, "\\"), true)
145+
.withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_FILTER_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES))
146+
.bold();
147+
148+
lookupElements.add(lookupElement);
149+
}
150+
151+
// Add AsTwigFunction attribute completion
152+
if (PhpElementsUtil.getClassInterface(project, AS_TWIG_FUNCTION_ATTRIBUTE_FQN) != null) {
153+
LookupElement lookupElement = LookupElementBuilder
154+
.create("#[AsTwigFunction]")
155+
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
156+
.withTypeText(StringUtils.stripStart(AS_TWIG_FUNCTION_ATTRIBUTE_FQN, "\\"), true)
157+
.withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_FUNCTION_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES))
158+
.bold();
159+
160+
lookupElements.add(lookupElement);
161+
}
162+
163+
// Add AsTwigTest attribute completion
164+
if (PhpElementsUtil.getClassInterface(project, AS_TWIG_TEST_ATTRIBUTE_FQN) != null) {
165+
LookupElement lookupElement = LookupElementBuilder
166+
.create("#[AsTwigTest]")
167+
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
168+
.withTypeText(StringUtils.stripStart(AS_TWIG_TEST_ATTRIBUTE_FQN, "\\"), true)
169+
.withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_TEST_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES))
170+
.bold();
171+
172+
lookupElements.add(lookupElement);
173+
}
174+
175+
return lookupElements;
176+
}
177+
178+
/**
179+
* Check if the class is a TwigExtension class.
180+
* A class is considered a TwigExtension if:
181+
* - Its name ends with "TwigExtension", OR
182+
* - It extends AbstractExtension or implements ExtensionInterface, OR
183+
* - Any other public method in the class already has an AsTwig* attribute
184+
*/
185+
private boolean isTwigExtensionClass(@NotNull PhpClass phpClass) {
186+
// Check if the class name ends with "TwigExtension"
187+
if (phpClass.getName().endsWith("TwigExtension")) {
188+
return true;
189+
}
190+
191+
// Check if the class extends AbstractExtension
192+
if (PhpElementsUtil.isInstanceOf(phpClass, TWIG_EXTENSION_FQN)) {
193+
return true;
194+
}
195+
196+
// Check if any other public method in the class has an AsTwig* attribute
197+
for (Method ownMethod : phpClass.getOwnMethods()) {
198+
if (!ownMethod.getAccess().isPublic() || ownMethod.isStatic()) {
199+
continue;
200+
}
201+
202+
// Collect attributes once and check for any AsTwig* attribute
203+
Collection<PhpAttribute> attributes = ownMethod.getAttributes();
204+
for (PhpAttribute attribute : attributes) {
205+
String fqn = attribute.getFQN();
206+
if (AS_TWIG_FILTER_ATTRIBUTE_FQN.equals(fqn) ||
207+
AS_TWIG_FUNCTION_ATTRIBUTE_FQN.equals(fqn) ||
208+
AS_TWIG_TEST_ATTRIBUTE_FQN.equals(fqn)) {
209+
return true;
210+
}
211+
}
212+
}
213+
214+
return false;
215+
}
216+
128217
/**
129218
* Check if we're in a context where typing "#" for attributes makes sense
130219
* (i.e., after "#" character with whitespace before it)

src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,111 @@ public void testCacheAttributeInsertionWithoutQuotes() {
101101
assertTrue("Result should contain Cache use statement", result.contains("use Symfony\\Component\\HttpKernel\\Attribute\\Cache;"));
102102
assertTrue("Result should contain empty parentheses", result.contains("#[Cache()]"));
103103
}
104+
105+
public void testAsTwigFilterAttributeCompletionInClassEndingWithTwigExtension() {
106+
// Test that AsTwigFilter attribute appears when class name ends with "TwigExtension"
107+
assertCompletionContains(PhpFileType.INSTANCE,
108+
"<?php\n\nclass MyTwigExtension {\n #<caret>\n public function myFilter() { }\n}",
109+
"#[AsTwigFilter]"
110+
);
111+
}
112+
113+
public void testAsTwigFunctionAttributeCompletionInClassExtendingAbstractExtension() {
114+
// Test that AsTwigFunction attribute appears when class extends AbstractExtension
115+
assertCompletionContains(PhpFileType.INSTANCE,
116+
"<?php\n\nuse Twig\\Extension\\AbstractExtension;\n\nclass MyExtension extends AbstractExtension {\n #<caret>\n public function myFunction() { }\n}",
117+
"#[AsTwigFunction]"
118+
);
119+
}
120+
121+
public void testAsTwigTestAttributeCompletionInClassWithExistingAsTwigAttribute() {
122+
// Test that AsTwigTest attribute appears when another method already has an AsTwig* attribute
123+
assertCompletionContains(PhpFileType.INSTANCE,
124+
"<?php\n\nuse Twig\\Attribute\\AsTwigFilter;\n\nclass MyExtension {\n #[AsTwigFilter('existing')]\n public function existingFilter() { }\n\n #<caret>\n public function myTest() { }\n}",
125+
"#[AsTwigTest]"
126+
);
127+
}
128+
129+
public void testAllAsTwigAttributesAvailableInTwigExtension() {
130+
// Test that all three AsTwig* attributes are available in a TwigExtension class
131+
assertCompletionContains(PhpFileType.INSTANCE,
132+
"<?php\n\nclass MyTwigExtension {\n #<caret>\n public function myMethod() { }\n}",
133+
"#[AsTwigFilter]", "#[AsTwigFunction]", "#[AsTwigTest]"
134+
);
135+
}
136+
137+
public void testNoAsTwigAttributesInNonTwigExtensionClass() {
138+
// Test that AsTwig* attributes don't appear in classes that don't match TwigExtension criteria
139+
assertCompletionNotContains(PhpFileType.INSTANCE,
140+
"<?php\n\nclass MyService {\n #<caret>\n public function myMethod() { }\n}",
141+
"#[AsTwigFilter]", "#[AsTwigFunction]", "#[AsTwigTest]"
142+
);
143+
}
144+
145+
public void testAsTwigFilterAttributeInsertionWithNamespaceAddsUseStatement() {
146+
// Test AsTwigFilter attribute insertion with namespace - should add use import
147+
myFixture.configureByText(PhpFileType.INSTANCE,
148+
"<?php\n\n" +
149+
"namespace App\\Twig;\n\n" +
150+
"class MyTwigExtension {\n" +
151+
" #<caret>\n" +
152+
" public function myFilter() { }\n" +
153+
"}"
154+
);
155+
myFixture.completeBasic();
156+
157+
var items = myFixture.getLookupElements();
158+
var filterItem = java.util.Arrays.stream(items)
159+
.filter(l -> "#[AsTwigFilter]".equals(l.getLookupString()))
160+
.findFirst()
161+
.orElse(null);
162+
163+
if (filterItem != null) {
164+
myFixture.getLookup().setCurrentItem(filterItem);
165+
myFixture.type('\n');
166+
167+
String result = myFixture.getFile().getText();
168+
169+
assertTrue("Result should contain use statement", result.contains("use Twig\\Attribute\\AsTwigFilter;"));
170+
assertTrue("Result should contain quotes for filter name", result.contains("#[AsTwigFilter(\"\")]"));
171+
}
172+
}
173+
174+
public void testAsTwigFunctionAttributeInsertionWithQuotes() {
175+
// Test that AsTwigFunction attribute insertion includes quotes (for the Twig function name)
176+
myFixture.configureByText(PhpFileType.INSTANCE,
177+
"<?php\n\n" +
178+
"namespace App\\Twig;\n\n" +
179+
"use Twig\\Extension\\AbstractExtension;\n\n" +
180+
"class MyExtension extends AbstractExtension {\n" +
181+
" #<caret>\n" +
182+
" public function myFunction() { }\n" +
183+
"}"
184+
);
185+
myFixture.completeBasic();
186+
187+
var items = myFixture.getLookupElements();
188+
var functionItem = java.util.Arrays.stream(items)
189+
.filter(l -> "#[AsTwigFunction]".equals(l.getLookupString()))
190+
.findFirst()
191+
.orElse(null);
192+
193+
if (functionItem != null) {
194+
myFixture.getLookup().setCurrentItem(functionItem);
195+
myFixture.type('\n');
196+
197+
String result = myFixture.getFile().getText();
198+
199+
assertTrue("Result should contain use statement", result.contains("use Twig\\Attribute\\AsTwigFunction;"));
200+
assertTrue("Result should contain quotes for function name", result.contains("#[AsTwigFunction(\"\")]"));
201+
}
202+
}
203+
204+
public void testNoAsTwigAttributesOutsidePublicMethod() {
205+
// Test that AsTwig* attributes are not suggested outside of a method
206+
assertCompletionNotContains(PhpFileType.INSTANCE,
207+
"<?php\n\nclass MyTwigExtension {\n #<caret>\n private function privateMethod() { }\n}",
208+
"#[AsTwigFilter]", "#[AsTwigFunction]", "#[AsTwigTest]"
209+
);
210+
}
104211
}

src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,24 @@ class IsGranted
6262
class Cache
6363
{
6464
}
65+
}
66+
67+
namespace Twig\Extension {
68+
class AbstractExtension
69+
{
70+
}
71+
}
72+
73+
namespace Twig\Attribute {
74+
class AsTwigFilter
75+
{
76+
}
77+
78+
class AsTwigFunction
79+
{
80+
}
81+
82+
class AsTwigTest
83+
{
84+
}
6585
}

0 commit comments

Comments
 (0)