@@ -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)
0 commit comments