Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cdeace8
Initial plan
Copilot Apr 11, 2026
1fae5be
test: add regression coverage for broken param phpdoc raw
Copilot Apr 11, 2026
f4f4072
test: expand broken phpdoc type regression coverage
Copilot Apr 11, 2026
d48c560
feat: recover coarse types from broken phpdoc
Copilot Apr 11, 2026
d22f7dd
refactor: keep phpdoc recovery and errors consistent
Copilot Apr 11, 2026
5180aa4
chore: tighten phpdoc recovery exception handling
Copilot Apr 11, 2026
6bc14a7
style: simplify phpdoc recovery token check
Copilot Apr 11, 2026
75671f9
feat: add PHP 8.4 property hooks and asymmetric visibility support
Copilot Apr 11, 2026
1d1d4fa
fix: skip hook params with error/empty names in PHPProperty
Copilot Apr 11, 2026
9d4fe95
fix: resolve both CI failures (phpunit.xml warning + property-hooks t…
Copilot Apr 11, 2026
5c3e936
test: expand Dummy8 blind-spot coverage (getPropertiesInfo, callable …
Copilot Apr 11, 2026
e466f5f
Add coverage for function and constant attributes
Copilot Apr 11, 2026
d034f80
Refine null-default parameter metadata assertions
Copilot Apr 11, 2026
35bf8b8
Fix PHPStan and add PHP 8.4 fallback regression tests
Copilot Apr 11, 2026
3833dc7
Deduplicate asymmetric visibility helper
Copilot Apr 11, 2026
37b9ace
Stop promoted property scan after constructor
Copilot Apr 11, 2026
f315024
Fix promoted property CI regressions
Copilot Apr 11, 2026
6fed600
Tighten promoted property merge logic
Copilot Apr 11, 2026
72f1c78
Rename promoted parameter loop variable
Copilot Apr 11, 2026
8f45db2
Exclude 8.4-only fixtures from v4 directory scans
Copilot Apr 11, 2026
d5eb4ca
Address property hook review feedback
Copilot Apr 11, 2026
cfd4ad0
Fix nullable null normalization edge case
Copilot Apr 11, 2026
d01b6b8
Reuse shared PHPClass autoload guard
Copilot Apr 11, 2026
8b03fb1
Optimize broken phpdoc recovery retries
Copilot Apr 11, 2026
9433ca1
Tighten phpdoc recovery token reuse
Copilot Apr 11, 2026
e95fe6f
Fix promoted property visibility merge
Copilot Apr 11, 2026
29aa9bd
fix: phpstan/psalm annotations always win over plain @var/@return/@pa…
Copilot Apr 11, 2026
00ed1ea
Fix two phpdoc type-parsing bugs: splitTypeAndVariable array-shape co…
Copilot Apr 11, 2026
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
5 changes: 0 additions & 5 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,4 @@
<testsuite name="Simple-PHP-Code-Parser Test Suite">
<directory>tests</directory>
</testsuite>
<source>
<include>
<directory suffix=".php">./src/</directory>
</include>
</source>
</phpunit>
59 changes: 59 additions & 0 deletions src/voku/SimplePhpParser/Model/BasePHPClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

private const PHP_VERSION_8_3_0 = 80300;

private const PHP_VERSION_8_4_0 = 80400;

/**
* @var array<string, PHPMethod>
*/
Expand Down Expand Up @@ -52,7 +54,7 @@
* Check if the parsed class-like node can be safely autoloaded on the
* current runtime without triggering fatal syntax errors from newer PHP features.
*/
protected static function canAutoloadFromPhpNode(\PhpParser\Node $node): bool

Check warning on line 57 in src/voku/SimplePhpParser/Model/BasePHPClass.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 4 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=voku_Simple-PHP-Code-Parser&issues=AZ17kbrR1Rn55vnUSkXw&open=AZ17kbrR1Rn55vnUSkXw&pullRequest=83
{
if (\PHP_VERSION_ID < self::PHP_VERSION_8_2_0 && self::containsPHP82PlusSyntax($node)) {
return false;
Expand All @@ -62,6 +64,10 @@
return false;
}

if (\PHP_VERSION_ID < self::PHP_VERSION_8_4_0 && self::containsPHP84PlusSyntax($node)) {
return false;
}

return true;
}

Expand Down Expand Up @@ -155,4 +161,57 @@

return false;
}

/**
* Detect PHP 8.4-only syntax within a class-like AST such as property hooks
* and asymmetric visibility modifiers.
*/
protected static function containsPHP84PlusSyntax(\PhpParser\Node $node): bool

Check failure on line 169 in src/voku/SimplePhpParser/Model/BasePHPClass.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=voku_Simple-PHP-Code-Parser&issues=AZ17kbrR1Rn55vnUSkXy&open=AZ17kbrR1Rn55vnUSkXy&pullRequest=83

Check warning on line 169 in src/voku/SimplePhpParser/Model/BasePHPClass.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 7 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=voku_Simple-PHP-Code-Parser&issues=AZ17kbrR1Rn55vnUSkXx&open=AZ17kbrR1Rn55vnUSkXx&pullRequest=83
{
// Property hooks (PHP 8.4+)
if ($node instanceof \PhpParser\Node\Stmt\Property && !empty($node->hooks)) {
return true;
}

// Asymmetric visibility on properties (PHP 8.4+)
if (
$node instanceof \PhpParser\Node\Stmt\Property
&& self::getAsymmetricSetVisibility($node) !== ''
) {
return true;
}

// Property hooks on promoted constructor parameters (PHP 8.4+)
if ($node instanceof \PhpParser\Node\Param && !empty($node->hooks)) {
return true;
}

// Asymmetric visibility on promoted constructor parameters (PHP 8.4+)
if (
$node instanceof \PhpParser\Node\Param
&& self::getAsymmetricSetVisibility($node) !== ''
) {
return true;
}

foreach ($node->getSubNodeNames() as $subNodeName) {
$subNode = $node->{$subNodeName};

if ($subNode instanceof \PhpParser\Node && self::containsPHP84PlusSyntax($subNode)) {
return true;
}

if (!\is_array($subNode)) {
continue;
}

foreach ($subNode as $subNodeInner) {
if ($subNodeInner instanceof \PhpParser\Node && self::containsPHP84PlusSyntax($subNodeInner)) {
return true;
}
}
}

return false;
}
}
55 changes: 55 additions & 0 deletions src/voku/SimplePhpParser/Model/BasePHPElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,59 @@
? $node->getStartLine()
: $node->getLine();
}

