diff --git a/rules.neon b/rules.neon index cead736f..b8ed1c41 100644 --- a/rules.neon +++ b/rules.neon @@ -13,6 +13,8 @@ services: arguments: tmpDir: %tmpDir% offloadCollectorData: %shipmonkDeadCode.cache.offloadCollectorData% + - + class: ShipMonk\PHPStan\DeadCode\Composer\ComposerIntrospector - class: ShipMonk\PHPStan\DeadCode\Hierarchy\ClassHierarchy - diff --git a/src/Composer/ComposerIntrospector.php b/src/Composer/ComposerIntrospector.php new file mode 100644 index 00000000..46259fa2 --- /dev/null +++ b/src/Composer/ComposerIntrospector.php @@ -0,0 +1,78 @@ +autodetectVendorDir(); + + if ($vendorDir === null) { + return null; + } + + $composerJsonPath = $vendorDir . '/../composer.json'; + + if (!is_file($composerJsonPath)) { + return null; + } + + return $composerJsonPath; + } + + /** + * @return array + */ + public function parseComposerJson(string $composerJsonPath): array + { + if (!is_file($composerJsonPath)) { + return []; + } + + $composerJsonRawData = file_get_contents($composerJsonPath); + + if ($composerJsonRawData === false) { + return []; + } + + $composerJsonData = json_decode($composerJsonRawData, associative: true); + + if (json_last_error() !== JSON_ERROR_NONE || !is_array($composerJsonData)) { + return []; + } + + return $composerJsonData; // @phpstan-ignore return.type (composer.json keys are strings) + } + +} diff --git a/src/Excluder/TestsUsageExcluder.php b/src/Excluder/TestsUsageExcluder.php index 76113304..97cc09ff 100644 --- a/src/Excluder/TestsUsageExcluder.php +++ b/src/Excluder/TestsUsageExcluder.php @@ -2,34 +2,28 @@ namespace ShipMonk\PHPStan\DeadCode\Excluder; -use Composer\Autoload\ClassLoader; use LogicException; use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\Reflection\ReflectionProvider; +use ShipMonk\PHPStan\DeadCode\Composer\ComposerIntrospector; use ShipMonk\PHPStan\DeadCode\Graph\ClassMemberUsage; -use function array_filter; -use function array_keys; -use function count; use function dirname; -use function file_get_contents; use function glob; use function is_array; -use function is_file; -use function json_decode; -use function json_last_error; +use function is_string; use function preg_match; use function realpath; -use function reset; use function str_contains; use function str_starts_with; -use const JSON_ERROR_NONE; final class TestsUsageExcluder implements MemberUsageExcluder { private readonly ReflectionProvider $reflectionProvider; + private readonly ComposerIntrospector $composerIntrospector; + private readonly bool $enabled; /** @@ -42,11 +36,13 @@ final class TestsUsageExcluder implements MemberUsageExcluder */ public function __construct( ReflectionProvider $reflectionProvider, + ComposerIntrospector $composerIntrospector, bool $enabled, ?array $devPaths, ) { $this->reflectionProvider = $reflectionProvider; + $this->composerIntrospector = $composerIntrospector; $this->enabled = $enabled; if ($devPaths !== null) { @@ -120,78 +116,53 @@ private function getDeclarationFile(?string $className): ?string */ private function autodetectComposerDevPaths(): array { - $vendorDirs = array_filter(array_keys(ClassLoader::getRegisteredLoaders()), static function (string $vendorDir): bool { - return !str_starts_with($vendorDir, 'phar://'); - }); - - if (count($vendorDirs) !== 1) { - return []; - } - - $vendorDir = reset($vendorDirs); - $composerJsonPath = $vendorDir . '/../composer.json'; - - $composerJsonData = $this->parseComposerJson($composerJsonPath); - $basePath = dirname($composerJsonPath); + $composerJsonPath = $this->composerIntrospector->autodetectComposerJsonPath(); - return [ - ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['psr-0'] ?? []), - ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['psr-4'] ?? []), - ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['files'] ?? []), - ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['classmap'] ?? []), - ]; - } - - /** - * @return array{ - * autoload-dev?: array{ - * psr-0?: array, - * psr-4?: array, - * files?: string[], - * classmap?: string[], - * } - * } - */ - private function parseComposerJson(string $composerJsonPath): array - { - if (!is_file($composerJsonPath)) { + if ($composerJsonPath === null) { return []; } - $composerJsonRawData = file_get_contents($composerJsonPath); + $composerJsonData = $this->composerIntrospector->parseComposerJson($composerJsonPath); + $autoloadDev = $composerJsonData['autoload-dev'] ?? []; - if ($composerJsonRawData === false) { + if (!is_array($autoloadDev)) { return []; } - $composerJsonData = json_decode($composerJsonRawData, associative: true); - - $jsonError = json_last_error(); - - if ($jsonError !== JSON_ERROR_NONE) { - return []; - } + $basePath = dirname($composerJsonPath); - return $composerJsonData; // @phpstan-ignore-line ignore mixed returned + return [ + ...$this->extractAutoloadPaths($basePath, $autoloadDev['psr-0'] ?? []), + ...$this->extractAutoloadPaths($basePath, $autoloadDev['psr-4'] ?? []), + ...$this->extractAutoloadPaths($basePath, $autoloadDev['files'] ?? []), + ...$this->extractAutoloadPaths($basePath, $autoloadDev['classmap'] ?? []), + ]; } /** - * @param array> $autoload * @return list */ private function extractAutoloadPaths( string $basePath, - array $autoload, + mixed $autoload, ): array { + if (!is_array($autoload)) { + return []; + } + $result = []; foreach ($autoload as $paths) { if (!is_array($paths)) { - $paths = [$paths]; // @phpstan-ignore shipmonk.variableTypeOverwritten + $paths = [$paths]; } foreach ($paths as $path) { + if (!is_string($path)) { + continue; + } + $isAbsolute = preg_match('#([a-z]:)?[/\\\\]#Ai', $path); if ($isAbsolute === 1) { diff --git a/src/Provider/ComposerUsageProvider.php b/src/Provider/ComposerUsageProvider.php index ba5323e2..1c3693dc 100644 --- a/src/Provider/ComposerUsageProvider.php +++ b/src/Provider/ComposerUsageProvider.php @@ -2,26 +2,18 @@ namespace ShipMonk\PHPStan\DeadCode\Provider; -use Composer\Autoload\ClassLoader; use LogicException; use PHPStan\Reflection\ReflectionProvider; use ReflectionMethod; -use function array_filter; -use function array_keys; -use function count; +use ShipMonk\PHPStan\DeadCode\Composer\ComposerIntrospector; use function explode; -use function file_get_contents; use function is_array; use function is_file; use function is_string; -use function json_decode; -use function json_last_error; use function ltrim; -use function reset; use function sprintf; use function str_contains; use function str_starts_with; -use const JSON_ERROR_NONE; /** * Detects static methods referenced as PHP callbacks in the scripts section of composer.json, @@ -34,6 +26,8 @@ final class ComposerUsageProvider extends ReflectionBasedMemberUsageProvider private readonly ReflectionProvider $reflectionProvider; + private readonly ComposerIntrospector $composerIntrospector; + /** * declaring class => [method => note] * @@ -43,11 +37,13 @@ final class ComposerUsageProvider extends ReflectionBasedMemberUsageProvider public function __construct( ReflectionProvider $reflectionProvider, + ComposerIntrospector $composerIntrospector, bool $enabled, ?string $composerJsonPath, ) { $this->reflectionProvider = $reflectionProvider; + $this->composerIntrospector = $composerIntrospector; $this->loadScriptCallbacks($enabled, $composerJsonPath); } @@ -61,7 +57,7 @@ private function loadScriptCallbacks( } if ($composerJsonPath === null) { - $autodetectedPath = $this->autodetectComposerJsonPath(); + $autodetectedPath = $this->composerIntrospector->autodetectComposerJsonPath(); if ($autodetectedPath !== null) { $this->extractScriptCallbacks($autodetectedPath); @@ -88,18 +84,7 @@ protected function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsa private function extractScriptCallbacks(string $composerJsonPath): void { - $composerJsonRawData = file_get_contents($composerJsonPath); - - if ($composerJsonRawData === false) { - return; - } - - $composerJsonData = json_decode($composerJsonRawData, associative: true); - - if (json_last_error() !== JSON_ERROR_NONE || !is_array($composerJsonData)) { - return; - } - + $composerJsonData = $this->composerIntrospector->parseComposerJson($composerJsonPath); $scripts = $composerJsonData['scripts'] ?? []; if (!is_array($scripts)) { @@ -155,23 +140,4 @@ private function isPhpScript(string $listener): bool && str_contains($listener, '::'); } - private function autodetectComposerJsonPath(): ?string - { - $vendorDirs = array_filter(array_keys(ClassLoader::getRegisteredLoaders()), static function (string $vendorDir): bool { - return !str_starts_with($vendorDir, 'phar://'); - }); - - if (count($vendorDirs) !== 1) { - return null; - } - - $composerJsonPath = reset($vendorDirs) . '/../composer.json'; - - if (!is_file($composerJsonPath)) { - return null; - } - - return $composerJsonPath; - } - } diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index 1dba8444..2ce7a047 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -2,7 +2,6 @@ namespace ShipMonk\PHPStan\DeadCode\Provider; -use Composer\Autoload\ClassLoader; use Composer\InstalledVersions; use FilesystemIterator; use LogicException; @@ -33,6 +32,7 @@ use ReflectionAttribute; use ReflectionNamedType; use Reflector; +use ShipMonk\PHPStan\DeadCode\Composer\ComposerIntrospector; use ShipMonk\PHPStan\DeadCode\Enum\AccessType; use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantUsage; @@ -47,9 +47,7 @@ use SplFileInfo; use Symfony\UX\TwigComponent\Attribute\FromMethod; use UnexpectedValueException; -use function array_filter; use function array_key_first; -use function array_keys; use function count; use function explode; use function extension_loaded; @@ -59,7 +57,6 @@ use function is_dir; use function is_string; use function preg_match_all; -use function reset; use function simplexml_load_string; use function sprintf; use function str_ends_with; @@ -72,6 +69,8 @@ final class SymfonyUsageProvider implements MemberUsageProvider private readonly ReflectionProvider $reflectionProvider; + private readonly ComposerIntrospector $composerIntrospector; + private readonly TemplateViewDataTraverser $traverser; private readonly bool $enabled; @@ -112,6 +111,7 @@ final class SymfonyUsageProvider implements MemberUsageProvider public function __construct( Container $container, ReflectionProvider $reflectionProvider, + ComposerIntrospector $composerIntrospector, TemplateViewDataTraverser $traverser, ?bool $enabled, ?string $configDir, @@ -119,6 +119,7 @@ public function __construct( ) { $this->reflectionProvider = $reflectionProvider; + $this->composerIntrospector = $composerIntrospector; $this->traverser = $traverser; $this->enabled = $enabled ?? $this->isSymfonyInstalled(); $this->configDir = $configDir ?? $this->autodetectConfigDir(); @@ -1575,15 +1576,12 @@ private function createUsage( private function autodetectConfigDir(): ?string { - $vendorDirs = array_filter(array_keys(ClassLoader::getRegisteredLoaders()), static function (string $vendorDir): bool { - return !str_starts_with($vendorDir, 'phar://'); - }); + $vendorDir = $this->composerIntrospector->autodetectVendorDir(); - if (count($vendorDirs) !== 1) { + if ($vendorDir === null) { return null; } - $vendorDir = reset($vendorDirs); $configDir = $vendorDir . '/../config'; if (is_dir($configDir)) { diff --git a/tests/Excluder/TestsUsageExcluderTest.php b/tests/Excluder/TestsUsageExcluderTest.php index 8f12d2ff..fd1cf773 100644 --- a/tests/Excluder/TestsUsageExcluderTest.php +++ b/tests/Excluder/TestsUsageExcluderTest.php @@ -5,6 +5,7 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\Testing\PHPStanTestCase; use ReflectionClass; +use ShipMonk\PHPStan\DeadCode\Composer\ComposerIntrospector; use function realpath; final class TestsUsageExcluderTest extends PHPStanTestCase @@ -12,7 +13,7 @@ final class TestsUsageExcluderTest extends PHPStanTestCase public function testAutodetectComposerDevPaths(): void { - $excluder = new TestsUsageExcluder(self::getContainer()->getByType(ReflectionProvider::class), true, null); + $excluder = new TestsUsageExcluder(self::getContainer()->getByType(ReflectionProvider::class), new ComposerIntrospector(), true, null); $excluderReflection = new ReflectionClass(TestsUsageExcluder::class); $devPathsPropertyReflection = $excluderReflection->getProperty('devPaths'); diff --git a/tests/Provider/ComposerUsageProviderTest.php b/tests/Provider/ComposerUsageProviderTest.php index 50d280d4..0368e05a 100644 --- a/tests/Provider/ComposerUsageProviderTest.php +++ b/tests/Provider/ComposerUsageProviderTest.php @@ -7,6 +7,7 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\Testing\PHPStanTestCase; use ReflectionClass; +use ShipMonk\PHPStan\DeadCode\Composer\ComposerIntrospector; final class ComposerUsageProviderTest extends PHPStanTestCase { @@ -15,7 +16,7 @@ public function testComposerJsonPathCollectsScriptCallbacks(): void { $composerJsonPath = __DIR__ . '/../Rule/data/providers/composer/composer.json'; - $provider = new ComposerUsageProvider($this->getReflectionProviderFromContainer(), true, $composerJsonPath); + $provider = new ComposerUsageProvider($this->getReflectionProviderFromContainer(), new ComposerIntrospector(), true, $composerJsonPath); $scriptCalls = $this->getScriptCalls($provider); self::assertArrayHasKey('ComposerProvider\Scripts', $scriptCalls); @@ -40,7 +41,7 @@ public function testComposerJsonPathCollectsScriptCallbacks(): void public function testAutodetectsComposerJson(): void { - $provider = new ComposerUsageProvider($this->getReflectionProviderFromContainer(), true, null); + $provider = new ComposerUsageProvider($this->getReflectionProviderFromContainer(), new ComposerIntrospector(), true, null); // this repository has no PHP callbacks in its own composer.json scripts self::assertSame([], $this->getScriptCalls($provider)); @@ -52,7 +53,7 @@ public function testAutodetectionRequiresSingleVendorDir(): void $extraLoader->register(); try { - $provider = new ComposerUsageProvider($this->getReflectionProviderFromContainer(), true, null); + $provider = new ComposerUsageProvider($this->getReflectionProviderFromContainer(), new ComposerIntrospector(), true, null); self::assertSame([], $this->getScriptCalls($provider)); } finally { @@ -62,7 +63,7 @@ public function testAutodetectionRequiresSingleVendorDir(): void public function testDisabled(): void { - $provider = new ComposerUsageProvider($this->getReflectionProviderFromContainer(), false, __DIR__ . '/not-a-file.json'); + $provider = new ComposerUsageProvider($this->getReflectionProviderFromContainer(), new ComposerIntrospector(), false, __DIR__ . '/not-a-file.json'); self::assertSame([], $this->getScriptCalls($provider)); } @@ -71,7 +72,7 @@ public function testInvalidPathThrows(): void { self::expectException(LogicException::class); - new ComposerUsageProvider($this->getReflectionProviderFromContainer(), true, __DIR__ . '/not-a-file.json'); + new ComposerUsageProvider($this->getReflectionProviderFromContainer(), new ComposerIntrospector(), true, __DIR__ . '/not-a-file.json'); } private function getReflectionProviderFromContainer(): ReflectionProvider diff --git a/tests/Provider/SymfonyUsageProviderTest.php b/tests/Provider/SymfonyUsageProviderTest.php index 9e670275..e3c1fe25 100644 --- a/tests/Provider/SymfonyUsageProviderTest.php +++ b/tests/Provider/SymfonyUsageProviderTest.php @@ -5,6 +5,7 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\Testing\PHPStanTestCase; use ReflectionClass; +use ShipMonk\PHPStan\DeadCode\Composer\ComposerIntrospector; use function mkdir; use function realpath; use function rmdir; @@ -17,7 +18,7 @@ public function testAutodetectConfigDir(): void $configDir = __DIR__ . '/../../config'; @mkdir($configDir); - $provider = new SymfonyUsageProvider(self::getContainer(), self::getContainer()->getByType(ReflectionProvider::class), new TemplateViewDataTraverser(self::getContainer()->getByType(ReflectionProvider::class), []), true, null, []); + $provider = new SymfonyUsageProvider(self::getContainer(), self::getContainer()->getByType(ReflectionProvider::class), new ComposerIntrospector(), new TemplateViewDataTraverser(self::getContainer()->getByType(ReflectionProvider::class), []), true, null, []); $providerReflection = new ReflectionClass(SymfonyUsageProvider::class); $configDirPropertyReflection = $providerReflection->getProperty('configDir'); @@ -39,7 +40,7 @@ public function testExplicitContainerXmlPaths(): void { $containerXmlPath = __DIR__ . '/../Rule/data/providers/symfony/services.xml'; - $provider = new SymfonyUsageProvider(self::getContainer(), self::getContainer()->getByType(ReflectionProvider::class), new TemplateViewDataTraverser(self::getContainer()->getByType(ReflectionProvider::class), []), true, null, [$containerXmlPath]); + $provider = new SymfonyUsageProvider(self::getContainer(), self::getContainer()->getByType(ReflectionProvider::class), new ComposerIntrospector(), new TemplateViewDataTraverser(self::getContainer()->getByType(ReflectionProvider::class), []), true, null, [$containerXmlPath]); $providerReflection = new ReflectionClass(SymfonyUsageProvider::class); $dicCallsReflection = $providerReflection->getProperty('dicCalls'); @@ -62,7 +63,7 @@ public function testExplicitContainerXmlPathsTakesPrecedenceOverContainer(): voi $containerXmlPath = __DIR__ . '/../Rule/data/providers/symfony/services.xml'; // Even though self::getContainer() has no symfony config, the explicit paths are used - $provider = new SymfonyUsageProvider(self::getContainer(), self::getContainer()->getByType(ReflectionProvider::class), new TemplateViewDataTraverser(self::getContainer()->getByType(ReflectionProvider::class), []), true, null, [$containerXmlPath]); + $provider = new SymfonyUsageProvider(self::getContainer(), self::getContainer()->getByType(ReflectionProvider::class), new ComposerIntrospector(), new TemplateViewDataTraverser(self::getContainer()->getByType(ReflectionProvider::class), []), true, null, [$containerXmlPath]); $providerReflection = new ReflectionClass(SymfonyUsageProvider::class); $dicCallsReflection = $providerReflection->getProperty('dicCalls'); @@ -77,7 +78,7 @@ public function testEmptyContainerXmlPathsFallsBackToContainer(): void { // When containerXmlPaths is empty, it falls back to getContainerXmlPath(container) // self::getContainer() has no symfony parameter, so no DIC classes are loaded - $provider = new SymfonyUsageProvider(self::getContainer(), self::getContainer()->getByType(ReflectionProvider::class), new TemplateViewDataTraverser(self::getContainer()->getByType(ReflectionProvider::class), []), true, null, []); + $provider = new SymfonyUsageProvider(self::getContainer(), self::getContainer()->getByType(ReflectionProvider::class), new ComposerIntrospector(), new TemplateViewDataTraverser(self::getContainer()->getByType(ReflectionProvider::class), []), true, null, []); $providerReflection = new ReflectionClass(SymfonyUsageProvider::class); $dicCallsReflection = $providerReflection->getProperty('dicCalls'); diff --git a/tests/Rule/DeadCodeRuleTest.php b/tests/Rule/DeadCodeRuleTest.php index 9d88c430..54ec27b6 100644 --- a/tests/Rule/DeadCodeRuleTest.php +++ b/tests/Rule/DeadCodeRuleTest.php @@ -37,6 +37,7 @@ use ShipMonk\PHPStan\DeadCode\Collector\PropertyAccessCollector; use ShipMonk\PHPStan\DeadCode\Collector\ProvidedUsagesCollector; use ShipMonk\PHPStan\DeadCode\Compatibility\BackwardCompatibilityChecker; +use ShipMonk\PHPStan\DeadCode\Composer\ComposerIntrospector; use ShipMonk\PHPStan\DeadCode\Debug\DebugUsagePrinter; use ShipMonk\PHPStan\DeadCode\Error\BlackMember; use ShipMonk\PHPStan\DeadCode\Excluder\MemberUsageExcluder; @@ -1262,12 +1263,14 @@ private function getMemberUsageProviders(): array ), new ComposerUsageProvider( self::getContainer()->getByType(ReflectionProvider::class), + new ComposerIntrospector(), $this->providersEnabled, __DIR__ . '/data/providers/composer/composer.json', ), new SymfonyUsageProvider( $this->createContainerMockWithSymfonyConfig(), self::getContainer()->getByType(ReflectionProvider::class), + new ComposerIntrospector(), $templateViewDataTraverser, $this->providersEnabled, __DIR__ . '/data/providers/symfony/', @@ -1333,6 +1336,7 @@ private function getMemberUsageExcluders(): array $excluders = [ new TestsUsageExcluder( self::createReflectionProvider(), + new ComposerIntrospector(), true, [__DIR__ . '/data/excluders/../excluders/tests/tests'], // tests path normalization ),