Skip to content

Commit bbeddee

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 bbeddee

File tree

7 files changed

+510
-0
lines changed

7 files changed

+510
-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: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
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+
CharSequence documentText = parameters.getEditor().getDocument().getCharsSequence();
123+
return documentText.charAt(offset - 1) == '#' && psiFile.findElementAt(offset - 2) instanceof PsiWhiteSpace;
124+
}
125+
}
126+
127+
/**
128+
* Enum to specify where the cursor should be positioned after attribute insertion
129+
*/
130+
private enum CursorPosition {
131+
/** Position cursor inside quotes: #[Attribute("<caret>")] */
132+
INSIDE_QUOTES,
133+
/** Position cursor inside parentheses: #[Attribute(<caret>)] */
134+
INSIDE_PARENTHESES
135+
}
136+
137+
/**
138+
* Insert handler that adds a PHP attribute
139+
*/
140+
private record PhpAttributeInsertHandler(@NotNull String attributeFqn, @NotNull CursorPosition cursorPosition) implements InsertHandler<LookupElement> {
141+
142+
@Override
143+
public void handleInsert(@NotNull InsertionContext context, @NotNull LookupElement item) {
144+
Editor editor = context.getEditor();
145+
Document document = editor.getDocument();
146+
Project project = context.getProject();
147+
148+
int startOffset = context.getStartOffset();
149+
int tailOffset = context.getTailOffset();
150+
151+
// Store the original insertion offset (where user typed "#")
152+
int originalInsertionOffset = startOffset;
153+
154+
// Check if there's a "#" before the completion position
155+
// If yes, we need to delete it to avoid "##[Attribute()]"
156+
if (startOffset > 0) {
157+
CharSequence text = document.getCharsSequence();
158+
if (text.charAt(startOffset - 1) == '#') {
159+
// Delete the "#" that was typed
160+
document.deleteString(startOffset - 1, tailOffset);
161+
originalInsertionOffset = startOffset - 1;
162+
} else {
163+
// Delete just the dummy identifier
164+
document.deleteString(startOffset, tailOffset);
165+
}
166+
} else {
167+
// Delete just the dummy identifier
168+
document.deleteString(startOffset, tailOffset);
169+
}
170+
171+
// First commit to get proper PSI
172+
PsiDocumentManager.getInstance(project).commitDocument(document);
173+
PsiFile file = context.getFile();
174+
175+
// Find the insertion position - look for the next method
176+
PsiElement elementAt = file.findElementAt(originalInsertionOffset);
177+
PhpClass phpClass = PsiTreeUtil.getParentOfType(elementAt, PhpClass.class);
178+
179+
// Find the method we're adding the attribute to
180+
Method targetMethod = null;
181+
if (phpClass != null) {
182+
for (Method method : phpClass.getOwnMethods()) {
183+
if (method.getTextOffset() > originalInsertionOffset) {
184+
targetMethod = method;
185+
break;
186+
}
187+
}
188+
}
189+
190+
if (targetMethod == null) {
191+
return; // Can't find target method
192+
}
193+
194+
// Extract class name from FQN (get the last part after the last backslash)
195+
String className = attributeFqn.substring(attributeFqn.lastIndexOf('\\') + 1);
196+
197+
// Store document length before adding import to calculate offset shift
198+
int documentLengthBeforeImport = document.getTextLength();
199+
200+
// Add import if necessary - this will modify the document!
201+
String importedName = PhpElementsUtil.insertUseIfNecessary(phpClass, attributeFqn);
202+
if (importedName != null) {
203+
className = importedName;
204+
}
205+
206+
// IMPORTANT: After adding import, commit and recalculate the insertion position
207+
PsiDocumentManager psiDocManager = PsiDocumentManager.getInstance(project);
208+
psiDocManager.commitDocument(document);
209+
psiDocManager.doPostponedOperationsAndUnblockDocument(document);
210+
211+
// Calculate how much the document length changed (import adds characters above our insertion point)
212+
int documentLengthAfterImport = document.getTextLength();
213+
int offsetShift = documentLengthAfterImport - documentLengthBeforeImport;
214+
215+
// Adjust insertion offset by the shift caused by import
216+
int currentInsertionOffset = originalInsertionOffset + offsetShift;
217+
218+
// Build attribute text based on cursor position
219+
String attributeText = "#[" + className + (cursorPosition == CursorPosition.INSIDE_QUOTES ? "(\"\")]\n" : "()]\n");
220+
221+
// Insert at the cursor position where user typed "#"
222+
document.insertString(currentInsertionOffset, attributeText);
223+
224+
// Commit and reformat
225+
psiDocManager.commitDocument(document);
226+
psiDocManager.doPostponedOperationsAndUnblockDocument(document);
227+
228+
// Reformat the added attribute
229+
CodeUtil.reformatAddedAttribute(project, document, currentInsertionOffset);
230+
231+
// After reformatting, position cursor based on the cursor position mode
232+
psiDocManager.commitDocument(document);
233+
234+
// Get fresh PSI and find the attribute we just added
235+
PsiFile finalFile = psiDocManager.getPsiFile(document);
236+
if (finalFile != null) {
237+
// Look for element INSIDE the inserted attribute (a few chars after insertion point)
238+
PsiElement elementInsideAttribute = finalFile.findElementAt(currentInsertionOffset + 3);
239+
if (elementInsideAttribute != null) {
240+
// Find the PhpAttribute element
241+
PhpAttribute phpAttribute =
242+
PsiTreeUtil.getParentOfType(elementInsideAttribute, PhpAttribute.class);
243+
244+
if (phpAttribute != null) {
245+
int attributeStart = phpAttribute.getTextRange().getStartOffset();
246+
int attributeEnd = phpAttribute.getTextRange().getEndOffset();
247+
CharSequence attributeContent = document.getCharsSequence().subSequence(attributeStart, attributeEnd);
248+
249+
// Find cursor position based on mode
250+
String searchChar = cursorPosition == CursorPosition.INSIDE_QUOTES ? "\"" : "(";
251+
int searchIndex = attributeContent.toString().indexOf(searchChar);
252+
253+
if (searchIndex >= 0) {
254+
// Position cursor right after the search character
255+
int caretOffset = attributeStart + searchIndex + 1;
256+
editor.getCaretModel().moveToOffset(caretOffset);
257+
}
258+
}
259+
}
260+
}
261+
}
262+
}
263+
}
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)