Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,10 @@ When adding or editing PHPDoc comments in this codebase, follow these guidelines

When methods are called on union types (`Foo|Bar`), the resolved method reflection is a `UnionTypeMethodReflection` that wraps the individual method reflections. Similarly, `IntersectionTypeMethodReflection` handles intersection types. These two classes must maintain feature parity for things like `getAsserts()`, `getSelfOutType()`, etc. When one class correctly combines member data (e.g. `IntersectionTypeMethodReflection::getAsserts()` iterating over methods and calling `intersectWith()`), the other should do the same rather than returning empty/null. The `Assertions::intersectWith()` method merges assertion tag lists from multiple sources.

### InitializerExprTypeResolver: closure return type inference from body

`InitializerExprTypeResolver` resolves types for constant expressions (class constant initializers, global constant initializers, default parameter values). For static closures, it constructs a `ClosureType` using parameter types and the return type annotation. Unlike `MutatingScope::getClosureType()` which performs full closure body analysis via `NodeScopeResolver`, `InitializerExprTypeResolver` has no scope — it resolves expressions statically. When inferring closure return types from the body, a `$variableTypes` map must be built from the closure's parameters and passed through to expression resolution, since variables fall through to `MixedType` in the normal `getType()` path. The `resolveExprTypeWithVariables()` helper intercepts `Variable` nodes and `Array_` expressions (which need the variable-aware callback passed to `getArrayType()`) to provide parameter-aware type resolution. The inferred return type is then intersected with the declared return type annotation via `intersectButNotNever()`.

## Important dependencies

- `nikic/php-parser` ^5.7.0 - PHP AST parsing
Expand Down
171 changes: 171 additions & 0 deletions src/Reflection/InitializerExprTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Closure;
use Nette\Utils\Strings;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\ComplexType;
use PhpParser\Node\Expr;
Expand Down Expand Up @@ -96,6 +97,7 @@
use PHPStan\Type\TypeUtils;
use PHPStan\Type\TypeWithClassName;
use PHPStan\Type\UnionType;
use PHPStan\Type\VoidType;
use stdClass;
use Throwable;
use function array_key_exists;
Expand Down Expand Up @@ -251,6 +253,24 @@ public function getType(Expr $expr, InitializerExprContext $context): Type
$returnType = $this->getFunctionType($expr->returnType, false, false, $context);
}

$variableTypes = [];
foreach ($expr->params as $param) {
if (!($param->var instanceof Variable) || !is_string($param->var->name)) {
continue;
}

$variableTypes[$param->var->name] = $this->getFunctionType($param->type, $this->isParameterValueNullable($param), $param->variadic, $context);
}

$inferredReturnType = $this->inferClosureReturnType($expr->stmts, $context, $variableTypes);
if ($inferredReturnType !== null) {
if ($expr->returnType !== null) {
$returnType = self::intersectButNotNever($returnType, $inferredReturnType);
} else {
$returnType = $inferredReturnType;
}
}

return new ClosureType(
$parameters,
$returnType,
Expand Down Expand Up @@ -503,6 +523,157 @@ public function getType(Expr $expr, InitializerExprContext $context): Type
return new MixedType();
}

/**
* @param Node\Stmt[] $stmts
* @param array<string, Type> $variableTypes
*/
private function inferClosureReturnType(array $stmts, InitializerExprContext $context, array $variableTypes): ?Type
{
$returnExprs = [];
$hasNull = false;
$this->collectReturnExpressions($stmts, $returnExprs, $hasNull);

if ($returnExprs === [] && !$hasNull) {
return null;
}

$returnTypes = [];
foreach ($returnExprs as $returnExpr) {
$returnTypes[] = $this->resolveExprTypeWithVariables($returnExpr, $context, $variableTypes);
}

if ($returnTypes === []) {
return new VoidType();
}

if ($hasNull) {
$returnTypes[] = new NullType();
}

return TypeCombinator::union(...$returnTypes);
}