/**
* @phpstan-return ''|'private'|'protected'|'public'
*/
protected static function getAsymmetricSetVisibility(object $node): string

Check warning on line 110 in src/voku/SimplePhpParser/Model/BasePHPElement.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 4 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=voku_Simple-PHP-Code-Parser&issues=AZ18KZxt1Rn55vnUWtH9&open=AZ18KZxt1Rn55vnUWtH9&pullRequest=83
{
if (\method_exists($node, 'isPublicSet') && $node->isPublicSet()) {
return 'public';
}

if (\method_exists($node, 'isProtectedSet') && $node->isProtectedSet()) {
return 'protected';
}

if (\method_exists($node, 'isPrivateSet') && $node->isPrivateSet()) {
return 'private';
}

return '';
}

protected static function isPromotedParameter(\PhpParser\Node\Param $parameter): bool
{
return ($parameter->flags & \PhpParser\Node\Stmt\Class_::VISIBILITY_MODIFIER_MASK) !== 0;
}

/**
* @phpstan-return ''|'private'|'protected'|'public'
*/
protected static function getVisibilityFromModifierFlags(int $flags): string

Check warning on line 135 in src/voku/SimplePhpParser/Model/BasePHPElement.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 4 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=voku_Simple-PHP-Code-Parser&issues=AZ18aO1et-zbsGOGyms6&open=AZ18aO1et-zbsGOGyms6&pullRequest=83
{
if (($flags & \PhpParser\Node\Stmt\Class_::MODIFIER_PRIVATE) !== 0) {
return 'private';
}

if (($flags & \PhpParser\Node\Stmt\Class_::MODIFIER_PROTECTED) !== 0) {
return 'protected';
}

if (($flags & \PhpParser\Node\Stmt\Class_::MODIFIER_PUBLIC) !== 0) {
return 'public';
}

return '';
}

protected static function hasReadonlyModifier(int $flags): bool
{
return ($flags & \PhpParser\Node\Stmt\Class_::MODIFIER_READONLY) !== 0;
}
Comment on lines +152 to +155
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider adding a helper method to check for the final modifier flag, as properties (including promoted ones) can now be marked as final in PHP 8.4.

    protected static function hasReadonlyModifier(int $flags): bool
    {
        return ($flags & \PhpParser\Node\Stmt\Class_::MODIFIER_READONLY) !== 0;
    }

    protected static function hasFinalModifier(int $flags): bool
    {
        return ($flags & \PhpParser\Node\Stmt\Class_::MODIFIER_FINAL) !== 0;
    }


