From 18ab7838237184315ce3792a2e4fd59986ef7b7f Mon Sep 17 00:00:00 2001 From: Daniel Espendiller Date: Thu, 18 Dec 2025 11:12:29 +0100 Subject: [PATCH] Add `#[ExposeInTemplate]`, `#[PreMount]`, and `#[PostMount]` attribute completions for Twig component methods and properties. --- .../PhpAttributeCompletionContributor.java | 162 ++++++++++--- ...etionPopupHandlerCompletionConfidence.java | 46 +++- ...PhpAttributeCompletionContributorTest.java | 213 ++++++++++++++++++ .../tests/completion/fixtures/classes.php | 12 + 4 files changed, 396 insertions(+), 37 deletions(-) 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 af477f9b4..63ccae7ac 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java @@ -11,6 +11,7 @@ import com.intellij.psi.util.PsiTreeUtil; import com.intellij.util.ProcessingContext; import com.jetbrains.php.lang.PhpLanguage; +import com.jetbrains.php.lang.psi.elements.Field; import com.jetbrains.php.lang.psi.elements.Method; import com.jetbrains.php.lang.psi.elements.PhpAttribute; import com.jetbrains.php.lang.psi.elements.PhpClass; @@ -30,11 +31,12 @@ /** * Provides completion for Symfony PHP attributes like #[Route()] and #[AsController] * - * Triggers when typing "#" before a public method or class + * Triggers when typing "#" before a public method, class, or property * * Supports: * - Class-level attributes: #[Route], #[AsController], #[IsGranted], #[AsTwigComponent] - * - Method-level attributes: #[Route], #[IsGranted], #[Cache] + * - Method-level attributes: #[Route], #[IsGranted], #[Cache], #[ExposeInTemplate], #[PreMount], #[PostMount] + * - Property-level attributes: #[ExposeInTemplate] * - Twig extension attributes: #[AsTwigFilter], #[AsTwigFunction], #[AsTwigTest] * * @author Daniel Espendiller @@ -49,6 +51,9 @@ public class PhpAttributeCompletionContributor extends CompletionContributor { private static final String AS_TWIG_FUNCTION_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigFunction"; private static final String AS_TWIG_TEST_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigTest"; private static final String AS_TWIG_COMPONENT_ATTRIBUTE_FQN = "\\Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent"; + private static final String EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN = "\\Symfony\\UX\\TwigComponent\\Attribute\\ExposeInTemplate"; + private static final String PRE_MOUNT_ATTRIBUTE_FQN = "\\Symfony\\UX\\TwigComponent\\Attribute\\PreMount"; + private static final String POST_MOUNT_ATTRIBUTE_FQN = "\\Symfony\\UX\\TwigComponent\\Attribute\\PostMount"; private static final String TWIG_EXTENSION_FQN = "\\Twig\\Extension\\AbstractExtension"; public PhpAttributeCompletionContributor() { @@ -90,17 +95,31 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull if (containingClass != null && isTwigExtensionClass(containingClass)) { lookupElements.addAll(getTwigExtensionCompletions(project)); } + + if (containingClass != null && hasAsTwigComponentAttribute(containingClass)) { + lookupElements.addAll(getTwigComponentMethodCompletions(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)); + // Check if we're before a property/field + Field field = PhpAttributeCompletionPopupHandlerCompletionConfidence.getField(position); + if (field != null) { + // Property-level attribute completions + PhpClass containingClass = field.getContainingClass(); + if (containingClass != null && hasAsTwigComponentAttribute(containingClass)) { + lookupElements.addAll(getTwigComponentPropertyCompletions(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)); + } - if (isTwigComponentClass(project, phpClass)) { - lookupElements.addAll(getTwigComponentClassCompletions(project)); + if (isTwigComponentClass(project, phpClass)) { + lookupElements.addAll(getTwigComponentClassCompletions(project)); + } } } } @@ -256,7 +275,7 @@ private Collection getTwigComponentClassCompletions(@NotNull Proj .create("#[AsTwigComponent]") .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) .withTypeText(StringUtils.stripStart(AS_TWIG_COMPONENT_ATTRIBUTE_FQN, "\\"), true) - .withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_COMPONENT_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES)) + .withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_COMPONENT_ATTRIBUTE_FQN, CursorPosition.NONE)) .bold(); lookupElements.add(lookupElement); @@ -352,6 +371,81 @@ private boolean isTwigExtensionClass(@NotNull PhpClass phpClass) { return false; } + /** + * Get attribute completions for public methods in AsTwigComponent classes + * Includes: ExposeInTemplate, PreMount, PostMount + */ + private Collection getTwigComponentMethodCompletions(@NotNull Project project) { + Collection lookupElements = new ArrayList<>(); + + // Add ExposeInTemplate attribute completion + if (PhpElementsUtil.hasClassOrInterface(project, EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN)) { + LookupElement lookupElement = LookupElementBuilder + .create("#[ExposeInTemplate]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN, CursorPosition.NONE)) + .bold(); + + lookupElements.add(lookupElement); + } + + // Add PreMount attribute completion + if (PhpElementsUtil.hasClassOrInterface(project, PRE_MOUNT_ATTRIBUTE_FQN)) { + LookupElement lookupElement = LookupElementBuilder + .create("#[PreMount]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(PRE_MOUNT_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(PRE_MOUNT_ATTRIBUTE_FQN, CursorPosition.NONE)) + .bold(); + + lookupElements.add(lookupElement); + } + + // Add PostMount attribute completion + if (PhpElementsUtil.hasClassOrInterface(project, POST_MOUNT_ATTRIBUTE_FQN)) { + LookupElement lookupElement = LookupElementBuilder + .create("#[PostMount]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(POST_MOUNT_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(POST_MOUNT_ATTRIBUTE_FQN, CursorPosition.NONE)) + .bold(); + + lookupElements.add(lookupElement); + } + + return lookupElements; + } + + /** + * Get attribute completions for properties in AsTwigComponent classes + * Includes: ExposeInTemplate + */ + private Collection getTwigComponentPropertyCompletions(@NotNull Project project) { + Collection lookupElements = new ArrayList<>(); + + // Add ExposeInTemplate attribute completion + if (PhpElementsUtil.hasClassOrInterface(project, EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN)) { + LookupElement lookupElement = LookupElementBuilder + .create("#[ExposeInTemplate]") + .withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE) + .withTypeText(StringUtils.stripStart(EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN, "\\"), true) + .withInsertHandler(new PhpAttributeInsertHandler(EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES)) + .bold(); + + lookupElements.add(lookupElement); + } + + return lookupElements; + } + + /** + * Check if the class has the #[AsTwigComponent] attribute + */ + private boolean hasAsTwigComponentAttribute(@NotNull PhpClass phpClass) { + return !phpClass.getAttributes(AS_TWIG_COMPONENT_ATTRIBUTE_FQN).isEmpty(); + } + /** * Check if we're in a context where typing "#" for attributes makes sense * (i.e., after "#" character with whitespace before it) @@ -410,40 +504,46 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme return; } - // Determine the target context (method or class) dynamically + // Determine the target context (method, field, 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; + // Try field context + Field targetField = PhpAttributeCompletionPopupHandlerCompletionConfidence.getField(originalElement); + if (targetField != null) { + phpClass = targetField.getContainingClass(); + } else { + // Try class context + phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence.getPhpClass(originalElement); + if (phpClass == null) { + return; + } } } // 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); + // Find and delete the "#" before the completion position to avoid "##[Attribute()]" + // Check the 1-2 positions immediately before startOffset + CharSequence text = document.getCharsSequence(); + int deleteStart = startOffset; + + // Check startOffset - 1 and startOffset - 2 for the "#" character + if (startOffset > 0 && text.charAt(startOffset - 1) == '#') { + deleteStart = startOffset - 1; + } else if (startOffset > 1 && text.charAt(startOffset - 2) == '#') { + // Handle case where there might be a single whitespace between # and dummy identifier + deleteStart = startOffset - 2; } + // Delete from the "#" (or startOffset if no "#" found) to tailOffset + document.deleteString(deleteStart, tailOffset); + originalInsertionOffset = deleteStart; + // Commit after deletion PsiDocumentManager.getInstance(project).commitDocument(document); 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 3b22de677..083f4b4cf 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java @@ -8,11 +8,11 @@ import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiWhiteSpace; +import com.intellij.psi.util.PsiTreeUtil; 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 com.jetbrains.php.lang.psi.elements.PhpClass; +import com.jetbrains.php.lang.psi.elements.*; import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -32,8 +32,8 @@ public ThreeState shouldSkipAutopopup(@NotNull Editor editor, @NotNull PsiElemen return ThreeState.UNSURE; } - // Check if we're before a method or a class - if (getMethod(contextElement) == null && getPhpClass(contextElement) == null) { + // Check if we're before a method, class, or field + if (getMethod(contextElement) == null && getPhpClass(contextElement) == null && getField(contextElement) == null) { return ThreeState.UNSURE; } @@ -71,8 +71,8 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate { return Result.CONTINUE; } - // Check if we're before a method or a class - if (getMethod(element) == null && getPhpClass(element) == null) { + // Check if we're before a method, class, or field + if (getMethod(element) == null && getPhpClass(element) == null && getField(element) == null) { return Result.CONTINUE; } @@ -119,4 +119,38 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate { return null; } + + /** + * Finds a Field (property) associated with the given element. + * Returns the field if the element is a child of a field or if the next sibling is a field. + * + * @param element The PSI element to check + * @return The Field if found, null otherwise + */ + public static @Nullable Field getField(@NotNull PsiElement element) { + PsiElement nextSiblingIgnoreWhitespace = PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true); + if (nextSiblingIgnoreWhitespace instanceof PhpModifierList phpModifierList && phpModifierList.hasPublic()) { + if (phpModifierList.getNextPsiSibling() instanceof Field field) { + return field; + } + } + + if (nextSiblingIgnoreWhitespace instanceof PhpPsiElement phpPsiElement) { + PhpPsiElement firstPsiChild = phpPsiElement.getFirstPsiChild(); + if (firstPsiChild instanceof PhpModifierList phpModifierList && phpModifierList.hasPublic()) { + PhpPsiElement nextPsiSibling = phpModifierList.getNextPsiSibling(); + + if (nextPsiSibling instanceof Field field) { + return field; + } else if(nextPsiSibling instanceof PhpFieldType phpFieldType) { + PhpPsiElement nextPsiSibling1 = phpFieldType.getNextPsiSibling(); + if (nextPsiSibling1 instanceof Field field1) { + return field1; + } + } + } + } + + 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 f0461b0a6..258bd0bd4 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 @@ -480,4 +480,217 @@ public void testAsTwigComponentAttributeCompletionForClassInSameNamespaceAsIndex "#[AsTwigComponent]" ); } + + // =============================== + // ExposeInTemplate attribute tests + // =============================== + + public void testExposeInTemplateAttributeCompletionOnPublicMethodInAsTwigComponentClass() { + // Test that ExposeInTemplate attribute appears for public methods in classes with #[AsTwigComponent] + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function getLabel(): string { return 'test'; }\n}", + "#[ExposeInTemplate]" + ); + } + + public void testNoExposeInTemplateOnPropertyInNonAsTwigComponentClass() { + // Test that ExposeInTemplate does NOT appear for properties in classes without #[AsTwigComponent] + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n private string $property;\n}", + "#[ExposeInTemplate]" + ); + } + + public void testNoExposeInTemplateOnMethodInNonAsTwigComponentClass() { + // Test that ExposeInTemplate does NOT appear for methods in classes without #[AsTwigComponent] + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n public function doSomething() { }\n}", + "#[ExposeInTemplate]" + ); + } + + public void testNoExposeInTemplateAtClassLevel() { + // Test that ExposeInTemplate is NOT available at class level (property/method-only) + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n#[AsTwigComponent]\nclass Button {\n}", + "#[ExposeInTemplate]" + ); + } + + public void testExposeInTemplateAttributeInsertionOnPublicMethod() { + // Test that ExposeInTemplate attribute insertion on public method includes quotes (for custom name) + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + " public function getTitle(): string { return 'test'; }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + var exposeItem = java.util.Arrays.stream(items) + .filter(l -> "#[ExposeInTemplate]".equals(l.getLookupString())) + .findFirst() + .orElse(null); + + myFixture.getLookup().setCurrentItem(exposeItem); + myFixture.type('\n'); + + String result = myFixture.getFile().getText(); + + assertTrue("Result should contain ExposeInTemplate use statement", result.contains("use Symfony\\UX\\TwigComponent\\Attribute\\ExposeInTemplate;")); + assertTrue("Result should contain quotes for method name", result.contains("#[ExposeInTemplate]")); + } + + public void testExposeInTemplateOnPublicPropertyInAsTwigComponentClass() { + // Test that ExposeInTemplate appears for public properties (though they're auto-exposed) + assertCompletionContains(PhpFileType.INSTANCE, + "\n public string $title;\n}", + "#[ExposeInTemplate]" + ); + } + + // =============================== + // PreMount and PostMount attribute tests + // =============================== + + public void testPreMountAttributeCompletionOnPublicMethodInAsTwigComponentClass() { + // Test that PreMount attribute appears for public methods in classes with #[AsTwigComponent] + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function preMount(array $data): array { return $data; }\n}", + "#[PreMount]" + ); + } + + public void testPostMountAttributeCompletionOnPublicMethodInAsTwigComponentClass() { + // Test that PostMount attribute appears for public methods in classes with #[AsTwigComponent] + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function postMount(): void { }\n}", + "#[PostMount]" + ); + } + + public void testPreMountAndPostMountBothAvailableInAsTwigComponentClass() { + // Test that both PreMount and PostMount attributes are available for methods + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function mount(): void { }\n}", + "#[PreMount]", "#[PostMount]" + ); + } + + public void testNoPreMountOnMethodInNonAsTwigComponentClass() { + // Test that PreMount does NOT appear for methods in classes without #[AsTwigComponent] + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n public function doSomething() { }\n}", + "#[PreMount]" + ); + } + + public void testNoPostMountOnMethodInNonAsTwigComponentClass() { + // Test that PostMount does NOT appear for methods in classes without #[AsTwigComponent] + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n public function doSomething() { }\n}", + "#[PostMount]" + ); + } + + public void testNoPreMountOnPropertyInAsTwigComponentClass() { + // Test that PreMount is NOT available on properties (method-only) + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n private string $label;\n}", + "#[PreMount]" + ); + } + + public void testNoPostMountOnPropertyInAsTwigComponentClass() { + // Test that PostMount is NOT available on properties (method-only) + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n private string $title;\n}", + "#[PostMount]" + ); + } + + public void testNoPreMountOrPostMountAtClassLevel() { + // Test that PreMount and PostMount are NOT available at class level (method-only) + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n#[AsTwigComponent]\nclass Widget {\n}", + "#[PreMount]", "#[PostMount]" + ); + } + + public void testPreMountAttributeInsertionOnMethodWithoutParentheses() { + // Test that PreMount attribute insertion does NOT include parentheses + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + " public function preMount(array $data): array { return $data; }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + var preMountItem = java.util.Arrays.stream(items) + .filter(l -> "#[PreMount]".equals(l.getLookupString())) + .findFirst() + .orElse(null); + + if (preMountItem != null) { + myFixture.getLookup().setCurrentItem(preMountItem); + myFixture.type('\n'); + + String result = myFixture.getFile().getText(); + + assertTrue("Result should contain PreMount use statement", result.contains("use Symfony\\UX\\TwigComponent\\Attribute\\PreMount;")); + assertTrue("Result should contain PreMount without parentheses", result.contains("#[PreMount]")); + assertFalse("Result should NOT contain parentheses for PreMount", result.contains("#[PreMount(")); + } + } + + public void testPostMountAttributeInsertionOnMethodWithoutParentheses() { + // Test that PostMount attribute insertion does NOT include parentheses + myFixture.configureByText(PhpFileType.INSTANCE, + "\n" + + " public function postMount(): void { }\n" + + "}" + ); + myFixture.completeBasic(); + + var items = myFixture.getLookupElements(); + var postMountItem = java.util.Arrays.stream(items) + .filter(l -> "#[PostMount]".equals(l.getLookupString())) + .findFirst() + .orElse(null); + + if (postMountItem != null) { + myFixture.getLookup().setCurrentItem(postMountItem); + myFixture.type('\n'); + + String result = myFixture.getFile().getText(); + + assertTrue("Result should contain PostMount use statement", result.contains("use Symfony\\UX\\TwigComponent\\Attribute\\PostMount;")); + assertTrue("Result should contain PostMount without parentheses", result.contains("#[PostMount]")); + assertFalse("Result should NOT contain parentheses for PostMount", result.contains("#[PostMount(")); + } + } + + public void testAllTwigComponentMethodAttributesAvailable() { + // Test that all Twig component method attributes are available together + assertCompletionContains(PhpFileType.INSTANCE, + "\n public function init(): void { }\n}", + "#[ExposeInTemplate]", "#[PreMount]", "#[PostMount]" + ); + } } \ 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 914e0758a..c2bc64864 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 @@ -92,4 +92,16 @@ class AsTwigTest class AsTwigComponent { } + + class ExposeInTemplate + { + } + + class PreMount + { + } + + class PostMount + { + } } \ No newline at end of file