Skip to content

Commit 33ae9a1

Browse files
committed
[Symfony 7.3] add constant and test for 7.3 and test help to attribute
1 parent 9a4e2c8 commit 33ae9a1

File tree

12 files changed

+477
-2
lines changed

12 files changed

+477
-2
lines changed

config/sets/symfony/symfony73.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
declare(strict_types=1);
44

55
use Rector\Config\RectorConfig;
6+
use Rector\Symfony\Symfony73\Rector\Class_\CommandHelpToAttributeRector;
67
use Rector\Symfony\Symfony73\Rector\Class_\InvokableCommandRector;
78

89
// @see https://github.com/symfony/symfony/blame/7.3/UPGRADE-7.3.md
910

1011
return RectorConfig::configure()
11-
->withRules([InvokableCommandRector::class]);
12+
->withRules([CommandHelpToAttributeRector::class, InvokableCommandRector::class]);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\Symfony\Tests\Symfony73\Rector\Class_\CommandHelpToAttributeRector;
6+
7+
use Iterator;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
10+
11+
final class CommandHelpToAttributeRectorTest extends AbstractRectorTestCase
12+
{
13+
#[DataProvider('provideData')]
14+
public function test(string $filePath): void
15+
{
16+
$this->doTestFile($filePath);
17+
}
18+
19+
public static function provideData(): Iterator
20+
{
21+
return self::yieldFilesFromDirectory(__DIR__ . '/Fixture');
22+
}
23+
24+
public function provideConfigFilePath(): string
25+
{
26+
return __DIR__ . '/config/configured_rule.php';
27+
}
28+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace Rector\Symfony\Tests\Symfony73\Rector\Class_\CommandHelpToAttributeRector\Fixture;
4+
5+
use Symfony\Component\Console\Attribute\AsCommand;
6+
use Symfony\Component\Console\Command\Command;
7+
8+
#[AsCommand(name: 'some_name')]
9+
final class SomeCommand extends Command
10+
{
11+
public function configure()
12+
{
13+
$this->setHelp('Some help text');
14+
}
15+
}
16+
17+
?>
18+
-----
19+
<?php
20+
21+
namespace Rector\Symfony\Tests\Symfony73\Rector\Class_\CommandHelpToAttributeRector\Fixture;
22+
23+
use Symfony\Component\Console\Attribute\AsCommand;
24+
use Symfony\Component\Console\Command\Command;
25+
26+
#[AsCommand(name: 'some_name', help: '<<<TXT Some help text TXT')]
27+
final class SomeCommand extends Command
28+
{
29+
public function configure()
30+
{
31+
}
32+
}
33+
34+
?>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Rector\Config\RectorConfig;
6+
use Rector\Symfony\Symfony73\Rector\Class_\CommandHelpToAttributeRector;
7+
8+
return static function (RectorConfig $rectorConfig): void {
9+
$rectorConfig->rule(CommandHelpToAttributeRector::class);
10+
};

rules-tests/Symfony73/Rector/Class_/InvokableCommandRector/Fixture/some_command.php.inc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ final class SomeCommand extends Command
1414
{
1515
public function configure()
1616
{
17+
$this->setHelp('argument');
1718
$this->addArgument('argument', InputArgument::REQUIRED, 'Argument description');
1819
$this->addOption('option', 'o', InputOption::VALUE_NONE, 'Option description');
1920
}
@@ -45,6 +46,11 @@ use Symfony\Component\Console\Input\InputOption;
4546
#[AsCommand(name: 'some_name')]
4647
final class SomeCommand
4748
{
49+
public function configure()
50+
{
51+
$this->setHelp('argument');
52+
}
53+
4854
public function __invoke(#[\Symfony\Component\Console\Attribute\Command\Argument]
4955
string $argument, #[\Symfony\Component\Console\Attribute\Command\Option]
5056
$option): int
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\Symfony\Symfony73\Rector\Class_;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Arg;
9+
use PhpParser\Node\Attribute;
10+
use PhpParser\Node\Expr;
11+
use PhpParser\Node\Expr\MethodCall;
12+
use PhpParser\Node\Expr\Variable;
13+
use PhpParser\Node\Identifier;
14+
use PhpParser\Node\Scalar\String_;
15+
use PhpParser\Node\Stmt;
16+
use PhpParser\Node\Stmt\Class_;
17+
use PhpParser\Node\Stmt\ClassMethod;
18+
use PhpParser\Node\Stmt\Expression;
19+
use PHPStan\Reflection\ReflectionProvider;
20+
use PHPStan\Type\ObjectType;
21+
use Rector\PhpAttribute\NodeFactory\PhpAttributeGroupFactory;
22+
use Rector\Rector\AbstractRector;
23+
use Rector\Symfony\Enum\CommandMethodName;
24+
use Rector\Symfony\Enum\SymfonyAttribute;
25+
use Rector\Symfony\Enum\SymfonyClass;
26+
use Rector\ValueObject\PhpVersionFeature;
27+
use Rector\VersionBonding\Contract\MinPhpVersionInterface;
28+
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
29+
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
30+
31+
/**
32+
* @see https://symfony.com/doc/current/console.html#help-message
33+
*
34+
* @see \Rector\Symfony\Tests\Symfony73\Rector\Class_\CommandHelpToAttributeRector\CommandHelpToAttributeRectorTest
35+
*/
36+
final class CommandHelpToAttributeRector extends AbstractRector implements MinPhpVersionInterface
37+
{
38+
public function __construct(
39+
private readonly PhpAttributeGroupFactory $phpAttributeGroupFactory,
40+
private readonly ReflectionProvider $reflectionProvider,
41+
) {
42+
}
43+
44+
public function provideMinPhpVersion(): int
45+
{
46+
return PhpVersionFeature::ATTRIBUTES;
47+
}
48+
49+
public function getRuleDefinition(): RuleDefinition
50+
{
51+
return new RuleDefinition(
52+
'Moves $this->setHelp() to the "help" named argument of #[AsCommand]',
53+
[
54+
new CodeSample(
55+
<<<'CODE_SAMPLE'
56+
use Symfony\Component\Console\Command\Command;
57+
58+
final class SomeCommand extends Command
59+
{
60+
protected function configure(): void
61+
{
62+
$this->setHelp('Some help text');
63+
}
64+
}
65+
CODE_SAMPLE
66+
,
67+
<<<'CODE_SAMPLE'
68+
use Symfony\Component\Console\Attribute\AsCommand;
69+
use Symfony\Component\Console\Command\Command;
70+
71+
#[AsCommand(name: 'app:some', help: '<<<TXT Some help text TXT')]
72+
final class SomeCommand extends Command
73+
{
74+
}
75+
CODE_SAMPLE
76+
),
77+
]
78+
);
79+
}
80+
81+
/**
82+
* @return array<class-string<Node>>
83+
*/
84+
public function getNodeTypes(): array
85+
{
86+
return [Class_::class];
87+
}
88+
89+
/**
90+
* @param Class_ $node
91+
*/
92+
public function refactor(Node $node): ?Node
93+
{
94+
if ($node->isAbstract()) {
95+
return null;
96+
}
97+
98+
if (! $this->reflectionProvider->hasClass(SymfonyAttribute::AS_COMMAND)) {
99+
return null;
100+
}
101+
102+
if (! $this->isObjectType($node, new ObjectType(SymfonyClass::COMMAND))) {
103+
return null;
104+
}
105+
106+
$asCommandAttribute = $this->ensureAsCommandAttribute($node);
107+
108+
foreach ($asCommandAttribute->args as $arg) {
109+
if ($arg->name?->toString() === 'help') {
110+
return $node;
111+
}
112+
}
113+
114+
$configureClassMethod = $node->getMethod(CommandMethodName::CONFIGURE);
115+
if (! $configureClassMethod instanceof ClassMethod) {
116+
return null;
117+
}
118+
119+
$helpExpr = $this->findAndRemoveSetHelpExpr($configureClassMethod);
120+
if (! $helpExpr instanceof String_) {
121+
return null;
122+
}
123+
124+
$wrappedHelp = new String_(
125+
'<<<TXT ' . $helpExpr->value . ' TXT'
126+
);
127+
128+
$asCommandAttribute->args[] = new Arg($wrappedHelp, false, false, [], new Identifier('help'));
129+
130+
if ($configureClassMethod->stmts === []) {
131+
unset($configureClassMethod);
132+
}
133+
134+
return $node;
135+
}
136+
137+
private function ensureAsCommandAttribute(Class_ $class): Attribute
138+
{
139+
foreach ($class->attrGroups as $attrGroup) {
140+
foreach ($attrGroup->attrs as $attribute) {
141+
if ($this->nodeNameResolver->isName($attribute->name, SymfonyAttribute::AS_COMMAND)) {
142+
return $attribute;
143+
}
144+
}
145+
}
146+
147+
$attrGroup = $this->phpAttributeGroupFactory->createFromClass(SymfonyAttribute::AS_COMMAND);
148+
$class->attrGroups[] = $attrGroup;
149+
150+
return $attrGroup->attrs[0];
151+
}
152+
153+
/**
154+
* Returns the argument passed to setHelp() and removes the MethodCall node.
155+
*/
156+
private function findAndRemoveSetHelpExpr(ClassMethod $configureMethod): ?String_
157+
{
158+
$helpString = null;
159+
160+
$this->traverseNodesWithCallable(
161+
(array) $configureMethod->stmts,
162+
function (Node $node) use (&$helpString): ?Expr {
163+
if (! $node instanceof MethodCall) {
164+
return null;
165+
}
166+
167+
if (! $this->isName($node->name, 'setHelp')) {
168+
return null;
169+
}
170+
171+
$argExpr = $node->getArgs()[0]
172+
->value;
173+
if ($argExpr instanceof String_) {
174+
$helpString = $argExpr;
175+
}
176+
177+
$parent = $node->getAttribute('parent');
178+
if ($parent instanceof Expression) {
179+
unset($parent);
180+
}
181+
182+
return $node->var;
183+
}
184+
);
185+
186+
foreach ((array) $configureMethod->stmts as $key => $stmt) {
187+
if ($this->isExpressionVariableThis($stmt)) {
188+
unset($configureMethod->stmts[$key]);
189+
}
190+
}
191+
192+
return $helpString;
193+
}
194+
195+
private function isExpressionVariableThis(Stmt $stmt): bool
196+
{
197+
if (! $stmt instanceof Expression) {
198+
return false;
199+
}
200+
201+
if (! $stmt->expr instanceof Variable) {
202+
return false;
203+
}
204+
205+
return $this->isName($stmt->expr, 'this');
206+
}
207+
}

rules/Symfony73/Rector/Class_/InvokableCommandRector.php

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use PhpParser\Node\Scalar\String_;
1414
use PhpParser\Node\Stmt\Class_;
1515
use PhpParser\Node\Stmt\ClassMethod;
16+
use PhpParser\Node\Stmt\Expression;
1617
use Rector\Doctrine\NodeAnalyzer\AttributeFinder;
1718
use Rector\Exception\ShouldNotHappenException;
1819
use Rector\Rector\AbstractRector;
@@ -32,6 +33,8 @@
3233
*/
3334
final class InvokableCommandRector extends AbstractRector
3435
{
36+
private const MIGRATED_CONFIGURE_CALLS = ['addArgument', 'addOption'];
37+
3538
public function __construct(
3639
private readonly AttributeFinder $attributeFinder,
3740
private readonly CommandArgumentsAndOptionsResolver $commandArgumentsAndOptionsResolver,
@@ -188,7 +191,33 @@ private function removeConfigureClassMethod(Class_ $class): void
188191
continue;
189192
}
190193

191-
unset($class->stmts[$key]);
194+
foreach ((array) $stmt->stmts as $innerKey => $innerStmt) {
195+
if (! $innerStmt instanceof Expression) {
196+
continue;
197+
}
198+
199+
$expr = $innerStmt->expr;
200+
if (! $expr instanceof MethodCall) {
201+
continue;
202+
}
203+
204+
if (! $this->isName($expr->var, 'this')) {
205+
continue;
206+
}
207+
208+
if (! $this->isNames($expr->name, self::MIGRATED_CONFIGURE_CALLS)) {
209+
continue;
210+
}
211+
212+
// remove the consumed call
213+
unset($stmt->stmts[$innerKey]);
214+
}
215+
216+
// 2. if configure() has become empty → remove the method itself
217+
if ($stmt->stmts === [] || $stmt->stmts === null) {
218+
unset($class->stmts[$key]);
219+
}
220+
192221
return;
193222
}
194223
}

src/Set/SymfonySetList.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ final class SymfonySetList
160160
*/
161161
final public const SYMFONY_72 = __DIR__ . '/../../config/sets/symfony/symfony72.php';
162162

163+
/**
164+
* @var string
165+
*/
166+
final public const SYMFONY_73 = __DIR__ . '/../../config/sets/symfony/symfony73.php';
167+
163168
/**
164169
* @var string
165170
*/

0 commit comments

Comments
 (0)