Skip to content

Commit 28f6ffe

Browse files
authored
Don't report "no value type specified in iterable type array&callable" (#5565)
1 parent 6a76584 commit 28f6ffe

7 files changed

Lines changed: 134 additions & 18 deletions

File tree

src/Rules/MissingTypehintCheck.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,23 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array
7373
if ($type instanceof AccessoryType) {
7474
return $type;
7575
}
76+
if (
77+
$type instanceof IntersectionType
78+
&& $type->isCallable()->yes()
79+
&& $type->isArray()->yes()
80+
) {
81+
$nonArrayInner = [];
82+
foreach ($type->getTypes() as $innerType) {
83+
if ($innerType->isArray()->yes()) {
84+
continue;
85+
}
86+
$nonArrayInner[] = $innerType;
87+
}
88+
if (count($nonArrayInner) === 1) {
89+
return $traverse($nonArrayInner[0]);
90+
}
91+
return $traverse(new IntersectionType($nonArrayInner));
92+
}
7693
if ($type instanceof ConditionalType || $type instanceof ConditionalTypeForParameter) {
7794
$iterablesWithMissingValueTypehint = array_merge(
7895
$iterablesWithMissingValueTypehint,

src/Type/IntersectionType.php

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,9 @@ public function getArraySize(): Type
782782

783783
public function getIterableKeyType(): Type
784784
{
785+
if ($this->isCallable()->yes() && $this->isArray()->yes()) {
786+
return new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]);
787+
}
785788
return $this->intersectTypes(static fn (Type $type): Type => $type->getIterableKeyType());
786789
}
787790

@@ -797,7 +800,17 @@ public function getLastIterableKeyType(): Type
797800

798801
public function getIterableValueType(): Type
799802
{
800-
return $this->intersectTypes(static fn (Type $type): Type => $type->getIterableValueType());
803+
$result = $this->intersectTypes(static fn (Type $type): Type => $type->getIterableValueType());
804+
if ($this->isCallable()->yes() && $this->isArray()->yes()) {
805+
return TypeCombinator::intersect(
806+
$result,
807+
new UnionType([
808+
new ObjectWithoutClassType(),
809+
new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]),
810+
]),
811+
);
812+
}
813+
return $result;
801814
}
802815

803816
public function getFirstIterableValueType(): Type
@@ -960,17 +973,15 @@ private function doHasOffsetValueType(Type $offsetType): TrinaryLogic
960973
}
961974
}
962975

963-
$result = $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType));
964-
965-
if (!$result->yes() && $this->isCallable()->yes() && $this->isArray()->yes()) {
976+
if ($this->isCallable()->yes() && $this->isArray()->yes()) {
966977
$arrayKeyOffsetType = $offsetType->toArrayKey();
967978
$callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]);
968979
if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
969980
return TrinaryLogic::createYes();
970981
}
971982
}
972983

973-
return $result;
984+
return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType));
974985
}
975986

976987
public function getOffsetValueType(Type $offsetType): Type
@@ -991,17 +1002,14 @@ private function doGetOffsetValueType(Type $offsetType): Type
9911002

9921003
if ($this->isCallable()->yes() && $this->isArray()->yes()) {
9931004
$arrayKeyOffsetType = $offsetType->toArrayKey();
994-
$callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]);
995-
if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
996-
if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
997-
$narrowedType = new UnionType([new ClassStringType(), new ObjectWithoutClassType()]);
998-
} elseif ((new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
999-
$narrowedType = new StringType();
1000-
} else {
1001-
$narrowedType = new UnionType([new StringType(), new ObjectWithoutClassType()]);
1002-
}
1003-
$result = TypeCombinator::intersect($result, $narrowedType);
1005+
if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
1006+
$narrowedType = new UnionType([new ClassStringType(), new ObjectWithoutClassType()]);
1007+
} elseif ((new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
1008+
$narrowedType = new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]);
1009+
} else {
1010+
$narrowedType = new UnionType([new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]), new ObjectWithoutClassType()]);
10041011
}
1012+
$result = TypeCombinator::intersect($result, $narrowedType);
10051013
}
10061014

10071015
return $result;

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ private static function findTestFiles(): iterable
190190
yield __DIR__ . '/../Rules/Variables/data/bug-7417.php';
191191
yield __DIR__ . '/../Rules/Arrays/data/bug-7469.php';
192192
yield __DIR__ . '/../Rules/Variables/data/bug-3391.php';
193+
yield __DIR__ . '/../Rules/Methods/data/bug-14549.php';
193194

