Skip to content

Commit

Permalink
Symfony: autodetect constants used in yamls (#131)
Browse files Browse the repository at this point in the history
  • Loading branch information
janedbal authored Dec 27, 2024
1 parent 020be30 commit ba0e2fc
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 21 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ includes:
- `#[Route]` attributes
- `EventSubscriberInterface::getSubscribedEvents`
- `onKernelResponse`, `onKernelRequest`, etc
- `!php const` references in `config` yamls

#### Doctrine:
- `#[AsEntityListener]` attribute
Expand Down
3 changes: 1 addition & 2 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,7 @@
intval=>null,
strval=>null,
settype=>null,
exit=>null,
reset=>array_key_first
exit=>null
"
/>
</properties>
Expand Down
2 changes: 2 additions & 0 deletions rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ parameters:
enabled: null
symfony:
enabled: null
configDir: null
doctrine:
enabled: null
nette:
Expand All @@ -136,6 +137,7 @@ parametersSchema:
])
symfony: structure([
enabled: schema(bool(), nullable())
configDir: schema(string(), nullable())
])
doctrine: structure([
enabled: schema(bool(), nullable())
Expand Down
129 changes: 125 additions & 4 deletions src/Provider/SymfonyUsageProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,16 +54,29 @@ class SymfonyUsageProvider implements MemberUsageProvider
*/
private array $dicCalls = [];

/**
* class => [constant]
*
* @var array<string, list<string>>
*/
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
Expand All @@ -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()),
];
}

Expand Down Expand Up @@ -157,7 +189,7 @@ private function getUsagesOfEventSubscriber(Return_ $node, Scope $scope): array
/**
* @return list<ClassMethodUsage>
*/
private function getUsagesFromReflection(InClassNode $node): array
private function getMethodUsagesFromReflection(InClassNode $node): array
{
$classReflection = $node->getClassReflection();
$nativeReflection = $classReflection->getNativeReflection();
Expand Down Expand Up @@ -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<ClassConstantUsage>
*/
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;
}

}
19 changes: 4 additions & 15 deletions tests/Rule/DeadCodeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/Rule/data/providers/symfony.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,7 @@ public function create(): self {
return new self();
}
}

class Sftp {
const RETRY_LIMIT = 3; // used in yaml via !php/const
}
7 changes: 7 additions & 0 deletions tests/Rule/data/providers/symfony/config/sftp.yaml
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit ba0e2fc

Please sign in to comment.