Skip to content

Commit b4e20e5

Browse files
committed
Add intention to add parameters to __invoke method of invokable Symfony Commands
1 parent ef06063 commit b4e20e5

File tree

9 files changed

+665
-0
lines changed

9 files changed

+665
-0
lines changed
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package fr.adrienbrault.idea.symfony2plugin.intentions.php;
2+
3+
import com.intellij.codeInsight.intention.PsiElementBaseIntentionAction;
4+
import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo;
5+
import com.intellij.openapi.command.WriteCommandAction;
6+
import com.intellij.openapi.editor.Editor;
7+
import com.intellij.openapi.project.Project;
8+
import com.intellij.openapi.ui.popup.JBPopupFactory;
9+
import com.intellij.openapi.util.Iconable;
10+
import com.intellij.psi.PsiElement;
11+
import com.intellij.psi.PsiFile;
12+
import com.intellij.psi.util.PsiTreeUtil;
13+
import com.intellij.util.IncorrectOperationException;
14+
import com.jetbrains.php.lang.psi.elements.*;
15+
import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent;
16+
import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil;
17+
import icons.SymfonyIcons;
18+
import org.jetbrains.annotations.NotNull;
19+
20+
import javax.swing.*;
21+
import java.util.*;
22+
23+
/**
24+
* Intention action to add parameters to the __invoke method of an invokable Symfony Command.
25+
*
26+
* @author Daniel Espendiller <daniel@espendiller.net>
27+
*/
28+
public class CommandInvokeParameterIntention extends PsiElementBaseIntentionAction implements Iconable {
29+
30+
private static final String AS_COMMAND_ATTRIBUTE = "\\Symfony\\Component\\Console\\Attribute\\AsCommand";
31+
32+
/**
33+
* Map of FQN (without leading backslash) to variable name
34+
*/
35+
private static final Map<String, String> AVAILABLE_PARAMETERS = new LinkedHashMap<>() {{
36+
put("Symfony\\Component\\Console\\Input\\InputInterface", "input");
37+
put("Symfony\\Component\\Console\\Output\\OutputInterface", "output");
38+
put("Symfony\\Component\\Console\\Cursor", "cursor");
39+
put("Symfony\\Component\\Console\\Style\\SymfonyStyle", "io");
40+
put("Symfony\\Component\\Console\\Application", "application");
41+
}};
42+
43+
@Override
44+
public void invoke(@NotNull Project project, Editor editor, @NotNull PsiElement psiElement) throws IncorrectOperationException {
45+
PhpClass phpClass = PsiTreeUtil.getParentOfType(psiElement, PhpClass.class);
46+
if (phpClass == null) {
47+
return;
48+
}
49+
50+
Method invokeMethod = phpClass.findOwnMethodByName("__invoke");
51+
if (invokeMethod == null) {
52+
return;
53+
}
54+
55+
List<String> availableFqns = getAvailableParameterFqns(invokeMethod);
56+
if (availableFqns.isEmpty()) {
57+
return;
58+
}
59+
60+
// Build display names: "ClassName (namespace)"
61+
List<String> displayNames = new ArrayList<>();
62+
Map<String, String> displayToFqn = new LinkedHashMap<>();
63+
for (String fqn : availableFqns) {
64+
String displayName = formatDisplayName(fqn);
65+
displayNames.add(displayName);
66+
displayToFqn.put(displayName, fqn);
67+
}
68+
69+
JBPopupFactory.getInstance().createPopupChooserBuilder(displayNames)
70+
.setTitle("Symfony: Add Parameter to __invoke")
71+
.setItemChosenCallback(selectedDisplay -> WriteCommandAction.writeCommandAction(project)
72+
.withName("Add __invoke Parameter")
73+
.run(() -> {
74+
String fqn = displayToFqn.get(selectedDisplay);
75+
String variableName = AVAILABLE_PARAMETERS.get(fqn);
76+
if (variableName != null && fqn != null) {
77+
PhpElementsUtil.addParameterToMethod(invokeMethod, "\\" + fqn, variableName);
78+
}
79+
}))
80+
.createPopup()
81+
.showInBestPositionFor(editor);
82+
}
83+
84+
/**
85+
* Formats a FQN for display as "ClassName (namespace)"
86+
* e.g., "Symfony\\Component\\Console\\Style\\SymfonyStyle" -> "SymfonyStyle (Symfony\\Component\\Console\\Style)"
87+
*/
88+
@NotNull
89+
private static String formatDisplayName(@NotNull String fqn) {
90+
int lastBackslash = fqn.lastIndexOf('\\');
91+
if (lastBackslash == -1) {
92+
return fqn;
93+
}
94+
String className = fqn.substring(lastBackslash + 1);
95+
String namespace = fqn.substring(0, lastBackslash);
96+
return className + " (" + namespace + ")";
97+
}
98+
99+
/**
100+
* Returns the list of FQNs (without leading backslash) that are available to be added to the given method.
101+
* Filters out parameters whose types are already present in the method signature.
102+
*
103+
* @param method The method to check
104+
* @return List of available FQNs (e.g., "Symfony\\Component\\Console\\Style\\SymfonyStyle")
105+
*/
106+
@NotNull
107+
public static List<String> getAvailableParameterFqns(@NotNull Method method) {
108+
Set<String> existingParameterTypes = getExistingParameterTypes(method);
109+
110+
List<String> availableFqns = new ArrayList<>();
111+
for (String fqn : AVAILABLE_PARAMETERS.keySet()) {
112+
if (!existingParameterTypes.contains("\\" + fqn)) {
113+
availableFqns.add(fqn);
114+
}
115+
}
116+
117+
return availableFqns;
118+
}
119+
120+
@NotNull
121+
private static Set<String> getExistingParameterTypes(@NotNull Method method) {
122+
Set<String> types = new HashSet<>();
123+
for (Parameter parameter : method.getParameters()) {
124+
types.addAll(parameter.getDeclaredType().getTypes());
125+
}
126+
return types;
127+
}
128+
129+
@Override
130+
public boolean isAvailable(@NotNull Project project, Editor editor, @NotNull PsiElement psiElement) {
131+
if (!Symfony2ProjectComponent.isEnabled(project)) {
132+
return false;
133+
}
134+
135+
PhpClass phpClass = PsiTreeUtil.getParentOfType(psiElement, PhpClass.class);
136+
if (phpClass == null) {
137+
return false;
138+
}
139+
140+
if (PhpElementsUtil.isInstanceOf(phpClass, "\\Symfony\\Component\\Console\\Command\\Command")) {
141+
return false;
142+
}
143+
144+
if (phpClass.getAttributes(AS_COMMAND_ATTRIBUTE).isEmpty()) {
145+
return false;
146+
}
147+
148+
Method invokeMethod = phpClass.findOwnMethodByName("__invoke");
149+
if (invokeMethod == null) {
150+
return false;
151+
}
152+
153+
return !getAvailableParameterFqns(invokeMethod).isEmpty();
154+
}
155+
156+
@NotNull
157+
@Override
158+
public String getFamilyName() {
159+
return "Symfony";
160+
}
161+
162+
@NotNull
163+
@Override
164+
public String getText() {
165+
return "Symfony: Add parameter to __invoke";
166+
}
167+
168+
@Override
169+
public Icon getIcon(int flags) {
170+
return SymfonyIcons.Symfony;
171+
}
172+
173+
@Override
174+
public @NotNull IntentionPreviewInfo generatePreview(@NotNull Project project, @NotNull Editor editor, @NotNull PsiFile file) {
175+
return IntentionPreviewInfo.EMPTY;
176+
}
177+
}

