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
6 changes: 3 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ php /tmp/test_reflection.php
- `composer.json` - Dependencies: php >=8.2, nikic/php-parser ^5.0
- `phpunit.xml.dist` - Test configuration (1536M memory limit)
- `rector.php` - Code quality rules
- `.github/workflows/phpunit.yml` - CI pipeline (PHP 8.2, 8.3)
- `.github/workflows/phpunit.yml` - CI pipeline (PHP 8.2, 8.3, 8.4)

## Common Issues and Troubleshooting

Expand Down Expand Up @@ -143,12 +143,12 @@ php /tmp/test_reflection.php
- Core functionality requires nikic/php-parser for AST generation
- Tests use Composer's autoloader for class location
- Memory usage can be high for large codebases (configure php.ini accordingly)
- Compatible with PHP 8.2+ (tested on 8.2, 8.3)
- Compatible with PHP 8.2+ (tested on 8.2, 8.3, 8.4)

## CI/Build Pipeline Reference

The GitHub Actions pipeline (`.github/workflows/phpunit.yml`) runs:
- Matrix testing: PHP 8.2, 8.3 on Ubuntu
- Matrix testing: PHP 8.2, 8.3, 8.4 on Ubuntu
- Dependency variations: lowest, highest
- Standard `composer install` (works in CI with GitHub tokens)
- PHPUnit test suite execution
4 changes: 2 additions & 2 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ name: "PHPUnit tests"

on:
pull_request:
push:

jobs:
phpunit:
Expand All @@ -16,8 +15,9 @@ jobs:
- "lowest"
- "highest"
php-version:
- "8.2"
- "8.4"
- "8.3"
- "8.2"
operating-system:
- "ubuntu-latest"

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/vendor/
composer.lock
/.phpunit.result.cache
/.phpunit.result.cache
*.zip
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
},
"require": {
"php": ">=8.2",
"nikic/php-parser": "^5.0"
"nikic/php-parser": "^5.4"
},
"require-dev": {
"phpunit/phpunit": "^11.0.7",
Expand Down
3 changes: 3 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@
</source>
<php>
<ini name="memory_limit" value="1536M" />
<ini name="error_reporting" value="E_ALL" />
<ini name="display_errors" value="1" />
<ini name="display_startup_errors" value="1" />
</php>
</phpunit>
80 changes: 80 additions & 0 deletions src/ReflectionProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,23 @@ public function getModifiers(): int
if ($this->isReadOnly()) {
$modifiers += self::IS_READONLY;
}
if (PHP_VERSION_ID >= 80400 && $this->isAbstract()) {
$modifiers += self::IS_ABSTRACT;
}
if (PHP_VERSION_ID >= 80400 && $this->isFinal()) {
$modifiers += self::IS_FINAL;
}
if (PHP_VERSION_ID >= 80400 && $this->isProtectedSet()) {
$modifiers += self::IS_PROTECTED_SET;
}
if (PHP_VERSION_ID >= 80400 && $this->isPrivateSet()) {
$modifiers += self::IS_PRIVATE_SET;
}

// Handle PHP 8.4+ asymmetric visibility modifiers
// Note: IS_PRIVATE_SET and IS_PROTECTED_SET are only added for properties with explicit
// asymmetric visibility syntax like "public private(set) $prop", not for regular readonly properties
// TODO: Implement when nikic/php-parser supports asymmetric visibility syntax

return $modifiers;
}
Expand Down Expand Up @@ -272,6 +289,18 @@ public function getDefaultValue(): mixed
return $this->defaultValue;
}

/**
* @inheritDoc
*/
public function isAbstract(): bool
{
if ($this->propertyOrPromotedParam instanceof Property) {
return $this->propertyOrPromotedParam->isAbstract();
}

return false;
}

/**
* @inheritDoc
*/
Expand All @@ -282,6 +311,22 @@ public function isDefault(): bool
return true;
}

/**
* {@inheritDoc}
*
* @see Property::isFinal()
*/
public function isFinal(): bool
{
$explicitFinal = false;
if ($this->propertyOrPromotedParam instanceof Property) {
$explicitFinal = $this->propertyOrPromotedParam->isFinal();
}

// Property with private(set) modifier is implicitly final
return $explicitFinal || $this->isPrivateSet();
}

/**
* {@inheritDoc}
*
Expand All @@ -293,6 +338,17 @@ public function isPrivate(): bool
return $this->propertyOrPromotedParam->isPrivate();
}

/**
* @inheritDoc
*
* @see Property::isPrivateSet()
* @see Param::isPrivateSet()
*/
public function isPrivateSet(): bool
{
return ($this->propertyOrPromotedParam->isPrivateSet() && !$this->propertyOrPromotedParam->isPrivate());
}

