Skip to content

Commit 53123fa

Browse files
authored
Fix phpstan/phpstan#11073: Nullsafe operator chaining (#5407)
1 parent 41ff9e0 commit 53123fa

File tree

7 files changed

+156
-5
lines changed

7 files changed

+156
-5
lines changed

src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PHPStan\DependencyInjection\AutowiredService;
1010
use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider;
1111
use PHPStan\Reflection\ParametersAcceptorSelector;
12+
use PHPStan\Type\ObjectType;
1213
use PHPStan\Type\Type;
1314
use PHPStan\Type\TypeCombinator;
1415
use function count;
@@ -52,7 +53,9 @@ public function methodCallReturnType(
5253
}
5354

5455
$resolvedTypes = [];
55-
foreach ($typeWithMethod->getObjectClassNames() as $className) {
56+
$allClassNames = $typeWithMethod->getObjectClassNames();
57+
$handledClassNames = [];
58+
foreach ($allClassNames as $className) {
5659
if ($normalizedMethodCall instanceof MethodCall) {
5760
foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) {
5861
if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) {
@@ -65,6 +68,7 @@ public function methodCallReturnType(
6568
}
6669

6770
$resolvedTypes[] = $resolvedType;
71+
$handledClassNames[] = $className;
6872
}
6973
} else {
7074
foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) {
@@ -82,11 +86,29 @@ public function methodCallReturnType(
8286
}
8387

8488
$resolvedTypes[] = $resolvedType;
89+
$handledClassNames[] = $className;
8590
}
8691
}
8792
}
8893

8994
if (count($resolvedTypes) > 0) {
95+
if (count($allClassNames) !== count($handledClassNames)) {
96+
$remainingType = $typeWithMethod;
97+
foreach ($handledClassNames as $handledClassName) {
98+
$remainingType = TypeCombinator::remove($remainingType, new ObjectType($handledClassName));
99+
}
100+
if ($remainingType->hasMethod($methodName)->yes()) {
101+
$remainingMethod = $remainingType->getMethod($methodName, $scope);
102+
$remainingParametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
103+
$scope,
104+
$methodCall->getArgs(),
105+
$remainingMethod->getVariants(),
106+
$remainingMethod->getNamedArgumentsVariants(),
107+
);
108+
$resolvedTypes[] = $remainingParametersAcceptor->getReturnType();
109+
}
110+
}
111+
90112
return VoidToNullTypeTransformer::transform(TypeCombinator::union(...$resolvedTypes), $methodCall);
91113
}
92114

src/Type/Php/DateTimeModifyReturnTypeExtension.php

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
use PHPStan\Type\Constant\ConstantBooleanType;
1212
use PHPStan\Type\DynamicMethodReturnTypeExtension;
1313
use PHPStan\Type\NeverType;
14+
use PHPStan\Type\ObjectType;
1415
use PHPStan\Type\Type;
1516
use PHPStan\Type\TypeCombinator;
17+
use PHPStan\Type\TypeTraverser;
18+
use PHPStan\Type\UnionType;
1619
use Throwable;
1720
use function count;
1821

@@ -39,11 +42,12 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
3942

