diff --git a/phpstan.neon b/phpstan.neon index 1494f51..34be61c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,3 +3,10 @@ parameters: paths: - src - tests + + excludePaths: + analyseAndScan: + - tests/data/* + +rules: + - \Ebln\PHPStan\EnforceFactory\ForceFactoryRule diff --git a/src/ForceFactory.php b/src/ForceFactory.php new file mode 100644 index 0000000..324fda4 --- /dev/null +++ b/src/ForceFactory.php @@ -0,0 +1,36 @@ + */ + private array $allowedFactories; + + /** @param class-string ...$factories */ + public function __construct(string ...$factories) + { + $allowedFactories = []; + foreach ($factories as $factory) { + $allowedFactories[$factory] = $factory; + } + + $this->allowedFactories = array_values($allowedFactories); + } + + /** @return array */ + public function getAllowedFactories(): array + { + return $this->allowedFactories; + } +} diff --git a/src/ForceFactoryRule.php b/src/ForceFactoryRule.php index 7afe65f..6cfa650 100644 --- a/src/ForceFactoryRule.php +++ b/src/ForceFactoryRule.php @@ -6,6 +6,7 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -14,6 +15,13 @@ */ class ForceFactoryRule implements Rule { + private ReflectionProvider $reflectionProvider; + + public function __construct(ReflectionProvider $reflectionProvider) + { + $this->reflectionProvider = $reflectionProvider; + } + public function getNodeType(): string { return \PhpParser\Node\Expr\New_::class; @@ -26,17 +34,18 @@ public function processNode(Node $node, Scope $scope): array if (!$isName) { continue; // newly instantiated class name couldn't be infered } + /** @var class-string $class → sadly Psalm cannot be convinced to return class-string for getClassNames in reasonable amount of time */ $allowedFactories = $this->getAllowedFactories($class); if (null === $allowedFactories) { - continue; // newly instantiated class dowsn't implement ForceFactory interface + continue; // newly instantiated class doesn't implement ForceFactory interface } - if (empty($allowedFactories)) { + if ([] === $allowedFactories) { $errors[] = RuleErrorBuilder::message( - ltrim($class, '\\') . ' cannot be instantiated by other classes; see ' . ForceFactoryInterface::class + ltrim($class, '\\') . ' has either no factories defined or a conflict between interface and attribute!' )->build(); - continue; + continue; // bogus configuration } /** @psalm-suppress PossiblyNullReference | sad that even phpstan cannot infer that from isInClass */ @@ -57,17 +66,55 @@ public function processNode(Node $node, Scope $scope): array } /** + * @phpstan-param class-string $className + * * @return null|string[] List of FQCNs * * @phpstan-return null|class-string[] */ private function getAllowedFactories(string $className): ?array { - if (!is_a($className, ForceFactoryInterface::class, true)) { + $allowedFactories = $this->getFactoriesFromAttribute($className); + + if (is_a($className, ForceFactoryInterface::class, true)) { + /* phpstan-var class-string $className */ + $interfaceFactories = $className::getFactories(); + sort($interfaceFactories); + if (null === $allowedFactories) { + $allowedFactories = $interfaceFactories; + } elseif ($allowedFactories !== $interfaceFactories) { + $allowedFactories = []; // Will result in a bogus definition error + } + } + + return $allowedFactories; + } + + /** + * @phpstan-param class-string $className + * + * @return array + */ + private function getFactoriesFromAttribute(string $className): ?array + { + if (\PHP_VERSION_ID < 80000 && $this->reflectionProvider->hasClass($className)) { return null; } + /** @psalm-suppress UndefinedDocblockClass */ + $reflection = $this->reflectionProvider->getClass($className)->getNativeReflection(); + /** @psalm-suppress UndefinedClass */ + foreach ($reflection->getAttributes() as $attribute) { + if (ForceFactory::class === $attribute->getName()) { + /** @var ForceFactory $forceFactory */ + $forceFactory = $attribute->newInstance(); + $allowedFactories = $forceFactory->getAllowedFactories(); + sort($allowedFactories); + + return $allowedFactories; + } + } - return $className::getFactories(); + return null; } /** diff --git a/tests/ForceFactoryRuleTest.php b/tests/ForceFactoryRuleTest.php index 5520bd8..3bfdb1f 100644 --- a/tests/ForceFactoryRuleTest.php +++ b/tests/ForceFactoryRuleTest.php @@ -25,7 +25,7 @@ public function testEmptyAllowedClasses(): void { $this->analyse([__DIR__ . '/data/EmptyFactory.php'], [ [ - 'Test\Ebln\PHPStan\EnforceFactory\data\code\EmptyProduct cannot be instantiated by other classes; see Ebln\PHPStan\EnforceFactory\ForceFactoryInterface', + 'Test\Ebln\PHPStan\EnforceFactory\data\code\EmptyProduct has either no factories defined or a conflict between interface and attribute!', 13, ], ]); @@ -67,6 +67,6 @@ public function testAllowedFactory(): void protected function getRule(): Rule { - return new ForceFactoryRule(); + return new ForceFactoryRule($this->createReflectionProvider()); } }