Skip to content
Open
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
27 changes: 25 additions & 2 deletions src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ final class ClosureTypeResolver implements PerFileAnalysisResettable

private static int $resolveClosureTypeDepth = 0;

/**
* Counts the closure types the $resolveClosureTypeDepth guard answered
* without a walk. A body walk that sees it change stored the types of
* nested closures it could not walk, so its return types are priced again
* on the scope once the guard is lower. See readableWalkStorage().
*/
private static int $cappedClosureTypeWalks = 0;

/**
* Per-context resolved closure types, keyed by the closure node. Node
* attributes would persist on the parser cache's retained ASTs after the
Expand Down Expand Up @@ -156,6 +164,7 @@ public function getClosureType(
return $this->createClosureTypeFromCache($scope, $expr, $parameters, $isVariadic, $cachedTypes[$cacheKey], $contextFree);
}
if (self::$resolveClosureTypeDepth >= 2) {
self::$cappedClosureTypeWalks++;
return new ClosureType(
$parameters,
$scope->getFunctionType($expr->returnType, false, false),
Expand All @@ -173,6 +182,7 @@ public function getClosureType(
$arrowFunctionImpurePoints = [];
$invalidateExpressions = [];
self::$resolveClosureTypeDepth++;
$cappedBefore = self::$cappedClosureTypeWalks;
$walkStorage = new ExpressionResultStorage();
$arrowScope->pushExpressionResultStorage($walkStorage);
try {
Expand Down Expand Up @@ -215,12 +225,13 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu

// the body was processed just above; resolve the return type from its stored
// result rather than reading the still-unprocessed body expression
$returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, false, $walkStorage);
$returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, false, self::readableWalkStorage($walkStorage, $cappedBefore));

return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $cacheKey, $contextFree);
}

self::$resolveClosureTypeDepth++;
$cappedBefore = self::$cappedClosureTypeWalks;

$closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters);
$closureReturnStatements = [];
Expand Down Expand Up @@ -290,11 +301,23 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu
$invalidateExpressions,
$cacheKey,
false,
$walkStorage,
self::readableWalkStorage($walkStorage, $cappedBefore),
$contextFree,
);
}

/**
* The walk's storage, or null when the depth guard answered a nested
* closure type during the walk. The stored types then hold the declared
* return types of the closures the walk could not reach, and reading
* the returns on the scope after the walk prices those closures with
* the guard one level lower.
*/
private static function readableWalkStorage(ExpressionResultStorage $walkStorage, int $cappedBefore): ?ExpressionResultStorage
{
return self::$cappedClosureTypeWalks === $cappedBefore ? $walkStorage : null;
}

/**
* Constructs a closure type from data the engine already gathered while
* walking the body once (see ClosureProcessor::processClosureNode()),
Expand Down
68 changes: 68 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15406.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php // lint >= 8.1

declare(strict_types = 1);

namespace Bug15406;

use function PHPStan\Testing\assertType;

final class Node
{
/** @param list<self> $children */
public function __construct(public readonly array $children, public readonly int $value) {}
}

/**
* @template T
* @template U
*
* @param list<T> $list
* @param callable(T): iterable<U> $fn
*
* @return list<U>
*/
function collect(array $list, callable $fn): array
{
$result = [];
foreach ($list as $value) {
foreach ($fn($value) as $item) {
$result[] = $item;
}
}

return $result;
}

/**
* @param list<Node> $nodes
*/
function twoLevels(array $nodes): void
{
assertType('list<int>', collect($nodes, static fn (Node $node): array
=> collect($node->children, static fn (Node $child): array => [$child->value]),
));
}

/**
* @param list<Node> $nodes
*/
function threeLevels(array $nodes): void
{
assertType('list<int>', collect($nodes, static fn (Node $node): array
=> collect($node->children, static fn (Node $child): array
=> collect($child->children, static fn (Node $grandChild): array => [$grandChild->value]),
),
));
}

/**
* @param list<Node> $nodes
*/
function threeLevelsGenerators(array $nodes): void
{
assertType('list<int>', collect($nodes, static fn (Node $node): iterable
=> yield from collect($node->children, static fn (Node $child): iterable
=> yield from collect($child->children, static fn (Node $grandChild): iterable => yield $grandChild->value),
),
));
}
60 changes: 60 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15406b.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug15406b;

use function PHPStan\Testing\assertType;

/**
* @template T
*/
final class Box
{
/** @param T $value */
public function __construct(public mixed $value)
{
}

/**
* @template U
* @param callable(T): U $callback
* @return Box<U>
*/
public function map(callable $callback): Box
{
return new Box($callback($this->value));
}
}

