diff --git a/README.md b/README.md index 9da179f..fb96e30 100644 --- a/README.md +++ b/README.md @@ -366,6 +366,15 @@ enum TaskStatus: int And if you're using the same meta property in multiple enums, you can create a dedicated trait that includes this `@method` annotation. +## PHPStan + +To assist PHPStan when using invokable cases, you can include the PHPStan extensions into your own `phpstan.neon` file: + +```yaml +includes: + - ./vendor/archtechx/enums/extension.neon +``` + ## Development Run all checks locally: diff --git a/extension.neon b/extension.neon new file mode 100644 index 0000000..d236892 --- /dev/null +++ b/extension.neon @@ -0,0 +1,2 @@ +includes: + - ./src/PHPStan/InvokableCases/extension.neon \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon index 6a91329..8efbb66 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,6 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon + - ./extension.neon parameters: paths: diff --git a/src/PHPStan/InvokableCases/ReflectionExtension.php b/src/PHPStan/InvokableCases/ReflectionExtension.php new file mode 100644 index 0000000..6ef612a --- /dev/null +++ b/src/PHPStan/InvokableCases/ReflectionExtension.php @@ -0,0 +1,31 @@ +getNativeReflection() instanceof ReflectionEnum + && $classReflection->hasTraitUse(InvokableCases::class) + ) { + return $classReflection->getNativeReflection()->hasCase($methodName); + } + + return false; + } + + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return new StaticInvokableCaseMethodReflection($classReflection, $methodName); + } +} diff --git a/src/PHPStan/InvokableCases/StaticInvokableCaseMethodReflection.php b/src/PHPStan/InvokableCases/StaticInvokableCaseMethodReflection.php new file mode 100644 index 0000000..0abae71 --- /dev/null +++ b/src/PHPStan/InvokableCases/StaticInvokableCaseMethodReflection.php @@ -0,0 +1,109 @@ +callStaticMethod = $this->classReflection->getMethod('__callStatic', new OutOfClassScope()); + } + + public function getDeclaringClass(): ClassReflection + { + return $this->classReflection; + } + + public function isStatic(): bool + { + return true; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getDocComment(): ?string + { + return $this->callStaticMethod->getDocComment(); + } + + public function getName(): string + { + return $this->name; + } + + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * @return ParametersAcceptor[] + */ + public function getVariants(): array + { + return [ + new FunctionVariant( + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + [], + false, + $this->classReflection->getBackedEnumType() ?? new StringType() + ), + ]; + } + + public function isDeprecated(): TrinaryLogic + { + return $this->callStaticMethod->isDeprecated(); + } + + public function getDeprecatedDescription(): ?string + { + return $this->callStaticMethod->getDeprecatedDescription(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getThrowType(): ?Type + { + return $this->callStaticMethod->getThrowType(); + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } +} diff --git a/src/PHPStan/InvokableCases/extension.neon b/src/PHPStan/InvokableCases/extension.neon new file mode 100644 index 0000000..6c9eb58 --- /dev/null +++ b/src/PHPStan/InvokableCases/extension.neon @@ -0,0 +1,5 @@ +services: + - + class: ArchTech\Enums\PHPStan\InvokableCases\ReflectionExtension + tags: + - phpstan.broker.methodsClassReflectionExtension \ No newline at end of file diff --git a/tests/PHPStan/InvokableCases/InvokableCasesTestCase.php b/tests/PHPStan/InvokableCases/InvokableCasesTestCase.php new file mode 100644 index 0000000..ae04874 --- /dev/null +++ b/tests/PHPStan/InvokableCases/InvokableCasesTestCase.php @@ -0,0 +1,44 @@ +createReflectionProvider(); + $class = $reflectionProvider->getClass($enum); + + if ($exists) { + $this->assertTrue($class->hasMethod($case), sprintf('%s on class %s does not exist', $case, $enum)); + $method = $class->getMethod($case, new OutOfClassScope()); + if ($static) { + $this->assertTrue($method->isStatic(), sprintf('%s on class %s is not static', $case, $enum)); + } else { + $this->assertFalse($method->isStatic(), sprintf('%s on class %s is static', $case, $enum)); + } + } else { + $this->assertFalse($class->hasMethod($case), sprintf('%s on class %s exists', $case, $enum)); + } + } + + public function assertStaticallyCallableType(string $enum, string $case, string $type): void + { + $reflectionProvider = $this->createReflectionProvider(); + $class = $reflectionProvider->getClass($enum); + + $method = $class->getMethod($case, new OutOfClassScope()); + $methodVariant = ParametersAcceptorSelector::selectSingle($method->getVariants()); + $methodReturnType = $methodVariant->getReturnType(); + $this->assertInstanceOf($type, $methodReturnType); + } +} diff --git a/tests/PHPStan/InvokableCases/Role.php b/tests/PHPStan/InvokableCases/Role.php new file mode 100644 index 0000000..9f7519c --- /dev/null +++ b/tests/PHPStan/InvokableCases/Role.php @@ -0,0 +1,26 @@ +value > self::created->value; + } +} diff --git a/tests/PHPStan/InvokableCases/Suits.php b/tests/PHPStan/InvokableCases/Suits.php new file mode 100644 index 0000000..c4ebc57 --- /dev/null +++ b/tests/PHPStan/InvokableCases/Suits.php @@ -0,0 +1,30 @@ + true, + default => false, + }; + } +} diff --git a/tests/PHPStan/InvokableCasesTest.php b/tests/PHPStan/InvokableCasesTest.php new file mode 100644 index 0000000..26a6bc3 --- /dev/null +++ b/tests/PHPStan/InvokableCasesTest.php @@ -0,0 +1,99 @@ +assertStaticallyCallable($class, 'cases'); + $this->assertStaticallyCallable($class, 'from', false); + $this->assertStaticallyCallable($class, 'tryFrom', false); + + // Defined methods + $this->assertStaticallyCallable($class, 'administrator'); + $this->assertStaticallyCallable($class, 'isManager', true, false); + + // Cases + $this->assertStaticallyCallable($class, 'admin'); + $this->assertStaticallyCallable($class, 'manager'); + $this->assertStaticallyCallable($class, 'staff'); + + // Missing Case + $this->assertStaticallyCallable($class, 'customer', false); +}); + +it('correctly identifies allowable static method calls for invokable int backed enum', function () { + $class = \ArchTech\Enums\Tests\PHPStan\InvokableCases\Status::class; + + // Base enum + $this->assertStaticallyCallable($class, 'cases'); + $this->assertStaticallyCallable($class, 'from'); + $this->assertStaticallyCallable($class, 'tryFrom'); + + // Defined methods + $this->assertStaticallyCallable($class, 'initial'); + $this->assertStaticallyCallable($class, 'isStarted', true, false); + + // Cases + $this->assertStaticallyCallable($class, 'created'); + $this->assertStaticallyCallable($class, 'running'); + $this->assertStaticallyCallable($class, 'done'); + + // Missing Case + $this->assertStaticallyCallable($class, 'failed', false); +}); + +it('correctly identifies allowable static method calls for invokable string backed enum', function () { + $class = \ArchTech\Enums\Tests\PHPStan\InvokableCases\Suits::class; + + // Base enum + $this->assertStaticallyCallable($class, 'cases'); + $this->assertStaticallyCallable($class, 'from'); + $this->assertStaticallyCallable($class, 'tryFrom'); + + // Defined methods + $this->assertStaticallyCallable($class, 'valuable'); + $this->assertStaticallyCallable($class, 'isRed', true, false); + + // Cases + $this->assertStaticallyCallable($class, 'clubs'); + $this->assertStaticallyCallable($class, 'diamonds'); + $this->assertStaticallyCallable($class, 'hearts'); + $this->assertStaticallyCallable($class, 'spades'); + + // Missing Case + $this->assertStaticallyCallable($class, 'joker', false); +}); + +it('correctly identifies types for invoked pure enum cases', function () { + $class = \ArchTech\Enums\Tests\PHPStan\InvokableCases\Role::class; + + // Cases + $this->assertStaticallyCallableType($class, 'admin', StringType::class); + $this->assertStaticallyCallableType($class, 'manager', StringType::class); + $this->assertStaticallyCallableType($class, 'staff', StringType::class); +}); + +it('correctly identifies types for invoked int backed enum cases', function () { + $class = \ArchTech\Enums\Tests\PHPStan\InvokableCases\Status::class; + + // Cases + $this->assertStaticallyCallableType($class, 'created', IntegerType::class); + $this->assertStaticallyCallableType($class, 'running', IntegerType::class); + $this->assertStaticallyCallableType($class, 'done', IntegerType::class); +}); + +it('correctly identifies types for invoked string backed enum cases', function () { + $class = \ArchTech\Enums\Tests\PHPStan\InvokableCases\Suits::class; + + // Cases + $this->assertStaticallyCallableType($class, 'clubs', StringType::class); + $this->assertStaticallyCallableType($class, 'diamonds', StringType::class); + $this->assertStaticallyCallableType($class, 'hearts', StringType::class); + $this->assertStaticallyCallableType($class, 'spades', StringType::class); +});