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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,29 @@ class SomeClass

<br>

Skipped when a child class overrides the constant, as late static binding then changes the value on purpose. `static::class` is always allowed.

```php
class ParentClass
{
protected const SOME_CONST = 'parent';

public function run()
{
return static::SOME_CONST;
}
}

class ChildClass extends ParentClass
{
protected const SOME_CONST = 'child';
}
```

:+1:

<br>

### NoDynamicNameRule

Use explicit names over dynamic ones
Expand Down
6 changes: 6 additions & 0 deletions config/static-rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ conditionalTags:
phpstan.rules.rule: %symplify.static%
Symplify\PHPStanRules\Rules\ForbiddenStaticClassConstFetchRule:
phpstan.rules.rule: %symplify.static%
Symplify\PHPStanRules\Collector\StaticClassConstFetchCollector:
phpstan.collector: %symplify.static%
Symplify\PHPStanRules\Collector\ClassConstantDeclarationCollector:
phpstan.collector: %symplify.static%
Symplify\PHPStanRules\Rules\Complexity\ForbiddenArrayMethodCallRule:
phpstan.rules.rule: %symplify.static%

Expand All @@ -41,6 +45,8 @@ services:

- Symplify\PHPStanRules\Rules\ForbiddenMultipleClassLikeInOneFileRule
- Symplify\PHPStanRules\Rules\ForbiddenStaticClassConstFetchRule
- Symplify\PHPStanRules\Collector\StaticClassConstFetchCollector
- Symplify\PHPStanRules\Collector\ClassConstantDeclarationCollector
- Symplify\PHPStanRules\Rules\Complexity\ForbiddenArrayMethodCallRule

# docblock
Expand Down
47 changes: 47 additions & 0 deletions src/Collector/ClassConstantDeclarationCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Collector;

use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;

/**
* Collects each class with its parent and declared constant names, so a child class overriding a parent
* constant can be detected.
*
* @implements Collector<Class_, array{string, string|null, string[]}>
*/
final class ClassConstantDeclarationCollector implements Collector
{
public function getNodeType(): string
{
return Class_::class;
}

/**
* @return array{string, string|null, string[]}|null the class name, its parent class name and the
* constant names it declares
*/
public function processNode(Node $node, Scope $scope): ?array
{
if (! $node->namespacedName instanceof Name) {
return null;
}

$parentClassName = $node->extends instanceof Name ? $node->extends->toString() : null;

$constantNames = [];
foreach ($node->getConstants() as $classConst) {
foreach ($classConst->consts as $const) {
$constantNames[] = $const->name->toString();
}
}

return [$node->namespacedName->toString(), $parentClassName, $constantNames];
}
}
57 changes: 57 additions & 0 deletions src/Collector/StaticClassConstFetchCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Collector;

use PhpParser\Node;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;
use PHPStan\Reflection\ClassReflection;

/**
* Collects every "static::SOME_CONST" fetch, so a child class overriding that constant can later be looked up.
*
* @implements Collector<ClassConstFetch, array{string, string, int}>
*/
final class StaticClassConstFetchCollector implements Collector
{
public function getNodeType(): string
{
return ClassConstFetch::class;
}

/**
* @return array{string, string, int}|null the class the fetch lives in, the constant name and its line
*/
public function processNode(Node $node, Scope $scope): ?array
{
if (! $node->class instanceof Name) {
return null;
}

if ($node->class->toString() !== 'static') {
return null;
}

// "static::class" is late static binding magic, not an overridable constant
if (! $node->name instanceof Identifier || $node->name->toLowerString() === 'class') {
return null;
}

$classReflection = $scope->getClassReflection();
if (! $classReflection instanceof ClassReflection) {
return null;
}

// abstract class is designed to be extended, so static fetch is expected
if ($classReflection->isAbstract()) {
return null;
}

return [$classReflection->getName(), $node->name->toString(), $node->getStartLine()];
}
}
108 changes: 92 additions & 16 deletions src/Rules/ForbiddenStaticClassConstFetchRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
namespace Symplify\PHPStanRules\Rules;

use PhpParser\Node;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Node\CollectedDataNode;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Symplify\PHPStanRules\Collector\ClassConstantDeclarationCollector;
use Symplify\PHPStanRules\Collector\StaticClassConstFetchCollector;

/**
* @implements Rule<ClassConstFetch>
* @see \Symplify\PHPStanRules\Tests\Rules\ForbiddenStaticClassConstFetchRule\ForbiddenStaticClassConstFetchRuleTest
*
* @implements Rule<CollectedDataNode>
*/
final class ForbiddenStaticClassConstFetchRule implements Rule
{
Expand All @@ -25,29 +27,103 @@ final class ForbiddenStaticClassConstFetchRule implements Rule

public function getNodeType(): string
{
return ClassConstFetch::class;
return CollectedDataNode::class;
}

/**
* @param ClassConstFetch $node
* @param CollectedDataNode $node
* @return list<IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $node->class instanceof Name) {
return [];
[$constantNamesByClass, $parentClassByClass] = $this->resolveClassMaps($node);

/** @var array<string, list<array{string, string, int}>> $fetchesByFilePath */
$fetchesByFilePath = $node->get(StaticClassConstFetchCollector::class);

$ruleErrors = [];

foreach ($fetchesByFilePath as $filePath => $fetches) {
foreach ($fetches as [$className, $constantName, $line]) {
// a child class overriding the constant makes late static binding meaningful, so keep it
if ($this->isOverriddenInChildClass($className, $constantName, $constantNamesByClass, $parentClassByClass)) {
continue;
}

$ruleErrors[] = RuleErrorBuilder::message(self::ERROR_MESSAGE)
->identifier('symplify.forbiddenStaticClassConstFetch')
->file($filePath)
->line($line)
->build();
}
}

return $ruleErrors;
}