src/main/java/fr/adrienbrault/idea/symfony2plugin/util/PhpElementsUtil.java

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import com.intellij.codeInsight.completion.CompletionResultSet;
44
import com.intellij.lang.ASTNode;
5+
import com.intellij.openapi.editor.Document;
56
import com.intellij.openapi.project.Project;
67
import com.intellij.openapi.util.Ref;
78
import com.intellij.patterns.ElementPattern;
89
import com.intellij.patterns.PatternCondition;
910
import com.intellij.patterns.PlatformPatterns;
1011
import com.intellij.patterns.PsiElementPattern;
1112
import com.intellij.psi.*;
13+
import com.intellij.psi.codeStyle.CodeStyleManager;
1214
import com.intellij.psi.formatter.FormatterUtil;
1315
import com.intellij.psi.util.PsiTreeUtil;
1416
import com.intellij.util.ProcessingContext;
@@ -2218,4 +2220,123 @@ private static String resolvePhpReference(@NotNull PhpReference parameter) {
22182220
return null;
22192221
}
22202222
}
2223+
2224+
/**
2225+
* Adds a parameter with the given fully qualified class name and variable name to a method.
2226+
* This method handles use statement insertion and parameter list modification with reformatting.
2227+
* The parameter is inserted before any optional parameters to maintain valid PHP syntax.
2228+
*
2229+
* @param method The method to add the parameter to
2230+
* @param fqn The fully qualified class name for the parameter type (e.g., "\\Symfony\\Component\\Console\\Style\\SymfonyStyle")
2231+
* @param variableName The variable name for the parameter (e.g., "io")
2232+
*/
2233+
public static void addParameterToMethod(@NotNull Method method, @NotNull String fqn, @NotNull String variableName) {
2234+
PhpClass phpClass = method.getContainingClass();
2235+
if (phpClass == null) {
2236+
return;
2237+
}
2238+
2239+
Project project = method.getProject();
2240+
2241+
insertUseIfNecessary(phpClass, fqn);
2242+
2243+
PsiFile file = method.getContainingFile();
2244+
Document document = PsiDocumentManager.getInstance(project).getDocument(file);
2245+
if (document == null) {
2246+
return;
2247+
}
2248+
2249+
PsiDocumentManager psiDocManager = PsiDocumentManager.getInstance(project);
2250+
psiDocManager.doPostponedOperationsAndUnblockDocument(document);
2251+
2252+
String shortName = fqn.substring(fqn.lastIndexOf('\\') + 1);
2253+
String newParameter = shortName + " $" + variableName;
2254+
2255+
Parameter[] existingParams = method.getParameters();
2256+
int insertOffset;
2257+
String textToInsert;
2258+
2259+
if (existingParams.length > 0) {
2260+
// Find the first optional parameter (has default value)
2261+
Parameter firstOptionalParam = null;
2262+
Parameter lastRequiredParam = null;
2263+
2264+
for (Parameter param : existingParams) {
2265+
if (param.getDefaultValue() != null) {
2266+
if (firstOptionalParam == null) {
2267+
firstOptionalParam = param;
2268+
}
2269+
} else {
2270+
lastRequiredParam = param;
2271+
}
2272+
}
2273+
2274+
if (firstOptionalParam != null && lastRequiredParam == null) {
2275+
// All existing parameters are optional, insert at the beginning
2276+
insertOffset = existingParams[0].getTextRange().getStartOffset();
2277+
textToInsert = newParameter + ", ";
2278+
} else if (firstOptionalParam != null) {
2279+
// Insert after the last required parameter
2280+
insertOffset = lastRequiredParam.getTextRange().getEndOffset();
2281+
textToInsert = ", " + newParameter;
2282+
} else {
2283+
// No optional parameters, append at the end
2284+
Parameter lastParam = existingParams[existingParams.length - 1];
2285+
insertOffset = lastParam.getTextRange().getEndOffset();
2286+
textToInsert = ", " + newParameter;
2287+
}
2288+
} else {
2289+
String methodText = method.getText();
2290+
int methodStartOffset = method.getTextRange().getStartOffset();
2291+
2292+
int openParenIndex = methodText.indexOf('(');
2293+
if (openParenIndex == -1) {
2294+
return;
2295+
}
2296+
2297+
insertOffset = methodStartOffset + openParenIndex + 1;
2298+
textToInsert = newParameter;
2299+
}
2300+
2301+
document.insertString(insertOffset, textToInsert);
2302+
2303+
psiDocManager.commitDocument(document);
2304+
psiDocManager.doPostponedOperationsAndUnblockDocument(document);
2305+
2306+
PsiFile freshFile = PsiDocumentManager.getInstance(project).getPsiFile(document);
2307+
if (freshFile != null) {
2308+
PhpClass freshPhpClass = PsiTreeUtil.findChildOfType(freshFile, PhpClass.class);
2309+
if (freshPhpClass != null) {
2310+
Method freshMethod = freshPhpClass.findOwnMethodByName(method.getName());
2311+
if (freshMethod != null) {
2312+
reformatMethodSignature(project, freshMethod);
2313+
}
2314+
}
2315+
}
2316+
}
2317+
2318+
/**
2319+
* Reformats the method signature (from method start to body start).
2320+
*
2321+
* @param project The project
2322+
* @param method The method to reformat
2323+
*/
2324+
public static void reformatMethodSignature(@NotNull Project project, @NotNull Method method) {
2325+
PsiFile containingFile = method.getContainingFile();
2326+
2327+
GroupStatement body = PsiTreeUtil.findChildOfType(method, GroupStatement.class);
2328+
if (body == null) {
2329+
return;
2330+
}
2331+
2332+
int startOffset = method.getTextRange().getStartOffset();
2333+
int endOffset = body.getTextRange().getStartOffset();
2334+
2335+
CodeStyleManager codeStyleManager = CodeStyleManager.getInstance(project);
2336+
try {
2337+
codeStyleManager.reformatRange(containingFile, startOffset, endOffset);
2338+
} catch (com.intellij.util.IncorrectOperationException e) {
2339+
// Ignore formatting errors
2340+
}
2341+
}
22212342
}

