diff --git a/README.md b/README.md index ce6acd9..ec323f8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ > It moves PHP closer to compiled languages in the sense that the correctness > of each line of the code can be checked before you run the actual line. -This PHPStan extension makes enumerator accessor methods known to PHPStan. +This PHPStan extension makes enumerator accessor methods and enum possible values known to PHPStan. ## Install diff --git a/composer.json b/composer.json index 8fb0d33..8f52191 100644 --- a/composer.json +++ b/composer.json @@ -6,11 +6,11 @@ "license": "BSD-3-Clause", "require": { "php": "~7.1", - "marc-mabe/php-enum": "^1.0 || ^2.0 || ^3.0 || ^4.0", + "marc-mabe/php-enum": "^1.1 || ^2.0 || ^3.0 || ^4.0", "phpstan/phpstan": "^0.12" }, "require-dev": { - "phpunit/phpunit": "^7.5" + "phpunit/phpunit": "^7.5.20" }, "autoload": { "psr-4": { diff --git a/extension.neon b/extension.neon index 37e101d..f6c4b98 100644 --- a/extension.neon +++ b/extension.neon @@ -2,3 +2,7 @@ services: - class: MabeEnumPHPStan\EnumMethodsClassReflectionExtension tags: - phpstan.broker.methodsClassReflectionExtension + + - class: MabeEnumPHPStan\EnumDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension diff --git a/src/EnumDynamicReturnTypeExtension.php b/src/EnumDynamicReturnTypeExtension.php new file mode 100644 index 0000000..00d5b1a --- /dev/null +++ b/src/EnumDynamicReturnTypeExtension.php @@ -0,0 +1,132 @@ +, Type[]> + */ + private $enumValueTypesBuffer = []; + + /** + * Buffer of all types of enumeration ordinals + * @phpstan-var array, Type[]> + */ + private $enumOrdinalTypesBuffer = []; + + public function getClass(): string + { + return Enum::class; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + $supportedMethods = ['getvalue']; + if (method_exists(Enum::class, 'getValues')) { + array_push($supportedMethods, 'getvalues'); + } + + return in_array(strtolower($methodReflection->getName()), $supportedMethods, true); + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope + ): Type { + $callType = $scope->getType($methodCall->var); + $callClasses = $callType->getReferencedClasses(); + $methodName = strtolower($methodReflection->getName()); + $returnTypes = []; + foreach ($callClasses as $callClass) { + if (!is_subclass_of($callClass, Enum::class, true)) { + $returnTypes[] = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants()) + ->getReturnType(); + } else { + switch ($methodName) { + case 'getvalue': + $returnTypes[] = $this->enumGetValueReturnType($callClass); + break; + case 'getvalues': + $returnTypes[] = $this->enumGetValuesReturnType($callClass); + break; + default: + throw new ShouldNotHappenException("Method {$methodName} is not supported"); + } + } + } + + return TypeCombinator::union(...$returnTypes); + } + + /** + * Returns types of all values of an enumeration + * @phpstan-param class-string $enumeration + * @return Type[] + */ + private function enumValueTypes(string $enumeration): array + { + if (isset($this->enumValueTypesBuffer[$enumeration])) { + return $this->enumValueTypesBuffer[$enumeration]; + } + + $values = array_values($enumeration::getConstants()); + $types = array_map([ConstantTypeHelper::class, 'getTypeFromValue'], $values); + + return $this->enumValueTypesBuffer[$enumeration] = $types; + } + + /** + * Returns types of all ordinals of an enumeration + * @phpstan-param class-string $enumeration + * @return Type[] + */ + private function enumOrdinalTypes(string $enumeration): array + { + if (isset($this->enumOrdinalTypesBuffer[$enumeration])) { + return $this->enumOrdinalTypesBuffer[$enumeration]; + } + + $ordinals = array_keys($enumeration::getOrdinals()); + $types = array_map([ConstantTypeHelper::class, 'getTypeFromValue'], $ordinals); + + return $this->enumOrdinalTypesBuffer[$enumeration] = $types; + } + + /** + * Returns return type of Enum::getValue() + * @phpstan-param class-string $enumeration + */ + private function enumGetValueReturnType(string $enumeration): Type + { + return TypeCombinator::union(...$this->enumValueTypes($enumeration)); + } + + /** + * Returns return type of Enum::getValues() + * @phpstan-param class-string $enumeration + */ + private function enumGetValuesReturnType(string $enumeration): ArrayType + { + $keyTypes = $this->enumOrdinalTypes($enumeration); + $valueTypes = $this->enumValueTypes($enumeration); + return new ConstantArrayType($keyTypes, $valueTypes, count($keyTypes)); + } +} diff --git a/tests/Assets/AllTypeEnum.php b/tests/Assets/AllTypeEnum.php new file mode 100644 index 0000000..90d86ac --- /dev/null +++ b/tests/Assets/AllTypeEnum.php @@ -0,0 +1,17 @@ +extension = new EnumDynamicReturnTypeExtension(); + + // Version < 3.x did not support array values + if (method_exists(Enum::class, 'getByName')) { + $this->defaultReturnType = 'bool|float|int|string|null'; + } + } + + public function testNullType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValue()', 'null', $this->extension); + } + + public function testBoolType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValue()', 'bool', $this->extension); + } + + public function testStringType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValue()', "'str1'|'str2'", $this->extension); + } + + public function testIntType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValue()', '0|1', $this->extension); + } + + public function testFloatType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValue()', '1.1|1.2', $this->extension); + } + + public function testArrayType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValue()', 'array(array())', $this->extension); + } + + public function testAllTypes(): void + { + $code = <<<'CODE' +processCode( + $code, + '$enum->getValue()', + "1|1.1|'str'|array(null, true, 1, 1.1, 'str', array())|true|null", + $this->extension + ); + } + + public function testGeneralizedTypes(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValue()', 'string', $this->extension); + } + + public function testBaseEnum(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValue()', $this->defaultReturnType, $this->extension); + } + + public function testUnionEnum(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValue()', "0|1|'str1'|'str2'", $this->extension); + } + + public function testEnumAndNonEnum(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValue()', $this->defaultReturnType, $this->extension); + } +} diff --git a/tests/EnumDynamicReturnTypeExtensionGetValuesTest.php b/tests/EnumDynamicReturnTypeExtensionGetValuesTest.php new file mode 100644 index 0000000..30e338b --- /dev/null +++ b/tests/EnumDynamicReturnTypeExtensionGetValuesTest.php @@ -0,0 +1,158 @@ +markTestSkipped('Enum::getValues() not supported in version 1.x'); + } + + $this->extension = new EnumDynamicReturnTypeExtension(); + } + + public function testNullType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValues()', 'array(null)', $this->extension); + } + + public function testBoolType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValues()', 'array(true, false)', $this->extension); + } + + public function testStringType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValues()', "array('str1', 'str2')", $this->extension); + } + + public function testIntType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValues()', 'array(0, 1)', $this->extension); + } + + public function testFloatType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValues()', 'array(1.1, 1.2)', $this->extension); + } + + public function testArrayType(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValues()', 'array(array(array()))', $this->extension); + } + + public function testAllTypes(): void + { + $code = <<<'CODE' +processCode( + $code, + '$enum->getValues()', + "array(null, true, 1, 1.1, 'str', array(null, true, 1, 1.1, 'str', array()))", + $this->extension + ); + } + + public function testBaseEnum(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValues()', 'array', $this->extension); + } + + public function testUnionEnum(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValues()', "array(0|'str1', 1|'str2')", $this->extension); + } + + public function testEnumAndNonEnum(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getValues()', 'array', $this->extension); + } +} diff --git a/tests/EnumDynamicReturnTypeExtensionTest.php b/tests/EnumDynamicReturnTypeExtensionTest.php new file mode 100644 index 0000000..9b2c676 --- /dev/null +++ b/tests/EnumDynamicReturnTypeExtensionTest.php @@ -0,0 +1,33 @@ +extension = new EnumDynamicReturnTypeExtension(); + } + + public function testUnsupportedMethod(): void + { + $code = <<<'CODE' +processCode($code, '$enum->getName()', 'string', $this->extension); + } +} diff --git a/tests/EnumMethodReflectionTest.php b/tests/EnumMethodReflectionTest.php index abb1b68..fd27ce2 100644 --- a/tests/EnumMethodReflectionTest.php +++ b/tests/EnumMethodReflectionTest.php @@ -7,8 +7,9 @@ use MabeEnumPHPStan\EnumMethodReflection; use MabeEnumPHPStan\EnumMethodsClassReflectionExtension; use MabeEnumPHPStanTest\Assets\DeprecatedEnum; +use MabeEnumPHPStanTest\Assets\DocCommentEnum; use MabeEnumPHPStanTest\Assets\NotAnEnum; -use MabeEnumPHPStanTest\Assets\StrEnum; +use MabeEnumPHPStanTest\Assets\VisibilityEnum; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Testing\TestCase; use PHPStan\TrinaryLogic; @@ -34,7 +35,7 @@ public function setUp() public function getDeclaringClass() { - $classReflection = $this->broker->getClass(StrEnum::class); + $classReflection = $this->broker->getClass(VisibilityEnum::class); $methodReflection = $this->reflectionExtension->getMethod($classReflection, 'STR'); $this->assertSame($classReflection, $methodReflection->getDeclaringClass()); @@ -42,7 +43,7 @@ public function getDeclaringClass() public function testShouldBeStatic() { - $classReflection = $this->broker->getClass(StrEnum::class); + $classReflection = $this->broker->getClass(VisibilityEnum::class); $methodReflection = $this->reflectionExtension->getMethod($classReflection, 'STR'); $this->assertTrue($methodReflection->isStatic()); @@ -50,7 +51,7 @@ public function testShouldBeStatic() public function testShouldNotBePrivate() { - $classReflection = $this->broker->getClass(StrEnum::class); + $classReflection = $this->broker->getClass(VisibilityEnum::class); $methodReflection = $this->reflectionExtension->getMethod($classReflection, 'STR'); $this->assertFalse($methodReflection->isPrivate()); @@ -58,7 +59,7 @@ public function testShouldNotBePrivate() public function testShouldBePublic() { - $classReflection = $this->broker->getClass(StrEnum::class); + $classReflection = $this->broker->getClass(VisibilityEnum::class); $methodReflection = $this->reflectionExtension->getMethod($classReflection, 'STR'); $this->assertTrue($methodReflection->isPublic()); @@ -66,24 +67,24 @@ public function testShouldBePublic() public function testGetVariants() { - $classReflection = $this->broker->getClass(StrEnum::class); + $classReflection = $this->broker->getClass(VisibilityEnum::class); $methodReflection = $this->reflectionExtension->getMethod($classReflection, 'STR'); $parametersAcceptor = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants()); - $this->assertSame(StrEnum::class, $parametersAcceptor->getReturnType()->describe(VerbosityLevel::value())); + $this->assertSame(VisibilityEnum::class, $parametersAcceptor->getReturnType()->describe(VerbosityLevel::value())); } public function testGetDocComment() { - $classReflection = $this->broker->getClass(StrEnum::class); - $docMethodRefl = $this->reflectionExtension->getMethod($classReflection, 'STR'); - $noDocMethodRefl = $this->reflectionExtension->getMethod($classReflection, 'NO_DOC_BLOCK'); + $classReflection = $this->broker->getClass(DocCommentEnum::class); + $docMethodRefl = $this->reflectionExtension->getMethod($classReflection, 'WITH_DOC_BLOCK'); + $noDocMethodRefl = $this->reflectionExtension->getMethod($classReflection, 'WITHOUT_DOC_BLOCK'); // return null on no doc block $this->assertSame(null, $noDocMethodRefl->getDocComment()); // return the correct doc block - $this->assertRegExp('/String const without visibility declaration/', $docMethodRefl->getDocComment()); + $this->assertRegExp('/With doc block/', $docMethodRefl->getDocComment()); // remove @var declaration $this->assertNotRegExp('/@var/', $docMethodRefl->getDocComment()); diff --git a/tests/EnumMethodsClassReflectionExtensionTest.php b/tests/EnumMethodsClassReflectionExtensionTest.php index f390933..fd474a8 100644 --- a/tests/EnumMethodsClassReflectionExtensionTest.php +++ b/tests/EnumMethodsClassReflectionExtensionTest.php @@ -7,7 +7,7 @@ use MabeEnumPHPStan\EnumMethodReflection; use MabeEnumPHPStan\EnumMethodsClassReflectionExtension; use MabeEnumPHPStanTest\Assets\NotAnEnum; -use MabeEnumPHPStanTest\Assets\StrEnum; +use MabeEnumPHPStanTest\Assets\VisibilityEnum; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Testing\TestCase; use PHPStan\Type\VerbosityLevel; @@ -32,16 +32,16 @@ public function setUp() public function testHasMethodSuccess() { - $classReflection = $this->broker->getClass(StrEnum::class); + $classReflection = $this->broker->getClass(VisibilityEnum::class); - foreach (array_keys(StrEnum::getConstants()) as $name) { + foreach (array_keys(VisibilityEnum::getConstants()) as $name) { $this->assertTrue($this->reflectionExtension->hasMethod($classReflection, $name)); } } public function testHasMethodUnknownNotFound() { - $classReflection = $this->broker->getClass(StrEnum::class); + $classReflection = $this->broker->getClass(VisibilityEnum::class); $this->assertFalse($this->reflectionExtension->hasMethod($classReflection, 'UNKNOWN')); } @@ -53,9 +53,9 @@ public function testHasMethodNotSubclassOfEnumNotFound() public function testGetMethodSuccess() { - $classReflection = $this->broker->getClass(StrEnum::class); + $classReflection = $this->broker->getClass(VisibilityEnum::class); - foreach (array_keys(StrEnum::getConstants()) as $name) { + foreach (array_keys(VisibilityEnum::getConstants()) as $name) { $methodReflection = $this->reflectionExtension->getMethod($classReflection, $name); $this->assertInstanceOf(EnumMethodReflection::class, $methodReflection); diff --git a/tests/ExtensionTestCase.php b/tests/ExtensionTestCase.php new file mode 100644 index 0000000..4971c98 --- /dev/null +++ b/tests/ExtensionTestCase.php @@ -0,0 +1,91 @@ +createBroker([$extension]); + $parser = $this->getParser(); + $currentWorkingDirectory = $this->getCurrentWorkingDirectory(); + $fileHelper = new FileHelper($currentWorkingDirectory); + $typeSpecifier = $this->createTypeSpecifier(new Standard(), $broker); + /** @var \PHPStan\PhpDoc\PhpDocStringResolver $phpDocStringResolver */ + $phpDocStringResolver = self::getContainer()->getByType(PhpDocStringResolver::class); + $resolver = new NodeScopeResolver( + $broker, + $parser, + new FileTypeMapper( + $parser, + $phpDocStringResolver, + self::getContainer()->getByType(PhpDocNodeResolver::class), + $this->createMock(Cache::class), + $this->createMock(AnonymousClassNameHelper::class) + ), + $fileHelper, + $typeSpecifier, + true, + true, + true, + [], + [] + ); + $resolver->setAnalysedFiles([$fileHelper->normalizePath($file)]); + + $run = false; + $resolver->processNodes( + $parser->parseFile($file), + $this->createScopeFactory($broker, $typeSpecifier)->create(ScopeContext::create($file)), + function (Node $node, Scope $scope) use ($expression, $expectedType, &$run): void { + if ($node instanceof VirtualNode) { + return; + } + if ((new Standard())->prettyPrint([$node]) !== 'die') { + return; + } + /** @var \PhpParser\Node\Stmt\Expression $expNode */ + $expNode = $this->getParser()->parseString(sprintf('getType($expNode->expr)->describe(VerbosityLevel::precise())); + $run = true; + } + ); + self::assertTrue($run); + } + + protected function processCode( + string $code, + string $expression, + string $expectedType, + DynamicMethodReturnTypeExtension $extension + ): void { + $fd = tmpfile(); + $fdMeta = stream_get_meta_data($fd); + $file = $fdMeta['uri']; + fwrite($fd, $code, strlen($code)); + + $this->processFile($file, $expression, $expectedType, $extension); + } +}