Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions src/Instrument/Transformer/ConstructorExecutionTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,8 @@

use Go\Aop\Framework\ReflectionConstructorInvocation;
use Go\Aop\InitializationAware;
use PhpParser\Node;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Name;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\FindingVisitor;

/**
* Transforms the source code to add an ability to intercept new instances creation
Expand Down Expand Up @@ -57,15 +54,16 @@ public static function getInstance(): self
*/
public function transform(StreamMetaData $metadata): TransformerResultEnum
{
$newExpressionFinder = new FindingVisitor(fn(Node $node) => $node instanceof New_);
// Skips `new` inside constant-expression contexts (parameter defaults, static var
// initializers, attribute arguments, constants, enum cases) — see issue #603.
$newExpressionFinder = new NewExpressionFinderVisitor();

// TODO: move this logic into walkSyntaxTree(Visitor $nodeVistor) method
$traverser = new NodeTraverser();
$traverser->addVisitor($newExpressionFinder);
$traverser->traverse($metadata->syntaxTree);

/** @var Node\Expr\New_[] $newExpressions */
$newExpressions = $newExpressionFinder->getFoundNodes();
$newExpressions = $newExpressionFinder->getFoundNewExpressions();

if (empty($newExpressions)) {
return TransformerResultEnum::RESULT_ABSTAIN;
Expand Down
104 changes: 104 additions & 0 deletions src/Instrument/Transformer/NewExpressionFinderVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);
/*
* Go! AOP framework
*
* @copyright Copyright 2026, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* This source file is subject to the license that is bundled
* with this source code in the file LICENSE.
*/

namespace Go\Instrument\Transformer;

use PhpParser\Node;
use PhpParser\Node\Attribute;
use PhpParser\Node\Const_;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Param;
use PhpParser\Node\PropertyItem;
use PhpParser\Node\StaticVar;
use PhpParser\Node\Stmt\EnumCase;
use PhpParser\NodeVisitorAbstract;

/**
* Finds all `new` expressions that are legal to rewrite into runtime interceptor calls.
*
* Since PHP 8.1 `new` may appear inside constant-expression contexts: parameter default
* values, static variable initializers, attribute arguments and global constants (and
* php-parser also accepts it in property/class-constant defaults and enum case values).
* Such occurrences must stay untouched — the interceptor rewrite
* `...getInstance()->{Foo::class}(...)` is not a valid constant expression and would
* trigger a compile-time fatal error (https://github.com/goaop/framework/issues/603).
*/
final class NewExpressionFinderVisitor extends NodeVisitorAbstract
{
/**
* @var list<New_>
*/
private array $newExpressions = [];

/**
* Object ids of subtree roots that are constant-expression contexts
*
* @var array<int, true>
*/
private array $constExprRoots = [];

/**
* How deep the traversal currently is inside constant-expression subtrees
*/
private int $constExprDepth = 0;

/**
* @return list<New_> All found `new` expressions outside of constant-expression contexts
*/
public function getFoundNewExpressions(): array
{
return $this->newExpressions;
}

public function enterNode(Node $node): null
{
if ($node instanceof Attribute) {
// Attribute arguments are always constant expressions — skip the whole attribute.
// Property hooks on promoted parameters contain runtime code, so for the other
// containers only the initializer child expression is marked, not the container.
$this->constExprRoots[spl_object_id($node)] = true;
} else {
$constExpr = match (true) {
$node instanceof Param => $node->default,
$node instanceof StaticVar => $node->default,
$node instanceof PropertyItem => $node->default,
$node instanceof Const_ => $node->value,
$node instanceof EnumCase => $node->expr,
default => null,
};
if ($constExpr !== null) {
$this->constExprRoots[spl_object_id($constExpr)] = true;
}
}

if (isset($this->constExprRoots[spl_object_id($node)])) {
++$this->constExprDepth;
}

if ($this->constExprDepth === 0 && $node instanceof New_) {
$this->newExpressions[] = $node;
}

return null;
}

public function leaveNode(Node $node): null
{
$nodeId = spl_object_id($node);
if (isset($this->constExprRoots[$nodeId])) {
--$this->constExprDepth;
unset($this->constExprRoots[$nodeId]);
}

return null;
}
}
187 changes: 184 additions & 3 deletions src/Instrument/Transformer/WeavingTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
use Go\Proxy\EnumProxyGenerator;
use Go\Proxy\FunctionProxyGenerator;
use Go\Proxy\TraitProxyGenerator;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt\ClassLike;
use PhpParser\Node\Stmt\EnumCase;
use PhpParser\Node\Stmt\Property;
use ReflectionProperty;
Expand Down Expand Up @@ -205,7 +207,7 @@ private function adjustOriginalTrait(
string $newClassName
): void {
$classNode = $class->getNode();
$position = $classNode->getAttribute('startTokenPos');
$position = $this->getPositionAfterAttributeGroups($classNode);
if (!is_int($position)) {
return;
}
Expand All @@ -223,6 +225,32 @@ private function adjustOriginalTrait(
} while (true);
}

/**
* Returns the token position where the class/enum declaration scan should start.
*
* A ClassLike node's startTokenPos includes its attribute groups (`#[...]`), so scanning
* from there would rename the first T_STRING inside the attribute to the trait name and
* then delete the real class header (see https://github.com/goaop/framework/issues/598).
* Class-level attributes are kept as-is on the generated trait — attributes are legal
* on traits — so the scan starts right after the last attribute group.
*/
private function getPositionAfterAttributeGroups(ClassLike $classNode): ?int
{
$position = $classNode->getAttribute('startTokenPos');
if (!is_int($position)) {
return null;
}
$lastAttrGroup = end($classNode->attrGroups);
if ($lastAttrGroup !== false) {
$attrGroupsEnd = $lastAttrGroup->getAttribute('endTokenPos');
if (is_int($attrGroupsEnd)) {
$position = $attrGroupsEnd + 1;
}
}

return $position;
}

/**
* Convert a regular class declaration into a trait for the trait-based AOP engine.
*
Expand All @@ -241,7 +269,7 @@ private function convertClassToTrait(
string $newClassName
): void {
$classNode = $class->getNode();
$position = $classNode->getAttribute('startTokenPos');
$position = $this->getPositionAfterAttributeGroups($classNode);
if (!is_int($position)) {
return;
}
Expand Down Expand Up @@ -327,7 +355,7 @@ private function convertEnumToTrait(
string $newClassName
): void {
$classNode = $class->getNode();
$position = $classNode->getAttribute('startTokenPos');
$position = $this->getPositionAfterAttributeGroups($classNode);
if (!is_int($position)) {
return;
}
Expand Down Expand Up @@ -586,6 +614,7 @@ private function commentOutInterceptedPropertiesInTraitBody(
}

$mask = ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE;
$promotedAssignments = [];
foreach ($class->getProperties($mask) as $property) {
if (!isset($interceptedProperties[$property->getName()])) {
continue;
Expand All @@ -598,13 +627,165 @@ private function commentOutInterceptedPropertiesInTraitBody(
if (!is_object($propertyNode) || !method_exists($propertyNode, 'getAttribute')) {
continue;
}
if ($propertyNode instanceof Param) {
// Promoted constructor property (issue #599): the declaration cannot be commented
// out — it doubles as the constructor parameter. Demote it to a plain parameter
// instead and assign it to the (proxy-declared) property in the constructor body.
$this->demotePromotedPropertyParameter($propertyNode, $streamMetaData);
$propertyName = $property->getName();
$promotedAssignments[] = sprintf('$this->%1$s = $%1$s;', $propertyName);
continue;
}
$start = $propertyNode->getAttribute('startTokenPos');
$end = $propertyNode->getAttribute('endTokenPos');
if (!is_int($start) || !is_int($end)) {
continue;
}
$this->commentOutMovedPropertyTokenRange($class->name, $property->getName(), $start, $end, $streamMetaData);
}

if ($promotedAssignments !== []) {
$this->injectConstructorAssignments($class, $promotedAssignments, $streamMetaData);
}
}

/**
* Demotes a promoted constructor property to a plain constructor parameter (issue #599).
*
* Removes only the promotion modifiers (visibility, asymmetric set-visibility, readonly,
* final) from the parameter tokens, keeping attributes, type, name, and default value.
* The property itself is re-declared with interception hooks in the proxy class, and the
* value is assigned in the constructor body (see injectConstructorAssignments), which
* routes the write through the proxy's set hook.
*
* Whitespace containing newlines is preserved so line numbers stay intact.
*/
private function demotePromotedPropertyParameter(Param $parameterNode, StreamMetaData $streamMetaData): void
{
$start = $parameterNode->getAttribute('startTokenPos');
$end = $parameterNode->getAttribute('endTokenPos');
if (!is_int($start) || !is_int($end)) {
return;
}

$modifierTokenIds = [
T_PUBLIC, T_PROTECTED, T_PRIVATE,
T_PUBLIC_SET, T_PROTECTED_SET, T_PRIVATE_SET,
T_READONLY, T_FINAL,
];

$position = $start;
while ($position <= $end) {
if (!isset($streamMetaData->tokenStream[$position])) {
++$position;
continue;
}
$token = $streamMetaData->tokenStream[$position];
// Modifiers can only appear before the parameter variable — stop there so that
// tokens inside the default value expression are never touched
if ($token->id === T_VARIABLE) {
break;
}
// Skip parameter attribute groups entirely: '#[' opens a bracket context that can
// contain arbitrary nested brackets inside attribute arguments
if ($token->id === T_ATTRIBUTE) {
$bracketDepth = 1;
++$position;
while ($position <= $end && $bracketDepth > 0) {
$innerText = isset($streamMetaData->tokenStream[$position]) ? $streamMetaData->tokenStream[$position]->text : '';
if ($innerText === '[') {
++$bracketDepth;
} elseif ($innerText === ']') {
--$bracketDepth;
}
++$position;
}
continue;
}
if (in_array($token->id, $modifierTokenIds, true)) {
unset($streamMetaData->tokenStream[$position]);
// Also drop the following whitespace unless it holds a newline (line budget)
if (isset($streamMetaData->tokenStream[$position + 1])) {
$nextToken = $streamMetaData->tokenStream[$position + 1];
if ($nextToken->id === T_WHITESPACE && strpbrk($nextToken->text, "\r\n") === false) {
unset($streamMetaData->tokenStream[$position + 1]);
}
}
}
++$position;
}
}

/**
* Injects property assignments at the very beginning of the constructor body.
*
* The assignments are appended to the opening '{' token of the constructor body, all on
* the same line, so the original line numbers of the constructor statements are preserved.
*
* @param non-empty-list<string> $assignments Assignment statements like '$this->name = $name;'
*/
private function injectConstructorAssignments(
ReflectionClass $class,
array $assignments,
StreamMetaData $streamMetaData
): void {
$constructor = $class->getConstructor();
if ($constructor === null || !$constructor instanceof ReflectionMethod) {
return;
}
$constructorNode = $constructor->getNode();
$start = $constructorNode->getAttribute('startTokenPos');
$end = $constructorNode->getAttribute('endTokenPos');
if (!is_int($start) || !is_int($end)) {
return;
}

// The body '{' is the first '{' token after the parameter list closes (parenthesis
// depth back to zero). Hook bodies of promoted parameters contain '{' too, but they
// are always nested inside the parameter parentheses, so the depth guard skips them.
$position = $start;
$seenFunction = false;
$seenParameterList = false;
$parenthesisDepth = 0;
while ($position <= $end) {
if (!isset($streamMetaData->tokenStream[$position])) {
++$position;
continue;
}
$token = $streamMetaData->tokenStream[$position];
if (!$seenFunction) {
// Skip attribute groups before the 'function' keyword — their arguments
// may contain arbitrary parentheses
if ($token->id === T_ATTRIBUTE) {
$bracketDepth = 1;
++$position;
while ($position <= $end && $bracketDepth > 0) {
$innerText = isset($streamMetaData->tokenStream[$position]) ? $streamMetaData->tokenStream[$position]->text : '';
if ($innerText === '[') {
++$bracketDepth;
} elseif ($innerText === ']') {
--$bracketDepth;
}
++$position;
}
continue;
}
$seenFunction = ($token->id === T_FUNCTION);
++$position;
continue;
}
if ($token->text === '(') {
++$parenthesisDepth;
$seenParameterList = true;
} elseif ($token->text === ')') {
--$parenthesisDepth;
} elseif ($token->text === '{' && $seenParameterList && $parenthesisDepth === 0) {
$streamMetaData->tokenStream[$position]->text .= ' ' . implode(' ', $assignments);

return;
}
++$position;
}
}

private function commentOutMovedPropertyTokenRange(
Expand Down
Loading