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 @@ -37,6 +37,10 @@ public class PhpAttributeCompletionContributor extends CompletionContributor {
private static final String ROUTE_ATTRIBUTE_FQN = "\\Symfony\\Component\\Routing\\Attribute\\Route";
private static final String IS_GRANTED_ATTRIBUTE_FQN = "\\Symfony\\Component\\Security\\Http\\Attribute\\IsGranted";
private static final String CACHE_ATTRIBUTE_FQN = "\\Symfony\\Component\\HttpKernel\\Attribute\\Cache";
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 TWIG_EXTENSION_FQN = "\\Twig\\Extension\\AbstractExtension";

public PhpAttributeCompletionContributor() {
// Match any element in PHP files - we'll do more specific checking in the provider
Expand Down Expand Up @@ -76,6 +80,10 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull
lookupElements.addAll(getControllerCompletions(project));
}

if (containingClass != null && isTwigExtensionClass(containingClass)) {
lookupElements.addAll(getTwigExtensionCompletions(project));
}

// Stop here - don't show other completions when typing "#" for attributes
if (!lookupElements.isEmpty()) {
result.addAllElements(lookupElements);
Expand Down Expand Up @@ -125,6 +133,87 @@ private Collection<LookupElement> getControllerCompletions(@NotNull Project proj
return lookupElements;
}

private Collection<LookupElement> getTwigExtensionCompletions(@NotNull Project project) {
Collection<LookupElement> lookupElements = new ArrayList<>();

// Add AsTwigFilter attribute completion
if (PhpElementsUtil.getClassInterface(project, AS_TWIG_FILTER_ATTRIBUTE_FQN) != null) {
LookupElement lookupElement = LookupElementBuilder
.create("#[AsTwigFilter]")
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
.withTypeText(StringUtils.stripStart(AS_TWIG_FILTER_ATTRIBUTE_FQN, "\\"), true)
.withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_FILTER_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES))
.bold();

lookupElements.add(lookupElement);
}

// Add AsTwigFunction attribute completion
if (PhpElementsUtil.getClassInterface(project, AS_TWIG_FUNCTION_ATTRIBUTE_FQN) != null) {
LookupElement lookupElement = LookupElementBuilder
.create("#[AsTwigFunction]")
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
.withTypeText(StringUtils.stripStart(AS_TWIG_FUNCTION_ATTRIBUTE_FQN, "\\"), true)
.withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_FUNCTION_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES))
.bold();

lookupElements.add(lookupElement);
}

// Add AsTwigTest attribute completion
if (PhpElementsUtil.getClassInterface(project, AS_TWIG_TEST_ATTRIBUTE_FQN) != null) {
LookupElement lookupElement = LookupElementBuilder
.create("#[AsTwigTest]")
.withIcon(Symfony2Icons.SYMFONY_ATTRIBUTE)
.withTypeText(StringUtils.stripStart(AS_TWIG_TEST_ATTRIBUTE_FQN, "\\"), true)
.withInsertHandler(new PhpAttributeInsertHandler(AS_TWIG_TEST_ATTRIBUTE_FQN, CursorPosition.INSIDE_QUOTES))
.bold();

lookupElements.add(lookupElement);
}

return lookupElements;
}

/**
* Check if the class is a TwigExtension class.
* A class is considered a TwigExtension if:
* - Its name ends with "TwigExtension", OR
* - It extends AbstractExtension or implements ExtensionInterface, OR
* - Any other public method in the class already has an AsTwig* attribute
*/
private boolean isTwigExtensionClass(@NotNull PhpClass phpClass) {
// Check if the class name ends with "TwigExtension"
if (phpClass.getName().endsWith("TwigExtension")) {
return true;
}

// Check if the class extends AbstractExtension
if (PhpElementsUtil.isInstanceOf(phpClass, TWIG_EXTENSION_FQN)) {
return true;
}

// Check if any other public method in the class has an AsTwig* attribute
for (Method ownMethod : phpClass.getOwnMethods()) {
if (!ownMethod.getAccess().isPublic() || ownMethod.isStatic()) {
continue;
}

// Collect attributes once and check for any AsTwig* attribute
Collection<PhpAttribute> attributes = ownMethod.getAttributes();
for (PhpAttribute attribute : attributes) {
String fqn = attribute.getFQN();
if (AS_TWIG_FILTER_ATTRIBUTE_FQN.equals(fqn) ||
AS_TWIG_FUNCTION_ATTRIBUTE_FQN.equals(fqn) ||
AS_TWIG_TEST_ATTRIBUTE_FQN.equals(fqn)) {
return true;
}
}
}

return false;
}

