diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index ff165386f7..3a9ebb7701 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -21,16 +21,19 @@ use PHPStan\Node\InvalidateExprNode; use PHPStan\Node\PropertyAssignNode; use PHPStan\Parser\ArrayMapArgVisitor; +use PHPStan\Parser\ArrayReduceArgVisitor; use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\Native\NativeParameterReflection; +use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\PassedByReference; use PHPStan\Reflection\Php\DummyParameter; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\ClosureType; +use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVarianceMap; @@ -38,6 +41,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\NullType; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VoidType; use function array_key_exists; @@ -100,7 +104,9 @@ public function getClosureType( $callableParameters = null; $nativeCallableParameters = null; $arrayMapArgs = $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); + $arrayReduceArgs = $expr->getAttribute(ArrayReduceArgVisitor::ATTRIBUTE_NAME); $immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME); + $arrayReducePass = null; if ($arrayMapArgs !== null) { $callableParameters = []; $nativeCallableParameters = []; @@ -108,59 +114,75 @@ public function getClosureType( $callableParameters[] = new DummyParameter('item', $scope->getType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } + } elseif ($arrayReduceArgs !== null) { + [$reduceArrayArg, $reduceInitialArg] = $arrayReduceArgs; + $reduceInitialType = $reduceInitialArg !== null ? $scope->getType($reduceInitialArg->value) : new NullType(); + $callableParameters = [ + new DummyParameter('carry', $reduceInitialType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', $scope->getType($reduceArrayArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + ]; + $nativeCallableParameters = [ + new DummyParameter('carry', new MixedType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', $scope->getNativeType($reduceArrayArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + ]; + $arrayReducePass = 1; } elseif ($immediatelyInvokedArgs !== null) { foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { $callableParameters[] = new DummyParameter('item', $scope->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } } else { - $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); - if ($inFunctionCallsStackCount > 0) { - [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; - if ($inParameter !== null) { - $callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $inParameter->getType()); - $nativeType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType(); - $nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativeType); - } - } + [$callableParameters, $nativeCallableParameters] = $this->getCallableParametersFromCallStack($scope, $expr); } if ($expr instanceof ArrowFunction) { - $arrowScope = $scope->enterArrowFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); + while (true) { + $arrowScope = $scope->enterArrowFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - if ($expr->expr instanceof Yield_ || $expr->expr instanceof YieldFrom) { - $yieldNode = $expr->expr; + if ($expr->expr instanceof Yield_ || $expr->expr instanceof YieldFrom) { + $yieldNode = $expr->expr; - if ($yieldNode instanceof Yield_) { - if ($yieldNode->key === null) { - $keyType = new IntegerType(); - } else { - $keyType = $arrowScope->getType($yieldNode->key); - } + if ($yieldNode instanceof Yield_) { + if ($yieldNode->key === null) { + $keyType = new IntegerType(); + } else { + $keyType = $arrowScope->getType($yieldNode->key); + } - if ($yieldNode->value === null) { - $valueType = new NullType(); + if ($yieldNode->value === null) { + $valueType = new NullType(); + } else { + $valueType = $arrowScope->getType($yieldNode->value); + } } else { - $valueType = $arrowScope->getType($yieldNode->value); + $yieldFromType = $arrowScope->getType($yieldNode->expr); + $keyType = $arrowScope->getIterableKeyType($yieldFromType); + $valueType = $arrowScope->getIterableValueType($yieldFromType); } + + $returnType = new GenericObjectType(Generator::class, [ + $keyType, + $valueType, + new MixedType(), + new VoidType(), + ]); } else { - $yieldFromType = $arrowScope->getType($yieldNode->expr); - $keyType = $arrowScope->getIterableKeyType($yieldFromType); - $valueType = $arrowScope->getIterableValueType($yieldFromType); + $returnType = $arrowScope->getKeepVoidType($expr->expr); + if ($expr->returnType !== null) { + $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); + $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + } } - $returnType = new GenericObjectType(Generator::class, [ - $keyType, - $valueType, - new MixedType(), - new VoidType(), - ]); - } else { - $returnType = $arrowScope->getKeepVoidType($expr->expr); - if ($expr->returnType !== null) { - $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); - $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + if ($arrayReducePass !== null && $callableParameters !== null) { + $nextPassParameters = $this->getNextArrayReducePassParameters($scope, $expr, $arrayReducePass, $callableParameters, $nativeCallableParameters, $returnType); + if ($nextPassParameters !== null) { + [$callableParameters, $nativeCallableParameters, $arrayReducePass] = $nextPassParameters; + continue; + } } + + break; } $arrowFunctionImpurePoints = []; @@ -239,145 +261,157 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu ); } - self::$resolveClosureTypeDepth++; + while (true) { + self::$resolveClosureTypeDepth++; - $closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - $closureReturnStatements = []; - $closureYieldStatements = []; - $onlyNeverExecutionEnds = null; - $closureImpurePoints = []; - $invalidateExpressions = []; + $closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); + $closureReturnStatements = []; + $closureYieldStatements = []; + $onlyNeverExecutionEnds = null; + $closureImpurePoints = []; + $invalidateExpressions = []; - try { - $closureStatementResult = $this->nodeScopeResolver->processStmtNodes($expr, $expr->stmts, $closureScope, static function (Node $node, Scope $scope) use ($closureScope, &$closureReturnStatements, &$closureYieldStatements, &$onlyNeverExecutionEnds, &$closureImpurePoints, &$invalidateExpressions): void { - if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { - return; - } + try { + $closureStatementResult = $this->nodeScopeResolver->processStmtNodes($expr, $expr->stmts, $closureScope, static function (Node $node, Scope $scope) use ($closureScope, &$closureReturnStatements, &$closureYieldStatements, &$onlyNeverExecutionEnds, &$closureImpurePoints, &$invalidateExpressions): void { + if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { + return; + } - if ($node instanceof InvalidateExprNode) { - $invalidateExpressions[] = $node; - return; - } + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } - if ($node instanceof PropertyAssignNode) { - $closureImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); - return; - } + if ($node instanceof PropertyAssignNode) { + $closureImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); + return; + } - if ($node instanceof ExecutionEndNode) { - if ($node->getStatementResult()->isAlwaysTerminating()) { - foreach ($node->getStatementResult()->getExitPoints() as $exitPoint) { - if ($exitPoint->getStatement() instanceof Node\Stmt\Return_) { - $onlyNeverExecutionEnds = false; - continue; - } + if ($node instanceof ExecutionEndNode) { + if ($node->getStatementResult()->isAlwaysTerminating()) { + foreach ($node->getStatementResult()->getExitPoints() as $exitPoint) { + if ($exitPoint->getStatement() instanceof Node\Stmt\Return_) { + $onlyNeverExecutionEnds = false; + continue; + } - if ($onlyNeverExecutionEnds === null) { - $onlyNeverExecutionEnds = true; - } + if ($onlyNeverExecutionEnds === null) { + $onlyNeverExecutionEnds = true; + } - break; - } + break; + } - if (count($node->getStatementResult()->getExitPoints()) === 0) { - if ($onlyNeverExecutionEnds === null) { - $onlyNeverExecutionEnds = true; + if (count($node->getStatementResult()->getExitPoints()) === 0) { + if ($onlyNeverExecutionEnds === null) { + $onlyNeverExecutionEnds = true; + } } + } else { + $onlyNeverExecutionEnds = false; } - } else { - $onlyNeverExecutionEnds = false; + + return; } - return; - } + if ($node instanceof Node\Stmt\Return_) { + $closureReturnStatements[] = [$node, $scope]; + } - if ($node instanceof Node\Stmt\Return_) { - $closureReturnStatements[] = [$node, $scope]; - } + if (!$node instanceof Yield_ && !$node instanceof YieldFrom) { + return; + } - if (!$node instanceof Yield_ && !$node instanceof YieldFrom) { - return; - } + $closureYieldStatements[] = [$node, $scope]; + }, StatementContext::createTopLevel()); + } finally { + self::$resolveClosureTypeDepth--; + } - $closureYieldStatements[] = [$node, $scope]; - }, StatementContext::createTopLevel()); - } finally { - self::$resolveClosureTypeDepth--; - } + $throwPoints = $closureStatementResult->getThrowPoints(); + $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); - $throwPoints = $closureStatementResult->getThrowPoints(); - $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); + $returnTypes = []; + $hasNull = false; + foreach ($closureReturnStatements as [$returnNode, $returnScope]) { + if ($returnNode->expr === null) { + $hasNull = true; + continue; + } - $returnTypes = []; - $hasNull = false; - foreach ($closureReturnStatements as [$returnNode, $returnScope]) { - if ($returnNode->expr === null) { - $hasNull = true; - continue; + $returnTypes[] = $returnScope->toMutatingScope()->getType($returnNode->expr); } - $returnTypes[] = $returnScope->toMutatingScope()->getType($returnNode->expr); - } - - if (count($returnTypes) === 0) { - if ($onlyNeverExecutionEnds === true && !$hasNull) { - $returnType = new NonAcceptingNeverType(); + if (count($returnTypes) === 0) { + if ($onlyNeverExecutionEnds === true && !$hasNull) { + $returnType = new NonAcceptingNeverType(); + } else { + $returnType = new VoidType(); + } } else { - $returnType = new VoidType(); - } - } else { - if ($onlyNeverExecutionEnds === true) { - $returnTypes[] = new NonAcceptingNeverType(); - } - if ($hasNull) { - $returnTypes[] = new NullType(); + if ($onlyNeverExecutionEnds === true) { + $returnTypes[] = new NonAcceptingNeverType(); + } + if ($hasNull) { + $returnTypes[] = new NullType(); + } + $returnType = TypeCombinator::union(...$returnTypes); } - $returnType = TypeCombinator::union(...$returnTypes); - } - if (count($closureYieldStatements) > 0) { - $keyTypes = []; - $valueTypes = []; - foreach ($closureYieldStatements as [$yieldNode, $yieldScope]) { - if ($yieldNode instanceof Yield_) { - if ($yieldNode->key === null) { - $keyTypes[] = new IntegerType(); - } else { - $keyTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->key); - } + if (count($closureYieldStatements) > 0) { + $keyTypes = []; + $valueTypes = []; + foreach ($closureYieldStatements as [$yieldNode, $yieldScope]) { + if ($yieldNode instanceof Yield_) { + if ($yieldNode->key === null) { + $keyTypes[] = new IntegerType(); + } else { + $keyTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->key); + } - if ($yieldNode->value === null) { - $valueTypes[] = new NullType(); - } else { - $valueTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->value); + if ($yieldNode->value === null) { + $valueTypes[] = new NullType(); + } else { + $valueTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->value); + } + + continue; } - continue; + $yieldFromType = $yieldScope->toMutatingScope()->getType($yieldNode->expr); + $keyTypes[] = $yieldScope->toMutatingScope()->getIterableKeyType($yieldFromType); + $valueTypes[] = $yieldScope->toMutatingScope()->getIterableValueType($yieldFromType); } - $yieldFromType = $yieldScope->toMutatingScope()->getType($yieldNode->expr); - $keyTypes[] = $yieldScope->toMutatingScope()->getIterableKeyType($yieldFromType); - $valueTypes[] = $yieldScope->toMutatingScope()->getIterableValueType($yieldFromType); + $returnType = new GenericObjectType(Generator::class, [ + TypeCombinator::union(...$keyTypes), + TypeCombinator::union(...$valueTypes), + new MixedType(), + $returnType, + ]); + } else { + if ($expr->returnType !== null) { + $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); + $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + } } - $returnType = new GenericObjectType(Generator::class, [ - TypeCombinator::union(...$keyTypes), - TypeCombinator::union(...$valueTypes), - new MixedType(), - $returnType, - ]); - } else { - if ($expr->returnType !== null) { - $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); - $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + if ($arrayReducePass !== null && $callableParameters !== null) { + $nextPassParameters = $this->getNextArrayReducePassParameters($scope, $expr, $arrayReducePass, $callableParameters, $nativeCallableParameters, $returnType); + if ($nextPassParameters !== null) { + [$callableParameters, $nativeCallableParameters, $arrayReducePass] = $nextPassParameters; + continue; + } } + + break; } $usedVariables = []; @@ -459,4 +493,86 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu ); } + /** + * @return array{ParameterReflection[]|null, ParameterReflection[]|null} + */ + private function getCallableParametersFromCallStack( + MutatingScope $scope, + Node\Expr\Closure|ArrowFunction $expr, + ): array + { + $callableParameters = null; + $nativeCallableParameters = null; + $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); + if ($inFunctionCallsStackCount > 0) { + [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; + if ($inParameter !== null) { + $callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $inParameter->getType()); + $nativeType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType(); + $nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativeType); + } + } + + return [$callableParameters, $nativeCallableParameters]; + } + + /** + * Decides whether the body of an array_reduce() callback needs to be analysed again. + * + * The first pass types $carry as the initial argument. When the callback's return + * type escapes it, the carry type is widened and verified in a second pass. When + * even the widened type is not a fixed point, the callback is analysed once more + * with the parameter types it would have without this inference. + * + * @param ParameterReflection[] $callableParameters + * @param ParameterReflection[]|null $nativeCallableParameters + * @return array{ParameterReflection[]|null, ParameterReflection[]|null, int}|null parameters and pass number for the next pass, or null when no more passes are needed + */ + private function getNextArrayReducePassParameters( + MutatingScope $scope, + Node\Expr\Closure|ArrowFunction $expr, + int $pass, + array $callableParameters, + ?array $nativeCallableParameters, + Type $returnType, + ): ?array + { + if ($pass >= 3 || !isset($callableParameters[0])) { + return null; + } + + $carryType = $callableParameters[0]->getType(); + if ($carryType->isSuperTypeOf($returnType)->yes()) { + return null; + } + + if ($pass === 1) { + $callableParameters[0] = new DummyParameter('carry', self::widenArrayReduceCarryType($carryType, $returnType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + + return [$callableParameters, $nativeCallableParameters, 2]; + } + + // the widened carry type is not a fixed point - fall back to the naive behaviour + [$callableParameters, $nativeCallableParameters] = $this->getCallableParametersFromCallStack($scope, $expr); + + return [$callableParameters, $nativeCallableParameters, 3]; + } + + /** Widens while preserving constant array shapes so that the second pass can converge. */ + private static function widenArrayReduceCarryType(Type $initialType, Type $returnType): Type + { + $union = TypeCombinator::union($initialType, $returnType); + $constantArrays = $union->getConstantArrays(); + if (count($constantArrays) > 0) { + $generalized = []; + foreach ($constantArrays as $constantArray) { + $generalized[] = $constantArray->generalizeValues(); + } + + return TypeCombinator::union(...$generalized); + } + + return $union->generalize(GeneralizePrecision::lessSpecific()); + } + } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 33e32539ff..7950d128f4 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -40,6 +40,7 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Node\VirtualNode; use PHPStan\Parser\ArrayMapArgVisitor; +use PHPStan\Parser\ArrayReduceArgVisitor; use PHPStan\Parser\Parser; use PHPStan\Php\PhpVersion; use PHPStan\Php\PhpVersionFactory; @@ -979,11 +980,14 @@ public function getNodeKey(Expr $node): string $attributes = $node->getAttributes(); if ( $node instanceof Node\FunctionLike - && (($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? null) !== null) + && ( + (($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? null) !== null) + || (($attributes[ArrayReduceArgVisitor::ATTRIBUTE_NAME] ?? null) !== null) + ) && (($attributes['startFilePos'] ?? null) !== null) ) { $key .= '/*' . $attributes['startFilePos']; - foreach ($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] as $arg) { + foreach ($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? [] as $arg) { $key .= ':' . $this->exprPrinter->printExpr($arg->value); } $key .= '*/'; diff --git a/src/Parser/ArrayReduceArgVisitor.php b/src/Parser/ArrayReduceArgVisitor.php new file mode 100644 index 0000000000..e83563e57e --- /dev/null +++ b/src/Parser/ArrayReduceArgVisitor.php @@ -0,0 +1,46 @@ +name instanceof Node\Name && !$node->isFirstClassCallable()) { + $functionName = $node->name->toLowerString(); + if ($functionName === 'array_reduce') { + $arrayArg = null; + $callbackArg = null; + $initialArg = null; + foreach ($node->getArgs() as $i => $arg) { + if ($arg->unpack) { + return null; + } + $name = $arg->name !== null ? $arg->name->toString() : null; + if ($name === 'array' || ($name === null && $i === 0)) { + $arrayArg = $arg; + } elseif ($name === 'callback' || ($name === null && $i === 1)) { + $callbackArg = $arg; + } elseif ($name === 'initial' || ($name === null && $i === 2)) { + $initialArg = $arg; + } + } + if ($arrayArg !== null && $callbackArg !== null) { + $callbackArg->value->setAttribute(self::ATTRIBUTE_NAME, [$arrayArg, $initialArg]); + } + } + } + return null; + } + +} diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index d918b512f2..4326bcb3ae 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -11,6 +11,7 @@ use PHPStan\Parser\ArrayFilterArgVisitor; use PHPStan\Parser\ArrayFindArgVisitor; use PHPStan\Parser\ArrayMapArgVisitor; +use PHPStan\Parser\ArrayReduceArgVisitor; use PHPStan\Parser\ArrayWalkArgVisitor; use PHPStan\Parser\ClosureBindArgVisitor; use PHPStan\Parser\ClosureBindToVarVisitor; @@ -132,6 +133,41 @@ public static function selectFromArgs( } } + foreach ($args as $arg) { + $arrayReduceArgs = $arg->value->getAttribute(ArrayReduceArgVisitor::ATTRIBUTE_NAME); + if ($arrayReduceArgs === null) { + continue; + } + if (!$arg->value instanceof Node\Expr\Closure && !$arg->value instanceof Node\Expr\ArrowFunction) { + break; + } + + $acceptor = $parametersAcceptors[0]; + $parameters = $acceptor->getParameters(); + if (!isset($parameters[1]) || !$parameters[1]->getType()->isCallable()->yes()) { + // the acceptor belongs to the callback itself, not to array_reduce() + break; + } + + $callbackParameterAcceptors = $parameters[1]->getType()->getCallableParametersAcceptors($scope); + $callbackAcceptors = $scope->getType($arg->value)->getCallableParametersAcceptors($scope); + if (count($callbackParameterAcceptors) !== 1 || count($callbackAcceptors) !== 1) { + break; + } + + [$arrayArg, $initialArg] = $arrayReduceArgs; + $initialType = $initialArg !== null ? $scope->getType($initialArg->value) : new NullType(); + $carryType = TypeCombinator::union($initialType, $callbackAcceptors[0]->getReturnType()); + $itemType = $scope->getIterableValueType($scope->getType($arrayArg->value)); + $callableType = new CallableType([ + new DummyParameter('carry', $carryType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', $itemType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + ], $callbackParameterAcceptors[0]->getReturnType(), false); + $parameters[1] = self::overrideParameterType($parameters[1], $callableType, $callableType); + $parametersAcceptors = [self::overrideAcceptorParameters($acceptor, $parameters)]; + break; + } + if (count($args) >= 3 && (bool) $args[0]->getAttribute(CurlSetOptArgVisitor::ATTRIBUTE_NAME)) { $optType = $scope->getType($args[1]->value); diff --git a/tests/PHPStan/Analyser/nsrt/bug-7280.php b/tests/PHPStan/Analyser/nsrt/bug-7280.php new file mode 100644 index 0000000000..fbc9a00ba9 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7280.php @@ -0,0 +1,116 @@ += 8.1 + +namespace Bug7280; + +use function PHPStan\Testing\assertType; + +function namedArguments(): void +{ + $result = array_reduce( + ['test1', 'test2'], + static function (array $carry, string $value): array{ + assertType('array{starts: array{}, ends: array{}}|array{starts: non-empty-list, ends: non-empty-list}', $carry); + $carry['starts'][] = $value; + $carry['ends'][] = $value; + + return $carry; + }, + initial: ['starts' => [], 'ends' => []], + ); + assertType('array{starts: non-empty-list, ends: non-empty-list}', $result); +} + +function positionalArguments(): void +{ + $result = array_reduce( + ['test1', 'test2'], + static function (array $carry, string $value): array{ + assertType('array{starts: array{}, ends: array{}}|array{starts: non-empty-list, ends: non-empty-list}', $carry); + $carry['starts'][] = $value; + $carry['ends'][] = $value; + + return $carry; + }, + ['starts' => [], 'ends' => []], + ); + assertType('array{starts: non-empty-list, ends: non-empty-list}', $result); +} + +function reducer(?int $carry, int $item): int +{ + return ($carry ?? 0) + $item; +} + +/** + * @param list $integers + */ +function firstClassCallable(array $integers): void +{ + $result = array_reduce($integers, reducer(...), 0); + assertType('int', $result); +} + +/** + * @param int[] $integers + */ +function nonConvergingCallback(array $integers): void +{ + // the carry type never reaches a fixed point here - the naive behaviour is kept + $result = array_reduce($integers, function ($carry, $value) { + assertType('array{}|array{x: mixed}', $carry); + + return ['x' => $carry]; + }, []); + assertType('array{}|array{x: mixed}', $result); + + $result2 = array_reduce($integers, fn ($carry, $value) => ['x' => $carry], []); + assertType('array{}|array{x: mixed}', $result2); +} + +/** + * @param array $strings + */ +function arrowFunction(array $strings): void +{ + $result = array_reduce($strings, fn (int $carry, string $value): int => $carry + strlen($value), 0); + assertType('int', $result); +} + +/** + * @param int[] $integers + */ +function intSum(array $integers): void +{ + $sum = array_reduce($integers, function ($carry, $n) { + assertType('int', $carry); + + return $carry + $n; + }, 0); + assertType('int', $sum); +} + +/** + * @param array $strings + */ +function stringConcat(array $strings): void +{ + $concatenated = array_reduce($strings, function (string $carry, string $string): string { + assertType('string', $carry); + + return $carry . $string; + }, ''); + assertType('string', $concatenated); +} + +/** + * @param array $strings + */ +function withoutInitial(array $strings): void +{ + $concatenated = array_reduce($strings, function ($carry, string $string) { + assertType('string|null', $carry); + + return ($carry ?? '') . $string; + }); + assertType('string|null', $concatenated); +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 5e08bfb8ad..f34928956c 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -11,7 +11,6 @@ use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; -use function getenv; use function sprintf; use const PHP_VERSION_ID; @@ -643,12 +642,12 @@ public function testArrayReduceCallback(): void 5, ], [ - sprintf('Parameter #2 $callback of function array_reduce expects callable(%1$s|null, 1|2|3): (%1$s|null), Closure(string, int): non-falsy-string given.', getenv('PHPSTAN_FNSR') !== '0' && PHP_VERSION_ID >= 80100 ? 'non-falsy-string' : 'non-empty-string'), + 'Parameter #2 $callback of function array_reduce expects callable(non-falsy-string|null, 1|2|3): (non-falsy-string|null), Closure(string, int): non-falsy-string given.', 13, 'Type string of parameter #1 $foo of passed callable needs to be same or wider than parameter type string|null of accepting callable.', ], [ - sprintf('Parameter #2 $callback of function array_reduce expects callable(%1$s|null, 1|2|3): (%1$s|null), Closure(string, int): non-falsy-string given.', getenv('PHPSTAN_FNSR') !== '0' && PHP_VERSION_ID >= 80100 ? 'non-falsy-string' : 'non-empty-string'), + 'Parameter #2 $callback of function array_reduce expects callable(non-falsy-string|null, 1|2|3): (non-falsy-string|null), Closure(string, int): non-falsy-string given.', 22, 'Type string of parameter #1 $foo of passed callable needs to be same or wider than parameter type string|null of accepting callable.', ], @@ -663,12 +662,12 @@ public function testArrayReduceArrowFunctionCallback(): void 5, ], [ - sprintf('Parameter #2 $callback of function array_reduce expects callable(%1$s|null, 1|2|3): (%1$s|null), Closure(string, int): non-falsy-string given.', getenv('PHPSTAN_FNSR') !== '0' && PHP_VERSION_ID >= 80100 ? 'non-falsy-string' : 'non-empty-string'), + 'Parameter #2 $callback of function array_reduce expects callable(non-falsy-string|null, 1|2|3): (non-falsy-string|null), Closure(string, int): non-falsy-string given.', 11, 'Type string of parameter #1 $foo of passed callable needs to be same or wider than parameter type string|null of accepting callable.', ], [ - sprintf('Parameter #2 $callback of function array_reduce expects callable(%1$s|null, 1|2|3): (%1$s|null), Closure(string, int): non-falsy-string given.', getenv('PHPSTAN_FNSR') !== '0' && PHP_VERSION_ID >= 80100 ? 'non-falsy-string' : 'non-empty-string'), + 'Parameter #2 $callback of function array_reduce expects callable(non-falsy-string|null, 1|2|3): (non-falsy-string|null), Closure(string, int): non-falsy-string given.', 18, 'Type string of parameter #1 $foo of passed callable needs to be same or wider than parameter type string|null of accepting callable.', ],