diff --git a/src/Collector/ConstantFetchCollector.php b/src/Collector/ConstantFetchCollector.php index 5d1427ef..d305288d 100644 --- a/src/Collector/ConstantFetchCollector.php +++ b/src/Collector/ConstantFetchCollector.php @@ -8,11 +8,17 @@ use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Function_; +use PHPStan\Analyser\NameScope; use PHPStan\Analyser\Scope; use PHPStan\Collectors\Collector; +use PHPStan\PhpDocParser\Ast\NodeTraverser as PhpDocNodeTraverser; use PHPStan\Reflection\ReflectionProvider; use PHPStan\TrinaryLogic; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\FileTypeMapper; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeUtils; use ShipMonk\PHPStan\DeadCode\Cache\UsageCacheStorage; @@ -21,11 +27,13 @@ use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantUsage; use ShipMonk\PHPStan\DeadCode\Graph\CollectedUsage; use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; +use ShipMonk\PHPStan\DeadCode\Visitor\PhpDocConstFetchCollectingVisitor; use function array_map; use function count; use function current; use function explode; use function str_contains; +use function strtolower; /** * @implements Collector> @@ -41,6 +49,7 @@ final class ConstantFetchCollector implements Collector public function __construct( UsageCacheStorage $usageCacheStorage, private readonly ReflectionProvider $reflectionProvider, + private readonly FileTypeMapper $fileTypeMapper, private readonly array $memberUsageExcluders, ) { @@ -68,6 +77,8 @@ public function processNode( $this->registerFunctionCall($node, $scope); } + $this->registerPhpDocConstantFetches($node, $scope); + return $this->tryFlushBuffer($node, $scope); } @@ -124,6 +135,91 @@ private function registerFunctionCall( } } + /** + * PHPStan eagerly resolves e.g. int<1, self::MAX> down to a literal int<1, 100>, so the reference + * to the constant survives only in the raw PhpDoc type AST. We walk it for ConstFetchNode occurrences. + */ + private function registerPhpDocConstantFetches( + Node $node, + Scope $scope, + ): void + { + $docComment = $node->getDocComment(); + + if ($docComment === null || !str_contains($docComment->getText(), '::')) { + return; + } + + if ($node instanceof ClassMethod || $node instanceof Function_) { + $functionName = $node->name->toString(); + } else { + $function = $scope->getFunction(); + $functionName = $function !== null ? $function->getName() : null; + } + + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $functionName, + $docComment->getText(), + ); + + $nameScope = $resolvedPhpDoc->getNullableNameScope(); + + if ($nameScope === null) { + return; + } + + $visitor = new PhpDocConstFetchCollectingVisitor(); + (new PhpDocNodeTraverser([$visitor]))->traverse($resolvedPhpDoc->getPhpDocNodes()); + + foreach ($visitor->getConstFetchNodes() as $constFetchNode) { + if (str_contains($constFetchNode->name, '*')) { + continue; // constant mask (e.g. self::SIZE_*) + } + + $ownerClassName = $this->resolvePhpDocConstFetchOwner($constFetchNode->className, $nameScope); + + if ($ownerClassName === null) { + continue; + } + + $possibleDescendant = strtolower($constFetchNode->className) === 'static'; + + foreach ($this->getDeclaringTypesWithConstant(new ObjectType($ownerClassName), $constFetchNode->name, $possibleDescendant) as $constantRef) { + $usage = new ClassConstantUsage(UsageOrigin::createRegular($node, $scope), $constantRef); + $this->registerUsage($usage, $node, $scope); + } + } + } + + private function resolvePhpDocConstFetchOwner( + string $className, + NameScope $nameScope, + ): ?string + { + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' || $lowerClassName === 'static') { + return $nameScope->getClassName(); + } + + if ($lowerClassName === 'parent') { + $currentClassName = $nameScope->getClassName(); + + if ($currentClassName === null || !$this->reflectionProvider->hasClass($currentClassName)) { + return null; + } + + $parent = $this->reflectionProvider->getClass($currentClassName)->getParentClass(); + + return $parent !== null ? $parent->getName() : null; + } + + return $nameScope->resolveStringName($className); + } + private function registerFetch( ClassConstFetch $node, Scope $scope, diff --git a/src/Visitor/PhpDocConstFetchCollectingVisitor.php b/src/Visitor/PhpDocConstFetchCollectingVisitor.php new file mode 100644 index 00000000..0994a3eb --- /dev/null +++ b/src/Visitor/PhpDocConstFetchCollectingVisitor.php @@ -0,0 +1,34 @@ + + */ + private array $constFetchNodes = []; + + public function enterNode(Node $node): ?Node + { + if ($node instanceof ConstFetchNode && $node->className !== '') { + $this->constFetchNodes[] = $node; + } + + return null; + } + + /** + * @return list + */ + public function getConstFetchNodes(): array + { + return $this->constFetchNodes; + } + +} diff --git a/tests/AllServicesInConfigTest.php b/tests/AllServicesInConfigTest.php index c71f8835..d503f0c0 100644 --- a/tests/AllServicesInConfigTest.php +++ b/tests/AllServicesInConfigTest.php @@ -20,6 +20,7 @@ use ShipMonk\PHPStan\DeadCode\Provider\VirtualUsageData; use ShipMonk\PHPStan\DeadCode\Transformer\RemoveClassMemberVisitor; use ShipMonk\PHPStan\DeadCode\Transformer\RemoveDeadCodeTransformer; +use ShipMonk\PHPStan\DeadCode\Visitor\PhpDocConstFetchCollectingVisitor; use ShipMonk\PHPStan\DeadCode\Visitor\PropertyHookBackingValueVisitor; use function array_merge; use function class_exists; @@ -63,6 +64,7 @@ public function test(): void RemoveDeadCodeTransformer::class, RemoveClassMemberVisitor::class, PropertyHookBackingValueVisitor::class, + PhpDocConstFetchCollectingVisitor::class, ]; /** @var DirectoryIterator $file */ diff --git a/tests/Rule/DeadCodeRuleTest.php b/tests/Rule/DeadCodeRuleTest.php index aa31ccd6..edfd0689 100644 --- a/tests/Rule/DeadCodeRuleTest.php +++ b/tests/Rule/DeadCodeRuleTest.php @@ -21,6 +21,7 @@ use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Testing\RuleTestCase as OriginalRuleTestCase; +use PHPStan\Type\FileTypeMapper; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\MockObject\MockObject; @@ -172,7 +173,7 @@ protected function getCollectors(): array ), new ClassDefinitionCollector($reflectionProvider), new MethodCallCollector($usageCacheStorage, $this->getMemberUsageExcluders()), - new ConstantFetchCollector($usageCacheStorage, $reflectionProvider, $this->getMemberUsageExcluders()), + new ConstantFetchCollector($usageCacheStorage, $reflectionProvider, self::getContainer()->getByType(FileTypeMapper::class), $this->getMemberUsageExcluders()), new PropertyAccessCollector($usageCacheStorage, $reflectionProvider, [__DIR__ . '/data/'], $this->getMemberUsageExcluders()), ]; } @@ -1043,6 +1044,7 @@ public static function provideFiles(): Traversable yield 'const-descendant-4' => [__DIR__ . '/data/constants/descendant-4.php']; yield 'const-dynamic' => [__DIR__ . '/data/constants/dynamic.php']; yield 'const-expr' => [__DIR__ . '/data/constants/expr.php']; + yield 'const-phpdoc' => [__DIR__ . '/data/constants/phpdoc.php']; yield 'const-magic' => [__DIR__ . '/data/constants/magic.php']; yield 'const-mixed' => [__DIR__ . '/data/constants/mixed/tracked.php']; yield 'const-soft-final' => [__DIR__ . '/data/constants/soft-final.php']; diff --git a/tests/Rule/data/constants/phpdoc.php b/tests/Rule/data/constants/phpdoc.php new file mode 100644 index 00000000..b55473af --- /dev/null +++ b/tests/Rule/data/constants/phpdoc.php @@ -0,0 +1,89 @@ + $pageSize + */ + public static function create(int $pageSize): void + { + echo $pageSize; + } + +} + +final class Limits +{ + + public const MAX_ITEMS = 1000; + public const MIN_ITEMS = 1; + public const VIA_ALIAS = 5; + +} + +final class BulkInput +{ + + /** + * @param int<0, Limits::MAX_ITEMS> $count + * @param array $names + * @return AliasedLimits::VIA_ALIAS|null + */ + public static function process(int $count, array $names): ?int + { + echo $count; + echo count($names); + + return null; + } + +} + +abstract class Base +{ + + public const BASE_MAX = 50; + +} + +final class Child extends Base +{ + + /** + * @param int<1, parent::BASE_MAX> $value + */ + public static function handle(int $value): void + { + echo $value; + } + +} + +final class Sizes +{ + + public const SIZE_SMALL = 1; // error: Unused DeadConstPhpDoc\Sizes::SIZE_SMALL + public const SIZE_LARGE = 2; // error: Unused DeadConstPhpDoc\Sizes::SIZE_LARGE + + /** + * @param self::SIZE_* $size + */ + public static function pick(int $size): void + { + echo $size; + } + +} + +PaginationInput::create(5); +BulkInput::process(1, []); +Child::handle(1); +Sizes::pick(1);