diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4680ddb82d..86ee004f07 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -116,7 +116,7 @@ jobs: uses: "shivammathur/setup-php@v2" with: coverage: "none" - php-version: "8.3" + php-version: "8.4" - name: "Install dependencies" run: "composer install --no-interaction --no-progress" diff --git a/Makefile b/Makefile index 3282ee2c4e..be44969c3c 100644 --- a/Makefile +++ b/Makefile @@ -76,6 +76,16 @@ lint: --exclude tests/PHPStan/Rules/Classes/data/extends-readonly-class.php \ --exclude tests/PHPStan/Rules/Classes/data/instantiation-promoted-properties.php \ --exclude tests/PHPStan/Rules/Classes/data/bug-11592.php \ + --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/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 \ + --exclude tests/PHPStan/Rules/Properties/data/non-abstract-hooked-properties-in-abstract-class.php \ + --exclude tests/PHPStan/Rules/Properties/data/non-abstract-hooked-properties-in-class.php \ + --exclude tests/PHPStan/Rules/Properties/data/hooked-properties-in-class.php \ + --exclude tests/PHPStan/Rules/Properties/data/hooked-properties-without-bodies-in-class.php \ src tests cs: diff --git a/build/collision-detector.json b/build/collision-detector.json index 21228704f5..12de9af1d3 100644 --- a/build/collision-detector.json +++ b/build/collision-detector.json @@ -12,5 +12,5 @@ "../tests/notAutoloaded", "../tests/PHPStan/Rules/Functions/data/define-bug-3349.php", "../tests/PHPStan/Levels/data/stubs/function.php" - ] + ] } diff --git a/conf/config.level0.neon b/conf/config.level0.neon index d4927a56c4..8c2a23a9d4 100644 --- a/conf/config.level0.neon +++ b/conf/config.level0.neon @@ -96,6 +96,7 @@ rules: - PHPStan\Rules\Properties\MissingReadOnlyByPhpDocPropertyAssignRule - PHPStan\Rules\Properties\PropertiesInInterfaceRule - PHPStan\Rules\Properties\PropertyAttributesRule + - PHPStan\Rules\Properties\PropertyInClassRule - PHPStan\Rules\Properties\ReadOnlyPropertyRule - PHPStan\Rules\Properties\ReadOnlyByPhpDocPropertyRule - PHPStan\Rules\Regexp\RegularExpressionPatternRule diff --git a/src/Node/ClassPropertyNode.php b/src/Node/ClassPropertyNode.php index 3f500b62c1..c8602aef79 100644 --- a/src/Node/ClassPropertyNode.php +++ b/src/Node/ClassPropertyNode.php @@ -111,6 +111,11 @@ public function isAllowedPrivateMutation(): bool return $this->isAllowedPrivateMutation; } + public function isAbstract(): bool + { + return (bool) ($this->flags & Modifiers::ABSTRACT); + } + public function getNativeType(): ?Type { return $this->type; @@ -142,4 +147,17 @@ public function getSubNodeNames(): array return []; } + public function hasHooks(): bool + { + return $this->getHooks() !== []; + } + + /** + * @return Node\PropertyHook[] + */ + public function getHooks(): array + { + return $this->originalNode->hooks; + } + } diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 8520f6488d..e636945cc2 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -347,6 +347,11 @@ public function supportsPregCaptureOnlyNamedGroups(): bool return $this->versionId >= 80200; } + public function supportsPropertyHooks(): bool + { + return $this->versionId >= 80400; + } + public function hasDateTimeExceptions(): bool { return $this->versionId >= 80300; diff --git a/src/Rules/Properties/PropertiesInInterfaceRule.php b/src/Rules/Properties/PropertiesInInterfaceRule.php index d9f62fa618..df5354adb3 100644 --- a/src/Rules/Properties/PropertiesInInterfaceRule.php +++ b/src/Rules/Properties/PropertiesInInterfaceRule.php @@ -5,6 +5,7 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\Node\ClassPropertyNode; +use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -14,6 +15,10 @@ final class PropertiesInInterfaceRule implements Rule { + public function __construct(private PhpVersion $phpVersion) + { + } + public function getNodeType(): string { return ClassPropertyNode::class; @@ -25,12 +30,54 @@ public function processNode(Node $node, Scope $scope): array return []; } - return [ - RuleErrorBuilder::message('Interfaces may not include properties.') - ->nonIgnorable() - ->identifier('property.inInterface') - ->build(), - ]; + if (!$this->phpVersion->supportsPropertyHooks()) { + return [ + RuleErrorBuilder::message('Interfaces cannot include properties.') + ->nonIgnorable() + ->identifier('property.inInterface') + ->build(), + ]; + } + + if (!$node->hasHooks()) { + return [ + RuleErrorBuilder::message('Interfaces can only include hooked properties.') + ->nonIgnorable() + ->identifier('property.nonHookedInInterface') + ->build(), + ]; + } + + if (!$node->isPublic()) { + return [ + RuleErrorBuilder::message('Interfaces cannot include non-public properties.') + ->nonIgnorable() + ->identifier('property.nonPublicInInterface') + ->build(), + ]; + } + + if ($this->hasAnyHookBody($node)) { + return [ + RuleErrorBuilder::message('Interfaces cannot include property hooks with bodies.') + ->nonIgnorable() + ->identifier('property.hookBodyInInterface') + ->build(), + ]; + } + + return []; + } + + private function hasAnyHookBody(ClassPropertyNode $node): bool + { + foreach ($node->getHooks() as $hook) { + if ($hook->body !== null) { + return true; + } + } + + return false; } } diff --git a/src/Rules/Properties/PropertyInClassRule.php b/src/Rules/Properties/PropertyInClassRule.php new file mode 100644 index 0000000000..3500959541 --- /dev/null +++ b/src/Rules/Properties/PropertyInClassRule.php @@ -0,0 +1,113 @@ + + */ +final class PropertyInClassRule implements Rule +{ + + public function __construct(private PhpVersion $phpVersion) + { + } + + public function getNodeType(): string + { + return ClassPropertyNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + + if (!$classReflection->isClass()) { + return []; + } + + if (!$this->phpVersion->supportsPropertyHooks()) { + if ($node->hasHooks()) { + return [ + RuleErrorBuilder::message('Property hooks are supported only on PHP 8.4 and later.') + ->nonIgnorable() + ->identifier('property.hooksNotSupported') + ->build(), + ]; + } + + return []; + } + + if ($node->isAbstract()) { + if (!$node->hasHooks()) { + return [ + RuleErrorBuilder::message('Only hooked properties can be declared abstract.') + ->nonIgnorable() + ->identifier('property.abstractNonHooked') + ->build(), + ]; + } + + if (!$this->isAtLeastOneHookBodyEmpty($node)) { + return [ + RuleErrorBuilder::message('Abstract properties must specify at least one abstract hook.') + ->nonIgnorable() + ->identifier('property.abstractWithoutAbstractHook') + ->build(), + ]; + } + + if (!$classReflection->isAbstract()) { + return [ + RuleErrorBuilder::message('Non-abstract classes cannot include abstract properties.') + ->nonIgnorable() + ->identifier('property.abstract') + ->build(), + ]; + } + + return []; + } + + if (!$this->doAllHooksHaveBody($node)) { + return [ + RuleErrorBuilder::message('Non-abstract properties cannot include hooks without bodies.') + ->nonIgnorable() + ->identifier('property.hookWithoutBody') + ->build(), + ]; + } + + return []; + } + + private function doAllHooksHaveBody(ClassPropertyNode $node): bool + { + foreach ($node->getHooks() as $hook) { + if ($hook->body === null) { + return false; + } + } + + return true; + } + + private function isAtLeastOneHookBodyEmpty(ClassPropertyNode $node): bool + { + foreach ($node->getHooks() as $hook) { + if ($hook->body === null) { + return true; + } + } + + return false; + } + +} diff --git a/tests/PHPStan/Rules/Properties/PropertiesInInterfaceRuleTest.php b/tests/PHPStan/Rules/Properties/PropertiesInInterfaceRuleTest.php index ecf4597d97..de425931da 100644 --- a/tests/PHPStan/Rules/Properties/PropertiesInInterfaceRuleTest.php +++ b/tests/PHPStan/Rules/Properties/PropertiesInInterfaceRuleTest.php @@ -2,8 +2,10 @@ namespace PHPStan\Rules\Properties; +use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -13,21 +15,107 @@ class PropertiesInInterfaceRuleTest extends RuleTestCase protected function getRule(): Rule { - return new PropertiesInInterfaceRule(); + return new PropertiesInInterfaceRule(new PhpVersion(PHP_VERSION_ID)); } - public function testRule(): void + public function testPhp83AndPropertiesInInterface(): void { + if (PHP_VERSION_ID >= 80400) { + $this->markTestSkipped('Test requires PHP 8.3 or earlier.'); + } + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('Property hooks cause syntax error on PHP 7.4'); + } + + $this->analyse([__DIR__ . '/data/properties-in-interface.php'], [ + [ + 'Interfaces cannot include properties.', + 7, + ], + [ + 'Interfaces cannot include properties.', + 9, + ], + [ + 'Interfaces cannot include properties.', + 11, + ], + ]); + } + + public function testPhp83AndPropertyHooksInInterface(): void + { + if (PHP_VERSION_ID >= 80400) { + $this->markTestSkipped('Test requires PHP 8.3 or earlier.'); + } + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('Property hooks cause syntax error on PHP 7.4'); + } + + $this->analyse([__DIR__ . '/data/property-hooks-in-interface.php'], [ + [ + 'Interfaces cannot include properties.', + 7, + ], + [ + 'Interfaces cannot include properties.', + 9, + ], + ]); + } + + public function testPhp84AndPropertiesInInterface(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + $this->analyse([__DIR__ . '/data/properties-in-interface.php'], [ [ - 'Interfaces may not include properties.', + 'Interfaces can only include hooked properties.', + 9, + ], + [ + 'Interfaces can only include hooked properties.', + 11, + ], + ]); + } + + public function testPhp84AndNonPublicPropertyHooksInInterface(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/property-hooks-visibility-in-interface.php'], [ + [ + 'Interfaces cannot include non-public properties.', 7, ], [ - 'Interfaces may not include properties.', + 'Interfaces cannot include non-public properties.', 9, ], ]); } + public function testPhp84AndPropertyHooksWithBodiesInInterface(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/property-hooks-bodies-in-interface.php'], [ + [ + 'Interfaces cannot include property hooks with bodies.', + 7, + ], + [ + 'Interfaces cannot include property hooks with bodies.', + 13, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php b/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php new file mode 100644 index 0000000000..0b2ca5ba09 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php @@ -0,0 +1,150 @@ + + */ +class PropertyInClassRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new PropertyInClassRule(new PhpVersion(PHP_VERSION_ID)); + } + + public function testPhpLessThan84AndHookedPropertiesInClass(): void + { + if (PHP_VERSION_ID >= 80400) { + $this->markTestSkipped('Test requires PHP 8.3 or earlier.'); + } + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('Property hooks cause syntax error on PHP 7.4'); + } + + $this->analyse([__DIR__ . '/data/hooked-properties-in-class.php'], [ + [ + 'Property hooks are supported only on PHP 8.4 and later.', + 7, + ], + ]); + } + + public function testPhp84AndHookedPropertiesWithoutBodiesInClass(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/hooked-properties-without-bodies-in-class.php'], [ + [ + 'Non-abstract properties cannot include hooks without bodies.', + 7, + ], + [ + 'Non-abstract properties cannot include hooks without bodies.', + 9, + ], + ]); + } + + public function testPhp84AndNonAbstractHookedPropertiesInClass(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/non-abstract-hooked-properties-in-class.php'], [ + [ + 'Non-abstract properties cannot include hooks without bodies.', + 7, + ], + [ + 'Non-abstract properties cannot include hooks without bodies.', + 9, + ], + ]); + } + + public function testPhp84AndAbstractHookedPropertiesInClass(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/abstract-hooked-properties-in-class.php'], [ + [ + 'Non-abstract classes cannot include abstract properties.', + 7, + ], + [ + 'Non-abstract classes cannot include abstract properties.', + 9, + ], + ]); + } + + public function testPhp84AndNonAbstractHookedPropertiesInAbstractClass(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/non-abstract-hooked-properties-in-abstract-class.php'], [ + [ + 'Non-abstract properties cannot include hooks without bodies.', + 7, + ], + [ + 'Non-abstract properties cannot include hooks without bodies.', + 9, + ], + [ + 'Non-abstract properties cannot include hooks without bodies.', + 25, + ], + ]); + } + + public function testPhp84AndAbstractNonHookedPropertiesInAbstractClass(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/abstract-non-hooked-properties-in-abstract-class.php'], [ + [ + 'Only hooked properties can be declared abstract.', + 7, + ], + [ + 'Only hooked properties can be declared abstract.', + 9, + ], + ]); + } + + public function testPhp84AndAbstractHookedPropertiesWithBodies(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/abstract-hooked-properties-with-bodies.php'], [ + [ + 'Abstract properties must specify at least one abstract hook.', + 7, + ], + [ + 'Abstract properties must specify at least one abstract hook.', + 12, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Properties/data/abstract-hooked-properties-in-class.php b/tests/PHPStan/Rules/Properties/data/abstract-hooked-properties-in-class.php new file mode 100644 index 0000000000..d035d36810 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/abstract-hooked-properties-in-class.php @@ -0,0 +1,10 @@ + $this->name; + set => $this->name = $value; + } + + public abstract string $lastName { + get => $this->lastName; + set => $this->lastName = $value; + } + + public abstract string $middleName { + get => $this->name; + set; + } + + public abstract string $familyName { + get; + set; + } +} diff --git a/tests/PHPStan/Rules/Properties/data/abstract-non-hooked-properties-in-abstract-class.php b/tests/PHPStan/Rules/Properties/data/abstract-non-hooked-properties-in-abstract-class.php new file mode 100644 index 0000000000..b34e66a886 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/abstract-non-hooked-properties-in-abstract-class.php @@ -0,0 +1,10 @@ + $this->name; + set => $this->name = $value; + } +} diff --git a/tests/PHPStan/Rules/Properties/data/hooked-properties-without-bodies-in-class.php b/tests/PHPStan/Rules/Properties/data/hooked-properties-without-bodies-in-class.php new file mode 100644 index 0000000000..dc839f0d2c --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/hooked-properties-without-bodies-in-class.php @@ -0,0 +1,10 @@ +