diff --git a/src/Node/ClassConstantsNode.php b/src/Node/ClassConstantsNode.php index fbc120199d1..f896e0a74a3 100644 --- a/src/Node/ClassConstantsNode.php +++ b/src/Node/ClassConstantsNode.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt\ClassLike; use PhpParser\NodeAbstract; use PHPStan\Node\Constant\ClassConstantFetch; +use PHPStan\Node\Constant\PhpDocClassConstantReference; use PHPStan\Reflection\ClassReflection; /** @@ -15,6 +16,9 @@ final class ClassConstantsNode extends NodeAbstract implements VirtualNode { + /** @var PhpDocClassConstantReference[]|null */ + private ?array $phpDocFetches = null; + /** * @param ClassConst[] $constants * @param ClassConstantFetch[] $fetches @@ -45,6 +49,66 @@ public function getFetches(): array return $this->fetches; } + /** + * @return PhpDocClassConstantReference[] + */ + public function getPhpDocFetches(): array + { + if ($this->phpDocFetches !== null) { + return $this->phpDocFetches; + } + + $result = []; + $className = $this->classReflection->getName(); + + $resolvedPhpDoc = $this->classReflection->getResolvedPhpDoc(); + if ($resolvedPhpDoc !== null) { + foreach ($resolvedPhpDoc->getClassConstantReferences() as $reference) { + $result[] = $reference; + } + } + + foreach ($this->classReflection->getNativeReflection()->getMethods() as $method) { + if ($method->getDeclaringClass()->getName() !== $className) { + continue; + } + if (!$this->classReflection->hasNativeMethod($method->getName())) { + continue; + } + $methodReflection = $this->classReflection->getNativeMethod($method->getName()); + $methodPhpDoc = $methodReflection->getResolvedPhpDoc(); + if ($methodPhpDoc === null) { + continue; + } + + foreach ($methodPhpDoc->getClassConstantReferences() as $reference) { + $result[] = $reference; + } + } + + foreach ($this->classReflection->getNativeReflection()->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() !== $className) { + continue; + } + if (!$this->classReflection->hasNativeProperty($property->getName())) { + continue; + } + $propertyReflection = $this->classReflection->getNativeProperty($property->getName()); + $propertyPhpDoc = $propertyReflection->getResolvedPhpDoc(); + if ($propertyPhpDoc === null) { + continue; + } + + foreach ($propertyPhpDoc->getClassConstantReferences() as $reference) { + $result[] = $reference; + } + } + + $this->phpDocFetches = $result; + + return $result; + } + #[Override] public function getType(): string { diff --git a/src/Node/Constant/PhpDocClassConstantReference.php b/src/Node/Constant/PhpDocClassConstantReference.php new file mode 100644 index 00000000000..ebe369b53b6 --- /dev/null +++ b/src/Node/Constant/PhpDocClassConstantReference.php @@ -0,0 +1,22 @@ +className; + } + + public function getConstantName(): string + { + return $this->constantName; + } + +} diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index be6da4fc46d..509cd300561 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -3,6 +3,7 @@ namespace PHPStan\PhpDoc; use PHPStan\Analyser\NameScope; +use PHPStan\Node\Constant\PhpDocClassConstantReference; use PHPStan\PhpDoc\Tag\AssertTag; use PHPStan\PhpDoc\Tag\DeprecatedTag; use PHPStan\PhpDoc\Tag\ExtendsTag; @@ -25,6 +26,8 @@ use PHPStan\PhpDoc\Tag\TypedTag; use PHPStan\PhpDoc\Tag\UsesTag; use PHPStan\PhpDoc\Tag\VarTag; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode; +use PHPStan\PhpDocParser\Ast\Node as PhpDocAstNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ReflectionProvider; @@ -38,7 +41,11 @@ use function array_key_exists; use function array_map; use function count; +use function get_object_vars; +use function in_array; +use function is_array; use function is_bool; +use function strtolower; use function substr; /** @@ -153,6 +160,9 @@ final class ResolvedPhpDocBlock private ?bool $acceptsNamedArguments = null; + /** @var PhpDocClassConstantReference[]|false */ + private array|false $classConstantReferences = false; + private function __construct() { } @@ -244,6 +254,7 @@ public static function createEmpty(): self $self->isAllowedPrivateMutation = false; $self->hasConsistentConstructor = false; $self->acceptsNamedArguments = true; + $self->classConstantReferences = []; return $self; } @@ -300,6 +311,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->isAllowedPrivateMutation = $this->isAllowedPrivateMutation(); $result->hasConsistentConstructor = $this->hasConsistentConstructor(); $result->acceptsNamedArguments = $acceptsNamedArguments; + $result->classConstantReferences = $this->getClassConstantReferences(); return $result; } @@ -1148,4 +1160,63 @@ private static function resolveTemplateTypeInTag( return $tag->withType($type); } + /** + * @return PhpDocClassConstantReference[] + */ + public function getClassConstantReferences(): array + { + if ($this->classConstantReferences !== false) { + return $this->classConstantReferences; + } + + $nameScope = $this->nameScope; + if ($nameScope === null) { + $this->classConstantReferences = []; + return $this->classConstantReferences; + } + + $result = []; + foreach ($this->phpDocNodes as $phpDocNode) { + $this->collectConstFetchNodes($phpDocNode, $nameScope, $result); + } + + $this->classConstantReferences = $result; + + return $result; + } + + /** + * @param PhpDocClassConstantReference[] $result + */ + private function collectConstFetchNodes(PhpDocAstNode $node, NameScope $nameScope, array &$result): void + { + if ($node instanceof ConstFetchNode) { + if ($node->className !== '') { + if (in_array(strtolower($node->className), ['self', 'static'], true)) { + $className = $nameScope->getClassName(); + } else { + $className = $nameScope->resolveStringName($node->className); + } + + if ($className !== null) { + $result[] = new PhpDocClassConstantReference($className, $node->name); + } + } + return; + } + + foreach (get_object_vars($node) as $prop) { + if ($prop instanceof PhpDocAstNode) { + $this->collectConstFetchNodes($prop, $nameScope, $result); + } elseif (is_array($prop)) { + foreach ($prop as $item) { + if (!($item instanceof PhpDocAstNode)) { + continue; + } + $this->collectConstFetchNodes($item, $nameScope, $result); + } + } + } + } + } diff --git a/src/Rules/DeadCode/UnusedPrivateConstantRule.php b/src/Rules/DeadCode/UnusedPrivateConstantRule.php index 6e017873d20..dd64bd17da9 100644 --- a/src/Rules/DeadCode/UnusedPrivateConstantRule.php +++ b/src/Rules/DeadCode/UnusedPrivateConstantRule.php @@ -6,11 +6,17 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\ClassConstantsNode; +use PHPStan\Reflection\ClassReflection; use PHPStan\Rules\Constants\AlwaysUsedClassConstantsExtensionProvider; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\ObjectType; +use function array_keys; +use function preg_match; +use function preg_quote; use function sprintf; +use function str_contains; +use function str_replace; /** * @implements Rule @@ -93,6 +99,10 @@ public function processNode(Node $node, Scope $scope): array unset($constants[$fetchNode->name->toString()]); } + if ($constants !== []) { + $this->removePhpDocReferencedConstants($node, $classReflection, $constants); + } + $errors = []; foreach ($constants as $constantName => $constantNode) { $errors[] = RuleErrorBuilder::message(sprintf('Constant %s::%s is unused.', $classReflection->getDisplayName(), $constantName)) @@ -105,4 +115,36 @@ public function processNode(Node $node, Scope $scope): array return $errors; } + /** + * @param array $constants + */ + private function removePhpDocReferencedConstants(ClassConstantsNode $node, ClassReflection $classReflection, array &$constants): void + { + $className = $classReflection->getName(); + + foreach ($node->getPhpDocFetches() as $phpDocFetch) { + if ($phpDocFetch->getClassName() !== $className) { + continue; + } + + $name = $phpDocFetch->getConstantName(); + if (str_contains($name, '*')) { + $pattern = '{^' . str_replace('\\*', '.*', preg_quote($name, '{')) . '$}D'; + foreach (array_keys($constants) as $constantName) { + if (preg_match($pattern, $constantName) !== 1) { + continue; + } + + unset($constants[$constantName]); + } + } else { + unset($constants[$name]); + } + + if ($constants === []) { + return; + } + } + } + } diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 27b9f44c3de..67b605bd08e 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1200,8 +1200,7 @@ public function testBug9039(): void { // crash $errors = $this->runAnalyse(__DIR__ . '/data/bug-9039.php'); - $this->assertCount(1, $errors); - $this->assertSame('Constant Bug9039\Test::RULES is unused.', $errors[0]->getMessage()); + $this->assertNoErrors($errors); } #[RequiresPhp('>= 8.0.0')] diff --git a/tests/PHPStan/Rules/DeadCode/UnusedPrivateConstantRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnusedPrivateConstantRuleTest.php index e990fae8340..8de21153a0b 100644 --- a/tests/PHPStan/Rules/DeadCode/UnusedPrivateConstantRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnusedPrivateConstantRuleTest.php @@ -100,4 +100,15 @@ public function testDynamicConstantFetch(): void ]); } + public function testBug6415(): void + { + $this->analyse([__DIR__ . '/data/bug-6415.php'], [ + [ + 'Constant Bug6415\MixedUsage::ACTUALLY_UNUSED is unused.', + 141, + 'See: https://phpstan.org/developing-extensions/always-used-class-constants', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-6415.php b/tests/PHPStan/Rules/DeadCode/data/bug-6415.php new file mode 100644 index 00000000000..b76c77f6dad --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-6415.php @@ -0,0 +1,149 @@ + 1, + 'b' => 2, + 'c' => 3, + ]; + + /** @return value-of */ + public function getValueOf(): int + { + return 1; + } + +} + +class Bar +{ + + private const SOME_MAP = [ + 'x' => 'hello', + 'y' => 'world', + ]; + + /** @return key-of */ + public function getKeyOf(): string + { + return 'x'; + } + +} + +class Baz +{ + + private const STATUS_ACTIVE = 'active'; + private const STATUS_INACTIVE = 'inactive'; + + private const STATUSES = [ + self::STATUS_ACTIVE, + self::STATUS_INACTIVE, + ]; + + /** @return value-of */ + public function getStatus(): string + { + return self::STATUS_ACTIVE; + } + +} + +class PropertyPhpDoc +{ + + private const ALLOWED_KEYS = ['foo', 'bar', 'baz']; + + /** @var key-of */ + private int $selectedIndex = 0; + +} + +class IntMaskOf +{ + + private const FLAG_A = 1; + private const FLAG_B = 2; + private const FLAG_C = 4; + + /** @return int-mask-of */ + public function getFlags(): int + { + return self::FLAG_A | self::FLAG_B; + } + +} + +class ConstTypeNotation +{ + + private const MODE_READ = 'read'; + private const MODE_WRITE = 'write'; + + /** @param self::MODE_* $mode */ + public function setMode(string $mode): void + { + } + +} + +/** + * @phpstan-type AllowedValue value-of + */ +class TypeAlias +{ + + private const ALLOWED_VALUES = ['a', 'b', 'c']; + + /** @return AllowedValue */ + public function getValue(): string + { + return 'a'; + } + +} + +class ParamType +{ + + private const FORMATS = ['json', 'xml', 'csv']; + + /** @param value-of $format */ + public function export(string $format): void + { + } + +} + +class AssertType +{ + + private const VALID_TYPES = ['foo', 'bar']; + + /** + * @phpstan-assert value-of $value + */ + public function assertValid(string $value): void + { + } + +} + +class MixedUsage +{ + + private const USED_IN_PHPDOC = ['a', 'b']; + private const ACTUALLY_UNUSED = 'unused'; + + /** @return value-of */ + public function getValue(): string + { + return 'a'; + } + +}