diff --git a/Makefile b/Makefile index 47ccd330d4..1122b4ab0f 100644 --- a/Makefile +++ b/Makefile @@ -101,6 +101,12 @@ lint: --exclude tests/PHPStan/Rules/Properties/data/existing-classes-property-hooks.php \ --exclude tests/PHPStan/Rules/Properties/data/set-property-hook-parameter.php \ --exclude tests/PHPStan/Rules/Properties/data/overriding-final-property.php \ + --exclude tests/PHPStan/Rules/Properties/data/private-final-property-hooks.php \ + --exclude tests/PHPStan/Rules/Properties/data/abstract-final-property-hook.php \ + --exclude tests/PHPStan/Rules/Properties/data/final-property-hooks-in-interface.php \ + --exclude tests/PHPStan/Rules/Properties/data/final-property-hooks.php \ + --exclude tests/PHPStan/Rules/Properties/data/final-properties.php \ + --exclude tests/PHPStan/Rules/Properties/data/property-in-interface-explicit-abstract.php \ src tests cs: diff --git a/src/Node/ClassPropertyNode.php b/src/Node/ClassPropertyNode.php index b567aa7f7b..aae4446638 100644 --- a/src/Node/ClassPropertyNode.php +++ b/src/Node/ClassPropertyNode.php @@ -86,6 +86,11 @@ public function isPrivate(): bool return (bool) ($this->flags & Modifiers::PRIVATE); } + public function isFinal(): bool + { + return (bool) ($this->flags & Modifiers::FINAL); + } + public function isStatic(): bool { return (bool) ($this->flags & Modifiers::STATIC); diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 651aff5205..6f55bdda15 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -357,6 +357,11 @@ public function supportsPropertyHooks(): bool return $this->versionId >= 80400; } + public function supportsFinalProperties(): bool + { + return $this->versionId >= 80400; + } + public function supportsAsymmetricVisibility(): bool { return $this->versionId >= 80400; diff --git a/src/Rules/Properties/PropertiesInInterfaceRule.php b/src/Rules/Properties/PropertiesInInterfaceRule.php index 3ff9546d35..6126625da6 100644 --- a/src/Rules/Properties/PropertiesInInterfaceRule.php +++ b/src/Rules/Properties/PropertiesInInterfaceRule.php @@ -32,7 +32,7 @@ public function processNode(Node $node, Scope $scope): array if (!$this->phpVersion->supportsPropertyHooks()) { return [ - RuleErrorBuilder::message('Interfaces cannot include properties.') + RuleErrorBuilder::message('Interfaces can include properties only on PHP 8.4 and later.') ->nonIgnorable() ->identifier('property.inInterface') ->build(), @@ -75,6 +75,37 @@ public function processNode(Node $node, Scope $scope): array ]; } + if ($node->isAbstract()) { + return [ + RuleErrorBuilder::message('Property in interface cannot be explicitly abstract.') + ->nonIgnorable() + ->identifier('property.abstractInInterface') + ->build(), + ]; + } + + if ($node->isFinal()) { + return [ + RuleErrorBuilder::message('Interfaces cannot include final properties.') + ->nonIgnorable() + ->identifier('property.finalInInterface') + ->build(), + ]; + } + + foreach ($node->getHooks() as $hook) { + if (!$hook->isFinal()) { + continue; + } + + return [ + RuleErrorBuilder::message('Property hook cannot be both abstract and final.') + ->nonIgnorable() + ->identifier('property.abstractFinalHook') + ->build(), + ]; + } + if ($this->hasAnyHookBody($node)) { return [ RuleErrorBuilder::message('Interfaces cannot include property hooks with bodies.') diff --git a/src/Rules/Properties/PropertyInClassRule.php b/src/Rules/Properties/PropertyInClassRule.php index f14c9730a0..607fa30792 100644 --- a/src/Rules/Properties/PropertyInClassRule.php +++ b/src/Rules/Properties/PropertyInClassRule.php @@ -32,6 +32,18 @@ public function processNode(Node $node, Scope $scope): array return []; } + if ( + $node->isFinal() + && !$this->phpVersion->supportsFinalProperties() + ) { + return [ + RuleErrorBuilder::message('Final properties are supported only on PHP 8.4 and later.') + ->nonIgnorable() + ->identifier('property.final') + ->build(), + ]; + } + if (!$this->phpVersion->supportsPropertyHooks()) { if ($node->hasHooks()) { return [ @@ -81,6 +93,58 @@ public function processNode(Node $node, Scope $scope): array ]; } + if ($node->isPrivate()) { + if ($node->isFinal()) { + return [ + RuleErrorBuilder::message('Property cannot be both final and private.') + ->nonIgnorable() + ->identifier('property.finalPrivate') + ->build(), + ]; + } + + foreach ($node->getHooks() as $hook) { + if (!$hook->isFinal()) { + continue; + } + + return [ + RuleErrorBuilder::message('Private property cannot have a final hook.') + ->nonIgnorable() + ->identifier('property.finalPrivateHook') + ->build(), + ]; + } + } + + if ($node->isAbstract()) { + if ($node->isFinal()) { + return [ + RuleErrorBuilder::message('Property cannot be both abstract and final.') + ->nonIgnorable() + ->identifier('property.abstractFinal') + ->build(), + ]; + } + + foreach ($node->getHooks() as $hook) { + if ($hook->body !== null) { + continue; + } + + if (!$hook->isFinal()) { + continue; + } + + return [ + RuleErrorBuilder::message('Property cannot be both abstract and final.') + ->nonIgnorable() + ->identifier('property.abstractFinal') + ->build(), + ]; + } + } + if ($node->isReadOnly()) { if ($node->hasHooks()) { return [ diff --git a/tests/PHPStan/Rules/Properties/PropertiesInInterfaceRuleTest.php b/tests/PHPStan/Rules/Properties/PropertiesInInterfaceRuleTest.php index 3d6dffcb8c..5cfb5e7b58 100644 --- a/tests/PHPStan/Rules/Properties/PropertiesInInterfaceRuleTest.php +++ b/tests/PHPStan/Rules/Properties/PropertiesInInterfaceRuleTest.php @@ -29,17 +29,21 @@ public function testPhp83AndPropertiesInInterface(): void $this->analyse([__DIR__ . '/data/properties-in-interface.php'], [ [ - 'Interfaces cannot include properties.', + 'Interfaces can include properties only on PHP 8.4 and later.', 7, ], [ - 'Interfaces cannot include properties.', + 'Interfaces can include properties only on PHP 8.4 and later.', 9, ], [ - 'Interfaces cannot include properties.', + 'Interfaces can include properties only on PHP 8.4 and later.', 11, ], + [ + 'Interfaces can include properties only on PHP 8.4 and later.', + 13, + ], ]); } @@ -54,11 +58,11 @@ public function testPhp83AndPropertyHooksInInterface(): void $this->analyse([__DIR__ . '/data/property-hooks-in-interface.php'], [ [ - 'Interfaces cannot include properties.', + 'Interfaces can include properties only on PHP 8.4 and later.', 7, ], [ - 'Interfaces cannot include properties.', + 'Interfaces can include properties only on PHP 8.4 and later.', 9, ], ]); @@ -79,6 +83,10 @@ public function testPhp84AndPropertiesInInterface(): void 'Interfaces can only include hooked properties.', 11, ], + [ + 'Interfaces can only include hooked properties.', + 13, + ], ]); } @@ -140,6 +148,50 @@ public function testPhp84AndReadonlyPropertyHooksInInterface(): void ]); } + public function testPhp84AndFinalPropertyHooksInInterface(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/final-property-hooks-in-interface.php'], [ + [ + 'Interfaces cannot include final properties.', + 7, + ], + [ + 'Interfaces cannot include final properties.', + 9, + ], + [ + 'Interfaces cannot include final properties.', + 11, + ], + [ + 'Property hook cannot be both abstract and final.', + 13, + ], + [ + 'Property hook cannot be both abstract and final.', + 17, + ], + ]); + } + + public function testPhp84AndExplicitAbstractProperty(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/property-in-interface-explicit-abstract.php'], [ + [ + 'Property in interface cannot be explicitly abstract.', + 8, + ], + ]); + } + public function testPhp84AndStaticHookedPropertyInInterface(): void { if (PHP_VERSION_ID < 80400) { diff --git a/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php b/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php index 54e041e694..7be86e2089 100644 --- a/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php +++ b/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php @@ -213,4 +213,101 @@ public function testPhp84AndStaticHookedProperties(): void ]); } + public function testPhp84AndPrivateFinalHookedProperties(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/private-final-property-hooks.php'], [ + [ + 'Property cannot be both final and private.', + 7, + ], + [ + 'Private property cannot have a final hook.', + 11, + ], + ]); + } + + public function testPhp84AndAbstractFinalHookedProperties(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/abstract-final-property-hook.php'], [ + [ + 'Property cannot be both abstract and final.', + 7, + ], + ]); + } + + public function testPhp84AndAbstractFinalHookedPropertiesParseError(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + // errors when parsing with php-parser, see https://github.com/nikic/PHP-Parser/issues/1071 + $this->analyse([__DIR__ . '/data/abstract-final-property-hook-parse-error.php'], [ + [ + 'Cannot use the final modifier on an abstract class member on line 7', + 7, + ], + ]); + } + + public function testPhp84FinalProperties(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/final-properties.php'], [ + [ + 'Property cannot be both final and private.', + 7, + ], + ]); + } + + public function testBeforePhp84FinalProperties(): void + { + if (PHP_VERSION_ID >= 80400) { + $this->markTestSkipped('Test requires PHP 8.3 or earlier.'); + } + + $this->analyse([__DIR__ . '/data/final-properties.php'], [ + [ + 'Final properties are supported only on PHP 8.4 and later.', + 7, + ], + [ + 'Final properties are supported only on PHP 8.4 and later.', + 8, + ], + [ + 'Final properties are supported only on PHP 8.4 and later.', + 9, + ], + ]); + } + + public function testPhp84FinalPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4 or later.'); + } + + $this->analyse([__DIR__ . '/data/final-property-hooks.php'], [ + [ + 'Cannot use the final modifier on an abstract class member on line 19', + 19, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/abstract-final-property-hook-parse-error.php b/tests/PHPStan/Rules/Properties/data/abstract-final-property-hook-parse-error.php new file mode 100644 index 0000000000..230de9d816 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/abstract-final-property-hook-parse-error.php @@ -0,0 +1,10 @@ += 8.4 + +namespace AbstractFinalHookParseError; + +abstract class User +{ + final abstract public string $bar { + get; + } +} diff --git a/tests/PHPStan/Rules/Properties/data/abstract-final-property-hook.php b/tests/PHPStan/Rules/Properties/data/abstract-final-property-hook.php new file mode 100644 index 0000000000..baba303bf1 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/abstract-final-property-hook.php @@ -0,0 +1,15 @@ += 8.4 + +namespace AbstractFinalHook; + +abstract class User +{ + abstract public string $foo { + final get; + } +} + +abstract class Foo +{ + abstract public int $i { final get { return 1;} set; } +} diff --git a/tests/PHPStan/Rules/Properties/data/final-properties.php b/tests/PHPStan/Rules/Properties/data/final-properties.php new file mode 100644 index 0000000000..1e04ef49b6 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/final-properties.php @@ -0,0 +1,11 @@ + $this->firstName; + set => $this->firstName; + } + + public final string $middleName { get => $this->middleName; } + + public final string $lastName { set => $this->lastName; } +} + +abstract class HiWorld +{ + public abstract final string $firstName { get { return 'jake'; } set; } +} + +final class GoodMorningWorld +{ + public string $firstName { + get => $this->firstName; + set => $this->firstName; + } +} diff --git a/tests/PHPStan/Rules/Properties/data/private-final-property-hooks.php b/tests/PHPStan/Rules/Properties/data/private-final-property-hooks.php new file mode 100644 index 0000000000..1ce2830a95 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/private-final-property-hooks.php @@ -0,0 +1,36 @@ += 8.4 + +namespace PrivateFinalHook; + +final class User +{ + final private string $privatePropGet = 'mailto: example.org' { + get => 'private:' . $this->privatePropGet; + } + + private string $private = 'mailto: example.org' { + final set => 'private:' . $this->private; + get => 'private:' . $this->private; + } + + protected string $protected = 'mailto: example.org' { + final get => 'protected:' . $this->protected; + } + + public string $public = 'mailto: example.org' { + final get => 'public:' . $this->public; + } + + private string $email = 'mailto: example.org' { + get => 'mailto:' . $this->email; + } + + function doFoo(): void + { + $u = new User; + var_dump($u->private); + var_dump($u->protected); + var_dump($u->public); + var_dump($u->email); + } +} diff --git a/tests/PHPStan/Rules/Properties/data/properties-in-interface.php b/tests/PHPStan/Rules/Properties/data/properties-in-interface.php index 3d64511f64..4d104487fb 100644 --- a/tests/PHPStan/Rules/Properties/data/properties-in-interface.php +++ b/tests/PHPStan/Rules/Properties/data/properties-in-interface.php @@ -9,4 +9,6 @@ interface HelloWorld public \DateTimeInterface $dateTime; public static \Closure $callable; + + public final \DateTime $finalProperty; } diff --git a/tests/PHPStan/Rules/Properties/data/property-in-interface-explicit-abstract.php b/tests/PHPStan/Rules/Properties/data/property-in-interface-explicit-abstract.php new file mode 100644 index 0000000000..d91986b659 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/property-in-interface-explicit-abstract.php @@ -0,0 +1,15 @@ +