1111import com .intellij .psi .util .PsiTreeUtil ;
1212import com .intellij .util .ProcessingContext ;
1313import com .jetbrains .php .lang .PhpLanguage ;
14+ import com .jetbrains .php .lang .psi .elements .Field ;
1415import com .jetbrains .php .lang .psi .elements .Method ;
1516import com .jetbrains .php .lang .psi .elements .PhpAttribute ;
1617import com .jetbrains .php .lang .psi .elements .PhpClass ;
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
0 commit comments