Skip to content

Commit 1429939

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

File tree

4 files changed

+383
-54
lines changed

4 files changed

+383
-54
lines changed

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

Lines changed: 138 additions & 48 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], #[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
}

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.jetbrains.php.lang.psi.PhpFile;
1313
import com.jetbrains.php.lang.psi.PhpPsiUtil;
1414
import com.jetbrains.php.lang.psi.elements.Method;
15+
import com.jetbrains.php.lang.psi.elements.PhpClass;
1516
import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent;
1617
import org.jetbrains.annotations.NotNull;
1718
import org.jetbrains.annotations.Nullable;
@@ -31,8 +32,8 @@ public ThreeState shouldSkipAutopopup(@NotNull Editor editor, @NotNull PsiElemen
3132
return ThreeState.UNSURE;
3233
}
3334

34-
Method foundMethod = getMethod(contextElement);
35-
if (foundMethod == null) {
35+
// Check if we're before a method or a class
36+
if (getMethod(contextElement) == null && getPhpClass(contextElement) == null) {
3637
return ThreeState.UNSURE;
3738
}
3839

@@ -48,7 +49,7 @@ public ThreeState shouldSkipAutopopup(@NotNull Editor editor, @NotNull PsiElemen
4849

4950
/**
5051
* Triggers auto-popup completion after typing '#' character in PHP files
51-
* when positioned before a public method (for PHP attributes like #[Route()])
52+
* when positioned before a public method or class (for PHP attributes like #[Route()])
5253
*
5354
* @author Daniel Espendiller <daniel@espendiller.net>
5455
*/
@@ -61,7 +62,7 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate {
6162

6263
// Check if we're in a class context
6364
int offset = editor.getCaretModel().getOffset();
64-
if (!(file.findElementAt(offset - 2) instanceof PsiWhiteSpace)) {
65+
if (!(file.findElementAt(offset - 2) instanceof PsiWhiteSpace) && !(file.findElementAt(offset - 1) instanceof PsiWhiteSpace)) {
6566
return Result.CONTINUE;
6667
}
6768

@@ -70,8 +71,8 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate {
7071
return Result.CONTINUE;
7172
}
7273

73-
Method foundMethod = getMethod(element);
74-
if (foundMethod == null) {
74+
// Check if we're before a method or a class
75+
if (getMethod(element) == null && getPhpClass(element) == null) {
7576
return Result.CONTINUE;
7677
}
7778

@@ -100,4 +101,22 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate {
100101
? foundMethod
101102
: null;
102103
}
104+
105+
/**
106+
* Finds a PhpClass associated with the given element.
107+
* Returns the class if the element is a child of a class or if the next sibling is a class.
108+
* Also handles cases where we're in the middle of an attribute list.
109+
*
110+
* @param element The PSI element to check
111+
* @return The PhpClass if found, null otherwise
112+
*/
113+
public static @Nullable PhpClass getPhpClass(@NotNull PsiElement element) {
114+
if (element.getParent() instanceof PhpClass phpClass) {
115+
return phpClass;
116+
} else if (PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true) instanceof PhpClass phpClass) {
117+
return phpClass;
118+
}
119+
120+
return null;
121+
}
103122
}

0 commit comments

Comments
 (0)