/**
* {@inheritDoc}
*
Expand All @@ -304,6 +360,22 @@ public function isProtected(): bool
return $this->propertyOrPromotedParam->isProtected();
}

/**
* @inheritDoc
*
* @see Property::isProtectedSet()
* @see Param::isProtectedSet()
*/
public function isProtectedSet(): bool
{
/*
* Behavior of readonly is to imply protected(set), not private(set).
* A readonly property may still be explicitly declared private(set), in which case it will also be implicitly final
*/
return ($this->propertyOrPromotedParam->isProtectedSet() && !$this->propertyOrPromotedParam->isProtected())
|| ($this->isPublic() && $this->isReadonly() && !$this->isPrivateSet() && !$this->propertyOrPromotedParam->isPublicSet());
}

/**
* {@inheritDoc}
*
Expand Down Expand Up @@ -363,6 +435,14 @@ public function isInitialized(?object $object = null): bool
return $this->hasDefaultValue();
}

/**
* @inheritDoc
*/
public function isVirtual(): bool
{
return $this->propertyOrPromotedParam->isVirtual();
}

/**
* {@inheritDoc}
*/
Expand Down
3 changes: 3 additions & 0 deletions tests/AbstractTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ public static function getFilesToAnalyze(): \Generator
if (PHP_VERSION_ID >= 80300) {
yield 'PHP8.3' => [__DIR__ . '/Stub/FileWithClasses83.php'];
}
if (PHP_VERSION_ID >= 80400) {
yield 'PHP8.4' => [__DIR__ . '/Stub/FileWithClasses84.php'];
}
}

/**
Expand Down
7 changes: 7 additions & 0 deletions tests/ReflectionClassTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use Go\ParserReflection\Stub\ClassWithPhp50ConstantsAndInheritance;
use Go\ParserReflection\Stub\ClassWithPhp50MagicConstants;
use Go\ParserReflection\Stub\ClassWithPhp84PropertyHooks;
use Go\ParserReflection\Stub\SimplePhp50ClassWithMethodsAndProperties;
use Go\ParserReflection\Stub\ClassWithPhp50ScalarConstants;
use Go\ParserReflection\Stub\ClassWithPhp50FinalKeyword;
Expand Down Expand Up @@ -69,6 +70,12 @@ public function testReflectionGetterParity(
"See https://github.com/goaop/parser-reflection/issues/132"
);
}
if ($parsedClass->getName() === ClassWithPhp84PropertyHooks::class && in_array($getterName, ['isIterable', 'isIterateable'], true)) {
$this->markTestSkipped(
"isIterable for class with hooks returns true.\n" .
"See https://github.com/php/php-src/issues/20217"
);
}
$this->assertSame(
$expectedValue,
$actualValue,
Expand Down
8 changes: 7 additions & 1 deletion tests/ReflectionPropertyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,16 @@ public static function propertiesDataProvider(): \Generator
*/
protected static function getGettersToCheck(): array
{
return [
$getters = [
'isDefault', 'getName', 'getModifiers', 'getDocComment',
'isPrivate', 'isProtected', 'isPublic', 'isStatic', 'isReadOnly', 'isInitialized',
'hasType', 'hasDefaultValue', 'getDefaultValue', '__toString'
];

if (PHP_VERSION_ID >= 80400) {
array_push($getters, 'isAbstract', 'isProtectedSet', 'isPrivateSet', 'isFinal');
}

return $getters;
}
}
69 changes: 69 additions & 0 deletions tests/Stub/FileWithClasses84.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php
/**
* Parser Reflection API
*
* @copyright Copyright 2025, Lisachenko Alexander <[email protected]>
*
* 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 ClassWithPhp84PropertyHooks
{
private string $backing = 'default';

public string $name {
get => $this->backing;
set => $this->backing = strtoupper($value);
}
}

/* Not supported yet
interface InterfaceWithPhp84AbstractProperty
{
public string $name { get; }
}
*/

/**
* https://wiki.php.net/rfc/asymmetric-visibility-v2
*/
class ClassWithPhp84AsymmetricVisibility
{
// These create a public-read, protected-write, write-once property.
public protected(set) readonly string $explicitPublicWriteOnceProtectedProperty;
public readonly string $implicitPublicReadonlyWriteOnceProperty;
readonly string $implicitReadonlyWriteOnceProperty;

// These creates a public-read, private-set, write-once, final property.
public private(set) readonly string $explicitPublicWriteOncePrivateProperty;
private(set) readonly string $implicitPublicReadonlyWriteOncePrivateProperty;

// These create a public-read, public-write, write-once property.
// While use cases for this configuration are likely few,
// there's no intrinsic reason it should be forbidden.
public public(set) readonly string $explicitPublicWriteOncePublicProperty;
public(set) readonly string $implicitPublicReadonlyWriteOncePublicProperty;

// These create a private-read, private-write, write-once, final property.
private private(set) readonly string $explicitPrivateWriteOncePrivateProperty;
private readonly string $implicitPrivateReadonlyWriteOncePrivateProperty;

// These create a protected-read, protected-write, write-once property.
protected protected(set) readonly string $explicitProtectedWriteOnceProtectedProperty;
protected readonly string $implicitProtectedReadonlyWriteOnceProtectedProperty;

public function __construct(
private(set) string $promotedPrivateSetStringProperty,
protected(set) string $promotedProtectedSetStringProperty,
protected private(set) int $promotedProtectedPrivateSetIntProperty,
) {}

}
Loading