Skip to content

Commit 312dbd3

Browse files
authored
Merge pull request #530 from goaop/feature/enum-interception-support
Feature: Add Enum interception support
2 parents 9c4bfc0 + 32373b7 commit 312dbd3

File tree

20 files changed

+1391
-12
lines changed

20 files changed

+1391
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Changelog
44
* [BC BREAK] Requires PHP 8.4+
55
* [BC BREAK] Proxy engine switched from inheritance-based to **trait-based**: the original class body is converted to a PHP trait (`Foo__AopProxied`) and the proxy class uses it via `use` with private method aliases instead of extending the renamed class. This removes the `__AopProxied` parent from the inheritance chain.
66
* [Feature] **Private method interception** — both dynamic (`private function foo()`) and static (`private static function bar()`) private methods can now be intercepted by aspects. This was impossible with the old extend-based engine because PHP does not allow overriding private methods in subclasses.
7+
* [Feature] **PHP 8.1+ enum interception** — instance and static methods on both unit (pure) and backed enums can now be intercepted by aspects. The enum body is extracted into a trait (`Foo__AopProxied`); a proxy enum re-declares the cases and dispatches intercepted methods via per-method `static $__joinPoint` caching. Built-in enum methods (`cases`, `from`, `tryFrom`) and initialization joinpoints are never woven.
78
* [Feature] `self::` in proxied classes now resolves to the proxy class naturally (via PHP trait semantics), removing the need for `SelfValueTransformer`.
89
* [Removed] `SelfValueTransformer` and `SelfValueVisitor` — no longer needed with the trait-based engine.
910
* [Performance] Pre-bound `Closure::bind` closures replace per-call `ReflectionMethod::getClosure()` + rebind in the method invocation proceed path, eliminating reflection overhead on every intercepted call.

CLAUDE.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,14 @@ Key properties of this engine:
136136

137137
The framework supports PHP 8.4+ and handles most modern PHP syntax transparently. The following constructs have documented limitations or are intentionally excluded:
138138

139-
### Enums (PHP 8.1+) — not woven
139+
### Enums (PHP 8.1+) — woven via trait extraction
140140

141-
Enums are **silently skipped** by `WeavingTransformer`. They cannot be converted to traits (PHP does not allow traits to become enums), and they cannot be extended. Aspects targeting enum methods will have no effect.
141+
Enums are supported by `WeavingTransformer` using the same trait-extraction approach as classes, with adjustments for enum constraints:
142+
- The original enum body is converted to a **trait** (cases stripped, backed type removed, `enum``trait`)
143+
- A proxy **enum** is generated that re-uses the trait, re-declares all cases, and adds per-method `static $__joinPoint` dispatch — using `EnumProxyGenerator`
144+
- Enums **cannot** have properties (static or instance), so the `$__joinPoints` class property pattern used by `ClassProxyGenerator` does not apply; per-method static variables are used instead (same as `TraitProxyGenerator`)
145+
- Built-in enum methods (`cases`, `from`, `tryFrom`) are **never** intercepted — they are synthesised by PHP and cannot be aliased via trait use
146+
- Built-in PHP enum interfaces (`UnitEnum`, `BackedEnum`) are **never** listed in the proxy's `implements` clause — PHP applies them automatically, and listing them explicitly in a namespaced file resolves them as `Ns\UnitEnum` instead of the global `\UnitEnum`, causing a fatal error
142147

143148
### Readonly classes (PHP 8.2+) — proxy is not readonly
144149

