diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index 81ea6c216b..1c0fec310f 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -1,6 +1,7 @@ parameters: featureToggles: bleedingEdge: true + checkNonStringableDynamicAccess: true checkParameterCastableToNumberFunctions: true skipCheckGenericClasses!: [] stricterFunctionMap: true diff --git a/conf/config.neon b/conf/config.neon index 73a8062c27..efdc585aa7 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -24,6 +24,7 @@ parameters: tooWideThrowType: true featureToggles: bleedingEdge: false + checkNonStringableDynamicAccess: false checkParameterCastableToNumberFunctions: false skipCheckGenericClasses: [] stricterFunctionMap: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 6af9e2737e..9c962d67ed 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -28,6 +28,7 @@ parametersSchema: ]) featureToggles: structure([ bleedingEdge: bool(), + checkNonStringableDynamicAccess: bool(), checkParameterCastableToNumberFunctions: bool(), skipCheckGenericClasses: listOf(string()), stricterFunctionMap: bool() diff --git a/src/Rules/Classes/ClassConstantRule.php b/src/Rules/Classes/ClassConstantRule.php index 3e3f6d3ead..a8220d5e11 100644 --- a/src/Rules/Classes/ClassConstantRule.php +++ b/src/Rules/Classes/ClassConstantRule.php @@ -5,9 +5,11 @@ use PhpParser\Node; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\ClassConstFetch; +use PhpParser\Node\Name; use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\Scope; +use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Internal\SprintfHelper; use PHPStan\Php\PhpVersion; @@ -42,6 +44,8 @@ public function __construct( private RuleLevelHelper $ruleLevelHelper, private ClassNameCheck $classCheck, private PhpVersion $phpVersion, + #[AutowiredParameter(ref: '%featureToggles.checkNonStringableDynamicAccess%')] + private bool $checkNonStringableDynamicAccess, ) { } @@ -63,6 +67,26 @@ public function processNode(Node $node, Scope $scope): array $name = $constantString->getValue(); $constantNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } + + if ($this->checkNonStringableDynamicAccess) { + $nameTypeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->name, + '', + static fn (Type $type) => $type->isString()->yes(), + ); + + $nameType = $nameTypeResult->getType(); + if (!$nameType instanceof ErrorType && !$nameType->isString()->yes()) { + $className = $node->class instanceof Name + ? $scope->resolveName($node->class) + : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); + + $errors[] = RuleErrorBuilder::message(sprintf('Class constant name for %s must be a string, but %s was given.', $className, $nameType->describe(VerbosityLevel::precise()))) + ->identifier('classConstant.nameNotString') + ->build(); + } + } } foreach ($constantNameScopes as $constantName => $constantScope) { diff --git a/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php b/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php index 3ecc4572f9..9d24119731 100644 --- a/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php +++ b/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php @@ -26,7 +26,7 @@ protected function getRule(): Rule $reflectionProvider = self::createReflectionProvider(); return new ClassConstantRule( $reflectionProvider, - new RuleLevelHelper($reflectionProvider, true, false, true, false, false, false, true), + new RuleLevelHelper($reflectionProvider, true, false, true, true, true, false, true), new ClassNameCheck( new ClassCaseSensitivityCheck($reflectionProvider, true), new ClassForbiddenNameCheck(self::getContainer()), @@ -34,6 +34,7 @@ protected function getRule(): Rule self::getContainer(), ), new PhpVersion($this->phpVersion), + true, ); } @@ -59,6 +60,10 @@ public function testClassConstant(): void 'Access to undefined constant ClassConstantNamespace\Foo::DOLOR.', 10, ], + [ + 'Cannot access constant LOREM on mixed.', + 11, + ], [ 'Access to undefined constant ClassConstantNamespace\Foo::DOLOR.', 16, @@ -439,6 +444,14 @@ public function testDynamicAccess(): void $this->phpVersion = PHP_VERSION_ID; $this->analyse([__DIR__ . '/data/dynamic-constant-access.php'], [ + [ + 'Access to undefined constant ClassConstantDynamicAccess\Foo::FOO.', + 17, + ], + [ + 'Class constant name for ClassConstantDynamicAccess\Foo must be a string, but object was given.', + 19, + ], [ 'Access to undefined constant ClassConstantDynamicAccess\Foo::FOO.', 20, @@ -474,4 +487,61 @@ public function testDynamicAccess(): void ]); } + #[RequiresPhp('>= 8.3')] + public function testStringableDynamicAccess(): void + { + $this->phpVersion = PHP_VERSION_ID; + + $this->analyse([__DIR__ . '/data/dynamic-constant-stringable-access.php'], [ + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Foo must be a string, but mixed was given.', + 14, + ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Foo must be a string, but string|null was given.', + 15, + ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Foo must be a string, but Stringable|null was given.', + 16, + ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Foo must be a string, but int was given.', + 17, + ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Foo must be a string, but int|null was given.', + 18, + ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Foo must be a string, but DateTime|string was given.', + 19, + ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Foo must be a string, but 1111 was given.', + 20, + ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Foo must be a string, but Stringable was given.', + 22, + ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Foo must be a string, but mixed was given.', + 32, + ], + [ + 'Class constant name for ClassConstantDynamicStringableAccess\Bar must be a string, but mixed was given.', + 33, + ], + [ + 'Class constant name for DateTime|DateTimeImmutable must be a string, but mixed was given.', + 38, + ], + [ + 'Class constant name for object must be a string, but mixed was given.', + 39, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/dynamic-constant-access.php b/tests/PHPStan/Rules/Classes/data/dynamic-constant-access.php index 10809e566a..09c0b176fe 100644 --- a/tests/PHPStan/Rules/Classes/data/dynamic-constant-access.php +++ b/tests/PHPStan/Rules/Classes/data/dynamic-constant-access.php @@ -14,7 +14,7 @@ public function test(string $string, object $obj): void { $bar = 'FOO'; - echo self::{$foo}; + echo self::{$bar}; echo self::{$string}; echo self::{$obj}; echo self::{$this->name}; @@ -44,5 +44,4 @@ public function testScope(): void echo self::{$name}; } - } diff --git a/tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php b/tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php new file mode 100644 index 0000000000..e944e19947 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php @@ -0,0 +1,42 @@ += 8.3 + +namespace ClassConstantDynamicStringableAccess; + +use Stringable; +use DateTime; +use DateTimeImmutable; + +abstract class Foo +{ + + public function test(mixed $mixed, ?string $nullableStr, ?Stringable $nullableStringable, int $int, ?int $nullableInt, DateTime|string $datetimeOrStr, Stringable $stringable): void + { + echo self::{$mixed}; + echo self::{$nullableStr}; + echo self::{$nullableStringable}; + echo self::{$int}; + echo self::{$nullableInt}; + echo self::{$datetimeOrStr}; + echo self::{1111}; + echo self::{(string)$stringable}; + echo self::{$stringable}; // Uncast Stringable objects will cause a runtime error + } + +} + +final class Bar extends Foo +{ + + public function test(mixed $mixed, ?string $nullableStr, ?Stringable $nullableStringable, int $int, ?int $nullableInt, DateTime|string $datetimeOrStr, Stringable $stringable): void + { + echo parent::{$mixed}; + echo self::{$mixed}; + } + + public function testClassDynamic(DateTime|DateTimeImmutable $datetime, object $obj, mixed $mixed): void + { + echo $datetime::{$mixed}; + echo $obj::{$mixed}; + } + +}