diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java index 254efffce..df054d7a8 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java @@ -26,9 +26,14 @@ import java.util.Collection; /** - * Provides completion for Symfony PHP attributes like #[Route()] + * Provides completion for Symfony PHP attributes like #[Route()] and #[AsController] * - * Triggers when typing "#" before a public method + * Triggers when typing "#" before a public method or class + * + * Supports: + * - Class-level attributes: #[Route], #[AsController], #[IsGranted] + * - Method-level attributes: #[Route], #[IsGranted], #[Cache] + * - Twig extension attributes: #[AsTwigFilter], #[AsTwigFunction], #[AsTwigTest] * * @author Daniel Espendiller */ @@ -37,6 +42,7 @@ 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"; + private static final String AS_CONTROLLER_ATTRIBUTE_FQN = "\\Symfony\\Component\\HttpKernel\\Attribute\\AsController"; private static final String AS_TWIG_FILTER_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigFilter"; private static final String AS_TWIG_FUNCTION_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigFunction"; private static final String AS_TWIG_TEST_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigTest"; @@ -67,21 +73,29 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull return; } - // Check if we're before a public method (using shared logic from PhpAttributeCompletionPopupHandlerCompletionConfidence) - Method method = PhpAttributeCompletionPopupHandlerCompletionConfidence.getMethod(position); - if (method == null) { - return; - } - Collection lookupElements = new ArrayList<>(); - PhpClass containingClass = method.getContainingClass(); - if (containingClass != null && AddRouteAttributeIntention.isControllerClass(containingClass)) { - lookupElements.addAll(getControllerCompletions(project)); - } + // Check if we're before a public method (using shared logic from PhpAttributeCompletionPopupHandlerCompletionConfidence) + Method method = PhpAttributeCompletionPopupHandlerCompletionConfidence.getMethod(position); + if (method != null) { + // Method-level attribute completions + PhpClass containingClass = method.getContainingClass(); + if (containingClass != null && AddRouteAttributeIntention.isControllerClass(containingClass)) { + lookupElements.addAll(getControllerMethodCompletions(project)); + } - if (containingClass != null && isTwigExtensionClass(containingClass)) { - lookupElements.addAll(getTwigExtensionCompletions(project)); + if (containingClass != null && isTwigExtensionClass(containingClass)) { + lookupElements.addAll(getTwigExtensionCompletions(project)); + } + } else { + // Check if we're before a class + PhpClass phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence.getPhpClass(position); + if (phpClass != null) { + // Class-level attribute completions + if (AddRouteAttributeIntention.isControllerClass(phpClass)) { + lookupElements.addAll(getControllerClassCompletions(project)); + } + } } // Stop here - don't show other completions when typing "#" for attributes @@ -91,7 +105,10 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull } } - private Collection getControllerCompletions(@NotNull Project project) { + /** + * Get controller method-level attribute completions (for methods in controller classes) + */ + private Collection getControllerMethodCompletions(@NotNull Project project) { Collection lookupElements = new ArrayList<>(); // Add Route attribute completion @@ -133,6 +150,51 @@ private Collection getControllerCompletions(@NotNull Project proj return lookupElements; } + /** + * Get controller class-level attribute completions (for controller classes) + */ + private Collection getControllerClassCompletions(@NotNull Project project) { + Collection lookupElements = new ArrayList<>(); + + // Add Route attribute completion (for class-level route prefix) + if (PhpElementsUtil.hasClassOrInterface(project, ROUTE_ATTRIBUTE_FQN)) { + 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(); + + lookupElements.add(routeLookupElement); + } + + // Add AsController attribute completion + if (PhpElementsUtil.hasClassOrInterface(project, AS_CONTROLLER_ATTRIBUTE_FQN)) { + LookupElement asControllerLookupElement = LookupElementBuilder + .create("#[AsController]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(AS_CONTROLLER_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(AS_CONTROLLER_ATTRIBUTE_FQN, CursorPosition.NONE)) + .bold(); + + lookupElements.add(asControllerLookupElement); + } + + // Add IsGranted attribute completion (for class-level security) + if (PhpElementsUtil.hasClassOrInterface(project, IS_GRANTED_ATTRIBUTE_FQN)) { + 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(); + + lookupElements.add(isGrantedLookupElement); + } + + return lookupElements; + } + private Collection getTwigExtensionCompletions(@NotNull Project project) { Collection lookupElements = new ArrayList<>(); @@ -245,7 +307,9 @@ private enum CursorPosition { /** Position cursor inside quotes: #[Attribute("")] */ INSIDE_QUOTES, /** Position cursor inside parentheses: #[Attribute()] */ - INSIDE_PARENTHESES + INSIDE_PARENTHESES, + /** No parentheses needed: #[Attribute] */ + NONE } /** @@ -262,6 +326,28 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme int startOffset = context.getStartOffset(); int tailOffset = context.getTailOffset(); + // IMPORTANT: Find the target class/method BEFORE modifying the document + // because PSI structure might change after deletions + PsiFile file = context.getFile(); + PsiElement originalElement = file.findElementAt(startOffset); + if (originalElement == null) { + return; + } + + // Determine the target context (method or class) dynamically + PhpClass phpClass; + Method targetMethod = PhpAttributeCompletionPopupHandlerCompletionConfidence.getMethod(originalElement); + if (targetMethod != null) { + // We're in a method context + phpClass = targetMethod.getContainingClass(); + } else { + // Try class context + phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence.getPhpClass(originalElement); + if (phpClass == null) { + return; + } + } + // Store the original insertion offset (where user typed "#") int originalInsertionOffset = startOffset; @@ -282,28 +368,8 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme document.deleteString(startOffset, tailOffset); } - // First commit to get proper PSI + // Commit after deletion 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); @@ -329,8 +395,27 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme // Adjust insertion offset by the shift caused by import int currentInsertionOffset = originalInsertionOffset + offsetShift; + // Check if there's already a newline at the current position + // to avoid adding double newlines + CharSequence currentText = document.getCharsSequence(); + boolean hasNewlineAfter = false; + if (currentInsertionOffset < currentText.length()) { + char nextChar = currentText.charAt(currentInsertionOffset); + hasNewlineAfter = (nextChar == '\n' || nextChar == '\r'); + } + // Build attribute text based on cursor position - String attributeText = "#[" + className + (cursorPosition == CursorPosition.INSIDE_QUOTES ? "(\"\")]\n" : "()]\n"); + String attributeText; + String newline = hasNewlineAfter ? "" : "\n"; + + if (cursorPosition == CursorPosition.INSIDE_QUOTES) { + attributeText = "#[" + className + "(\"\")]" + newline; + } else if (cursorPosition == CursorPosition.INSIDE_PARENTHESES) { + attributeText = "#[" + className + "()]" + newline; + } else { + // CursorPosition.NONE - no parentheses + attributeText = "#[" + className + "]" + newline; + } // Insert at the cursor position where user typed "#" document.insertString(currentInsertionOffset, attributeText); @@ -352,22 +437,27 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme PsiElement elementInsideAttribute = finalFile.findElementAt(currentInsertionOffset + 3); if (elementInsideAttribute != null) { // Find the PhpAttribute element - PhpAttribute phpAttribute = - PsiTreeUtil.getParentOfType(elementInsideAttribute, PhpAttribute.class); + 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); + if (cursorPosition == CursorPosition.NONE) { + // For attributes without parentheses, position cursor at the end of the line + // (after the closing bracket and newline) + editor.getCaretModel().moveToOffset(attributeEnd + 1); + } else { + // 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 index bc13312e4..3b22de677 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java @@ -12,6 +12,7 @@ import com.jetbrains.php.lang.psi.PhpFile; import com.jetbrains.php.lang.psi.PhpPsiUtil; import com.jetbrains.php.lang.psi.elements.Method; +import com.jetbrains.php.lang.psi.elements.PhpClass; import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -31,8 +32,8 @@ public ThreeState shouldSkipAutopopup(@NotNull Editor editor, @NotNull PsiElemen return ThreeState.UNSURE; } - Method foundMethod = getMethod(contextElement); - if (foundMethod == null) { + // Check if we're before a method or a class + if (getMethod(contextElement) == null && getPhpClass(contextElement) == null) { return ThreeState.UNSURE; } @@ -48,7 +49,7 @@ public ThreeState shouldSkipAutopopup(@NotNull Editor editor, @NotNull PsiElemen /** * Triggers auto-popup completion after typing '#' character in PHP files - * when positioned before a public method (for PHP attributes like #[Route()]) + * when positioned before a public method or class (for PHP attributes like #[Route()]) * * @author Daniel Espendiller */ @@ -61,7 +62,7 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate { // Check if we're in a class context int offset = editor.getCaretModel().getOffset(); - if (!(file.findElementAt(offset - 2) instanceof PsiWhiteSpace)) { + if (!(file.findElementAt(offset - 2) instanceof PsiWhiteSpace) && !(file.findElementAt(offset - 1) instanceof PsiWhiteSpace)) { return Result.CONTINUE; } @@ -70,8 +71,8 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate { return Result.CONTINUE; } - Method foundMethod = getMethod(element); - if (foundMethod == null) { + // Check if we're before a method or a class + if (getMethod(element) == null && getPhpClass(element) == null) { return Result.CONTINUE; } @@ -100,4 +101,22 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate { ? foundMethod : null; } + + /** + * Finds a PhpClass associated with the given element. + * Returns the class if the element is a child of a class or if the next sibling is a class. + * Also handles cases where we're in the middle of an attribute list. + * + * @param element The PSI element to check + * @return The PhpClass if found, null otherwise + */ + public static @Nullable PhpClass getPhpClass(@NotNull PsiElement element) { + if (element.getParent() instanceof PhpClass phpClass) { + return phpClass; + } else if (PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true) instanceof PhpClass phpClass) { + return phpClass; + } + + return null; + } } 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 index f25d3f96e..4fae03a95 100644 --- a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java @@ -208,4 +208,220 @@ public void testNoAsTwigAttributesOutsidePublicMethod() { "#[AsTwigFilter]", "#[AsTwigFunction]", "#[AsTwigTest]" ); } + + // =============================== + // Class-level attribute tests + // =============================== + + public void testRouteAttributeCompletionAtClassLevel() { + // Test that the Route attribute appears in completion for controller classes + assertCompletionContains(PhpFileType.INSTANCE, + "\nclass TestController {\n public function index() { }\n}", + "#[Route]" + ); + } + + public void testAsControllerAttributeCompletionAtClassLevel() { + // Test that the AsController attribute appears in completion for controller classes + assertCompletionContains(PhpFileType.INSTANCE, + "\nclass TestController {\n public function index() { }\n}", + "#[AsController]" + ); + } + + public void testBothRouteAndAsControllerAtClassLevel() { + // Test that both Route and AsController attributes are available at class level + assertCompletionContains(PhpFileType.INSTANCE, + "\nclass MyController {\n public function action() { }\n}", + "#[Route]", "#[AsController]" + ); + } + + public void testNoIsGrantedOrCacheAtClassLevel() { + // Test that IsGranted and Cache attributes are NOT available at class level (method-only) + assertCompletionNotContains(PhpFileType.INSTANCE, + "\nclass TestController {\n public function index() { }\n}", + "#[IsGranted]", "#[Cache]" + ); + } + + public void testNoClassLevelAttributesForNonControllerClass() { + // Test that class-level controller attributes don't appear for non-controller classes + assertCompletionNotContains(PhpFileType.INSTANCE, + "\nclass MyService {\n public function doSomething() { }\n}", + "#[Route]", "#[AsController]" + ); + } + + public void testMethodLevelAttributesStillWorkWithClassLevelSupport() { + // Test that method-level attributes still work correctly (regression test) + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function index() { }\n}", + "#[Route]", "#[IsGranted]", "#[Cache]" + ); + } + + public void testNoAsControllerAtMethodLevel() { + // Test that AsController is NOT available at method level (class-only) + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n public function index() { }\n}", + "#[AsController]" + ); + } + + public void testRouteAttributeInsertionAtClassLevelWithNamespace() { + // Test Route attribute insertion at class level with namespace - should add use import + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + "class TestController {\n" + + " public function index() { }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + var routeItem = java.util.Arrays.stream(items) + .filter(l -> "#[Route]".equals(l.getLookupString())) + .findFirst() + .orElse(null); + + if (routeItem != null) { + myFixture.getLookup().setCurrentItem(routeItem); + myFixture.type('\n'); + + String result = myFixture.getFile().getText(); + + assertTrue("Result should contain Route use statement", result.contains("use Symfony\\Component\\Routing\\Attribute\\Route;")); + assertTrue("Result should contain quotes for route path", result.contains("#[Route(\"\")]")); + assertTrue("Result should have Route attribute before class", result.indexOf("#[Route") < result.indexOf("class TestController")); + } + } + + public void testAsControllerAttributeInsertionAtClassLevelWithoutParentheses() { + // Test AsController attribute insertion at class level - should NOT have parentheses + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + "class TestController {\n" + + " public function index() { }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + var asControllerItem = java.util.Arrays.stream(items) + .filter(l -> "#[AsController]".equals(l.getLookupString())) + .findFirst() + .orElse(null); + + if (asControllerItem != null) { + myFixture.getLookup().setCurrentItem(asControllerItem); + myFixture.type('\n'); + + String result = myFixture.getFile().getText(); + + assertTrue("Result should contain AsController use statement", result.contains("use Symfony\\Component\\HttpKernel\\Attribute\\AsController;")); + assertTrue("Result should contain AsController without parentheses", result.contains("#[AsController]")); + assertFalse("Result should NOT contain parentheses for AsController", result.contains("#[AsController(")); + assertTrue("Result should have AsController attribute before class", result.indexOf("#[AsController]") < result.indexOf("class TestController")); + } + } + + public void testClassLevelRouteAttributeWithQuotes() { + // Test that class-level Route attribute insertion includes quotes (for route prefix) + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + "class ApiController {\n" + + " public function index() { }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + var routeItem = java.util.Arrays.stream(items) + .filter(l -> "#[Route]".equals(l.getLookupString())) + .findFirst() + .orElse(null); + + if (routeItem != null) { + myFixture.getLookup().setCurrentItem(routeItem); + myFixture.type('\n'); + + String result = myFixture.getFile().getText(); + + assertTrue("Result should contain Route use statement", result.contains("use Symfony\\Component\\Routing\\Attribute\\Route;")); + assertTrue("Result should contain quotes for route path at class level", result.contains("#[Route(\"\")]")); + } + } + + public void testNoClassLevelCompletionWithoutHash() { + // Test that no class-level attributes are suggested without the # character + assertCompletionNotContains(PhpFileType.INSTANCE, + "\nclass TestController {\n public function index() { }\n}", + "#[Route]", "#[AsController]" + ); + } + + public void testNoClassLevelCompletionOutsideClass() { + // Test that class-level attributes don't appear outside of a class context + assertCompletionNotContains(PhpFileType.INSTANCE, + "\nfunction globalFunction() { }\n", + "#[Route]", "#[AsController]" + ); + } + + public void testClassLevelCompletionOnlyForControllerNamedClasses() { + // Test that class-level attributes only appear for classes named with "Controller" suffix + assertCompletionContains(PhpFileType.INSTANCE, + "\nclass UserController {\n public function show() { }\n}", + "#[Route]", "#[AsController]" + ); + } + + public void testMultipleAttributesAtClassLevel() { + // Test that multiple attributes can be added at class level (e.g., both Route and AsController) + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + "class ProductController {\n" + + " public function list() { }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + + // Check that both Route and AsController are available + long routeCount = java.util.Arrays.stream(items) + .filter(l -> "#[Route]".equals(l.getLookupString())) + .count(); + long asControllerCount = java.util.Arrays.stream(items) + .filter(l -> "#[AsController]".equals(l.getLookupString())) + .count(); + + assertTrue("Route attribute should be available", routeCount > 0); + assertTrue("AsController attribute should be available", asControllerCount > 0); + } + + public void testClassLevelCompletionWithExistingAttributes() { + // Test that completion works when there are already attributes on the class + assertCompletionContains(PhpFileType.INSTANCE, + "\nclass FoobarController {\n public function index() { }\n}", + "#[Route]" + ); + } + + public void testClassLevelCompletionWithMultipleExistingAttributes() { + // Test that completion works when there are multiple existing attributes + assertCompletionContains(PhpFileType.INSTANCE, + "\nclass TestController {\n public function test() { }\n}", + "#[Route]", "#[AsController]" + ); + } } \ 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 a8cf2126c..fd6eddfd7 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 @@ -62,6 +62,10 @@ class IsGranted class Cache { } + + class AsController + { + } } namespace Twig\Extension {