├── .ecrc ├── src ├── Enum │ ├── MemberType.php │ ├── ClassLikeKind.php │ ├── Visibility.php │ └── NeverReportedReason.php ├── Provider │ ├── VirtualUsageData.php │ ├── MemberUsageProvider.php │ ├── PhpStanUsageProvider.php │ ├── BuiltinUsageProvider.php │ ├── StreamWrapperUsageProvider.php │ ├── VendorUsageProvider.php │ ├── BehatUsageProvider.php │ ├── EnumUsageProvider.php │ ├── ApiPhpDocUsageProvider.php │ ├── NetteUsageProvider.php │ ├── PhpUnitUsageProvider.php │ ├── TwigUsageProvider.php │ ├── ReflectionBasedMemberUsageProvider.php │ ├── PhpBenchUsageProvider.php │ ├── ReflectionUsageProvider.php │ └── DoctrineUsageProvider.php ├── Excluder │ ├── MixedUsageExcluder.php │ ├── MemberUsageExcluder.php │ └── TestsUsageExcluder.php ├── Output │ ├── TextUtils.php │ └── OutputEnhancer.php ├── Hierarchy │ └── ClassHierarchy.php ├── Transformer │ ├── FileSystem.php │ ├── RemoveDeadCodeTransformer.php │ └── RemoveClassMemberVisitor.php ├── Collector │ ├── BufferedUsageCollector.php │ ├── ProvidedUsagesCollector.php │ ├── PropertyAccessCollector.php │ ├── ConstantFetchCollector.php │ ├── ClassDefinitionCollector.php │ └── MethodCallCollector.php ├── Graph │ ├── ClassMemberUsage.php │ ├── ClassMethodUsage.php │ ├── ClassPropertyUsage.php │ ├── ClassConstantUsage.php │ ├── ClassMethodRef.php │ ├── ClassPropertyRef.php │ ├── ClassConstantRef.php │ ├── ClassMemberRef.php │ ├── UsageOrigin.php │ └── CollectedUsage.php ├── Compatibility │ └── BackwardCompatibilityChecker.php ├── Reflection │ └── ReflectionHelper.php ├── Error │ └── BlackMember.php └── Formatter │ └── RemoveDeadCodeFormatter.php ├── coverage-guard.php ├── composer.json └── rules.neon /.ecrc: -------------------------------------------------------------------------------- 1 | { 2 | "Exclude": [ 3 | "output.txt$" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/Enum/MemberType.php: -------------------------------------------------------------------------------- 1 | note = $note; 13 | } 14 | 15 | /** 16 | * @param string $note More detailed info why provider emitted this virtual usage 17 | */ 18 | public static function withNote(string $note): self 19 | { 20 | return new self($note); 21 | } 22 | 23 | public function getNote(): string 24 | { 25 | return $this->note; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Provider/MemberUsageProvider.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | public function getUsages( 27 | Node $node, 28 | Scope $scope 29 | ): array; 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/Excluder/MixedUsageExcluder.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled; 17 | } 18 | 19 | public function getIdentifier(): string 20 | { 21 | return 'usageOverMixed'; 22 | } 23 | 24 | public function shouldExclude( 25 | ClassMemberUsage $usage, 26 | Node $node, 27 | Scope $scope 28 | ): bool 29 | { 30 | if (!$this->enabled) { 31 | return false; 32 | } 33 | 34 | return $usage->getMemberRef()->getClassName() === null; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/Output/TextUtils.php: -------------------------------------------------------------------------------- 1 | childrenClassName[] 12 | * 13 | * @var array> 14 | */ 15 | private array $classDescendants = []; 16 | 17 | public function registerClassPair( 18 | string $ancestorName, 19 | string $descendantName 20 | ): void 21 | { 22 | $this->classDescendants[$ancestorName][$descendantName] = true; 23 | } 24 | 25 | /** 26 | * @return list 27 | */ 28 | public function getClassDescendants(string $className): array 29 | { 30 | return isset($this->classDescendants[$className]) 31 | ? array_keys($this->classDescendants[$className]) 32 | : []; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Transformer/FileSystem.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | private array $usages = []; 20 | 21 | /** 22 | * @return non-empty-list|null 23 | */ 24 | private function emitUsages(Scope $scope): ?array 25 | { 26 | try { 27 | return $this->usages === [] 28 | ? null 29 | : array_map( 30 | static fn (CollectedUsage $usage): string => $usage->serialize($scope->getFile()), 31 | $this->usages, 32 | ); 33 | } finally { 34 | $this->usages = []; 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/Graph/ClassMemberUsage.php: -------------------------------------------------------------------------------- 1 | origin = $origin; 22 | } 23 | 24 | public function getOrigin(): UsageOrigin 25 | { 26 | return $this->origin; 27 | } 28 | 29 | /** 30 | * @return MemberType::* 31 | */ 32 | abstract public function getMemberType(): int; 33 | 34 | /** 35 | * @return ClassMemberRef 36 | */ 37 | abstract public function getMemberRef(): ClassMemberRef; 38 | 39 | /** 40 | * @return static 41 | */ 42 | abstract public function concretizeMixedClassNameUsage(string $className): self; 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/Provider/PhpStanUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled; 21 | $this->container = $container; 22 | } 23 | 24 | public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 25 | { 26 | if (!$this->enabled) { 27 | return null; 28 | } 29 | 30 | return $this->isConstructorCallInPhpStanDic($method); 31 | } 32 | 33 | private function isConstructorCallInPhpStanDic(ReflectionMethod $method): ?VirtualUsageData 34 | { 35 | if (!$method->isConstructor()) { 36 | return null; 37 | } 38 | 39 | if ($this->container->findServiceNamesByType($method->getDeclaringClass()->getName()) !== []) { 40 | return VirtualUsageData::withNote('Constructor call from PHPStan DI container'); 41 | } 42 | 43 | return null; 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/Graph/ClassMethodUsage.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private ClassMethodRef $callee; 18 | 19 | /** 20 | * @param UsageOrigin $origin The method where the call occurs 21 | * @param ClassMethodRef $callee The method being called 22 | */ 23 | public function __construct( 24 | UsageOrigin $origin, 25 | ClassMethodRef $callee 26 | ) 27 | { 28 | parent::__construct($origin); 29 | 30 | $this->callee = $callee; 31 | } 32 | 33 | /** 34 | * @return MemberType::METHOD 35 | */ 36 | public function getMemberType(): int 37 | { 38 | return MemberType::METHOD; 39 | } 40 | 41 | /** 42 | * @return ClassMethodRef 43 | */ 44 | public function getMemberRef(): ClassMethodRef 45 | { 46 | return $this->callee; 47 | } 48 | 49 | public function concretizeMixedClassNameUsage(string $className): self 50 | { 51 | if ($this->callee->getClassName() !== null) { 52 | throw new LogicException('Usage is not mixed, thus it cannot be concretized'); 53 | } 54 | 55 | return new self( 56 | $this->getOrigin(), 57 | new ClassMethodRef( 58 | $className, 59 | $this->callee->getMemberName(), 60 | $this->callee->isPossibleDescendant(), 61 | ), 62 | ); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/Graph/ClassPropertyUsage.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private ClassPropertyRef $access; 18 | 19 | /** 20 | * @param UsageOrigin $origin The method where the read occurs 21 | * @param ClassPropertyRef $access The property being read 22 | */ 23 | public function __construct( 24 | UsageOrigin $origin, 25 | ClassPropertyRef $access 26 | ) 27 | { 28 | parent::__construct($origin); 29 | $this->access = $access; 30 | } 31 | 32 | /** 33 | * @return MemberType::PROPERTY 34 | */ 35 | public function getMemberType(): int 36 | { 37 | return MemberType::PROPERTY; 38 | } 39 | 40 | /** 41 | * @return ClassPropertyRef 42 | */ 43 | public function getMemberRef(): ClassPropertyRef 44 | { 45 | return $this->access; 46 | } 47 | 48 | public function concretizeMixedClassNameUsage(string $className): self 49 | { 50 | if ($this->access->getClassName() !== null) { 51 | throw new LogicException('Usage is not mixed, thus it cannot be concretized'); 52 | } 53 | 54 | return new self( 55 | $this->getOrigin(), 56 | new ClassPropertyRef( 57 | $className, 58 | $this->access->getMemberName(), 59 | $this->access->isPossibleDescendant(), 60 | ), 61 | ); 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/Graph/ClassConstantUsage.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private ClassConstantRef $fetch; 18 | 19 | /** 20 | * @param UsageOrigin $origin The method where the fetch occurs 21 | * @param ClassConstantRef $fetch The fetch of the constant or enum case 22 | */ 23 | public function __construct( 24 | UsageOrigin $origin, 25 | ClassConstantRef $fetch 26 | ) 27 | { 28 | parent::__construct($origin); 29 | $this->fetch = $fetch; 30 | } 31 | 32 | /** 33 | * @return MemberType::CONSTANT 34 | */ 35 | public function getMemberType(): int 36 | { 37 | return MemberType::CONSTANT; 38 | } 39 | 40 | /** 41 | * @return ClassConstantRef 42 | */ 43 | public function getMemberRef(): ClassConstantRef 44 | { 45 | return $this->fetch; 46 | } 47 | 48 | public function concretizeMixedClassNameUsage(string $className): self 49 | { 50 | if ($this->fetch->getClassName() !== null) { 51 | throw new LogicException('Usage is not mixed, thus it cannot be concretized'); 52 | } 53 | 54 | return new self( 55 | $this->getOrigin(), 56 | new ClassConstantRef( 57 | $className, 58 | $this->fetch->getMemberName(), 59 | $this->fetch->isPossibleDescendant(), 60 | $this->fetch->isEnumCase(), 61 | ), 62 | ); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/Compatibility/BackwardCompatibilityChecker.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | private array $servicesWithOldTag; 19 | 20 | private ?bool $trackMixedAccessParameterValue; 21 | 22 | /** 23 | * @param list $servicesWithOldTag 24 | */ 25 | public function __construct( 26 | array $servicesWithOldTag, 27 | ?bool $trackMixedAccessParameterValue 28 | ) 29 | { 30 | $this->servicesWithOldTag = $servicesWithOldTag; 31 | $this->trackMixedAccessParameterValue = $trackMixedAccessParameterValue; 32 | } 33 | 34 | public function check(): void 35 | { 36 | if (count($this->servicesWithOldTag) > 0) { 37 | $serviceClassNames = implode(' and ', array_map(static fn (object $service) => get_class($service), $this->servicesWithOldTag)); 38 | $plural = count($this->servicesWithOldTag) > 1 ? 's' : ''; 39 | $isAre = count($this->servicesWithOldTag) > 1 ? 'are' : 'is'; 40 | 41 | throw new LogicException("Service$plural $serviceClassNames $isAre registered with old tag 'shipmonk.deadCode.entrypointProvider'. Please update the tag to 'shipmonk.deadCode.memberUsageProvider'."); 42 | } 43 | 44 | if ($this->trackMixedAccessParameterValue !== null) { 45 | $newValue = var_export(!$this->trackMixedAccessParameterValue, true); 46 | throw new LogicException("Using deprecated parameter 'trackMixedAccess', please use 'parameters.shipmonkDeadCode.usageExcluders.usageOverMixed.enabled: $newValue' instead."); 47 | } 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/Graph/ClassMethodRef.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class ClassMethodRef extends ClassMemberRef 13 | { 14 | 15 | /** 16 | * @param C $className 17 | * @param M $methodName 18 | * @param bool $possibleDescendant True if the $className can be a descendant of the actual class 19 | */ 20 | public function __construct( 21 | ?string $className, 22 | ?string $methodName, 23 | bool $possibleDescendant 24 | ) 25 | { 26 | parent::__construct($className, $methodName, $possibleDescendant); 27 | } 28 | 29 | /** 30 | * @return list 31 | */ 32 | protected function getKeyPrefixes(): array 33 | { 34 | return ['m']; 35 | } 36 | 37 | /** 38 | * @return MemberType::METHOD 39 | */ 40 | public function getMemberType(): int 41 | { 42 | return MemberType::METHOD; 43 | } 44 | 45 | public function withKnownNames( 46 | string $className, 47 | string $memberName 48 | ): self 49 | { 50 | return new self( 51 | $className, 52 | $memberName, 53 | $this->isPossibleDescendant(), 54 | ); 55 | } 56 | 57 | public function withKnownClass(string $className): self 58 | { 59 | return new self( 60 | $className, 61 | $this->getMemberName(), 62 | $this->isPossibleDescendant(), 63 | ); 64 | } 65 | 66 | public function withKnownMember(string $memberName): self 67 | { 68 | return new self( 69 | $this->getClassName(), 70 | $memberName, 71 | $this->isPossibleDescendant(), 72 | ); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/Graph/ClassPropertyRef.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class ClassPropertyRef extends ClassMemberRef 13 | { 14 | 15 | /** 16 | * @param C $className 17 | * @param M $propertyName 18 | * @param bool $possibleDescendant True if the $className can be a descendant of the actual class 19 | */ 20 | public function __construct( 21 | ?string $className, 22 | ?string $propertyName, 23 | bool $possibleDescendant 24 | ) 25 | { 26 | parent::__construct($className, $propertyName, $possibleDescendant); 27 | } 28 | 29 | /** 30 | * @return list 31 | */ 32 | protected function getKeyPrefixes(): array 33 | { 34 | return ['p']; 35 | } 36 | 37 | /** 38 | * @return MemberType::PROPERTY 39 | */ 40 | public function getMemberType(): int 41 | { 42 | return MemberType::PROPERTY; 43 | } 44 | 45 | public function withKnownNames( 46 | string $className, 47 | string $memberName 48 | ): self 49 | { 50 | return new self( 51 | $className, 52 | $memberName, 53 | $this->isPossibleDescendant(), 54 | ); 55 | } 56 | 57 | public function withKnownClass(string $className): self 58 | { 59 | return new self( 60 | $className, 61 | $this->getMemberName(), 62 | $this->isPossibleDescendant(), 63 | ); 64 | } 65 | 66 | public function withKnownMember(string $memberName): self 67 | { 68 | return new self( 69 | $this->getClassName(), 70 | $memberName, 71 | $this->isPossibleDescendant(), 72 | ); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/Transformer/RemoveDeadCodeTransformer.php: -------------------------------------------------------------------------------- 1 | $deadMethods 28 | * @param list $deadConstants 29 | * @param list $deadProperties 30 | */ 31 | public function __construct( 32 | array $deadMethods, 33 | array $deadConstants, 34 | array $deadProperties 35 | ) 36 | { 37 | $this->phpLexer = new Lexer(); 38 | $this->phpParser = new Php8($this->phpLexer); 39 | 40 | $this->cloningTraverser = new PhpTraverser(); 41 | $this->cloningTraverser->addVisitor(new CloningVisitor()); 42 | 43 | $this->removingTraverser = new PhpTraverser(); 44 | $this->removingTraverser->addVisitor(new RemoveClassMemberVisitor($deadMethods, $deadConstants, $deadProperties)); 45 | 46 | $this->phpPrinter = new PhpPrinter(); 47 | } 48 | 49 | public function transformCode(string $oldCode): string 50 | { 51 | $oldAst = $this->phpParser->parse($oldCode); 52 | 53 | if ($oldAst === null) { 54 | throw new LogicException('Failed to parse the code'); 55 | } 56 | 57 | $oldTokens = $this->phpParser->getTokens(); 58 | $newAst = $this->removingTraverser->traverse($this->cloningTraverser->traverse($oldAst)); 59 | return $this->phpPrinter->printFormatPreserving($newAst, $oldAst, $oldTokens); 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /coverage-guard.php: -------------------------------------------------------------------------------- 1 | addRule(new class implements CoverageRule { 17 | 18 | public function inspect( 19 | CodeBlock $codeBlock, 20 | InspectionContext $context 21 | ): ?CoverageError 22 | { 23 | if (!$codeBlock instanceof ClassMethodBlock) { 24 | return null; 25 | } 26 | 27 | if ($codeBlock->getExecutableLinesCount() < 5) { 28 | return null; 29 | } 30 | 31 | $classReflection = $context->getClassReflection(); 32 | if ($classReflection === null) { 33 | return null; 34 | } 35 | 36 | $coverage = $codeBlock->getCoveragePercentage(); 37 | $requiredCoverage = $this->getRequiredCoverage($classReflection); 38 | 39 | if ($codeBlock->getCoveragePercentage() < $requiredCoverage) { 40 | return CoverageError::create("Method {$codeBlock->getMethodName()} requires $requiredCoverage% coverage, but has only $coverage%."); 41 | } 42 | 43 | return null; 44 | } 45 | 46 | /** 47 | * @param ReflectionClass $classReflection 48 | */ 49 | private function getRequiredCoverage(ReflectionClass $classReflection): int 50 | { 51 | $isPoor = $classReflection->getName() === BackwardCompatibilityChecker::class; 52 | $isCore = $classReflection->implementsInterface(MemberUsageProvider::class) 53 | || $classReflection->implementsInterface(Collector::class) 54 | || $classReflection->implementsInterface(Rule::class); 55 | 56 | return match (true) { 57 | $isCore => 80, 58 | $isPoor => 20, 59 | default => 50, 60 | }; 61 | } 62 | 63 | }); 64 | 65 | return $config; 66 | -------------------------------------------------------------------------------- /src/Graph/ClassConstantRef.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class ClassConstantRef extends ClassMemberRef 14 | { 15 | 16 | private TrinaryLogic $isEnumCase; 17 | 18 | /** 19 | * @param C $className 20 | * @param M $constantName 21 | */ 22 | public function __construct( 23 | ?string $className, 24 | ?string $constantName, 25 | bool $possibleDescendant, 26 | TrinaryLogic $isEnumCase 27 | ) 28 | { 29 | parent::__construct($className, $constantName, $possibleDescendant); 30 | 31 | $this->isEnumCase = $isEnumCase; 32 | } 33 | 34 | protected function getKeyPrefixes(): array 35 | { 36 | if ($this->isEnumCase->maybe()) { 37 | return ['c', 'e']; 38 | } elseif ($this->isEnumCase->yes()) { 39 | return ['e']; 40 | } else { 41 | return ['c']; 42 | } 43 | } 44 | 45 | /** 46 | * @return MemberType::CONSTANT 47 | */ 48 | public function getMemberType(): int 49 | { 50 | return MemberType::CONSTANT; 51 | } 52 | 53 | public function isEnumCase(): TrinaryLogic 54 | { 55 | return $this->isEnumCase; 56 | } 57 | 58 | public function withKnownNames( 59 | string $className, 60 | string $memberName 61 | ): self 62 | { 63 | return new self( 64 | $className, 65 | $memberName, 66 | $this->isPossibleDescendant(), 67 | $this->isEnumCase, 68 | ); 69 | } 70 | 71 | public function withKnownClass(string $className): self 72 | { 73 | return new self( 74 | $className, 75 | $this->getMemberName(), 76 | $this->isPossibleDescendant(), 77 | $this->isEnumCase, 78 | ); 79 | } 80 | 81 | public function withKnownMember(string $memberName): self 82 | { 83 | return new self( 84 | $this->getClassName(), 85 | $memberName, 86 | $this->isPossibleDescendant(), 87 | $this->isEnumCase, 88 | ); 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/Output/OutputEnhancer.php: -------------------------------------------------------------------------------- 1 | relativePathHelper = $relativePathHelper; 24 | $this->editorUrl = $editorUrl; 25 | } 26 | 27 | public function getOriginLink( 28 | UsageOrigin $origin, 29 | string $title 30 | ): string 31 | { 32 | $file = $origin->getFile(); 33 | $line = $origin->getLine(); 34 | 35 | if ($line !== null) { 36 | $title = sprintf('%s:%s', $title, $line); 37 | } 38 | 39 | if ($file !== null && $line !== null) { 40 | return $this->getLinkOrPlain($title, $file, $line); 41 | } 42 | 43 | return $title; 44 | } 45 | 46 | public function getOriginReference( 47 | UsageOrigin $origin, 48 | bool $preferFileLine = true 49 | ): string 50 | { 51 | $file = $origin->getFile(); 52 | $line = $origin->getLine(); 53 | 54 | if ($file !== null && $line !== null) { 55 | $relativeFile = $this->relativePathHelper->getRelativePath($file); 56 | 57 | $title = $origin->getClassName() !== null && $origin->getMethodName() !== null && !$preferFileLine 58 | ? sprintf('%s::%s:%d', $origin->getClassName(), $origin->getMethodName(), $line) 59 | : sprintf('%s:%s', $relativeFile, $line); 60 | 61 | return $this->getLinkOrPlain($title, $file, $line); 62 | } 63 | 64 | if ($origin->getProvider() !== null) { 65 | $note = $origin->getNote() !== null ? " ({$origin->getNote()})" : ''; 66 | return 'virtual usage from ' . $origin->getProvider() . $note; 67 | } 68 | 69 | throw new LogicException('Unknown state of usage origin'); 70 | } 71 | 72 | private function getLinkOrPlain( 73 | string $title, 74 | string $file, 75 | int $line 76 | ): string 77 | { 78 | if ($this->editorUrl === null) { 79 | return $title; 80 | } 81 | 82 | $relativeFile = $this->relativePathHelper->getRelativePath($file); 83 | 84 | return sprintf( 85 | '%s', 86 | str_replace(['%file%', '%relFile%', '%line%'], [$file, $relativeFile, (string) $line], $this->editorUrl), 87 | $title, 88 | ); 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/Reflection/ReflectionHelper.php: -------------------------------------------------------------------------------- 1 | hasMethod($methodName)) { 35 | return false; 36 | } 37 | 38 | try { 39 | return $classReflection->getNativeReflection()->getMethod($methodName)->getBetterReflection()->getDeclaringClass()->getName() === $classReflection->getName(); 40 | } catch (ReflectionException $e) { 41 | return false; 42 | } 43 | } 44 | 45 | public static function hasOwnConstant( 46 | ClassReflection $classReflection, 47 | string $constantName 48 | ): bool 49 | { 50 | $constantReflection = $classReflection->getNativeReflection()->getReflectionConstant($constantName); 51 | 52 | if ($constantReflection === false) { 53 | return false; 54 | } 55 | 56 | return !$constantReflection->isEnumCase() && $constantReflection->getBetterReflection()->getDeclaringClass()->getName() === $classReflection->getName(); 57 | } 58 | 59 | public static function hasOwnEnumCase( 60 | ClassReflection $classReflection, 61 | string $constantName 62 | ): bool 63 | { 64 | $constantReflection = $classReflection->getNativeReflection()->getReflectionConstant($constantName); 65 | 66 | if ($constantReflection === false) { 67 | return false; 68 | } 69 | 70 | return $constantReflection->isEnumCase() && $constantReflection->getBetterReflection()->getDeclaringClass()->getName() === $classReflection->getName(); 71 | } 72 | 73 | public static function hasOwnProperty( 74 | ClassReflection $classReflection, 75 | string $propertyName 76 | ): bool 77 | { 78 | if (!$classReflection->hasProperty($propertyName)) { 79 | return false; 80 | } 81 | 82 | try { 83 | return $classReflection->getNativeReflection()->getProperty($propertyName)->getBetterReflection()->getDeclaringClass()->getName() === $classReflection->getName(); 84 | } catch (ReflectionException $e) { 85 | return false; 86 | } 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /src/Graph/ClassMemberRef.php: -------------------------------------------------------------------------------- 1 | method() 31 | * @param M $memberName Null if member name is unknown, e.g. unknown method like $class->$unknown() 32 | */ 33 | public function __construct( 34 | ?string $className, 35 | ?string $memberName, 36 | bool $possibleDescendant 37 | ) 38 | { 39 | $this->className = $className; 40 | $this->memberName = $memberName; 41 | $this->possibleDescendant = $possibleDescendant; 42 | } 43 | 44 | /** 45 | * @return C 46 | */ 47 | public function getClassName(): ?string 48 | { 49 | return $this->className; 50 | } 51 | 52 | /** 53 | * @return M 54 | */ 55 | public function getMemberName(): ?string 56 | { 57 | return $this->memberName; 58 | } 59 | 60 | public function isPossibleDescendant(): bool 61 | { 62 | return $this->possibleDescendant; 63 | } 64 | 65 | public function toHumanString(): string 66 | { 67 | $classRef = $this->className ?? self::UNKNOWN_CLASS; 68 | $memberRef = $this->memberName ?? self::UNKNOWN_CLASS; 69 | return $classRef . '::' . $memberRef; 70 | } 71 | 72 | /** 73 | * @return list 74 | */ 75 | public function toKeys(): array 76 | { 77 | if ($this->className === null) { 78 | throw new LogicException('Cannot convert to keys without known class name.'); 79 | } 80 | 81 | if ($this->memberName === null) { 82 | throw new LogicException('Cannot convert to keys without known member name.'); 83 | } 84 | 85 | $result = []; 86 | foreach ($this->getKeyPrefixes() as $prefix) { 87 | $result[] = "$prefix/$this->className::$this->memberName"; 88 | } 89 | return $result; 90 | } 91 | 92 | /** 93 | * @phpstan-assert-if-true self $this 94 | */ 95 | public function hasKnownClass(): bool 96 | { 97 | return $this->className !== null; 98 | } 99 | 100 | /** 101 | * @phpstan-assert-if-true self $this 102 | */ 103 | public function hasKnownMember(): bool 104 | { 105 | return $this->memberName !== null; 106 | } 107 | 108 | /** 109 | * @return static 110 | */ 111 | abstract public function withKnownNames( 112 | string $className, 113 | string $memberName 114 | ): self; 115 | 116 | /** 117 | * @return static 118 | */ 119 | abstract public function withKnownClass(string $className): self; 120 | 121 | /** 122 | * @return static 123 | */ 124 | abstract public function withKnownMember(string $memberName): self; 125 | 126 | /** 127 | * @return list 128 | */ 129 | abstract protected function getKeyPrefixes(): array; 130 | 131 | /** 132 | * @return MemberType::* 133 | */ 134 | abstract public function getMemberType(): int; 135 | 136 | } 137 | -------------------------------------------------------------------------------- /src/Provider/BuiltinUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled; 21 | } 22 | 23 | public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 24 | { 25 | if (!$this->enabled) { 26 | return null; 27 | } 28 | 29 | return $this->shouldMarkMemberAsUsed($method); 30 | } 31 | 32 | protected function shouldMarkConstantAsUsed(ReflectionClassConstant $constant): ?VirtualUsageData 33 | { 34 | if (!$this->enabled) { 35 | return null; 36 | } 37 | 38 | return $this->shouldMarkMemberAsUsed($constant); 39 | } 40 | 41 | protected function shouldMarkPropertyAsUsed(ReflectionProperty $property): ?VirtualUsageData 42 | { 43 | if (!$this->enabled) { 44 | return null; 45 | } 46 | 47 | return $this->shouldMarkMemberAsUsed($property); 48 | } 49 | 50 | /** 51 | * @param ReflectionMethod|ReflectionClassConstant|ReflectionProperty $member 52 | */ 53 | private function shouldMarkMemberAsUsed(Reflector $member): ?VirtualUsageData 54 | { 55 | $reflectionClass = $member->getDeclaringClass(); 56 | 57 | do { 58 | if ($this->isBuiltinMember($reflectionClass, $member)) { 59 | return $this->createUsageNote($member); 60 | } 61 | 62 | foreach ($reflectionClass->getInterfaces() as $interface) { 63 | if ($this->isBuiltinMember($interface, $member)) { 64 | return $this->createUsageNote($member); 65 | } 66 | } 67 | 68 | foreach ($reflectionClass->getTraits() as $trait) { 69 | if ($this->isBuiltinMember($trait, $member)) { 70 | return $this->createUsageNote($member); 71 | } 72 | } 73 | 74 | $reflectionClass = $reflectionClass->getParentClass(); 75 | } while ($reflectionClass !== false); 76 | 77 | return null; 78 | } 79 | 80 | /** 81 | * @param ReflectionMethod|ReflectionClassConstant|ReflectionProperty $member 82 | * @param ReflectionClass $reflectionClass 83 | */ 84 | private function isBuiltinMember( 85 | ReflectionClass $reflectionClass, 86 | Reflector $member 87 | ): bool 88 | { 89 | if ($member instanceof ReflectionMethod && !$reflectionClass->hasMethod($member->getName())) { 90 | return false; 91 | } 92 | 93 | if ($member instanceof ReflectionClassConstant && !$reflectionClass->hasConstant($member->getName())) { 94 | return false; 95 | } 96 | 97 | if ($member instanceof ReflectionProperty && !$reflectionClass->hasProperty($member->getName())) { 98 | return false; 99 | } 100 | 101 | return $reflectionClass->getExtensionName() !== false; 102 | } 103 | 104 | /** 105 | * @param ReflectionMethod|ReflectionClassConstant|ReflectionProperty $member 106 | */ 107 | private function createUsageNote(Reflector $member): VirtualUsageData 108 | { 109 | $memberString = ucfirst(ReflectionHelper::getMemberType($member)); 110 | return VirtualUsageData::withNote("$memberString overrides builtin one, thus is assumed to be used by some PHP code."); 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shipmonk/dead-code-detector", 3 | "description": "Dead code detector to find unused PHP code via PHPStan extension. Can automatically remove dead PHP code. Supports libraries like Symfony, Doctrine, PHPUnit etc. Detects dead cycles. Can detect dead code that is tested.", 4 | "license": [ 5 | "MIT" 6 | ], 7 | "type": "phpstan-extension", 8 | "keywords": [ 9 | "phpstan", 10 | "static analysis", 11 | "unused code", 12 | "dead code" 13 | ], 14 | "require": { 15 | "php": "^7.4 || ^8.0", 16 | "phpstan/phpstan": "^2.1.23" 17 | }, 18 | "require-dev": { 19 | "composer-runtime-api": "^2.0", 20 | "composer/semver": "^3.4", 21 | "doctrine/orm": "^2.19 || ^3.0", 22 | "editorconfig-checker/editorconfig-checker": "^10.6.0", 23 | "ergebnis/composer-normalize": "^2.48.1", 24 | "nette/application": "^3.1", 25 | "nette/component-model": "^3.0", 26 | "nette/utils": "^3.0 || ^4.0", 27 | "nikic/php-parser": "^5.4.0", 28 | "phpbench/phpbench": "^1.2", 29 | "phpstan/phpstan-phpunit": "^2.0.4", 30 | "phpstan/phpstan-strict-rules": "^2.0.3", 31 | "phpstan/phpstan-symfony": "^2.0.2", 32 | "phpunit/phpcov": "^8.2", 33 | "phpunit/phpunit": "^9.6.22", 34 | "shipmonk/coding-standard": "^0.2.0", 35 | "shipmonk/composer-dependency-analyser": "^1.8.2", 36 | "shipmonk/coverage-guard": "^1.0.0", 37 | "shipmonk/name-collision-detector": "^2.1.1", 38 | "shipmonk/phpstan-dev": "^0.1.1", 39 | "shipmonk/phpstan-rules": "^4.1.0", 40 | "symfony/contracts": "^2.5 || ^3.0 || ^4.0", 41 | "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0", 42 | "symfony/doctrine-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0", 43 | "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0 || ^8.0", 44 | "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0 || ^8.0", 45 | "symfony/routing": "^5.4 || ^6.0 || ^7.0 || ^8.0", 46 | "symfony/validator": "^5.4 || ^6.0 || ^7.0 || ^8.0", 47 | "twig/twig": "^3.0" 48 | }, 49 | "autoload": { 50 | "psr-4": { 51 | "ShipMonk\\PHPStan\\DeadCode\\": "src/" 52 | } 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "ShipMonk\\PHPStan\\DeadCode\\": "tests/" 57 | }, 58 | "classmap": [ 59 | "tests/Rule/data" 60 | ] 61 | }, 62 | "config": { 63 | "allow-plugins": { 64 | "dealerdirect/phpcodesniffer-composer-installer": false, 65 | "ergebnis/composer-normalize": true 66 | }, 67 | "sort-packages": true 68 | }, 69 | "extra": { 70 | "phpstan": { 71 | "includes": [ 72 | "rules.neon" 73 | ] 74 | } 75 | }, 76 | "scripts": { 77 | "check": [ 78 | "@check:composer", 79 | "@check:ec", 80 | "@check:cs", 81 | "@check:types", 82 | "@check:coverage", 83 | "@check:collisions", 84 | "@check:dependencies" 85 | ], 86 | "check:collisions": "detect-collisions src tests", 87 | "check:composer": [ 88 | "composer normalize --dry-run --no-update-lock", 89 | "composer validate --strict" 90 | ], 91 | "check:coverage": [ 92 | "XDEBUG_MODE=coverage phpunit tests --coverage-clover cache/clover.xml", 93 | "coverage-guard check cache/clover.xml --color" 94 | ], 95 | "check:cs": "phpcs", 96 | "check:dependencies": "composer-dependency-analyser", 97 | "check:ec": "ec src tests", 98 | "check:tests": "phpunit tests", 99 | "check:types": "phpstan analyse -vv --ansi", 100 | "fix:cs": "phpcbf" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Provider/StreamWrapperUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled; 53 | } 54 | 55 | public function getUsages( 56 | Node $node, 57 | Scope $scope 58 | ): array 59 | { 60 | if (!$this->enabled) { 61 | return []; 62 | } 63 | 64 | if (!$node instanceof FuncCall) { 65 | return []; 66 | } 67 | 68 | return $this->processFunctionCall($node, $scope); 69 | } 70 | 71 | /** 72 | * @return list 73 | */ 74 | private function processFunctionCall( 75 | FuncCall $node, 76 | Scope $scope 77 | ): array 78 | { 79 | $functionNames = $this->getFunctionNames($node, $scope); 80 | 81 | if (in_array('stream_wrapper_register', $functionNames, true)) { 82 | return $this->handleStreamWrapperRegister($node, $scope); 83 | } 84 | 85 | return []; 86 | } 87 | 88 | /** 89 | * @return list 90 | */ 91 | private function getFunctionNames( 92 | FuncCall $node, 93 | Scope $scope 94 | ): array 95 | { 96 | if ($node->name instanceof Name) { 97 | return [$node->name->toString()]; 98 | } 99 | 100 | $functionNames = []; 101 | foreach ($scope->getType($node->name)->getConstantStrings() as $constantString) { 102 | $functionNames[] = $constantString->getValue(); 103 | } 104 | 105 | return $functionNames; 106 | } 107 | 108 | /** 109 | * @return list 110 | */ 111 | private function handleStreamWrapperRegister( 112 | FuncCall $node, 113 | Scope $scope 114 | ): array 115 | { 116 | $secondArg = $node->getArgs()[1] ?? null; 117 | if ($secondArg === null) { 118 | return []; 119 | } 120 | 121 | $argType = $scope->getType($secondArg->value); 122 | 123 | $usages = []; 124 | $classNames = []; 125 | foreach ($argType->getConstantStrings() as $constantString) { 126 | $classNames[] = $constantString->getValue(); 127 | } 128 | 129 | foreach ($classNames as $className) { 130 | foreach (self::STREAM_WRAPPER_METHODS as $methodName) { 131 | $usages[] = new ClassMethodUsage( 132 | UsageOrigin::createRegular($node, $scope), 133 | new ClassMethodRef( 134 | $className, 135 | $methodName, 136 | false, 137 | ), 138 | ); 139 | } 140 | } 141 | 142 | return $usages; 143 | } 144 | 145 | } 146 | -------------------------------------------------------------------------------- /src/Graph/UsageOrigin.php: -------------------------------------------------------------------------------- 1 | className = $className; 43 | $this->methodName = $methodName; 44 | $this->fileName = $fileName; 45 | $this->line = $line; 46 | $this->provider = $provider; 47 | $this->note = $note; 48 | } 49 | 50 | /** 51 | * Creates virtual usage origin with no reference to any place in code 52 | */ 53 | public static function createVirtual( 54 | MemberUsageProvider $provider, 55 | VirtualUsageData $data 56 | ): self 57 | { 58 | return new self( 59 | null, 60 | null, 61 | null, 62 | null, 63 | get_class($provider), 64 | $data->getNote(), 65 | ); 66 | } 67 | 68 | /** 69 | * Creates usage origin with reference to file:line 70 | */ 71 | public static function createRegular( 72 | Node $node, 73 | Scope $scope 74 | ): self 75 | { 76 | $file = $scope->isInTrait() 77 | ? $scope->getTraitReflection()->getFileName() 78 | : $scope->getFile(); 79 | 80 | $function = $scope->getFunction(); 81 | $isRegularMethod = $function !== null && $function->isMethodOrPropertyHook() && !$function->isPropertyHook(); 82 | 83 | if (!$scope->isInClass() || !$isRegularMethod) { 84 | return new self( 85 | null, 86 | null, 87 | $file, 88 | $node->getStartLine(), 89 | null, 90 | null, 91 | ); 92 | } 93 | 94 | return new self( 95 | $scope->getClassReflection()->getName(), 96 | $function->getName(), 97 | $file, 98 | $node->getStartLine(), 99 | null, 100 | null, 101 | ); 102 | } 103 | 104 | public function getClassName(): ?string 105 | { 106 | return $this->className; 107 | } 108 | 109 | public function getMethodName(): ?string 110 | { 111 | return $this->methodName; 112 | } 113 | 114 | public function getFile(): ?string 115 | { 116 | return $this->fileName; 117 | } 118 | 119 | public function getLine(): ?int 120 | { 121 | return $this->line; 122 | } 123 | 124 | public function getProvider(): ?string 125 | { 126 | return $this->provider; 127 | } 128 | 129 | public function getNote(): ?string 130 | { 131 | return $this->note; 132 | } 133 | 134 | public function hasClassMethodRef(): bool 135 | { 136 | return $this->className !== null && $this->methodName !== null; 137 | } 138 | 139 | /** 140 | * @return ClassMethodRef 141 | */ 142 | public function toClassMethodRef(): ClassMethodRef 143 | { 144 | if ($this->className === null || $this->methodName === null) { 145 | throw new LogicException('Usage origin does not have class method ref'); 146 | } 147 | 148 | return new ClassMethodRef( 149 | $this->className, 150 | $this->methodName, 151 | false, 152 | ); 153 | } 154 | 155 | } 156 | -------------------------------------------------------------------------------- /src/Error/BlackMember.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | private ClassMemberRef $member; 24 | 25 | private string $file; 26 | 27 | private int $line; 28 | 29 | /** 30 | * @var array> 31 | */ 32 | private array $excludedUsages = []; 33 | 34 | /** 35 | * @param ClassMemberRef $member 36 | */ 37 | public function __construct( 38 | ClassMemberRef $member, 39 | string $file, 40 | int $line 41 | ) 42 | { 43 | if ($member->isPossibleDescendant()) { 44 | throw new LogicException('Using possible descendant does not make sense here'); 45 | } 46 | 47 | if ($member instanceof ClassConstantRef && $member->isEnumCase()->maybe()) { 48 | throw new LogicException('Black member cannot be unresolved, it references definition, not usage'); 49 | } 50 | 51 | $this->member = $member; 52 | $this->file = $file; 53 | $this->line = $line; 54 | } 55 | 56 | /** 57 | * @return ClassMemberRef 58 | */ 59 | public function getMember(): ClassMemberRef 60 | { 61 | return $this->member; 62 | } 63 | 64 | public function getFile(): string 65 | { 66 | return $this->file; 67 | } 68 | 69 | public function getLine(): int 70 | { 71 | return $this->line; 72 | } 73 | 74 | public function addExcludedUsage(CollectedUsage $excludedUsage): void 75 | { 76 | if (!$excludedUsage->isExcluded()) { 77 | throw new LogicException('Given usage is not excluded!'); 78 | } 79 | 80 | $excludedBy = $excludedUsage->getExcludedBy(); 81 | 82 | $this->excludedUsages[$excludedBy][] = $excludedUsage->getUsage(); 83 | } 84 | 85 | public function getErrorIdentifier(): string 86 | { 87 | if ($this->member instanceof ClassConstantRef) { 88 | if ($this->member->isEnumCase()->yes()) { 89 | return DeadCodeRule::IDENTIFIER_ENUM_CASE; 90 | 91 | } elseif ($this->member->isEnumCase()->no()) { 92 | return DeadCodeRule::IDENTIFIER_CONSTANT; 93 | 94 | } else { 95 | throw new LogicException('Cannot happen, ensured in constructor'); 96 | } 97 | 98 | } elseif ($this->member instanceof ClassMethodRef) { 99 | return DeadCodeRule::IDENTIFIER_METHOD; 100 | 101 | } elseif ($this->member instanceof ClassPropertyRef) { 102 | return DeadCodeRule::IDENTIFIER_PROPERTY; 103 | 104 | } else { 105 | throw new LogicException('Unknown member type'); 106 | } 107 | } 108 | 109 | public function getExclusionMessage(): string 110 | { 111 | if (count($this->excludedUsages) === 0) { 112 | return ''; 113 | } 114 | 115 | $excluderNames = implode(', ', array_keys($this->excludedUsages)); 116 | $plural = count($this->excludedUsages) > 1 ? 's' : ''; 117 | 118 | return " (all usages excluded by {$excluderNames} excluder{$plural})"; 119 | } 120 | 121 | /** 122 | * @return list 123 | */ 124 | public function getExcludedUsages(): array 125 | { 126 | $result = []; 127 | 128 | foreach ($this->excludedUsages as $usages) { 129 | foreach ($usages as $usage) { 130 | $result[] = $usage; 131 | } 132 | } 133 | 134 | return $result; 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /src/Collector/ProvidedUsagesCollector.php: -------------------------------------------------------------------------------- 1 | > 19 | */ 20 | final class ProvidedUsagesCollector implements Collector 21 | { 22 | 23 | use BufferedUsageCollector; 24 | 25 | private ReflectionProvider $reflectionProvider; 26 | 27 | /** 28 | * @var list 29 | */ 30 | private array $memberUsageProviders; 31 | 32 | /** 33 | * @var list 34 | */ 35 | private array $memberUsageExcluders; 36 | 37 | /** 38 | * @param list $memberUsageProviders 39 | * @param list $memberUsageExcluders 40 | */ 41 | public function __construct( 42 | ReflectionProvider $reflectionProvider, 43 | array $memberUsageProviders, 44 | array $memberUsageExcluders 45 | ) 46 | { 47 | $this->reflectionProvider = $reflectionProvider; 48 | $this->memberUsageProviders = $memberUsageProviders; 49 | $this->memberUsageExcluders = $memberUsageExcluders; 50 | } 51 | 52 | public function getNodeType(): string 53 | { 54 | return Node::class; 55 | } 56 | 57 | /** 58 | * @return non-empty-list|null 59 | */ 60 | public function processNode( 61 | Node $node, 62 | Scope $scope 63 | ): ?array 64 | { 65 | foreach ($this->memberUsageProviders as $memberUsageProvider) { 66 | $newUsages = $memberUsageProvider->getUsages($node, $scope); 67 | 68 | foreach ($newUsages as $newUsage) { 69 | $collectedUsage = $this->resolveExclusion($newUsage, $node, $scope); 70 | 71 | $this->validateUsage($newUsage, $memberUsageProvider, $node, $scope); 72 | $this->usages[] = $collectedUsage; 73 | } 74 | } 75 | 76 | return $this->emitUsages($scope); 77 | } 78 | 79 | private function validateUsage( 80 | ClassMemberUsage $usage, 81 | MemberUsageProvider $provider, 82 | Node $node, 83 | Scope $scope 84 | ): void 85 | { 86 | $origin = $usage->getOrigin(); 87 | $originClass = $origin->getClassName(); 88 | $originMethod = $origin->getMethodName(); 89 | 90 | $context = sprintf( 91 | "It emitted usage of %s by %s for node '%s' in '%s' on line %s", 92 | $usage->getMemberRef()->toHumanString(), 93 | get_class($provider), 94 | get_class($node), 95 | $scope->getFile(), 96 | $node->getStartLine(), 97 | ); 98 | 99 | if ($originClass !== null) { 100 | if (!$this->reflectionProvider->hasClass($originClass)) { 101 | throw new LogicException("Class '{$originClass}' does not exist. $context"); 102 | } 103 | 104 | if ($originMethod !== null && !$this->reflectionProvider->getClass($originClass)->hasMethod($originMethod)) { 105 | throw new LogicException("Method '{$originMethod}' does not exist in class '$originClass'. $context"); 106 | } 107 | } 108 | } 109 | 110 | private function resolveExclusion( 111 | ClassMemberUsage $usage, 112 | Node $node, 113 | Scope $scope 114 | ): CollectedUsage 115 | { 116 | $excluderName = null; 117 | 118 | foreach ($this->memberUsageExcluders as $excludedUsageDecider) { 119 | if ($excludedUsageDecider->shouldExclude($usage, $node, $scope)) { 120 | $excluderName = $excludedUsageDecider->getIdentifier(); 121 | break; 122 | } 123 | } 124 | 125 | return new CollectedUsage($usage, $excluderName); 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /src/Provider/VendorUsageProvider.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | private array $vendorDirs; 25 | 26 | private bool $enabled; 27 | 28 | public function __construct(bool $enabled) 29 | { 30 | $this->vendorDirs = array_keys(ClassLoader::getRegisteredLoaders()); 31 | $this->enabled = $enabled; 32 | } 33 | 34 | public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 35 | { 36 | if (!$this->enabled) { 37 | return null; 38 | } 39 | 40 | return $this->shouldMarkMemberAsUsed($method); 41 | } 42 | 43 | protected function shouldMarkConstantAsUsed(ReflectionClassConstant $constant): ?VirtualUsageData 44 | { 45 | if (!$this->enabled) { 46 | return null; 47 | } 48 | 49 | return $this->shouldMarkMemberAsUsed($constant); 50 | } 51 | 52 | protected function shouldMarkPropertyAsUsed(ReflectionProperty $property): ?VirtualUsageData 53 | { 54 | if (!$this->enabled) { 55 | return null; 56 | } 57 | 58 | return $this->shouldMarkMemberAsUsed($property); 59 | } 60 | 61 | /** 62 | * @param ReflectionMethod|ReflectionClassConstant|ReflectionProperty $member 63 | */ 64 | private function shouldMarkMemberAsUsed(Reflector $member): ?VirtualUsageData 65 | { 66 | $reflectionClass = $member->getDeclaringClass(); 67 | $memberString = ucfirst(ReflectionHelper::getMemberType($member)); 68 | $usage = VirtualUsageData::withNote($memberString . ' overrides vendor one, thus is expected to be used by vendor code'); 69 | 70 | do { 71 | if ($this->isForeignMember($reflectionClass, $member)) { 72 | return $usage; 73 | } 74 | 75 | foreach ($reflectionClass->getInterfaces() as $interface) { 76 | if ($this->isForeignMember($interface, $member)) { 77 | return $usage; 78 | } 79 | } 80 | 81 | foreach ($reflectionClass->getTraits() as $trait) { 82 | if ($this->isForeignMember($trait, $member)) { 83 | return $usage; 84 | } 85 | } 86 | 87 | $reflectionClass = $reflectionClass->getParentClass(); 88 | } while ($reflectionClass !== false); 89 | 90 | return null; 91 | } 92 | 93 | /** 94 | * @param ReflectionMethod|ReflectionClassConstant|ReflectionProperty $member 95 | * @param ReflectionClass $reflectionClass 96 | */ 97 | private function isForeignMember( 98 | ReflectionClass $reflectionClass, 99 | Reflector $member 100 | ): bool 101 | { 102 | if ($member instanceof ReflectionMethod && !$reflectionClass->hasMethod($member->getName())) { 103 | return false; 104 | } 105 | 106 | if ($member instanceof ReflectionClassConstant && !$reflectionClass->hasConstant($member->getName())) { 107 | return false; 108 | } 109 | 110 | if ($member instanceof ReflectionProperty && !$reflectionClass->hasProperty($member->getName())) { 111 | return false; 112 | } 113 | 114 | if ($reflectionClass->getExtensionName() !== false) { 115 | return false; // many built-in classes have stubs in PHPStan (with filepath in vendor); BuiltinUsageProvider will handle them 116 | } 117 | 118 | $filePath = $reflectionClass->getFileName(); 119 | if ($filePath === false) { 120 | return false; 121 | } 122 | 123 | $pharPrefix = 'phar://'; 124 | 125 | if (strpos($filePath, $pharPrefix) === 0) { 126 | /** @var string $filePath Cannot resolve to false */ 127 | $filePath = substr($filePath, strlen($pharPrefix)); 128 | } 129 | 130 | foreach ($this->vendorDirs as $vendorDir) { 131 | if (strpos($filePath, $vendorDir) === 0) { 132 | return true; 133 | } 134 | } 135 | 136 | return false; 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /src/Provider/BehatUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled ?? InstalledVersions::isInstalled('behat/behat'); 23 | } 24 | 25 | public function getUsages( 26 | Node $node, 27 | Scope $scope 28 | ): array 29 | { 30 | if (!$this->enabled || !$node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 31 | return []; 32 | } 33 | 34 | $classReflection = $node->getClassReflection(); 35 | 36 | if (!$classReflection->implementsInterface('Behat\Behat\Context\Context')) { 37 | return []; 38 | } 39 | 40 | $usages = []; 41 | $className = $classReflection->getName(); 42 | 43 | foreach ($classReflection->getNativeReflection()->getMethods() as $method) { 44 | $methodName = $method->getName(); 45 | 46 | if ($method->isConstructor()) { 47 | $usages[] = $this->createUsage($className, $methodName, 'Behat context constructor'); 48 | } elseif ($this->isBehatContextMethod($method)) { 49 | $usages[] = $this->createUsage($className, $methodName, 'Behat step definition or hook'); 50 | } 51 | } 52 | 53 | return $usages; 54 | } 55 | 56 | private function isBehatContextMethod(ReflectionMethod $method): bool 57 | { 58 | return $this->hasAnnotation($method, '@Given') 59 | || $this->hasAnnotation($method, '@When') 60 | || $this->hasAnnotation($method, '@Then') 61 | || $this->hasAnnotation($method, '@BeforeScenario') 62 | || $this->hasAnnotation($method, '@AfterScenario') 63 | || $this->hasAnnotation($method, '@BeforeStep') 64 | || $this->hasAnnotation($method, '@AfterStep') 65 | || $this->hasAnnotation($method, '@BeforeSuite') 66 | || $this->hasAnnotation($method, '@AfterSuite') 67 | || $this->hasAnnotation($method, '@BeforeFeature') 68 | || $this->hasAnnotation($method, '@AfterFeature') 69 | || $this->hasAnnotation($method, '@Transform') 70 | || $this->hasAttribute($method, 'Behat\Step\Given') 71 | || $this->hasAttribute($method, 'Behat\Step\When') 72 | || $this->hasAttribute($method, 'Behat\Step\Then') 73 | || $this->hasAttribute($method, 'Behat\Hook\BeforeScenario') 74 | || $this->hasAttribute($method, 'Behat\Hook\AfterScenario') 75 | || $this->hasAttribute($method, 'Behat\Hook\BeforeStep') 76 | || $this->hasAttribute($method, 'Behat\Hook\AfterStep') 77 | || $this->hasAttribute($method, 'Behat\Hook\BeforeSuite') 78 | || $this->hasAttribute($method, 'Behat\Hook\AfterSuite') 79 | || $this->hasAttribute($method, 'Behat\Hook\BeforeFeature') 80 | || $this->hasAttribute($method, 'Behat\Hook\AfterFeature') 81 | || $this->hasAttribute($method, 'Behat\Transformation\Transform'); 82 | } 83 | 84 | private function hasAnnotation( 85 | ReflectionMethod $method, 86 | string $string 87 | ): bool 88 | { 89 | if ($method->getDocComment() === false) { 90 | return false; 91 | } 92 | 93 | return strpos($method->getDocComment(), $string) !== false; 94 | } 95 | 96 | private function hasAttribute( 97 | ReflectionMethod $method, 98 | string $attributeClass 99 | ): bool 100 | { 101 | return $method->getAttributes($attributeClass) !== []; 102 | } 103 | 104 | private function createUsage( 105 | string $className, 106 | string $methodName, 107 | string $reason 108 | ): ClassMethodUsage 109 | { 110 | return new ClassMethodUsage( 111 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote($reason)), 112 | new ClassMethodRef( 113 | $className, 114 | $methodName, 115 | false, 116 | ), 117 | ); 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/Transformer/RemoveClassMemberVisitor.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | private array $deadMethods; 35 | 36 | /** 37 | * @var array 38 | */ 39 | private array $deadConstants; 40 | 41 | /** 42 | * @var array 43 | */ 44 | private array $deadProperties; 45 | 46 | /** 47 | * @param list $deadMethods 48 | * @param list $deadConstants 49 | * @param list $deadProperties 50 | */ 51 | public function __construct( 52 | array $deadMethods, 53 | array $deadConstants, 54 | array $deadProperties 55 | ) 56 | { 57 | $this->deadMethods = array_fill_keys($deadMethods, true); 58 | $this->deadConstants = array_fill_keys($deadConstants, true); 59 | $this->deadProperties = array_fill_keys($deadProperties, true); 60 | } 61 | 62 | public function enterNode(Node $node): ?Node 63 | { 64 | if ($node instanceof Namespace_ && $node->name !== null) { 65 | $this->currentNamespace = $node->name->toString(); 66 | 67 | } elseif ($node instanceof ClassLike && $node->name !== null) { 68 | $this->currentClass = $node->name->name; 69 | } 70 | 71 | return null; 72 | } 73 | 74 | public function leaveNode(Node $node): ?int 75 | { 76 | if ($node instanceof ClassMethod) { 77 | $methodKey = $this->getNamespacedName($node->name); 78 | 79 | if (isset($this->deadMethods[$methodKey])) { 80 | return NodeTraverser::REMOVE_NODE; 81 | } 82 | 83 | // Handle promoted properties in constructor parameters 84 | $node->params = array_filter($node->params, function (Param $param): bool { 85 | if (!$param->isPromoted() || !$param->var instanceof Variable) { 86 | return true; 87 | } 88 | 89 | $paramName = $param->var->name; 90 | 91 | if (!is_string($paramName)) { 92 | return true; 93 | } 94 | 95 | $propertyKey = ltrim($this->currentNamespace . '\\' . $this->currentClass, '\\') . '::' . $paramName; 96 | 97 | return !isset($this->deadProperties[$propertyKey]); 98 | }); 99 | } 100 | 101 | if ($node instanceof ClassConst) { 102 | $allDead = true; 103 | 104 | foreach ($node->consts as $const) { 105 | $constKey = $this->getNamespacedName($const->name); 106 | 107 | if (!isset($this->deadConstants[$constKey])) { 108 | $allDead = false; 109 | break; 110 | } 111 | } 112 | 113 | if ($allDead) { 114 | return NodeTraverser::REMOVE_NODE; 115 | } 116 | } 117 | 118 | if ($node instanceof Const_) { 119 | $constKey = $this->getNamespacedName($node->name); 120 | 121 | if (isset($this->deadConstants[$constKey])) { 122 | return NodeTraverser::REMOVE_NODE; 123 | } 124 | } 125 | 126 | if ($node instanceof EnumCase) { 127 | $enumCaseKey = $this->getNamespacedName($node->name); 128 | 129 | if (isset($this->deadConstants[$enumCaseKey])) { 130 | return NodeTraverser::REMOVE_NODE; 131 | } 132 | } 133 | 134 | if ($node instanceof Property) { 135 | $allDead = true; 136 | 137 | foreach ($node->props as $prop) { 138 | $propertyKey = $this->getNamespacedName($prop->name); 139 | 140 | if (!isset($this->deadProperties[$propertyKey])) { 141 | $allDead = false; 142 | break; 143 | } 144 | } 145 | 146 | if ($allDead) { 147 | return NodeTraverser::REMOVE_NODE; 148 | } 149 | } 150 | 151 | return null; 152 | } 153 | 154 | /** 155 | * @param Name|Identifier $name 156 | */ 157 | private function getNamespacedName(Node $name): string 158 | { 159 | return ltrim($this->currentNamespace . '\\' . $this->currentClass, '\\') . '::' . $name->name; 160 | } 161 | 162 | } 163 | -------------------------------------------------------------------------------- /src/Provider/EnumUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled; 35 | } 36 | 37 | public function getUsages( 38 | Node $node, 39 | Scope $scope 40 | ): array 41 | { 42 | if ($this->enabled === false) { 43 | return []; 44 | } 45 | 46 | if ($node instanceof StaticCall || $node instanceof MethodCall) { 47 | return $this->getTryFromUsages($node, $scope); 48 | } 49 | 50 | return []; 51 | } 52 | 53 | /** 54 | * @param StaticCall|MethodCall $methodCall 55 | * @return list 56 | */ 57 | private function getTryFromUsages( 58 | CallLike $methodCall, 59 | Scope $scope 60 | ): array 61 | { 62 | $methodNames = $this->getMethodNames($methodCall, $scope); 63 | $firstArgType = $this->getArgType($methodCall, $scope, 0); 64 | 65 | if ($methodCall instanceof StaticCall) { 66 | $callerType = $methodCall->class instanceof Expr 67 | ? $scope->getType($methodCall->class) 68 | : $scope->resolveTypeByName($methodCall->class); 69 | } else { 70 | $callerType = $scope->getType($methodCall->var); 71 | } 72 | 73 | $typeNoNull = TypeCombinator::removeNull($callerType); // remove null to support nullsafe calls 74 | $typeNormalized = TypeUtils::toBenevolentUnion($typeNoNull); // extract possible calls even from Class|int 75 | $classReflections = $typeNormalized->getObjectTypeOrClassStringObjectType()->getObjectClassReflections(); 76 | 77 | $result = []; 78 | 79 | foreach ($methodNames as $methodName) { 80 | if (!in_array($methodName, ['tryFrom', 'from', 'cases'], true)) { 81 | continue; 82 | } 83 | 84 | foreach ($classReflections as $classReflection) { 85 | if (!$classReflection->isEnum()) { 86 | continue; 87 | } 88 | 89 | $valueToCaseMapping = $this->getValueToEnumCaseMapping($classReflection->getNativeReflection()); 90 | $triedValues = $firstArgType->getConstantScalarValues() === [] 91 | ? [null] 92 | : array_filter($firstArgType->getConstantScalarValues(), static fn ($value): bool => is_string($value) || is_int($value)); 93 | 94 | foreach ($triedValues as $value) { 95 | $enumCase = $value === null ? null : $valueToCaseMapping[$value] ?? null; 96 | $result[] = new ClassConstantUsage( 97 | UsageOrigin::createRegular($methodCall, $scope), 98 | new ClassConstantRef($classReflection->getName(), $enumCase, false, TrinaryLogic::createYes()), 99 | ); 100 | } 101 | } 102 | } 103 | 104 | return $result; 105 | } 106 | 107 | /** 108 | * @param StaticCall|MethodCall $call 109 | * @return list 110 | */ 111 | private function getMethodNames( 112 | CallLike $call, 113 | Scope $scope 114 | ): array 115 | { 116 | if ($call->name instanceof Expr) { 117 | $possibleMethodNames = []; 118 | 119 | foreach ($scope->getType($call->name)->getConstantStrings() as $constantString) { 120 | $possibleMethodNames[] = $constantString->getValue(); 121 | } 122 | 123 | return $possibleMethodNames === [] 124 | ? [null] // unknown method name 125 | : $possibleMethodNames; 126 | } 127 | 128 | return [$call->name->name]; 129 | } 130 | 131 | /** 132 | * @param StaticCall|MethodCall $call 133 | */ 134 | private function getArgType( 135 | CallLike $call, 136 | Scope $scope, 137 | int $position 138 | ): Type 139 | { 140 | $args = $call->getArgs(); 141 | 142 | if (isset($args[$position])) { 143 | return $scope->getType($args[$position]->value); 144 | } 145 | 146 | return new MixedType(); 147 | } 148 | 149 | /** 150 | * @param ReflectionEnum $enumReflection 151 | * @return array 152 | */ 153 | private function getValueToEnumCaseMapping(ReflectionEnum $enumReflection): array 154 | { 155 | $mapping = []; 156 | 157 | foreach ($enumReflection->getCases() as $enumCaseReflection) { 158 | if (!$enumCaseReflection instanceof ReflectionEnumBackedCase) { 159 | continue; 160 | } 161 | 162 | $mapping[$enumCaseReflection->getBackingValue()] = $enumCaseReflection->getName(); 163 | } 164 | 165 | return $mapping; 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /src/Provider/ApiPhpDocUsageProvider.php: -------------------------------------------------------------------------------- 1 | reflectionProvider = $reflectionProvider; 27 | $this->enabled = $enabled; 28 | } 29 | 30 | public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 31 | { 32 | return $this->enabled ? $this->shouldMarkMemberAsUsed($method) : null; 33 | } 34 | 35 | public function shouldMarkConstantAsUsed(ReflectionClassConstant $constant): ?VirtualUsageData 36 | { 37 | return $this->enabled ? $this->shouldMarkMemberAsUsed($constant) : null; 38 | } 39 | 40 | public function shouldMarkEnumCaseAsUsed(ReflectionEnumUnitCase $enumCase): ?VirtualUsageData 41 | { 42 | return $this->enabled ? $this->shouldMarkMemberAsUsed($enumCase) : null; 43 | } 44 | 45 | public function shouldMarkPropertyAsUsed(ReflectionProperty $property): ?VirtualUsageData 46 | { 47 | return $this->enabled ? $this->shouldMarkMemberAsUsed($property) : null; 48 | } 49 | 50 | /** 51 | * @param ReflectionClassConstant|ReflectionMethod|ReflectionProperty $member 52 | */ 53 | public function shouldMarkMemberAsUsed(object $member): ?VirtualUsageData 54 | { 55 | $reflectionClass = $this->reflectionProvider->getClass($member->getDeclaringClass()->getName()); 56 | $memberType = ReflectionHelper::getMemberType($member); 57 | $memberName = $member->getName(); 58 | 59 | if ($this->isApiMember($reflectionClass, $member)) { 60 | return VirtualUsageData::withNote("Class {$reflectionClass->getName()} is public @api"); 61 | } 62 | 63 | do { 64 | foreach ($reflectionClass->getInterfaces() as $interface) { 65 | if ($this->isApiMember($interface, $member)) { 66 | return VirtualUsageData::withNote("Interface $memberType {$interface->getName()}::{$memberName} is public @api"); 67 | } 68 | } 69 | 70 | foreach ($reflectionClass->getParents() as $parent) { 71 | if ($this->isApiMember($parent, $member)) { 72 | return VirtualUsageData::withNote("Class $memberType {$parent->getName()}::{$memberName} is public @api"); 73 | } 74 | } 75 | 76 | $reflectionClass = $reflectionClass->getParentClass(); 77 | } while ($reflectionClass !== null); 78 | 79 | return null; 80 | } 81 | 82 | /** 83 | * @param ReflectionClassConstant|ReflectionMethod|ReflectionProperty $member 84 | */ 85 | private function isApiMember( 86 | ClassReflection $reflection, 87 | object $member 88 | ): bool 89 | { 90 | if (!$this->hasOwnMember($reflection, $member)) { 91 | return false; 92 | } 93 | 94 | if ($this->isApiClass($reflection)) { 95 | return true; 96 | } 97 | 98 | if ($member instanceof ReflectionClassConstant) { 99 | $constant = $reflection->getConstant($member->getName()); 100 | $phpDoc = $constant->getDocComment(); 101 | 102 | if ($this->isApiPhpDoc($phpDoc)) { 103 | return true; 104 | } 105 | 106 | return false; 107 | } 108 | 109 | if ($member instanceof ReflectionProperty) { 110 | $property = $reflection->getNativeProperty($member->getName()); 111 | $phpDoc = $property->getDocComment(); 112 | 113 | if ($this->isApiPhpDoc($phpDoc)) { 114 | return true; 115 | } 116 | 117 | return false; 118 | } 119 | 120 | $phpDoc = $reflection->getNativeMethod($member->getName())->getDocComment(); 121 | 122 | if ($this->isApiPhpDoc($phpDoc)) { 123 | return true; 124 | } 125 | 126 | return false; 127 | } 128 | 129 | /** 130 | * @param ReflectionClassConstant|ReflectionMethod|ReflectionProperty $member 131 | */ 132 | private function hasOwnMember( 133 | ClassReflection $reflection, 134 | object $member 135 | ): bool 136 | { 137 | if ($member instanceof ReflectionEnumUnitCase) { 138 | return ReflectionHelper::hasOwnEnumCase($reflection, $member->getName()); 139 | } 140 | 141 | if ($member instanceof ReflectionClassConstant) { 142 | return ReflectionHelper::hasOwnConstant($reflection, $member->getName()); 143 | } 144 | 145 | if ($member instanceof ReflectionProperty) { 146 | return ReflectionHelper::hasOwnProperty($reflection, $member->getName()); 147 | } 148 | 149 | return ReflectionHelper::hasOwnMethod($reflection, $member->getName()); 150 | } 151 | 152 | private function isApiClass(ClassReflection $reflection): bool 153 | { 154 | $phpDoc = $reflection->getResolvedPhpDoc(); 155 | 156 | if ($phpDoc === null) { 157 | return false; 158 | } 159 | 160 | if ($this->isApiPhpDoc($phpDoc->getPhpDocString())) { 161 | return true; 162 | } 163 | 164 | return false; 165 | } 166 | 167 | private function isApiPhpDoc(?string $phpDoc): bool 168 | { 169 | return $phpDoc !== null && strpos($phpDoc, '@api') !== false; 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /src/Graph/CollectedUsage.php: -------------------------------------------------------------------------------- 1 | usage = $usage; 26 | $this->excludedBy = $excludedBy; 27 | } 28 | 29 | public function getUsage(): ClassMemberUsage 30 | { 31 | return $this->usage; 32 | } 33 | 34 | public function isExcluded(): bool 35 | { 36 | return $this->excludedBy !== null; 37 | } 38 | 39 | public function getExcludedBy(): string 40 | { 41 | if ($this->excludedBy === null) { 42 | throw new LogicException('Usage is not excluded, use isExcluded() before calling this method'); 43 | } 44 | 45 | return $this->excludedBy; 46 | } 47 | 48 | public function concretizeMixedClassNameUsage(string $className): self 49 | { 50 | return new self( 51 | $this->usage->concretizeMixedClassNameUsage($className), 52 | $this->excludedBy, 53 | ); 54 | } 55 | 56 | /** 57 | * Scope file is passed to optimize transferred data size (and thus result cache size) 58 | * - PHPStan itself transfers all collector data along with scope file 59 | * - thus if our data match those already-transferred ones, lets omit those 60 | * 61 | * @see https://github.com/phpstan/phpstan-src/blob/2fe4e0f94e75fe8844a21fdb81799f01f0591dfe/src/Analyser/FileAnalyser.php#L198 62 | */ 63 | public function serialize(string $scopeFile): string 64 | { 65 | $origin = $this->usage->getOrigin(); 66 | $memberRef = $this->usage->getMemberRef(); 67 | 68 | $data = [ 69 | 'e' => $this->excludedBy, 70 | 't' => $this->usage->getMemberType(), 71 | 'o' => [ 72 | 'c' => $origin->getClassName(), 73 | 'm' => $origin->getMethodName(), 74 | 'f' => $origin->getFile() === $scopeFile ? '_' : $origin->getFile(), 75 | 'l' => $origin->getLine(), 76 | 'p' => $origin->getProvider(), 77 | 'n' => $origin->getNote(), 78 | ], 79 | 'm' => [ 80 | 'c' => $memberRef->getClassName(), 81 | 'm' => $memberRef->getMemberName(), 82 | 'd' => $memberRef->isPossibleDescendant(), 83 | 'e' => $memberRef instanceof ClassConstantRef ? $this->serializeTrinary($memberRef->isEnumCase()) : null, 84 | ], 85 | ]; 86 | 87 | try { 88 | return json_encode($data, JSON_THROW_ON_ERROR); 89 | } catch (JsonException $e) { 90 | throw new LogicException('Serialization failure: ' . $e->getMessage(), 0, $e); 91 | } 92 | } 93 | 94 | public static function deserialize( 95 | string $data, 96 | string $scopeFile 97 | ): self 98 | { 99 | try { 100 | /** @var array{e: string|null, t: MemberType::*, o: array{c: string|null, m: string|null, f: string|null, l: int|null, p: string|null, n: string|null}, m: array{c: string|null, m: string, d: bool, e: int}} $result */ 101 | $result = json_decode($data, true, 3, JSON_THROW_ON_ERROR); 102 | } catch (JsonException $e) { 103 | throw new LogicException('Deserialization failure: ' . $e->getMessage(), 0, $e); 104 | } 105 | 106 | $memberType = $result['t']; 107 | $origin = new UsageOrigin( 108 | $result['o']['c'], 109 | $result['o']['m'], 110 | $result['o']['f'] === '_' ? $scopeFile : $result['o']['f'], 111 | $result['o']['l'], 112 | $result['o']['p'], 113 | $result['o']['n'], 114 | ); 115 | $exclusionReason = $result['e']; 116 | 117 | if ($memberType === MemberType::CONSTANT) { 118 | $usage = new ClassConstantUsage( 119 | $origin, 120 | new ClassConstantRef( 121 | $result['m']['c'], 122 | $result['m']['m'], 123 | $result['m']['d'], 124 | self::deserializeTrinary($result['m']['e']), 125 | ), 126 | ); 127 | } elseif ($memberType === MemberType::METHOD) { 128 | $usage = new ClassMethodUsage( 129 | $origin, 130 | new ClassMethodRef($result['m']['c'], $result['m']['m'], $result['m']['d']), 131 | ); 132 | } elseif ($memberType === MemberType::PROPERTY) { 133 | $usage = new ClassPropertyUsage( 134 | $origin, 135 | new ClassPropertyRef($result['m']['c'], $result['m']['m'], $result['m']['d']), 136 | ); 137 | } else { 138 | throw new LogicException('Unknown member type: ' . $memberType); 139 | } 140 | 141 | return new self($usage, $exclusionReason); 142 | } 143 | 144 | private function serializeTrinary(TrinaryLogic $isEnumCaseFetch): int 145 | { 146 | if ($isEnumCaseFetch->no()) { 147 | return -1; 148 | } 149 | 150 | if ($isEnumCaseFetch->yes()) { 151 | return 1; 152 | } 153 | 154 | return 0; 155 | } 156 | 157 | public static function deserializeTrinary(int $value): TrinaryLogic 158 | { 159 | if ($value === -1) { 160 | return TrinaryLogic::createNo(); 161 | } 162 | 163 | if ($value === 1) { 164 | return TrinaryLogic::createYes(); 165 | } 166 | 167 | return TrinaryLogic::createMaybe(); 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /src/Formatter/RemoveDeadCodeFormatter.php: -------------------------------------------------------------------------------- 1 | fileSystem = $fileSystem; 31 | $this->outputEnhancer = $outputEnhancer; 32 | } 33 | 34 | public function formatErrors( 35 | AnalysisResult $analysisResult, 36 | Output $output 37 | ): int 38 | { 39 | $internalErrors = $analysisResult->getInternalErrorObjects(); 40 | 41 | foreach ($internalErrors as $internalError) { 42 | $output->writeLineFormatted('' . $internalError->getMessage() . ''); 43 | } 44 | 45 | if (count($internalErrors) > 0) { 46 | $output->writeLineFormatted(''); 47 | $output->writeLineFormatted('Fix listed internal errors first.'); 48 | return 1; 49 | } 50 | 51 | /** @var array>>> $deadMembersByFiles file => [identifier => [key => excludedUsages[]]] */ 52 | $deadMembersByFiles = []; 53 | 54 | foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { 55 | if ( 56 | $fileSpecificError->getIdentifier() !== DeadCodeRule::IDENTIFIER_METHOD 57 | && $fileSpecificError->getIdentifier() !== DeadCodeRule::IDENTIFIER_CONSTANT 58 | && $fileSpecificError->getIdentifier() !== DeadCodeRule::IDENTIFIER_ENUM_CASE 59 | && $fileSpecificError->getIdentifier() !== DeadCodeRule::IDENTIFIER_PROPERTY 60 | ) { 61 | continue; 62 | } 63 | 64 | /** @var array}> $metadata */ 65 | $metadata = $fileSpecificError->getMetadata(); 66 | 67 | foreach ($metadata as $memberKey => $data) { 68 | $file = $data['file']; 69 | $type = $data['type']; 70 | $deadMembersByFiles[$file][$type][$memberKey] = $data['excludedUsages']; 71 | } 72 | } 73 | 74 | $membersCount = 0; 75 | $filesCount = count($deadMembersByFiles); 76 | 77 | foreach ($deadMembersByFiles as $file => $deadMembersByType) { 78 | /** @var array> $deadConstants */ 79 | $deadConstants = $deadMembersByType[MemberType::CONSTANT] ?? []; 80 | /** @var array> $deadMethods */ 81 | $deadMethods = $deadMembersByType[MemberType::METHOD] ?? []; 82 | /** @var array> $deadProperties */ 83 | $deadProperties = $deadMembersByType[MemberType::PROPERTY] ?? []; 84 | 85 | $membersCount += count($deadConstants) + count($deadMethods) + count($deadProperties); 86 | 87 | $transformer = new RemoveDeadCodeTransformer(array_keys($deadMethods), array_keys($deadConstants), array_keys($deadProperties)); 88 | $oldCode = $this->fileSystem->read($file); 89 | $newCode = $transformer->transformCode($oldCode); 90 | $this->fileSystem->write($file, $newCode); 91 | 92 | foreach ($deadConstants as $constant => $excludedUsages) { 93 | $output->writeLineFormatted(" • Removed constant $constant"); 94 | $this->printExcludedUsages($output, $excludedUsages); 95 | } 96 | 97 | foreach ($deadMethods as $method => $excludedUsages) { 98 | $output->writeLineFormatted(" • Removed method $method"); 99 | $this->printExcludedUsages($output, $excludedUsages); 100 | } 101 | 102 | foreach ($deadProperties as $property => $excludedUsages) { 103 | $output->writeLineFormatted(" • Removed property $property"); 104 | $this->printExcludedUsages($output, $excludedUsages); 105 | } 106 | } 107 | 108 | $memberPlural = $membersCount === 1 ? '' : 's'; 109 | $filePlural = $filesCount === 1 ? '' : 's'; 110 | 111 | $output->writeLineFormatted(''); 112 | $output->writeLineFormatted("Removed $membersCount dead member$memberPlural in $filesCount file$filePlural."); 113 | 114 | return 0; 115 | } 116 | 117 | /** 118 | * @param list $excludedUsages 119 | */ 120 | private function printExcludedUsages( 121 | Output $output, 122 | array $excludedUsages 123 | ): void 124 | { 125 | foreach ($excludedUsages as $excludedUsage) { 126 | $originLink = $this->getOriginLink($excludedUsage->getOrigin()); 127 | 128 | if ($originLink === null) { 129 | continue; 130 | } 131 | 132 | $output->writeLineFormatted(" ! Excluded usage at {$originLink} left intact"); 133 | } 134 | } 135 | 136 | private function getOriginLink(UsageOrigin $origin): ?string 137 | { 138 | if ($origin->getFile() === null || $origin->getLine() === null) { 139 | return null; 140 | } 141 | 142 | return $this->outputEnhancer->getOriginReference($origin); 143 | } 144 | 145 | } 146 | -------------------------------------------------------------------------------- /src/Provider/NetteUsageProvider.php: -------------------------------------------------------------------------------- 1 | > 30 | */ 31 | private array $smartObjectCache = []; 32 | 33 | public function __construct( 34 | ReflectionProvider $reflectionProvider, 35 | ?bool $enabled 36 | ) 37 | { 38 | $this->reflectionProvider = $reflectionProvider; 39 | $this->enabled = $enabled ?? $this->isNetteInstalled(); 40 | } 41 | 42 | public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 43 | { 44 | if (!$this->enabled) { 45 | return null; 46 | } 47 | 48 | $methodName = $method->getName(); 49 | $class = $method->getDeclaringClass(); 50 | $className = $class->getName(); 51 | $reflection = $this->reflectionProvider->getClass($className); 52 | 53 | return $this->isNetteMagic($reflection, $methodName); 54 | } 55 | 56 | private function isNetteMagic( 57 | ClassReflection $reflection, 58 | string $methodName 59 | ): ?VirtualUsageData 60 | { 61 | if ( 62 | $reflection->is(SignalReceiver::class) 63 | && strpos($methodName, 'handle') === 0 64 | ) { 65 | return VirtualUsageData::withNote('Signal handler method'); 66 | } 67 | 68 | if ( 69 | $reflection->is(Container::class) 70 | && strpos($methodName, 'createComponent') === 0 71 | ) { 72 | return VirtualUsageData::withNote('Component factory method'); 73 | } 74 | 75 | if ( 76 | $reflection->is(Control::class) 77 | && strpos($methodName, 'render') === 0 78 | ) { 79 | return VirtualUsageData::withNote('Render method'); 80 | } 81 | 82 | if ( 83 | $reflection->is(Presenter::class) && strpos($methodName, 'action') === 0 84 | ) { 85 | return VirtualUsageData::withNote('Presenter action method'); 86 | } 87 | 88 | if ( 89 | $reflection->is(Presenter::class) && strpos($methodName, 'inject') === 0 90 | ) { 91 | return VirtualUsageData::withNote('Presenter inject method'); 92 | } 93 | 94 | if ( 95 | $reflection->hasTraitUse(SmartObject::class) 96 | ) { 97 | if (strpos($methodName, 'is') === 0) { 98 | /** @var string $name cannot be false */ 99 | $name = substr($methodName, 2); 100 | 101 | } elseif (strpos($methodName, 'get') === 0 || strpos($methodName, 'set') === 0) { 102 | /** @var string $name cannot be false */ 103 | $name = substr($methodName, 3); 104 | 105 | } else { 106 | $name = null; 107 | } 108 | 109 | if ($name !== null) { 110 | $name = lcfirst($name); 111 | $property = $this->getMagicProperties($reflection)[$name] ?? null; 112 | 113 | if ($property !== null) { 114 | return VirtualUsageData::withNote('Access method for magic property ' . $name); 115 | } 116 | } 117 | } 118 | 119 | return null; 120 | } 121 | 122 | /** 123 | * @return array 124 | * 125 | * @see ObjectHelpers::getMagicProperties() Modified to use static reflection 126 | */ 127 | private function getMagicProperties(ClassReflection $reflection): array 128 | { 129 | $rc = $reflection->getNativeReflection(); 130 | $class = $rc->getName(); 131 | 132 | if (isset($this->smartObjectCache[$class])) { 133 | return $this->smartObjectCache[$class]; 134 | } 135 | 136 | preg_match_all( 137 | '~^ [ \t*]* @property(|-read|-write|-deprecated) [ \t]+ [^\s$]+ [ \t]+ \$ (\w+) ()~mx', 138 | (string) $rc->getDocComment(), 139 | $matches, 140 | PREG_SET_ORDER, 141 | ); 142 | 143 | $props = []; 144 | 145 | foreach ($matches as [, $type, $name]) { 146 | $uname = ucfirst($name); 147 | $write = $type !== '-read' 148 | && $rc->hasMethod($nm = 'set' . $uname) 149 | && ($rm = $rc->getMethod($nm))->name === $nm && !$rm->isPrivate() && !$rm->isStatic(); // @phpstan-ignore missingType.checkedException 150 | $read = $type !== '-write' 151 | && ($rc->hasMethod($nm = 'get' . $uname) || $rc->hasMethod($nm = 'is' . $uname)) 152 | && ($rm = $rc->getMethod($nm))->name === $nm && !$rm->isPrivate() && !$rm->isStatic(); // @phpstan-ignore missingType.checkedException 153 | 154 | if ($read || $write) { 155 | $props[$name] = true; 156 | } 157 | } 158 | 159 | foreach ($reflection->getTraits() as $trait) { 160 | $props += $this->getMagicProperties($trait); 161 | } 162 | 163 | foreach ($reflection->getParents() as $parent) { 164 | $props += $this->getMagicProperties($parent); 165 | } 166 | 167 | $this->smartObjectCache[$class] = $props; 168 | return $props; 169 | } 170 | 171 | private function isNetteInstalled(): bool 172 | { 173 | return InstalledVersions::isInstalled('nette/application') 174 | || InstalledVersions::isInstalled('nette/component-model') 175 | || InstalledVersions::isInstalled('nette/utils'); 176 | } 177 | 178 | } 179 | -------------------------------------------------------------------------------- /src/Collector/PropertyAccessCollector.php: -------------------------------------------------------------------------------- 1 | > 22 | */ 23 | final class PropertyAccessCollector implements Collector 24 | { 25 | 26 | use BufferedUsageCollector; 27 | 28 | /** 29 | * @var list 30 | */ 31 | private array $memberUsageExcluders; 32 | 33 | /** 34 | * @param list $memberUsageExcluders 35 | */ 36 | public function __construct( 37 | array $memberUsageExcluders 38 | ) 39 | { 40 | $this->memberUsageExcluders = $memberUsageExcluders; 41 | } 42 | 43 | public function getNodeType(): string 44 | { 45 | return Node::class; 46 | } 47 | 48 | /** 49 | * @return non-empty-list|null 50 | */ 51 | public function processNode( 52 | Node $node, 53 | Scope $scope 54 | ): ?array 55 | { 56 | if ($this->isInPropertyHook($scope)) { 57 | return null; 58 | } 59 | 60 | if ($node instanceof PropertyFetch && !$scope->isInExpressionAssign($node)) { 61 | $this->registerInstancePropertyAccess($node, $scope); 62 | } 63 | 64 | if ($node instanceof StaticPropertyFetch && !$scope->isInExpressionAssign($node)) { 65 | $this->registerStaticPropertyAccess($node, $scope); 66 | } 67 | 68 | return $this->emitUsages($scope); 69 | } 70 | 71 | private function registerInstancePropertyAccess( 72 | PropertyFetch $node, 73 | Scope $scope 74 | ): void 75 | { 76 | $propertyNames = $this->getPropertyNames($node, $scope); 77 | $callerType = $scope->getType($node->var); 78 | 79 | foreach ($propertyNames as $propertyName) { 80 | foreach ($this->getDeclaringTypesWithProperty($propertyName, $callerType, null) as $propertyRef) { 81 | $this->registerUsage( 82 | new ClassPropertyUsage( 83 | UsageOrigin::createRegular($node, $scope), 84 | $propertyRef, 85 | ), 86 | $node, 87 | $scope, 88 | ); 89 | } 90 | } 91 | } 92 | 93 | private function registerStaticPropertyAccess( 94 | StaticPropertyFetch $node, 95 | Scope $scope 96 | ): void 97 | { 98 | $propertyNames = $this->getPropertyNames($node, $scope); 99 | $possibleDescendant = $node->class instanceof Expr || $node->class->toString() === 'static'; 100 | 101 | if ($node->class instanceof Expr) { 102 | $callerType = $scope->getType($node->class); 103 | } else { 104 | $callerType = $scope->resolveTypeByName($node->class); 105 | } 106 | 107 | foreach ($propertyNames as $propertyName) { 108 | foreach ($this->getDeclaringTypesWithProperty($propertyName, $callerType, $possibleDescendant) as $propertyRef) { 109 | $this->registerUsage( 110 | new ClassPropertyUsage( 111 | UsageOrigin::createRegular($node, $scope), 112 | $propertyRef, 113 | ), 114 | $node, 115 | $scope, 116 | ); 117 | } 118 | } 119 | } 120 | 121 | /** 122 | * @param PropertyFetch|StaticPropertyFetch $fetch 123 | * @return list 124 | */ 125 | private function getPropertyNames( 126 | Expr $fetch, 127 | Scope $scope 128 | ): array 129 | { 130 | if ($fetch->name instanceof Expr) { 131 | $possiblePropertyNames = []; 132 | 133 | foreach ($scope->getType($fetch->name)->getConstantStrings() as $constantString) { 134 | $possiblePropertyNames[] = $constantString->getValue(); 135 | } 136 | 137 | return $possiblePropertyNames === [] 138 | ? [null] // unknown property name 139 | : $possiblePropertyNames; 140 | } 141 | 142 | return [$fetch->name->toString()]; 143 | } 144 | 145 | /** 146 | * @return list> 147 | */ 148 | private function getDeclaringTypesWithProperty( 149 | ?string $propertyName, 150 | Type $callerType, 151 | ?bool $isPossibleDescendant 152 | ): array 153 | { 154 | $typeNoNull = TypeUtils::toBenevolentUnion( // extract possible accesses even from Class|int 155 | TypeCombinator::removeNull($callerType), // remove null to support nullsafe access 156 | ); 157 | $classReflections = $typeNoNull->getObjectTypeOrClassStringObjectType()->getObjectClassReflections(); 158 | 159 | $propertyRefs = []; 160 | 161 | foreach ($classReflections as $classReflection) { 162 | $possibleDescendant = $isPossibleDescendant ?? !$classReflection->isFinalByKeyword(); 163 | $propertyRefs[] = new ClassPropertyRef( 164 | $classReflection->getName(), 165 | $propertyName, 166 | $possibleDescendant, 167 | ); 168 | } 169 | 170 | if ($propertyRefs === []) { // access over unknown type 171 | $propertyRefs[] = new ClassPropertyRef(null, $propertyName, true); 172 | } 173 | 174 | return $propertyRefs; 175 | } 176 | 177 | private function registerUsage( 178 | ClassPropertyUsage $usage, 179 | Node $node, 180 | Scope $scope 181 | ): void 182 | { 183 | $excluderName = null; 184 | 185 | foreach ($this->memberUsageExcluders as $excludedUsageDecider) { 186 | if ($excludedUsageDecider->shouldExclude($usage, $node, $scope)) { 187 | $excluderName = $excludedUsageDecider->getIdentifier(); 188 | break; 189 | } 190 | } 191 | 192 | $this->usages[] = new CollectedUsage($usage, $excluderName); 193 | } 194 | 195 | private function isInPropertyHook(Scope $scope): bool 196 | { 197 | $function = $scope->getFunction(); 198 | if ($function === null) { 199 | return false; 200 | } 201 | 202 | return $function->isMethodOrPropertyHook() && $function->isPropertyHook(); 203 | } 204 | 205 | } 206 | -------------------------------------------------------------------------------- /src/Excluder/TestsUsageExcluder.php: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | private array $devPaths = []; 36 | 37 | private bool $enabled; 38 | 39 | /** 40 | * @param list|null $devPaths 41 | */ 42 | public function __construct( 43 | ReflectionProvider $reflectionProvider, 44 | bool $enabled, 45 | ?array $devPaths 46 | ) 47 | { 48 | $this->reflectionProvider = $reflectionProvider; 49 | $this->enabled = $enabled; 50 | 51 | if ($devPaths !== null) { 52 | foreach ($devPaths as $devPath) { 53 | $this->devPaths[] = $this->realpath($devPath); 54 | } 55 | } else { 56 | $this->devPaths = $this->autodetectComposerDevPaths(); 57 | } 58 | } 59 | 60 | public function getIdentifier(): string 61 | { 62 | return 'tests'; 63 | } 64 | 65 | public function shouldExclude( 66 | ClassMemberUsage $usage, 67 | Node $node, 68 | Scope $scope 69 | ): bool 70 | { 71 | if (!$this->enabled) { 72 | return false; 73 | } 74 | 75 | return $this->isWithinDevPaths($this->realpath($scope->getFile())) === true 76 | && $this->isWithinDevPaths($this->getDeclarationFile($usage->getMemberRef()->getClassName())) === false; 77 | } 78 | 79 | private function isWithinDevPaths(?string $filePath): ?bool 80 | { 81 | if ($filePath === null) { 82 | return null; 83 | } 84 | 85 | foreach ($this->devPaths as $devPath) { 86 | if (strpos($filePath, $devPath) === 0) { 87 | return true; 88 | } 89 | } 90 | 91 | return false; 92 | } 93 | 94 | private function getDeclarationFile(?string $className): ?string 95 | { 96 | if ($className === null) { 97 | return null; 98 | } 99 | 100 | if (!$this->reflectionProvider->hasClass($className)) { 101 | return null; 102 | } 103 | 104 | $filePath = $this->reflectionProvider->getClass($className)->getFileName(); 105 | 106 | if ($filePath === null) { 107 | return null; 108 | } 109 | 110 | return $this->realpath($filePath); 111 | } 112 | 113 | /** 114 | * @return list 115 | */ 116 | private function autodetectComposerDevPaths(): array 117 | { 118 | $vendorDirs = array_filter(array_keys(ClassLoader::getRegisteredLoaders()), static function (string $vendorDir): bool { 119 | return strpos($vendorDir, 'phar://') === false; 120 | }); 121 | 122 | if (count($vendorDirs) !== 1) { 123 | return []; 124 | } 125 | 126 | $vendorDir = reset($vendorDirs); 127 | $composerJsonPath = $vendorDir . '/../composer.json'; 128 | 129 | $composerJsonData = $this->parseComposerJson($composerJsonPath); 130 | $basePath = dirname($composerJsonPath); 131 | 132 | return [ 133 | ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['psr-0'] ?? []), 134 | ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['psr-4'] ?? []), 135 | ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['files'] ?? []), 136 | ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['classmap'] ?? []), 137 | ]; 138 | } 139 | 140 | /** 141 | * @return array{ 142 | * autoload-dev?: array{ 143 | * psr-0?: array, 144 | * psr-4?: array, 145 | * files?: string[], 146 | * classmap?: string[], 147 | * } 148 | * } 149 | */ 150 | private function parseComposerJson(string $composerJsonPath): array 151 | { 152 | if (!is_file($composerJsonPath)) { 153 | return []; 154 | } 155 | 156 | $composerJsonRawData = file_get_contents($composerJsonPath); 157 | 158 | if ($composerJsonRawData === false) { 159 | return []; 160 | } 161 | 162 | $composerJsonData = json_decode($composerJsonRawData, true); 163 | 164 | $jsonError = json_last_error(); 165 | 166 | if ($jsonError !== JSON_ERROR_NONE) { 167 | return []; 168 | } 169 | 170 | return $composerJsonData; // @phpstan-ignore-line ignore mixed returned 171 | } 172 | 173 | /** 174 | * @param array> $autoload 175 | * @return list 176 | */ 177 | private function extractAutoloadPaths( 178 | string $basePath, 179 | array $autoload 180 | ): array 181 | { 182 | $result = []; 183 | 184 | foreach ($autoload as $paths) { 185 | if (!is_array($paths)) { 186 | $paths = [$paths]; // @phpstan-ignore shipmonk.variableTypeOverwritten 187 | } 188 | 189 | foreach ($paths as $path) { 190 | $isAbsolute = preg_match('#([a-z]:)?[/\\\\]#Ai', $path); 191 | 192 | if ($isAbsolute === 1) { 193 | $absolutePath = $path; 194 | } else { 195 | $absolutePath = $basePath . '/' . $path; 196 | } 197 | 198 | if (strpos($path, '*') !== false) { // https://getcomposer.org/doc/04-schema.md#classmap 199 | $globPaths = glob($absolutePath); 200 | 201 | if ($globPaths === false) { 202 | continue; 203 | } 204 | 205 | foreach ($globPaths as $globPath) { 206 | $result[] = $this->realpath($globPath); 207 | } 208 | 209 | continue; 210 | } 211 | 212 | $result[] = $this->realpath($absolutePath); 213 | } 214 | } 215 | 216 | return $result; 217 | } 218 | 219 | private function realpath(string $path): string 220 | { 221 | if (strpos($path, 'phar://') === 0) { 222 | return $path; 223 | } 224 | 225 | $realPath = realpath($path); 226 | 227 | if ($realPath === false) { 228 | throw new LogicException("Unable to realpath '$path'"); 229 | } 230 | 231 | return $realPath; 232 | } 233 | 234 | } 235 | -------------------------------------------------------------------------------- /src/Provider/PhpUnitUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled ?? InstalledVersions::isInstalled('phpunit/phpunit'); 37 | $this->lexer = $lexer; 38 | $this->phpDocParser = $phpDocParser; 39 | } 40 | 41 | public function getUsages( 42 | Node $node, 43 | Scope $scope 44 | ): array 45 | { 46 | if (!$this->enabled || !$node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 47 | return []; 48 | } 49 | 50 | $classReflection = $node->getClassReflection(); 51 | 52 | if (!$classReflection->is(TestCase::class)) { 53 | return []; 54 | } 55 | 56 | $usages = []; 57 | $className = $classReflection->getName(); 58 | 59 | foreach ($classReflection->getNativeReflection()->getMethods() as $method) { 60 | $methodName = $method->getName(); 61 | 62 | $externalDataProviderMethods = $this->getExternalDataProvidersFromAttributes($method); 63 | $localDataProviderMethods = array_merge( 64 | $this->getDataProvidersFromAnnotations($method->getDocComment()), 65 | $this->getDataProvidersFromAttributes($method), 66 | ); 67 | 68 | foreach ($externalDataProviderMethods as [$externalClassName, $externalMethodName]) { 69 | $usages[] = $this->createUsage($externalClassName, $externalMethodName, "External data provider method, used by $className::$methodName"); 70 | } 71 | 72 | foreach ($localDataProviderMethods as $dataProvider) { 73 | $usages[] = $this->createUsage($className, $dataProvider, "Data provider method, used by $methodName"); 74 | } 75 | 76 | if ($this->isTestCaseMethod($method)) { 77 | $usages[] = $this->createUsage($className, $methodName, 'Test method'); 78 | } 79 | } 80 | 81 | return $usages; 82 | } 83 | 84 | private function isTestCaseMethod(ReflectionMethod $method): bool 85 | { 86 | return strpos($method->getName(), 'test') === 0 87 | || $this->hasAnnotation($method, '@test') 88 | || $this->hasAnnotation($method, '@after') 89 | || $this->hasAnnotation($method, '@afterClass') 90 | || $this->hasAnnotation($method, '@before') 91 | || $this->hasAnnotation($method, '@beforeClass') 92 | || $this->hasAnnotation($method, '@postCondition') 93 | || $this->hasAnnotation($method, '@preCondition') 94 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\Test') 95 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\After') 96 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\AfterClass') 97 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\Before') 98 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\BeforeClass') 99 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\PostCondition') 100 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\PreCondition'); 101 | } 102 | 103 | /** 104 | * @param false|string $rawPhpDoc 105 | * @return list 106 | */ 107 | private function getDataProvidersFromAnnotations($rawPhpDoc): array 108 | { 109 | if ($rawPhpDoc === false || strpos($rawPhpDoc, '@dataProvider') === false) { 110 | return []; 111 | } 112 | 113 | $tokens = new TokenIterator($this->lexer->tokenize($rawPhpDoc)); 114 | $phpDoc = $this->phpDocParser->parse($tokens); 115 | 116 | $result = []; 117 | 118 | foreach ($phpDoc->getTagsByName('@dataProvider') as $tag) { 119 | $result[] = (string) $tag->value; 120 | } 121 | 122 | return $result; 123 | } 124 | 125 | /** 126 | * @return list 127 | */ 128 | private function getDataProvidersFromAttributes(ReflectionMethod $method): array 129 | { 130 | $result = []; 131 | 132 | foreach ($method->getAttributes('PHPUnit\Framework\Attributes\DataProvider') as $providerAttributeReflection) { 133 | $methodName = $providerAttributeReflection->getArguments()[0] ?? $providerAttributeReflection->getArguments()['methodName'] ?? null; 134 | 135 | if (is_string($methodName)) { 136 | $result[] = $methodName; 137 | } 138 | } 139 | 140 | return $result; 141 | } 142 | 143 | /** 144 | * @return list 145 | */ 146 | private function getExternalDataProvidersFromAttributes(ReflectionMethod $method): array 147 | { 148 | $result = []; 149 | 150 | foreach ($method->getAttributes('PHPUnit\Framework\Attributes\DataProviderExternal') as $providerAttributeReflection) { 151 | $className = $providerAttributeReflection->getArguments()[0] ?? $providerAttributeReflection->getArguments()['className'] ?? null; 152 | $methodName = $providerAttributeReflection->getArguments()[1] ?? $providerAttributeReflection->getArguments()['methodName'] ?? null; 153 | 154 | if (is_string($className) && is_string($methodName)) { 155 | $result[] = [$className, $methodName]; 156 | } 157 | } 158 | 159 | return $result; 160 | } 161 | 162 | private function hasAttribute( 163 | ReflectionMethod $method, 164 | string $attributeClass 165 | ): bool 166 | { 167 | return $method->getAttributes($attributeClass) !== []; 168 | } 169 | 170 | private function hasAnnotation( 171 | ReflectionMethod $method, 172 | string $string 173 | ): bool 174 | { 175 | if ($method->getDocComment() === false) { 176 | return false; 177 | } 178 | 179 | return strpos($method->getDocComment(), $string) !== false; 180 | } 181 | 182 | private function createUsage( 183 | string $className, 184 | string $methodName, 185 | string $reason 186 | ): ClassMethodUsage 187 | { 188 | return new ClassMethodUsage( 189 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote($reason)), 190 | new ClassMethodRef( 191 | $className, 192 | $methodName, 193 | false, 194 | ), 195 | ); 196 | } 197 | 198 | } 199 | -------------------------------------------------------------------------------- /src/Provider/TwigUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled ?? $this->isTwigInstalled(); 32 | } 33 | 34 | private function isTwigInstalled(): bool 35 | { 36 | return InstalledVersions::isInstalled('twig/twig'); 37 | } 38 | 39 | public function getUsages( 40 | Node $node, 41 | Scope $scope 42 | ): array 43 | { 44 | if (!$this->enabled) { 45 | return []; 46 | } 47 | 48 | $usages = []; 49 | 50 | if ($node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 51 | $usages = [ 52 | ...$usages, 53 | ...$this->getMethodUsagesFromReflection($node), 54 | ]; 55 | } 56 | 57 | if ($node instanceof New_) { 58 | $usages = [ 59 | ...$usages, 60 | ...$this->getMethodUsageFromNew($node, $scope), 61 | ]; 62 | } 63 | 64 | return $usages; 65 | } 66 | 67 | /** 68 | * @return list 69 | */ 70 | private function getMethodUsageFromNew( 71 | New_ $node, 72 | Scope $scope 73 | ): array 74 | { 75 | if (!$node->class instanceof Name) { 76 | return []; 77 | } 78 | 79 | if (!in_array($node->class->toString(), [ 80 | 'Twig\TwigFilter', 81 | 'Twig\TwigFunction', 82 | 'Twig\TwigTest', 83 | ], true)) { 84 | return []; 85 | } 86 | 87 | $callerType = $scope->resolveTypeByName($node->class); 88 | $methodReflection = $scope->getMethodReflection($callerType, '__construct'); 89 | 90 | if ($methodReflection === null) { 91 | return []; 92 | } 93 | 94 | $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( 95 | $scope, 96 | $node->getArgs(), 97 | $methodReflection->getVariants(), 98 | $methodReflection->getNamedArgumentsVariants(), 99 | ); 100 | $arg = (ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $node) ?? $node)->getArgs()[1] ?? null; 101 | 102 | if ($arg === null) { 103 | return []; 104 | } 105 | 106 | $argType = $scope->getType($arg->value); 107 | 108 | $argTypes = $argType instanceof UnionType ? $argType->getTypes() : [$argType]; 109 | 110 | $callables = []; 111 | 112 | foreach ($argTypes as $callableType) { 113 | foreach ($callableType->getConstantArrays() as $arrayType) { 114 | $callable = []; 115 | 116 | foreach ($arrayType->getValueTypes() as $valueType) { 117 | $callable[] = array_map(static function ($stringType): string { 118 | return $stringType->getValue(); 119 | }, $valueType->getConstantStrings()); 120 | } 121 | 122 | if (count($callable) === 2) { 123 | foreach ($callable[0] as $className) { 124 | foreach ($callable[1] as $methodName) { 125 | $callables[] = [$className, $methodName]; 126 | } 127 | } 128 | } 129 | } 130 | 131 | foreach ($callableType->getConstantStrings() as $stringType) { 132 | $callable = explode('::', $stringType->getValue()); 133 | 134 | if (count($callable) === 2) { 135 | $callables[] = $callable; 136 | } 137 | } 138 | } 139 | 140 | $usages = []; 141 | 142 | foreach ($callables as $callable) { 143 | $usages[] = new ClassMethodUsage( 144 | UsageOrigin::createRegular($node, $scope), 145 | new ClassMethodRef( 146 | $callable[0], 147 | $callable[1], 148 | false, 149 | ), 150 | ); 151 | } 152 | 153 | return $usages; 154 | } 155 | 156 | /** 157 | * @return list 158 | */ 159 | private function getMethodUsagesFromReflection(InClassNode $node): array 160 | { 161 | $classReflection = $node->getClassReflection(); 162 | $nativeReflection = $classReflection->getNativeReflection(); 163 | 164 | $usages = []; 165 | 166 | foreach ($nativeReflection->getMethods() as $method) { 167 | if ($method->getDeclaringClass()->getName() !== $nativeReflection->getName()) { 168 | continue; 169 | } 170 | 171 | $usageNote = $this->shouldMarkAsUsed($method); 172 | 173 | if ($usageNote !== null) { 174 | $usages[] = $this->createUsage($classReflection->getNativeMethod($method->getName()), $usageNote); 175 | } 176 | } 177 | 178 | return $usages; 179 | } 180 | 181 | private function shouldMarkAsUsed(ReflectionMethod $method): ?string 182 | { 183 | if ($this->isMethodWithAsTwigFilterAttribute($method)) { 184 | return 'Twig filter method via #[AsTwigFilter] attribute'; 185 | } 186 | 187 | if ($this->isMethodWithAsTwigFunctionAttribute($method)) { 188 | return 'Twig function method via #[AsTwigFunction] attribute'; 189 | } 190 | 191 | if ($this->isMethodWithAsTwigTestAttribute($method)) { 192 | return 'Twig test method via #[AsTwigTest] attribute'; 193 | } 194 | 195 | return null; 196 | } 197 | 198 | private function isMethodWithAsTwigFilterAttribute(ReflectionMethod $method): bool 199 | { 200 | return $this->hasAttribute($method, 'Twig\Attribute\AsTwigFilter'); 201 | } 202 | 203 | private function isMethodWithAsTwigFunctionAttribute(ReflectionMethod $method): bool 204 | { 205 | return $this->hasAttribute($method, 'Twig\Attribute\AsTwigFunction'); 206 | } 207 | 208 | private function isMethodWithAsTwigTestAttribute(ReflectionMethod $method): bool 209 | { 210 | return $this->hasAttribute($method, 'Twig\Attribute\AsTwigTest'); 211 | } 212 | 213 | private function hasAttribute( 214 | ReflectionMethod $method, 215 | string $attributeClass 216 | ): bool 217 | { 218 | return $method->getAttributes($attributeClass) !== []; 219 | } 220 | 221 | private function createUsage( 222 | ExtendedMethodReflection $methodReflection, 223 | string $reason 224 | ): ClassMethodUsage 225 | { 226 | return new ClassMethodUsage( 227 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote($reason)), 228 | new ClassMethodRef( 229 | $methodReflection->getDeclaringClass()->getName(), 230 | $methodReflection->getName(), 231 | false, 232 | ), 233 | ); 234 | } 235 | 236 | } 237 | -------------------------------------------------------------------------------- /src/Collector/ConstantFetchCollector.php: -------------------------------------------------------------------------------- 1 | > 31 | */ 32 | final class ConstantFetchCollector implements Collector 33 | { 34 | 35 | use BufferedUsageCollector; 36 | 37 | private ReflectionProvider $reflectionProvider; 38 | 39 | /** 40 | * @var list 41 | */ 42 | private array $memberUsageExcluders; 43 | 44 | /** 45 | * @param list $memberUsageExcluders 46 | */ 47 | public function __construct( 48 | ReflectionProvider $reflectionProvider, 49 | array $memberUsageExcluders 50 | ) 51 | { 52 | $this->reflectionProvider = $reflectionProvider; 53 | $this->memberUsageExcluders = $memberUsageExcluders; 54 | } 55 | 56 | public function getNodeType(): string 57 | { 58 | return Node::class; 59 | } 60 | 61 | /** 62 | * @return non-empty-list|null 63 | */ 64 | public function processNode( 65 | Node $node, 66 | Scope $scope 67 | ): ?array 68 | { 69 | if ($node instanceof ClassConstFetch) { 70 | $this->registerFetch($node, $scope); 71 | } 72 | 73 | if ($node instanceof FuncCall) { 74 | $this->registerFunctionCall($node, $scope); 75 | } 76 | 77 | return $this->emitUsages($scope); 78 | } 79 | 80 | private function registerFunctionCall( 81 | FuncCall $node, 82 | Scope $scope 83 | ): void 84 | { 85 | if (count($node->args) !== 1) { 86 | return; 87 | } 88 | 89 | /** @var Arg $firstArg */ 90 | $firstArg = current($node->args); 91 | 92 | if ($node->name instanceof Name) { 93 | $functionNames = [$node->name->toString()]; 94 | } else { 95 | $nameType = $scope->getType($node->name); 96 | $functionNames = array_map(static fn (ConstantStringType $string): string => $string->getValue(), $nameType->getConstantStrings()); 97 | } 98 | 99 | foreach ($functionNames as $functionName) { 100 | if ($functionName !== 'constant') { 101 | continue; 102 | } 103 | 104 | $argumentType = $scope->getType($firstArg->value); 105 | 106 | foreach ($argumentType->getConstantStrings() as $constantString) { 107 | if (strpos($constantString->getValue(), '::') === false) { 108 | continue; 109 | } 110 | 111 | // @phpstan-ignore offsetAccess.notFound 112 | [$className, $constantName] = explode('::', $constantString->getValue()); 113 | 114 | if ($this->reflectionProvider->hasClass($className)) { 115 | $reflection = $this->reflectionProvider->getClass($className); 116 | 117 | if ($reflection->hasConstant($constantName)) { 118 | $className = $reflection->getConstant($constantName)->getDeclaringClass()->getName(); 119 | } 120 | } 121 | 122 | $this->registerUsage( 123 | new ClassConstantUsage( 124 | UsageOrigin::createRegular($node, $scope), 125 | new ClassConstantRef($className, $constantName, true, TrinaryLogic::createMaybe()), 126 | ), 127 | $node, 128 | $scope, 129 | ); 130 | } 131 | } 132 | } 133 | 134 | private function registerFetch( 135 | ClassConstFetch $node, 136 | Scope $scope 137 | ): void 138 | { 139 | if ($node->class instanceof Expr) { 140 | $ownerType = $scope->getType($node->class); 141 | $possibleDescendantFetch = null; 142 | } else { 143 | $ownerType = $scope->resolveTypeByName($node->class); 144 | $possibleDescendantFetch = $node->class->toString() === 'static'; 145 | } 146 | 147 | $constantNames = $this->getConstantNames($node, $scope); 148 | 149 | foreach ($constantNames as $constantName) { 150 | if ($constantName === 'class') { 151 | continue; // reserved for class name fetching 152 | } 153 | 154 | foreach ($this->getDeclaringTypesWithConstant($ownerType, $constantName, $possibleDescendantFetch) as $constantRef) { 155 | $origin = UsageOrigin::createRegular($node, $scope); 156 | $usage = new ClassConstantUsage($origin, $constantRef); 157 | 158 | $this->registerUsage($usage, $node, $scope); 159 | } 160 | } 161 | } 162 | 163 | /** 164 | * @return list 165 | */ 166 | private function getConstantNames( 167 | ClassConstFetch $fetch, 168 | Scope $scope 169 | ): array 170 | { 171 | if ($fetch->name instanceof Expr) { 172 | $possibleConstantNames = []; 173 | 174 | foreach ($scope->getType($fetch->name)->getConstantStrings() as $constantString) { 175 | $possibleConstantNames[] = $constantString->getValue(); 176 | } 177 | 178 | return $possibleConstantNames === [] 179 | ? [null] // unknown constant name 180 | : $possibleConstantNames; 181 | } 182 | 183 | return [$fetch->name->toString()]; 184 | } 185 | 186 | /** 187 | * @return list> 188 | */ 189 | private function getDeclaringTypesWithConstant( 190 | Type $type, 191 | ?string $constantName, 192 | ?bool $isPossibleDescendant 193 | ): array 194 | { 195 | $typeNormalized = TypeUtils::toBenevolentUnion($type) // extract possible fetches even from Class|int 196 | ->getObjectTypeOrClassStringObjectType(); 197 | $classReflections = $typeNormalized->getObjectClassReflections(); 198 | 199 | $result = []; 200 | $isEnumCaseFetch = $typeNormalized->isEnum()->no() ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(); 201 | 202 | foreach ($classReflections as $classReflection) { 203 | $possibleDescendant = $isPossibleDescendant ?? !$classReflection->isFinalByKeyword(); 204 | $result[] = new ClassConstantRef( 205 | $classReflection->getName(), 206 | $constantName, 207 | $possibleDescendant, 208 | $isEnumCaseFetch, 209 | ); 210 | } 211 | 212 | if ($result === []) { // call over unknown type 213 | $result[] = new ClassConstantRef(null, $constantName, true, $isEnumCaseFetch); 214 | } 215 | 216 | return $result; 217 | } 218 | 219 | private function registerUsage( 220 | ClassConstantUsage $usage, 221 | Node $node, 222 | Scope $scope 223 | ): void 224 | { 225 | $excluderName = null; 226 | 227 | foreach ($this->memberUsageExcluders as $excludedUsageDecider) { 228 | if ($excludedUsageDecider->shouldExclude($usage, $node, $scope)) { 229 | $excluderName = $excludedUsageDecider->getIdentifier(); 230 | break; 231 | } 232 | } 233 | 234 | $this->usages[] = new CollectedUsage($usage, $excluderName); 235 | } 236 | 237 | } 238 | -------------------------------------------------------------------------------- /src/Provider/ReflectionBasedMemberUsageProvider.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | public function getUsages( 32 | Node $node, 33 | Scope $scope 34 | ): array 35 | { 36 | if ($node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 37 | $classReflection = $node->getClassReflection(); 38 | 39 | return array_merge( 40 | $this->getMethodUsages($classReflection), 41 | $this->getConstantUsages($classReflection), 42 | $this->getEnumCaseUsages($classReflection), 43 | $this->getPropertyUsages($classReflection), 44 | ); 45 | } 46 | 47 | return []; 48 | } 49 | 50 | protected function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 51 | { 52 | return null; // Expected to be overridden by subclasses. 53 | } 54 | 55 | protected function shouldMarkConstantAsUsed(ReflectionClassConstant $constant): ?VirtualUsageData 56 | { 57 | return null; // Expected to be overridden by subclasses. 58 | } 59 | 60 | protected function shouldMarkEnumCaseAsUsed(ReflectionEnumUnitCase $enumCase): ?VirtualUsageData 61 | { 62 | return null; // Expected to be overridden by subclasses. 63 | } 64 | 65 | protected function shouldMarkPropertyAsUsed(ReflectionProperty $property): ?VirtualUsageData 66 | { 67 | return null; // Expected to be overridden by subclasses. 68 | } 69 | 70 | /** 71 | * @return list 72 | */ 73 | private function getMethodUsages(ClassReflection $classReflection): array 74 | { 75 | $nativeClassReflection = $classReflection->getNativeReflection(); 76 | 77 | $usages = []; 78 | 79 | foreach ($nativeClassReflection->getMethods() as $nativeMethodReflection) { 80 | if ($nativeMethodReflection->getDeclaringClass()->getName() !== $nativeClassReflection->getName()) { 81 | continue; // skip methods from ancestors 82 | } 83 | 84 | $usage = $this->shouldMarkMethodAsUsed($nativeMethodReflection); 85 | 86 | if ($usage !== null) { 87 | $usages[] = $this->createMethodUsage($nativeMethodReflection, $usage); 88 | } 89 | } 90 | 91 | return $usages; 92 | } 93 | 94 | /** 95 | * @return list 96 | */ 97 | private function getConstantUsages(ClassReflection $classReflection): array 98 | { 99 | $nativeClassReflection = $classReflection->getNativeReflection(); 100 | 101 | $usages = []; 102 | 103 | foreach ($nativeClassReflection->getReflectionConstants() as $nativeConstantReflection) { 104 | if ($nativeConstantReflection->getDeclaringClass()->getName() !== $nativeClassReflection->getName()) { 105 | continue; // skip constants from ancestors 106 | } 107 | 108 | if ($nativeConstantReflection->isEnumCase()) { 109 | continue; // handled separately 110 | } 111 | 112 | $usage = $this->shouldMarkConstantAsUsed($nativeConstantReflection); 113 | 114 | if ($usage !== null) { 115 | $usages[] = $this->createConstantUsage($nativeConstantReflection, $usage); 116 | } 117 | } 118 | 119 | return $usages; 120 | } 121 | 122 | /** 123 | * @return list 124 | */ 125 | private function getEnumCaseUsages(ClassReflection $classReflection): array 126 | { 127 | $nativeClassReflection = $classReflection->getNativeReflection(); 128 | 129 | if (!$nativeClassReflection instanceof ReflectionEnum) { 130 | return []; 131 | } 132 | 133 | $usages = []; 134 | 135 | foreach ($nativeClassReflection->getCases() as $nativeEnumCaseReflection) { 136 | $usage = $this->shouldMarkEnumCaseAsUsed($nativeEnumCaseReflection); 137 | 138 | if ($usage !== null) { 139 | $usages[] = $this->createEnumCaseUsage($nativeEnumCaseReflection, $usage); 140 | } 141 | } 142 | 143 | return $usages; 144 | } 145 | 146 | /** 147 | * @return list 148 | */ 149 | private function getPropertyUsages(ClassReflection $classReflection): array 150 | { 151 | $nativeClassReflection = $classReflection->getNativeReflection(); 152 | 153 | $usages = []; 154 | 155 | foreach ($nativeClassReflection->getProperties() as $nativePropertyReflection) { 156 | if ($nativePropertyReflection->getDeclaringClass()->getName() !== $nativeClassReflection->getName()) { 157 | continue; // skip properties from ancestors 158 | } 159 | 160 | $usage = $this->shouldMarkPropertyAsUsed($nativePropertyReflection); 161 | 162 | if ($usage !== null) { 163 | $usages[] = $this->createPropertyUsage($nativePropertyReflection, $usage); 164 | } 165 | } 166 | 167 | return $usages; 168 | } 169 | 170 | private function createConstantUsage( 171 | ReflectionClassConstant $constantReflection, 172 | VirtualUsageData $data 173 | ): ClassConstantUsage 174 | { 175 | return new ClassConstantUsage( 176 | UsageOrigin::createVirtual($this, $data), 177 | new ClassConstantRef( 178 | $constantReflection->getDeclaringClass()->getName(), 179 | $constantReflection->getName(), 180 | false, 181 | TrinaryLogic::createNo(), 182 | ), 183 | ); 184 | } 185 | 186 | private function createMethodUsage( 187 | ReflectionMethod $methodReflection, 188 | VirtualUsageData $data 189 | ): ClassMethodUsage 190 | { 191 | return new ClassMethodUsage( 192 | UsageOrigin::createVirtual($this, $data), 193 | new ClassMethodRef( 194 | $methodReflection->getDeclaringClass()->getName(), 195 | $methodReflection->getName(), 196 | false, 197 | ), 198 | ); 199 | } 200 | 201 | private function createEnumCaseUsage( 202 | ReflectionEnumUnitCase $enumCaseReflection, 203 | VirtualUsageData $usage 204 | ): ClassConstantUsage 205 | { 206 | return new ClassConstantUsage( 207 | UsageOrigin::createVirtual($this, $usage), 208 | new ClassConstantRef( 209 | $enumCaseReflection->getDeclaringClass()->getName(), 210 | $enumCaseReflection->getName(), 211 | false, 212 | TrinaryLogic::createYes(), 213 | ), 214 | ); 215 | } 216 | 217 | private function createPropertyUsage( 218 | ReflectionProperty $propertyReflection, 219 | VirtualUsageData $data 220 | ): ClassPropertyUsage 221 | { 222 | return new ClassPropertyUsage( 223 | UsageOrigin::createVirtual($this, $data), 224 | new ClassPropertyRef( 225 | $propertyReflection->getDeclaringClass()->getName(), 226 | $propertyReflection->getName(), 227 | false, 228 | ), 229 | ); 230 | } 231 | 232 | } 233 | -------------------------------------------------------------------------------- /src/Collector/ClassDefinitionCollector.php: -------------------------------------------------------------------------------- 1 | , 32 | * constants: array, 33 | * properties: array, 34 | * methods: array}>, 35 | * parents: array, 36 | * traits: array, aliases?: array}>, 37 | * interfaces: array, 38 | * }> 39 | */ 40 | final class ClassDefinitionCollector implements Collector 41 | { 42 | 43 | private ReflectionProvider $reflectionProvider; 44 | 45 | private bool $detectDeadConstants; 46 | 47 | private bool $detectDeadEnumCases; 48 | 49 | private bool $detectDeadProperties; 50 | 51 | public function __construct( 52 | ReflectionProvider $reflectionProvider, 53 | bool $detectDeadConstants, 54 | bool $detectDeadEnumCases, 55 | bool $detectDeadProperties 56 | ) 57 | { 58 | $this->reflectionProvider = $reflectionProvider; 59 | $this->detectDeadConstants = $detectDeadConstants; 60 | $this->detectDeadEnumCases = $detectDeadEnumCases; 61 | $this->detectDeadProperties = $detectDeadProperties; 62 | } 63 | 64 | public function getNodeType(): string 65 | { 66 | return ClassLike::class; 67 | } 68 | 69 | /** 70 | * @param ClassLike $node 71 | * @return array{ 72 | * kind: string, 73 | * name: string, 74 | * cases: array, 75 | * constants: array, 76 | * properties: array, 77 | * methods: array}>, 78 | * parents: array, 79 | * traits: array, aliases?: array}>, 80 | * interfaces: array, 81 | * }|null 82 | */ 83 | public function processNode( 84 | Node $node, 85 | Scope $scope 86 | ): ?array 87 | { 88 | if ($node->namespacedName === null) { 89 | return null; 90 | } 91 | 92 | $kind = $this->getKind($node); 93 | $typeName = $node->namespacedName->toString(); 94 | $reflection = $this->reflectionProvider->getClass($typeName); 95 | 96 | $methods = []; 97 | $constants = []; 98 | $cases = []; 99 | $properties = []; 100 | 101 | foreach ($node->getMethods() as $method) { 102 | $methodName = $method->name->toString(); 103 | $methods[$methodName] = [ 104 | 'line' => $method->name->getStartLine(), 105 | 'params' => count($method->params), 106 | 'abstract' => $method->isAbstract() || $node instanceof Interface_, 107 | 'visibility' => $method->flags & (Visibility::PUBLIC | Visibility::PROTECTED | Visibility::PRIVATE), 108 | ]; 109 | 110 | if ($methodName === '__construct') { 111 | foreach ($method->getParams() as $param) { 112 | if ($param->isPromoted() && $param->var instanceof Variable && is_string($param->var->name)) { 113 | $properties[$param->var->name] = [ 114 | 'line' => $param->var->getStartLine(), 115 | ]; 116 | } 117 | } 118 | } 119 | } 120 | 121 | foreach ($node->getConstants() as $constant) { 122 | foreach ($constant->consts as $const) { 123 | $constants[$const->name->toString()] = [ 124 | 'line' => $const->getStartLine(), 125 | ]; 126 | } 127 | } 128 | 129 | foreach ($this->getEnumCases($node) as $case) { 130 | $cases[$case->name->toString()] = [ 131 | 'line' => $case->name->getStartLine(), 132 | ]; 133 | } 134 | 135 | foreach ($node->getProperties() as $property) { 136 | foreach ($property->props as $prop) { 137 | $properties[$prop->name->toString()] = [ 138 | 'line' => $prop->getStartLine(), 139 | ]; 140 | } 141 | } 142 | 143 | if (!$this->detectDeadConstants) { 144 | $constants = []; 145 | } 146 | 147 | if (!$this->detectDeadEnumCases) { 148 | $cases = []; 149 | } 150 | 151 | if (!$this->detectDeadProperties) { 152 | $properties = []; 153 | } 154 | 155 | return [ 156 | 'kind' => $kind, 157 | 'name' => $typeName, 158 | 'methods' => $methods, 159 | 'cases' => $cases, 160 | 'constants' => $constants, 161 | 'properties' => $properties, 162 | 'parents' => $this->getParents($reflection), 163 | 'traits' => $this->getTraits($node), 164 | 'interfaces' => $this->getInterfaces($reflection), 165 | ]; 166 | } 167 | 168 | /** 169 | * @return array 170 | */ 171 | private function getParents(ClassReflection $reflection): array 172 | { 173 | $parents = []; 174 | 175 | foreach ($reflection->getParentClassesNames() as $parent) { 176 | $parents[$parent] = null; 177 | } 178 | 179 | return $parents; 180 | } 181 | 182 | /** 183 | * @return array 184 | */ 185 | private function getInterfaces(ClassReflection $reflection): array 186 | { 187 | return array_fill_keys(array_map(static fn (ClassReflection $reflection) => $reflection->getName(), $reflection->getInterfaces()), null); 188 | } 189 | 190 | /** 191 | * @return array, aliases?: array}> 192 | */ 193 | private function getTraits(ClassLike $node): array 194 | { 195 | $traits = []; 196 | 197 | foreach ($node->getTraitUses() as $traitUse) { 198 | foreach ($traitUse->traits as $trait) { 199 | $traits[$trait->toString()] = []; 200 | } 201 | 202 | foreach ($traitUse->adaptations as $adaptation) { 203 | if ($adaptation instanceof Precedence) { 204 | foreach ($adaptation->insteadof as $insteadof) { 205 | $traits[$insteadof->toString()]['excluded'][] = $adaptation->method->toString(); 206 | } 207 | } 208 | 209 | if ($adaptation instanceof Alias && $adaptation->newName !== null) { 210 | if ($adaptation->trait === null) { 211 | // assign alias to all traits, wrong ones are eliminated in Rule logic 212 | foreach ($traitUse->traits as $trait) { 213 | $traits[$trait->toString()]['aliases'][$adaptation->method->toString()] = $adaptation->newName->toString(); 214 | } 215 | } else { 216 | $traits[$adaptation->trait->toString()]['aliases'][$adaptation->method->toString()] = $adaptation->newName->toString(); 217 | } 218 | } 219 | } 220 | } 221 | 222 | return $traits; 223 | } 224 | 225 | private function getKind(ClassLike $node): string 226 | { 227 | if ($node instanceof Class_) { 228 | return ClassLikeKind::CLASSS; 229 | } 230 | 231 | if ($node instanceof Interface_) { 232 | return ClassLikeKind::INTERFACE; 233 | } 234 | 235 | if ($node instanceof Trait_) { 236 | return ClassLikeKind::TRAIT; 237 | } 238 | 239 | if ($node instanceof Enum_) { 240 | return ClassLikeKind::ENUM; 241 | } 242 | 243 | throw new LogicException('Unknown class-like node'); 244 | } 245 | 246 | /** 247 | * @return list 248 | */ 249 | private function getEnumCases(ClassLike $node): array 250 | { 251 | if (!$node instanceof Enum_) { 252 | return []; 253 | } 254 | 255 | $result = []; 256 | 257 | foreach ($node->stmts as $stmt) { 258 | if ($stmt instanceof EnumCase) { 259 | $result[] = $stmt; 260 | } 261 | } 262 | 263 | return $result; 264 | } 265 | 266 | } 267 | -------------------------------------------------------------------------------- /src/Provider/PhpBenchUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled ?? InstalledVersions::isInstalled('phpbench/phpbench'); 46 | $this->phpDocParser = $phpDocParser; 47 | $this->lexer = $lexer; 48 | } 49 | 50 | public function getUsages( 51 | Node $node, 52 | Scope $scope 53 | ): array 54 | { 55 | if (!$this->enabled || !$node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 56 | return []; 57 | } 58 | 59 | $classReflection = $node->getClassReflection(); 60 | $className = $classReflection->getName(); 61 | 62 | if (substr($className, -5) !== 'Bench') { 63 | return []; 64 | } 65 | 66 | $usages = []; 67 | 68 | foreach ($classReflection->getNativeReflection()->getMethods() as $method) { 69 | $methodName = $method->getName(); 70 | 71 | $paramProviderMethods = array_merge( 72 | $this->getMethodNamesFromAnnotation($method->getDocComment(), '@ParamProviders'), 73 | $this->getParamProvidersFromAttributes($method), 74 | ); 75 | 76 | foreach ($paramProviderMethods as $paramProvider) { 77 | $usages[] = $this->createUsage( 78 | $className, 79 | $paramProvider, 80 | sprintf('Param provider method, used by %s', $methodName), 81 | ); 82 | } 83 | 84 | $beforeAfterMethodsFromAttributes = array_merge( 85 | $this->getMethodNamesFromAttribute($method, BeforeMethods::class), 86 | $this->getMethodNamesFromAttribute($method, AfterMethods::class), 87 | ); 88 | 89 | foreach ($beforeAfterMethodsFromAttributes as $beforeAfterMethod) { 90 | $usages[] = $this->createUsage( 91 | $className, 92 | $beforeAfterMethod, 93 | sprintf('Before/After method, used by %s', $methodName), 94 | ); 95 | } 96 | 97 | if ($this->isBenchmarkMethod($method)) { 98 | $usages[] = $this->createUsage($className, $methodName, 'Benchmark method'); 99 | } 100 | 101 | if ($this->isBeforeOrAfterMethod($method)) { 102 | $usages[] = $this->createUsage($className, $methodName, 'Before/After method'); 103 | } 104 | } 105 | 106 | return $usages; 107 | } 108 | 109 | private function isBenchmarkMethod(ReflectionMethod $method): bool 110 | { 111 | return strpos($method->getName(), 'bench') === 0; 112 | } 113 | 114 | /** 115 | * @return list 116 | */ 117 | private function getMethodNamesFromAttribute( 118 | ReflectionMethod $method, 119 | string $attributeClass 120 | ): array 121 | { 122 | $result = []; 123 | 124 | foreach ($method->getAttributes($attributeClass) as $attribute) { 125 | $methods = $attribute->getArguments()[0] ?? $attribute->getArguments()['methods'] ?? []; 126 | if (!is_array($methods)) { 127 | $methods = [$methods]; 128 | } 129 | 130 | foreach ($methods as $methodName) { 131 | if (is_string($methodName)) { 132 | $result[] = $methodName; 133 | } 134 | } 135 | } 136 | 137 | return $result; 138 | } 139 | 140 | private function isBeforeOrAfterMethod(ReflectionMethod $method): bool 141 | { 142 | $classReflection = $method->getDeclaringClass(); 143 | $methodName = $method->getName(); 144 | 145 | // Check class-level annotations 146 | $docComment = $classReflection->getDocComment(); 147 | if ($docComment !== false) { 148 | $beforeMethodsFromAnnotations = $this->getMethodNamesFromAnnotation($docComment, '@BeforeMethods'); 149 | $afterMethodsFromAnnotations = $this->getMethodNamesFromAnnotation($docComment, '@AfterMethods'); 150 | 151 | if (in_array($methodName, $beforeMethodsFromAnnotations, true) || in_array($methodName, $afterMethodsFromAnnotations, true)) { 152 | return true; 153 | } 154 | } 155 | 156 | // Check class-level attributes 157 | foreach ($classReflection->getAttributes(BeforeMethods::class) as $attribute) { 158 | $methods = $attribute->getArguments()[0] ?? $attribute->getArguments()['methods'] ?? []; 159 | if (!is_array($methods)) { 160 | $methods = [$methods]; 161 | } 162 | 163 | foreach ($methods as $beforeMethod) { 164 | if ($beforeMethod === $methodName) { 165 | return true; 166 | } 167 | } 168 | } 169 | 170 | foreach ($classReflection->getAttributes(AfterMethods::class) as $attribute) { 171 | $methods = $attribute->getArguments()[0] ?? $attribute->getArguments()['methods'] ?? []; 172 | if (!is_array($methods)) { 173 | $methods = [$methods]; 174 | } 175 | 176 | foreach ($methods as $afterMethod) { 177 | if ($afterMethod === $methodName) { 178 | return true; 179 | } 180 | } 181 | } 182 | 183 | return false; 184 | } 185 | 186 | /** 187 | * @param false|string $rawPhpDoc 188 | * @return list 189 | */ 190 | private function getMethodNamesFromAnnotation( 191 | $rawPhpDoc, 192 | string $annotationName 193 | ): array 194 | { 195 | if ($rawPhpDoc === false || strpos($rawPhpDoc, $annotationName) === false) { 196 | return []; 197 | } 198 | 199 | $tokens = new TokenIterator($this->lexer->tokenize($rawPhpDoc)); 200 | $phpDoc = $this->phpDocParser->parse($tokens); 201 | 202 | $result = []; 203 | 204 | foreach ($phpDoc->getTagsByName($annotationName) as $tag) { 205 | $value = (string) $tag->value; 206 | 207 | // Extract content from parentheses: @BeforeMethods("setUp") -> "setUp" 208 | // or @BeforeMethods({"setUp", "tearDown"}) -> {"setUp", "tearDown"} 209 | if (preg_match('~\((.+)\)\s*$~', $value, $matches) === 1) { 210 | $value = $matches[1]; 211 | } 212 | 213 | $value = trim($value); 214 | $value = trim($value, '"\''); 215 | 216 | // If it's a single method name, add it directly 217 | if (strpos($value, ',') === false && strpos($value, '{') === false) { 218 | $result[] = $value; 219 | continue; 220 | } 221 | 222 | // Handle array format: {"method1", "method2"} 223 | $value = trim($value, '{}'); 224 | $methods = explode(',', $value); 225 | foreach ($methods as $method) { 226 | $method = trim($method); 227 | $method = trim($method, '"\''); 228 | if ($method !== '') { 229 | $result[] = $method; 230 | } 231 | } 232 | } 233 | 234 | return $result; 235 | } 236 | 237 | /** @return list */ 238 | private function getParamProvidersFromAttributes(ReflectionMethod $method): array 239 | { 240 | $result = []; 241 | 242 | foreach ($method->getAttributes(ParamProviders::class) as $providerAttributeReflection) { 243 | $providers = $providerAttributeReflection->getArguments()[0] 244 | ?? $providerAttributeReflection->getArguments()['providers'] 245 | ?? null; 246 | 247 | if (!is_array($providers)) { 248 | continue; 249 | } 250 | 251 | foreach ($providers as $provider) { 252 | if (!is_string($provider)) { 253 | continue; 254 | } 255 | 256 | $result[] = $provider; 257 | } 258 | } 259 | 260 | return $result; 261 | } 262 | 263 | private function createUsage( 264 | string $className, 265 | string $methodName, 266 | string $reason 267 | ): ClassMethodUsage 268 | { 269 | return new ClassMethodUsage( 270 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote($reason)), 271 | new ClassMethodRef( 272 | $className, 273 | $methodName, 274 | false, 275 | ), 276 | ); 277 | } 278 | 279 | } 280 | -------------------------------------------------------------------------------- /rules.neon: -------------------------------------------------------------------------------- 1 | services: 2 | errorFormatter.removeDeadCode: 3 | class: ShipMonk\PHPStan\DeadCode\Formatter\RemoveDeadCodeFormatter 4 | 5 | - 6 | class: ShipMonk\PHPStan\DeadCode\Hierarchy\ClassHierarchy 7 | - 8 | class: ShipMonk\PHPStan\DeadCode\Transformer\FileSystem 9 | - 10 | class: ShipMonk\PHPStan\DeadCode\Output\OutputEnhancer 11 | arguments: 12 | editorUrl: %editorUrl% 13 | 14 | - 15 | class: ShipMonk\PHPStan\DeadCode\Debug\DebugUsagePrinter 16 | 17 | - 18 | class: ShipMonk\PHPStan\DeadCode\Provider\ApiPhpDocUsageProvider 19 | tags: 20 | - shipmonk.deadCode.memberUsageProvider 21 | arguments: 22 | enabled: %shipmonkDeadCode.usageProviders.apiPhpDoc.enabled% 23 | 24 | - 25 | class: ShipMonk\PHPStan\DeadCode\Provider\EnumUsageProvider 26 | tags: 27 | - shipmonk.deadCode.memberUsageProvider 28 | arguments: 29 | enabled: %shipmonkDeadCode.usageProviders.enum.enabled% 30 | 31 | - 32 | class: ShipMonk\PHPStan\DeadCode\Provider\VendorUsageProvider 33 | tags: 34 | - shipmonk.deadCode.memberUsageProvider 35 | arguments: 36 | enabled: %shipmonkDeadCode.usageProviders.vendor.enabled% 37 | 38 | - 39 | class: ShipMonk\PHPStan\DeadCode\Provider\BuiltinUsageProvider 40 | tags: 41 | - shipmonk.deadCode.memberUsageProvider 42 | arguments: 43 | enabled: %shipmonkDeadCode.usageProviders.builtin.enabled% 44 | 45 | - 46 | class: ShipMonk\PHPStan\DeadCode\Provider\ReflectionUsageProvider 47 | tags: 48 | - shipmonk.deadCode.memberUsageProvider 49 | arguments: 50 | enabled: %shipmonkDeadCode.usageProviders.reflection.enabled% 51 | 52 | - 53 | class: ShipMonk\PHPStan\DeadCode\Provider\PhpUnitUsageProvider 54 | tags: 55 | - shipmonk.deadCode.memberUsageProvider 56 | arguments: 57 | enabled: %shipmonkDeadCode.usageProviders.phpunit.enabled% 58 | 59 | - 60 | class: ShipMonk\PHPStan\DeadCode\Provider\PhpBenchUsageProvider 61 | tags: 62 | - shipmonk.deadCode.memberUsageProvider 63 | arguments: 64 | enabled: %shipmonkDeadCode.usageProviders.phpbench.enabled% 65 | 66 | - 67 | class: ShipMonk\PHPStan\DeadCode\Provider\BehatUsageProvider 68 | tags: 69 | - shipmonk.deadCode.memberUsageProvider 70 | arguments: 71 | enabled: %shipmonkDeadCode.usageProviders.behat.enabled% 72 | 73 | - 74 | class: ShipMonk\PHPStan\DeadCode\Provider\SymfonyUsageProvider 75 | tags: 76 | - shipmonk.deadCode.memberUsageProvider 77 | arguments: 78 | enabled: %shipmonkDeadCode.usageProviders.symfony.enabled% 79 | configDir: %shipmonkDeadCode.usageProviders.symfony.configDir% 80 | 81 | - 82 | class: ShipMonk\PHPStan\DeadCode\Provider\TwigUsageProvider 83 | tags: 84 | - shipmonk.deadCode.memberUsageProvider 85 | arguments: 86 | enabled: %shipmonkDeadCode.usageProviders.twig.enabled% 87 | 88 | - 89 | class: ShipMonk\PHPStan\DeadCode\Provider\DoctrineUsageProvider 90 | tags: 91 | - shipmonk.deadCode.memberUsageProvider 92 | arguments: 93 | enabled: %shipmonkDeadCode.usageProviders.doctrine.enabled% 94 | 95 | - 96 | class: ShipMonk\PHPStan\DeadCode\Provider\PhpStanUsageProvider 97 | tags: 98 | - shipmonk.deadCode.memberUsageProvider 99 | arguments: 100 | enabled: %shipmonkDeadCode.usageProviders.phpstan.enabled% 101 | 102 | - 103 | class: ShipMonk\PHPStan\DeadCode\Provider\NetteUsageProvider 104 | tags: 105 | - shipmonk.deadCode.memberUsageProvider 106 | arguments: 107 | enabled: %shipmonkDeadCode.usageProviders.nette.enabled% 108 | 109 | - 110 | class: ShipMonk\PHPStan\DeadCode\Provider\StreamWrapperUsageProvider 111 | tags: 112 | - shipmonk.deadCode.memberUsageProvider 113 | arguments: 114 | enabled: %shipmonkDeadCode.usageProviders.streamWrapper.enabled% 115 | 116 | 117 | - 118 | class: ShipMonk\PHPStan\DeadCode\Excluder\TestsUsageExcluder 119 | tags: 120 | - shipmonk.deadCode.memberUsageExcluder 121 | arguments: 122 | enabled: %shipmonkDeadCode.usageExcluders.tests.enabled% 123 | devPaths: %shipmonkDeadCode.usageExcluders.tests.devPaths% 124 | 125 | - 126 | class: ShipMonk\PHPStan\DeadCode\Excluder\MixedUsageExcluder 127 | tags: 128 | - shipmonk.deadCode.memberUsageExcluder 129 | arguments: 130 | enabled: %shipmonkDeadCode.usageExcluders.usageOverMixed.enabled% 131 | 132 | 133 | - 134 | class: ShipMonk\PHPStan\DeadCode\Collector\MethodCallCollector 135 | tags: 136 | - phpstan.collector 137 | arguments: 138 | memberUsageExcluders: tagged(shipmonk.deadCode.memberUsageExcluder) 139 | 140 | - 141 | class: ShipMonk\PHPStan\DeadCode\Collector\ConstantFetchCollector 142 | tags: 143 | - phpstan.collector 144 | arguments: 145 | memberUsageExcluders: tagged(shipmonk.deadCode.memberUsageExcluder) 146 | 147 | - 148 | class: ShipMonk\PHPStan\DeadCode\Collector\PropertyAccessCollector 149 | tags: 150 | - phpstan.collector 151 | arguments: 152 | memberUsageExcluders: tagged(shipmonk.deadCode.memberUsageExcluder) 153 | 154 | - 155 | class: ShipMonk\PHPStan\DeadCode\Collector\ClassDefinitionCollector 156 | tags: 157 | - phpstan.collector 158 | arguments: 159 | detectDeadConstants: %shipmonkDeadCode.detect.deadConstants% 160 | detectDeadEnumCases: %shipmonkDeadCode.detect.deadEnumCases% 161 | detectDeadProperties: %shipmonkDeadCode.detect.deadProperties% 162 | 163 | - 164 | class: ShipMonk\PHPStan\DeadCode\Collector\ProvidedUsagesCollector 165 | tags: 166 | - phpstan.collector 167 | arguments: 168 | memberUsageProviders: tagged(shipmonk.deadCode.memberUsageProvider) 169 | memberUsageExcluders: tagged(shipmonk.deadCode.memberUsageExcluder) 170 | 171 | - 172 | class: ShipMonk\PHPStan\DeadCode\Rule\DeadCodeRule 173 | tags: 174 | - phpstan.rules.rule 175 | - phpstan.diagnoseExtension 176 | arguments: 177 | detectDeadMethods: %shipmonkDeadCode.detect.deadMethods% 178 | reportTransitivelyDeadMethodAsSeparateError: %shipmonkDeadCode.reportTransitivelyDeadMethodAsSeparateError% 179 | 180 | - 181 | class: ShipMonk\PHPStan\DeadCode\Compatibility\BackwardCompatibilityChecker 182 | arguments: 183 | servicesWithOldTag: tagged(shipmonk.deadCode.entrypointProvider) 184 | trackMixedAccessParameterValue: %shipmonkDeadCode.trackMixedAccess% 185 | 186 | parameters: 187 | parametersNotInvalidatingCache: 188 | - parameters.shipmonkDeadCode.debug.usagesOf 189 | - parameters.shipmonkDeadCode.reportTransitivelyDeadMethodAsSeparateError 190 | shipmonkDeadCode: 191 | trackMixedAccess: null 192 | reportTransitivelyDeadMethodAsSeparateError: false 193 | detect: 194 | deadMethods: true 195 | deadConstants: true 196 | deadEnumCases: false 197 | deadProperties: false 198 | usageProviders: 199 | apiPhpDoc: 200 | enabled: true 201 | enum: 202 | enabled: true 203 | vendor: 204 | enabled: true 205 | builtin: 206 | enabled: true 207 | reflection: 208 | enabled: true 209 | phpstan: 210 | enabled: true 211 | phpunit: 212 | enabled: null 213 | phpbench: 214 | enabled: null 215 | behat: 216 | enabled: null 217 | symfony: 218 | enabled: null 219 | configDir: null 220 | twig: 221 | enabled: null 222 | doctrine: 223 | enabled: null 224 | nette: 225 | enabled: null 226 | streamWrapper: 227 | enabled: true 228 | usageExcluders: 229 | tests: 230 | enabled: false 231 | devPaths: null 232 | usageOverMixed: 233 | enabled: false 234 | debug: 235 | usagesOf: [] 236 | 237 | expandRelativePaths: 238 | - '[parameters][shipmonkDeadCode][usageProviders][symfony][configDir]' 239 | - '[parameters][shipmonkDeadCode][usageExcluders][tests][devPaths]' 240 | 241 | parametersSchema: 242 | shipmonkDeadCode: structure([ 243 | trackMixedAccess: schema(bool(), nullable()) # deprecated, use usageExcluders.usageOverMixed.enabled 244 | reportTransitivelyDeadMethodAsSeparateError: bool() 245 | detect: structure([ 246 | deadMethods: bool() 247 | deadConstants: bool() 248 | deadEnumCases: bool() 249 | deadProperties: bool() 250 | ]) 251 | usageProviders: structure([ 252 | apiPhpDoc: structure([ 253 | enabled: bool() 254 | ]) 255 | enum: structure([ 256 | enabled: bool() 257 | ]) 258 | vendor: structure([ 259 | enabled: bool() 260 | ]) 261 | builtin: structure([ 262 | enabled: bool() 263 | ]) 264 | reflection: structure([ 265 | enabled: bool() 266 | ]) 267 | phpstan: structure([ 268 | enabled: bool() 269 | ]) 270 | phpunit: structure([ 271 | enabled: schema(bool(), nullable()) 272 | ]) 273 | phpbench: structure([ 274 | enabled: schema(bool(), nullable()) 275 | ]) 276 | behat: structure([ 277 | enabled: schema(bool(), nullable()) 278 | ]) 279 | symfony: structure([ 280 | enabled: schema(bool(), nullable()) 281 | configDir: schema(string(), nullable()) 282 | ]) 283 | twig: structure([ 284 | enabled: schema(bool(), nullable()) 285 | ]) 286 | doctrine: structure([ 287 | enabled: schema(bool(), nullable()) 288 | ]) 289 | nette: structure([ 290 | enabled: schema(bool(), nullable()) 291 | ]) 292 | streamWrapper: structure([ 293 | enabled: bool() 294 | ]) 295 | ]) 296 | usageExcluders: structure([ 297 | tests: structure([ 298 | enabled: bool() 299 | devPaths: schema(listOf(string()), nullable()) 300 | ]) 301 | usageOverMixed: structure([ 302 | enabled: bool() 303 | ]) 304 | ]) 305 | debug: structure([ 306 | usagesOf: listOf(string()) 307 | ]) 308 | ]) 309 | -------------------------------------------------------------------------------- /src/Provider/ReflectionUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled; 40 | } 41 | 42 | public function getUsages( 43 | Node $node, 44 | Scope $scope 45 | ): array 46 | { 47 | if (!$this->enabled) { 48 | return []; 49 | } 50 | 51 | if ($node instanceof MethodCall) { 52 | return $this->processMethodCall($node, $scope); 53 | } 54 | 55 | return []; 56 | } 57 | 58 | /** 59 | * @return list 60 | */ 61 | private function processMethodCall( 62 | MethodCall $node, 63 | Scope $scope 64 | ): array 65 | { 66 | $callerType = $scope->getType($node->var); 67 | $methodNames = $this->getMethodNames($node, $scope); 68 | 69 | $usedConstants = []; 70 | $usedMethods = []; 71 | $usedEnumCases = []; 72 | $usedProperties = []; 73 | 74 | foreach ($methodNames as $methodName) { 75 | foreach ($callerType->getObjectClassReflections() as $reflection) { 76 | if (!$reflection->is(ReflectionClass::class)) { 77 | continue; 78 | } 79 | 80 | // ideally, we should check if T is covariant (marks children as used) or invariant (should not mark children as used) 81 | // the default changed in PHP 8.4, see: https://github.com/phpstan/phpstan/issues/12459#issuecomment-2607123277 82 | foreach ($reflection->getActiveTemplateTypeMap()->getTypes() as $genericType) { 83 | $genericClassNames = $genericType->getObjectClassNames() === [] 84 | ? [null] // call over ReflectionClass without specifying the generic type 85 | : $genericType->getObjectClassNames(); 86 | 87 | foreach ($genericClassNames as $genericClassName) { 88 | $usedConstants = [ 89 | ...$usedConstants, 90 | ...$this->extractConstantsUsedByReflection($genericClassName, $methodName, $node->getArgs(), $node, $scope), 91 | ]; 92 | $usedMethods = [ 93 | ...$usedMethods, 94 | ...$this->extractMethodsUsedByReflection($genericClassName, $methodName, $node->getArgs(), $node, $scope), 95 | ]; 96 | $usedEnumCases = [ 97 | ...$usedEnumCases, 98 | ...$this->extractEnumCasesUsedByReflection($genericClassName, $methodName, $node->getArgs(), $node, $scope), 99 | ]; 100 | $usedProperties = [ 101 | ...$usedProperties, 102 | ...$this->extractPropertiesUsedByReflection($genericClassName, $methodName, $node->getArgs(), $node, $scope), 103 | ]; 104 | } 105 | } 106 | } 107 | } 108 | 109 | return array_values(array_filter([ 110 | ...$usedConstants, 111 | ...$usedMethods, 112 | ...$usedEnumCases, 113 | ...$usedProperties, 114 | ], static fn (?ClassMemberUsage $usage): bool => $usage !== null)); 115 | } 116 | 117 | /** 118 | * @param array $args 119 | * @return list 120 | */ 121 | private function extractConstantsUsedByReflection( 122 | ?string $genericClassName, 123 | string $methodName, 124 | array $args, 125 | Node $node, 126 | Scope $scope 127 | ): array 128 | { 129 | $usedConstants = []; 130 | 131 | if ($methodName === 'getConstants' || $methodName === 'getReflectionConstants') { 132 | $usedConstants[] = $this->createConstantUsage($node, $scope, $genericClassName, null); 133 | } 134 | 135 | if (($methodName === 'getConstant' || $methodName === 'getReflectionConstant') && count($args) === 1) { 136 | $firstArg = $args[array_key_first($args)]; 137 | 138 | foreach ($scope->getType($firstArg->value)->getConstantStrings() as $constantString) { 139 | $usedConstants[] = $this->createConstantUsage($node, $scope, $genericClassName, $constantString->getValue()); 140 | } 141 | } 142 | 143 | return $usedConstants; 144 | } 145 | 146 | /** 147 | * @param array $args 148 | * @return list 149 | */ 150 | private function extractEnumCasesUsedByReflection( 151 | ?string $genericClassName, 152 | string $methodName, 153 | array $args, 154 | Node $node, 155 | Scope $scope 156 | ): array 157 | { 158 | $usedConstants = []; 159 | 160 | if ($methodName === 'getCases') { 161 | $usedConstants[] = $this->createEnumCaseUsage($node, $scope, $genericClassName, null); 162 | } 163 | 164 | if (($methodName === 'getCase') && count($args) === 1) { 165 | $firstArg = $args[array_key_first($args)]; 166 | 167 | foreach ($scope->getType($firstArg->value)->getConstantStrings() as $constantString) { 168 | $usedConstants[] = $this->createEnumCaseUsage($node, $scope, $genericClassName, $constantString->getValue()); 169 | } 170 | } 171 | 172 | return $usedConstants; 173 | } 174 | 175 | /** 176 | * @param array $args 177 | * @return list 178 | */ 179 | private function extractMethodsUsedByReflection( 180 | ?string $genericClassName, 181 | string $methodName, 182 | array $args, 183 | Node $node, 184 | Scope $scope 185 | ): array 186 | { 187 | $usedMethods = []; 188 | 189 | if ($methodName === 'getMethods') { 190 | $usedMethods[] = $this->createMethodUsage($node, $scope, $genericClassName, null); 191 | } 192 | 193 | if ($methodName === 'getMethod' && count($args) === 1) { 194 | $firstArg = $args[array_key_first($args)]; 195 | 196 | foreach ($scope->getType($firstArg->value)->getConstantStrings() as $constantString) { 197 | $usedMethods[] = $this->createMethodUsage($node, $scope, $genericClassName, $constantString->getValue()); 198 | } 199 | } 200 | 201 | if (in_array($methodName, ['getConstructor', 'newInstance', 'newInstanceArgs'], true)) { 202 | $usedMethods[] = $this->createMethodUsage($node, $scope, $genericClassName, '__construct'); 203 | } 204 | 205 | return $usedMethods; 206 | } 207 | 208 | /** 209 | * @param array $args 210 | * @return list 211 | */ 212 | private function extractPropertiesUsedByReflection( 213 | ?string $genericClassName, 214 | string $methodName, 215 | array $args, 216 | Node $node, 217 | Scope $scope 218 | ): array 219 | { 220 | $usedProperties = []; 221 | 222 | if ( 223 | $methodName === 'getProperties' 224 | || $methodName === 'getDefaultProperties' // simplified, ideally should mark white only default properties 225 | || $methodName === 'getStaticProperties' // simplified, ideally should mark white only static properties 226 | ) { 227 | $usedProperties[] = $this->createPropertyUsage($node, $scope, $genericClassName, null); 228 | } 229 | 230 | if (in_array($methodName, ['getProperty', 'getStaticPropertyValue'], true) && count($args) >= 1) { 231 | $firstArg = $args[array_key_first($args)]; 232 | 233 | foreach ($scope->getType($firstArg->value)->getConstantStrings() as $constantString) { 234 | $usedProperties[] = $this->createPropertyUsage($node, $scope, $genericClassName, $constantString->getValue()); 235 | } 236 | } 237 | 238 | return $usedProperties; 239 | } 240 | 241 | /** 242 | * @param NullsafeMethodCall|MethodCall|StaticCall|New_ $call 243 | * @return list 244 | */ 245 | private function getMethodNames( 246 | CallLike $call, 247 | Scope $scope 248 | ): array 249 | { 250 | if ($call instanceof New_) { 251 | return ['__construct']; 252 | } 253 | 254 | if ($call->name instanceof Expr) { 255 | $possibleMethodNames = []; 256 | 257 | foreach ($scope->getType($call->name)->getConstantStrings() as $constantString) { 258 | $possibleMethodNames[] = $constantString->getValue(); 259 | } 260 | 261 | return $possibleMethodNames; 262 | } 263 | 264 | return [$call->name->toString()]; 265 | } 266 | 267 | private function createConstantUsage( 268 | Node $node, 269 | Scope $scope, 270 | ?string $className, 271 | ?string $constantName 272 | ): ?ClassConstantUsage 273 | { 274 | if ($className === null && $constantName === null) { 275 | return null; 276 | } 277 | 278 | return new ClassConstantUsage( 279 | UsageOrigin::createRegular($node, $scope), 280 | new ClassConstantRef( 281 | $className, 282 | $constantName, 283 | true, 284 | TrinaryLogic::createMaybe(), 285 | ), 286 | ); 287 | } 288 | 289 | private function createEnumCaseUsage( 290 | Node $node, 291 | Scope $scope, 292 | ?string $className, 293 | ?string $enumCaseName 294 | ): ?ClassConstantUsage 295 | { 296 | if ($className === null && $enumCaseName === null) { 297 | return null; 298 | } 299 | 300 | return new ClassConstantUsage( 301 | UsageOrigin::createRegular($node, $scope), 302 | new ClassConstantRef( 303 | $className, 304 | $enumCaseName, 305 | false, 306 | TrinaryLogic::createYes(), 307 | ), 308 | ); 309 | } 310 | 311 | private function createMethodUsage( 312 | Node $node, 313 | Scope $scope, 314 | ?string $className, 315 | ?string $methodName 316 | ): ?ClassMethodUsage 317 | { 318 | if ($className === null && $methodName === null) { 319 | return null; 320 | } 321 | 322 | return new ClassMethodUsage( 323 | UsageOrigin::createRegular($node, $scope), 324 | new ClassMethodRef( 325 | $className, 326 | $methodName, 327 | true, 328 | ), 329 | ); 330 | } 331 | 332 | private function createPropertyUsage( 333 | Node $node, 334 | Scope $scope, 335 | ?string $className, 336 | ?string $propertyName 337 | ): ?ClassPropertyUsage 338 | { 339 | if ($className === null && $propertyName === null) { 340 | return null; 341 | } 342 | 343 | return new ClassPropertyUsage( 344 | UsageOrigin::createRegular($node, $scope), 345 | new ClassPropertyRef( 346 | $className, 347 | $propertyName, 348 | true, 349 | ), 350 | ); 351 | } 352 | 353 | } 354 | -------------------------------------------------------------------------------- /src/Collector/MethodCallCollector.php: -------------------------------------------------------------------------------- 1 | > 37 | */ 38 | final class MethodCallCollector implements Collector 39 | { 40 | 41 | use BufferedUsageCollector; 42 | 43 | /** 44 | * @var list 45 | */ 46 | private array $memberUsageExcluders; 47 | 48 | /** 49 | * @param list $memberUsageExcluders 50 | */ 51 | public function __construct( 52 | array $memberUsageExcluders 53 | ) 54 | { 55 | $this->memberUsageExcluders = $memberUsageExcluders; 56 | } 57 | 58 | public function getNodeType(): string 59 | { 60 | return Node::class; 61 | } 62 | 63 | /** 64 | * @return non-empty-list|null 65 | */ 66 | public function processNode( 67 | Node $node, 68 | Scope $scope 69 | ): ?array 70 | { 71 | if ($node instanceof MethodCallableNode) { // @phpstan-ignore-line ignore BC promise 72 | $this->registerMethodCall($node->getOriginalNode(), $scope); 73 | } 74 | 75 | if ($node instanceof StaticMethodCallableNode) { // @phpstan-ignore-line ignore BC promise 76 | $this->registerStaticCall($node->getOriginalNode(), $scope); 77 | } 78 | 79 | if ($node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof New_) { 80 | $this->registerMethodCall($node, $scope); 81 | } 82 | 83 | if ($node instanceof StaticCall) { 84 | $this->registerStaticCall($node, $scope); 85 | } 86 | 87 | if ($node instanceof Array_) { 88 | $this->registerArrayCallable($node, $scope); 89 | } 90 | 91 | if ($node instanceof Clone_) { 92 | $this->registerClone($node, $scope); 93 | } 94 | 95 | if ($node instanceof FuncCall) { 96 | $this->registerFunctionCall($node, $scope); 97 | } 98 | 99 | if ($node instanceof Attribute) { 100 | $this->registerAttribute($node, $scope); 101 | } 102 | 103 | return $this->emitUsages($scope); 104 | } 105 | 106 | /** 107 | * @param NullsafeMethodCall|MethodCall|New_ $methodCall 108 | */ 109 | private function registerMethodCall( 110 | CallLike $methodCall, 111 | Scope $scope 112 | ): void 113 | { 114 | $methodNames = $this->getMethodNames($methodCall, $scope); 115 | 116 | if ($methodCall instanceof New_) { 117 | if ($methodCall->class instanceof Expr) { 118 | $callerType = $scope->getType($methodCall); 119 | $possibleDescendantCall = null; 120 | 121 | } elseif ($methodCall->class instanceof Name) { 122 | $callerType = $scope->resolveTypeByName($methodCall->class); 123 | $possibleDescendantCall = $methodCall->class->toString() === 'static'; 124 | 125 | } else { 126 | return; 127 | } 128 | } else { 129 | $callerType = $scope->getType($methodCall->var); 130 | $possibleDescendantCall = null; 131 | } 132 | 133 | foreach ($methodNames as $methodName) { 134 | foreach ($this->getDeclaringTypesWithMethod($methodName, $callerType, TrinaryLogic::createNo(), $possibleDescendantCall) as $methodRef) { 135 | $this->registerUsage( 136 | new ClassMethodUsage( 137 | UsageOrigin::createRegular($methodCall, $scope), 138 | $methodRef, 139 | ), 140 | $methodCall, 141 | $scope, 142 | ); 143 | } 144 | } 145 | } 146 | 147 | private function registerStaticCall( 148 | StaticCall $staticCall, 149 | Scope $scope 150 | ): void 151 | { 152 | $methodNames = $this->getMethodNames($staticCall, $scope); 153 | 154 | if ($staticCall->class instanceof Expr) { 155 | $callerType = $scope->getType($staticCall->class); 156 | $possibleDescendantCall = null; 157 | 158 | } else { 159 | $callerType = $scope->resolveTypeByName($staticCall->class); 160 | $possibleDescendantCall = $staticCall->class->toString() === 'static'; 161 | } 162 | 163 | foreach ($methodNames as $methodName) { 164 | foreach ($this->getDeclaringTypesWithMethod($methodName, $callerType, TrinaryLogic::createYes(), $possibleDescendantCall) as $methodRef) { 165 | $this->registerUsage( 166 | new ClassMethodUsage( 167 | UsageOrigin::createRegular($staticCall, $scope), 168 | $methodRef, 169 | ), 170 | $staticCall, 171 | $scope, 172 | ); 173 | } 174 | } 175 | } 176 | 177 | private function registerArrayCallable( 178 | Array_ $array, 179 | Scope $scope 180 | ): void 181 | { 182 | if ($scope->getType($array)->isCallable()->yes()) { 183 | foreach ($scope->getType($array)->getConstantArrays() as $constantArray) { 184 | $callableTypeAndNames = $constantArray->findTypeAndMethodNames(); 185 | 186 | foreach ($callableTypeAndNames as $typeAndName) { 187 | $caller = $typeAndName->getType(); 188 | $methodName = $typeAndName->getMethod(); 189 | 190 | foreach ($this->getDeclaringTypesWithMethod($methodName, $caller, TrinaryLogic::createMaybe()) as $methodRef) { 191 | $this->registerUsage( 192 | new ClassMethodUsage( 193 | UsageOrigin::createRegular($array, $scope), 194 | $methodRef, 195 | ), 196 | $array, 197 | $scope, 198 | ); 199 | } 200 | } 201 | } 202 | } 203 | } 204 | 205 | private function registerAttribute( 206 | Attribute $node, 207 | Scope $scope 208 | ): void 209 | { 210 | $this->registerUsage( 211 | new ClassMethodUsage( 212 | UsageOrigin::createRegular($node, $scope), 213 | new ClassMethodRef($scope->resolveName($node->name), '__construct', false), 214 | ), 215 | $node, 216 | $scope, 217 | ); 218 | } 219 | 220 | private function registerClone( 221 | Clone_ $node, 222 | Scope $scope 223 | ): void 224 | { 225 | $methodName = '__clone'; 226 | $callerType = $scope->getType($node->expr); 227 | 228 | foreach ($this->getDeclaringTypesWithMethod($methodName, $callerType, TrinaryLogic::createNo()) as $methodRef) { 229 | $this->registerUsage( 230 | new ClassMethodUsage( 231 | UsageOrigin::createRegular($node, $scope), 232 | $methodRef, 233 | ), 234 | $node, 235 | $scope, 236 | ); 237 | } 238 | } 239 | 240 | private function registerFunctionCall( 241 | FuncCall $node, 242 | Scope $scope 243 | ): void 244 | { 245 | $args = $node->getArgs(); 246 | if (count($args) === 0) { 247 | return; 248 | } 249 | 250 | $firstArg = current($args); 251 | 252 | if ($node->name instanceof Name) { 253 | $functionNames = [$node->name->toString()]; 254 | } else { 255 | $nameType = $scope->getType($node->name); 256 | $functionNames = array_map(static fn (ConstantStringType $string): string => $string->getValue(), $nameType->getConstantStrings()); 257 | } 258 | 259 | foreach ($functionNames as $functionName) { 260 | if ($functionName !== 'clone') { 261 | continue; 262 | } 263 | 264 | $callerType = $scope->getType($firstArg->value); 265 | 266 | foreach ($this->getDeclaringTypesWithMethod('__clone', $callerType, TrinaryLogic::createNo()) as $methodRef) { 267 | $this->registerUsage( 268 | new ClassMethodUsage( 269 | UsageOrigin::createRegular($node, $scope), 270 | $methodRef, 271 | ), 272 | $node, 273 | $scope, 274 | ); 275 | } 276 | } 277 | } 278 | 279 | /** 280 | * @param NullsafeMethodCall|MethodCall|StaticCall|New_ $call 281 | * @return list 282 | */ 283 | private function getMethodNames( 284 | CallLike $call, 285 | Scope $scope 286 | ): array 287 | { 288 | if ($call instanceof New_) { 289 | return ['__construct']; 290 | } 291 | 292 | if ($call->name instanceof Expr) { 293 | $possibleMethodNames = []; 294 | 295 | foreach ($scope->getType($call->name)->getConstantStrings() as $constantString) { 296 | $possibleMethodNames[] = $constantString->getValue(); 297 | } 298 | 299 | return $possibleMethodNames === [] 300 | ? [null] // unknown method name 301 | : $possibleMethodNames; 302 | } 303 | 304 | return [$call->name->toString()]; 305 | } 306 | 307 | /** 308 | * @return list> 309 | */ 310 | private function getDeclaringTypesWithMethod( 311 | ?string $methodName, 312 | Type $type, 313 | TrinaryLogic $isStaticCall, 314 | ?bool $isPossibleDescendant = null 315 | ): array 316 | { 317 | $typeNoNull = TypeCombinator::removeNull($type); // remove null to support nullsafe calls 318 | $typeNormalized = TypeUtils::toBenevolentUnion($typeNoNull); // extract possible calls even from Class|int 319 | $classReflections = $typeNormalized->getObjectTypeOrClassStringObjectType()->getObjectClassReflections(); 320 | 321 | $result = []; 322 | 323 | foreach ($classReflections as $classReflection) { 324 | $possibleDescendant = $isPossibleDescendant ?? !$classReflection->isFinalByKeyword(); 325 | $result[] = new ClassMethodRef($classReflection->getName(), $methodName, $possibleDescendant); 326 | } 327 | 328 | $canBeObjectCall = !$typeNoNull->isObject()->no() && !$isStaticCall->yes(); 329 | $canBeClassStringCall = !$typeNoNull->isClassString()->no() && !$isStaticCall->no(); 330 | 331 | if ($result === [] && ($canBeObjectCall || $canBeClassStringCall)) { 332 | $result[] = new ClassMethodRef(null, $methodName, true); // call over unknown type 333 | } 334 | 335 | return $result; 336 | } 337 | 338 | private function registerUsage( 339 | ClassMethodUsage $usage, 340 | Node $node, 341 | Scope $scope 342 | ): void 343 | { 344 | $excluderName = null; 345 | 346 | foreach ($this->memberUsageExcluders as $excludedUsageDecider) { 347 | if ($excludedUsageDecider->shouldExclude($usage, $node, $scope)) { 348 | $excluderName = $excludedUsageDecider->getIdentifier(); 349 | break; 350 | } 351 | } 352 | 353 | $this->usages[] = new CollectedUsage($usage, $excluderName); 354 | } 355 | 356 | } 357 | -------------------------------------------------------------------------------- /src/Provider/DoctrineUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled ?? $this->isDoctrineInstalled(); 35 | } 36 | 37 | public function getUsages( 38 | Node $node, 39 | Scope $scope 40 | ): array 41 | { 42 | if (!$this->enabled) { 43 | return []; 44 | } 45 | 46 | $usages = []; 47 | 48 | if ($node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 49 | $usages = [ 50 | ...$usages, 51 | ...$this->getUsagesFromReflection($node, $scope), 52 | ]; 53 | } 54 | 55 | if ($node instanceof Return_) { 56 | $usages = [ 57 | ...$usages, 58 | ...$this->getUsagesOfEventSubscriber($node, $scope), 59 | ]; 60 | } 61 | 62 | return $usages; 63 | } 64 | 65 | /** 66 | * @return list 67 | */ 68 | private function getUsagesFromReflection( 69 | InClassNode $node, 70 | Scope $scope 71 | ): array 72 | { 73 | $classReflection = $node->getClassReflection(); 74 | $nativeReflection = $classReflection->getNativeReflection(); 75 | 76 | $usages = []; 77 | 78 | foreach ($nativeReflection->getProperties() as $nativePropertyReflection) { 79 | $propertyName = $nativePropertyReflection->name; 80 | $propertyReflection = $classReflection->getProperty($propertyName, $scope); 81 | 82 | $usages = [ 83 | ...$usages, 84 | ...$this->getUsagesOfEnumColumn($classReflection->getName(), $propertyReflection), 85 | ]; 86 | 87 | $propertyUsageNote = $this->shouldMarkPropertyAsUsed($nativePropertyReflection); 88 | 89 | if ($propertyUsageNote !== null) { 90 | $usages[] = $this->createPropertyUsage($nativePropertyReflection, $propertyUsageNote); 91 | } 92 | } 93 | 94 | foreach ($nativeReflection->getMethods() as $method) { 95 | if ($method->getDeclaringClass()->getName() !== $nativeReflection->getName()) { 96 | continue; 97 | } 98 | 99 | $usageNote = $this->shouldMarkMethodAsUsed($method); 100 | 101 | if ($usageNote !== null) { 102 | $usages[] = $this->createMethodUsage($classReflection->getNativeMethod($method->getName()), $usageNote); 103 | } 104 | } 105 | 106 | return $usages; 107 | } 108 | 109 | /** 110 | * @return list 111 | */ 112 | private function getUsagesOfEventSubscriber( 113 | Return_ $node, 114 | Scope $scope 115 | ): array 116 | { 117 | if ($node->expr === null) { 118 | return []; 119 | } 120 | 121 | if (!$scope->isInClass()) { 122 | return []; 123 | } 124 | 125 | if (!$scope->getFunction() instanceof MethodReflection) { 126 | return []; 127 | } 128 | 129 | if ($scope->getFunction()->getName() !== 'getSubscribedEvents') { 130 | return []; 131 | } 132 | 133 | if (!$scope->getClassReflection()->implementsInterface('Doctrine\Common\EventSubscriber')) { 134 | return []; 135 | } 136 | 137 | $className = $scope->getClassReflection()->getName(); 138 | 139 | $usages = []; 140 | $usageOrigin = UsageOrigin::createRegular($node, $scope); 141 | 142 | foreach ($scope->getType($node->expr)->getConstantArrays() as $rootArray) { 143 | foreach ($rootArray->getValuesArray()->getValueTypes() as $eventConfig) { 144 | foreach ($eventConfig->getConstantStrings() as $subscriberMethodString) { 145 | $usages[] = new ClassMethodUsage( 146 | $usageOrigin, 147 | new ClassMethodRef( 148 | $className, 149 | $subscriberMethodString->getValue(), 150 | true, 151 | ), 152 | ); 153 | } 154 | } 155 | } 156 | 157 | return $usages; 158 | } 159 | 160 | private function shouldMarkMethodAsUsed(ReflectionMethod $method): ?string 161 | { 162 | $methodName = $method->getName(); 163 | $class = $method->getDeclaringClass(); 164 | 165 | if ($this->isLifecycleEventMethod($method)) { 166 | return 'Lifecycle event method via attribute'; 167 | } 168 | 169 | if ($this->isEntityRepositoryConstructor($class, $method)) { 170 | return 'Entity repository constructor (created by EntityRepositoryFactory)'; 171 | } 172 | 173 | if ($this->isPartOfAsEntityListener($class, $methodName)) { 174 | return 'Is part of AsEntityListener methods'; 175 | } 176 | 177 | if ($this->isPartOfAsDoctrineListener($class, $methodName)) { 178 | return 'Is part of AsDoctrineListener methods'; 179 | } 180 | 181 | if ($this->isPartOfAutoconfigureTagDoctrineListener($class, $methodName)) { 182 | return 'Is part of AutoconfigureTag doctrine.event_listener methods'; 183 | } 184 | 185 | if ($this->isProbablyDoctrineListener($methodName)) { 186 | return 'Is probable listener method'; 187 | } 188 | 189 | return null; 190 | } 191 | 192 | private function shouldMarkPropertyAsUsed(ReflectionProperty $property): ?string 193 | { 194 | $attributes = $property->getAttributes(); 195 | 196 | foreach ($attributes as $attribute) { 197 | $attributeName = $attribute->getName(); 198 | 199 | if (strpos($attributeName, 'Doctrine\ORM\Mapping\\') === 0) { 200 | return 'Doctrine ORM mapped property'; 201 | } 202 | } 203 | 204 | return null; 205 | } 206 | 207 | private function isLifecycleEventMethod(ReflectionMethod $method): bool 208 | { 209 | return $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PostLoad') 210 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PostPersist') 211 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PostUpdate') 212 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PreFlush') 213 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PrePersist') 214 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PreRemove') 215 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PreUpdate'); 216 | } 217 | 218 | /** 219 | * Ideally, we would need to parse DIC xml to know this for sure just like phpstan-symfony does. 220 | * - see Doctrine\ORM\Events::* 221 | */ 222 | private function isProbablyDoctrineListener(string $methodName): bool 223 | { 224 | return $methodName === 'preRemove' 225 | || $methodName === 'postRemove' 226 | || $methodName === 'prePersist' 227 | || $methodName === 'postPersist' 228 | || $methodName === 'preUpdate' 229 | || $methodName === 'postUpdate' 230 | || $methodName === 'postLoad' 231 | || $methodName === 'loadClassMetadata' 232 | || $methodName === 'onClassMetadataNotFound' 233 | || $methodName === 'preFlush' 234 | || $methodName === 'onFlush' 235 | || $methodName === 'postFlush' 236 | || $methodName === 'onClear'; 237 | } 238 | 239 | private function hasAttribute( 240 | ReflectionMethod $method, 241 | string $attributeClass 242 | ): bool 243 | { 244 | return $method->getAttributes($attributeClass) !== []; 245 | } 246 | 247 | private function isPartOfAsEntityListener( 248 | ReflectionClass $class, 249 | string $methodName 250 | ): bool 251 | { 252 | foreach ($class->getAttributes('Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener') as $attribute) { 253 | $listenerMethodName = $attribute->getArguments()['method'] ?? $attribute->getArguments()[1] ?? null; 254 | 255 | if ($listenerMethodName === $methodName) { 256 | return true; 257 | } 258 | } 259 | 260 | return false; 261 | } 262 | 263 | private function isPartOfAsDoctrineListener( 264 | ReflectionClass $class, 265 | string $methodName 266 | ): bool 267 | { 268 | foreach ($class->getAttributes('Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener') as $attribute) { 269 | $eventName = $attribute->getArguments()['event'] ?? $attribute->getArguments()[0] ?? null; 270 | 271 | // AsDoctrineListener doesn't have a 'method' parameter 272 | // Symfony looks for a method named after the event, or falls back to __invoke 273 | if ($eventName === $methodName || $methodName === '__invoke') { 274 | return true; 275 | } 276 | } 277 | 278 | return false; 279 | } 280 | 281 | private function isPartOfAutoconfigureTagDoctrineListener( 282 | ReflectionClass $class, 283 | string $methodName 284 | ): bool 285 | { 286 | foreach ($class->getAttributes('Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag') as $attribute) { 287 | $arguments = $attribute->getArguments(); 288 | $tagName = $arguments[0] ?? $arguments['name'] ?? null; 289 | 290 | // Only handle doctrine.event_listener tags 291 | if ($tagName !== 'doctrine.event_listener') { 292 | continue; 293 | } 294 | 295 | $listenerMethodName = $arguments['method'] ?? null; 296 | 297 | // If no method is specified, the listener method name is inferred from the event name 298 | if ($listenerMethodName === null) { 299 | $eventName = $arguments['event'] ?? null; 300 | 301 | if ($eventName === $methodName) { 302 | return true; 303 | } 304 | } elseif ($listenerMethodName === $methodName) { 305 | return true; 306 | } 307 | } 308 | 309 | return false; 310 | } 311 | 312 | private function isEntityRepositoryConstructor( 313 | ReflectionClass $class, 314 | ReflectionMethod $method 315 | ): bool 316 | { 317 | if (!$method->isConstructor()) { 318 | return false; 319 | } 320 | 321 | return $class->isSubclassOf('Doctrine\ORM\EntityRepository'); 322 | } 323 | 324 | private function isDoctrineInstalled(): bool 325 | { 326 | return InstalledVersions::isInstalled('doctrine/orm') 327 | || InstalledVersions::isInstalled('doctrine/event-manager') 328 | || InstalledVersions::isInstalled('doctrine/doctrine-bundle'); 329 | } 330 | 331 | private function createMethodUsage( 332 | ExtendedMethodReflection $methodReflection, 333 | string $note 334 | ): ClassMethodUsage 335 | { 336 | return new ClassMethodUsage( 337 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote($note)), 338 | new ClassMethodRef( 339 | $methodReflection->getDeclaringClass()->getName(), 340 | $methodReflection->getName(), 341 | false, 342 | ), 343 | ); 344 | } 345 | 346 | private function createPropertyUsage( 347 | ReflectionProperty $propertyReflection, 348 | string $note 349 | ): ClassPropertyUsage 350 | { 351 | return new ClassPropertyUsage( 352 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote($note)), 353 | new ClassPropertyRef( 354 | $propertyReflection->getDeclaringClass()->getName(), 355 | $propertyReflection->getName(), 356 | false, 357 | ), 358 | ); 359 | } 360 | 361 | /** 362 | * @return list 363 | */ 364 | private function getUsagesOfEnumColumn( 365 | string $className, 366 | ExtendedPropertyReflection $property 367 | ): array 368 | { 369 | $usages = []; 370 | $propertyName = $property->getName(); 371 | 372 | foreach ($property->getAttributes() as $attribute) { 373 | if ($attribute->getName() !== 'Doctrine\ORM\Mapping\Column') { 374 | continue; 375 | } 376 | 377 | foreach ($attribute->getArgumentTypes() as $name => $type) { 378 | if ($name !== 'enumType') { 379 | continue; 380 | } 381 | 382 | foreach ($type->getConstantStrings() as $constantString) { 383 | $usages[] = new ClassConstantUsage( 384 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote("Used in enumType of #[Column] of $className::$propertyName")), 385 | new ClassConstantRef( 386 | $constantString->getValue(), 387 | null, 388 | false, 389 | TrinaryLogic::createYes(), 390 | ), 391 | ); 392 | } 393 | } 394 | } 395 | 396 | return $usages; 397 | } 398 | 399 | } 400 | --------------------------------------------------------------------------------