diff --git a/src/ReflectionProperty.php b/src/ReflectionProperty.php index 67905dc..7b78fd7 100644 --- a/src/ReflectionProperty.php +++ b/src/ReflectionProperty.php @@ -419,6 +419,17 @@ public function isProtectedSet(): bool || ($this->isPublic() && $this->isReadonly() && !$this->isPrivateSet() && !$this->propertyOrPromotedParam->isPublicSet()); } + /** + * Checks if the property has an explicit public(set) visibility that differs from the main visibility. + * + * @see Property::isPublicSet() + * @see Param::isPublicSet() + */ + public function isPublicSet(): bool + { + return $this->propertyOrPromotedParam->isPublicSet() && !$this->propertyOrPromotedParam->isPublic(); + } + /** * {@inheritDoc} * @@ -490,9 +501,15 @@ public function isVirtual(): bool if (empty($hooks)) { return false; } - // A property is virtual if it has hooks but none expose backing storage (byRef) + + $propertyName = $this->getName(); foreach ($hooks as $hook) { - if ($hook->byRef) { + // A short set hook (body is Expr) always stores the result in the backing field + if ($hook->name->name === 'set' && $hook->body instanceof Expr) { + return false; + } + // A block-form hook that references $this->propertyName uses the backing store + if (is_array($hook->body) && $this->hookBodyUsesBackingStore($hook->body, $propertyName)) { return false; } } @@ -500,6 +517,25 @@ public function isVirtual(): bool return true; } + /** + * Checks whether the hook body references the property's own backing store via $this->propertyName + * + * @param \PhpParser\Node\Stmt[] $stmts + */ + private function hookBodyUsesBackingStore(array $stmts, string $propertyName): bool + { + $finder = new \PhpParser\NodeFinder(); + $found = $finder->findFirst($stmts, function (\PhpParser\Node $node) use ($propertyName): bool { + return $node instanceof \PhpParser\Node\Expr\PropertyFetch + && $node->var instanceof \PhpParser\Node\Expr\Variable + && $node->var->name === 'this' + && $node->name instanceof \PhpParser\Node\Identifier + && $node->name->name === $propertyName; + }); + + return $found !== null; + } + /** * {@inheritDoc} */ diff --git a/tests/ReflectionPropertyTest.php b/tests/ReflectionPropertyTest.php index dc587b6..2ea46c9 100644 --- a/tests/ReflectionPropertyTest.php +++ b/tests/ReflectionPropertyTest.php @@ -216,6 +216,49 @@ public static function propertyHooksDataProvider(): \Generator } } + /** + * Tests isPublicSet() method for asymmetric visibility properties. + * + * Note: Native \ReflectionProperty does not have isPublicSet(), so this is tested + * against expected values rather than parity with native reflection. + */ + #[DataProvider('isPublicSetDataProvider')] + public function testIsPublicSetMethod(string $className, string $propertyName, bool $expectedValue): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('isPublicSet() requires PHP 8.4+'); + } + + $parsedClass = new ReflectionClass($className); + $parsedProperty = $parsedClass->getProperty($propertyName); + + $this->assertSame( + $expectedValue, + $parsedProperty->isPublicSet(), + "isPublicSet() for property {$className}::{$propertyName} should be " . ($expectedValue ? 'true' : 'false') + ); + } + + public static function isPublicSetDataProvider(): \Generator + { + if (PHP_VERSION_ID < 80400) { + return; + } + + $class = Stub\ClassWithPhp84AsymmetricVisibility::class; + + // public public(set) readonly — main visibility is public, so isPublicSet() returns false + yield 'explicit public public(set)' => [$class, 'explicitPublicWriteOncePublicProperty', false]; + // public(set) readonly — implicit public, so isPublicSet() returns false + yield 'implicit public public(set)' => [$class, 'implicitPublicReadonlyWriteOncePublicProperty', false]; + // public protected(set) readonly — not public(set) at all + yield 'public protected(set) readonly' => [$class, 'explicitPublicWriteOnceProtectedProperty', false]; + // public private(set) readonly — not public(set) at all + yield 'public private(set) readonly' => [$class, 'explicitPublicWriteOncePrivateProperty', false]; + // private(set) readonly — not public(set) at all + yield 'implicit public private(set) readonly' => [$class, 'implicitPublicReadonlyWriteOncePrivateProperty', false]; + } + /** * Returns list of ReflectionMethod getters that be checked directly without additional arguments */ @@ -228,7 +271,7 @@ protected static function getGettersToCheck(): array ]; if (PHP_VERSION_ID >= 80400) { - array_push($getters, 'isAbstract', 'isProtectedSet', 'isPrivateSet', 'isFinal', 'hasHooks'); + array_push($getters, 'isAbstract', 'isProtectedSet', 'isPrivateSet', 'isFinal', 'hasHooks', 'isVirtual'); } return $getters;