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 @@ -78,6 +78,7 @@ public class Symfony2Icons {

public static final Icon SYMFONY_AI = IconLoader.getIcon("/icons/symfony_ai.png", Symfony2Icons.class);
public static final Icon SYMFONY_AI_OPACITY = IconLoader.getIcon("/icons/symfony_ai_opacity.png", Symfony2Icons.class);
public static final Icon SYMFONY_ATTRIBUTE = IconLoader.getIcon("/icons/symfony_attribute.svg", Symfony2Icons.class);

public static Image getImage(Icon icon) {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
package fr.adrienbrault.idea.symfony2plugin.completion;

import com.intellij.codeInsight.completion.*;
import com.intellij.codeInsight.lookup.LookupElement;
import com.intellij.codeInsight.lookup.LookupElementBuilder;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.project.Project;
import com.intellij.patterns.PlatformPatterns;
import com.intellij.psi.*;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.util.ProcessingContext;
import com.jetbrains.php.lang.PhpLanguage;
import com.jetbrains.php.lang.psi.elements.Method;
import com.jetbrains.php.lang.psi.elements.PhpAttribute;
import com.jetbrains.php.lang.psi.elements.PhpClass;
import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons;
import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent;
import fr.adrienbrault.idea.symfony2plugin.util.CodeUtil;
import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;

/**
* Provides completion for Symfony PHP attributes like #[Route()]
*
* Triggers when typing "#<caret>" before a public method
*
* @author Daniel Espendiller <daniel@espendiller.net>
*/
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";

public PhpAttributeCompletionContributor() {
// Match any element in PHP files - we'll do more specific checking in the provider
// Using a broad pattern to catch completion after "#" character
extend(
CompletionType.BASIC,
PlatformPatterns.psiElement().inFile(PlatformPatterns.psiFile().withLanguage(PhpLanguage.INSTANCE)),
new PhpAttributeCompletionProvider()
);
}

private static class PhpAttributeCompletionProvider extends CompletionProvider<CompletionParameters> {
@Override
protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull ProcessingContext context, @NotNull CompletionResultSet result) {
PsiElement position = parameters.getPosition();
Project project = position.getProject();

if (!Symfony2ProjectComponent.isEnabled(project)) {
return;
}

// Check if we're in a context where an attribute makes sense (after "#" with whitespace before it)
if (!isAttributeContext(parameters)) {
return;
}

// Check if we're before a public method (using shared logic from PhpAttributeCompletionPopupHandlerCompletionConfidence)
Method method = PhpAttributeCompletionPopupHandlerCompletionConfidence.getMethod(position);
if (method == null) {
return;
}

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

result.addElement(routeLookupElement);
}

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

result.addElement(isGrantedLookupElement);
}

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

result.addElement(cacheLookupElement);
}

// Stop here - don't show other completions when typing "#" for attributes
result.stopHere();
}

/**
* Check if we're in a context where typing "#" for attributes makes sense
* (i.e., after "#" character with whitespace before it)
*/
private boolean isAttributeContext(@NotNull CompletionParameters parameters) {
int offset = parameters.getOffset();
PsiFile psiFile = parameters.getOriginalFile();

// Need at least 2 characters before cursor to check for "# " pattern
if (offset < 2) {
return false;
}

// Check if there's a "#" before the cursor with whitespace before it
// secure length check
CharSequence documentText = parameters.getEditor().getDocument().getCharsSequence();
if (offset < documentText.length()) {
return documentText.charAt(offset - 1) == '#' && psiFile.findElementAt(offset - 2) instanceof PsiWhiteSpace;
}

return false;
}
}

/**
* Enum to specify where the cursor should be positioned after attribute insertion
*/
private enum CursorPosition {
/** Position cursor inside quotes: #[Attribute("<caret>")] */
INSIDE_QUOTES,
/** Position cursor inside parentheses: #[Attribute(<caret>)] */
INSIDE_PARENTHESES
}

