Skip to content

Commit 4a0434a

Browse files
committed
EnumUsageProvider
1 parent 62a6134 commit 4a0434a

File tree

5 files changed

+197
-0
lines changed

5 files changed

+197
-0
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ parameters:
8888
- Any overridden method that originates in `vendor` is not reported as dead
8989
- e.g. implementing `Psr\Log\LoggerInterface::log` is automatically considered used
9090

91+
#### Enum:
92+
- Detects usages caused by `BackedEnum::from`, `BackedEnum::tryFrom` and `UnitEnum::cases`
93+
9194
Those providers are enabled by default, but you can disable them if needed.
9295

9396
## Excluding usages in tests:

rules.neon

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ services:
1414
-
1515
class: ShipMonk\PHPStan\DeadCode\Debug\DebugUsagePrinter
1616

17+
-
18+
class: ShipMonk\PHPStan\DeadCode\Provider\EnumUsageProvider
19+
tags:
20+
- shipmonk.deadCode.memberUsageProvider
21+
arguments:
22+
enabled: %shipmonkDeadCode.usageProviders.enum.enabled%
23+
1724
-
1825
class: ShipMonk\PHPStan\DeadCode\Provider\VendorUsageProvider
1926
tags:
@@ -136,6 +143,8 @@ parameters:
136143
trackMixedAccess: null
137144
reportTransitivelyDeadMethodAsSeparateError: false
138145
usageProviders:
146+
enum:
147+
enabled: true
139148
vendor:
140149
enabled: true
141150
reflection:
@@ -167,6 +176,9 @@ parametersSchema:
167176
trackMixedAccess: schema(bool(), nullable()) # deprecated, use usageExcluders.usageOverMixed.enabled
168177
reportTransitivelyDeadMethodAsSeparateError: bool()
169178
usageProviders: structure([
179+
enum: structure([
180+
enabled: bool()
181+
])
170182
vendor: structure([
171183
enabled: bool()
172184
])

src/Provider/EnumUsageProvider.php

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\PHPStan\DeadCode\Provider;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr;
7+
use PhpParser\Node\Expr\StaticCall;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Type\MixedType;
10+
use PHPStan\Type\Type;
11+
use PHPStan\Type\TypeCombinator;
12+
use PHPStan\Type\TypeUtils;
13+
use ReflectionEnum;
14+
use ReflectionEnumBackedCase;
15+
use ShipMonk\PHPStan\DeadCode\Graph\EnumCaseRef;
16+
use ShipMonk\PHPStan\DeadCode\Graph\EnumCaseUsage;
17+
use ShipMonk\PHPStan\DeadCode\Graph\UsageOrigin;
18+
use UnitEnum;
19+
use function array_filter;
20+
use function in_array;
21+
use function is_int;
22+
use function is_string;
23+
24+
class EnumUsageProvider implements MemberUsageProvider
25+
{
26+
27+
private bool $enabled;
28+
29+
public function __construct(bool $enabled)
30+
{
31+
$this->enabled = $enabled;
32+
}
33+
34+
public function getUsages(Node $node, Scope $scope): array
35+
{
36+
if ($this->enabled === false) {
37+
return [];
38+
}
39+
40+
if ($node instanceof StaticCall) {
41+
return $this->getTryFromUsages($node, $scope);
42+
}
43+
44+
return [];
45+
}
46+
47+
/**
48+
* @return list<EnumCaseUsage>
49+
*/
50+
private function getTryFromUsages(StaticCall $staticCall, Scope $scope): array
51+
{
52+
$methodNames = $this->getMethodNames($staticCall, $scope);
53+
$firstArgType = $this->getArgType($staticCall, $scope, 0);
54+
55+
$callerType = $staticCall->class instanceof Expr
56+
? $scope->getType($staticCall->class)
57+
: $scope->resolveTypeByName($staticCall->class);
58+
59+
$typeNoNull = TypeCombinator::removeNull($callerType); // remove null to support nullsafe calls
60+
$typeNormalized = TypeUtils::toBenevolentUnion($typeNoNull); // extract possible calls even from Class|int
61+
$classReflections = $typeNormalized->getObjectTypeOrClassStringObjectType()->getObjectClassReflections();
62+
63+
$result = [];
64+
65+
foreach ($methodNames as $methodName) {
66+
if (!in_array($methodName, ['tryFrom', 'from', 'cases'], true)) {
67+
continue;
68+
}
69+
70+
foreach ($classReflections as $classReflection) {
71+
if (!$classReflection->isEnum()) {
72+
continue;
73+
}
74+
75+
$valueToCaseMapping = $this->getValueToEnumCaseMapping($classReflection->getNativeReflection()); // @phpstan-ignore argument.type (https://github.com/phpstan/phpstan-src/pull/3925)
76+
$triedValues = $firstArgType->getConstantScalarValues() === []
77+
? [null]
78+
: array_filter($firstArgType->getConstantScalarValues(), static fn($value): bool => is_string($value) || is_int($value));
79+
80+
foreach ($triedValues as $value) {
81+
$enumCase = $value === null ? null : $valueToCaseMapping[$value] ?? null;
82+
$result[] = new EnumCaseUsage(
83+
UsageOrigin::createRegular($staticCall, $scope),
84+
new EnumCaseRef($classReflection->getName(), $enumCase),
85+
);
86+
}
87+
}
88+
}
89+
90+
return $result;
91+
}
92+
93+
/**
94+
* @return list<string|null>
95+
*/
96+
private function getMethodNames(StaticCall $call, Scope $scope): array
97+
{
98+
if ($call->name instanceof Expr) {
99+
$possibleMethodNames = [];
100+
101+
foreach ($scope->getType($call->name)->getConstantStrings() as $constantString) {
102+
$possibleMethodNames[] = $constantString->getValue();
103+
}
104+
105+
return $possibleMethodNames === []
106+
? [null] // unknown method name
107+
: $possibleMethodNames;
108+
}
109+
110+
return [$call->name->name];
111+
}
112+
113+
private function getArgType(StaticCall $staticCall, Scope $scope, int $position): Type
114+
{
115+
$args = $staticCall->getArgs();
116+
117+
if (isset($args[$position])) {
118+
return $scope->getType($args[$position]->value);
119+
}
120+
121+
return new MixedType();
122+
}
123+
124+
/**
125+
* @param ReflectionEnum<UnitEnum> $enumReflection
126+
* @return array<array-key, string>
127+
*/
128+
private function getValueToEnumCaseMapping(ReflectionEnum $enumReflection): array
129+
{
130+
$mapping = [];
131+
132+
foreach ($enumReflection->getCases() as $enumCaseReflection) {
133+
if (!$enumCaseReflection instanceof ReflectionEnumBackedCase) {
134+
continue;
135+
}
136+
137+
$mapping[$enumCaseReflection->getBackingValue()] = $enumCaseReflection->getName();
138+
}
139+
140+
return $mapping;
141+
}
142+
143+
}

tests/Rule/DeadCodeRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use ShipMonk\PHPStan\DeadCode\Hierarchy\ClassHierarchy;
3333
use ShipMonk\PHPStan\DeadCode\Output\OutputEnhancer;
3434
use ShipMonk\PHPStan\DeadCode\Provider\DoctrineUsageProvider;
35+
use ShipMonk\PHPStan\DeadCode\Provider\EnumUsageProvider;
3536
use ShipMonk\PHPStan\DeadCode\Provider\MemberUsageProvider;
3637
use ShipMonk\PHPStan\DeadCode\Provider\NetteUsageProvider;
3738
use ShipMonk\PHPStan\DeadCode\Provider\PhpStanUsageProvider;
@@ -690,6 +691,7 @@ public static function provideFiles(): Traversable
690691
yield 'provider-doctrine' => [__DIR__ . '/data/providers/doctrine.php', self::requiresPhp(8_01_00)];
691692
yield 'provider-phpstan' => [__DIR__ . '/data/providers/phpstan.php'];
692693
yield 'provider-nette' => [__DIR__ . '/data/providers/nette.php'];
694+
yield 'provider-enum' => [__DIR__ . '/data/providers/enum.php', self::requiresPhp(8_01_00)];
693695

694696
// excluders
695697
yield 'excluder-tests' => [[__DIR__ . '/data/excluders/tests/src/code.php', __DIR__ . '/data/excluders/tests/tests/code.php']];
@@ -846,6 +848,9 @@ public function shouldMarkMethodAsUsed(ReflectionMethod $method): ?VirtualUsageD
846848
new TwigUsageProvider(
847849
true,
848850
),
851+
new EnumUsageProvider(
852+
true,
853+
),
849854
];
850855
}
851856

tests/Rule/data/providers/enum.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace EnumProvider;
4+
5+
use Nette\Application\UI\Form as UIForm;
6+
use Nette\Application\UI\Presenter;
7+
use Nette\SmartObject;
8+
9+
enum MyEnum0: int {
10+
case One = 1;
11+
case Two = 2; // error: Unused EnumProvider\MyEnum0::Two
12+
}
13+
14+
enum MyEnum1: string {
15+
case Used = 'used';
16+
case Unused = 'unused'; // error: Unused EnumProvider\MyEnum1::Unused
17+
}
18+
19+
enum MyEnum2: string {
20+
case Used = 'used';
21+
case UsedToo = 'used_too';
22+
}
23+
24+
enum MyEnum3: string {
25+
case Used = 'used';
26+
case UsedToo = 'used_too';
27+
}
28+
29+
function test(string $any) {
30+
MyEnum0::tryFrom(1);
31+
MyEnum1::tryFrom('used');
32+
MyEnum2::from($any);
33+
MyEnum3::cases();
34+
}

0 commit comments

Comments
 (0)