diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index 07f584fb8a..b43d51b6dd 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -73,6 +73,23 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array if ($type instanceof AccessoryType) { return $type; } + if ( + $type instanceof IntersectionType + && $type->isCallable()->yes() + && $type->isArray()->yes() + ) { + $nonArrayInner = []; + foreach ($type->getTypes() as $innerType) { + if ($innerType->isArray()->yes()) { + continue; + } + $nonArrayInner[] = $innerType; + } + if (count($nonArrayInner) === 1) { + return $traverse($nonArrayInner[0]); + } + return $traverse(new IntersectionType($nonArrayInner)); + } if ($type instanceof ConditionalType || $type instanceof ConditionalTypeForParameter) { $iterablesWithMissingValueTypehint = array_merge( $iterablesWithMissingValueTypehint, diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 7ca93e1176..f5dceab600 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -782,6 +782,9 @@ public function getArraySize(): Type public function getIterableKeyType(): Type { + if ($this->isCallable()->yes() && $this->isArray()->yes()) { + return new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]); + } return $this->intersectTypes(static fn (Type $type): Type => $type->getIterableKeyType()); } @@ -797,7 +800,17 @@ public function getLastIterableKeyType(): Type public function getIterableValueType(): Type { - return $this->intersectTypes(static fn (Type $type): Type => $type->getIterableValueType()); + $result = $this->intersectTypes(static fn (Type $type): Type => $type->getIterableValueType()); + if ($this->isCallable()->yes() && $this->isArray()->yes()) { + return TypeCombinator::intersect( + $result, + new UnionType([ + new ObjectWithoutClassType(), + new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]), + ]), + ); + } + return $result; } public function getFirstIterableValueType(): Type @@ -960,9 +973,7 @@ private function doHasOffsetValueType(Type $offsetType): TrinaryLogic } } - $result = $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType)); - - if (!$result->yes() && $this->isCallable()->yes() && $this->isArray()->yes()) { + if ($this->isCallable()->yes() && $this->isArray()->yes()) { $arrayKeyOffsetType = $offsetType->toArrayKey(); $callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]); if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) { @@ -970,7 +981,7 @@ private function doHasOffsetValueType(Type $offsetType): TrinaryLogic } } - return $result; + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType)); } public function getOffsetValueType(Type $offsetType): Type @@ -991,17 +1002,14 @@ private function doGetOffsetValueType(Type $offsetType): Type 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); + if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { + $narrowedType = new UnionType([new ClassStringType(), new ObjectWithoutClassType()]); + } elseif ((new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { + $narrowedType = new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]); + } else { + $narrowedType = new UnionType([new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]), new ObjectWithoutClassType()]); } + $result = TypeCombinator::intersect($result, $narrowedType); } return $result; diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index bdf01e4c52..810af0c7dd 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -190,6 +190,7 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Variables/data/bug-7417.php'; yield __DIR__ . '/../Rules/Arrays/data/bug-7469.php'; yield __DIR__ . '/../Rules/Variables/data/bug-3391.php'; + yield __DIR__ . '/../Rules/Methods/data/bug-14549.php'; yield __DIR__ . '/../Rules/Functions/data/bug-anonymous-function-method-constant.php'; diff --git a/tests/PHPStan/Analyser/nsrt/bug-3842.php b/tests/PHPStan/Analyser/nsrt/bug-3842.php index 51e607ea40..a752e41b30 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3842.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3842.php @@ -22,7 +22,7 @@ function testIsArrayOnCallable(callable $value): void { if (is_array($value)) { assertType('array&callable(): mixed', $value); assertType('class-string|object', $value[0]); - assertType('string', $value[1]); + assertType('non-falsy-string', $value[1]); } } @@ -30,7 +30,7 @@ function testIsArrayOnCallable(callable $value): void { function testCallableArrayPhpDoc(array $value): void { assertType('array&callable(): mixed', $value); assertType('class-string|object', $value[0]); - assertType('string', $value[1]); + assertType('non-falsy-string', $value[1]); } function testIsStringOnCallable(callable $value): void { @@ -50,7 +50,7 @@ function checkClassString(array $values): void { /** @param 0|1 $offset */ function testCallableArrayUnionOffset(callable $value, int $offset): void { if (is_array($value)) { - assertType('object|string', $value[$offset]); + assertType('object|non-falsy-string', $value[$offset]); } } diff --git a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php index db4b818f92..9f2056500d 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php @@ -148,4 +148,24 @@ public function testBug7662(): void ]); } + public function testBug14549(): void + { + $this->analyse([__DIR__ . '/data/bug-14549.php'], [ + [ + 'Method Bug14549\Foo::doFoo() has parameter $task with no signature specified for callable.', + 12, + ], + [ + 'Method Bug14549\Foo::doIntersection() has parameter $array with no value type specified in iterable type array.', + 46, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + [ + 'Method Bug14549\Foo::doIntersection() has parameter $array with no value type specified in iterable type array.', + 46, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + ]); + } + } 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 0000000000..c713ac3092 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14549.php @@ -0,0 +1,52 @@ + $v) { + assertType('0|1', $k); + assertType('object|non-falsy-string', $v); + } + assertType('class-string|object', $task[0]); + assertType('non-falsy-string', $task[1]); + } + + /** + * @param non-empty-list $list + */ + public function doBar(array $list): void + { + if ($list[0] !== '') { + assertType('non-empty-list&hasOffsetValue(0, non-empty-string)', $list); + + if (is_callable($list)) { + assertType('non-empty-list&callable(): mixed&hasOffsetValue(0, non-empty-string)', $list); + assertType('non-empty-string', $list[0]); + assertType('non-falsy-string', $list[1]); + + foreach($list as $k => $v) { + assertType('0|1', $k); + assertType('non-falsy-string', $v); + } + } + } + } + + /** + * @param (array&callable(array): array) $array + */ + public function doIntersection($array): void + { + } + +} + + diff --git a/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php b/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php index d07d72d48a..5b11f9913e 100644 --- a/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantStringTypeTest.php @@ -185,4 +185,22 @@ public function testSetInvalidValue(): void $this->assertInstanceOf(ErrorType::class, $result); } + #[DataProvider('dataIsCallable')] + public function testIsCallable(TrinaryLogic $trinaryLogic, string $constantValue): void + { + $this->assertSame( + $trinaryLogic, + (new ConstantStringType($constantValue))->isCallable(), + ); + } + + public static function dataIsCallable(): iterable + { + yield [TrinaryLogic::createNo(), '']; + yield [TrinaryLogic::createNo(), '0']; + yield [TrinaryLogic::createYes(), 'substr']; + yield [TrinaryLogic::createYes(), self::class . '::dataIsCallable']; + yield [TrinaryLogic::createMaybe(), self::class . '::methodDoesNotExist']; + } + }