diff --git a/src/Rules/Methods/MethodCallCheck.php b/src/Rules/Methods/MethodCallCheck.php index 0a5c11b454..cc1a1b79cf 100644 --- a/src/Rules/Methods/MethodCallCheck.php +++ b/src/Rules/Methods/MethodCallCheck.php @@ -6,6 +6,7 @@ use PhpParser\Node\Expr; use PhpParser\Node\Identifier; use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; @@ -65,6 +66,18 @@ public function check( if ($type instanceof StaticType) { $typeForDescribe = $type->getStaticObjectType(); } + $methodExistsCall = new Expr\FuncCall(new FullyQualified('method_exists'), [ + new Arg($var), + new Arg(new String_($methodName)), + ]); + if ($scope->getType($methodExistsCall)->isTrue()->yes()) { + if ($type->hasMethod($methodName)->yes()) { + return [[], $type->getMethod($methodName, $scope)]; + } + + return [[], null]; + } + if (!$type->canCallMethods()->yes() || $type->isClassString()->yes()) { return [ [ @@ -122,15 +135,20 @@ public function check( } } - if ($astName instanceof Expr) { + if ($astName instanceof Identifier) { + $methodExistsExpr = new Expr\FuncCall(new FullyQualified('method_exists'), [ + new Arg($var), + new Arg(new String_($methodName)), + ]); + } else { $methodExistsExpr = new Expr\FuncCall(new FullyQualified('method_exists'), [ new Arg($var), new Arg($astName), ]); + } - if ($scope->getType($methodExistsExpr)->isTrue()->yes()) { - return [[], null]; - } + if ($scope->getType($methodExistsExpr)->isTrue()->yes()) { + return [[], null]; } return [ diff --git a/src/Rules/Properties/AccessPropertiesCheck.php b/src/Rules/Properties/AccessPropertiesCheck.php index ce6717addf..a334cc2ef7 100644 --- a/src/Rules/Properties/AccessPropertiesCheck.php +++ b/src/Rules/Properties/AccessPropertiesCheck.php @@ -3,11 +3,11 @@ namespace PHPStan\Rules\Properties; use PhpParser\Node\Arg; -use PhpParser\Node\Expr; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Identifier; use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; @@ -119,6 +119,14 @@ private function processSingleProperty(Scope $scope, PropertyFetch $node, string $typeForDescribe = $type->getStaticObjectType(); } + $propertyExistsCall = new FuncCall(new FullyQualified('property_exists'), [ + new Arg($node->var), + new Arg(new String_($name)), + ]); + if ($scope->getType($propertyExistsCall)->isTrue()->yes() && !$type->canAccessProperties()->no()) { + return []; + } + if ($type->canAccessProperties()->no() || $type->canAccessProperties()->maybe() && !$scope->isUndefinedExpressionAllowed($node)) { return [ RuleErrorBuilder::message(sprintf( @@ -202,15 +210,20 @@ private function processSingleProperty(Scope $scope, PropertyFetch $node, string } } - if ($node->name instanceof Expr) { + if ($node->name instanceof Identifier) { + $propertyExistsExpr = new FuncCall(new FullyQualified('property_exists'), [ + new Arg($node->var), + new Arg(new String_($name)), + ]); + } else { $propertyExistsExpr = new FuncCall(new FullyQualified('property_exists'), [ new Arg($node->var), new Arg($node->name), ]); + } - if ($scope->getType($propertyExistsExpr)->isTrue()->yes()) { - return []; - } + if ($scope->getType($propertyExistsExpr)->isTrue()->yes()) { + return []; } if ($hasStatic->yes()) { diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php index 0422497f37..12a0cb9ee8 100644 --- a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -56,10 +56,10 @@ public function specifyTypes( return $this->createFuncCallSpec($node, $context, $scope); } - $objectType = $scope->getType($args[0]->value); - if ($objectType->isString()->yes()) { - if ($objectType->isClassString()->yes()) { - foreach ($objectType->getClassStringObjectType()->getObjectClassReflections() as $classReflection) { + $objectOrStringType = $scope->getType($args[0]->value); + if ($objectOrStringType->isString()->yes()) { + if ($objectOrStringType->isClassString()->yes()) { + foreach ($objectOrStringType->getClassStringObjectType()->getObjectClassReflections() as $classReflection) { if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { return $this->createFuncCallSpec($node, $context, $scope); } @@ -68,7 +68,7 @@ public function specifyTypes( return $this->typeSpecifier->create( $args[0]->value, new IntersectionType([ - $objectType, + $objectOrStringType, new HasMethodType($methodNameType->getValue()), ]), $context, @@ -79,7 +79,7 @@ public function specifyTypes( return new SpecifiedTypes([], []); } - foreach ($objectType->getObjectClassReflections() as $classReflection) { + foreach ($objectOrStringType->getObjectClassReflections() as $classReflection) { if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { return $this->createFuncCallSpec($node, $context, $scope); } @@ -92,11 +92,14 @@ public function specifyTypes( new ObjectWithoutClassType(), new HasMethodType($methodNameType->getValue()), ]), - new ClassStringType(), + new IntersectionType([ + new ClassStringType(), + new HasMethodType($methodNameType->getValue()), + ]), ]), $context, $scope, - ); + )->unionWith($this->createFuncCallSpec($node, $context, $scope)); } private function createFuncCallSpec(FuncCall $node, TypeSpecifierContext $context, Scope $scope): SpecifiedTypes diff --git a/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php b/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php index 2680750662..d3bb527813 100644 --- a/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php @@ -67,17 +67,8 @@ public function specifyTypes( return new SpecifiedTypes([], []); } - $objectType = $scope->getType($args[0]->value); - if ($objectType->isString()->yes()) { - return $this->typeSpecifier->create( - new FuncCall(new FullyQualified('property_exists'), $node->getRawArgs()), - new ConstantBooleanType(true), - $context, - $scope, - ); - } - - if (!$objectType->isObject()->yes()) { + $objectOrStringType = $scope->getType($args[0]->value); + if (!$objectOrStringType->isObject()->yes()) { return $this->typeSpecifier->create( $args[0]->value, new UnionType([ @@ -85,11 +76,14 @@ public function specifyTypes( new ObjectWithoutClassType(), new HasPropertyType($propertyNameType->getValue()), ]), - new ClassStringType(), + new IntersectionType([ + new ClassStringType(), + new HasPropertyType($propertyNameType->getValue()), + ]), ]), $context, $scope, - ); + )->unionWith($this->createFuncCallSpec($node, $context, $scope)); } $propertyNode = new PropertyFetch( diff --git a/src/Type/StringType.php b/src/Type/StringType.php index 8c5fc17f4a..7474c6bfd5 100644 --- a/src/Type/StringType.php +++ b/src/Type/StringType.php @@ -5,6 +5,9 @@ use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\Reflection\ClassMemberAccessAnswerer; +use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\MissingPropertyFromReflectionException; use PHPStan\Reflection\ReflectionProviderStaticAccessor; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; @@ -291,6 +294,38 @@ public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType return new BooleanType(); } + public function hasInstanceProperty(string $propertyName): TrinaryLogic + { + if ($this->isClassString()->yes()) { + return TrinaryLogic::createMaybe(); + } + return TrinaryLogic::createNo(); + } + + public function getInstanceProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + if ($this->isClassString()->yes()) { + throw new MissingPropertyFromReflectionException($this->describe(VerbosityLevel::typeOnly()), $propertyName); + } + throw new ShouldNotHappenException(); + } + + public function hasStaticProperty(string $propertyName): TrinaryLogic + { + if ($this->isClassString()->yes()) { + return TrinaryLogic::createMaybe(); + } + return TrinaryLogic::createNo(); + } + + public function getStaticProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + if ($this->isClassString()->yes()) { + throw new MissingPropertyFromReflectionException($this->describe(VerbosityLevel::typeOnly()), $propertyName); + } + throw new ShouldNotHappenException(); + } + public function hasMethod(string $methodName): TrinaryLogic { if ($this->isClassString()->yes()) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14667.php b/tests/PHPStan/Analyser/nsrt/bug-14667.php new file mode 100644 index 0000000000..aa4cd62f13 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14667.php @@ -0,0 +1,76 @@ += 8.0 + +namespace Bug14667Nsrt; + +use function PHPStan\Testing\assertType; + +/** @param mixed $row */ +function testMixed($row): void +{ + if (property_exists($row, 'prop')) { + assertType('(class-string&hasProperty(prop))|(object&hasProperty(prop))', $row); + } +} + +function testExplicitMixed(mixed $row): void +{ + if (property_exists($row, 'prop')) { + assertType('(class-string&hasProperty(prop))|(object&hasProperty(prop))', $row); + } +} + +/** @param mixed $row */ +function testMethodExistsMixed($row): void +{ + if (method_exists($row, 'foo')) { + assertType('(class-string&hasMethod(foo))|(object&hasMethod(foo))', $row); + $row->foo(); + } +} + +function testMethodExistsExplicitMixed(mixed $row): void +{ + if (method_exists($row, 'foo')) { + assertType('(class-string&hasMethod(foo))|(object&hasMethod(foo))', $row); + $row->foo(); + } +} + +/** @param object|string $row */ +function testMethodExistsObjectOrString($row): void +{ + if (method_exists($row, 'foo')) { + $row->foo(); + } +} + +function testMethodExistsObject(object $row): void +{ + if (method_exists($row, 'bar')) { + $row->bar(); + } +} + +/** @param mixed $x */ +function testMethodExistsMixedChained($x): void +{ + if (method_exists($x, 'getName') && $x->getName() !== null) { + echo $x->getName(); + } +} + +/** @param class-string|object $row */ +function testMethodExistsClassStringOrObject($row): void +{ + if (method_exists($row, 'foo')) { + $row->foo(); + } +} + +/** @param class-string $row */ +function testMethodExistsClassString(string $row): void +{ + if (method_exists($row, 'foo')) { + $row->foo(); // error: Cannot call method foo() on class-string. + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-2861.php b/tests/PHPStan/Analyser/nsrt/bug-2861.php index 8142e859f9..ebb1440780 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-2861.php +++ b/tests/PHPStan/Analyser/nsrt/bug-2861.php @@ -9,7 +9,7 @@ */ function testObjectOrString($objectOrClass): void { if (property_exists($objectOrClass, 'foo')) { - assertType('class-string|(object&hasProperty(foo))', $objectOrClass); + assertType('(class-string&hasProperty(foo))|(object&hasProperty(foo))', $objectOrClass); } } @@ -18,6 +18,6 @@ function testObjectOrString($objectOrClass): void { */ function testObjectOrClassString($objectOrClass): void { if (property_exists($objectOrClass, 'bar')) { - assertType('class-string|(object&hasProperty(bar))', $objectOrClass); + assertType('(class-string&hasProperty(bar))|(object&hasProperty(bar))', $objectOrClass); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-4573.php b/tests/PHPStan/Analyser/nsrt/bug-4573.php index 92fb321de5..b6b6012273 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4573.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4573.php @@ -23,7 +23,7 @@ class Foo public function doFoo($stringOrObject): void { if (is_callable([$stringOrObject, 'doFoo'])) { - assertType('Bug4573\Bar|class-string', $stringOrObject); + assertType('Bug4573\Bar|(class-string&hasMethod(doFoo))', $stringOrObject); } } @@ -33,7 +33,7 @@ public function doFoo($stringOrObject): void public function doBar($stringOrObject): void { if (method_exists($stringOrObject, 'doFoo')) { - assertType('Bug4573\Bar|class-string', $stringOrObject); + assertType('Bug4573\Bar|(class-string&hasMethod(doFoo))', $stringOrObject); } } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index d7a4d6766d..404ca6b378 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -427,10 +427,6 @@ public function testCallMethods(): void 'Call to an undefined method Test\Foo::lorem().', 911, ], - [ - 'Cannot call method foo() on class-string|object.', - 914, - ], [ 'Parameter #1 $callable of method Test\\MethodExists::doBar() expects callable(): mixed, array{class-string|object, \'foo\'} given.', 915, @@ -4106,4 +4102,18 @@ public function testBug14596(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testBug14667(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14667.php'], [ + [ + 'Cannot call method foo() on class-string.', + 74, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 47a70c9d02..9df45d830f 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -1033,4 +1033,32 @@ public function testBug14596(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testBug14667(): void + { + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/bug-14667-static.php'], [ + [ + 'Static call to instance method stdClass::foo().', + 9, + ], + [ + 'Static call to instance method stdClass::foo().', + 16, + ], + [ + 'Static call to instance method stdClass::foo().', + 24, + ], + [ + 'Static call to instance method stdClass::foo().', + 31, + ], + [ + 'Static call to instance method stdClass::foo().', + 39, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14667-static.php b/tests/PHPStan/Rules/Methods/data/bug-14667-static.php new file mode 100644 index 0000000000..50ed44ec4a --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14667-static.php @@ -0,0 +1,49 @@ += 8.0 + +namespace Bug14667StaticMethods; + +/** @param mixed $row */ +function testStaticImplicitMixed($row): void +{ + if (method_exists($row, 'foo')) { + $row::foo(); + } +} + +function testStaticExplicitMixed(mixed $row): void +{ + if (method_exists($row, 'foo')) { + $row::foo(); + } +} + +/** @param object|string $row */ +function testStaticObjectOrString($row): void +{ + if (method_exists($row, 'foo')) { + $row::foo(); + } +} + +function testStaticObject(object $row): void +{ + if (method_exists($row, 'foo')) { + $row::foo(); + } +} + +/** @param class-string|object $row */ +function testStaticClassStringOrObject($row): void +{ + if (method_exists($row, 'foo')) { + $row::foo(); + } +} + +/** @param class-string $row */ +function testStaticClassString(string $row): void +{ + if (method_exists($row, 'foo')) { + $row::foo(); + } +} diff --git a/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php b/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php index b07e632d17..01aea7cc36 100644 --- a/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php @@ -258,4 +258,9 @@ public function testBug2861(): void $this->analyse([__DIR__ . '/data/bug-2861-assign.php'], []); } + public function testBug14667(): void + { + $this->analyse([__DIR__ . '/data/bug-14667.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php index 0a6c124090..8ed0a9ac85 100644 --- a/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php @@ -1302,4 +1302,18 @@ public function testBug2861(): void $this->analyse([__DIR__ . '/data/bug-2861.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug14667(): void + { + $this->checkThisOnly = false; + $this->checkUnionTypes = true; + $this->checkDynamicProperties = true; + $this->analyse([__DIR__ . '/data/bug-14667.php'], [ + [ + 'Cannot access property $prop on class-string.', + 96, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php index a6e80a4dff..f79687d2cd 100644 --- a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php @@ -9,6 +9,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; use const PHP_VERSION_ID; /** @@ -358,4 +359,10 @@ public function testBug2861(): void $this->analyse([__DIR__ . '/data/bug-2861.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug14667(): void + { + $this->analyse([__DIR__ . '/data/bug-14667-static.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-14667-static.php b/tests/PHPStan/Rules/Properties/data/bug-14667-static.php new file mode 100644 index 0000000000..8b6744b282 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-14667-static.php @@ -0,0 +1,49 @@ += 8.0 + +namespace Bug14667Static; + +/** @param mixed $row */ +function testStaticImplicitMixed($row): void +{ + if (property_exists($row, 'prop')) { + echo $row::$prop; + } +} + +function testStaticExplicitMixed(mixed $row): void +{ + if (property_exists($row, 'prop')) { + echo $row::$prop; + } +} + +/** @param object|string $row */ +function testStaticObjectOrString($row): void +{ + if (property_exists($row, 'prop')) { + echo $row::$prop; + } +} + +function testStaticObject(object $row): void +{ + if (property_exists($row, 'prop')) { + echo $row::$prop; + } +} + +/** @param class-string|object $row */ +function testStaticClassStringOrObject($row): void +{ + if (property_exists($row, 'prop')) { + echo $row::$prop; + } +} + +/** @param class-string $row */ +function testStaticClassString(string $row): void +{ + if (property_exists($row, 'prop')) { + echo $row::$prop; + } +} diff --git a/tests/PHPStan/Rules/Properties/data/bug-14667.php b/tests/PHPStan/Rules/Properties/data/bug-14667.php new file mode 100644 index 0000000000..556a157a43 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-14667.php @@ -0,0 +1,98 @@ += 8.0 + +namespace Bug14667; + +/** @param mixed $row */ +function testImplicitMixed($row): void +{ + if (property_exists($row, 'prop')) { + echo $row->prop; + } +} + +function testExplicitMixed(mixed $row): void +{ + if (property_exists($row, 'prop')) { + echo $row->prop; + } +} + +/** @param object|string $row */ +function testObjectOrString($row): void +{ + if (property_exists($row, 'prop')) { + echo $row->prop; + } +} + +function testObject(object $row): void +{ + if (property_exists($row, 'prop')) { + echo $row->prop; + } +} + +final class Foo +{ + public function testThis(): void + { + if (property_exists($this, 'default')) { + echo $this->default; + } + } + + /** @param self $obj */ + public function testSelf(self $obj): void + { + if (property_exists($obj, 'default')) { + echo $obj->default; + } + } + + /** @param mixed $x */ + public function testMixedChained($x): void + { + if (property_exists($x, 'name') && $x->name !== null) { + echo $x->name; + } + } +} + +/** @param mixed $row */ +function testImplicitMixedAssign($row): void +{ + if (property_exists($row, 'prop')) { + $row->prop = 'value'; + } +} + +function testExplicitMixedAssign(mixed $row): void +{ + if (property_exists($row, 'prop')) { + $row->prop = 'value'; + } +} + +/** @param object|string $row */ +function testObjectOrStringAssign($row): void +{ + if (property_exists($row, 'prop')) { + $row->prop = 'value'; + } +} + +/** @param class-string|object $row */ +function testClassStringOrObject($row): void +{ + if (property_exists($row, 'prop')) { + echo $row->prop; + } +} + +/** @param class-string $row */ +function testClassString(string $row): void +{ + if (property_exists($row, 'prop')) { + echo $row->prop; // error: Cannot access property $prop on class-string. + } +}