Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/Rules/Classes/ClassConstantRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Internal\SprintfHelper;
use PHPStan\Node\Expr\TypeExpr;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\ClassNameCheck;
use PHPStan\Rules\ClassNameNodePair;
Expand All @@ -26,6 +27,7 @@
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\VerbosityLevel;
use function array_merge;
use function count;
use function in_array;
use function sprintf;
use function strtolower;
Expand Down Expand Up @@ -182,9 +184,25 @@ private function processSingleClassConstFetch(Scope $scope, ClassConstFetch $nod
return $messages;
}
} else {
$exprToCheck = NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class);
if (strtolower($constantName) !== 'class') {
// A constant class-name string or a class-string type describes the
// object it names, so route the check through that object type. This
// makes constant-string / class-string unions behave exactly like
// object unions with regard to rule levels (member filtering when
// checkUnionTypes is off, whole-union check when it is on).
// Every other type (objects, or unions with members that are not
// class-strings) yields no class name here and keeps the old path.
// The ::class pseudo-constant is excluded above because
// $string::class is a runtime TypeError, handled below as a string.
$objectType = $scope->getType($exprToCheck)->getClassStringObjectType();
if ($this->canCheckThroughObjectType($objectType)) {
$exprToCheck = new TypeExpr($objectType);
}
}
$classTypeResult = $this->ruleLevelHelper->findTypeToCheck(
$scope,
NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class),
$exprToCheck,
sprintf('Access to constant %s on an unknown class %%s.', SprintfHelper::escapeFormatString($constantName)),
static fn (Type $type): bool => $type->canAccessConstants()->yes() && $type->hasConstant($constantName)->yes(),
);
Expand Down Expand Up @@ -266,4 +284,25 @@ private function processSingleClassConstFetch(Scope $scope, ClassConstFetch $nod
return $messages;
}

/**
* Class-strings without a class name (class-string, class-string<object>,
* class-string<T of object>) and class-strings naming a trait keep the
* string code path: RuleLevelHelper would filter the object type out or
* report the trait as an unknown class.
*/
private function canCheckThroughObjectType(Type $objectType): bool
{
if (count($objectType->getObjectClassNames()) === 0) {
return false;
}

foreach ($objectType->getObjectClassReflections() as $classReflection) {
if ($classReflection->isTrait()) {
return false;
}
}

return true;
}

}
59 changes: 56 additions & 3 deletions src/Type/Php/DefinedConstantTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr;
use PhpParser\Node\Expr\BinaryOp\Concat;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\InterpolatedStringPart;
use PhpParser\Node\Scalar\InterpolatedString;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
Expand All @@ -13,6 +19,8 @@
use PHPStan\Type\FunctionTypeSpecifyingExtension;
use PHPStan\Type\MixedType;
use function count;
use function preg_match;
use function strtolower;

#[AutowiredService]
final class DefinedConstantTypeSpecifyingExtension implements FunctionTypeSpecifyingExtension, TypeSpecifierAwareExtension
Expand Down Expand Up @@ -50,11 +58,16 @@ public function specifyTypes(
$args = $node->getArgs();
$constantNames = $scope->getType($args[0]->value)->getConstantStrings();

if (count($constantNames) !== 1 || $constantNames[0]->getValue() === '') {
return new SpecifiedTypes([], []);
if (count($constantNames) === 1) {
if ($constantNames[0]->getValue() === '') {
return new SpecifiedTypes([], []);
}

$expr = $this->constantHelper->createExprFromConstantName($constantNames[0]->getValue());
} else {
$expr = $this->createClassConstFetchFromDynamicName($args[0]->value, $scope);
}

$expr = $this->constantHelper->createExprFromConstantName($constantNames[0]->getValue());
if ($expr === null) {
return new SpecifiedTypes([], []);
}
Expand All @@ -67,4 +80,44 @@ public function specifyTypes(
);
}

/**
* Recognizes `$class . '::NAME'`, `"$class::NAME"` and `"{$class}::NAME"`
* so that the guarded `$class::NAME` fetch is narrowed.
*/
private function createClassConstFetchFromDynamicName(Expr $nameExpr, Scope $scope): ?ClassConstFetch
{
if ($nameExpr instanceof Concat) {
$classExpr = $nameExpr->left;
$suffixes = $scope->getType($nameExpr->right)->getConstantStrings();
if (count($suffixes) !== 1) {
return null;
}
$suffix = $suffixes[0]->getValue();
} elseif (
$nameExpr instanceof InterpolatedString
&& count($nameExpr->parts) === 2
&& $nameExpr->parts[0] instanceof Expr
&& $nameExpr->parts[1] instanceof InterpolatedStringPart
) {
$classExpr = $nameExpr->parts[0];
$suffix = $nameExpr->parts[1]->value;
} else {
return null;
}

if (!$scope->getType($classExpr)->isString()->yes()) {
return null;
}

if (preg_match('/^::([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)$/', $suffix, $matches) !== 1) {
return null;
}

if (strtolower($matches[1]) === 'class') {
return null;
}

return new ClassConstFetch($classExpr, new Identifier($matches[1]));
}

}
63 changes: 63 additions & 0 deletions tests/PHPStan/Analyser/nsrt/defined-dynamic-class-constant.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php declare(strict_types = 1);

