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 63ccae7ac..59659e041 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionContributor.java @@ -83,8 +83,8 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull Collection lookupElements = new ArrayList<>(); - // Check if we're before a public method (using shared logic from PhpAttributeCompletionPopupHandlerCompletionConfidence) - Method method = PhpAttributeCompletionPopupHandlerCompletionConfidence.getMethod(position); + // Check if we're before a public method (using shared scope validator) + Method method = PhpAttributeScopeValidator.getMethod(position); if (method != null) { // Method-level attribute completions PhpClass containingClass = method.getContainingClass(); @@ -101,7 +101,7 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull } } else { // Check if we're before a property/field - Field field = PhpAttributeCompletionPopupHandlerCompletionConfidence.getField(position); + Field field = PhpAttributeScopeValidator.getField(position); if (field != null) { // Property-level attribute completions PhpClass containingClass = field.getContainingClass(); @@ -110,7 +110,7 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull } } else { // Check if we're before a class - PhpClass phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence.getPhpClass(position); + PhpClass phpClass = PhpAttributeScopeValidator.getPhpClass(position); if (phpClass != null) { // Class-level attribute completions if (AddRouteAttributeIntention.isControllerClass(phpClass)) { @@ -124,11 +124,8 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull } } - // Stop here - don't show other completions when typing "#" for attributes - if (!lookupElements.isEmpty()) { - result.addAllElements(lookupElements); - result.stopHere(); - } + result.addAllElements(lookupElements); + result.stopHere(); } /** @@ -504,20 +501,20 @@ public void handleInsert(@NotNull InsertionContext context, @NotNull LookupEleme return; } - // Determine the target context (method, field, or class) dynamically + // Determine the target context (method, field, or class) dynamically using shared scope validator PhpClass phpClass; - Method targetMethod = PhpAttributeCompletionPopupHandlerCompletionConfidence.getMethod(originalElement); + Method targetMethod = PhpAttributeScopeValidator.getMethod(originalElement); if (targetMethod != null) { // We're in a method context phpClass = targetMethod.getContainingClass(); } else { // Try field context - Field targetField = PhpAttributeCompletionPopupHandlerCompletionConfidence.getField(originalElement); + Field targetField = PhpAttributeScopeValidator.getField(originalElement); if (targetField != null) { phpClass = targetField.getContainingClass(); } else { // Try class context - phpClass = PhpAttributeCompletionPopupHandlerCompletionConfidence.getPhpClass(originalElement); + phpClass = PhpAttributeScopeValidator.getPhpClass(originalElement); if (phpClass == null) { return; } diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java index 60e397cfb..8f9bf8e43 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeCompletionPopupHandlerCompletionConfidence.java @@ -9,13 +9,9 @@ import com.intellij.psi.PsiFile; import com.intellij.psi.PsiWhiteSpace; import com.intellij.util.ThreeState; -import com.jetbrains.php.lang.parser.PhpElementTypes; import com.jetbrains.php.lang.psi.PhpFile; -import com.jetbrains.php.lang.psi.PhpPsiUtil; -import com.jetbrains.php.lang.psi.elements.*; import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; public class PhpAttributeCompletionPopupHandlerCompletionConfidence { /** @@ -28,19 +24,23 @@ public static class PhpAttributeCompletionConfidence extends CompletionConfidenc @NotNull @Override public ThreeState shouldSkipAutopopup(@NotNull Editor editor, @NotNull PsiElement contextElement, @NotNull PsiFile psiFile, int offset) { - if (offset <= 0 || !(psiFile instanceof PhpFile) || !Symfony2ProjectComponent.isEnabled(editor.getProject())) { + if (offset <= 0 || !(psiFile instanceof PhpFile)) { return ThreeState.UNSURE; } - // Check if we're before a method, class, or field - if (getMethod(contextElement) == null && getPhpClass(contextElement) == null && getField(contextElement) == null) { + Project project = editor.getProject(); + if (!Symfony2ProjectComponent.isEnabled(project)) { return ThreeState.UNSURE; } // Check if there's a "#" before the cursor in the document CharSequence documentText = editor.getDocument().getCharsSequence(); if (documentText.charAt(offset - 1) == '#' && psiFile.findElementAt(offset - 2) instanceof PsiWhiteSpace) { - return ThreeState.NO; + // Check if we should provide attribute completions for this context + // (controller class, twig component, twig extension, etc.) + if (PhpAttributeScopeValidator.shouldProvideAttributeCompletions(contextElement, project)) { + return ThreeState.NO; + } } return ThreeState.UNSURE; @@ -71,8 +71,9 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate { return Result.CONTINUE; } - // Check if we're before a method, class, or field - if (getMethod(element) == null && getField(element) == null && getPhpClass(element) == null) { + // Check if we should provide attribute completions for this context + // (controller class, twig component, twig extension, etc.) + if (!PhpAttributeScopeValidator.shouldProvideAttributeCompletions(element, project)) { return Result.CONTINUE; } @@ -80,88 +81,4 @@ public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate { return Result.STOP; } } - - /** - * Finds a public method associated with the given element. - * Returns the method if the element is a child of a method or if the next sibling is a method. - * - * @param element The PSI element to check - * @return The public method if found, null otherwise - */ - public static @Nullable Method getMethod(@NotNull PsiElement element) { - Method foundMethod = null; - - if (element.getParent() instanceof Method method) { - foundMethod = method; - } else if (PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true) instanceof Method method) { - foundMethod = method; - } - - return foundMethod != null && foundMethod.getAccess().isPublic() - ? foundMethod - : null; - } - - /** - * Finds a PhpClass associated with the given element. - * Returns the class if the element is a child of a class or if the next sibling is a class. - * Also handles cases where we're in the middle of an attribute list. - * - * @param element The PSI element to check - * @return The PhpClass if found, null otherwise - */ - public static @Nullable PhpClass getPhpClass(@NotNull PsiElement element) { - if (element.getParent() instanceof PhpClass phpClass) { - return phpClass; - } - - // with use statement given - PsiElement nextSiblingIgnoreWhitespace = PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true); - if (nextSiblingIgnoreWhitespace instanceof PhpClass phpClass) { - return phpClass; - } - - // no use statements - if (nextSiblingIgnoreWhitespace != null && nextSiblingIgnoreWhitespace.getNode().getElementType() == PhpElementTypes.NON_LAZY_GROUP_STATEMENT) { - if (nextSiblingIgnoreWhitespace.getFirstChild() instanceof PhpClass phpClass) { - return phpClass; - } - } - - return null; - } - - /** - * Finds a Field (property) associated with the given element. - * Returns the field if the element is a child of a field or if the next sibling is a field. - * - * @param element The PSI element to check - * @return The Field if found, null otherwise - */ - public static @Nullable Field getField(@NotNull PsiElement element) { - PsiElement nextSiblingIgnoreWhitespace = PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true); - if (nextSiblingIgnoreWhitespace instanceof PhpModifierList phpModifierList && phpModifierList.hasPublic()) { - if (phpModifierList.getNextPsiSibling() instanceof Field field) { - return field; - } - } - - if (nextSiblingIgnoreWhitespace instanceof PhpPsiElement phpPsiElement) { - PhpPsiElement firstPsiChild = phpPsiElement.getFirstPsiChild(); - if (firstPsiChild instanceof PhpModifierList phpModifierList && phpModifierList.hasPublic()) { - PhpPsiElement nextPsiSibling = phpModifierList.getNextPsiSibling(); - - if (nextPsiSibling instanceof Field field) { - return field; - } else if(nextPsiSibling instanceof PhpFieldType phpFieldType) { - PhpPsiElement nextPsiSibling1 = phpFieldType.getNextPsiSibling(); - if (nextPsiSibling1 instanceof Field field1) { - return field1; - } - } - } - } - - return null; - } } diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeScopeValidator.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeScopeValidator.java new file mode 100644 index 000000000..25e6ff275 --- /dev/null +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/completion/PhpAttributeScopeValidator.java @@ -0,0 +1,283 @@ +package fr.adrienbrault.idea.symfony2plugin.completion; + +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiWhiteSpace; +import com.jetbrains.php.lang.parser.PhpElementTypes; +import com.jetbrains.php.lang.psi.PhpPsiUtil; +import com.jetbrains.php.lang.psi.elements.*; +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.PhpElementsUtil; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Utility class for validating PHP attribute scopes (class, method, property). + * + * Provides methods to determine if a given PSI element is positioned before + * a valid PHP attribute target (class, public method, or public field) and + * whether we should provide attribute completions for that context. + * + * @author Daniel Espendiller + */ +public class PhpAttributeScopeValidator { + + private static final String AS_TWIG_COMPONENT_ATTRIBUTE_FQN = "\\Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent"; + private static final String TWIG_EXTENSION_FQN = "\\Twig\\Extension\\AbstractExtension"; + 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"; + + /** + * Check if the element is in a valid PHP attribute context. + * Returns true if positioned before a class, public method, or public field. + * + * @param element The PSI element to check + * @return true if in a valid attribute scope, false otherwise + */ + public static boolean isValidAttributeScope(@NotNull PsiElement element) { + return getMethod(element) != null || getPhpClass(element) != null || getField(element) != null; + } + + /** + * Check if we should provide attribute completions for this element. + * This checks not just if we're before a class/method/field, but also if that + * class/method/field is in a context where we provide attribute completions + * (controller, twig component, twig extension, etc.) + * + * @param element The PSI element to check + * @param project The project + * @return true if we should provide attribute completions, false otherwise + */ + public static boolean shouldProvideAttributeCompletions(@NotNull PsiElement element, @NotNull Project project) { + // Check if we're before a public method + Method method = getMethod(element); + if (method != null) { + PhpClass containingClass = method.getContainingClass(); + if (containingClass != null) { + // Method-level completions for controller methods + if (AddRouteAttributeIntention.isControllerClass(containingClass)) { + return true; + } + + // Method-level completions for Twig extension methods + if (isTwigExtensionClass(containingClass)) { + return true; + } + + // Method-level completions for Twig component methods + if (hasAsTwigComponentAttribute(containingClass)) { + return true; + } + } + } + + // Check if we're before a property/field + Field field = getField(element); + if (field != null) { + PhpClass containingClass = field.getContainingClass(); + if (containingClass != null) { + // Property-level completions for Twig component properties + if (hasAsTwigComponentAttribute(containingClass)) { + return true; + } + } + } + + // Check if we're before a class + PhpClass phpClass = getPhpClass(element); + if (phpClass != null) { + // Class-level completions for controller classes + if (AddRouteAttributeIntention.isControllerClass(phpClass)) { + return true; + } + + // Class-level completions for Twig component classes + if (isTwigComponentClass(project, phpClass)) { + return true; + } + } + + return false; + } + + /** + * Finds a public method associated with the given element. + * Returns the method if the element is a child of a method or if the next sibling is a method. + * + * @param element The PSI element to check + * @return The public method if found, null otherwise + */ + public static @Nullable Method getMethod(@NotNull PsiElement element) { + Method foundMethod = null; + + if (element.getParent() instanceof Method method) { + foundMethod = method; + } else if (PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true) instanceof Method method) { + foundMethod = method; + } + + return foundMethod != null && foundMethod.getAccess().isPublic() + ? foundMethod + : null; + } + + /** + * Finds a PhpClass associated with the given element. + * Returns the class if the element is a child of a class or if the next sibling is a class. + * Also handles cases where we're in the middle of an attribute list. + * + * @param element The PSI element to check + * @return The PhpClass if found, null otherwise + */ + public static @Nullable PhpClass getPhpClass(@NotNull PsiElement element) { + if (element.getParent() instanceof PhpClass phpClass) { + return phpClass; + } + + // with use statement given + PsiElement nextSiblingIgnoreWhitespace = PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true); + if (nextSiblingIgnoreWhitespace instanceof PhpClass phpClass) { + return phpClass; + } + + // no use statements + if (nextSiblingIgnoreWhitespace != null && nextSiblingIgnoreWhitespace.getNode().getElementType() == PhpElementTypes.NON_LAZY_GROUP_STATEMENT) { + if (nextSiblingIgnoreWhitespace.getFirstChild() instanceof PhpClass phpClass) { + return phpClass; + } + } + + return null; + } + + /** + * Finds a Field (property) associated with the given element. + * Returns the field if the element is a child of a field or if the next sibling is a field. + * + * @param element The PSI element to check + * @return The Field if found, null otherwise + */ + public static @Nullable Field getField(@NotNull PsiElement element) { + PsiElement nextSiblingIgnoreWhitespace = PhpPsiUtil.getNextSiblingIgnoreWhitespace(element, true); + if (nextSiblingIgnoreWhitespace instanceof PhpModifierList phpModifierList && phpModifierList.hasPublic()) { + if (phpModifierList.getNextPsiSibling() instanceof Field field) { + return field; + } + } + + if (nextSiblingIgnoreWhitespace instanceof PhpPsiElement phpPsiElement) { + PhpPsiElement firstPsiChild = phpPsiElement.getFirstPsiChild(); + if (firstPsiChild instanceof PhpModifierList phpModifierList && phpModifierList.hasPublic()) { + PhpPsiElement nextPsiSibling = phpModifierList.getNextPsiSibling(); + + if (nextPsiSibling instanceof Field field) { + return field; + } else if(nextPsiSibling instanceof PhpFieldType phpFieldType) { + PhpPsiElement nextPsiSibling1 = phpFieldType.getNextPsiSibling(); + if (nextPsiSibling1 instanceof Field field1) { + return field1; + } + } + } + } + + return null; + } + + /** + * Check if the class has the #[AsTwigComponent] attribute + */ + private static boolean hasAsTwigComponentAttribute(@NotNull PhpClass phpClass) { + return !phpClass.getAttributes(AS_TWIG_COMPONENT_ATTRIBUTE_FQN).isEmpty(); + } + + /** + * 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 static 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 + for (PhpAttribute attribute : ownMethod.getAttributes()) { + 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 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 static 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; + } +}