From f808f2499006c7908dcd5f4b85fea902a0e10c18 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 26 Nov 2025 21:16:15 +0100 Subject: [PATCH] Add RouteBuilderToCallbackFirstRector for CakePHP 6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This rector rule transforms RouteBuilder method calls from the old argument order (params/options second, callback third) to the new order (callback second, params/options third). Affected methods: - scope(path, callback, params) - prefix(name, callback, params) - plugin(name, callback, params) - resources(name, callback, options) Transformations: - 3 args with array second, closure third: swap to callback second - 3 args with empty array second: remove the empty array - resources with 2 args (name + options): insert null as callback Also adds: - CakePHPSetList::CAKEPHP_60 constant - cakephp60.php set file - Full test coverage with fixtures Refs cakephp/cakephp#19095 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- config/rector/sets/cakephp60.php | 10 ++ .../RouteBuilderToCallbackFirstRector.php | 138 ++++++++++++++++++ src/Rector/Set/CakePHPSetList.php | 5 + .../Fixture/fixture.php.inc | 129 ++++++++++++++++ .../Fixture/skip_non_routebuilder.php.inc | 29 ++++ .../RouteBuilderToCallbackFirstRectorTest.php | 28 ++++ .../config/configured_rule.php | 9 ++ 7 files changed, 348 insertions(+) create mode 100644 config/rector/sets/cakephp60.php create mode 100644 src/Rector/Rector/MethodCall/RouteBuilderToCallbackFirstRector.php create mode 100644 tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/Fixture/fixture.php.inc create mode 100644 tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/Fixture/skip_non_routebuilder.php.inc create mode 100644 tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/RouteBuilderToCallbackFirstRectorTest.php create mode 100644 tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/config/configured_rule.php diff --git a/config/rector/sets/cakephp60.php b/config/rector/sets/cakephp60.php new file mode 100644 index 0000000..95ad878 --- /dev/null +++ b/config/rector/sets/cakephp60.php @@ -0,0 +1,10 @@ +rule(RouteBuilderToCallbackFirstRector::class); +}; diff --git a/src/Rector/Rector/MethodCall/RouteBuilderToCallbackFirstRector.php b/src/Rector/Rector/MethodCall/RouteBuilderToCallbackFirstRector.php new file mode 100644 index 0000000..f6045e4 --- /dev/null +++ b/src/Rector/Rector/MethodCall/RouteBuilderToCallbackFirstRector.php @@ -0,0 +1,138 @@ +scope('/api', ['prefix' => 'Api'], function ($routes) { + $routes->resources('Articles'); +}); +$routes->prefix('admin', [], function ($routes) { + $routes->connect('/', ['controller' => 'Dashboard']); +}); +$routes->resources('Articles', ['only' => 'index']); +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']); +CODE_SAMPLE + ), + ] + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [MethodCall::class]; + } + + /** + * @param MethodCall $node + */ + public function refactor(Node $node): ?Node + { + if (!$this->isObjectType($node->var, new \PHPStan\Type\ObjectType('Cake\Routing\RouteBuilder'))) { + return null; + } + + $methodName = $this->getName($node->name); + if (!in_array($methodName, self::METHODS, true)) { + return null; + } + + $args = $node->getArgs(); + $argCount = count($args); + + // Handle resources() special case: 2 args with options array (no callback) + // resources('Name', ['only' => 'index']) -> resources('Name', null, ['only' => 'index']) + if ($methodName === 'resources' && $argCount === 2) { + $secondArg = $args[1]->value; + if ($secondArg instanceof Array_ && !$this->isEmptyArray($secondArg)) { + $node->args = [ + $args[0], + new Arg(new ConstFetch(new Name('null'))), + $args[1], + ]; + + return $node; + } + } + + // Need exactly 3 arguments for swap + if ($argCount !== 3) { + return null; + } + + $secondArg = $args[1]->value; + $thirdArg = $args[2]->value; + + // Check if second arg is array and third is closure (old style) + if (!$secondArg instanceof Array_) { + return null; + } + + if (!$thirdArg instanceof Closure) { + return null; + } + + // If array is empty, just remove it (callback becomes second arg) + if ($this->isEmptyArray($secondArg)) { + $node->args = [ + $args[0], + $args[2], + ]; + + return $node; + } + + // Swap: callback second, array third + $node->args = [ + $args[0], + $args[2], + $args[1], + ]; + + return $node; + } + + private function isEmptyArray(Array_ $array): bool + { + return $array->items === []; + } +} diff --git a/src/Rector/Set/CakePHPSetList.php b/src/Rector/Set/CakePHPSetList.php index 6a0b0fc..3e0365d 100644 --- a/src/Rector/Set/CakePHPSetList.php +++ b/src/Rector/Set/CakePHPSetList.php @@ -85,6 +85,11 @@ final class CakePHPSetList */ public const CAKEPHP_53 = __DIR__ . '/../../../config/rector/sets/cakephp53.php'; + /** + * @var string + */ + public const CAKEPHP_60 = __DIR__ . '/../../../config/rector/sets/cakephp60.php'; + /** * @var string */ 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..95e76b0 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/Fixture/fixture.php.inc @@ -0,0 +1,129 @@ +scope('/api', ['prefix' => 'Api'], function (RouteBuilder $routes): void { + $routes->connect('/articles', ['controller' => 'Articles']); + }); + + // scope with empty array - should remove empty array + $routes->scope('/admin', [], function (RouteBuilder $routes): void { + $routes->connect('/dashboard', ['controller' => 'Dashboard']); + }); + + // prefix with params - should swap + $routes->prefix('admin', ['_namePrefix' => 'admin:'], function (RouteBuilder $routes): void { + $routes->connect('/users', ['controller' => 'Users']); + }); + + // prefix with empty array - should remove + $routes->prefix('api', [], function (RouteBuilder $routes): void { + $routes->connect('/status', ['controller' => 'Status']); + }); + + // plugin with params - should swap + $routes->plugin('Blog', ['path' => '/blog'], function (RouteBuilder $routes): void { + $routes->connect('/posts', ['controller' => 'Posts']); + }); + + // plugin with empty array - should remove + $routes->plugin('Forum', [], function (RouteBuilder $routes): void { + $routes->connect('/threads', ['controller' => 'Threads']); + }); + + // resources with options only (no callback) - should insert null + $routes->resources('Articles', ['only' => 'index']); + + // resources with callback and options - should swap + $routes->resources('Comments', ['only' => ['index', 'view']], function (RouteBuilder $routes): void { + $routes->resources('Replies'); + }); + + // resources with empty options and callback - should remove empty array + $routes->resources('Tags', [], function (RouteBuilder $routes): void { + $routes->connect('/popular', ['action' => 'popular']); + }); + + // Should NOT transform: already new style (callback second) + $routes->scope('/new', function (RouteBuilder $routes): void { + $routes->connect('/test', ['controller' => 'Test']); + }); + + // Should NOT transform: resources with just name + $routes->resources('Users'); + } +} + +?> +----- +scope('/api', function (RouteBuilder $routes): void { + $routes->connect('/articles', ['controller' => 'Articles']); + }, ['prefix' => 'Api']); + + // scope with empty array - should remove empty array + $routes->scope('/admin', function (RouteBuilder $routes): void { + $routes->connect('/dashboard', ['controller' => 'Dashboard']); + }); + + // prefix with params - should swap + $routes->prefix('admin', function (RouteBuilder $routes): void { + $routes->connect('/users', ['controller' => 'Users']); + }, ['_namePrefix' => 'admin:']); + + // prefix with empty array - should remove + $routes->prefix('api', function (RouteBuilder $routes): void { + $routes->connect('/status', ['controller' => 'Status']); + }); + + // plugin with params - should swap + $routes->plugin('Blog', function (RouteBuilder $routes): void { + $routes->connect('/posts', ['controller' => 'Posts']); + }, ['path' => '/blog']); + + // plugin with empty array - should remove + $routes->plugin('Forum', function (RouteBuilder $routes): void { + $routes->connect('/threads', ['controller' => 'Threads']); + }); + + // resources with options only (no callback) - should insert null + $routes->resources('Articles', null, ['only' => 'index']); + + // resources with callback and options - should swap + $routes->resources('Comments', function (RouteBuilder $routes): void { + $routes->resources('Replies'); + }, ['only' => ['index', 'view']]); + + // resources with empty options and callback - should remove empty array + $routes->resources('Tags', function (RouteBuilder $routes): void { + $routes->connect('/popular', ['action' => 'popular']); + }); + + // Should NOT transform: already new style (callback second) + $routes->scope('/new', function (RouteBuilder $routes): void { + $routes->connect('/test', ['controller' => 'Test']); + }); + + // Should NOT transform: resources with just name + $routes->resources('Users'); + } +} + +?> diff --git a/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/Fixture/skip_non_routebuilder.php.inc b/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/Fixture/skip_non_routebuilder.php.inc new file mode 100644 index 0000000..fa15a72 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/Fixture/skip_non_routebuilder.php.inc @@ -0,0 +1,29 @@ +scope('/path', ['key' => 'value'], function () { + }); + $other->prefix('admin', [], function () { + }); + } +} + +?> 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..47b2868 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/RouteBuilderToCallbackFirstRector/config/configured_rule.php @@ -0,0 +1,9 @@ +rule(RouteBuilderToCallbackFirstRector::class); +};