Skip to content

Commit 257c8f8

Browse files
committed
Implement ArrayAccess->offsetExists narrowing
1 parent 06d592d commit 257c8f8

File tree

5 files changed

+198
-4
lines changed

5 files changed

+198
-4
lines changed

conf/config.neon

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,6 +1097,16 @@ services:
10971097
tags:
10981098
- phpstan.broker.dynamicFunctionReturnTypeExtension
10991099

1100+
-
1101+
class: PHPStan\Type\Php\ArrayAccessOffsetExistsMethodTypeSpecifyingExtension
1102+
tags:
1103+
- phpstan.typeSpecifier.methodTypeSpecifyingExtension
1104+
1105+
-
1106+
class: PHPStan\Type\Php\ArrayAccessOffsetGetMethodReturnTypeExtension
1107+
tags:
1108+
- phpstan.broker.dynamicMethodReturnTypeExtension
1109+
11001110
-
11011111
class: PHPStan\Type\Php\ArrayIntersectKeyFunctionReturnTypeExtension
11021112
tags:

src/Type/ObjectType.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,14 +1149,14 @@ public function hasOffsetValueType(Type $offsetType): TrinaryLogic
11491149

11501150
public function getOffsetValueType(Type $offsetType): Type
11511151
{
1152-
if (!$this->isExtraOffsetAccessibleClass()->no()) {
1153-
return new MixedType();
1154-
}
1155-
11561152
if ($this->isInstanceOf(ArrayAccess::class)->yes()) {
11571153
return RecursionGuard::run($this, fn (): Type => $this->getMethod('offsetGet', new OutOfClassScope())->getOnlyVariant()->getReturnType());
11581154
}
11591155

1156+
if (!$this->isExtraOffsetAccessibleClass()->no()) {
1157+
return new MixedType();
1158+
}
1159+
11601160
return new ErrorType();
11611161
}
11621162

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use ArrayAccess;
6+
use PhpParser\Node\Expr\MethodCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Analyser\SpecifiedTypes;
9+
use PHPStan\Analyser\TypeSpecifier;
10+
use PHPStan\Analyser\TypeSpecifierAwareExtension;
11+
use PHPStan\Analyser\TypeSpecifierContext;
12+
use PHPStan\Reflection\MethodReflection;
13+
use PHPStan\Type\Accessory\HasOffsetValueType;
14+
use PHPStan\Type\Constant\ConstantIntegerType;
15+
use PHPStan\Type\Constant\ConstantStringType;
16+
use PHPStan\Type\Generic\GenericObjectType;
17+
use PHPStan\Type\MethodTypeSpecifyingExtension;
18+
use function count;
19+
20+
final class ArrayAccessOffsetExistsMethodTypeSpecifyingExtension implements MethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
21+
{
22+
23+
private TypeSpecifier $typeSpecifier;
24+
25+
public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
26+
{
27+
$this->typeSpecifier = $typeSpecifier;
28+
}
29+
30+
public function getClass(): string
31+
{
32+
return ArrayAccess::class;
33+
}
34+
35+
public function isMethodSupported(
36+
MethodReflection $methodReflection,
37+
MethodCall $node,
38+
TypeSpecifierContext $context,
39+
): bool
40+
{
41+
return $methodReflection->getName() === 'offsetExists' && $context->true();
42+
}
43+
44+
public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
45+
{
46+
if (count($node->getArgs()) < 1) {
47+
return new SpecifiedTypes();
48+
}
49+
$key = $node->getArgs()[0]->value;
50+
$keyType = $scope->getType($key);
51+
52+
if (
53+
!$keyType instanceof ConstantStringType
54+
&& !$keyType instanceof ConstantIntegerType
55+
) {
56+
return new SpecifiedTypes();
57+
}
58+
59+
foreach ($scope->getType($node->var)->getObjectClassReflections() as $classReflection) {
60+
$implementsTags = $classReflection->getImplementsTags();
61+
62+
if (
63+
!isset($implementsTags[ArrayAccess::class])
64+
|| !$implementsTags[ArrayAccess::class]->getType() instanceof GenericObjectType
65+
) {
66+
continue;
67+
}
68+
69+
$implementsType = $implementsTags[ArrayAccess::class]->getType();
70+
$arrayAccessGenericTypes = $implementsType->getTypes();
71+
if (!isset($arrayAccessGenericTypes[1])) {
72+
continue;
73+
}
74+
75+
return $this->typeSpecifier->create(
76+
$node->var,
77+
new HasOffsetValueType($keyType, $arrayAccessGenericTypes[1]),
78+
$context,
79+
$scope,
80+
);
81+
}
82+
83+
return new SpecifiedTypes();
84+
}
85+
86+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use ArrayAccess;
6+
use PhpParser\Node\Expr\MethodCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\MethodReflection;
9+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
10+
use PHPStan\Type\Type;
11+
use function count;
12+
13+
final class ArrayAccessOffsetGetMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension
14+
{
15+
16+
public function getClass(): string
17+
{
18+
return ArrayAccess::class;
19+
}
20+
21+
public function isMethodSupported(
22+
MethodReflection $methodReflection,
23+
): bool
24+
{
25+
return $methodReflection->getName() === 'offsetGet';
26+
}
27+
28+
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
29+
{
30+
if (count($methodCall->getArgs()) < 1) {
31+
return null;
32+
}
33+
$key = $methodCall->getArgs()[0]->value;
34+
$keyType = $scope->getType($key);
35+
$objectType = $scope->getType($methodCall->var);
36+
37+
if (!$objectType->hasOffsetValueType($keyType)->yes()) {
38+
return null;
39+
}
40+
41+
return $objectType->getOffsetValueType($keyType);
42+
}
43+
44+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace Bug3323;
4+
5+
use function PHPStan\Testing\assertType;
6+
use ArrayAccess;
7+
8+
/**
9+
* @implements ArrayAccess<string, self>
10+
*/
11+
class FormView implements \ArrayAccess
12+
{
13+
public array $vars = [];
14+
15+
public function offsetExists($offset) {
16+
return array_key_exists($offset, $this->vars);
17+
}
18+
public function offsetGet($offset) {
19+
return $this->vars[$offset] ?? null;
20+
}
21+
public function offsetSet($offset, $value) {
22+
$this->vars[$offset] = $value;
23+
}
24+
public function offsetUnset($offset) {
25+
unset($this->vars[$offset]);
26+
}
27+
}
28+
29+
function doFoo() {
30+
$formView = new FormView();
31+
assertType('Bug3323\FormView', $formView);
32+
if ($formView->offsetExists('_token')) {
33+
assertType("Bug3323\FormView&hasOffsetValue('_token', Bug3323\FormView)", $formView);
34+
35+
$a = $formView->offsetGet('_token');
36+
assertType("Bug3323\FormView", $a);
37+
38+
$a = $formView->offsetGet(123);
39+
assertType("Bug3323\FormView|null", $a);
40+
} else {
41+
assertType('Bug3323\FormView', $formView);
42+
43+
$a = $formView->offsetGet('_token');
44+
assertType("Bug3323\FormView|null", $a); // could be "null" only
45+
}
46+
assertType('Bug3323\FormView', $formView);
47+
48+
$a = $formView->offsetGet('_token');
49+
assertType("Bug3323\FormView|null", $a);
50+
51+
$a = $formView->offsetGet(123);
52+
assertType("Bug3323\FormView|null", $a);
53+
}
54+

0 commit comments

Comments
 (0)