From ba0e2fcddd00c6c4b0dc2aca25d2187744c6cff3 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Fri, 27 Dec 2024 11:56:04 +0100 Subject: [PATCH] Symfony: autodetect constants used in yamls (#131) --- README.md | 1 + phpcs.xml.dist | 3 +- rules.neon | 2 + src/Provider/SymfonyUsageProvider.php | 129 +++++++++++++++++- tests/Rule/DeadCodeRuleTest.php | 19 +-- tests/Rule/data/providers/symfony.php | 4 + .../data/providers/symfony/config/sftp.yaml | 7 + 7 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 tests/Rule/data/providers/symfony/config/sftp.yaml diff --git a/README.md b/README.md index 21c16f7..a1817dc 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ includes: - `#[Route]` attributes - `EventSubscriberInterface::getSubscribedEvents` - `onKernelResponse`, `onKernelRequest`, etc +- `!php const` references in `config` yamls #### Doctrine: - `#[AsEntityListener]` attribute diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 23b8f00..e7e6b95 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -80,8 +80,7 @@ intval=>null, strval=>null, settype=>null, - exit=>null, - reset=>array_key_first + exit=>null " /> diff --git a/rules.neon b/rules.neon index 3217ab4..5739d61 100644 --- a/rules.neon +++ b/rules.neon @@ -112,6 +112,7 @@ parameters: enabled: null symfony: enabled: null + configDir: null doctrine: enabled: null nette: @@ -136,6 +137,7 @@ parametersSchema: ]) symfony: structure([ enabled: schema(bool(), nullable()) + configDir: schema(string(), nullable()) ]) doctrine: structure([ enabled: schema(bool(), nullable()) diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index abaf73e..74f2980 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -2,26 +2,44 @@ namespace ShipMonk\PHPStan\DeadCode\Provider; +use Composer\Autoload\ClassLoader; use Composer\InstalledVersions; +use FilesystemIterator; use LogicException; use PhpParser\Node; use PhpParser\Node\Stmt\Return_; use PHPStan\Analyser\Scope; use PHPStan\BetterReflection\Reflector\Exception\IdentifierNotFound; use PHPStan\Node\InClassNode; +use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Symfony\Configuration as PHPStanSymfonyConfiguration; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; use Reflector; +use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantRef; +use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantUsage; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; use SimpleXMLElement; +use SplFileInfo; +use UnexpectedValueException; +use function array_filter; +use function array_keys; +use function count; +use function explode; use function file_get_contents; +use function in_array; +use function is_dir; +use function preg_match_all; +use function reset; use function simplexml_load_string; use function sprintf; +use function strpos; use const PHP_VERSION_ID; class SymfonyUsageProvider implements MemberUsageProvider @@ -36,16 +54,29 @@ class SymfonyUsageProvider implements MemberUsageProvider */ private array $dicCalls = []; + /** + * class => [constant] + * + * @var array> + */ + private array $dicConstants = []; + public function __construct( ?PHPStanSymfonyConfiguration $symfonyConfiguration, - ?bool $enabled + ?bool $enabled, + ?string $configDir ) { $this->enabled = $enabled ?? $this->isSymfonyInstalled(); + $resolvedConfigDir = $configDir ?? $this->autodetectConfigDir(); - if ($symfonyConfiguration !== null && $symfonyConfiguration->getContainerXmlPath() !== null) { // @phpstan-ignore phpstanApi.method + if ($this->enabled && $symfonyConfiguration !== null && $symfonyConfiguration->getContainerXmlPath() !== null) { // @phpstan-ignore phpstanApi.method $this->fillDicClasses($symfonyConfiguration->getContainerXmlPath()); // @phpstan-ignore phpstanApi.method } + + if ($this->enabled && $resolvedConfigDir !== null) { + $this->fillDicConstants($resolvedConfigDir); + } } public function getUsages(Node $node, Scope $scope): array @@ -59,7 +90,8 @@ public function getUsages(Node $node, Scope $scope): array if ($node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption $usages = [ ...$usages, - ...$this->getUsagesFromReflection($node), + ...$this->getMethodUsagesFromReflection($node), + ...$this->getConstantUsages($node->getClassReflection()), ]; } @@ -157,7 +189,7 @@ private function getUsagesOfEventSubscriber(Return_ $node, Scope $scope): array /** * @return list */ - private function getUsagesFromReflection(InClassNode $node): array + private function getMethodUsagesFromReflection(InClassNode $node): array { $classReflection = $node->getClassReflection(); $nativeReflection = $classReflection->getNativeReflection(); @@ -374,4 +406,93 @@ private function createUsage(ExtendedMethodReflection $methodReflection): ClassM ); } + private function autodetectConfigDir(): ?string + { + $vendorDirs = array_filter(array_keys(ClassLoader::getRegisteredLoaders()), static function (string $vendorDir): bool { + return strpos($vendorDir, 'phar://') === false; + }); + + if (count($vendorDirs) !== 1) { + return null; + } + + $vendorDir = reset($vendorDirs); + $configDir = $vendorDir . '/../config'; + + if (is_dir($configDir)) { + return $configDir; + } + + return null; + } + + private function fillDicConstants(string $configDir): void + { + try { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($configDir, FilesystemIterator::SKIP_DOTS), + ); + } catch (UnexpectedValueException $e) { + throw new LogicException("Provided config path '$configDir' is not a directory", 0, $e); + } + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if ( + $file->isFile() + && in_array($file->getExtension(), ['yaml', 'yml'], true) + && $file->getRealPath() !== false + ) { + $this->extractYamlConstants($file->getRealPath()); + } + } + } + + private function extractYamlConstants(string $yamlFile): void + { + $dicFileContents = file_get_contents($yamlFile); + + if ($dicFileContents === false) { + return; + } + + $nameRegex = '[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*'; // https://www.php.net/manual/en/language.oop5.basic.php + + preg_match_all( + "~!php/const ($nameRegex(?:\\\\$nameRegex)+::$nameRegex)~", + $dicFileContents, + $matches, + ); + + foreach ($matches[1] as $usedConstants) { + [$className, $constantName] = explode('::', $usedConstants); // @phpstan-ignore offsetAccess.notFound + $this->dicConstants[$className][] = $constantName; + } + } + + /** + * @return list + */ + private function getConstantUsages(ClassReflection $classReflection): array + { + $usages = []; + + foreach ($this->dicConstants[$classReflection->getName()] ?? [] as $constantName) { + if (!$classReflection->hasConstant($constantName)) { + continue; + } + + $usages[] = new ClassConstantUsage( + null, + new ClassConstantRef( + $classReflection->getName(), + $constantName, + false, + ), + ); + } + + return $usages; + } + } diff --git a/tests/Rule/DeadCodeRuleTest.php b/tests/Rule/DeadCodeRuleTest.php index aa6fc1d..7405f27 100644 --- a/tests/Rule/DeadCodeRuleTest.php +++ b/tests/Rule/DeadCodeRuleTest.php @@ -397,23 +397,12 @@ public function shouldMarkMethodAsUsed(ReflectionMethod $method): bool self::getContainer()->getByType(ReflectionProvider::class), true, ), - $this->createSymfonyUsageProvider(), - ]; - } - - private function createSymfonyUsageProvider(): SymfonyUsageProvider - { - /** @var SymfonyUsageProvider|null $cache */ - static $cache = null; - - if ($cache === null) { - $cache = new SymfonyUsageProvider( + new SymfonyUsageProvider( new Configuration(['containerXmlPath' => __DIR__ . '/data/providers/symfony/services.xml']), // @phpstan-ignore phpstanApi.constructor true, - ); - } - - return $cache; + __DIR__ . '/data/providers/symfony/', + ), + ]; } private function createPhpStanContainerMock(): Container diff --git a/tests/Rule/data/providers/symfony.php b/tests/Rule/data/providers/symfony.php index 46c7f3f..57679a4 100644 --- a/tests/Rule/data/providers/symfony.php +++ b/tests/Rule/data/providers/symfony.php @@ -108,3 +108,7 @@ public function create(): self { return new self(); } } + +class Sftp { + const RETRY_LIMIT = 3; // used in yaml via !php/const +} diff --git a/tests/Rule/data/providers/symfony/config/sftp.yaml b/tests/Rule/data/providers/symfony/config/sftp.yaml new file mode 100644 index 0000000..a19cba9 --- /dev/null +++ b/tests/Rule/data/providers/symfony/config/sftp.yaml @@ -0,0 +1,7 @@ +some_package: + sftp: + options: + host: "%foo_ftp_host%" + username: "%foo_ftp_username%" + password: "%foo_ftp_password%" + maxTries: !php/const Symfony\Sftp::RETRY_LIMIT