|
25 | 25 | use PHPStan\Node\ExecutionEndNode; |
26 | 26 | use PHPStan\Node\InvalidateExprNode; |
27 | 27 | use PHPStan\Node\PropertyAssignNode; |
| 28 | +use PHPStan\Parser\ArrayReduceArgVisitor; |
28 | 29 | use PHPStan\Reflection\Callables\SimpleImpurePoint; |
29 | 30 | use PHPStan\Reflection\Callables\SimpleThrowPoint; |
30 | 31 | use PHPStan\Reflection\ExtendedParameterReflection; |
|
33 | 34 | use PHPStan\Reflection\Native\NativeParameterReflection; |
34 | 35 | use PHPStan\Reflection\ParameterReflection; |
35 | 36 | use PHPStan\Reflection\PassedByReference; |
| 37 | +use PHPStan\Reflection\Php\DummyParameter; |
36 | 38 | use PHPStan\ShouldNotHappenException; |
37 | 39 | use PHPStan\TrinaryLogic; |
38 | 40 | use PHPStan\Turbo\ShadowedByTurboExtension; |
39 | 41 | use PHPStan\Type\ClosureType; |
| 42 | +use PHPStan\Type\GeneralizePrecision; |
40 | 43 | use PHPStan\Type\Generic\GenericObjectType; |
41 | 44 | use PHPStan\Type\Generic\TemplateTypeMap; |
42 | 45 | use PHPStan\Type\Generic\TemplateTypeVarianceMap; |
43 | 46 | use PHPStan\Type\IntegerType; |
44 | 47 | use PHPStan\Type\MixedType; |
| 48 | +use PHPStan\Type\NeverType; |
45 | 49 | use PHPStan\Type\NonAcceptingNeverType; |
46 | 50 | use PHPStan\Type\NullType; |
47 | 51 | use PHPStan\Type\Type; |
@@ -150,6 +154,131 @@ public function getClosureType( |
150 | 154 | ); |
151 | 155 | } |
152 | 156 |
|
| 157 | + // once array_reduce()'s TReturn is resolved, the callback is typed by the |
| 158 | + // carry it resolved to, like the walk of its body that put that carry in |
| 159 | + $arrayReduceArgs = $expr->getAttribute(ArrayReduceArgVisitor::ATTRIBUTE_NAME); |
| 160 | + if ( |
| 161 | + $arrayReduceArgs !== null |
| 162 | + && ( |
| 163 | + $callableParameters === null |
| 164 | + || !isset($callableParameters[0]) |
| 165 | + || $callableParameters[0]->getType()->hasTemplateOrLateResolvableType() |
| 166 | + ) |
| 167 | + ) { |
| 168 | + return $this->resolveArrayReduceCallbackType($scope, $expr, $storage, $arrayReduceArgs, $parameters, $isVariadic, $contextFree); |
| 169 | + } |
| 170 | + |
| 171 | + return $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, $callableParameters, $nativeCallableParameters, $contextFree); |
| 172 | + } |
| 173 | + |
| 174 | + /** |
| 175 | + * Resolves an array_reduce() callback with a bounded fixed-point iteration |
| 176 | + * over its $carry parameter. |
| 177 | + * |
| 178 | + * The first pass types $carry as the initial argument. When the callback's return |
| 179 | + * type escapes it, the carry type is widened and verified in a second pass. When |
| 180 | + * even the widened type is not a fixed point, the callback is analysed once more |
| 181 | + * with an unknown carry instead of claiming a shape the carry can outgrow. |
| 182 | + * See isArrayReduceCarryFixedPoint() for what counts as a fixed point. |
| 183 | + * |
| 184 | + * @param array{Node\Arg, Node\Arg|null} $arrayReduceArgs |
| 185 | + * @param list<NativeParameterReflection> $parameters |
| 186 | + */ |
| 187 | + private function resolveArrayReduceCallbackType( |
| 188 | + MutatingScope $scope, |
| 189 | + Node\Expr\Closure|ArrowFunction $expr, |
| 190 | + ?ExpressionResultStorage $storage, |
| 191 | + array $arrayReduceArgs, |
| 192 | + array $parameters, |
| 193 | + bool $isVariadic, |
| 194 | + bool $contextFree, |
| 195 | + ): ClosureType |
| 196 | + { |
| 197 | + [$arrayArg, $initialArg] = $arrayReduceArgs; |
| 198 | + $itemType = $this->readExprType($storage, $arrayArg->value, $scope, false)->getIterableValueType(); |
| 199 | + $nativeCallableParameters = self::createArrayReduceCallbackParameters( |
| 200 | + new MixedType(), |
| 201 | + $this->readExprType($storage, $arrayArg->value, $scope->doNotTreatPhpDocTypesAsCertain(), true)->getIterableValueType(), |
| 202 | + ); |
| 203 | + |
| 204 | + $carryType = $initialArg !== null ? $this->readExprType($storage, $initialArg->value, $scope, false) : new NullType(); |
| 205 | + $closureType = $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters($carryType, $itemType), $nativeCallableParameters, $contextFree); |
| 206 | + if (self::isArrayReduceCarryFixedPoint($carryType, $closureType->getReturnType())) { |
| 207 | + return $closureType; |
| 208 | + } |
| 209 | + |
| 210 | + $carryType = self::widenArrayReduceCarryType($carryType, $closureType->getReturnType()); |
| 211 | + $closureType = $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters($carryType, $itemType), $nativeCallableParameters, $contextFree); |
| 212 | + if (self::isArrayReduceCarryFixedPoint($carryType, $closureType->getReturnType())) { |
| 213 | + return $closureType; |
| 214 | + } |
| 215 | + |
| 216 | + return $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters(new MixedType(), $itemType), $nativeCallableParameters, $contextFree); |
| 217 | + } |
| 218 | + |
| 219 | + /** |
| 220 | + * Whether the callback returning $returnType keeps the carry within $carryType. |
| 221 | + * |
| 222 | + * Not isSuperTypeOf(): an array shape without definite sealedness is a supertype |
| 223 | + * of the same shape with extra keys, so a carry that grows one key per iteration |
| 224 | + * would pass. An implicit never return is the inference giving up on the body, |
| 225 | + * not a callback that always throws, so it proves nothing about the carry. |
| 226 | + */ |
| 227 | + private static function isArrayReduceCarryFixedPoint(Type $carryType, Type $returnType): bool |
| 228 | + { |
| 229 | + if ($returnType instanceof NeverType && !$returnType->isExplicit()) { |
| 230 | + return false; |
| 231 | + } |
| 232 | + |
| 233 | + return TypeCombinator::union($carryType, $returnType)->equals($carryType); |
| 234 | + } |
| 235 | + |
| 236 | + /** |
| 237 | + * @return list<DummyParameter> |
| 238 | + */ |
| 239 | + private static function createArrayReduceCallbackParameters(Type $carryType, Type $itemType): array |
| 240 | + { |
| 241 | + return [ |
| 242 | + new DummyParameter('carry', $carryType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), |
| 243 | + new DummyParameter('item', $itemType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), |
| 244 | + ]; |
| 245 | + } |
| 246 | + |
| 247 | + /** Widens while preserving constant array shapes so that the second pass can converge. */ |
| 248 | + private static function widenArrayReduceCarryType(Type $initialType, Type $returnType): Type |
| 249 | + { |
| 250 | + $union = TypeCombinator::union($initialType, $returnType); |
| 251 | + $constantArrays = $union->getConstantArrays(); |
| 252 | + if (count($constantArrays) > 0) { |
| 253 | + $generalized = []; |
| 254 | + foreach ($constantArrays as $constantArray) { |
| 255 | + $generalized[] = $constantArray->generalizeValues(); |
| 256 | + } |
| 257 | + |
| 258 | + return TypeCombinator::union(...$generalized); |
| 259 | + } |
| 260 | + |
| 261 | + return $union->generalize(GeneralizePrecision::lessSpecific()); |
| 262 | + } |
| 263 | + |
| 264 | + /** |
| 265 | + * Walks the body with the given callable parameters, or answers from the |
| 266 | + * per-context cache. |
| 267 | + * |
| 268 | + * @param list<NativeParameterReflection> $parameters |
| 269 | + * @param ParameterReflection[]|null $callableParameters |
| 270 | + * @param ParameterReflection[]|null $nativeCallableParameters |
| 271 | + */ |
| 272 | + private function resolveClosureTypeWithParameters( |
| 273 | + MutatingScope $scope, |
| 274 | + Node\Expr\Closure|ArrowFunction $expr, |
| 275 | + array $parameters, |
| 276 | + bool $isVariadic, |
| 277 | + ?array $callableParameters, |
| 278 | + ?array $nativeCallableParameters, |
| 279 | + bool $contextFree, |
| 280 | + ): ClosureType |
| 281 | + { |
153 | 282 | $cachedTypes = $this->findCachedTypes($expr); |
154 | 283 | $cacheKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters); |
155 | 284 | if (array_key_exists($cacheKey, $cachedTypes)) { |
|
0 commit comments