/**
* @template T
* @param callable(): T $callback
* @return T
*/
function run(callable $callback): mixed
{
return $callback();
}

function threeLevels(): void
{
$box = new Box(1);
assertType('Bug15406b\\Box<Bug15406b\\Box<Bug15406b\\Box<array{k: 1}>>>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): Box => (new Box($j))->map(fn (int $k): array => ['k' => $k]))));
}

function twoLevels(): void
{
/** @var Box<int> $box */
$box = new Box(1);
assertType('Bug15406b\\Box<Bug15406b\\Box<array{j: int}>>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): array => ['j' => $j])));
}

run(function (): void {
$box = new Box(1);
assertType('Bug15406b\\Box<Bug15406b\\Box<array{j: 1}>>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): array => ['j' => $j])));

/** @var Box<int> $box */
$box = new Box(1);
assertType('Bug15406b\\Box<Bug15406b\\Box<array{j: int}>>', $box->map(fn (int $i): Box => (new Box($i))->map(fn (int $j): array => ['j' => $j])));
});
Original file line number Diff line number Diff line change
Expand Up @@ -3325,6 +3325,14 @@ public function testBug11510(): void
]);
}

#[RequiresPhp('>= 8.1.0')]
public function testBug15406(): void
{
$this->checkExplicitMixed = true;
$this->checkImplicitMixed = true;
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-15406.php'], []);
}

public function testBug15487(): void
{
$this->checkExplicitMixed = true;
Expand Down
34 changes: 34 additions & 0 deletions tests/bench/data/bug-15406.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php declare(strict_types = 1);

namespace Bug15406Bench;

// closures that call themselves by reference, nested eight levels deep
function run(): int
{
$f1 = function (int $n1) use (&$f1): int {
$f2 = function (int $n2) use (&$f2): int {
$f3 = function (int $n3) use (&$f3): int {
$f4 = function (int $n4) use (&$f4): int {
$f5 = function (int $n5) use (&$f5): int {
$f6 = function (int $n6) use (&$f6): int {
$f7 = function (int $n7) use (&$f7): int {
$f8 = function (int $n8) use (&$f8): int {
return $n8 > 0 ? $f8($n8 - 1) + array_sum(array_map(fn (int $x): int => $f8($x), [1])) : 0;
};
return $n7 > 0 ? $f7($n7 - 1) + array_sum(array_map(fn (int $x): int => $f7($x), [1])) : 0;
};
return $n6 > 0 ? $f6($n6 - 1) + array_sum(array_map(fn (int $x): int => $f6($x), [1])) : 0;
};
return $n5 > 0 ? $f5($n5 - 1) + array_sum(array_map(fn (int $x): int => $f5($x), [1])) : 0;
};
return $n4 > 0 ? $f4($n4 - 1) + array_sum(array_map(fn (int $x): int => $f4($x), [1])) : 0;
};
return $n3 > 0 ? $f3($n3 - 1) + array_sum(array_map(fn (int $x): int => $f3($x), [1])) : 0;
};
return $n2 > 0 ? $f2($n2 - 1) + array_sum(array_map(fn (int $x): int => $f2($x), [1])) : 0;
};
return $n1 > 0 ? $f1($n1 - 1) + array_sum(array_map(fn (int $x): int => $f1($x), [1])) : 0;
};

return $f1(3);
}
25 changes: 20 additions & 5 deletions turbo-ext/src/ClosureTypeResolver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
* constructor keeps the twin's arginfo so Nette autowires it, the per-file
* cache lives in the twin's $cachedTypes slot with the twin's shape
* (spl_object_id => ['expr' => ..., 'types' => [cacheKey => [...]]]) and is
* updated in place. The private static $resolveClosureTypeDepth guard is a
* C++ counter (the declared static stays for reflection parity).
* updated in place. The private static $resolveClosureTypeDepth guard and
* $cappedClosureTypeWalks are C++ counters (the declared statics stay for
* reflection parity).
*
* getClosureType(), buildClosureTypeForClosure(),
* buildClosureTypeForArrowFunction() and getDeclaredClosureType() are exported
Expand Down Expand Up @@ -58,6 +59,9 @@ zend_string *pt_ctr_used_variables = nullptr;
/* the private static $resolveClosureTypeDepth */
int pt_ctr_resolve_closure_type_depth = 0;

/* the private static $cappedClosureTypeWalks */
int pt_ctr_capped_closure_type_walks = 0;

/* {{{ the PHP collaborators (one site each; switch to their direct entries
* once they are ported) */

Expand Down Expand Up @@ -355,7 +359,10 @@ class ClosureTypeResolver
ZVAL_DEREF(cached);
return createClosureTypeFromCache(scope, expr, p.parameters.raw(), p.isVariadic, cached, p.contextFree);
}
if (pt_ctr_resolve_closure_type_depth >= 2) return signatureClosureType(scope, expr, p.parameters.raw(), p.isVariadic);
if (pt_ctr_resolve_closure_type_depth >= 2) {
pt_ctr_capped_closure_type_walks++;
return signatureClosureType(scope, expr, p.parameters.raw(), p.isVariadic);
}