protected static function hasFinalModifier(int $flags): bool
{
return ($flags & \PhpParser\Node\Stmt\Class_::MODIFIER_FINAL) !== 0;
}
}
143 changes: 59 additions & 84 deletions src/voku/SimplePhpParser/Model/PHPClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,9 @@ public function readObjectFromPhpNode($node, $dummy = null): self
$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));
// Skip autoloading when the current runtime cannot safely compile newer syntax;
// AST data is still read from the node below.
$canAutoload = self::canAutoloadFromPhpNode($node);
$classExists = false;
if ($canAutoload) {
try {
Expand Down Expand Up @@ -122,6 +119,8 @@ public function readObjectFromPhpNode($node, $dummy = null): self
}
}

$this->addPromotedPropertiesFromConstructor($node);

if (!empty($node->implements)) {
foreach ($node->implements as $interfaceObject) {
$interfaceFQN = $interfaceObject->toString();
Expand Down Expand Up @@ -462,101 +461,77 @@ private function readPhpDocProperties($doc): void
}
}

/**
* 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
private function addPromotedPropertiesFromConstructor(Class_ $node): void
{
// readonly class is PHP 8.2+
if ($node->isReadonly()) {
return true;
$method = $node->getMethod('__construct');
if ($method === null) {
return;
}

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;
}
foreach ($method->params as $parameter) {
if (!self::isPromotedParameter($parameter)) {
continue;
}
}

return false;
}
$parameterVar = $parameter->var;
if (
!($parameterVar instanceof \PhpParser\Node\Expr\Variable)
|| !\is_string($parameterVar->name)
) {
continue;
}

/**
* 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;
$promotedProperty = (new PHPProperty($this->parserContainer))
->readObjectFromPromotedParam($parameter, $this->name);

$propertyName = $parameterVar->name;
$existingProperty = $this->properties[$propertyName] ?? null;
if ($existingProperty !== null) {
$this->mergePromotedPropertyData($existingProperty, $promotedProperty, $parameter);

continue;
}
}

return false;
$this->properties[$propertyName] = $promotedProperty;
}
}

/**
* 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;
private function mergePromotedPropertyData(
PHPProperty $existingProperty,
PHPProperty $promotedProperty,
\PhpParser\Node\Param $parameter
): void {
if ($promotedProperty->access !== '') {
$existingProperty->access = $promotedProperty->access;
}

// 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';
if ($existingProperty->type === null && $promotedProperty->type !== null) {
$existingProperty->type = $promotedProperty->type;
}

// 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;
}
}
if ($existingProperty->is_readonly === null && $promotedProperty->is_readonly !== null) {
$existingProperty->is_readonly = $promotedProperty->is_readonly;
}

if ($existingProperty->is_final === null && $promotedProperty->is_final !== null) {
$existingProperty->is_final = $promotedProperty->is_final;
}

// Recurse into nullable type
if ($typeNode instanceof \PhpParser\Node\NullableType) {
return self::containsPHP82PlusType($typeNode->type);
if ($existingProperty->access_set === '' && $promotedProperty->access_set !== '') {
$existingProperty->access_set = $promotedProperty->access_set;
}

return false;
if ($existingProperty->hooks === [] && $promotedProperty->hooks !== []) {
$existingProperty->hooks = $promotedProperty->hooks;
}

if ($existingProperty->attributes === [] && $promotedProperty->attributes !== []) {
$existingProperty->attributes = $promotedProperty->attributes;
}
Comment on lines +512 to +530
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When merging promoted property data, the is_final status and hooks should also be transferred to the existing property.

        if ($existingProperty->is_readonly === null && $promotedProperty->is_readonly !== null) {
            $existingProperty->is_readonly = $promotedProperty->is_readonly;
        }

        if ($existingProperty->is_final === null && $promotedProperty->is_final !== null) {
            $existingProperty->is_final = $promotedProperty->is_final;
        }

        if ($existingProperty->access_set === '' && $promotedProperty->access_set !== '') {
            $existingProperty->access_set = $promotedProperty->access_set;
        }

        if ($existingProperty->hooks === [] && $promotedProperty->hooks !== []) {
            $existingProperty->hooks = $promotedProperty->hooks;
        }

        if ($existingProperty->attributes === [] && $promotedProperty->attributes !== []) {
            $existingProperty->attributes = $promotedProperty->attributes;
        }


if ($parameter->default !== null && $promotedProperty->typeFromDefaultValue !== null) {
$existingProperty->defaultValue = $promotedProperty->defaultValue;
$existingProperty->typeFromDefaultValue = $promotedProperty->typeFromDefaultValue;
}
}
}
Loading
Loading