Skip to content

Commit 24f9e57

Browse files
committed
remove parent::__construct calls during invokable command migration / replace $input->getArgument() and $input->getOption() calls with direct variable usage during invokable command migration
1 parent 334bd41 commit 24f9e57

File tree

5 files changed

+471
-7
lines changed

5 files changed

+471
-7
lines changed

src/main/java/fr/adrienbrault/idea/symfony2plugin/intentions/php/CommandToInvokableIntention.java

Lines changed: 211 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package fr.adrienbrault.idea.symfony2plugin.intentions.php;
22

33
import com.intellij.codeInsight.intention.PsiElementBaseIntentionAction;
4+
import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo;
45
import com.intellij.lang.LanguageImportStatements;
56
import com.intellij.openapi.editor.Document;
67
import com.intellij.openapi.editor.Editor;
@@ -12,6 +13,7 @@
1213
import com.intellij.psi.codeStyle.CodeStyleManager;
1314
import com.intellij.psi.util.PsiTreeUtil;
1415
import com.intellij.util.IncorrectOperationException;
16+
import com.jetbrains.php.lang.psi.PhpPsiElementFactory;
1517
import com.jetbrains.php.lang.psi.elements.*;
1618
import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent;
1719
import fr.adrienbrault.idea.symfony2plugin.util.CodeUtil;
@@ -33,6 +35,10 @@
3335
* @author Daniel Espendiller <daniel@espendiller.net>
3436
*/
3537
public class CommandToInvokableIntention extends PsiElementBaseIntentionAction implements Iconable {
38+
@Override
39+
public @NotNull IntentionPreviewInfo generatePreview(@NotNull Project project, @NotNull Editor editor, @NotNull PsiFile file) {
40+
return IntentionPreviewInfo.EMPTY;
41+
}
3642

3743
@Override
3844
public void invoke(@NotNull Project project, Editor editor, @NotNull PsiElement psiElement) throws IncorrectOperationException {
@@ -48,9 +54,12 @@ public void invoke(@NotNull Project project, Editor editor, @NotNull PsiElement
4854

4955
// Remove "extends Command" FIRST, before other PSI modifications
5056
// This must be done before replacing methods to avoid PSI invalidation issues
51-
removeCommandExtends(phpClass);
57+
if (removeCommandExtends(phpClass)) {
58+
// Remove parent::__construct() calls since we no longer extend Command
59+
removeParentConstructorCalls(phpClass);
60+
}
5261

53-
// Get parameter names from execute method
62+
// Get parameter names from the execute method
5463
ParameterNames paramNames = extractParameterNames(executeMethod);
5564

5665
// Collect arguments and options from configure() method
@@ -69,14 +78,59 @@ public void invoke(@NotNull Project project, Editor editor, @NotNull PsiElement
6978
optimizeImports(phpClass.getContainingFile());
7079
}
7180

72-
private void removeCommandExtends(@NotNull PhpClass phpClass) {
81+
private boolean removeCommandExtends(@NotNull PhpClass phpClass) {
7382
// Check if class extends Command
7483
if (!PhpElementsUtil.isInstanceOf(phpClass, "\\Symfony\\Component\\Console\\Command\\Command")) {
75-
return;
84+
return false;
7685
}
7786

7887
// Use shared utility method to remove the extends clause
79-
CodeUtil.removeExtendsClause(phpClass, "\\Symfony\\Component\\Console\\Command\\Command");
88+
return CodeUtil.removeExtendsClause(phpClass, "\\Symfony\\Component\\Console\\Command\\Command");
89+
}
90+
91+
/**
92+
* Removes parent::__construct() calls from the class constructor.
93+
* Since we're removing "extends Command", any parent constructor calls become invalid.
94+
*/
95+
private void removeParentConstructorCalls(@NotNull PhpClass phpClass) {
96+
// Find the constructor method
97+
Method constructor = phpClass.getConstructor();
98+
if (constructor == null) {
99+
return;
100+
}
101+
102+
// Find the method body
103+
GroupStatement body = PsiTreeUtil.findChildOfType(constructor, GroupStatement.class);
104+
if (body == null) {
105+
return;
106+
}
107+
108+
// Find all parent::__construct() calls
109+
Collection<MethodReference> methodReferences = PsiTreeUtil.findChildrenOfType(body, MethodReference.class);
110+
List<MethodReference> parentConstructorCalls = new ArrayList<>();
111+
112+
for (MethodReference methodRef : methodReferences) {
113+
// Check if this is a parent::__construct() call
114+
if ("__construct".equals(methodRef.getName())) {
115+
PsiElement classReference = methodRef.getClassReference();
116+
if (classReference != null && "parent".equals(classReference.getText())) {
117+
parentConstructorCalls.add(methodRef);
118+
}
119+
}
120+
}
121+
122+
if (parentConstructorCalls.isEmpty()) {
123+
return;
124+
}
125+
126+
// Remove each parent::__construct() call statement
127+
for (MethodReference parentCall : parentConstructorCalls) {
128+
// Navigate up to find the statement containing this call
129+
PsiElement statement = PsiTreeUtil.getParentOfType(parentCall, com.jetbrains.php.lang.psi.elements.Statement.class);
130+
if (statement != null) {
131+
statement.delete();
132+
}
133+
}
80134
}
81135

82136
private void optimizeImports(@NotNull PsiFile file) {
@@ -336,6 +390,13 @@ private void migrateExecuteToInvoke(@NotNull Project project, @NotNull Method ex
336390
if (freshPhpClass != null) {
337391
Method invokeMethod = freshPhpClass.findOwnMethodByName("__invoke");
338392
if (invokeMethod != null) {
393+
// Replace $input->getArgument() and $input->getOption() calls with direct variables
394+
replaceInputMethodCallsWithVariables(invokeMethod, configureData, paramNames);
395+
396+
// Commit after replacements
397+
psiDocManager.commitDocument(document);
398+
psiDocManager.doPostponedOperationsAndUnblockDocument(document);
399+
339400
// Reformat the __invoke method
340401
reformatMethod(project, invokeMethod);
341402
}
@@ -346,6 +407,151 @@ private void migrateExecuteToInvoke(@NotNull Project project, @NotNull Method ex
346407
psiDocManager.doPostponedOperationsAndUnblockDocument(document);
347408
}
348409

410+
/**
411+
* Replaces $input->getArgument('name') and $input->getOption('name') calls with direct variable access.
412+
* Also handles type casts like (int) $input->getArgument('name').
413+
* Removes redundant self-assignments like $var = $var.
414+
*/
415+
private void replaceInputMethodCallsWithVariables(@NotNull Method invokeMethod, @NotNull ConfigureData configureData, @NotNull ParameterNames paramNames) {
416+
GroupStatement body = PsiTreeUtil.findChildOfType(invokeMethod, GroupStatement.class);
417+
if (body == null) {
418+
return;
419+
}
420+
421+
// Build a map of argument/option names to their variable names
422+
java.util.Map<String, String> argumentVariableMap = new java.util.HashMap<>();
423+
java.util.Map<String, String> optionVariableMap = new java.util.HashMap<>();
424+
425+
for (ArgumentInfo arg : configureData.arguments) {
426+
String variableName = convertToValidPhpVariableName(arg.name);
427+
argumentVariableMap.put(arg.name, variableName);
428+
}
429+
430+
for (OptionInfo opt : configureData.options) {
431+
String variableName = convertToValidPhpVariableName(opt.name);
432+
optionVariableMap.put(opt.name, variableName);
433+
}
434+
435+
// Find all method calls in the body
436+
Collection<MethodReference> methodCalls = PsiTreeUtil.findChildrenOfType(body, MethodReference.class);
437+
List<MethodReference> callsToReplace = new ArrayList<>();
438+
439+
for (MethodReference methodCall : methodCalls) {
440+
String methodName = methodCall.getName();
441+
if (methodName == null) {
442+
continue;
443+
}
444+
445+
// Check if this is $input->getArgument() or $input->getOption() on InputInterface
446+
if ("getArgument".equals(methodName)) {
447+
if (PhpElementsUtil.isMethodReferenceInstanceOf(methodCall, "\\Symfony\\Component\\Console\\Input\\InputInterface", "getArgument")) {
448+
callsToReplace.add(methodCall);
449+
}
450+
} else if ("getOption".equals(methodName)) {
451+
if (PhpElementsUtil.isMethodReferenceInstanceOf(methodCall, "\\Symfony\\Component\\Console\\Input\\InputInterface", "getOption")) {
452+
callsToReplace.add(methodCall);
453+
}
454+
}
455+
}
456+
457+
// Replace each call
458+
for (MethodReference methodCall : callsToReplace) {
459+
String methodName = methodCall.getName();
460+
if (methodName == null) {
461+
continue;
462+
}
463+
464+
// Get the argument/option name from the first parameter
465+
PsiElement nameParam = PsiElementUtils.getMethodParameterPsiElementAt(methodCall, 0);
466+
if (nameParam == null) {
467+
continue;
468+
}
469+
470+
String paramName = PhpElementsUtil.getStringValue(nameParam);
471+
if (StringUtils.isBlank(paramName)) {
472+
continue;
473+
}
474+
475+
// Get the variable name from our map
476+
String variableName;
477+
if ("getArgument".equals(methodName)) {
478+
variableName = argumentVariableMap.get(paramName);
479+
} else {
480+
variableName = optionVariableMap.get(paramName);
481+
}
482+
483+
if (variableName == null) {
484+
continue;
485+
}
486+
487+
// Check if the method call is wrapped in a type cast
488+
PsiElement parent = methodCall.getParent();
489+
PsiElement elementToReplace = methodCall;
490+
491+
if (parent instanceof UnaryExpression) {
492+
UnaryExpression unaryExpr = (UnaryExpression) parent;
493+
// Check if this is a cast expression by looking at the operator
494+
PsiElement operator = unaryExpr.getOperation();
495+
if (operator != null) {
496+
String operatorText = operator.getText();
497+
// Common PHP type casts: (int), (string), (bool), (array), (object), (float), (double)
498+
if (operatorText.matches("\\(\\s*(int|integer|string|bool|boolean|array|object|float|double|real)\\s*\\)")) {
499+
elementToReplace = unaryExpr;
500+
}
501+
}
502+
}
503+
504+
// Create a new variable reference
505+
PhpPsiElement newVariable = PhpPsiElementFactory.createFromText(
506+
invokeMethod.getProject(),
507+
Variable.class,
508+
"$" + variableName
509+
);
510+
511+
if (newVariable != null) {
512+
elementToReplace.replace(newVariable);
513+
}
514+
}
515+
516+
// Remove redundant self-assignments like $var = $var
517+
removeRedundantSelfAssignments(body);
518+
}
519+
520+
/**
521+
* Removes redundant self-assignments like $var = $var.
522+
* These occur when we replace $input->getArgument('name') with $name,
523+
* and the original code had $name = $input->getArgument('name').
524+
*/
525+
private void removeRedundantSelfAssignments(@NotNull GroupStatement body) {
526+
Collection<AssignmentExpression> assignments = PsiTreeUtil.findChildrenOfType(body, AssignmentExpression.class);
527+
List<PsiElement> statementsToRemove = new ArrayList<>();
528+
529+
for (AssignmentExpression assignment : assignments) {
530+
PhpPsiElement variable = assignment.getVariable();
531+
PhpPsiElement value = assignment.getValue();
532+
533+
// Check if both left and right side are variables
534+
if (variable instanceof Variable && value instanceof Variable) {
535+
String leftVarName = ((Variable) variable).getName();
536+
String rightVarName = ((Variable) value).getName();
537+
538+
// If they're the same variable, mark the statement for removal
539+
if (leftVarName != null && leftVarName.equals(rightVarName)) {
540+
// Navigate up to find the statement containing this assignment
541+
PsiElement statement = PsiTreeUtil.getParentOfType(assignment, com.jetbrains.php.lang.psi.elements.Statement.class);
542+
if (statement != null && !statementsToRemove.contains(statement)) {
543+
statementsToRemove.add(statement);
544+
}
545+
}
546+
}
547+
}
548+
549+
// Remove all redundant statements
550+
for (PsiElement statement : statementsToRemove) {
551+
statement.delete();
552+
}
553+
}
554+
349555
private void reformatMethod(@NotNull Project project, @NotNull Method method) {
350556
// Reformat only the method signature (parameters), not the entire body
351557
// Use CodeStyleManager directly to avoid creating extra undo events

src/main/resources/intentionDescriptions/CommandToInvokableIntention/after.php.template

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ use Symfony\Component\Console\Output\OutputInterface;
77

88
class CreateUserCommand
99
{
10+
public function __construct()
11+
{
12+
}
13+
1014
public function __invoke(
1115
OutputInterface $output,
1216
#[Argument(description: 'The username')]

src/main/resources/intentionDescriptions/CommandToInvokableIntention/before.php.template

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ use Symfony\Component\Console\Output\OutputInterface;
88

99
class CreateUserCommand extends Command
1010
{
11+
public function __construct()
12+
{
13+
parent::__construct();
14+
}
15+
1116
protected function configure(): void
1217
{
1318
$this

src/main/resources/intentionDescriptions/CommandToInvokableIntention/description.html

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@
33
Migrate Symfony Command to invokable style (Symfony 7.3+)
44
<!-- tooltip end -->
55
<p>
6-
Converts a traditional Symfony Command with <code>execute()</code> method to the new invokable command style
7-
with <code>__invoke()</code> method using SymfonyStyle and input attributes.
6+
Converts traditional Symfony Command with <code>execute()</code> method to invokable command style using <code>__invoke()</code> with Argument and Option attributes.
87
</p>
8+
<p>
9+
This intention automatically:
10+
</p>
11+
<ul>
12+
<li>Removes <code>extends Command</code> and <code>parent::__construct()</code> calls</li>
13+
<li>Migrates <code>configure()</code> arguments/options to method parameters with attributes</li>
14+
<li>Replaces <code>$input->getArgument()</code> and <code>$input->getOption()</code> with direct variable access</li>
15+
<li>Removes redundant type casts and self-assignments</li>
16+
</ul>
917
</body>
1018
</html>

0 commit comments

Comments
 (0)