Skip to content

Commit 6164d3e

Browse files
committed
Add class-level attribute completion for #[Route] and #[AsController]
1 parent cf0e1c6 commit 6164d3e

File tree

4 files changed

+391
-61
lines changed

4 files changed

+391
-61
lines changed

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

Lines changed: 146 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,14 @@
2626
import 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

Comments
 (0)