From ec98d5f7627b772a0e8f5c97f7cfa07ab4e8086d Mon Sep 17 00:00:00 2001 From: Daniel Espendiller Date: Thu, 18 Dec 2025 09:40:36 +0100 Subject: [PATCH] Add `#[AsTwigComponent]` attribute completion for Twig component classes --- .../PhpAttributeCompletionContributor.java | 78 ++++++++++++++++++- ...PhpAttributeCompletionContributorTest.java | 56 +++++++++++++ .../tests/completion/fixtures/classes.php | 6 ++ 3 files changed, 139 insertions(+), 1 deletion(-) 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 df054d7a8..af477f9b4 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java @@ -17,6 +17,8 @@ import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons; import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; import fr.adrienbrault.idea.symfony2plugin.intentions.php.AddRouteAttributeIntention; +import fr.adrienbrault.idea.symfony2plugin.stubs.indexes.UxTemplateStubIndex; +import fr.adrienbrault.idea.symfony2plugin.stubs.util.IndexUtil; import fr.adrienbrault.idea.symfony2plugin.util.CodeUtil; import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil; import org.apache.commons.lang3.StringUtils; @@ -31,7 +33,7 @@ * Triggers when typing "#" before a public method or class * * Supports: - * - Class-level attributes: #[Route], #[AsController], #[IsGranted] + * - Class-level attributes: #[Route], #[AsController], #[IsGranted], #[AsTwigComponent] * - Method-level attributes: #[Route], #[IsGranted], #[Cache] * - Twig extension attributes: #[AsTwigFilter], #[AsTwigFunction], #[AsTwigTest] * @@ -46,6 +48,7 @@ public class PhpAttributeCompletionContributor extends CompletionContributor { 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"; + private static final String AS_TWIG_COMPONENT_ATTRIBUTE_FQN = "\\Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent"; private static final String TWIG_EXTENSION_FQN = "\\Twig\\Extension\\AbstractExtension"; public PhpAttributeCompletionContributor() { @@ -95,6 +98,10 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull if (AddRouteAttributeIntention.isControllerClass(phpClass)) { lookupElements.addAll(getControllerClassCompletions(project)); } + + if (isTwigComponentClass(project, phpClass)) { + lookupElements.addAll(getTwigComponentClassCompletions(project)); + } } } @@ -237,6 +244,75 @@ private Collection getTwigExtensionCompletions(@NotNull Project p return lookupElements; } + /** + * Get Twig component class-level attribute completions (for component classes) + */ + private Collection getTwigComponentClassCompletions(@NotNull Project project) { + Collection lookupElements = new ArrayList<>(); + + // Add AsTwigComponent attribute completion + if (PhpElementsUtil.hasClassOrInterface(project, AS_TWIG_COMPONENT_ATTRIBUTE_FQN)) { + LookupElement lookupElement = LookupElementBuilder + .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)) + .bold(); + + lookupElements.add(lookupElement); + } + + return lookupElements; + } + + /** + * Check if the class is a Twig component class. + * A class is considered a Twig component if: + * - Its namespace contains "\\Components\\" or ends with "\\Components", OR + * - There are existing component classes (from index) in the same namespace + * (e.g., App\Twig\Components\Button, Foo\Components\Form\Input) + */ + private boolean isTwigComponentClass(@NotNull Project project, @NotNull PhpClass phpClass) { + String fqn = phpClass.getFQN(); + if (fqn.isBlank()) { + return false; + } + + fqn = StringUtils.stripStart(fqn, "\\"); + + int lastBackslash = fqn.lastIndexOf('\\'); + if (lastBackslash == -1) { + return false; // No namespace + } + + String namespace = fqn.substring(0, lastBackslash); + if (namespace.contains("\\Components\\") || + namespace.endsWith("\\Components") || + namespace.equals("Components")) { + return true; + } + + // Check if there are any component classes in the same namespace from the index + // keys are FQN class names of components with #[AsTwigComponent] attribute + for (String key : IndexUtil.getAllKeysForProject(UxTemplateStubIndex.KEY, project)) { + String componentFqn = StringUtils.stripStart(key, "\\"); + + // Extract namespace from the component FQN + int componentLastBackslash = componentFqn.lastIndexOf('\\'); + if (componentLastBackslash == -1) { + continue; + } + + // Check if the current class's namespace matches the component namespace + String componentNamespace = componentFqn.substring(0, componentLastBackslash); + if (namespace.equals(componentNamespace)) { + return true; + } + } + + return false; + } + /** * Check if the class is a TwigExtension class. * A class is considered a TwigExtension if: 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 4fae03a95..f0461b0a6 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 @@ -424,4 +424,60 @@ public void testClassLevelCompletionWithMultipleExistingAttributes() { "#[Route]", "#[AsController]" ); } + + // =============================== + // AsTwigComponent attribute tests + // =============================== + + public void testAsTwigComponentAttributeCompletionForComponentClass() { + // Test that AsTwigComponent attribute appears for classes in Components namespace + assertCompletionContains(PhpFileType.INSTANCE, + "\nclass Button {\n}", + "#[AsTwigComponent]" + ); + } + + public void testAsTwigComponentAttributeCompletionForNestedComponentClass() { + // Test that AsTwigComponent attribute appears for classes in nested Components namespace + assertCompletionContains(PhpFileType.INSTANCE, + "\nclass Input {\n}", + "#[AsTwigComponent]" + ); + } + + public void testNoAsTwigComponentForNonComponentClass() { + // Test that AsTwigComponent attribute does not appear for non-component classes + assertCompletionNotContains(PhpFileType.INSTANCE, + "\nclass MyService {\n}", + "#[AsTwigComponent]" + ); + } + + public void testNoAsTwigComponentAtMethodLevel() { + // Test that AsTwigComponent is NOT available at method level (class-only) + assertCompletionNotContains(PhpFileType.INSTANCE, + "\n public function render() { }\n}", + "#[AsTwigComponent]" + ); + } + + public void testAsTwigComponentAttributeCompletionForClassInSameNamespaceAsIndexedComponent() { + // Test that AsTwigComponent attribute appears for classes in the same namespace as an existing indexed component + // First, add a component class with the attribute to the project (this will be indexed) + myFixture.addFileToProject("ExistingWidget.php", + "\nclass NewWidget {\n}", + "#[AsTwigComponent]" + ); + } } \ 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 fd6eddfd7..914e0758a 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 @@ -86,4 +86,10 @@ class AsTwigFunction class AsTwigTest { } +} + +namespace Symfony\UX\TwigComponent\Attribute { + class AsTwigComponent + { + } } \ No newline at end of file