From a623cce02dcdc0051f39c6f0f0748cf10a384404 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 6 Oct 2025 15:25:22 +0200 Subject: [PATCH 1/6] Add ArrayCombineFunctionThrowTypeExtension --- ...rrayCombineFunctionReturnTypeExtension.php | 124 ++------------- ...ArrayCombineFunctionThrowTypeExtension.php | 48 ++++++ src/Type/Php/ArrayCombineHelper.php | 141 ++++++++++++++++++ ...idMethodWithExplicitThrowPointRuleTest.php | 8 + .../Rules/Exceptions/data/bug-13642.php | 12 ++ 5 files changed, 220 insertions(+), 113 deletions(-) create mode 100644 src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php create mode 100644 src/Type/Php/ArrayCombineHelper.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-13642.php diff --git a/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php b/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php index a9b9c0e042..a0ebb2c567 100644 --- a/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php @@ -3,33 +3,25 @@ namespace PHPStan\Type\Php; use PhpParser\Node\Expr\FuncCall; -use PhpParser\Node\Expr\Variable; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\FunctionReflection; -use PHPStan\Type\Accessory\NonEmptyArrayType; -use PHPStan\Type\ArrayType; -use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantBooleanType; -use PHPStan\Type\ConstantScalarType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; -use PHPStan\Type\ErrorType; -use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; -use function array_key_exists; use function count; -use function is_int; -use function is_string; #[AutowiredService] final class ArrayCombineFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - public function __construct(private PhpVersion $phpVersion) + public function __construct( + private ArrayCombineHelper $arrayCombineHelper, + private PhpVersion $phpVersion + ) { } @@ -47,119 +39,25 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $firstArg = $functionCall->getArgs()[0]->value; $secondArg = $functionCall->getArgs()[1]->value; - $keysParamType = $scope->getType($firstArg); - $valuesParamType = $scope->getType($secondArg); + [$arrayType, $hasError] = $this->arrayCombineHelper->getArrayAndThrowType($firstArg, $secondArg, $scope); - $constantKeysArrays = $keysParamType->getConstantArrays(); - $constantValuesArrays = $valuesParamType->getConstantArrays(); - if ( - $constantKeysArrays !== [] - && $constantValuesArrays !== [] - && count($constantKeysArrays) === count($constantValuesArrays) - ) { - $results = []; - foreach ($constantKeysArrays as $k => $constantKeysArray) { - $constantValueArrays = $constantValuesArrays[$k]; - - $keyTypes = $constantKeysArray->getValueTypes(); - $valueTypes = $constantValueArrays->getValueTypes(); - - if (count($keyTypes) !== count($valueTypes)) { - if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { - return new NeverType(); - } - return new ConstantBooleanType(false); - } - - $keyTypes = $this->sanitizeConstantArrayKeyTypes($keyTypes); - if ($keyTypes === null) { - continue; - } - - $builder = ConstantArrayTypeBuilder::createEmpty(); - foreach ($keyTypes as $i => $keyType) { - if (!array_key_exists($i, $valueTypes)) { - $results = []; - break 2; - } - $valueType = $valueTypes[$i]; - $builder->setOffsetValueType($keyType, $valueType); - } - - $results[] = $builder->getArray(); - } - - if ($results !== []) { - return TypeCombinator::union(...$results); - } + if ($hasError->no()) { + return $arrayType; } - if ($keysParamType->isArray()->yes()) { - $itemType = $keysParamType->getIterableValueType(); - - if ($itemType->isInteger()->no()) { - if ($itemType->toString() instanceof ErrorType) { - return new NeverType(); - } - - $keyType = $itemType->toString(); - } else { - $keyType = $itemType; + if ($hasError->yes()) { + if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + return new NeverType(); } - } else { - $keyType = new MixedType(); - } - - $arrayType = new ArrayType( - $keyType, - $valuesParamType->isArray()->yes() ? $valuesParamType->getIterableValueType() : new MixedType(), - ); - if ($keysParamType->isIterableAtLeastOnce()->yes() && $valuesParamType->isIterableAtLeastOnce()->yes()) { - $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + return new ConstantBooleanType(false); } if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { return $arrayType; } - if ($firstArg instanceof Variable && $secondArg instanceof Variable && $firstArg->name === $secondArg->name) { - return $arrayType; - } - return new UnionType([$arrayType, new ConstantBooleanType(false)]); } - /** - * @param array $types - * - * @return list|null - */ - private function sanitizeConstantArrayKeyTypes(array $types): ?array - { - $sanitizedTypes = []; - - foreach ($types as $type) { - if ($type->isInteger()->no() && ! $type->toString() instanceof ErrorType) { - $type = $type->toString(); - } - - $scalars = $type->getConstantScalarTypes(); - if (count($scalars) === 0) { - return null; - } - - foreach ($scalars as $scalar) { - $value = $scalar->getValue(); - if (!is_int($value) && !is_string($value)) { - return null; - } - - $sanitizedTypes[] = $scalar; - } - } - - return $sanitizedTypes; - } - } diff --git a/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php b/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php new file mode 100644 index 0000000000..ddc7721667 --- /dev/null +++ b/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php @@ -0,0 +1,48 @@ +getName() === 'array_combine'; + } + + public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): ?Type + { + if (count($funcCall->getArgs()) < 2) { + return $functionReflection->getThrowType(); + } + + $firstArg = $funcCall->getArgs()[0]->value; + $secondArg = $funcCall->getArgs()[1]->value; + + $hasError = $this->arrayCombineHelper->getArrayAndThrowType($firstArg, $secondArg, $scope)[1]; + if (!$hasError->no()) { + return $functionReflection->getThrowType(); + } + + return null; + } + +} diff --git a/src/Type/Php/ArrayCombineHelper.php b/src/Type/Php/ArrayCombineHelper.php new file mode 100644 index 0000000000..a89562216f --- /dev/null +++ b/src/Type/Php/ArrayCombineHelper.php @@ -0,0 +1,141 @@ +getType($firstArg); + $valuesParamType = $scope->getType($secondArg); + + $constantKeysArrays = $keysParamType->getConstantArrays(); + $constantValuesArrays = $valuesParamType->getConstantArrays(); + if ( + $constantKeysArrays !== [] + && $constantValuesArrays !== [] + && count($constantKeysArrays) === count($constantValuesArrays) + ) { + $results = []; + foreach ($constantKeysArrays as $k => $constantKeysArray) { + $constantValueArrays = $constantValuesArrays[$k]; + + $keyTypes = $constantKeysArray->getValueTypes(); + $valueTypes = $constantValueArrays->getValueTypes(); + + if (count($keyTypes) !== count($valueTypes)) { + return [new NeverType(), TrinaryLogic::createYes()]; + } + + $keyTypes = $this->sanitizeConstantArrayKeyTypes($keyTypes); + if ($keyTypes === null) { + continue; + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($keyTypes as $i => $keyType) { + if (!array_key_exists($i, $valueTypes)) { + $results = []; + break 2; + } + $valueType = $valueTypes[$i]; + $builder->setOffsetValueType($keyType, $valueType); + } + + $results[] = $builder->getArray(); + } + + if ($results !== []) { + return [TypeCombinator::union(...$results), TrinaryLogic::createNo()]; + } + } + + if ($keysParamType->isArray()->yes()) { + $itemType = $keysParamType->getIterableValueType(); + + if ($itemType->isInteger()->no()) { + if ($itemType->toString() instanceof ErrorType) { + return [new NeverType(), TrinaryLogic::createYes()]; + } + + $keyType = $itemType->toString(); + } else { + $keyType = $itemType; + } + } else { + $keyType = new MixedType(); + } + + $arrayType = new ArrayType( + $keyType, + $valuesParamType->isArray()->yes() ? $valuesParamType->getIterableValueType() : new MixedType(), + ); + + if ($keysParamType->isIterableAtLeastOnce()->yes() && $valuesParamType->isIterableAtLeastOnce()->yes()) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + + if ($firstArg instanceof Variable && $secondArg instanceof Variable && $firstArg->name === $secondArg->name) { + return [$arrayType, TrinaryLogic::createNo()]; + } + + return [$arrayType, TrinaryLogic::createMaybe()]; + } + + /** + * @param array $types + * + * @return list|null + */ + private function sanitizeConstantArrayKeyTypes(array $types): ?array + { + $sanitizedTypes = []; + + foreach ($types as $type) { + if ($type->isInteger()->no() && ! $type->toString() instanceof ErrorType) { + $type = $type->toString(); + } + + $scalars = $type->getConstantScalarTypes(); + if (count($scalars) === 0) { + return null; + } + + foreach ($scalars as $scalar) { + $value = $scalar->getValue(); + if (!is_int($value) && !is_string($value)) { + return null; + } + + $sanitizedTypes[] = $scalar; + } + } + + return $sanitizedTypes; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php b/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php index 196d99a84c..2b6ab34a92 100644 --- a/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\RequiresPhp; use ThrowsVoidMethod\MyException; use UnhandledMatchError; +use ValueError; /** * @extends RuleTestCase @@ -99,6 +100,13 @@ public function testRule(bool $missingCheckedExceptionInThrows, array $checkedEx $this->analyse([__DIR__ . '/data/throws-void-method.php'], $errors); } + public function testBug13642(): void + { + $this->missingCheckedExceptionInThrows = false; + $this->checkedExceptionClasses = [ValueError::class]; + $this->analyse([__DIR__ . '/data/bug-13642.php'], []); + } + #[RequiresPhp('>= 8.0')] public function testBug6910(): void { diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-13642.php b/tests/PHPStan/Rules/Exceptions/data/bug-13642.php new file mode 100644 index 0000000000..749c882738 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-13642.php @@ -0,0 +1,12 @@ + Date: Mon, 6 Oct 2025 15:40:26 +0200 Subject: [PATCH 2/6] Fix --- ...rrayCombineFunctionReturnTypeExtension.php | 19 +++++++++---------- ...ArrayCombineFunctionThrowTypeExtension.php | 9 ++------- src/Type/Php/ArrayCombineHelper.php | 6 +++--- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php b/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php index a0ebb2c567..5d7ac326af 100644 --- a/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayCombineFunctionReturnTypeExtension.php @@ -20,7 +20,7 @@ final class ArrayCombineFunctionReturnTypeExtension implements DynamicFunctionRe public function __construct( private ArrayCombineHelper $arrayCombineHelper, - private PhpVersion $phpVersion + private PhpVersion $phpVersion, ) { } @@ -39,25 +39,24 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $firstArg = $functionCall->getArgs()[0]->value; $secondArg = $functionCall->getArgs()[1]->value; - [$arrayType, $hasError] = $this->arrayCombineHelper->getArrayAndThrowType($firstArg, $secondArg, $scope); - - if ($hasError->no()) { - return $arrayType; + [$returnType, $hasValueError] = $this->arrayCombineHelper->getReturnAndThrowType($firstArg, $secondArg, $scope); + if ($hasValueError->no()) { + return $returnType; } - if ($hasError->yes()) { - if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + if ($hasValueError->yes()) { + if ($this->phpVersion->throwsValueErrorForInternalFunctions()) { return new NeverType(); } return new ConstantBooleanType(false); } - if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { - return $arrayType; + if ($this->phpVersion->throwsValueErrorForInternalFunctions()) { + return $returnType; } - return new UnionType([$arrayType, new ConstantBooleanType(false)]); + return new UnionType([$returnType, new ConstantBooleanType(false)]); } } diff --git a/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php b/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php index ddc7721667..1c28031b33 100644 --- a/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php +++ b/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php @@ -5,14 +5,9 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersion; use PHPStan\Reflection\FunctionReflection; -use PHPStan\Type\Constant\ConstantBooleanType; -use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\DynamicFunctionThrowTypeExtension; -use PHPStan\Type\NeverType; use PHPStan\Type\Type; -use PHPStan\Type\UnionType; use function count; #[AutowiredService] @@ -37,8 +32,8 @@ public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflect $firstArg = $funcCall->getArgs()[0]->value; $secondArg = $funcCall->getArgs()[1]->value; - $hasError = $this->arrayCombineHelper->getArrayAndThrowType($firstArg, $secondArg, $scope)[1]; - if (!$hasError->no()) { + $hasValueError = $this->arrayCombineHelper->getReturnAndThrowType($firstArg, $secondArg, $scope)[1]; + if (!$hasValueError->no()) { return $functionReflection->getThrowType(); } diff --git a/src/Type/Php/ArrayCombineHelper.php b/src/Type/Php/ArrayCombineHelper.php index a89562216f..7171bb3d46 100644 --- a/src/Type/Php/ArrayCombineHelper.php +++ b/src/Type/Php/ArrayCombineHelper.php @@ -26,9 +26,9 @@ final class ArrayCombineHelper { /** - * @return array{Type, TrinaryLogic} The array result and if an error may occur. + * @return array{Type, TrinaryLogic} The return type and if a ValueError may occur on PHP8 (and a warning on PHP7). */ - public function getArrayAndThrowType(Expr $firstArg, Expr $secondArg, Scope $scope): array + public function getReturnAndThrowType(Expr $firstArg, Expr $secondArg, Scope $scope): array { $keysParamType = $scope->getType($firstArg); $valuesParamType = $scope->getType($secondArg); @@ -79,7 +79,7 @@ public function getArrayAndThrowType(Expr $firstArg, Expr $secondArg, Scope $sco if ($itemType->isInteger()->no()) { if ($itemType->toString() instanceof ErrorType) { - return [new NeverType(), TrinaryLogic::createYes()]; + return [new NeverType(), TrinaryLogic::createNo()]; } $keyType = $itemType->toString(); From 016d5684997914c958c5be14bd6a7cfd3d33184b Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 5 Dec 2025 13:41:00 +0100 Subject: [PATCH 3/6] Add test --- ...ThrowsVoidMethodWithExplicitThrowPointRuleTest.php | 8 +++++++- tests/PHPStan/Rules/Exceptions/data/bug-13642.php | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php b/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php index 2b6ab34a92..24735d6260 100644 --- a/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/ThrowsVoidMethodWithExplicitThrowPointRuleTest.php @@ -100,11 +100,17 @@ public function testRule(bool $missingCheckedExceptionInThrows, array $checkedEx $this->analyse([__DIR__ . '/data/throws-void-method.php'], $errors); } + #[RequiresPhp('>= 8.0')] public function testBug13642(): void { $this->missingCheckedExceptionInThrows = false; $this->checkedExceptionClasses = [ValueError::class]; - $this->analyse([__DIR__ . '/data/bug-13642.php'], []); + $this->analyse([__DIR__ . '/data/bug-13642.php'], [ + [ + 'Method Bug13642\HelloWorld::sayHello2() throws exception ValueError but the PHPDoc contains @throws void.', + 21, + ], + ]); } #[RequiresPhp('>= 8.0')] diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-13642.php b/tests/PHPStan/Rules/Exceptions/data/bug-13642.php index 749c882738..52e639958a 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-13642.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-13642.php @@ -9,4 +9,15 @@ public function sayHello(): void { array_combine([1, 2], [1, 2]); } + + /** + * @param mixed $mixed1 + * @param mixed $mixed2 + * + * @throws void + */ + public function sayHello2($mixed1, $mixed2): void + { + array_combine($mixed1, $mixed2); + } } From 1398ae004593b7a16de54ce17a4684cedc6a0701 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 5 Dec 2025 14:24:51 +0100 Subject: [PATCH 4/6] Add test --- src/Type/Php/ArrayCombineHelper.php | 2 +- .../PHPStan/Analyser/nsrt/array-combine-php8.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Type/Php/ArrayCombineHelper.php b/src/Type/Php/ArrayCombineHelper.php index 7171bb3d46..d0dc7f66d6 100644 --- a/src/Type/Php/ArrayCombineHelper.php +++ b/src/Type/Php/ArrayCombineHelper.php @@ -116,7 +116,7 @@ private function sanitizeConstantArrayKeyTypes(array $types): ?array $sanitizedTypes = []; foreach ($types as $type) { - if ($type->isInteger()->no() && ! $type->toString() instanceof ErrorType) { + if (!$type->isInteger()->yes() && ! $type->toString() instanceof ErrorType) { $type = $type->toString(); } diff --git a/tests/PHPStan/Analyser/nsrt/array-combine-php8.php b/tests/PHPStan/Analyser/nsrt/array-combine-php8.php index b3a74723d7..9eb9827154 100644 --- a/tests/PHPStan/Analyser/nsrt/array-combine-php8.php +++ b/tests/PHPStan/Analyser/nsrt/array-combine-php8.php @@ -147,3 +147,18 @@ function bug11819(): void $types[] = 'foo'; assertType('array{1: false, 2: false, 3: false, 4: \'foo\'}', $types); } + +function withMixed(mixed $mixed1, mixed $mixed2) +{ + assertType('array', array_combine($mixed1, $mixed2)); +} + +/** + * @param 1|true $oneOrBool + */ +function withUnionAsKey(int|bool $oneOrBool) +{ + $keys = [$oneOrBool]; + + assertType("array{1: 'bar'}", array_combine($keys, ['bar'])); +} From c8ad51ed488bd980ec2f6ca87b057d70fac61ef6 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 5 Dec 2025 15:13:56 +0100 Subject: [PATCH 5/6] Try the mutant --- src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php b/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php index 1c28031b33..2e053cd8dc 100644 --- a/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php +++ b/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php @@ -33,7 +33,7 @@ public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflect $secondArg = $funcCall->getArgs()[1]->value; $hasValueError = $this->arrayCombineHelper->getReturnAndThrowType($firstArg, $secondArg, $scope)[1]; - if (!$hasValueError->no()) { + if ($hasValueError->yes()) { return $functionReflection->getThrowType(); } From 7e56be11723cb65904d727d129aac6781c3a9b45 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 5 Dec 2025 15:30:26 +0100 Subject: [PATCH 6/6] Revert "Try the mutant" This reverts commit c8ad51ed488bd980ec2f6ca87b057d70fac61ef6. --- src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php b/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php index 2e053cd8dc..1c28031b33 100644 --- a/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php +++ b/src/Type/Php/ArrayCombineFunctionThrowTypeExtension.php @@ -33,7 +33,7 @@ public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflect $secondArg = $funcCall->getArgs()[1]->value; $hasValueError = $this->arrayCombineHelper->getReturnAndThrowType($firstArg, $secondArg, $scope)[1]; - if ($hasValueError->yes()) { + if (!$hasValueError->no()) { return $functionReflection->getThrowType(); }