diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index 0e9bc9149ef..da565b78d8d 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -9,6 +9,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function count; @@ -52,7 +53,9 @@ public function methodCallReturnType( } $resolvedTypes = []; - foreach ($typeWithMethod->getObjectClassNames() as $className) { + $allClassNames = $typeWithMethod->getObjectClassNames(); + $handledClassNames = []; + foreach ($allClassNames as $className) { if ($normalizedMethodCall instanceof MethodCall) { foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) { @@ -65,6 +68,7 @@ public function methodCallReturnType( } $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; } } else { foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { @@ -82,11 +86,29 @@ public function methodCallReturnType( } $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; } } } if (count($resolvedTypes) > 0) { + if (count($allClassNames) !== count($handledClassNames)) { + $remainingType = $typeWithMethod; + foreach ($handledClassNames as $handledClassName) { + $remainingType = TypeCombinator::remove($remainingType, new ObjectType($handledClassName)); + } + if ($remainingType->hasMethod($methodName)->yes()) { + $remainingMethod = $remainingType->getMethod($methodName, $scope); + $remainingParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $remainingMethod->getVariants(), + $remainingMethod->getNamedArgumentsVariants(), + ); + $resolvedTypes[] = $remainingParametersAcceptor->getReturnType(); + } + } + return VoidToNullTypeTransformer::transform(TypeCombinator::union(...$resolvedTypes), $methodCall); } diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index 0ed2933856d..9b927a19780 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -11,8 +11,11 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\NeverType; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; +use PHPStan\Type\UnionType; use Throwable; use function count; @@ -39,11 +42,12 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { - if (count($methodCall->getArgs()) < 1) { + $args = $methodCall->getArgs(); + if (count($args) < 1) { return null; } - $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $valueType = $scope->getType($args[0]->value); $constantStrings = $valueType->getConstantStrings(); $hasFalse = false; @@ -77,7 +81,25 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } elseif ($hasDateTime) { - return $scope->getType($methodCall->var); + $callerType = $scope->getType($methodCall->var); + + $dateTimeInterfaceType = new ObjectType(DateTimeInterface::class); + if ($dateTimeInterfaceType->isSuperTypeOf($callerType)->yes()) { + return $callerType; + } + + return TypeTraverser::map( + $callerType, + static function (Type $type, callable $traverse) use ($dateTimeInterfaceType): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + if ($dateTimeInterfaceType->isSuperTypeOf($type)->yes()) { + return $type; + } + return new NeverType(); + }, + ); } if ($this->phpVersion->hasDateTimeExceptions()) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-11073.php b/tests/PHPStan/Analyser/nsrt/bug-11073.php new file mode 100644 index 00000000000..b61f9f03ea8 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11073.php @@ -0,0 +1,34 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11073Nsrt; + +use DateTimeImmutable; +use function PHPStan\Testing\assertType; + +class HelloWorld +{ + public function sayHello(?DateTimeImmutable $date): void + { + assertType('DateTimeImmutable|null', $date?->modify('+1 year')->setTime(23, 59, 59)); + } +} + +class Foo +{ + public function getCode(): bool { return false; } +} + +class HelloWorld2 +{ + public function sayHello(\Throwable|Foo $foo): void + { + assertType('bool|int|string', $foo->getCode()); + } + + public function sayHello2(\LogicException|Foo $foo): void + { + assertType('bool|int', $foo->getCode()); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/date-format.php b/tests/PHPStan/Analyser/nsrt/date-format.php index e8a68785216..2f11a334717 100644 --- a/tests/PHPStan/Analyser/nsrt/date-format.php +++ b/tests/PHPStan/Analyser/nsrt/date-format.php @@ -45,5 +45,27 @@ function (\DateTimeImmutable $dt, string $s): void { }; function (?\DateTimeImmutable $d): void { - assertType('DateTimeImmutable|null', $d->modify('+1 day')); + assertType('DateTimeImmutable', $d->modify('+1 day')); +}; + +function (?\DateTimeImmutable $d): void { + assertType('DateTimeImmutable|null', $d?->modify('+1 day')); +}; + +class Foo extends \DateTimeImmutable {} +class Bar { + /** @return string */ + public function modify($string) {} +} +class Bar2 { + /** @return string|false */ + public function modify($string) {} +} + +function foo(Foo|Bar $d): void { + assertType('DateFormatReturnType\Foo|string', $d->modify('+1 day')); +}; + +function foo2(Foo|Bar2 $d): void { + assertType('DateFormatReturnType\Foo|string|false', $d->modify('+1 day')); }; diff --git a/tests/PHPStan/Analyser/nsrt/method-call-return-type-fallback.php b/tests/PHPStan/Analyser/nsrt/method-call-return-type-fallback.php new file mode 100644 index 00000000000..9639e09e543 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/method-call-return-type-fallback.php @@ -0,0 +1,27 @@ += 8.1 + +namespace MethodCallReturnTypeFallback; + +use function PHPStan\Testing\assertType; + +enum Suit: string { + case Hearts = 'hearts'; + case Diamonds = 'diamonds'; +} + +class MyClass { + /** @return self */ + public static function from(string $value): self { + return new self(); + } +} + +/** @param class-string|class-string $class */ +function testStaticCallOnUnionWithConstant(string $class): void { + assertType('MethodCallReturnTypeFallback\MyClass|MethodCallReturnTypeFallback\Suit::Hearts', $class::from('hearts')); +} + +/** @param class-string|class-string $class */ +function testStaticCallOnUnionWithVariable(string $class, string $value): void { + assertType('MethodCallReturnTypeFallback\MyClass|MethodCallReturnTypeFallback\Suit', $class::from($value)); +} diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 686afb3f8be..2bbd2621e0f 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3945,6 +3945,15 @@ public function testBug7369(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testBug11073(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-11073.php'], []); + } + public function testBug11463(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/data/bug-11073.php b/tests/PHPStan/Rules/Methods/data/bug-11073.php new file mode 100644 index 00000000000..1f0d0301ff7 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-11073.php @@ -0,0 +1,15 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11073; + +use DateTimeImmutable; + +class HelloWorld +{ + public function sayHello(?DateTimeImmutable $date): ?DateTimeImmutable + { + return $date?->modify('+1 year')->setTime(23, 59, 59); + } +}