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
1 change: 1 addition & 0 deletions config.xsd
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@
<xs:element name="UnusedPsalmSuppress" type="FunctionIssueHandlerType" minOccurs="0" />
<xs:element name="UnusedReturnValue" type="IssueHandlerType" minOccurs="0" />
<xs:element name="UnusedVariable" type="IssueHandlerType" minOccurs="0" />
<xs:element name="HiddenGeneratorReturn" type="IssueHandlerType" minOccurs="0" />
</xs:choice>
<xs:anyAttribute processContents="skip" />
</xs:complexType>
Expand Down
1 change: 1 addition & 0 deletions docs/running_psalm/issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,4 @@
- [UnusedPsalmSuppress](issues/UnusedPsalmSuppress.md)
- [UnusedReturnValue](issues/UnusedReturnValue.md)
- [UnusedVariable](issues/UnusedVariable.md)
- [HiddenGeneratorReturn](issues/HiddenGeneratorReturn.md)
17 changes: 17 additions & 0 deletions docs/running_psalm/issues/HiddenGeneratorReturn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# HiddenGeneratorReturn

Emitted when trying to return a value from a generator function that does not have a `Generator` return type.

```php
<?php

/**
* @return iterable<"foo">
*/
function generator(): iterable
{
yield "foo";

return 1;
}
```
73 changes: 71 additions & 2 deletions src/Psalm/Internal/Analyzer/Statements/ReturnAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Psalm\Internal\Type\TemplateResult;
use Psalm\Internal\Type\TypeExpander;
use Psalm\Issue\FalsableReturnStatement;
use Psalm\Issue\HiddenGeneratorReturn;
use Psalm\Issue\InvalidDocblock;
use Psalm\Issue\InvalidReturnStatement;
use Psalm\Issue\LessSpecificReturnStatement;
Expand Down Expand Up @@ -330,10 +331,28 @@ public static function analyze(
}
}

if ($local_return_type->isGenerator() && $storage->has_yield) {
return;
if ($storage->has_yield) {
$generator_return = self::extractGeneratorReturnType($codebase, $local_return_type);
if (null !== $generator_return) {
[$generator_return_type, $is_from_generator] = $generator_return;
if (!$is_from_generator) {
if (IssueBuffer::accepts(
new HiddenGeneratorReturn(
'The value returned by generator ' . $cased_method_id
. ' may be inaccessible to callers.',
new CodeLocation($source, $stmt->expr),
),
$statements_analyzer->getSuppressedIssues(),
)) {
return;
}
}

$local_return_type = $generator_return_type;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the actual fix for #11458 which replaces the return type being compared against with TReturn from a generator, or with mixed if the current return type is not a generator.

}
}


if ($stmt_type->hasMixed()) {
if ($local_return_type->isVoid() || $local_return_type->isNever()) {
if (IssueBuffer::accepts(
Expand Down Expand Up @@ -716,4 +735,54 @@ private static function inferInnerClosureTypeFromParent(
}
return $return_type;
}

/**
* @return null|array{0: Union, 1: bool}
*/
private static function extractGeneratorReturnType(Codebase $codebase, Union $actual_return_type): ?array
{
$generator_return = null;
$could_be_array_or_traversable = false;

foreach ($actual_return_type->getAtomicTypes() as $atomic) {
if (!$atomic instanceof Type\Atomic\TArray &&
!$atomic instanceof Type\Atomic\TIterable &&
!$atomic instanceof Type\Atomic\TMixed &&
!$atomic->hasTraversableInterface($codebase)
) {
continue;
}

$could_be_array_or_traversable = true;
if (!$atomic instanceof Type\Atomic\TNamedObject) {
continue;
}

if ($atomic->value !== 'Generator') {
continue;
}

if ($atomic instanceof Type\Atomic\TGenericObject) {
$atomic_generator_return = $atomic->type_params[3] ?? type::getMixed();
} else {
$atomic_generator_return = Type::getMixed();
}

$generator_return = Type::combineUnionTypes(
$generator_return,
$atomic_generator_return,
$codebase,
);
}

if (!$could_be_array_or_traversable) {
return null;
}

if (null === $generator_return) {
return [Type::getMixed(), false];
}

return [$generator_return, true];
}
}
11 changes: 11 additions & 0 deletions src/Psalm/Issue/HiddenGeneratorReturn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Psalm\Issue;

final class HiddenGeneratorReturn extends CodeIssue
{
public const ERROR_LEVEL = 4;
public const SHORTCODE = 284;
Comment on lines +9 to +10
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if those values are correct.

}
10 changes: 8 additions & 2 deletions tests/Loop/ForeachTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1176,7 +1176,10 @@ function f(array $a): array {
'generatorWithUnspecifiedSend' => [
'code' => <<<'PHP'
<?php
/** @return Generator<int,int> */
/**
* @return Generator<int,int>
* @psalm-suppress MixedReturnStatement
*/
Comment on lines +1179 to +1182
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the MixedReturnStatement is correct, yield 1 results in mixed, which is returned. previously psalm did not check return statements for generators, so this was never triggered.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could make sense to not report MixedReturnStatement here actually, we do that in mago, the reason is the return type of the function is not mixed but rather TReturn on the Generator type is, so it might be a good idea to either not report it, or have a different error code that tells user to have a better TReturn for the generator other than mixed 🤔

function gen() : Generator {
return yield 1;
}
Expand All @@ -1187,7 +1190,10 @@ function gen() : Generator {
'generatorWithMixedSend' => [
'code' => <<<'PHP'
<?php
/** @return Generator<int,int, mixed, mixed> */
/**
* @return Generator<int,int, mixed, mixed>
* @psalm-suppress MixedReturnStatement
*/
Comment on lines +1193 to +1196
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

function gen() : Generator {
return yield 1;
}
Expand Down
28 changes: 28 additions & 0 deletions tests/ReturnTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ final class ReturnTypeTest extends TestCase
public function providerValidCodeParse(): iterable
{
return [
'suppressHiddenGeneratorReturn' => [
'code' => '<?php
/**
* @return iterable<"f">
* @psalm-suppress HiddenGeneratorReturn
*/
function generator(): iterable
{
yield "f";

return 1;
}
',
],
'arrayCombine' => [
'code' => '<?php
class a {}
Expand Down Expand Up @@ -1350,6 +1364,20 @@ function throwError(): never
public function providerInvalidCodeParse(): iterable
{
return [
'hiddenGeneratorReturn' => [
'code' => '<?php
/**
* @return iterable<"f">
*/
function generator(): iterable
{
yield "f";

return 1;
}
',
'error_message' => 'HiddenGeneratorReturn',
],
'wrongReturnType1' => [
'code' => '<?php
function fooFoo(): string {
Expand Down
Loading