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;
+ }
+}