diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 7a9db84a9e3..87568af4911 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -1333,7 +1333,11 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc if ($constExpr instanceof ConstFetchNode) { if ($constExpr->className === '') { - throw new ShouldNotHappenException(); // global constant should get parsed as class name in IdentifierTypeNode + if (!Strings::contains($constExpr->name, '*')) { + throw new ShouldNotHappenException(); // global constant should get parsed as class name in IdentifierTypeNode + } + + return $this->resolveGlobalConstantPattern($constExpr->name, $nameScope); } $isStatic = false; @@ -1476,6 +1480,41 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc return new ErrorType(); } + /** + * Resolves a pattern like PHP_ROUND_* to the union of the global constants it matches. Like PHP does + * for an unqualified constant name, an unqualified pattern looks in the current namespace first and + * falls back to the global one. + */ + private function resolveGlobalConstantPattern(string $pattern, NameScope $nameScope): Type + { + $namespace = $nameScope->getNamespace(); + if (Strings::contains($pattern, '\\')) { + $patterns = [$nameScope->resolveStringName($pattern)]; + } elseif ($namespace !== null) { + $patterns = [$namespace . '\\' . $pattern, $pattern]; + } else { + $patterns = [$pattern]; + } + + foreach ($patterns as $fullyQualifiedPattern) { + $constantTypes = []; + foreach ($this->getReflectionProvider()->findConstantNames($fullyQualifiedPattern) as $constantName) { + $constantType = $this->constantResolver->resolveConstant(new Name\FullyQualified($constantName), null); + if ($constantType === null) { + continue; + } + + $constantTypes[] = $constantType; + } + + if (count($constantTypes) > 0) { + return TypeCombinator::union(...$constantTypes); + } + } + + return new ErrorType(); + } + private function resolveOffsetAccessNode(OffsetAccessTypeNode $typeNode, NameScope $nameScope): Type { $type = $this->resolve($typeNode->type, $nameScope); diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index 391e293a2ec..f1272ad759f 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -3,6 +3,7 @@ namespace PHPStan\Reflection\BetterReflection; use Closure; +use JetBrains\PHPStormStub\PhpStormStubsMap; use Nette\Utils\Strings; use PhpParser\Node; use PHPStan\Analyser\Scope; @@ -39,6 +40,7 @@ use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ClassReflectionFactory; use PHPStan\Reflection\Constant\RuntimeConstantReflection; +use PHPStan\Reflection\ConstantNameHelper; use PHPStan\Reflection\ConstantReflection; use PHPStan\Reflection\Deprecation\DeprecationProvider; use PHPStan\Reflection\FunctionReflection; @@ -61,9 +63,14 @@ use PHPStan\Type\VerbosityLevel; use function array_key_exists; use function array_key_first; +use function array_keys; use function array_map; +use function array_values; +use function get_defined_constants; use function in_array; +use function preg_quote; use function sprintf; +use function str_replace; use function strtolower; use const PHP_VERSION_ID; @@ -90,6 +97,9 @@ final class BetterReflectionProvider implements ReflectionProvider /** @var array> */ private array $cachedConstants = []; + /** @var list|null */ + private ?array $constantNameCandidates = null; + /** * @param list $universalObjectCratesClasses */ @@ -526,6 +536,56 @@ public function resolveConstantName(Node\Name $nameNode, ?NamespaceAnswerer $nam return $resolved; } + public function findConstantNames(string $pattern): array + { + $regex = '{^' . str_replace('\\*', '.*', preg_quote(ConstantNameHelper::normalize($pattern))) . '$}D'; + $names = []; + foreach ($this->getConstantNameCandidates() as $candidate) { + if (Strings::match($candidate, $regex) === null) { + continue; + } + + // a candidate can be a stub of a constant the configured PHP version does not have + if (!$this->hasConstant(new Node\Name\FullyQualified($candidate), null)) { + continue; + } + + $names[] = $candidate; + } + + return $names; + } + + /** + * Names of every global constant the reflection might find: the built-in ones from the stubs, + * the ones defined at runtime, and the ones declared in the analysed and scanned files. + * + * @return list + */ + private function getConstantNameCandidates(): array + { + if ($this->constantNameCandidates !== null) { + return $this->constantNameCandidates; + } + + $names = [...array_keys(PhpStormStubsMap::CONSTANTS), ...array_keys(get_defined_constants())]; + foreach ($this->reflector->reflectAllConstants() as $constantReflection) { + $names[] = $constantReflection->getName(); + } + + $candidates = []; + foreach ($names as $name) { + $name = ConstantNameHelper::normalize($name); + if ($name === '') { + continue; // define('', ...) is allowed, but no pattern can name it + } + + $candidates[$name] = $name; + } + + return $this->constantNameCandidates = array_values($candidates); + } + private function nameResolutionCacheKey(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): string { return ($namespaceAnswerer !== null ? ($namespaceAnswerer->getNamespace() ?? '') : '') diff --git a/src/Reflection/ReflectionProvider.php b/src/Reflection/ReflectionProvider.php index 03bda099065..18ae0cc1131 100644 --- a/src/Reflection/ReflectionProvider.php +++ b/src/Reflection/ReflectionProvider.php @@ -39,4 +39,11 @@ public function getConstant(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAn public function resolveConstantName(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ?string; + /** + * Names of the global constants whose fully qualified name matches $pattern, in which * stands for any sequence of characters. + * + * @return list + */ + public function findConstantNames(string $pattern): array; + } diff --git a/src/Reflection/ReflectionProvider/DummyReflectionProvider.php b/src/Reflection/ReflectionProvider/DummyReflectionProvider.php index 7d18639f8ca..b6b0b58f55c 100644 --- a/src/Reflection/ReflectionProvider/DummyReflectionProvider.php +++ b/src/Reflection/ReflectionProvider/DummyReflectionProvider.php @@ -69,4 +69,9 @@ public function resolveConstantName(Node\Name $nameNode, ?NamespaceAnswerer $nam return null; } + public function findConstantNames(string $pattern): array + { + return []; + } + } diff --git a/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php b/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php index 8e3e3741608..4cb8a1eabaf 100644 --- a/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php +++ b/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php @@ -104,4 +104,9 @@ public function resolveConstantName(Node\Name $nameNode, ?NamespaceAnswerer $nam return $this->provider->resolveConstantName($nameNode, $namespaceAnswerer); } + public function findConstantNames(string $pattern): array + { + return $this->provider->findConstantNames($pattern); + } + } diff --git a/tests/PHPStan/Dependency/CountingReflectionProvider.php b/tests/PHPStan/Dependency/CountingReflectionProvider.php index f1524732f38..1ecee7bd544 100644 --- a/tests/PHPStan/Dependency/CountingReflectionProvider.php +++ b/tests/PHPStan/Dependency/CountingReflectionProvider.php @@ -80,4 +80,9 @@ public function resolveConstantName(Node\Name $nameNode, ?NamespaceAnswerer $nam return $this->inner->resolveConstantName($nameNode, $namespaceAnswerer); } + public function findConstantNames(string $pattern): array + { + return $this->inner->findConstantNames($pattern); + } + } diff --git a/tests/PHPStan/PhpDoc/TypeNodeResolverTest.php b/tests/PHPStan/PhpDoc/TypeNodeResolverTest.php new file mode 100644 index 00000000000..574e6397c60 --- /dev/null +++ b/tests/PHPStan/PhpDoc/TypeNodeResolverTest.php @@ -0,0 +1,52 @@ +, string}> + */ + public static function dataGlobalConstantPattern(): iterable + { + yield 'built-in' => ['PHP_ROUND_*', null, [], '1|2|3|4']; + yield 'asterisk in the middle' => ['E_*_ERROR', null, [], '16|64|256|4096']; + yield 'fully qualified in a namespace' => ['\\PHP_ROUND_*', 'GlobalConstantWildcard', [], '1|2|3|4']; + yield 'falls back to the global namespace' => ['PHP_ROUND_*', 'GlobalConstantWildcard', [], '1|2|3|4']; + yield 'current namespace' => ['FLAG_*', 'GlobalConstantWildcard', [], "'read'|'write'"]; + yield 'current namespace before the global one' => ['PHP_ROUND_*', 'GlobalConstantWildcard\\Sub', [], "'shadowed'"]; + yield 'fully qualified namespaced' => ['\\GlobalConstantWildcard\\FLAG_*', null, [], "'read'|'write'"]; + yield 'qualified, relative to the namespace' => ['Sub\\FLAG_*', 'GlobalConstantWildcard', [], "'execute'"]; + yield 'qualified, through a use' => ['S\\FLAG_*', 'Other', ['s' => 'GlobalConstantWildcard\\Sub'], "'execute'"]; + yield 'defined' => ['GLOBAL_CONSTANT_WILDCARD_*', null, [], '10|100']; + yield 'no match' => ['NOPE_*', null, [], '*ERROR*']; + yield 'no global match for a namespaced constant' => ['\\FLAG_*', 'GlobalConstantWildcard', [], '*ERROR*']; + } + + /** + * @param non-empty-string|null $namespace + * @param array $uses + */ + #[DataProvider('dataGlobalConstantPattern')] + public function testGlobalConstantPattern(string $pattern, ?string $namespace, array $uses, string $expectedType): void + { + $typeNodeResolver = self::getContainer()->getByType(TypeNodeResolver::class); + + // the node phpstan/phpdoc-parser reads @param PHP_ROUND_* $mode into + $type = $typeNodeResolver->resolve( + new ConstTypeNode(new ConstFetchNode('', $pattern)), + new NameScope($namespace, $uses), + ); + + $this->assertSame($expectedType, $type->describe(VerbosityLevel::precise())); + } + +} diff --git a/tests/PHPStan/PhpDoc/data/global-constant-wildcard.php b/tests/PHPStan/PhpDoc/data/global-constant-wildcard.php new file mode 100644 index 00000000000..2b01bacfeb0 --- /dev/null +++ b/tests/PHPStan/PhpDoc/data/global-constant-wildcard.php @@ -0,0 +1,19 @@ +