Skip to content

Commit

Permalink
feat: Handle native intersection types
Browse files Browse the repository at this point in the history
Adds native intersection type handling to psalm, removing the previous `UnexpectedValueException`.

Where an intersection is found in the parse tree, the types are resolved using the existing `Type::intersectUnionTypes` function, which I assume is being used when they're encountered in the existing docblock annotations.

I've added a handful of tests to cover this, but they're certainly not exhaustive. Are there any specific edge cases I should target?

This change feels way too simple... so apologies if I've missed something fundamental..!
  • Loading branch information
petewalker committed Jan 21, 2022
1 parent f9ea275 commit 93d3196
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 23 deletions.
Expand Up @@ -8,6 +8,7 @@
use PhpParser;
use PhpParser\Node\Expr\BinaryOp\Concat;
use PhpParser\Node\Identifier;
use PhpParser\Node\IntersectionType;
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\UnionType;
Expand Down Expand Up @@ -1489,14 +1490,11 @@ private function visitPropertyDeclaration(

if ($stmt->type) {
$parser_property_type = $stmt->type;
if ($parser_property_type instanceof PhpParser\Node\IntersectionType) {
throw new UnexpectedValueException('Intersection types not yet supported');
}
/** @var Identifier|Name|NullableType|UnionType $parser_property_type */
/** @var Identifier|IntersectionType|Name|NullableType|UnionType $parser_property_type */

$signature_type = TypeHintResolver::resolve(
$parser_property_type,
$this->codebase->scanner,
$this->codebase,
$this->file_storage,
$this->storage,
$this->aliases,
Expand Down
Expand Up @@ -5,6 +5,7 @@
use LogicException;
use PhpParser;
use PhpParser\Node\Identifier;
use PhpParser\Node\IntersectionType;
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\Stmt\Class_;
Expand Down Expand Up @@ -423,14 +424,11 @@ public function start(PhpParser\Node\FunctionLike $stmt, bool $fake_method = fal

if ($parser_return_type) {
$original_type = $parser_return_type;
if ($original_type instanceof PhpParser\Node\IntersectionType) {
throw new UnexpectedValueException('Intersection types not yet supported');
}
/** @var Identifier|Name|NullableType|UnionType $original_type */
/** @var Identifier|IntersectionType|Name|NullableType|UnionType $original_type */

$storage->return_type = TypeHintResolver::resolve(
$original_type,
$this->codebase->scanner,
$this->codebase,
$this->file_storage,
$this->classlike_storage,
$this->aliases,
Expand Down Expand Up @@ -820,14 +818,11 @@ private function getTranslatedFunctionParam(
$param_typehint = $param->type;

if ($param_typehint) {
if ($param_typehint instanceof PhpParser\Node\IntersectionType) {
throw new UnexpectedValueException('Intersection types not yet supported');
}
/** @var Identifier|Name|NullableType|UnionType $param_typehint */
/** @var Identifier|IntersectionType|Name|NullableType|UnionType $param_typehint */

$param_type = TypeHintResolver::resolve(
$param_typehint,
$this->codebase->scanner,
$this->codebase,
$this->file_storage,
$this->classlike_storage,
$this->aliases,
Expand Down
44 changes: 38 additions & 6 deletions src/Psalm/Internal/PhpVisitor/Reflector/TypeHintResolver.php
Expand Up @@ -3,9 +3,14 @@
namespace Psalm\Internal\PhpVisitor\Reflector;

use PhpParser;
use PhpParser\Node\Identifier;
use PhpParser\Node\IntersectionType;
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\UnionType;
use Psalm\Aliases;
use Psalm\Codebase;
use Psalm\Internal\Analyzer\ClassLikeAnalyzer;
use Psalm\Internal\Codebase\Scanner as CodebaseScanner;
use Psalm\Storage\ClassLikeStorage;
use Psalm\Storage\FileStorage;
use Psalm\Type;
Expand All @@ -19,11 +24,11 @@
class TypeHintResolver
{
/**
* @param PhpParser\Node\Identifier|PhpParser\Node\Name|PhpParser\Node\NullableType|PhpParser\Node\UnionType $hint
* @param Identifier|IntersectionType|Name|NullableType|UnionType $hint
*/
public static function resolve(
PhpParser\NodeAbstract $hint,
CodebaseScanner $scanner,
Codebase $codebase,
FileStorage $file_storage,
?ClassLikeStorage $classlike_storage,
Aliases $aliases,
Expand All @@ -40,7 +45,7 @@ public static function resolve(
foreach ($hint->types as $atomic_typehint) {
$resolved_type = self::resolve(
$atomic_typehint,
$scanner,
$codebase,
$file_storage,
$classlike_storage,
$aliases,
Expand All @@ -54,6 +59,33 @@ public static function resolve(
return $type;
}

if ($hint instanceof PhpParser\Node\IntersectionType) {
$type = null;

if (!$hint->types) {
throw new UnexpectedValueException('bad');
}

foreach ($hint->types as $atomic_typehint) {
$resolved_type = self::resolve(
$atomic_typehint,
$codebase,
$file_storage,
$classlike_storage,
$aliases,
$analysis_php_version_id
);

$type = Type::intersectUnionTypes($resolved_type, $type, $codebase);
}

if ($type === null) {
throw new UnexpectedValueException('bad');
}

return $type;
}

$is_nullable = false;

if ($hint instanceof PhpParser\Node\NullableType) {
Expand All @@ -68,7 +100,7 @@ public static function resolve(
} elseif ($hint instanceof PhpParser\Node\Name\FullyQualified) {
$fq_type_string = (string)$hint;

$scanner->queueClassLikeForScanning($fq_type_string);
$codebase->scanner->queueClassLikeForScanning($fq_type_string);
$file_storage->referenced_classlikes[strtolower($fq_type_string)] = $fq_type_string;
} else {
$lower_hint = strtolower($hint->parts[0]);
Expand All @@ -86,7 +118,7 @@ public static function resolve(
$type_string = implode('\\', $hint->parts);
$fq_type_string = ClassLikeAnalyzer::getFQCLNFromNameObject($hint, $aliases);

$scanner->queueClassLikeForScanning($fq_type_string);
$codebase->scanner->queueClassLikeForScanning($fq_type_string);
$file_storage->referenced_classlikes[strtolower($fq_type_string)] = $fq_type_string;
}
}
Expand Down
20 changes: 18 additions & 2 deletions src/Psalm/Type.php
Expand Up @@ -563,10 +563,26 @@ public static function combineUnionTypes(
*
*/
public static function intersectUnionTypes(
Union $type_1,
Union $type_2,
?Union $type_1,
?Union $type_2,
Codebase $codebase
): ?Union {
if ($type_2 === null && $type_1 === null) {
throw new UnexpectedValueException('At least one type must be provided to combine');
}

if ($type_1 === null) {
return $type_2;
}

if ($type_2 === null) {
return $type_1;
}

if ($type_1 === $type_2) {
return $type_1;
}

$intersection_performed = false;
$type_1_mixed = $type_1->isMixed();
$type_2_mixed = $type_2->isMixed();
Expand Down
108 changes: 108 additions & 0 deletions tests/NativeIntersectionsTest.php
@@ -0,0 +1,108 @@
<?php

namespace Psalm\Tests;

use Psalm\Tests\Traits\InvalidCodeAnalysisTestTrait;
use Psalm\Tests\Traits\ValidCodeAnalysisTestTrait;

class NativeIntersectionsTest extends TestCase
{
use InvalidCodeAnalysisTestTrait;
use ValidCodeAnalysisTestTrait;

/**
* @return iterable<string,array{code:string,assertions?:array<string,string>,ignored_issues?:list<string>}>
*/
public function providerValidCodeParse(): iterable
{
return [
'nativeTypeIntersectionInConstructor' => [
'code' => '<?php
interface A {
}
interface B {
}
class Foo {
public function __construct(private A&B $self) {}
public function self(): A&B
{
return $this->self;
}
}',
'assertions' => [],
'ignored_issues' => [],
'php_version' => '8.1'
],
'nativeTypeIntersectionAsArgument' => [
'code' => '<?php
interface A {
function foo(): void;
}
interface B {
}
class C implements A, B {
function foo(): void {
}
}
function test(A&B $in): void {
$in->foo();
}
test(new C());
',
'assertions' => [],
'ignored_issues' => [],
'php_version' => '8.1'
],
];
}

/**
* @return iterable<string,array{code:string,error_message:string,ignored_issues?:list<string>,php_version?:string}>
*/
public function providerInvalidCodeParse(): iterable
{
return [
'invalidNativeIntersectionArgument' => [
'code' => '<?php
interface A {
function foo(): void;
}
interface B {
}
class C implements A {
function foo(): void {
}
}
function test(A&B $in): void {
$in->foo();
}
test(new C());
',
'error_message' => 'InvalidArgument',
'ignored_issues' => [],
'php_version' => '8.1'
],
'mismatchDocblockNativeIntersectionArgument' => [
'code' => '<?php
interface A {
function foo(): void;
}
interface B {
}
interface C {
}
/**
* @param A&C $in
*/
function test(A&B $in): void {
$in->foo();
}
',
'error_message' => 'MismatchingDocblockParamType',
'ignored_issues' => [],
'php_version' => '8.1'
],
];
}
}

0 comments on commit 93d3196

Please sign in to comment.