diff --git a/config/rector/sets/cakephp53.php b/config/rector/sets/cakephp53.php index 83c1c5d5..c8d9a539 100644 --- a/config/rector/sets/cakephp53.php +++ b/config/rector/sets/cakephp53.php @@ -6,6 +6,7 @@ use Cake\Upgrade\Rector\Rector\MethodCall\EntityPatchRector; use Cake\Upgrade\Rector\Rector\MethodCall\NewExprToFuncRector; use Cake\Upgrade\Rector\Rector\MethodCall\QueryParamAccessRector; +use Cake\Upgrade\Rector\Rector\MethodCall\RouteBuilderCleanupRector; use Rector\Config\RectorConfig; use Rector\Renaming\Rector\MethodCall\RenameMethodRector; use Rector\Renaming\Rector\Name\RenameClassRector; @@ -15,6 +16,10 @@ return static function (RectorConfig $rectorConfig): void { // Apply newExpr()->count() -> func()->count('*') transformation before general newExpr rename $rectorConfig->rule(NewExprToFuncRector::class); + $rectorConfig->rule(EntityIsEmptyRector::class); + $rectorConfig->rule(EntityPatchRector::class); + $rectorConfig->rule(FormExecuteToProcessRector::class); + $rectorConfig->rule(QueryParamAccessRector::class); $rectorConfig->ruleWithConfiguration(RenameMethodRector::class, [ new MethodCallRename('Cake\Database\Query', 'newExpr', 'expr'), @@ -24,8 +29,12 @@ 'Cake\TestSuite\Fixture\TransactionFixtureStrategy' => 'Cake\TestSuite\Fixture\TransactionStrategy', 'Cake\TestSuite\Fixture\TruncateFixtureStrategy' => 'Cake\TestSuite\Fixture\TruncateStrategy', ]); - $rectorConfig->rule(EntityIsEmptyRector::class); - $rectorConfig->rule(EntityPatchRector::class); - $rectorConfig->rule(FormExecuteToProcessRector::class); - $rectorConfig->rule(QueryParamAccessRector::class); + $rectorConfig->ruleWithConfiguration(RouteBuilderCleanupRector::class, [ + 'methods' => [ + 'scope' => ['path', 'params', 'callback'], + 'resources' => ['name', 'options', 'callback'], + 'prefix' => ['name', 'params', 'callback'], + 'plugin' => ['name', 'options', 'callback'], + ], + ]); }; diff --git a/src/Rector/Rector/MethodCall/RouteBuilderCleanupRector.php b/src/Rector/Rector/MethodCall/RouteBuilderCleanupRector.php new file mode 100644 index 00000000..0c2b47b2 --- /dev/null +++ b/src/Rector/Rector/MethodCall/RouteBuilderCleanupRector.php @@ -0,0 +1,147 @@ + + * e.g. ['scope' => ['path', 'options', 'callback']] + */ + private array $methods = []; + + public function configure(array $configuration): void + { + $this->methods = $configuration['methods'] ?? []; + } + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition('Normalize RouteBuilder calls to always use named arguments based on configuration', [ + new CodeSample( + <<<'CODE_SAMPLE' +$routes->scope('/api', function (RouteBuilder $routes): void {}); +CODE_SAMPLE, + <<<'CODE_SAMPLE' +$routes->scope(path: '/api', options: [], callback: function (RouteBuilder $routes): void {}); +CODE_SAMPLE, + ), + ]); + } + + public function getNodeTypes(): array + { + return [MethodCall::class]; + } + + public function refactor(Node $node): ?Node + { + if (! $node instanceof MethodCall) { + return null; + } + + $methodName = $this->getName($node->name); + if (! isset($this->methods[$methodName])) { + return null; + } + + // Must be called on a Cake\Routing\RouteBuilder + $callerType = $this->getType($node->var); + if (! (new ObjectType(RouteBuilder::class))->isSuperTypeOf($callerType)->yes()) { + return null; + } + + $argNames = $this->methods[$methodName]; + $args = $node->args; + + $pathValue = $args[0]->value ?? null; + $optionsValue = null; + $callbackValue = null; + + // Handle case where 2nd param is callable or array + if (isset($args[1])) { + if ($this->isCallableNode($args[1]->value)) { + // Case: scope('/api', fn() => null) + $callbackValue = $args[1]->value; + } else { + // Case: scope('/api', [], fn() => null) + $optionsValue = $args[1]->value; + $callbackValue = $args[2]->value ?? null; + } + } + + if ($callbackValue === null) { + // no callable = no change + return null; + } + + $newArgs = []; + + // always add first argument (path/name) without a named argument + if (isset($argNames[0]) && $pathValue !== null) { + $newArgs[] = new Arg( + $pathValue, + false, + false, + [], + ); + } + + // only add options if it existed in original call + if (isset($argNames[1]) && $optionsValue !== null) { + $newArgs[] = new Arg( + $optionsValue, + false, + false, + [], + new Identifier($argNames[1]), + ); + } elseif ($methodName === 'scope') { + // scope() must always have options + $newArgs[] = new Arg( + $optionsValue ?? $this->nodeFactory->createArray([]), + false, + false, + [], + new Identifier($argNames[1]), + ); + } + + // always add callback if present + if (isset($argNames[2])) { + $newArgs[] = new Arg( + $callbackValue, + false, + false, + [], + new Identifier($argNames[2]), + ); + } + + $node->args = $newArgs; + + return $node; + } + + private function isCallableNode(Node $node): bool + { + return $node instanceof Closure + || $node instanceof ArrowFunction + || $node instanceof FuncCall; + } +} diff --git a/tests/test_apps/original/RectorCommand-testApply53/src/SomeTest.php b/tests/test_apps/original/RectorCommand-testApply53/src/SomeTest.php index e3d9c78b..95da3b04 100644 --- a/tests/test_apps/original/RectorCommand-testApply53/src/SomeTest.php +++ b/tests/test_apps/original/RectorCommand-testApply53/src/SomeTest.php @@ -6,6 +6,8 @@ use Cake\ORM\Entity; use Cake\ORM\Locator\LocatorAwareTrait; use Cake\ORM\Query; +use Cake\Routing\RouteBuilder; +use Cake\Routing\RouteCollection; class SomeTest { @@ -27,4 +29,21 @@ public function testRenames(): void public function findSomething(Query $query, array $options): Query { return $query; } + + public function routes(): void + { + $routes = new RouteBuilder(new RouteCollection(), '/'); + + $routes->scope('/api', function ($routes): void {}); + $routes->scope('/api', ['something'], function ($routes): void {}); + + $routes->resources('/api', function ($routes): void {}); + $routes->resources('/api', ['something'], function ($routes): void {}); + + $routes->prefix('/api', function ($routes): void {}); + $routes->prefix('/api', ['something'], function ($routes): void {}); + + $routes->plugin('/api', function ($routes): void {}); + $routes->plugin('/api', ['something'], function ($routes): void {}); + } } diff --git a/tests/test_apps/upgraded/RectorCommand-testApply53/src/SomeTest.php b/tests/test_apps/upgraded/RectorCommand-testApply53/src/SomeTest.php index 304c6187..dcbd608b 100644 --- a/tests/test_apps/upgraded/RectorCommand-testApply53/src/SomeTest.php +++ b/tests/test_apps/upgraded/RectorCommand-testApply53/src/SomeTest.php @@ -6,6 +6,8 @@ use Cake\ORM\Entity; use Cake\ORM\Locator\LocatorAwareTrait; use Cake\ORM\Query; +use Cake\Routing\RouteBuilder; +use Cake\Routing\RouteCollection; class SomeTest { @@ -27,4 +29,21 @@ public function testRenames(): void public function findSomething(\Cake\ORM\Query\SelectQuery $query, array $options): \Cake\ORM\Query\SelectQuery { return $query; } + + public function routes(): void + { + $routes = new RouteBuilder(new RouteCollection(), '/'); + + $routes->scope('/api', params: [], callback: function ($routes): void {}); + $routes->scope('/api', params: ['something'], callback: function ($routes): void {}); + + $routes->resources('/api', callback: function ($routes): void {}); + $routes->resources('/api', options: ['something'], callback: function ($routes): void {}); + + $routes->prefix('/api', callback: function ($routes): void {}); + $routes->prefix('/api', params: ['something'], callback: function ($routes): void {}); + + $routes->plugin('/api', callback: function ($routes): void {}); + $routes->plugin('/api', options: ['something'], callback: function ($routes): void {}); + } }