Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Send yielded keys and values to the declared generator types
A yield hands its key and value to the consumer of the generator: the key
and value types of the enclosing function's declared return type
(Generator<TKey, TValue>, iterable<K, V>) are now send targets, like a
declared return type is for a returned value. yield from sends the keys
and values of the iterable it delegates to.

Unresolved template arguments resolve from them (`yield new
Collection([1])` in a Generator<int, Collection<int>> function gives
Collection<int>), and so do the parameters of closures whose signature is
inferred from their usages. A return type that describes no iterable keeps
yielded closures escaping, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpbB99tJfUmeArX74xqvHv
  • Loading branch information
ondrejmirtes and claude committed Sep 27, 2026
commit 7410e2df96e133a8c9cfd5849b558152b1090bb7
7 changes: 1 addition & 6 deletions src/Analyser/ExprHandler/YieldFromHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
use PHPStan\Analyser\ExpressionResultStorage;
use PHPStan\Analyser\ExprHandler;
use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper;
use PHPStan\Analyser\Generics\TemplateArgumentObserver;
use PHPStan\Analyser\ImpurePoint;
use PHPStan\Analyser\InternalThrowPoint;
use PHPStan\Analyser\MutatingScope;
Expand All @@ -37,7 +36,6 @@ final class YieldFromHandler implements ExprHandler
public function __construct(
private ExpressionResultFactory $expressionResultFactory,
private DefaultNarrowingHelper $defaultNarrowingHelper,
private TemplateArgumentObserver $templateArgumentObserver,
)
{
}
Expand All @@ -52,10 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$beforeScope = $scope;
$exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep());
$scope = $exprResult->getScope();
if ($nodeScopeResolver->observingTemplateArgumentFrame($scope) !== null) {
// the consumer of the generator can do anything with what it yields
$scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectEscape($exprResult->getType()));
}
$scope = $scope->addTemplateArgumentConstraints($nodeScopeResolver->collectYieldFromSend($scope, $exprResult->getType()));

$throwPoint = InternalThrowPoint::createImplicit($scope, $expr);

Expand Down
16 changes: 5 additions & 11 deletions src/Analyser/ExprHandler/YieldHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
use PHPStan\Analyser\ExpressionResultStorage;
use PHPStan\Analyser\ExprHandler;
use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper;
use PHPStan\Analyser\Generics\TemplateArgumentObserver;
use PHPStan\Analyser\ImpurePoint;
use PHPStan\Analyser\InternalThrowPoint;
use PHPStan\Analyser\MutatingScope;
Expand All @@ -38,7 +37,6 @@ final class YieldHandler implements ExprHandler
public function __construct(
private ExpressionResultFactory $expressionResultFactory,
private DefaultNarrowingHelper $defaultNarrowingHelper,
private TemplateArgumentObserver $templateArgumentObserver,
)
{
}
Expand Down Expand Up @@ -81,15 +79,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$isAlwaysTerminating = $isAlwaysTerminating || $valueResult->isAlwaysTerminating();
}

if ($nodeScopeResolver->observingTemplateArgumentFrame($scope) !== null) {
// the consumer of the generator can do anything with what it yields
foreach ([$keyResult, $valueResult] as $yieldedResult) {
if ($yieldedResult === null) {
continue;
}
$scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectEscape($yieldedResult->getType()));
}
}
$scope = $scope->addTemplateArgumentConstraints($nodeScopeResolver->collectYieldSend(
$scope,
$keyResult !== null ? $keyResult->getType() : null,
$valueResult !== null ? $valueResult->getType() : null,
));

// the enclosing function is lexical - the generator TSend type does not
// vary with the scope the callback is later invoked on - resolve it once here.
Expand Down
66 changes: 60 additions & 6 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -1121,17 +1121,71 @@ public function collectReturnSend(MutatingScope $scope, ExpressionResult $return
if ($frame === null) {
return TemplateArgumentConstraints::createEmpty();
}
if ($scope->isInAnonymousFunction()) {
$declaredReturnType = $scope->getAnonymousFunctionReturnType();
} else {
$function = $scope->getFunction();
$declaredReturnType = $function !== null ? $function->getReturnType() : null;
}
$declaredReturnType = self::getDeclaredReturnType($scope);
if ($declaredReturnType === null) {
return TemplateArgumentConstraints::createEmpty();
}

return $this->templateArgumentObserver->collectSend($declaredReturnType, $returnedResult->getType());
}

