diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/Symfony2Icons.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/Symfony2Icons.java index fa7fa5a48..e489de25e 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/Symfony2Icons.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/Symfony2Icons.java @@ -78,6 +78,7 @@ public class Symfony2Icons { public static final Icon SYMFONY_AI = IconLoader.getIcon("/icons/symfony_ai.png", Symfony2Icons.class); public static final Icon SYMFONY_AI_OPACITY = IconLoader.getIcon("/icons/symfony_ai_opacity.png", Symfony2Icons.class); + public static final Icon SYMFONY_ATTRIBUTE = IconLoader.getIcon("/icons/symfony_attribute.svg", Symfony2Icons.class); public static Image getImage(Icon icon) { diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java new file mode 100644 index 000000000..0b1799ed3 --- /dev/null +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java @@ -0,0 +1,268 @@ +package fr.adrienbrault.idea.symfony2plugin.completion; + +import com.intellij.codeInsight.completion.*; +import com.intellij.codeInsight.lookup.LookupElement; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.*; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.util.ProcessingContext; +import com.jetbrains.php.lang.PhpLanguage; +import com.jetbrains.php.lang.psi.elements.Method; +import com.jetbrains.php.lang.psi.elements.PhpAttribute; +import com.jetbrains.php.lang.psi.elements.PhpClass; +import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons; +import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; +import fr.adrienbrault.idea.symfony2plugin.util.CodeUtil; +import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; + +/** + * Provides completion for Symfony PHP attributes like #[Route()] + * + * Triggers when typing "#" before a public method + * + * @author Daniel Espendiller + */ +public class PhpAttributeCompletionContributor extends CompletionContributor { + + private static final String ROUTE_ATTRIBUTE_FQN = "\\Symfony\\Component\\Routing\\Attribute\\Route"; + private static final String IS_GRANTED_ATTRIBUTE_FQN = "\\Symfony\\Component\\Security\\Http\\Attribute\\IsGranted"; + private static final String CACHE_ATTRIBUTE_FQN = "\\Symfony\\Component\\HttpKernel\\Attribute\\Cache"; + + public PhpAttributeCompletionContributor() { + // Match any element in PHP files - we'll do more specific checking in the provider + // Using a broad pattern to catch completion after "#" character + extend( + CompletionType.BASIC, + PlatformPatterns.psiElement().inFile(PlatformPatterns.psiFile().withLanguage(PhpLanguage.INSTANCE)), + new PhpAttributeCompletionProvider() + ); + } + + private static class PhpAttributeCompletionProvider extends CompletionProvider { + @Override + protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull ProcessingContext context, @NotNull CompletionResultSet result) { + PsiElement position = parameters.getPosition(); + Project project = position.getProject(); + + if (!Symfony2ProjectComponent.isEnabled(project)) { + return; + } + + // Check if we're in a context where an attribute makes sense (after "#" with whitespace before it) + if (!isAttributeContext(parameters)) { + return; + } + + // Check if we're before a public method (using shared logic from PhpAttributeCompletionPopupHandlerCompletionConfidence) + Method method = PhpAttributeCompletionPopupHandlerCompletionConfidence.getMethod(position); + if (method == null) { + return; + } + + // Add Route attribute completion + if (PhpElementsUtil.getClassInterface(project, ROUTE_ATTRIBUTE_FQN) != null) { + LookupElement routeLookupElement = LookupElementBuilder + .create("#[Route]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(ROUTE_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(ROUTE_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES)) + .bold(); + + result.addElement(routeLookupElement); + } + + // Add IsGranted attribute completion + if (PhpElementsUtil.getClassInterface(project, IS_GRANTED_ATTRIBUTE_FQN) != null) { + LookupElement isGrantedLookupElement = LookupElementBuilder + .create("#[IsGranted]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(IS_GRANTED_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(IS_GRANTED_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES)) + .bold(); + + result.addElement(isGrantedLookupElement); + } + + // Add Cache attribute completion + if (PhpElementsUtil.getClassInterface(project, CACHE_ATTRIBUTE_FQN) != null) { + LookupElement cacheLookupElement = LookupElementBuilder + .create("#[Cache]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(CACHE_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(CACHE_ATTRIBUTE_FQN, CursorPosition.INSIDE_PARENTHESES)) + .bold(); + + result.addElement(cacheLookupElement); + } + + // Stop here - don't show other completions when typing "#" for attributes + result.stopHere(); + } + + /** + * Check if we're in a context where typing "#" for attributes makes sense + * (i.e., after "#" character with whitespace before it) + */ + private boolean isAttributeContext(@NotNull CompletionParameters parameters) { + int offset = parameters.getOffset(); + PsiFile psiFile = parameters.getOriginalFile(); + + // Need at least 2 characters before cursor to check for "# " pattern + if (offset < 2) { + return false; + } + + // Check if there's a "#" before the cursor with whitespace before it + // secure length check + CharSequence documentText = parameters.getEditor().getDocument().getCharsSequence(); + if (offset < documentText.length()) { + return documentText.charAt(offset - 1) == '#' && psiFile.findElementAt(offset - 2) instanceof PsiWhiteSpace; + } + + return false; + } + } + + /** + * Enum to specify where the cursor should be positioned after attribute insertion + */ + private enum CursorPosition { + /** Position cursor inside quotes: #[Attribute("")] */ + INSIDE_QUOTES, + /** Position cursor inside parentheses: #[Attribute()] */ + INSIDE_PARENTHESES + } + + /** + * Insert handler that adds a PHP attribute + */ + private record PhpAttributeInsertHandler(@NotNull String attributeFqn, @NotNull CursorPosition cursorPosition) implements InsertHandler { + + @Override + public void handleInsert(@NotNull InsertionContext context, @NotNull LookupElement item) { + Editor editor = context.getEditor(); + Document document = editor.getDocument(); + Project project = context.getProject(); + + int startOffset = context.getStartOffset(); + int tailOffset = context.getTailOffset(); + + // Store the original insertion offset (where user typed "#") + int originalInsertionOffset = startOffset; + + // Check if there's a "#" before the completion position + // If yes, we need to delete it to avoid "##[Attribute()]" + if (startOffset > 0) { + CharSequence text = document.getCharsSequence(); + if (text.charAt(startOffset - 1) == '#') { + // Delete the "#" that was typed + document.deleteString(startOffset - 1, tailOffset); + originalInsertionOffset = startOffset - 1; + } else { + // Delete just the dummy identifier + document.deleteString(startOffset, tailOffset); + } + } else { + // Delete just the dummy identifier + document.deleteString(startOffset, tailOffset); + } + + // First commit to get proper PSI + PsiDocumentManager.getInstance(project).commitDocument(document); + PsiFile file = context.getFile(); + + // Find the insertion position - look for the next method + PsiElement elementAt = file.findElementAt(originalInsertionOffset); + PhpClass phpClass = PsiTreeUtil.getParentOfType(elementAt, PhpClass.class); + + // Find the method we're adding the attribute to + Method targetMethod = null; + if (phpClass != null) { + for (Method method : phpClass.getOwnMethods()) { + if (method.getTextOffset() > originalInsertionOffset) { + targetMethod = method; + break; + } + } + } + + if (targetMethod == null) { + return; // Can't find target method + } + + // Extract class name from FQN (get the last part after the last backslash) + String className = attributeFqn.substring(attributeFqn.lastIndexOf('\\') + 1); + + // Store document length before adding import to calculate offset shift + int documentLengthBeforeImport = document.getTextLength(); + + // Add import if necessary - this will modify the document! + String importedName = PhpElementsUtil.insertUseIfNecessary(phpClass, attributeFqn); + if (importedName != null) { + className = importedName; + } + + // IMPORTANT: After adding import, commit and recalculate the insertion position + PsiDocumentManager psiDocManager = PsiDocumentManager.getInstance(project); + psiDocManager.commitDocument(document); + psiDocManager.doPostponedOperationsAndUnblockDocument(document); + + // Calculate how much the document length changed (import adds characters above our insertion point) + int documentLengthAfterImport = document.getTextLength(); + int offsetShift = documentLengthAfterImport - documentLengthBeforeImport; + + // Adjust insertion offset by the shift caused by import + int currentInsertionOffset = originalInsertionOffset + offsetShift; + + // Build attribute text based on cursor position + String attributeText = "#[" + className + (cursorPosition == CursorPosition.INSIDE_QUOTES ? "(\"\")]\n" : "()]\n"); + + // Insert at the cursor position where user typed "#" + document.insertString(currentInsertionOffset, attributeText); + + // Commit and reformat + psiDocManager.commitDocument(document); + psiDocManager.doPostponedOperationsAndUnblockDocument(document); + + // Reformat the added attribute + CodeUtil.reformatAddedAttribute(project, document, currentInsertionOffset); + + // After reformatting, position cursor based on the cursor position mode + psiDocManager.commitDocument(document); + + // Get fresh PSI and find the attribute we just added + PsiFile finalFile = psiDocManager.getPsiFile(document); + if (finalFile != null) { + // Look for element INSIDE the inserted attribute (a few chars after insertion point) + PsiElement elementInsideAttribute = finalFile.findElementAt(currentInsertionOffset + 3); + if (elementInsideAttribute != null) { + // Find the PhpAttribute element + PhpAttribute phpAttribute = + PsiTreeUtil.getParentOfType(elementInsideAttribute, PhpAttribute.class); + + if (phpAttribute != null) { + int attributeStart = phpAttribute.getTextRange().getStartOffset(); + int attributeEnd = phpAttribute.getTextRange().getEndOffset(); + CharSequence attributeContent = document.getCharsSequence().subSequence(attributeStart, attributeEnd); + + // Find cursor position based on mode + String searchChar = cursorPosition == CursorPosition.INSIDE_QUOTES ? "\"" : "("; + int searchIndex = attributeContent.toString().indexOf(searchChar); + + if (searchIndex >= 0) { + // Position cursor right after the search character + int caretOffset = attributeStart + searchIndex + 1; + editor.getCaretModel().moveToOffset(caretOffset); + } + } + } + } + } + } +} diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java new file mode 100644 index 000000000..bc13312e4 --- /dev/null +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java @@ -0,0 +1,103 @@ +package fr.adrienbrault.idea.symfony2plugin.completion; + +import com.intellij.codeInsight.AutoPopupController; +import com.intellij.codeInsight.completion.CompletionConfidence; +import com.intellij.codeInsight.editorActions.TypedHandlerDelegate; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiWhiteSpace; +import com.intellij.util.ThreeState; +import com.jetbrains.php.lang.psi.PhpFile; +import com.jetbrains.php.lang.psi.PhpPsiUtil; +import com.jetbrains.php.lang.psi.elements.Method; +import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class PhpAttributeCompletionPopupHandlerCompletionConfidence { + /** + * Tells IntelliJ that completion should definitely run after "#" in PHP classes + * This is needed for auto-popup to work for PHP attributes + * + * @author Daniel Espendiller + */ + public static class PhpAttributeCompletionConfidence extends CompletionConfidence { + @NotNull + @Override + public ThreeState shouldSkipAutopopup(@NotNull Editor editor, @NotNull PsiElement contextElement, @NotNull PsiFile psiFile, int offset) { + if (offset <= 0 || !(psiFile instanceof PhpFile) || !Symfony2ProjectComponent.isEnabled(editor.getProject())) { + return ThreeState.UNSURE; + } + + Method foundMethod = getMethod(contextElement); + if (foundMethod == null) { + return ThreeState.UNSURE; + } + + // Check if there's a "#" before the cursor in the document + CharSequence documentText = editor.getDocument().getCharsSequence(); + if (documentText.charAt(offset - 1) == '#' && psiFile.findElementAt(offset - 2) instanceof PsiWhiteSpace) { + return ThreeState.NO; + } + + return ThreeState.UNSURE; + } + } + + /** + * Triggers auto-popup completion after typing '#' character in PHP files + * when positioned before a public method (for PHP attributes like #[Route()]) + * + * @author Daniel Espendiller + */ + public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate { + public @NotNull Result checkAutoPopup(char charTyped, @NotNull Project project, @NotNull Editor editor, @NotNull PsiFile file) { + if (charTyped != '#' || !(file instanceof PhpFile) || !Symfony2ProjectComponent.isEnabled(project)) { + return Result.CONTINUE; + } + + + // Check if we're in a class context + int offset = editor.getCaretModel().getOffset(); + if (!(file.findElementAt(offset - 2) instanceof PsiWhiteSpace)) { + return Result.CONTINUE; + } + + PsiElement element = file.findElementAt(offset - 1); + if (element == null) { + return Result.CONTINUE; + } + + Method foundMethod = getMethod(element); + if (foundMethod == null) { + return Result.CONTINUE; + } + + AutoPopupController.getInstance(project).scheduleAutoPopup(editor); + return Result.STOP; + } + } + + /** + * Finds a public method associated with the given element. + * Returns the method if the element is a child of a method or if the next sibling is a method. + * + * @param element The PSI element to check + * @return The public method if found, null otherwise + */ + public static @Nullable Method getMethod(@NotNull PsiElement element) { + Method foundMethod = null; + + if (element.getParent() instanceof Method method) { + foundMethod = method; + } else if (PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true) instanceof Method method) { + foundMethod = method; + } + + return foundMethod != null && foundMethod.getAccess().isPublic() + ? foundMethod + : null; + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index dd51af956..25a70498f 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -249,6 +249,12 @@ + + + + + + diff --git a/src/main/resources/icons/symfony_attribute.svg b/src/main/resources/icons/symfony_attribute.svg new file mode 100644 index 000000000..ac319bbe0 --- /dev/null +++ b/src/main/resources/icons/symfony_attribute.svg @@ -0,0 +1,15 @@ + + + + + # + + \ No newline at end of file diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java new file mode 100644 index 000000000..c12881bad --- /dev/null +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java @@ -0,0 +1,104 @@ +package fr.adrienbrault.idea.symfony2plugin.tests.completion; + +import com.jetbrains.php.lang.PhpFileType; +import fr.adrienbrault.idea.symfony2plugin.tests.SymfonyLightCodeInsightFixtureTestCase; + +/** + * Test for PHP attribute completion + * + * @author Daniel Espendiller + * @see fr.adrienbrault.idea.symfony2plugin.completion.PhpAttributeCompletionContributor + */ +public class PhpAttributeCompletionContributorTest extends SymfonyLightCodeInsightFixtureTestCase { + + @Override + public void setUp() throws Exception { + super.setUp(); + myFixture.copyFileToProject("classes.php"); + } + + @Override + public String getTestDataPath() { + return "src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures"; + } + + public void testRouteAttributeCompletion() { + // Test that the Route attribute appears in completion when the class exists + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function index() { }\n}", + "#[Route]" + ); + } + + public void testNoCompletionOutsideClass() { + // Test that no attributes are suggested outside of a class + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n function test() { }\n", + "#[Route]", "#[IsGranted]", "#[Cache]" + ); + } + + public void testNoCompletionWithoutHash() { + // Test that no attributes are suggested without the # character + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n public function index() { }\n}", + "#[Route]", "#[IsGranted]", "#[Cache]" + ); + } + + public void testCacheAttributeInsertionWithNamespaceAddsUseStatement() { + // Test Cache attribute insertion with namespace - should add use import + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + " public function index() { }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + var cacheItem = java.util.Arrays.stream(items) + .filter(l -> "#[Cache]".equals(l.getLookupString())) + .findFirst() + .orElse(null); + + if (cacheItem != null) { + myFixture.getLookup().setCurrentItem(cacheItem); + myFixture.type('\n'); + + String result = myFixture.getFile().getText(); + + assertTrue("Result should contain use statement", result.contains("use Symfony\\Component\\HttpKernel\\Attribute\\Cache;")); + assertTrue("Result should contain empty parentheses", result.contains("#[Cache()]")); + } + } + + public void testCacheAttributeInsertionWithoutQuotes() { + // Test that Cache attribute insertion doesn't include quotes (different from Route/IsGranted) + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + " public function index() { }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + var cacheItem = java.util.Arrays.stream(items) + .filter(l -> "#[Cache]".equals(l.getLookupString())) + .findFirst() + .orElse(null); + + myFixture.getLookup().setCurrentItem(cacheItem); + myFixture.type('\n'); + + String result = myFixture.getFile().getText(); + + assertTrue("Result should contain Cache use statement", result.contains("use Symfony\\Component\\HttpKernel\\Attribute\\Cache;")); + assertTrue("Result should contain empty parentheses", result.contains("#[Cache()]")); + } +} \ No newline at end of file diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php index 0b038c0ee..26e9529ff 100644 --- a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php @@ -44,4 +44,22 @@ class FooControllerInvoke { public function __invoke() {} } +} + +namespace Symfony\Component\Routing\Attribute { + class Route + { + } +} + +namespace Symfony\Component\Security\Http\Attribute { + class IsGranted + { + } +} + +namespace Symfony\Component\HttpKernel\Attribute { + class Cache + { + } } \ No newline at end of file