diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index c54f9668437..1eb12c177a2 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -808,7 +808,18 @@ private static function foldConstantStringKeyCase(ConstantStringType $type, ?int public function isCallable(): TrinaryLogic { - return TrinaryLogic::createMaybe()->and($this->itemType->isString()); + if (!$this->itemType->isString()->no()) { + return TrinaryLogic::createMaybe(); + } + + // StrictMixedType denies isString() even though it is a supertype of + // string, so a value of that item type can still be the method name + // of a callable array. + if ((new StringType())->isSuperTypeOf($this->itemType)->maybe()) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createNo(); } public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 44bc0a56d08..df3def0c39d 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -3029,4 +3029,67 @@ public function testBug11494(): void ]); } + public static function dataBug13114(): array + { + return [ + [false, false], + [true, false], + [true, true], + ]; + } + + #[DataProvider('dataBug13114')] + public function testBug13114(bool $checkExplicitMixed, bool $checkImplicitMixed): void + { + $this->checkExplicitMixed = $checkExplicitMixed; + $this->checkImplicitMixed = $checkImplicitMixed; + $this->analyse([__DIR__ . '/data/bug-13114.php'], [ + [ + 'Parameter #1 $arg of function Bug13114\\foo expects list{class-string|object, string}&callable(): mixed, array{Bug13114\\C, \'h\'} given.', + 37, + ], + [ + 'Parameter #1 $arg of function Bug13114\\bar expects non-empty-list&callable(): mixed, array{Bug13114\\C, \'h\'} given.', + 42, + ], + [ + 'Parameter #1 $arg of function Bug13114\\baz expects non-empty-list&callable(): mixed, array{Bug13114\\C, \'h\'} given.', + 47, + ], + [ + 'Parameter #1 $arg of function Bug13114\\baz expects non-empty-list&callable(): mixed, array{1, 2} given.', + 48, + ], + [ + 'Parameter #1 $arg of function Bug13114\\baz expects non-empty-list&callable(): mixed, 42 given.', + 49, + "\u{2022} 42 is not a list.\n\u{2022} 42 is empty.", + ], + [ + 'Parameter #1 $arg of function Bug13114\\callableString expects callable-string, \'nonexistentFunction\' given.', + 52, + ], + [ + 'Parameter #1 $arg of function Bug13114\\callableString expects callable-string, 42 given.', + 53, + ], + [ + 'Parameter #1 $arg of function Bug13114\\callableObject expects callable-object, Bug13114\\C given.', + 55, + ], + [ + 'Parameter #1 $arg of function Bug13114\\callableObject expects callable-object, 42 given.', + 57, + ], + [ + 'Parameter #1 $i of function Bug13114\\takesInt expects int, callable&list given.', + 67, + ], + [ + 'Parameter #1 $i of function Bug13114\\takesInt expects int, callable&list given.', + 77, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 21c047c0612..88a4b5562cf 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -481,4 +481,16 @@ public function testBug13190(): void ]); } + public function testBug13114(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13114-return.php'], [ + [ + 'Function Bug13114Return\\returnsCallableArray() should return non-empty-list&callable(): mixed but returns array{Bug13114Return\\C, \'h\'}.', + 14, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-13114-return.php b/tests/PHPStan/Rules/Functions/data/bug-13114-return.php new file mode 100644 index 00000000000..8dd9898c130 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13114-return.php @@ -0,0 +1,23 @@ + + */ +function returnsCallableArray(): array +{ + return [new C, 'h']; +} + +/** + * @return callable&array + */ +function returnsValidCallableArray(): array +{ + return [C::class, 'f']; +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-13114.php b/tests/PHPStan/Rules/Functions/data/bug-13114.php new file mode 100644 index 00000000000..7426d7a112e --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13114.php @@ -0,0 +1,79 @@ + $arg + */ +function bar(array $arg): void {} + +/** + * @param callable-array $arg + */ +function baz($arg): void {} + +/** + * @param callable&string $arg + */ +function callableString($arg): void {} + +/** + * @param callable&object $arg + */ +function callableObject($arg): void {} + +foo([new C, 'f']); +foo([new C, 'g']); +foo([new C, 'h']); // error +foo([C::class, 'f']); + +bar([new C, 'f']); +bar([new C, 'g']); +bar([new C, 'h']); // error +bar([C::class, 'f']); + +baz([new C, 'f']); +baz([new C, 'g']); +baz([new C, 'h']); // error +baz([1, 2]); // error +baz(42); // error + +callableString('strtoupper'); +callableString('nonexistentFunction'); // error +callableString(42); // error + +callableObject(new C()); // error +callableObject(function (): void {}); +callableObject(42); // error + +function takesInt(int $i): void {} + +/** + * @param array $a + */ +function narrowedByIsCallable(array $a): void +{ + if (is_callable($a)) { + takesInt($a); // error + } +} + +/** + * @param mixed $m + */ +function narrowedFromMixed($m): void +{ + if (is_callable($m) && is_array($m)) { + takesInt($m); // error + } +} diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 4251d2d2d3b..749059f548f 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -1067,4 +1067,25 @@ public function testBug10749(): void $this->analyse([__DIR__ . '/data/bug-10749.php'], []); } + public function testBug13114(): void + { + $this->checkExplicitMixed = true; + $this->checkImplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13114.php'], [ + [ + 'Property Bug13114Property\\Holder::$implicit (non-empty-list&callable(): mixed) does not accept array{Bug13114Property\\C, \'h\'}.', + 20, + ], + [ + 'Property Bug13114Property\\Holder::$explicit (non-empty-list&callable(): mixed) does not accept array{Bug13114Property\\C, \'h\'}.', + 21, + ], + [ + 'Property Bug13114Property\\Holder::$implicit (non-empty-list&callable(): mixed) does not accept 42.', + 22, + "\u{2022} 42 is not a list.\n\u{2022} 42 is empty.", + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-13114.php b/tests/PHPStan/Rules/Properties/data/bug-13114.php new file mode 100644 index 00000000000..e07e474dd08 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-13114.php @@ -0,0 +1,31 @@ + */ + private $explicit; + + public function doFoo(): void + { + $this->implicit = [new C, 'h']; + $this->explicit = [new C, 'h']; + $this->implicit = 42; + } + + public function doBar(): void + { + $this->implicit = [C::class, 'f']; + $this->explicit = [C::class, 'f']; + } + +} diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index efd6f985883..b4aec71755c 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5760,6 +5760,24 @@ public static function dataIntersect(): iterable UnionType::class, "1|'0'", ]; + + yield [ + [ + new ArrayType(new MixedType(), new StrictMixedType()), + new CallableType(), + ], + IntersectionType::class, + 'non-empty-list&callable(): mixed', + ]; + + yield [ + [ + new ArrayType(new MixedType(), new IntegerType()), + new CallableType(), + ], + NeverType::class, + '*NEVER*=implicit', + ]; } /**