├── .ecrc ├── README.md ├── composer.json ├── rules.neon └── src ├── Collector ├── BufferedUsageCollector.php ├── ClassDefinitionCollector.php ├── ConstantFetchCollector.php ├── MethodCallCollector.php └── ProvidedUsagesCollector.php ├── Compatibility └── BackwardCompatibilityChecker.php ├── Debug └── DebugUsagePrinter.php ├── Enum ├── ClassLikeKind.php ├── MemberType.php ├── NeverReportedReason.php └── Visibility.php ├── Error └── BlackMember.php ├── Excluder ├── MemberUsageExcluder.php ├── MixedUsageExcluder.php └── TestsUsageExcluder.php ├── Formatter └── RemoveDeadCodeFormatter.php ├── Graph ├── ClassConstantRef.php ├── ClassConstantUsage.php ├── ClassMemberRef.php ├── ClassMemberUsage.php ├── ClassMethodRef.php ├── ClassMethodUsage.php ├── CollectedUsage.php └── UsageOrigin.php ├── Hierarchy └── ClassHierarchy.php ├── Output └── OutputEnhancer.php ├── Provider ├── ApiPhpDocUsageProvider.php ├── DoctrineUsageProvider.php ├── MemberUsageProvider.php ├── NetteUsageProvider.php ├── PhpStanUsageProvider.php ├── PhpUnitUsageProvider.php ├── ReflectionBasedMemberUsageProvider.php ├── ReflectionUsageProvider.php ├── SymfonyUsageProvider.php ├── TwigUsageProvider.php ├── VendorUsageProvider.php └── VirtualUsageData.php ├── Reflection └── ReflectionHelper.php ├── Rule └── DeadCodeRule.php └── Transformer ├── FileSystem.php ├── RemoveClassMemberVisitor.php └── RemoveDeadCodeTransformer.php /.ecrc: -------------------------------------------------------------------------------- 1 | { 2 | "Exclude": [ 3 | "output.txt$" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dead code detector for PHP 2 | 3 | [PHPStan](https://phpstan.org/) extension to find unused PHP code in your project with ease! 4 | 5 | ## Summary: 6 | 7 | - ✅ **PHPStan** extension 8 | - ♻️ **Dead cycles** detection 9 | - 🔗 **Transitive dead** member detection 10 | - 🧪 **Dead tested code** detection 11 | - 🧹 **Automatic removal** of unused code 12 | - 📚 **Popular libraries** support 13 | - ✨ **Customizable** usage providers 14 | 15 | ## Installation: 16 | 17 | ```sh 18 | composer require --dev shipmonk/dead-code-detector 19 | ``` 20 | 21 | Use [official extension-installer](https://phpstan.org/user-guide/extension-library#installing-extensions) or just load the rules: 22 | 23 | ```neon 24 | # phpstan.neon.dist 25 | includes: 26 | - vendor/shipmonk/dead-code-detector/rules.neon 27 | ``` 28 | 29 | ## Usage: 30 | 31 | ```sh 32 | $ vendor/bin/phpstan 33 | ``` 34 | 35 | > [!NOTE] 36 | > Make sure you analyse whole codebase (e.g. both `src` and `tests`) so that all usages are found. 37 | 38 | ## Supported libraries: 39 | 40 | #### Symfony: 41 | - **Calls made by DIC over your services!** 42 | - constructors, calls, factory methods 43 | - [`phpstan/phpstan-symfony`](https://github.com/phpstan/phpstan-symfony) with `containerXmlPath` must be used 44 | - `#[AsEventListener]` attribute 45 | - `#[AsController]` attribute 46 | - `#[AsCommand]` attribute 47 | - `#[Required]` attribute 48 | - `#[Route]` attributes 49 | - `#[Assert\Callback]` attributes 50 | - `EventSubscriberInterface::getSubscribedEvents` 51 | - `onKernelResponse`, `onKernelRequest`, etc 52 | - `!php const` references in `config` yamls 53 | - `defaultIndexMethod` in `#[AutowireLocator]` and `#[AutowireIterator]` 54 | 55 | #### Doctrine: 56 | - `#[AsEntityListener]` attribute 57 | - `Doctrine\ORM\Events::*` events 58 | - `Doctrine\Common\EventSubscriber` methods 59 | - `repositoryMethod` in `#[UniqueEntity]` attribute 60 | - lifecycle event attributes `#[PreFlush]`, `#[PostLoad]`, ... 61 | 62 | #### PHPUnit: 63 | - **data provider methods** 64 | - `testXxx` methods 65 | - annotations like `@test`, `@before`, `@afterClass` etc 66 | - attributes like `#[Test]`, `#[Before]`, `#[AfterClass]` etc 67 | 68 | #### PHPStan: 69 | - constructor calls for DIC services (rules, extensions, ...) 70 | 71 | #### Nette: 72 | - `handleXxx`, `renderXxx`, `actionXxx`, `injectXxx`, `createComponentXxx` 73 | - `SmartObject` magic calls for `@property` annotations 74 | 75 | #### Twig: 76 | - `#[AsTwigFilter]`, `#[AsTwigFunction]`, `#[AsTwigTest]` 77 | - `new TwigFilter(..., callback)`, `new TwigFunction(..., callback)`, `new TwigTest(..., callback)` 78 | 79 | All those libraries are autoenabled when found within your composer dependencies. 80 | If you want to force enable/disable some of them, you can: 81 | 82 | ```neon 83 | parameters: 84 | shipmonkDeadCode: 85 | usageProviders: 86 | phpunit: 87 | enabled: true 88 | ``` 89 | 90 | ## Generic usage providers: 91 | 92 | #### Reflection: 93 | - Any constant or method accessed via `ReflectionClass` is detected as used 94 | - e.g. `$reflection->getConstructor()`, `$reflection->getConstant('NAME')`, `$reflection->getMethods()`, ... 95 | 96 | #### Vendor: 97 | - Any overridden method that originates in `vendor` is not reported as dead 98 | - e.g. implementing `Psr\Log\LoggerInterface::log` is automatically considered used 99 | 100 | Those providers are enabled by default, but you can disable them if needed. 101 | 102 | ## Excluding usages in tests: 103 | - By default, all usages within scanned paths can mark members as used 104 | - But that might not be desirable if class declared in `src` is **only used in `tests`** 105 | - You can exclude those usages by enabling `tests` usage excluder: 106 | - This **will not disable analysis for tests** as only usages of src-defined classes will be excluded 107 | 108 | ```neon 109 | parameters: 110 | shipmonkDeadCode: 111 | usageExcluders: 112 | tests: 113 | enabled: true 114 | devPaths: # optional, autodetects from autoload-dev sections of composer.json when omitted 115 | - %currentWorkingDirectory%/tests 116 | ``` 117 | 118 | With such setup, members used only in tests will be reported with corresponding message, e.g: 119 | 120 | ``` 121 | Unused AddressValidator::isValidPostalCode (all usages excluded by tests excluder) 122 | ``` 123 | 124 | ## Customization: 125 | - If your application does some magic calls unknown to this library, you can implement your own usage provider. 126 | - Just tag it with `shipmonk.deadCode.memberUsageProvider` and implement `ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider` 127 | 128 | ```neon 129 | services: 130 | - 131 | class: App\ApiOutputUsageProvider 132 | tags: 133 | - shipmonk.deadCode.memberUsageProvider 134 | ``` 135 | 136 | > [!IMPORTANT] 137 | > _The interface & tag changed in [0.7](../../releases/tag/0.7.0). If you are using PHPStan 1.x, those were [used differently](../../blob/0.5.0/README.md#customization)._ 138 | 139 | ### Reflection-based customization: 140 | - For simple reflection usecases, you can just extend `ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider`: 141 | 142 | ```php 143 | 144 | use ReflectionMethod; 145 | use ShipMonk\PHPStan\DeadCode\Provider\VirtualUsageData; 146 | use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider; 147 | 148 | class FuzzyTwigUsageProvider extends ReflectionBasedMemberUsageProvider 149 | { 150 | 151 | public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 152 | { 153 | if ($method->getDeclaringClass()->implementsInterface(UsedInTwigMarkerInterface::class)) { 154 | return VirtualUsageData::withNote('Probably used in twig'); 155 | } 156 | return null; 157 | } 158 | 159 | } 160 | ``` 161 | 162 | ### AST-based customization: 163 | - For more complex usecases that are deducible only from AST, you just stick with raw `MemberUsageProvider` interface. 164 | - Here is simplified example how to emit `User::__construct` usage in following PHP snippet: 165 | 166 | ```php 167 | function test(SerializerInterface $serializer): User { 168 | return $serializer->deserialize('{"name": "John"}', User::class, 'json'); 169 | } 170 | ``` 171 | 172 | ```php 173 | use PhpParser\Node; 174 | use PhpParser\Node\Expr\MethodCall; 175 | use PHPStan\Analyser\Scope; 176 | use ReflectionMethod; 177 | use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; 178 | use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; 179 | use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin; 180 | use ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider; 181 | use Symfony\Component\Serializer\SerializerInterface; 182 | 183 | class DeserializationUsageProvider implements MemberUsageProvider 184 | { 185 | 186 | public function __construct( 187 | private UsageOriginDetector $originDetector, 188 | ) {} 189 | 190 | /** 191 | * @return list 192 | */ 193 | public function getUsages(Node $node, Scope $scope): array 194 | { 195 | if (!$node instanceof MethodCall) { 196 | return []; 197 | } 198 | 199 | if ( 200 | // our deserialization calls constructor 201 | $scope->getType($node->var)->getObjectClassNames() === [SerializerInterface::class] && 202 | $node->name->toString() === 'deserialize' 203 | ) { 204 | $secondArgument = $node->getArgs()[1]->value; 205 | $serializedClass = $scope->getType($secondArgument)->getConstantStrings()[0]; 206 | 207 | // record the place it was called from (needed for proper transitive dead code elimination) 208 | $usageOrigin = UsageOrigin::createRegular($node, $scope); 209 | 210 | // record the hidden constructor call 211 | $constructorRef = new ClassMethodRef($serializedClass->getValue(), '__construct', false); 212 | 213 | return [new ClassMethodUsage($usageOrigin, $constructorRef)]; 214 | } 215 | 216 | return []; 217 | } 218 | 219 | } 220 | ``` 221 | 222 | ### Excluding usages: 223 | 224 | You can exclude any usage based on custom logic, just implement `MemberUsageExcluder` and register it with `shipmonk.deadCode.memberUsageExcluder` tag: 225 | 226 | ```php 227 | 228 | use ShipMonk\PHPStan\DeadCode\Excluder\MemberUsageExcluder; 229 | 230 | class MyUsageExcluder implements MemberUsageExcluder 231 | { 232 | 233 | public function shouldExclude(ClassMemberUsage $usage, Node $node, Scope $scope): bool 234 | { 235 | // ... 236 | } 237 | 238 | } 239 | ``` 240 | 241 | ```neon 242 | # phpstan.neon.dist 243 | services: 244 | - 245 | class: App\MyUsageExcluder 246 | tags: 247 | - shipmonk.deadCode.memberUsageExcluder 248 | ``` 249 | 250 | The same interface is used for exclusion of test-only usages, see above. 251 | 252 | > [!NOTE] 253 | > Excluders are called **after** providers. 254 | 255 | ## Dead cycles & transitively dead methods 256 | - This library automatically detects dead cycles and transitively dead methods (methods that are only called from dead methods) 257 | - By default, it reports only the first dead method in the subtree and the rest as a tip: 258 | 259 | ``` 260 | ------ ------------------------------------------------------------------------ 261 | Line src/App/Facade/UserFacade.php 262 | ------ ------------------------------------------------------------------------ 263 | 26 Unused App\Facade\UserFacade::updateUserAddress 264 | 🪪 shipmonk.deadMethod 265 | 💡 Thus App\Entity\User::updateAddress is transitively also unused 266 | 💡 Thus App\Entity\Address::setPostalCode is transitively also unused 267 | 💡 Thus App\Entity\Address::setCountry is transitively also unused 268 | 💡 Thus App\Entity\Address::setStreet is transitively also unused 269 | 💡 Thus App\Entity\Address::MAX_STREET_CHARS is transitively also unused 270 | ------ ------------------------------------------------------------------------ 271 | ``` 272 | 273 | - If you want to report all dead methods individually, you can enable it in your `phpstan.neon.dist`: 274 | 275 | ```neon 276 | parameters: 277 | shipmonkDeadCode: 278 | reportTransitivelyDeadMethodAsSeparateError: true 279 | ``` 280 | 281 | ## Automatic removal of dead code 282 | - If you are sure that the reported methods are dead, you can automatically remove them by running PHPStan with `removeDeadCode` error format: 283 | 284 | ```bash 285 | vendor/bin/phpstan analyse --error-format removeDeadCode 286 | ``` 287 | 288 | ```diff 289 | class UserFacade 290 | { 291 | - public const TRANSITIVELY_DEAD = 1; 292 | - 293 | - public function deadMethod(): void 294 | - { 295 | - echo self::TRANSITIVELY_DEAD; 296 | - } 297 | } 298 | ``` 299 | 300 | - If you are excluding tests usages (see above), this will not cause the related tests to be removed alongside. 301 | - But you will see all those kept usages in output (with links to your IDE if you set up `editorUrl` [parameter](https://phpstan.org/user-guide/output-format#opening-file-in-an-editor)) 302 | 303 | ```txt 304 | • Removed method UserFacade::deadMethod 305 | ! Excluded usage at tests/User/UserFacadeTest.php:241 left intact 306 | ``` 307 | 308 | 309 | ## Calls over unknown types 310 | - In order to prevent false positives, we support even calls over unknown types (e.g. `$unknown->method()`) by marking all methods named `method` as used 311 | - Such behaviour might not be desired for strictly typed codebases, because e.g. single `new $unknown()` will mark all constructors as used 312 | - The same applies to constant fetches over unknown types (e.g. `$unknown::CONSTANT`) 313 | - Thus, you can disable this feature in your `phpstan.neon.dist` by excluding such usages: 314 | 315 | ```neon 316 | parameters: 317 | shipmonkDeadCode: 318 | usageExcluders: 319 | usageOverMixed: 320 | enabled: true 321 | ``` 322 | 323 | - If you want to check how many of those cases are present in your codebase, you can run PHPStan analysis with `-vvv` and you will see some diagnostics: 324 | 325 | ``` 326 | Found 2 usages over unknown type: 327 | • setCountry method, for example in App\Entity\User::updateAddress 328 | • setStreet method, for example in App\Entity\User::updateAddress 329 | ``` 330 | 331 | ## Access of unknown member 332 | - In order to prevent false positives, we support even calls of unknown methods (e.g. `$class->$unknown()`) by marking all possible methods as used 333 | - If we find unknown call over unknown type (e.g. `$unknownClass->$unknownMethod()`), we ignore such usage (as it would mark all methods in codebase as used) and show warning in debug verbosity (`-vvv`) 334 | - Note that some calls over `ReflectionClass` also emit unknown method calls: 335 | 336 | ```php 337 | /** @var ReflectionClass $reflection */ 338 | $methods = $reflection->getMethods(); // all Foo methods are used here 339 | ``` 340 | 341 | - All that applies even to constant fetches (e.g. `Foo::{$unknown}`) 342 | 343 | ## Comparison with tomasvotruba/unused-public 344 | - You can see [detailed comparison PR](https://github.com/shipmonk-rnd/dead-code-detector/pull/53) 345 | - Basically, their analysis is less precise and less flexible. Mainly: 346 | - It cannot detect dead constructors 347 | - It does not properly detect calls within inheritance hierarchy 348 | - It does not offer any custom adjustments of used methods 349 | - It has almost no built-in library extensions 350 | - It ignores trait methods 351 | - Is lacks many minor features like class-string calls, dynamic method calls, array callbacks, nullsafe call chains etc 352 | - It cannot detect dead cycles nor transitively dead methods 353 | - It has no built-in dead code removal 354 | 355 | ## Limitations: 356 | - Methods of anonymous classes are never reported as dead ([PHPStan limitation](https://github.com/phpstan/phpstan/issues/8410)) 357 | - Abstract trait methods are never reported as dead 358 | - Most magic methods (e.g. `__get`, `__set` etc) are never reported as dead 359 | - Only supported are: `__construct`, `__clone` 360 | 361 | ### Other problematic cases: 362 | 363 | #### Constructors: 364 | - For symfony apps & PHPStan extensions, we simplify the detection by assuming all DIC classes have used constructor. 365 | - For other apps, you may get false-positives if services are created magically. 366 | - To avoid those, you can easily disable constructor analysis with single ignore: 367 | 368 | ```neon 369 | parameters: 370 | ignoreErrors: 371 | - '#^Unused .*?::__construct$#' 372 | ``` 373 | 374 | #### Private constructors: 375 | - Those are never reported as dead as those are often used to deny class instantiation 376 | - This applies only to constructors without any parameters 377 | 378 | #### Interface methods: 379 | - If you never call interface method over the interface, but only over its implementors, it gets reported as dead 380 | - But you may want to keep the interface method to force some unification across implementors 381 | - The easiest way to ignore it is via custom `MemberUsageProvider`: 382 | 383 | ```php 384 | use ShipMonk\PHPStan\DeadCode\Provider\VirtualUsageData; 385 | use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider; 386 | 387 | class IgnoreDeadInterfaceUsageProvider extends ReflectionBasedMemberUsageProvider 388 | { 389 | public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 390 | { 391 | if ($method->getDeclaringClass()->isInterface()) { 392 | return VirtualUsageData::withNote('Interface method, kept for unification even when possibly unused'); 393 | } 394 | 395 | return null; 396 | } 397 | } 398 | ``` 399 | 400 | ## Debugging: 401 | - If you want to see how dead code detector evaluated usages of certain method, you do the following: 402 | 403 | ```neon 404 | parameters: 405 | shipmonkDeadCode: 406 | debug: 407 | usagesOf: 408 | - App\User\Entity\Address::__construct 409 | ``` 410 | 411 | Then, run PHPStan with `-vvv` CLI option and you will see the output like this: 412 | 413 | ```txt 414 | App\User\Entity\Address::__construct 415 | | 416 | | Marked as alive by: 417 | | entry virtual usage from ShipMonk\PHPStan\DeadCode\Provider\SymfonyUsageProvider 418 | | calls App\User\RegisterUserController::__invoke:36 419 | | calls App\User\UserFacade::registerUser:142 420 | | calls App\User\Entity\Address::__construct 421 | | 422 | | Found 2 usages: 423 | | • src/User/UserFacade.php:142 424 | | • tests/User/Entity/AddressTest.php:64 - excluded by tests excluder 425 | ``` 426 | 427 | 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. 428 | 429 | > [!TIP] 430 | > You can change the list of debug references without affecting result cache, so rerun is instant! 431 | 432 | ## Usage in libraries: 433 | - Libraries typically contain public api, that is unused 434 | - If you mark such methods with `@api` phpdoc, those will be considered entrypoints 435 | - You can also mark whole class or interface with `@api` to mark all its methods as entrypoints 436 | 437 | ## Future scope: 438 | - Dead class property detection 439 | - Dead class detection 440 | 441 | ## Contributing 442 | - Check your code by `composer check` 443 | - Autofix coding-style by `composer fix:cs` 444 | - All functionality must be tested 445 | 446 | ## Supported PHP versions 447 | - PHP 7.4 - 8.4 448 | -------------------------------------------------------------------------------- /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.9" 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.45.0", 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 | "phpstan/phpstan-phpunit": "^2.0.4", 29 | "phpstan/phpstan-strict-rules": "^2.0.3", 30 | "phpstan/phpstan-symfony": "^2.0.2", 31 | "phpunit/phpunit": "^9.6.22", 32 | "shipmonk/composer-dependency-analyser": "^1.8.2", 33 | "shipmonk/name-collision-detector": "^2.1.1", 34 | "shipmonk/phpstan-rules": "^4.1.0", 35 | "slevomat/coding-standard": "^8.16.0", 36 | "symfony/contracts": "^2.5 || ^3.0", 37 | "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", 38 | "symfony/doctrine-bridge": "^5.4 || ^6.0 || ^7.0", 39 | "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", 40 | "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", 41 | "symfony/routing": "^5.4 || ^6.0 || ^7.0", 42 | "symfony/validator": "^5.4 || ^6.0 || ^7.0", 43 | "twig/twig": "^3.0" 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "ShipMonk\\PHPStan\\DeadCode\\": "src/" 48 | } 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "ShipMonk\\PHPStan\\DeadCode\\": "tests/" 53 | }, 54 | "classmap": [ 55 | "tests/Rule/data" 56 | ] 57 | }, 58 | "config": { 59 | "allow-plugins": { 60 | "dealerdirect/phpcodesniffer-composer-installer": false, 61 | "ergebnis/composer-normalize": true 62 | }, 63 | "sort-packages": true 64 | }, 65 | "extra": { 66 | "phpstan": { 67 | "includes": [ 68 | "rules.neon" 69 | ] 70 | } 71 | }, 72 | "scripts": { 73 | "check": [ 74 | "@check:composer", 75 | "@check:ec", 76 | "@check:cs", 77 | "@check:types", 78 | "@check:tests", 79 | "@check:collisions", 80 | "@check:dependencies" 81 | ], 82 | "check:collisions": "detect-collisions src tests", 83 | "check:composer": "composer normalize --dry-run --no-check-lock --no-update-lock", 84 | "check:cs": "phpcs", 85 | "check:dependencies": "composer-dependency-analyser", 86 | "check:ec": "ec src tests", 87 | "check:tests": "phpunit tests", 88 | "check:types": "phpstan analyse -vv --ansi", 89 | "fix:cs": "phpcbf" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /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\VendorUsageProvider 26 | tags: 27 | - shipmonk.deadCode.memberUsageProvider 28 | arguments: 29 | enabled: %shipmonkDeadCode.usageProviders.vendor.enabled% 30 | 31 | - 32 | class: ShipMonk\PHPStan\DeadCode\Provider\ReflectionUsageProvider 33 | tags: 34 | - shipmonk.deadCode.memberUsageProvider 35 | arguments: 36 | enabled: %shipmonkDeadCode.usageProviders.reflection.enabled% 37 | 38 | - 39 | class: ShipMonk\PHPStan\DeadCode\Provider\PhpUnitUsageProvider 40 | tags: 41 | - shipmonk.deadCode.memberUsageProvider 42 | arguments: 43 | enabled: %shipmonkDeadCode.usageProviders.phpunit.enabled% 44 | 45 | - 46 | class: ShipMonk\PHPStan\DeadCode\Provider\SymfonyUsageProvider 47 | tags: 48 | - shipmonk.deadCode.memberUsageProvider 49 | arguments: 50 | enabled: %shipmonkDeadCode.usageProviders.symfony.enabled% 51 | configDir: %shipmonkDeadCode.usageProviders.symfony.configDir% 52 | 53 | - 54 | class: ShipMonk\PHPStan\DeadCode\Provider\TwigUsageProvider 55 | tags: 56 | - shipmonk.deadCode.memberUsageProvider 57 | arguments: 58 | enabled: %shipmonkDeadCode.usageProviders.twig.enabled% 59 | 60 | - 61 | class: ShipMonk\PHPStan\DeadCode\Provider\DoctrineUsageProvider 62 | tags: 63 | - shipmonk.deadCode.memberUsageProvider 64 | arguments: 65 | enabled: %shipmonkDeadCode.usageProviders.doctrine.enabled% 66 | 67 | - 68 | class: ShipMonk\PHPStan\DeadCode\Provider\PhpStanUsageProvider 69 | tags: 70 | - shipmonk.deadCode.memberUsageProvider 71 | arguments: 72 | enabled: %shipmonkDeadCode.usageProviders.phpstan.enabled% 73 | 74 | - 75 | class: ShipMonk\PHPStan\DeadCode\Provider\NetteUsageProvider 76 | tags: 77 | - shipmonk.deadCode.memberUsageProvider 78 | arguments: 79 | enabled: %shipmonkDeadCode.usageProviders.nette.enabled% 80 | 81 | 82 | - 83 | class: ShipMonk\PHPStan\DeadCode\Excluder\TestsUsageExcluder 84 | tags: 85 | - shipmonk.deadCode.memberUsageExcluder 86 | arguments: 87 | enabled: %shipmonkDeadCode.usageExcluders.tests.enabled% 88 | devPaths: %shipmonkDeadCode.usageExcluders.tests.devPaths% 89 | 90 | - 91 | class: ShipMonk\PHPStan\DeadCode\Excluder\MixedUsageExcluder 92 | tags: 93 | - shipmonk.deadCode.memberUsageExcluder 94 | arguments: 95 | enabled: %shipmonkDeadCode.usageExcluders.usageOverMixed.enabled% 96 | 97 | 98 | - 99 | class: ShipMonk\PHPStan\DeadCode\Collector\MethodCallCollector 100 | tags: 101 | - phpstan.collector 102 | arguments: 103 | memberUsageExcluders: tagged(shipmonk.deadCode.memberUsageExcluder) 104 | 105 | - 106 | class: ShipMonk\PHPStan\DeadCode\Collector\ConstantFetchCollector 107 | tags: 108 | - phpstan.collector 109 | arguments: 110 | memberUsageExcluders: tagged(shipmonk.deadCode.memberUsageExcluder) 111 | 112 | - 113 | class: ShipMonk\PHPStan\DeadCode\Collector\ClassDefinitionCollector 114 | tags: 115 | - phpstan.collector 116 | 117 | - 118 | class: ShipMonk\PHPStan\DeadCode\Collector\ProvidedUsagesCollector 119 | tags: 120 | - phpstan.collector 121 | arguments: 122 | memberUsageProviders: tagged(shipmonk.deadCode.memberUsageProvider) 123 | memberUsageExcluders: tagged(shipmonk.deadCode.memberUsageExcluder) 124 | 125 | - 126 | class: ShipMonk\PHPStan\DeadCode\Rule\DeadCodeRule 127 | tags: 128 | - phpstan.rules.rule 129 | - phpstan.diagnoseExtension 130 | arguments: 131 | reportTransitivelyDeadMethodAsSeparateError: %shipmonkDeadCode.reportTransitivelyDeadMethodAsSeparateError% 132 | 133 | - 134 | class: ShipMonk\PHPStan\DeadCode\Compatibility\BackwardCompatibilityChecker 135 | arguments: 136 | servicesWithOldTag: tagged(shipmonk.deadCode.entrypointProvider) 137 | trackMixedAccessParameterValue: %shipmonkDeadCode.trackMixedAccess% 138 | 139 | 140 | parameters: 141 | parametersNotInvalidatingCache: 142 | - parameters.shipmonkDeadCode.debug.usagesOf 143 | - parameters.shipmonkDeadCode.reportTransitivelyDeadMethodAsSeparateError 144 | shipmonkDeadCode: 145 | trackMixedAccess: null 146 | reportTransitivelyDeadMethodAsSeparateError: false 147 | usageProviders: 148 | apiPhpDoc: 149 | enabled: true 150 | vendor: 151 | enabled: true 152 | reflection: 153 | enabled: true 154 | phpstan: 155 | enabled: true 156 | phpunit: 157 | enabled: null 158 | symfony: 159 | enabled: null 160 | configDir: null 161 | twig: 162 | enabled: null 163 | doctrine: 164 | enabled: null 165 | nette: 166 | enabled: null 167 | usageExcluders: 168 | tests: 169 | enabled: false 170 | devPaths: null 171 | usageOverMixed: 172 | enabled: false 173 | debug: 174 | usagesOf: [] 175 | 176 | parametersSchema: 177 | shipmonkDeadCode: structure([ 178 | trackMixedAccess: schema(bool(), nullable()) # deprecated, use usageExcluders.usageOverMixed.enabled 179 | reportTransitivelyDeadMethodAsSeparateError: bool() 180 | usageProviders: structure([ 181 | apiPhpDoc: structure([ 182 | enabled: bool() 183 | ]) 184 | vendor: structure([ 185 | enabled: bool() 186 | ]) 187 | reflection: structure([ 188 | enabled: bool() 189 | ]) 190 | phpstan: structure([ 191 | enabled: bool() 192 | ]) 193 | phpunit: structure([ 194 | enabled: schema(bool(), nullable()) 195 | ]) 196 | symfony: structure([ 197 | enabled: schema(bool(), nullable()) 198 | configDir: schema(string(), nullable()) 199 | ]) 200 | twig: structure([ 201 | enabled: schema(bool(), nullable()) 202 | ]) 203 | doctrine: structure([ 204 | enabled: schema(bool(), nullable()) 205 | ]) 206 | nette: structure([ 207 | enabled: schema(bool(), nullable()) 208 | ]) 209 | ]) 210 | usageExcluders: structure([ 211 | tests: structure([ 212 | enabled: bool() 213 | devPaths: schema(listOf(string()), nullable()) 214 | ]) 215 | usageOverMixed: structure([ 216 | enabled: bool() 217 | ]) 218 | ]) 219 | debug: structure([ 220 | usagesOf: listOf(string()) 221 | ]) 222 | ]) 223 | -------------------------------------------------------------------------------- /src/Collector/BufferedUsageCollector.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | private array $usages = []; 16 | 17 | /** 18 | * @return non-empty-list|null 19 | */ 20 | private function emitUsages(Scope $scope): ?array 21 | { 22 | try { 23 | return $this->usages === [] 24 | ? null 25 | : array_map( 26 | static fn (CollectedUsage $usage): string => $usage->serialize($scope->getFile()), 27 | $this->usages, 28 | ); 29 | } finally { 30 | $this->usages = []; 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/Collector/ClassDefinitionCollector.php: -------------------------------------------------------------------------------- 1 | , 29 | * methods: array}>, 30 | * parents: array, 31 | * traits: array, aliases?: array}>, 32 | * interfaces: array, 33 | * }> 34 | */ 35 | class ClassDefinitionCollector implements Collector 36 | { 37 | 38 | private ReflectionProvider $reflectionProvider; 39 | 40 | public function __construct(ReflectionProvider $reflectionProvider) 41 | { 42 | $this->reflectionProvider = $reflectionProvider; 43 | } 44 | 45 | public function getNodeType(): string 46 | { 47 | return ClassLike::class; 48 | } 49 | 50 | /** 51 | * @param ClassLike $node 52 | * @return array{ 53 | * kind: string, 54 | * name: string, 55 | * constants: array, 56 | * methods: array}>, 57 | * parents: array, 58 | * traits: array, aliases?: array}>, 59 | * interfaces: array, 60 | * }|null 61 | */ 62 | public function processNode( 63 | Node $node, 64 | Scope $scope 65 | ): ?array 66 | { 67 | if ($node->namespacedName === null) { 68 | return null; 69 | } 70 | 71 | $kind = $this->getKind($node); 72 | $typeName = $node->namespacedName->toString(); 73 | $reflection = $this->reflectionProvider->getClass($typeName); 74 | 75 | $methods = []; 76 | 77 | foreach ($node->getMethods() as $method) { 78 | $methods[$method->name->toString()] = [ 79 | 'line' => $method->name->getStartLine(), 80 | 'params' => count($method->params), 81 | 'abstract' => $method->isAbstract() || $node instanceof Interface_, 82 | 'visibility' => $method->flags & (Visibility::PUBLIC | Visibility::PROTECTED | Visibility::PRIVATE), 83 | ]; 84 | } 85 | 86 | $constants = []; 87 | 88 | foreach ($node->getConstants() as $constant) { 89 | foreach ($constant->consts as $const) { 90 | $constants[$const->name->toString()] = [ 91 | 'line' => $const->getStartLine(), 92 | ]; 93 | } 94 | } 95 | 96 | return [ 97 | 'kind' => $kind, 98 | 'name' => $typeName, 99 | 'methods' => $methods, 100 | 'constants' => $constants, 101 | 'parents' => $this->getParents($reflection), 102 | 'traits' => $this->getTraits($node), 103 | 'interfaces' => $this->getInterfaces($reflection), 104 | ]; 105 | } 106 | 107 | /** 108 | * @return array 109 | */ 110 | private function getParents(ClassReflection $reflection): array 111 | { 112 | $parents = []; 113 | 114 | foreach ($reflection->getParentClassesNames() as $parent) { 115 | $parents[$parent] = null; 116 | } 117 | 118 | return $parents; 119 | } 120 | 121 | /** 122 | * @return array 123 | */ 124 | private function getInterfaces(ClassReflection $reflection): array 125 | { 126 | return array_fill_keys(array_map(static fn (ClassReflection $reflection) => $reflection->getName(), $reflection->getInterfaces()), null); 127 | } 128 | 129 | /** 130 | * @return array, aliases?: array}> 131 | */ 132 | private function getTraits(ClassLike $node): array 133 | { 134 | $traits = []; 135 | 136 | foreach ($node->getTraitUses() as $traitUse) { 137 | foreach ($traitUse->traits as $trait) { 138 | $traits[$trait->toString()] = []; 139 | } 140 | 141 | foreach ($traitUse->adaptations as $adaptation) { 142 | if ($adaptation instanceof Precedence) { 143 | foreach ($adaptation->insteadof as $insteadof) { 144 | $traits[$insteadof->toString()]['excluded'][] = $adaptation->method->toString(); 145 | } 146 | } 147 | 148 | if ($adaptation instanceof Alias && $adaptation->newName !== null) { 149 | if ($adaptation->trait === null) { 150 | // assign alias to all traits, wrong ones are eliminated in Rule logic 151 | foreach ($traitUse->traits as $trait) { 152 | $traits[$trait->toString()]['aliases'][$adaptation->method->toString()] = $adaptation->newName->toString(); 153 | } 154 | } else { 155 | $traits[$adaptation->trait->toString()]['aliases'][$adaptation->method->toString()] = $adaptation->newName->toString(); 156 | } 157 | } 158 | } 159 | } 160 | 161 | return $traits; 162 | } 163 | 164 | private function getKind(ClassLike $node): string 165 | { 166 | if ($node instanceof Class_) { 167 | return ClassLikeKind::CLASSS; 168 | } 169 | 170 | if ($node instanceof Interface_) { 171 | return ClassLikeKind::INTERFACE; 172 | } 173 | 174 | if ($node instanceof Trait_) { 175 | return ClassLikeKind::TRAIT; 176 | } 177 | 178 | if ($node instanceof Enum_) { 179 | return ClassLikeKind::ENUM; 180 | } 181 | 182 | throw new LogicException('Unknown class-like node'); 183 | } 184 | 185 | } 186 | -------------------------------------------------------------------------------- /src/Collector/ConstantFetchCollector.php: -------------------------------------------------------------------------------- 1 | > 30 | */ 31 | class ConstantFetchCollector implements Collector 32 | { 33 | 34 | use BufferedUsageCollector; 35 | 36 | private ReflectionProvider $reflectionProvider; 37 | 38 | /** 39 | * @var list 40 | */ 41 | private array $memberUsageExcluders; 42 | 43 | /** 44 | * @param list $memberUsageExcluders 45 | */ 46 | public function __construct( 47 | ReflectionProvider $reflectionProvider, 48 | array $memberUsageExcluders 49 | ) 50 | { 51 | $this->reflectionProvider = $reflectionProvider; 52 | $this->memberUsageExcluders = $memberUsageExcluders; 53 | } 54 | 55 | public function getNodeType(): string 56 | { 57 | return Node::class; 58 | } 59 | 60 | /** 61 | * @return non-empty-list|null 62 | */ 63 | public function processNode( 64 | Node $node, 65 | Scope $scope 66 | ): ?array 67 | { 68 | if ($node instanceof ClassConstFetch) { 69 | $this->registerFetch($node, $scope); 70 | } 71 | 72 | if ($node instanceof FuncCall) { 73 | $this->registerFunctionCall($node, $scope); 74 | } 75 | 76 | return $this->emitUsages($scope); 77 | } 78 | 79 | private function registerFunctionCall(FuncCall $node, Scope $scope): void 80 | { 81 | if (count($node->args) !== 1) { 82 | return; 83 | } 84 | 85 | /** @var Arg $firstArg */ 86 | $firstArg = current($node->args); 87 | 88 | if ($node->name instanceof Name) { 89 | $functionNames = [$node->name->toString()]; 90 | } else { 91 | $nameType = $scope->getType($node->name); 92 | $functionNames = array_map(static fn (ConstantStringType $string): string => $string->getValue(), $nameType->getConstantStrings()); 93 | } 94 | 95 | foreach ($functionNames as $functionName) { 96 | if ($functionName !== 'constant') { 97 | continue; 98 | } 99 | 100 | $argumentType = $scope->getType($firstArg->value); 101 | 102 | foreach ($argumentType->getConstantStrings() as $constantString) { 103 | if (strpos($constantString->getValue(), '::') === false) { 104 | continue; 105 | } 106 | 107 | // @phpstan-ignore offsetAccess.notFound 108 | [$className, $constantName] = explode('::', $constantString->getValue()); 109 | 110 | if ($this->reflectionProvider->hasClass($className)) { 111 | $reflection = $this->reflectionProvider->getClass($className); 112 | 113 | if ($reflection->hasConstant($constantName)) { 114 | $className = $reflection->getConstant($constantName)->getDeclaringClass()->getName(); 115 | } 116 | } 117 | 118 | $this->registerUsage( 119 | new ClassConstantUsage( 120 | UsageOrigin::createRegular($node, $scope), 121 | new ClassConstantRef($className, $constantName, true), 122 | ), 123 | $node, 124 | $scope, 125 | ); 126 | } 127 | } 128 | } 129 | 130 | private function registerFetch(ClassConstFetch $node, Scope $scope): void 131 | { 132 | if ($node->class instanceof Expr) { 133 | $ownerType = $scope->getType($node->class); 134 | $possibleDescendantFetch = null; 135 | } else { 136 | $ownerType = $scope->resolveTypeByName($node->class); 137 | $possibleDescendantFetch = $node->class->toString() === 'static'; 138 | } 139 | 140 | $constantNames = $this->getConstantNames($node, $scope); 141 | 142 | foreach ($constantNames as $constantName) { 143 | if ($constantName === 'class') { 144 | continue; // reserved for class name fetching 145 | } 146 | 147 | foreach ($this->getDeclaringTypesWithConstant($ownerType, $constantName, $possibleDescendantFetch) as $constantRef) { 148 | $this->registerUsage( 149 | new ClassConstantUsage( 150 | UsageOrigin::createRegular($node, $scope), 151 | $constantRef, 152 | ), 153 | $node, 154 | $scope, 155 | ); 156 | } 157 | } 158 | } 159 | 160 | /** 161 | * @return list 162 | */ 163 | private function getConstantNames(ClassConstFetch $fetch, Scope $scope): array 164 | { 165 | if ($fetch->name instanceof Expr) { 166 | $possibleConstantNames = []; 167 | 168 | foreach ($scope->getType($fetch->name)->getConstantStrings() as $constantString) { 169 | $possibleConstantNames[] = $constantString->getValue(); 170 | } 171 | 172 | return $possibleConstantNames === [] 173 | ? [null] // unknown constant name 174 | : $possibleConstantNames; 175 | } 176 | 177 | return [$fetch->name->toString()]; 178 | } 179 | 180 | /** 181 | * @return list 182 | */ 183 | private function getDeclaringTypesWithConstant( 184 | Type $type, 185 | ?string $constantName, 186 | ?bool $isPossibleDescendant 187 | ): array 188 | { 189 | $typeNormalized = TypeUtils::toBenevolentUnion($type); // extract possible fetches even from Class|int 190 | $classReflections = $typeNormalized->getObjectTypeOrClassStringObjectType()->getObjectClassReflections(); 191 | 192 | $result = []; 193 | 194 | foreach ($classReflections as $classReflection) { 195 | $possibleDescendant = $isPossibleDescendant ?? !$classReflection->isFinal(); 196 | $result[] = new ClassConstantRef($classReflection->getName(), $constantName, $possibleDescendant); 197 | } 198 | 199 | if ($result === []) { 200 | $result[] = new ClassConstantRef(null, $constantName, true); // call over unknown type 201 | } 202 | 203 | return $result; 204 | } 205 | 206 | private function registerUsage(ClassConstantUsage $usage, Node $node, Scope $scope): void 207 | { 208 | $excluderName = null; 209 | 210 | foreach ($this->memberUsageExcluders as $excludedUsageDecider) { 211 | if ($excludedUsageDecider->shouldExclude($usage, $node, $scope)) { 212 | $excluderName = $excludedUsageDecider->getIdentifier(); 213 | break; 214 | } 215 | } 216 | 217 | $this->usages[] = new CollectedUsage($usage, $excluderName); 218 | } 219 | 220 | } 221 | -------------------------------------------------------------------------------- /src/Collector/MethodCallCollector.php: -------------------------------------------------------------------------------- 1 | > 32 | */ 33 | class MethodCallCollector implements Collector 34 | { 35 | 36 | use BufferedUsageCollector; 37 | 38 | /** 39 | * @var list 40 | */ 41 | private array $memberUsageExcluders; 42 | 43 | /** 44 | * @param list $memberUsageExcluders 45 | */ 46 | public function __construct( 47 | array $memberUsageExcluders 48 | ) 49 | { 50 | $this->memberUsageExcluders = $memberUsageExcluders; 51 | } 52 | 53 | public function getNodeType(): string 54 | { 55 | return Node::class; 56 | } 57 | 58 | /** 59 | * @return non-empty-list|null 60 | */ 61 | public function processNode( 62 | Node $node, 63 | Scope $scope 64 | ): ?array 65 | { 66 | if ($node instanceof MethodCallableNode) { // @phpstan-ignore-line ignore BC promise 67 | $this->registerMethodCall($node->getOriginalNode(), $scope); 68 | } 69 | 70 | if ($node instanceof StaticMethodCallableNode) { // @phpstan-ignore-line ignore BC promise 71 | $this->registerStaticCall($node->getOriginalNode(), $scope); 72 | } 73 | 74 | if ($node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof New_) { 75 | $this->registerMethodCall($node, $scope); 76 | } 77 | 78 | if ($node instanceof StaticCall) { 79 | $this->registerStaticCall($node, $scope); 80 | } 81 | 82 | if ($node instanceof Array_) { 83 | $this->registerArrayCallable($node, $scope); 84 | } 85 | 86 | if ($node instanceof Clone_) { 87 | $this->registerClone($node, $scope); 88 | } 89 | 90 | if ($node instanceof Attribute) { 91 | $this->registerAttribute($node, $scope); 92 | } 93 | 94 | return $this->emitUsages($scope); 95 | } 96 | 97 | /** 98 | * @param NullsafeMethodCall|MethodCall|New_ $methodCall 99 | */ 100 | private function registerMethodCall( 101 | CallLike $methodCall, 102 | Scope $scope 103 | ): void 104 | { 105 | $methodNames = $this->getMethodNames($methodCall, $scope); 106 | 107 | if ($methodCall instanceof New_) { 108 | if ($methodCall->class instanceof Expr) { 109 | $callerType = $scope->getType($methodCall); 110 | $possibleDescendantCall = null; 111 | 112 | } elseif ($methodCall->class instanceof Name) { 113 | $callerType = $scope->resolveTypeByName($methodCall->class); 114 | $possibleDescendantCall = $methodCall->class->toString() === 'static'; 115 | 116 | } else { 117 | return; 118 | } 119 | } else { 120 | $callerType = $scope->getType($methodCall->var); 121 | $possibleDescendantCall = null; 122 | } 123 | 124 | foreach ($methodNames as $methodName) { 125 | foreach ($this->getDeclaringTypesWithMethod($methodName, $callerType, TrinaryLogic::createNo(), $possibleDescendantCall) as $methodRef) { 126 | $this->registerUsage( 127 | new ClassMethodUsage( 128 | UsageOrigin::createRegular($methodCall, $scope), 129 | $methodRef, 130 | ), 131 | $methodCall, 132 | $scope, 133 | ); 134 | } 135 | } 136 | } 137 | 138 | private function registerStaticCall( 139 | StaticCall $staticCall, 140 | Scope $scope 141 | ): void 142 | { 143 | $methodNames = $this->getMethodNames($staticCall, $scope); 144 | 145 | if ($staticCall->class instanceof Expr) { 146 | $callerType = $scope->getType($staticCall->class); 147 | $possibleDescendantCall = null; 148 | 149 | } else { 150 | $callerType = $scope->resolveTypeByName($staticCall->class); 151 | $possibleDescendantCall = $staticCall->class->toString() === 'static'; 152 | } 153 | 154 | foreach ($methodNames as $methodName) { 155 | foreach ($this->getDeclaringTypesWithMethod($methodName, $callerType, TrinaryLogic::createYes(), $possibleDescendantCall) as $methodRef) { 156 | $this->registerUsage( 157 | new ClassMethodUsage( 158 | UsageOrigin::createRegular($staticCall, $scope), 159 | $methodRef, 160 | ), 161 | $staticCall, 162 | $scope, 163 | ); 164 | } 165 | } 166 | } 167 | 168 | private function registerArrayCallable( 169 | Array_ $array, 170 | Scope $scope 171 | ): void 172 | { 173 | if ($scope->getType($array)->isCallable()->yes()) { 174 | foreach ($scope->getType($array)->getConstantArrays() as $constantArray) { 175 | $callableTypeAndNames = $constantArray->findTypeAndMethodNames(); 176 | 177 | foreach ($callableTypeAndNames as $typeAndName) { 178 | $caller = $typeAndName->getType(); 179 | $methodName = $typeAndName->getMethod(); 180 | 181 | foreach ($this->getDeclaringTypesWithMethod($methodName, $caller, TrinaryLogic::createMaybe()) as $methodRef) { 182 | $this->registerUsage( 183 | new ClassMethodUsage( 184 | UsageOrigin::createRegular($array, $scope), 185 | $methodRef, 186 | ), 187 | $array, 188 | $scope, 189 | ); 190 | } 191 | } 192 | } 193 | } 194 | } 195 | 196 | private function registerAttribute(Attribute $node, Scope $scope): void 197 | { 198 | $this->registerUsage( 199 | new ClassMethodUsage( 200 | UsageOrigin::createRegular($node, $scope), 201 | new ClassMethodRef($scope->resolveName($node->name), '__construct', false), 202 | ), 203 | $node, 204 | $scope, 205 | ); 206 | } 207 | 208 | private function registerClone(Clone_ $node, Scope $scope): void 209 | { 210 | $methodName = '__clone'; 211 | $callerType = $scope->getType($node->expr); 212 | 213 | foreach ($this->getDeclaringTypesWithMethod($methodName, $callerType, TrinaryLogic::createNo()) as $methodRef) { 214 | $this->registerUsage( 215 | new ClassMethodUsage( 216 | UsageOrigin::createRegular($node, $scope), 217 | $methodRef, 218 | ), 219 | $node, 220 | $scope, 221 | ); 222 | } 223 | } 224 | 225 | /** 226 | * @param NullsafeMethodCall|MethodCall|StaticCall|New_ $call 227 | * @return list 228 | */ 229 | private function getMethodNames(CallLike $call, Scope $scope): array 230 | { 231 | if ($call instanceof New_) { 232 | return ['__construct']; 233 | } 234 | 235 | if ($call->name instanceof Expr) { 236 | $possibleMethodNames = []; 237 | 238 | foreach ($scope->getType($call->name)->getConstantStrings() as $constantString) { 239 | $possibleMethodNames[] = $constantString->getValue(); 240 | } 241 | 242 | return $possibleMethodNames === [] 243 | ? [null] // unknown method name 244 | : $possibleMethodNames; 245 | } 246 | 247 | return [$call->name->toString()]; 248 | } 249 | 250 | /** 251 | * @return list 252 | */ 253 | private function getDeclaringTypesWithMethod( 254 | ?string $methodName, 255 | Type $type, 256 | TrinaryLogic $isStaticCall, 257 | ?bool $isPossibleDescendant = null 258 | ): array 259 | { 260 | $typeNoNull = TypeCombinator::removeNull($type); // remove null to support nullsafe calls 261 | $typeNormalized = TypeUtils::toBenevolentUnion($typeNoNull); // extract possible calls even from Class|int 262 | $classReflections = $typeNormalized->getObjectTypeOrClassStringObjectType()->getObjectClassReflections(); 263 | 264 | $result = []; 265 | 266 | foreach ($classReflections as $classReflection) { 267 | $possibleDescendant = $isPossibleDescendant ?? !$classReflection->isFinal(); 268 | $result[] = new ClassMethodRef($classReflection->getName(), $methodName, $possibleDescendant); 269 | } 270 | 271 | $canBeObjectCall = !$typeNoNull->isObject()->no() && !$isStaticCall->yes(); 272 | $canBeClassStringCall = !$typeNoNull->isClassString()->no() && !$isStaticCall->no(); 273 | 274 | if ($result === [] && ($canBeObjectCall || $canBeClassStringCall)) { 275 | $result[] = new ClassMethodRef(null, $methodName, true); // call over unknown type 276 | } 277 | 278 | return $result; 279 | } 280 | 281 | private function registerUsage(ClassMethodUsage $usage, Node $node, Scope $scope): void 282 | { 283 | $excluderName = null; 284 | 285 | foreach ($this->memberUsageExcluders as $excludedUsageDecider) { 286 | if ($excludedUsageDecider->shouldExclude($usage, $node, $scope)) { 287 | $excluderName = $excludedUsageDecider->getIdentifier(); 288 | break; 289 | } 290 | } 291 | 292 | $this->usages[] = new CollectedUsage($usage, $excluderName); 293 | } 294 | 295 | } 296 | -------------------------------------------------------------------------------- /src/Collector/ProvidedUsagesCollector.php: -------------------------------------------------------------------------------- 1 | > 19 | */ 20 | 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(ClassMemberUsage $usage, Node $node, Scope $scope): CollectedUsage 111 | { 112 | $excluderName = null; 113 | 114 | foreach ($this->memberUsageExcluders as $excludedUsageDecider) { 115 | if ($excludedUsageDecider->shouldExclude($usage, $node, $scope)) { 116 | $excluderName = $excludedUsageDecider->getIdentifier(); 117 | break; 118 | } 119 | } 120 | 121 | return new CollectedUsage($usage, $excluderName); 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /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/Debug/DebugUsagePrinter.php: -------------------------------------------------------------------------------- 1 | usage info 41 | * 42 | * @var array, eliminationPath?: array>, neverReported?: string}> 43 | */ 44 | private array $debugMembers; 45 | 46 | public function __construct( 47 | Container $container, 48 | OutputEnhancer $outputEnhancer, 49 | ReflectionProvider $reflectionProvider 50 | ) 51 | { 52 | $this->outputEnhancer = $outputEnhancer; 53 | $this->reflectionProvider = $reflectionProvider; 54 | $this->debugMembers = $this->buildDebugMemberKeys( 55 | // @phpstan-ignore offsetAccess.nonOffsetAccessible, offsetAccess.nonOffsetAccessible, missingType.checkedException, argument.type 56 | $container->getParameter('shipmonkDeadCode')['debug']['usagesOf'], // prevents https://github.com/phpstan/phpstan/issues/12740 57 | ); 58 | } 59 | 60 | /** 61 | * @param array>> $mixedMemberUsages 62 | */ 63 | public function printMixedMemberUsages(Output $output, array $mixedMemberUsages): void 64 | { 65 | if ($mixedMemberUsages === [] || !$output->isDebug()) { 66 | return; 67 | } 68 | 69 | $mixedEverythingUsages = []; 70 | $mixedClassNameUsages = []; 71 | 72 | foreach ($mixedMemberUsages as $memberType => $collectedUsagesByMemberName) { 73 | foreach ($collectedUsagesByMemberName as $memberName => $collectedUsages) { 74 | foreach ($collectedUsages as $collectedUsage) { 75 | if ($collectedUsage->isExcluded()) { 76 | continue; 77 | } 78 | 79 | if ($memberName === self::ANY_MEMBER) { 80 | $mixedEverythingUsages[$memberType][] = $collectedUsage; 81 | } else { 82 | $mixedClassNameUsages[$memberType][$memberName][] = $collectedUsage; 83 | } 84 | } 85 | } 86 | } 87 | 88 | $this->printMixedEverythingUsages($output, $mixedEverythingUsages); 89 | $this->printMixedClassNameUsages($output, $mixedClassNameUsages); 90 | } 91 | 92 | /** 93 | * @param array>> $mixedMemberUsages 94 | */ 95 | private function printMixedClassNameUsages(Output $output, array $mixedMemberUsages): void 96 | { 97 | $totalCount = array_sum(array_map('count', $mixedMemberUsages)); 98 | 99 | if ($totalCount === 0) { 100 | return; 101 | } 102 | 103 | $maxExamplesToShow = 20; 104 | $examplesShown = 0; 105 | $plural = $totalCount > 1 ? 's' : ''; 106 | $output->writeLineFormatted(sprintf('Found %d usage%s over unknown type:', $totalCount, $plural)); 107 | 108 | foreach ($mixedMemberUsages as $memberType => $collectedUsages) { 109 | foreach ($collectedUsages as $memberName => $usages) { 110 | $examplesShown++; 111 | $memberAccessString = $memberType === MemberType::METHOD ? 'method' : 'constant'; 112 | $output->writeFormatted(sprintf(' • %s %s', $memberName, $memberAccessString)); 113 | 114 | $exampleCaller = $this->getExampleCaller($usages); 115 | $output->writeFormatted(sprintf(', for example in %s', $exampleCaller)); 116 | 117 | $output->writeLineFormatted(''); 118 | 119 | if ($examplesShown >= $maxExamplesToShow) { 120 | break 2; 121 | } 122 | } 123 | } 124 | 125 | if ($totalCount > $maxExamplesToShow) { 126 | $output->writeLineFormatted(sprintf('... and %d more', $totalCount - $maxExamplesToShow)); 127 | } 128 | 129 | $output->writeLineFormatted(''); 130 | $output->writeLineFormatted('Thus, any member named the same is considered used, no matter its declaring class!'); 131 | $output->writeLineFormatted(''); 132 | } 133 | 134 | /** 135 | * @param array> $fullyMixedUsages 136 | */ 137 | private function printMixedEverythingUsages(Output $output, array $fullyMixedUsages): void 138 | { 139 | if ($fullyMixedUsages === []) { 140 | return; 141 | } 142 | 143 | foreach ($fullyMixedUsages as $memberType => $collectedUsages) { 144 | $fullyMixedCount = count($collectedUsages); 145 | 146 | $memberTypeString = $memberType === MemberType::METHOD ? 'method' : 'constant'; 147 | $memberAccessString = $memberType === MemberType::METHOD ? 'call' : 'fetch'; 148 | $fullyMixedPlural = $fullyMixedCount > 1 ? ($memberType === MemberType::METHOD ? 's' : 'es') : ''; 149 | $output->writeLineFormatted(sprintf('Found %d UNKNOWN %s%s over UNKNOWN type!!', $fullyMixedCount, $memberAccessString, $fullyMixedPlural)); 150 | 151 | foreach ($collectedUsages as $usages) { 152 | $output->writeLineFormatted( 153 | sprintf( 154 | ' • %s in %s', 155 | $memberType === MemberType::METHOD ? 'method call' : 'constant fetch', 156 | $this->getExampleCaller([$usages]), 157 | ), 158 | ); 159 | } 160 | 161 | $output->writeLineFormatted(''); 162 | $output->writeLineFormatted(sprintf( 163 | 'Such usages basically break whole dead code analysis, because any %s on any class can be %sed there!', 164 | $memberTypeString, 165 | $memberAccessString, 166 | )); 167 | $output->writeLineFormatted('All those usages were ignored!'); 168 | $output->writeLineFormatted(''); 169 | } 170 | } 171 | 172 | /** 173 | * @param non-empty-list $usages 174 | */ 175 | private function getExampleCaller(array $usages): string 176 | { 177 | foreach ($usages as $usage) { 178 | $origin = $usage->getUsage()->getOrigin(); 179 | 180 | if ($origin->getFile() !== null) { 181 | return $this->outputEnhancer->getOriginReference($origin); 182 | } 183 | } 184 | 185 | foreach ($usages as $usage) { 186 | $origin = $usage->getUsage()->getOrigin(); 187 | return $this->outputEnhancer->getOriginReference($origin); // show virtual usages only as last resort 188 | } 189 | } 190 | 191 | /** 192 | * @param array $analysedClasses 193 | */ 194 | public function printDebugMemberUsages(Output $output, array $analysedClasses): void 195 | { 196 | if ($this->debugMembers === [] || !$output->isDebug()) { 197 | return; 198 | } 199 | 200 | $output->writeLineFormatted("\nUsage debugging information:"); 201 | 202 | foreach ($this->debugMembers as $memberKey => $debugMember) { 203 | $typeName = $debugMember['typename']; 204 | 205 | $output->writeLineFormatted(sprintf("\n%s", $this->prettyMemberKey($memberKey))); 206 | 207 | if (isset($debugMember['eliminationPath'])) { 208 | $output->writeLineFormatted("|\n| Marked as alive at:"); 209 | $depth = 1; 210 | 211 | foreach ($debugMember['eliminationPath'] as $fragmentKey => $fragmentUsages) { 212 | if ($depth === 1) { 213 | $entrypoint = $this->outputEnhancer->getOriginReference($fragmentUsages[0]->getOrigin(), false); 214 | $output->writeLineFormatted(sprintf('| entry %s', $entrypoint)); 215 | } 216 | 217 | $indent = str_repeat(' ', $depth) . 'calls '; 218 | 219 | $nextFragmentUsages = next($debugMember['eliminationPath']); 220 | $nextFragmentFirstUsage = $nextFragmentUsages !== false ? reset($nextFragmentUsages) : null; 221 | $nextFragmentFirstUsageOrigin = $nextFragmentFirstUsage instanceof ClassMemberUsage ? $nextFragmentFirstUsage->getOrigin() : null; 222 | 223 | $pathFragment = $nextFragmentFirstUsageOrigin === null 224 | ? $this->prettyMemberKey($fragmentKey) 225 | : $this->outputEnhancer->getOriginLink($nextFragmentFirstUsageOrigin, $this->prettyMemberKey($fragmentKey)); 226 | 227 | $output->writeLineFormatted(sprintf('| %s%s', $indent, $pathFragment)); 228 | 229 | $depth++; 230 | } 231 | } elseif (!isset($analysedClasses[$typeName])) { 232 | $output->writeLineFormatted("|\n| Not defined within analysed files!"); 233 | 234 | } elseif (isset($debugMember['usages'])) { 235 | $output->writeLineFormatted("|\n| Dead because:"); 236 | 237 | if ($this->allUsagesExcluded($debugMember['usages'])) { 238 | $output->writeLineFormatted('| all usages are excluded'); 239 | } else { 240 | $output->writeLineFormatted('| all usages originate in unused code'); 241 | } 242 | } 243 | 244 | if (isset($debugMember['usages'])) { 245 | $plural = count($debugMember['usages']) > 1 ? 's' : ''; 246 | $output->writeLineFormatted(sprintf("|\n| Found %d usage%s:", count($debugMember['usages']), $plural)); 247 | 248 | foreach ($debugMember['usages'] as $collectedUsage) { 249 | $origin = $collectedUsage->getUsage()->getOrigin(); 250 | $output->writeFormatted(sprintf('| • %s', $this->outputEnhancer->getOriginReference($origin))); 251 | 252 | if ($collectedUsage->isExcluded()) { 253 | $output->writeFormatted(sprintf(' - excluded by %s excluder', $collectedUsage->getExcludedBy())); 254 | } 255 | 256 | $output->writeLineFormatted(''); 257 | } 258 | } elseif (isset($debugMember['neverReported'])) { 259 | $output->writeLineFormatted(sprintf("|\n| Is never reported as dead: %s", $debugMember['neverReported'])); 260 | } else { 261 | $output->writeLineFormatted("|\n| No usages found"); 262 | } 263 | 264 | $output->writeLineFormatted(''); 265 | } 266 | } 267 | 268 | private function prettyMemberKey(string $memberKey): string 269 | { 270 | $replaced = preg_replace('/^(m|c)\//', '', $memberKey); 271 | 272 | if ($replaced === null) { 273 | throw new LogicException('Failed to pretty member key ' . $memberKey); 274 | } 275 | 276 | return $replaced; 277 | } 278 | 279 | /** 280 | * @param list $alternativeKeys 281 | */ 282 | public function recordUsage(CollectedUsage $collectedUsage, array $alternativeKeys): void 283 | { 284 | $memberKeys = array_unique([ 285 | $collectedUsage->getUsage()->getMemberRef()->toKey(), 286 | ...$alternativeKeys, 287 | ]); 288 | 289 | foreach ($memberKeys as $memberKey) { 290 | if (!isset($this->debugMembers[$memberKey])) { 291 | continue; 292 | } 293 | 294 | $this->debugMembers[$memberKey]['usages'][] = $collectedUsage; 295 | } 296 | } 297 | 298 | /** 299 | * @param array> $eliminationPath 300 | */ 301 | public function markMemberAsWhite(BlackMember $blackMember, array $eliminationPath): void 302 | { 303 | $memberKey = $blackMember->getMember()->toKey(); 304 | 305 | if (!isset($this->debugMembers[$memberKey])) { 306 | return; 307 | } 308 | 309 | $this->debugMembers[$memberKey]['eliminationPath'] = $eliminationPath; 310 | } 311 | 312 | public function markMemberAsNeverReported(BlackMember $blackMember, string $reason): void 313 | { 314 | $memberKey = $blackMember->getMember()->toKey(); 315 | 316 | if (!isset($this->debugMembers[$memberKey])) { 317 | return; 318 | } 319 | 320 | $this->debugMembers[$memberKey]['neverReported'] = $reason; 321 | } 322 | 323 | /** 324 | * @param list $debugMembers 325 | * @return array, eliminationPath?: array>, neverReported?: string}> 326 | */ 327 | private function buildDebugMemberKeys(array $debugMembers): array 328 | { 329 | $result = []; 330 | 331 | foreach ($debugMembers as $debugMember) { 332 | if (strpos($debugMember, '::') === false) { 333 | throw new LogicException("Invalid debug member format: '$debugMember', expected 'ClassName::memberName'"); 334 | } 335 | 336 | [$class, $memberName] = explode('::', $debugMember); // @phpstan-ignore offsetAccess.notFound 337 | $normalizedClass = ltrim($class, '\\'); 338 | 339 | if (!$this->reflectionProvider->hasClass($normalizedClass)) { 340 | throw new LogicException("Class '$normalizedClass' does not exist"); 341 | } 342 | 343 | $classReflection = $this->reflectionProvider->getClass($normalizedClass); 344 | 345 | if (ReflectionHelper::hasOwnMethod($classReflection, $memberName)) { 346 | $key = ClassMethodRef::buildKey($normalizedClass, $memberName); 347 | 348 | } elseif (ReflectionHelper::hasOwnConstant($classReflection, $memberName)) { 349 | $key = ClassConstantRef::buildKey($normalizedClass, $memberName); 350 | 351 | } elseif (ReflectionHelper::hasOwnProperty($classReflection, $memberName)) { 352 | throw new LogicException("Cannot debug '$debugMember', properties are not supported yet"); 353 | 354 | } else { 355 | throw new LogicException("Member '$memberName' does not exist directly in '$normalizedClass'"); 356 | } 357 | 358 | $result[$key] = [ 359 | 'typename' => $normalizedClass, 360 | ]; 361 | } 362 | 363 | return $result; 364 | } 365 | 366 | /** 367 | * @param list $collectedUsages 368 | */ 369 | private function allUsagesExcluded(array $collectedUsages): bool 370 | { 371 | foreach ($collectedUsages as $collectedUsage) { 372 | if (!$collectedUsage->isExcluded()) { 373 | return false; 374 | } 375 | } 376 | 377 | return true; 378 | } 379 | 380 | } 381 | -------------------------------------------------------------------------------- /src/Enum/ClassLikeKind.php: -------------------------------------------------------------------------------- 1 | > 26 | */ 27 | private array $excludedUsages = []; 28 | 29 | public function __construct( 30 | ClassMemberRef $member, 31 | string $file, 32 | int $line 33 | ) 34 | { 35 | if ($member->getClassName() === null) { 36 | throw new LogicException('Class name must be known'); 37 | } 38 | 39 | if ($member->getMemberName() === null) { 40 | throw new LogicException('Member name must be known'); 41 | } 42 | 43 | if ($member->isPossibleDescendant()) { 44 | throw new LogicException('Using possible descendant does not make sense here'); 45 | } 46 | 47 | $this->member = $member; 48 | $this->file = $file; 49 | $this->line = $line; 50 | } 51 | 52 | public function getMember(): ClassMemberRef 53 | { 54 | return $this->member; 55 | } 56 | 57 | public function getFile(): string 58 | { 59 | return $this->file; 60 | } 61 | 62 | public function getLine(): int 63 | { 64 | return $this->line; 65 | } 66 | 67 | public function addExcludedUsage(CollectedUsage $excludedUsage): void 68 | { 69 | if (!$excludedUsage->isExcluded()) { 70 | throw new LogicException('Given usage is not excluded!'); 71 | } 72 | 73 | $excludedBy = $excludedUsage->getExcludedBy(); 74 | 75 | $this->excludedUsages[$excludedBy][] = $excludedUsage->getUsage(); 76 | } 77 | 78 | public function getErrorIdentifier(): string 79 | { 80 | return $this->member instanceof ClassConstantRef 81 | ? DeadCodeRule::IDENTIFIER_CONSTANT 82 | : DeadCodeRule::IDENTIFIER_METHOD; 83 | } 84 | 85 | public function getExclusionMessage(): string 86 | { 87 | if (count($this->excludedUsages) === 0) { 88 | return ''; 89 | } 90 | 91 | $excluderNames = implode(', ', array_keys($this->excludedUsages)); 92 | $plural = count($this->excludedUsages) > 1 ? 's' : ''; 93 | 94 | return " (all usages excluded by {$excluderNames} excluder{$plural})"; 95 | } 96 | 97 | /** 98 | * @return list 99 | */ 100 | public function getExcludedUsages(): array 101 | { 102 | $result = []; 103 | 104 | foreach ($this->excludedUsages as $usages) { 105 | foreach ($usages as $usage) { 106 | $result[] = $usage; 107 | } 108 | } 109 | 110 | return $result; 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /src/Excluder/MemberUsageExcluder.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled; 17 | } 18 | 19 | public function getIdentifier(): string 20 | { 21 | return 'usageOverMixed'; 22 | } 23 | 24 | public function shouldExclude(ClassMemberUsage $usage, Node $node, Scope $scope): bool 25 | { 26 | if (!$this->enabled) { 27 | return false; 28 | } 29 | 30 | return $usage->getMemberRef()->getClassName() === null; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /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(ClassMemberUsage $usage, Node $node, Scope $scope): bool 66 | { 67 | if (!$this->enabled) { 68 | return false; 69 | } 70 | 71 | return $this->isWithinDevPaths($this->realpath($scope->getFile())) === true 72 | && $this->isWithinDevPaths($this->getDeclarationFile($usage->getMemberRef()->getClassName())) === false; 73 | } 74 | 75 | private function isWithinDevPaths(?string $filePath): ?bool 76 | { 77 | if ($filePath === null) { 78 | return null; 79 | } 80 | 81 | foreach ($this->devPaths as $devPath) { 82 | if (strpos($filePath, $devPath) === 0) { 83 | return true; 84 | } 85 | } 86 | 87 | return false; 88 | } 89 | 90 | private function getDeclarationFile(?string $className): ?string 91 | { 92 | if ($className === null) { 93 | return null; 94 | } 95 | 96 | if (!$this->reflectionProvider->hasClass($className)) { 97 | return null; 98 | } 99 | 100 | $filePath = $this->reflectionProvider->getClass($className)->getFileName(); 101 | 102 | if ($filePath === null) { 103 | return null; 104 | } 105 | 106 | return $this->realpath($filePath); 107 | } 108 | 109 | /** 110 | * @return list 111 | */ 112 | private function autodetectComposerDevPaths(): array 113 | { 114 | $vendorDirs = array_filter(array_keys(ClassLoader::getRegisteredLoaders()), static function (string $vendorDir): bool { 115 | return strpos($vendorDir, 'phar://') === false; 116 | }); 117 | 118 | if (count($vendorDirs) !== 1) { 119 | return []; 120 | } 121 | 122 | $vendorDir = reset($vendorDirs); 123 | $composerJsonPath = $vendorDir . '/../composer.json'; 124 | 125 | $composerJsonData = $this->parseComposerJson($composerJsonPath); 126 | $basePath = dirname($composerJsonPath); 127 | 128 | return [ 129 | ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['psr-0'] ?? []), 130 | ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['psr-4'] ?? []), 131 | ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['files'] ?? []), 132 | ...$this->extractAutoloadPaths($basePath, $composerJsonData['autoload-dev']['classmap'] ?? []), 133 | ]; 134 | } 135 | 136 | /** 137 | * @return array{ 138 | * autoload-dev?: array{ 139 | * psr-0?: array, 140 | * psr-4?: array, 141 | * files?: string[], 142 | * classmap?: string[], 143 | * } 144 | * } 145 | */ 146 | private function parseComposerJson(string $composerJsonPath): array 147 | { 148 | if (!is_file($composerJsonPath)) { 149 | return []; 150 | } 151 | 152 | $composerJsonRawData = file_get_contents($composerJsonPath); 153 | 154 | if ($composerJsonRawData === false) { 155 | return []; 156 | } 157 | 158 | $composerJsonData = json_decode($composerJsonRawData, true); 159 | 160 | $jsonError = json_last_error(); 161 | 162 | if ($jsonError !== JSON_ERROR_NONE) { 163 | return []; 164 | } 165 | 166 | return $composerJsonData; // @phpstan-ignore-line ignore mixed returned 167 | } 168 | 169 | /** 170 | * @param array> $autoload 171 | * @return list 172 | */ 173 | private function extractAutoloadPaths(string $basePath, array $autoload): array 174 | { 175 | $result = []; 176 | 177 | foreach ($autoload as $paths) { 178 | if (!is_array($paths)) { 179 | $paths = [$paths]; // @phpstan-ignore shipmonk.variableTypeOverwritten 180 | } 181 | 182 | foreach ($paths as $path) { 183 | $isAbsolute = preg_match('#([a-z]:)?[/\\\\]#Ai', $path); 184 | 185 | if ($isAbsolute === 1) { 186 | $absolutePath = $path; 187 | } else { 188 | $absolutePath = $basePath . '/' . $path; 189 | } 190 | 191 | if (strpos($path, '*') !== false) { // https://getcomposer.org/doc/04-schema.md#classmap 192 | $globPaths = glob($absolutePath); 193 | 194 | if ($globPaths === false) { 195 | continue; 196 | } 197 | 198 | foreach ($globPaths as $globPath) { 199 | $result[] = $this->realpath($globPath); 200 | } 201 | 202 | continue; 203 | } 204 | 205 | $result[] = $this->realpath($absolutePath); 206 | } 207 | } 208 | 209 | return $result; 210 | } 211 | 212 | private function realpath(string $path): string 213 | { 214 | if (strpos($path, 'phar://') === 0) { 215 | return $path; 216 | } 217 | 218 | $realPath = realpath($path); 219 | 220 | if ($realPath === false) { 221 | throw new LogicException("Unable to realpath '$path'"); 222 | } 223 | 224 | return $realPath; 225 | } 226 | 227 | } 228 | -------------------------------------------------------------------------------- /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 | ) { 59 | continue; 60 | } 61 | 62 | /** @var array}> $metadata */ 63 | $metadata = $fileSpecificError->getMetadata(); 64 | 65 | foreach ($metadata as $memberKey => $data) { 66 | $file = $data['file']; 67 | $type = $data['type']; 68 | $deadMembersByFiles[$file][$type][$memberKey] = $data['excludedUsages']; 69 | } 70 | } 71 | 72 | $membersCount = 0; 73 | $filesCount = count($deadMembersByFiles); 74 | 75 | foreach ($deadMembersByFiles as $file => $deadMembersByType) { 76 | /** @var array> $deadConstants */ 77 | $deadConstants = $deadMembersByType[MemberType::CONSTANT] ?? []; 78 | /** @var array> $deadMethods */ 79 | $deadMethods = $deadMembersByType[MemberType::METHOD] ?? []; 80 | 81 | $membersCount += count($deadConstants) + count($deadMethods); 82 | 83 | $transformer = new RemoveDeadCodeTransformer(array_keys($deadMethods), array_keys($deadConstants)); 84 | $oldCode = $this->fileSystem->read($file); 85 | $newCode = $transformer->transformCode($oldCode); 86 | $this->fileSystem->write($file, $newCode); 87 | 88 | foreach ($deadConstants as $constant => $excludedUsages) { 89 | $output->writeLineFormatted(" • Removed constant $constant"); 90 | $this->printExcludedUsages($output, $excludedUsages); 91 | } 92 | 93 | foreach ($deadMethods as $method => $excludedUsages) { 94 | $output->writeLineFormatted(" • Removed method $method"); 95 | $this->printExcludedUsages($output, $excludedUsages); 96 | } 97 | } 98 | 99 | $memberPlural = $membersCount === 1 ? '' : 's'; 100 | $filePlural = $filesCount === 1 ? '' : 's'; 101 | 102 | $output->writeLineFormatted(''); 103 | $output->writeLineFormatted("Removed $membersCount dead member$memberPlural in $filesCount file$filePlural."); 104 | 105 | return 0; 106 | } 107 | 108 | /** 109 | * @param list $excludedUsages 110 | */ 111 | private function printExcludedUsages(Output $output, array $excludedUsages): void 112 | { 113 | foreach ($excludedUsages as $excludedUsage) { 114 | $originLink = $this->getOriginLink($excludedUsage->getOrigin()); 115 | 116 | if ($originLink === null) { 117 | continue; 118 | } 119 | 120 | $output->writeLineFormatted(" ! Excluded usage at {$originLink} left intact"); 121 | } 122 | } 123 | 124 | private function getOriginLink(UsageOrigin $origin): ?string 125 | { 126 | if ($origin->getFile() === null || $origin->getLine() === null) { 127 | return null; 128 | } 129 | 130 | return $this->outputEnhancer->getOriginReference($origin); 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /src/Graph/ClassConstantRef.php: -------------------------------------------------------------------------------- 1 | fetch = $fetch; 27 | } 28 | 29 | /** 30 | * @return MemberType::CONSTANT 31 | */ 32 | public function getMemberType(): int 33 | { 34 | return MemberType::CONSTANT; 35 | } 36 | 37 | public function getMemberRef(): ClassConstantRef 38 | { 39 | return $this->fetch; 40 | } 41 | 42 | public function concretizeMixedClassNameUsage(string $className): self 43 | { 44 | if ($this->fetch->getClassName() !== null) { 45 | throw new LogicException('Usage is not mixed, thus it cannot be concretized'); 46 | } 47 | 48 | return new self( 49 | $this->getOrigin(), 50 | new ClassConstantRef( 51 | $className, 52 | $this->fetch->getMemberName(), 53 | $this->fetch->isPossibleDescendant(), 54 | ), 55 | ); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/Graph/ClassMemberRef.php: -------------------------------------------------------------------------------- 1 | method() 23 | * @param string|null $memberName Null if member name is unknown, e.g. unknown method like $class->$unknown() 24 | */ 25 | public function __construct( 26 | ?string $className, 27 | ?string $memberName, 28 | bool $possibleDescendant 29 | ) 30 | { 31 | $this->className = $className; 32 | $this->memberName = $memberName; 33 | $this->possibleDescendant = $possibleDescendant; 34 | } 35 | 36 | public function getClassName(): ?string 37 | { 38 | return $this->className; 39 | } 40 | 41 | public function getMemberName(): ?string 42 | { 43 | return $this->memberName; 44 | } 45 | 46 | public function isPossibleDescendant(): bool 47 | { 48 | return $this->possibleDescendant; 49 | } 50 | 51 | public function toHumanString(): string 52 | { 53 | $classRef = $this->className ?? self::UNKNOWN_CLASS; 54 | $memberRef = $this->memberName ?? self::UNKNOWN_CLASS; 55 | return $classRef . '::' . $memberRef; 56 | } 57 | 58 | public function toKey(): string 59 | { 60 | $classRef = $this->className ?? self::UNKNOWN_CLASS; 61 | $memberRef = $this->memberName ?? self::UNKNOWN_CLASS; 62 | return static::buildKey($classRef, $memberRef); 63 | } 64 | 65 | abstract public static function buildKey(string $typeName, string $memberName): string; 66 | 67 | /** 68 | * @return MemberType::* 69 | */ 70 | abstract public function getMemberType(): int; 71 | 72 | } 73 | -------------------------------------------------------------------------------- /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 | abstract public function getMemberRef(): ClassMemberRef; 35 | 36 | /** 37 | * @return static 38 | */ 39 | abstract public function concretizeMixedClassNameUsage(string $className): self; 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/Graph/ClassMethodRef.php: -------------------------------------------------------------------------------- 1 | callee = $callee; 28 | } 29 | 30 | /** 31 | * @return MemberType::METHOD 32 | */ 33 | public function getMemberType(): int 34 | { 35 | return MemberType::METHOD; 36 | } 37 | 38 | public function getMemberRef(): ClassMethodRef 39 | { 40 | return $this->callee; 41 | } 42 | 43 | public function concretizeMixedClassNameUsage(string $className): self 44 | { 45 | if ($this->callee->getClassName() !== null) { 46 | throw new LogicException('Usage is not mixed, thus it cannot be concretized'); 47 | } 48 | 49 | return new self( 50 | $this->getOrigin(), 51 | new ClassMethodRef( 52 | $className, 53 | $this->callee->getMemberName(), 54 | $this->callee->isPossibleDescendant(), 55 | ), 56 | ); 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/Graph/CollectedUsage.php: -------------------------------------------------------------------------------- 1 | usage = $usage; 25 | $this->excludedBy = $excludedBy; 26 | } 27 | 28 | public function getUsage(): ClassMemberUsage 29 | { 30 | return $this->usage; 31 | } 32 | 33 | public function isExcluded(): bool 34 | { 35 | return $this->excludedBy !== null; 36 | } 37 | 38 | public function getExcludedBy(): string 39 | { 40 | if ($this->excludedBy === null) { 41 | throw new LogicException('Usage is not excluded, use isExcluded() before calling this method'); 42 | } 43 | 44 | return $this->excludedBy; 45 | } 46 | 47 | public function concretizeMixedClassNameUsage(string $className): self 48 | { 49 | return new self( 50 | $this->usage->concretizeMixedClassNameUsage($className), 51 | $this->excludedBy, 52 | ); 53 | } 54 | 55 | /** 56 | * Scope file is passed to optimize transferred data size (and thus result cache size) 57 | * - PHPStan itself transfers all collector data along with scope file 58 | * - thus if our data match those already-transferred ones, lets omit those 59 | * 60 | * @see https://github.com/phpstan/phpstan-src/blob/2fe4e0f94e75fe8844a21fdb81799f01f0591dfe/src/Analyser/FileAnalyser.php#L198 61 | */ 62 | public function serialize(string $scopeFile): string 63 | { 64 | $origin = $this->usage->getOrigin(); 65 | $memberRef = $this->usage->getMemberRef(); 66 | 67 | $data = [ 68 | 'e' => $this->excludedBy, 69 | 't' => $this->usage->getMemberType(), 70 | 'o' => [ 71 | 'c' => $origin->getClassName(), 72 | 'm' => $origin->getMethodName(), 73 | 'f' => $origin->getFile() === $scopeFile ? '_' : $origin->getFile(), 74 | 'l' => $origin->getLine(), 75 | 'p' => $origin->getProvider(), 76 | 'n' => $origin->getNote(), 77 | ], 78 | 'm' => [ 79 | 'c' => $memberRef->getClassName(), 80 | 'm' => $memberRef->getMemberName(), 81 | 'd' => $memberRef->isPossibleDescendant(), 82 | ], 83 | ]; 84 | 85 | try { 86 | return json_encode($data, JSON_THROW_ON_ERROR); 87 | } catch (JsonException $e) { 88 | throw new LogicException('Serialization failure: ' . $e->getMessage(), 0, $e); 89 | } 90 | } 91 | 92 | public static function deserialize(string $data, string $scopeFile): self 93 | { 94 | try { 95 | /** @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 */ 96 | $result = json_decode($data, true, 3, JSON_THROW_ON_ERROR); 97 | } catch (JsonException $e) { 98 | throw new LogicException('Deserialization failure: ' . $e->getMessage(), 0, $e); 99 | } 100 | 101 | $memberType = $result['t']; 102 | $origin = new UsageOrigin( 103 | $result['o']['c'], 104 | $result['o']['m'], 105 | $result['o']['f'] === '_' ? $scopeFile : $result['o']['f'], 106 | $result['o']['l'], 107 | $result['o']['p'], 108 | $result['o']['n'], 109 | ); 110 | $exclusionReason = $result['e']; 111 | 112 | $usage = $memberType === MemberType::CONSTANT 113 | ? new ClassConstantUsage( 114 | $origin, 115 | new ClassConstantRef($result['m']['c'], $result['m']['m'], $result['m']['d']), 116 | ) 117 | : new ClassMethodUsage( 118 | $origin, 119 | new ClassMethodRef($result['m']['c'], $result['m']['m'], $result['m']['d']), 120 | ); 121 | 122 | return new self($usage, $exclusionReason); 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /src/Graph/UsageOrigin.php: -------------------------------------------------------------------------------- 1 | className = $className; 44 | $this->methodName = $methodName; 45 | $this->fileName = $fileName; 46 | $this->line = $line; 47 | $this->provider = $provider; 48 | $this->note = $note; 49 | } 50 | 51 | /** 52 | * Creates virtual usage origin with no reference to any place in code 53 | */ 54 | public static function createVirtual(MemberUsageProvider $provider, VirtualUsageData $data): self 55 | { 56 | return new self( 57 | null, 58 | null, 59 | null, 60 | null, 61 | get_class($provider), 62 | $data->getNote(), 63 | ); 64 | } 65 | 66 | /** 67 | * Creates usage origin with reference to file:line 68 | */ 69 | public static function createRegular(Node $node, Scope $scope): self 70 | { 71 | $file = $scope->isInTrait() 72 | ? $scope->getTraitReflection()->getFileName() 73 | : $scope->getFile(); 74 | 75 | if (!$scope->isInClass() || !$scope->getFunction() instanceof MethodReflection) { 76 | return new self( 77 | null, 78 | null, 79 | $file, 80 | $node->getStartLine(), 81 | null, 82 | null, 83 | ); 84 | } 85 | 86 | return new self( 87 | $scope->getClassReflection()->getName(), 88 | $scope->getFunction()->getName(), 89 | $file, 90 | $node->getStartLine(), 91 | null, 92 | null, 93 | ); 94 | } 95 | 96 | public function getClassName(): ?string 97 | { 98 | return $this->className; 99 | } 100 | 101 | public function getMethodName(): ?string 102 | { 103 | return $this->methodName; 104 | } 105 | 106 | public function getFile(): ?string 107 | { 108 | return $this->fileName; 109 | } 110 | 111 | public function getLine(): ?int 112 | { 113 | return $this->line; 114 | } 115 | 116 | public function getProvider(): ?string 117 | { 118 | return $this->provider; 119 | } 120 | 121 | public function getNote(): ?string 122 | { 123 | return $this->note; 124 | } 125 | 126 | public function hasClassMethodRef(): bool 127 | { 128 | return $this->className !== null && $this->methodName !== null; 129 | } 130 | 131 | public function toClassMethodRef(): ClassMethodRef 132 | { 133 | if ($this->className === null || $this->methodName === null) { 134 | throw new LogicException('Usage origin does not have class method ref'); 135 | } 136 | 137 | return new ClassMethodRef( 138 | $this->className, 139 | $this->methodName, 140 | false, 141 | ); 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /src/Hierarchy/ClassHierarchy.php: -------------------------------------------------------------------------------- 1 | childrenClassName[] 12 | * 13 | * @var array> 14 | */ 15 | private array $classDescendants = []; 16 | 17 | public function registerClassPair(string $ancestorName, string $descendantName): void 18 | { 19 | $this->classDescendants[$ancestorName][$descendantName] = true; 20 | } 21 | 22 | /** 23 | * @return list 24 | */ 25 | public function getClassDescendants(string $className): array 26 | { 27 | return isset($this->classDescendants[$className]) 28 | ? array_keys($this->classDescendants[$className]) 29 | : []; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/Output/OutputEnhancer.php: -------------------------------------------------------------------------------- 1 | relativePathHelper = $relativePathHelper; 24 | $this->editorUrl = $editorUrl; 25 | } 26 | 27 | public function getOriginLink(UsageOrigin $origin, string $title): string 28 | { 29 | $file = $origin->getFile(); 30 | $line = $origin->getLine(); 31 | 32 | if ($line !== null) { 33 | $title = sprintf('%s:%s', $title, $line); 34 | } 35 | 36 | if ($file !== null && $line !== null) { 37 | return $this->getLinkOrPlain($title, $file, $line); 38 | } 39 | 40 | return $title; 41 | } 42 | 43 | public function getOriginReference(UsageOrigin $origin, bool $preferFileLine = true): string 44 | { 45 | $file = $origin->getFile(); 46 | $line = $origin->getLine(); 47 | 48 | if ($file !== null && $line !== null) { 49 | $relativeFile = $this->relativePathHelper->getRelativePath($file); 50 | 51 | $title = $origin->getClassName() !== null && $origin->getMethodName() !== null && !$preferFileLine 52 | ? sprintf('%s::%s:%d', $origin->getClassName(), $origin->getMethodName(), $line) 53 | : sprintf('%s:%s', $relativeFile, $line); 54 | 55 | return $this->getLinkOrPlain($title, $file, $line); 56 | } 57 | 58 | if ($origin->getProvider() !== null) { 59 | $note = $origin->getNote() !== null ? " ({$origin->getNote()})" : ''; 60 | return 'virtual usage from ' . $origin->getProvider() . $note; 61 | } 62 | 63 | throw new LogicException('Unknown state of usage origin'); 64 | } 65 | 66 | private function getLinkOrPlain(string $title, string $file, int $line): string 67 | { 68 | if ($this->editorUrl === null) { 69 | return $title; 70 | } 71 | 72 | $relativeFile = $this->relativePathHelper->getRelativePath($file); 73 | 74 | return sprintf( 75 | '%s', 76 | str_replace(['%file%', '%relFile%', '%line%'], [$file, $relativeFile, (string) $line], $this->editorUrl), 77 | $title, 78 | ); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/Provider/ApiPhpDocUsageProvider.php: -------------------------------------------------------------------------------- 1 | reflectionProvider = $reflectionProvider; 25 | $this->enabled = $enabled; 26 | } 27 | 28 | public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 29 | { 30 | return $this->enabled ? $this->shouldMarkMemberAsUsed($method) : null; 31 | } 32 | 33 | public function shouldMarkConstantAsUsed(ReflectionClassConstant $constant): ?VirtualUsageData 34 | { 35 | return $this->enabled ? $this->shouldMarkMemberAsUsed($constant) : null; 36 | } 37 | 38 | /** 39 | * @param ReflectionClassConstant|ReflectionMethod $member 40 | */ 41 | public function shouldMarkMemberAsUsed(object $member): ?VirtualUsageData 42 | { 43 | $reflectionClass = $this->reflectionProvider->getClass($member->getDeclaringClass()->getName()); 44 | $memberType = $member instanceof ReflectionClassConstant ? 'constant' : 'method'; 45 | $memberName = $member->getName(); 46 | 47 | if ($this->isApiMember($reflectionClass, $member)) { 48 | return VirtualUsageData::withNote("Class {$reflectionClass->getName()} is public @api"); 49 | } 50 | 51 | do { 52 | foreach ($reflectionClass->getInterfaces() as $interface) { 53 | if ($this->isApiMember($interface, $member)) { 54 | return VirtualUsageData::withNote("Interface $memberType {$interface->getName()}::{$memberName} is public @api"); 55 | } 56 | } 57 | 58 | foreach ($reflectionClass->getParents() as $parent) { 59 | if ($this->isApiMember($parent, $member)) { 60 | return VirtualUsageData::withNote("Class $memberType {$parent->getName()}::{$memberName} is public @api"); 61 | } 62 | } 63 | 64 | $reflectionClass = $reflectionClass->getParentClass(); 65 | } while ($reflectionClass !== null); 66 | 67 | return null; 68 | } 69 | 70 | /** 71 | * @param ReflectionClassConstant|ReflectionMethod $member 72 | */ 73 | private function isApiMember(ClassReflection $reflection, object $member): bool 74 | { 75 | if (!$this->hasOwnMember($reflection, $member)) { 76 | return false; 77 | } 78 | 79 | if ($this->isApiClass($reflection)) { 80 | return true; 81 | } 82 | 83 | if ($member instanceof ReflectionClassConstant) { 84 | $constant = $reflection->getConstant($member->getName()); 85 | $phpDoc = $constant->getDocComment(); 86 | 87 | if ($this->isApiPhpDoc($phpDoc)) { 88 | return true; 89 | } 90 | 91 | return false; 92 | } 93 | 94 | $phpDoc = $reflection->getNativeMethod($member->getName())->getDocComment(); 95 | 96 | if ($this->isApiPhpDoc($phpDoc)) { 97 | return true; 98 | } 99 | 100 | return false; 101 | } 102 | 103 | /** 104 | * @param ReflectionClassConstant|ReflectionMethod $member 105 | */ 106 | private function hasOwnMember(ClassReflection $reflection, object $member): bool 107 | { 108 | if ($member instanceof ReflectionClassConstant) { 109 | return ReflectionHelper::hasOwnConstant($reflection, $member->getName()); 110 | } 111 | 112 | return ReflectionHelper::hasOwnMethod($reflection, $member->getName()); 113 | } 114 | 115 | private function isApiClass(ClassReflection $reflection): bool 116 | { 117 | $phpDoc = $reflection->getResolvedPhpDoc(); 118 | 119 | if ($phpDoc === null) { 120 | return false; 121 | } 122 | 123 | if ($this->isApiPhpDoc($phpDoc->getPhpDocString())) { 124 | return true; 125 | } 126 | 127 | return false; 128 | } 129 | 130 | private function isApiPhpDoc(?string $phpDoc): bool 131 | { 132 | return $phpDoc !== null && strpos($phpDoc, '@api') !== false; 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /src/Provider/DoctrineUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled ?? $this->isDoctrineInstalled(); 26 | } 27 | 28 | public function getUsages(Node $node, Scope $scope): array 29 | { 30 | if (!$this->enabled) { 31 | return []; 32 | } 33 | 34 | $usages = []; 35 | 36 | if ($node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 37 | $usages = [ 38 | ...$usages, 39 | ...$this->getUsagesFromReflection($node), 40 | ]; 41 | } 42 | 43 | if ($node instanceof Return_) { 44 | $usages = [ 45 | ...$usages, 46 | ...$this->getUsagesOfEventSubscriber($node, $scope), 47 | ]; 48 | } 49 | 50 | return $usages; 51 | } 52 | 53 | /** 54 | * @return list 55 | */ 56 | private function getUsagesFromReflection(InClassNode $node): array 57 | { 58 | $classReflection = $node->getClassReflection(); 59 | $nativeReflection = $classReflection->getNativeReflection(); 60 | 61 | $usages = []; 62 | 63 | foreach ($nativeReflection->getMethods() as $method) { 64 | if ($method->getDeclaringClass()->getName() !== $nativeReflection->getName()) { 65 | continue; 66 | } 67 | 68 | $usageNote = $this->shouldMarkMethodAsUsed($method); 69 | 70 | if ($usageNote !== null) { 71 | $usages[] = $this->createMethodUsage($classReflection->getNativeMethod($method->getName()), $usageNote); 72 | } 73 | } 74 | 75 | return $usages; 76 | } 77 | 78 | /** 79 | * @return list 80 | */ 81 | private function getUsagesOfEventSubscriber(Return_ $node, Scope $scope): array 82 | { 83 | if ($node->expr === null) { 84 | return []; 85 | } 86 | 87 | if (!$scope->isInClass()) { 88 | return []; 89 | } 90 | 91 | if (!$scope->getFunction() instanceof MethodReflection) { 92 | return []; 93 | } 94 | 95 | if ($scope->getFunction()->getName() !== 'getSubscribedEvents') { 96 | return []; 97 | } 98 | 99 | if (!$scope->getClassReflection()->implementsInterface('Doctrine\Common\EventSubscriber')) { 100 | return []; 101 | } 102 | 103 | $className = $scope->getClassReflection()->getName(); 104 | 105 | $usages = []; 106 | $usageOrigin = UsageOrigin::createRegular($node, $scope); 107 | 108 | foreach ($scope->getType($node->expr)->getConstantArrays() as $rootArray) { 109 | foreach ($rootArray->getValuesArray()->getValueTypes() as $eventConfig) { 110 | foreach ($eventConfig->getConstantStrings() as $subscriberMethodString) { 111 | $usages[] = new ClassMethodUsage( 112 | $usageOrigin, 113 | new ClassMethodRef( 114 | $className, 115 | $subscriberMethodString->getValue(), 116 | true, 117 | ), 118 | ); 119 | } 120 | } 121 | } 122 | 123 | return $usages; 124 | } 125 | 126 | protected function shouldMarkMethodAsUsed(ReflectionMethod $method): ?string 127 | { 128 | $methodName = $method->getName(); 129 | $class = $method->getDeclaringClass(); 130 | 131 | if ($this->isLifecycleEventMethod($method)) { 132 | return 'Lifecycle event method via attribute'; 133 | } 134 | 135 | if ($this->isEntityRepositoryConstructor($class, $method)) { 136 | return 'Entity repository constructor (created by EntityRepositoryFactory)'; 137 | } 138 | 139 | if ($this->isPartOfAsEntityListener($class, $methodName)) { 140 | return 'Is part of AsEntityListener methods'; 141 | } 142 | 143 | if ($this->isProbablyDoctrineListener($methodName)) { 144 | return 'Is probable listener method'; 145 | } 146 | 147 | return null; 148 | } 149 | 150 | protected function isLifecycleEventMethod(ReflectionMethod $method): bool 151 | { 152 | return $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PostLoad') 153 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PostPersist') 154 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PostUpdate') 155 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PreFlush') 156 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PrePersist') 157 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PreRemove') 158 | || $this->hasAttribute($method, 'Doctrine\ORM\Mapping\PreUpdate'); 159 | } 160 | 161 | /** 162 | * Ideally, we would need to parse DIC xml to know this for sure just like phpstan-symfony does. 163 | * - see Doctrine\ORM\Events::* 164 | */ 165 | protected function isProbablyDoctrineListener(string $methodName): bool 166 | { 167 | return $methodName === 'preRemove' 168 | || $methodName === 'postRemove' 169 | || $methodName === 'prePersist' 170 | || $methodName === 'postPersist' 171 | || $methodName === 'preUpdate' 172 | || $methodName === 'postUpdate' 173 | || $methodName === 'postLoad' 174 | || $methodName === 'loadClassMetadata' 175 | || $methodName === 'onClassMetadataNotFound' 176 | || $methodName === 'preFlush' 177 | || $methodName === 'onFlush' 178 | || $methodName === 'postFlush' 179 | || $methodName === 'onClear'; 180 | } 181 | 182 | protected function hasAttribute(ReflectionMethod $method, string $attributeClass): bool 183 | { 184 | return $method->getAttributes($attributeClass) !== []; 185 | } 186 | 187 | protected function isPartOfAsEntityListener(ReflectionClass $class, string $methodName): bool 188 | { 189 | foreach ($class->getAttributes('Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener') as $attribute) { 190 | $listenerMethodName = $attribute->getArguments()['method'] ?? $attribute->getArguments()[1] ?? null; 191 | 192 | if ($listenerMethodName === $methodName) { 193 | return true; 194 | } 195 | } 196 | 197 | return false; 198 | } 199 | 200 | protected function isEntityRepositoryConstructor(ReflectionClass $class, ReflectionMethod $method): bool 201 | { 202 | if (!$method->isConstructor()) { 203 | return false; 204 | } 205 | 206 | return $class->isSubclassOf('Doctrine\ORM\EntityRepository'); 207 | } 208 | 209 | private function isDoctrineInstalled(): bool 210 | { 211 | return InstalledVersions::isInstalled('doctrine/orm') 212 | || InstalledVersions::isInstalled('doctrine/event-manager') 213 | || InstalledVersions::isInstalled('doctrine/doctrine-bundle'); 214 | } 215 | 216 | private function createMethodUsage(ExtendedMethodReflection $methodReflection, string $note): ClassMethodUsage 217 | { 218 | return new ClassMethodUsage( 219 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote($note)), 220 | new ClassMethodRef( 221 | $methodReflection->getDeclaringClass()->getName(), 222 | $methodReflection->getName(), 223 | false, 224 | ), 225 | ); 226 | } 227 | 228 | } 229 | -------------------------------------------------------------------------------- /src/Provider/MemberUsageProvider.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | public function getUsages(Node $node, Scope $scope): array; 27 | 28 | } 29 | -------------------------------------------------------------------------------- /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(ClassReflection $reflection, string $methodName): ?VirtualUsageData 57 | { 58 | if ( 59 | $reflection->is(SignalReceiver::class) 60 | && strpos($methodName, 'handle') === 0 61 | ) { 62 | return VirtualUsageData::withNote('Signal handler method'); 63 | } 64 | 65 | if ( 66 | $reflection->is(Container::class) 67 | && strpos($methodName, 'createComponent') === 0 68 | ) { 69 | return VirtualUsageData::withNote('Component factory method'); 70 | } 71 | 72 | if ( 73 | $reflection->is(Control::class) 74 | && strpos($methodName, 'render') === 0 75 | ) { 76 | return VirtualUsageData::withNote('Render method'); 77 | } 78 | 79 | if ( 80 | $reflection->is(Presenter::class) && strpos($methodName, 'action') === 0 81 | ) { 82 | return VirtualUsageData::withNote('Presenter action method'); 83 | } 84 | 85 | if ( 86 | $reflection->is(Presenter::class) && strpos($methodName, 'inject') === 0 87 | ) { 88 | return VirtualUsageData::withNote('Presenter inject method'); 89 | } 90 | 91 | if ( 92 | $reflection->hasTraitUse(SmartObject::class) 93 | ) { 94 | if (strpos($methodName, 'is') === 0) { 95 | /** @var string $name cannot be false */ 96 | $name = substr($methodName, 2); 97 | 98 | } elseif (strpos($methodName, 'get') === 0 || strpos($methodName, 'set') === 0) { 99 | /** @var string $name cannot be false */ 100 | $name = substr($methodName, 3); 101 | 102 | } else { 103 | $name = null; 104 | } 105 | 106 | if ($name !== null) { 107 | $name = lcfirst($name); 108 | $property = $this->getMagicProperties($reflection)[$name] ?? null; 109 | 110 | if ($property !== null) { 111 | return VirtualUsageData::withNote('Access method for magic property ' . $name); 112 | } 113 | } 114 | } 115 | 116 | return null; 117 | } 118 | 119 | /** 120 | * @return array 121 | * @see ObjectHelpers::getMagicProperties() Modified to use static reflection 122 | */ 123 | private function getMagicProperties(ClassReflection $reflection): array 124 | { 125 | $rc = $reflection->getNativeReflection(); 126 | $class = $rc->getName(); 127 | 128 | if (isset($this->smartObjectCache[$class])) { 129 | return $this->smartObjectCache[$class]; 130 | } 131 | 132 | preg_match_all( 133 | '~^ [ \t*]* @property(|-read|-write|-deprecated) [ \t]+ [^\s$]+ [ \t]+ \$ (\w+) ()~mx', 134 | (string) $rc->getDocComment(), 135 | $matches, 136 | PREG_SET_ORDER, 137 | ); 138 | 139 | $props = []; 140 | 141 | foreach ($matches as [, $type, $name]) { 142 | $uname = ucfirst($name); 143 | $write = $type !== '-read' 144 | && $rc->hasMethod($nm = 'set' . $uname) 145 | && ($rm = $rc->getMethod($nm))->name === $nm && !$rm->isPrivate() && !$rm->isStatic(); // @phpstan-ignore missingType.checkedException 146 | $read = $type !== '-write' 147 | && ($rc->hasMethod($nm = 'get' . $uname) || $rc->hasMethod($nm = 'is' . $uname)) 148 | && ($rm = $rc->getMethod($nm))->name === $nm && !$rm->isPrivate() && !$rm->isStatic(); // @phpstan-ignore missingType.checkedException 149 | 150 | if ($read || $write) { 151 | $props[$name] = true; 152 | } 153 | } 154 | 155 | foreach ($reflection->getTraits() as $trait) { 156 | $props += $this->getMagicProperties($trait); 157 | } 158 | 159 | foreach ($reflection->getParents() as $parent) { 160 | $props += $this->getMagicProperties($parent); 161 | } 162 | 163 | $this->smartObjectCache[$class] = $props; 164 | return $props; 165 | } 166 | 167 | private function isNetteInstalled(): bool 168 | { 169 | return InstalledVersions::isInstalled('nette/application') 170 | || InstalledVersions::isInstalled('nette/component-model') 171 | || InstalledVersions::isInstalled('nette/utils'); 172 | } 173 | 174 | } 175 | -------------------------------------------------------------------------------- /src/Provider/PhpStanUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled; 18 | $this->container = $container; 19 | } 20 | 21 | public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 22 | { 23 | if (!$this->enabled) { 24 | return null; 25 | } 26 | 27 | return $this->isConstructorCallInPhpStanDic($method); 28 | } 29 | 30 | private function isConstructorCallInPhpStanDic(ReflectionMethod $method): ?VirtualUsageData 31 | { 32 | if (!$method->isConstructor()) { 33 | return null; 34 | } 35 | 36 | if ($this->container->findServiceNamesByType($method->getDeclaringClass()->getName()) !== []) { 37 | return VirtualUsageData::withNote('Constructor call from PHPStan DI container'); 38 | } 39 | 40 | return null; 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/Provider/PhpUnitUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled ?? InstalledVersions::isInstalled('phpunit/phpunit'); 33 | $this->lexer = $lexer; 34 | $this->phpDocParser = $phpDocParser; 35 | } 36 | 37 | public function getUsages(Node $node, Scope $scope): array 38 | { 39 | if (!$this->enabled || !$node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 40 | return []; 41 | } 42 | 43 | $classReflection = $node->getClassReflection(); 44 | 45 | if (!$classReflection->is(TestCase::class)) { 46 | return []; 47 | } 48 | 49 | $usages = []; 50 | $className = $classReflection->getName(); 51 | 52 | foreach ($classReflection->getNativeReflection()->getMethods() as $method) { 53 | $methodName = $method->getName(); 54 | 55 | $externalDataProviderMethods = $this->getExternalDataProvidersFromAttributes($method); 56 | $localDataProviderMethods = array_merge( 57 | $this->getDataProvidersFromAnnotations($method->getDocComment()), 58 | $this->getDataProvidersFromAttributes($method), 59 | ); 60 | 61 | foreach ($externalDataProviderMethods as [$externalClassName, $externalMethodName]) { 62 | $usages[] = $this->createUsage($externalClassName, $externalMethodName, "External data provider method, used by $className::$methodName"); 63 | } 64 | 65 | foreach ($localDataProviderMethods as $dataProvider) { 66 | $usages[] = $this->createUsage($className, $dataProvider, "Data provider method, used by $methodName"); 67 | } 68 | 69 | if ($this->isTestCaseMethod($method)) { 70 | $usages[] = $this->createUsage($className, $methodName, 'Test method'); 71 | } 72 | } 73 | 74 | return $usages; 75 | } 76 | 77 | private function isTestCaseMethod(ReflectionMethod $method): bool 78 | { 79 | return strpos($method->getName(), 'test') === 0 80 | || $this->hasAnnotation($method, '@test') 81 | || $this->hasAnnotation($method, '@after') 82 | || $this->hasAnnotation($method, '@afterClass') 83 | || $this->hasAnnotation($method, '@before') 84 | || $this->hasAnnotation($method, '@beforeClass') 85 | || $this->hasAnnotation($method, '@postCondition') 86 | || $this->hasAnnotation($method, '@preCondition') 87 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\Test') 88 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\After') 89 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\AfterClass') 90 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\Before') 91 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\BeforeClass') 92 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\PostCondition') 93 | || $this->hasAttribute($method, 'PHPUnit\Framework\Attributes\PreCondition'); 94 | } 95 | 96 | /** 97 | * @param false|string $rawPhpDoc 98 | * @return list 99 | */ 100 | private function getDataProvidersFromAnnotations($rawPhpDoc): array 101 | { 102 | if ($rawPhpDoc === false || strpos($rawPhpDoc, '@dataProvider') === false) { 103 | return []; 104 | } 105 | 106 | $tokens = new TokenIterator($this->lexer->tokenize($rawPhpDoc)); 107 | $phpDoc = $this->phpDocParser->parse($tokens); 108 | 109 | $result = []; 110 | 111 | foreach ($phpDoc->getTagsByName('@dataProvider') as $tag) { 112 | $result[] = (string) $tag->value; 113 | } 114 | 115 | return $result; 116 | } 117 | 118 | /** 119 | * @return list 120 | */ 121 | private function getDataProvidersFromAttributes(ReflectionMethod $method): array 122 | { 123 | $result = []; 124 | 125 | foreach ($method->getAttributes('PHPUnit\Framework\Attributes\DataProvider') as $providerAttributeReflection) { 126 | $methodName = $providerAttributeReflection->getArguments()[0] ?? $providerAttributeReflection->getArguments()['methodName'] ?? null; 127 | 128 | if (is_string($methodName)) { 129 | $result[] = $methodName; 130 | } 131 | } 132 | 133 | return $result; 134 | } 135 | 136 | /** 137 | * @return list 138 | */ 139 | private function getExternalDataProvidersFromAttributes(ReflectionMethod $method): array 140 | { 141 | $result = []; 142 | 143 | foreach ($method->getAttributes('PHPUnit\Framework\Attributes\DataProviderExternal') as $providerAttributeReflection) { 144 | $className = $providerAttributeReflection->getArguments()[0] ?? $providerAttributeReflection->getArguments()['className'] ?? null; 145 | $methodName = $providerAttributeReflection->getArguments()[1] ?? $providerAttributeReflection->getArguments()['methodName'] ?? null; 146 | 147 | if (is_string($className) && is_string($methodName)) { 148 | $result[] = [$className, $methodName]; 149 | } 150 | } 151 | 152 | return $result; 153 | } 154 | 155 | private function hasAttribute(ReflectionMethod $method, string $attributeClass): bool 156 | { 157 | return $method->getAttributes($attributeClass) !== []; 158 | } 159 | 160 | private function hasAnnotation(ReflectionMethod $method, string $string): bool 161 | { 162 | if ($method->getDocComment() === false) { 163 | return false; 164 | } 165 | 166 | return strpos($method->getDocComment(), $string) !== false; 167 | } 168 | 169 | private function createUsage(string $className, string $methodName, string $reason): ClassMethodUsage 170 | { 171 | return new ClassMethodUsage( 172 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote($reason)), 173 | new ClassMethodRef( 174 | $className, 175 | $methodName, 176 | false, 177 | ), 178 | ); 179 | } 180 | 181 | } 182 | -------------------------------------------------------------------------------- /src/Provider/ReflectionBasedMemberUsageProvider.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | public function getUsages(Node $node, Scope $scope): array 26 | { 27 | if ($node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 28 | $classReflection = $node->getClassReflection(); 29 | 30 | return array_merge( 31 | $this->getMethodUsages($classReflection), 32 | $this->getConstantUsages($classReflection), 33 | ); 34 | } 35 | 36 | return []; 37 | } 38 | 39 | protected function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 40 | { 41 | return null; // Expected to be overridden by subclasses. 42 | } 43 | 44 | protected function shouldMarkConstantAsUsed(ReflectionClassConstant $constant): ?VirtualUsageData 45 | { 46 | return null; // Expected to be overridden by subclasses. 47 | } 48 | 49 | /** 50 | * @return list 51 | */ 52 | private function getMethodUsages(ClassReflection $classReflection): array 53 | { 54 | $nativeClassReflection = $classReflection->getNativeReflection(); 55 | 56 | $usages = []; 57 | 58 | foreach ($nativeClassReflection->getMethods() as $nativeMethodReflection) { 59 | if ($nativeMethodReflection->getDeclaringClass()->getName() !== $nativeClassReflection->getName()) { 60 | continue; // skip methods from ancestors 61 | } 62 | 63 | $usage = $this->shouldMarkMethodAsUsed($nativeMethodReflection); 64 | 65 | if ($usage !== null) { 66 | $usages[] = $this->createMethodUsage($nativeMethodReflection, $usage); 67 | } 68 | } 69 | 70 | return $usages; 71 | } 72 | 73 | /** 74 | * @return list 75 | */ 76 | private function getConstantUsages(ClassReflection $classReflection): array 77 | { 78 | $nativeClassReflection = $classReflection->getNativeReflection(); 79 | 80 | $usages = []; 81 | 82 | foreach ($nativeClassReflection->getReflectionConstants() as $nativeConstantReflection) { 83 | if ($nativeConstantReflection->getDeclaringClass()->getName() !== $nativeClassReflection->getName()) { 84 | continue; // skip constants from ancestors 85 | } 86 | 87 | $usage = $this->shouldMarkConstantAsUsed($nativeConstantReflection); 88 | 89 | if ($usage !== null) { 90 | $usages[] = $this->createConstantUsage($nativeConstantReflection, $usage); 91 | } 92 | } 93 | 94 | return $usages; 95 | } 96 | 97 | private function createConstantUsage(ReflectionClassConstant $constantReflection, VirtualUsageData $data): ClassConstantUsage 98 | { 99 | return new ClassConstantUsage( 100 | UsageOrigin::createVirtual($this, $data), 101 | new ClassConstantRef( 102 | $constantReflection->getDeclaringClass()->getName(), 103 | $constantReflection->getName(), 104 | false, 105 | ), 106 | ); 107 | } 108 | 109 | private function createMethodUsage(ReflectionMethod $methodReflection, VirtualUsageData $data): ClassMethodUsage 110 | { 111 | return new ClassMethodUsage( 112 | UsageOrigin::createVirtual($this, $data), 113 | new ClassMethodRef( 114 | $methodReflection->getDeclaringClass()->getName(), 115 | $methodReflection->getName(), 116 | false, 117 | ), 118 | ); 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /src/Provider/ReflectionUsageProvider.php: -------------------------------------------------------------------------------- 1 | enabled = $enabled; 35 | } 36 | 37 | public function getUsages(Node $node, Scope $scope): array 38 | { 39 | if (!$this->enabled) { 40 | return []; 41 | } 42 | 43 | if ($node instanceof MethodCall) { 44 | return $this->processMethodCall($node, $scope); 45 | } 46 | 47 | return []; 48 | } 49 | 50 | /** 51 | * @return list 52 | */ 53 | private function processMethodCall(MethodCall $node, Scope $scope): array 54 | { 55 | $callerType = $scope->getType($node->var); 56 | $methodNames = $this->getMethodNames($node, $scope); 57 | 58 | $usedConstants = []; 59 | $usedMethods = []; 60 | 61 | foreach ($methodNames as $methodName) { 62 | foreach ($callerType->getObjectClassReflections() as $reflection) { 63 | if (!$reflection->is(ReflectionClass::class)) { 64 | continue; 65 | } 66 | 67 | // ideally, we should check if T is covariant (marks children as used) or invariant (should not mark children as used) 68 | // the default changed in PHP 8.4, see: https://github.com/phpstan/phpstan/issues/12459#issuecomment-2607123277 69 | foreach ($reflection->getActiveTemplateTypeMap()->getTypes() as $genericType) { 70 | $genericClassNames = $genericType->getObjectClassNames() === [] 71 | ? [null] // call over ReflectionClass without specifying the generic type 72 | : $genericType->getObjectClassNames(); 73 | 74 | foreach ($genericClassNames as $genericClassName) { 75 | $usedConstants = [ 76 | ...$usedConstants, 77 | ...$this->extractConstantsUsedByReflection($genericClassName, $methodName, $node->getArgs(), $node, $scope), 78 | ]; 79 | $usedMethods = [ 80 | ...$usedMethods, 81 | ...$this->extractMethodsUsedByReflection($genericClassName, $methodName, $node->getArgs(), $node, $scope), 82 | ]; 83 | } 84 | } 85 | } 86 | } 87 | 88 | return [ 89 | ...$usedConstants, 90 | ...$usedMethods, 91 | ]; 92 | } 93 | 94 | /** 95 | * @param array $args 96 | * @return list 97 | */ 98 | private function extractConstantsUsedByReflection( 99 | ?string $genericClassName, 100 | string $methodName, 101 | array $args, 102 | Node $node, 103 | Scope $scope 104 | ): array 105 | { 106 | $usedConstants = []; 107 | 108 | if ($methodName === 'getConstants' || $methodName === 'getReflectionConstants') { 109 | $usedConstants[] = $this->createConstantUsage($node, $scope, $genericClassName, null); 110 | } 111 | 112 | if (($methodName === 'getConstant' || $methodName === 'getReflectionConstant') && count($args) === 1) { 113 | $firstArg = $args[array_key_first($args)]; 114 | 115 | foreach ($scope->getType($firstArg->value)->getConstantStrings() as $constantString) { 116 | $usedConstants[] = $this->createConstantUsage($node, $scope, $genericClassName, $constantString->getValue()); 117 | } 118 | } 119 | 120 | return $usedConstants; 121 | } 122 | 123 | /** 124 | * @param array $args 125 | * @return list 126 | */ 127 | private function extractMethodsUsedByReflection( 128 | ?string $genericClassName, 129 | string $methodName, 130 | array $args, 131 | Node $node, 132 | Scope $scope 133 | ): array 134 | { 135 | $usedMethods = []; 136 | 137 | if ($methodName === 'getMethods') { 138 | $usedMethods[] = $this->createMethodUsage($node, $scope, $genericClassName, null); 139 | } 140 | 141 | if ($methodName === 'getMethod' && count($args) === 1) { 142 | $firstArg = $args[array_key_first($args)]; 143 | 144 | foreach ($scope->getType($firstArg->value)->getConstantStrings() as $constantString) { 145 | $usedMethods[] = $this->createMethodUsage($node, $scope, $genericClassName, $constantString->getValue()); 146 | } 147 | } 148 | 149 | if (in_array($methodName, ['getConstructor', 'newInstance', 'newInstanceArgs'], true)) { 150 | $usedMethods[] = $this->createMethodUsage($node, $scope, $genericClassName, '__construct'); 151 | } 152 | 153 | return $usedMethods; 154 | } 155 | 156 | /** 157 | * @param NullsafeMethodCall|MethodCall|StaticCall|New_ $call 158 | * @return list 159 | */ 160 | private function getMethodNames(CallLike $call, Scope $scope): array 161 | { 162 | if ($call instanceof New_) { 163 | return ['__construct']; 164 | } 165 | 166 | if ($call->name instanceof Expr) { 167 | $possibleMethodNames = []; 168 | 169 | foreach ($scope->getType($call->name)->getConstantStrings() as $constantString) { 170 | $possibleMethodNames[] = $constantString->getValue(); 171 | } 172 | 173 | return $possibleMethodNames; 174 | } 175 | 176 | return [$call->name->toString()]; 177 | } 178 | 179 | private function createConstantUsage( 180 | Node $node, 181 | Scope $scope, 182 | ?string $className, 183 | ?string $constantName 184 | ): ClassConstantUsage 185 | { 186 | return new ClassConstantUsage( 187 | UsageOrigin::createRegular($node, $scope), 188 | new ClassConstantRef( 189 | $className, 190 | $constantName, 191 | true, 192 | ), 193 | ); 194 | } 195 | 196 | private function createMethodUsage( 197 | Node $node, 198 | Scope $scope, 199 | ?string $className, 200 | ?string $methodName 201 | ): ClassMethodUsage 202 | { 203 | return new ClassMethodUsage( 204 | UsageOrigin::createRegular($node, $scope), 205 | new ClassMethodRef( 206 | $className, 207 | $methodName, 208 | true, 209 | ), 210 | ); 211 | } 212 | 213 | } 214 | -------------------------------------------------------------------------------- /src/Provider/SymfonyUsageProvider.php: -------------------------------------------------------------------------------- 1 | [method => true] 58 | * 59 | * @var array> 60 | */ 61 | private array $dicCalls = []; 62 | 63 | /** 64 | * class => [constant => config file] 65 | * 66 | * @var array> 67 | */ 68 | private array $dicConstants = []; 69 | 70 | public function __construct( 71 | Container $container, 72 | ?bool $enabled, 73 | ?string $configDir 74 | ) 75 | { 76 | $this->enabled = $enabled ?? $this->isSymfonyInstalled(); 77 | $this->configDir = $configDir ?? $this->autodetectConfigDir(); 78 | $containerXmlPath = $this->getContainerXmlPath($container); 79 | 80 | if ($this->enabled && $containerXmlPath !== null) { 81 | $this->fillDicClasses($containerXmlPath); 82 | } 83 | 84 | if ($this->enabled && $this->configDir !== null) { 85 | $this->fillDicConstants($this->configDir); 86 | } 87 | } 88 | 89 | public function getUsages(Node $node, Scope $scope): array 90 | { 91 | if (!$this->enabled) { 92 | return []; 93 | } 94 | 95 | $usages = []; 96 | 97 | if ($node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 98 | $usages = [ 99 | ...$usages, 100 | ...$this->getUniqueEntityUsages($node), 101 | ...$this->getMethodUsagesFromReflection($node), 102 | ...$this->getConstantUsages($node->getClassReflection()), 103 | ]; 104 | } 105 | 106 | if ($node instanceof InClassMethodNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 107 | $usages = [ 108 | ...$usages, 109 | ...$this->getMethodUsagesFromAttributeReflection($node, $scope), 110 | ]; 111 | } 112 | 113 | if ($node instanceof Return_) { 114 | $usages = [ 115 | ...$usages, 116 | ...$this->getUsagesOfEventSubscriber($node, $scope), 117 | ]; 118 | } 119 | 120 | return $usages; 121 | } 122 | 123 | /** 124 | * @return list 125 | */ 126 | private function getUniqueEntityUsages(InClassNode $node): array 127 | { 128 | $repositoryClass = null; 129 | $repositoryMethod = null; 130 | 131 | foreach ($node->getClassReflection()->getNativeReflection()->getAttributes() as $attribute) { 132 | if ($attribute->getName() === 'Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity') { 133 | $arguments = $attribute->getArguments(); 134 | 135 | if (isset($arguments['repositoryMethod']) && is_string($arguments['repositoryMethod'])) { 136 | $repositoryMethod = $arguments['repositoryMethod']; 137 | } 138 | } 139 | 140 | if ($attribute->getName() === 'Doctrine\ORM\Mapping\Entity') { 141 | $arguments = $attribute->getArguments(); 142 | 143 | if (isset($arguments['repositoryClass']) && is_string($arguments['repositoryClass'])) { 144 | $repositoryClass = $arguments['repositoryClass']; 145 | } 146 | } 147 | } 148 | 149 | if ($repositoryClass !== null && $repositoryMethod !== null) { 150 | $usage = new ClassMethodUsage( 151 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote('Used in #[UniqueEntity] attribute')), 152 | new ClassMethodRef( 153 | $repositoryClass, 154 | $repositoryMethod, 155 | false, 156 | ), 157 | ); 158 | return [$usage]; 159 | } 160 | 161 | return []; 162 | } 163 | 164 | /** 165 | * @return list 166 | */ 167 | private function getUsagesOfEventSubscriber(Return_ $node, Scope $scope): array 168 | { 169 | if ($node->expr === null) { 170 | return []; 171 | } 172 | 173 | if (!$scope->isInClass()) { 174 | return []; 175 | } 176 | 177 | if (!$scope->getFunction() instanceof MethodReflection) { 178 | return []; 179 | } 180 | 181 | if ($scope->getFunction()->getName() !== 'getSubscribedEvents') { 182 | return []; 183 | } 184 | 185 | if (!$scope->getClassReflection()->implementsInterface('Symfony\Component\EventDispatcher\EventSubscriberInterface')) { 186 | return []; 187 | } 188 | 189 | $className = $scope->getClassReflection()->getName(); 190 | 191 | $usages = []; 192 | $usageOrigin = UsageOrigin::createRegular($node, $scope); 193 | 194 | // phpcs:disable Squiz.PHP.CommentedOutCode.Found 195 | foreach ($scope->getType($node->expr)->getConstantArrays() as $rootArray) { 196 | foreach ($rootArray->getValuesArray()->getValueTypes() as $eventConfig) { 197 | // ['eventName' => 'methodName'] 198 | foreach ($eventConfig->getConstantStrings() as $subscriberMethodString) { 199 | $usages[] = new ClassMethodUsage( 200 | $usageOrigin, 201 | new ClassMethodRef( 202 | $className, 203 | $subscriberMethodString->getValue(), 204 | true, 205 | ), 206 | ); 207 | } 208 | 209 | // ['eventName' => ['methodName', $priority]] 210 | foreach ($eventConfig->getConstantArrays() as $subscriberMethodArray) { 211 | foreach ($subscriberMethodArray->getFirstIterableValueType()->getConstantStrings() as $subscriberMethodString) { 212 | $usages[] = new ClassMethodUsage( 213 | $usageOrigin, 214 | new ClassMethodRef( 215 | $className, 216 | $subscriberMethodString->getValue(), 217 | true, 218 | ), 219 | ); 220 | } 221 | } 222 | 223 | // ['eventName' => [['methodName', $priority], ['methodName', $priority]]] 224 | foreach ($eventConfig->getConstantArrays() as $subscriberMethodArray) { 225 | foreach ($subscriberMethodArray->getIterableValueType()->getConstantArrays() as $innerArray) { 226 | foreach ($innerArray->getFirstIterableValueType()->getConstantStrings() as $subscriberMethodString) { 227 | $usages[] = new ClassMethodUsage( 228 | $usageOrigin, 229 | new ClassMethodRef( 230 | $className, 231 | $subscriberMethodString->getValue(), 232 | true, 233 | ), 234 | ); 235 | } 236 | } 237 | } 238 | } 239 | } 240 | 241 | // phpcs:disable Squiz.PHP.CommentedOutCode.Found 242 | 243 | return $usages; 244 | } 245 | 246 | /** 247 | * @return list 248 | */ 249 | private function getMethodUsagesFromReflection(InClassNode $node): array 250 | { 251 | $classReflection = $node->getClassReflection(); 252 | $nativeReflection = $classReflection->getNativeReflection(); 253 | $className = $classReflection->getName(); 254 | 255 | $usages = []; 256 | 257 | foreach ($nativeReflection->getMethods() as $method) { 258 | if (isset($this->dicCalls[$className][$method->getName()])) { 259 | $usages[] = $this->createUsage($classReflection->getNativeMethod($method->getName()), 'Called via DIC'); 260 | } 261 | 262 | if ($method->getDeclaringClass()->getName() !== $nativeReflection->getName()) { 263 | continue; 264 | } 265 | 266 | $note = $this->shouldMarkAsUsed($method); 267 | 268 | if ($note !== null) { 269 | $usages[] = $this->createUsage($classReflection->getNativeMethod($method->getName()), $note); 270 | } 271 | } 272 | 273 | return $usages; 274 | } 275 | 276 | /** 277 | * @return list 278 | */ 279 | private function getMethodUsagesFromAttributeReflection(InClassMethodNode $node, Scope $scope): array 280 | { 281 | $usages = []; 282 | $usageOrigin = UsageOrigin::createRegular($node, $scope); 283 | 284 | foreach ($node->getMethodReflection()->getParameters() as $parameter) { 285 | foreach ($parameter->getAttributes() as $attributeReflection) { 286 | if ($attributeReflection->getName() === 'Symfony\Component\DependencyInjection\Attribute\AutowireLocator') { 287 | $arguments = $attributeReflection->getArgumentTypes(); 288 | 289 | if (!isset($arguments['services']) || !isset($arguments['defaultIndexMethod'])) { 290 | continue; 291 | } 292 | 293 | if ($arguments['services']->isArray()->yes()) { 294 | $classNames = $arguments['services']->getIterableValueType()->getConstantStrings(); 295 | } else { 296 | $classNames = $arguments['services']->getConstantStrings(); 297 | } 298 | 299 | $defaultIndexMethod = $arguments['defaultIndexMethod']->getConstantStrings(); 300 | 301 | if ($classNames === [] || !isset($defaultIndexMethod[0])) { 302 | continue; 303 | } 304 | 305 | foreach ($classNames as $className) { 306 | $usages[] = new ClassMethodUsage( 307 | $usageOrigin, 308 | new ClassMethodRef( 309 | $className->getValue(), 310 | $defaultIndexMethod[0]->getValue(), 311 | true, 312 | ), 313 | ); 314 | } 315 | } elseif ($attributeReflection->getName() === 'Symfony\Component\DependencyInjection\Attribute\AutowireIterator') { 316 | $arguments = $attributeReflection->getArgumentTypes(); 317 | 318 | if (!isset($arguments['tag']) || !isset($arguments['defaultIndexMethod'])) { 319 | continue; 320 | } 321 | 322 | $classNames = $arguments['tag']->getConstantStrings(); 323 | $defaultIndexMethod = $arguments['defaultIndexMethod']->getConstantStrings(); 324 | 325 | if ($classNames === [] || !isset($defaultIndexMethod[0])) { 326 | continue; 327 | } 328 | 329 | foreach ($classNames as $className) { 330 | $usages[] = new ClassMethodUsage( 331 | UsageOrigin::createRegular($node, $scope), 332 | new ClassMethodRef( 333 | $className->getValue(), 334 | $defaultIndexMethod[0]->getValue(), 335 | true, 336 | ), 337 | ); 338 | } 339 | } 340 | } 341 | } 342 | 343 | return $usages; 344 | } 345 | 346 | protected function shouldMarkAsUsed(ReflectionMethod $method): ?string 347 | { 348 | if ($this->isBundleConstructor($method)) { 349 | return 'Bundle constructor (created by Kernel)'; 350 | } 351 | 352 | if ($this->isEventListenerMethodWithAsEventListenerAttribute($method)) { 353 | return 'Event listener method via #[AsEventListener] attribute'; 354 | } 355 | 356 | if ($this->isAutowiredWithRequiredAttribute($method)) { 357 | return 'Autowired with #[Required] (called by DIC)'; 358 | } 359 | 360 | if ($this->isConstructorWithAsCommandAttribute($method)) { 361 | return 'Class has #[AsCommand] attribute'; 362 | } 363 | 364 | if ($this->isConstructorWithAsControllerAttribute($method)) { 365 | return 'Class has #[AsController] attribute'; 366 | } 367 | 368 | if ($this->isMethodWithRouteAttribute($method)) { 369 | return 'Route method via #[Route] attribute'; 370 | } 371 | 372 | if ($this->isMethodWithCallbackConstraintAttribute($method)) { 373 | return 'Callback constraint method via #[Assert\Callback] attribute'; 374 | } 375 | 376 | if ($this->isProbablySymfonyListener($method)) { 377 | return 'Probable listener method'; 378 | } 379 | 380 | return null; 381 | } 382 | 383 | protected function fillDicClasses(string $containerXmlPath): void 384 | { 385 | $fileContents = file_get_contents($containerXmlPath); 386 | 387 | if ($fileContents === false) { 388 | throw new LogicException(sprintf('Container %s does not exist', $containerXmlPath)); 389 | } 390 | 391 | if (!extension_loaded('simplexml')) { // should never happen as phpstan-doctrine requires that 392 | throw new LogicException('Extension simplexml is required to parse DIC xml'); 393 | } 394 | 395 | $xml = @simplexml_load_string($fileContents); 396 | 397 | if ($xml === false) { 398 | throw new LogicException(sprintf('Container %s cannot be parsed', $containerXmlPath)); 399 | } 400 | 401 | if (!isset($xml->services->service)) { 402 | throw new LogicException(sprintf('XML %s does not contain container.services.service structure', $containerXmlPath)); 403 | } 404 | 405 | $serviceMap = $this->buildXmlServiceMap($xml->services->service); 406 | 407 | foreach ($xml->services->service as $serviceDefinition) { 408 | /** @var SimpleXMLElement $serviceAttributes */ 409 | $serviceAttributes = $serviceDefinition->attributes(); 410 | $class = isset($serviceAttributes->class) ? (string) $serviceAttributes->class : null; 411 | $constructor = isset($serviceAttributes->constructor) ? (string) $serviceAttributes->constructor : '__construct'; 412 | 413 | if ($class === null) { 414 | continue; 415 | } 416 | 417 | $this->dicCalls[$class][$constructor] = true; 418 | 419 | foreach ($serviceDefinition->call ?? [] as $callDefinition) { 420 | /** @var SimpleXMLElement $callAttributes */ 421 | $callAttributes = $callDefinition->attributes(); 422 | $method = $callAttributes->method !== null ? (string) $callAttributes->method : null; 423 | 424 | if ($method === null) { 425 | continue; 426 | } 427 | 428 | $this->dicCalls[$class][$method] = true; 429 | } 430 | 431 | foreach ($serviceDefinition->factory ?? [] as $factoryDefinition) { 432 | /** @var SimpleXMLElement $factoryAttributes */ 433 | $factoryAttributes = $factoryDefinition->attributes(); 434 | $factoryClass = $factoryAttributes->class !== null ? (string) $factoryAttributes->class : null; 435 | $factoryService = $factoryAttributes->service !== null ? (string) $factoryAttributes->service : null; 436 | $factoryMethod = $factoryAttributes->method !== null ? (string) $factoryAttributes->method : null; 437 | 438 | if ($factoryClass !== null && $factoryMethod !== null) { 439 | $this->dicCalls[$factoryClass][$factoryMethod] = true; 440 | } 441 | 442 | if ($factoryService !== null && $factoryMethod !== null && isset($serviceMap[$factoryService])) { 443 | $factoryServiceClass = $serviceMap[$factoryService]; 444 | $this->dicCalls[$factoryServiceClass][$factoryMethod] = true; 445 | } 446 | } 447 | } 448 | } 449 | 450 | /** 451 | * @return array 452 | */ 453 | private function buildXmlServiceMap(SimpleXMLElement $serviceDefinitions): array 454 | { 455 | $serviceMap = []; 456 | 457 | foreach ($serviceDefinitions as $serviceDefinition) { 458 | /** @var SimpleXMLElement $serviceAttributes */ 459 | $serviceAttributes = $serviceDefinition->attributes(); 460 | $id = isset($serviceAttributes->id) ? (string) $serviceAttributes->id : null; 461 | $class = isset($serviceAttributes->class) ? (string) $serviceAttributes->class : null; 462 | 463 | if ($id === null || $class === null) { 464 | continue; 465 | } 466 | 467 | $serviceMap[$id] = $class; 468 | } 469 | 470 | return $serviceMap; 471 | } 472 | 473 | protected function isBundleConstructor(ReflectionMethod $method): bool 474 | { 475 | return $method->isConstructor() && $method->getDeclaringClass()->isSubclassOf('Symfony\Component\HttpKernel\Bundle\Bundle'); 476 | } 477 | 478 | protected function isAutowiredWithRequiredAttribute(ReflectionMethod $method): bool 479 | { 480 | return $this->hasAttribute($method, 'Symfony\Contracts\Service\Attribute\Required'); 481 | } 482 | 483 | protected function isEventListenerMethodWithAsEventListenerAttribute(ReflectionMethod $method): bool 484 | { 485 | $class = $method->getDeclaringClass(); 486 | 487 | return $this->hasAttribute($class, 'Symfony\Component\EventDispatcher\Attribute\AsEventListener') 488 | || $this->hasAttribute($method, 'Symfony\Component\EventDispatcher\Attribute\AsEventListener'); 489 | } 490 | 491 | protected function isConstructorWithAsCommandAttribute(ReflectionMethod $method): bool 492 | { 493 | $class = $method->getDeclaringClass(); 494 | return $method->isConstructor() && $this->hasAttribute($class, 'Symfony\Component\Console\Attribute\AsCommand'); 495 | } 496 | 497 | protected function isConstructorWithAsControllerAttribute(ReflectionMethod $method): bool 498 | { 499 | $class = $method->getDeclaringClass(); 500 | return $method->isConstructor() && $this->hasAttribute($class, 'Symfony\Component\HttpKernel\Attribute\AsController'); 501 | } 502 | 503 | protected function isMethodWithRouteAttribute(ReflectionMethod $method): bool 504 | { 505 | $isInstanceOf = 2; // ReflectionAttribute::IS_INSTANCEOF, since PHP 8.0 506 | 507 | return $this->hasAttribute($method, 'Symfony\Component\Routing\Attribute\Route', $isInstanceOf) 508 | || $this->hasAttribute($method, 'Symfony\Component\Routing\Annotation\Route', $isInstanceOf); 509 | } 510 | 511 | protected function isMethodWithCallbackConstraintAttribute(ReflectionMethod $method): bool 512 | { 513 | $attributes = $method->getDeclaringClass()->getAttributes('Symfony\Component\Validator\Constraints\Callback'); 514 | 515 | foreach ($attributes as $attribute) { 516 | $arguments = $attribute->getArguments(); 517 | 518 | $callback = $arguments['callback'] ?? $arguments[0] ?? null; 519 | 520 | if ($callback === $method->getName()) { 521 | return true; 522 | } 523 | } 524 | 525 | return $this->hasAttribute($method, 'Symfony\Component\Validator\Constraints\Callback'); 526 | } 527 | 528 | /** 529 | * Ideally, we would need to parse DIC xml to know this for sure just like phpstan-symfony does. 530 | */ 531 | protected function isProbablySymfonyListener(ReflectionMethod $method): bool 532 | { 533 | $methodName = $method->getName(); 534 | 535 | return $methodName === 'onKernelResponse' 536 | || $methodName === 'onKernelException' 537 | || $methodName === 'onKernelRequest' 538 | || $methodName === 'onConsoleError' 539 | || $methodName === 'onConsoleCommand' 540 | || $methodName === 'onConsoleSignal' 541 | || $methodName === 'onConsoleTerminate'; 542 | } 543 | 544 | /** 545 | * @param ReflectionClass|ReflectionMethod $classOrMethod 546 | * @param ReflectionAttribute::IS_*|0 $flags 547 | */ 548 | protected function hasAttribute(Reflector $classOrMethod, string $attributeClass, int $flags = 0): bool 549 | { 550 | if ($classOrMethod->getAttributes($attributeClass) !== []) { 551 | return true; 552 | } 553 | 554 | try { 555 | /** @throws IdentifierNotFound */ 556 | return $classOrMethod->getAttributes($attributeClass, $flags) !== []; 557 | } catch (IdentifierNotFound $e) { 558 | return false; // prevent https://github.com/phpstan/phpstan/issues/9618 559 | } 560 | } 561 | 562 | private function isSymfonyInstalled(): bool 563 | { 564 | foreach (InstalledVersions::getInstalledPackages() as $package) { 565 | if (strpos($package, 'symfony/') === 0) { 566 | return true; 567 | } 568 | } 569 | 570 | return false; 571 | } 572 | 573 | private function createUsage(ExtendedMethodReflection $methodReflection, string $reason): ClassMethodUsage 574 | { 575 | return new ClassMethodUsage( 576 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote($reason)), 577 | new ClassMethodRef( 578 | $methodReflection->getDeclaringClass()->getName(), 579 | $methodReflection->getName(), 580 | false, 581 | ), 582 | ); 583 | } 584 | 585 | private function autodetectConfigDir(): ?string 586 | { 587 | $vendorDirs = array_filter(array_keys(ClassLoader::getRegisteredLoaders()), static function (string $vendorDir): bool { 588 | return strpos($vendorDir, 'phar://') === false; 589 | }); 590 | 591 | if (count($vendorDirs) !== 1) { 592 | return null; 593 | } 594 | 595 | $vendorDir = reset($vendorDirs); 596 | $configDir = $vendorDir . '/../config'; 597 | 598 | if (is_dir($configDir)) { 599 | return $configDir; 600 | } 601 | 602 | return null; 603 | } 604 | 605 | private function fillDicConstants(string $configDir): void 606 | { 607 | try { 608 | $iterator = new RecursiveIteratorIterator( 609 | new RecursiveDirectoryIterator($configDir, FilesystemIterator::SKIP_DOTS), 610 | ); 611 | } catch (UnexpectedValueException $e) { 612 | throw new LogicException("Provided config path '$configDir' is not a directory", 0, $e); 613 | } 614 | 615 | /** @var SplFileInfo $file */ 616 | foreach ($iterator as $file) { 617 | if ( 618 | $file->isFile() 619 | && in_array($file->getExtension(), ['yaml', 'yml'], true) 620 | && $file->getRealPath() !== false 621 | ) { 622 | $this->extractYamlConstants($file->getRealPath()); 623 | } 624 | } 625 | } 626 | 627 | private function extractYamlConstants(string $yamlFile): void 628 | { 629 | $dicFileContents = file_get_contents($yamlFile); 630 | 631 | if ($dicFileContents === false) { 632 | return; 633 | } 634 | 635 | $nameRegex = '[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*'; // https://www.php.net/manual/en/language.oop5.basic.php 636 | 637 | preg_match_all( 638 | "~!php/const ($nameRegex(?:\\\\$nameRegex)+::$nameRegex)~", 639 | $dicFileContents, 640 | $matches, 641 | ); 642 | 643 | foreach ($matches[1] as $usedConstants) { 644 | [$className, $constantName] = explode('::', $usedConstants); // @phpstan-ignore offsetAccess.notFound 645 | $this->dicConstants[$className][$constantName] = $yamlFile; 646 | } 647 | } 648 | 649 | /** 650 | * @return list 651 | */ 652 | private function getConstantUsages(ClassReflection $classReflection): array 653 | { 654 | $usages = []; 655 | 656 | foreach ($this->dicConstants[$classReflection->getName()] ?? [] as $constantName => $configFile) { 657 | if (!$classReflection->hasConstant($constantName)) { 658 | continue; 659 | } 660 | 661 | $usages[] = new ClassConstantUsage( 662 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote('Referenced in config in ' . $configFile)), 663 | new ClassConstantRef( 664 | $classReflection->getName(), 665 | $constantName, 666 | false, 667 | ), 668 | ); 669 | } 670 | 671 | return $usages; 672 | } 673 | 674 | private function getContainerXmlPath(Container $container): ?string 675 | { 676 | try { 677 | /** @var array{containerXmlPath: string|null} $symfonyConfig */ 678 | $symfonyConfig = $container->getParameter('symfony'); 679 | 680 | return $symfonyConfig['containerXmlPath']; 681 | } catch (ParameterNotFoundException $e) { 682 | return null; 683 | } 684 | } 685 | 686 | } 687 | -------------------------------------------------------------------------------- /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(Node $node, Scope $scope): array 40 | { 41 | if (!$this->enabled) { 42 | return []; 43 | } 44 | 45 | $usages = []; 46 | 47 | if ($node instanceof InClassNode) { // @phpstan-ignore phpstanApi.instanceofAssumption 48 | $usages = [ 49 | ...$usages, 50 | ...$this->getMethodUsagesFromReflection($node), 51 | ]; 52 | } 53 | 54 | if ($node instanceof New_) { 55 | $usages = [ 56 | ...$usages, 57 | ...$this->getMethodUsageFromNew($node, $scope), 58 | ]; 59 | } 60 | 61 | return $usages; 62 | } 63 | 64 | /** 65 | * @return list 66 | */ 67 | private function getMethodUsageFromNew(New_ $node, Scope $scope): array 68 | { 69 | if (!$node->class instanceof Name) { 70 | return []; 71 | } 72 | 73 | if (!in_array($node->class->toString(), [ 74 | 'Twig\TwigFilter', 75 | 'Twig\TwigFunction', 76 | 'Twig\TwigTest', 77 | ], true)) { 78 | return []; 79 | } 80 | 81 | $callerType = $scope->resolveTypeByName($node->class); 82 | $methodReflection = $scope->getMethodReflection($callerType, '__construct'); 83 | 84 | if ($methodReflection === null) { 85 | return []; 86 | } 87 | 88 | $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( 89 | $scope, 90 | $node->getArgs(), 91 | $methodReflection->getVariants(), 92 | $methodReflection->getNamedArgumentsVariants(), 93 | ); 94 | $arg = (ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $node) ?? $node)->getArgs()[1] ?? null; 95 | 96 | if ($arg === null) { 97 | return []; 98 | } 99 | 100 | $argType = $scope->getType($arg->value); 101 | 102 | $argTypes = $argType instanceof UnionType ? $argType->getTypes() : [$argType]; 103 | 104 | $callables = []; 105 | 106 | foreach ($argTypes as $callableType) { 107 | foreach ($callableType->getConstantArrays() as $arrayType) { 108 | $callable = []; 109 | 110 | foreach ($arrayType->getValueTypes() as $valueType) { 111 | $callable[] = array_map(static function ($stringType): string { 112 | return $stringType->getValue(); 113 | }, $valueType->getConstantStrings()); 114 | } 115 | 116 | if (count($callable) === 2) { 117 | foreach ($callable[0] as $className) { 118 | foreach ($callable[1] as $methodName) { 119 | $callables[] = [$className, $methodName]; 120 | } 121 | } 122 | } 123 | } 124 | 125 | foreach ($callableType->getConstantStrings() as $stringType) { 126 | $callable = explode('::', $stringType->getValue()); 127 | 128 | if (count($callable) === 2) { 129 | $callables[] = $callable; 130 | } 131 | } 132 | } 133 | 134 | $usages = []; 135 | 136 | foreach ($callables as $callable) { 137 | $usages[] = new ClassMethodUsage( 138 | UsageOrigin::createRegular($node, $scope), 139 | new ClassMethodRef( 140 | $callable[0], 141 | $callable[1], 142 | false, 143 | ), 144 | ); 145 | } 146 | 147 | return $usages; 148 | } 149 | 150 | /** 151 | * @return list 152 | */ 153 | private function getMethodUsagesFromReflection(InClassNode $node): array 154 | { 155 | $classReflection = $node->getClassReflection(); 156 | $nativeReflection = $classReflection->getNativeReflection(); 157 | 158 | $usages = []; 159 | 160 | foreach ($nativeReflection->getMethods() as $method) { 161 | if ($method->getDeclaringClass()->getName() !== $nativeReflection->getName()) { 162 | continue; 163 | } 164 | 165 | $usageNote = $this->shouldMarkAsUsed($method); 166 | 167 | if ($usageNote !== null) { 168 | $usages[] = $this->createUsage($classReflection->getNativeMethod($method->getName()), $usageNote); 169 | } 170 | } 171 | 172 | return $usages; 173 | } 174 | 175 | protected function shouldMarkAsUsed(ReflectionMethod $method): ?string 176 | { 177 | if ($this->isMethodWithAsTwigFilterAttribute($method)) { 178 | return 'Twig filter method via #[AsTwigFilter] attribute'; 179 | } 180 | 181 | if ($this->isMethodWithAsTwigFunctionAttribute($method)) { 182 | return 'Twig function method via #[AsTwigFunction] attribute'; 183 | } 184 | 185 | if ($this->isMethodWithAsTwigTestAttribute($method)) { 186 | return 'Twig test method via #[AsTwigTest] attribute'; 187 | } 188 | 189 | return null; 190 | } 191 | 192 | protected function isMethodWithAsTwigFilterAttribute(ReflectionMethod $method): bool 193 | { 194 | return $this->hasAttribute($method, 'Twig\Attribute\AsTwigFilter'); 195 | } 196 | 197 | protected function isMethodWithAsTwigFunctionAttribute(ReflectionMethod $method): bool 198 | { 199 | return $this->hasAttribute($method, 'Twig\Attribute\AsTwigFunction'); 200 | } 201 | 202 | protected function isMethodWithAsTwigTestAttribute(ReflectionMethod $method): bool 203 | { 204 | return $this->hasAttribute($method, 'Twig\Attribute\AsTwigTest'); 205 | } 206 | 207 | protected function hasAttribute(ReflectionMethod $method, string $attributeClass): bool 208 | { 209 | return $method->getAttributes($attributeClass) !== []; 210 | } 211 | 212 | private function createUsage(ExtendedMethodReflection $methodReflection, string $reason): ClassMethodUsage 213 | { 214 | return new ClassMethodUsage( 215 | UsageOrigin::createVirtual($this, VirtualUsageData::withNote($reason)), 216 | new ClassMethodRef( 217 | $methodReflection->getDeclaringClass()->getName(), 218 | $methodReflection->getName(), 219 | false, 220 | ), 221 | ); 222 | } 223 | 224 | } 225 | -------------------------------------------------------------------------------- /src/Provider/VendorUsageProvider.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | private array $vendorDirs; 20 | 21 | private bool $enabled; 22 | 23 | public function __construct(bool $enabled) 24 | { 25 | $this->vendorDirs = array_keys(ClassLoader::getRegisteredLoaders()); 26 | $this->enabled = $enabled; 27 | } 28 | 29 | public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageData 30 | { 31 | if (!$this->enabled) { 32 | return null; 33 | } 34 | 35 | $reflectionClass = $method->getDeclaringClass(); 36 | $methodName = $method->getName(); 37 | 38 | $usage = VirtualUsageData::withNote('Method overrides vendor one, thus is expected to be used by vendor code'); 39 | 40 | do { 41 | if ($this->isForeignMethod($reflectionClass, $methodName)) { 42 | return $usage; 43 | } 44 | 45 | foreach ($reflectionClass->getInterfaces() as $interface) { 46 | if ($this->isForeignMethod($interface, $methodName)) { 47 | return $usage; 48 | } 49 | } 50 | 51 | foreach ($reflectionClass->getTraits() as $trait) { 52 | if ($this->isForeignMethod($trait, $methodName)) { 53 | return $usage; 54 | } 55 | } 56 | 57 | $reflectionClass = $reflectionClass->getParentClass(); 58 | } while ($reflectionClass !== false); 59 | 60 | return null; 61 | } 62 | 63 | /** 64 | * @param ReflectionClass $reflectionClass 65 | */ 66 | private function isForeignMethod(ReflectionClass $reflectionClass, string $methodName): bool 67 | { 68 | if (!$reflectionClass->hasMethod($methodName)) { 69 | return false; 70 | } 71 | 72 | $filePath = $reflectionClass->getFileName(); 73 | 74 | if ($filePath === false) { 75 | return true; // php core or extension 76 | } 77 | 78 | $pharPrefix = 'phar://'; 79 | 80 | if (strpos($filePath, $pharPrefix) === 0) { 81 | /** @var string $filePath Cannot resolve to false */ 82 | $filePath = substr($filePath, strlen($pharPrefix)); 83 | } 84 | 85 | foreach ($this->vendorDirs as $vendorDir) { 86 | if (strpos($filePath, $vendorDir) === 0) { 87 | return true; 88 | } 89 | } 90 | 91 | return false; 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/Provider/VirtualUsageData.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/Reflection/ReflectionHelper.php: -------------------------------------------------------------------------------- 1 | hasMethod($methodName)) { 14 | return false; 15 | } 16 | 17 | try { 18 | return $classReflection->getNativeReflection()->getMethod($methodName)->getBetterReflection()->getDeclaringClass()->getName() === $classReflection->getName(); 19 | } catch (ReflectionException $e) { 20 | return false; 21 | } 22 | } 23 | 24 | public static function hasOwnConstant(ClassReflection $classReflection, string $constantName): bool 25 | { 26 | $constantReflection = $classReflection->getNativeReflection()->getReflectionConstant($constantName); 27 | 28 | if ($constantReflection === false) { 29 | return false; 30 | } 31 | 32 | return $constantReflection->getBetterReflection()->getDeclaringClass()->getName() === $classReflection->getName(); 33 | } 34 | 35 | public static function hasOwnProperty(ClassReflection $classReflection, string $propertyName): bool 36 | { 37 | if (!$classReflection->hasProperty($propertyName)) { 38 | return false; 39 | } 40 | 41 | try { 42 | return $classReflection->getNativeReflection()->getProperty($propertyName)->getBetterReflection()->getDeclaringClass()->getName() === $classReflection->getName(); 43 | } catch (ReflectionException $e) { 44 | return false; 45 | } 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/Transformer/FileSystem.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | private array $deadMethods; 29 | 30 | /** 31 | * @var array 32 | */ 33 | private array $deadConstants; 34 | 35 | /** 36 | * @param list $deadMethods 37 | * @param list $deadConstants 38 | */ 39 | public function __construct(array $deadMethods, array $deadConstants) 40 | { 41 | $this->deadMethods = array_fill_keys($deadMethods, true); 42 | $this->deadConstants = array_fill_keys($deadConstants, true); 43 | } 44 | 45 | public function enterNode(Node $node): ?Node 46 | { 47 | if ($node instanceof Namespace_ && $node->name !== null) { 48 | $this->currentNamespace = $node->name->toString(); 49 | 50 | } elseif ($node instanceof ClassLike && $node->name !== null) { 51 | $this->currentClass = $node->name->name; 52 | } 53 | 54 | return null; 55 | } 56 | 57 | public function leaveNode(Node $node): ?int 58 | { 59 | if ($node instanceof ClassMethod) { 60 | $methodKey = $this->getNamespacedName($node->name); 61 | 62 | if (isset($this->deadMethods[$methodKey])) { 63 | return NodeTraverser::REMOVE_NODE; 64 | } 65 | } 66 | 67 | if ($node instanceof ClassConst) { 68 | $allDead = true; 69 | 70 | foreach ($node->consts as $const) { 71 | $constKey = $this->getNamespacedName($const->name); 72 | 73 | if (!isset($this->deadConstants[$constKey])) { 74 | $allDead = false; 75 | break; 76 | } 77 | } 78 | 79 | if ($allDead) { 80 | return NodeTraverser::REMOVE_NODE; 81 | } 82 | } 83 | 84 | if ($node instanceof Const_) { 85 | $constKey = $this->getNamespacedName($node->name); 86 | 87 | if (isset($this->deadConstants[$constKey])) { 88 | return NodeTraverser::REMOVE_NODE; 89 | } 90 | } 91 | 92 | return null; 93 | } 94 | 95 | /** 96 | * @param Name|Identifier $name 97 | */ 98 | private function getNamespacedName(Node $name): string 99 | { 100 | return ltrim($this->currentNamespace . '\\' . $this->currentClass, '\\') . '::' . $name->name; 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/Transformer/RemoveDeadCodeTransformer.php: -------------------------------------------------------------------------------- 1 | $deadMethods 28 | * @param list $deadConstants 29 | */ 30 | public function __construct(array $deadMethods, array $deadConstants) 31 | { 32 | $this->phpLexer = new Lexer(); 33 | $this->phpParser = new Php8($this->phpLexer); 34 | 35 | $this->cloningTraverser = new PhpTraverser(); 36 | $this->cloningTraverser->addVisitor(new CloningVisitor()); 37 | 38 | $this->removingTraverser = new PhpTraverser(); 39 | $this->removingTraverser->addVisitor(new RemoveClassMemberVisitor($deadMethods, $deadConstants)); 40 | 41 | $this->phpPrinter = new PhpPrinter(); 42 | } 43 | 44 | public function transformCode(string $oldCode): string 45 | { 46 | $oldAst = $this->phpParser->parse($oldCode); 47 | 48 | if ($oldAst === null) { 49 | throw new LogicException('Failed to parse the code'); 50 | } 51 | 52 | $oldTokens = $this->phpParser->getTokens(); 53 | $newAst = $this->removingTraverser->traverse($this->cloningTraverser->traverse($oldAst)); 54 | return $this->phpPrinter->printFormatPreserving($newAst, $oldAst, $oldTokens); 55 | } 56 | 57 | } 58 | --------------------------------------------------------------------------------