diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c4ee87..8a2f184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,19 +17,19 @@ jobs: fail-fast: false matrix: php: [ - 7.4, - 8.0, 8.1, - 8.2 + 8.2, + 8.3, + 8.4 ] composer: [basic] timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup PHP - uses: shivammathur/setup-php@2.25.5 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: ${{ matrix.php }} coverage: xdebug @@ -38,10 +38,10 @@ jobs: - name: Determine composer cache directory id: composer-cache - run: echo "::set-output name=directory::$(composer config cache-dir)" + run: echo "directory=$(composer config cache-dir)" >> "$GITHUB_OUTPUT" - name: Cache composer dependencies - uses: actions/cache@v3.3.1 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ steps.composer-cache.outputs.directory }} key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} @@ -49,10 +49,6 @@ jobs: - name: Install dependencies run: | - if [[ "${{ matrix.php }}" == "8.0" ]]; then - composer require phpstan/phpstan --no-update - fi; - if [[ "${{ matrix.composer }}" == "lowest" ]]; then composer update --prefer-dist --no-interaction --prefer-lowest --prefer-stable fi; @@ -70,7 +66,7 @@ jobs: - name: Run phpstan continue-on-error: true - if: ${{ matrix.php == '7.4' }} + if: ${{ matrix.php == '8.3' }} run: | php vendor/bin/phpstan analyse @@ -82,13 +78,13 @@ jobs: php-coveralls --coverage_clover=build/logs/clover.xml -v - name: Upload coverage results to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 with: files: build/logs/clover.xml - name: Archive logs artifacts if: ${{ failure() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: logs_composer-${{ matrix.composer }}_php-${{ matrix.php }} path: | diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index c71272d..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,3 +0,0 @@ -tools: - external_code_coverage: - timeout: 800 diff --git a/README.md b/README.md index 5e2020a..3d7e489 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,15 @@ You can simply scan a string, a file or a full directory and you can see a simple data structure from your php code. - Classes (**PHPClass**) -- Properties (**PHPProperties**) +- Properties (**PHPProperty**) - Constants (**PHPConst**) - Methods (**PHPMethod**) - Interfaces (**PHPInterface**) - Traits (**PHPTrait**) +- Enums (**PHPEnum**) - Functions (**PHPFunction**) - Parameter (**PHPParameter**) +- Attributes (**PHPAttribute**) This code is forked from [JetBrains/phpstorm-stubs](https://github.com/JetBrains/phpstorm-stubs/tree/master/tests) but you can't use the classes from "phpstorm-stubs" directly, because they are in a test namespace and the autoloader is "autoload-dev", so here is a extended version. @@ -27,6 +29,31 @@ We will use: - [phpDocumentor](https://github.com/phpDocumentor/) - [PHPStan/phpdoc-parser](https://github.com/phpstan/phpdoc-parser) +### Requirements + +- PHP >= 8.1 + +### Supported PHP Features + +| Feature | PHP Version | Supported | +|---|---|---| +| Attributes (class, method, property, parameter, constant) | 8.0+ | ✅ | +| Constructor property promotion | 8.0+ | ✅ | +| Union types | 8.0+ | ✅ | +| Named arguments | 8.0+ | ✅ | +| Match expressions | 8.0+ | ✅ | +| Nullsafe operator | 8.0+ | ✅ | +| Enums (unit, string-backed, int-backed) | 8.1+ | ✅ | +| Readonly properties | 8.1+ | ✅ | +| Intersection types | 8.1+ | ✅ | +| `never` return type | 8.1+ | ✅ | +| First-class callable syntax | 8.1+ | ✅ | +| Readonly classes | 8.2+ | ✅ | +| DNF types | 8.2+ | ✅ | +| Standalone `true`, `false`, `null` types | 8.2+ | ✅ | +| Trait constants | 8.2+ | ✅ | +| Typed class constants | 8.3+ | ✅ | +| `#[\Override]` attribute detection | 8.3+ | ✅ | ### Install via "composer require" @@ -96,19 +123,42 @@ $phpClasses = $phpCode->getClasses(); var_dump($phpClasses[Dummy::class]); // "PHPClass"-object ```` +Access enums: +```php +$phpCode = \voku\SimplePhpParser\Parsers\PhpCodeParser::getPhpFiles(__DIR__ . '/src'); +$phpEnums = $phpCode->getEnums(); +// PHPEnum objects with scalarType, cases, methods, constants, attributes +```` + +Access attributes: +```php +$phpCode = \voku\SimplePhpParser\Parsers\PhpCodeParser::getPhpFiles(__DIR__ . '/src'); +$phpClasses = $phpCode->getClasses(); +$class = $phpClasses['MyClass']; + +// Class-level attributes +foreach ($class->attributes as $attr) { + echo $attr->name; // e.g. "MyAttribute" + print_r($attr->arguments); // constructor arguments (array) +} + +// Method/property/parameter attributes work the same way +foreach ($class->methods['myMethod']->attributes as $attr) { ... } +foreach ($class->properties['myProp']->attributes as $attr) { ... } +foreach ($class->methods['myMethod']->parameters['param']->attributes as $attr) { ... } +```` + ### Support -For support and donations please visit [Github](https://github.com/voku/simple_html_dom/) | [Issues](https://github.com/voku/simple_html_dom/issues) | [PayPal](https://paypal.me/moelleken) | [Patreon](https://www.patreon.com/voku). +For support and donations please visit [GitHub](https://github.com/voku/Simple-PHP-Code-Parser/) | [Issues](https://github.com/voku/Simple-PHP-Code-Parser/issues) | [PayPal](https://paypal.me/moelleken) | [Patreon](https://www.patreon.com/voku). -For status updates and release announcements please visit [Releases](https://github.com/voku/simple_html_dom/releases) | [Twitter](https://twitter.com/suckup_de) | [Patreon](https://www.patreon.com/voku/posts). +For status updates and release announcements please visit [Releases](https://github.com/voku/Simple-PHP-Code-Parser/releases) | [Patreon](https://www.patreon.com/voku/posts). For professional support please contact [me](https://about.me/voku). ### Thanks -- Thanks to [GitHub](https://github.com) (Microsoft) for hosting the code and a good infrastructure including Issues-Managment, etc. +- Thanks to [GitHub](https://github.com) (Microsoft) for hosting the code and a good infrastructure including Issues-Management, etc. - Thanks to [IntelliJ](https://www.jetbrains.com) as they make the best IDEs for PHP and they gave me an open source license for PhpStorm! -- Thanks to [Travis CI](https://travis-ci.com/) for being the most awesome, easiest continous integration tool out there! -- Thanks to [StyleCI](https://styleci.io/) for the simple but powerfull code style check. - Thanks to [PHPStan](https://github.com/phpstan/phpstan) && [Psalm](https://github.com/vimeo/psalm) for really great Static analysis tools and for discover bugs in the code! diff --git a/composer.json b/composer.json index 8ecd799..a9d7c04 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ } ], "require": { - "php": ">=7.4", + "php": ">=8.1", "react/async": "~3.0.0 || ~4.1.0", "react/filesystem": "^0.2@dev", "phpdocumentor/type-resolver": "~1.7.2", @@ -24,10 +24,11 @@ "phpdocumentor/reflection-common": "~2.2", "phpstan/phpdoc-parser": "~1.23", "voku/simple-cache": "~4.1", - "nikic/php-parser": "~4.16" + "nikic/php-parser": "~4.18" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~9.6", + "phpstan/phpstan": "^1.10" }, "autoload": { "psr-4": { diff --git a/src/voku/SimplePhpParser/Model/BasePHPClass.php b/src/voku/SimplePhpParser/Model/BasePHPClass.php index 90cf08f..b460dd6 100644 --- a/src/voku/SimplePhpParser/Model/BasePHPClass.php +++ b/src/voku/SimplePhpParser/Model/BasePHPClass.php @@ -23,6 +23,13 @@ abstract class BasePHPClass extends BasePHPElement */ public array $constants = []; + /** + * PHP 8.0+ attributes on this class/interface/trait/enum. + * + * @var PHPAttribute[] + */ + public array $attributes = []; + public ?bool $is_final = null; public ?bool $is_abstract = null; diff --git a/src/voku/SimplePhpParser/Model/PHPAttribute.php b/src/voku/SimplePhpParser/Model/PHPAttribute.php new file mode 100644 index 0000000..0d06df0 --- /dev/null +++ b/src/voku/SimplePhpParser/Model/PHPAttribute.php @@ -0,0 +1,29 @@ + + */ + public array $arguments = []; + + public function __construct(string $name, array $arguments = []) + { + $this->name = $name; + $this->arguments = $arguments; + } +} diff --git a/src/voku/SimplePhpParser/Model/PHPClass.php b/src/voku/SimplePhpParser/Model/PHPClass.php index 9246244..8669f0c 100644 --- a/src/voku/SimplePhpParser/Model/PHPClass.php +++ b/src/voku/SimplePhpParser/Model/PHPClass.php @@ -50,13 +50,26 @@ public function readObjectFromPhpNode($node, $dummy = null): self $this->is_anonymous = $node->isAnonymous(); + // Extract PHP 8.0+ attributes + if (!empty($node->attrGroups)) { + $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups); + } + + // PHP < 8.2 raises an uncatchable E_COMPILE_ERROR for certain PHP 8.2+ syntax + // (standalone true/false/null types, DNF types, readonly class). Similarly, + // PHP < 8.3 raises an error for PHP 8.3+ syntax (typed class constants). + // Skip autoloading in those cases; AST data is still read from the node below. + $canAutoload = (\PHP_VERSION_ID >= 80200 || !self::nodeUsesPHP82PlusSyntax($node)) + && (\PHP_VERSION_ID >= 80300 || !self::nodeUsesPHP83PlusSyntax($node)); $classExists = false; - try { - if (\class_exists($this->name, true)) { - $classExists = true; + if ($canAutoload) { + try { + if (\class_exists($this->name, true)) { + $classExists = true; + } + } catch (\Throwable $e) { + // nothing } - } catch (\Exception $e) { - // nothing } if ($classExists) { $reflectionClass = Utils::createClassReflectionInstance($this->name); @@ -156,6 +169,9 @@ public function readObjectFromReflection($clazz): self $this->is_iterable = $clazz->isIterable(); + // Extract PHP 8.0+ attributes + $this->attributes = Utils::extractAttributesFromReflection($clazz); + $parent = $clazz->getParentClass(); if ($parent) { $this->parentClass = $parent->getName(); @@ -169,7 +185,7 @@ public function readObjectFromReflection($clazz): self ) { $classExists = true; } - } catch (\Exception $e) { + } catch (\Throwable $e) { // nothing } if ($classExists) { @@ -442,4 +458,102 @@ private function readPhpDocProperties($doc): void $this->parseError[\md5($tmpErrorMessage)] = $tmpErrorMessage; } } + + /** + * Returns true if the class node uses syntax that requires PHP 8.2+ and would + * cause an uncatchable E_COMPILE_ERROR when autoloaded on PHP < 8.2. + * + * @param Class_ $node + * + * @return bool + */ + private static function nodeUsesPHP82PlusSyntax(Class_ $node): bool + { + // readonly class is PHP 8.2+ + if (\method_exists($node, 'isReadOnly') && $node->isReadOnly()) { + return true; + } + + foreach ($node->stmts as $stmt) { + if ($stmt instanceof \PhpParser\Node\Stmt\ClassMethod) { + if (self::containsPHP82PlusType($stmt->returnType)) { + return true; + } + foreach ($stmt->params as $param) { + if (self::containsPHP82PlusType($param->type)) { + return true; + } + } + } elseif ($stmt instanceof \PhpParser\Node\Stmt\Property) { + if (self::containsPHP82PlusType($stmt->type)) { + return true; + } + } + } + + return false; + } + + /** + * Returns true if the class node uses syntax that requires PHP 8.3+ and would + * cause an uncatchable E_COMPILE_ERROR when autoloaded on PHP < 8.3. + * + * Covers: typed class constants (Stmt\ClassConst with a non-null type). + * + * @param Class_ $node + * + * @return bool + */ + private static function nodeUsesPHP83PlusSyntax(Class_ $node): bool + { + foreach ($node->stmts as $stmt) { + // Typed class constants are PHP 8.3+ + if ($stmt instanceof \PhpParser\Node\Stmt\ClassConst && $stmt->type !== null) { + return true; + } + } + + return false; + } + + /** + * Returns true if the given type node is a PHP 8.2+ type that causes an + * uncatchable E_COMPILE_ERROR when loaded on PHP < 8.2. + * + * Covers: standalone true/false/null types and DNF types (union of intersections). + * + * @param \PhpParser\Node|null $typeNode + * + * @return bool + */ + private static function containsPHP82PlusType($typeNode): bool + { + if ($typeNode === null) { + return false; + } + + // Standalone true, false, null as the *sole* type (not in a nullable like ?string) + // are PHP 8.2+ only. PHP-Parser represents these as Identifier nodes (not Name). + // Nullable null (?null) is syntactically invalid; NullableType wraps the inner type. + if ($typeNode instanceof \PhpParser\Node\Identifier) { + $name = \strtolower($typeNode->name); + return $name === 'true' || $name === 'false' || $name === 'null'; + } + + // DNF types: union type containing an intersection type (PHP 8.2+) + if ($typeNode instanceof \PhpParser\Node\UnionType) { + foreach ($typeNode->types as $t) { + if ($t instanceof \PhpParser\Node\IntersectionType || self::containsPHP82PlusType($t)) { + return true; + } + } + } + + // Recurse into nullable type + if ($typeNode instanceof \PhpParser\Node\NullableType) { + return self::containsPHP82PlusType($typeNode->type); + } + + return false; + } } diff --git a/src/voku/SimplePhpParser/Model/PHPConst.php b/src/voku/SimplePhpParser/Model/PHPConst.php index 0d2c281..9db7005 100644 --- a/src/voku/SimplePhpParser/Model/PHPConst.php +++ b/src/voku/SimplePhpParser/Model/PHPConst.php @@ -29,6 +29,18 @@ class PHPConst extends BasePHPElement public ?string $type = null; + /** + * Type from the constant's native type declaration (PHP 8.3+). + */ + public ?string $typeFromDeclaration = null; + + /** + * PHP 8.0+ attributes on this constant. + * + * @var PHPAttribute[] + */ + public array $attributes = []; + /** * @param Const_ $node * @param null $dummy @@ -57,6 +69,19 @@ public function readObjectFromPhpNode($node, $dummy = null): self } $this->parentName = self::getFQN($parentNode->getAttribute('parent')); + + // Typed class constants (PHP 8.3+) + if (\property_exists($parentNode, 'type') && $parentNode->type !== null) { + $typeDeclStr = Utils::typeNodeToString($parentNode->type); + if ($typeDeclStr !== null) { + $this->typeFromDeclaration = $typeDeclStr; + } + } + + // Extract PHP 8.0+ attributes (only if not already populated by reflection) + if (empty($this->attributes) && !empty($parentNode->attrGroups)) { + $this->attributes = Utils::extractAttributesFromAstNode($parentNode->attrGroups); + } } $this->collectTags($node); @@ -95,6 +120,17 @@ public function readObjectFromReflection($constant): self $this->visibility = 'public'; } + // Typed class constants (PHP 8.3+) + if (\method_exists($constant, 'getType')) { + $reflType = $constant->getType(); + if ($reflType !== null) { + $this->typeFromDeclaration = (string) $reflType; + } + } + + // Extract PHP 8.0+ attributes + $this->attributes = Utils::extractAttributesFromReflection($constant); + return $this; } diff --git a/src/voku/SimplePhpParser/Model/PHPEnum.php b/src/voku/SimplePhpParser/Model/PHPEnum.php new file mode 100644 index 0000000..f56bf9c --- /dev/null +++ b/src/voku/SimplePhpParser/Model/PHPEnum.php @@ -0,0 +1,208 @@ + + */ + public array $cases = []; + + /** + * @param Enum_ $node + * @param null $dummy + * + * @return $this + */ + public function readObjectFromPhpNode($node, $dummy = null): self + { + $this->prepareNode($node); + + $this->name = static::getFQN($node); + + if ($node->scalarType !== null) { + $this->scalarType = $node->scalarType->toString(); + } + + // Extract PHP 8.0+ attributes + if (!empty($node->attrGroups)) { + $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups); + } + + $enumExists = false; + try { + if (\class_exists($this->name, true) || \enum_exists($this->name, true)) { + $enumExists = true; + } + } catch (\Throwable $e) { + // nothing + } + if ($enumExists) { + $reflectionEnum = Utils::createClassReflectionInstance($this->name); + $this->readObjectFromReflection($reflectionEnum); + } + + $this->collectTags($node); + + // Extract enum cases from AST + foreach ($node->stmts as $stmt) { + if ($stmt instanceof EnumCase) { + $caseName = $stmt->name->name; + $caseValue = null; + if ($stmt->expr !== null) { + $caseValue = Utils::getPhpParserValueFromNode($stmt->expr); + if ($caseValue === Utils::GET_PHP_PARSER_VALUE_FROM_NODE_HELPER) { + $caseValue = null; + } + } + $this->cases[$caseName] = $caseValue; + } + } + + if (!empty($node->implements)) { + foreach ($node->implements as $interfaceObject) { + $interfaceFQN = \implode('\\', $interfaceObject->getParts()); + /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */ + /** @var class-string $interfaceFQN */ + $interfaceFQN = $interfaceFQN; + $this->interfaces[$interfaceFQN] = $interfaceFQN; + } + } + + foreach ($node->getMethods() as $method) { + $methodNameTmp = $method->name->name; + + if (isset($this->methods[$methodNameTmp])) { + $this->methods[$methodNameTmp] = $this->methods[$methodNameTmp]->readObjectFromPhpNode($method, $this->name); + } else { + $this->methods[$methodNameTmp] = (new PHPMethod($this->parserContainer))->readObjectFromPhpNode($method, $this->name); + } + + if (!$this->methods[$methodNameTmp]->file) { + $this->methods[$methodNameTmp]->file = $this->file; + } + } + + foreach ($node->getConstants() as $constNode) { + foreach ($constNode->consts as $const) { + $constNameTmp = $const->name->name; + + if (isset($this->constants[$constNameTmp])) { + $this->constants[$constNameTmp] = $this->constants[$constNameTmp]->readObjectFromPhpNode($const); + } else { + $this->constants[$constNameTmp] = (new PHPConst($this->parserContainer))->readObjectFromPhpNode($const); + } + + if (!$this->constants[$constNameTmp]->file) { + $this->constants[$constNameTmp]->file = $this->file; + } + } + } + + return $this; + } + + /** + * @param ReflectionClass $clazz + * + * @return $this + */ + public function readObjectFromReflection($clazz): self + { + $this->name = $clazz->getName(); + + if (!$this->line) { + $lineTmp = $clazz->getStartLine(); + if ($lineTmp !== false) { + $this->line = $lineTmp; + } + } + + $file = $clazz->getFileName(); + if ($file) { + $this->file = $file; + } + + $this->is_final = $clazz->isFinal(); + + // Extract PHP 8.0+ attributes + $this->attributes = Utils::extractAttributesFromReflection($clazz); + + if ($clazz instanceof ReflectionEnum) { + $backingType = $clazz->getBackingType(); + if ($backingType !== null) { + if (\method_exists($backingType, 'getName')) { + $this->scalarType = $backingType->getName(); + } else { + $this->scalarType = (string) $backingType; + } + } + + foreach ($clazz->getCases() as $case) { + $caseName = $case->getName(); + $caseValue = null; + if ($clazz->isBacked() && \method_exists($case, 'getBackingValue')) { + $caseValue = $case->getBackingValue(); + } + $this->cases[$caseName] = $caseValue; + } + } + + foreach ($clazz->getInterfaceNames() as $interfaceName) { + /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */ + /** @var class-string $interfaceName */ + $interfaceName = $interfaceName; + $this->interfaces[$interfaceName] = $interfaceName; + } + + foreach ($clazz->getMethods() as $method) { + $methodNameTmp = $method->getName(); + + $this->methods[$methodNameTmp] = (new PHPMethod($this->parserContainer))->readObjectFromReflection($method); + + if (!$this->methods[$methodNameTmp]->file) { + $this->methods[$methodNameTmp]->file = $this->file; + } + } + + foreach ($clazz->getReflectionConstants() as $constant) { + $constantNameTmp = $constant->getName(); + + $this->constants[$constantNameTmp] = (new PHPConst($this->parserContainer))->readObjectFromReflection($constant); + + if (!$this->constants[$constantNameTmp]->file) { + $this->constants[$constantNameTmp]->file = $this->file; + } + } + + return $this; + } +} diff --git a/src/voku/SimplePhpParser/Model/PHPFunction.php b/src/voku/SimplePhpParser/Model/PHPFunction.php index d907c3c..ab1af4a 100644 --- a/src/voku/SimplePhpParser/Model/PHPFunction.php +++ b/src/voku/SimplePhpParser/Model/PHPFunction.php @@ -21,6 +21,13 @@ class PHPFunction extends BasePHPElement */ public array $parameters = []; + /** + * PHP 8.0+ attributes on this function. + * + * @var PHPAttribute[] + */ + public array $attributes = []; + public ?string $returnPhpDocRaw = null; public ?string $returnType = null; @@ -49,6 +56,11 @@ public function readObjectFromPhpNode($node, $dummy = null): self $this->name = static::getFQN($node); + // Extract PHP 8.0+ attributes + if (!empty($node->attrGroups)) { + $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups); + } + /** @noinspection NotOptimalIfConditionsInspection */ if (\function_exists($this->name)) { $reflectionFunction = Utils::createFunctionReflectionInstance($this->name); @@ -57,10 +69,9 @@ public function readObjectFromPhpNode($node, $dummy = null): self if ($node->returnType) { if (!$this->returnType) { - if (\method_exists($node->returnType, 'toString')) { - $this->returnType = $node->returnType->toString(); - } elseif (\property_exists($node->returnType, 'name') && $node->returnType->name) { - $this->returnType = $node->returnType->name; + $returnTypeStr = Utils::typeNodeToString($node->returnType); + if ($returnTypeStr !== null) { + $this->returnType = $returnTypeStr; } } @@ -131,6 +142,9 @@ public function readObjectFromReflection($function): self { $this->name = $function->getName(); + // Extract PHP 8.0+ attributes + $this->attributes = Utils::extractAttributesFromReflection($function); + if (!$this->line) { $lineTmp = $function->getStartLine(); if ($lineTmp !== false) { diff --git a/src/voku/SimplePhpParser/Model/PHPInterface.php b/src/voku/SimplePhpParser/Model/PHPInterface.php index c06857f..9da54a0 100644 --- a/src/voku/SimplePhpParser/Model/PHPInterface.php +++ b/src/voku/SimplePhpParser/Model/PHPInterface.php @@ -34,12 +34,17 @@ public function readObjectFromPhpNode($node, $dummy = null): self $this->name = static::getFQN($node); + // Extract PHP 8.0+ attributes + if (!empty($node->attrGroups)) { + $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups); + } + $interfaceExists = false; try { if (\interface_exists($this->name, true)) { $interfaceExists = true; } - } catch (\Exception $e) { + } catch (\Throwable $e) { // nothing } if ($interfaceExists) { @@ -105,6 +110,9 @@ public function readObjectFromReflection($interface): self $this->is_iterable = $interface->isIterable(); + // Extract PHP 8.0+ attributes + $this->attributes = Utils::extractAttributesFromReflection($interface); + foreach ($interface->getMethods() as $method) { $this->methods[$method->getName()] = (new PHPMethod($this->parserContainer))->readObjectFromReflection($method); } @@ -123,7 +131,7 @@ public function readObjectFromReflection($interface): self ) { $interfaceExists = true; } - } catch (\Exception $e) { + } catch (\Throwable $e) { // nothing } if ($interfaceExists) { diff --git a/src/voku/SimplePhpParser/Model/PHPMethod.php b/src/voku/SimplePhpParser/Model/PHPMethod.php index 182c05d..11f8f41 100644 --- a/src/voku/SimplePhpParser/Model/PHPMethod.php +++ b/src/voku/SimplePhpParser/Model/PHPMethod.php @@ -20,6 +20,11 @@ class PHPMethod extends PHPFunction public ?bool $is_inheritdoc = null; + /** + * Whether the method has the #[\Override] attribute. + */ + public ?bool $is_override = null; + /** * @phpstan-var null|class-string */ @@ -61,10 +66,9 @@ public function readObjectFromPhpNode($node, $classStr = null): PHPFunction if ($node->returnType) { if (!$this->returnType) { - if (\method_exists($node->returnType, 'toString')) { - $this->returnType = $node->returnType->toString(); - } elseif (\property_exists($node->returnType, 'name') && $node->returnType->name) { - $this->returnType = $node->returnType->name; + $returnTypeStr = Utils::typeNodeToString($node->returnType); + if ($returnTypeStr !== null) { + $this->returnType = $returnTypeStr; } } @@ -94,6 +98,22 @@ public function readObjectFromPhpNode($node, $classStr = null): PHPFunction $this->is_static = $node->isStatic(); + // Extract PHP 8.0+ attributes (only if not already populated by reflection) + if (empty($this->attributes) && !empty($node->attrGroups)) { + $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups); + } + + // Detect #[\Override] (PHP 8.3+) + if ($this->is_override === null) { + foreach ($this->attributes as $attr) { + if ($attr->name === 'Override') { + $this->is_override = true; + + break; + } + } + } + if ($node->isPrivate()) { $this->access = 'private'; } elseif ($node->isProtected()) { @@ -152,6 +172,18 @@ public function readObjectFromReflection($method): PHPFunction $this->is_final = $method->isFinal(); + // Extract PHP 8.0+ attributes + $this->attributes = Utils::extractAttributesFromReflection($method); + + // Detect #[\Override] (PHP 8.3+) + foreach ($this->attributes as $attr) { + if ($attr->name === 'Override') { + $this->is_override = true; + + break; + } + } + $returnType = $method->getReturnType(); if ($returnType !== null) { if (\method_exists($returnType, 'getName')) { diff --git a/src/voku/SimplePhpParser/Model/PHPParameter.php b/src/voku/SimplePhpParser/Model/PHPParameter.php index 2175cdf..440ff4a 100644 --- a/src/voku/SimplePhpParser/Model/PHPParameter.php +++ b/src/voku/SimplePhpParser/Model/PHPParameter.php @@ -37,6 +37,13 @@ class PHPParameter extends BasePHPElement public ?bool $is_inheritdoc = null; + /** + * PHP 8.0+ attributes on this parameter. + * + * @var PHPAttribute[] + */ + public array $attributes = []; + /** * @param Param $parameter * @param FunctionLike $node @@ -74,13 +81,9 @@ public function readObjectFromPhpNode($parameter, $node = null, $classStr = null if ($parameter->type !== null) { if (!$this->type) { - if (\method_exists($parameter->type, 'getParts')) { - $parts = $parameter->type->getParts(); - if (!empty($parts)) { - $this->type = '\\' . \implode('\\', $parts); - } - } elseif (\property_exists($parameter->type, 'name')) { - $this->type = $parameter->type->name; + $typeStr = Utils::typeNodeToString($parameter->type); + if ($typeStr !== null) { + $this->type = $typeStr; } } @@ -106,6 +109,11 @@ public function readObjectFromPhpNode($parameter, $node = null, $classStr = null $this->is_passed_by_ref = $parameter->byRef; + // Extract PHP 8.0+ attributes (only if not already populated by reflection) + if (empty($this->attributes) && !empty($parameter->attrGroups)) { + $this->attributes = Utils::extractAttributesFromAstNode($parameter->attrGroups); + } + return $this; } @@ -182,6 +190,9 @@ public function readObjectFromReflection($parameter): self $this->is_passed_by_ref = $parameter->isPassedByReference(); + // Extract PHP 8.0+ attributes + $this->attributes = Utils::extractAttributesFromReflection($parameter); + return $this; } diff --git a/src/voku/SimplePhpParser/Model/PHPProperty.php b/src/voku/SimplePhpParser/Model/PHPProperty.php index 5f90f20..19a1e7e 100644 --- a/src/voku/SimplePhpParser/Model/PHPProperty.php +++ b/src/voku/SimplePhpParser/Model/PHPProperty.php @@ -41,6 +41,13 @@ class PHPProperty extends BasePHPElement public ?bool $is_inheritdoc = null; + /** + * PHP 8.0+ attributes on this property. + * + * @var PHPAttribute[] + */ + public array $attributes = []; + /** * @param Property $node * @param string|null $classStr @@ -59,6 +66,11 @@ public function readObjectFromPhpNode($node, $classStr = null): self $this->is_readonly = $node->isReadonly(); } + // Extract PHP 8.0+ attributes (only if not already populated by reflection) + if (empty($this->attributes) && !empty($node->attrGroups)) { + $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups); + } + $this->prepareNode($node); $docComment = $node->getDocComment(); @@ -74,13 +86,9 @@ public function readObjectFromPhpNode($node, $classStr = null): self if ($node->type !== null) { if (!$this->type) { - if (\method_exists($node->type, 'getParts')) { - $parts = $node->type->getParts(); - if (!empty($parts)) { - $this->type = '\\' . \implode('\\', $parts); - } - } elseif (\property_exists($node->type, 'name') && $node->type->name) { - $this->type = $node->type->name; + $typeStr = Utils::typeNodeToString($node->type); + if ($typeStr !== null) { + $this->type = $typeStr; } } @@ -129,6 +137,9 @@ public function readObjectFromReflection($property): self $this->is_static = $property->isStatic(); + // Extract PHP 8.0+ attributes + $this->attributes = Utils::extractAttributesFromReflection($property); + if ($this->is_static) { try { if (\class_exists($property->getDeclaringClass()->getName(), true)) { diff --git a/src/voku/SimplePhpParser/Model/PHPTrait.php b/src/voku/SimplePhpParser/Model/PHPTrait.php index 7335437..6a410e0 100644 --- a/src/voku/SimplePhpParser/Model/PHPTrait.php +++ b/src/voku/SimplePhpParser/Model/PHPTrait.php @@ -28,7 +28,25 @@ public function readObjectFromPhpNode($node, $dummy = null): self $this->name = static::getFQN($node); - if (\trait_exists($this->name, true)) { + // Extract PHP 8.0+ attributes + if (!empty($node->attrGroups)) { + $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups); + } + + // PHP < 8.2 raises an uncatchable E_COMPILE_ERROR for traits with constants. + // Skip autoloading in that case; constants are still read from the AST below. + $canAutoload = \PHP_VERSION_ID >= 80200 || empty($node->getConstants()); + $traitExists = false; + if ($canAutoload) { + try { + if (\trait_exists($this->name, true)) { + $traitExists = true; + } + } catch (\Throwable $e) { + // nothing + } + } + if ($traitExists) { $reflectionClass = Utils::createClassReflectionInstance($this->name); $this->readObjectFromReflection($reflectionClass); } @@ -64,6 +82,23 @@ public function readObjectFromPhpNode($node, $dummy = null): self } } + // Constants in traits (PHP 8.2+) + foreach ($node->getConstants() as $constNode) { + foreach ($constNode->consts as $const) { + $constNameTmp = $const->name->name; + + if (isset($this->constants[$constNameTmp])) { + $this->constants[$constNameTmp] = $this->constants[$constNameTmp]->readObjectFromPhpNode($const); + } else { + $this->constants[$constNameTmp] = (new PHPConst($this->parserContainer))->readObjectFromPhpNode($const); + } + + if (!$this->constants[$constNameTmp]->file) { + $this->constants[$constNameTmp]->file = $this->file; + } + } + } + return $this; } @@ -104,6 +139,9 @@ public function readObjectFromReflection($clazz): self $this->is_iterable = $clazz->isIterable(); + // Extract PHP 8.0+ attributes + $this->attributes = Utils::extractAttributesFromReflection($clazz); + foreach ($clazz->getProperties() as $property) { $propertyPhp = (new PHPProperty($this->parserContainer))->readObjectFromReflection($property); $this->properties[$propertyPhp->name] = $propertyPhp; diff --git a/src/voku/SimplePhpParser/Parsers/Helper/ParserContainer.php b/src/voku/SimplePhpParser/Parsers/Helper/ParserContainer.php index fdb2146..c34edcb 100644 --- a/src/voku/SimplePhpParser/Parsers/Helper/ParserContainer.php +++ b/src/voku/SimplePhpParser/Parsers/Helper/ParserContainer.php @@ -6,6 +6,7 @@ use voku\SimplePhpParser\Model\PHPClass; use voku\SimplePhpParser\Model\PHPConst; +use voku\SimplePhpParser\Model\PHPEnum; use voku\SimplePhpParser\Model\PHPFunction; use voku\SimplePhpParser\Model\PHPInterface; use voku\SimplePhpParser\Model\PHPTrait; @@ -47,6 +48,13 @@ class ParserContainer */ private array $interfaces = []; + /** + * @var \voku\SimplePhpParser\Model\PHPEnum[] + * + * @phpstan-var array + */ + private array $enums = []; + /** * @var string[] */ @@ -321,4 +329,37 @@ public function addInterface(PHPInterface $interface): void { $this->interfaces[$interface->name ?: \md5(\serialize($interface))] = $interface; } + + /** + * @param string $name + * + * @return \voku\SimplePhpParser\Model\PHPEnum|null + */ + public function getEnum(string $name): ?PHPEnum + { + return $this->enums[$name] ?? null; + } + + /** + * @return \voku\SimplePhpParser\Model\PHPEnum[] + */ + public function getEnums(): array + { + return $this->enums; + } + + public function addEnum(PHPEnum $enum): void + { + $this->enums[$enum->name ?: \md5(\serialize($enum))] = $enum; + } + + /** + * @param array $enums + */ + public function setEnums(array $enums): void + { + foreach ($enums as $name => $enum) { + $this->enums[$name] = $enum; + } + } } diff --git a/src/voku/SimplePhpParser/Parsers/Helper/Utils.php b/src/voku/SimplePhpParser/Parsers/Helper/Utils.php index dcd4cc4..754d471 100644 --- a/src/voku/SimplePhpParser/Parsers/Helper/Utils.php +++ b/src/voku/SimplePhpParser/Parsers/Helper/Utils.php @@ -9,6 +9,7 @@ use RecursiveIteratorIterator; use ReflectionClass; use ReflectionFunction; +use voku\SimplePhpParser\Model\PHPAttribute; final class Utils { @@ -112,7 +113,11 @@ public static function getPhpParserValueFromNode( && $node->value->name ) { - $value = implode('\\', $node->value->name->getParts()) ?: $node->value->name->name; + if ($node->value->name instanceof \PhpParser\Node\Name) { + $value = implode('\\', $node->value->name->getParts()) ?: $node->value->name->name; + } else { + $value = \is_string($node->value->name) ? $node->value->name : (string) $node->value->name; + } return $value === 'null' ? null : $value; } } @@ -203,6 +208,7 @@ public static function normalizePhpType(string $type_string, bool $sort = false) case 'false': case 'null': case 'mixed': + case 'never': return $type_string_lower; } @@ -498,6 +504,136 @@ public static function getCpuCores(): int return 1; } + /** + * Convert a PhpParser type node to a string representation. + * + * Handles Identifier, Name, NullableType, UnionType, IntersectionType + * and nested DNF types like (A&B)|C. + * + * @param \PhpParser\Node\Identifier|\PhpParser\Node\Name|\PhpParser\Node\ComplexType|null $typeNode + * + * @return string|null + */ + public static function typeNodeToString($typeNode): ?string + { + if ($typeNode === null) { + return null; + } + + if ($typeNode instanceof \PhpParser\Node\NullableType) { + $inner = self::typeNodeToString($typeNode->type); + + return $inner !== null ? 'null|' . $inner : 'null'; + } + + if ($typeNode instanceof \PhpParser\Node\UnionType) { + $parts = []; + foreach ($typeNode->types as $inner) { + if ($inner instanceof \PhpParser\Node\IntersectionType) { + $subParts = []; + foreach ($inner->types as $subType) { + $subParts[] = self::typeNodeToString($subType) ?? 'mixed'; + } + $parts[] = '(' . \implode('&', $subParts) . ')'; + } else { + $parts[] = self::typeNodeToString($inner) ?? 'mixed'; + } + } + + return \implode('|', $parts); + } + + if ($typeNode instanceof \PhpParser\Node\IntersectionType) { + $parts = []; + foreach ($typeNode->types as $inner) { + $parts[] = self::typeNodeToString($inner) ?? 'mixed'; + } + + return \implode('&', $parts); + } + + if ($typeNode instanceof \PhpParser\Node\Name) { + return '\\' . $typeNode->toString(); + } + + if (\method_exists($typeNode, 'toString')) { + return $typeNode->toString(); + } + + if (\property_exists($typeNode, 'name') && $typeNode->name) { + return $typeNode->name; + } + + return null; + } + + /** + * Extract PHPAttribute instances from AST node attribute groups. + * + * @param \PhpParser\Node\AttributeGroup[] $attrGroups + * + * @return PHPAttribute[] + */ + public static function extractAttributesFromAstNode(array $attrGroups): array + { + $result = []; + foreach ($attrGroups as $group) { + foreach ($group->attrs as $attr) { + // If NameResolver has already resolved the name to FullyQualified, + // use that; otherwise check resolvedName attribute, then fall back + if ($attr->name instanceof \PhpParser\Node\Name\FullyQualified) { + $name = $attr->name->toString(); + } else { + $resolvedName = $attr->name->getAttribute('resolvedName'); + if ($resolvedName instanceof \PhpParser\Node\Name) { + $name = $resolvedName->toString(); + } else { + $name = $attr->name->toString(); + } + } + + $arguments = []; + foreach ($attr->args as $arg) { + $argValue = self::getPhpParserValueFromNode($arg); + if ($argValue === self::GET_PHP_PARSER_VALUE_FROM_NODE_HELPER) { + $argValue = null; + } + + if ($arg->name !== null) { + $arguments[$arg->name->name] = $argValue; + } else { + $arguments[] = $argValue; + } + } + + $result[] = new PHPAttribute($name, $arguments); + } + } + + return $result; + } + + /** + * Extract PHPAttribute instances from a Reflection object that supports getAttributes(). + * + * @param \ReflectionClass|\ReflectionMethod|\ReflectionProperty|\ReflectionClassConstant|\ReflectionParameter|\ReflectionFunction $reflection + * + * @return PHPAttribute[] + */ + public static function extractAttributesFromReflection($reflection): array + { + if (!\method_exists($reflection, 'getAttributes')) { + return []; + } + + $result = []; + foreach ($reflection->getAttributes() as $attr) { + $result[] = new PHPAttribute($attr->getName(), $attr->getArguments()); + } + + return $result; + } + private static function findParentClassDeclaringConstant( string $classStr, string $constantName, diff --git a/src/voku/SimplePhpParser/Parsers/PhpCodeParser.php b/src/voku/SimplePhpParser/Parsers/PhpCodeParser.php index 5645859..1a37ab2 100644 --- a/src/voku/SimplePhpParser/Parsers/PhpCodeParser.php +++ b/src/voku/SimplePhpParser/Parsers/PhpCodeParser.php @@ -5,7 +5,6 @@ namespace voku\SimplePhpParser\Parsers; use FilesystemIterator; -use PhpParser\Lexer\Emulative; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use PhpParser\ParserFactory; @@ -28,7 +27,7 @@ final class PhpCodeParser /** * @internal */ - private const CACHE_KEY_HELPER = 'simple-php-code-parser-v4-'; + private const CACHE_KEY_HELPER = 'simple-php-code-parser-v5-'; /** * @param string $code @@ -119,6 +118,7 @@ public static function getPhpFiles( $parserContainer->setTraits($response->getTraits()); $parserContainer->setClasses($response->getClasses()); $parserContainer->setInterfaces($response->getInterfaces()); + $parserContainer->setEnums($response->getEnums()); $parserContainer->setConstants($response->getConstants()); $parserContainer->setFunctions($response->getFunctions()); } elseif ($response instanceof ParserErrorHandler) { @@ -200,20 +200,7 @@ public static function process( ParserContainer $parserContainer, ASTVisitor $visitor ) { - $parser = (new ParserFactory())->create( - ParserFactory::PREFER_PHP7, - new Emulative( - [ - 'usedAttributes' => [ - 'comments', - 'startLine', - 'endLine', - 'startTokenPos', - 'endTokenPos', - ], - ] - ) - ); + $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); $errorHandler = new ParserErrorHandler(); @@ -233,11 +220,21 @@ public static function process( $visitor->fileName = $fileName; - $traverser = new NodeTraverser(); - $traverser->addVisitor(new ParentConnector()); - $traverser->addVisitor($nameResolver); - $traverser->addVisitor($visitor); - $traverser->traverse($parsedCode); + // Pass 1: set parent attributes and fully resolve all names in the AST. + // NameResolver modifies Name nodes in-place (converting them to FullyQualified), + // so by the time ASTVisitor runs in pass 2, every type-hint Name node already + // carries its fully-qualified form. This is necessary because ASTVisitor processes + // class members (properties, methods) eagerly inside enterNode(Class_), before + // the single-pass traverser would have had a chance to visit those child nodes. + $traverser1 = new NodeTraverser(); + $traverser1->addVisitor(new ParentConnector()); + $traverser1->addVisitor($nameResolver); + $traverser1->traverse($parsedCode); + + // Pass 2: extract model objects from the already-resolved AST. + $traverser2 = new NodeTraverser(); + $traverser2->addVisitor($visitor); + $traverser2->traverse($parsedCode); return $parserContainer; } diff --git a/src/voku/SimplePhpParser/Parsers/Visitors/ASTVisitor.php b/src/voku/SimplePhpParser/Parsers/Visitors/ASTVisitor.php index 571bcfc..f31db51 100644 --- a/src/voku/SimplePhpParser/Parsers/Visitors/ASTVisitor.php +++ b/src/voku/SimplePhpParser/Parsers/Visitors/ASTVisitor.php @@ -8,6 +8,7 @@ use PhpParser\Node\Const_; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\Enum_; use PhpParser\Node\Stmt\Function_; use PhpParser\Node\Stmt\Interface_; use PhpParser\Node\Stmt\Trait_; @@ -15,6 +16,7 @@ use voku\SimplePhpParser\Model\PHPClass; use voku\SimplePhpParser\Model\PHPConst; use voku\SimplePhpParser\Model\PHPDefineConstant; +use voku\SimplePhpParser\Model\PHPEnum; use voku\SimplePhpParser\Model\PHPFunction; use voku\SimplePhpParser\Model\PHPInterface; use voku\SimplePhpParser\Model\PHPTrait; @@ -70,10 +72,17 @@ public function enterNode(Node $node) $this->parserContainer->addConstant($constant); } elseif (($phpCodeParentConstantName = $this->parserContainer->getClass($constant->parentName)) !== null) { $phpCodeParentConstantName->constants[$constant->name] = $constant; + } elseif (($enum = $this->parserContainer->getEnum($constant->parentName)) !== null) { + $enum->constants[$constant->name] = $constant; } else { $interface = $this->parserContainer->getInterface($constant->parentName); if ($interface) { $interface->constants[$constant->name] = $constant; + } else { + $trait = $this->parserContainer->getTrait($constant->parentName); + if ($trait) { + $trait->constants[$constant->name] = $constant; + } } } @@ -128,6 +137,17 @@ public function enterNode(Node $node) break; + case $node instanceof Enum_: + + $enum = new PHPEnum($this->parserContainer); + $enum = $enum->readObjectFromPhpNode($node); + if (!$enum->file) { + $enum->file = $this->fileName; + } + $this->parserContainer->addEnum($enum); + + break; + default: // DEBUG diff --git a/tests/Dummy14.php b/tests/Dummy14.php new file mode 100644 index 0000000..adb93d5 --- /dev/null +++ b/tests/Dummy14.php @@ -0,0 +1,43 @@ + 'red', + self::Clubs, self::Spades => 'black', + }; + } +} diff --git a/tests/DummyEnumInt.php b/tests/DummyEnumInt.php new file mode 100644 index 0000000..967da41 --- /dev/null +++ b/tests/DummyEnumInt.php @@ -0,0 +1,24 @@ + 'Low', + self::Medium => 'Medium', + self::High => 'High', + }; + } +} diff --git a/tests/DummyEnumUnit.php b/tests/DummyEnumUnit.php new file mode 100644 index 0000000..b5c6ceb --- /dev/null +++ b/tests/DummyEnumUnit.php @@ -0,0 +1,15 @@ + 'Active', + 'inactive' => 'Inactive', + default => 'Unknown', + }; + } + + public function namedArgExample(): string + { + return \implode(separator: ', ', array: ['a', 'b']); + } + + public function nullsafeExample(?object $obj): ?string + { + return $obj?->toString(); + } +} diff --git a/tests/DummyOverride.php b/tests/DummyOverride.php new file mode 100644 index 0000000..34462f7 --- /dev/null +++ b/tests/DummyOverride.php @@ -0,0 +1,39 @@ +getClasses(); - static::assertSame('\Foooooooo', $phpClasses[Dummy8::class]->properties['foooooooo']->defaultValue); + static::assertSame('\voku\tests\Foooooooo', $phpClasses[Dummy8::class]->properties['foooooooo']->defaultValue); static::assertSame(Dummy8::class, $phpClasses[Dummy8::class]->name); @@ -203,7 +203,7 @@ public function testSimpleOneClassWithTrait(): void ); static::assertSame( - 'array{stdClass: \stdClass, numbers: int|float $lall', + 'array{stdClass: \stdClass, numbers: int|float $lall ', $phpClasses[Dummy8::class]->methods['foo_broken']->parameters['lall']->phpDocRaw ); @@ -1183,4 +1183,437 @@ public static function removeLocalPathForTheTest(array $result): array return $helper; } + + public function testEnumString(): void + { + if (\PHP_VERSION_ID < 80100) { + static::markTestSkipped('only for PHP >= 8.1'); + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyEnum.php'); + $phpEnums = $phpCode->getEnums(); + + static::assertArrayHasKey(DummyEnum::class, $phpEnums); + + $enum = $phpEnums[DummyEnum::class]; + + static::assertSame(DummyEnum::class, $enum->name); + static::assertSame('string', $enum->scalarType); + + // Check cases + static::assertCount(4, $enum->cases); + static::assertSame('H', $enum->cases['Hearts']); + static::assertSame('D', $enum->cases['Diamonds']); + static::assertSame('C', $enum->cases['Clubs']); + static::assertSame('S', $enum->cases['Spades']); + + // Check method + static::assertArrayHasKey('color', $enum->methods); + static::assertSame('string', $enum->methods['color']->returnType); + } + + public function testEnumUnit(): void + { + if (\PHP_VERSION_ID < 80100) { + static::markTestSkipped('only for PHP >= 8.1'); + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyEnumUnit.php'); + $phpEnums = $phpCode->getEnums(); + + static::assertArrayHasKey(DummyEnumUnit::class, $phpEnums); + + $enum = $phpEnums[DummyEnumUnit::class]; + static::assertSame(DummyEnumUnit::class, $enum->name); + static::assertNull($enum->scalarType); + + // Unit enums have no backing values + static::assertCount(3, $enum->cases); + static::assertNull($enum->cases['Pending']); + static::assertNull($enum->cases['Active']); + static::assertNull($enum->cases['Closed']); + } + + public function testEnumInt(): void + { + if (\PHP_VERSION_ID < 80100) { + static::markTestSkipped('only for PHP >= 8.1'); + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyEnumInt.php'); + $phpEnums = $phpCode->getEnums(); + + static::assertArrayHasKey(DummyEnumInt::class, $phpEnums); + + $enum = $phpEnums[DummyEnumInt::class]; + static::assertSame(DummyEnumInt::class, $enum->name); + static::assertSame('int', $enum->scalarType); + + static::assertCount(3, $enum->cases); + static::assertSame(1, $enum->cases['Low']); + static::assertSame(2, $enum->cases['Medium']); + static::assertSame(3, $enum->cases['High']); + + // Check method + static::assertArrayHasKey('label', $enum->methods); + static::assertSame('string', $enum->methods['label']->returnType); + } + + public function testEnumFromString(): void + { + if (\PHP_VERSION_ID < 80100) { + static::markTestSkipped('only for PHP >= 8.1'); + } + + $code = 'value); + } + } + '; + + $phpCode = PhpCodeParser::getFromString($code); + $phpEnums = $phpCode->getEnums(); + + static::assertCount(1, $phpEnums); + $enum = \array_values($phpEnums)[0]; + static::assertSame('string', $enum->scalarType); + static::assertCount(2, $enum->cases); + static::assertSame('red', $enum->cases['Red']); + static::assertSame('blue', $enum->cases['Blue']); + static::assertArrayHasKey('label', $enum->methods); + } + + public function testIntersectionTypes(): void + { + if (\PHP_VERSION_ID < 80100) { + static::markTestSkipped('only for PHP >= 8.1'); + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/Dummy14.php'); + $phpClasses = $phpCode->getClasses(); + + static::assertArrayHasKey(Dummy14::class, $phpClasses); + + $class = $phpClasses[Dummy14::class]; + + // On PHP < 8.2, Dummy14 cannot be autoloaded (it contains PHP 8.2+ syntax such as + // standalone `null` return type), so intersection types are sourced from the AST and + // carry a leading backslash on each class-name component (FQN format). + // On PHP >= 8.2, the class is reflected and reflection's __toString() omits the backslash. + $expectedIntersection = \PHP_VERSION_ID >= 80200 + ? 'Countable&voku\tests\DummyInterface4' + : '\Countable&\voku\tests\DummyInterface4'; + + // Intersection type on property + static::assertSame($expectedIntersection, $class->properties['intersectionProp']->type); + + // Intersection type on parameter + $method = $class->methods['getIntersection']; + static::assertSame($expectedIntersection, $method->parameters['input']->type); + + // Intersection return type + static::assertSame($expectedIntersection, $method->returnType); + } + + public function testNeverReturnType(): void + { + if (\PHP_VERSION_ID < 80100) { + static::markTestSkipped('only for PHP >= 8.1'); + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/Dummy14.php'); + $phpClasses = $phpCode->getClasses(); + + static::assertSame('never', $phpClasses[Dummy14::class]->methods['neverReturn']->returnType); + } + + public function testStandaloneTypes(): void + { + if (\PHP_VERSION_ID < 80200) { + static::markTestSkipped('only for PHP >= 8.2'); + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/Dummy14.php'); + $phpClasses = $phpCode->getClasses(); + + $class = $phpClasses[Dummy14::class]; + + static::assertSame('true', $class->methods['returnTrue']->returnType); + static::assertSame('false', $class->methods['returnFalse']->returnType); + static::assertSame('null', $class->methods['returnNull']->returnType); + } + + public function testDnfTypes(): void + { + if (\PHP_VERSION_ID < 80200) { + static::markTestSkipped('only for PHP >= 8.2'); + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/Dummy15.php'); + $phpClasses = $phpCode->getClasses(); + + static::assertArrayHasKey(Dummy15::class, $phpClasses); + + $class = $phpClasses[Dummy15::class]; + + // DNF type on property: (\Countable&\Traversable)|null + $propType = $class->properties['dnfProp']->type; + static::assertNotNull($propType); + static::assertStringContainsString('Countable', $propType); + static::assertStringContainsString('Traversable', $propType); + static::assertStringContainsString('|', $propType); + static::assertStringContainsString('&', $propType); + + // DNF type on parameter + $paramType = $class->methods['getDnf']->parameters['input']->type; + static::assertNotNull($paramType); + static::assertStringContainsString('Countable', $paramType); + static::assertStringContainsString('Traversable', $paramType); + + // DNF return type + $returnType = $class->methods['getDnf']->returnType; + static::assertNotNull($returnType); + static::assertStringContainsString('Countable', $returnType); + static::assertStringContainsString('Traversable', $returnType); + } + + public function testTypedClassConstants(): void + { + if (\PHP_VERSION_ID < 80300) { + static::markTestSkipped('only for PHP >= 8.3'); + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/Dummy16.php'); + $phpClasses = $phpCode->getClasses(); + + static::assertArrayHasKey(Dummy16::class, $phpClasses); + + $class = $phpClasses[Dummy16::class]; + + static::assertSame('string', $class->constants['NAME']->typeFromDeclaration); + static::assertSame('dummy', $class->constants['NAME']->value); + static::assertSame('public', $class->constants['NAME']->visibility); + + static::assertSame('int', $class->constants['VERSION']->typeFromDeclaration); + static::assertSame(1, $class->constants['VERSION']->value); + + static::assertSame('float', $class->constants['RATIO']->typeFromDeclaration); + static::assertSame('protected', $class->constants['RATIO']->visibility); + + static::assertSame('bool', $class->constants['ACTIVE']->typeFromDeclaration); + static::assertSame('private', $class->constants['ACTIVE']->visibility); + } + + public function testTraitConstants(): void + { + if (\PHP_VERSION_ID < 80200) { + static::markTestSkipped('only for PHP >= 8.2'); + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyTrait2.php'); + $phpTraits = $phpCode->getTraits(); + + static::assertArrayHasKey(DummyTrait2::class, $phpTraits); + + $trait = $phpTraits[DummyTrait2::class]; + + static::assertArrayHasKey('TRAIT_CONST_A', $trait->constants); + static::assertSame('alpha', $trait->constants['TRAIT_CONST_A']->value); + static::assertSame('public', $trait->constants['TRAIT_CONST_A']->visibility); + + static::assertArrayHasKey('TRAIT_CONST_B', $trait->constants); + static::assertSame(42, $trait->constants['TRAIT_CONST_B']->value); + static::assertSame('protected', $trait->constants['TRAIT_CONST_B']->visibility); + + // Check trait method + static::assertArrayHasKey('traitMethod', $trait->methods); + static::assertSame('string', $trait->methods['traitMethod']->returnType); + } + + public function testEnumDirectoryParsing(): void + { + if (\PHP_VERSION_ID < 80100) { + static::markTestSkipped('only for PHP >= 8.1'); + } + + $phpCode = PhpCodeParser::getPhpFiles(__DIR__); + $phpEnums = $phpCode->getEnums(); + + // Should find all the enums we created + static::assertArrayHasKey(DummyEnum::class, $phpEnums); + static::assertArrayHasKey(DummyEnumUnit::class, $phpEnums); + static::assertArrayHasKey(DummyEnumInt::class, $phpEnums); + } + + public function testAttributesOnClass(): void + { + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyWithAttributes.php'); + $phpClasses = $phpCode->getClasses(); + + static::assertArrayHasKey(DummyWithAttributes::class, $phpClasses); + + $class = $phpClasses[DummyWithAttributes::class]; + + // Class-level attributes + static::assertNotEmpty($class->attributes); + static::assertSame('voku\tests\DummyAttribute', $class->attributes[0]->name); + static::assertSame('TestClass', $class->attributes[0]->arguments['name']); + static::assertSame(1, $class->attributes[0]->arguments['priority']); + } + + public function testAttributesOnProperty(): void + { + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyWithAttributes.php'); + $phpClasses = $phpCode->getClasses(); + + $class = $phpClasses[DummyWithAttributes::class]; + + // Property-level attributes + static::assertNotEmpty($class->properties['name']->attributes); + static::assertSame('voku\tests\DummyPropertyAttribute', $class->properties['name']->attributes[0]->name); + static::assertTrue($class->properties['name']->attributes[0]->arguments['required']); + + // Property without required arg — default value + static::assertNotEmpty($class->properties['age']->attributes); + static::assertSame('voku\tests\DummyPropertyAttribute', $class->properties['age']->attributes[0]->name); + } + + public function testAttributesOnMethod(): void + { + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyWithAttributes.php'); + $phpClasses = $phpCode->getClasses(); + + $class = $phpClasses[DummyWithAttributes::class]; + + // Method-level attributes + static::assertNotEmpty($class->methods['apiMethod']->attributes); + static::assertSame('voku\tests\DummyMethodAttribute', $class->methods['apiMethod']->attributes[0]->name); + static::assertSame('/api/test', $class->methods['apiMethod']->attributes[0]->arguments['route']); + + // Method without attributes + static::assertEmpty($class->methods['plainMethod']->attributes); + } + + public function testAttributesOnParameter(): void + { + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyWithAttributes.php'); + $phpClasses = $phpCode->getClasses(); + + $class = $phpClasses[DummyWithAttributes::class]; + + // Parameter-level attributes + $param1 = $class->methods['apiMethod']->parameters['param1']; + static::assertNotEmpty($param1->attributes); + static::assertSame('voku\tests\DummyParameterAttribute', $param1->attributes[0]->name); + static::assertSame('query', $param1->attributes[0]->arguments['type']); + + // Parameter without attributes + $param2 = $class->methods['apiMethod']->parameters['param2']; + static::assertEmpty($param2->attributes); + } + + public function testOverrideAttribute(): void + { + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyOverride.php'); + $phpClasses = $phpCode->getClasses(); + + static::assertArrayHasKey(DummyOverrideChild::class, $phpClasses); + + $child = $phpClasses[DummyOverrideChild::class]; + + // greet has #[\Override] + static::assertTrue($child->methods['greet']->is_override); + + // farewell does NOT have #[\Override] + static::assertNull($child->methods['farewell']->is_override); + + // newMethod does NOT have #[\Override] + static::assertNull($child->methods['newMethod']->is_override); + + // Also check the Override attribute is in the attributes array + static::assertNotEmpty($child->methods['greet']->attributes); + $foundOverride = false; + foreach ($child->methods['greet']->attributes as $attr) { + if ($attr->name === 'Override') { + $foundOverride = true; + } + } + static::assertTrue($foundOverride, '#[Override] should be in the attributes array'); + } + + public function testModernSyntaxParsing(): void + { + // Verify the parser doesn't choke on modern PHP 8.x syntax + $phpCode = PhpCodeParser::getPhpFiles(__DIR__ . '/DummyModernSyntax.php'); + $phpClasses = $phpCode->getClasses(); + + static::assertArrayHasKey(DummyFirstClassCallable::class, $phpClasses); + + $class = $phpClasses[DummyFirstClassCallable::class]; + + // first-class callable method has Closure return type + static::assertSame('\Closure', $class->methods['getCallable']->returnType); + + // match expression method parses fine + static::assertSame('string', $class->methods['matchExample']->returnType); + static::assertSame('string', $class->methods['matchExample']->parameters['status']->type); + + // named arguments example + static::assertSame('string', $class->methods['namedArgExample']->returnType); + + // nullsafe operator example + static::assertSame('null|string', $class->methods['nullsafeExample']->returnType); + } + + public function testAttributeFromStringInput(): void + { + $code = ' + getClasses(); + + static::assertArrayHasKey('TestTarget', $phpClasses); + + $class = $phpClasses['TestTarget']; + + // Class-level attribute + static::assertNotEmpty($class->attributes); + static::assertSame('MyCustomAttr', $class->attributes[0]->name); + static::assertSame('test', $class->attributes[0]->arguments['value']); + + // Property-level attribute + static::assertNotEmpty($class->properties['field']->attributes); + static::assertSame('MyCustomAttr', $class->properties['field']->attributes[0]->name); + static::assertSame('prop', $class->properties['field']->attributes[0]->arguments['value']); + + // Method-level attribute + static::assertNotEmpty($class->methods['doSomething']->attributes); + static::assertSame('MyCustomAttr', $class->methods['doSomething']->attributes[0]->name); + static::assertSame('method', $class->methods['doSomething']->attributes[0]->arguments['value']); + + // Parameter-level attribute + static::assertNotEmpty($class->methods['doSomething']->parameters['x']->attributes); + static::assertSame('MyCustomAttr', $class->methods['doSomething']->parameters['x']->attributes[0]->name); + static::assertSame('param', $class->methods['doSomething']->parameters['x']->attributes[0]->arguments['value']); + } }