Skip to content

Commit

Permalink
Add attributes from spike - yet without tests
Browse files Browse the repository at this point in the history
  • Loading branch information
ebln committed Jun 2, 2024
1 parent ba61f1c commit af4322f
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 8 deletions.
7 changes: 7 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,10 @@ parameters:
paths:
- src
- tests

excludePaths:
analyseAndScan:
- tests/data/*

rules:
- \Ebln\PHPStan\EnforceFactory\ForceFactoryRule
36 changes: 36 additions & 0 deletions src/ForceFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Ebln\PHPStan\EnforceFactory;

/**
* Marks classes to be instanciated by certain factories
*
* If used together with ForceFactoryInterface
* the configured factories must be congruent!
* This is enforced for PHP 8 and later.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class ForceFactory
{
/** @var array<int, class-string> */
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<int, class-string> */
public function getAllowedFactories(): array
{
return $this->allowedFactories;
}
}
59 changes: 53 additions & 6 deletions src/ForceFactoryRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

Expand All @@ -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;
Expand All @@ -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 */
Expand All @@ -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<ForceFactoryInterface> $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<class-string>
*/
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;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions tests/ForceFactoryRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
]);
Expand Down Expand Up @@ -67,6 +67,6 @@ public function testAllowedFactory(): void

protected function getRule(): Rule
{
return new ForceFactoryRule();
return new ForceFactoryRule($this->createReflectionProvider());
}
}

0 comments on commit af4322f

Please sign in to comment.