Skip to content

Commit eb3e4da

Browse files
committed
Add #[ExposeInTemplate], #[PreMount], and #[PostMount] attribute completions for Twig component methods and properties.
1 parent 0809df8 commit eb3e4da

File tree

4 files changed

+396
-37
lines changed

4 files changed

+396
-37
lines changed

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

Lines changed: 131 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.intellij.psi.util.PsiTreeUtil;
1212
import com.intellij.util.ProcessingContext;
1313
import com.jetbrains.php.lang.PhpLanguage;
14+
import com.jetbrains.php.lang.psi.elements.Field;
1415
import com.jetbrains.php.lang.psi.elements.Method;
1516
import com.jetbrains.php.lang.psi.elements.PhpAttribute;
1617
import com.jetbrains.php.lang.psi.elements.PhpClass;
@@ -30,11 +31,12 @@
3031
/**
3132
* Provides completion for Symfony PHP attributes like #[Route()] and #[AsController]
3233
*
33-
* Triggers when typing "#<caret>" before a public method or class
34+
* Triggers when typing "#<caret>" before a public method, class, or property
3435
*
3536
* Supports:
3637
* - Class-level attributes: #[Route], #[AsController], #[IsGranted], #[AsTwigComponent]
37-
* - Method-level attributes: #[Route], #[IsGranted], #[Cache]
38+
* - Method-level attributes: #[Route], #[IsGranted], #[Cache], #[ExposeInTemplate], #[PreMount], #[PostMount]
39+
* - Property-level attributes: #[ExposeInTemplate]
3840
* - Twig extension attributes: #[AsTwigFilter], #[AsTwigFunction], #[AsTwigTest]
3941
*
4042
* @author Daniel Espendiller <daniel@espendiller.net>
@@ -49,6 +51,9 @@ public class PhpAttributeCompletionContributor extends CompletionContributor {
4951
private static final String AS_TWIG_FUNCTION_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigFunction";
5052
private static final String AS_TWIG_TEST_ATTRIBUTE_FQN = "\\Twig\\Attribute\\AsTwigTest";
5153
private static final String AS_TWIG_COMPONENT_ATTRIBUTE_FQN = "\\Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent";
54+
private static final String EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN = "\\Symfony\\UX\\TwigComponent\\Attribute\\ExposeInTemplate";
55+
private static final String PRE_MOUNT_ATTRIBUTE_FQN = "\\Symfony\\UX\\TwigComponent\\Attribute\\PreMount";
56+
private static final String POST_MOUNT_ATTRIBUTE_FQN = "\\Symfony\\UX\\TwigComponent\\Attribute\\PostMount";
5257
private static final String TWIG_EXTENSION_FQN = "\\Twig\\Extension\\AbstractExtension";
5358

5459
public PhpAttributeCompletionContributor() {
@@ -90,17 +95,31 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull
9095
if (containingClass != null && isTwigExtensionClass(containingClass)) {
9196
lookupElements.addAll(getTwigExtensionCompletions(project));
9297
}
98+
99+
if (containingClass != null && hasAsTwigComponentAttribute(containingClass)) {
100+
lookupElements.addAll(getTwigComponentMethodCompletions(project));
101+
}
93102
} else {
94-
// Check if we're before a class
95-
PhpClass phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence.getPhpClass(position);
96-
if (phpClass != null) {
97-
// Class-level attribute completions
98-
if (AddRouteAttributeIntention.isControllerClass(phpClass)) {
99-
lookupElements.addAll(getControllerClassCompletions(project));
103+
// Check if we're before a property/field
104+
Field field = PhpAttributeCompletionPopupHandlerCompletionConfidence.getField(position);
105+
if (field != null) {
106+
// Property-level attribute completions
107+
PhpClass containingClass = field.getContainingClass();
108+
if (containingClass != null && hasAsTwigComponentAttribute(containingClass)) {
109+
lookupElements.addAll(getTwigComponentPropertyCompletions(project));
100110
}
111+
} else {
112+
// Check if we're before a class
113+
PhpClass phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence.getPhpClass(position);
114+
if (phpClass != null) {
115+
// Class-level attribute completions
116+
if (AddRouteAttributeIntention.isControllerClass(phpClass)) {
117+
lookupElements.addAll(getControllerClassCompletions(project));
118+
}
101119

102-
if (isTwigComponentClass(project, phpClass)) {
103-
lookupElements.addAll(getTwigComponentClassCompletions(project));
120+
if (isTwigComponentClass(project, phpClass)) {
121+
lookupElements.addAll(getTwigComponentClassCompletions(project));
122+
}
104123
}
105124
}
106125
}
@@ -256,7 +275,7 @@ private Collection<LookupElement> getTwigComponentClassCompletions(@NotNull Proj
256275
.create("#[AsTwigComponent]")
257276
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
258277
.withTypeText(StringUtils.stripStart(AS_TWIG_COMPONENT_ATTRIBUTE_FQN, "\\"), true)
259-
.withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_COMPONENT_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES))
278+
.withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_COMPONENT_ATTRIBUTE_FQN, CursorPosition.NONE))
260279
.bold();
261280

262281
lookupElements.add(lookupElement);
@@ -352,6 +371,81 @@ private boolean isTwigExtensionClass(@NotNull PhpClass phpClass) {
352371
return false;
353372
}
354373

374+
/**
375+
* Get attribute completions for public methods in AsTwigComponent classes
376+
* Includes: ExposeInTemplate, PreMount, PostMount
377+
*/
378+
private Collection<LookupElement> getTwigComponentMethodCompletions(@NotNull Project project) {
379+
Collection<LookupElement> lookupElements = new ArrayList<>();
380+
381+
// Add ExposeInTemplate attribute completion
382+
if (PhpElementsUtil.hasClassOrInterface(project, EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN)) {
383+
LookupElement lookupElement = LookupElementBuilder
384+
.create("#[ExposeInTemplate]")
385+
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
386+
.withTypeText(StringUtils.stripStart(EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN, "\\"), true)
387+
.withInsertHandler(new PhpAttributeInsertHandler(EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN, CursorPosition.NONE))
388+
.bold();
389+
390+
lookupElements.add(lookupElement);
391+
}
392+
393+
// Add PreMount attribute completion
394+
if (PhpElementsUtil.hasClassOrInterface(project, PRE_MOUNT_ATTRIBUTE_FQN)) {
395+
LookupElement lookupElement = LookupElementBuilder
396+
.create("#[PreMount]")
397+
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
398+
.withTypeText(StringUtils.stripStart(PRE_MOUNT_ATTRIBUTE_FQN, "\\"), true)
399+
.withInsertHandler(new PhpAttributeInsertHandler(PRE_MOUNT_ATTRIBUTE_FQN, CursorPosition.NONE))
400+
.bold();
401+
402+
lookupElements.add(lookupElement);
403+
}
404+
405+
// Add PostMount attribute completion
406+
if (PhpElementsUtil.hasClassOrInterface(project, POST_MOUNT_ATTRIBUTE_FQN)) {
407+
LookupElement lookupElement = LookupElementBuilder
408+
.create("#[PostMount]")
409+
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
410+
.withTypeText(StringUtils.stripStart(POST_MOUNT_ATTRIBUTE_FQN, "\\"), true)
411+
.withInsertHandler(new PhpAttributeInsertHandler(POST_MOUNT_ATTRIBUTE_FQN, CursorPosition.NONE))
412+
.bold();
413+
414+
lookupElements.add(lookupElement);
415+
}
416+
417+
return lookupElements;
418+
}
419+
420+
/**
421+
* Get attribute completions for properties in AsTwigComponent classes
422+
* Includes: ExposeInTemplate
423+
*/
424+
private Collection<LookupElement> getTwigComponentPropertyCompletions(@NotNull Project project) {
425+
Collection<LookupElement> lookupElements = new ArrayList<>();
426+
427+
// Add ExposeInTemplate attribute completion
428+
if (PhpElementsUtil.hasClassOrInterface(project, EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN)) {
429+
LookupElement lookupElement = LookupElementBuilder
430+
.create("#[ExposeInTemplate]")
431+
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
432+
.withTypeText(StringUtils.stripStart(EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN, "\\"), true)
433+
.withInsertHandler(new PhpAttributeInsertHandler(EXPOSE_IN_TEMPLATE_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES))
434+
.bold();
435+
436+
lookupElements.add(lookupElement);
437+
}
438+
439+
return lookupElements;
440+
}
441+
442+
/**
443+
* Check if the class has the #[AsTwigComponent] attribute
444+
*/
445+
private boolean hasAsTwigComponentAttribute(@NotNull PhpClass phpClass) {
446+
return !phpClass.getAttributes(AS_TWIG_COMPONENT_ATTRIBUTE_FQN).isEmpty();
447+
}
448+
355449
/**
356450
* Check if we're in a context where typing "#" for attributes makes sense
357451
* (i.e., after "#" character with whitespace before it)
@@ -410,40 +504,46 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme
410504
return;
411505
}
412506

413-
// Determine the target context (method or class) dynamically
507+
// Determine the target context (method, field, or class) dynamically
414508
PhpClass phpClass;
415509
Method targetMethod = PhpAttributeCompletionPopupHandlerCompletionConfidence.getMethod(originalElement);
416510
if (targetMethod != null) {
417511
// We're in a method context
418512
phpClass = targetMethod.getContainingClass();
419513
} else {
420-
// Try class context
421-
phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence.getPhpClass(originalElement);
422-
if (phpClass == null) {
423-
return;
514+
// Try field context
515+
Field targetField = PhpAttributeCompletionPopupHandlerCompletionConfidence.getField(originalElement);
516+
if (targetField != null) {
517+
phpClass = targetField.getContainingClass();
518+
} else {
519+
// Try class context
520+
phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence.getPhpClass(originalElement);
521+
if (phpClass == null) {
522+
return;
523+
}
424524
}
425525
}
426526

427527
// Store the original insertion offset (where user typed "#")
428528
int originalInsertionOffset = startOffset;
429529

430-
// Check if there's a "#" before the completion position
431-
// If yes, we need to delete it to avoid "##[Attribute()]"
432-
if (startOffset > 0) {
433-
CharSequence text = document.getCharsSequence();
434-
if (text.charAt(startOffset - 1) == '#') {
435-
// Delete the "#" that was typed
436-
document.deleteString(startOffset - 1, tailOffset);
437-
originalInsertionOffset = startOffset - 1;
438-
} else {
439-
// Delete just the dummy identifier
440-
document.deleteString(startOffset, tailOffset);
441-
}
442-
} else {
443-
// Delete just the dummy identifier
444-
document.deleteString(startOffset, tailOffset);
530+
// Find and delete the "#" before the completion position to avoid "##[Attribute()]"
531+
// Check the 1-2 positions immediately before startOffset
532+
CharSequence text = document.getCharsSequence();
533+
int deleteStart = startOffset;
534+
535+
// Check startOffset - 1 and startOffset - 2 for the "#" character
536+
if (startOffset > 0 && text.charAt(startOffset - 1) == '#') {
537+
deleteStart = startOffset - 1;
538+
} else if (startOffset > 1 && text.charAt(startOffset - 2) == '#') {
539+
// Handle case where there might be a single whitespace between # and dummy identifier
540+
deleteStart = startOffset - 2;
445541
}
446542

543+
// Delete from the "#" (or startOffset if no "#" found) to tailOffset
544+
document.deleteString(deleteStart, tailOffset);
545+
originalInsertionOffset = deleteStart;
546+
447547
// Commit after deletion
448548
PsiDocumentManager.getInstance(project).commitDocument(document);
449549

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

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
import com.intellij.psi.PsiElement;
99
import com.intellij.psi.PsiFile;
1010
import com.intellij.psi.PsiWhiteSpace;
11+
import com.intellij.psi.util.PsiTreeUtil;
1112
import com.intellij.util.ThreeState;
1213
import com.jetbrains.php.lang.psi.PhpFile;
1314
import com.jetbrains.php.lang.psi.PhpPsiUtil;
14-
import com.jetbrains.php.lang.psi.elements.Method;
15-
import com.jetbrains.php.lang.psi.elements.PhpClass;
15+
import com.jetbrains.php.lang.psi.elements.*;
1616
import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent;
1717
import org.jetbrains.annotations.NotNull;
1818
import org.jetbrains.annotations.Nullable;
@@ -32,8 +32,8 @@ public ThreeState shouldSkipAutopopup(@NotNull Editor editor, @NotNull PsiElemen
3232
return ThreeState.UNSURE;
3333
}
3434

35-
// Check if we're before a method or a class
36-
if (getMethod(contextElement) == null && getPhpClass(contextElement) == null) {
35+
// Check if we're before a method, class, or field
36+
if (getMethod(contextElement) == null && getPhpClass(contextElement) == null && getField(contextElement) == null) {
3737
return ThreeState.UNSURE;
3838
}
3939

@@ -71,8 +71,8 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate {
7171
return Result.CONTINUE;
7272
}
7373

74-
// Check if we're before a method or a class
75-
if (getMethod(element) == null && getPhpClass(element) == null) {
74+
// Check if we're before a method, class, or field
75+
if (getMethod(element) == null && getPhpClass(element) == null && getField(element) == null) {
7676
return Result.CONTINUE;
7777
}
7878

@@ -119,4 +119,38 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate {
119119

120120
return null;
121121
}
122+
123+
/**
124+
* Finds a Field (property) associated with the given element.
125+
* Returns the field if the element is a child of a field or if the next sibling is a field.
126+
*
127+
* @param element The PSI element to check
128+
* @return The Field if found, null otherwise
129+
*/
130+
public static @Nullable Field getField(@NotNull PsiElement element) {
131+
PsiElement nextSiblingIgnoreWhitespace = PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true);
132+
if (nextSiblingIgnoreWhitespace instanceof PhpModifierList phpModifierList && phpModifierList.hasPublic()) {
133+
if (phpModifierList.getNextPsiSibling() instanceof Field field) {
134+
return field;
135+
}
136+
}
137+
138+
if (nextSiblingIgnoreWhitespace instanceof PhpPsiElement phpPsiElement) {
139+
PhpPsiElement firstPsiChild = phpPsiElement.getFirstPsiChild();
140+
if (firstPsiChild instanceof PhpModifierList phpModifierList && phpModifierList.hasPublic()) {
141+
PhpPsiElement nextPsiSibling = phpModifierList.getNextPsiSibling();
142+
143+
if (nextPsiSibling instanceof Field field) {
144+
return field;
145+
} else if(nextPsiSibling instanceof PhpFieldType phpFieldType) {
146+
PhpPsiElement nextPsiSibling1 = phpFieldType.getNextPsiSibling();
147+
if (nextPsiSibling1 instanceof Field field1) {
148+
return field1;
149+
}
150+
}
151+
}
152+
}
153+
154+
return null;
155+
}
122156
}

0 commit comments

Comments
 (0)