namespace DefinedDynamicClassConstant;

use function PHPStan\Testing\assertType;

final class WithConstant
{

const FOO = 1;

}

class WithoutConstant
{

}

class Foo
{

/**
* @param class-string<WithoutConstant> $class
*/
public function concat(string $class): void
{
if (defined($class . '::FOO')) {
assertType('mixed', $class::FOO);
}
}

/**
* @param class-string<WithoutConstant> $class
*/
public function interpolated(string $class): void
{
if (defined("$class::FOO")) {
assertType('mixed', $class::FOO);
}
if (defined("{$class}::FOO")) {
assertType('mixed', $class::FOO);
}
}

public function plainString(string $class): void
{
if (defined($class . '::FOO')) {
assertType('mixed', $class::FOO);
}
}

/**
* @param class-string<WithConstant> $class
*/
public function existingConstantKeepsItsType(string $class): void
{
assertType('1', $class::FOO);
if (defined($class . '::FOO')) {
assertType('1', $class::FOO);
}
}

}
87 changes: 86 additions & 1 deletion tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class ClassConstantRuleTest extends RuleTestCase

private bool $checkImportedClassNameCase = false;

private bool $checkUnionTypes = true;

protected function getRule(): Rule
{
$reflectionProvider = self::createReflectionProvider();
Expand All @@ -31,7 +33,7 @@ protected function getRule(): Rule
$reflectionProvider,
checkNullables: true,
checkThisOnly: false,
checkUnionTypes: true,
checkUnionTypes: $this->checkUnionTypes,
checkExplicitMixed: true,
checkImplicitMixed: true,
checkBenevolentUnionTypes: false,
Expand Down Expand Up @@ -604,4 +606,87 @@ public function testConditionallyExecutedClassConstantOnExpression(): void
$this->analyse([__DIR__ . '/data/class-constant-on-expr-php-versions.php'], $errors);
}

#[RequiresPhp('>= 8.2.0')]
public function testClassConstantAccessOnStringUnions(): void
{
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
[
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
39,
],
[
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
45,
],
[
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
57,
],
[
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
65,
],
[
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
73,
],
[
'Accessing ::class constant on a dynamic string is not supported in PHP.',
87,
],
[
'Accessing ::class constant on a dynamic string is not supported in PHP.',
95,
],
[
'Access to private constant PRIVATE_CONST of class ClassConstantStringUnions\WithNonPublic.',
115,
],
[
'Access to protected constant PROTECTED_CONST of class ClassConstantStringUnions\WithNonPublic.',
116,
],
[
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
140,
],
]);
}

#[RequiresPhp('>= 8.2.0')]
public function testClassConstantAccessOnStringUnionsWithoutCheckUnionTypes(): void
{
$this->checkUnionTypes = false;
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
[
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
39,
],
[
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
73,
],
[
'Accessing ::class constant on a dynamic string is not supported in PHP.',
87,
],
[
'Accessing ::class constant on a dynamic string is not supported in PHP.',
95,
],
[
'Access to private constant PRIVATE_CONST of class ClassConstantStringUnions\WithNonPublic.',
115,
],
[
'Access to protected constant PROTECTED_CONST of class ClassConstantStringUnions\WithNonPublic.',
116,
],
[
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
140,
],
]);
}

}
Loading
Loading