diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b04fa3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor +/phpunit.xml +/.php_cs.cache +composer.lock +.php-cs-fixer.cache +.phpunit.result.cache diff --git a/extension.neon b/extension.neon index 80cf7ed..edf79e3 100644 --- a/extension.neon +++ b/extension.neon @@ -7,3 +7,6 @@ parameters: - stubs/Money/MoneyParser.stub rules: - Ibexa\PHPStan\Rules\NoConfigResolverParametersInConstructorRule + - Ibexa\PHPStan\Rules\RequireInterfaceInDependenciesRule + - Ibexa\PHPStan\Rules\ClassTypeNamingRule + - Ibexa\PHPStan\Rules\FinalClassRule diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..0258184 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,16 @@ +parameters: + ignoreErrors: + - + message: "#^Property Ibexa\\\\Tests\\\\PHPStan\\\\Rules\\\\Fixtures\\\\RequireInterfaceInDependenciesFixture\\:\\:\\$classWithoutInterface is never read, only written\\.$#" + count: 1 + path: tests/rules/Fixtures/RequireInterfaceInDependenciesFixture.php + + - + message: "#^Property Ibexa\\\\Tests\\\\PHPStan\\\\Rules\\\\Fixtures\\\\RequireInterfaceInDependenciesFixture\\:\\:\\$concreteClass is never read, only written\\.$#" + count: 1 + path: tests/rules/Fixtures/RequireInterfaceInDependenciesFixture.php + + - + message: "#^Property Ibexa\\\\Tests\\\\PHPStan\\\\Rules\\\\Fixtures\\\\RequireInterfaceInDependenciesFixture\\:\\:\\$testInterface is never read, only written\\.$#" + count: 1 + path: tests/rules/Fixtures/RequireInterfaceInDependenciesFixture.php diff --git a/phpstan.neon b/phpstan.neon index ab3d432..c4d75b1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,3 +1,6 @@ +includes: + - phpstan-baseline.neon + parameters: level: 8 paths: diff --git a/rules/ClassTypeNamingRule.php b/rules/ClassTypeNamingRule.php new file mode 100644 index 0000000..c259fab --- /dev/null +++ b/rules/ClassTypeNamingRule.php @@ -0,0 +1,75 @@ + + */ +final class ClassTypeNamingRule implements Rule +{ + public function getNodeType(): string + { + return Node\Stmt\ClassLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->isRelevantNode($node)) { + return []; + } + + if ($node->name === null) { + return []; + } + + $className = $node->name->toString(); + $errors = []; + + if ($node instanceof Node\Stmt\Interface_ && substr($className, -9) !== 'Interface') { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Interface "%s" should have "Interface" suffix', + $className + ) + )->build(); + } + + if ($node instanceof Node\Stmt\Trait_ && substr($className, -5) !== 'Trait') { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Trait "%s" should have "Trait" suffix', + $className + ) + )->build(); + } + + if ($node instanceof Node\Stmt\Class_ && $node->isAbstract() && strpos($className, 'Abstract') !== 0) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Abstract class "%s" should have "Abstract" prefix', + $className + ) + )->build(); + } + + return $errors; + } + + private function isRelevantNode(Node $node): bool + { + return $node instanceof Node\Stmt\Interface_ + || $node instanceof Node\Stmt\Trait_ + || ($node instanceof Node\Stmt\Class_ && $node->isAbstract()); + } +} diff --git a/rules/FinalClassRule.php b/rules/FinalClassRule.php new file mode 100644 index 0000000..962cb9b --- /dev/null +++ b/rules/FinalClassRule.php @@ -0,0 +1,77 @@ + + */ +final class FinalClassRule implements Rule +{ + private ReflectionProvider $reflectionProvider; + + public function __construct( + ReflectionProvider $reflectionProvider + ) { + $this->reflectionProvider = $reflectionProvider; + } + + public function getNodeType(): string + { + return Node\Stmt\Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + // Skip anonymous classes + if (!isset($node->namespacedName)) { + return []; + } + + $className = $node->namespacedName->toString(); + + if (!$this->reflectionProvider->hasClass($className)) { + return []; + } + + $reflection = $this->reflectionProvider->getClass($className); + + // Skip if already final + if ($reflection->isFinal()) { + return []; + } + + // Skip if abstract (abstract classes shouldn't be final) + if ($reflection->isAbstract()) { + return []; + } + + // Skip interfaces and traits + if ($reflection->isInterface() || $reflection->isTrait()) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf( + 'Class %s is not final. All non-abstract classes should be final.', + $reflection->getName() + ) + ) + ->identifier('class.notFinal') + ->tip('Add "final" keyword to the class declaration.') + ->build(), + ]; + } +} diff --git a/rules/RequireInterfaceInDependenciesRule.php b/rules/RequireInterfaceInDependenciesRule.php new file mode 100644 index 0000000..8499d31 --- /dev/null +++ b/rules/RequireInterfaceInDependenciesRule.php @@ -0,0 +1,78 @@ + + */ +final class RequireInterfaceInDependenciesRule implements Rule +{ + public function getNodeType(): string + { + return Node\Stmt\ClassMethod::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + + if (!$node->params) { + return []; + } + + foreach ($node->params as $param) { + if (!$param->type instanceof Node\Name) { + continue; + } + if ($param->var instanceof Error) { + continue; + } + $typeName = $param->type->toString(); + + // Skip built-in types and primitives + if ($this->isBuiltInType($typeName)) { + continue; + } + + if (!interface_exists($typeName) && class_exists($typeName)) { + // Check if this class implements any interface + $interfaces = class_implements($typeName); + + if (!empty($interfaces)) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Parameter $%s uses concrete class %s instead of an interface. Available interfaces: %s', + is_string($param->var->name) ? $param->var->name : $param->var->name->getType(), + $typeName, + implode(', ', $interfaces) + ) + )->build(); + } + } + } + + return $errors; + } + + private function isBuiltInType(string $type): bool + { + $builtInTypes = [ + 'string', 'int', 'float', 'bool', 'array', 'object', + 'callable', 'iterable', 'mixed', 'void', 'never', + ]; + + return in_array(strtolower($type), $builtInTypes); + } +} diff --git a/tests/rules/ClassTypeNamingRuleTest.php b/tests/rules/ClassTypeNamingRuleTest.php new file mode 100644 index 0000000..507f757 --- /dev/null +++ b/tests/rules/ClassTypeNamingRuleTest.php @@ -0,0 +1,52 @@ + + */ +final class ClassTypeNamingRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new ClassTypeNamingRule(); + } + + public function testRule(): void + { + $this->analyse( + [ + __DIR__ . '/Fixtures/ClassTypeNaming/WrongName.php', + __DIR__ . '/Fixtures/ClassTypeNaming/SimpleThing.php', + __DIR__ . '/Fixtures/ClassTypeNaming/SimpleClass.php', + __DIR__ . '/Fixtures/ClassTypeNaming/CorrectNameInterface.php', + __DIR__ . '/Fixtures/ClassTypeNaming/CorrectNameTrait.php', + __DIR__ . '/Fixtures/ClassTypeNaming/AbstractCorrectClass.php', + ], + [ + [ + 'Interface "WrongName" should have "Interface" suffix', + 11, + ], + [ + 'Trait "SimpleThing" should have "Trait" suffix', + 11, + ], + [ + 'Abstract class "SimpleClass" should have "Abstract" prefix', + 11, + ], + ] + ); + } +} diff --git a/tests/rules/FinalClassRuleTest.php b/tests/rules/FinalClassRuleTest.php new file mode 100644 index 0000000..6d91f24 --- /dev/null +++ b/tests/rules/FinalClassRuleTest.php @@ -0,0 +1,53 @@ + + */ +final class FinalClassRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new FinalClassRule($this->createReflectionProvider()); + } + + public function testRule(): void + { + $this->analyse( + [ + __DIR__ . '/Fixtures/FinalClass/NonFinalClass.php', + ], + [ + [ + 'Class Ibexa\Tests\PHPStan\Rules\Fixtures\FinalClass\NonFinalClass is not final. All non-abstract classes should be final.', + 11, + 'Add "final" keyword to the class declaration.', + ], + ] + ); + } + + public function testNoErrorsOnFinalAndAbstractClassesAndInterfaces(): void + { + $this->analyse( + [ + __DIR__ . '/Fixtures/FinalClass/FinalClass.php', + __DIR__ . '/Fixtures/FinalClass/AbstractClass.php', + __DIR__ . '/Fixtures/FinalClass/SomeInterface.php', + __DIR__ . '/Fixtures/FinalClass/SomeTrait.php', + ], + [] + ); + } +} diff --git a/tests/rules/Fixtures/ClassTypeNaming/AbstractCorrectClass.php b/tests/rules/Fixtures/ClassTypeNaming/AbstractCorrectClass.php new file mode 100644 index 0000000..1a2f293 --- /dev/null +++ b/tests/rules/Fixtures/ClassTypeNaming/AbstractCorrectClass.php @@ -0,0 +1,13 @@ +concreteClass = $concreteClass; + $this->testInterface = $testInterface; + $this->classWithoutInterface = $classWithoutInterface; + } + + public function methodWithConcreteClass(ConcreteClass $class): void + { + } + + public function methodWithInterface(TestInterface $interface): void + { + } + + public function methodWithoutInterface(ClassWithoutInterface $class): void + { + } + + public function methodWithBuiltInTypes(string $str, int $num): void + { + } +} diff --git a/tests/rules/RequireInterfaceInDependenciesRuleTest.php b/tests/rules/RequireInterfaceInDependenciesRuleTest.php new file mode 100644 index 0000000..13bdff2 --- /dev/null +++ b/tests/rules/RequireInterfaceInDependenciesRuleTest.php @@ -0,0 +1,43 @@ + + */ +final class RequireInterfaceInDependenciesRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new RequireInterfaceInDependenciesRule(); + } + + public function testRule(): void + { + $this->analyse( + [ + __DIR__ . '/Fixtures/RequireInterfaceInDependenciesFixture.php', + ], + [ + [ + 'Parameter $concreteClass uses concrete class Ibexa\Tests\PHPStan\Rules\Fixtures\RequireInterfaceInDependencies\ConcreteClass instead of an interface. Available interfaces: Ibexa\Tests\PHPStan\Rules\Fixtures\RequireInterfaceInDependencies\TestInterface', + 23, + ], + [ + 'Parameter $class uses concrete class Ibexa\Tests\PHPStan\Rules\Fixtures\RequireInterfaceInDependencies\ConcreteClass instead of an interface. Available interfaces: Ibexa\Tests\PHPStan\Rules\Fixtures\RequireInterfaceInDependencies\TestInterface', + 33, + ], + ] + ); + } +}