diff --git a/Makefile b/Makefile index bfaaa6b192..3fd79c8174 100644 --- a/Makefile +++ b/Makefile @@ -83,6 +83,7 @@ lint: --exclude tests/PHPStan/Rules/Properties/data/property-hooks-bodies-in-interface.php \ --exclude tests/PHPStan/Rules/Properties/data/property-hooks-in-interface.php \ --exclude tests/PHPStan/Rules/Properties/data/property-hooks-visibility-in-interface.php \ + --exclude tests/PHPStan/Rules/Properties/data/missing-property-hook-implementation.php \ --exclude tests/PHPStan/Rules/Properties/data/abstract-hooked-properties-in-class.php \ --exclude tests/PHPStan/Rules/Properties/data/abstract-hooked-properties-with-bodies.php \ --exclude tests/PHPStan/Rules/Properties/data/abstract-non-hooked-properties-in-abstract-class.php \ diff --git a/src/Rules/Properties/MissingPropertyHookImplementationRule.php b/src/Rules/Properties/MissingPropertyHookImplementationRule.php new file mode 100644 index 0000000000..787fc78700 --- /dev/null +++ b/src/Rules/Properties/MissingPropertyHookImplementationRule.php @@ -0,0 +1,80 @@ + + */ +#[RegisteredRule(level: 0)] +final class MissingPropertyHookImplementationRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->phpVersion->supportsPropertyHooks()) { + return []; + } + + $classReflection = $node->getClassReflection(); + if ($classReflection->isInterface()) { + return []; + } + if ($classReflection->isAbstract()) { + return []; + } + + try { + $nativeProperties = $classReflection->getNativeReflection()->getProperties(); + } catch (IdentifierNotFound) { + return []; + } + + $messages = []; + foreach ($nativeProperties as $property) { + if (!$property->isAbstract()) { + continue; + } + + $declaringClass = $property->getBetterReflection()->getDeclaringClass(); + $declaringClassType = 'class'; + if ($declaringClass->isInterface()) { + $declaringClassType = 'interface'; + } elseif ($declaringClass->isTrait()) { + $declaringClassType = 'trait'; + } + + $messages[] = RuleErrorBuilder::message(sprintf( + 'Non-abstract class %s contains abstract property $%s from %s %s.', + $classReflection->getDisplayName(), + $property->getName(), + $declaringClassType, + $declaringClass->getName(), + )) + ->nonIgnorable() + ->identifier('property.abstract') + ->build(); + } + + return $messages; + } + +} diff --git a/tests/PHPStan/Rules/Properties/MissingPropertyHookImplementationRuleTest.php b/tests/PHPStan/Rules/Properties/MissingPropertyHookImplementationRuleTest.php new file mode 100644 index 0000000000..2962c224ca --- /dev/null +++ b/tests/PHPStan/Rules/Properties/MissingPropertyHookImplementationRuleTest.php @@ -0,0 +1,58 @@ + + */ +class MissingPropertyHookImplementationRuleTest extends RuleTestCase +{ + + private int $phpVersionId = PHP_VERSION_ID; + + protected function getRule(): Rule + { + return new MissingPropertyHookImplementationRule(new PhpVersion($this->phpVersionId)); + } + + #[RequiresPhp('>= 8.4.0')] + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/missing-property-hook-implementation.php'], [ + [ + 'Non-abstract class MissingPropertyHookImplementation\\MissingGet contains abstract property $name from interface MissingPropertyHookImplementation\\RequiresGet.', + 10, + ], + [ + 'Non-abstract class MissingPropertyHookImplementation\\MissingSet contains abstract property $name from interface MissingPropertyHookImplementation\\RequiresSet.', + 19, + ], + [ + 'Non-abstract class MissingPropertyHookImplementation\\MissingBoth contains abstract property $id from class MissingPropertyHookImplementation\\AbstractBase.', + 28, + ], + [ + 'Non-abstract class MissingPropertyHookImplementation\\MissingTraitHook contains abstract property $active from trait MissingPropertyHookImplementation\\RequiresFromTrait.', + 41, + ], + [ + 'Non-abstract class MissingPropertyHookImplementation\\RequiresGet@anonymous/tests/PHPStan/Rules/Properties/data/missing-property-hook-implementation.php:59 contains abstract property $name from interface MissingPropertyHookImplementation\\RequiresGet.', + 59, + ], + ]); + } + + #[RequiresPhp('>= 8.4.0')] + public function testPhpLessThan84(): void + { + $this->phpVersionId = 80300; + $this->analyse([__DIR__ . '/data/missing-property-hook-implementation.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Properties/data/missing-property-hook-implementation.php b/tests/PHPStan/Rules/Properties/data/missing-property-hook-implementation.php new file mode 100644 index 0000000000..b1590616cc --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/missing-property-hook-implementation.php @@ -0,0 +1,61 @@ += 8.4 + +namespace MissingPropertyHookImplementation; + +interface RequiresGet +{ + public string $name { get; } +} + +final class MissingGet implements RequiresGet +{ +} + +interface RequiresSet +{ + public string $name { set; } +} + +final class MissingSet implements RequiresSet +{ +} + +abstract class AbstractBase +{ + abstract public int $id { get; set; } +} + +final class MissingBoth extends AbstractBase +{ +} + +abstract class AbstractChild extends AbstractBase +{ +} + +trait RequiresFromTrait +{ + abstract public bool $active { get; } +} + +final class MissingTraitHook +{ + use RequiresFromTrait; +} + +final class ImplementsWithProperty implements RequiresGet +{ + public string $name; +} + +final class ImplementsWithHooks extends AbstractBase +{ + public int $id { + get => 1; + set { } + } +} + +new class () implements RequiresGet +{ +};