Skip to content

Commit 014faf3

Browse files
committed
Add #[AsTwigComponent] attribute completion for Twig component classes
1 parent 1429939 commit 014faf3

File tree

3 files changed

+143
-1
lines changed

3 files changed

+143
-1
lines changed

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

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons;
1818
import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent;
1919
import 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;
2022
import fr.adrienbrault.idea.symfony2plugin.util.CodeUtil;
2123
import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil;
2224
import org.apache.commons.lang3.StringUtils;
@@ -31,7 +33,7 @@
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:

src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/PhpAttributeCompletionContributorTest.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,4 +424,60 @@ public void testClassLevelCompletionWithMultipleExistingAttributes() {
424424
"#[Route]", "#[AsController]"
425425
);
426426
}
427+
428+
// ===============================
429+
// AsTwigComponent attribute tests
430+
// ===============================
431+
432+
public void testAsTwigComponentAttributeCompletionForComponentClass() {
433+
// Test that AsTwigComponent attribute appears for classes in Components namespace
434+
assertCompletionContains(PhpFileType.INSTANCE,
435+
"<?php\n\nnamespace App\\Twig\\Components;\n\n#<caret>\nclass Button {\n}",
436+
"#[AsTwigComponent]"
437+
);
438+
}
439+
440+
public void testAsTwigComponentAttributeCompletionForNestedComponentClass() {
441+
// Test that AsTwigComponent attribute appears for classes in nested Components namespace
442+
assertCompletionContains(PhpFileType.INSTANCE,
443+
"<?php\n\nnamespace Foo\\Components\\Form;\n\n#<caret>\nclass Input {\n}",
444+
"#[AsTwigComponent]"
445+
);
446+
}
447+
448+
public void testNoAsTwigComponentForNonComponentClass() {
449+
// Test that AsTwigComponent attribute does not appear for non-component classes
450+
assertCompletionNotContains(PhpFileType.INSTANCE,
451+
"<?php\n\nnamespace App\\Service;\n\n#<caret>\nclass MyService {\n}",
452+
"#[AsTwigComponent]"
453+
);
454+
}
455+
456+
public void testNoAsTwigComponentAtMethodLevel() {
457+
// Test that AsTwigComponent is NOT available at method level (class-only)
458+
assertCompletionNotContains(PhpFileType.INSTANCE,
459+
"<?php\n\nnamespace App\\Twig\\Components;\n\nclass Button {\n #<caret>\n public function render() { }\n}",
460+
"#[AsTwigComponent]"
461+
);
462+
}
463+
464+
public void testAsTwigComponentAttributeCompletionForClassInSameNamespaceAsIndexedComponent() {
465+
// Test that AsTwigComponent attribute appears for classes in the same namespace as an existing indexed component
466+
// First, add a component class with the attribute to the project (this will be indexed)
467+
myFixture.addFileToProject("ExistingWidget.php",
468+
"<?php\n\n" +
469+
"namespace App\\Custom\\Widgets;\n\n" +
470+
"use Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n" +
471+
"#[AsTwigComponent('existing_widget')]\n" +
472+
"class ExistingWidget {\n" +
473+
"}"
474+
);
475+
476+
// Now check that a new class in the same namespace gets the completion
477+
// (namespace doesn't contain "Components", so it should work only because of the index)
478+
assertCompletionContains(PhpFileType.INSTANCE,
479+
"<?php\n\nnamespace App\\Custom\\Widgets;\n\n#<caret>\nclass NewWidget {\n}",
480+
"#[AsTwigComponent]"
481+
);
482+
}
427483
}

src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/completion/fixtures/classes.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,10 @@ class AsTwigFunction
8686
class AsTwigTest
8787
{
8888
}
89+
}
90+
91+
namespace Symfony\UX\TwigComponent\Attribute {
92+
class AsTwigComponent
93+
{
94+
}
8995
}

0 commit comments

Comments
 (0)