4043
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
4144
{
42-
if (count($methodCall->getArgs()) < 1) {
45+
$args = $methodCall->getArgs();
46+
if (count($args) < 1) {
4347
return null;
4448
}
4549

46-
$valueType = $scope->getType($methodCall->getArgs()[0]->value);
50+
$valueType = $scope->getType($args[0]->value);
4751
$constantStrings = $valueType->getConstantStrings();
4852

4953
$hasFalse = false;
@@ -77,7 +81,25 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method
7781

7882
return null;
7983
} elseif ($hasDateTime) {
80-
return $scope->getType($methodCall->var);
84+
$callerType = $scope->getType($methodCall->var);
85+
86+
$dateTimeInterfaceType = new ObjectType(DateTimeInterface::class);
87+
if ($dateTimeInterfaceType->isSuperTypeOf($callerType)->yes()) {
88+
return $callerType;
89+
}
90+
91+
return TypeTraverser::map(
92+
$callerType,
93+
static function (Type $type, callable $traverse) use ($dateTimeInterfaceType): Type {
94+
if ($type instanceof UnionType) {
95+
return $traverse($type);
96+
}
97+
if ($dateTimeInterfaceType->isSuperTypeOf($type)->yes()) {
98+
return $type;
99+
}
100+
return new NeverType();
101+
},
102+
);
81103
}
82104

83105
if ($this->phpVersion->hasDateTimeExceptions()) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug11073Nsrt;
6+
7+
use DateTimeImmutable;
8+
use function PHPStan\Testing\assertType;
9+
10+
class HelloWorld
11+
{
12+
public function sayHello(?DateTimeImmutable $date): void
13+
{
14+
assertType('DateTimeImmutable|null', $date?->modify('+1 year')->setTime(23, 59, 59));
15+
}
16+
}
17+
18+
class Foo
19+
{
20+
public function getCode(): bool { return false; }
21+
}
22+
23+
class HelloWorld2
24+
{
25+
public function sayHello(\Throwable|Foo $foo): void
26+
{
27+
assertType('bool|int|string', $foo->getCode());
28+
}
29+
30+
public function sayHello2(\LogicException|Foo $foo): void
31+
{
32+
assertType('bool|int', $foo->getCode());
33+
}
34+
}

tests/PHPStan/Analyser/nsrt/date-format.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,27 @@ function (\DateTimeImmutable $dt, string $s): void {
4545
};
4646

4747
function (?\DateTimeImmutable $d): void {
48-
assertType('DateTimeImmutable|null', $d->modify('+1 day'));
48+
assertType('DateTimeImmutable', $d->modify('+1 day'));
49+
};
50+
51+
function (?\DateTimeImmutable $d): void {
52+
assertType('DateTimeImmutable|null', $d?->modify('+1 day'));
53+
};
54+
55+
class Foo extends \DateTimeImmutable {}
56+
class Bar {
57+
/** @return string */
58+
public function modify($string) {}
59+
}
60+
class Bar2 {
61+
/** @return string|false */
62+
public function modify($string) {}
63+
}
64+
65+
function foo(Foo|Bar $d): void {
66+
assertType('DateFormatReturnType\Foo|string', $d->modify('+1 day'));
67+
};
68+
69+
function foo2(Foo|Bar2 $d): void {
70+
assertType('DateFormatReturnType\Foo|string|false', $d->modify('+1 day'));
4971
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php // lint >= 8.1
2+
3+
namespace MethodCallReturnTypeFallback;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
enum Suit: string {
8+
case Hearts = 'hearts';
9+
case Diamonds = 'diamonds';
10+
}
11+
12+
class MyClass {
13+
/** @return self */
14+
public static function from(string $value): self {
15+
return new self();
16+
}
17+
}
18+
19+
/** @param class-string<Suit>|class-string<MyClass> $class */
20+
function testStaticCallOnUnionWithConstant(string $class): void {
21+
assertType('MethodCallReturnTypeFallback\MyClass|MethodCallReturnTypeFallback\Suit::Hearts', $class::from('hearts'));
22+
}
23+
24+
/** @param class-string<Suit>|class-string<MyClass> $class */
25+
function testStaticCallOnUnionWithVariable(string $class, string $value): void {
26+
assertType('MethodCallReturnTypeFallback\MyClass|MethodCallReturnTypeFallback\Suit', $class::from($value));
27+
}

tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3945,6 +3945,15 @@ public function testBug7369(): void
39453945
]);
39463946
}
39473947

3948+
#[RequiresPhp('>= 8.0')]
3949+
public function testBug11073(): void
3950+
{
3951+
$this->checkThisOnly = false;
3952+
$this->checkNullables = true;
3953+
$this->checkUnionTypes = true;
3954+
$this->analyse([__DIR__ . '/data/bug-11073.php'], []);
3955+
}
3956+
39483957
public function testBug11463(): void
39493958
{
39503959
$this->checkThisOnly = false;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug11073;
6+
7+
use DateTimeImmutable;
8+
9+
class HelloWorld
10+
{
11+
public function sayHello(?DateTimeImmutable $date): ?DateTimeImmutable
12+
{
13+
return $date?->modify('+1 year')->setTime(23, 59, 59);
14+
}
15+
}

0 commit comments

Comments
 (0)