diff --git a/src/Rules/Classes/ClassConstantRule.php b/src/Rules/Classes/ClassConstantRule.php index ba307f16227..71074191c1a 100644 --- a/src/Rules/Classes/ClassConstantRule.php +++ b/src/Rules/Classes/ClassConstantRule.php @@ -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; @@ -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; @@ -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(), ); @@ -266,4 +284,25 @@ private function processSingleClassConstFetch(Scope $scope, ClassConstFetch $nod return $messages; } + /** + * Class-strings without a class name (class-string, class-string, + * class-string) 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; + } + } diff --git a/src/Type/Php/DefinedConstantTypeSpecifyingExtension.php b/src/Type/Php/DefinedConstantTypeSpecifyingExtension.php index e371fbbcdbf..6afdc298b9e 100644 --- a/src/Type/Php/DefinedConstantTypeSpecifyingExtension.php +++ b/src/Type/Php/DefinedConstantTypeSpecifyingExtension.php @@ -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; @@ -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 @@ -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([], []); } @@ -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])); + } + } diff --git a/tests/PHPStan/Analyser/nsrt/defined-dynamic-class-constant.php b/tests/PHPStan/Analyser/nsrt/defined-dynamic-class-constant.php new file mode 100644 index 00000000000..0190ba0c776 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/defined-dynamic-class-constant.php @@ -0,0 +1,63 @@ + $class + */ + public function concat(string $class): void + { + if (defined($class . '::FOO')) { + assertType('mixed', $class::FOO); + } + } + + /** + * @param class-string $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 $class + */ + public function existingConstantKeepsItsType(string $class): void + { + assertType('1', $class::FOO); + if (defined($class . '::FOO')) { + assertType('1', $class::FOO); + } + } + +} diff --git a/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php b/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php index d8fe7c5c3d5..701fa80a831 100644 --- a/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php +++ b/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php @@ -23,6 +23,8 @@ class ClassConstantRuleTest extends RuleTestCase private bool $checkImportedClassNameCase = false; + private bool $checkUnionTypes = true; + protected function getRule(): Rule { $reflectionProvider = self::createReflectionProvider(); @@ -31,7 +33,7 @@ protected function getRule(): Rule $reflectionProvider, checkNullables: true, checkThisOnly: false, - checkUnionTypes: true, + checkUnionTypes: $this->checkUnionTypes, checkExplicitMixed: true, checkImplicitMixed: true, checkBenevolentUnionTypes: false, @@ -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, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/class-constant-string-unions.php b/tests/PHPStan/Rules/Classes/data/class-constant-string-unions.php new file mode 100644 index 00000000000..f0e279ffc1f --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/class-constant-string-unions.php @@ -0,0 +1,177 @@ += 8.2 + +namespace ClassConstantStringUnions; + +class Foo +{ + + const BAR = 1; + +} + +class Bar +{ + + const BAR = 2; + +} + +class Baz +{ + +} + +enum SomeEnum: string +{ + + case A = 'a'; + + const K = 1; + +} + +class Runner +{ + + public function singleConstantString(): void + { + $class = Baz::class; + echo $class::BAR; + } + + public function unionOfConstantStrings(bool $flag): void + { + $class = $flag ? Foo::class : Baz::class; + echo $class::BAR; + } + + public function unionOfConstantStringsAllValid(bool $flag): void + { + $class = $flag ? Foo::class : Bar::class; + echo $class::BAR; + } + + public function getClassUnion(Foo|Baz $object): void + { + $class = get_class($object); + echo $class::BAR; + } + + /** + * @param class-string|class-string $class + */ + public function genericClassStringUnion(string $class): void + { + echo $class::BAR; + } + + /** + * @param class-string $class + */ + public function singleGenericClassString(string $class): void + { + echo $class::BAR; + } + + /** + * @param class-string $class + */ + public function plainClassString(string $class): void + { + echo $class::BAR; + } + + public function classPseudoConstantOnConstantStringUnion(bool $flag): void + { + $class = $flag ? Foo::class : Baz::class; + echo $class::class; + } + + /** + * @param class-string $class + */ + public function classPseudoConstantOnGenericClassString(string $class): void + { + echo $class::class; + } + + public function enumCaseAccessThroughConstantString(): void + { + $class = SomeEnum::class; + echo $class::A->value; + } + + /** + * @param class-string $class + */ + public function enumCaseAccessThroughGenericClassString(string $class): void + { + echo $class::A->value; + } + + public function visibilityThroughConstantString(): void + { + $class = WithNonPublic::class; + echo $class::PRIVATE_CONST; + echo $class::PROTECTED_CONST; + } + + public function traitThroughConstantString(): void + { + $trait = SomeTrait::class; + echo $trait::TRAIT_CONST; + echo $trait::MISSING; + } + + /** + * @param class-string $class + */ + public function definedGuards(string $class): void + { + if (defined($class . '::BAR')) { + echo $class::BAR; + } + if (defined("$class::BAR")) { + echo $class::BAR; + } + if (defined("{$class}::BAR")) { + echo $class::BAR; + } + echo $class::BAR; + } + + /** + * @param list> $classNames + * @param array $map + */ + public function definedGuardOnInterface(array $classNames, array $map): void + { + foreach ($classNames as $className) { + if (defined($className . '::ENTITY_CLASSNAME') && isset($map[$className::ENTITY_CLASSNAME])) { + echo $className::ENTITY_CLASSNAME; + } + } + } + +} + +class WithNonPublic +{ + + private const PRIVATE_CONST = 1; + + protected const PROTECTED_CONST = 2; + +} + +trait SomeTrait +{ + + public const TRAIT_CONST = 1; + +} + +interface SomeInterface +{ + +}