Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion build/collision-detector.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"../tests/notAutoloaded",
"../tests/PHPStan/Rules/Functions/data/define-bug-3349.php",
"../tests/PHPStan/Levels/data/stubs/function.php"
]
]
}
1 change: 1 addition & 0 deletions conf/config.level0.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/Node/ClassPropertyNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

}
5 changes: 5 additions & 0 deletions src/Php/PhpVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
59 changes: 53 additions & 6 deletions src/Rules/Properties/PropertiesInInterfaceRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -14,6 +15,10 @@
final class PropertiesInInterfaceRule implements Rule
{

public function __construct(private PhpVersion $phpVersion)
{
}

public function getNodeType(): string
{
return ClassPropertyNode::class;
Expand All @@ -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;
}

}
113 changes: 113 additions & 0 deletions src/Rules/Properties/PropertyInClassRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Properties;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\ClassPropertyNode;
use PHPStan\Php\PhpVersion;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* @implements Rule<ClassPropertyNode>
*/
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;
}

}
96 changes: 92 additions & 4 deletions tests/PHPStan/Rules/Properties/PropertiesInInterfaceRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<PropertiesInInterfaceRule>
Expand All @@ -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,
],
]);
}

}
Loading
Loading