From 685a17b4d6ee5a837633d8c2defa4bef03c5a8a5 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 6 Oct 2025 15:25:22 +0200 Subject: [PATCH 1/2] 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/2] 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();