Skip to content

Commit 64fbc7b

Browse files
committed
Narrow array&callable intersection to list{class-string|object, string}
When TypeCombinator::intersect() combines an ArrayType with a CallableType, replace the plain ArrayType with a ConstantArrayType representing array{class-string|object, string}. This reflects the fact that callable arrays in PHP are always two-element lists with a class-string or object at index 0 and a method name string at index 1. This fixes the issue where array&callable was too wide, causing: - Iteration over the intersection to yield mixed keys/values - Parameter type checking to accept array&callable where array<int> is expected Fixes phpstan/phpstan#14549
1 parent dc2650e commit 64fbc7b

8 files changed

Lines changed: 141 additions & 5 deletions

File tree

phpstan-baseline.neon

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1692,13 +1692,13 @@ parameters:
16921692
-
16931693
rawMessage: 'Doing instanceof PHPStan\Type\ArrayType is error-prone and deprecated. Use Type::isArray() or Type::getArrays() instead.'
16941694
identifier: phpstanApi.instanceofType
1695-
count: 5
1695+
count: 7
16961696
path: src/Type/TypeCombinator.php
16971697

16981698
-
16991699
rawMessage: 'Doing instanceof PHPStan\Type\CallableType is error-prone and deprecated. Use Type::isCallable() and Type::getCallableParametersAcceptors() instead.'
17001700
identifier: phpstanApi.instanceofType
1701-
count: 1
1701+
count: 3
17021702
path: src/Type/TypeCombinator.php
17031703

17041704
-

src/Type/TypeCombinator.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1683,6 +1683,46 @@ public static function intersect(Type ...$types): Type
16831683
continue;
16841684
}
16851685

1686+
if (
1687+
$types[$i] instanceof ArrayType
1688+
&& get_class($types[$i]) === ArrayType::class
1689+
&& $types[$j] instanceof CallableType
1690+
) {
1691+
$existingValueType = $types[$i]->getItemType();
1692+
$offset0ValueType = self::intersect($existingValueType, new UnionType([new ClassStringType(), new ObjectWithoutClassType()]));
1693+
$offset1ValueType = self::intersect($existingValueType, new StringType());
1694+
if ($offset0ValueType instanceof NeverType || $offset1ValueType instanceof NeverType) {
1695+
return new NeverType();
1696+
}
1697+
$types[$i] = new ConstantArrayType(
1698+
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
1699+
[$offset0ValueType, $offset1ValueType],
1700+
[2],
1701+
isList: TrinaryLogic::createYes(),
1702+
);
1703+
continue;
1704+
}
1705+
1706+
if (
1707+
$types[$j] instanceof ArrayType
1708+
&& get_class($types[$j]) === ArrayType::class
1709+
&& $types[$i] instanceof CallableType
1710+
) {
1711+
$existingValueType = $types[$j]->getItemType();
1712+
$offset0ValueType = self::intersect($existingValueType, new UnionType([new ClassStringType(), new ObjectWithoutClassType()]));
1713+
$offset1ValueType = self::intersect($existingValueType, new StringType());
1714+
if ($offset0ValueType instanceof NeverType || $offset1ValueType instanceof NeverType) {
1715+
return new NeverType();
1716+
}
1717+
$types[$j] = new ConstantArrayType(
1718+
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
1719+
[$offset0ValueType, $offset1ValueType],
1720+
[2],
1721+
isList: TrinaryLogic::createYes(),
1722+
);
1723+
continue;
1724+
}
1725+
16861726
continue;
16871727
}
16881728

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ class CallableArray {
160160

161161
public function doFoo(callable $foo): void {
162162
$this->foo = $foo;
163-
assertType('array', $this->foo); // could be non-empty-array
163+
assertType('array<mixed>', $this->foo); // could be non-empty-array
164164
}
165165
}
166166

tests/PHPStan/Analyser/nsrt/bug-12393b.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,7 @@ class CallableArray {
669669

670670
public function doFoo(callable $foo): void {
671671
$this->foo = $foo;
672-
assertType('array', $this->foo); // could be non-empty-array
672+
assertType('array<mixed>', $this->foo); // could be non-empty-array
673673
}
674674
}
675675

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14549;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Foo
8+
{
9+
public function foo(array $task): void
10+
{
11+
if (\is_callable($task)) {
12+
assertType('list{class-string|object, string}&callable(): mixed', $task);
13+
assertType('class-string|object', $task[0]);
14+
assertType('string', $task[1]);
15+
16+
foreach ($task as $key => $value) {
17+
assertType('object|string', $value);
18+
assertType('0|1', $key);
19+
}
20+
}
21+
}
22+
23+
public function testCallableArrayIterableTypes(callable $value): void
24+
{
25+
if (is_array($value)) {
26+
assertType('list{class-string|object, string}&callable(): mixed', $value);
27+
28+
foreach ($value as $key => $val) {
29+
assertType('0|1', $key);
30+
assertType('object|string', $val);
31+
}
32+
}
33+
}
34+
35+
/** @param array{string, string} $task */
36+
public function testConstantArrayNarrowing(array $task): void
37+
{
38+
if (\is_callable($task)) {
39+
// ConstantArrayType keeps its shape, callable narrows offset access
40+
assertType('list{string, string}&callable(): mixed', $task);
41+
assertType('class-string', $task[0]);
42+
assertType('string', $task[1]);
43+
}
44+
}
45+
46+
/** @param array<string> $task */
47+
public function testTypedArrayNarrowing(array $task): void
48+
{
49+
if (\is_callable($task)) {
50+
// When value type is string, intersect with class-string|object gives class-string
51+
// and intersect with string gives string
52+
assertType('list{class-string, string}&callable(): mixed', $task);
53+
}
54+
}
55+
56+
/** @param callable-array $value */
57+
public function testCallableArrayPhpDoc(array $value): void
58+
{
59+
assertType('array&callable(): mixed', $value);
60+
assertType('class-string|object', $value[0]);
61+
assertType('string', $value[1]);
62+
}
63+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public function callback(): void
2020

2121
function testIsArrayOnCallable(callable $value): void {
2222
if (is_array($value)) {
23-
assertType('array<mixed, mixed>&callable(): mixed', $value);
23+
assertType('list{class-string|object, string}&callable(): mixed', $value);
2424
assertType('class-string|object', $value[0]);
2525
assertType('string', $value[1]);
2626
}

tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4017,4 +4017,17 @@ public function testBug13272(): void
40174017
$this->analyse([__DIR__ . '/data/bug-13272.php'], []);
40184018
}
40194019

4020+
public function testBug14549(): void
4021+
{
4022+
$this->checkThisOnly = false;
4023+
$this->checkNullables = true;
4024+
$this->checkUnionTypes = true;
4025+
$this->analyse([__DIR__ . '/data/bug-14549.php'], [
4026+
[
4027+
'Parameter #1 $task of method Bug14549Rule\Foo::call() expects array<int>, callable&list<object|string> given.',
4028+
10,
4029+
],
4030+
]);
4031+
}
4032+
40204033
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14549Rule;
4+
5+
class Foo
6+
{
7+
public function foo(array $task): void
8+
{
9+
if (\is_callable($task)) {
10+
$this->call($task);
11+
}
12+
}
13+
14+
/**
15+
* @param array<int> $task
16+
*/
17+
public function call(array $task): void
18+
{
19+
}
20+
}

0 commit comments

Comments
 (0)