diff --git a/src/Analyser.php b/src/Analyser.php index 72de2fe..d90d916 100644 --- a/src/Analyser.php +++ b/src/Analyser.php @@ -42,6 +42,19 @@ class Analyser { + private const CORE_EXTENSIONS = [ + 'ext-Core', + 'ext-date', + 'ext-json', + 'ext-hash', + 'ext-pcre', + 'ext-Phar', + 'ext-Reflection', + 'ext-SPL', + 'ext-random', + 'ext-standard', + ]; + /** * @var Stopwatch */ @@ -67,7 +80,7 @@ class Analyser private $classmap = []; /** - * package name => is dev dependency + * package or ext-* => is dev dependency * * @var array */ @@ -81,15 +94,22 @@ class Analyser private $ignoredSymbols; /** - * function name => path + * custom function name => path * * @var array */ private $definedFunctions = []; + /** + * kind => [symbol name => ext-*] + * + * @var array> + */ + private $extensionSymbols; + /** * @param array $classLoaders vendorDir => ClassLoader (e.g. result of \Composer\Autoload\ClassLoader::getRegisteredLoaders()) - * @param array $composerJsonDependencies package name => is dev dependency + * @param array $composerJsonDependencies package or ext-* => is dev dependency */ public function __construct( Stopwatch $stopwatch, @@ -124,7 +144,7 @@ public function run(): AnalysisResult $prodOnlyInDevErrors = []; $unusedErrors = []; - $usedPackages = []; + $usedDependencies = []; $prodPackagesUsedInProdPath = []; $usages = []; @@ -142,60 +162,65 @@ public function run(): AnalysisResult continue; } - $symbolPath = $this->getSymbolPath($usedSymbol, $kind); + if (isset($this->extensionSymbols[$kind][$usedSymbol])) { + $dependencyName = $this->extensionSymbols[$kind][$usedSymbol]; + + } else { + $symbolPath = $this->getSymbolPath($usedSymbol, $kind); - if ($symbolPath === null) { - if ($kind === SymbolKind::CLASSLIKE && !$ignoreList->shouldIgnoreUnknownClass($usedSymbol, $filePath)) { - foreach ($lineNumbers as $lineNumber) { - $unknownClassErrors[$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); + if ($symbolPath === null) { + if ($kind === SymbolKind::CLASSLIKE && !$ignoreList->shouldIgnoreUnknownClass($usedSymbol, $filePath)) { + foreach ($lineNumbers as $lineNumber) { + $unknownClassErrors[$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); + } } - } - if ($kind === SymbolKind::FUNCTION && !$ignoreList->shouldIgnoreUnknownFunction($usedSymbol, $filePath)) { - foreach ($lineNumbers as $lineNumber) { - $unknownFunctionErrors[$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); + if ($kind === SymbolKind::FUNCTION && !$ignoreList->shouldIgnoreUnknownFunction($usedSymbol, $filePath)) { + foreach ($lineNumbers as $lineNumber) { + $unknownFunctionErrors[$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); + } } + + continue; } - continue; - } + if (!$this->isVendorPath($symbolPath)) { + continue; // local class + } - if (!$this->isVendorPath($symbolPath)) { - continue; // local class + $dependencyName = $this->getPackageNameFromVendorPath($symbolPath); } - $packageName = $this->getPackageNameFromVendorPath($symbolPath); - if ( - $this->isShadowDependency($packageName) - && !$ignoreList->shouldIgnoreError(ErrorType::SHADOW_DEPENDENCY, $filePath, $packageName) + $this->isShadowDependency($dependencyName) + && !$ignoreList->shouldIgnoreError(ErrorType::SHADOW_DEPENDENCY, $filePath, $dependencyName) ) { foreach ($lineNumbers as $lineNumber) { - $shadowErrors[$packageName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); + $shadowErrors[$dependencyName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); } } if ( !$isDevFilePath - && $this->isDevDependency($packageName) - && !$ignoreList->shouldIgnoreError(ErrorType::DEV_DEPENDENCY_IN_PROD, $filePath, $packageName) + && $this->isDevDependency($dependencyName) + && !$ignoreList->shouldIgnoreError(ErrorType::DEV_DEPENDENCY_IN_PROD, $filePath, $dependencyName) ) { foreach ($lineNumbers as $lineNumber) { - $devInProdErrors[$packageName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); + $devInProdErrors[$dependencyName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); } } if ( !$isDevFilePath - && !$this->isDevDependency($packageName) + && !$this->isDevDependency($dependencyName) ) { - $prodPackagesUsedInProdPath[$packageName] = true; + $prodPackagesUsedInProdPath[$dependencyName] = true; } - $usedPackages[$packageName] = true; + $usedDependencies[$dependencyName] = true; foreach ($lineNumbers as $lineNumber) { - $usages[$packageName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); + $usages[$dependencyName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); } } } @@ -208,19 +233,31 @@ public function run(): AnalysisResult continue; } - $symbolPath = $this->getSymbolPath($forceUsedSymbol, null); + if ( + isset($this->extensionSymbols[SymbolKind::FUNCTION][$forceUsedSymbol]) + || isset($this->extensionSymbols[SymbolKind::CONSTANT][$forceUsedSymbol]) + || isset($this->extensionSymbols[SymbolKind::CLASSLIKE][$forceUsedSymbol]) + ) { + $forceUsedDependency = $this->extensionSymbols[SymbolKind::FUNCTION][$forceUsedSymbol] + ?? $this->extensionSymbols[SymbolKind::CONSTANT][$forceUsedSymbol] + ?? $this->extensionSymbols[SymbolKind::CLASSLIKE][$forceUsedSymbol]; + } else { + $symbolPath = $this->getSymbolPath($forceUsedSymbol, null); + + if ($symbolPath === null || !$this->isVendorPath($symbolPath)) { + continue; + } - if ($symbolPath === null || !$this->isVendorPath($symbolPath)) { - continue; + $forceUsedDependency = $this->getPackageNameFromVendorPath($symbolPath); } - $forceUsedPackage = $this->getPackageNameFromVendorPath($symbolPath); - $usedPackages[$forceUsedPackage] = true; - $forceUsedPackages[$forceUsedPackage] = true; + $usedDependencies[$forceUsedDependency] = true; + $forceUsedPackages[$forceUsedDependency] = true; } if ($this->config->shouldReportUnusedDevDependencies()) { $dependenciesForUnusedAnalysis = array_keys($this->composerJsonDependencies); + } else { $dependenciesForUnusedAnalysis = array_keys(array_filter($this->composerJsonDependencies, static function (bool $devDependency) { return !$devDependency; // dev deps are typically used only in CI @@ -229,7 +266,8 @@ public function run(): AnalysisResult $unusedDependencies = array_diff( $dependenciesForUnusedAnalysis, - array_keys($usedPackages) + array_keys($usedDependencies), + self::CORE_EXTENSIONS ); foreach ($unusedDependencies as $unusedDependency) { @@ -245,7 +283,8 @@ public function run(): AnalysisResult $prodDependencies, array_keys($prodPackagesUsedInProdPath), array_keys($forceUsedPackages), // we dont know where are those used, lets not report them - $unusedDependencies + $unusedDependencies, + self::CORE_EXTENSIONS ); foreach ($prodPackagesUsedOnlyInDev as $prodPackageUsedOnlyInDev) { @@ -328,7 +367,10 @@ private function getUsedSymbolsInFile(string $filePath): array throw new InvalidPathException("Unable to get contents of '$filePath'"); } - return (new UsedSymbolExtractor($code))->parseUsedSymbols(); + return (new UsedSymbolExtractor($code))->parseUsedSymbols( + $this->extensionSymbols[SymbolKind::FUNCTION], + $this->extensionSymbols[SymbolKind::CONSTANT] + ); } /** @@ -473,9 +515,21 @@ private function initExistingSymbols(): void 'Composer\\Autoload\\ClassLoader' => true, ]; - /** @var string $constantName */ - foreach (get_defined_constants() as $constantName => $constantValue) { - $this->ignoredSymbols[$constantName] = true; + /** @var array $constants */ + foreach (get_defined_constants(true) as $constantExtension => $constants) { + foreach ($constants as $constantName => $_) { + if ($constantExtension === 'user') { + $this->ignoredSymbols[$constantName] = true; + } else { + $extensionName = 'ext-' . $constantExtension; + + if (in_array($extensionName, self::CORE_EXTENSIONS, true)) { + $this->ignoredSymbols[$constantName] = true; + } else { + $this->extensionSymbols[SymbolKind::CONSTANT][$constantName] = $extensionName; + } + } + } } foreach (get_defined_functions() as $functionNames) { @@ -483,10 +537,18 @@ private function initExistingSymbols(): void $reflectionFunction = new ReflectionFunction($functionName); $functionFilePath = $reflectionFunction->getFileName(); - if ($reflectionFunction->getExtension() === null && is_string($functionFilePath)) { - $this->definedFunctions[$functionName] = Path::normalize($functionFilePath); + if ($reflectionFunction->getExtension() === null) { + if (is_string($functionFilePath)) { + $this->definedFunctions[$functionName] = Path::normalize($functionFilePath); + } } else { - $this->ignoredSymbols[$functionName] = true; + $extensionName = 'ext-' . $reflectionFunction->getExtension()->name; + + if (in_array($extensionName, self::CORE_EXTENSIONS, true)) { + $this->ignoredSymbols[$functionName] = true; + } else { + $this->extensionSymbols[SymbolKind::FUNCTION][$functionName] = $extensionName; + } } } } @@ -499,8 +561,16 @@ private function initExistingSymbols(): void foreach ($classLikes as $classLikeNames) { foreach ($classLikeNames as $classLikeName) { - if ((new ReflectionClass($classLikeName))->getExtension() !== null) { - $this->ignoredSymbols[$classLikeName] = true; + $classReflection = new ReflectionClass($classLikeName); + + if ($classReflection->getExtension() !== null) { + $extensionName = 'ext-' . $classReflection->getExtension()->name; + + if (in_array($extensionName, self::CORE_EXTENSIONS, true)) { + $this->ignoredSymbols[$classLikeName] = true; + } else { + $this->extensionSymbols[SymbolKind::CLASSLIKE][$classLikeName] = $extensionName; + } } } } diff --git a/src/ComposerJson.php b/src/ComposerJson.php index b9feedc..d05d0d7 100644 --- a/src/ComposerJson.php +++ b/src/ComposerJson.php @@ -31,7 +31,7 @@ class ComposerJson public $composerAutoloadPath; /** - * Package => isDev + * Package or ext-* => isDev * * @readonly * @var array @@ -73,13 +73,18 @@ public function __construct( $this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['classmap'] ?? [], true) ); + $filterExtensions = static function (string $dependency): bool { + return strpos($dependency, 'ext-') === 0; + }; $filterPackages = static function (string $package): bool { return strpos($package, '/') !== false; }; $this->dependencies = array_merge( array_fill_keys(array_keys(array_filter($requiredPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), false), - array_fill_keys(array_keys(array_filter($requiredDevPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), true) + array_fill_keys(array_keys(array_filter($requiredPackages, $filterExtensions, ARRAY_FILTER_USE_KEY)), false), + array_fill_keys(array_keys(array_filter($requiredDevPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), true), + array_fill_keys(array_keys(array_filter($requiredDevPackages, $filterExtensions, ARRAY_FILTER_USE_KEY)), true) ); if (count($this->dependencies) === 0) { diff --git a/src/UsedSymbolExtractor.php b/src/UsedSymbolExtractor.php index ec2ebcd..a35e908 100644 --- a/src/UsedSymbolExtractor.php +++ b/src/UsedSymbolExtractor.php @@ -2,6 +2,9 @@ namespace ShipMonk\ComposerDependencyAnalyser; +use function array_combine; +use function array_fill_keys; +use function array_merge; use function count; use function explode; use function is_array; @@ -58,14 +61,25 @@ public function __construct(string $code) * It does not produce any local names in current namespace * - this results in very limited functionality in files without namespace * + * @param array $definedFunctions + * @param array $definedConstants * @return array>> * @license Inspired by https://github.com/doctrine/annotations/blob/2.0.0/lib/Doctrine/Common/Annotations/TokenParser.php */ - public function parseUsedSymbols(): array + public function parseUsedSymbols( + array $definedFunctions, + array $definedConstants + ): array { $usedSymbols = []; - $useStatements = []; - $useStatementKinds = []; + $useStatements = $initialSseStatements = array_merge( + array_combine($definedFunctions, $definedFunctions), + array_combine($definedConstants, $definedConstants) + ); + $useStatementKinds = $initialUseStatementKinds = array_merge( + array_fill_keys($definedFunctions, SymbolKind::FUNCTION), + array_fill_keys($definedConstants, SymbolKind::CONSTANT) + ); $level = 0; $inClassLevel = null; @@ -96,7 +110,9 @@ public function parseUsedSymbols(): array break; case PHP_VERSION_ID >= 80000 ? T_NAMESPACE : -1: - $useStatements = []; // reset use statements on namespace change + // reset use statements on namespace change + $useStatements = $initialSseStatements; + $useStatementKinds = $initialUseStatementKinds; break; case PHP_VERSION_ID >= 80000 ? T_NAME_FULLY_QUALIFIED : -1: @@ -132,7 +148,9 @@ public function parseUsedSymbols(): array $nextName = $this->parseNameForOldPhp(); if (substr($nextName, 0, 1) !== '\\') { // not a namespace-relative name, but a new namespace declaration - $useStatements = []; // reset use statements on namespace change + // reset use statements on namespace change + $useStatements = $initialSseStatements; + $useStatementKinds = $initialUseStatementKinds; } break; @@ -185,7 +203,7 @@ public function parseUsedSymbols(): array } } - return $usedSymbols; // @phpstan-ignore-line Not enough precise analysis "Offset 'kind' (1|2|3) does not accept type int<1, max>" + return $usedSymbols; } /** diff --git a/tests/UsedSymbolExtractorTest.php b/tests/UsedSymbolExtractorTest.php index 1df98bb..220ece4 100644 --- a/tests/UsedSymbolExtractorTest.php +++ b/tests/UsedSymbolExtractorTest.php @@ -19,7 +19,7 @@ public function test(string $path, array $expectedUsages): void $extractor = new UsedSymbolExtractor($code); - self::assertSame($expectedUsages, $extractor->parseUsedSymbols()); + self::assertSame($expectedUsages, $extractor->parseUsedSymbols(['json_encode'], [])); } /** @@ -112,6 +112,16 @@ public function provideVariants(): iterable __DIR__ . '/data/not-autoloaded/used-symbols/curly-braces.php', [] ]; + + yield 'extensions' => [ + __DIR__ . '/data/not-autoloaded/used-symbols/extensions.php', + [ + SymbolKind::FUNCTION => [ + 'json_encode' => [5], + 'json_decode' => [6], + ], + ], + ]; } } diff --git a/tests/data/not-autoloaded/used-symbols/extensions.php b/tests/data/not-autoloaded/used-symbols/extensions.php new file mode 100644 index 0000000..bd6c94a --- /dev/null +++ b/tests/data/not-autoloaded/used-symbols/extensions.php @@ -0,0 +1,6 @@ +