@@ -152,6 +157,16 @@ When a method marked `#[\Override]` is intercepted (i.e., aliased as `__aop__met
152157

153158
Property hooks are included verbatim in the generated trait body; they are not a separate join-point type. You can intercept the owning method (if any) but you cannot write a pointcut that targets a hook `get`/`set` clause directly. `ClassFieldAccess` property interception and hooked properties on the same property are not supported simultaneously.
154159

160+
### Woven trait file line numbers must match the original source (XDebug compatibility)
161+
162+
The **woven file** (the trait that replaces the original class/enum body) **must** preserve the original source line numbers. This is required for XDebug breakpoints to map correctly: a breakpoint placed at a method in the original source file must land on the same line number in the woven trait file, because that is the file XDebug steps through when executing the real method body.
163+
164+
`WeavingTransformer` achieves this via token-level surgery on the original source:
165+
- For classes: `convertClassToTrait()` replaces the `class` keyword and strips modifiers/extends/implements, but keeps all other tokens (including blank lines) in place.
166+
- For enums: `convertEnumToTrait()` must **replace removed tokens** (case declarations, backed type, implements clause) with an **equal number of newlines** so that methods remain at their original line positions. Removing tokens without replacement shifts subsequent lines upward.
167+
168+
The proxy file (generated by `ClassProxyGenerator`/`EnumProxyGenerator`) is a thin dispatch wrapper and does **not** need to match original line numbers. Debuggers will step through the woven trait for the real method bodies.
169+
155170
### Aspects themselves — never woven
156171

157172
Classes that implement `\Go\Aop\Aspect` are unconditionally skipped by `WeavingTransformer`. Aspects cannot weave themselves.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ The framework provides powerful core interception capabilities that can be used
3434
| Interception of private methods ||
3535
| Interception of methods in `final` classes ||
3636
| Interception of trait methods ||
37+
| Interception of enum methods (PHP 8.1+) ||
3738
| Before, After and Around type of hooks ||
3839

3940
### 🛠️ Developer Experience

src/Instrument/Transformer/WeavingTransformer.php

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
use Go\ParserReflection\ReflectionFileNamespace;
2828
use Go\ParserReflection\ReflectionMethod;
2929
use Go\Proxy\ClassProxyGenerator;
30+
use Go\Proxy\EnumProxyGenerator;
3031
use Go\Proxy\FunctionProxyGenerator;
3132
use Go\Proxy\TraitProxyGenerator;
33+
use PhpParser\Node\Stmt\EnumCase;
3234

3335
/**
3436
* Main transformer that performs weaving of aspects into the source code
@@ -95,8 +97,8 @@ public function transform(StreamMetaData $metadata): TransformerResultEnum
9597
foreach ($namespaces as $namespace) {
9698
$classes = $namespace->getClasses();
9799
foreach ($classes as $class) {
98-
// Skip interfaces, enums, and aspects — these cannot be proxied as traits
99-
if ($class->isInterface() || $class->isEnum() || in_array(Aspect::class, $class->getInterfaceNames(), true)) {
100+
// Skip interfaces and aspects — enums are now supported via EnumProxyGenerator
101+
if ($class->isInterface() || in_array(Aspect::class, $class->getInterfaceNames(), true)) {
100102
continue;
101103
}
102104
$wasClassProcessed = $this->processSingleClass(
@@ -146,10 +148,14 @@ private function processSingleClass(
146148
$newFqcn = ($class->getNamespaceName() !== '' ? $class->getNamespaceName() . '\\' : '') . $newClassName;
147149

148150
// For traits: rename the trait (legacy approach, TraitProxyGenerator generates a child trait).
151+
// For enums: convert the enum body to a trait (cases extracted to proxy enum by EnumProxyGenerator).
149152
// For classes: convert the class body to a trait (new trait-based engine).
150153
if ($class->isTrait()) {
151154
$this->adjustOriginalClass($class, $advices, $metadata, $newClassName);
152155
$childProxyGenerator = new TraitProxyGenerator($class, $newFqcn, $advices, $this->useParameterWidening);
156+
} elseif ($class->isEnum()) {
157+
$this->convertEnumToTrait($class, $advices, $metadata, $newClassName);
158+
$childProxyGenerator = new EnumProxyGenerator($class, $newFqcn, $advices, $this->useParameterWidening);
153159
} else {
154160
$this->convertClassToTrait($class, $advices, $metadata, $newClassName);
155161
$childProxyGenerator = new ClassProxyGenerator($class, $newFqcn, $advices, $this->useParameterWidening);
@@ -372,6 +378,127 @@ private function convertClassToTrait(
372378
$this->stripOverrideAttributeFromInterceptedMethods($class, $advices, $streamMetaData);
373379
}
374380

381+
/**
382+
* Convert an enum declaration into a trait for the trait-based AOP engine.
383+
*
384+
* Performs the following token-stream modifications in-place:
385+
* - Changes 'enum' keyword text to 'trait'
386+
* - Renames the enum to $newClassName (__AopProxied suffix)
387+
* - Removes the backed type (': string' / ': int') and any 'implements ...' clause
388+
* - Removes all enum case declarations from the body (cases live in the proxy enum instead)
389+
*
390+
* @param array<string, array<string, array<string>>> $advices List of class advices (sorted advice IDs)
391+
*/
392+
private function convertEnumToTrait(
393+
ReflectionClass $class,
394+
array $advices,
395+
StreamMetaData $streamMetaData,
396+
string $newClassName
397+
): void {
398+
$classNode = $class->getNode();
399+
if ($classNode === null) {
400+
return;
401+
}
402+
$position = $classNode->getAttribute('startTokenPos');
403+
if (!is_int($position)) {
404+
return;
405+
}
406+
407+
$classNameFound = false;
408+
409+
do {
410+
if (!isset($streamMetaData->tokenStream[$position])) {
411+
++$position;
412+
continue;
413+
}
414+
415+
$token = $streamMetaData->tokenStream[$position];
416+
417+
if (!$classNameFound) {
418+
// Rewrite 'enum' keyword to 'trait'
419+
if ($token->id === T_ENUM) {
420+
$streamMetaData->tokenStream[$position]->text = 'trait';
421+
++$position;
422+
continue;
423+
}
424+
// First T_STRING after the keyword is the enum name — rename it
425+
if ($token->id === T_STRING) {
426+
$streamMetaData->tokenStream[$position]->text = $newClassName;
427+
$classNameFound = true;
428+
++$position;
429+
continue;
430+
}
431+
} else {
432+
// After the enum name: strip backed type (': string/int') and 'implements ...' up to '{'
433+
if ($token->text === '{') {
434+
break;
435+
}
436+
// Keep whitespace tokens to preserve original brace placement
437+
if ($token->id !== T_WHITESPACE) {
438+
unset($streamMetaData->tokenStream[$position]);
439+
}
440+
}
441+
442+
++$position;
443+
} while (true);
444+
445+
// Remove all enum case declarations from the trait body.
446+
// Cases cannot exist in traits; they are re-declared in the proxy enum by EnumProxyGenerator.
447+
// The trailing whitespace token (newline + indent) after each case is intentionally kept so
448+
// that subsequent methods remain at their original line numbers in the woven file, which is
449+
// required for XDebug breakpoints to map correctly (see CLAUDE.md).
450+
foreach ($classNode->stmts as $stmt) {
451+
if (!($stmt instanceof EnumCase)) {
452+
continue;
453+
}
454+
$start = $stmt->getAttribute('startTokenPos');
455+
$end = $stmt->getAttribute('endTokenPos');
456+
if (!is_int($start) || !is_int($end)) {
457+
continue;
458+
}
459+
// Remove the case tokens only (not the trailing whitespace/newline).
460+
// Keeping the trailing whitespace preserves blank lines in place of the removed case,
461+
// so the line numbers of all following methods are unchanged.
462+
for ($pos = $start; $pos <= $end; $pos++) {
463+
unset($streamMetaData->tokenStream[$pos]);
464+
}
465+
}
466+
467+
// Remove 'final' from all intercepted enum methods — final methods cannot be overridden in
468+
// the proxy enum. Only intercepted methods need stripping; unintercepted final methods are
469+
// not overridden by the proxy and can safely remain final in the trait.
470+
foreach ($class->getMethods(ReflectionMethod::IS_FINAL) as $finalMethod) {
471+
if ($finalMethod->getDeclaringClass()->name !== $class->name) {
472+
continue;
473+
}
474+
$hasDynamicAdvice = isset($advices[AspectContainer::METHOD_PREFIX][$finalMethod->name]);
475+
$hasStaticAdvice = isset($advices[AspectContainer::STATIC_METHOD_PREFIX][$finalMethod->name]);
476+
if (!$hasDynamicAdvice && !$hasStaticAdvice) {
477+
continue;
478+
}
479+
$methodNode = $finalMethod->getNode();
480+
$position = $methodNode->getAttribute('startTokenPos');
481+
if (!is_int($position)) {
482+
continue;
483+
}
484+
do {
485+
if (isset($streamMetaData->tokenStream[$position])) {
486+
$token = $streamMetaData->tokenStream[$position];
487+
if ($token->id === T_FINAL) {
488+
unset($streamMetaData->tokenStream[$position], $streamMetaData->tokenStream[$position + 1]);
489+
break;
490+
}
491+
}
492+
++$position;
493+
} while (true);
494+
}
495+
496+
// Strip #[\Override] from intercepted methods to prevent fatal errors on the alias.
497+
// PHP copies attributes to alias names (e.g. __aop__label), and since __aop__label has
498+
// no matching parent method, #[\Override] on the alias would be a fatal error.
499+
$this->stripOverrideAttributeFromInterceptedMethods($class, $advices, $streamMetaData);
500+
}
501+
375502
/**
376503
* Removes #[\Override] attribute groups from all intercepted methods in the token stream.
377504
*

0 commit comments

Comments
 (0)