diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f97efba..088ca96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,8 @@ jobs: 8.1, 8.2, 8.3, - 8.4 + 8.4, + 8.5 ] composer: [basic] # Test against both supported nikic/php-parser major versions. diff --git a/README.md b/README.md index 3d7e489..cb1b4ba 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,24 @@ We will use: ### Supported PHP Features +Runtime support for this library is PHP 8.1+, while the parser test suite validates analyzable source syntax from PHP 5.3 through PHP 8.5. + +#### Legacy source syntax that can still be analyzed + +| Source Syntax Generation | Representative coverage | +|---|---| +| PHP 5.3 | namespaces, closures with `use`, `array()` syntax | +| PHP 5.4 | traits, `callable`, short arrays | +| PHP 5.5 | generators / `yield`, `finally` | +| PHP 5.6 | variadics, argument unpacking-ready syntax, constant arrays | +| PHP 7.0 | scalar parameter types, return types, anonymous classes | +| PHP 7.1 | nullable types, `iterable`, `void` | +| PHP 7.2 | `object` type | +| PHP 7.3 | trailing commas in calls | +| PHP 7.4 | typed properties, arrow functions | + +#### Modern source syntax coverage + | Feature | PHP Version | Supported | |---|---|---| | Attributes (class, method, property, parameter, constant) | 8.0+ | ✅ | @@ -54,6 +72,7 @@ We will use: | Trait constants | 8.2+ | ✅ | | Typed class constants | 8.3+ | ✅ | | `#[\Override]` attribute detection | 8.3+ | ✅ | +| Property hooks / asymmetric visibility | 8.4+ | ✅ | ### Install via "composer require" @@ -123,6 +142,24 @@ $phpClasses = $phpCode->getClasses(); var_dump($phpClasses[Dummy::class]); // "PHPClass"-object ```` +Unified metadata API: +```php +$phpCode = \voku\SimplePhpParser\Parsers\PhpCodeParser::getPhpFiles(__DIR__ . '/src'); + +$phpClasses = $phpCode->getClasses(); +$phpInterfaces = $phpCode->getInterfaces(); +$phpTraits = $phpCode->getTraits(); +$phpEnums = $phpCode->getEnums(); +$phpFunctions = $phpCode->getFunctions(); +$phpConstants = $phpCode->getConstants(); + +$functionInfo = $phpCode->getFunctionsInfo(); +$methodInfo = $phpClasses[MyService::class]->getMethodsInfo(); +$propertyInfo = $phpClasses[MyService::class]->getPropertiesInfo(); +``` + +The library is meant to be the simple integration layer that other tools can call instead of wiring together `nikic/php-parser`, `phpstan/phpdoc-parser`, `phpDocumentor`, and native reflection themselves. The test suite validates analyzable PHP source from PHP 5.3 through PHP 8.5, including legacy syntax generations and modern type declarations / metadata such as attributes, enums, readonly constructs, typed constants, and property hooks. + Access enums: ```php $phpCode = \voku\SimplePhpParser\Parsers\PhpCodeParser::getPhpFiles(__DIR__ . '/src'); diff --git a/src/voku/SimplePhpParser/Model/PHPClass.php b/src/voku/SimplePhpParser/Model/PHPClass.php index 445d0a0..553805f 100644 --- a/src/voku/SimplePhpParser/Model/PHPClass.php +++ b/src/voku/SimplePhpParser/Model/PHPClass.php @@ -521,7 +521,10 @@ private function mergePromotedPropertyData( $existingProperty->access_set = $promotedProperty->access_set; } - if ($existingProperty->hooks === [] && $promotedProperty->hooks !== []) { + // AST is the ground truth for promoted-property hooks; always prefer it + // when the param node carries hook data. Only fall back to whatever + // reflection already populated when the AST node has nothing. + if ($promotedProperty->hooks !== []) { $existingProperty->hooks = $promotedProperty->hooks; } diff --git a/tests/DummyCombinedSources.php b/tests/DummyCombinedSources.php new file mode 100644 index 0000000..edb5b28 --- /dev/null +++ b/tests/DummyCombinedSources.php @@ -0,0 +1,85 @@ + + */ + #[DummyCombinedPropertyAttribute(source: 'reflection')] + public string $dependencyClass = DummyCombinedDependency::class; + + /** + * Build a payload snapshot. + * + * Collects native types, advanced phpDoc types and reflection metadata together. + * + * @param array{status: string, retries: int|float} $payload + * @param callable(string): string $formatter + * + * @return array{status: string, retries: int|float} + */ + #[DummyCombinedMethodAttribute(label: 'method')] + public function buildSnapshot( + #[DummyCombinedParameterAttribute(label: 'payload')] array $payload, + #[DummyCombinedParameterAttribute(label: 'formatter')] callable $formatter, + bool $withMeta = true + ): array { + return [ + 'status' => $formatter($payload['status']), + 'retries' => $payload['retries'], + ]; + } + + public function freeze(DateTimeImmutable $at): DateTimeImmutable + { + return $at; + } +} diff --git a/tests/ParserTest.php b/tests/ParserTest.php index cccbfd6..8870ae4 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -84,6 +84,308 @@ public function testSimpleOneClassV4(): void static::assertSame('voku\tests\Dummy6', $phpClasses[Dummy9::class]->parentClass); } + public function testPhp53LegacySyntaxCheckpoint(): void + { + $this->assertLegacyVersionCheckpoint( + '5.3', + <<<'PHP' +getClasses(); + $phpInterfaces = $phpCode->getInterfaces(); + $phpFunctions = $phpCode->getFunctions(); + + static::assertArrayHasKey('Legacy53\FeatureSet', $phpClasses); + static::assertArrayHasKey('Legacy53\Contract', $phpInterfaces); + static::assertArrayHasKey('Legacy53\helper', $phpFunctions); + static::assertSame('5.3', $phpClasses['Legacy53\FeatureSet']->constants['VERSION']->value); + static::assertSame(['legacy'], $phpClasses['Legacy53\FeatureSet']->properties['items']->defaultValue); + static::assertSame('array', $phpFunctions['Legacy53\helper']->parameters['input']->typeFromDefaultValue); + } + ); + } + + public function testPhp54LegacySyntaxCheckpoint(): void + { + $this->assertLegacyVersionCheckpoint( + '5.4', + <<<'PHP' + true]; + } +} + +class FeatureSet +{ + use TimestampTrait; + + public function walk(callable $callback, array $items = []) + { + return $callback($items); + } +} +PHP, + static function (\voku\SimplePhpParser\Parsers\Helper\ParserContainer $phpCode): void { + $phpClasses = $phpCode->getClasses(); + $phpTraits = $phpCode->getTraits(); + + static::assertArrayHasKey('Legacy54\FeatureSet', $phpClasses); + static::assertArrayHasKey('Legacy54\TimestampTrait', $phpTraits); + static::assertArrayHasKey('walk', $phpClasses['Legacy54\FeatureSet']->methods); + static::assertSame('callable', $phpClasses['Legacy54\FeatureSet']->methods['walk']->parameters['callback']->type); + static::assertSame([], $phpClasses['Legacy54\FeatureSet']->methods['walk']->parameters['items']->defaultValue); + } + ); + } + + public function testPhp55LegacySyntaxCheckpoint(): void + { + $this->assertLegacyVersionCheckpoint( + '5.5', + <<<'PHP' +getClasses(); + + static::assertArrayHasKey('Legacy55\FeatureSet', $phpClasses); + static::assertArrayHasKey('stream', $phpClasses['Legacy55\FeatureSet']->methods); + static::assertSame('array', $phpClasses['Legacy55\FeatureSet']->methods['stream']->parameters['items']->type); + } + ); + } + + public function testPhp56LegacySyntaxCheckpoint(): void + { + $this->assertLegacyVersionCheckpoint( + '5.6', + <<<'PHP' + 1, 'b' => 2]; + + public function joinParts($prefix, ...$parts) + { + return vsprintf($prefix, $parts); + } +} +PHP, + static function (\voku\SimplePhpParser\Parsers\Helper\ParserContainer $phpCode): void { + $phpClasses = $phpCode->getClasses(); + $class = $phpClasses['Legacy56\FeatureSet']; + + static::assertArrayHasKey('OPTIONS', $class->constants); + static::assertTrue($class->methods['joinParts']->parameters['parts']->is_vararg ?? false); + } + ); + } + + public function testPhp70LegacySyntaxCheckpoint(): void + { + $this->assertLegacyVersionCheckpoint( + '7.0', + <<<'PHP' +getClasses(); + $method = $phpClasses['Legacy70\FeatureSet']->methods['sum']; + + static::assertSame('int', $method->parameters['left']->type); + static::assertSame('int', $method->parameters['right']->type); + static::assertSame('int', $method->returnType); + } + ); + } + + public function testPhp71LegacySyntaxCheckpoint(): void + { + $this->assertLegacyVersionCheckpoint( + '7.1', + <<<'PHP' +getClasses(); + $method = $phpClasses['Legacy71\FeatureSet']->methods['hydrate']; + + static::assertSame('null|string', $method->parameters['name']->type); + static::assertSame('iterable', $method->parameters['rows']->type); + static::assertSame('void', $method->returnType); + } + ); + } + + public function testPhp72LegacySyntaxCheckpoint(): void + { + $this->assertLegacyVersionCheckpoint( + '7.2', + <<<'PHP' +getClasses(); + $method = $phpClasses['Legacy72\FeatureSet']->methods['normalize']; + + static::assertSame('object', $method->parameters['input']->type); + static::assertSame('object', $method->returnType); + } + ); + } + + public function testPhp73LegacySyntaxCheckpoint(): void + { + $this->assertLegacyVersionCheckpoint( + '7.3', + <<<'PHP' +getClasses(); + $method = $phpClasses['Legacy73\FeatureSet']->methods['render']; + + static::assertSame('array', $method->parameters['parts']->type); + static::assertSame('string', $method->returnType); + } + ); + } + + public function testPhp74LegacySyntaxCheckpoint(): void + { + $this->assertLegacyVersionCheckpoint( + '7.4', + <<<'PHP' + $value + $this->count; + + return array_map($transform, $items); + } +} +PHP, + static function (\voku\SimplePhpParser\Parsers\Helper\ParserContainer $phpCode): void { + $phpClasses = $phpCode->getClasses(); + $class = $phpClasses['Legacy74\FeatureSet']; + + static::assertSame('int', $class->properties['count']->type); + static::assertSame(0, $class->properties['count']->defaultValue); + static::assertSame('array', $class->methods['map']->parameters['items']->type); + static::assertSame('array', $class->methods['map']->returnType); + } + ); + } + public function testUnionTypes(): void { if (PHP_VERSION_ID < 80000) { @@ -442,6 +744,79 @@ public function testSimpleDirectory(): void static::assertSame('array{parsedParamTagStr: string, variableName: (null[]|string)}', $phpInterfaces[DummyInterface::class]->methods['withComplexReturnArray']->returnTypeFromPhpDocExtended); } + public function testCurrentPhpFeaturesAreAvailableViaSimpleApi(): void + { + $pathExcludeRegex = ['/Dummy5|Dummy1[0|1|3]|Dummy8/']; + if (!\class_exists(\PhpParser\Node\PropertyHook::class)) { + $pathExcludeRegex[] = '/DummyPropertyHooks|DummyPromotedPropertyHooks/'; + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__, [], $pathExcludeRegex); + + $phpClasses = $phpCode->getClasses(); + $phpInterfaces = $phpCode->getInterfaces(); + $phpTraits = $phpCode->getTraits(); + $phpEnums = $phpCode->getEnums(); + $phpFunctions = $phpCode->getFunctions(); + $phpConstants = $phpCode->getConstants(); + $phpFunctionsInfo = self::removeLocalPathForTheTest($phpCode->getFunctionsInfo()); + + static::assertArrayHasKey(Dummy::class, $phpClasses); + static::assertArrayHasKey(DummyWithAttributes::class, $phpClasses); + static::assertArrayHasKey(DummyFirstClassCallable::class, $phpClasses); + static::assertArrayHasKey(DummyOverrideChild::class, $phpClasses); + static::assertArrayHasKey(DummyCombinedSources::class, $phpClasses); + + static::assertArrayHasKey(DummyInterface::class, $phpInterfaces); + static::assertArrayHasKey(DummyTrait::class, $phpTraits); + static::assertArrayHasKey('voku\tests\foo', $phpFunctions); + static::assertArrayHasKey('LALL', $phpConstants); + static::assertArrayHasKey(DummyEnum::class, $phpEnums); + + static::assertSame('int', $phpFunctionsInfo['voku\tests\foo']['paramsTypes']['foo']['type']); + static::assertSame('int', $phpFunctionsInfo['voku\tests\foo']['paramsTypes']['foo']['typeFromDefaultValue']); + + static::assertSame('voku\tests\DummyAttribute', $phpClasses[DummyWithAttributes::class]->attributes[0]->name); + static::assertSame('\Closure', $phpClasses[DummyFirstClassCallable::class]->methods['getCallable']->returnType); + static::assertTrue($phpClasses[DummyOverrideChild::class]->methods['greet']->is_override); + static::assertSame( + 'array{status: string, retries: (int|float)}', + $phpClasses[DummyCombinedSources::class]->methods['buildSnapshot']->returnTypeFromPhpDocExtended + ); + + if (\PHP_VERSION_ID >= 80100) { + $promotedProperties = PhpCodeParser::getPhpFiles(__DIR__ . '/Dummy11.php')->getClasses(); + static::assertArrayHasKey(Dummy11::class, $promotedProperties); + static::assertSame(true, $promotedProperties[Dummy11::class]->properties['title']->is_readonly); + } + + if (\PHP_VERSION_ID >= 80200) { + $readonlyClasses = PhpCodeParser::getPhpFiles(__DIR__ . '/Dummy13.php')->getClasses(); + static::assertArrayHasKey(Dummy13::class, $readonlyClasses); + static::assertTrue($readonlyClasses[Dummy13::class]->is_readonly); + + $dnfClasses = PhpCodeParser::getPhpFiles(__DIR__ . '/Dummy15.php')->getClasses(); + static::assertArrayHasKey(Dummy15::class, $dnfClasses); + static::assertStringContainsString('Countable', (string) $dnfClasses[Dummy15::class]->methods['getDnf']->returnType); + static::assertStringContainsString('Traversable', (string) $dnfClasses[Dummy15::class]->methods['getDnf']->returnType); + static::assertStringContainsString('|', (string) $dnfClasses[Dummy15::class]->methods['getDnf']->returnType); + static::assertStringContainsString('&', (string) $dnfClasses[Dummy15::class]->methods['getDnf']->returnType); + } + + if (\PHP_VERSION_ID >= 80300) { + static::assertArrayHasKey(Dummy16::class, $phpClasses); + static::assertSame('string', $phpClasses[Dummy16::class]->constants['NAME']->typeFromDeclaration); + } + + if (\class_exists(\PhpParser\Node\PropertyHook::class)) { + $propertyHooks = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyPropertyHooks.php')->getClasses()['voku\tests\DummyPropertyHooks']; + static::assertArrayHasKey('get', $propertyHooks->properties['fullName']->hooks); + static::assertArrayHasKey('set', $propertyHooks->properties['fullName']->hooks); + static::assertSame('private', $propertyHooks->properties['email']->access_set); + static::assertSame('protected', $propertyHooks->properties['age']->access_set); + } + } + public function testSimpleStringInputClasses(): void { $code = 'getParseErrors(), 'PHP ' . $version . ' checkpoint should parse without errors'); + + $assertions($phpCode); + } + public function testEnumString(): void { if (\PHP_VERSION_ID < 80100) { @@ -1824,6 +2220,57 @@ public function testModernSyntaxParsing(): void static::assertSame('null|string', $class->methods['nullsafeExample']->returnType); } + public function testCombinedMetadataSources(): void + { + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyCombinedSources.php'); + $phpClasses = $phpCode->getClasses(); + + static::assertArrayHasKey(DummyCombinedSources::class, $phpClasses); + + $class = $phpClasses[DummyCombinedSources::class]; + $property = $class->properties['dependencyClass']; + $method = $class->methods['buildSnapshot']; + $freeze = $class->methods['freeze']; + $shapeFromPhpDoc = 'array{status: string, retries: int|float}'; + $shapeFromPhpDocExtended = 'array{status: string, retries: (int|float)}'; + + static::assertSame('voku\tests\DummyCombinedClassAttribute', $class->attributes[0]->name); + static::assertSame('combined', $class->attributes[0]->arguments['label']); + + static::assertSame('string', $property->type); + static::assertSame('class-string', $property->typeFromPhpDocExtended); + static::assertSame('string', $property->typeFromPhpDocSimple); + static::assertSame('string', $property->typeFromDefaultValue); + static::assertSame('voku\tests\DummyCombinedPropertyAttribute', $property->attributes[0]->name); + static::assertSame('reflection', $property->attributes[0]->arguments['source']); + + static::assertSame('Build a payload snapshot.', $method->summary); + static::assertSame('Collects native types, advanced phpDoc types and reflection metadata together.', $method->description); + static::assertSame('array', $method->returnType); + static::assertSame($shapeFromPhpDoc, $method->returnTypeFromPhpDoc); + static::assertSame($shapeFromPhpDocExtended, $method->returnTypeFromPhpDocExtended); + static::assertSame('voku\tests\DummyCombinedMethodAttribute', $method->attributes[0]->name); + static::assertSame('method', $method->attributes[0]->arguments['label']); + + static::assertSame('array', $method->parameters['payload']->type); + static::assertSame($shapeFromPhpDoc, $method->parameters['payload']->typeFromPhpDoc); + static::assertSame('array', $method->parameters['payload']->typeFromPhpDocSimple); + static::assertSame($shapeFromPhpDocExtended, $method->parameters['payload']->typeFromPhpDocExtended); + static::assertSame('voku\tests\DummyCombinedParameterAttribute', $method->parameters['payload']->attributes[0]->name); + static::assertSame('payload', $method->parameters['payload']->attributes[0]->arguments['label']); + + static::assertSame('callable', $method->parameters['formatter']->type); + static::assertSame('callable(string): string', $method->parameters['formatter']->typeFromPhpDocExtended); + static::assertSame('voku\tests\DummyCombinedParameterAttribute', $method->parameters['formatter']->attributes[0]->name); + static::assertSame('formatter', $method->parameters['formatter']->attributes[0]->arguments['label']); + + static::assertTrue($method->parameters['withMeta']->defaultValue); + static::assertSame('bool', $method->parameters['withMeta']->typeFromDefaultValue); + + static::assertSame('DateTimeImmutable', self::normalizeTypeForAssertion($freeze->parameters['at']->type)); + static::assertSame('DateTimeImmutable', self::normalizeTypeForAssertion($freeze->returnType)); + } + public function testAttributeFromStringInput(): void { $code = '