Skip to content

Commit 04cc02d

Browse files
committed
Enhance attribute completion by restricting #[Route()], #[IsGranted()], and #[Cache()] suggestions to controller classes.
1 parent f95b201 commit 04cc02d

File tree

8 files changed

+547
-8
lines changed

8 files changed

+547
-8
lines changed

src/main/java/fr/adrienbrault/idea/symfony2plugin/Symfony2Icons.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public class Symfony2Icons {
7878

7979
public static final Icon SYMFONY_AI = IconLoader.getIcon("/icons/symfony_ai.png", Symfony2Icons.class);
8080
public static final Icon SYMFONY_AI_OPACITY = IconLoader.getIcon("/icons/symfony_ai_opacity.png", Symfony2Icons.class);
81+
public static final Icon SYMFONY_ATTRIBUTE = IconLoader.getIcon("/icons/symfony_attribute.svg", Symfony2Icons.class);
8182

8283
public static Image getImage(Icon icon) {
8384

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
package fr.adrienbrault.idea.symfony2plugin.completion;
2+
3+
import com.intellij.codeInsight.completion.*;
4+
import com.intellij.codeInsight.lookup.LookupElement;
5+
import com.intellij.codeInsight.lookup.LookupElementBuilder;
6+
import com.intellij.openapi.editor.Document;
7+
import com.intellij.openapi.editor.Editor;
8+
import com.intellij.openapi.project.Project;
9+
import com.intellij.patterns.PlatformPatterns;
10+
import com.intellij.psi.*;
11+
import com.intellij.psi.util.PsiTreeUtil;
12+
import com.intellij.util.ProcessingContext;
13+
import com.jetbrains.php.lang.PhpLanguage;
14+
import com.jetbrains.php.lang.psi.elements.Method;
15+
import com.jetbrains.php.lang.psi.elements.PhpAttribute;
16+
import com.jetbrains.php.lang.psi.elements.PhpClass;
17+
import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons;
18+
import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent;
19+
import fr.adrienbrault.idea.symfony2plugin.intentions.php.AddRouteAttributeIntention;
20+
import fr.adrienbrault.idea.symfony2plugin.util.CodeUtil;
21+
import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil;
22+
import org.apache.commons.lang3.StringUtils;
23+
import org.jetbrains.annotations.NotNull;
24+
25+
import java.util.ArrayList;
26+
import java.util.Collection;
27+
28+
/**
29+
* Provides completion for Symfony PHP attributes like #[Route()]
30+
*
31+
* Triggers when typing "#<caret>" before a public method
32+
*
33+
* @author Daniel Espendiller <daniel@espendiller.net>
34+
*/
35+
public class PhpAttributeCompletionContributor extends CompletionContributor {
36+
37+
private static final String ROUTE_ATTRIBUTE_FQN = "\\Symfony\\Component\\Routing\\Attribute\\Route";
38+
private static final String IS_GRANTED_ATTRIBUTE_FQN = "\\Symfony\\Component\\Security\\Http\\Attribute\\IsGranted";
39+
private static final String CACHE_ATTRIBUTE_FQN = "\\Symfony\\Component\\HttpKernel\\Attribute\\Cache";
40+
41+
public PhpAttributeCompletionContributor() {
42+
// Match any element in PHP files - we'll do more specific checking in the provider
43+
// Using a broad pattern to catch completion after "#" character
44+
extend(
45+
CompletionType.BASIC,
46+
PlatformPatterns.psiElement().inFile(PlatformPatterns.psiFile().withLanguage(PhpLanguage.INSTANCE)),
47+
new PhpAttributeCompletionProvider()
48+
);
49+
}
50+
51+
private static class PhpAttributeCompletionProvider extends CompletionProvider<CompletionParameters> {
52+
@Override
53+
protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull ProcessingContext context, @NotNull CompletionResultSet result) {
54+
PsiElement position = parameters.getPosition();
55+
Project project = position.getProject();
56+
57+
if (!Symfony2ProjectComponent.isEnabled(project)) {
58+
return;
59+
}
60+
61+
// Check if we're in a context where an attribute makes sense (after "#" with whitespace before it)
62+
if (!isAttributeContext(parameters)) {
63+
return;
64+
}
65+
66+
// Check if we're before a public method (using shared logic from PhpAttributeCompletionPopupHandlerCompletionConfidence)
67+
Method method = PhpAttributeCompletionPopupHandlerCompletionConfidence.getMethod(position);
68+
if (method == null) {
69+
return;
70+
}
71+
72+
Collection<LookupElement> lookupElements = new ArrayList<>();
73+
74+
PhpClass containingClass = method.getContainingClass();
75+
if (containingClass != null && AddRouteAttributeIntention.isControllerClass(containingClass)) {
76+
lookupElements.addAll(getControllerCompletions(project));
77+
}
78+
79+
// Stop here - don't show other completions when typing "#" for attributes
80+
if (!lookupElements.isEmpty()) {
81+
result.addAllElements(lookupElements);
82+
result.stopHere();
83+
}
84+
}
85+
86+
private Collection<LookupElement> getControllerCompletions(@NotNull Project project) {
87+
Collection<LookupElement> lookupElements = new ArrayList<>();
88+
89+
// Add Route attribute completion
90+
if (PhpElementsUtil.getClassInterface(project, ROUTE_ATTRIBUTE_FQN) != null) {
91+
LookupElement routeLookupElement = LookupElementBuilder
92+
.create("#[Route]")
93+
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
94+
.withTypeText(StringUtils.stripStart(ROUTE_ATTRIBUTE_FQN, "\\"), true)
95+
.withInsertHandler(new PhpAttributeInsertHandler(ROUTE_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES))
96+
.bold();
97+
98+
lookupElements.add(routeLookupElement);
99+
}
100+
101+
// Add IsGranted attribute completion
102+
if (PhpElementsUtil.getClassInterface(project, IS_GRANTED_ATTRIBUTE_FQN) != null) {
103+
LookupElement isGrantedLookupElement = LookupElementBuilder
104+
.create("#[IsGranted]")
105+
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
106+
.withTypeText(StringUtils.stripStart(IS_GRANTED_ATTRIBUTE_FQN, "\\"), true)
107+
.withInsertHandler(new PhpAttributeInsertHandler(IS_GRANTED_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES))
108+
.bold();
109+
110+
lookupElements.add(isGrantedLookupElement);
111+
}
112+
113+
// Add Cache attribute completion
114+
if (PhpElementsUtil.getClassInterface(project, CACHE_ATTRIBUTE_FQN) != null) {
115+
LookupElement cacheLookupElement = LookupElementBuilder
116+
.create("#[Cache]")
117+
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
118+
.withTypeText(StringUtils.stripStart(CACHE_ATTRIBUTE_FQN, "\\"), true)
119+
.withInsertHandler(new PhpAttributeInsertHandler(CACHE_ATTRIBUTE_FQN, CursorPosition.INSIDE_PARENTHESES))
120+
.bold();
121+
122+
lookupElements.add(cacheLookupElement);
123+
}
124+
125+
return lookupElements;
126+
}
127+
128+
/**
129+
* Check if we're in a context where typing "#" for attributes makes sense
130+
* (i.e., after "#" character with whitespace before it)
131+
*/
132+
private boolean isAttributeContext(@NotNull CompletionParameters parameters) {
133+
int offset = parameters.getOffset();
134+
PsiFile psiFile = parameters.getOriginalFile();
135+
136+
// Need at least 2 characters before cursor to check for "# " pattern
137+
if (offset < 2) {
138+
return false;
139+
}
140+
141+
// Check if there's a "#" before the cursor with whitespace before it
142+
// secure length check
143+
CharSequence documentText = parameters.getEditor().getDocument().getCharsSequence();
144+
if (offset < documentText.length()) {
145+
return documentText.charAt(offset - 1) == '#' && psiFile.findElementAt(offset - 2) instanceof PsiWhiteSpace;
146+
}
147+
148+
return false;
149+
}
150+
}
151+
152+
/**
153+
* Enum to specify where the cursor should be positioned after attribute insertion
154+
*/
155+
private enum CursorPosition {
156+
/** Position cursor inside quotes: #[Attribute("<caret>")] */
157+
INSIDE_QUOTES,
158+
/** Position cursor inside parentheses: #[Attribute(<caret>)] */
159+
INSIDE_PARENTHESES
160+
}
161+
162+
/**
163+
* Insert handler that adds a PHP attribute
164+
*/
165+
private record PhpAttributeInsertHandler(@NotNull String attributeFqn, @NotNull CursorPosition cursorPosition) implements InsertHandler<LookupElement> {
166+
167+
@Override
168+
public void handleInsert(@NotNull InsertionContext context, @NotNull LookupElement item) {
169+
Editor editor = context.getEditor();
170+
Document document = editor.getDocument();
171+
Project project = context.getProject();
172+
173+
int startOffset = context.getStartOffset();
174+
int tailOffset = context.getTailOffset();
175+
176+
// Store the original insertion offset (where user typed "#")
177+
int originalInsertionOffset = startOffset;
178+
179+
// Check if there's a "#" before the completion position
180+
// If yes, we need to delete it to avoid "##[Attribute()]"
181+
if (startOffset > 0) {
182+
CharSequence text = document.getCharsSequence();
183+
if (text.charAt(startOffset - 1) == '#') {
184+
// Delete the "#" that was typed
185+
document.deleteString(startOffset - 1, tailOffset);
186+
originalInsertionOffset = startOffset - 1;
187+
} else {
188+
// Delete just the dummy identifier
189+
document.deleteString(startOffset, tailOffset);
190+
}
191+
} else {
192+
// Delete just the dummy identifier
193+
document.deleteString(startOffset, tailOffset);
194+
}
195+
196+
// First commit to get proper PSI
197+
PsiDocumentManager.getInstance(project).commitDocument(document);
198+
PsiFile file = context.getFile();
199+
200+
// Find the insertion position - look for the next method
201+
PsiElement elementAt = file.findElementAt(originalInsertionOffset);
202+
PhpClass phpClass = PsiTreeUtil.getParentOfType(elementAt, PhpClass.class);
203+
204+
// Find the method we're adding the attribute to
205+
Method targetMethod = null;
206+
if (phpClass != null) {
207+
for (Method method : phpClass.getOwnMethods()) {
208+
if (method.getTextOffset() > originalInsertionOffset) {
209+
targetMethod = method;
210+
break;
211+
}
212+
}
213+
}
214+
215+
if (targetMethod == null) {
216+
return; // Can't find target method
217+
}
218+
219+
// Extract class name from FQN (get the last part after the last backslash)
220+
String className = attributeFqn.substring(attributeFqn.lastIndexOf('\\') + 1);
221+
222+
// Store document length before adding import to calculate offset shift
223+
int documentLengthBeforeImport = document.getTextLength();
224+
225+
// Add import if necessary - this will modify the document!
226+
String importedName = PhpElementsUtil.insertUseIfNecessary(phpClass, attributeFqn);
227+
if (importedName != null) {
228+
className = importedName;
229+
}
230+
231+
// IMPORTANT: After adding import, commit and recalculate the insertion position
232+
PsiDocumentManager psiDocManager = PsiDocumentManager.getInstance(project);
233+
psiDocManager.commitDocument(document);
234+
psiDocManager.doPostponedOperationsAndUnblockDocument(document);
235+
236+
// Calculate how much the document length changed (import adds characters above our insertion point)
237+
int documentLengthAfterImport = document.getTextLength();
238+
int offsetShift = documentLengthAfterImport - documentLengthBeforeImport;
239+
240+
// Adjust insertion offset by the shift caused by import
241+
int currentInsertionOffset = originalInsertionOffset + offsetShift;
242+
243+
// Build attribute text based on cursor position
244+
String attributeText = "#[" + className + (cursorPosition == CursorPosition.INSIDE_QUOTES ? "(\"\")]\n" : "()]\n");
245+
246+
// Insert at the cursor position where user typed "#"
247+
document.insertString(currentInsertionOffset, attributeText);
248+
249+
// Commit and reformat
250+
psiDocManager.commitDocument(document);
251+
psiDocManager.doPostponedOperationsAndUnblockDocument(document);
252+
253+
// Reformat the added attribute
254+
CodeUtil.reformatAddedAttribute(project, document, currentInsertionOffset);
255+
256+
// After reformatting, position cursor based on the cursor position mode
257+
psiDocManager.commitDocument(document);
258+
259+
// Get fresh PSI and find the attribute we just added
260+
PsiFile finalFile = psiDocManager.getPsiFile(document);
261+
if (finalFile != null) {
262+
// Look for element INSIDE the inserted attribute (a few chars after insertion point)
263+
PsiElement elementInsideAttribute = finalFile.findElementAt(currentInsertionOffset + 3);
264+
if (elementInsideAttribute != null) {
265+
// Find the PhpAttribute element
266+
PhpAttribute phpAttribute =
267+
PsiTreeUtil.getParentOfType(elementInsideAttribute, PhpAttribute.class);
268+
269+
if (phpAttribute != null) {
270+
int attributeStart = phpAttribute.getTextRange().getStartOffset();
271+
int attributeEnd = phpAttribute.getTextRange().getEndOffset();
272+
CharSequence attributeContent = document.getCharsSequence().subSequence(attributeStart, attributeEnd);
273+
274+
// Find cursor position based on mode
275+
String searchChar = cursorPosition == CursorPosition.INSIDE_QUOTES ? "\"" : "(";
276+
int searchIndex = attributeContent.toString().indexOf(searchChar);
277+
278+
if (searchIndex >= 0) {
279+
// Position cursor right after the search character
280+
int caretOffset = attributeStart + searchIndex + 1;
281+
editor.getCaretModel().moveToOffset(caretOffset);
282+
}
283+
}
284+
}
285+
}
286+
}
287+
}
288+
}

0 commit comments

Comments
 (0)