Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions src/ReflectionProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*
Expand Down Expand Up @@ -490,16 +501,41 @@ 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;
}
}

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}
*/
Expand Down
45 changes: 44 additions & 1 deletion tests/ReflectionPropertyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
Expand Down
Loading