/**
* @param array<string, Type> $variableTypes
*/
private function resolveExprTypeWithVariables(Expr $expr, InitializerExprContext $context, array $variableTypes): Type
{
if ($expr instanceof Variable && is_string($expr->name) && isset($variableTypes[$expr->name])) {
return $variableTypes[$expr->name];
}

if ($expr instanceof Expr\Array_) {
return $this->getArrayType($expr, fn (Expr $expr): Type => $this->resolveExprTypeWithVariables($expr, $context, $variableTypes));
}

return $this->getType($expr, $context);
}

/**
* @param Node\Stmt[] $stmts
* @param Expr[] $returnExprs
*/
private function collectReturnExpressions(array $stmts, array &$returnExprs, bool &$hasNull): void
{
foreach ($stmts as $stmt) {
if ($stmt instanceof Node\Stmt\Return_) {
if ($stmt->expr !== null) {
$returnExprs[] = $stmt->expr;
} else {
$hasNull = true;
}
continue;
}

// Skip nested closures, functions, and classes - they have their own scope
if (
$stmt instanceof Node\Stmt\Function_
|| $stmt instanceof Node\Stmt\Class_
|| $stmt instanceof Node\Stmt\Interface_
|| $stmt instanceof Node\Stmt\Trait_
|| $stmt instanceof Node\Stmt\Enum_
) {
continue;
}

// Check for expression statements containing closures/arrow functions
if ($stmt instanceof Node\Stmt\Expression) {
if ($stmt->expr instanceof Expr\Closure || $stmt->expr instanceof Expr\ArrowFunction) {
continue;
}
}

// Recurse into compound statements
if ($stmt instanceof Node\Stmt\If_) {
$this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull);
foreach ($stmt->elseifs as $elseif) {
$this->collectReturnExpressions($elseif->stmts, $returnExprs, $hasNull);
}
if ($stmt->else !== null) {
$this->collectReturnExpressions($stmt->else->stmts, $returnExprs, $hasNull);
}
continue;
}

if ($stmt instanceof Node\Stmt\Foreach_) {
$this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull);
continue;
}

if ($stmt instanceof Node\Stmt\For_) {
$this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull);
continue;
}

if ($stmt instanceof Node\Stmt\While_) {
$this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull);
continue;
}

if ($stmt instanceof Node\Stmt\Do_) {
$this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull);
continue;
}

if ($stmt instanceof Node\Stmt\Switch_) {
foreach ($stmt->cases as $case) {
$this->collectReturnExpressions($case->stmts, $returnExprs, $hasNull);
}
continue;
}

if ($stmt instanceof Node\Stmt\TryCatch) {
$this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull);
foreach ($stmt->catches as $catch) {
$this->collectReturnExpressions($catch->stmts, $returnExprs, $hasNull);
}
if ($stmt->finally !== null) {
$this->collectReturnExpressions($stmt->finally->stmts, $returnExprs, $hasNull);
}
continue;
}

if ($stmt instanceof Node\Stmt\Block) {
$this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull);
continue;
}
}
}

private static function intersectButNotNever(Type $nativeType, Type $inferredType): Type
{
if ($nativeType->isSuperTypeOf($inferredType)->no()) {
return $nativeType;
}

$result = TypeCombinator::intersect($nativeType, $inferredType);
if (TypeCombinator::containsNull($nativeType)) {
return TypeCombinator::addNull($result);
}

return $result;
}

/**
* @param callable(Expr): Type $getTypeCallback
*/
Expand Down
21 changes: 21 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14105.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php // lint >= 8.2

declare(strict_types = 1);

namespace Bug14105;

use function PHPStan\Testing\assertType;

final readonly class ABC
{
const func = static function (int $num): array {
return ['num' => $num];
};
}

const func = static function (int $num): array {
return ['num' => $num];
};

assertType('Closure(int): array{num: int}', ABC::func);
assertType('Closure(int): array{num: int}', func);
Loading