diff --git a/composer.json b/composer.json index 19de5c3d66a..2eee7c46682 100644 --- a/composer.json +++ b/composer.json @@ -61,6 +61,7 @@ "Rector\\Sensio\\": "packages/Sensio/src", "Rector\\Sylius\\": "packages/Sylius/src", "Rector\\PHPStan\\": "packages/PHPStan/src", + "Rector\\PSR4\\": "packages/PSR4/src", "Rector\\PHPUnit\\": "packages/PHPUnit/src", "Rector\\Twig\\": "packages/Twig/src", "Rector\\TypeDeclaration\\": "packages/TypeDeclaration/src", diff --git a/config/set/psr-4/psr-4.yaml b/config/set/psr-4/psr-4.yaml new file mode 100644 index 00000000000..ed67acdc239 --- /dev/null +++ b/config/set/psr-4/psr-4.yaml @@ -0,0 +1,2 @@ +services: + Rector\PSR4\Rector\Namespace_\NormalizeNamespaceByPSR4ComposerAutoloadRector: ~ diff --git a/docs/AllRectorsOverview.md b/docs/AllRectorsOverview.md index c8d818a2706..fee242759a7 100644 --- a/docs/AllRectorsOverview.md +++ b/docs/AllRectorsOverview.md @@ -1,4 +1,4 @@ -# All 333 Rectors Overview +# All 334 Rectors Overview - [Projects](#projects) - [General](#general) @@ -24,6 +24,7 @@ - [PHPStan](#phpstan) - [PHPUnit](#phpunit) - [PHPUnitSymfony](#phpunitsymfony) +- [PSR4](#psr4) - [Php](#php) - [PhpParser](#phpparser) - [PhpSpecToPHPUnit](#phpspectophpunit) @@ -3236,6 +3237,16 @@ Add response content to response code assert, so it is easier to debug
+## PSR4 + +### `NormalizeNamespaceByPSR4ComposerAutoloadRector` + +- class: `Rector\PSR4\Rector\Namespace_\NormalizeNamespaceByPSR4ComposerAutoloadRector` + +Changes namespace and class names to match PSR-4 in composer.json autoload section + +
+ ## Php ### `AddDefaultValueForUndefinedVariableRector` diff --git a/packages/PSR4/config/config.yaml b/packages/PSR4/config/config.yaml new file mode 100644 index 00000000000..5d876ff7ae2 --- /dev/null +++ b/packages/PSR4/config/config.yaml @@ -0,0 +1,8 @@ +services: + _defaults: + public: true + autowire: true + + Rector\PSR4\: + resource: '../src' + exclude: '../src/{Rector/**/*Rector.php}' diff --git a/packages/PSR4/src/Collector/RenamedClassesCollector.php b/packages/PSR4/src/Collector/RenamedClassesCollector.php new file mode 100644 index 00000000000..604af245151 --- /dev/null +++ b/packages/PSR4/src/Collector/RenamedClassesCollector.php @@ -0,0 +1,24 @@ +renamedClasses[$oldClass] = $newClass; + } + + /** + * @return string[] + */ + public function getRenamedClasses(): array + { + return $this->renamedClasses; + } +} diff --git a/packages/PSR4/src/Composer/PSR4AutoloadPathsProvider.php b/packages/PSR4/src/Composer/PSR4AutoloadPathsProvider.php new file mode 100644 index 00000000000..a733d8ffaab --- /dev/null +++ b/packages/PSR4/src/Composer/PSR4AutoloadPathsProvider.php @@ -0,0 +1,61 @@ +cachedComposerJsonPSR4AutoloadPaths !== []) { + return $this->cachedComposerJsonPSR4AutoloadPaths; + } + + $composerJson = $this->readFileToJsonArray($this->getComposerJsonPath()); + $psr4Autoloads = array_merge( + $composerJson['autoload']['psr-4'] ?? [], + $composerJson['autoload-dev']['psr-4'] ?? [] + ); + + $this->cachedComposerJsonPSR4AutoloadPaths = $this->removeEmptyNamespaces($psr4Autoloads); + + return $this->cachedComposerJsonPSR4AutoloadPaths; + } + + private function getComposerJsonPath(): string + { + // assume the project has "composer.json" in root directory + return getcwd() . '/composer.json'; + } + + /** + * @return mixed[] + */ + private function readFileToJsonArray(string $composerJson): array + { + $composerJsonContent = FileSystem::read($composerJson); + + return Json::decode($composerJsonContent, Json::FORCE_ARRAY); + } + + /** + * @param string[] $psr4Autoloads + * @return string[] + */ + private function removeEmptyNamespaces(array $psr4Autoloads): array + { + return array_filter($psr4Autoloads, function ($value): bool { + return $value !== ''; + }, ARRAY_FILTER_USE_KEY); + } +} diff --git a/packages/PSR4/src/Extension/RenamedClassesReportExtension.php b/packages/PSR4/src/Extension/RenamedClassesReportExtension.php new file mode 100644 index 00000000000..7d82ba34669 --- /dev/null +++ b/packages/PSR4/src/Extension/RenamedClassesReportExtension.php @@ -0,0 +1,116 @@ +renamedClassesCollector = $renamedClassesCollector; + $this->symfonyStyle = $symfonyStyle; + $this->betterStandardPrinter = $betterStandardPrinter; + } + + public function run(): void + { + if ($this->renamedClassesCollector->getRenamedClasses() === []) { + return; + } + + $data = [ + # rector.yaml + 'services' => [ + RenameClassRector::class => [ + '$oldToNewClasses' => $this->renamedClassesCollector->getRenamedClasses(), + ], + ], + ]; + + // 1. + $yaml = Yaml::dump($data, 5); + FileSystem::write(getcwd() . '/rename-classes-rector.yaml', $yaml); + + $nodes = []; + + $renamedClasses = $this->sortInterfaceFirst($this->renamedClassesCollector->getRenamedClasses()); + foreach ($renamedClasses as $oldClass => $newClass) { + $classAlias = new FuncCall(new Name('class_alias')); + $classAlias->args[] = new Arg(new String_($newClass)); + $classAlias->args[] = new Arg(new String_($oldClass)); + + $nodes[] = new Expression($classAlias); + } + + // 2. + $aliasesContent = 'betterStandardPrinter->print($nodes); + FileSystem::write(getcwd() . '/rename-classes-aliases.php', $aliasesContent); + + // @todo shell exec!? + $this->symfonyStyle->warning( + 'Run: "vendor/bin/rector process tests --config rename-classes-rector.yaml --autoload-file rename-classes-aliases.php" to finish the process' + ); + } + + /** + * Interfaces needs to be aliased first, as aliased class needs to have an aliased interface that implements. + * PHP will crash with not very helpful "missing interface" error otherwise. + * + * Also abstract classes and traits, as other code depens on them. + * + * @param string[] $types + * @return string[] + */ + private function sortInterfaceFirst(array $types): array + { + $interfaces = []; + $abstractClasses = []; + $traits = []; + $classes = []; + + foreach ($types as $oldType => $newType) { + if (Strings::endsWith($oldType, 'Interface')) { + $interfaces[$oldType] = $newType; + } elseif (Strings::contains($oldType, 'Abstract')) { + $abstractClasses[$oldType] = $newType; + } elseif (Strings::contains($oldType, 'Trait')) { + $traits[$oldType] = $newType; + } else { + $classes[$oldType] = $newType; + } + } + + return array_merge($interfaces, $traits, $abstractClasses, $classes); + } +} diff --git a/packages/PSR4/src/Rector/Namespace_/NormalizeNamespaceByPSR4ComposerAutoloadRector.php b/packages/PSR4/src/Rector/Namespace_/NormalizeNamespaceByPSR4ComposerAutoloadRector.php new file mode 100644 index 00000000000..b335bc92c7f --- /dev/null +++ b/packages/PSR4/src/Rector/Namespace_/NormalizeNamespaceByPSR4ComposerAutoloadRector.php @@ -0,0 +1,153 @@ +psR4AutoloadPathsProvider = $psR4AutoloadPathsProvider; + $this->renamedClassesCollector = $renamedClassesCollector; + } + + public function getDefinition(): RectorDefinition + { + return new RectorDefinition( + 'Changes namespace and class names to match PSR-4 in composer.json autoload section' + ); + } + + /** + * @return string[] + */ + public function getNodeTypes(): array + { + return [Namespace_::class]; + } + + /** + * @param Namespace_ $node + */ + public function refactor(Node $node): ?Node + { + $expectedNamespace = $this->getExpectedNamespace($node); + if ($expectedNamespace === null) { + return null; + } + + $currentNamespace = $this->getName($node); + + // namespace is correct → skip + if ($currentNamespace === $expectedNamespace) { + return null; + } + + // change it + $node->name = new Name($expectedNamespace); + + // add use import for classes from the same namespace + $newUseImports = []; + $this->traverseNodesWithCallable($node, function (Node $node) use ($currentNamespace, &$newUseImports) { + if (! $node instanceof Name) { + return null; + } + + /** @var Name|null $originalName */ + $originalName = $node->getAttribute('originalName'); + if ($originalName instanceof Name) { + if ($currentNamespace . '\\' . $originalName->toString() === $this->getName($node)) { + // this needs to be imported + $newUseImports[] = $this->getName($node); + } + } + }); + + $newUseImports = array_unique($newUseImports); + + if ($newUseImports) { + $useImports = $this->createUses($newUseImports); + $node->stmts = array_merge($useImports, $node->stmts); + } + + /** @var SmartFileInfo $smartFileInfo */ + $smartFileInfo = $node->getAttribute(AttributeKey::FILE_INFO); + $oldClassName = $currentNamespace . '\\' . $smartFileInfo->getBasenameWithoutSuffix(); + $newClassName = $expectedNamespace . '\\' . $smartFileInfo->getBasenameWithoutSuffix(); + + $this->renamedClassesCollector->addClassRename($oldClassName, $newClassName); + + // collect changed class + return $node; + } + + private function getExpectedNamespace(Node $node): ?string + { + /** @var SmartFileInfo $smartFileInfo */ + $smartFileInfo = $node->getAttribute(AttributeKey::FILE_INFO); + + $psr4Autoloads = $this->psR4AutoloadPathsProvider->provide(); + + foreach ($psr4Autoloads as $namespace => $path) { + if (Strings::startsWith($smartFileInfo->getRelativeDirectoryPath(), $path)) { + return $namespace . $this->resolveExtraNamespace($smartFileInfo, $path); + } + } + + return null; + } + + /** + * Get the extra path that is not included in root PSR-4 namespace + */ + private function resolveExtraNamespace(SmartFileInfo $smartFileInfo, string $path): string + { + $extraNamespace = Strings::substring($smartFileInfo->getRelativeDirectoryPath(), Strings::length($path) + 1); + $extraNamespace = Strings::replace($extraNamespace, '#/#', '\\'); + + return trim($extraNamespace); + } + + /** + * @param string[] $newUseImports + * @return Use_[] + */ + private function createUses(array $newUseImports): array + { + $uses = []; + + foreach ($newUseImports as $newUseImport) { + $useUse = new UseUse(new Name($newUseImport)); + $uses[] = new Use_([$useUse]); + } + + return $uses; + } +}