194195
yield __DIR__ . '/../Rules/Functions/data/bug-anonymous-function-method-constant.php';
195196

tests/PHPStan/Analyser/nsrt/bug-3842.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,15 @@ function testIsArrayOnCallable(callable $value): void {
2222
if (is_array($value)) {
2323
assertType('array<mixed, mixed>&callable(): mixed', $value);
2424
assertType('class-string|object', $value[0]);
25-
assertType('string', $value[1]);
25+
assertType('non-falsy-string', $value[1]);
2626
}
2727
}
2828

2929
/** @param callable-array $value */
3030
function testCallableArrayPhpDoc(array $value): void {
3131
assertType('array&callable(): mixed', $value);
3232
assertType('class-string|object', $value[0]);
33-
assertType('string', $value[1]);
33+
assertType('non-falsy-string', $value[1]);
3434
}
3535

3636
function testIsStringOnCallable(callable $value): void {
@@ -50,7 +50,7 @@ function checkClassString(array $values): void {
5050
/** @param 0|1 $offset */
5151
function testCallableArrayUnionOffset(callable $value, int $offset): void {
5252
if (is_array($value)) {
53-
assertType('object|string', $value[$offset]);
53+
assertType('object|non-falsy-string', $value[$offset]);
5454
}
5555
}
5656

tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,24 @@ public function testBug7662(): void
148148
]);
149149
}
150150

151+
public function testBug14549(): void
152+
{
153+
$this->analyse([__DIR__ . '/data/bug-14549.php'], [
154+
[
155+
'Method Bug14549\Foo::doFoo() has parameter $task with no signature specified for callable.',
156+
12,
157+
],
158+
[
159+
'Method Bug14549\Foo::doIntersection() has parameter $array with no value type specified in iterable type array.',
160+
46,
161+
MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP,
162+
],
163+
[
164+
'Method Bug14549\Foo::doIntersection() has parameter $array with no value type specified in iterable type array.',
165+
46,
166+
MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP,
167+
],
168+
]);
169+
}
170+
151171
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace Bug14549;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Foo
8+
{
9+
/**
10+
* @param callable-array $task
11+
*/
12+
public function doFoo(array $task): void
13+
{
14+
foreach($task as $k => $v) {
15+
assertType('0|1', $k);
16+
assertType('object|non-falsy-string', $v);
17+
}
18+
assertType('class-string|object', $task[0]);
19+
assertType('non-falsy-string', $task[1]);
20+
}
21+
22+
/**
23+
* @param non-empty-list<string> $list
24+
*/
25+
public function doBar(array $list): void
26+
{
27+
if ($list[0] !== '') {
28+
assertType('non-empty-list<string>&hasOffsetValue(0, non-empty-string)', $list);
29+
30+
if (is_callable($list)) {
31+
assertType('non-empty-list<string>&callable(): mixed&hasOffsetValue(0, non-empty-string)', $list);
32+
assertType('non-empty-string', $list[0]);
33+
assertType('non-falsy-string', $list[1]);
34+
35+
foreach($list as $k => $v) {
36+
assertType('0|1', $k);
37+
assertType('non-falsy-string', $v);
38+
}
39+
}
40+
}
41+
}
42+
43+
/**
44+
* @param (array&callable(array): array) $array
45+
*/
46+
public function doIntersection($array): void
47+
{
48+
}
49+
50+
}
51+
52+

tests/PHPStan/Type/Constant/ConstantStringTypeTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,4 +185,22 @@ public function testSetInvalidValue(): void
185185
$this->assertInstanceOf(ErrorType::class, $result);
186186
}
187187

188+
#[DataProvider('dataIsCallable')]
189+
public function testIsCallable(TrinaryLogic $trinaryLogic, string $constantValue): void
190+
{
191+
$this->assertSame(
192+
$trinaryLogic,
193+
(new ConstantStringType($constantValue))->isCallable(),
194+
);
195+
}
196+
197+
public static function dataIsCallable(): iterable
198+
{
199+
yield [TrinaryLogic::createNo(), ''];
200+
yield [TrinaryLogic::createNo(), '0'];
201+
yield [TrinaryLogic::createYes(), 'substr'];
202+
yield [TrinaryLogic::createYes(), self::class . '::dataIsCallable'];
203+
yield [TrinaryLogic::createMaybe(), self::class . '::methodDoesNotExist'];
204+
}
205+
188206
}

0 commit comments

Comments
 (0)