diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ec7779bfbed..8a28e579a7e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1692,19 +1692,19 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\ArrayType is error-prone and deprecated. Use Type::isArray() or Type::getArrays() instead.' identifier: phpstanApi.instanceofType - count: 5 + count: 7 path: src/Type/TypeCombinator.php - rawMessage: 'Doing instanceof PHPStan\Type\CallableType is error-prone and deprecated. Use Type::isCallable() and Type::getCallableParametersAcceptors() instead.' identifier: phpstanApi.instanceofType - count: 1 + count: 5 path: src/Type/TypeCombinator.php - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.' identifier: phpstanApi.instanceofType - count: 19 + count: 21 path: src/Type/TypeCombinator.php - diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index bd243ef7d26..2bc8da6f970 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -429,7 +429,7 @@ private function resolveIdentifierTypeNode(IdentifierTypeNode $typeNode, NameSco return new IntersectionType([new ObjectWithoutClassType(), new CallableType()]); case 'callable-array': - return new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]); + return TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), new CallableType()); case 'never': case 'noreturn': diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 81e6d3272da..e40b753e04d 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -945,14 +945,6 @@ public function hasOffsetValueType(Type $offsetType): TrinaryLogic $result = $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType)); - if (!$result->yes() && $this->isCallable()->yes() && $this->isArray()->yes()) { - $arrayKeyOffsetType = $offsetType->toArrayKey(); - $callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]); - if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - return TrinaryLogic::createYes(); - } - } - return $result; } @@ -963,21 +955,6 @@ public function getOffsetValueType(Type $offsetType): Type return TypeUtils::toBenevolentUnion($result); } - if ($this->isCallable()->yes() && $this->isArray()->yes()) { - $arrayKeyOffsetType = $offsetType->toArrayKey(); - $callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]); - if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - $narrowedType = new UnionType([new ClassStringType(), new ObjectWithoutClassType()]); - } elseif ((new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - $narrowedType = new StringType(); - } else { - $narrowedType = new UnionType([new StringType(), new ObjectWithoutClassType()]); - } - $result = TypeCombinator::intersect($result, $narrowedType); - } - } - return $result; } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 8181f93fe0d..5eeff2ed845 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1683,6 +1683,54 @@ public static function intersect(Type ...$types): Type continue; } + if ( + $types[$i] instanceof ArrayType + && get_class($types[$i]) === ArrayType::class + && $types[$j] instanceof CallableType + ) { + $narrowed = self::narrowArrayTypeWithCallable($types[$i]); + if ($narrowed instanceof NeverType) { + return new NeverType(); + } + $types[$i] = $narrowed; + continue; + } + + if ( + $types[$j] instanceof ArrayType + && get_class($types[$j]) === ArrayType::class + && $types[$i] instanceof CallableType + ) { + $narrowed = self::narrowArrayTypeWithCallable($types[$j]); + if ($narrowed instanceof NeverType) { + return new NeverType(); + } + $types[$j] = $narrowed; + continue; + } + + if ( + $types[$i] instanceof ConstantArrayType + && $types[$j] instanceof CallableType + ) { + $types[$i] = self::narrowConstantArrayWithCallable($types[$i]); + if ($types[$i] instanceof NeverType) { + return new NeverType(); + } + continue; + } + + if ( + $types[$j] instanceof ConstantArrayType + && $types[$i] instanceof CallableType + ) { + $types[$j] = self::narrowConstantArrayWithCallable($types[$j]); + if ($types[$j] instanceof NeverType) { + return new NeverType(); + } + continue; + } + continue; } @@ -1779,4 +1827,68 @@ public static function removeTruthy(Type $type): Type return self::remove($type, StaticTypeFactory::truthy()); } + private static function narrowArrayTypeWithCallable(ArrayType $arrayType): Type + { + if ($arrayType->getKeyType()->isSuperTypeOf(new IntegerType())->no()) { + return new NeverType(); + } + + $existingValueType = $arrayType->getItemType(); + $offset0ValueType = self::intersect($existingValueType, new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); + $offset1ValueType = self::intersect($existingValueType, new StringType()); + if ($offset0ValueType instanceof NeverType || $offset1ValueType instanceof NeverType) { + return new NeverType(); + } + + return new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [$offset0ValueType, $offset1ValueType], + [2], + isList: TrinaryLogic::createYes(), + ); + } + + private static function narrowConstantArrayWithCallable(ConstantArrayType $constantArray): Type + { + $keyTypes = $constantArray->getKeyTypes(); + $valueTypes = $constantArray->getValueTypes(); + + if (count($keyTypes) < 2) { + return new NeverType(); + } + + $offset0Index = null; + $offset1Index = null; + + foreach ($keyTypes as $k => $keyType) { + if ((new ConstantIntegerType(0))->isSuperTypeOf($keyType)->yes()) { + $offset0Index = $k; + } elseif ((new ConstantIntegerType(1))->isSuperTypeOf($keyType)->yes()) { + $offset1Index = $k; + } + } + + if ($offset0Index === null || $offset1Index === null) { + return new NeverType(); + } + + $newValueTypes = $valueTypes; + $newValueTypes[$offset0Index] = self::intersect($valueTypes[$offset0Index], new UnionType([new ClassStringType(), new ObjectWithoutClassType()])); + if ($newValueTypes[$offset0Index] instanceof NeverType) { + return new NeverType(); + } + $newValueTypes[$offset1Index] = self::intersect($valueTypes[$offset1Index], new StringType()); + if ($newValueTypes[$offset1Index] instanceof NeverType) { + return new NeverType(); + } + + return new ConstantArrayType( + $keyTypes, + $newValueTypes, + $constantArray->getNextAutoIndexes(), + $constantArray->getOptionalKeys(), + $constantArray->isList(), + ); + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12393.php b/tests/PHPStan/Analyser/nsrt/bug-12393.php index 4edd2300c13..813dc3b7b46 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12393.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12393.php @@ -160,7 +160,7 @@ class CallableArray { public function doFoo(callable $foo): void { $this->foo = $foo; - assertType('array', $this->foo); // could be non-empty-array + assertType('array', $this->foo); // could be non-empty-array } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12393b.php b/tests/PHPStan/Analyser/nsrt/bug-12393b.php index a21dcf561a0..944646d3656 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12393b.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12393b.php @@ -669,7 +669,7 @@ class CallableArray { public function doFoo(callable $foo): void { $this->foo = $foo; - assertType('array', $this->foo); // could be non-empty-array + assertType('array', $this->foo); // could be non-empty-array } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14549.php b/tests/PHPStan/Analyser/nsrt/bug-14549.php new file mode 100644 index 00000000000..99e3b2a6cf1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14549.php @@ -0,0 +1,70 @@ + $value) { + assertType('object|string', $value); + assertType('0|1', $key); + } + } + } + + public function testCallableArrayIterableTypes(callable $value): void + { + if (is_array($value)) { + assertType('list{class-string|object, string}&callable(): mixed', $value); + + foreach ($value as $key => $val) { + assertType('0|1', $key); + assertType('object|string', $val); + } + } + } + + /** @param array{string, string} $task */ + public function testConstantArrayNarrowing(array $task): void + { + if (\is_callable($task)) { + assertType('list{class-string, string}&callable(): mixed', $task); + assertType('class-string', $task[0]); + assertType('string', $task[1]); + } + } + + /** @param array $task */ + public function testTypedArrayNarrowing(array $task): void + { + if (\is_callable($task)) { + // When value type is string, intersect with class-string|object gives class-string + // and intersect with string gives string + assertType('list{class-string, string}&callable(): mixed', $task); + } + } + + /** @param array $task */ + public function testStringKeyedArrayNarrowing(array $task): void + { + if (\is_callable($task)) { + assertType('*NEVER*', $task); + } + } + + /** @param callable-array $value */ + public function testCallableArrayPhpDoc(array $value): void + { + assertType('list{class-string|object, string}&callable(): mixed', $value); + assertType('class-string|object', $value[0]); + assertType('string', $value[1]); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-3842.php b/tests/PHPStan/Analyser/nsrt/bug-3842.php index 51e607ea409..2f05197da56 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3842.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3842.php @@ -20,7 +20,7 @@ public function callback(): void function testIsArrayOnCallable(callable $value): void { if (is_array($value)) { - assertType('array&callable(): mixed', $value); + assertType('list{class-string|object, string}&callable(): mixed', $value); assertType('class-string|object', $value[0]); assertType('string', $value[1]); } @@ -28,7 +28,7 @@ function testIsArrayOnCallable(callable $value): void { /** @param callable-array $value */ function testCallableArrayPhpDoc(array $value): void { - assertType('array&callable(): mixed', $value); + assertType('list{class-string|object, string}&callable(): mixed', $value); assertType('class-string|object', $value[0]); assertType('string', $value[1]); } diff --git a/tests/PHPStan/Analyser/nsrt/more-types.php b/tests/PHPStan/Analyser/nsrt/more-types.php index c8ae927b2d8..5f51f9e5793 100644 --- a/tests/PHPStan/Analyser/nsrt/more-types.php +++ b/tests/PHPStan/Analyser/nsrt/more-types.php @@ -39,7 +39,7 @@ public function doFoo( ): void { assertType('pure-callable(): mixed', $pureCallable); - assertType('array&callable(): mixed', $callableArray); + assertType('list{class-string|object, string}&callable(): mixed', $callableArray); assertType('resource', $closedResource); assertType('resource', $openResource); assertType('class-string', $enumString); diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 9a2a8e1a9f9..762adc57f36 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4017,4 +4017,17 @@ public function testBug13272(): void $this->analyse([__DIR__ . '/data/bug-13272.php'], []); } + public function testBug14549(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-14549.php'], [ + [ + 'Parameter #1 $task of method Bug14549Rule\Foo::call() expects array, callable&list given.', + 10, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14549.php b/tests/PHPStan/Rules/Methods/data/bug-14549.php new file mode 100644 index 00000000000..51e05189c2f --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14549.php @@ -0,0 +1,20 @@ +call($task); + } + } + + /** + * @param array $task + */ + public function call(array $task): void + { + } +}