diff --git a/config/rector/sets/cakephp60.php b/config/rector/sets/cakephp60.php index 99e0e38..e194966 100644 --- a/config/rector/sets/cakephp60.php +++ b/config/rector/sets/cakephp60.php @@ -3,6 +3,7 @@ use Cake\Upgrade\Rector\Cake6\EventManagerOnRector; use Cake\Upgrade\Rector\Cake6\ReplaceCommandArgsIoWithPropertiesRector; +use Cake\Upgrade\Rector\Cake6\RouteBuilderToCallbackFirstRector; use PHPStan\Type\ObjectType; use Rector\Config\RectorConfig; use Rector\Renaming\Rector\MethodCall\RenameMethodRector; @@ -23,6 +24,9 @@ $rectorConfig->rule(ReplaceCommandArgsIoWithPropertiesRector::class); + // RouteBuilder argument reordering + $rectorConfig->rule(RouteBuilderToCallbackFirstRector::class); + // Changes related to the accessible => patchable rename $rectorConfig->ruleWithConfiguration(RenameMethodRector::class, [ new MethodCallRename('Cake\ORM\Entity', 'setAccess', 'setPatchable'), diff --git a/src/Rector/Cake6/RouteBuilderToCallbackFirstRector.php b/src/Rector/Cake6/RouteBuilderToCallbackFirstRector.php new file mode 100644 index 0000000..71a3340 --- /dev/null +++ b/src/Rector/Cake6/RouteBuilderToCallbackFirstRector.php @@ -0,0 +1,174 @@ +scope('/api', ['prefix' => 'Api'], function ($routes) { + $routes->resources('Articles'); +}); +$routes->prefix('admin', [], function ($routes) { + $routes->connect('/', ['controller' => 'Dashboard']); +}); +$routes->resources('Articles', ['only' => 'index']); +$routes->resources('Posts', ['only' => 'index'], function ($routes) { + $routes->resources('Comments'); +}); +CODE_SAMPLE + , + <<<'CODE_SAMPLE' +$routes->scope('/api', function ($routes) { + $routes->resources('Articles'); +}, ['prefix' => 'Api']); +$routes->prefix('admin', function ($routes) { + $routes->connect('/', ['controller' => 'Dashboard']); +}); +$routes->resources('Articles', null, ['only' => 'index']); +$routes->resources('Posts', function ($routes) { + $routes->resources('Comments'); +}, ['only' => 'index']); +CODE_SAMPLE, + ), + ], + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [MethodCall::class]; + } + + /** + * @param \PhpParser\Node\Expr\MethodCall $node + */ + public function refactor(Node $node): ?Node + { + // Check if the object is a RouteBuilder + if (!$this->isObjectType($node->var, new ObjectType('Cake\Routing\RouteBuilder'))) { + return null; + } + + // Check if this is one of our target methods + if (!$this->isNames($node->name, self::METHODS)) { + return null; + } + + $methodName = $this->getName($node->name); + $argCount = count($node->args); + + // For resources method + // Old signature: resources(string $name, array $options = [], ?callable $callback = null) + // New signature: resources(string $name, ?Closure $callback = null, array $options = []) + if ($methodName === 'resources') { + // resources('Articles', ['only' => 'index']) -> resources('Articles', null, ['only' => 'index']) + if ($argCount === 2) { + $secondArg = $node->args[1]; + // Check if second arg is an array (options) + if ($secondArg->value instanceof Array_) { + // Insert null as second argument, move array to third + $node->args = [ + $node->args[0], + new Arg(new ConstFetch(new Name('null'))), + $secondArg, + ]; + + return $node; + } + } + + // resources('Articles', ['only' => 'index'], fn) -> resources('Articles', fn, ['only' => 'index']) + if ($argCount === 3) { + $secondArg = $node->args[1]; + $thirdArg = $node->args[2]; + + // Check if third arg is closure and second is array + if ($thirdArg->value instanceof Closure && $secondArg->value instanceof Array_) { + // Check if second argument is an empty array - if so, just remove it + $isEmptyArray = count($secondArg->value->items) === 0; + + if ($isEmptyArray) { + $node->args = [ + $node->args[0], + $thirdArg, + ]; + } else { + // Swap: callback becomes second, options becomes third + $node->args[1] = $thirdArg; + $node->args[2] = $secondArg; + } + + return $node; + } + } + + return null; + } + + // For scope/prefix/plugin methods + // Old signature: method(string $path, array|callable $params, ?callable $callback = null) + // New signature: method(string $path, Closure $callback, array $params = []) + + // Only process if there are exactly 3 arguments + if ($argCount !== 3) { + return null; + } + + $secondArg = $node->args[1]; + $thirdArg = $node->args[2]; + + // Check if the third argument is a closure/callable + // and second argument is array (the old signature) + if ($thirdArg->value instanceof Closure) { + // Check if second argument is an empty array - if so, just remove it + $isEmptyArray = $secondArg->value instanceof Array_ && count($secondArg->value->items) === 0; + + if ($isEmptyArray) { + // Just use callback as second arg, drop the empty array + $node->args = [ + $node->args[0], + $thirdArg, + ]; + } else { + // Swap: callback becomes second, params becomes third + $node->args[1] = $thirdArg; + $node->args[2] = $secondArg; + } + + return $node; + } + + return null; + } +} diff --git a/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/Fixture/fixture.php.inc b/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/Fixture/fixture.php.inc new file mode 100644 index 0000000..8282fa7 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/Fixture/fixture.php.inc @@ -0,0 +1,113 @@ +scope('/api', ['prefix' => 'Api'], function ($routes) { + $routes->resources('Articles'); + }); + + // Should be transformed - prefix with empty array and callback + $routes->prefix('admin', [], function ($routes) { + $routes->connect('/', ['controller' => 'Dashboard']); + }); + + // Should be transformed - plugin with params + $routes->plugin('Blog', ['path' => '/blog'], function ($routes) { + $routes->resources('Posts'); + }); + + // Should be transformed - resources with only options (no callback) + $routes->resources('Articles', ['only' => 'index']); + + // Should be transformed - resources with options and callback (3 args) + $routes->resources('Posts', ['only' => ['index', 'view']], function ($routes) { + $routes->resources('Comments'); + }); + + // Should be transformed - resources with empty options and callback + $routes->resources('Tags', [], function ($routes) { + $routes->connect('/popular', ['action' => 'popular']); + }); + + // Should NOT be transformed - scope with only callback (2 args, new style) + $routes->scope('/v2', function ($routes) { + $routes->resources('Users'); + }); + + // Should NOT be transformed - prefix with only callback + $routes->prefix('api', function ($routes) { + $routes->connect('/', []); + }); + + // Should NOT be transformed - resources with callback (3 args, but callback is second) + $routes->resources('Comments', function ($routes) { + $routes->resources('Replies'); + }); + } +} + +?> +----- +scope('/api', function ($routes) { + $routes->resources('Articles'); + }, ['prefix' => 'Api']); + + // Should be transformed - prefix with empty array and callback + $routes->prefix('admin', function ($routes) { + $routes->connect('/', ['controller' => 'Dashboard']); + }); + + // Should be transformed - plugin with params + $routes->plugin('Blog', function ($routes) { + $routes->resources('Posts'); + }, ['path' => '/blog']); + + // Should be transformed - resources with only options (no callback) + $routes->resources('Articles', null, ['only' => 'index']); + + // Should be transformed - resources with options and callback (3 args) + $routes->resources('Posts', function ($routes) { + $routes->resources('Comments'); + }, ['only' => ['index', 'view']]); + + // Should be transformed - resources with empty options and callback + $routes->resources('Tags', function ($routes) { + $routes->connect('/popular', ['action' => 'popular']); + }); + + // Should NOT be transformed - scope with only callback (2 args, new style) + $routes->scope('/v2', function ($routes) { + $routes->resources('Users'); + }); + + // Should NOT be transformed - prefix with only callback + $routes->prefix('api', function ($routes) { + $routes->connect('/', []); + }); + + // Should NOT be transformed - resources with callback (3 args, but callback is second) + $routes->resources('Comments', function ($routes) { + $routes->resources('Replies'); + }); + } +} + +?> diff --git a/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/RouteBuilderToCallbackFirstRectorTest.php b/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/RouteBuilderToCallbackFirstRectorTest.php new file mode 100644 index 0000000..dbe33e1 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/RouteBuilderToCallbackFirstRectorTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/config/configured_rule.php b/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/config/configured_rule.php new file mode 100644 index 0000000..f4b1569 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/config/configured_rule.php @@ -0,0 +1,9 @@ +rule(RouteBuilderToCallbackFirstRector::class); +};