/**
* @return array{array<string, string[]>, array<string, string|null>} constant names and parent class per class
*/
private function resolveClassMaps(CollectedDataNode $collectedDataNode): array
{
/** @var array<string, list<array{string, string|null, string[]}>> $declarationsByFilePath */
$declarationsByFilePath = $collectedDataNode->get(ClassConstantDeclarationCollector::class);

$constantNamesByClass = [];
$parentClassByClass = [];

foreach ($declarationsByFilePath as $declarations) {
foreach ($declarations as [$className, $parentClassName, $constantNames]) {
$constantNamesByClass[$className] = $constantNames;
$parentClassByClass[$className] = $parentClassName;
}
}

if ($node->class->toString() !== 'static') {
return [];
return [$constantNamesByClass, $parentClassByClass];
}

/**
* @param array<string, string[]> $constantNamesByClass
* @param array<string, string|null> $parentClassByClass
*/
private function isOverriddenInChildClass(
string $parentClassName,
string $constantName,
array $constantNamesByClass,
array $parentClassByClass
): bool {
foreach ($constantNamesByClass as $declaringClassName => $constantNames) {
if ($declaringClassName === $parentClassName) {
continue;
}

if (! in_array($constantName, $constantNames, true)) {
continue;
}

if ($this->isSubclassOf($declaringClassName, $parentClassName, $parentClassByClass)) {
return true;
}
}

$classReflection = $scope->getClassReflection();
if ($classReflection instanceof ClassReflection && $classReflection->isAbstract()) {
return [];
return false;
}

/**
* @param array<string, string|null> $parentClassByClass
*/
private function isSubclassOf(string $className, string $ancestorClassName, array $parentClassByClass): bool
{
$currentClassName = $parentClassByClass[$className] ?? null;

while ($currentClassName !== null) {
if ($currentClassName === $ancestorClassName) {
return true;
}

$currentClassName = $parentClassByClass[$currentClassName] ?? null;
}

return [RuleErrorBuilder::message(self::ERROR_MESSAGE)
->identifier('symplify.forbiddenStaticClassConstFetch')
->build()];
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Symplify\PHPStanRules\Tests\Rules\ForbiddenStaticClassConstFetchRule\Fixture;

class ParentWithoutOverriddenConst
{
protected const SOME_CONST = 'parent_value';

public function run()
{
return static::SOME_CONST;
}
}

class ChildWithoutConstOverride extends ParentWithoutOverriddenConst
{
public function other()
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Symplify\PHPStanRules\Tests\Rules\ForbiddenStaticClassConstFetchRule\Fixture;

class ParentWithOverriddenConst
{
protected const SOME_CONST = 'parent_value';

public function run()
{
return static::SOME_CONST;
}
}

class ChildOverridingConst extends ParentWithOverriddenConst
{
protected const SOME_CONST = 'child_value';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Symplify\PHPStanRules\Tests\Rules\ForbiddenStaticClassConstFetchRule\Fixture;

class SkipStaticClass
{
public function run()
{
return static::class;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@

use Iterator;
use Override;
use PhpParser\Node;
use PHPStan\Collectors\Collector;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Symplify\PHPStanRules\Collector\ClassConstantDeclarationCollector;
use Symplify\PHPStanRules\Collector\StaticClassConstFetchCollector;
use Symplify\PHPStanRules\Rules\ForbiddenStaticClassConstFetchRule;

final class ForbiddenStaticClassConstFetchRuleTest extends RuleTestCase
Expand All @@ -32,7 +36,14 @@ public static function provideData(): Iterator
[[ForbiddenStaticClassConstFetchRule::ERROR_MESSAGE, 11]],
];

yield [
__DIR__ . '/Fixture/ConstNotOverriddenByChild.php',
[[ForbiddenStaticClassConstFetchRule::ERROR_MESSAGE, 11]],
];

yield [__DIR__ . '/Fixture/SkipAbstractClass.php', []];
yield [__DIR__ . '/Fixture/SkipStaticClass.php', []];
yield [__DIR__ . '/Fixture/SkipConstOverriddenByChild.php', []];
}

/**
Expand All @@ -48,4 +59,16 @@ protected function getRule(): Rule
{
return self::getContainer()->getByType(ForbiddenStaticClassConstFetchRule::class);
}

/**
* @return list<Collector<Node, mixed>>
*/
#[Override]
protected function getCollectors(): array
{
return [
self::getContainer()->getByType(StaticClassConstFetchCollector::class),
self::getContainer()->getByType(ClassConstantDeclarationCollector::class),
];
}
}
Loading
Loading