Skip to content

Commit 56dc4ed

Browse files
authored
Merge pull request #367 from cakephp/6.x-routebuilder-callback-first
Add RouteBuilderToCallbackFirstRector for CakePHP 6.0
2 parents ee7ad10 + 06d57bc commit 56dc4ed

File tree

5 files changed

+328
-0
lines changed

5 files changed

+328
-0
lines changed

config/rector/sets/cakephp60.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
use Cake\Upgrade\Rector\Cake6\EventManagerOnRector;
55
use Cake\Upgrade\Rector\Cake6\ReplaceCommandArgsIoWithPropertiesRector;
6+
use Cake\Upgrade\Rector\Cake6\RouteBuilderToCallbackFirstRector;
67
use PHPStan\Type\ObjectType;
78
use Rector\Config\RectorConfig;
89
use Rector\Renaming\Rector\MethodCall\RenameMethodRector;
@@ -23,6 +24,9 @@
2324

2425
$rectorConfig->rule(ReplaceCommandArgsIoWithPropertiesRector::class);
2526

27+
// RouteBuilder argument reordering
28+
$rectorConfig->rule(RouteBuilderToCallbackFirstRector::class);
29+
2630
// Changes related to the accessible => patchable rename
2731
$rectorConfig->ruleWithConfiguration(RenameMethodRector::class, [
2832
new MethodCallRename('Cake\ORM\Entity', 'setAccess', 'setPatchable'),
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Cake\Upgrade\Rector\Cake6;
5+
6+
use PhpParser\Node;
7+
use PhpParser\Node\Arg;
8+
use PhpParser\Node\Expr\Array_;
9+
use PhpParser\Node\Expr\Closure;
10+
use PhpParser\Node\Expr\ConstFetch;
11+
use PhpParser\Node\Expr\MethodCall;
12+
use PhpParser\Node\Name;
13+
use PHPStan\Type\ObjectType;
14+
use Rector\Rector\AbstractRector;
15+
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
16+
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
17+
18+
/**
19+
* Reorders RouteBuilder scope/prefix/plugin/resources arguments
20+
* to have callback second and params/options third.
21+
*
22+
* @see \Cake\Upgrade\Test\TestCase\Rector\MethodCall\RouteBuilderToCallbackFirstRector\RouteBuilderToCallbackFirstRectorTest
23+
*/
24+
final class RouteBuilderToCallbackFirstRector extends AbstractRector
25+
{
26+
private const METHODS = ['scope', 'prefix', 'plugin', 'resources'];
27+
28+
public function getRuleDefinition(): RuleDefinition
29+
{
30+
return new RuleDefinition(
31+
'Reorder RouteBuilder scope/prefix/plugin/resources arguments ' .
32+
'to have callback second and params/options third',
33+
[
34+
new CodeSample(
35+
<<<'CODE_SAMPLE'
36+
$routes->scope('/api', ['prefix' => 'Api'], function ($routes) {
37+
$routes->resources('Articles');
38+
});
39+
$routes->prefix('admin', [], function ($routes) {
40+
$routes->connect('/', ['controller' => 'Dashboard']);
41+
});
42+
$routes->resources('Articles', ['only' => 'index']);
43+
$routes->resources('Posts', ['only' => 'index'], function ($routes) {
44+
$routes->resources('Comments');
45+
});
46+
CODE_SAMPLE
47+
,
48+
<<<'CODE_SAMPLE'
49+
$routes->scope('/api', function ($routes) {
50+
$routes->resources('Articles');
51+
}, ['prefix' => 'Api']);
52+
$routes->prefix('admin', function ($routes) {
53+
$routes->connect('/', ['controller' => 'Dashboard']);
54+
});
55+
$routes->resources('Articles', null, ['only' => 'index']);
56+
$routes->resources('Posts', function ($routes) {
57+
$routes->resources('Comments');
58+
}, ['only' => 'index']);
59+
CODE_SAMPLE,
60+
),
61+
],
62+
);
63+
}
64+
65+
/**
66+
* @return array<class-string<\PhpParser\Node>>
67+
*/
68+
public function getNodeTypes(): array
69+
{
70+
return [MethodCall::class];
71+
}
72+
73+
/**
74+
* @param \PhpParser\Node\Expr\MethodCall $node
75+
*/
76+
public function refactor(Node $node): ?Node
77+
{
78+
// Check if the object is a RouteBuilder
79+
if (!$this->isObjectType($node->var, new ObjectType('Cake\Routing\RouteBuilder'))) {
80+
return null;
81+
}
82+
83+
// Check if this is one of our target methods
84+
if (!$this->isNames($node->name, self::METHODS)) {
85+
return null;
86+
}
87+
88+
$methodName = $this->getName($node->name);
89+
$argCount = count($node->args);
90+
91+
// For resources method
92+
// Old signature: resources(string $name, array $options = [], ?callable $callback = null)
93+
// New signature: resources(string $name, ?Closure $callback = null, array $options = [])
94+
if ($methodName === 'resources') {
95+
// resources('Articles', ['only' => 'index']) -> resources('Articles', null, ['only' => 'index'])
96+
if ($argCount === 2) {
97+
$secondArg = $node->args[1];
98+
// Check if second arg is an array (options)
99+
if ($secondArg->value instanceof Array_) {
100+
// Insert null as second argument, move array to third
101+
$node->args = [
102+
$node->args[0],
103+
new Arg(new ConstFetch(new Name('null'))),
104+
$secondArg,
105+
];
106+
107+
return $node;
108+
}
109+
}
110+
111+
// resources('Articles', ['only' => 'index'], fn) -> resources('Articles', fn, ['only' => 'index'])
112+
if ($argCount === 3) {
113+
$secondArg = $node->args[1];
114+
$thirdArg = $node->args[2];
115+
116+
// Check if third arg is closure and second is array
117+
if ($thirdArg->value instanceof Closure && $secondArg->value instanceof Array_) {
118+
// Check if second argument is an empty array - if so, just remove it
119+
$isEmptyArray = count($secondArg->value->items) === 0;
120+
121+
if ($isEmptyArray) {
122+
$node->args = [
123+
$node->args[0],
124+
$thirdArg,
125+
];
126+
} else {
127+
// Swap: callback becomes second, options becomes third
128+
$node->args[1] = $thirdArg;
129+
$node->args[2] = $secondArg;
130+
}
131+
132+
return $node;
133+
}
134+
}
135+
136+
return null;
137+
}
138+
139+
// For scope/prefix/plugin methods
140+
// Old signature: method(string $path, array|callable $params, ?callable $callback = null)
141+
// New signature: method(string $path, Closure $callback, array $params = [])
142+
143+
// Only process if there are exactly 3 arguments
144+
if ($argCount !== 3) {
145+
return null;
146+
}
147+
148+
$secondArg = $node->args[1];
149+
$thirdArg = $node->args[2];
150+
151+
// Check if the third argument is a closure/callable
152+
// and second argument is array (the old signature)
153+
if ($thirdArg->value instanceof Closure) {
154+
// Check if second argument is an empty array - if so, just remove it
155+
$isEmptyArray = $secondArg->value instanceof Array_ && count($secondArg->value->items) === 0;
156+
157+
if ($isEmptyArray) {
158+
// Just use callback as second arg, drop the empty array
159+
$node->args = [
160+
$node->args[0],
161+
$thirdArg,
162+
];
163+
} else {
164+
// Swap: callback becomes second, params becomes third
165+
$node->args[1] = $thirdArg;
166+
$node->args[2] = $secondArg;
167+
}
168+
169+
return $node;
170+
}
171+
172+
return null;
173+
}
174+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace Cake\Upgrade\Test\TestCase\Rector\MethodCall\RouteBuilderToCallbackFirstRector\Fixture;
4+
5+
use Cake\Routing\RouteBuilder;
6+
7+
class Fixture
8+
{
9+
public function run(RouteBuilder $routes)
10+
{
11+
// Should be transformed - scope with params and callback (3 args)
12+
$routes->scope('/api', ['prefix' => 'Api'], function ($routes) {
13+
$routes->resources('Articles');
14+
});
15+
16+
// Should be transformed - prefix with empty array and callback
17+
$routes->prefix('admin', [], function ($routes) {
18+
$routes->connect('/', ['controller' => 'Dashboard']);
19+
});
20+
21+
// Should be transformed - plugin with params
22+
$routes->plugin('Blog', ['path' => '/blog'], function ($routes) {
23+
$routes->resources('Posts');
24+
});
25+
26+
// Should be transformed - resources with only options (no callback)
27+
$routes->resources('Articles', ['only' => 'index']);
28+
29+
// Should be transformed - resources with options and callback (3 args)
30+
$routes->resources('Posts', ['only' => ['index', 'view']], function ($routes) {
31+
$routes->resources('Comments');
32+
});
33+
34+
// Should be transformed - resources with empty options and callback
35+
$routes->resources('Tags', [], function ($routes) {
36+
$routes->connect('/popular', ['action' => 'popular']);
37+
});
38+
39+
// Should NOT be transformed - scope with only callback (2 args, new style)
40+
$routes->scope('/v2', function ($routes) {
41+
$routes->resources('Users');
42+
});
43+
44+
// Should NOT be transformed - prefix with only callback
45+
$routes->prefix('api', function ($routes) {
46+
$routes->connect('/', []);
47+
});
48+
49+
// Should NOT be transformed - resources with callback (3 args, but callback is second)
50+
$routes->resources('Comments', function ($routes) {
51+
$routes->resources('Replies');
52+
});
53+
}
54+
}
55+
56+
?>
57+
-----
58+
<?php
59+
60+
namespace Cake\Upgrade\Test\TestCase\Rector\MethodCall\RouteBuilderToCallbackFirstRector\Fixture;
61+
62+
use Cake\Routing\RouteBuilder;
63+
64+
class Fixture
65+
{
66+
public function run(RouteBuilder $routes)
67+
{
68+
// Should be transformed - scope with params and callback (3 args)
69+
$routes->scope('/api', function ($routes) {
70+
$routes->resources('Articles');
71+
}, ['prefix' => 'Api']);
72+
73+
// Should be transformed - prefix with empty array and callback
74+
$routes->prefix('admin', function ($routes) {
75+
$routes->connect('/', ['controller' => 'Dashboard']);
76+
});
77+
78+
// Should be transformed - plugin with params
79+
$routes->plugin('Blog', function ($routes) {
80+
$routes->resources('Posts');
81+
}, ['path' => '/blog']);
82+
83+
// Should be transformed - resources with only options (no callback)
84+
$routes->resources('Articles', null, ['only' => 'index']);
85+
86+
// Should be transformed - resources with options and callback (3 args)
87+
$routes->resources('Posts', function ($routes) {
88+
$routes->resources('Comments');
89+
}, ['only' => ['index', 'view']]);
90+
91+
// Should be transformed - resources with empty options and callback
92+
$routes->resources('Tags', function ($routes) {
93+
$routes->connect('/popular', ['action' => 'popular']);
94+
});
95+
96+
// Should NOT be transformed - scope with only callback (2 args, new style)
97+
$routes->scope('/v2', function ($routes) {
98+
$routes->resources('Users');
99+
});
100+
101+
// Should NOT be transformed - prefix with only callback
102+
$routes->prefix('api', function ($routes) {
103+
$routes->connect('/', []);
104+
});
105+
106+
// Should NOT be transformed - resources with callback (3 args, but callback is second)
107+
$routes->resources('Comments', function ($routes) {
108+
$routes->resources('Replies');
109+
});
110+
}
111+
}
112+
113+
?>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Cake\Upgrade\Test\TestCase\Rector\MethodCall\RouteBuilderToCallbackFirstRector;
5+
6+
use Iterator;
7+
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
8+
9+
final class RouteBuilderToCallbackFirstRectorTest extends AbstractRectorTestCase
10+
{
11+
/**
12+
* @dataProvider provideData()
13+
*/
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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
use Cake\Upgrade\Rector\Cake6\RouteBuilderToCallbackFirstRector;
5+
use Rector\Config\RectorConfig;
6+
7+
return static function (RectorConfig $rectorConfig): void {
8+
$rectorConfig->rule(RouteBuilderToCallbackFirstRector::class);
9+
};

0 commit comments

Comments
 (0)