2626import java .util .Collection ;
2727
2828/**
29- * Provides completion for Symfony PHP attributes like #[Route()]
29+ * Provides completion for Symfony PHP attributes like #[Route()] and #[AsController]
3030 *
31- * Triggers when typing "#<caret>" before a public method
31+ * Triggers when typing "#<caret>" before a public method or class
32+ *
33+ * Supports:
34+ * - Class-level attributes: #[Route], #[AsController]
35+ * - Method-level attributes: #[Route], #[IsGranted], #[Cache]
36+ * - Twig extension attributes: #[AsTwigFilter], #[AsTwigFunction], #[AsTwigTest]
3237 *
3338 * @author Daniel Espendiller <daniel@espendiller.net>
3439 */
@@ -37,6 +42,7 @@ public class PhpAttributeCompletionContributor extends CompletionContributor {
3742 private static final String ROUTE_ATTRIBUTE_FQN = "\\ Symfony\\ Component\\ Routing\\ Attribute\\ Route" ;
3843 private static final String IS_GRANTED_ATTRIBUTE_FQN = "\\ Symfony\\ Component\\ Security\\ Http\\ Attribute\\ IsGranted" ;
3944 private static final String CACHE_ATTRIBUTE_FQN = "\\ Symfony\\ Component\\ HttpKernel\\ Attribute\\ Cache" ;
45+ private static final String AS_CONTROLLER_ATTRIBUTE_FQN = "\\ Symfony\\ Component\\ HttpKernel\\ Attribute\\ AsController" ;
4046 private static final String AS_TWIG_FILTER_ATTRIBUTE_FQN = "\\ Twig\\ Attribute\\ AsTwigFilter" ;
4147 private static final String AS_TWIG_FUNCTION_ATTRIBUTE_FQN = "\\ Twig\\ Attribute\\ AsTwigFunction" ;
4248 private static final String AS_TWIG_TEST_ATTRIBUTE_FQN = "\\ Twig\\ Attribute\\ AsTwigTest" ;
@@ -67,21 +73,29 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull
6773 return ;
6874 }
6975
70- // Check if we're before a public method (using shared logic from PhpAttributeCompletionPopupHandlerCompletionConfidence)
71- Method method = PhpAttributeCompletionPopupHandlerCompletionConfidence .getMethod (position );
72- if (method == null ) {
73- return ;
74- }
75-
7676 Collection <LookupElement > lookupElements = new ArrayList <>();
7777
78- PhpClass containingClass = method .getContainingClass ();
79- if (containingClass != null && AddRouteAttributeIntention .isControllerClass (containingClass )) {
80- lookupElements .addAll (getControllerCompletions (project ));
81- }
78+ // Check if we're before a public method (using shared logic from PhpAttributeCompletionPopupHandlerCompletionConfidence)
79+ Method method = PhpAttributeCompletionPopupHandlerCompletionConfidence .getMethod (position );
80+ if (method != null ) {
81+ // Method-level attribute completions
82+ PhpClass containingClass = method .getContainingClass ();
83+ if (containingClass != null && AddRouteAttributeIntention .isControllerClass (containingClass )) {
84+ lookupElements .addAll (getControllerMethodCompletions (project ));
85+ }
8286
83- if (containingClass != null && isTwigExtensionClass (containingClass )) {
84- lookupElements .addAll (getTwigExtensionCompletions (project ));
87+ if (containingClass != null && isTwigExtensionClass (containingClass )) {
88+ lookupElements .addAll (getTwigExtensionCompletions (project ));
89+ }
90+ } else {
91+ // Check if we're before a class
92+ PhpClass phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence .getPhpClass (position );
93+ if (phpClass != null ) {
94+ // Class-level attribute completions
95+ if (AddRouteAttributeIntention .isControllerClass (phpClass )) {
96+ lookupElements .addAll (getControllerClassCompletions (project ));
97+ }
98+ }
8599 }
86100
87101 // Stop here - don't show other completions when typing "#" for attributes
@@ -91,7 +105,10 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull
91105 }
92106 }
93107
94- private Collection <LookupElement > getControllerCompletions (@ NotNull Project project ) {
108+ /**
109+ * Get controller method-level attribute completions (for methods in controller classes)
110+ */
111+ private Collection <LookupElement > getControllerMethodCompletions (@ NotNull Project project ) {
95112 Collection <LookupElement > lookupElements = new ArrayList <>();
96113
97114 // Add Route attribute completion
@@ -100,7 +117,7 @@ private Collection<LookupElement> getControllerCompletions(@NotNull Project proj
100117 .create ("#[Route]" )
101118 .withIcon (Symfony2Icons .SYMFONY_ATTRIBUTE )
102119 .withTypeText (StringUtils .stripStart (ROUTE_ATTRIBUTE_FQN , "\\ " ), true )
103- .withInsertHandler (new PhpAttributeInsertHandler (ROUTE_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES ))
120+ .withInsertHandler (new PhpAttributeInsertHandler (ROUTE_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES , AttributeTarget . METHOD ))
104121 .bold ();
105122
106123 lookupElements .add (routeLookupElement );
@@ -112,7 +129,7 @@ private Collection<LookupElement> getControllerCompletions(@NotNull Project proj
112129 .create ("#[IsGranted]" )
113130 .withIcon (Symfony2Icons .SYMFONY_ATTRIBUTE )
114131 .withTypeText (StringUtils .stripStart (IS_GRANTED_ATTRIBUTE_FQN , "\\ " ), true )
115- .withInsertHandler (new PhpAttributeInsertHandler (IS_GRANTED_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES ))
132+ .withInsertHandler (new PhpAttributeInsertHandler (IS_GRANTED_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES , AttributeTarget . METHOD ))
116133 .bold ();
117134
118135 lookupElements .add (isGrantedLookupElement );
@@ -124,7 +141,7 @@ private Collection<LookupElement> getControllerCompletions(@NotNull Project proj
124141 .create ("#[Cache]" )
125142 .withIcon (Symfony2Icons .SYMFONY_ATTRIBUTE )
126143 .withTypeText (StringUtils .stripStart (CACHE_ATTRIBUTE_FQN , "\\ " ), true )
127- .withInsertHandler (new PhpAttributeInsertHandler (CACHE_ATTRIBUTE_FQN , CursorPosition .INSIDE_PARENTHESES ))
144+ .withInsertHandler (new PhpAttributeInsertHandler (CACHE_ATTRIBUTE_FQN , CursorPosition .INSIDE_PARENTHESES , AttributeTarget . METHOD ))
128145 .bold ();
129146
130147 lookupElements .add (cacheLookupElement );
@@ -133,6 +150,39 @@ private Collection<LookupElement> getControllerCompletions(@NotNull Project proj
133150 return lookupElements ;
134151 }
135152
153+ /**
154+ * Get controller class-level attribute completions (for controller classes)
155+ */
156+ private Collection <LookupElement > getControllerClassCompletions (@ NotNull Project project ) {
157+ Collection <LookupElement > lookupElements = new ArrayList <>();
158+
159+ // Add Route attribute completion (for class-level route prefix)
160+ if (PhpElementsUtil .hasClassOrInterface (project , ROUTE_ATTRIBUTE_FQN )) {
161+ LookupElement routeLookupElement = LookupElementBuilder
162+ .create ("#[Route]" )
163+ .withIcon (Symfony2Icons .SYMFONY_ATTRIBUTE )
164+ .withTypeText (StringUtils .stripStart (ROUTE_ATTRIBUTE_FQN , "\\ " ), true )
165+ .withInsertHandler (new PhpAttributeInsertHandler (ROUTE_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES , AttributeTarget .CLASS ))
166+ .bold ();
167+
168+ lookupElements .add (routeLookupElement );
169+ }
170+
171+ // Add AsController attribute completion
172+ if (PhpElementsUtil .hasClassOrInterface (project , AS_CONTROLLER_ATTRIBUTE_FQN )) {
173+ LookupElement asControllerLookupElement = LookupElementBuilder
174+ .create ("#[AsController]" )
175+ .withIcon (Symfony2Icons .SYMFONY_ATTRIBUTE )
176+ .withTypeText (StringUtils .stripStart (AS_CONTROLLER_ATTRIBUTE_FQN , "\\ " ), true )
177+ .withInsertHandler (new PhpAttributeInsertHandler (AS_CONTROLLER_ATTRIBUTE_FQN , CursorPosition .NONE , AttributeTarget .CLASS ))
178+ .bold ();
179+
180+ lookupElements .add (asControllerLookupElement );
181+ }
182+
183+ return lookupElements ;
184+ }
185+
136186 private Collection <LookupElement > getTwigExtensionCompletions (@ NotNull Project project ) {
137187 Collection <LookupElement > lookupElements = new ArrayList <>();
138188
@@ -142,7 +192,7 @@ private Collection<LookupElement> getTwigExtensionCompletions(@NotNull Project p
142192 .create ("#[AsTwigFilter]" )
143193 .withIcon (Symfony2Icons .SYMFONY_ATTRIBUTE )
144194 .withTypeText (StringUtils .stripStart (AS_TWIG_FILTER_ATTRIBUTE_FQN , "\\ " ), true )
145- .withInsertHandler (new PhpAttributeInsertHandler (AS_TWIG_FILTER_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES ))
195+ .withInsertHandler (new PhpAttributeInsertHandler (AS_TWIG_FILTER_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES , AttributeTarget . METHOD ))
146196 .bold ();
147197
148198 lookupElements .add (lookupElement );
@@ -154,7 +204,7 @@ private Collection<LookupElement> getTwigExtensionCompletions(@NotNull Project p
154204 .create ("#[AsTwigFunction]" )
155205 .withIcon (Symfony2Icons .SYMFONY_ATTRIBUTE )
156206 .withTypeText (StringUtils .stripStart (AS_TWIG_FUNCTION_ATTRIBUTE_FQN , "\\ " ), true )
157- .withInsertHandler (new PhpAttributeInsertHandler (AS_TWIG_FUNCTION_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES ))
207+ .withInsertHandler (new PhpAttributeInsertHandler (AS_TWIG_FUNCTION_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES , AttributeTarget . METHOD ))
158208 .bold ();
159209
160210 lookupElements .add (lookupElement );
@@ -166,7 +216,7 @@ private Collection<LookupElement> getTwigExtensionCompletions(@NotNull Project p
166216 .create ("#[AsTwigTest]" )
167217 .withIcon (Symfony2Icons .SYMFONY_ATTRIBUTE )
168218 .withTypeText (StringUtils .stripStart (AS_TWIG_TEST_ATTRIBUTE_FQN , "\\ " ), true )
169- .withInsertHandler (new PhpAttributeInsertHandler (AS_TWIG_TEST_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES ))
219+ .withInsertHandler (new PhpAttributeInsertHandler (AS_TWIG_TEST_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES , AttributeTarget . METHOD ))
170220 .bold ();
171221
172222 lookupElements .add (lookupElement );
@@ -245,13 +295,25 @@ private enum CursorPosition {
245295 /** Position cursor inside quotes: #[Attribute("<caret>")] */
246296 INSIDE_QUOTES ,
247297 /** Position cursor inside parentheses: #[Attribute(<caret>)] */
248- INSIDE_PARENTHESES
298+ INSIDE_PARENTHESES ,
299+ /** No parentheses needed: #[Attribute]<caret> */
300+ NONE
301+ }
302+
303+ /**
304+ * Enum to specify the target of the attribute (class or method)
305+ */
306+ private enum AttributeTarget {
307+ /** Attribute applies to a class */
308+ CLASS ,
309+ /** Attribute applies to a method */
310+ METHOD
249311 }
250312
251313 /**
252314 * Insert handler that adds a PHP attribute
253315 */
254- private record PhpAttributeInsertHandler (@ NotNull String attributeFqn , @ NotNull CursorPosition cursorPosition ) implements InsertHandler <LookupElement > {
316+ private record PhpAttributeInsertHandler (@ NotNull String attributeFqn , @ NotNull CursorPosition cursorPosition , @ NotNull AttributeTarget attributeTarget ) implements InsertHandler <LookupElement > {
255317
256318 @ Override
257319 public void handleInsert (@ NotNull InsertionContext context , @ NotNull LookupElement item ) {
@@ -262,6 +324,31 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme
262324 int startOffset = context .getStartOffset ();
263325 int tailOffset = context .getTailOffset ();
264326
327+ // IMPORTANT: Find the target class/method BEFORE modifying the document
328+ // because PSI structure might change after deletions
329+ PsiFile file = context .getFile ();
330+ PsiElement originalElement = file .findElementAt (startOffset );
331+ if (originalElement == null ) {
332+ return ;
333+ }
334+
335+ PhpClass phpClass = null ;
336+ if (attributeTarget == AttributeTarget .METHOD ) {
337+ // Find the target method
338+ Method targetMethod = PhpAttributeCompletionPopupHandlerCompletionConfidence .getMethod (originalElement );
339+ if (targetMethod == null ) {
340+ return ;
341+ }
342+
343+ phpClass = targetMethod .getContainingClass ();
344+ } else if (attributeTarget == AttributeTarget .CLASS ) {
345+ // For class-level attributes, find the target class
346+ phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence .getPhpClass (originalElement );
347+ if (phpClass == null ) {
348+ return ;
349+ }
350+ }
351+
265352 // Store the original insertion offset (where user typed "#")
266353 int originalInsertionOffset = startOffset ;
267354
@@ -282,28 +369,8 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme
282369 document .deleteString (startOffset , tailOffset );
283370 }
284371
285- // First commit to get proper PSI
372+ // Commit after deletion
286373 PsiDocumentManager .getInstance (project ).commitDocument (document );
287- PsiFile file = context .getFile ();
288-
289- // Find the insertion position - look for the next method
290- PsiElement elementAt = file .findElementAt (originalInsertionOffset );
291- PhpClass phpClass = PsiTreeUtil .getParentOfType (elementAt , PhpClass .class );
292-
293- // Find the method we're adding the attribute to
294- Method targetMethod = null ;
295- if (phpClass != null ) {
296- for (Method method : phpClass .getOwnMethods ()) {
297- if (method .getTextOffset () > originalInsertionOffset ) {
298- targetMethod = method ;
299- break ;
300- }
301- }
302- }
303-
304- if (targetMethod == null ) {
305- return ; // Can't find target method
306- }
307374
308375 // Extract class name from FQN (get the last part after the last backslash)
309376 String className = attributeFqn .substring (attributeFqn .lastIndexOf ('\\' ) + 1 );
@@ -329,8 +396,27 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme
329396 // Adjust insertion offset by the shift caused by import
330397 int currentInsertionOffset = originalInsertionOffset + offsetShift ;
331398
399+ // Check if there's already a newline at the current position
400+ // to avoid adding double newlines
401+ CharSequence currentText = document .getCharsSequence ();
402+ boolean hasNewlineAfter = false ;
403+ if (currentInsertionOffset < currentText .length ()) {
404+ char nextChar = currentText .charAt (currentInsertionOffset );
405+ hasNewlineAfter = (nextChar == '\n' || nextChar == '\r' );
406+ }
407+
332408 // Build attribute text based on cursor position
333- String attributeText = "#[" + className + (cursorPosition == CursorPosition .INSIDE_QUOTES ? "(\" \" )]\n " : "()]\n " );
409+ String attributeText ;
410+ String newline = hasNewlineAfter ? "" : "\n " ;
411+
412+ if (cursorPosition == CursorPosition .INSIDE_QUOTES ) {
413+ attributeText = "#[" + className + "(\" \" )]" + newline ;
414+ } else if (cursorPosition == CursorPosition .INSIDE_PARENTHESES ) {
415+ attributeText = "#[" + className + "()]" + newline ;
416+ } else {
417+ // CursorPosition.NONE - no parentheses
418+ attributeText = "#[" + className + "]" + newline ;
419+ }
334420
335421 // Insert at the cursor position where user typed "#"
336422 document .insertString (currentInsertionOffset , attributeText );
@@ -352,22 +438,27 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme
352438 PsiElement elementInsideAttribute = finalFile .findElementAt (currentInsertionOffset + 3 );
353439 if (elementInsideAttribute != null ) {
354440 // Find the PhpAttribute element
355- PhpAttribute phpAttribute =
356- PsiTreeUtil .getParentOfType (elementInsideAttribute , PhpAttribute .class );
441+ PhpAttribute phpAttribute = PsiTreeUtil .getParentOfType (elementInsideAttribute , PhpAttribute .class );
357442
358443 if (phpAttribute != null ) {
359444 int attributeStart = phpAttribute .getTextRange ().getStartOffset ();
360445 int attributeEnd = phpAttribute .getTextRange ().getEndOffset ();
361446 CharSequence attributeContent = document .getCharsSequence ().subSequence (attributeStart , attributeEnd );
362447
363- // Find cursor position based on mode
364- String searchChar = cursorPosition == CursorPosition .INSIDE_QUOTES ? "\" " : "(" ;
365- int searchIndex = attributeContent .toString ().indexOf (searchChar );
366-
367- if (searchIndex >= 0 ) {
368- // Position cursor right after the search character
369- int caretOffset = attributeStart + searchIndex + 1 ;
370- editor .getCaretModel ().moveToOffset (caretOffset );
448+ if (cursorPosition == CursorPosition .NONE ) {
449+ // For attributes without parentheses, position cursor at the end of the line
450+ // (after the closing bracket and newline)
451+ editor .getCaretModel ().moveToOffset (attributeEnd + 1 );
452+ } else {
453+ // Find cursor position based on mode
454+ String searchChar = cursorPosition == CursorPosition .INSIDE_QUOTES ? "\" " : "(" ;
455+ int searchIndex = attributeContent .toString ().indexOf (searchChar );
456+
457+ if (searchIndex >= 0 ) {
458+ // Position cursor right after the search character
459+ int caretOffset = attributeStart + searchIndex + 1 ;
460+ editor .getCaretModel ().moveToOffset (caretOffset );
461+ }
371462 }
372463 }
373464 }
0 commit comments