1717import fr .adrienbrault .idea .symfony2plugin .Symfony2Icons ;
1818import fr .adrienbrault .idea .symfony2plugin .Symfony2ProjectComponent ;
1919import fr .adrienbrault .idea .symfony2plugin .intentions .php .AddRouteAttributeIntention ;
20+ import fr .adrienbrault .idea .symfony2plugin .stubs .indexes .UxTemplateStubIndex ;
21+ import fr .adrienbrault .idea .symfony2plugin .stubs .util .IndexUtil ;
2022import fr .adrienbrault .idea .symfony2plugin .util .CodeUtil ;
2123import fr .adrienbrault .idea .symfony2plugin .util .PhpElementsUtil ;
2224import org .apache .commons .lang3 .StringUtils ;
3133 * Triggers when typing "#<caret>" before a public method or class
3234 *
3335 * Supports:
34- * - Class-level attributes: #[Route], #[AsController], #[IsGranted]
36+ * - Class-level attributes: #[Route], #[AsController], #[IsGranted], #[AsTwigComponent]
3537 * - Method-level attributes: #[Route], #[IsGranted], #[Cache]
3638 * - Twig extension attributes: #[AsTwigFilter], #[AsTwigFunction], #[AsTwigTest]
3739 *
@@ -46,6 +48,7 @@ public class PhpAttributeCompletionContributor extends CompletionContributor {
4648 private static final String AS_TWIG_FILTER_ATTRIBUTE_FQN = "\\ Twig\\ Attribute\\ AsTwigFilter" ;
4749 private static final String AS_TWIG_FUNCTION_ATTRIBUTE_FQN = "\\ Twig\\ Attribute\\ AsTwigFunction" ;
4850 private static final String AS_TWIG_TEST_ATTRIBUTE_FQN = "\\ Twig\\ Attribute\\ AsTwigTest" ;
51+ private static final String AS_TWIG_COMPONENT_ATTRIBUTE_FQN = "\\ Symfony\\ UX\\ TwigComponent\\ Attribute\\ AsTwigComponent" ;
4952 private static final String TWIG_EXTENSION_FQN = "\\ Twig\\ Extension\\ AbstractExtension" ;
5053
5154 public PhpAttributeCompletionContributor () {
@@ -95,6 +98,10 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull
9598 if (AddRouteAttributeIntention .isControllerClass (phpClass )) {
9699 lookupElements .addAll (getControllerClassCompletions (project ));
97100 }
101+
102+ if (isTwigComponentClass (project , phpClass )) {
103+ lookupElements .addAll (getTwigComponentClassCompletions (project ));
104+ }
98105 }
99106 }
100107
@@ -237,6 +244,79 @@ private Collection<LookupElement> getTwigExtensionCompletions(@NotNull Project p
237244 return lookupElements ;
238245 }
239246
247+ /**
248+ * Get Twig component class-level attribute completions (for component classes)
249+ */
250+ private Collection <LookupElement > getTwigComponentClassCompletions (@ NotNull Project project ) {
251+ Collection <LookupElement > lookupElements = new ArrayList <>();
252+
253+ // Add AsTwigComponent attribute completion
254+ if (PhpElementsUtil .hasClassOrInterface (project , AS_TWIG_COMPONENT_ATTRIBUTE_FQN )) {
255+ LookupElement lookupElement = LookupElementBuilder
256+ .create ("#[AsTwigComponent]" )
257+ .withIcon (Symfony2Icons .SYMFONY_ATTRIBUTE )
258+ .withTypeText (StringUtils .stripStart (AS_TWIG_COMPONENT_ATTRIBUTE_FQN , "\\ " ), true )
259+ .withInsertHandler (new PhpAttributeInsertHandler (AS_TWIG_COMPONENT_ATTRIBUTE_FQN , CursorPosition .INSIDE_QUOTES ))
260+ .bold ();
261+
262+ lookupElements .add (lookupElement );
263+ }
264+
265+ return lookupElements ;
266+ }
267+
268+ /**
269+ * Check if the class is a Twig component class.
270+ * A class is considered a Twig component if:
271+ * - Its namespace contains "\\Components\\" or ends with "\\Components", OR
272+ * - There are existing component classes (from index) in the same namespace
273+ * (e.g., App\Twig\Components\Button, Foo\Components\Form\Input)
274+ */
275+ private boolean isTwigComponentClass (@ NotNull Project project , @ NotNull PhpClass phpClass ) {
276+ String fqn = phpClass .getFQN ();
277+ if (fqn == null || fqn .isEmpty ()) {
278+ return false ;
279+ }
280+
281+ // Strip leading backslash for consistency
282+ fqn = StringUtils .stripStart (fqn , "\\ " );
283+
284+ // Extract namespace (without class name)
285+ int lastBackslash = fqn .lastIndexOf ('\\' );
286+ if (lastBackslash == -1 ) {
287+ return false ; // No namespace
288+ }
289+ String namespace = fqn .substring (0 , lastBackslash );
290+
291+ // Check if namespace contains "\Components\" or ends with "\Components" or equals "Components"
292+ if (namespace .contains ("\\ Components\\ " ) ||
293+ namespace .endsWith ("\\ Components" ) ||
294+ namespace .equals ("Components" )) {
295+ return true ;
296+ }
297+
298+ // Check if there are any component classes in the same namespace from the index
299+ // The index keys are FQN class names of components with #[AsTwigComponent] attribute
300+ for (String key : IndexUtil .getAllKeysForProject (UxTemplateStubIndex .KEY , project )) {
301+ // Strip leading backslash from key
302+ String componentFqn = StringUtils .stripStart (key , "\\ " );
303+
304+ // Extract namespace from the component FQN
305+ int componentLastBackslash = componentFqn .lastIndexOf ('\\' );
306+ if (componentLastBackslash == -1 ) {
307+ continue ;
308+ }
309+ String componentNamespace = componentFqn .substring (0 , componentLastBackslash );
310+
311+ // Check if the current class's namespace matches the component namespace
312+ if (namespace .equals (componentNamespace )) {
313+ return true ;
314+ }
315+ }
316+
317+ return false ;
318+ }
319+
240320 /**
241321 * Check if the class is a TwigExtension class.
242322 * A class is considered a TwigExtension if:
0 commit comments