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], #[IsGranted]
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
@@ -133,6 +150,51 @@ 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 ))
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 ))
178+ .bold ();
179+
180+ lookupElements .add (asControllerLookupElement );
181+ }
182+
183+ // Add IsGranted attribute completion (for class-level security)
184+ if (PhpElementsUtil .hasClassOrInterface (project , IS_GRANTED_ATTRIBUTE_FQN )) {
185+ LookupElement isGrantedLookupElement = LookupElementBuilder
186+ .create ("#[IsGranted]" )
187+ .withIcon (Symfony2Icons .SYMFONY_ATTRIBUTE )
188+ .withTypeText (StringUtils .stripStart (IS_GRANTED_ATTRIBUTE_FQN , "\\ " ), true )
189+ .withInsertHandler (new PhpAttributeInsertHandler (IS_GRANTED_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES ))
190+ .bold ();
191+
192+ lookupElements .add (isGrantedLookupElement );
193+ }
194+
195+ return lookupElements ;
196+ }
197+
136198 private Collection <LookupElement > getTwigExtensionCompletions (@ NotNull Project project ) {
137199 Collection <LookupElement > lookupElements = new ArrayList <>();
138200
@@ -245,7 +307,9 @@ private enum CursorPosition {
245307 /** Position cursor inside quotes: #[Attribute("<caret>")] */
246308 INSIDE_QUOTES ,
247309 /** Position cursor inside parentheses: #[Attribute(<caret>)] */
248- INSIDE_PARENTHESES
310+ INSIDE_PARENTHESES ,
311+ /** No parentheses needed: #[Attribute]<caret> */
312+ NONE
249313 }
250314
251315 /**
@@ -262,6 +326,28 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme
262326 int startOffset = context .getStartOffset ();
263327 int tailOffset = context .getTailOffset ();
264328
329+ // IMPORTANT: Find the target class/method BEFORE modifying the document
330+ // because PSI structure might change after deletions
331+ PsiFile file = context .getFile ();
332+ PsiElement originalElement = file .findElementAt (startOffset );
333+ if (originalElement == null ) {
334+ return ;
335+ }
336+
337+ // Determine the target context (method or class) dynamically
338+ PhpClass phpClass ;
339+ Method targetMethod = PhpAttributeCompletionPopupHandlerCompletionConfidence .getMethod (originalElement );
340+ if (targetMethod != null ) {
341+ // We're in a method context
342+ phpClass = targetMethod .getContainingClass ();
343+ } else {
344+ // Try class context
345+ phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence .getPhpClass (originalElement );
346+ if (phpClass == null ) {
347+ return ;
348+ }
349+ }
350+
265351 // Store the original insertion offset (where user typed "#")
266352 int originalInsertionOffset = startOffset ;
267353
@@ -282,28 +368,8 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme
282368 document .deleteString (startOffset , tailOffset );
283369 }
284370
285- // First commit to get proper PSI
371+ // Commit after deletion
286372 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- }
307373
308374 // Extract class name from FQN (get the last part after the last backslash)
309375 String className = attributeFqn .substring (attributeFqn .lastIndexOf ('\\' ) + 1 );
@@ -329,8 +395,27 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme
329395 // Adjust insertion offset by the shift caused by import
330396 int currentInsertionOffset = originalInsertionOffset + offsetShift ;
331397
398+ // Check if there's already a newline at the current position
399+ // to avoid adding double newlines
400+ CharSequence currentText = document .getCharsSequence ();
401+ boolean hasNewlineAfter = false ;
402+ if (currentInsertionOffset < currentText .length ()) {
403+ char nextChar = currentText .charAt (currentInsertionOffset );
404+ hasNewlineAfter = (nextChar == '\n' || nextChar == '\r' );
405+ }
406+
332407 // Build attribute text based on cursor position
333- String attributeText = "#[" + className + (cursorPosition == CursorPosition .INSIDE_QUOTES ? "(\" \" )]\n " : "()]\n " );
408+ String attributeText ;
409+ String newline = hasNewlineAfter ? "" : "\n " ;
410+
411+ if (cursorPosition == CursorPosition .INSIDE_QUOTES ) {
412+ attributeText = "#[" + className + "(\" \" )]" + newline ;
413+ } else if (cursorPosition == CursorPosition .INSIDE_PARENTHESES ) {
414+ attributeText = "#[" + className + "()]" + newline ;
415+ } else {
416+ // CursorPosition.NONE - no parentheses
417+ attributeText = "#[" + className + "]" + newline ;
418+ }
334419
335420 // Insert at the cursor position where user typed "#"
336421 document .insertString (currentInsertionOffset , attributeText );
@@ -352,22 +437,27 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme
352437 PsiElement elementInsideAttribute = finalFile .findElementAt (currentInsertionOffset + 3 );
353438 if (elementInsideAttribute != null ) {
354439 // Find the PhpAttribute element
355- PhpAttribute phpAttribute =
356- PsiTreeUtil .getParentOfType (elementInsideAttribute , PhpAttribute .class );
440+ PhpAttribute phpAttribute = PsiTreeUtil .getParentOfType (elementInsideAttribute , PhpAttribute .class );
357441
358442 if (phpAttribute != null ) {
359443 int attributeStart = phpAttribute .getTextRange ().getStartOffset ();
360444 int attributeEnd = phpAttribute .getTextRange ().getEndOffset ();
361445 CharSequence attributeContent = document .getCharsSequence ().subSequence (attributeStart , attributeEnd );
362446
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 );
447+ if (cursorPosition == CursorPosition .NONE ) {
448+ // For attributes without parentheses, position cursor at the end of the line
449+ // (after the closing bracket and newline)
450+ editor .getCaretModel ().moveToOffset (attributeEnd + 1 );
451+ } else {
452+ // Find cursor position based on mode
453+ String searchChar = cursorPosition == CursorPosition .INSIDE_QUOTES ? "\" " : "(" ;
454+ int searchIndex = attributeContent .toString ().indexOf (searchChar );
455+
456+ if (searchIndex >= 0 ) {
457+ // Position cursor right after the search character
458+ int caretOffset = attributeStart + searchIndex + 1 ;
459+ editor .getCaretModel ().moveToOffset (caretOffset );
460+ }
371461 }
372462 }
373463 }
0 commit comments