diff --git a/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Fixture/skip_constant_compare_from_vendor.php.inc b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Fixture/skip_constant_compare_from_vendor.php.inc new file mode 100644 index 00000000000..9cd3ad8e7ea --- /dev/null +++ b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Fixture/skip_constant_compare_from_vendor.php.inc @@ -0,0 +1,16 @@ + $level] + : new LogRecord(); + } +} diff --git a/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Source/composer.json b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Source/composer.json new file mode 100644 index 00000000000..9c2eb41b878 --- /dev/null +++ b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/Source/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "monolog/monolog": "^2.0 || ^3.0" + } +} diff --git a/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/config/configured_rule.php b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/config/configured_rule.php index d869bb77c80..e64ea99b6e6 100644 --- a/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/config/configured_rule.php +++ b/rules-tests/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector/config/configured_rule.php @@ -2,11 +2,18 @@ declare(strict_types=1); +use Rector\Composer\ComposerJsonPackageVersionResolver; use Rector\Config\RectorConfig; use Rector\DeadCode\Rector\FunctionLike\NarrowWideUnionReturnTypeRector; use Rector\ValueObject\PhpVersionFeature; return static function (RectorConfig $rectorConfig): void { + $rectorConfig->singleton( + ComposerJsonPackageVersionResolver::class, + static fn (): ComposerJsonPackageVersionResolver => new ComposerJsonPackageVersionResolver( + __DIR__ . '/../Source/composer.json' + ) + ); $rectorConfig->rule(NarrowWideUnionReturnTypeRector::class); $rectorConfig->phpVersion(PhpVersionFeature::UNION_TYPES); }; diff --git a/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php b/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php index a7a11b1a215..99ffb859742 100644 --- a/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php +++ b/rules/DeadCode/Rector/FunctionLike/NarrowWideUnionReturnTypeRector.php @@ -7,8 +7,10 @@ use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrowFunction; +use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Expr\Ternary; use PhpParser\Node\FunctionLike; use PhpParser\Node\Name; use PhpParser\Node\NullableType; @@ -19,12 +21,14 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; use PHPStan\Reflection\ClassReflection; use PHPStan\Type\NullType; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType as PHPStanUnionType; use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfo; use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfoFactory; use Rector\BetterPhpDocParser\PhpDocManipulator\PhpDocTypeChanger; +use Rector\Composer\ComposerJsonPackageVersionResolver; use Rector\NodeTypeResolver\PHPStan\Type\TypeFactory; use Rector\PhpParser\Node\BetterNodeFinder; use Rector\PHPStanStaticTypeMapper\Enum\TypeKind; @@ -49,7 +53,8 @@ public function __construct( private readonly SilentVoidResolver $silentVoidResolver, private readonly PhpDocTypeChanger $phpDocTypeChanger, private readonly PhpDocInfoFactory $phpDocInfoFactory, - private readonly TypeFactory $typeFactory + private readonly TypeFactory $typeFactory, + private readonly ComposerJsonPackageVersionResolver $composerJsonPackageVersionResolver ) { } @@ -255,12 +260,54 @@ private function collectActualReturnTypes(array $returnStatements): array continue; } - $returnTypes[] = $this->nodeTypeResolver->getNativeType($returnStatement->expr); + $returnTypes = [...$returnTypes, ...$this->resolveNativeReturnTypes($returnStatement->expr)]; } return $returnTypes; } + /** + * @return Type[] + */ + private function resolveNativeReturnTypes(Expr $expr): array + { + if (! $expr instanceof Ternary || ! $this->hasVendorClassConstFetch($expr->cond)) { + return [$this->nodeTypeResolver->getNativeType($expr)]; + } + + $ifExpr = $expr->if instanceof Expr ? $expr->if : $expr->cond; + + return [...$this->resolveNativeReturnTypes($ifExpr), ...$this->resolveNativeReturnTypes($expr->else)]; + } + + private function hasVendorClassConstFetch(Expr $expr): bool + { + $classConstFetches = $this->betterNodeFinder->findInstanceOf($expr, ClassConstFetch::class); + + foreach ($classConstFetches as $classConstFetch) { + $classType = $this->nodeTypeResolver->getType($classConstFetch->class); + if (! $classType instanceof ObjectType) { + continue; + } + + $classReflection = $classType->getClassReflection(); + if (! $classReflection instanceof ClassReflection) { + continue; + } + + $fileName = $classReflection->getFileName(); + if ($fileName === null) { + continue; + } + + if ($this->composerJsonPackageVersionResolver->hasPackageMultiMajorVersions($fileName)) { + return true; + } + } + + return false; + } + /** * @param Type[] $actualReturnTypes * @return Type[] diff --git a/src/Composer/ComposerJsonPackageVersionResolver.php b/src/Composer/ComposerJsonPackageVersionResolver.php new file mode 100644 index 00000000000..5bc5556e4c2 --- /dev/null +++ b/src/Composer/ComposerJsonPackageVersionResolver.php @@ -0,0 +1,158 @@ + + */ + private ?array $packageVersionConstraints = null; + + /** + * @var array + */ + private array $packageMultiMajorVersions = []; + + /** + * @var array + */ + private array $packageNamesByFilePath = []; + + private readonly string $composerJsonFilePath; + + public function __construct(?string $composerJsonFilePath = null) + { + $this->composerJsonFilePath = $composerJsonFilePath ?? getcwd() . '/composer.json'; + } + + public function hasPackageMultiMajorVersions(string $filePath): bool + { + $packageName = $this->resolvePackageName($filePath); + if ($packageName === null) { + return false; + } + + return $this->packageMultiMajorVersions[$packageName] ??= $this->resolveHasPackageMultiMajorVersions( + $packageName + ); + } + + private function resolveHasPackageMultiMajorVersions(string $packageName): bool + { + $versionConstraint = $this->resolvePackageVersionConstraints()[$packageName] ?? null; + if ($versionConstraint === null) { + return false; + } + + $versionConstraintParts = preg_split('#\s*\|\|?\s*#', $versionConstraint); + if ($versionConstraintParts === false || count($versionConstraintParts) < 2) { + return false; + } + + $majorVersions = []; + $versionParser = new VersionParser(); + + foreach ($versionConstraintParts as $versionConstraintPart) { + if (preg_match('#\d+#', $versionConstraintPart) !== 1) { + continue; + } + + try { + $constraint = $versionParser->parseConstraints($versionConstraintPart); + } catch (UnexpectedValueException) { + continue; + } + + $lowerMajorVersion = $this->resolveMajorVersion($constraint->getLowerBound()->getVersion()); + if ($lowerMajorVersion === null) { + continue; + } + + $majorVersions[$lowerMajorVersion] = true; + } + + return count($majorVersions) > 1; + } + + private function resolvePackageName(string $filePath): ?string + { + if (array_key_exists($filePath, $this->packageNamesByFilePath)) { + return $this->packageNamesByFilePath[$filePath]; + } + + $normalizedFilePath = PathNormalizer::normalize($filePath); + $vendorPosition = strpos($normalizedFilePath, '/vendor/'); + + if ($vendorPosition === false) { + return $this->packageNamesByFilePath[$filePath] = null; + } + + $vendorRelativePath = substr($normalizedFilePath, $vendorPosition + strlen('/vendor/')); + $pathParts = explode('/', $vendorRelativePath); + + if (count($pathParts) < 2) { + return $this->packageNamesByFilePath[$filePath] = null; + } + + return $this->packageNamesByFilePath[$filePath] = $pathParts[0] . '/' . $pathParts[1]; + } + + /** + * @return array + */ + private function resolvePackageVersionConstraints(): array + { + if ($this->packageVersionConstraints !== null) { + return $this->packageVersionConstraints; + } + + if (! file_exists($this->composerJsonFilePath)) { + return $this->packageVersionConstraints = []; + } + + $composerJson = JsonFileSystem::readFilePath($this->composerJsonFilePath); + $packageVersionConstraints = []; + + foreach (['require', 'require-dev'] as $requireKey) { + $requires = $composerJson[$requireKey] ?? []; + if (! is_array($requires)) { + continue; + } + + foreach ($requires as $packageName => $versionConstraint) { + if (! is_string($packageName)) { + continue; + } + + if (! is_string($versionConstraint)) { + continue; + } + + $packageVersionConstraints[$packageName] = $versionConstraint; + } + } + + return $this->packageVersionConstraints = $packageVersionConstraints; + } + + private function resolveMajorVersion(string $version): ?int + { + $match = preg_match('#^(\d+)\.#', $version, $matches); + if ($match !== 1) { + return null; + } + + return (int) $matches[1]; + } +} diff --git a/stubs/Monolog/vendor/monolog/monolog/src/LogRecord.php b/stubs/Monolog/vendor/monolog/monolog/src/LogRecord.php new file mode 100644 index 00000000000..edad0c23abd --- /dev/null +++ b/stubs/Monolog/vendor/monolog/monolog/src/LogRecord.php @@ -0,0 +1,13 @@ +hasPackageMultiMajorVersions( + '/project/vendor/monolog/monolog/src/Logger.php' + ); + + $this->assertSame($expected, $hasPackageMultiMajorVersions); + $this->assertSame( + $expected, + $composerJsonPackageVersionResolver->hasPackageMultiMajorVersions( + '/project/vendor/monolog/monolog/src/Logger.php' + ) + ); + $this->assertSame( + $expected, + $composerJsonPackageVersionResolver->hasPackageMultiMajorVersions( + '/project/vendor/monolog/monolog/src/Handler/StreamHandler.php' + ) + ); + } + + /** + * @return Iterator<(array|array)> + */ + public static function provideData(): Iterator + { + yield [__DIR__ . '/Fixture/ComposerJsonPackageVersionResolver/single_major.json', false]; + yield [__DIR__ . '/Fixture/ComposerJsonPackageVersionResolver/multi_major.json', true]; + } +} diff --git a/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/multi_major.json b/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/multi_major.json new file mode 100644 index 00000000000..9c2eb41b878 --- /dev/null +++ b/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/multi_major.json @@ -0,0 +1,5 @@ +{ + "require": { + "monolog/monolog": "^2.0 || ^3.0" + } +} diff --git a/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/single_major.json b/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/single_major.json new file mode 100644 index 00000000000..93f0b9fb0aa --- /dev/null +++ b/tests/Composer/Fixture/ComposerJsonPackageVersionResolver/single_major.json @@ -0,0 +1,5 @@ +{ + "require": { + "monolog/monolog": "^2.0" + } +}