Skip to content

Commit d04d50c

Browse files
committed
Report impossible class_exists/interface_exists/trait_exists/enum_exists
Detect via reflection when the constant-string argument names a struct of the wrong kind (e.g. class_exists() on an interface name) and narrow the argument to never in the truthy context. Drop the ImpossibleCheckTypeHelper bailout for these four functions so the rule can pick up the impossibility, while still suppressing "always true" since runtime autoload can fail. Closes phpstan/phpstan#14683
1 parent 7c119ff commit d04d50c

5 files changed

Lines changed: 191 additions & 9 deletions

File tree

src/Rules/Comparison/ImpossibleCheckTypeHelper.php

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public function findSpecifiedType(
5858
Expr $node,
5959
): ?bool
6060
{
61+
$onlyReportFalse = false;
6162
if ($node instanceof FuncCall) {
6263
if ($node->isFirstClassCallable()) {
6364
return null;
@@ -66,6 +67,17 @@ public function findSpecifiedType(
6667
$argsCount = count($args);
6768
if ($node->name instanceof Node\Name) {
6869
$functionName = strtolower((string) $node->name);
70+
if (in_array($functionName, [
71+
'class_exists',
72+
'interface_exists',
73+
'trait_exists',
74+
'enum_exists',
75+
], true)) {
76+
// Runtime autoload can always fail, so do not report "always true" for these.
77+
// "Always false" is still reportable when the type specifier proves impossibility
78+
// (e.g. class_exists() on a constant string that names an interface).
79+
$onlyReportFalse = true;
80+
}
6981
if ($functionName === 'assert' && $argsCount >= 1) {
7082
$arg = $args[0]->value;
7183
$assertValue = ($this->treatPhpDocTypesAsCertain ? $scope->getType($arg) : $scope->getNativeType($arg))->toBoolean();
@@ -76,13 +88,7 @@ public function findSpecifiedType(
7688

7789
return $assertValueIsTrue;
7890
}
79-
if (in_array($functionName, [
80-
'class_exists',
81-
'interface_exists',
82-
'trait_exists',
83-
'enum_exists',
84-
'function_exists',
85-
], true)) {
91+
if ($functionName === 'function_exists') {
8692
return null;
8793
}
8894
if (in_array($functionName, ['count', 'sizeof'], true)) {
@@ -284,7 +290,12 @@ public function findSpecifiedType(
284290

285291
$rootExprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($rootExpr) : $scope->getNativeType($rootExpr);
286292
if ($rootExprType instanceof ConstantBooleanType) {
287-
return $rootExprType->getValue();
293+
$value = $rootExprType->getValue();
294+
if ($onlyReportFalse && $value === true) {
295+
return null;
296+
}
297+
298+
return $value;
288299
}
289300

290301
return null;
@@ -362,7 +373,16 @@ public function findSpecifiedType(
362373
}
363374

364375
$result = TrinaryLogic::createYes()->and(...$results);
365-
return $result->maybe() ? null : $result->yes();
376+
if ($result->maybe()) {
377+
return null;
378+
}
379+
380+
$value = $result->yes();
381+
if ($onlyReportFalse && $value === true) {
382+
return null;
383+
}
384+
385+
return $value;
366386
}
367387

368388
private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool

src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@
1313
use PHPStan\Analyser\TypeSpecifierContext;
1414
use PHPStan\DependencyInjection\AutowiredService;
1515
use PHPStan\Node\Expr\AlwaysRememberedExpr;
16+
use PHPStan\Reflection\ClassReflection;
1617
use PHPStan\Reflection\FunctionReflection;
18+
use PHPStan\Reflection\ReflectionProvider;
1719
use PHPStan\ShouldNotHappenException;
1820
use PHPStan\Type\BooleanType;
1921
use PHPStan\Type\ClassStringType;
2022
use PHPStan\Type\Constant\ConstantBooleanType;
2123
use PHPStan\Type\FunctionTypeSpecifyingExtension;
2224
use PHPStan\Type\Generic\GenericClassStringType;
25+
use PHPStan\Type\NeverType;
2326
use PHPStan\Type\ObjectType;
27+
use PHPStan\Type\Type;
2428
use function count;
2529
use function in_array;
2630
use function ltrim;
@@ -31,6 +35,10 @@ final class ClassExistsFunctionTypeSpecifyingExtension implements FunctionTypeSp
3135

3236
private TypeSpecifier $typeSpecifier;
3337

38+
public function __construct(private ReflectionProvider $reflectionProvider)
39+
{
40+
}
41+
3442
public function isFunctionSupported(
3543
FunctionReflection $functionReflection,
3644
FuncCall $node,
@@ -49,6 +57,16 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
4957
{
5058
$args = $node->getArgs();
5159
$argType = $scope->getType($args[0]->value);
60+
$functionName = $functionReflection->getName();
61+
62+
if ($this->isCallAlwaysFalse($functionName, $argType)) {
63+
return $this->typeSpecifier->create(
64+
$args[0]->value,
65+
new NeverType(),
66+
$context,
67+
$scope,
68+
);
69+
}
5270

5371
// class_exists() will only assure one of the classes to exist.
5472
$constantStrings = $argType->getConstantStrings();
@@ -101,4 +119,40 @@ public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
101119
$this->typeSpecifier = $typeSpecifier;
102120
}
103121

122+
private function isCallAlwaysFalse(string $functionName, Type $argType): bool
123+
{
124+
$constantStrings = $argType->getConstantStrings();
125+
if ($constantStrings === []) {
126+
return false;
127+
}
128+
129+
foreach ($constantStrings as $constantString) {
130+
$name = ltrim($constantString->getValue(), '\\');
131+
if (!$this->reflectionProvider->hasClass($name)) {
132+
return false;
133+
}
134+
if ($this->matchesFunctionKind($functionName, $this->reflectionProvider->getClass($name))) {
135+
return false;
136+
}
137+
}
138+
139+
return true;
140+
}
141+
142+
private function matchesFunctionKind(string $functionName, ClassReflection $reflection): bool
143+
{
144+
switch ($functionName) {
145+
case 'class_exists':
146+
return !$reflection->isInterface() && !$reflection->isTrait();
147+
case 'interface_exists':
148+
return $reflection->isInterface();
149+
case 'trait_exists':
150+
return $reflection->isTrait();
151+
case 'enum_exists':
152+
return $reflection->isEnum();
153+
default:
154+
return true;
155+
}
156+
}
157+
104158
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14683;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
interface SomeInterface {}
10+
class SomeClass {}
11+
trait SomeTrait {}
12+
enum SomeEnum {}
13+
14+
function classExistsOnConstantStringInterface(): void
15+
{
16+
if (class_exists(SomeInterface::class)) {
17+
assertType('*NEVER*', SomeInterface::class);
18+
}
19+
}
20+
21+
function classExistsOnConstantStringTrait(): void
22+
{
23+
if (class_exists(SomeTrait::class)) {
24+
assertType('*NEVER*', SomeTrait::class);
25+
}
26+
}
27+
28+
function interfaceExistsOnConstantStringClass(): void
29+
{
30+
if (interface_exists(SomeClass::class)) {
31+
assertType('*NEVER*', SomeClass::class);
32+
}
33+
}
34+
35+
function enumExistsOnConstantStringEnum(): void
36+
{
37+
if (enum_exists(SomeEnum::class)) {
38+
assertType('\'Bug14683\\\\SomeEnum\'', SomeEnum::class);
39+
}
40+
}
41+
42+
function classExistsOnEnumConstantString(): void
43+
{
44+
// enums are classes in PHP, so this is NOT impossible
45+
if (class_exists(SomeEnum::class)) {
46+
assertType('\'Bug14683\\\\SomeEnum\'', SomeEnum::class);
47+
}
48+
}

tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,25 @@ public function testBug7898(): void
284284
$this->analyse([__DIR__ . '/data/bug-7898.php'], []);
285285
}
286286

287+
#[RequiresPhp('>= 8.1.0')]
288+
public function testStructExists(): void
289+
{
290+
$this->treatPhpDocTypesAsCertain = true;
291+
$this->analyse([__DIR__ . '/data/check-type-function-call-struct-exists.php'], [
292+
['Call to function class_exists() with \'CheckTypeFunctionCall\\\\_Interface\' will always evaluate to false.', 23],
293+
['Call to function class_exists() with \'CheckTypeFunctionCall\\\\_Trait\' will always evaluate to false.', 25],
294+
['Call to function interface_exists() with \'CheckTypeFunctionCall\\\\_Enum\' will always evaluate to false.', 27],
295+
['Call to function interface_exists() with \'CheckTypeFunctionCall\\\\_Class\' will always evaluate to false.', 29],
296+
['Call to function interface_exists() with \'CheckTypeFunctionCall\\\\_Trait\' will always evaluate to false.', 30],
297+
['Call to function trait_exists() with \'CheckTypeFunctionCall\\\\_Enum\' will always evaluate to false.', 32],
298+
['Call to function trait_exists() with \'CheckTypeFunctionCall\\\\_Interface\' will always evaluate to false.', 33],
299+
['Call to function trait_exists() with \'CheckTypeFunctionCall\\\\_Class\' will always evaluate to false.', 34],
300+
['Call to function enum_exists() with \'CheckTypeFunctionCall\\\\_Interface\' will always evaluate to false.', 38],
301+
['Call to function enum_exists() with \'CheckTypeFunctionCall\\\\_Class\' will always evaluate to false.', 39],
302+
['Call to function enum_exists() with \'CheckTypeFunctionCall\\\\_Trait\' will always evaluate to false.', 40],
303+
]);
304+
}
305+
287306
public function testDoNotReportTypesFromPhpDocs(): void
288307
{
289308
$this->treatPhpDocTypesAsCertain = false;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php // lint >= 8.1
2+
3+
namespace CheckTypeFunctionCall;
4+
5+
// see https://3v4l.org/V9nmf
6+
7+
interface _Interface {}
8+
class _Class {}
9+
trait _Trait {}
10+
enum _Enum {}
11+
12+
class_exists($mixed);
13+
interface_exists($mixed);
14+
enum_exists($mixed);
15+
trait_exists($mixed);
16+
17+
class_exists();
18+
interface_exists();
19+
enum_exists();
20+
trait_exists();
21+
22+
class_exists(_Enum::class);
23+
class_exists(_Interface::class); // always false
24+
class_exists(_Class::class);
25+
class_exists(_Trait::class); // always false
26+
27+
interface_exists(_Enum::class); // always false
28+
interface_exists(_Interface::class);
29+
interface_exists(_Class::class); // always false
30+
interface_exists(_Trait::class); // always false
31+
32+
trait_exists(_Enum::class); // always false
33+
trait_exists(_Interface::class); // always false
34+
trait_exists(_Class::class); // always false
35+
trait_exists(_Trait::class);
36+
37+
enum_exists(_Enum::class);
38+
enum_exists(_Interface::class); // always false
39+
enum_exists(_Class::class); // always false
40+
enum_exists(_Trait::class); // always false
41+

0 commit comments

Comments
 (0)