diff --git a/src/Rules/Properties/AccessStaticPropertiesCheck.php b/src/Rules/Properties/AccessStaticPropertiesCheck.php index 92742621326..d328a3b28f8 100644 --- a/src/Rules/Properties/AccessStaticPropertiesCheck.php +++ b/src/Rules/Properties/AccessStaticPropertiesCheck.php @@ -16,6 +16,7 @@ use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Internal\SprintfHelper; +use PHPStan\Node\Expr\TypeExpr; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\ClassNameCheck; @@ -175,9 +176,21 @@ private function processSingleProperty(Scope $scope, StaticPropertyFetch $node, $classType = $scope->resolveTypeByName($node->class); } } else { + $exprToCheck = NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $node->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. + $objectType = $scope->getType($exprToCheck)->getClassStringObjectType(); + if ($this->canCheckThroughObjectType($objectType)) { + $exprToCheck = new TypeExpr($objectType); + } $classTypeResult = $this->ruleLevelHelper->findTypeToCheck( $scope, - NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $node->class), + $exprToCheck, sprintf('Access to static property $%s on an unknown class %%s.', SprintfHelper::escapeFormatString($name)), static fn (Type $type): bool => $type->canAccessProperties()->yes() && $type->hasStaticProperty($name)->yes(), ); @@ -339,4 +352,25 @@ private function processSingleProperty(Scope $scope, StaticPropertyFetch $node, ]); } + /** + * 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/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php index e9e8421ef6c..9e2ab1435e3 100644 --- a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php @@ -109,4 +109,46 @@ public function testAsymmetricVisibility(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testWritesOnStringUnions(): void + { + $this->analyse([__DIR__ . '/data/static-property-string-unions-assign.php'], [ + [ + 'Access to an undefined static property StaticPropertyStringUnionsAssign\Baz::$missing.', + 32, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnionsAssign\Baz::$missing.', + 38, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnionsAssign\Baz::$missing.', + 44, + ], + [ + 'Access to private property $secret of class StaticPropertyStringUnionsAssign\Foo.', + 50, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnionsAssign\Baz|StaticPropertyStringUnionsAssign\Foo::$count.', + 56, + ], + ]); + } + + #[RequiresPhp('>= 8.5.0')] + public function testAsymmetricVisibilityOnConstantString(): void + { + $this->analyse([__DIR__ . '/data/static-property-string-unions-asymmetric.php'], [ + [ + 'Access to private(set) property $privateSet of class StaticPropertyStringUnionsAsymmetric\Foo.', + 16, + ], + [ + 'Access to protected(set) property $protectedSet of class StaticPropertyStringUnionsAsymmetric\Foo.', + 17, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php index b94650dcb48..02b28659528 100644 --- a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php @@ -21,6 +21,8 @@ class AccessStaticPropertiesRuleTest extends RuleTestCase { + private bool $checkUnionTypes = true; + protected function getRule(): Rule { $reflectionProvider = self::createReflectionProvider(); @@ -28,7 +30,7 @@ protected function getRule(): Rule $reflectionProvider, checkNullables: true, checkThisOnly: false, - checkUnionTypes: true, + checkUnionTypes: $this->checkUnionTypes, checkExplicitMixed: false, checkImplicitMixed: false, checkBenevolentUnionTypes: false, @@ -396,4 +398,95 @@ public function testBug15002(): void $this->analyse([__DIR__ . '/../Methods/data/bug-15002.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testStaticPropertyAccessOnStringUnions(): void + { + $this->analyse([__DIR__ . '/data/static-property-string-unions.php'], [ + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz::$create.', + 32, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz|StaticPropertyStringUnions\Foo::$create.', + 38, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz|StaticPropertyStringUnions\Foo::$create.', + 50, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz|StaticPropertyStringUnions\Foo::$create.', + 58, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz::$create.', + 66, + ], + [ + 'Static access to instance property StaticPropertyStringUnions\Foo::$doBar.', + 80, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.', + 118, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.', + 119, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.', + 120, + ], + [ + 'Access to private property $privateProperty of class StaticPropertyStringUnions\WithNonPublic.', + 126, + ], + [ + 'Access to protected property $protectedProperty of class StaticPropertyStringUnions\WithNonPublic.', + 127, + ], + ]); + } + + #[RequiresPhp('>= 8.0.0')] + public function testStaticPropertyAccessOnStringUnionsWithoutCheckUnionTypes(): void + { + $this->checkUnionTypes = false; + $this->analyse([__DIR__ . '/data/static-property-string-unions.php'], [ + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz::$create.', + 32, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz::$create.', + 66, + ], + [ + 'Static access to instance property StaticPropertyStringUnions\Foo::$doBar.', + 80, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.', + 118, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.', + 119, + ], + [ + 'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.', + 120, + ], + [ + 'Access to private property $privateProperty of class StaticPropertyStringUnions\WithNonPublic.', + 126, + ], + [ + 'Access to protected property $protectedProperty of class StaticPropertyStringUnions\WithNonPublic.', + 127, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/static-property-string-unions-assign.php b/tests/PHPStan/Rules/Properties/data/static-property-string-unions-assign.php new file mode 100644 index 00000000000..2d6d72b04e4 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/static-property-string-unions-assign.php @@ -0,0 +1,71 @@ += 8.0 + +namespace StaticPropertyStringUnionsAssign; + +class Foo +{ + + public static int $count = 0; + + private static int $secret = 0; + +} + +class Baz +{ + +} + +trait SomeTrait +{ + + public static int $traitProperty = 0; + +} + +class Runner +{ + + public function writeToMissingProperty(): void + { + $class = Baz::class; + $class::$missing = 1; + } + + public function incrementMissingProperty(): void + { + $class = Baz::class; + $class::$missing++; + } + + public function coalesceAssignMissingProperty(): void + { + $class = Baz::class; + $class::$missing ??= 1; + } + + public function writeToPrivateProperty(): void + { + $class = Foo::class; + $class::$secret = 1; + } + + public function writeToUnion(bool $flag): void + { + $class = $flag ? Foo::class : Baz::class; + $class::$count = 1; + } + + public function writeToExistingProperty(): void + { + $class = Foo::class; + $class::$count = 1; + } + + public function writeThroughTrait(): void + { + $trait = SomeTrait::class; + $trait::$traitProperty = 1; + } + +} diff --git a/tests/PHPStan/Rules/Properties/data/static-property-string-unions-asymmetric.php b/tests/PHPStan/Rules/Properties/data/static-property-string-unions-asymmetric.php new file mode 100644 index 00000000000..fc2605cc298 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/static-property-string-unions-asymmetric.php @@ -0,0 +1,19 @@ += 8.5 + +namespace StaticPropertyStringUnionsAsymmetric; + +class Foo +{ + + public private(set) static int $privateSet = 0; + + public protected(set) static int $protectedSet = 0; + +} + +function (): void { + $class = Foo::class; + $class::$privateSet = 1; + $class::$protectedSet = 1; + echo $class::$privateSet; +}; diff --git a/tests/PHPStan/Rules/Properties/data/static-property-string-unions.php b/tests/PHPStan/Rules/Properties/data/static-property-string-unions.php new file mode 100644 index 00000000000..ce078161341 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/static-property-string-unions.php @@ -0,0 +1,146 @@ += 8.0 + +namespace StaticPropertyStringUnions; + +class Foo +{ + + public static $create; + + public $doBar; + +} + +class Bar +{ + + public static $create; + +} + +class Baz +{ + +} + +class Runner +{ + + public function singleConstantString(): void + { + $class = Baz::class; + echo $class::$create; + } + + public function unionOfConstantStrings(bool $flag): void + { + $class = $flag ? Foo::class : Baz::class; + echo $class::$create; + } + + public function unionOfConstantStringsAllValid(bool $flag): void + { + $class = $flag ? Foo::class : Bar::class; + echo $class::$create; + } + + public function getClassUnion(Foo|Baz $object): void + { + $class = get_class($object); + echo $class::$create; + } + + /** + * @param class-string|class-string $class + */ + public function genericClassStringUnion(string $class): void + { + echo $class::$create; + } + + /** + * @param class-string $class + */ + public function singleGenericClassString(string $class): void + { + echo $class::$create; + } + + /** + * @param class-string $class + */ + public function plainClassString(string $class): void + { + echo $class::$create; + } + + public function instancePropertyThroughConstantString(): void + { + $class = Foo::class; + echo $class::$doBar; + } + + /** + * @param class-string $class + */ + public function propertyExistsNarrowing(string $class): void + { + if (property_exists($class, 'doesNotExistStatically')) { + echo $class::$doesNotExistStatically; + } + } + + /** + * @param class-string|class-string $class + */ + public function genericClassStringUnionAllValid(string $class): void + { + echo $class::$create; + } + + /** + * @param class-string $class + */ + public function singleGenericClassStringValid(string $class): void + { + echo $class::$create; + } + + public function traitThroughConstantString(): void + { + $trait = SomeTrait::class; + echo $trait::$traitProperty; + } + + public function issetOnMissingProperty(): void + { + $class = Baz::class; + echo isset($class::$missing) ? 1 : 0; + echo $class::$missing ?? 'default'; + echo empty($class::$missing) ? 1 : 0; + } + + public function visibilityThroughConstantString(): void + { + $class = WithNonPublic::class; + echo $class::$privateProperty; + echo $class::$protectedProperty; + } + +} + +trait SomeTrait +{ + + public static $traitProperty; + +} + +class WithNonPublic +{ + + private static $privateProperty; + + protected static $protectedProperty; + +}