if (UNEXPECTED(!countClosureTypeBodyWalk())) return zv::Val();
int isArrowFunction = ptclosure::instanceOf(expr, PT_CLASS_ARROW_FUNCTION);
Expand Down Expand Up @@ -1336,6 +1343,12 @@ class ClosureTypeResolver
zend_hash_index_update(Z_ARRVAL_P(cachedTypes), id, &value);
}

/* Mirrors readableWalkStorage() (NULL for null) */
static zval *readableWalkStorage(zval *walkStorage, int cappedBefore)
{
return pt_ctr_capped_closure_type_walks == cappedBefore ? walkStorage : NULL;
}

/* the arrow-function body walk of getClosureType() */
zv::Val walkArrowFunction(zval *scope, zval *expr, Parameters &p, zend_string *cacheKey)
{
Expand All @@ -1354,6 +1367,7 @@ class ClosureTypeResolver
zv::Val arrowFunctionImpurePoints = ptsh::newArrayReference();
zv::Val invalidateExpressions = ptsh::newArrayReference();
pt_ctr_resolve_closure_type_depth++;
int cappedBefore = pt_ctr_capped_closure_type_walks;
zv::Val walkStorage = pt_expression_result_storage_new();
if (UNEXPECTED(walkStorage.isUndef())) return zv::Val();
if (UNEXPECTED(!pt_mutating_scope_push_expression_result_storage(Z_OBJ_P(arrowScope.raw()), walkStorage.raw()))) return zv::Val();
Expand Down Expand Up @@ -1390,7 +1404,7 @@ class ClosureTypeResolver

// the body was processed just above; resolve the return type from its stored
// result rather than reading the still-unprocessed body expression
zv::Val returnType = resolveArrowFunctionReturnType(scope, arrowScope.raw(), expr, false, walkStorage.raw());
zv::Val returnType = resolveArrowFunctionReturnType(scope, arrowScope.raw(), expr, false, readableWalkStorage(walkStorage.raw(), cappedBefore));
if (UNEXPECTED(returnType.isUndef())) return zv::Val();

zv::Arr usedVariables = zv::Arr::empty();
Expand All @@ -1401,6 +1415,7 @@ class ClosureTypeResolver
zv::Val walkClosure(zval *scope, zval *expr, Parameters &p, zend_string *cacheKey)
{
pt_ctr_resolve_closure_type_depth++;
int cappedBefore = pt_ctr_capped_closure_type_walks;

zval null;
ZVAL_NULL(&null);
Expand Down Expand Up @@ -1461,7 +1476,7 @@ class ClosureTypeResolver
zv::Val impurePoints = mergeLists(Z_REFVAL_P(closureImpurePoints.raw()), resultImpurePoints);
if (UNEXPECTED(impurePoints.isUndef())) return zv::Val();

return buildClosureTypeFromClosureWalk(scope, expr, p.parameters.raw(), p.isVariadic, Z_REFVAL_P(closureReturnStatements.raw()), Z_REFVAL_P(closureYieldStatements.raw()), Z_REFVAL_P(closureExecutionEnds.raw()), throwPointsOwned.raw(), impurePoints.raw(), Z_REFVAL_P(invalidateExpressions.raw()), cacheKey, false, walkStorage.raw(), p.contextFree);
return buildClosureTypeFromClosureWalk(scope, expr, p.parameters.raw(), p.isVariadic, Z_REFVAL_P(closureReturnStatements.raw()), Z_REFVAL_P(closureYieldStatements.raw()), Z_REFVAL_P(closureExecutionEnds.raw()), throwPointsOwned.raw(), impurePoints.raw(), Z_REFVAL_P(invalidateExpressions.raw()), cacheKey, false, readableWalkStorage(walkStorage.raw(), cappedBefore), p.contextFree);
}
};

Expand Down
Loading
Loading