src/main/resources/META-INF/plugin.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,12 @@
749749
<className>fr.adrienbrault.idea.symfony2plugin.intentions.php.AddRouteAttributeIntention</className>
750750
<category>Symfony</category>
751751
</intentionAction>
752+
753+
<intentionAction>
754+
<language>PHP</language>
755+
<className>fr.adrienbrault.idea.symfony2plugin.intentions.php.CommandInvokeParameterIntention</className>
756+
<category>PHP</category>
757+
</intentionAction>
752758
</extensions>
753759

754760
<extensionPoints>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace App\Command;
4+
5+
use Symfony\Component\Console\Attribute\AsCommand;
6+
use Symfony\Component\Console\Style\SymfonyStyle;
7+
8+
#[AsCommand(name: 'app:example')]
9+
class ExampleCommand
10+
{
11+
public function __invoke(SymfonyStyle $io): int
12+
{
13+
return 0;
14+
}
15+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Command;
4+
5+
use Symfony\Component\Console\Attribute\AsCommand;
6+
7+
#[AsCommand(name: 'app:example')]
8+
class ExampleCommand
9+
{
10+
public function __invoke(): int
11+
{
12+
return 0;
13+
}
14+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<html>
2+
<body>
3+
Add parameter to invokable Symfony Command __invoke method
4+
<!-- tooltip end -->
5+
<p>
6+
Adds a parameter to the <code>__invoke()</code> method of an invokable Symfony Command.
7+
</p>
8+
<p>
9+
Available parameters:
10+
<ul>
11+
<li><code>InputInterface</code> - Access to command input</li>
12+
<li><code>OutputInterface</code> - Access to command output</li>
13+
<li><code>Cursor</code> - Control cursor position in terminal</li>
14+
<li><code>SymfonyStyle</code> - Styled input/output helper</li>
15+
<li><code>Application</code> - Access to console application</li>
16+
</ul>
17+
</p>
18+
<p>
19+
The intention is only available when the class does not extend <code>Command</code> and has an <code>__invoke()</code> method.
20+
</p>
21+
</body>
22+
</html>

0 commit comments

Comments
 (0)