diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ea4808b..b2fb0f1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 @@ -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 \ No newline at end of file diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index f7d766c..6816b8c 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -2,7 +2,6 @@ name: "PHPUnit tests" on: pull_request: - push: jobs: phpunit: @@ -16,8 +15,9 @@ jobs: - "lowest" - "highest" php-version: - - "8.2" + - "8.4" - "8.3" + - "8.2" operating-system: - "ubuntu-latest" diff --git a/.gitignore b/.gitignore index 224256b..bfdcf8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /vendor/ composer.lock -/.phpunit.result.cache \ No newline at end of file +/.phpunit.result.cache +*.zip \ No newline at end of file diff --git a/composer.json b/composer.json index b4a6f1f..b71bee9 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6fb5c8f..ce8dd12 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,5 +12,8 @@ + + + diff --git a/src/ReflectionProperty.php b/src/ReflectionProperty.php index 7d7ec1c..2c3ce60 100644 --- a/src/ReflectionProperty.php +++ b/src/ReflectionProperty.php @@ -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; } @@ -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 */ @@ -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} * @@ -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} * @@ -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} * @@ -363,6 +435,14 @@ public function isInitialized(?object $object = null): bool return $this->hasDefaultValue(); } + /** + * @inheritDoc + */ + public function isVirtual(): bool + { + return $this->propertyOrPromotedParam->isVirtual(); + } + /** * {@inheritDoc} */ diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php index d496ca8..dec318d 100644 --- a/tests/AbstractTestCase.php +++ b/tests/AbstractTestCase.php @@ -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']; + } } /** diff --git a/tests/ReflectionClassTest.php b/tests/ReflectionClassTest.php index 5369295..b320453 100644 --- a/tests/ReflectionClassTest.php +++ b/tests/ReflectionClassTest.php @@ -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; @@ -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, diff --git a/tests/ReflectionPropertyTest.php b/tests/ReflectionPropertyTest.php index d701b20..49e0a19 100644 --- a/tests/ReflectionPropertyTest.php +++ b/tests/ReflectionPropertyTest.php @@ -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; } } diff --git a/tests/Stub/FileWithClasses84.php b/tests/Stub/FileWithClasses84.php new file mode 100644 index 0000000..be06130 --- /dev/null +++ b/tests/Stub/FileWithClasses84.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 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, + ) {} + +}