Skip to content

Commit 9e45a0c

Browse files
committed
Add completion and auto-popup support for PHP attributes using #[Route()], handling # within class method scopes via a new contributor and confidence handler.
1 parent f95b201 commit 9e45a0c

File tree

7 files changed

+515
-0
lines changed

7 files changed

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

src/main/resources/META-INF/plugin.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,12 @@
249249

250250
<completion.contributor language="PHP" order="last" implementationClass="fr.adrienbrault.idea.symfony2plugin.completion.PhpIncompleteCompletionContributor"/>
251251

252+
<!-- provide completion after "#" inside the method scope -->
253+
<completion.contributor language="PHP" implementationClass="fr.adrienbrault.idea.symfony2plugin.completion.PhpAttributeCompletionContributor" order="first"/>
254+
<completion.confidence implementationClass="fr.adrienbrault.idea.symfony2plugin.completion.PhpAttributeCompletionPopupHandlerCompletionConfidence$PhpAttributeCompletionConfidence" language="PHP"/>
255+
<typedHandler implementation="fr.adrienbrault.idea.symfony2plugin.completion.PhpAttributeCompletionPopupHandlerCompletionConfidence$PhpAttributeAutoPopupHandler"/>
256+
257+
252258
<fileBasedIndex implementation="fr.adrienbrault.idea.symfony2plugin.stubs.indexes.RoutesStubIndex"/>
253259
<fileBasedIndex implementation="fr.adrienbrault.idea.symfony2plugin.stubs.indexes.TwigExtendsStubIndex"/>
254260
<fileBasedIndex implementation="fr.adrienbrault.idea.symfony2plugin.stubs.indexes.ServicesDefinitionStubIndex"/>

0 commit comments

Comments
 (0)