diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 67dabae8fea..564df2bc20d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1581,6 +1581,12 @@ parameters: count: 1 path: src/Type/Php/FunctionExistsFunctionTypeSpecifyingExtension.php + - + rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.' + identifier: phpstanApi.instanceofType + count: 1 + path: src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php + - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType diff --git a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php index 0af00a4ffb5..c22cad0f723 100644 --- a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php @@ -16,8 +16,10 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; +use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\FunctionTypeSpecifyingExtension; use PHPStan\Type\MixedType; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function count; use function strtolower; @@ -113,25 +115,18 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n } $specifiedTypes = new SpecifiedTypes(); - if ( - $context->true() - || ( - $context->false() - && count($arrayValueType->getFiniteTypes()) > 0 - && count($needleType->getFiniteTypes()) > 0 - && $arrayType->isIterableAtLeastOnce()->yes() - ) - ) { + $narrowingValueType = $this->computeNeedleNarrowingType($context, $needleType, $arrayType, $arrayValueType); + if ($narrowingValueType !== null) { $specifiedTypes = $this->typeSpecifier->create( $needleExpr, - $arrayValueType, + $narrowingValueType, $context, $scope, ); if ($needleExpr instanceof AlwaysRememberedExpr) { $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create( $needleExpr->getExpr(), - $arrayValueType, + $narrowingValueType, $context, $scope, )); @@ -171,4 +166,68 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n return $specifiedTypes; } + /** + * Computes the type to narrow the needle against, or null if no narrowing should occur. + * In true context, returns the array value type directly. + * In false context, returns only the values guaranteed to be in every possible variant of the array. + */ + private function computeNeedleNarrowingType(TypeSpecifierContext $context, Type $needleType, Type $arrayType, Type $arrayValueType): ?Type + { + if ($context->true()) { + return $arrayValueType; + } + + if ( + !$context->false() + || count($needleType->getFiniteTypes()) === 0 + || !$arrayType->isIterableAtLeastOnce()->yes() + ) { + return null; + } + + $arrays = $arrayType->getArrays(); + $guaranteedValueTypePerArray = []; + foreach ($arrays as $array) { + if ($array instanceof ConstantArrayType) { + $innerGuaranteeValueType = []; + foreach ($array->getValueTypes() as $i => $valueType) { + if ($array->isOptionalKey($i)) { + continue; + } + + $finiteTypes = $valueType->getFiniteTypes(); + if (count($finiteTypes) !== 1) { + continue; + } + + $innerGuaranteeValueType[] = $finiteTypes[0]; + } + + if (count($innerGuaranteeValueType) === 0) { + return null; + } + + $guaranteedValueTypePerArray[] = TypeCombinator::union(...$innerGuaranteeValueType); + } else { + $finiteValueType = $array->getIterableValueType()->getFiniteTypes(); + if (count($finiteValueType) !== 1) { + return null; + } + + $guaranteedValueTypePerArray[] = $finiteValueType[0]; + } + } + + if (count($guaranteedValueTypePerArray) === 0) { + return null; + } + + $guaranteedValueType = TypeCombinator::intersect(...$guaranteedValueTypePerArray); + if (count($guaranteedValueType->getFiniteTypes()) === 0) { + return null; + } + + return $guaranteedValueType; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13421.php b/tests/PHPStan/Analyser/nsrt/bug-13421.php new file mode 100644 index 00000000000..835af42d3b9 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13421.php @@ -0,0 +1,26 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug13421; + +use function PHPStan\Testing\assertType; + +enum Bar +{ + case Bar1; + case Bar2; + case Bar3; +} + +/** + * @param non-empty-array $nonEmptyFilterArray + */ +function test(array $nonEmptyFilterArray): void +{ + $bars = [Bar::Bar1, Bar::Bar2, Bar::Bar3]; + + $filteredBars = array_filter($bars, fn (Bar $bar) => in_array($bar, $nonEmptyFilterArray)); + + assertType("array{0?: Bug13421\Bar::Bar1, 1?: Bug13421\Bar::Bar2, 2?: Bug13421\Bar::Bar3}", $filteredBars); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14407.php b/tests/PHPStan/Analyser/nsrt/bug-14407.php new file mode 100644 index 00000000000..20b1d685124 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14407.php @@ -0,0 +1,100 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14407; + +use function PHPStan\Testing\assertType; + +enum SomeEnum { + case A; + case B; +} + +function () { + $arr = []; + + if (rand(0, 1) === 0) { + $arr[] = SomeEnum::A; + } else { + $arr[] = SomeEnum::B; + } + + if (rand(0, 1) === 0) { + $x = SomeEnum::A; + } else { + $x = SomeEnum::B; + } + + if (!in_array($x, $arr)) { + // either $x=A, $arr=[B] or $x=B, $arr=[A] + assertType('Bug14407\SomeEnum::A|Bug14407\SomeEnum::B', $x); + } + + if (!in_array($x, $arr) && $x === SomeEnum::A) { + // $x=A, $arr=[B] + assertType('Bug14407\SomeEnum::A', $x); + } +}; + +function () { + $arr = [SomeEnum::A, SomeEnum::B]; + + if (rand(0, 1) === 0) { + $x = SomeEnum::A; + } else { + $x = SomeEnum::B; + } + + if (!in_array($x, $arr)) { + // array always contains both A and B, so this is correctly *NEVER* + assertType('*NEVER*', $x); + } +}; + +function () { + $arr = []; + + $r = rand(0, 2); + + if ($r === 0) { + $arr[] = SomeEnum::A; + } elseif ($r === 1) { + $arr[] = SomeEnum::B; + } + + if (rand(0, 1) === 0) { + $x = SomeEnum::A; + } else { + $x = SomeEnum::B; + } + + // arr might be empty, so no narrowing possible + if (!in_array($x, $arr) && $x === SomeEnum::A) { + assertType('Bug14407\SomeEnum::A', $x); + } +}; + +/** + * @param 'a'|'b'|'c' $x + * @param array{a: 'a', c: 'c'}|array{a?:'a', b: 'b'} $a + */ +function testUnionWithOptionalKeys($x, $a): void +{ + assertType("array{a: 'a', c: 'c'}|array{a?: 'a', b: 'b'}", $a); + if (!\in_array($x, $a, true)) { + assertType("'a'|'b'|'c'", $x); + } +}; + +/** + * @param 'a'|'b'|'c' $x + * @param non-empty-array<'a'|'b'> $a + */ +function testNonConstantArray($x, $a): void +{ + assertType("non-empty-array<'a'|'b'>", $a); + if (!\in_array($x, $a, true)) { + assertType("'a'|'b'|'c'", $x); + } +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-8864.php b/tests/PHPStan/Analyser/nsrt/bug-8864.php new file mode 100644 index 00000000000..cee48930142 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-8864.php @@ -0,0 +1,37 @@ += 8.1')] + public function testBug14407(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14407.php'], []); + } + + #[RequiresPhp('>= 8.1')] + public function testBug13421(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13421.php'], []); + } + }