Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,7 +33,7 @@
* Triggers when typing "#<caret>" 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]
*
Expand All @@ -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() {
Expand Down Expand Up @@ -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));
}
}
}

Expand Down Expand Up @@ -237,6 +244,75 @@ private Collection<LookupElement> getTwigExtensionCompletions(@NotNull Project p
return lookupElements;
}

/**
* Get Twig component class-level attribute completions (for component classes)
*/
private Collection<LookupElement> getTwigComponentClassCompletions(@NotNull Project project) {
Collection<LookupElement> 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
"<?php\n\nnamespace App\\Twig\\Components;\n\n#<caret>\nclass Button {\n}",
"#[AsTwigComponent]"
);
}

public void testAsTwigComponentAttributeCompletionForNestedComponentClass() {
// Test that AsTwigComponent attribute appears for classes in nested Components namespace
assertCompletionContains(PhpFileType.INSTANCE,
"<?php\n\nnamespace Foo\\Components\\Form;\n\n#<caret>\nclass Input {\n}",
"#[AsTwigComponent]"
);
}

public void testNoAsTwigComponentForNonComponentClass() {
// Test that AsTwigComponent attribute does not appear for non-component classes
assertCompletionNotContains(PhpFileType.INSTANCE,
"<?php\n\nnamespace App\\Service;\n\n#<caret>\nclass MyService {\n}",
"#[AsTwigComponent]"
);
}

public void testNoAsTwigComponentAtMethodLevel() {
// Test that AsTwigComponent is NOT available at method level (class-only)
assertCompletionNotContains(PhpFileType.INSTANCE,
"<?php\n\nnamespace App\\Twig\\Components;\n\nclass Button {\n #<caret>\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",
"<?php\n\n" +
"namespace App\\Custom\\Widgets;\n\n" +
"use Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n" +
"#[AsTwigComponent('existing_widget')]\n" +
"class ExistingWidget {\n" +
"}"
);

// Now check that a new class in the same namespace gets the completion
// (namespace doesn't contain "Components", so it should work only because of the index)
assertCompletionContains(PhpFileType.INSTANCE,
"<?php\n\nnamespace App\\Custom\\Widgets;\n\n#<caret>\nclass NewWidget {\n}",
"#[AsTwigComponent]"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,10 @@ class AsTwigFunction
class AsTwigTest
{
}
}

namespace Symfony\UX\TwigComponent\Attribute {
class AsTwigComponent
{
}
}