/**
* A generator hands out a key and a value: the key and value types of the
* declared return type (Generator<TKey, TValue>, iterable<K, V>) are send
* targets for the unresolved template arguments and the closures they carry.
* A return type that describes no iterable lets the closures escape.
*/
public function collectYieldSend(MutatingScope $scope, ?Type $keyType, ?Type $valueType): TemplateArgumentConstraints
{
$constraints = TemplateArgumentConstraints::createEmpty();
if ($this->observingTemplateArgumentFrame($scope) === null) {
return $constraints;
}
$declaredReturnType = self::getDeclaredReturnType($scope);
if ($declaredReturnType === null || !$declaredReturnType->isIterable()->yes()) {
foreach ([$keyType, $valueType] as $yieldedType) {
if ($yieldedType === null) {
continue;
}
$constraints = $constraints->merge($this->templateArgumentObserver->collectEscape($yieldedType));
}

return $constraints;
}
if ($keyType !== null) {
$constraints = $constraints->merge($this->templateArgumentObserver->collectSend($declaredReturnType->getIterableKeyType(), $keyType));
}
if ($valueType !== null) {
$constraints = $constraints->merge($this->templateArgumentObserver->collectSend($declaredReturnType->getIterableValueType(), $valueType));
}

return $constraints;
}

/**
* `yield from` hands out the keys and values of the iterable it delegates to.
*/
public function collectYieldFromSend(MutatingScope $scope, Type $delegatedType): TemplateArgumentConstraints
{
if ($this->observingTemplateArgumentFrame($scope) === null) {
return TemplateArgumentConstraints::createEmpty();
}
if (!$delegatedType->isIterable()->yes()) {
return $this->templateArgumentObserver->collectEscape($delegatedType);
}

return $this->collectYieldSend($scope, $delegatedType->getIterableKeyType(), $delegatedType->getIterableValueType());
}

private static function getDeclaredReturnType(MutatingScope $scope): ?Type
{
if ($scope->isInAnonymousFunction()) {
return $scope->getAnonymousFunctionReturnType();
}

$function = $scope->getFunction();

return $function !== null ? $function->getReturnType() : null;
}

}
36 changes: 36 additions & 0 deletions tests/PHPStan/Analyser/nsrt/closure-signature-from-usages.php
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,42 @@ public function escapeViaYield(): \Generator
yield $c;
}

/** @return \Generator<int, Closure(int): void> */
public function yieldToTypedGenerator(): \Generator
{
$c = function ($a): void {
assertType('int', $a);
};
yield $c;
}

/** @return \Generator<callable(string): void, int> */
public function yieldKeyToTypedGenerator(): \Generator
{
$c = function ($a): void {
assertType('string', $a);
};
yield $c => 1;
}

/** @return \Generator<int, callable(int): void> */
public function yieldFromToTypedGenerator(): \Generator
{
$c = function ($a): void {
assertType('int', $a);
};
yield from [$c];
}

/** @return iterable<int, callable(int): void> */
public function yieldToTypedIterable(): iterable
{
$c = function ($a): void {
assertType('int', $a);
};
yield $c;
}

public function escapeViaInclude(): void
{
$c = function ($a) {
Expand Down
65 changes: 65 additions & 0 deletions tests/PHPStan/Analyser/nsrt/template-argument-yield-send.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php // lint >= 8.0

namespace TemplateArgumentYieldSend;

use function PHPStan\Testing\assertType;

/** @template T */
class Collection
{

/** @param array<T> $items */
public function __construct(array $items = [])
{
}

}

class Foo
{

/** @return \Generator<int, Collection<int>> */
public function yieldValue(): \Generator
{
$c = new Collection([1]);
assertType('TemplateArgumentYieldSend\Collection<int>', $c);

yield $c;
}

/** @return \Generator<Collection<string>, int> */
public function yieldKey(): \Generator
{
$c = new Collection(['a']);
assertType('TemplateArgumentYieldSend\Collection<string>', $c);

yield $c => 1;
}

/** @return \Generator<int, Collection<int>> */
public function yieldFrom(): \Generator
{
$c = new Collection([1]);
assertType('TemplateArgumentYieldSend\Collection<int>', $c);

yield from [$c];
}

/** @return iterable<int, Collection<int>> */
public function yieldIterable(): iterable
{
$c = new Collection([1]);
assertType('TemplateArgumentYieldSend\Collection<int>', $c);

yield $c;
}

public function yieldUntyped(): \Generator
{
$c = new Collection([1]);
assertType('TemplateArgumentYieldSend\Collection<1>', $c);

yield $c;
}

}
Loading