/**
* Check if we're in a context where typing "#" for attributes makes sense
* (i.e., after "#" character with whitespace before it)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,111 @@ public void testCacheAttributeInsertionWithoutQuotes() {
assertTrue("Result should contain Cache use statement", result.contains("use Symfony\\Component\\HttpKernel\\Attribute\\Cache;"));
assertTrue("Result should contain empty parentheses", result.contains("#[Cache()]"));
}

public void testAsTwigFilterAttributeCompletionInClassEndingWithTwigExtension() {
// Test that AsTwigFilter attribute appears when class name ends with "TwigExtension"
assertCompletionContains(PhpFileType.INSTANCE,
"<?php\n\nclass MyTwigExtension {\n #<caret>\n public function myFilter() { }\n}",
"#[AsTwigFilter]"
);
}

public void testAsTwigFunctionAttributeCompletionInClassExtendingAbstractExtension() {
// Test that AsTwigFunction attribute appears when class extends AbstractExtension
assertCompletionContains(PhpFileType.INSTANCE,
"<?php\n\nuse Twig\\Extension\\AbstractExtension;\n\nclass MyExtension extends AbstractExtension {\n #<caret>\n public function myFunction() { }\n}",
"#[AsTwigFunction]"
);
}

public void testAsTwigTestAttributeCompletionInClassWithExistingAsTwigAttribute() {
// Test that AsTwigTest attribute appears when another method already has an AsTwig* attribute
assertCompletionContains(PhpFileType.INSTANCE,
"<?php\n\nuse Twig\\Attribute\\AsTwigFilter;\n\nclass MyExtension {\n #[AsTwigFilter('existing')]\n public function existingFilter() { }\n\n #<caret>\n public function myTest() { }\n}",
"#[AsTwigTest]"
);
}

public void testAllAsTwigAttributesAvailableInTwigExtension() {
// Test that all three AsTwig* attributes are available in a TwigExtension class
assertCompletionContains(PhpFileType.INSTANCE,
"<?php\n\nclass MyTwigExtension {\n #<caret>\n public function myMethod() { }\n}",
"#[AsTwigFilter]", "#[AsTwigFunction]", "#[AsTwigTest]"
);
}

public void testNoAsTwigAttributesInNonTwigExtensionClass() {
// Test that AsTwig* attributes don't appear in classes that don't match TwigExtension criteria
assertCompletionNotContains(PhpFileType.INSTANCE,
"<?php\n\nclass MyService {\n #<caret>\n public function myMethod() { }\n}",
"#[AsTwigFilter]", "#[AsTwigFunction]", "#[AsTwigTest]"
);
}

public void testAsTwigFilterAttributeInsertionWithNamespaceAddsUseStatement() {
// Test AsTwigFilter attribute insertion with namespace - should add use import
myFixture.configureByText(PhpFileType.INSTANCE,
"<?php\n\n" +
"namespace App\\Twig;\n\n" +
"class MyTwigExtension {\n" +
" #<caret>\n" +
" public function myFilter() { }\n" +
"}"
);
myFixture.completeBasic();

var items = myFixture.getLookupElements();
var filterItem = java.util.Arrays.stream(items)
.filter(l -> "#[AsTwigFilter]".equals(l.getLookupString()))
.findFirst()
.orElse(null);

if (filterItem != null) {
myFixture.getLookup().setCurrentItem(filterItem);
myFixture.type('\n');

String result = myFixture.getFile().getText();

assertTrue("Result should contain use statement", result.contains("use Twig\\Attribute\\AsTwigFilter;"));
assertTrue("Result should contain quotes for filter name", result.contains("#[AsTwigFilter(\"\")]"));
}
}

public void testAsTwigFunctionAttributeInsertionWithQuotes() {
// Test that AsTwigFunction attribute insertion includes quotes (for the Twig function name)
myFixture.configureByText(PhpFileType.INSTANCE,
"<?php\n\n" +
"namespace App\\Twig;\n\n" +
"use Twig\\Extension\\AbstractExtension;\n\n" +
"class MyExtension extends AbstractExtension {\n" +
" #<caret>\n" +
" public function myFunction() { }\n" +
"}"
);
myFixture.completeBasic();

var items = myFixture.getLookupElements();
var functionItem = java.util.Arrays.stream(items)
.filter(l -> "#[AsTwigFunction]".equals(l.getLookupString()))
.findFirst()
.orElse(null);

if (functionItem != null) {
myFixture.getLookup().setCurrentItem(functionItem);
myFixture.type('\n');

String result = myFixture.getFile().getText();

assertTrue("Result should contain use statement", result.contains("use Twig\\Attribute\\AsTwigFunction;"));
assertTrue("Result should contain quotes for function name", result.contains("#[AsTwigFunction(\"\")]"));
}
}

public void testNoAsTwigAttributesOutsidePublicMethod() {
// Test that AsTwig* attributes are not suggested outside of a method
assertCompletionNotContains(PhpFileType.INSTANCE,
"<?php\n\nclass MyTwigExtension {\n #<caret>\n private function privateMethod() { }\n}",
"#[AsTwigFilter]", "#[AsTwigFunction]", "#[AsTwigTest]"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,24 @@ class IsGranted
class Cache
{
}
}

namespace Twig\Extension {
class AbstractExtension
{
}
}

namespace Twig\Attribute {
class AsTwigFilter
{
}

class AsTwigFunction
{
}

class AsTwigTest
{
}
}