diff --git a/.ecrc b/.ecrc new file mode 100644 index 00000000..430e6751 --- /dev/null +++ b/.ecrc @@ -0,0 +1,5 @@ +{ + "Exclude": [ + "^tests/Rule/data/debug/expected_output.txt" + ] +} diff --git a/README.md b/README.md index ac6605ab..69287ee4 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ use PHPStan\Analyser\Scope; use ReflectionMethod; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; -use ShipMonk\PHPStan\DeadCode\Graph\UsageOriginDetector; +use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; use ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider; use Symfony\Component\Serializer\SerializerInterface; @@ -185,13 +185,13 @@ class DeserializationUsageProvider implements MemberUsageProvider $secondArgument = $node->getArgs()[1]->value; $serializedClass = $scope->getType($secondArgument)->getConstantStrings()[0]; - // record the method it was called from (needed for proper transitive dead code elimination) - $originRef = $this->originDetector->detectOrigin($scope); + // record the place it was called from (needed for proper transitive dead code elimination) + $usageOrigin = UsageOrigin::createRegular($node, $scope); // record the hidden constructor call $constructorRef = new ClassMethodRef($serializedClass->getValue(), '__construct', false); - return [new ClassMethodUsage($originRef, $constructorRef)]; + return [new ClassMethodUsage($usageOrigin, $constructorRef)]; } return []; @@ -350,6 +350,34 @@ class IgnoreDeadInterfaceUsageProvider extends ReflectionBasedMemberUsageProvide } ``` +## Debugging: +- If you want to see how dead code detector evaluated usages of certain method, you do the following: + +```neon +parameters: + shipmonkDeadCode: + debug: + usagesOf: + - App\User\Entity\Address::__construct +``` + +Then, run PHPStan with `-vvv` CLI option and you will see the output like this: + +```txt +App\User\Entity\Address::__construct +| +| Marked as alive by: +| entry virtual usage from ShipMonk\PHPStan\DeadCode\Provider\SymfonyUsageProvider +| calls App\User\RegisterUserController::__invoke:36 +| calls App\User\UserFacade::registerUser:142 +| calls App\User\Entity\Address::__construct +| +| Found 2 usages: +| • src/User/UserFacade.php:142 +| • tests/User/Entity/AddressTest.php:64 - excluded by tests excluder +``` + +If you set up `editorUrl` [parameter](https://phpstan.org/user-guide/output-format#opening-file-in-an-editor), you can click on the usages to open it in your IDE. ## Future scope: - Dead class property detection diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 124aafd5..6cd93ebc 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -196,7 +196,6 @@ - diff --git a/rules.neon b/rules.neon index e85ef4a2..1af62305 100644 --- a/rules.neon +++ b/rules.neon @@ -8,7 +8,10 @@ services: class: ShipMonk\PHPStan\DeadCode\Transformer\FileSystem - - class: ShipMonk\PHPStan\DeadCode\Graph\UsageOriginDetector + class: ShipMonk\PHPStan\DeadCode\Debug\DebugUsagePrinter + arguments: + trackMixedAccess: %shipmonkDeadCode.trackMixedAccess% + editorUrl: %editorUrl% - class: ShipMonk\PHPStan\DeadCode\Provider\VendorUsageProvider @@ -105,7 +108,6 @@ services: - phpstan.diagnoseExtension arguments: reportTransitivelyDeadMethodAsSeparateError: %shipmonkDeadCode.reportTransitivelyDeadMethodAsSeparateError% - trackMixedAccess: %shipmonkDeadCode.trackMixedAccess% - class: ShipMonk\PHPStan\DeadCode\Compatibility\BackwardCompatibilityChecker @@ -137,6 +139,8 @@ parameters: tests: enabled: false devPaths: null + debug: + usagesOf: [] parametersSchema: shipmonkDeadCode: structure([ @@ -172,4 +176,7 @@ parametersSchema: devPaths: schema(listOf(string()), nullable()) ]) ]) + debug: structure([ + usagesOf: listOf(string()) + ]) ]) diff --git a/src/Collector/ConstantFetchCollector.php b/src/Collector/ConstantFetchCollector.php index 07d9c11e..98a21061 100644 --- a/src/Collector/ConstantFetchCollector.php +++ b/src/Collector/ConstantFetchCollector.php @@ -18,7 +18,7 @@ use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantUsage; use ShipMonk\PHPStan\DeadCode\Graph\CollectedUsage; -use ShipMonk\PHPStan\DeadCode\Graph\UsageOriginDetector; +use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; use function array_map; use function count; use function current; @@ -33,8 +33,6 @@ class ConstantFetchCollector implements Collector use BufferedUsageCollector; - private UsageOriginDetector $usageOriginDetector; - private ReflectionProvider $reflectionProvider; private bool $trackMixedAccess; @@ -48,7 +46,6 @@ class ConstantFetchCollector implements Collector * @param list $memberUsageExcluders */ public function __construct( - UsageOriginDetector $usageOriginDetector, ReflectionProvider $reflectionProvider, bool $trackMixedAccess, array $memberUsageExcluders @@ -56,7 +53,6 @@ public function __construct( { $this->reflectionProvider = $reflectionProvider; $this->trackMixedAccess = $trackMixedAccess; - $this->usageOriginDetector = $usageOriginDetector; $this->memberUsageExcluders = $memberUsageExcluders; } @@ -125,7 +121,7 @@ private function registerFunctionCall(FuncCall $node, Scope $scope): void $this->registerUsage( new ClassConstantUsage( - $this->usageOriginDetector->detectOrigin($scope), + UsageOrigin::createRegular($node, $scope), new ClassConstantRef($className, $constantName, true), ), $node, @@ -157,7 +153,7 @@ private function registerFetch(ClassConstFetch $node, Scope $scope): void foreach ($this->getDeclaringTypesWithConstant($ownerType, $constantName) as $className) { $this->registerUsage( new ClassConstantUsage( - $this->usageOriginDetector->detectOrigin($scope), + UsageOrigin::createRegular($node, $scope), new ClassConstantRef($className, $constantName, $possibleDescendantFetch), ), $node, diff --git a/src/Collector/MethodCallCollector.php b/src/Collector/MethodCallCollector.php index a6b8e6b4..bc1f4c20 100644 --- a/src/Collector/MethodCallCollector.php +++ b/src/Collector/MethodCallCollector.php @@ -25,7 +25,7 @@ use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; use ShipMonk\PHPStan\DeadCode\Graph\CollectedUsage; -use ShipMonk\PHPStan\DeadCode\Graph\UsageOriginDetector; +use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; /** * @implements Collector> @@ -35,8 +35,6 @@ class MethodCallCollector implements Collector use BufferedUsageCollector; - private UsageOriginDetector $usageOriginDetector; - private bool $trackMixedAccess; /** @@ -48,12 +46,10 @@ class MethodCallCollector implements Collector * @param list $memberUsageExcluders */ public function __construct( - UsageOriginDetector $usageOriginDetector, bool $trackMixedAccess, array $memberUsageExcluders ) { - $this->usageOriginDetector = $usageOriginDetector; $this->trackMixedAccess = $trackMixedAccess; $this->memberUsageExcluders = $memberUsageExcluders; } @@ -133,7 +129,7 @@ private function registerMethodCall( foreach ($this->getDeclaringTypesWithMethod($methodName, $callerType, TrinaryLogic::createNo(), $possibleDescendantCall) as $methodRef) { $this->registerUsage( new ClassMethodUsage( - $this->usageOriginDetector->detectOrigin($scope), + UsageOrigin::createRegular($methodCall, $scope), $methodRef, ), $methodCall, @@ -163,7 +159,7 @@ private function registerStaticCall( foreach ($this->getDeclaringTypesWithMethod($methodName, $callerType, TrinaryLogic::createYes(), $possibleDescendantCall) as $methodRef) { $this->registerUsage( new ClassMethodUsage( - $this->usageOriginDetector->detectOrigin($scope), + UsageOrigin::createRegular($staticCall, $scope), $methodRef, ), $staticCall, @@ -189,7 +185,7 @@ private function registerArrayCallable( foreach ($this->getDeclaringTypesWithMethod($methodName, $caller, TrinaryLogic::createMaybe()) as $methodRef) { $this->registerUsage( new ClassMethodUsage( - $this->usageOriginDetector->detectOrigin($scope), + UsageOrigin::createRegular($array, $scope), $methodRef, ), $array, @@ -205,7 +201,7 @@ private function registerAttribute(Attribute $node, Scope $scope): void { $this->registerUsage( new ClassMethodUsage( - null, + UsageOrigin::createRegular($node, $scope), new ClassMethodRef($scope->resolveName($node->name), '__construct', false), ), $node, @@ -221,7 +217,7 @@ private function registerClone(Clone_ $node, Scope $scope): void foreach ($this->getDeclaringTypesWithMethod($methodName, $callerType, TrinaryLogic::createNo()) as $methodRef) { $this->registerUsage( new ClassMethodUsage( - $this->usageOriginDetector->detectOrigin($scope), + UsageOrigin::createRegular($node, $scope), $methodRef, ), $node, diff --git a/src/Collector/ProvidedUsagesCollector.php b/src/Collector/ProvidedUsagesCollector.php index 6df74e3b..fb26a570 100644 --- a/src/Collector/ProvidedUsagesCollector.php +++ b/src/Collector/ProvidedUsagesCollector.php @@ -86,12 +86,13 @@ private function validateUsage( $memberRef = $usage->getMemberRef(); $memberRefClass = $memberRef->getClassName(); - $originRef = $usage->getOrigin(); - $originRefClass = $originRef === null ? null : $originRef->getClassName(); + $origin = $usage->getOrigin(); + $originClass = $origin->getClassName(); + $originMethod = $origin->getMethodName(); $context = sprintf( - "It was emitted as %s by %s for node '%s' in '%s' on line %s", - $usage->toHumanString(), + "It emitted usage of %s by %s for node '%s' in '%s' on line %s", + $usage->getMemberRef()->toHumanString(), get_class($provider), get_class($node), $scope->getFile(), @@ -102,16 +103,13 @@ private function validateUsage( throw new LogicException("Class '$memberRefClass' does not exist. $context"); } - if ( - $originRef !== null - && $originRefClass !== null - ) { - if (!$this->reflectionProvider->hasClass($originRefClass)) { - throw new LogicException("Class '{$originRefClass}' does not exist. $context"); + if ($originClass !== null) { + if (!$this->reflectionProvider->hasClass($originClass)) { + throw new LogicException("Class '{$originClass}' does not exist. $context"); } - if (!$this->reflectionProvider->getClass($originRefClass)->hasMethod($originRef->getMemberName())) { - throw new LogicException("Method '{$originRef->getMemberName()}' does not exist in class '$originRefClass'. $context"); + if ($originMethod !== null && !$this->reflectionProvider->getClass($originClass)->hasMethod($originMethod)) { + throw new LogicException("Method '{$originMethod}' does not exist in class '$originClass'. $context"); } } } diff --git a/src/Debug/DebugUsagePrinter.php b/src/Debug/DebugUsagePrinter.php new file mode 100644 index 00000000..65699051 --- /dev/null +++ b/src/Debug/DebugUsagePrinter.php @@ -0,0 +1,389 @@ + usage info + * + * @var array, eliminationPath?: array>, neverReported?: string}> + */ + private array $debugMembers; + + public function __construct( + Container $container, + RelativePathHelper $relativePathHelper, + ReflectionProvider $reflectionProvider, + ?string $editorUrl, + bool $trackMixedAccess + ) + { + $this->relativePathHelper = $relativePathHelper; + $this->reflectionProvider = $reflectionProvider; + $this->editorUrl = $editorUrl; + $this->trackMixedAccess = $trackMixedAccess; + $this->debugMembers = $this->buildDebugMemberKeys( + // @phpstan-ignore offsetAccess.nonOffsetAccessible, offsetAccess.nonOffsetAccessible, missingType.checkedException, argument.type + $container->getParameter('shipmonkDeadCode')['debug']['usagesOf'], // prevents https://github.com/phpstan/phpstan/issues/12740 + ); + } + + /** + * @param array>> $mixedMemberUsages + */ + public function printMixedMemberUsages(Output $output, array $mixedMemberUsages): void + { + if ($mixedMemberUsages === [] || !$output->isDebug() || !$this->trackMixedAccess) { + return; + } + + $totalCount = array_sum(array_map('count', $mixedMemberUsages)); + $maxExamplesToShow = 20; + $examplesShown = 0; + $plural = $totalCount > 1 ? 's' : ''; + $output->writeLineFormatted(sprintf('Found %d usage%s over unknown type:', $totalCount, $plural)); + + foreach ($mixedMemberUsages as $memberType => $collectedUsages) { + foreach ($collectedUsages as $memberName => $usages) { + $examplesShown++; + $memberTypeString = $memberType === MemberType::METHOD ? 'method' : 'constant'; + $output->writeFormatted(sprintf(' • %s %s', $memberName, $memberTypeString)); + + $exampleCaller = $this->getExampleCaller($usages); + + if ($exampleCaller !== null) { + $output->writeFormatted(sprintf(', for example in %s', $exampleCaller)); + } + + $output->writeLineFormatted(''); + + if ($examplesShown >= $maxExamplesToShow) { + break 2; + } + } + } + + if ($totalCount > $maxExamplesToShow) { + $output->writeLineFormatted(sprintf('... and %d more', $totalCount - $maxExamplesToShow)); + } + + $output->writeLineFormatted(''); + $output->writeLineFormatted('Thus, any member named the same is considered used, no matter its declaring class!'); + $output->writeLineFormatted(''); + } + + /** + * @param list $usages + */ + private function getExampleCaller(array $usages): ?string + { + foreach ($usages as $usage) { + $origin = $usage->getUsage()->getOrigin(); + + if ($origin->getFile() !== null) { + return $this->getOriginReference($origin); + } + } + + return null; + } + + /** + * @param array $analysedClasses + */ + public function printDebugMemberUsages(Output $output, array $analysedClasses): void + { + if ($this->debugMembers === [] || !$output->isDebug()) { + return; + } + + $output->writeLineFormatted("\nUsage debugging information:"); + + foreach ($this->debugMembers as $memberKey => $debugMember) { + $typeName = $debugMember['typename']; + + $output->writeLineFormatted(sprintf("\n%s", $this->prettyMemberKey($memberKey))); + + if (isset($debugMember['eliminationPath'])) { + $output->writeLineFormatted("|\n| Marked as alive at:"); + $depth = 1; + + foreach ($debugMember['eliminationPath'] as $fragmentKey => $fragmentUsages) { + if ($depth === 1) { + $entrypoint = $this->getOriginReference($fragmentUsages[0]->getOrigin(), false); + $output->writeLineFormatted(sprintf('| entry %s', $entrypoint)); + } + + $indent = str_repeat(' ', $depth) . 'calls '; + + $nextFragmentUsages = next($debugMember['eliminationPath']); + $nextFragmentFirstUsage = $nextFragmentUsages !== false ? reset($nextFragmentUsages) : null; + $nextFragmentFirstUsageOrigin = $nextFragmentFirstUsage instanceof ClassMemberUsage ? $nextFragmentFirstUsage->getOrigin() : null; + + $pathFragment = $nextFragmentFirstUsageOrigin === null + ? $this->prettyMemberKey($fragmentKey) + : $this->getOriginLink($nextFragmentFirstUsageOrigin, $this->prettyMemberKey($fragmentKey)); + + $output->writeLineFormatted(sprintf('| %s%s', $indent, $pathFragment)); + + $depth++; + } + } elseif (!isset($analysedClasses[$typeName])) { + $output->writeLineFormatted("|\n| Not defined within analysed files!"); + + } elseif (isset($debugMember['usages'])) { + $output->writeLineFormatted("|\n| Dead because:"); + $output->writeLineFormatted('| all usages originate in unused code'); + } + + if (isset($debugMember['usages'])) { + $plural = count($debugMember['usages']) > 1 ? 's' : ''; + $output->writeLineFormatted(sprintf("|\n| Found %d usage%s:", count($debugMember['usages']), $plural)); + + foreach ($debugMember['usages'] as $collectedUsage) { + $origin = $collectedUsage->getUsage()->getOrigin(); + $output->writeFormatted(sprintf('| • %s', $this->getOriginReference($origin))); + + if ($collectedUsage->isExcluded()) { + $output->writeFormatted(sprintf(' - excluded by %s excluder', $collectedUsage->getExcludedBy())); + } + + $output->writeLineFormatted(''); + } + } elseif (isset($debugMember['neverReported'])) { + $output->writeLineFormatted(sprintf("|\n| Is never reported as dead: %s", $debugMember['neverReported'])); + } else { + $output->writeLineFormatted("|\n| No usages found"); + } + + $output->writeLineFormatted(''); + } + } + + private function prettyMemberKey(string $memberKey): string + { + $replaced = preg_replace('/^(m|c)\//', '', $memberKey); + + if ($replaced === null) { + throw new LogicException('Failed to pretty member key ' . $memberKey); + } + + return $replaced; + } + + private function getOriginReference(UsageOrigin $origin, bool $preferFileLine = true): string + { + $file = $origin->getFile(); + $line = $origin->getLine(); + + if ($file !== null && $line !== null) { + $relativeFile = $this->relativePathHelper->getRelativePath($file); + + $title = $origin->getClassName() !== null && $origin->getMethodName() !== null && !$preferFileLine + ? sprintf('%s::%s:%d', $origin->getClassName(), $origin->getMethodName(), $line) + : sprintf('%s:%s', $relativeFile, $line); + + if ($this->editorUrl === null) { + return $title; + } + + return sprintf( + '%s', + str_replace(['%file%', '%relFile%', '%line%'], [$file, $relativeFile, (string) $line], $this->editorUrl), + $title, + ); + } + + if ($origin->getProvider() !== null) { + $note = $origin->getNote() !== null ? " ({$origin->getNote()})" : ''; + return 'virtual usage from ' . $origin->getProvider() . $note; + } + + throw new LogicException('Unknown state of usage origin'); + } + + private function getOriginLink(UsageOrigin $origin, string $title): string + { + $file = $origin->getFile(); + $line = $origin->getLine(); + + if ($line !== null) { + $title = sprintf('%s:%s', $title, $line); + } + + if ($this->editorUrl !== null && $file !== null && $line !== null) { + $relativeFile = $this->relativePathHelper->getRelativePath($file); + + return sprintf( + '%s', + str_replace(['%file%', '%relFile%', '%line%'], [$file, $relativeFile, (string) $line], $this->editorUrl), + $title, + ); + } + + return $title; + } + + /** + * @param list $alternativeKeys + */ + public function recordUsage(CollectedUsage $collectedUsage, array $alternativeKeys = []): void + { + $memberKeys = array_unique([ + $collectedUsage->getUsage()->getMemberRef()->toKey(), + ...$alternativeKeys, + ]); + + foreach ($memberKeys as $memberKey) { + if (!isset($this->debugMembers[$memberKey])) { + continue; + } + + $this->debugMembers[$memberKey]['usages'][] = $collectedUsage; + } + } + + /** + * @param array> $eliminationPath + */ + public function markMemberAsWhite(BlackMember $blackMember, array $eliminationPath): void + { + $memberKey = $blackMember->getMember()->toKey(); + + if (!isset($this->debugMembers[$memberKey])) { + return; + } + + $this->debugMembers[$memberKey]['eliminationPath'] = $eliminationPath; + } + + public function markMemberAsNeverReported(BlackMember $blackMember, string $reason): void + { + $memberKey = $blackMember->getMember()->toKey(); + + if (!isset($this->debugMembers[$memberKey])) { + return; + } + + $this->debugMembers[$memberKey]['neverReported'] = $reason; + } + + /** + * @param list $debugMembers + * @return array, eliminationPath?: array>, neverReported?: string}> + */ + private function buildDebugMemberKeys(array $debugMembers): array + { + $result = []; + + foreach ($debugMembers as $debugMember) { + if (strpos($debugMember, '::') === false) { + throw new LogicException("Invalid debug member format: '$debugMember', expected 'ClassName::memberName'"); + } + + [$class, $memberName] = explode('::', $debugMember); // @phpstan-ignore offsetAccess.notFound + $normalizedClass = ltrim($class, '\\'); + + if (!$this->reflectionProvider->hasClass($normalizedClass)) { + throw new LogicException("Class '$normalizedClass' does not exist"); + } + + $classReflection = $this->reflectionProvider->getClass($normalizedClass); + + if ($this->hasOwnMethod($classReflection, $memberName)) { + $key = ClassMethodRef::buildKey($normalizedClass, $memberName); + + } elseif ($this->hasOwnConstant($classReflection, $memberName)) { + $key = ClassConstantRef::buildKey($normalizedClass, $memberName); + + } elseif ($this->hasOwnProperty($classReflection, $memberName)) { + throw new LogicException("Cannot debug '$debugMember', properties are not supported yet"); + + } else { + throw new LogicException("Member '$memberName' does not exist directly in '$normalizedClass'"); + } + + $result[$key] = [ + 'typename' => $normalizedClass, + ]; + } + + return $result; + } + + private function hasOwnMethod(ClassReflection $classReflection, string $methodName): bool + { + if (!$classReflection->hasMethod($methodName)) { + return false; + } + + try { + return $classReflection->getNativeReflection()->getMethod($methodName)->getBetterReflection()->getDeclaringClass()->getName() === $classReflection->getName(); + } catch (ReflectionException $e) { + return false; + } + } + + private function hasOwnConstant(ClassReflection $classReflection, string $constantName): bool + { + $constantReflection = $classReflection->getNativeReflection()->getReflectionConstant($constantName); + + if ($constantReflection === false) { + return false; + } + + return $constantReflection->getBetterReflection()->getDeclaringClass()->getName() === $classReflection->getName(); + } + + private function hasOwnProperty(ClassReflection $classReflection, string $propertyName): bool + { + if (!$classReflection->hasProperty($propertyName)) { + return false; + } + + try { + return $classReflection->getNativeReflection()->getProperty($propertyName)->getBetterReflection()->getDeclaringClass()->getName() === $classReflection->getName(); + } catch (ReflectionException $e) { + return false; + } + } + +} diff --git a/src/Enum/NeverReportedReason.php b/src/Enum/NeverReportedReason.php new file mode 100644 index 00000000..4cd3414f --- /dev/null +++ b/src/Enum/NeverReportedReason.php @@ -0,0 +1,12 @@ +isPossibleDescendant()) { - throw new LogicException('Origin should always be exact place in codebase.'); - } - - if ($origin !== null && $origin->getClassName() === null) { - throw new LogicException('Origin should always be exact place in codebase, thus className should be known.'); - } - $this->origin = $origin; } - public function getOrigin(): ?ClassMethodRef + public function getOrigin(): UsageOrigin { return $this->origin; } @@ -49,12 +38,4 @@ abstract public function getMemberRef(): ClassMemberRef; */ abstract public function concretizeMixedUsage(string $className): self; - public function toHumanString(): string - { - $origin = $this->origin !== null ? $this->origin->toHumanString() : 'unknown'; - $callee = $this->getMemberRef()->toHumanString(); - - return "$origin -> $callee"; - } - } diff --git a/src/Graph/ClassMethodUsage.php b/src/Graph/ClassMethodUsage.php index 6630bcb6..e900c019 100644 --- a/src/Graph/ClassMethodUsage.php +++ b/src/Graph/ClassMethodUsage.php @@ -14,11 +14,11 @@ final class ClassMethodUsage extends ClassMemberUsage private ClassMethodRef $callee; /** - * @param ClassMethodRef|null $origin The method where the call occurs + * @param UsageOrigin $origin The method where the call occurs * @param ClassMethodRef $callee The method being called */ public function __construct( - ?ClassMethodRef $origin, + UsageOrigin $origin, ClassMethodRef $callee ) { diff --git a/src/Graph/CollectedUsage.php b/src/Graph/CollectedUsage.php index 9a7cfa46..68e58b18 100644 --- a/src/Graph/CollectedUsage.php +++ b/src/Graph/CollectedUsage.php @@ -60,12 +60,13 @@ public function serialize(): string $data = [ 'e' => $this->excludedBy, 't' => $this->usage->getMemberType(), - 'o' => $origin === null - ? null - : [ + 'o' => [ 'c' => $origin->getClassName(), - 'm' => $origin->getMemberName(), - 'd' => $origin->isPossibleDescendant(), + 'm' => $origin->getMethodName(), + 'f' => $origin->getFile(), + 'l' => $origin->getLine(), + 'p' => $origin->getProvider(), + 'n' => $origin->getNote(), ], 'm' => [ 'c' => $memberRef->getClassName(), @@ -84,14 +85,14 @@ public function serialize(): string public static function deserialize(string $data): self { try { - /** @var array{e: string|null, t: MemberType::*, o: array{c: string|null, m: string, d: bool}|null, m: array{c: string|null, m: string, d: bool}} $result */ + /** @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}} $result */ $result = json_decode($data, true, 3, JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new LogicException('Deserialization failure: ' . $e->getMessage(), 0, $e); } $memberType = $result['t']; - $origin = $result['o'] === null ? null : new ClassMethodRef($result['o']['c'], $result['o']['m'], $result['o']['d']); + $origin = new UsageOrigin($result['o']['c'], $result['o']['m'], $result['o']['f'], $result['o']['l'], $result['o']['p'], $result['o']['n']); $exclusionReason = $result['e']; $usage = $memberType === MemberType::CONSTANT diff --git a/src/Graph/UsageOrigin.php b/src/Graph/UsageOrigin.php new file mode 100644 index 00000000..b06698d0 --- /dev/null +++ b/src/Graph/UsageOrigin.php @@ -0,0 +1,141 @@ +className = $className; + $this->methodName = $methodName; + $this->fileName = $fileName; + $this->line = $line; + $this->provider = $provider; + $this->note = $note; + } + + /** + * Creates virtual usage origin with no reference to any place in code + * + * @param ?string $note More detailed identification why provider emitted this virtual usage + */ + public static function createVirtual(MemberUsageProvider $provider, ?string $note = null): self + { + return new self( + null, + null, + null, + null, + get_class($provider), + $note, + ); + } + + /** + * Creates usage origin with reference to file:line + */ + public static function createRegular(Node $node, Scope $scope): self + { + if (!$scope->isInClass() || !$scope->getFunction() instanceof MethodReflection) { + return new self( + null, + null, + $scope->getFile(), + $node->getStartLine(), + null, + null, + ); + } + + return new self( + $scope->getClassReflection()->getName(), + $scope->getFunction()->getName(), + $scope->getFile(), + $node->getStartLine(), + null, + null, + ); + } + + public function getClassName(): ?string + { + return $this->className; + } + + public function getMethodName(): ?string + { + return $this->methodName; + } + + public function getFile(): ?string + { + return $this->fileName; + } + + public function getLine(): ?int + { + return $this->line; + } + + public function getProvider(): ?string + { + return $this->provider; + } + + public function getNote(): ?string + { + return $this->note; + } + + public function hasClassMethodRef(): bool + { + return $this->className !== null && $this->methodName !== null; + } + + public function toClassMethodRef(): ClassMethodRef + { + if ($this->className === null || $this->methodName === null) { + throw new LogicException('Usage origin does not have class method ref'); + } + + return new ClassMethodRef( + $this->className, + $this->methodName, + false, + ); + } + +} diff --git a/src/Graph/UsageOriginDetector.php b/src/Graph/UsageOriginDetector.php deleted file mode 100644 index 03911259..00000000 --- a/src/Graph/UsageOriginDetector.php +++ /dev/null @@ -1,31 +0,0 @@ -isInClass()) { - return null; - } - - if (!$scope->getFunction() instanceof MethodReflection) { - return null; - } - - return new ClassMethodRef( - $scope->getClassReflection()->getName(), - $scope->getFunction()->getName(), - false, - ); - } - -} diff --git a/src/Provider/DoctrineUsageProvider.php b/src/Provider/DoctrineUsageProvider.php index e50c9cf7..04f874f1 100644 --- a/src/Provider/DoctrineUsageProvider.php +++ b/src/Provider/DoctrineUsageProvider.php @@ -13,6 +13,7 @@ use PHPStan\Reflection\MethodReflection; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; +use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; class DoctrineUsageProvider implements MemberUsageProvider { @@ -100,12 +101,13 @@ private function getUsagesOfEventSubscriber(Return_ $node, Scope $scope): array $className = $scope->getClassReflection()->getName(); $usages = []; + $usageOrigin = UsageOrigin::createRegular($node, $scope); foreach ($scope->getType($node->expr)->getConstantArrays() as $rootArray) { foreach ($rootArray->getValuesArray()->getValueTypes() as $eventConfig) { foreach ($eventConfig->getConstantStrings() as $subscriberMethodString) { $usages[] = new ClassMethodUsage( - null, + $usageOrigin, new ClassMethodRef( $className, $subscriberMethodString->getValue(), @@ -199,7 +201,7 @@ private function isDoctrineInstalled(): bool private function createMethodUsage(ExtendedMethodReflection $methodReflection): ClassMethodUsage { return new ClassMethodUsage( - null, + UsageOrigin::createVirtual($this), new ClassMethodRef( $methodReflection->getDeclaringClass()->getName(), $methodReflection->getName(), diff --git a/src/Provider/PhpUnitUsageProvider.php b/src/Provider/PhpUnitUsageProvider.php index 2911bee6..eb02b8ac 100644 --- a/src/Provider/PhpUnitUsageProvider.php +++ b/src/Provider/PhpUnitUsageProvider.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; +use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; use function array_merge; use function is_string; use function strpos; @@ -56,12 +57,12 @@ public function getUsages(Node $node, Scope $scope): array foreach ($dataProviders as $dataProvider) { if ($classReflection->hasNativeMethod($dataProvider)) { - $usages[] = $this->createUsage($classReflection->getNativeMethod($dataProvider)); + $usages[] = $this->createUsage($classReflection->getNativeMethod($dataProvider), 'data provider method'); } } if ($this->isTestCaseMethod($method)) { - $usages[] = $this->createUsage($classReflection->getNativeMethod($method->getName())); + $usages[] = $this->createUsage($classReflection->getNativeMethod($method->getName()), 'test method'); } } @@ -141,10 +142,10 @@ private function hasAnnotation(ReflectionMethod $method, string $string): bool return strpos($method->getDocComment(), $string) !== false; } - private function createUsage(ExtendedMethodReflection $getNativeMethod): ClassMethodUsage + private function createUsage(ExtendedMethodReflection $getNativeMethod, string $reason): ClassMethodUsage { return new ClassMethodUsage( - null, + UsageOrigin::createVirtual($this, $reason), new ClassMethodRef( $getNativeMethod->getDeclaringClass()->getName(), $getNativeMethod->getName(), diff --git a/src/Provider/ReflectionBasedMemberUsageProvider.php b/src/Provider/ReflectionBasedMemberUsageProvider.php index c7626c21..54c13a1f 100644 --- a/src/Provider/ReflectionBasedMemberUsageProvider.php +++ b/src/Provider/ReflectionBasedMemberUsageProvider.php @@ -13,6 +13,7 @@ use ShipMonk\PHPStan\DeadCode\Graph\ClassMemberUsage; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; +use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; use function array_merge; abstract class ReflectionBasedMemberUsageProvider implements MemberUsageProvider @@ -92,7 +93,7 @@ private function getConstantUsages(ClassReflection $classReflection): array private function createConstantUsage(ReflectionClassConstant $constantReflection): ClassConstantUsage { return new ClassConstantUsage( - null, + UsageOrigin::createVirtual($this), new ClassConstantRef( $constantReflection->getDeclaringClass()->getName(), $constantReflection->getName(), @@ -104,7 +105,7 @@ private function createConstantUsage(ReflectionClassConstant $constantReflection private function createMethodUsage(ReflectionMethod $methodReflection): ClassMethodUsage { return new ClassMethodUsage( - null, + UsageOrigin::createVirtual($this), new ClassMethodRef( $methodReflection->getDeclaringClass()->getName(), $methodReflection->getName(), diff --git a/src/Provider/ReflectionUsageProvider.php b/src/Provider/ReflectionUsageProvider.php index 3f279aa7..a5f9a211 100644 --- a/src/Provider/ReflectionUsageProvider.php +++ b/src/Provider/ReflectionUsageProvider.php @@ -18,7 +18,7 @@ use ShipMonk\PHPStan\DeadCode\Graph\ClassMemberUsage; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; -use ShipMonk\PHPStan\DeadCode\Graph\UsageOriginDetector; +use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; use function array_key_first; use function count; use function in_array; @@ -26,16 +26,12 @@ class ReflectionUsageProvider implements MemberUsageProvider { - private UsageOriginDetector $usageOriginDetector; - private bool $enabled; public function __construct( - UsageOriginDetector $usageOriginDetector, bool $enabled ) { - $this->usageOriginDetector = $usageOriginDetector; $this->enabled = $enabled; } @@ -73,11 +69,11 @@ private function processMethodCall(MethodCall $node, Scope $scope): array foreach ($genericType->getObjectClassReflections() as $genericReflection) { $usedConstants = [ ...$usedConstants, - ...$this->extractConstantsUsedByReflection($methodName, $genericReflection, $node->getArgs(), $scope), + ...$this->extractConstantsUsedByReflection($methodName, $genericReflection, $node->getArgs(), $node, $scope), ]; $usedMethods = [ ...$usedMethods, - ...$this->extractMethodsUsedByReflection($methodName, $genericReflection, $node->getArgs(), $scope), + ...$this->extractMethodsUsedByReflection($methodName, $genericReflection, $node->getArgs(), $node, $scope), ]; } } @@ -98,6 +94,7 @@ private function extractConstantsUsedByReflection( string $methodName, ClassReflection $genericReflection, array $args, + Node $node, Scope $scope ): array { @@ -105,7 +102,7 @@ private function extractConstantsUsedByReflection( if ($methodName === 'getConstants' || $methodName === 'getReflectionConstants') { foreach ($genericReflection->getNativeReflection()->getReflectionConstants() as $reflectionConstant) { - $usedConstants[] = $this->createConstantUsage($scope, $reflectionConstant->getDeclaringClass()->getName(), $reflectionConstant->getName()); + $usedConstants[] = $this->createConstantUsage($node, $scope, $reflectionConstant->getDeclaringClass()->getName(), $reflectionConstant->getName()); } } @@ -113,7 +110,7 @@ private function extractConstantsUsedByReflection( $firstArg = $args[array_key_first($args)]; // @phpstan-ignore offsetAccess.notFound foreach ($scope->getType($firstArg->value)->getConstantStrings() as $constantString) { - $usedConstants[] = $this->createConstantUsage($scope, $genericReflection->getName(), $constantString->getValue()); + $usedConstants[] = $this->createConstantUsage($node, $scope, $genericReflection->getName(), $constantString->getValue()); } } @@ -128,6 +125,7 @@ private function extractMethodsUsedByReflection( string $methodName, ClassReflection $genericReflection, array $args, + Node $node, Scope $scope ): array { @@ -135,7 +133,7 @@ private function extractMethodsUsedByReflection( if ($methodName === 'getMethods') { foreach ($genericReflection->getNativeReflection()->getMethods() as $reflectionMethod) { - $usedMethods[] = $this->createMethodUsage($scope, $reflectionMethod->getDeclaringClass()->getName(), $reflectionMethod->getName()); + $usedMethods[] = $this->createMethodUsage($node, $scope, $reflectionMethod->getDeclaringClass()->getName(), $reflectionMethod->getName()); } } @@ -143,7 +141,7 @@ private function extractMethodsUsedByReflection( $firstArg = $args[array_key_first($args)]; // @phpstan-ignore offsetAccess.notFound foreach ($scope->getType($firstArg->value)->getConstantStrings() as $constantString) { - $usedMethods[] = $this->createMethodUsage($scope, $genericReflection->getName(), $constantString->getValue()); + $usedMethods[] = $this->createMethodUsage($node, $scope, $genericReflection->getName(), $constantString->getValue()); } } @@ -151,7 +149,7 @@ private function extractMethodsUsedByReflection( $constructor = $genericReflection->getNativeReflection()->getConstructor(); if ($constructor !== null) { - $usedMethods[] = $this->createMethodUsage($scope, $constructor->getDeclaringClass()->getName(), '__construct'); + $usedMethods[] = $this->createMethodUsage($node, $scope, $constructor->getDeclaringClass()->getName(), '__construct'); } } @@ -181,10 +179,15 @@ private function getMethodNames(CallLike $call, Scope $scope): array return [$call->name->toString()]; } - private function createConstantUsage(Scope $scope, string $className, string $constantName): ClassConstantUsage + private function createConstantUsage( + Node $node, + Scope $scope, + string $className, + string $constantName + ): ClassConstantUsage { return new ClassConstantUsage( - $this->usageOriginDetector->detectOrigin($scope), + UsageOrigin::createRegular($node, $scope), new ClassConstantRef( $className, $constantName, @@ -193,10 +196,15 @@ private function createConstantUsage(Scope $scope, string $className, string $co ); } - private function createMethodUsage(Scope $scope, string $className, string $methodName): ClassMethodUsage + private function createMethodUsage( + Node $node, + Scope $scope, + string $className, + string $methodName + ): ClassMethodUsage { return new ClassMethodUsage( - $this->usageOriginDetector->detectOrigin($scope), + UsageOrigin::createRegular($node, $scope), new ClassMethodRef( $className, $methodName, diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index 1b6902e6..4297dd03 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -27,7 +27,7 @@ use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantUsage; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; -use ShipMonk\PHPStan\DeadCode\Graph\UsageOriginDetector; +use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; use SimpleXMLElement; use SplFileInfo; use UnexpectedValueException; @@ -64,16 +64,12 @@ class SymfonyUsageProvider implements MemberUsageProvider */ private array $dicConstants = []; - private UsageOriginDetector $usageOriginDetector; - public function __construct( Container $container, - UsageOriginDetector $usageOriginDetector, ?bool $enabled, ?string $configDir ) { - $this->usageOriginDetector = $usageOriginDetector; $this->enabled = $enabled ?? $this->isSymfonyInstalled(); $resolvedConfigDir = $configDir ?? $this->autodetectConfigDir(); $containerXmlPath = $this->getContainerXmlPath($container); @@ -148,6 +144,7 @@ private function getUsagesOfEventSubscriber(Return_ $node, Scope $scope): array $className = $scope->getClassReflection()->getName(); $usages = []; + $usageOrigin = UsageOrigin::createRegular($node, $scope); // phpcs:disable Squiz.PHP.CommentedOutCode.Found foreach ($scope->getType($node->expr)->getConstantArrays() as $rootArray) { @@ -155,7 +152,7 @@ private function getUsagesOfEventSubscriber(Return_ $node, Scope $scope): array // ['eventName' => 'methodName'] foreach ($eventConfig->getConstantStrings() as $subscriberMethodString) { $usages[] = new ClassMethodUsage( - null, + $usageOrigin, new ClassMethodRef( $className, $subscriberMethodString->getValue(), @@ -168,7 +165,7 @@ private function getUsagesOfEventSubscriber(Return_ $node, Scope $scope): array foreach ($eventConfig->getConstantArrays() as $subscriberMethodArray) { foreach ($subscriberMethodArray->getFirstIterableValueType()->getConstantStrings() as $subscriberMethodString) { $usages[] = new ClassMethodUsage( - null, + $usageOrigin, new ClassMethodRef( $className, $subscriberMethodString->getValue(), @@ -183,7 +180,7 @@ private function getUsagesOfEventSubscriber(Return_ $node, Scope $scope): array foreach ($subscriberMethodArray->getIterableValueType()->getConstantArrays() as $innerArray) { foreach ($innerArray->getFirstIterableValueType()->getConstantStrings() as $subscriberMethodString) { $usages[] = new ClassMethodUsage( - null, + $usageOrigin, new ClassMethodRef( $className, $subscriberMethodString->getValue(), @@ -214,7 +211,7 @@ private function getMethodUsagesFromReflection(InClassNode $node): array foreach ($nativeReflection->getMethods() as $method) { if (isset($this->dicCalls[$className][$method->getName()])) { - $usages[] = $this->createUsage($classReflection->getNativeMethod($method->getName())); + $usages[] = $this->createUsage($classReflection->getNativeMethod($method->getName()), 'called via DIC'); } if ($method->getDeclaringClass()->getName() !== $nativeReflection->getName()) { @@ -222,7 +219,7 @@ private function getMethodUsagesFromReflection(InClassNode $node): array } if ($this->shouldMarkAsUsed($method)) { - $usages[] = $this->createUsage($classReflection->getNativeMethod($method->getName())); + $usages[] = $this->createUsage($classReflection->getNativeMethod($method->getName()), null); } } @@ -235,6 +232,7 @@ private function getMethodUsagesFromReflection(InClassNode $node): array private function getMethodUsagesFromAttributeReflection(InClassMethodNode $node, Scope $scope): array { $usages = []; + $usageOrigin = UsageOrigin::createRegular($node, $scope); foreach ($node->getMethodReflection()->getParameters() as $parameter) { foreach ($parameter->getAttributes() as $attributeReflection) { @@ -259,7 +257,7 @@ private function getMethodUsagesFromAttributeReflection(InClassMethodNode $node, foreach ($classNames as $className) { $usages[] = new ClassMethodUsage( - $this->usageOriginDetector->detectOrigin($scope), + $usageOrigin, new ClassMethodRef( $className->getValue(), $defaultIndexMethod[0]->getValue(), @@ -283,7 +281,7 @@ private function getMethodUsagesFromAttributeReflection(InClassMethodNode $node, foreach ($classNames as $className) { $usages[] = new ClassMethodUsage( - $this->usageOriginDetector->detectOrigin($scope), + UsageOrigin::createRegular($node, $scope), new ClassMethodRef( $className->getValue(), $defaultIndexMethod[0]->getValue(), @@ -481,10 +479,10 @@ private function isSymfonyInstalled(): bool || InstalledVersions::isInstalled('symfony/dependency-injection'); } - private function createUsage(ExtendedMethodReflection $methodReflection): ClassMethodUsage + private function createUsage(ExtendedMethodReflection $methodReflection, ?string $reason): ClassMethodUsage { return new ClassMethodUsage( - null, + UsageOrigin::createVirtual($this, $reason), new ClassMethodRef( $methodReflection->getDeclaringClass()->getName(), $methodReflection->getName(), @@ -570,7 +568,7 @@ private function getConstantUsages(ClassReflection $classReflection): array } $usages[] = new ClassConstantUsage( - null, + UsageOrigin::createVirtual($this, 'used in DIC'), new ClassConstantRef( $classReflection->getName(), $constantName, diff --git a/src/Rule/DeadCodeRule.php b/src/Rule/DeadCodeRule.php index 31648930..7b21597b 100644 --- a/src/Rule/DeadCodeRule.php +++ b/src/Rule/DeadCodeRule.php @@ -16,8 +16,10 @@ use ShipMonk\PHPStan\DeadCode\Collector\MethodCallCollector; use ShipMonk\PHPStan\DeadCode\Collector\ProvidedUsagesCollector; use ShipMonk\PHPStan\DeadCode\Compatibility\BackwardCompatibilityChecker; +use ShipMonk\PHPStan\DeadCode\Debug\DebugUsagePrinter; use ShipMonk\PHPStan\DeadCode\Enum\ClassLikeKind; use ShipMonk\PHPStan\DeadCode\Enum\MemberType; +use ShipMonk\PHPStan\DeadCode\Enum\NeverReportedReason; use ShipMonk\PHPStan\DeadCode\Enum\Visibility; use ShipMonk\PHPStan\DeadCode\Error\BlackMember; use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantRef; @@ -27,16 +29,16 @@ use ShipMonk\PHPStan\DeadCode\Graph\CollectedUsage; use ShipMonk\PHPStan\DeadCode\Hierarchy\ClassHierarchy; use function array_key_exists; +use function array_key_last; use function array_keys; use function array_map; use function array_merge; use function array_merge_recursive; use function array_slice; -use function array_sum; +use function array_unique; use function array_values; use function in_array; use function ksort; -use function sprintf; use function strpos; /** @@ -66,6 +68,8 @@ class DeadCodeRule implements Rule, DiagnoseExtension '__debugInfo' => null, ]; + private DebugUsagePrinter $debugUsagePrinter; + private ClassHierarchy $classHierarchy; /** @@ -91,8 +95,6 @@ class DeadCodeRule implements Rule, DiagnoseExtension private bool $reportTransitivelyDeadAsSeparateError; - private bool $trackMixedAccess; - /** * memberKey => DeadMember * @@ -108,20 +110,20 @@ class DeadCodeRule implements Rule, DiagnoseExtension private array $mixedMemberUsages = []; /** - * @var array> callerKey => memberUseKey[] + * @var array>> callerKey => array */ private array $usageGraph = []; public function __construct( + DebugUsagePrinter $debugUsagePrinter, ClassHierarchy $classHierarchy, bool $reportTransitivelyDeadMethodAsSeparateError, - bool $trackMixedAccess, BackwardCompatibilityChecker $checker ) { + $this->debugUsagePrinter = $debugUsagePrinter; $this->classHierarchy = $classHierarchy; $this->reportTransitivelyDeadAsSeparateError = $reportTransitivelyDeadMethodAsSeparateError; - $this->trackMixedAccess = $trackMixedAccess; $checker->check(); } @@ -224,8 +226,8 @@ public function processNode( } } - /** @var list $whiteMemberKeys */ - $whiteMemberKeys = []; + /** @var array> $whiteMembers */ + $whiteMembers = []; /** @var list $excludedMemberUsages */ $excludedMemberUsages = []; @@ -239,30 +241,40 @@ public function processNode( $isWhite = $this->isConsideredWhite($memberUsage); $alternativeMemberKeys = $this->getAlternativeMemberKeys($memberUsage->getMemberRef()); - $alternativeOriginKeys = $memberUsage->getOrigin() !== null ? $this->getAlternativeMemberKeys($memberUsage->getOrigin()) : []; + $alternativeOriginKeys = $memberUsage->getOrigin()->hasClassMethodRef() + ? $this->getAlternativeMemberKeys($memberUsage->getOrigin()->toClassMethodRef()) + : []; foreach ($alternativeMemberKeys as $alternativeMemberKey) { foreach ($alternativeOriginKeys as $alternativeOriginKey) { - $this->usageGraph[$alternativeOriginKey][] = $alternativeMemberKey; + $this->usageGraph[$alternativeOriginKey][$alternativeMemberKey][] = $memberUsage; } if ($isWhite) { - $whiteMemberKeys[] = $alternativeMemberKey; + $whiteMembers[$alternativeMemberKey][] = $collectedUsage->getUsage(); } } + + $this->debugUsagePrinter->recordUsage($collectedUsage, $alternativeMemberKeys); } - foreach ($whiteMemberKeys as $whiteCalleeKey) { - $this->markTransitivesWhite($whiteCalleeKey); + foreach ($whiteMembers as $whiteCalleeKey => $usages) { + $this->markTransitivesWhite([$whiteCalleeKey => $usages]); } foreach ($this->blackMembers as $blackMemberKey => $blackMember) { - if ($this->isNeverReportedAsDead($blackMember)) { + $neverReportedReason = $this->isNeverReportedAsDead($blackMember); + + if ($neverReportedReason !== null) { + $this->debugUsagePrinter->markMemberAsNeverReported($blackMember, $neverReportedReason); + unset($this->blackMembers[$blackMemberKey]); } } foreach ($excludedMemberUsages as $excludedMemberUsage) { + $this->debugUsagePrinter->recordUsage($excludedMemberUsage); + $excludedBy = $excludedMemberUsage->getExcludedBy(); $excludedMemberRef = $excludedMemberUsage->getUsage()->getMemberRef(); @@ -407,6 +419,8 @@ private function getAlternativeMemberKeys(ClassMemberRef $member): array } } + $result = array_values(array_unique($result)); + $this->memberAlternativesCache[$cacheKey] = $result; return $result; @@ -447,17 +461,21 @@ private function findDefinerMemberKey( } /** - * @param array $visitedKeys + * @param non-empty-array> $stack callerKey => usages[] */ - private function markTransitivesWhite(string $callerKey, array $visitedKeys = []): void + private function markTransitivesWhite(array $stack): void { - $visitedKeys = $visitedKeys === [] ? [$callerKey => null] : $visitedKeys; - $calleeKeys = $this->usageGraph[$callerKey] ?? []; + $callerKey = array_key_last($stack); + $callees = $this->usageGraph[$callerKey] ?? []; - unset($this->blackMembers[$callerKey]); + if (isset($this->blackMembers[$callerKey])) { + $this->debugUsagePrinter->markMemberAsWhite($this->blackMembers[$callerKey], $stack); - foreach ($calleeKeys as $calleeKey) { - if (array_key_exists($calleeKey, $visitedKeys)) { + unset($this->blackMembers[$callerKey]); + } + + foreach ($callees as $calleeKey => $usages) { + if (array_key_exists($calleeKey, $stack)) { continue; } @@ -465,7 +483,7 @@ private function markTransitivesWhite(string $callerKey, array $visitedKeys = [] continue; } - $this->markTransitivesWhite($calleeKey, array_merge($visitedKeys, [$calleeKey => null])); + $this->markTransitivesWhite(array_merge($stack, [$calleeKey => $usages])); } } @@ -476,11 +494,11 @@ private function markTransitivesWhite(string $callerKey, array $visitedKeys = [] private function getTransitiveDeadCalls(string $callerKey, array $visitedKeys = []): array { $visitedKeys = $visitedKeys === [] ? [$callerKey => null] : $visitedKeys; - $calleeKeys = $this->usageGraph[$callerKey] ?? []; + $callees = $this->usageGraph[$callerKey] ?? []; $result = []; - foreach ($calleeKeys as $calleeKey) { + foreach ($callees as $calleeKey => $_) { if (array_key_exists($calleeKey, $visitedKeys)) { continue; } @@ -516,7 +534,7 @@ private function groupDeadMembers(): array continue; } - foreach ($callees as $callee) { + foreach ($callees as $callee => $_) { if (array_key_exists($callee, $this->blackMembers)) { $deadMethodsWithCaller[$callee] = true; } @@ -673,15 +691,18 @@ private function getTraitUsages(string $typeName): array private function isConsideredWhite(ClassMemberUsage $memberUsage): bool { - return $memberUsage->getOrigin() === null + return $memberUsage->getOrigin()->getClassName() === null // out-of-class scope || $this->isAnonymousClass($memberUsage->getOrigin()->getClassName()) - || (array_key_exists($memberUsage->getOrigin()->getMemberName(), self::UNSUPPORTED_MAGIC_METHODS)); + || (array_key_exists((string) $memberUsage->getOrigin()->getMethodName(), self::UNSUPPORTED_MAGIC_METHODS)); } - private function isNeverReportedAsDead(BlackMember $blackMember): bool + /** + * @return NeverReportedReason::*|null + */ + private function isNeverReportedAsDead(BlackMember $blackMember): ?string { if (!$blackMember->getMember() instanceof ClassMethodRef) { - return false; + return null; } $typeName = $blackMember->getMember()->getClassName(); @@ -699,73 +720,25 @@ private function isNeverReportedAsDead(BlackMember $blackMember): bool if ($kind === ClassLikeKind::TRAIT && $abstract) { // abstract methods in traits make sense (not dead) only when called within the trait itself, but that is hard to detect for now, so lets ignore them completely // the difference from interface methods (or abstract methods) is that those methods can be called over the interface, but you cannot call method over trait - return true; + return NeverReportedReason::ABSTRACT_TRAIT_METHOD; } if ($memberName === '__construct' && ($visibility & Visibility::PRIVATE) !== 0 && $params === 0) { // private constructors with zero parameters are often used to deny instantiation - return true; + return NeverReportedReason::PRIVATE_CONSTRUCTOR_NO_PARAMS; } if (array_key_exists($memberName, self::UNSUPPORTED_MAGIC_METHODS)) { - return true; + return NeverReportedReason::UNSUPPORTED_MAGIC_METHOD; } - return false; + return null; } public function print(Output $output): void { - if ($this->mixedMemberUsages === [] || !$output->isDebug() || !$this->trackMixedAccess) { - return; - } - - $totalCount = array_sum(array_map('count', $this->mixedMemberUsages)); - $maxExamplesToShow = 20; - $examplesShown = 0; - $output->writeLineFormatted(sprintf('Found %d usages over unknown type:', $totalCount)); - - foreach ($this->mixedMemberUsages as $memberType => $collectedUsages) { - foreach ($collectedUsages as $memberName => $usages) { - $examplesShown++; - $memberTypeString = $memberType === MemberType::METHOD ? 'method' : 'constant'; - $output->writeFormatted(sprintf(' • %s %s', $memberName, $memberTypeString)); - - $exampleCaller = $this->getExampleCaller($usages); - - if ($exampleCaller !== null) { - $output->writeFormatted(sprintf(', for example in %s', $exampleCaller)); - } - - $output->writeLineFormatted(''); - - if ($examplesShown >= $maxExamplesToShow) { - break 2; - } - } - } - - if ($totalCount > $maxExamplesToShow) { - $output->writeLineFormatted(sprintf('... and %d more', $totalCount - $maxExamplesToShow)); - } - - $output->writeLineFormatted(''); - $output->writeLineFormatted('Thus, any member named the same is considered used, no matter its declaring class!'); - $output->writeLineFormatted(''); - } - - /** - * @param list $usages - */ - private function getExampleCaller(array $usages): ?string - { - foreach ($usages as $usage) { - if ($usage->getUsage()->getOrigin() !== null) { - return $usage->getUsage()->getOrigin()->toHumanString(); - } - } - - return null; + $this->debugUsagePrinter->printMixedMemberUsages($output, $this->mixedMemberUsages); + $this->debugUsagePrinter->printDebugMemberUsages($output, $this->typeDefinitions); } } diff --git a/tests/AllServicesInConfigTest.php b/tests/AllServicesInConfigTest.php index 8d1b4a33..f9c9bb95 100644 --- a/tests/AllServicesInConfigTest.php +++ b/tests/AllServicesInConfigTest.php @@ -10,6 +10,7 @@ use ShipMonk\PHPStan\DeadCode\Collector\BufferedUsageCollector; use ShipMonk\PHPStan\DeadCode\Enum\ClassLikeKind; use ShipMonk\PHPStan\DeadCode\Enum\MemberType; +use ShipMonk\PHPStan\DeadCode\Enum\NeverReportedReason; use ShipMonk\PHPStan\DeadCode\Enum\Visibility; use ShipMonk\PHPStan\DeadCode\Error\BlackMember; use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantRef; @@ -19,6 +20,7 @@ use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; use ShipMonk\PHPStan\DeadCode\Graph\CollectedUsage; +use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; use ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider; use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider; use ShipMonk\PHPStan\DeadCode\Transformer\RemoveClassMemberVisitor; @@ -54,6 +56,7 @@ public function test(): void $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)); $missingClassNames = []; $excluded = [ + UsageOrigin::class, ClassMethodUsage::class, ClassMethodRef::class, ClassConstantRef::class, @@ -70,6 +73,7 @@ public function test(): void RemoveDeadCodeTransformer::class, RemoveClassMemberVisitor::class, MemberType::class, + NeverReportedReason::class, ]; /** @var DirectoryIterator $file */ diff --git a/tests/Graph/SerializationTest.php b/tests/Graph/SerializationTest.php index 63bdbca3..b8fe9a1b 100644 --- a/tests/Graph/SerializationTest.php +++ b/tests/Graph/SerializationTest.php @@ -12,10 +12,8 @@ class SerializationTest extends TestCase */ public function testSerialization(CollectedUsage $expected, string $serialized): void { - $unserialized = CollectedUsage::deserialize($serialized); - self::assertSame($serialized, $expected->serialize()); - self::assertEquals($expected, $unserialized); + self::assertEquals($expected, CollectedUsage::deserialize($serialized)); } /** @@ -23,25 +21,15 @@ public function testSerialization(CollectedUsage $expected, string $serialized): */ public static function provideData(): iterable { - yield [ - new CollectedUsage( - new ClassMethodUsage( - null, - new ClassMethodRef('Some', 'method', false), - ), - null, - ), - '{"e":null,"t":1,"o":null,"m":{"c":"Some","m":"method","d":false}}', - ]; yield [ new CollectedUsage( new ClassConstantUsage( - new ClassMethodRef('Clazz', 'method', false), + new UsageOrigin('Clazz', 'method', '/app/index.php', 7, null, null), new ClassConstantRef(null, 'CONSTANT', true), ), 'excluder', ), - '{"e":"excluder","t":2,"o":{"c":"Clazz","m":"method","d":false},"m":{"c":null,"m":"CONSTANT","d":true}}', + '{"e":"excluder","t":2,"o":{"c":"Clazz","m":"method","f":"\/app\/index.php","l":7,"p":null,"n":null},"m":{"c":null,"m":"CONSTANT","d":true}}', ]; } diff --git a/tests/Rule/DeadCodeRuleTest.php b/tests/Rule/DeadCodeRuleTest.php index db7ecca7..62dccc09 100644 --- a/tests/Rule/DeadCodeRuleTest.php +++ b/tests/Rule/DeadCodeRuleTest.php @@ -4,6 +4,7 @@ use Composer\InstalledVersions; use Composer\Semver\VersionParser; +use LogicException; use PhpParser\Node; use PHPStan\Analyser\Error; use PHPStan\Analyser\Scope; @@ -11,6 +12,7 @@ use PHPStan\Command\AnalysisResult; use PHPStan\Command\Output; use PHPStan\DependencyInjection\Container; +use PHPStan\File\SimpleRelativePathHelper; use PHPStan\PhpDocParser\Lexer\Lexer; use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\Reflection\ReflectionProvider; @@ -20,11 +22,11 @@ use ShipMonk\PHPStan\DeadCode\Collector\MethodCallCollector; use ShipMonk\PHPStan\DeadCode\Collector\ProvidedUsagesCollector; use ShipMonk\PHPStan\DeadCode\Compatibility\BackwardCompatibilityChecker; +use ShipMonk\PHPStan\DeadCode\Debug\DebugUsagePrinter; use ShipMonk\PHPStan\DeadCode\Excluder\MemberUsageExcluder; use ShipMonk\PHPStan\DeadCode\Excluder\TestsUsageExcluder; use ShipMonk\PHPStan\DeadCode\Formatter\RemoveDeadCodeFormatter; use ShipMonk\PHPStan\DeadCode\Graph\ClassMemberUsage; -use ShipMonk\PHPStan\DeadCode\Graph\UsageOriginDetector; use ShipMonk\PHPStan\DeadCode\Hierarchy\ClassHierarchy; use ShipMonk\PHPStan\DeadCode\Provider\DoctrineUsageProvider; use ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider; @@ -38,6 +40,7 @@ use ShipMonk\PHPStan\DeadCode\Transformer\FileSystem; use function file_get_contents; use function is_array; +use function preg_replace; use function str_replace; use function strpos; use const PHP_VERSION_ID; @@ -48,6 +51,11 @@ class DeadCodeRuleTest extends RuleTestCase { + /** + * @var list + */ + private array $debugMembers = []; + private bool $trackMixedAccess = true; private bool $emitErrorsInGroups = true; @@ -58,11 +66,22 @@ class DeadCodeRuleTest extends RuleTestCase protected function getRule(): DeadCodeRule { + $container = $this->createMock(Container::class); + $container->expects(self::any()) + ->method('getParameter') + ->willReturn(['debug' => ['usagesOf' => $this->debugMembers]]); + if ($this->rule === null) { $this->rule = new DeadCodeRule( + new DebugUsagePrinter( + $container, + new SimpleRelativePathHelper(__DIR__), // @phpstan-ignore phpstanApi.constructor + self::createReflectionProvider(), + null, + true, + ), new ClassHierarchy(), !$this->emitErrorsInGroups, - true, new BackwardCompatibilityChecker([]), ); } @@ -79,9 +98,9 @@ protected function getCollectors(): array return [ new ProvidedUsagesCollector($reflectionProvider, $this->getMemberUsageProviders(), $this->getMemberUsageExcluders()), - new ClassDefinitionCollector(self::createReflectionProvider()), - new MethodCallCollector($this->createUsageOriginDetector(), $this->trackMixedAccess, $this->getMemberUsageExcluders()), - new ConstantFetchCollector($this->createUsageOriginDetector(), $reflectionProvider, $this->trackMixedAccess, $this->getMemberUsageExcluders()), + new ClassDefinitionCollector($reflectionProvider), + new MethodCallCollector($this->trackMixedAccess, $this->getMemberUsageExcluders()), + new ConstantFetchCollector($reflectionProvider, $this->trackMixedAccess, $this->getMemberUsageExcluders()), ]; } @@ -159,10 +178,10 @@ static function (string $message) use (&$actualOutput): void { $ec = ''; // hack editorconfig checker to ignore wrong indentation $expectedOutput = <<<"OUTPUT" Found 4 usages over unknown type: - $ec • getter1 method, for example in DeadMixed1\Tester::__construct - $ec • getter2 method, for example in DeadMixed1\Tester::__construct - $ec • getter3 method, for example in DeadMixed1\Tester::__construct - $ec • staticMethod method, for example in DeadMixed1\Tester::__construct + $ec • getter1 method, for example in data/methods/mixed/tracked.php:46 + $ec • getter2 method, for example in data/methods/mixed/tracked.php:49 + $ec • getter3 method, for example in data/methods/mixed/tracked.php:52 + $ec • staticMethod method, for example in data/methods/mixed/tracked.php:57 Thus, any member named the same is considered used, no matter its declaring class! @@ -172,6 +191,92 @@ static function (string $message) use (&$actualOutput): void { self::assertSame($expectedOutput, $actualOutput); } + public function testDebugUsage(): void + { + $this->debugMembers = [ + 'DateTime::format', + 'DebugAlternative\Foo::foo', + 'DebugCtor\Foo::__construct', + 'DebugExclude\Foo::mixedExcluder', + 'DebugNever\Foo::__get', + 'DebugVirtual\FooTest::testFoo', + 'DebugGlobal\Foo::chain2', + 'DebugMixed\Foo::any', + 'DebugCycle\Foo::__construct', + 'DebugRegular\Another::call', + 'DebugUnsupported\Foo::notDead', + 'DebugZero\Foo::__construct', + ]; + $this->analyseFiles([ + __DIR__ . '/data/debug/alternative.php', + __DIR__ . '/data/debug/ctor.php', + __DIR__ . '/data/debug/exclude.php', + __DIR__ . '/data/debug/cycle.php', + __DIR__ . '/data/debug/foreign.php', + __DIR__ . '/data/debug/global.php', + __DIR__ . '/data/debug/mixed.php', + __DIR__ . '/data/debug/never.php', + __DIR__ . '/data/debug/regular.php', + __DIR__ . '/data/debug/unsupported.php', + __DIR__ . '/data/debug/virtual.php', + __DIR__ . '/data/debug/zero.php', + ]); + $rule = $this->getRule(); + + $actualOutput = ''; + $output = $this->createMock(Output::class); + $output->expects(self::atLeastOnce()) + ->method('isDebug') + ->willReturn(true); + $output->expects(self::atLeastOnce()) + ->method('writeFormatted') + ->willReturnCallback( + static function (string $message) use (&$actualOutput): void { + $actualOutput .= $message; + }, + ); + $output->expects(self::atLeastOnce()) + ->method('writeLineFormatted') + ->willReturnCallback( + static function (string $message) use (&$actualOutput): void { + $actualOutput .= $message . "\n"; + }, + ); + + $rule->print($output); + + $expectedOutput = file_get_contents(__DIR__ . '/data/debug/expected_output.txt'); + self::assertNotFalse($expectedOutput); + self::assertSame($expectedOutput . "\n", $this->trimFgColors($actualOutput)); + } + + /** + * @dataProvider provideDebugUsageInvalidArgs + */ + public function testDebugUsageInvalidArgs(string $member, string $error): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage($error); + + $this->debugMembers = [$member]; + $this->analyseFiles([__DIR__ . '/data/debug/alternative.php']); + $this->getRule(); + } + + /** + * @return array + */ + public static function provideDebugUsageInvalidArgs(): array + { + return [ + 'method not owned' => ['DebugAlternative\Clazz::foo', "Member 'foo' does not exist directly in 'DebugAlternative\Clazz'"], + 'method not declared' => ['DebugAlternative\Clazz::__construct', "Member '__construct' does not exist directly in 'DebugAlternative\Clazz'"], + 'no method' => ['DebugAlternative\Clazz::xyz', "Member 'xyz' does not exist directly in 'DebugAlternative\Clazz'"], + 'no class' => ['InvalidClass::foo', "Class 'InvalidClass' does not exist"], + 'invalid format' => ['InvalidFormat', "Invalid debug member format: 'InvalidFormat', expected 'ClassName::memberName'"], + ]; + } + /** * @dataProvider provideAutoRemoveFiles */ @@ -448,7 +553,6 @@ private function getMemberUsageProviders(): array { return [ new ReflectionUsageProvider( - $this->createUsageOriginDetector(), true, ), new class extends ReflectionBasedMemberUsageProvider @@ -481,7 +585,6 @@ public function shouldMarkMethodAsUsed(ReflectionMethod $method): bool ), new SymfonyUsageProvider( $this->createContainerMockWithSymfonyConfig(), - new UsageOriginDetector(), true, __DIR__ . '/data/providers/symfony/', ), @@ -532,18 +635,6 @@ static function (string $type): array { return $mock; } - private function createUsageOriginDetector(): UsageOriginDetector - { - /** @var UsageOriginDetector|null $detector */ - static $detector = null; - - if ($detector === null) { - $detector = new UsageOriginDetector(); - } - - return $detector; - } - public function gatherAnalyserErrors(array $files): array { if (!$this->unwrapGroupedErrors) { @@ -604,4 +695,19 @@ private static function requiresPackage(string $package, string $constraint): bo return InstalledVersions::satisfies(new VersionParser(), $package, $constraint); } + private function trimFgColors(string $output): string + { + $replaced = preg_replace( + '/(.*?)<\/>/', + '$1', + $output, + ); + + if ($replaced === null) { + throw new LogicException('Failed to trim colors'); + } + + return $replaced; + } + } diff --git a/tests/Rule/data/debug/alternative.php b/tests/Rule/data/debug/alternative.php new file mode 100644 index 00000000..e7672611 --- /dev/null +++ b/tests/Rule/data/debug/alternative.php @@ -0,0 +1,13 @@ +foo(); diff --git a/tests/Rule/data/debug/ctor.php b/tests/Rule/data/debug/ctor.php new file mode 100644 index 00000000..a5ab7d90 --- /dev/null +++ b/tests/Rule/data/debug/ctor.php @@ -0,0 +1,13 @@ +format('Y-m-d H:i:s'); diff --git a/tests/Rule/data/debug/global.php b/tests/Rule/data/debug/global.php new file mode 100644 index 00000000..1e0e8ea8 --- /dev/null +++ b/tests/Rule/data/debug/global.php @@ -0,0 +1,20 @@ +chain2(); + } + + public function chain2() + { + + } +} + +(new Foo())->chain1(); diff --git a/tests/Rule/data/debug/mixed.php b/tests/Rule/data/debug/mixed.php new file mode 100644 index 00000000..d859ba4b --- /dev/null +++ b/tests/Rule/data/debug/mixed.php @@ -0,0 +1,14 @@ +any(); +} diff --git a/tests/Rule/data/debug/never.php b/tests/Rule/data/debug/never.php new file mode 100644 index 00000000..185761bd --- /dev/null +++ b/tests/Rule/data/debug/never.php @@ -0,0 +1,9 @@ +call(); + $another->call(); + } +} + +class Another +{ + public function call(): void + { + } +} diff --git a/tests/Rule/data/debug/unsupported.php b/tests/Rule/data/debug/unsupported.php new file mode 100644 index 00000000..b170a8f4 --- /dev/null +++ b/tests/Rule/data/debug/unsupported.php @@ -0,0 +1,14 @@ +notDead(); + } + + private function notDead() + { + } +} diff --git a/tests/Rule/data/debug/virtual.php b/tests/Rule/data/debug/virtual.php new file mode 100644 index 00000000..760bfd59 --- /dev/null +++ b/tests/Rule/data/debug/virtual.php @@ -0,0 +1,13 @@ +