From f15adf3e8654e3762727003f93eab24a37cea391 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:31:09 +0000 Subject: [PATCH 01/13] feat: parse sources with newest supported PHP grammar instead of host version ReflectionEngine now defaults to createForNewestSupportedVersion() so that PHP 8.5 sources (pipe operator, etc.) can be statically reflected on older runtimes. An explicit PhpVersion can still be passed to init() to pin an older grammar. Covered by a PHP 8.5 syntax stub that is parsed but never loaded. Fixes #210 --- src/ReflectionEngine.php | 16 +++- tests/Php85ParsingTest.php | 119 +++++++++++++++++++++++++++++ tests/Stub/FileWithPhp85Syntax.php | 38 +++++++++ 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 tests/Php85ParsingTest.php create mode 100644 tests/Stub/FileWithPhp85Syntax.php diff --git a/src/ReflectionEngine.php b/src/ReflectionEngine.php index 2874f47..9bba8d4 100644 --- a/src/ReflectionEngine.php +++ b/src/ReflectionEngine.php @@ -26,6 +26,7 @@ use PhpParser\NodeVisitor\NameResolver; use PhpParser\Parser; use PhpParser\ParserFactory; +use PhpParser\PhpVersion; /** * AST-based reflection engine, powered by PHP-Parser @@ -47,9 +48,20 @@ class ReflectionEngine private function __construct() {} - public static function init(LocatorInterface $locator): void + /** + * Initializes the engine with the given locator and an optional grammar version + * + * By default the newest grammar supported by PHP-Parser is used, so that sources written for a newer + * PHP version than the host one can still be analysed statically. + * + * @param PhpVersion|null $phpVersion Optional PHP version of the grammar to parse sources with + */ + public static function init(LocatorInterface $locator, ?PhpVersion $phpVersion = null): void { - self::$parser = (new ParserFactory())->createForHostVersion(); + $parserFactory = new ParserFactory(); + self::$parser = isset($phpVersion) + ? $parserFactory->createForVersion($phpVersion) + : $parserFactory->createForNewestSupportedVersion(); self::$traverser = $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver( diff --git a/tests/Php85ParsingTest.php b/tests/Php85ParsingTest.php new file mode 100644 index 0000000..c1f7a24 --- /dev/null +++ b/tests/Php85ParsingTest.php @@ -0,0 +1,119 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use PHPUnit\Framework\TestCase; + +/** + * Verifies that sources written for a PHP version newer than the host one can still be analyzed. + * + * The stub file uses the PHP 8.5 pipe operator, therefore it must never be included or required, + * it is only allowed to be parsed by the reflection engine. + */ +class Php85ParsingTest extends TestCase +{ + public const STUB_FILE = '/Stub/FileWithPhp85Syntax.php'; + + public const STUB_CLASS = 'Go\ParserReflection\Stub\ClassWithPhp85PipeOperator'; + + private string $stubFileName; + + protected function setUp(): void + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . self::STUB_FILE); + $this->assertIsString($resolvedFileName, 'PHP 8.5 stub file should be available'); + + $this->stubFileName = $resolvedFileName; + } + + protected function tearDown(): void + { + // Restores the default locator for the following tests, because some of them replace it + ReflectionEngine::init(new ComposerLocator()); + } + + public function testFileWithNewerSyntaxIsParsed(): void + { + $reflectionFile = new ReflectionFile($this->stubFileName); + + $this->assertTrue($reflectionFile->isStrictMode()); + $this->assertTrue($reflectionFile->hasFileNamespace('Go\ParserReflection\Stub')); + } + + public function testClassWithNewerSyntaxIsFound(): void + { + $reflectionFile = new ReflectionFile($this->stubFileName); + $reflectionNamespace = $reflectionFile->getFileNamespace('Go\ParserReflection\Stub'); + + $this->assertArrayHasKey(self::STUB_CLASS, $reflectionNamespace->getClasses()); + + $parsedClass = $reflectionNamespace->getClass(self::STUB_CLASS); + $this->assertSame(self::STUB_CLASS, $parsedClass->getName()); + $this->assertSame($this->stubFileName, $parsedClass->getFileName()); + $this->assertSame( + ['normalize', 'splitAndTrim', 'firstOrNull'], + array_map( + static fn(\ReflectionMethod $method): string => $method->getName(), + $parsedClass->getMethods() + ) + ); + } + + public function testMethodMetadataIsAvailable(): void + { + $reflectionFile = new ReflectionFile($this->stubFileName); + $reflectionNamespace = $reflectionFile->getFileNamespace('Go\ParserReflection\Stub'); + $parsedClass = $reflectionNamespace->getClass(self::STUB_CLASS); + + $parsedMethod = $parsedClass->getMethod('splitAndTrim'); + $this->assertSame('splitAndTrim', $parsedMethod->getName()); + $this->assertFalse($parsedMethod->isStatic()); + $this->assertTrue($parsedMethod->isPublic()); + $this->assertSame('array', (string) $parsedMethod->getReturnType()); + + $parsedParameters = $parsedMethod->getParameters(); + $this->assertCount(2, $parsedParameters); + + [$sentenceParameter, $separatorParameter] = $parsedParameters; + $this->assertSame('sentence', $sentenceParameter->getName()); + $this->assertSame('string', (string) $sentenceParameter->getType()); + $this->assertFalse($sentenceParameter->isDefaultValueAvailable()); + + $this->assertSame('separator', $separatorParameter->getName()); + $this->assertSame('string', (string) $separatorParameter->getType()); + $this->assertTrue($separatorParameter->isDefaultValueAvailable()); + $this->assertSame(',', $separatorParameter->getDefaultValue()); + + $staticMethod = $parsedClass->getMethod('firstOrNull'); + $this->assertTrue($staticMethod->isStatic()); + $this->assertSame('?string', (string) $staticMethod->getReturnType()); + } + + public function testClassWithNewerSyntaxIsReflectableByName(): void + { + $stubFileName = $this->stubFileName; + $locator = new CallableLocator( + static fn(string $className): false|string + => $className === self::STUB_CLASS ? $stubFileName : false + ); + ReflectionEngine::init($locator); + + $parsedClass = new ReflectionClass(self::STUB_CLASS); + + $this->assertSame(self::STUB_CLASS, $parsedClass->getName()); + $this->assertFalse(class_exists(self::STUB_CLASS, false), 'Stub class should not be loaded'); + $this->assertTrue($parsedClass->hasMethod('normalize')); + $this->assertSame('string', (string) $parsedClass->getMethod('normalize')->getReturnType()); + } +} diff --git a/tests/Stub/FileWithPhp85Syntax.php b/tests/Stub/FileWithPhp85Syntax.php new file mode 100644 index 0000000..87f2c29 --- /dev/null +++ b/tests/Stub/FileWithPhp85Syntax.php @@ -0,0 +1,38 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +/** + * This file contains PHP 8.5 syntax and can not be loaded by an older runtime. + * It is intended to be analyzed statically only, never include it directly. + * + * @see https://wiki.php.net/rfc/pipe-operator-v3 + */ + +class ClassWithPhp85PipeOperator +{ + public function normalize(string $value): string + { + return $value |> trim(...) |> strtoupper(...); + } + + public function splitAndTrim(string $sentence, string $separator = ','): array + { + return explode($separator, $sentence) + |> (static fn(array $parts): array => array_map(trim(...), $parts)); + } + + public static function firstOrNull(array $values): ?string + { + return $values |> array_values(...) |> (static fn(array $list): ?string => $list[0] ?? null); + } +} From 7ee40c8f8d9713a14fd3e5b6b2423fa47458a38e Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:32:39 +0000 Subject: [PATCH 02/13] feat: complete PHP 8.4 ReflectionProperty API (hooks list, dynamic flag, raw-value/lazy delegation) getHooks() is now composed statically from the AST hook nodes (keyed by PropertyHookType value, native ordering) and isDynamic() answers false for declared/promoted properties. Object-bound members (isLazy, getRawValue, setRawValue, setRawValueWithoutLazyInitialization, skipLazyInitialization) initialize the internal reflection and delegate to the native parent. Fixes #207 --- src/ReflectionProperty.php | 83 ++++++++++ tests/PropertyHooksApiTest.php | 208 +++++++++++++++++++++++++ tests/Stub/FileWithPropertyHooks84.php | 69 ++++++++ 3 files changed, 360 insertions(+) create mode 100644 tests/PropertyHooksApiTest.php create mode 100644 tests/Stub/FileWithPropertyHooks84.php diff --git a/src/ReflectionProperty.php b/src/ReflectionProperty.php index 345fa91..b0647d2 100644 --- a/src/ReflectionProperty.php +++ b/src/ReflectionProperty.php @@ -373,6 +373,24 @@ public function getHook(PropertyHookType $type): ?ReflectionMethod return null; } + /** + * {@inheritDoc} + * + * @return array Hooks, keyed by the hook name (@see PropertyHookType::$value) + */ + public function getHooks(): array + { + $hooks = []; + foreach (PropertyHookType::cases() as $type) { + $hook = $this->getHook($type); + if ($hook !== null) { + $hooks[$type->value] = $hook; + } + } + + return $hooks; + } + /** * @inheritDoc * @@ -439,6 +457,16 @@ public function isDefault(): bool return true; } + /** + * {@inheritDoc} + */ + public function isDynamic(): bool + { + // Declared and promoted properties are always a part of the class definition + + return false; + } + /** * {@inheritDoc} * @@ -563,6 +591,61 @@ public function isInitialized(?object $object = null): bool return $this->hasDefaultValue(); } + /** + * {@inheritDoc} + */ + public function isLazy(object $object): bool + { + // Laziness is a property of the concrete object, so original reflection is required + $this->initializeInternalReflection(); + + return parent::isLazy($object); + } + + /** + * {@inheritDoc} + */ + public function getRawValue(object $object): mixed + { + // With object we should call original reflection to bypass property hooks + $this->initializeInternalReflection(); + + return parent::getRawValue($object); + } + + /** + * {@inheritDoc} + */ + public function setRawValue(object $object, mixed $value): void + { + // With object we should call original reflection to bypass property hooks + $this->initializeInternalReflection(); + + parent::setRawValue($object, $value); + } + + /** + * {@inheritDoc} + */ + public function setRawValueWithoutLazyInitialization(object $object, mixed $value): void + { + // Laziness is a property of the concrete object, so original reflection is required + $this->initializeInternalReflection(); + + parent::setRawValueWithoutLazyInitialization($object, $value); + } + + /** + * {@inheritDoc} + */ + public function skipLazyInitialization(object $object): void + { + // Laziness is a property of the concrete object, so original reflection is required + $this->initializeInternalReflection(); + + parent::skipLazyInitialization($object); + } + /** * @inheritDoc */ diff --git a/tests/PropertyHooksApiTest.php b/tests/PropertyHooksApiTest.php new file mode 100644 index 0000000..f407b15 --- /dev/null +++ b/tests/PropertyHooksApiTest.php @@ -0,0 +1,208 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use PHPUnit\Framework\TestCase; + +/** + * Tests for the PHP 8.4 property API that is inherited from the internal reflection + * + * @see \Go\ParserReflection\ReflectionProperty::getHooks() + * @see \Go\ParserReflection\ReflectionProperty::isDynamic() + */ +class PropertyHooksApiTest extends TestCase +{ + private const STUB_FILE = __DIR__ . '/Stub/FileWithPropertyHooks84.php'; + + private const STUB_NAMESPACE = 'Go\\ParserReflection\\Stub\\'; + + private const HOOKED_CLASS = self::STUB_NAMESPACE . 'ClassWithHookedAndPlainProperties'; + + private const HOOKED_INTERFACE = self::STUB_NAMESPACE . 'InterfaceWithAbstractHook'; + + /** + * Expected hook names for every property of the stub class, keyed by the property name + * + * @var array> + */ + private const EXPECTED_HOOKS = [ + 'plainProperty' => [], + 'backedCounter' => ['get', 'set'], + 'virtualWithBothHooks' => ['get', 'set'], + 'reversedHookOrder' => ['get', 'set'], + 'virtualReadOnly' => ['get'], + 'promotedProperty' => [], + ]; + + protected function setUp(): void + { + // Stub file holds several classes at once, so it can not be resolved by the PSR-4 rules + $locator = new CallableLocator( + static fn(string $className): false|string + => str_starts_with($className, self::STUB_NAMESPACE) ? self::STUB_FILE : false + ); + ReflectionEngine::init($locator); + } + + protected function tearDown(): void + { + ReflectionEngine::init(new ComposerLocator()); + } + + /** + * This test intentionally goes first to check the pure AST-based path for the non-loaded classes + */ + public function testGetHooksAndIsDynamicWithoutLoadingClass(): void + { + $this->assertFalse( + class_exists(self::HOOKED_CLASS, false), + 'Stub class should not be loaded to verify the static analysis path' + ); + + $parsedClass = new ReflectionClass(self::HOOKED_CLASS); + foreach (self::EXPECTED_HOOKS as $propertyName => $expectedHookNames) { + $parsedProperty = $parsedClass->getProperty($propertyName); + $hooks = $parsedProperty->getHooks(); + + $this->assertSame( + $expectedHookNames, + array_keys($hooks), + "Hook names for property {$propertyName} should be resolved from the AST" + ); + foreach ($hooks as $hookName => $hook) { + $this->assertInstanceOf(ReflectionMethod::class, $hook); + $this->assertSame('$' . $propertyName . '::' . $hookName, $hook->getName()); + $this->assertSame(self::HOOKED_CLASS, $hook->getDeclaringClass()->getName()); + } + $this->assertFalse($parsedProperty->isDynamic(), "Property {$propertyName} should not be dynamic"); + } + + $this->assertFalse(class_exists(self::HOOKED_CLASS, false), 'Static analysis should not load the class'); + } + + public function testGetHooksForAbstractInterfaceHookWithoutLoadingInterface(): void + { + $this->assertFalse( + interface_exists(self::HOOKED_INTERFACE, false), + 'Stub interface should not be loaded to verify the static analysis path' + ); + + $parsedProperty = (new ReflectionClass(self::HOOKED_INTERFACE))->getProperty('abstractHooked'); + $hooks = $parsedProperty->getHooks(); + + $this->assertSame(['get'], array_keys($hooks)); + $this->assertSame('$abstractHooked::get', $hooks['get']->getName()); + $this->assertTrue($parsedProperty->isAbstract()); + $this->assertFalse($parsedProperty->isDynamic()); + } + + public function testGetHooksParityWithNativeReflection(): void + { + $parsedClass = $this->loadStubClass(); + + foreach (array_keys(self::EXPECTED_HOOKS) as $propertyName) { + $parsedProperty = $parsedClass->getProperty($propertyName); + $nativeProperty = new \ReflectionProperty(self::HOOKED_CLASS, $propertyName); + + $parsedHooks = $parsedProperty->getHooks(); + $nativeHooks = $nativeProperty->getHooks(); + + $this->assertSame( + array_keys($nativeHooks), + array_keys($parsedHooks), + "Hook names for property {$propertyName} should be equal to the native ones" + ); + foreach ($nativeHooks as $hookName => $nativeHook) { + $this->assertSame( + $nativeHook->getName(), + $parsedHooks[$hookName]->getName(), + "Hook method name for {$propertyName}::{$hookName} should be equal" + ); + $this->assertSame( + $nativeHook->getNumberOfParameters(), + $parsedHooks[$hookName]->getNumberOfParameters(), + "Hook parameter count for {$propertyName}::{$hookName} should be equal" + ); + } + $this->assertSame( + $nativeProperty->isDynamic(), + $parsedProperty->isDynamic(), + "Dynamic flag for property {$propertyName} should be equal to the native one" + ); + } + } + + public function testRawValueRoundTripOnRealInstance(): void + { + $parsedClass = $this->loadStubClass(); + $className = self::HOOKED_CLASS; + $instance = new $className(); + + $parsedProperty = $parsedClass->getProperty('backedCounter'); + $nativeProperty = new \ReflectionProperty($className, 'backedCounter'); + + // Raw access bypasses the get hook that adds one to the stored value + $this->assertSame(1, $parsedProperty->getRawValue($instance)); + $this->assertSame($nativeProperty->getRawValue($instance), $parsedProperty->getRawValue($instance)); + + $parsedProperty->setRawValue($instance, 21); + $this->assertSame(21, $parsedProperty->getRawValue($instance)); + // The set hook is bypassed too, but the get hook is still used for the normal read + $this->assertSame(22, $instance->backedCounter); + + $this->assertFalse($parsedProperty->isLazy($instance)); + } + + public function testLazyObjectMethodsAreDelegatedToNativeReflection(): void + { + $parsedClass = $this->loadStubClass(); + $nativeClass = new \ReflectionClass(self::HOOKED_CLASS); + $initializer = static function (object $instance): void { + $instance->__construct('initialized'); + }; + + $parsedProperty = $parsedClass->getProperty('plainProperty'); + $nativeProperty = new \ReflectionProperty(self::HOOKED_CLASS, 'plainProperty'); + + $parsedGhost = $nativeClass->newLazyGhost($initializer); + $nativeGhost = $nativeClass->newLazyGhost($initializer); + + $this->assertTrue($parsedProperty->isLazy($parsedGhost)); + $this->assertSame($nativeProperty->isLazy($nativeGhost), $parsedProperty->isLazy($parsedGhost)); + + $parsedProperty->setRawValueWithoutLazyInitialization($parsedGhost, 'preset'); + $nativeProperty->setRawValueWithoutLazyInitialization($nativeGhost, 'preset'); + + $this->assertSame('preset', $parsedProperty->getRawValue($parsedGhost)); + $this->assertSame($nativeProperty->getRawValue($nativeGhost), $parsedProperty->getRawValue($parsedGhost)); + $this->assertFalse($parsedProperty->isLazy($parsedGhost)); + + $skippedGhost = $nativeClass->newLazyGhost($initializer); + $promotedParsed = $parsedClass->getProperty('promotedProperty'); + + $this->assertTrue($promotedParsed->isLazy($skippedGhost)); + $promotedParsed->skipLazyInitialization($skippedGhost); + $this->assertFalse($promotedParsed->isLazy($skippedGhost)); + } + + /** + * Loads the stub file and returns a parsed reflection for the class with hooked properties + */ + private function loadStubClass(): ReflectionClass + { + include_once self::STUB_FILE; + + return new ReflectionClass(self::HOOKED_CLASS); + } +} diff --git a/tests/Stub/FileWithPropertyHooks84.php b/tests/Stub/FileWithPropertyHooks84.php new file mode 100644 index 0000000..f500153 --- /dev/null +++ b/tests/Stub/FileWithPropertyHooks84.php @@ -0,0 +1,69 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +/** + * @see https://wiki.php.net/rfc/property-hooks + */ + +class ClassWithHookedAndPlainProperties +{ + private string $storage = 'stored'; + + public string $plainProperty = 'plain'; + + /** + * Backed property with both hooks, raw value is stored in the property itself + */ + public int $backedCounter = 1 { + get => $this->backedCounter + 1; + set => $value * 2; + } + + /** + * Virtual property with both hooks, there is no backing store at all + */ + public string $virtualWithBothHooks { + get => $this->storage; + set (string $value) { + $this->storage = strtolower($value); + } + } + + /** + * Virtual property that declares the set hook before the get one + */ + public string $reversedHookOrder { + set (string $value) { + $this->storage = trim($value); + } + get => $this->storage; + } + + /** + * Virtual property with a single get hook + */ + public string $virtualReadOnly { + get => strtoupper($this->storage); + } + + public function __construct(public string $promotedProperty = 'promoted') + { + } +} + +interface InterfaceWithAbstractHook +{ + public string $abstractHooked { + get; + } +} From 8ce339e9b19aed93adeb6901ce7b9824220b7978 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:34:41 +0000 Subject: [PATCH 03/13] feat: enum case constant introspection and PHP 8.4 lazy-object API Enum cases now answer getModifiers()/hasType()/getType()/isDeprecated() statically from the EnumCase AST node, matching native semantics (IS_PUBLIC only, untyped, #[\Deprecated]-aware), and isFinal() is aligned with native reflection which reports false for enum cases. The lazy-object API (newLazyGhost, newLazyProxy, resetAsLazyGhost/Proxy, initializeLazyObject, isUninitializedLazyObject, markLazyObjectAsInitialized, getLazyInitializer) delegates to the native parent after initializing the internal reflection, for both classes and enums. Fixes #205 Fixes #208 --- src/ReflectionEnumBackedCase.php | 63 ++++++- src/ReflectionEnumUnitCase.php | 63 ++++++- src/Traits/ReflectionClassLikeTrait.php | 96 +++++++++++ tests/EnumCaseAndLazyObjectTest.php | 217 ++++++++++++++++++++++++ tests/Stub/FileWithEnumCases84.php | 49 ++++++ 5 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 tests/EnumCaseAndLazyObjectTest.php create mode 100644 tests/Stub/FileWithEnumCases84.php diff --git a/src/ReflectionEnumBackedCase.php b/src/ReflectionEnumBackedCase.php index 2447b2c..0c7be40 100644 --- a/src/ReflectionEnumBackedCase.php +++ b/src/ReflectionEnumBackedCase.php @@ -12,9 +12,13 @@ namespace Go\ParserReflection; +use Deprecated; use Go\ParserReflection\Resolver\NodeExpressionResolver; +use PhpParser\Node\Name; use PhpParser\Node\Stmt\EnumCase; +use ReflectionClassConstant as InternalReflectionClassConstant; use ReflectionEnumBackedCase as InternalReflectionEnumBackedCase; +use ReflectionType; use UnitEnum; /** @@ -189,7 +193,64 @@ public function isProtected(): bool */ public function isFinal(): bool { - return true; + return false; + } + + /** + * {@inheritDoc} + * + * Enum cases are always public and never expose the final bit, exactly like the native reflection does. + */ + public function getModifiers(): int + { + return InternalReflectionClassConstant::IS_PUBLIC; + } + + /** + * {@inheritDoc} + * + * Enum cases never have a declared type, even for backed enums. + */ + public function hasType(): bool + { + return false; + } + + /** + * {@inheritDoc} + * + * Enum cases never have a declared type, even for backed enums. + */ + public function getType(): ?ReflectionType + { + return null; + } + + /** + * {@inheritDoc} + * + * Resolved from the #[\Deprecated] attribute at the pure AST level, without loading the enum. + */ + public function isDeprecated(): bool + { + foreach ($this->enumCaseNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $attributeNameNode = $attr->name; + // If we have resolved node name, then we should use it instead + if ($attributeNameNode->hasAttribute('resolvedName')) { + $resolvedNameNode = $attributeNameNode->getAttribute('resolvedName'); + if ($resolvedNameNode instanceof Name) { + $attributeNameNode = $resolvedNameNode; + } + } + + if (strcasecmp(ltrim($attributeNameNode->toString(), '\\'), Deprecated::class) === 0) { + return true; + } + } + } + + return false; } /** diff --git a/src/ReflectionEnumUnitCase.php b/src/ReflectionEnumUnitCase.php index 59f68a9..7dd3e53 100644 --- a/src/ReflectionEnumUnitCase.php +++ b/src/ReflectionEnumUnitCase.php @@ -12,10 +12,14 @@ namespace Go\ParserReflection; +use Deprecated; use Go\ParserReflection\Traits\AttributeResolverTrait; use Go\ParserReflection\Traits\InternalPropertiesEmulationTrait; +use PhpParser\Node\Name; use PhpParser\Node\Stmt\EnumCase; +use ReflectionClassConstant as InternalReflectionClassConstant; use ReflectionEnumUnitCase as InternalReflectionEnumUnitCase; +use ReflectionType; use UnitEnum; /** @@ -168,7 +172,64 @@ public function isProtected(): bool */ public function isFinal(): bool { - return true; + return false; + } + + /** + * {@inheritDoc} + * + * Enum cases are always public and never expose the final bit, exactly like the native reflection does. + */ + public function getModifiers(): int + { + return InternalReflectionClassConstant::IS_PUBLIC; + } + + /** + * {@inheritDoc} + * + * Enum cases never have a declared type, even for backed enums. + */ + public function hasType(): bool + { + return false; + } + + /** + * {@inheritDoc} + * + * Enum cases never have a declared type, even for backed enums. + */ + public function getType(): ?ReflectionType + { + return null; + } + + /** + * {@inheritDoc} + * + * Resolved from the #[\Deprecated] attribute at the pure AST level, without loading the enum. + */ + public function isDeprecated(): bool + { + foreach ($this->enumCaseNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $attributeNameNode = $attr->name; + // If we have resolved node name, then we should use it instead + if ($attributeNameNode->hasAttribute('resolvedName')) { + $resolvedNameNode = $attributeNameNode->getAttribute('resolvedName'); + if ($resolvedNameNode instanceof Name) { + $attributeNameNode = $resolvedNameNode; + } + } + + if (strcasecmp(ltrim($attributeNameNode->toString(), '\\'), Deprecated::class) === 0) { + return true; + } + } + } + + return false; } /** diff --git a/src/Traits/ReflectionClassLikeTrait.php b/src/Traits/ReflectionClassLikeTrait.php index 4c7131a..335b9e1 100644 --- a/src/Traits/ReflectionClassLikeTrait.php +++ b/src/Traits/ReflectionClassLikeTrait.php @@ -1046,6 +1046,102 @@ public function newInstanceWithoutConstructor(): object return parent::newInstanceWithoutConstructor(); } + /** + * Creates a new lazy ghost instance of the class. + * + * @link https://php.net/manual/en/reflectionclass.newlazyghost.php + */ + public function newLazyGhost(callable $initializer, int $options = 0): object + { + $this->initializeInternalReflection(); + + return parent::newLazyGhost($initializer, $options); + } + + /** + * Creates a new lazy proxy instance of the class. + * + * @link https://php.net/manual/en/reflectionclass.newlazyproxy.php + */ + public function newLazyProxy(callable $factory, int $options = 0): object + { + $this->initializeInternalReflection(); + + return parent::newLazyProxy($factory, $options); + } + + /** + * Resets an existing object and makes it a lazy ghost. + * + * @link https://php.net/manual/en/reflectionclass.resetaslazyghost.php + */ + public function resetAsLazyGhost(object $object, callable $initializer, int $options = 0): void + { + $this->initializeInternalReflection(); + + parent::resetAsLazyGhost($object, $initializer, $options); + } + + /** + * Resets an existing object and makes it a lazy proxy. + * + * @link https://php.net/manual/en/reflectionclass.resetaslazyproxy.php + */ + public function resetAsLazyProxy(object $object, callable $factory, int $options = 0): void + { + $this->initializeInternalReflection(); + + parent::resetAsLazyProxy($object, $factory, $options); + } + + /** + * Forces the initialization of a lazy object. + * + * @link https://php.net/manual/en/reflectionclass.initializelazyobject.php + */ + public function initializeLazyObject(object $object): object + { + $this->initializeInternalReflection(); + + return parent::initializeLazyObject($object); + } + + /** + * Checks if a lazy object is still uninitialized. + * + * @link https://php.net/manual/en/reflectionclass.isuninitializedlazyobject.php + */ + public function isUninitializedLazyObject(object $object): bool + { + $this->initializeInternalReflection(); + + return parent::isUninitializedLazyObject($object); + } + + /** + * Marks a lazy object as initialized, without calling the initializer or factory. + * + * @link https://php.net/manual/en/reflectionclass.marklazyobjectasinitialized.php + */ + public function markLazyObjectAsInitialized(object $object): object + { + $this->initializeInternalReflection(); + + return parent::markLazyObjectAsInitialized($object); + } + + /** + * Returns the initializer or factory of a lazy object, or null if it is not lazy anymore. + * + * @link https://php.net/manual/en/reflectionclass.getlazyinitializer.php + */ + public function getLazyInitializer(object $object): ?callable + { + $this->initializeInternalReflection(); + + return parent::getLazyInitializer($object); + } + /** * Sets static property value * diff --git a/tests/EnumCaseAndLazyObjectTest.php b/tests/EnumCaseAndLazyObjectTest.php new file mode 100644 index 0000000..c6644a1 --- /dev/null +++ b/tests/EnumCaseAndLazyObjectTest.php @@ -0,0 +1,217 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Stub\ClassForLazyObjects; +use PHPUnit\Framework\TestCase; + +/** + * Tests for the inherited \ReflectionClassConstant methods on enum cases and for the PHP 8.4 lazy-object API + * + * @see ReflectionEnumUnitCase + * @see ReflectionEnumBackedCase + * @see \Go\ParserReflection\Traits\ReflectionClassLikeTrait + */ +class EnumCaseAndLazyObjectTest extends TestCase +{ + private const STUB_FILENAME = '/Stub/FileWithEnumCases84.php'; + + private const BACKED_ENUM_NAME = 'Go\ParserReflection\Stub\EnumCaseSuit'; + + private const UNIT_ENUM_NAME = 'Go\ParserReflection\Stub\EnumCaseDirection'; + + private ReflectionFileNamespace $parsedRefFileNamespace; + + protected function setUp(): void + { + $fileName = stream_resolve_include_path(__DIR__ . self::STUB_FILENAME); + $fileNode = ReflectionEngine::parseFile($fileName); + + $reflectionFile = new ReflectionFile($fileName, $fileNode); + + $this->parsedRefFileNamespace = $reflectionFile->getFileNamespace('Go\ParserReflection\Stub'); + } + + /** + * Enum cases must answer the inherited constant methods from the AST only, without loading the enum + */ + public function testEnumCaseMethodsAreResolvedWithoutLoadingEnum(): void + { + $this->assertFalse(enum_exists(self::BACKED_ENUM_NAME, false), 'Backed enum should not be loaded yet'); + $this->assertFalse(enum_exists(self::UNIT_ENUM_NAME, false), 'Unit enum should not be loaded yet'); + + $parsedBackedEnum = $this->getParsedEnum(self::BACKED_ENUM_NAME); + + $deprecatedBackedCase = $parsedBackedEnum->getCase('Hearts'); + $this->assertInstanceOf(ReflectionEnumBackedCase::class, $deprecatedBackedCase); + $this->assertSame(\ReflectionClassConstant::IS_PUBLIC, $deprecatedBackedCase->getModifiers()); + $this->assertFalse($deprecatedBackedCase->hasType()); + $this->assertNull($deprecatedBackedCase->getType()); + $this->assertTrue($deprecatedBackedCase->isDeprecated()); + + $plainBackedCase = $parsedBackedEnum->getCase('Spades'); + $this->assertSame(\ReflectionClassConstant::IS_PUBLIC, $plainBackedCase->getModifiers()); + $this->assertFalse($plainBackedCase->hasType()); + $this->assertNull($plainBackedCase->getType()); + $this->assertFalse($plainBackedCase->isDeprecated()); + + $parsedUnitEnum = $this->getParsedEnum(self::UNIT_ENUM_NAME); + + $plainUnitCase = $parsedUnitEnum->getCase('Up'); + $this->assertInstanceOf(ReflectionEnumUnitCase::class, $plainUnitCase); + $this->assertSame(\ReflectionClassConstant::IS_PUBLIC, $plainUnitCase->getModifiers()); + $this->assertFalse($plainUnitCase->hasType()); + $this->assertNull($plainUnitCase->getType()); + $this->assertFalse($plainUnitCase->isDeprecated()); + + $deprecatedUnitCase = $parsedUnitEnum->getCase('Down'); + $this->assertSame(\ReflectionClassConstant::IS_PUBLIC, $deprecatedUnitCase->getModifiers()); + $this->assertTrue($deprecatedUnitCase->isDeprecated()); + + $this->assertFalse(enum_exists(self::BACKED_ENUM_NAME, false), 'Backed enum should still not be loaded'); + $this->assertFalse(enum_exists(self::UNIT_ENUM_NAME, false), 'Unit enum should still not be loaded'); + } + + /** + * Parsed enum cases should give exactly the same answers as the native reflection for a loaded enum + */ + public function testEnumCaseMethodsMatchNativeReflection(): void + { + $this->loadStubFile(); + + foreach ([self::BACKED_ENUM_NAME, self::UNIT_ENUM_NAME] as $enumName) { + $parsedEnum = $this->getParsedEnum($enumName); + /** @var \ReflectionEnum<\UnitEnum> $nativeEnum */ + $nativeEnum = new \ReflectionEnum($enumName); + + foreach ($nativeEnum->getCases() as $nativeCase) { + $caseName = $nativeCase->getName(); + $parsedCase = $parsedEnum->getCase($caseName); + $message = $enumName . '::' . $caseName; + + $this->assertSame($nativeCase->getModifiers(), $parsedCase->getModifiers(), $message); + $this->assertSame($nativeCase->hasType(), $parsedCase->hasType(), $message); + $this->assertSame($nativeCase->getType(), $parsedCase->getType(), $message); + $this->assertSame($nativeCase->isDeprecated(), $parsedCase->isDeprecated(), $message); + } + } + } + + /** + * Lazy ghosts created from a parsed class should behave exactly like the native ones + */ + public function testNewLazyGhost(): void + { + $this->loadStubFile(); + + $parsedClass = $this->parsedRefFileNamespace->getClass(ClassForLazyObjects::class); + $nativeClass = new \ReflectionClass(ClassForLazyObjects::class); + + $initializer = static function (ClassForLazyObjects $instance): void { + $instance->value = 42; + $instance->title = 'initialized'; + }; + + $parsedGhost = $parsedClass->newLazyGhost($initializer); + $nativeGhost = $nativeClass->newLazyGhost($initializer); + + $this->assertInstanceOf(ClassForLazyObjects::class, $parsedGhost); + $this->assertTrue($parsedClass->isUninitializedLazyObject($parsedGhost)); + $this->assertSame($nativeClass->isUninitializedLazyObject($nativeGhost), $parsedClass->isUninitializedLazyObject($parsedGhost)); + $this->assertIsCallable($parsedClass->getLazyInitializer($parsedGhost)); + + $this->assertSame($parsedGhost, $parsedClass->initializeLazyObject($parsedGhost)); + $this->assertFalse($parsedClass->isUninitializedLazyObject($parsedGhost)); + $this->assertNull($parsedClass->getLazyInitializer($parsedGhost)); + $this->assertSame(42, $parsedGhost->value); + $this->assertSame('initialized', $parsedGhost->title); + } + + /** + * Lazy proxies created from a parsed class should delegate to the real instance built by the factory + */ + public function testNewLazyProxy(): void + { + $this->loadStubFile(); + + $parsedClass = $this->parsedRefFileNamespace->getClass(ClassForLazyObjects::class); + + $parsedProxy = $parsedClass->newLazyProxy(static fn(): ClassForLazyObjects => new ClassForLazyObjects(7)); + + $this->assertInstanceOf(ClassForLazyObjects::class, $parsedProxy); + $this->assertTrue($parsedClass->isUninitializedLazyObject($parsedProxy)); + + $realInstance = $parsedClass->initializeLazyObject($parsedProxy); + $this->assertFalse($parsedClass->isUninitializedLazyObject($parsedProxy)); + $this->assertSame(7, $realInstance->value); + $this->assertSame(7, $parsedProxy->value); + } + + /** + * An existing instance should be resettable to a lazy ghost or to a lazy proxy + */ + public function testResetAsLazyGhostAndProxy(): void + { + $this->loadStubFile(); + + $parsedClass = $this->parsedRefFileNamespace->getClass(ClassForLazyObjects::class); + + $ghostCandidate = new ClassForLazyObjects(5); + $this->assertFalse($parsedClass->isUninitializedLazyObject($ghostCandidate)); + $this->assertNull($parsedClass->getLazyInitializer($ghostCandidate)); + + $parsedClass->resetAsLazyGhost($ghostCandidate, static function (ClassForLazyObjects $instance): void { + $instance->value = 99; + }); + $this->assertTrue($parsedClass->isUninitializedLazyObject($ghostCandidate)); + $this->assertSame(99, $ghostCandidate->value); + $this->assertFalse($parsedClass->isUninitializedLazyObject($ghostCandidate)); + + $proxyCandidate = new ClassForLazyObjects(5); + $parsedClass->resetAsLazyProxy($proxyCandidate, static fn(): ClassForLazyObjects => new ClassForLazyObjects(3)); + $this->assertTrue($parsedClass->isUninitializedLazyObject($proxyCandidate)); + $this->assertSame(3, $proxyCandidate->value); + $this->assertFalse($parsedClass->isUninitializedLazyObject($proxyCandidate)); + } + + /** + * Marking a lazy object as initialized should skip the initializer and keep declared default values + */ + public function testMarkLazyObjectAsInitialized(): void + { + $this->loadStubFile(); + + $parsedClass = $this->parsedRefFileNamespace->getClass(ClassForLazyObjects::class); + + $parsedGhost = $parsedClass->newLazyGhost(static function (ClassForLazyObjects $instance): void { + $instance->value = 1000; + }); + + $this->assertSame($parsedGhost, $parsedClass->markLazyObjectAsInitialized($parsedGhost)); + $this->assertFalse($parsedClass->isUninitializedLazyObject($parsedGhost)); + $this->assertSame(0, $parsedGhost->value); + $this->assertSame('untouched', $parsedGhost->title); + } + + private function getParsedEnum(string $enumName): ReflectionEnum + { + $parsedEnums = $this->parsedRefFileNamespace->getEnums(); + $this->assertArrayHasKey($enumName, $parsedEnums); + + return $parsedEnums[$enumName]; + } + + private function loadStubFile(): void + { + include_once stream_resolve_include_path(__DIR__ . self::STUB_FILENAME); + } +} diff --git a/tests/Stub/FileWithEnumCases84.php b/tests/Stub/FileWithEnumCases84.php new file mode 100644 index 0000000..88c41d4 --- /dev/null +++ b/tests/Stub/FileWithEnumCases84.php @@ -0,0 +1,49 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +/** + * Backed enum with a deprecated case + */ +enum EnumCaseSuit: string +{ + #[\Deprecated(message: 'Use Spades instead')] + case Hearts = 'H'; + + case Spades = 'S'; +} + +/** + * Pure unit enum with a deprecated case + */ +enum EnumCaseDirection +{ + case Up; + + #[\Deprecated] + case Down; +} + +/** + * Simple class used to verify the PHP 8.4 lazy-object API + */ +class ClassForLazyObjects +{ + public int $value = 0; + + public string $title = 'untouched'; + + public function __construct(int $value = 1) + { + $this->value = $value; + } +} From 40ccfbe478539fed9bbc3d7f8028728ee1cb0a1d Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:34:52 +0000 Subject: [PATCH 04/13] fix: reflect final modifier on promoted constructor properties PHP 8.5 allows the final modifier on constructor-promoted properties. isFinal() now reads the FINAL flag from the promoted Param node as well, instead of only from Property nodes, while keeping the implicit finality of private(set) properties. getModifiers() picks the bit up automatically. Fixes #212 --- src/ReflectionProperty.php | 7 +- tests/FinalPromotedPropertyTest.php | 124 +++++++++++++++++++++++++ tests/Stub/FileWithFinalPromoted85.php | 30 ++++++ 3 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 tests/FinalPromotedPropertyTest.php create mode 100644 tests/Stub/FileWithFinalPromoted85.php diff --git a/src/ReflectionProperty.php b/src/ReflectionProperty.php index b0647d2..2dbe37a 100644 --- a/src/ReflectionProperty.php +++ b/src/ReflectionProperty.php @@ -471,13 +471,12 @@ public function isDynamic(): bool * {@inheritDoc} * * @see Property::isFinal() + * @see Param::isFinal() */ public function isFinal(): bool { - $explicitFinal = false; - if ($this->propertyOrPromotedParam instanceof Property) { - $explicitFinal = $this->propertyOrPromotedParam->isFinal(); - } + // Promoted properties can also be declared final since PHP 8.5 + $explicitFinal = $this->propertyOrPromotedParam->isFinal(); // Property with private(set) modifier is implicitly final return $explicitFinal || $this->isPrivateSet(); diff --git a/tests/FinalPromotedPropertyTest.php b/tests/FinalPromotedPropertyTest.php new file mode 100644 index 0000000..8cdc943 --- /dev/null +++ b/tests/FinalPromotedPropertyTest.php @@ -0,0 +1,124 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use PHPUnit\Framework\TestCase; + +/** + * Verifies that the PHP 8.5 `final` modifier on promoted constructor properties is reflected. + * + * The stub file uses PHP 8.5 syntax, therefore it must never be included or required, + * it is only allowed to be parsed by the reflection engine. + */ +class FinalPromotedPropertyTest extends TestCase +{ + public const STUB_FILE = '/Stub/FileWithFinalPromoted85.php'; + + public const STUB_CLASS = 'Go\ParserReflection\Stub\ClassWithFinalPromotedProperty85'; + + private string $stubFileName; + + protected function setUp(): void + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . self::STUB_FILE); + $this->assertIsString($resolvedFileName, 'PHP 8.5 stub file should be available'); + + $this->stubFileName = $resolvedFileName; + } + + protected function tearDown(): void + { + // Restores the default locator for the following tests, because some of them replace it + ReflectionEngine::init(new ComposerLocator()); + } + + public function testFinalPromotedPropertyIsFinal(): void + { + $parsedClass = $this->getParsedClass(); + + $parsedProperty = $parsedClass->getProperty('finalPromoted'); + $this->assertTrue($parsedProperty->isPromoted(), 'Property should be promoted'); + $this->assertTrue($parsedProperty->isFinal(), 'Promoted property should be final'); + $this->assertSame( + \ReflectionProperty::IS_FINAL, + $parsedProperty->getModifiers() & \ReflectionProperty::IS_FINAL, + 'Modifiers should contain the IS_FINAL bit' + ); + $this->assertTrue($parsedProperty->isPublic()); + } + + public function testPlainPromotedPropertyIsNotFinal(): void + { + $parsedClass = $this->getParsedClass(); + + $parsedProperty = $parsedClass->getProperty('plainPromoted'); + $this->assertTrue($parsedProperty->isPromoted(), 'Property should be promoted'); + $this->assertFalse($parsedProperty->isFinal(), 'Plain promoted property should not be final'); + $this->assertSame( + 0, + $parsedProperty->getModifiers() & \ReflectionProperty::IS_FINAL, + 'Modifiers should not contain the IS_FINAL bit' + ); + } + + public function testFinalReadonlyPromotedPropertyIsFinal(): void + { + $parsedClass = $this->getParsedClass(); + + $parsedProperty = $parsedClass->getProperty('finalReadonlyPromoted'); + $this->assertTrue($parsedProperty->isFinal()); + $this->assertTrue($parsedProperty->isReadOnly()); + $this->assertTrue($parsedProperty->isProtected()); + $this->assertSame( + \ReflectionProperty::IS_FINAL, + $parsedProperty->getModifiers() & \ReflectionProperty::IS_FINAL + ); + } + + public function testPrivateSetPromotedPropertyRemainsImplicitlyFinal(): void + { + $parsedClass = $this->getParsedClass(); + + $parsedProperty = $parsedClass->getProperty('privateSetPromoted'); + $this->assertTrue($parsedProperty->isPrivateSet(), 'Property should have private(set) visibility'); + $this->assertTrue($parsedProperty->isFinal(), 'Property with private(set) is implicitly final'); + $this->assertSame( + \ReflectionProperty::IS_FINAL, + $parsedProperty->getModifiers() & \ReflectionProperty::IS_FINAL + ); + } + + public function testStubClassIsNeverLoaded(): void + { + $parsedClass = $this->getParsedClass(); + + $this->assertSame(self::STUB_CLASS, $parsedClass->getName()); + $this->assertFalse(class_exists(self::STUB_CLASS, false), 'Stub class should not be loaded'); + } + + /** + * Reflects the stub class without triggering autoloading of the PHP 8.5 source + */ + private function getParsedClass(): ReflectionClass + { + $stubFileName = $this->stubFileName; + $locator = new CallableLocator( + static fn(string $className): false|string + => $className === self::STUB_CLASS ? $stubFileName : false + ); + ReflectionEngine::init($locator); + + return new ReflectionClass(self::STUB_CLASS); + } +} diff --git a/tests/Stub/FileWithFinalPromoted85.php b/tests/Stub/FileWithFinalPromoted85.php new file mode 100644 index 0000000..52b39a0 --- /dev/null +++ b/tests/Stub/FileWithFinalPromoted85.php @@ -0,0 +1,30 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +/** + * This file contains PHP 8.5 syntax and can not be loaded by an older runtime. + * It is intended to be analyzed statically only, never include it directly. + * + * @see https://wiki.php.net/rfc/final_promotion + */ + +class ClassWithFinalPromotedProperty85 +{ + public function __construct( + public final int $finalPromoted = 1, + public int $plainPromoted = 2, + protected final readonly string $finalReadonlyPromoted = 'value', + private(set) string $privateSetPromoted = 'implicitly final' + ) { + } +} From 5d645ecdc1b2414c0b8c265f4b0d6d927b96f740 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:36:04 +0000 Subject: [PATCH 05/13] feat: fill PHP 8.1-8.4 function-like reflection gaps and #[Deprecated] support Tentative return type accessors, closure introspection (getClosureCalledClass, getClosureUsedVariables), isAnonymous()/isStatic() on functions and createFromMethodName() on methods now answer statically from the AST instead of fataling on parser-based instances. isDeprecated() honors the PHP 8.4 #[\Deprecated] attribute for functions, methods and class constants via a pure-AST attribute check. Fixes #204 Fixes #206 --- src/ReflectionClassConstant.php | 31 +++ src/ReflectionFunction.php | 20 ++ src/ReflectionMethod.php | 23 ++ src/Traits/ReflectionFunctionLikeTrait.php | 84 +++++- tests/DeprecatedAndFunctionLikeGapsTest.php | 272 ++++++++++++++++++++ tests/Stub/FileWithDeprecatedFeatures84.php | 70 +++++ 6 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 tests/DeprecatedAndFunctionLikeGapsTest.php create mode 100644 tests/Stub/FileWithDeprecatedFeatures84.php diff --git a/src/ReflectionClassConstant.php b/src/ReflectionClassConstant.php index 6a24445..51e9f3c 100644 --- a/src/ReflectionClassConstant.php +++ b/src/ReflectionClassConstant.php @@ -249,6 +249,37 @@ public function isFinal(): bool return $this->classConstOrEnumCaseNode instanceof ClassConst && $this->classConstOrEnumCaseNode->isFinal(); } + /** + * @inheritDoc + */ + public function isDeprecated(): bool + { + // Since PHP 8.4 class constants and enum cases can be marked with the #[\Deprecated] attribute + foreach ($this->classConstOrEnumCaseNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if (self::isDeprecatedAttributeName($attr->name)) { + return true; + } + } + } + + return false; + } + + /** + * Checks statically, without any autoloading, if the given attribute name points to the + * global `\Deprecated` attribute class + */ + private static function isDeprecatedAttributeName(Node\Name $attributeName): bool + { + $resolvedName = $attributeName->getAttribute('resolvedName'); + if ($resolvedName instanceof Node\Name) { + $attributeName = $resolvedName; + } + + return strcasecmp(ltrim($attributeName->toString(), '\\'), 'Deprecated') === 0; + } + /** * @inheritDoc */ diff --git a/src/ReflectionFunction.php b/src/ReflectionFunction.php index dbb431d..a66e7e8 100644 --- a/src/ReflectionFunction.php +++ b/src/ReflectionFunction.php @@ -113,6 +113,26 @@ public function invokeArgs(array $args): mixed return parent::invokeArgs($args); } + /** + * Checks if the function is anonymous + * + * This reflection is always built from a named `function` node, so it can never be anonymous. + */ + public function isAnonymous(): bool + { + return false; + } + + /** + * Checks if the function is static + * + * Named functions are never static, this matches the behaviour of the native reflection. + */ + public function isStatic(): bool + { + return false; + } + /** * Checks if function is disabled * diff --git a/src/ReflectionMethod.php b/src/ReflectionMethod.php index 90381fb..cc53d25 100644 --- a/src/ReflectionMethod.php +++ b/src/ReflectionMethod.php @@ -409,6 +409,29 @@ public function setAccessible(bool $accessible): void { } + /** + * Creates a reflection instance from the `Class::method` notation + * + * Unlike the native implementation the class is resolved with the {@see ReflectionEngine}, + * so it is never loaded into memory. + * + * @throws ReflectionException if the given name is not a valid method name + */ + public static function createFromMethodName(string $method): static + { + $separatorPosition = strpos($method, '::'); + if ($separatorPosition === false) { + throw new ReflectionException( + 'ReflectionMethod::createFromMethodName(): Argument #1 ($method) must be a valid method name' + ); + } + + $className = substr($method, 0, $separatorPosition); + $methodName = substr($method, $separatorPosition + 2); + + return new static($className, $methodName); + } + /** * Parses methods from the concrete class node * diff --git a/src/Traits/ReflectionFunctionLikeTrait.php b/src/Traits/ReflectionFunctionLikeTrait.php index fd608d1..4666433 100644 --- a/src/Traits/ReflectionFunctionLikeTrait.php +++ b/src/Traits/ReflectionFunctionLikeTrait.php @@ -21,6 +21,7 @@ use PhpParser\Node\Expr\Closure; use PhpParser\Node\FunctionLike; use PhpParser\Node\Identifier; +use PhpParser\Node\Name; use PhpParser\Node\NullableType; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Function_; @@ -65,6 +66,18 @@ protected function getParentClassNameForTypes(): ?string */ protected array $parameters; + /** + * {@inheritDoc} + * + * Statically analysed code never holds a bound closure, so there is no called class to report. + * + * @return \ReflectionClass|null + */ + public function getClosureCalledClass(): ?\ReflectionClass + { + return null; + } + /** * {@inheritDoc} * @@ -87,6 +100,30 @@ public function getClosureThis(): ?object return parent::getClosureThis(); } + /** + * {@inheritDoc} + * + * Values of the used variables are only known at runtime, therefore every captured variable + * is reported with a `null` value. Non-closures report an empty list, just like the engine does. + * + * @return array + */ + public function getClosureUsedVariables(): array + { + if (!$this->functionLikeNode instanceof Closure) { + return []; + } + + $usedVariables = []; + foreach ($this->functionLikeNode->uses as $closureUse) { + if (is_string($closureUse->var->name)) { + $usedVariables[$closureUse->var->name] = null; + } + } + + return $usedVariables; + } + public function getDocComment(): string|false { $docComment = $this->functionLikeNode->getDocComment(); @@ -255,6 +292,19 @@ public function getStaticVariables(): array return $variablesCollector->getStaticVariables(); } + /** + * Returns the tentative return type of a function + * + * Tentative return types exist only for internal functions, user-land code always declares + * its return type explicitly, so there is nothing tentative to report. + * + * @link http://php.net/manual/en/reflectionfunctionabstract.gettentativereturntype.php + */ + public function getTentativeReturnType(): ?\ReflectionType + { + return null; + } + /** * Checks if the function has a specified return type * @@ -268,6 +318,16 @@ public function hasReturnType(): bool return isset($returnType); } + /** + * Checks if the function has a tentative return type + * + * @link http://php.net/manual/en/reflectionfunctionabstract.hastentativereturntype.php + */ + public function hasTentativeReturnType(): bool + { + return false; + } + /** * {@inheritDoc} */ @@ -289,10 +349,32 @@ public function isClosure(): bool */ public function isDeprecated(): bool { - // user-land method/function/closure can not be deprecated + // Since PHP 8.4 user-land functions and methods can be marked with the #[\Deprecated] attribute + foreach ($this->functionLikeNode->getAttrGroups() as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if (self::isDeprecatedAttributeName($attr->name)) { + return true; + } + } + } + return false; } + /** + * Checks statically, without any autoloading, if the given attribute name points to the + * global `\Deprecated` attribute class + */ + private static function isDeprecatedAttributeName(Name $attributeName): bool + { + $resolvedName = $attributeName->getAttribute('resolvedName'); + if ($resolvedName instanceof Name) { + $attributeName = $resolvedName; + } + + return strcasecmp(ltrim($attributeName->toString(), '\\'), 'Deprecated') === 0; + } + /** * {@inheritDoc} */ diff --git a/tests/DeprecatedAndFunctionLikeGapsTest.php b/tests/DeprecatedAndFunctionLikeGapsTest.php new file mode 100644 index 0000000..beb0f56 --- /dev/null +++ b/tests/DeprecatedAndFunctionLikeGapsTest.php @@ -0,0 +1,272 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use PHPUnit\Framework\TestCase; + +/** + * Verifies the function-like reflection methods that used to fall back to the uninitialized + * internal reflection, together with the PHP 8.4 `#[\Deprecated]` attribute support. + * + * Test methods that must not load the stub file are declared before the ones comparing the + * results with the native reflection, because the latter have to include the stub file. + */ +class DeprecatedAndFunctionLikeGapsTest extends TestCase +{ + public const STUB_FILE = '/Stub/FileWithDeprecatedFeatures84.php'; + + public const STUB_NAMESPACE = 'Go\ParserReflection\Stub'; + + public const STUB_CLASS = 'Go\ParserReflection\Stub\ClassWithPhp84DeprecatedFeatures'; + + /** + * Names of the deprecated methods of the stub class + */ + private const DEPRECATED_METHODS = ['deprecatedMethod', 'deprecatedStaticMethod', 'importedDeprecatedMethod']; + + /** + * Names of the deprecated constants of the stub class + */ + private const DEPRECATED_CONSTANTS = [ + 'DEPRECATED_CONSTANT', + 'DEPRECATED_CONSTANT_WITH_ARGUMENTS', + 'IMPORTED_DEPRECATED_CONSTANT', + ]; + + /** + * Names of the deprecated functions of the stub file, without the namespace prefix + */ + private const DEPRECATED_FUNCTIONS = [ + 'php84DeprecatedFunction', + 'php84DeprecatedFunctionWithArguments', + 'php84ImportedDeprecatedFunction', + ]; + + private string $stubFileName; + + protected function setUp(): void + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . self::STUB_FILE); + $this->assertIsString($resolvedFileName, 'Stub file with deprecated features should be available'); + + $this->stubFileName = $resolvedFileName; + } + + protected function tearDown(): void + { + // Restores the default locator for the following tests, because some of them replace it + ReflectionEngine::init(new ComposerLocator()); + } + + public function testMethodDeprecationIsResolvedWithoutLoadingTheClass(): void + { + $parsedClass = $this->getNotLoadedStubClass(); + + foreach (self::DEPRECATED_METHODS as $methodName) { + $this->assertTrue( + $parsedClass->getMethod($methodName)->isDeprecated(), + "Method {$methodName}() should be reported as deprecated" + ); + } + $this->assertFalse($parsedClass->getMethod('actualMethod')->isDeprecated()); + $this->assertStubClassIsNotLoaded(); + } + + public function testConstantDeprecationIsResolvedWithoutLoadingTheClass(): void + { + $parsedClass = $this->getNotLoadedStubClass(); + + foreach (self::DEPRECATED_CONSTANTS as $constantName) { + $this->assertTrue( + $parsedClass->getReflectionConstant($constantName)->isDeprecated(), + "Constant {$constantName} should be reported as deprecated" + ); + } + $this->assertFalse($parsedClass->getReflectionConstant('ACTUAL_CONSTANT')->isDeprecated()); + $this->assertStubClassIsNotLoaded(); + } + + public function testFunctionLikeGapsAreResolvedWithoutLoadingTheClass(): void + { + $parsedClass = $this->getNotLoadedStubClass(); + $parsedMethod = $parsedClass->getMethod('actualMethod'); + + $this->assertFalse($parsedMethod->hasTentativeReturnType()); + $this->assertNull($parsedMethod->getTentativeReturnType()); + $this->assertSame([], $parsedMethod->getClosureUsedVariables()); + $this->assertNull($parsedMethod->getClosureCalledClass()); + $this->assertStubClassIsNotLoaded(); + } + + public function testCreateFromMethodNameDoesNotLoadTheClass(): void + { + ReflectionEngine::init($this->createStubLocator()); + + $parsedMethod = ReflectionMethod::createFromMethodName(self::STUB_CLASS . '::deprecatedStaticMethod'); + + $this->assertInstanceOf(ReflectionMethod::class, $parsedMethod); + $this->assertSame('deprecatedStaticMethod', $parsedMethod->getName()); + $this->assertSame(self::STUB_CLASS, $parsedMethod->getDeclaringClass()->getName()); + $this->assertTrue($parsedMethod->isStatic()); + $this->assertTrue($parsedMethod->isDeprecated()); + $this->assertStubClassIsNotLoaded(); + } + + public function testCreateFromMethodNameRejectsInvalidMethodName(): void + { + $this->expectException(\ReflectionException::class); + + ReflectionMethod::createFromMethodName('thisIsNotAMethodName'); + } + + public function testFunctionDeprecationIsResolvedWithoutLoadingTheFile(): void + { + $parsedNamespace = $this->getStubFileNamespace(); + + foreach (self::DEPRECATED_FUNCTIONS as $functionName) { + $this->assertFalse( + function_exists(self::STUB_NAMESPACE . '\\' . $functionName), + "Function {$functionName}() should not be declared yet" + ); + $this->assertTrue( + $parsedNamespace->getFunction($functionName)->isDeprecated(), + "Function {$functionName}() should be reported as deprecated" + ); + } + $this->assertFalse($parsedNamespace->getFunction('php84PlainFunction')->isDeprecated()); + } + + public function testMethodParityWithNativeReflection(): void + { + $parsedClass = $this->includeStubFileAndGetParsedClass(); + $nativeClass = new \ReflectionClass(self::STUB_CLASS); + + foreach ($nativeClass->getMethods() as $nativeMethod) { + $methodName = $nativeMethod->getName(); + $parsedMethod = $parsedClass->getMethod($methodName); + $message = "Method {$methodName}() should be reflected as the native one"; + + $this->assertSame($nativeMethod->isDeprecated(), $parsedMethod->isDeprecated(), $message); + $this->assertSame($nativeMethod->hasTentativeReturnType(), $parsedMethod->hasTentativeReturnType(), $message); + $this->assertSame($nativeMethod->getTentativeReturnType(), $parsedMethod->getTentativeReturnType(), $message); + $this->assertSame($nativeMethod->getClosureUsedVariables(), $parsedMethod->getClosureUsedVariables(), $message); + $this->assertSame($nativeMethod->getClosureCalledClass(), $parsedMethod->getClosureCalledClass(), $message); + } + } + + public function testConstantParityWithNativeReflection(): void + { + $parsedClass = $this->includeStubFileAndGetParsedClass(); + $nativeClass = new \ReflectionClass(self::STUB_CLASS); + + foreach ($nativeClass->getReflectionConstants() as $nativeConstant) { + $constantName = $nativeConstant->getName(); + $this->assertSame( + $nativeConstant->isDeprecated(), + $parsedClass->getReflectionConstant($constantName)->isDeprecated(), + "Constant {$constantName} should be reflected as the native one" + ); + } + } + + public function testFunctionParityWithNativeReflection(): void + { + $parsedNamespace = $this->includeStubFileAndGetParsedNamespace(); + + $functionNames = array_merge(self::DEPRECATED_FUNCTIONS, ['php84PlainFunction']); + foreach ($functionNames as $functionName) { + $parsedFunction = $parsedNamespace->getFunction($functionName); + $nativeFunction = new \ReflectionFunction(self::STUB_NAMESPACE . '\\' . $functionName); + $message = "Function {$functionName}() should be reflected as the native one"; + + $this->assertSame($nativeFunction->isDeprecated(), $parsedFunction->isDeprecated(), $message); + $this->assertSame($nativeFunction->isAnonymous(), $parsedFunction->isAnonymous(), $message); + $this->assertSame($nativeFunction->isStatic(), $parsedFunction->isStatic(), $message); + $this->assertSame($nativeFunction->hasTentativeReturnType(), $parsedFunction->hasTentativeReturnType(), $message); + $this->assertSame($nativeFunction->getTentativeReturnType(), $parsedFunction->getTentativeReturnType(), $message); + $this->assertSame($nativeFunction->getClosureUsedVariables(), $parsedFunction->getClosureUsedVariables(), $message); + $this->assertSame($nativeFunction->getClosureCalledClass(), $parsedFunction->getClosureCalledClass(), $message); + } + } + + public function testCreateFromMethodNameParityWithNativeReflection(): void + { + $this->includeStubFile(); + + $methodReference = self::STUB_CLASS . '::deprecatedStaticMethod'; + $parsedMethod = ReflectionMethod::createFromMethodName($methodReference); + $nativeMethod = \ReflectionMethod::createFromMethodName($methodReference); + + $this->assertInstanceOf(ReflectionMethod::class, $parsedMethod); + $this->assertSame($nativeMethod->getName(), $parsedMethod->getName()); + $this->assertSame($nativeMethod->getDeclaringClass()->getName(), $parsedMethod->getDeclaringClass()->getName()); + $this->assertSame($nativeMethod->isStatic(), $parsedMethod->isStatic()); + $this->assertSame($nativeMethod->isPublic(), $parsedMethod->isPublic()); + $this->assertSame($nativeMethod->isDeprecated(), $parsedMethod->isDeprecated()); + } + + /** + * Returns a locator that resolves the stub class without asking composer for it + */ + private function createStubLocator(): CallableLocator + { + $stubFileName = $this->stubFileName; + + return new CallableLocator( + static fn(string $className): false|string + => $className === self::STUB_CLASS ? $stubFileName : false + ); + } + + /** + * Returns the parsed stub class, resolved by name only, without loading it into memory + */ + private function getNotLoadedStubClass(): ReflectionClass + { + ReflectionEngine::init($this->createStubLocator()); + + return new ReflectionClass(self::STUB_CLASS); + } + + private function getStubFileNamespace(): ReflectionFileNamespace + { + $reflectionFile = new ReflectionFile($this->stubFileName); + + return $reflectionFile->getFileNamespace(self::STUB_NAMESPACE); + } + + private function includeStubFile(): void + { + include_once $this->stubFileName; + } + + private function includeStubFileAndGetParsedClass(): ReflectionClass + { + $this->includeStubFile(); + + return $this->getStubFileNamespace()->getClass(self::STUB_CLASS); + } + + private function includeStubFileAndGetParsedNamespace(): ReflectionFileNamespace + { + $this->includeStubFile(); + + return $this->getStubFileNamespace(); + } + + private function assertStubClassIsNotLoaded(): void + { + $this->assertFalse(class_exists(self::STUB_CLASS, false), 'Stub class should not be loaded'); + } +} diff --git a/tests/Stub/FileWithDeprecatedFeatures84.php b/tests/Stub/FileWithDeprecatedFeatures84.php new file mode 100644 index 0000000..6e06ba4 --- /dev/null +++ b/tests/Stub/FileWithDeprecatedFeatures84.php @@ -0,0 +1,70 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +use Deprecated as ImportedDeprecated; + +/** + * @see https://wiki.php.net/rfc/deprecated_attribute + */ + +#[\Deprecated] +function php84DeprecatedFunction(): void +{ +} + +#[\Deprecated(message: 'use php84PlainFunction() instead', since: '4.0')] +function php84DeprecatedFunctionWithArguments(): void +{ +} + +#[ImportedDeprecated] +function php84ImportedDeprecatedFunction(): void +{ +} + +function php84PlainFunction(): void +{ +} + +class ClassWithPhp84DeprecatedFeatures +{ + #[\Deprecated] + public const DEPRECATED_CONSTANT = 'deprecated'; + + #[\Deprecated(message: 'use ACTUAL_CONSTANT instead', since: '4.0')] + protected const DEPRECATED_CONSTANT_WITH_ARGUMENTS = 'deprecated'; + + #[ImportedDeprecated] + public const IMPORTED_DEPRECATED_CONSTANT = 'deprecated'; + + public const ACTUAL_CONSTANT = 'actual'; + + #[\Deprecated] + public function deprecatedMethod(): void + { + } + + #[\Deprecated(message: 'use actualMethod() instead', since: '4.0')] + public static function deprecatedStaticMethod(): void + { + } + + #[ImportedDeprecated] + public function importedDeprecatedMethod(): void + { + } + + public function actualMethod(): void + { + } +} From e8eda33ab63e26e22984a13ab54f620dd6c8eed5 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:36:53 +0000 Subject: [PATCH 06/13] fix: stabilize getHooks() array key type for static analysis PropertyHookType::$value is not recognized as string by PHPStan's stubs, widening the inferred key type to int|string; cast the key explicitly to match the declared array return type. --- src/ReflectionProperty.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ReflectionProperty.php b/src/ReflectionProperty.php index 2dbe37a..a1def9e 100644 --- a/src/ReflectionProperty.php +++ b/src/ReflectionProperty.php @@ -384,7 +384,7 @@ public function getHooks(): array foreach (PropertyHookType::cases() as $type) { $hook = $this->getHook($type); if ($hook !== null) { - $hooks[$type->value] = $hook; + $hooks[(string) $type->value] = $hook; } } From 9df6ccbd16cce13c827e5e6519a36946f8b7690f Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:41:25 +0000 Subject: [PATCH 07/13] feat: resolve constant-expression closures and stop implicit autoloading in resolver NodeExpressionResolver now handles PHP 8.5 closures and arrow functions in constant expressions by name-resolving, pretty-printing and evaluating the node as a static unbound closure, and first-class callables of undefined functions fail with a descriptive message instead of a missing-handler error. Object instantiation for new-in-initializers no longer triggers the autoloader: the class source is located explicitly through the registered locator (same for static-call targets), keeping reflection side-effect free. Fixes #203 Fixes #211 --- src/Resolver/NodeExpressionResolver.php | 153 +++++++++++- tests/ConstantExpressionClosuresTest.php | 266 +++++++++++++++++++++ tests/Stub/FileWithConstExprClosures85.php | 68 ++++++ tests/Stub/FileWithNewInInitializers81.php | 35 +++ 4 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 tests/ConstantExpressionClosuresTest.php create mode 100644 tests/Stub/FileWithConstExprClosures85.php create mode 100644 tests/Stub/FileWithNewInInitializers81.php diff --git a/src/Resolver/NodeExpressionResolver.php b/src/Resolver/NodeExpressionResolver.php index 3021fa7..73dd518 100644 --- a/src/Resolver/NodeExpressionResolver.php +++ b/src/Resolver/NodeExpressionResolver.php @@ -13,6 +13,7 @@ namespace Go\ParserReflection\Resolver; use Go\ParserReflection\ReflectionClass; +use Go\ParserReflection\ReflectionEngine; use Go\ParserReflection\ReflectionException; use Go\ParserReflection\ReflectionFileNamespace; use PhpParser\Node; @@ -24,6 +25,9 @@ use PhpParser\Node\Scalar\MagicConst\Line; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Expression; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\CloningVisitor; +use PhpParser\NodeVisitorAbstract; use PhpParser\PrettyPrinter\Standard; use Closure; use ReflectionFunction; @@ -233,6 +237,12 @@ protected function resolveExprFuncCall(Expr\FuncCall $node): mixed } // Set isConstExpr so callers can reconstruct the expression as code even if resolution fails $this->isConstExpr = true; + if (!function_exists($functionName)) { + throw new ReflectionException( + "First-class callable syntax refers to the function '{$functionName}(...)' that is not defined, " . + "therefore it can not be resolved to a Closure." + ); + } $reflectedFunction = new ReflectionFunction($functionName); if (!$reflectedFunction->isInternal()) { throw new ReflectionException( @@ -315,12 +325,16 @@ protected function resolveExprStaticCall(Expr\StaticCall $node): Closure $methodName = $resolvedName; } + // Set isConstExpr so callers can reconstruct the expression as code even if resolution fails + $this->isConstExpr = true; + + // is_callable() and Closure::fromCallable() would silently autoload the class, so it is loaded explicitly + $this->loadClassDefinition($className); + $callable = $className . '::' . $methodName; if (!is_callable($callable)) { throw new ReflectionException("'{$callable}' is not callable and cannot be used as a first-class callable."); } - // Set isConstExpr so callers can reconstruct the expression as code - $this->isConstExpr = true; return Closure::fromCallable($callable); } @@ -391,13 +405,40 @@ protected function resolveExprNew(Expr\New_ $node): object } // Use ReflectionClass to safely instantiate the class - if (!class_exists($className)) { + $this->loadClassDefinition($className); + if (!class_exists($className, false)) { throw new ReflectionException("Class '{$className}' does not exist and cannot be instantiated."); } $reflectionClass = new \ReflectionClass($className); return $reflectionClass->newInstance(...$resolvedArgs); } + /** + * Resolves a closure used as a constant expression, e.g. `const CALLBACK = static function (): int {...};` + * + * @throws ReflectionException If the closure can not be evaluated + */ + protected function resolveExprClosure(Expr\Closure $node): Closure + { + if ($node->uses !== []) { + throw new ReflectionException( + "Closure with captured variables can not be resolved, because there is no outer scope to capture." + ); + } + + return $this->evaluateClosureNode($node); + } + + /** + * Resolves an arrow function used as a constant expression, e.g. `const CALLBACK = static fn (): int => 1;` + * + * @throws ReflectionException If the arrow function can not be evaluated + */ + protected function resolveExprArrowFunction(Expr\ArrowFunction $node): Closure + { + return $this->evaluateClosureNode($node); + } + protected function resolveScalarFloat(Float_ $node): float { return $node->value; @@ -887,6 +928,112 @@ private function resolveInt(mixed $value): int return 0; } + /** + * Builds a real closure that is equivalent to the given closure-like node + * + * Closures in constant expressions are always static and never capture anything from the outer scope, + * therefore an equivalent closure can be built from the source code of the node itself. + * + * @throws ReflectionException If the given node can not be evaluated + */ + private function evaluateClosureNode(Expr\Closure|Expr\ArrowFunction $node): Closure + { + // Closure has a source code representation, so callers are able to reconstruct it even on failure + $this->isConstExpr = true; + + $closureNode = $this->resolveNodeNames($node); + if ($closureNode instanceof Expr\Closure || $closureNode instanceof Expr\ArrowFunction) { + // Constant expression closures are always static, this also prevents binding of the resolver itself + $closureNode->static = true; + } + + $printer = new Standard(['shortArraySyntax' => true]); + $closureSource = $printer->prettyPrintExpr($closureNode); + + try { + $closure = eval('return ' . $closureSource . ';'); + } catch (\Throwable $e) { + throw new ReflectionException("Could not evaluate the closure expression: {$e->getMessage()}", 0, $e); + } + if (!$closure instanceof Closure) { + throw new ReflectionException("Evaluation of the closure expression did not produce a closure."); + } + + // Closure is compiled in the scope of this class, thus the scope is dropped to mimic the original one + $unscopedClosure = Closure::bind($closure, null, null); + + return $unscopedClosure ?? $closure; + } + + /** + * Returns a deep copy of the given node with all resolved names replaced by fully-qualified ones + * + * Unqualified function and constant names are intentionally kept as is, because PHP resolves them at + * runtime with a fallback to the global namespace. + */ + private function resolveNodeNames(Node $node): Node + { + $cloningTraverser = new NodeTraverser(new CloningVisitor()); + [$clonedNode] = $cloningTraverser->traverse([$node]); + + $nameTraverser = new NodeTraverser(new class extends NodeVisitorAbstract { + public function enterNode(Node $node): ?Node + { + if ($node instanceof Name && $node->hasAttribute('resolvedName')) { + $resolvedName = $node->getAttribute('resolvedName'); + if ($resolvedName instanceof Name) { + return new Name\FullyQualified($resolvedName->toString(), $node->getAttributes()); + } + } + + return null; + } + }); + [$resolvedNode] = $nameTraverser->traverse([$clonedNode]); + + return $resolvedNode; + } + + /** + * Makes the definition of the given class available in the current runtime + * + * Reflection is performed on the AST only, but several expressions, such as object instantiation or + * first-class callables, can not be represented without a real class definition. For these cases the file + * with the class is resolved via the registered locator and included explicitly, thus an implicit + * autoloading is never triggered by the resolver itself. + * + * @throws ReflectionException If the class is not loaded and its file can not be located + */ + private function loadClassDefinition(string $className): void + { + if ($this->isClassDefinitionLoaded($className)) { + return; + } + + try { + $classFileName = ReflectionEngine::locateClassFile($className); + } catch (\Throwable $e) { + throw new ReflectionException( + "Class '{$className}' is not loaded and its file can not be found by the registered locator.", + 0, + $e + ); + } + + include_once $classFileName; + + if (!$this->isClassDefinitionLoaded($className)) { + throw new ReflectionException("Class '{$className}' was not found in the file '{$classFileName}'."); + } + } + + private function isClassDefinitionLoaded(string $className): bool + { + return class_exists($className, false) + || interface_exists($className, false) + || trait_exists($className, false); + } + private function getDispatchMethodFor(Node $node): string { $nodeType = $node->getType(); diff --git a/tests/ConstantExpressionClosuresTest.php b/tests/ConstantExpressionClosuresTest.php new file mode 100644 index 0000000..902403d --- /dev/null +++ b/tests/ConstantExpressionClosuresTest.php @@ -0,0 +1,266 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use Go\ParserReflection\Resolver\NodeExpressionResolver; +use PhpParser\Parser; +use PhpParser\ParserFactory; +use PHPUnit\Framework\TestCase; + +/** + * Verifies resolving of closures, arrow functions and first-class callables used in constant expressions. + * + * Stub file with such constant expressions uses PHP 8.5 syntax, therefore it is only parsed, never included. + */ +class ConstantExpressionClosuresTest extends TestCase +{ + public const CLOSURES_STUB_FILE = '/Stub/FileWithConstExprClosures85.php'; + + public const INITIALIZERS_STUB_FILE = '/Stub/FileWithNewInInitializers81.php'; + + public const STUB_NAMESPACE = 'Go\ParserReflection\Stub'; + + public const CLOSURES_STUB_CLASS = 'Go\ParserReflection\Stub\ClassWithConstExprClosures'; + + public const ATTRIBUTE_STUB_CLASS = 'Go\ParserReflection\Stub\ConstExprClosureAttribute'; + + public const INITIALIZERS_STUB_CLASS = 'Go\ParserReflection\Stub\ClassWithNewInInitializers'; + + public const INITIALIZERS_DEPENDENCY_CLASS = 'Go\ParserReflection\Stub\NewInInitializerDependency'; + + private string $closuresFileName; + + private string $initializersFileName; + + private Parser $parser; + + protected function setUp(): void + { + $this->closuresFileName = $this->resolveStubFile(self::CLOSURES_STUB_FILE); + $this->initializersFileName = $this->resolveStubFile(self::INITIALIZERS_STUB_FILE); + $this->parser = (new ParserFactory())->createForNewestSupportedVersion(); + + ReflectionEngine::init($this->createStubLocator([ + self::CLOSURES_STUB_CLASS => $this->closuresFileName, + self::ATTRIBUTE_STUB_CLASS => $this->closuresFileName, + self::INITIALIZERS_STUB_CLASS => $this->initializersFileName, + self::INITIALIZERS_DEPENDENCY_CLASS => $this->initializersFileName, + ])); + } + + protected function tearDown(): void + { + // Restores the default locator for the following tests, because this one replaces it + ReflectionEngine::init(new ComposerLocator()); + } + + public function testGlobalConstantsWithClosuresAreResolved(): void + { + $constants = $this->getStubFileNamespace()->getConstants(); + + $this->assertArrayHasKey('CLOSURE_CONST', $constants); + $this->assertInstanceOf(\Closure::class, $constants['CLOSURE_CONST']); + $this->assertSame(10, ($constants['CLOSURE_CONST'])(5)); + + $this->assertArrayHasKey('ARROW_FUNCTION_CONST', $constants); + $this->assertInstanceOf(\Closure::class, $constants['ARROW_FUNCTION_CONST']); + $this->assertSame(15, ($constants['ARROW_FUNCTION_CONST'])(5)); + + // Sibling constants should not be poisoned by the presence of closures in the same file + $this->assertSame('plain', $constants['PLAIN_CONST']); + } + + public function testGlobalFirstClassCallableConstantsAreResolved(): void + { + $constants = $this->getStubFileNamespace()->getConstants(); + + $this->assertInstanceOf(\Closure::class, $constants['FCC_CONST']); + $this->assertSame(6, ($constants['FCC_CONST'])('foobar')); + + $this->assertInstanceOf(\Closure::class, $constants['UNQUALIFIED_FCC_CONST']); + $this->assertSame('ABC', ($constants['UNQUALIFIED_FCC_CONST'])('abc')); + } + + public function testClassConstantsWithClosuresAreResolved(): void + { + $parsedClass = $this->getStubFileNamespace()->getClass(self::CLOSURES_STUB_CLASS); + $constants = $parsedClass->getConstants(); + + $this->assertInstanceOf(\Closure::class, $constants['CALLBACK']); + $this->assertSame(2, ($constants['CALLBACK'])(1)); + + $this->assertInstanceOf(\Closure::class, $constants['DOUBLER']); + $this->assertSame(8, ($constants['DOUBLER'])(4)); + + $this->assertInstanceOf(\Closure::class, $constants['UPPERCASE']); + $this->assertSame('ABC', ($constants['UPPERCASE'])('abc')); + + $this->assertSame('simple', $constants['PLAIN']); + $this->assertFalse(class_exists(self::CLOSURES_STUB_CLASS, false), 'Stub class should not be loaded'); + } + + public function testParameterDefaultValueWithClosureIsResolved(): void + { + $parsedClass = $this->getStubFileNamespace()->getClass(self::CLOSURES_STUB_CLASS); + $parsedMethod = $parsedClass->getMethod('methodWithClosureDefault'); + $parameter = $parsedMethod->getParameters()[0]; + + $this->assertTrue($parameter->isDefaultValueAvailable()); + $defaultValue = $parameter->getDefaultValue(); + $this->assertInstanceOf(\Closure::class, $defaultValue); + $this->assertSame(1, $defaultValue()); + + $expression = $parameter->getDefaultValueExpression(); + $this->assertNotNull($expression); + $this->assertStringContainsString('static function', $expression); + } + + public function testParameterDefaultValueWithArrowFunctionIsResolved(): void + { + $parsedClass = $this->getStubFileNamespace()->getClass(self::CLOSURES_STUB_CLASS); + $parsedMethod = $parsedClass->getMethod('methodWithArrowFunctionDefault'); + $parameter = $parsedMethod->getParameters()[0]; + + $defaultValue = $parameter->getDefaultValue(); + $this->assertInstanceOf(\Closure::class, $defaultValue); + $this->assertSame(12, $defaultValue(4)); + $this->assertStringContainsString('static fn', (string) $parameter->getDefaultValueExpression()); + } + + public function testPropertyDefaultValueWithArrowFunctionIsResolved(): void + { + $parsedClass = $this->getStubFileNamespace()->getClass(self::CLOSURES_STUB_CLASS); + $parsedProperty = $parsedClass->getProperty('handler'); + + $defaultValue = $parsedProperty->getDefaultValue(); + $this->assertInstanceOf(\Closure::class, $defaultValue); + $this->assertSame('trimmed', $defaultValue(' trimmed ')); + } + + public function testAttributeArgumentWithArrowFunctionIsResolved(): void + { + $parsedClass = $this->getStubFileNamespace()->getClass(self::CLOSURES_STUB_CLASS); + $attributes = $parsedClass->getAttributes(); + + $this->assertCount(1, $attributes); + $this->assertSame(self::ATTRIBUTE_STUB_CLASS, $attributes[0]->getName()); + + $arguments = $attributes[0]->getArguments(); + $this->assertInstanceOf(\Closure::class, $arguments[0]); + $this->assertSame(25, ($arguments[0])(5)); + } + + public function testResolvedClosureIsStaticAndHasNoScope(): void + { + $constants = $this->getStubFileNamespace()->getConstants(); + + $closureReflection = new \ReflectionFunction($constants['CLOSURE_CONST']); + $this->assertNull($closureReflection->getClosureThis()); + $this->assertNull($closureReflection->getClosureScopeClass()); + } + + public function testClosureWithCapturedVariablesIsNotResolved(): void + { + $this->expectException(ReflectionException::class); + $this->expectExceptionMessageMatches('/captured variables/'); + + $nodes = $this->parser->parse('process($nodes[0]); + } + + public function testFirstClassCallableForUnknownFunctionThrowsDescriptiveException(): void + { + $nodes = $this->parser->parse('process($nodes[0]); + $this->fail('Expected ReflectionException was not thrown'); + } catch (ReflectionException $exception) { + $this->assertStringNotContainsString('Could not find handler', $exception->getMessage()); + $this->assertMatchesRegularExpression('/is not defined/', $exception->getMessage()); + } + } + + public function testNewInInitializerDoesNotTriggerAutoloading(): void + { + $this->assertFalse( + class_exists(self::INITIALIZERS_DEPENDENCY_CLASS, false), + 'Dependency class should not be loaded before the test' + ); + + $autoloadedClasses = []; + $autoloadSpy = static function (string $className) use (&$autoloadedClasses): void { + $autoloadedClasses[] = $className; + }; + spl_autoload_register($autoloadSpy); + + try { + $reflectionFile = new ReflectionFile($this->initializersFileName); + $reflectionNamespace = $reflectionFile->getFileNamespace(self::STUB_NAMESPACE); + $parsedMethod = $reflectionNamespace->getClass(self::INITIALIZERS_STUB_CLASS) + ->getMethod('withDependency'); + + $defaultValue = $parsedMethod->getParameters()[0]->getDefaultValue(); + } finally { + spl_autoload_unregister($autoloadSpy); + } + + $this->assertIsObject($defaultValue); + $this->assertSame(self::INITIALIZERS_DEPENDENCY_CLASS, $defaultValue::class); + $this->assertSame('injected', $defaultValue->label); + $this->assertNotContains( + self::INITIALIZERS_DEPENDENCY_CLASS, + $autoloadedClasses, + 'Instantiation of a class in the initializer should not trigger the autoloader' + ); + } + + public function testNewExpressionForUnknownClassThrowsDescriptiveException(): void + { + $this->expectException(ReflectionException::class); + $this->expectExceptionMessageMatches('/can not be found by the registered locator/'); + + $nodes = $this->parser->parse('process($nodes[0]); + } + + private function getStubFileNamespace(): ReflectionFileNamespace + { + return (new ReflectionFile($this->closuresFileName))->getFileNamespace(self::STUB_NAMESPACE); + } + + /** + * @param array $classMap + */ + private function createStubLocator(array $classMap): CallableLocator + { + $composerLocator = new ComposerLocator(); + + return new CallableLocator( + static fn (string $className): false|string + => $classMap[$className] ?? $composerLocator->locateClass($className) + ); + } + + private function resolveStubFile(string $stubFileName): string + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . $stubFileName); + $this->assertIsString($resolvedFileName, "Stub file {$stubFileName} should be available"); + + return $resolvedFileName; + } +} diff --git a/tests/Stub/FileWithConstExprClosures85.php b/tests/Stub/FileWithConstExprClosures85.php new file mode 100644 index 0000000..7ef4243 --- /dev/null +++ b/tests/Stub/FileWithConstExprClosures85.php @@ -0,0 +1,68 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection\Stub; + +/** + * This file uses closures, arrow functions and first-class callables in constant expressions, which is + * allowed only since PHP 8.5, therefore it is intended to be analyzed statically only, never include it. + * + * @see https://wiki.php.net/rfc/closures_in_const_expr + */ + +const CLOSURE_CONST = static function (int $x): int { + return $x * 2; +}; + +const ARROW_FUNCTION_CONST = static fn (int $x): int => $x + 10; + +const FCC_CONST = \strlen(...); + +const UNQUALIFIED_FCC_CONST = strtoupper(...); + +const PLAIN_CONST = 'plain'; + +#[\Attribute] +class ConstExprClosureAttribute +{ + public function __construct(public \Closure $callback) + { + } +} + +#[ConstExprClosureAttribute(static fn (int $value): int => $value * 5)] +class ClassWithConstExprClosures +{ + public const CALLBACK = static fn (int $value): int => $value + 1; + + public const DOUBLER = static function (int $value): int { + return $value * 2; + }; + + public const UPPERCASE = \strtoupper(...); + + public const PLAIN = 'simple'; + + public \Closure $handler = static fn (string $value): string => \trim($value); + + public function methodWithClosureDefault(\Closure $callback = static function (): int { + return 1; + }): int + { + return $callback(); + } + + public function methodWithArrowFunctionDefault( + \Closure $callback = static fn (int $value): int => $value * 3 + ): int { + return $callback(1); + } +} diff --git a/tests/Stub/FileWithNewInInitializers81.php b/tests/Stub/FileWithNewInInitializers81.php new file mode 100644 index 0000000..f6a68f2 --- /dev/null +++ b/tests/Stub/FileWithNewInInitializers81.php @@ -0,0 +1,35 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection\Stub; + +/** + * Stub file with "new in initializers" syntax, available since PHP 8.1. + * + * File name intentionally does not follow the PSR-4 name of the classes below, so these classes can not be + * found by the composer autoloader and have to be resolved via the registered locator instead. + */ + +class NewInInitializerDependency +{ + public function __construct(public readonly string $label = 'default') + { + } +} + +class ClassWithNewInInitializers +{ + public function withDependency( + NewInInitializerDependency $dependency = new NewInInitializerDependency('injected') + ): NewInInitializerDependency { + return $dependency; + } +} From fd9976dd36b09d53828f84878c5336687e47fc90 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:42:50 +0000 Subject: [PATCH 08/13] fix: address static analysis findings in expression resolver Drop a redundant is_callable() guard already covered by function_exists(), narrow the closure node type before pretty-printing, and mark isClassDefinitionLoaded() as impure since its result changes after the located file is included. --- src/Resolver/NodeExpressionResolver.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Resolver/NodeExpressionResolver.php b/src/Resolver/NodeExpressionResolver.php index 73dd518..c27b23a 100644 --- a/src/Resolver/NodeExpressionResolver.php +++ b/src/Resolver/NodeExpressionResolver.php @@ -250,9 +250,6 @@ protected function resolveExprFuncCall(Expr\FuncCall $node): mixed "to a Closure statically, as closures cannot be represented as code in proxies." ); } - if (!is_callable($functionName)) { - throw new ReflectionException("Function '{$functionName}' is not callable."); - } return Closure::fromCallable($functionName); } @@ -942,10 +939,11 @@ private function evaluateClosureNode(Expr\Closure|Expr\ArrowFunction $node): Clo $this->isConstExpr = true; $closureNode = $this->resolveNodeNames($node); - if ($closureNode instanceof Expr\Closure || $closureNode instanceof Expr\ArrowFunction) { - // Constant expression closures are always static, this also prevents binding of the resolver itself - $closureNode->static = true; + if (!$closureNode instanceof Expr\Closure && !$closureNode instanceof Expr\ArrowFunction) { + throw new ReflectionException('Unexpected node type after closure name resolution.'); } + // Constant expression closures are always static, this also prevents binding of the resolver itself + $closureNode->static = true; $printer = new Standard(['shortArraySyntax' => true]); $closureSource = $printer->prettyPrintExpr($closureNode); @@ -1027,6 +1025,9 @@ private function loadClassDefinition(string $className): void } } + /** + * @phpstan-impure The result changes once the located class file has been included + */ private function isClassDefinitionLoaded(string $className): bool { return class_exists($className, false) From b51c3f5c36adc86f5509c262399517ad78203e73 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:43:02 +0000 Subject: [PATCH 09/13] feat: support ReflectionAttribute::IS_INSTANCEOF filtering in getAttributes() The flags argument is now honored: IS_INSTANCEOF matches attributes whose class extends or implements the requested name, resolving ancestry without autoloading (native reflection for already-loaded classes, parser-based reflection through the locator otherwise). Invalid flag values raise the same ValueError as native reflection. Fixes #202 --- src/Traits/AttributeResolverTrait.php | 108 +++++++++- tests/AttributeInstanceOfFilterTest.php | 221 ++++++++++++++++++++ tests/Stub/FileWithAttributeHierarchy80.php | 65 ++++++ 3 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 tests/AttributeInstanceOfFilterTest.php create mode 100644 tests/Stub/FileWithAttributeHierarchy80.php diff --git a/src/Traits/AttributeResolverTrait.php b/src/Traits/AttributeResolverTrait.php index 04477b3..81a7311 100644 --- a/src/Traits/AttributeResolverTrait.php +++ b/src/Traits/AttributeResolverTrait.php @@ -13,6 +13,7 @@ namespace Go\ParserReflection\Traits; use Go\ParserReflection\ReflectionAttribute; +use Go\ParserReflection\ReflectionClass as ParsedReflectionClass; use Go\ParserReflection\Resolver\NodeExpressionResolver; use PhpParser\Node\Name; use PhpParser\Node\Param; @@ -42,9 +43,17 @@ protected function getNodeForAttributes(): ClassLike|ClassMethod|Function_|Param */ public function getAttributes(?string $name = null, int $flags = 0): array { + if ($flags !== 0 && $flags !== \ReflectionAttribute::IS_INSTANCEOF) { + throw new \ValueError( + $this->getAttributeFilterOwnerName() + . '::getAttributes(): Argument #2 ($flags) must be a valid attribute filter flag' + ); + } + $node = $this->getNodeForAttributes(); - $attributes = []; + $filterByInstanceOf = $name !== null && ($flags & \ReflectionAttribute::IS_INSTANCEOF) !== 0; + $attributes = []; $nodeExpressionResolver = new NodeExpressionResolver($this); foreach ($node->attrGroups as $attrGroup) { @@ -67,6 +76,16 @@ public function getAttributes(?string $name = null, int $flags = 0): array continue; } + if ($filterByInstanceOf) { + if (!self::isAttributeInstanceOf($resolvedAttrName, $name)) { + continue; + } + + $attributes[] = new ReflectionAttribute($resolvedAttrName, $this, $arguments, $this->isAttributeRepeated($resolvedAttrName, $node->attrGroups)); + + continue; + } + if ($name !== $resolvedAttrName) { continue; } @@ -100,6 +119,93 @@ private static function resolveAttributeClassName(mixed $nameNode): string return $className; } + /** + * Returns the name of the internal reflection class that declares getAttributes(), used to build + * the same \ValueError message as the engine does for an invalid filter flag. + */ + private function getAttributeFilterOwnerName(): string + { + return match (true) { + $this instanceof \ReflectionFunctionAbstract => 'ReflectionFunctionAbstract', + $this instanceof \ReflectionParameter => 'ReflectionParameter', + $this instanceof \ReflectionProperty => 'ReflectionProperty', + $this instanceof \ReflectionClassConstant => 'ReflectionClassConstant', + default => 'ReflectionClass', + }; + } + + /** + * Checks that an attribute class is the given class, extends it or implements it, following the + * instanceof semantics of \ReflectionAttribute::IS_INSTANCEOF without triggering autoloading. + */ + private static function isAttributeInstanceOf(string $attributeClassName, string $filterClassName): bool + { + $filterClassName = strtolower(ltrim($filterClassName, '\\')); + if ($filterClassName === '') { + return false; + } + + $classNamesToVisit = [$attributeClassName]; + $visitedClassNames = []; + + while ($classNamesToVisit !== []) { + $currentClassName = ltrim(array_shift($classNamesToVisit), '\\'); + $lowerClassName = strtolower($currentClassName); + if ($lowerClassName === '' || isset($visitedClassNames[$lowerClassName])) { + continue; + } + $visitedClassNames[$lowerClassName] = true; + + if ($lowerClassName === $filterClassName) { + return true; + } + + foreach (self::resolveClassAncestorNames($currentClassName) as $ancestorClassName) { + $classNamesToVisit[] = $ancestorClassName; + } + } + + return false; + } + + /** + * Resolves direct and inherited ancestors of a class without loading it: already loaded classes are + * inspected with native reflection, everything else is resolved from the AST via the current locator. + * + * @return list + */ + private static function resolveClassAncestorNames(string $className): array + { + if (class_exists($className, false) || interface_exists($className, false)) { + $reflection = new \ReflectionClass($className); + } else { + try { + $reflection = new ParsedReflectionClass($className); + } catch (\Throwable) { + // Attribute classes are not required to exist until an attribute is instantiated + return []; + } + } + + $ancestorNames = []; + try { + $ancestorNames = array_values($reflection->getInterfaceNames()); + } catch (\Throwable) { + // Unresolvable interfaces simply do not participate in the instanceof check + } + + try { + $parentClass = $reflection->getParentClass(); + if ($parentClass !== false) { + $ancestorNames[] = $parentClass->getName(); + } + } catch (\Throwable) { + // Same for an unresolvable parent class + } + + return $ancestorNames; + } + /** * @param \PhpParser\Node\AttributeGroup[] $attrGroups */ diff --git a/tests/AttributeInstanceOfFilterTest.php b/tests/AttributeInstanceOfFilterTest.php new file mode 100644 index 0000000..7cc116b --- /dev/null +++ b/tests/AttributeInstanceOfFilterTest.php @@ -0,0 +1,221 @@ +setUpAstOnlyLocator(); + + $parsedClass = new ReflectionClass(self::STUB_NAMESPACE . '\HookedClass'); + + $childAttributes = $parsedClass->getAttributes( + self::STUB_NAMESPACE . '\ChildAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + ); + $this->assertCount(1, $childAttributes); + $this->assertSame(self::STUB_NAMESPACE . '\ChildAttribute', $childAttributes[0]->getName()); + $this->assertSame(['class-level'], $childAttributes[0]->getArguments()); + + $baseAttributes = $parsedClass->getAttributes( + self::STUB_NAMESPACE . '\BaseAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + ); + $this->assertCount(1, $baseAttributes); + $this->assertSame(self::STUB_NAMESPACE . '\ChildAttribute', $baseAttributes[0]->getName()); + + $this->assertCount(0, $parsedClass->getAttributes(self::STUB_NAMESPACE . '\BaseAttribute')); + + $parsedProperty = $parsedClass->getProperty('hookedProperty'); + $inheritedAttributes = $parsedProperty->getAttributes( + self::STUB_NAMESPACE . '\BaseAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + ); + $this->assertCount(1, $inheritedAttributes); + $this->assertSame(self::STUB_NAMESPACE . '\GrandChildAttribute', $inheritedAttributes[0]->getName()); + + $parsedMethod = $parsedClass->getMethod('hookedMethod'); + $this->assertCount(1, $parsedMethod->getAttributes( + self::STUB_NAMESPACE . '\MarkerInterface', + InternalReflectionAttribute::IS_INSTANCEOF + )); + $this->assertCount(1, $parsedMethod->getAttributes( + self::STUB_NAMESPACE . '\ExtendedMarkerInterface', + InternalReflectionAttribute::IS_INSTANCEOF + )); + $this->assertCount(0, $parsedMethod->getAttributes( + self::STUB_NAMESPACE . '\BaseAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + )); + + $this->assertStubHierarchyIsNotLoaded(); + } + + public function testInstanceOfFilterIgnoresUnknownAttributeClasses(): void + { + $parsedFile = new ReflectionFile(self::STUB_FILE); + $parsedClass = $parsedFile + ->getFileNamespace(self::STUB_NAMESPACE) + ->getClass(self::STUB_NAMESPACE . '\HookedClass'); + + ReflectionEngine::init(new CallableLocator(fn(string $className): false|string => false)); + + $this->assertCount(0, $parsedClass->getAttributes( + self::STUB_NAMESPACE . '\BaseAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + )); + $this->assertCount(2, $parsedClass->getAttributes()); + + $this->assertStubHierarchyIsNotLoaded(); + } + + public function testInvalidFilterFlagThrowsValueError(): void + { + $this->setUpAstOnlyLocator(); + + $parsedClass = new ReflectionClass(self::STUB_NAMESPACE . '\HookedClass'); + + $this->expectException(\ValueError::class); + $this->expectExceptionMessage( + 'ReflectionClass::getAttributes(): Argument #2 ($flags) must be a valid attribute filter flag' + ); + + $parsedClass->getAttributes(self::STUB_NAMESPACE . '\BaseAttribute', 3); + } + + public function testInvalidFilterFlagThrowsValueErrorForFunctionLike(): void + { + $this->setUpAstOnlyLocator(); + + $parsedMethod = (new ReflectionClass(self::STUB_NAMESPACE . '\HookedClass'))->getMethod('hookedMethod'); + + $this->expectException(\ValueError::class); + $this->expectExceptionMessage( + 'ReflectionFunctionAbstract::getAttributes(): Argument #2 ($flags) must be a valid attribute filter flag' + ); + + $parsedMethod->getAttributes(self::STUB_NAMESPACE . '\BaseAttribute', 3); + } + + /** + * Runs isolated to keep the stub hierarchy unloaded for the AST-only test cases + */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testInstanceOfFilterMatchesNativeReflectionForLoadedClasses(): void + { + require_once self::STUB_FILE; + + $className = self::STUB_NAMESPACE . '\HookedClass'; + $parsedClass = new ReflectionClass($className); + $nativeClass = new \ReflectionClass($className); + $filterNames = [ + self::STUB_NAMESPACE . '\BaseAttribute', + self::STUB_NAMESPACE . '\ChildAttribute', + self::STUB_NAMESPACE . '\GrandChildAttribute', + self::STUB_NAMESPACE . '\MarkerInterface', + self::STUB_NAMESPACE . '\ExtendedMarkerInterface', + self::STUB_NAMESPACE . '\UnrelatedAttribute', + ]; + + foreach ($filterNames as $filterName) { + $this->assertSame( + $this->describeAttributes($nativeClass->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + $this->describeAttributes($parsedClass->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + "Class attributes filtered by $filterName" + ); + + $this->assertSame( + $this->describeAttributes($nativeClass->getMethod('hookedMethod')->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + $this->describeAttributes($parsedClass->getMethod('hookedMethod')->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + "Method attributes filtered by $filterName" + ); + + $this->assertSame( + $this->describeAttributes($nativeClass->getProperty('hookedProperty')->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + $this->describeAttributes($parsedClass->getProperty('hookedProperty')->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + "Property attributes filtered by $filterName" + ); + + $this->assertSame( + $this->describeAttributes($nativeClass->getAttributes($filterName)), + $this->describeAttributes($parsedClass->getAttributes($filterName)), + "Class attributes filtered by exact name $filterName" + ); + } + + $this->assertSame( + $this->describeAttributes($nativeClass->getAttributes(null, InternalReflectionAttribute::IS_INSTANCEOF)), + $this->describeAttributes($parsedClass->getAttributes(null, InternalReflectionAttribute::IS_INSTANCEOF)), + 'Unfiltered attributes are not affected by the flag' + ); + + // Unlike the engine, an unresolvable filter class never triggers autoloading and matches nothing + $this->assertSame([], $parsedClass->getAttributes( + self::STUB_NAMESPACE . '\MissingAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + )); + } + + /** + * @param \ReflectionAttribute[] $attributes + * + * @return array}> + */ + private function describeAttributes(array $attributes): array + { + $description = []; + foreach ($attributes as $attribute) { + $description[] = [$attribute->getName(), $attribute->getArguments()]; + } + + return $description; + } + + private function setUpAstOnlyLocator(): void + { + ReflectionEngine::init(new CallableLocator( + fn(string $className): false|string => str_starts_with($className, self::STUB_NAMESPACE . '\\') + ? self::STUB_FILE + : false + )); + } + + private function assertStubHierarchyIsNotLoaded(): void + { + foreach (['HookedClass', 'BaseAttribute', 'ChildAttribute', 'GrandChildAttribute', 'InterfaceAttribute'] as $shortName) { + $this->assertFalse( + class_exists(self::STUB_NAMESPACE . '\\' . $shortName, false), + "Class $shortName must not be loaded by the AST-based instanceof resolution" + ); + } + + foreach (['MarkerInterface', 'ExtendedMarkerInterface'] as $shortName) { + $this->assertFalse( + interface_exists(self::STUB_NAMESPACE . '\\' . $shortName, false), + "Interface $shortName must not be loaded by the AST-based instanceof resolution" + ); + } + } +} diff --git a/tests/Stub/FileWithAttributeHierarchy80.php b/tests/Stub/FileWithAttributeHierarchy80.php new file mode 100644 index 0000000..b3047c2 --- /dev/null +++ b/tests/Stub/FileWithAttributeHierarchy80.php @@ -0,0 +1,65 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection\Stub\AttributeHierarchy; + +use Attribute; + +interface MarkerInterface +{ +} + +interface ExtendedMarkerInterface extends MarkerInterface +{ +} + +#[Attribute(Attribute::TARGET_ALL)] +class BaseAttribute +{ + public function __construct(public readonly string $title = 'base') + { + } +} + +#[Attribute(Attribute::TARGET_ALL)] +class ChildAttribute extends BaseAttribute +{ +} + +#[Attribute(Attribute::TARGET_ALL)] +class GrandChildAttribute extends ChildAttribute +{ +} + +#[Attribute(Attribute::TARGET_ALL)] +class InterfaceAttribute implements ExtendedMarkerInterface +{ +} + +#[Attribute(Attribute::TARGET_ALL)] +class UnrelatedAttribute +{ +} + +#[ChildAttribute('class-level')] +#[UnrelatedAttribute] +class HookedClass +{ + #[GrandChildAttribute('property-level')] + public int $hookedProperty = 1; + + #[InterfaceAttribute] + #[UnrelatedAttribute] + public function hookedMethod(): void + { + } +} From 28206aee319b012182e89dbcdcd792f07b7a78cb Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:44:05 +0000 Subject: [PATCH 10/13] fix: drop redundant array_values() flagged by static analysis --- src/Traits/AttributeResolverTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Traits/AttributeResolverTrait.php b/src/Traits/AttributeResolverTrait.php index 81a7311..2a5a95f 100644 --- a/src/Traits/AttributeResolverTrait.php +++ b/src/Traits/AttributeResolverTrait.php @@ -189,7 +189,7 @@ private static function resolveClassAncestorNames(string $className): array $ancestorNames = []; try { - $ancestorNames = array_values($reflection->getInterfaceNames()); + $ancestorNames = $reflection->getInterfaceNames(); } catch (\Throwable) { // Unresolvable interfaces simply do not participate in the instanceof check } From 55f0fc875bcc92586d3e647a3eeebfe44b87fd53 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:46:52 +0000 Subject: [PATCH 11/13] feat: add ReflectionConstant for const-declared global constants Mirrors the native PHP 8.4 ReflectionConstant API (the internal class is final, so it cannot be extended) on top of the AST: getName, getShortName, getNamespaceName, getValue, isDeprecated and __toString are resolved statically, and the PHP 8.5 getAttributes() extension works for attributes on constants including #[\Deprecated]. ReflectionFileNamespace exposes the new reflections via getReflectionConstant()/getReflectionConstants() without changing the existing getConstants() API. define()-style constants stay out of scope for static analysis. Fixes #209 --- src/ReflectionConstant.php | 328 +++++++++++++++++++ src/ReflectionFileNamespace.php | 67 ++++ tests/ReflectionConstantTest.php | 288 ++++++++++++++++ tests/Stub/FileWithAttributedConstants85.php | 46 +++ tests/Stub/FileWithGlobalConstants84.php | 22 ++ 5 files changed, 751 insertions(+) create mode 100644 src/ReflectionConstant.php create mode 100644 tests/ReflectionConstantTest.php create mode 100644 tests/Stub/FileWithAttributedConstants85.php create mode 100644 tests/Stub/FileWithGlobalConstants84.php diff --git a/src/ReflectionConstant.php b/src/ReflectionConstant.php new file mode 100644 index 0000000..c4d9109 --- /dev/null +++ b/src/ReflectionConstant.php @@ -0,0 +1,328 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Deprecated; +use Go\ParserReflection\Resolver\NodeExpressionResolver; +use PhpParser\Node\Attribute; +use PhpParser\Node\AttributeGroup; +use PhpParser\Node\Const_ as ConstItemNode; +use PhpParser\Node\Name; +use PhpParser\Node\Stmt\Const_ as ConstStatementNode; +use Reflector; +use Stringable; + +/** + * AST-based reflection for global and namespaced constants declared with the "const" keyword + * + * Native \ReflectionConstant is declared as final, therefore this reflection can not extend it and + * mirrors its public API instead. Constants defined via "define(...)" are not supported, because they + * are created at runtime and can not be resolved statically. + * + * @see \Go\ParserReflection\ReflectionConstantTest + */ +final class ReflectionConstant implements NodeAwareInterface, Reflector, Stringable +{ + /** + * Fully-qualified name of the constant, provided to mirror the native reflection property + */ + public string $name; + + /** + * Node with the concrete "NAME = value" declaration + */ + private ConstItemNode $constNode; + + /** + * Node with the whole "const ...;" statement, it holds attribute groups for the constant + */ + private ConstStatementNode $declarationNode; + + /** + * Namespace of the file where this constant is declared, used as a context for expressions + */ + private ?ReflectionFileNamespace $fileNamespace; + + /** + * Initializes a reflection for the global or namespaced constant + * + * @param string $constantName Fully-qualified name of the constant + * @param ConstItemNode|null $constNode Optional AST-node with the concrete constant declaration + * @param ConstStatementNode|null $declarationNode Optional AST-node with the whole "const" statement + * @param ReflectionFileNamespace|null $fileNamespace Optional namespace to search the constant in + * + * @throws ReflectionException if the constant nodes are not given and can not be found + */ + public function __construct( + string $constantName, + ?ConstItemNode $constNode = null, + ?ConstStatementNode $declarationNode = null, + ?ReflectionFileNamespace $fileNamespace = null + ) { + $this->name = ltrim($constantName, '\\'); + $this->fileNamespace = $fileNamespace; + + if (!isset($constNode, $declarationNode)) { + if (!isset($fileNamespace)) { + throw new ReflectionException( + "Could not find the constant " . $this->name . ", because global constants can not be located" + . " by name, an AST-node or a file namespace to search in should be given" + ); + } + [$declarationNode, $constNode] = self::findConstantNodes($fileNamespace, $this->getShortName()); + } + + $this->constNode = $constNode; + $this->declarationNode = $declarationNode; + } + + /** + * Emulating original behaviour of reflection + * + * @return array + */ + public function __debugInfo(): array + { + return ['name' => $this->name]; + } + + /** + * Returns the fully-qualified name of the constant + */ + public function getName(): string + { + return $this->name; + } + + /** + * Returns the name of the constant without the namespace prefix + */ + public function getShortName(): string + { + $namespaceParts = explode('\\', $this->name); + + return (string) array_pop($namespaceParts); + } + + /** + * Returns the namespace of the constant or an empty string for the global namespace + */ + public function getNamespaceName(): string + { + $namespaceParts = explode('\\', $this->name); + // Remove the last part with the constant name itself + array_pop($namespaceParts); + + return implode('\\', $namespaceParts); + } + + /** + * Returns the value of the constant, evaluated at the pure AST level + */ + public function getValue(): mixed + { + $expressionSolver = new NodeExpressionResolver($this->fileNamespace); + $expressionSolver->process($this->constNode->value); + + return $expressionSolver->getValue(); + } + + /** + * Checks if the constant is marked with the #[\Deprecated] attribute + * + * Resolved at the pure AST level, without loading anything into the memory. + */ + public function isDeprecated(): bool + { + foreach ($this->declarationNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if (strcasecmp(self::resolveAttributeName($attr), Deprecated::class) === 0) { + return true; + } + } + } + + return false; + } + + /** + * Returns the list of attributes, declared for this constant + * + * Attributes on constants are available since PHP 8.5, older versions can only parse them. + * + * @param class-string|null $name Optional name of the attribute to filter by + * + * @return ReflectionAttribute[] + */ + public function getAttributes(?string $name = null, int $flags = 0): array + { + $attributes = []; + $nodeExpressionResolver = new NodeExpressionResolver($this->fileNamespace); + + foreach ($this->declarationNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $resolvedAttrName = self::resolveAttributeName($attr); + if (isset($name) && $name !== $resolvedAttrName) { + continue; + } + + $arguments = []; + foreach ($attr->args as $arg) { + $nodeExpressionResolver->process($arg->value); + $arguments[] = $nodeExpressionResolver->getValue(); + } + + $isRepeated = self::isAttributeRepeated($resolvedAttrName, $this->declarationNode->attrGroups); + $attributes[] = self::createAttributeReflection($attr, $resolvedAttrName, $arguments, $isRepeated); + } + } + + return $attributes; + } + + /** + * Returns an AST-node for the concrete constant declaration + */ + public function getNode(): ConstItemNode + { + return $this->constNode; + } + + /** + * Returns textual representation of the constant, following the native format + */ + public function __toString(): string + { + $constantValue = $this->getValue(); + + return sprintf( + "Constant [ %s %s ] { %s }\n", + get_debug_type($constantValue), + $this->getName(), + is_array($constantValue) ? 'Array' : (string) $constantValue + ); + } + + /** + * Searches for the pair of [statement, declaration] nodes for the given constant short name + * + * @return array{0: ConstStatementNode, 1: ConstItemNode} + * + * @throws ReflectionException if there is no such constant in the given namespace + */ + private static function findConstantNodes(ReflectionFileNamespace $fileNamespace, string $shortName): array + { + // constants can be only top-level nodes in the namespace, so we can scan them directly + foreach ($fileNamespace->getNode()->stmts as $namespaceLevelNode) { + if (!$namespaceLevelNode instanceof ConstStatementNode) { + continue; + } + foreach ($namespaceLevelNode->consts as $nodeConstant) { + if ($nodeConstant->name->toString() === $shortName) { + return [$namespaceLevelNode, $nodeConstant]; + } + } + } + + throw new ReflectionException( + "Could not find the constant " . $shortName . " in the file " . $fileNamespace->getFileName() + ); + } + + /** + * Normalizes the attribute class name from the given attribute node, without triggering autoloading + */ + private static function resolveAttributeName(Attribute $attributeNode): string + { + $attributeNameNode = $attributeNode->name; + // If we have resolved node name, then we should use it instead + if ($attributeNameNode->hasAttribute('resolvedName')) { + $resolvedNameNode = $attributeNameNode->getAttribute('resolvedName'); + if ($resolvedNameNode instanceof Name) { + $attributeNameNode = $resolvedNameNode; + } + } + + return ltrim($attributeNameNode->toString(), '\\'); + } + + /** + * @param AttributeGroup[] $attrGroups + */ + private static function isAttributeRepeated(string $attributeName, array $attrGroups): bool + { + $count = 0; + + foreach ($attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if (self::resolveAttributeName($attr) === $attributeName) { + ++$count; + } + } + } + + return $count >= 2; + } + + /** + * Builds a reflection for the given attribute node + * + * Attributes are built here instead of the shared AttributeResolverTrait, because the reflector + * argument of ReflectionAttribute does not accept a constant reflection. + * + * @param class-string $attributeName + * @param array $arguments + */ + private static function createAttributeReflection( + Attribute $attributeNode, + string $attributeName, + array $arguments, + bool $isRepeated + ): ReflectionAttribute { + return new class ($attributeNode, $attributeName, $arguments, $isRepeated) extends ReflectionAttribute { + /** + * @param class-string $attributeClassName + * @param array $attributeArguments + */ + public function __construct( + private Attribute $attributeNode, + private string $attributeClassName, + private array $attributeArguments, + private bool $attributeIsRepeated + ) {} + + public function getNode(): Attribute + { + return $this->attributeNode; + } + + public function getName(): string + { + return $this->attributeClassName; + } + + /** + * @return array + */ + public function getArguments(): array + { + return $this->attributeArguments; + } + + public function isRepeated(): bool + { + return $this->attributeIsRepeated; + } + }; + } +} diff --git a/src/ReflectionFileNamespace.php b/src/ReflectionFileNamespace.php index 8761d09..0ee303c 100644 --- a/src/ReflectionFileNamespace.php +++ b/src/ReflectionFileNamespace.php @@ -65,6 +65,13 @@ class ReflectionFileNamespace implements NodeAwareInterface */ protected array $fileConstantsWithDefined; + /** + * List of reflections for constants in the namespace + * + * @var array + */ + protected array $fileReflectionConstants; + /** * List of imported namespaces (aliases) * @@ -178,6 +185,34 @@ public function getConstants(bool $withDefined = false): array return $this->fileConstants; } + /** + * Returns the reflection for the concrete constant, declared with the "const" keyword + * + * Constants defined via "define(...)" are not covered, because they are created at runtime. + * + * @param string $constantName Name of the constant without the namespace prefix + */ + public function getReflectionConstant(string $constantName): ReflectionConstant|false + { + $reflectionConstants = $this->getReflectionConstants(); + + return $reflectionConstants[$constantName] ?? false; + } + + /** + * Returns the list of reflections for constants, declared with the "const" keyword + * + * @return array + */ + public function getReflectionConstants(): array + { + if (!isset($this->fileReflectionConstants)) { + $this->fileReflectionConstants = $this->findReflectionConstants(); + } + + return $this->fileReflectionConstants; + } + /** * Gets doc comments from a namespace node if it exists, otherwise "false" */ @@ -458,6 +493,38 @@ private function findConstants(bool $withDefined = false): array return $constants; } + /** + * Searches for reflections of constants, declared with the "const" keyword, in the given AST + * + * @return array + */ + private function findReflectionConstants(): array + { + $reflectionConstants = []; + $namespaceName = $this->getName(); + + // constants can be only top-level nodes in the namespace, so we can scan them directly + foreach ($this->namespaceNode->stmts as $namespaceLevelNode) { + if (!$namespaceLevelNode instanceof Const_) { + continue; + } + $namespaceLevelNode->setAttribute('fileName', $this->fileName); + foreach ($namespaceLevelNode->consts as $nodeConstant) { + $constantShortName = $nodeConstant->name->toString(); + $constantName = $namespaceName ? $namespaceName . '\\' . $constantShortName : $constantShortName; + + $reflectionConstants[$constantShortName] = new ReflectionConstant( + $constantName, + $nodeConstant, + $namespaceLevelNode, + $this + ); + } + } + + return $reflectionConstants; + } + /** * Searches for namespace aliases for the current block * diff --git a/tests/ReflectionConstantTest.php b/tests/ReflectionConstantTest.php new file mode 100644 index 0000000..2ec2289 --- /dev/null +++ b/tests/ReflectionConstantTest.php @@ -0,0 +1,288 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Deprecated; +use PhpParser\Node\Attribute; +use PhpParser\Node\Const_; +use PHPUnit\Framework\TestCase; + +/** + * Tests for the AST-based reflection of global and namespaced constants + * + * @see ReflectionConstant + */ +class ReflectionConstantTest extends TestCase +{ + private const STUB_FILENAME = '/Stub/FileWithGlobalConstants84.php'; + + private const ATTRIBUTED_STUB_FILENAME = '/Stub/FileWithAttributedConstants85.php'; + + private const STUB_NAMESPACE = 'Go\ParserReflection\Stub'; + + /** + * List of constants from the stub file with their expected values + * + * @var array + */ + private const EXPECTED_CONSTANTS = [ + 'GLOBAL_CONSTANT_INT' => 42, + 'GLOBAL_CONSTANT_EXPRESSION' => 7, + 'GLOBAL_CONSTANT_STRING' => 'parser-reflection', + 'GLOBAL_CONSTANT_ARRAY' => [1, 2, 3], + 'GLOBAL_CONSTANT_FIRST' => 1, + 'GLOBAL_CONSTANT_SECOND' => 2, + ]; + + private ReflectionFileNamespace $parsedRefFileNamespace; + + protected function setUp(): void + { + $this->parsedRefFileNamespace = $this->getParsedFileNamespace(self::STUB_FILENAME); + } + + /** + * Constants should be resolved from the AST only, without including the file with them + */ + public function testConstantsAreResolvedWithoutLoadingFile(): void + { + foreach (array_keys(self::EXPECTED_CONSTANTS) as $shortName) { + $this->assertFalse( + defined(self::STUB_NAMESPACE . '\\' . $shortName), + 'Stub constant ' . $shortName . ' should not be defined yet' + ); + } + + $parsedConstants = $this->parsedRefFileNamespace->getReflectionConstants(); + $this->assertSame(array_keys(self::EXPECTED_CONSTANTS), array_keys($parsedConstants)); + + foreach (self::EXPECTED_CONSTANTS as $shortName => $expectedValue) { + $parsedConstant = $parsedConstants[$shortName]; + + $this->assertInstanceOf(ReflectionConstant::class, $parsedConstant); + $this->assertSame(self::STUB_NAMESPACE . '\\' . $shortName, $parsedConstant->getName()); + $this->assertSame(self::STUB_NAMESPACE . '\\' . $shortName, $parsedConstant->name); + $this->assertSame($shortName, $parsedConstant->getShortName()); + $this->assertSame(self::STUB_NAMESPACE, $parsedConstant->getNamespaceName()); + $this->assertSame($expectedValue, $parsedConstant->getValue()); + $this->assertFalse($parsedConstant->isDeprecated()); + $this->assertSame([], $parsedConstant->getAttributes()); + $this->assertInstanceOf(Const_::class, $parsedConstant->getNode()); + } + + foreach (array_keys(self::EXPECTED_CONSTANTS) as $shortName) { + $this->assertFalse( + defined(self::STUB_NAMESPACE . '\\' . $shortName), + 'Stub constant ' . $shortName . ' should still not be defined' + ); + } + } + + /** + * Textual representation should follow the native format even without loading the file + */ + public function testStringRepresentationIsResolvedWithoutLoadingFile(): void + { + $parsedConstant = $this->parsedRefFileNamespace->getReflectionConstant('GLOBAL_CONSTANT_INT'); + $this->assertInstanceOf(ReflectionConstant::class, $parsedConstant); + $this->assertSame( + "Constant [ int " . self::STUB_NAMESPACE . "\\GLOBAL_CONSTANT_INT ] { 42 }\n", + (string) $parsedConstant + ); + + $parsedArrayConstant = $this->parsedRefFileNamespace->getReflectionConstant('GLOBAL_CONSTANT_ARRAY'); + $this->assertInstanceOf(ReflectionConstant::class, $parsedArrayConstant); + $this->assertSame( + "Constant [ array " . self::STUB_NAMESPACE . "\\GLOBAL_CONSTANT_ARRAY ] { Array }\n", + (string) $parsedArrayConstant + ); + } + + /** + * Lookup of a single constant should be available by its short name only + */ + public function testGetReflectionConstant(): void + { + $parsedConstant = $this->parsedRefFileNamespace->getReflectionConstant('GLOBAL_CONSTANT_SECOND'); + + $this->assertInstanceOf(ReflectionConstant::class, $parsedConstant); + $this->assertSame(2, $parsedConstant->getValue()); + $this->assertFalse($this->parsedRefFileNamespace->getReflectionConstant('UNKNOWN_CONSTANT')); + } + + /** + * Existing methods for constants should not be affected by the new reflection + */ + public function testPlainConstantListIsNotAffected(): void + { + $this->assertSame(self::EXPECTED_CONSTANTS, $this->parsedRefFileNamespace->getConstants()); + $this->assertTrue($this->parsedRefFileNamespace->hasConstant('GLOBAL_CONSTANT_INT')); + $this->assertSame(42, $this->parsedRefFileNamespace->getConstant('GLOBAL_CONSTANT_INT')); + } + + /** + * Constant can be also reflected by its name, when the namespace to search in is known + */ + public function testConstantCanBeFoundByNameInGivenNamespace(): void + { + $parsedConstant = new ReflectionConstant( + self::STUB_NAMESPACE . '\GLOBAL_CONSTANT_STRING', + null, + null, + $this->parsedRefFileNamespace + ); + + $this->assertSame('GLOBAL_CONSTANT_STRING', $parsedConstant->getShortName()); + $this->assertSame('parser-reflection', $parsedConstant->getValue()); + $this->assertSame(['name' => self::STUB_NAMESPACE . '\GLOBAL_CONSTANT_STRING'], $parsedConstant->__debugInfo()); + } + + /** + * Global constants can not be located by name alone, because there is no locator for them + */ + public function testConstantWithoutNodesAndNamespaceIsRejected(): void + { + $this->expectException(ReflectionException::class); + + new ReflectionConstant(self::STUB_NAMESPACE . '\GLOBAL_CONSTANT_INT'); + } + + /** + * Unknown constant should be reported as an error during the search in the namespace + */ + public function testUnknownConstantIsRejected(): void + { + $this->expectException(ReflectionException::class); + + new ReflectionConstant( + self::STUB_NAMESPACE . '\UNKNOWN_CONSTANT', + null, + null, + $this->parsedRefFileNamespace + ); + } + + /** + * Parsed constants should give exactly the same answers as the native reflection for a loaded file + */ + public function testConstantsMatchNativeReflection(): void + { + include_once stream_resolve_include_path(__DIR__ . self::STUB_FILENAME); + + foreach (array_keys(self::EXPECTED_CONSTANTS) as $shortName) { + $constantName = self::STUB_NAMESPACE . '\\' . $shortName; + + $parsedConstant = $this->parsedRefFileNamespace->getReflectionConstant($shortName); + $this->assertInstanceOf(ReflectionConstant::class, $parsedConstant); + $nativeConstant = new \ReflectionConstant($constantName); + + $this->assertSame($nativeConstant->getName(), $parsedConstant->getName(), $constantName); + $this->assertSame($nativeConstant->getShortName(), $parsedConstant->getShortName(), $constantName); + $this->assertSame($nativeConstant->getNamespaceName(), $parsedConstant->getNamespaceName(), $constantName); + $this->assertSame($nativeConstant->getValue(), $parsedConstant->getValue(), $constantName); + $this->assertSame($nativeConstant->isDeprecated(), $parsedConstant->isDeprecated(), $constantName); + $this->assertSame($nativeConstant->__toString(), $parsedConstant->__toString(), $constantName); + $this->assertSame($nativeConstant->name, $parsedConstant->name, $constantName); + } + } + + /** + * Attributes on constants are a PHP 8.5 feature, they should be resolved from the AST only + */ + public function testAttributesOnConstantsAreResolvedStatically(): void + { + $parsedNamespace = $this->getParsedFileNamespace(self::ATTRIBUTED_STUB_FILENAME); + + $deprecatedConstant = $parsedNamespace->getReflectionConstant('ATTRIBUTED_CONSTANT_LEGACY'); + $this->assertInstanceOf(ReflectionConstant::class, $deprecatedConstant); + $this->assertTrue($deprecatedConstant->isDeprecated()); + $this->assertSame('legacy', $deprecatedConstant->getValue()); + + $attributes = $deprecatedConstant->getAttributes(); + $this->assertCount(1, $attributes); + $this->assertInstanceOf(ReflectionAttribute::class, $attributes[0]); + $this->assertSame(Deprecated::class, $attributes[0]->getName()); + $this->assertSame(['Use ATTRIBUTED_CONSTANT_MODERN instead', '8.5'], $attributes[0]->getArguments()); + $this->assertFalse($attributes[0]->isRepeated()); + $this->assertInstanceOf(Attribute::class, $attributes[0]->getNode()); + + $aliasedConstant = $parsedNamespace->getReflectionConstant('ATTRIBUTED_CONSTANT_ALIASED'); + $this->assertInstanceOf(ReflectionConstant::class, $aliasedConstant); + $this->assertTrue($aliasedConstant->isDeprecated()); + $this->assertSame( + [Deprecated::class], + array_map(static fn(ReflectionAttribute $attribute): string => $attribute->getName(), $aliasedConstant->getAttributes()) + ); + + $markedConstant = $parsedNamespace->getReflectionConstant('ATTRIBUTED_CONSTANT_MARKED'); + $this->assertInstanceOf(ReflectionConstant::class, $markedConstant); + $this->assertFalse($markedConstant->isDeprecated()); + $markedAttributes = $markedConstant->getAttributes(); + $this->assertCount(1, $markedAttributes); + $this->assertSame(self::STUB_NAMESPACE . '\ConstantMarker', $markedAttributes[0]->getName()); + $this->assertSame(['marked'], $markedAttributes[0]->getArguments()); + $this->assertSame([], $markedConstant->getAttributes(Deprecated::class)); + + $modernConstant = $parsedNamespace->getReflectionConstant('ATTRIBUTED_CONSTANT_MODERN'); + $this->assertInstanceOf(ReflectionConstant::class, $modernConstant); + $this->assertFalse($modernConstant->isDeprecated()); + $this->assertSame([], $modernConstant->getAttributes()); + + $this->assertCount(1, $deprecatedConstant->getAttributes(Deprecated::class)); + $this->assertFalse( + defined(self::STUB_NAMESPACE . '\ATTRIBUTED_CONSTANT_LEGACY'), + 'PHP 8.5 stub file should never be loaded' + ); + } + + /** + * Constants from the global namespace should not have any namespace prefix + */ + public function testConstantInGlobalNamespace(): void + { + $parsedNamespace = $this->getParsedFileNamespace(self::ATTRIBUTED_STUB_FILENAME, ''); + + $globalConstant = $parsedNamespace->getReflectionConstant('ATTRIBUTED_GLOBAL_CONSTANT'); + $this->assertInstanceOf(ReflectionConstant::class, $globalConstant); + $this->assertSame('ATTRIBUTED_GLOBAL_CONSTANT', $globalConstant->getName()); + $this->assertSame('ATTRIBUTED_GLOBAL_CONSTANT', $globalConstant->getShortName()); + $this->assertSame('', $globalConstant->getNamespaceName()); + $this->assertSame('global', $globalConstant->getValue()); + $this->assertTrue($globalConstant->isDeprecated()); + $this->assertFalse( + defined('ATTRIBUTED_GLOBAL_CONSTANT'), + 'PHP 8.5 stub file should never be loaded' + ); + } + + /** + * Constants defined via "define(...)" are created at runtime and should not be reflected + */ + public function testDefinedConstantsAreNotReflected(): void + { + $parsedNamespace = $this->getParsedFileNamespace('/Stub/FileWithGlobalNamespace.php', ''); + + $this->assertSame([], $parsedNamespace->getReflectionConstants()); + $this->assertFalse($parsedNamespace->getReflectionConstant('INT_CONST')); + $this->assertArrayHasKey('INT_CONST', $parsedNamespace->getConstants(true)); + } + + private function getParsedFileNamespace(string $stubFileName, string $namespaceName = self::STUB_NAMESPACE): ReflectionFileNamespace + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . $stubFileName); + $this->assertIsString($resolvedFileName, 'Stub file ' . $stubFileName . ' should be available'); + + $reflectionFile = new ReflectionFile($resolvedFileName); + + return $reflectionFile->getFileNamespace($namespaceName); + } +} diff --git a/tests/Stub/FileWithAttributedConstants85.php b/tests/Stub/FileWithAttributedConstants85.php new file mode 100644 index 0000000..9b362af --- /dev/null +++ b/tests/Stub/FileWithAttributedConstants85.php @@ -0,0 +1,46 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + * Attributes on constants are allowed since PHP 8.5, therefore this file can only be parsed, + * it must never be included or required by the tests. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub { + + use Deprecated as LegacyMarker; + + /** + * Attribute that can be applied to the constants only + */ + #[\Attribute(\Attribute::TARGET_CONSTANT)] + final class ConstantMarker + { + public function __construct(public readonly string $tag = '') + { + } + } + + #[\Deprecated(message: 'Use ATTRIBUTED_CONSTANT_MODERN instead', since: '8.5')] + const ATTRIBUTED_CONSTANT_LEGACY = 'legacy'; + + #[LegacyMarker] + const ATTRIBUTED_CONSTANT_ALIASED = 'aliased'; + + #[ConstantMarker(tag: 'marked')] + const ATTRIBUTED_CONSTANT_MARKED = 'marked'; + + const ATTRIBUTED_CONSTANT_MODERN = 'modern'; +} + +namespace { + + #[\Deprecated] + const ATTRIBUTED_GLOBAL_CONSTANT = 'global'; +} diff --git a/tests/Stub/FileWithGlobalConstants84.php b/tests/Stub/FileWithGlobalConstants84.php new file mode 100644 index 0000000..ed37662 --- /dev/null +++ b/tests/Stub/FileWithGlobalConstants84.php @@ -0,0 +1,22 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +const GLOBAL_CONSTANT_INT = 42; + +const GLOBAL_CONSTANT_EXPRESSION = 2 * 3 + 1; + +const GLOBAL_CONSTANT_STRING = 'parser' . '-' . 'reflection'; + +const GLOBAL_CONSTANT_ARRAY = [1, 2, 3]; + +const GLOBAL_CONSTANT_FIRST = 1, GLOBAL_CONSTANT_SECOND = 2; From 19a8f32c0bf7a880d22a540c0dccc3b01b992099 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:48:20 +0000 Subject: [PATCH 12/13] fix: static analysis findings in ReflectionConstant Annotate the resolved attribute name as class-string and print non-stringable constant values via their debug type instead of an unchecked string cast. --- src/ReflectionConstant.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ReflectionConstant.php b/src/ReflectionConstant.php index c4d9109..4f796c4 100644 --- a/src/ReflectionConstant.php +++ b/src/ReflectionConstant.php @@ -204,12 +204,18 @@ public function getNode(): ConstItemNode public function __toString(): string { $constantValue = $this->getValue(); + $printedValue = match (true) { + is_array($constantValue) => 'Array', + is_scalar($constantValue), $constantValue === null => (string) $constantValue, + $constantValue instanceof \Stringable => (string) $constantValue, + default => get_debug_type($constantValue), + }; return sprintf( "Constant [ %s %s ] { %s }\n", get_debug_type($constantValue), $this->getName(), - is_array($constantValue) ? 'Array' : (string) $constantValue + $printedValue ); } @@ -241,6 +247,8 @@ private static function findConstantNodes(ReflectionFileNamespace $fileNamespace /** * Normalizes the attribute class name from the given attribute node, without triggering autoloading + * + * @return class-string */ private static function resolveAttributeName(Attribute $attributeNode): string { From 0aebd64e7b065a9d3ee950ea8a4b5c8c28a2d7ba Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Sat, 1 Aug 2026 09:49:14 +0000 Subject: [PATCH 13/13] fix: assert attribute class-string type for static analysis --- src/ReflectionConstant.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ReflectionConstant.php b/src/ReflectionConstant.php index 4f796c4..deaf698 100644 --- a/src/ReflectionConstant.php +++ b/src/ReflectionConstant.php @@ -261,7 +261,10 @@ private static function resolveAttributeName(Attribute $attributeNode): string } } - return ltrim($attributeNameNode->toString(), '\\'); + /** @var class-string $attributeClassName */ + $attributeClassName = ltrim($attributeNameNode->toString(), '\\'); + + return $attributeClassName; } /**