|
65 | 65 | use PHPStan\Reflection\ExtendedMethodReflection; |
66 | 66 | use PHPStan\Reflection\ExtendedParameterReflection; |
67 | 67 | use PHPStan\Reflection\FunctionReflection; |
| 68 | +use PHPStan\Reflection\InitializerExprTypeResolver; |
68 | 69 | use PHPStan\Reflection\MethodReflection; |
69 | 70 | use PHPStan\Reflection\Native\NativeMethodReflection; |
70 | 71 | use PHPStan\Reflection\Native\NativeParameterReflection; |
|
77 | 78 | use PHPStan\ShouldNotHappenException; |
78 | 79 | use PHPStan\TrinaryLogic; |
79 | 80 | use PHPStan\Type\ClosureType; |
| 81 | +use PHPStan\Type\Constant\ConstantIntegerType; |
80 | 82 | use PHPStan\Type\FileTypeMapper; |
81 | 83 | use PHPStan\Type\FunctionParameterClosureThisExtension; |
82 | 84 | use PHPStan\Type\FunctionParameterClosureTypeExtension; |
@@ -121,6 +123,15 @@ class NodeScopeResolver |
121 | 123 | public const LOOP_SCOPE_ITERATIONS = 3; |
122 | 124 | public const GENERALIZE_AFTER_ITERATION = 1; |
123 | 125 |
|
| 126 | + /** |
| 127 | + * Set on a closure/arrow function nested inside a call argument (through |
| 128 | + * array literals and ternaries) to the parameter type it ends up passed |
| 129 | + * to, as array{Type, Type|null} (phpdoc type, native type). |
| 130 | + * A closure that IS the argument gets that type handed to |
| 131 | + * processClosureNode() directly and never carries the attribute. |
| 132 | + */ |
| 133 | + public const CLOSURE_PASSED_TO_TYPE_ATTRIBUTE = 'phpstanClosurePassedToType'; |
| 134 | + |
124 | 135 | /** @var array<string, true> filePath(string) => bool(true) */ |
125 | 136 | private array $analysedFiles = []; |
126 | 137 |
|
@@ -1934,12 +1945,19 @@ public function processArgs( |
1934 | 1945 | // args that pin them, so determining siblings are already processed; the mixed pad keeps |
1935 | 1946 | // the argument COUNT correct so the by-ref/variadic variant stays stable (e.g. sscanf), |
1936 | 1947 | // while processed siblings resolve a generic callable(T) parameter. No forward read. |
| 1948 | + // An argument that only CONTAINS closures (array literal, ternary) pins template |
| 1949 | + // types itself - array<T, Closure(T)> reads T off the keys - so it is padded with |
| 1950 | + // its structural type: the literal with each nested closure's declared signature. |
1937 | 1951 | $paddedTypes = []; |
1938 | 1952 | $paddedUnpack = false; |
1939 | 1953 | $paddedHasName = false; |
1940 | 1954 | foreach ($args as $j => $paddedArg) { |
1941 | 1955 | $paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg; |
1942 | | - $this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $gatheredArgTypeByIndex[$j] ?? new MixedType()); |
| 1956 | + $paddedType = $gatheredArgTypeByIndex[$j] ?? null; |
| 1957 | + if ($paddedType === null && $j === $i) { |
| 1958 | + $paddedType = $this->getStructuralArgType($scope, $arg->value); |
| 1959 | + } |
| 1960 | + $this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $paddedType ?? new MixedType()); |
1943 | 1961 | } |
1944 | 1962 | $argMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope); |
1945 | 1963 | } else { |
@@ -2178,6 +2196,9 @@ public function processArgs( |
2178 | 2196 | // getType() answers from the stored result |
2179 | 2197 | $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); |
2180 | 2198 | } else { |
| 2199 | + if ($parameterType !== null) { |
| 2200 | + $this->annotateNestedClosuresWithPassedToType($scope, $arg->value, $parameterType, $parameterNativeType); |
| 2201 | + } |
2181 | 2202 | $exprType = $scope->getType($arg->value); |
2182 | 2203 | $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; |
2183 | 2204 | if ($enterExpressionAssignForByRef) { |
@@ -2465,6 +2486,118 @@ private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $ |
2465 | 2486 | return $scope->getType($closureExpr); |
2466 | 2487 | } |
2467 | 2488 |
|
| 2489 | + /** |
| 2490 | + * The type of an argument that contains closures, without walking any |
| 2491 | + * closure body: array literals and ternaries are built structurally, |
| 2492 | + * a nested closure/arrow function contributes its declared signature |
| 2493 | + * only, and any other closure-containing expression (a call taking a |
| 2494 | + * closure, ...) is mixed. Sub-expressions without a closure inside read |
| 2495 | + * their scope type as usual. |
| 2496 | + * |
| 2497 | + * Null when the expression is not an array literal or ternary - there |
| 2498 | + * is nothing structural to read and the caller pads with mixed. |
| 2499 | + */ |
| 2500 | + private function getStructuralArgType(MutatingScope $scope, Expr $expr): ?Type |
| 2501 | + { |
| 2502 | + if ($expr instanceof Expr\Ternary) { |
| 2503 | + $ifType = $this->getStructuralItemType($scope, $expr->if ?? $expr->cond); |
| 2504 | + $elseType = $this->getStructuralItemType($scope, $expr->else); |
| 2505 | + |
| 2506 | + return TypeCombinator::union($ifType, $elseType); |
| 2507 | + } |
| 2508 | + |
| 2509 | + if (!$expr instanceof Expr\Array_) { |
| 2510 | + return null; |
| 2511 | + } |
| 2512 | + |
| 2513 | + return $this->container->getByType(InitializerExprTypeResolver::class)->getArrayType($expr, fn (Expr $itemExpr): Type => $this->getStructuralItemType($scope, $itemExpr)); |
| 2514 | + } |
| 2515 | + |
| 2516 | + private function getStructuralItemType(MutatingScope $scope, Expr $expr): Type |
| 2517 | + { |
| 2518 | + if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) { |
| 2519 | + return $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $expr, shallow: true); |
| 2520 | + } |
| 2521 | + |
| 2522 | + if (!$this->argConsumesResolvedParameterType($expr)) { |
| 2523 | + return $scope->getType($expr); |
| 2524 | + } |
| 2525 | + |
| 2526 | + return $this->getStructuralArgType($scope, $expr) ?? new MixedType(); |
| 2527 | + } |
| 2528 | + |
| 2529 | + /** |
| 2530 | + * Projects the parameter type an argument is passed to onto the |
| 2531 | + * closures/arrow functions nested inside it, so their parameters are |
| 2532 | + * inferred the same way as for a closure that IS the argument. |
| 2533 | + * |
| 2534 | + * Walks array literals (projecting through the key: a literal key or the |
| 2535 | + * auto-index reads the offset value type, so array shapes resolve per |
| 2536 | + * item) and both branches of a ternary. Any other expression stops the |
| 2537 | + * projection - its value is not the argument value itself. |
| 2538 | + */ |
| 2539 | + private function annotateNestedClosuresWithPassedToType(Scope $scope, Expr $expr, Type $type, ?Type $nativeType): void |
| 2540 | + { |
| 2541 | + if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) { |
| 2542 | + $expr->setAttribute(self::CLOSURE_PASSED_TO_TYPE_ATTRIBUTE, [$type, $nativeType]); |
| 2543 | + return; |
| 2544 | + } |
| 2545 | + |
| 2546 | + if (!$this->argConsumesResolvedParameterType($expr)) { |
| 2547 | + return; |
| 2548 | + } |
| 2549 | + |
| 2550 | + if ($expr instanceof Expr\Ternary) { |
| 2551 | + if ($expr->if !== null) { |
| 2552 | + $this->annotateNestedClosuresWithPassedToType($scope, $expr->if, $type, $nativeType); |
| 2553 | + } |
| 2554 | + $this->annotateNestedClosuresWithPassedToType($scope, $expr->else, $type, $nativeType); |
| 2555 | + return; |
| 2556 | + } |
| 2557 | + |
| 2558 | + if (!$expr instanceof Expr\Array_) { |
| 2559 | + return; |
| 2560 | + } |
| 2561 | + |
| 2562 | + // only the array members of a union describe the literal's items |
| 2563 | + if ($type instanceof UnionType) { |
| 2564 | + $type = $type->filterTypes(static fn (Type $innerType) => $innerType->isArray()->yes()); |
| 2565 | + if ($type->isArray()->no()) { |
| 2566 | + return; |
| 2567 | + } |
| 2568 | + } |
| 2569 | + if ($nativeType instanceof UnionType) { |
| 2570 | + $nativeType = $nativeType->filterTypes(static fn (Type $innerType) => $innerType->isArray()->yes()); |
| 2571 | + } |
| 2572 | + |
| 2573 | + $nextAutoIndex = 0; |
| 2574 | + foreach ($expr->items as $item) { |
| 2575 | + if ($item->unpack) { |
| 2576 | + // the unpacked count is unknown, later auto-indexes are too |
| 2577 | + $nextAutoIndex = null; |
| 2578 | + continue; |
| 2579 | + } |
| 2580 | + |
| 2581 | + if ($item->key === null) { |
| 2582 | + $keyType = $nextAutoIndex !== null ? new ConstantIntegerType($nextAutoIndex++) : null; |
| 2583 | + } else { |
| 2584 | + $keyType = $scope->getType($item->key); |
| 2585 | + $keyValues = $keyType->getConstantScalarValues(); |
| 2586 | + if ($nextAutoIndex !== null && count($keyValues) === 1 && is_int($keyValues[0])) { |
| 2587 | + $nextAutoIndex = max($nextAutoIndex, $keyValues[0] + 1); |
| 2588 | + } |
| 2589 | + } |
| 2590 | + |
| 2591 | + $itemType = $keyType !== null ? $type->getOffsetValueType($keyType) : $type->getIterableValueType(); |
| 2592 | + $itemNativeType = null; |
| 2593 | + if ($nativeType !== null) { |
| 2594 | + $itemNativeType = $keyType !== null ? $nativeType->getOffsetValueType($keyType) : $nativeType->getIterableValueType(); |
| 2595 | + } |
| 2596 | + |
| 2597 | + $this->annotateNestedClosuresWithPassedToType($scope, $item->value, $itemType, $itemNativeType); |
| 2598 | + } |
| 2599 | + } |
| 2600 | + |
2468 | 2601 | /** |
2469 | 2602 | * Whether processing this argument consumes the generic-RESOLVED parameter |
2470 | 2603 | * type: a closure/arrow function does - its parameters and body scope are |
|
0 commit comments