Skip to content

Commit 4e876c5

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 d42d090 commit 4e876c5

File tree

7 files changed

+657
-0
lines changed

7 files changed

+657
-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

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

Lines changed: 417 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
private static @Nullable Method getMethod(@NotNull PsiElement element) {
84+
Method foundMethod = null;
85+
86+
if (element.getParent() instanceof Method method) {
87+
foundMethod = method;
88+
} else if (PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true) instanceof Method method) {
89+
foundMethod = method;
90+
}
91+
92+
return foundMethod != null && foundMethod.getAccess().isPublic()
93+
? foundMethod
94+
: null;
95+
}
96+
}

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"/>
Lines changed: 15 additions & 0 deletions
Loading
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package fr.adrienbrault.idea.symfony2plugin.tests.completion;
2+
3+
import com.jetbrains.php.lang.PhpFileType;
4+
import fr.adrienbrault.idea.symfony2plugin.tests.SymfonyLightCodeInsightFixtureTestCase;
5+
6+
/**
7+
* Test for PHP attribute completion
8+
*
9+
* @author Daniel Espendiller <daniel@espendiller.net>
10+
* @see fr.adrienbrault.idea.symfony2plugin.completion.PhpAttributeCompletionContributor
11+
*/
12+
public class PhpAttributeCompletionContributorTest extends SymfonyLightCodeInsightFixtureTestCase {
13+
14+
@Override
15+
public void setUp() throws Exception {
16+
super.setUp();
17+
myFixture.copyFileToProject("classes.php");
18+
}
19+
20+
@Override
21+
public String getTestDataPath() {
22+
return "src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures";
23+
}
24+
25+
public void testRouteAttributeCompletion() {
26+
// Test that the Route attribute appears in completion when the class exists
27+
assertCompletionContains(PhpFileType.INSTANCE,
28+
"<?php\n\nclass TestController {\n #<caret>\n public function index() { }\n}",
29+
"#[Route]"
30+
);
31+
}
32+
33+
public void testNoCompletionOutsideClass() {
34+
// Test that no attributes are suggested outside of a class
35+
assertCompletionNotContains(PhpFileType.INSTANCE,
36+
"<?php\n #<caret>\n function test() { }\n",
37+
"#[Route]", "#[IsGranted]", "#[Cache]"
38+
);
39+
}
40+
41+
public void testNoCompletionWithoutHash() {
42+
// Test that no attributes are suggested without the # character
43+
assertCompletionNotContains(PhpFileType.INSTANCE,
44+
"<?php\n\nclass TestController {\n <caret>\n public function index() { }\n}",
45+
"#[Route]", "#[IsGranted]", "#[Cache]"
46+
);
47+
}
48+
49+
public void testCacheAttributeInsertionWithNamespaceAddsUseStatement() {
50+
// Test Cache attribute insertion with namespace - should add use import
51+
myFixture.configureByText(PhpFileType.INSTANCE,
52+
"<?php\n\n" +
53+
"namespace App\\Controller;\n\n" +
54+
"class TestController {\n" +
55+
" #<caret>\n" +
56+
" public function index() { }\n" +
57+
"}"
58+
);
59+
myFixture.completeBasic();
60+
61+
var items = myFixture.getLookupElements();
62+
var cacheItem = java.util.Arrays.stream(items)
63+
.filter(l -> "#[Cache]".equals(l.getLookupString()))
64+
.findFirst()
65+
.orElse(null);
66+
67+
if (cacheItem != null) {
68+
myFixture.getLookup().setCurrentItem(cacheItem);
69+
myFixture.type('\n');
70+
71+
String result = myFixture.getFile().getText();
72+
73+
assertTrue("Result should contain use statement", result.contains("use Symfony\\Component\\HttpKernel\\Attribute\\Cache;"));
74+
assertTrue("Result should contain empty parentheses", result.contains("#[Cache()]"));
75+
}
76+
}
77+
78+
public void testCacheAttributeInsertionWithoutQuotes() {
79+
// Test that Cache attribute insertion doesn't include quotes (different from Route/IsGranted)
80+
myFixture.configureByText(PhpFileType.INSTANCE,
81+
"<?php\n\n" +
82+
"namespace App\\Controller;\n\n" +
83+
"class TestController {\n" +
84+
" #<caret>\n" +
85+
" public function index() { }\n" +
86+
"}"
87+
);
88+
myFixture.completeBasic();
89+
90+
var items = myFixture.getLookupElements();
91+
var cacheItem = java.util.Arrays.stream(items)
92+
.filter(l -> "#[Cache]".equals(l.getLookupString()))
93+
.findFirst()
94+
.orElse(null);
95+
96+
myFixture.getLookup().setCurrentItem(cacheItem);
97+
myFixture.type('\n');
98+
99+
String result = myFixture.getFile().getText();
100+
101+
assertTrue("Result should contain Cache use statement", result.contains("use Symfony\\Component\\HttpKernel\\Attribute\\Cache;"));
102+
assertTrue("Result should contain empty parentheses", result.contains("#[Cache()]"));
103+
}
104+
}

src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,22 @@ class FooControllerInvoke
4444
{
4545
public function __invoke() {}
4646
}
47+
}
48+
49+
namespace Symfony\Component\Routing\Attribute {
50+
class Route
51+
{
52+
}
53+
}
54+
55+
namespace Symfony\Component\Security\Http\Attribute {
56+
class IsGranted
57+
{
58+
}
59+
}
60+
61+
namespace Symfony\Component\HttpKernel\Attribute {
62+
class Cache
63+
{
64+
}
4765
}

0 commit comments

Comments
 (0)