diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php index 9dffdfe8026..4c77a4ae1be 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php @@ -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; @@ -1485,19 +1486,7 @@ 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 */ - - $signature_type = TypeHintResolver::resolve( - $parser_property_type, - $this->codebase->scanner, - $this->file_storage, - $this->storage, - $this->aliases, - $this->codebase->analysis_php_version_id - ); + /** @var Identifier|IntersectionType|Name|NullableType|UnionType $parser_property_type */ $signature_type_location = new CodeLocation( $this->file_scanner, @@ -1506,6 +1495,16 @@ private function visitPropertyDeclaration( false, CodeLocation::FUNCTION_RETURN_TYPE ); + + $signature_type = TypeHintResolver::resolve( + $parser_property_type, + $signature_type_location, + $this->codebase, + $this->file_storage, + $this->storage, + $this->aliases, + $this->codebase->analysis_php_version_id + ); } $doc_var_group_type = $var_comment->type ?? null; diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php index 94546ec9745..fe5e1db2c35 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php @@ -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_; @@ -426,14 +427,15 @@ 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, + new CodeLocation( + $this->file_scanner, + $original_type + ), + $this->codebase, $this->file_storage, $this->classlike_storage, $this->aliases, @@ -824,14 +826,15 @@ 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, + new CodeLocation( + $this->file_scanner, + $param_typehint + ), + $this->codebase, $this->file_storage, $this->classlike_storage, $this->aliases, diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/TypeHintResolver.php b/src/Psalm/Internal/PhpVisitor/Reflector/TypeHintResolver.php index e594ac9afb1..9265d3c8b15 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/TypeHintResolver.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/TypeHintResolver.php @@ -3,9 +3,17 @@ 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\CodeLocation; +use Psalm\Codebase; use Psalm\Internal\Analyzer\ClassLikeAnalyzer; -use Psalm\Internal\Codebase\Scanner as CodebaseScanner; +use Psalm\Issue\ParseError; +use Psalm\IssueBuffer; use Psalm\Storage\ClassLikeStorage; use Psalm\Storage\FileStorage; use Psalm\Type; @@ -22,11 +30,12 @@ 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, + CodeLocation $code_location, + Codebase $codebase, FileStorage $file_storage, ?ClassLikeStorage $classlike_storage, Aliases $aliases, @@ -36,13 +45,23 @@ public static function resolve( $type = null; if (!$hint->types) { - throw new UnexpectedValueException('bad'); + throw new UnexpectedValueException('Union type should not be empty'); + } + + if ($analysis_php_version_id < 8_00_00) { + IssueBuffer::maybeAdd( + new ParseError( + 'Union types are not supported in PHP < 8', + $code_location + ) + ); } foreach ($hint->types as $atomic_typehint) { $resolved_type = self::resolve( $atomic_typehint, - $scanner, + $code_location, + $codebase, $file_storage, $classlike_storage, $aliases, @@ -55,6 +74,52 @@ public static function resolve( return $type; } + if ($hint instanceof PhpParser\Node\IntersectionType) { + $type = null; + + if (!$hint->types) { + throw new UnexpectedValueException('Intersection type should not be empty'); + } + + if ($analysis_php_version_id < 8_01_00) { + IssueBuffer::maybeAdd( + new ParseError( + 'Intersection types are not supported in PHP < 8.1', + $code_location + ) + ); + } + + foreach ($hint->types as $atomic_typehint) { + $resolved_type = self::resolve( + $atomic_typehint, + $code_location, + $codebase, + $file_storage, + $classlike_storage, + $aliases, + $analysis_php_version_id + ); + + if ($resolved_type->hasScalarType()) { + IssueBuffer::maybeAdd( + new ParseError( + 'Intersection types cannot contain scalar types', + $code_location + ) + ); + } + + $type = Type::intersectUnionTypes($resolved_type, $type, $codebase); + } + + if ($type === null) { + throw new UnexpectedValueException('Intersection type could not be resolved'); + } + + return $type; + } + $is_nullable = false; if ($hint instanceof PhpParser\Node\NullableType) { @@ -69,7 +134,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]); @@ -87,7 +152,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; } } diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index 513fab51d8a..68beb0cc670 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -551,10 +551,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(); diff --git a/tests/BinaryOperationTest.php b/tests/BinaryOperationTest.php index 63af0a13a53..2f2e37f197d 100644 --- a/tests/BinaryOperationTest.php +++ b/tests/BinaryOperationTest.php @@ -260,7 +260,7 @@ function takesI(I $i): void } /** - * @return iterable,ignored_issues?:list}> + * @return iterable,ignored_issues?:list,php_version?:string}> */ public function providerValidCodeParse(): iterable { @@ -818,6 +818,9 @@ function scope(array $a): int|float { return 0; } }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.0', ], 'NumericStringIncrementLiteral' => [ 'code' => ',ignored_issues?:list}> + * @return iterable,ignored_issues?:list,php_version?:string}> */ public function providerValidCodeParse(): iterable { @@ -719,7 +719,10 @@ function takesAorB(SomeClass|SomeInterface $some): void { if ($some instanceof SomeInterface) { $some->doStuff(); } - }' + }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.0', ], ]; } diff --git a/tests/NativeIntersectionsTest.php b/tests/NativeIntersectionsTest.php new file mode 100644 index 00000000000..d64587fc4ae --- /dev/null +++ b/tests/NativeIntersectionsTest.php @@ -0,0 +1,147 @@ +,ignored_issues?:list,php_version?:string}> + */ + public function providerValidCodeParse(): iterable + { + return [ + 'nativeTypeIntersectionInConstructor' => [ + 'code' => 'self; + } + }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1' + ], + 'nativeTypeIntersectionAsArgument' => [ + 'code' => 'foo(); + } + test(new C()); + ', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1' + ], + ]; + } + + /** + * @return iterable,php_version?:string}> + */ + public function providerInvalidCodeParse(): iterable + { + return [ + 'invalidNativeIntersectionArgument' => [ + 'code' => 'foo(); + } + test(new C()); + ', + 'error_message' => 'InvalidArgument', + 'ignored_issues' => [], + 'php_version' => '8.1' + ], + 'mismatchDocblockNativeIntersectionArgument' => [ + 'code' => 'foo(); + } + ', + 'error_message' => 'MismatchingDocblockParamType', + 'ignored_issues' => [], + 'php_version' => '8.1' + ], + 'intersectionsNotAllowedWithUnions' => [ + 'code' => ' 'ParseError', + 'ignored_issues' => [], + 'php_version' => '8.1' + ], + 'intersectionsNotAllowedWithNonClasses' => [ + 'code' => ' 'ParseError', + 'ignored_issues' => [], + 'php_version' => '8.1' + ], + 'intersectionsNotAllowedInPHP80' => [ + 'code' => ' 'ParseError', + 'ignored_issues' => [], + 'php_version' => '8.0' + ], + ]; + } +} diff --git a/tests/NativeUnionsTest.php b/tests/NativeUnionsTest.php new file mode 100644 index 00000000000..679c6d6bf51 --- /dev/null +++ b/tests/NativeUnionsTest.php @@ -0,0 +1,116 @@ +,ignored_issues?:list,php_version?:string}> + */ + public function providerValidCodeParse(): iterable + { + return [ + 'nativeTypeUnionInConstructor' => [ + 'code' => 'self; + } + }', + 'assertions' => [], + 'error_levels' => [], + 'php_version' => '8.0' + ], + 'nativeTypeUnionAsArgument' => [ + 'code' => 'foo(); + } + test(new C()); + ', + 'assertions' => [], + 'error_levels' => [], + 'php_version' => '8.0' + ], + 'unionAndNullableEquivalent' => [ + 'code' => ' [], + 'error_levels' => [], + 'php_version' => '8.0' + ], + ]; + } + + /** + * @return iterable,php_version?:string}> + */ + public function providerInvalidCodeParse(): iterable + { + return [ + 'invalidNativeUnionArgument' => [ + 'code' => ' 'InvalidScalarArgument', + 'ignored_issues' => [], + 'php_version' => '8.0' + ], + 'mismatchDocblockNativeUnionArgument' => [ + 'code' => ' 'MismatchingDocblockParamType', + 'ignored_issues' => [], + 'php_version' => '8.0' + ], + 'unionsNotAllowedInPHP74' => [ + 'code' => ' 'ParseError', + 'ignored_issues' => [], + 'php_version' => '7.4' + ], + ]; + } +} diff --git a/tests/PropertyTypeTest.php b/tests/PropertyTypeTest.php index fed1037eccd..8154402bd8c 100644 --- a/tests/PropertyTypeTest.php +++ b/tests/PropertyTypeTest.php @@ -346,7 +346,7 @@ public function getX(bool $b): int { } /** - * @return iterable,ignored_issues?:list}> + * @return iterable,ignored_issues?:list,php_version?:string}> */ public function providerValidCodeParse(): iterable { @@ -2157,7 +2157,10 @@ public function __construct() { $a = new A(); if ($a->i === 3) {} - if ($a->i === "foo") {}' + if ($a->i === "foo") {}', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.0', ], 'setClassStringOfStatic' => [ 'code' => ',ignored_issues?:list}> + * @return iterable,ignored_issues?:list,php_version?:string}> */ public function providerValidCodeParse(): iterable { @@ -4545,6 +4545,8 @@ class Secondary {} '$c3' => 'c3', '$resultC3' => 'RealE|Secondary', ], + 'ignored_issues' => [], + 'php_version' => '8.0', ], ]; } diff --git a/tests/Template/ClassTemplateTest.php b/tests/Template/ClassTemplateTest.php index 96ef16745ca..4c058f6eb43 100644 --- a/tests/Template/ClassTemplateTest.php +++ b/tests/Template/ClassTemplateTest.php @@ -14,7 +14,7 @@ class ClassTemplateTest extends TestCase use ValidCodeAnalysisTestTrait; /** - * @return iterable,ignored_issues?:list}> + * @return iterable,ignored_issues?:list,php_version?:string}> */ public function providerValidCodeParse(): iterable { @@ -3490,6 +3490,9 @@ class Dog {} function foo(Collection $c, Collection $d): object { return rand(0, 1) ? $c->get() : $d->get(); }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.0', ], 'allowCovariantBoundsMismatchDifferentContainers' => [ 'code' => 'get() : $d->get(); }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.0', ], 'allowCovariantBoundsMismatchContainerAndObject' => [ 'code' => 'get(); }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.0', ], 'allowCompatibleGenerics' => [ 'code' => ' 'InvalidReturnStatement', + 'ignored_issues' => [], + 'php_version' => '8.0', ], 'assignmentInBranchOfAndReferencedAfterIf' => [ 'code' => ',ignored_issues?:list}> + * @return iterable,ignored_issues?:list,php_version?:string}> */ public function providerValidCodeParse(): iterable { @@ -819,7 +819,10 @@ function bar(string $f) : void { function normalizeValue(bool|int|float|string $value): void { assert(is_string($value)); - }' + }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.0', ], 'NumericCanBeFalsy' => [ 'code' => ' [], + 'ignored_issues' => [], + 'php_version' => '8.0', ], 'NumericCanBeNotIntOrNotFloat' => [ 'code' => ',ignored_issues?:list}> + * @return iterable,ignored_issues?:list,php_version?:string}> */ public function providerValidCodeParse(): iterable { @@ -1037,7 +1037,10 @@ function foo(?B $b, ?C $c): B|C { } return $c; - }' + }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.0', ], 'dependentType' => [ 'code' => 'project_analyzer->setPhpVersion('7.4', 'tests'); $this->project_analyzer->getCodebase()->reportUnusedVariables(); } @@ -46,13 +45,15 @@ public function setUp(): void * @param array $error_levels * */ - public function testValidCode($code, array $error_levels = []): void + public function testValidCode($code, array $error_levels = [], string $php_version = '7.4'): void { $test_name = $this->getTestName(); if (strpos($test_name, 'SKIPPED-') !== false) { $this->markTestSkipped('Skipped due to a bug.'); } + $this->project_analyzer->setPhpVersion($php_version, 'tests'); + $file_path = self::$src_dir_path . 'somefile.php'; $this->addFile( @@ -84,6 +85,8 @@ public function testInvalidCode($code, $error_message, $error_levels = []): void $this->expectException(CodeException::class); $this->expectExceptionMessageRegExp('/\b' . preg_quote($error_message, '/') . '\b/'); + $this->project_analyzer->setPhpVersion('7.4', 'tests'); + $file_path = self::$src_dir_path . 'somefile.php'; foreach ($error_levels as $error_level) { @@ -99,7 +102,7 @@ public function testInvalidCode($code, $error_message, $error_levels = []): void } /** - * @return array}> + * @return array,php_version?:string}> */ public function providerValidCodeParse(): array { @@ -2371,7 +2374,9 @@ function bar(): int { return 2; } } - }' + }', + 'error_levels' => [], + 'php_version' => '8.0', ], 'concatWithUnknownProperty' => [ 'code' => '