From b552d756df690cdc2d83af9c8d55c5ba31279a46 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Sat, 7 Nov 2026 11:17:45 +0100 Subject: [PATCH 1/2] Resolve a global constant name written with asterisks in a PHPDoc type @param PHP_ROUND_* $mode now resolves to the union of the global constants the pattern matches, 1|2|3|4, the way @param Foo::BAR_* does for class constants. Like PHP does for an unqualified constant name, an unqualified pattern looks in the current namespace first and falls back to the global one; a qualified one is resolved like a class name. A pattern matching nothing is an unresolvable type. ReflectionProvider::findConstantNames() finds the matching constants among the built-in ones from the stubs, the ones defined at runtime and the ones declared in the analysed and scanned files, keeping only those the configured PHP version has. Needs phpstan/phpdoc-parser to read such a pattern. Co-Authored-By: Claude Opus 5.5 --- src/PhpDoc/TypeNodeResolver.php | 41 ++++++++++- .../BetterReflectionProvider.php | 60 ++++++++++++++++ src/Reflection/ReflectionProvider.php | 7 ++ .../DummyReflectionProvider.php | 5 ++ .../MemoizingReflectionProvider.php | 5 ++ tests/PHPStan/Analyser/nsrt/bug-7272.php | 69 +++++++++++++++++++ .../Dependency/CountingReflectionProvider.php | 5 ++ .../PhpDoc/IncompatiblePhpDocTypeRuleTest.php | 18 +++++ .../global-constant-wildcard-unresolvable.php | 16 +++++ 9 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7272.php create mode 100644 tests/PHPStan/Rules/PhpDoc/data/global-constant-wildcard-unresolvable.php 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/Analyser/nsrt/bug-7272.php b/tests/PHPStan/Analyser/nsrt/bug-7272.php new file mode 100644 index 00000000000..b24a606b622 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7272.php @@ -0,0 +1,69 @@ + $array + */ +function doFoo($fullyQualified, $nullable, $middle, $namespaced, $fullyQualifiedNamespaced, $relative, $defined, $array): void +{ + assertType('1|2|3|4', $fullyQualified); + assertType('1|2|3|4|null', $nullable); + assertType('16|64|256|4096', $middle); + assertType("'read'|'write'", $namespaced); + assertType("'read'|'write'", $fullyQualifiedNamespaced); + assertType("'execute'", $relative); + assertType('10|100', $defined); + assertType("array<1|2|3|4, 'read'|'write'>", $array); +} + +namespace Bug7272\Sub; + +use function PHPStan\Testing\assertType; + +const PHP_ROUND_SHADOWED = 'shadowed'; +const FLAG_EXECUTE = 'execute'; + +/** + * @param PHP_ROUND_* $shadowed + * @param \PHP_ROUND_* $global + * @param FLAG_* $local + */ +function doBar($shadowed, $global, $local): void +{ + // like PHP does for a constant name, an unqualified pattern looks in the current namespace before the global one + assertType("'shadowed'", $shadowed); + assertType('1|2|3|4', $global); + assertType("'execute'", $local); +} 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/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php index 35185a35039..b9978e48a0b 100644 --- a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php @@ -543,6 +543,24 @@ public function testClassConstantWildcardUnresolvable(): void ]); } + public function testGlobalConstantWildcardUnresolvable(): void + { + $this->analyse([__DIR__ . '/data/global-constant-wildcard-unresolvable.php'], [ + [ + 'PHPDoc tag @param for parameter $nope contains unresolvable type.', + 14, + ], + [ + 'PHPDoc tag @param for parameter $globalFlag contains unresolvable type.', + 14, + ], + [ + 'PHPDoc tag @param for parameter $nopeFlag contains unresolvable type.', + 14, + ], + ]); + } + public function testBug4548(): void { $this->analyse([__DIR__ . '/data/bug-4548.php'], []); diff --git a/tests/PHPStan/Rules/PhpDoc/data/global-constant-wildcard-unresolvable.php b/tests/PHPStan/Rules/PhpDoc/data/global-constant-wildcard-unresolvable.php new file mode 100644 index 00000000000..0b5b0d394d3 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/global-constant-wildcard-unresolvable.php @@ -0,0 +1,16 @@ + Date: Sat, 7 Nov 2026 11:33:23 +0100 Subject: [PATCH 2/2] Test the global constant pattern without the phpdoc-parser change The PHPDoc-level tests need phpstan/phpdoc-parser to read PHP_ROUND_*, and that change cannot be merged before this one: its downstream test of this repository already meets @param FILTER_VALIDATE_* in tests/PHPStan/Analyser/nsrt/filter-*.php and throws without it. So this tests TypeNodeResolver on the node that change produces, and the PHPDoc tests come back with the phpdoc-parser bump. Co-Authored-By: Claude Opus 5.5 --- tests/PHPStan/Analyser/nsrt/bug-7272.php | 69 ------------------- tests/PHPStan/PhpDoc/TypeNodeResolverTest.php | 52 ++++++++++++++ .../PhpDoc/data/global-constant-wildcard.php | 19 +++++ .../PhpDoc/IncompatiblePhpDocTypeRuleTest.php | 18 ----- .../global-constant-wildcard-unresolvable.php | 16 ----- 5 files changed, 71 insertions(+), 103 deletions(-) delete mode 100644 tests/PHPStan/Analyser/nsrt/bug-7272.php create mode 100644 tests/PHPStan/PhpDoc/TypeNodeResolverTest.php create mode 100644 tests/PHPStan/PhpDoc/data/global-constant-wildcard.php delete mode 100644 tests/PHPStan/Rules/PhpDoc/data/global-constant-wildcard-unresolvable.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-7272.php b/tests/PHPStan/Analyser/nsrt/bug-7272.php deleted file mode 100644 index b24a606b622..00000000000 --- a/tests/PHPStan/Analyser/nsrt/bug-7272.php +++ /dev/null @@ -1,69 +0,0 @@ - $array - */ -function doFoo($fullyQualified, $nullable, $middle, $namespaced, $fullyQualifiedNamespaced, $relative, $defined, $array): void -{ - assertType('1|2|3|4', $fullyQualified); - assertType('1|2|3|4|null', $nullable); - assertType('16|64|256|4096', $middle); - assertType("'read'|'write'", $namespaced); - assertType("'read'|'write'", $fullyQualifiedNamespaced); - assertType("'execute'", $relative); - assertType('10|100', $defined); - assertType("array<1|2|3|4, 'read'|'write'>", $array); -} - -namespace Bug7272\Sub; - -use function PHPStan\Testing\assertType; - -const PHP_ROUND_SHADOWED = 'shadowed'; -const FLAG_EXECUTE = 'execute'; - -/** - * @param PHP_ROUND_* $shadowed - * @param \PHP_ROUND_* $global - * @param FLAG_* $local - */ -function doBar($shadowed, $global, $local): void -{ - // like PHP does for a constant name, an unqualified pattern looks in the current namespace before the global one - assertType("'shadowed'", $shadowed); - assertType('1|2|3|4', $global); - assertType("'execute'", $local); -} 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 @@ +analyse([__DIR__ . '/data/global-constant-wildcard-unresolvable.php'], [ - [ - 'PHPDoc tag @param for parameter $nope contains unresolvable type.', - 14, - ], - [ - 'PHPDoc tag @param for parameter $globalFlag contains unresolvable type.', - 14, - ], - [ - 'PHPDoc tag @param for parameter $nopeFlag contains unresolvable type.', - 14, - ], - ]); - } - public function testBug4548(): void { $this->analyse([__DIR__ . '/data/bug-4548.php'], []); diff --git a/tests/PHPStan/Rules/PhpDoc/data/global-constant-wildcard-unresolvable.php b/tests/PHPStan/Rules/PhpDoc/data/global-constant-wildcard-unresolvable.php deleted file mode 100644 index 0b5b0d394d3..00000000000 --- a/tests/PHPStan/Rules/PhpDoc/data/global-constant-wildcard-unresolvable.php +++ /dev/null @@ -1,16 +0,0 @@ -