/**
* Insert handler that adds a PHP attribute
*/
private record PhpAttributeInsertHandler(@NotNull String attributeFqn, @NotNull CursorPosition cursorPosition) implements InsertHandler<LookupElement> {

@Override
public void handleInsert(@NotNull InsertionContext context, @NotNull LookupElement item) {
Editor editor = context.getEditor();
Document document = editor.getDocument();
Project project = context.getProject();

int startOffset = context.getStartOffset();
int tailOffset = context.getTailOffset();

// Store the original insertion offset (where user typed "#")
int originalInsertionOffset = startOffset;

// Check if there's a "#" before the completion position
// If yes, we need to delete it to avoid "##[Attribute()]"
if (startOffset > 0) {
CharSequence text = document.getCharsSequence();
if (text.charAt(startOffset - 1) == '#') {
// Delete the "#" that was typed
document.deleteString(startOffset - 1, tailOffset);
originalInsertionOffset = startOffset - 1;
} else {
// Delete just the dummy identifier
document.deleteString(startOffset, tailOffset);
}
} else {
// Delete just the dummy identifier
document.deleteString(startOffset, tailOffset);
}

// First commit to get proper PSI
PsiDocumentManager.getInstance(project).commitDocument(document);
PsiFile file = context.getFile();

// Find the insertion position - look for the next method
PsiElement elementAt = file.findElementAt(originalInsertionOffset);
PhpClass phpClass = PsiTreeUtil.getParentOfType(elementAt, PhpClass.class);

// Find the method we're adding the attribute to
Method targetMethod = null;
if (phpClass != null) {
for (Method method : phpClass.getOwnMethods()) {
if (method.getTextOffset() > originalInsertionOffset) {
targetMethod = method;
break;
}
}
}

if (targetMethod == null) {
return; // Can't find target method
}

// Extract class name from FQN (get the last part after the last backslash)
String className = attributeFqn.substring(attributeFqn.lastIndexOf('\\') + 1);

// Store document length before adding import to calculate offset shift
int documentLengthBeforeImport = document.getTextLength();

// Add import if necessary - this will modify the document!
String importedName = PhpElementsUtil.insertUseIfNecessary(phpClass, attributeFqn);
if (importedName != null) {
className = importedName;
}

// IMPORTANT: After adding import, commit and recalculate the insertion position
PsiDocumentManager psiDocManager = PsiDocumentManager.getInstance(project);
psiDocManager.commitDocument(document);
psiDocManager.doPostponedOperationsAndUnblockDocument(document);

// Calculate how much the document length changed (import adds characters above our insertion point)
int documentLengthAfterImport = document.getTextLength();
int offsetShift = documentLengthAfterImport - documentLengthBeforeImport;

// Adjust insertion offset by the shift caused by import
int currentInsertionOffset = originalInsertionOffset + offsetShift;

// Build attribute text based on cursor position
String attributeText = "#[" + className + (cursorPosition == CursorPosition.INSIDE_QUOTES ? "(\"\")]\n" : "()]\n");

// Insert at the cursor position where user typed "#"
document.insertString(currentInsertionOffset, attributeText);

// Commit and reformat
psiDocManager.commitDocument(document);
psiDocManager.doPostponedOperationsAndUnblockDocument(document);

// Reformat the added attribute
CodeUtil.reformatAddedAttribute(project, document, currentInsertionOffset);

// After reformatting, position cursor based on the cursor position mode
psiDocManager.commitDocument(document);

// Get fresh PSI and find the attribute we just added
PsiFile finalFile = psiDocManager.getPsiFile(document);
if (finalFile != null) {
// Look for element INSIDE the inserted attribute (a few chars after insertion point)
PsiElement elementInsideAttribute = finalFile.findElementAt(currentInsertionOffset + 3);
if (elementInsideAttribute != null) {
// Find the PhpAttribute element
PhpAttribute phpAttribute =
PsiTreeUtil.getParentOfType(elementInsideAttribute, PhpAttribute.class);

if (phpAttribute != null) {
int attributeStart = phpAttribute.getTextRange().getStartOffset();
int attributeEnd = phpAttribute.getTextRange().getEndOffset();
CharSequence attributeContent = document.getCharsSequence().subSequence(attributeStart, attributeEnd);

// Find cursor position based on mode
String searchChar = cursorPosition == CursorPosition.INSIDE_QUOTES ? "\"" : "(";
int searchIndex = attributeContent.toString().indexOf(searchChar);

if (searchIndex >= 0) {
// Position cursor right after the search character
int caretOffset = attributeStart + searchIndex + 1;
editor.getCaretModel().moveToOffset(caretOffset);
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package fr.adrienbrault.idea.symfony2plugin.completion;

import com.intellij.codeInsight.AutoPopupController;
import com.intellij.codeInsight.completion.CompletionConfidence;
import com.intellij.codeInsight.editorActions.TypedHandlerDelegate;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.project.Project;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiWhiteSpace;
import com.intellij.util.ThreeState;
import com.jetbrains.php.lang.psi.PhpFile;
import com.jetbrains.php.lang.psi.PhpPsiUtil;
import com.jetbrains.php.lang.psi.elements.Method;
import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

public class PhpAttributeCompletionPopupHandlerCompletionConfidence {
/**
* Tells IntelliJ that completion should definitely run after "#" in PHP classes
* This is needed for auto-popup to work for PHP attributes
*
* @author Daniel Espendiller <daniel@espendiller.net>
*/
public static class PhpAttributeCompletionConfidence extends CompletionConfidence {
@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())) {
return ThreeState.UNSURE;
}

Method foundMethod = getMethod(contextElement);
if (foundMethod == null) {
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;
}

return ThreeState.UNSURE;
}
}

/**
* Triggers auto-popup completion after typing '#' character in PHP files
* when positioned before a public method (for PHP attributes like #[Route()])
*
* @author Daniel Espendiller <daniel@espendiller.net>
*/
public static class PhpAttributeAutoPopupHandler extends TypedHandlerDelegate {
public @NotNull Result checkAutoPopup(char charTyped, @NotNull Project project, @NotNull Editor editor, @NotNull PsiFile file) {
if (charTyped != '#' || !(file instanceof PhpFile) || !Symfony2ProjectComponent.isEnabled(project)) {
return Result.CONTINUE;
}


// Check if we're in a class context
int offset = editor.getCaretModel().getOffset();
if (!(file.findElementAt(offset - 2) instanceof PsiWhiteSpace)) {
return Result.CONTINUE;
}

PsiElement element = file.findElementAt(offset - 1);
if (element == null) {
return Result.CONTINUE;
}

Method foundMethod = getMethod(element);
if (foundMethod == null) {
return Result.CONTINUE;
}

AutoPopupController.getInstance(project).scheduleAutoPopup(editor);
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;
}
}
6 changes: 6 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@

<completion.contributor language="PHP" order="last" implementationClass="fr.adrienbrault.idea.symfony2plugin.completion.PhpIncompleteCompletionContributor"/>

<!-- provide completion after "#" inside the method scope -->
<completion.contributor language="PHP" implementationClass="fr.adrienbrault.idea.symfony2plugin.completion.PhpAttributeCompletionContributor" order="first"/>
<completion.confidence implementationClass="fr.adrienbrault.idea.symfony2plugin.completion.PhpAttributeCompletionPopupHandlerCompletionConfidence$PhpAttributeCompletionConfidence" language="PHP"/>
<typedHandler implementation="fr.adrienbrault.idea.symfony2plugin.completion.PhpAttributeCompletionPopupHandlerCompletionConfidence$PhpAttributeAutoPopupHandler"/>


<fileBasedIndex implementation="fr.adrienbrault.idea.symfony2plugin.stubs.indexes.RoutesStubIndex"/>
<fileBasedIndex implementation="fr.adrienbrault.idea.symfony2plugin.stubs.indexes.TwigExtendsStubIndex"/>
<fileBasedIndex implementation="fr.adrienbrault.idea.symfony2plugin.stubs.indexes.ServicesDefinitionStubIndex"/>
Expand Down
Loading