From a0cae2ff020394dcd06aa029291922dd734bf1b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:33:45 +0000 Subject: [PATCH 1/5] Preserve constant-expression enum case values in generated proxy enums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnumProxyGenerator::resolveEnumData() only recognized String_/Int_ literal case values; any other constant expression (`case Negative = -1;`, `case Shifted = 1 << 2;`, `case FromConst = self::SHIFT + 10;`) resolved to null, so EnumGenerator::addEnumCase() skipped setValue() and the proxy declared a pure case inside a backed enum — a PHP fatal error ("Case Negative of backed enum ... must have a value") as soon as the proxy loaded. The parser-reflection path now passes the raw PhpParser Expr node through to EnumGenerator, which emits it verbatim in the proxy enum. `self::CONST` expressions keep resolving on the proxy because class constants stay in the woven trait and trait constants participate in the composing class since PHP 8.2 — verified by a runtime functional test that loads the woven trait plus proxy enum and asserts the case values (-1, 4, 12) and from() lookups. Coverage added: - EnumGeneratorTest: verbatim emission of Expr case values - EnumProxyGeneratorTest: native-reflection path emits evaluated scalars - WeavingTransformerTest: golden woven/proxy fixtures for the const-expr enum plus a runtime load-and-assert check - EnumWeavingTest: end-to-end weaving of a project fixture enum with constant-expression cases Fixes #600 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WFMYyvE4hYRUoMS8mtKrHP --- src/Proxy/EnumProxyGenerator.php | 24 ++++--- src/Proxy/Generator/EnumGenerator.php | 11 ++- .../src/Application/ConstExprBackedEnum.php | 25 +++++++ .../project/src/Aspect/EnumMethodAspect.php | 9 +++ tests/Functional/EnumWeavingTest.php | 24 +++++++ .../Transformer/WeavingTransformerTest.php | 64 +++++++++++++++++ .../_files/php81-enum-const-expr-proxy.php | 21 ++++++ .../_files/php81-enum-const-expr-woven.php | 21 ++++++ .../_files/php81-enum-const-expr.php | 20 ++++++ tests/Proxy/EnumProxyGeneratorTest.php | 32 +++++++++ tests/Proxy/Generator/EnumGeneratorTest.php | 71 +++++++++++++++++++ tests/Stubs/StubConstExprBackedEnum.php | 31 ++++++++ 12 files changed, 339 insertions(+), 14 deletions(-) create mode 100644 tests/Fixtures/project/src/Application/ConstExprBackedEnum.php create mode 100644 tests/Instrument/Transformer/_files/php81-enum-const-expr-proxy.php create mode 100644 tests/Instrument/Transformer/_files/php81-enum-const-expr-woven.php create mode 100644 tests/Instrument/Transformer/_files/php81-enum-const-expr.php create mode 100644 tests/Proxy/Generator/EnumGeneratorTest.php create mode 100644 tests/Stubs/StubConstExprBackedEnum.php diff --git a/src/Proxy/EnumProxyGenerator.php b/src/Proxy/EnumProxyGenerator.php index 024b3ced..59f2267a 100644 --- a/src/Proxy/EnumProxyGenerator.php +++ b/src/Proxy/EnumProxyGenerator.php @@ -19,8 +19,7 @@ use Go\Proxy\Generator\TypeGenerator; use Go\Proxy\Generator\ValueGenerator; use Go\Proxy\Part\FunctionCallArgumentListGenerator; -use PhpParser\Node\Scalar\Int_; -use PhpParser\Node\Scalar\String_; +use PhpParser\Node\Expr; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\EnumCase; use PhpParser\Node\Stmt\Enum_ as EnumNode; @@ -264,8 +263,15 @@ protected function getJoinpointInvocationBody(ReflectionMethod $method, ?Reflect * - Native \ReflectionClass / \ReflectionEnum (test context, class already loaded): * uses ReflectionEnum::isBacked(), getBackingType(), and getCases(). * + * On the parser-reflection path, backed case values are passed through as raw PhpParser + * {@see Expr} nodes and re-emitted verbatim by {@see EnumGenerator}. This preserves + * constant-expression case values (e.g. `-1`, `1 << 2`, `self::SHIFT + 10`) that are not + * plain string/int literals. `self::CONST` expressions keep resolving on the proxy enum + * because class constants stay in the woven trait and trait constants participate in the + * composing class since PHP 8.2. + * * @param ReflectionClass $class - * @return array{0: string|null, 1: list} + * @return array{0: string|null, 1: list} * [backingType, [[caseName, caseValue], ...]] */ private function resolveEnumData(ReflectionClass $class): array @@ -285,14 +291,10 @@ private function resolveEnumData(ReflectionClass $class): array if (!($stmt instanceof EnumCase)) { continue; } - $caseName = $stmt->name->toString(); - $caseValue = null; - if ($stmt->expr instanceof String_) { - $caseValue = $stmt->expr->value; - } elseif ($stmt->expr instanceof Int_) { - $caseValue = $stmt->expr->value; - } - $cases[] = [$caseName, $caseValue]; + // Pass the raw expression node through so that constant-expression case + // values (UnaryMinus, BitwiseShift, self::CONST arithmetic, ...) survive. + // Emitting a pure case inside a backed enum would be a PHP fatal error. + $cases[] = [$stmt->name->toString(), $stmt->expr]; } } diff --git a/src/Proxy/Generator/EnumGenerator.php b/src/Proxy/Generator/EnumGenerator.php index a86c5361..000960d1 100644 --- a/src/Proxy/Generator/EnumGenerator.php +++ b/src/Proxy/Generator/EnumGenerator.php @@ -14,6 +14,7 @@ use PhpParser\BuilderFactory; use PhpParser\Modifiers; +use PhpParser\Node\Expr; use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Stmt\Enum_ as EnumNode; @@ -57,7 +58,7 @@ final class EnumGenerator implements GeneratorInterface /** @var array{trait: string, method: string, alias: string, visibility: int}[] */ private array $traitAliases = []; - /** @var array{name: string, value: string|int|null}[] */ + /** @var array{name: string, value: string|int|Expr|null}[] */ private array $enumCases = []; /** @@ -89,9 +90,13 @@ public function addUse(string $use, ?string $alias = null): void /** * Adds an enum case to the generated enum. * - * @param string|int|null $value The case value (null for pure/unit enum cases) + * A scalar value is emitted as the corresponding literal. A {@see Expr} node (e.g. a constant + * expression such as `1 << 2`, `-1` or `self::SHIFT + 10` taken from the original enum AST) + * is emitted verbatim, preserving the original constant expression in the proxy enum. + * + * @param string|int|Expr|null $value The case value (null for pure/unit enum cases) */ - public function addEnumCase(string $name, string|int|null $value = null): void + public function addEnumCase(string $name, string|int|Expr|null $value = null): void { $this->enumCases[] = ['name' => $name, 'value' => $value]; } diff --git a/tests/Fixtures/project/src/Application/ConstExprBackedEnum.php b/tests/Fixtures/project/src/Application/ConstExprBackedEnum.php new file mode 100644 index 00000000..a963d96c --- /dev/null +++ b/tests/Fixtures/project/src/Application/ConstExprBackedEnum.php @@ -0,0 +1,25 @@ +name . '=' . $this->value; + } +} diff --git a/tests/Fixtures/project/src/Aspect/EnumMethodAspect.php b/tests/Fixtures/project/src/Aspect/EnumMethodAspect.php index aa65a2ef..d16d9ecf 100644 --- a/tests/Fixtures/project/src/Aspect/EnumMethodAspect.php +++ b/tests/Fixtures/project/src/Aspect/EnumMethodAspect.php @@ -47,4 +47,13 @@ public function afterBackedEnumStaticMethod(): void { // advice body intentionally empty — tested via weaving assertions only } + + /** + * Intercepts the instance method on the enum with constant-expression case values (issue #600). + */ + #[Pointcut\After("execution(public Go\Tests\TestProject\Application\ConstExprBackedEnum->describe(*))")] + public function afterConstExprEnumMethod(): void + { + // advice body intentionally empty — tested via weaving assertions only + } } diff --git a/tests/Functional/EnumWeavingTest.php b/tests/Functional/EnumWeavingTest.php index f26b6b04..165ab461 100644 --- a/tests/Functional/EnumWeavingTest.php +++ b/tests/Functional/EnumWeavingTest.php @@ -13,6 +13,7 @@ namespace Go\Functional; use Go\Tests\TestProject\Application\BackedEnum; +use Go\Tests\TestProject\Application\ConstExprBackedEnum; use Go\Tests\TestProject\Application\SimpleEnum; /** @@ -107,6 +108,29 @@ public function testBuiltinEnumMethodsAreNeverIntercepted(): void $this->assertMethodNotWoven(BackedEnum::class, 'tryFrom'); } + /** + * A backed enum whose case values are constant expressions (issue #600) must be woven, + * and the generated proxy enum must re-declare the cases with their original expressions. + * Dropping the expressions would emit pure cases inside a backed enum — a PHP fatal error + * ("Case Negative of backed enum ... must have a value") as soon as the proxy is loaded. + */ + public function testConstExprBackedEnumIsWovenWithCaseValuesPreserved(): void + { + $this->assertClassIsWoven(ConstExprBackedEnum::class); + $this->assertMethodWoven( + ConstExprBackedEnum::class, + 'describe', + 'Go\\Tests\\TestProject\\Aspect\\EnumMethodAspect->afterConstExprEnumMethod' + ); + + $proxyFile = $this->configuration['cacheDir'] . '/src/Application/ConstExprBackedEnum.php'; + $this->assertFileExists($proxyFile); + $proxyContent = file_get_contents($proxyFile); + $this->assertStringContainsString('case Negative = -1;', $proxyContent); + $this->assertStringContainsString('case Shifted = 1 << 2;', $proxyContent); + $this->assertStringContainsString('case FromConst = self::SHIFT + 10;', $proxyContent); + } + /** * Initialization joinpoints are never woven for enums. * PHP enums cannot be instantiated with `new`, so allowing initialization diff --git a/tests/Instrument/Transformer/WeavingTransformerTest.php b/tests/Instrument/Transformer/WeavingTransformerTest.php index db606afc..f6262e86 100644 --- a/tests/Instrument/Transformer/WeavingTransformerTest.php +++ b/tests/Instrument/Transformer/WeavingTransformerTest.php @@ -274,6 +274,70 @@ public function testWeaverForEnumPreservesMethodLineNumbers(): void ); } + /** + * Backed enum cases whose values are constant expressions (issue #600). + * + * `case Negative = -1;`, `case Shifted = 1 << 2;` and `case FromConst = self::SHIFT + 10;` + * are not String_/Int_ literals in the AST. The proxy enum must re-declare these cases with + * their original expressions verbatim — dropping the value would declare a pure case inside + * a backed enum, which is a PHP fatal error. + */ + public function testWeaverForEnumWithConstantExpressionCaseValues(): void + { + $metadata = $this->loadTestMetadata('php81-enum-const-expr'); + $this->transformer->transform($metadata); + + $actual = $this->normalizeWhitespaces($metadata->source); + $expected = $this->normalizeWhitespaces($this->loadTestMetadata('php81-enum-const-expr-woven')->source); + $this->assertEquals($expected, $actual); + $this->assertMatchesRegularExpression("/AOP_CACHE_DIR . '(.+)';$/m", $actual); + if (preg_match("/AOP_CACHE_DIR . '(.+)';$/m", $actual, $matches)) { + $actualProxyContent = $this->normalizeWhitespaces(file_get_contents('vfs://' . $matches[1])); + $expectedProxyContent = $this->normalizeWhitespaces($this->loadTestMetadata('php81-enum-const-expr-proxy')->source); + $this->assertEquals($expectedProxyContent, $actualProxyContent); + } + } + + /** + * Functional check for issue #600: the woven trait plus the generated proxy enum must + * actually load and keep the evaluated constant-expression case values at runtime. + * + * This also proves that `self::SHIFT` keeps resolving on the proxy enum: the class constant + * stays in the woven trait, and trait constants participate in the composing class (PHP 8.2+). + */ + public function testWovenEnumWithConstantExpressionCaseValuesWorksAtRuntime(): void + { + $metadata = $this->loadTestMetadata('php81-enum-const-expr'); + $this->transformer->transform($metadata); + + $this->assertMatchesRegularExpression("/AOP_CACHE_DIR . '(.+)';$/m", $metadata->source); + preg_match("/AOP_CACHE_DIR . '(.+)';$/m", $metadata->source, $matches); + $proxyContent = file_get_contents('vfs://' . $matches[1]); + + // The woven trait source, without the include_once tail (the proxy is included manually) + $traitSource = preg_replace('/^include_once AOP_CACHE_DIR.*$/m', '', $metadata->source); + + $tempDir = sys_get_temp_dir(); + $traitFile = tempnam($tempDir, 'aop_enum_trait_'); + $proxyFile = tempnam($tempDir, 'aop_enum_proxy_'); + try { + file_put_contents($traitFile, $traitSource); + file_put_contents($proxyFile, $proxyContent); + include $traitFile; + include $proxyFile; + + $enumName = 'Test\\ns1\\ConstExprStatus'; + $this->assertTrue(enum_exists($enumName)); + $this->assertSame(-1, $enumName::Negative->value); + $this->assertSame(1 << 2, $enumName::Shifted->value); + $this->assertSame(12, $enumName::FromConst->value, 'self::SHIFT + 10 must resolve via the trait constant'); + $this->assertSame($enumName::FromConst, $enumName::from(12)); + } finally { + unlink($traitFile); + unlink($proxyFile); + } + } + /** * PHP 8.3 #[\Override] attribute must be stripped from intercepted methods. * diff --git a/tests/Instrument/Transformer/_files/php81-enum-const-expr-proxy.php b/tests/Instrument/Transformer/_files/php81-enum-const-expr-proxy.php new file mode 100644 index 00000000..e992fb8a --- /dev/null +++ b/tests/Instrument/Transformer/_files/php81-enum-const-expr-proxy.php @@ -0,0 +1,21 @@ + $__joinPoint */ + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'describe', ['advisor.Test\ns1\ConstExprStatus->describe'], $this->__aop__describe(...)); + return $__joinPoint->__invoke($this); + } +} \ No newline at end of file diff --git a/tests/Instrument/Transformer/_files/php81-enum-const-expr-woven.php b/tests/Instrument/Transformer/_files/php81-enum-const-expr-woven.php new file mode 100644 index 00000000..c8f4f3ae --- /dev/null +++ b/tests/Instrument/Transformer/_files/php81-enum-const-expr-woven.php @@ -0,0 +1,21 @@ +name . '=' . $this->value; + } +} +include_once AOP_CACHE_DIR . '/Transformer/_files/php81-enum-const-expr.php'; diff --git a/tests/Instrument/Transformer/_files/php81-enum-const-expr.php b/tests/Instrument/Transformer/_files/php81-enum-const-expr.php new file mode 100644 index 00000000..cdf0487a --- /dev/null +++ b/tests/Instrument/Transformer/_files/php81-enum-const-expr.php @@ -0,0 +1,20 @@ +name . '=' . $this->value; + } +} diff --git a/tests/Proxy/EnumProxyGeneratorTest.php b/tests/Proxy/EnumProxyGeneratorTest.php index 6d6c8cc1..05da91fa 100644 --- a/tests/Proxy/EnumProxyGeneratorTest.php +++ b/tests/Proxy/EnumProxyGeneratorTest.php @@ -13,6 +13,7 @@ namespace Go\Proxy; use Go\Stubs\StubBackedEnum; +use Go\Stubs\StubConstExprBackedEnum; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -112,6 +113,37 @@ public function testGeneratePreservesEnumCases(): void $this->assertStringContainsString("case Inactive = 'inactive'", $output); } + /** + * Backed cases declared with constant expressions (issue #600) must keep a value in the + * proxy enum — emitting a valueless case inside a backed enum is a PHP fatal error. + * + * On the native reflection path (enum already loaded), the case values are re-emitted as + * the evaluated scalars; the parser-reflection path (weaving time) re-emits the original + * expressions verbatim and is covered by WeavingTransformerTest. + */ + public function testGeneratePreservesConstantExpressionCaseValues(): void + { + $reflectionClass = new ReflectionClass(StubConstExprBackedEnum::class); + $classAdvices = [ + 'method' => ['describe' => ['advisor']], + ]; + + $generator = new EnumProxyGenerator( + $reflectionClass, + 'Go\\Stubs\\StubConstExprBackedEnum__AopProxied', + $classAdvices, + false + ); + $output = "generate(); + + $this->assertStringContainsString('case Negative = -1;', $output); + $this->assertStringContainsString('case Shifted = 4;', $output); + $this->assertStringContainsString('case FromConst = 12;', $output); + + // No case may be emitted without a value in a backed enum + $this->assertDoesNotMatchRegularExpression('/case\s+\w+\s*;/', $output); + } + /** * The backed type (`: string`) must be preserved in the proxy enum declaration. */ diff --git a/tests/Proxy/Generator/EnumGeneratorTest.php b/tests/Proxy/Generator/EnumGeneratorTest.php new file mode 100644 index 00000000..f6a4fdb2 --- /dev/null +++ b/tests/Proxy/Generator/EnumGeneratorTest.php @@ -0,0 +1,71 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Proxy\Generator; + +use PhpParser\Node\Expr\BinaryOp\Plus; +use PhpParser\Node\Expr\BinaryOp\ShiftLeft; +use PhpParser\Node\Expr\ClassConstFetch; +use PhpParser\Node\Expr\UnaryMinus; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name; +use PhpParser\Node\Scalar\Int_; +use PHPUnit\Framework\TestCase; + +/** + * Unit tests for EnumGenerator case emission. + */ +class EnumGeneratorTest extends TestCase +{ + /** + * Scalar case values must be emitted as literals. + */ + public function testEmitsScalarCaseValues(): void + { + $generator = new EnumGenerator('Demo', null, 'string'); + $generator->addEnumCase('Active', 'active'); + + $this->assertStringContainsString("case Active = 'active';", $generator->generate()); + } + + /** + * Pure (unit) enum cases have no value. + */ + public function testEmitsPureCaseWithoutValue(): void + { + $generator = new EnumGenerator('Demo', null, null); + $generator->addEnumCase('Standalone'); + + $this->assertStringContainsString('case Standalone;', $generator->generate()); + } + + /** + * Constant-expression case values passed as raw PhpParser Expr nodes (issue #600) + * must be emitted verbatim instead of being silently dropped. + */ + public function testEmitsExpressionCaseValuesVerbatim(): void + { + $generator = new EnumGenerator('Demo', null, 'int'); + $generator->addEnumCase('Negative', new UnaryMinus(new Int_(1))); + $generator->addEnumCase('Shifted', new ShiftLeft(new Int_(1), new Int_(2))); + $generator->addEnumCase( + 'FromConst', + new Plus(new ClassConstFetch(new Name('self'), new Identifier('SHIFT')), new Int_(10)) + ); + + $output = $generator->generate(); + + $this->assertStringContainsString('case Negative = -1;', $output); + $this->assertStringContainsString('case Shifted = 1 << 2;', $output); + $this->assertStringContainsString('case FromConst = self::SHIFT + 10;', $output); + } +} diff --git a/tests/Stubs/StubConstExprBackedEnum.php b/tests/Stubs/StubConstExprBackedEnum.php new file mode 100644 index 00000000..b148bf3f --- /dev/null +++ b/tests/Stubs/StubConstExprBackedEnum.php @@ -0,0 +1,31 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Stubs; + +/** + * Stub backed enum with constant-expression case values (issue #600), + * used by EnumProxyGeneratorTest. + */ +enum StubConstExprBackedEnum: int +{ + private const int SHIFT = 2; + + case Negative = -1; + case Shifted = 1 << 2; + case FromConst = self::SHIFT + 10; + + public function describe(): string + { + return $this->name . '=' . $this->value; + } +} From eed7f976a3eff51fa41f58e2d71f206eb2d84be6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:33:59 +0000 Subject: [PATCH 2/5] Add union/intersection return-type matching and property modifier predicates ReturnTypePointcut now supports union, intersection and DNF return types. Both the pattern and the actual reflection type are normalized into sets of intersection groups (split on '|' at paren depth 0, then on '&'; parens and leading backslashes removed; a leading '?' on the actual type expands to '|null'). A single-type pattern matches if any member of the actual type matches (wildcards preserved per member); a composite pattern must correspond one-to-one to the actual member set, order-insensitively. In the pattern, '?' keeps its historical single-character-wildcard meaning (BC), so nullable patterns are written as 'Foo|null'. The "not supported yet" note is replaced by the documented semantics. The pointcut grammar accepts union/intersection return-type patterns after ':' (DNF written paren-free, 'A&B|C' == '(A&B)|C'), and new member modifier predicates 'readonly', 'private(set)' and 'protected(set)', mapped to ReflectionProperty::IS_READONLY / IS_PRIVATE_SET / IS_PROTECTED_SET. Matching stays bitmask-based in ModifierPointcut: both native reflection and parser-reflection expose these bits via getModifiers(), so no implementation-specific guards are needed. The LALR parse table was regenerated from the updated grammar (zero conflicts). The asymmetric-visibility tokens are lexed as single tokens ('private(set)'), keeping full BC for the existing grammar. Not implemented (out of scope, noted deliberately): - Parenthesized DNF groups in the grammar ('(A&B)|C'); the paren-free equivalent parses and matches identically, and ReturnTypePointcut itself normalizes parenthesized patterns when constructed directly. - Wildcards inside grammar-level return-type members (direct ReturnTypePointcut construction supports them). Fixes #604 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WFMYyvE4hYRUoMS8mtKrHP --- src/Aop/Pointcut/ModifierPointcut.php | 12 ++ src/Aop/Pointcut/PointcutGrammar.php | 26 ++- src/Aop/Pointcut/PointcutLexer.php | 6 + src/Aop/Pointcut/PointcutParseTable.php | 2 +- src/Aop/Pointcut/ReturnTypePointcut.php | 191 ++++++++++++++++-- tests/Aop/Pointcut/ModifierPointcutTest.php | 33 +++ tests/Aop/Pointcut/PointcutParserTest.php | 73 +++++++ tests/Aop/Pointcut/ReturnTypePointcutTest.php | 35 ++++ tests/Stubs/StubPropertyModifiers.php | 33 +++ 9 files changed, 389 insertions(+), 22 deletions(-) create mode 100644 tests/Stubs/StubPropertyModifiers.php diff --git a/src/Aop/Pointcut/ModifierPointcut.php b/src/Aop/Pointcut/ModifierPointcut.php index 653148f3..bf58e5fb 100644 --- a/src/Aop/Pointcut/ModifierPointcut.php +++ b/src/Aop/Pointcut/ModifierPointcut.php @@ -21,6 +21,18 @@ /** * ModifierPointcut performs matching on modifiers for reflector + * + * Matching is bitmask-based on {@see \ReflectionMethod::getModifiers()} / + * {@see \ReflectionProperty::getModifiers()}. Besides the classic visibility masks + * (public/protected/private/static/final), property-only masks are supported: + * + * - {@see \ReflectionProperty::IS_READONLY} — 'readonly' grammar predicate + * - {@see \ReflectionProperty::IS_PRIVATE_SET} — 'private(set)' grammar predicate (PHP 8.4+) + * - {@see \ReflectionProperty::IS_PROTECTED_SET} — 'protected(set)' grammar predicate (PHP 8.4+) + * + * Both native reflection and Go\ParserReflection\ReflectionProperty expose these bits via + * getModifiers(), so no reflection-implementation-specific guards are needed here. Methods + * never carry these bits, so such predicates simply never match method reflectors. */ final class ModifierPointcut implements Pointcut { diff --git a/src/Aop/Pointcut/PointcutGrammar.php b/src/Aop/Pointcut/PointcutGrammar.php index 277162b5..58e89246 100644 --- a/src/Aop/Pointcut/PointcutGrammar.php +++ b/src/Aop/Pointcut/PointcutGrammar.php @@ -18,6 +18,7 @@ use Go\Aop\Pointcut; use Go\Core\AspectContainer; use ReflectionMethod; +use ReflectionProperty; use function constant; /** @@ -185,7 +186,7 @@ function (ClassMemberReference $reference) { ); } ) - ->is('memberReference', '(', 'argumentList', ')', ':', 'namespaceName') + ->is('memberReference', '(', 'argumentList', ')', ':', 'returnTypePattern') ->call( function (ClassMemberReference $reference, mixed $_0, mixed $_1, mixed $_2, mixed $_3, string $returnType) { return new AndPointcut( @@ -211,7 +212,7 @@ function (string $namespacePattern, mixed $_0, string $namePattern) { ); } ) - ->is('namespacePattern', 'nsSeparator', 'namePatternPart', '(', 'argumentList', ')', ':', 'namespaceName') + ->is('namespacePattern', 'nsSeparator', 'namePatternPart', '(', 'argumentList', ')', ':', 'returnTypePattern') ->call( function (string $namespacePattern, mixed $_0, string $namePattern, mixed $_1, mixed $_2, mixed $_3, mixed $_4, string $returnType) { return new AndPointcut( @@ -304,6 +305,21 @@ function () { ->call($stringConverter) ; + // Return-type patterns support union ('|') and intersection ('&') members. + // DNF groups are written without parentheses — 'A&B|C' is equivalent to '(A&B)|C', + // matching PHP's own type precedence; ReturnTypePointcut normalizes both forms. + $this('returnTypeMember') + ->is('namespaceName') + ->is('returnTypeMember', '&', 'namespaceName') + ->call(fn(string $left, mixed $_0, string $right) => "{$left}&{$right}") + ; + + $this('returnTypePattern') + ->is('returnTypeMember') + ->is('returnTypePattern', '|', 'returnTypeMember') + ->call(fn(string $left, mixed $_0, string $right) => "{$left}|{$right}") + ; + $this('memberModifiers') ->is('memberModifier', '|', 'memberModifiers') ->call(fn(int $modifier, mixed $_0, ModifierPointcut $matcher) => $matcher->orMatch($modifier)) @@ -323,6 +339,12 @@ function () { ->call($converter) ->is('final') ->call($converter) + ->is('readonly') + ->call(fn() => ReflectionProperty::IS_READONLY) + ->is('private(set)') + ->call(fn() => ReflectionProperty::IS_PRIVATE_SET) + ->is('protected(set)') + ->call(fn() => ReflectionProperty::IS_PROTECTED_SET) ; $this->start('pointcutExpression'); diff --git a/src/Aop/Pointcut/PointcutLexer.php b/src/Aop/Pointcut/PointcutLexer.php index a6239f40..eab848b0 100644 --- a/src/Aop/Pointcut/PointcutLexer.php +++ b/src/Aop/Pointcut/PointcutLexer.php @@ -41,6 +41,12 @@ public function __construct() $this->token('protected'); $this->token('private'); $this->token('final'); + $this->token('readonly'); + + // Asymmetric visibility modifiers (PHP 8.4+), lexed as single tokens. + // The lexer prefers the longest match, so 'private(set)' wins over 'private' + '('. + $this->token('private(set)'); + $this->token('protected(set)'); // Access type (dynamic or static) $this->token('->'); diff --git a/src/Aop/Pointcut/PointcutParseTable.php b/src/Aop/Pointcut/PointcutParseTable.php index 9ee5bf1f..7bde720f 100644 --- a/src/Aop/Pointcut/PointcutParseTable.php +++ b/src/Aop/Pointcut/PointcutParseTable.php @@ -12,4 +12,4 @@ /** * This table was generated for production use, do not touch it */ -return ['action' => [0 => ['!' => 4, '(' => 6, 'access' => 18, 'annotation' => 19, 'execution' => 20, 'within' => 21, 'initialization' => 22, 'staticinitialization' => 23, 'matchInherited' => 24, 'namePart' => 26,], 1 => ['||' => 27, '$eof' => 0,], 2 => ['&&' => 28, '$eof' => -3, '||' => -3, ')' => -3,], 4 => ['(' => 6, 'access' => 18, 'annotation' => 19, 'execution' => 20, 'within' => 21, 'initialization' => 22, 'staticinitialization' => 23, 'matchInherited' => 24, 'namePart' => 26,], 6 => ['!' => 4, '(' => 6, 'access' => 18, 'annotation' => 19, 'execution' => 20, 'within' => 21, 'initialization' => 22, 'staticinitialization' => 23, 'matchInherited' => 24, 'namePart' => 26,], 18 => ['(' => 31,], 19 => ['access' => 32, 'execution' => 33, 'within' => 34,], 20 => ['(' => 35,], 21 => ['(' => 36,], 22 => ['(' => 37,], 23 => ['(' => 38,], 24 => ['(' => 39,], 25 => ['->' => 40, 'nsSeparator' => 41,], 27 => ['!' => 4, '(' => 6, 'access' => 18, 'annotation' => 19, 'execution' => 20, 'within' => 21, 'initialization' => 22, 'staticinitialization' => 23, 'matchInherited' => 24, 'namePart' => 26,], 28 => ['!' => 4, '(' => 6, 'access' => 18, 'annotation' => 19, 'execution' => 20, 'within' => 21, 'initialization' => 22, 'staticinitialization' => 23, 'matchInherited' => 24, 'namePart' => 26,], 30 => [')' => 44, '||' => 27,], 31 => ['public' => 49, 'protected' => 50, 'private' => 51, 'final' => 52,], 32 => ['(' => 53,], 33 => ['(' => 54,], 34 => ['(' => 55,], 35 => ['**' => 60, '*' => 62, 'namePart' => 63, 'public' => 49, 'protected' => 50, 'private' => 51, 'final' => 52,], 36 => ['**' => 60, '*' => 62, 'namePart' => 63,], 37 => ['**' => 60, '*' => 62, 'namePart' => 63,], 38 => ['**' => 60, '*' => 62, 'namePart' => 63,], 39 => [')' => 68,], 40 => ['*' => 62, 'namePart' => 63,], 41 => ['namePart' => 70,], 42 => ['&&' => 28, '$eof' => -2, '||' => -2, ')' => -2,], 45 => [')' => 71,], 47 => ['**' => 60, '*' => 62, 'namePart' => 63,], 48 => ['|' => 73, 'public' => 49, 'protected' => 50, 'private' => 51, 'final' => 52, '**' => -55, '*' => -55, 'namePart' => -55,], 53 => ['namePart' => 26,], 54 => ['namePart' => 26,], 55 => ['namePart' => 26,], 56 => [')' => 78,], 57 => [')' => 79,], 58 => ['(' => 80,], 59 => ['nsSeparator' => 81,], 61 => ['*' => 82, 'namePart' => 83, '|' => 84, 'nsSeparator' => -43, ')' => -43, '+' => -43, '::' => -43, '->' => -43,], 64 => [')' => 85,], 65 => ['+' => 86, 'nsSeparator' => 87, ')' => -37, '::' => -37, '->' => -37,], 66 => [')' => 88,], 67 => [')' => 89,], 69 => ['*' => 82, 'namePart' => 83, '|' => 84, '$eof' => -30, '||' => -30, '&&' => -30, ')' => -30,], 72 => ['::' => 91, '->' => 92,], 73 => ['public' => 49, 'protected' => 50, 'private' => 51, 'final' => 52,], 75 => [')' => 94, 'nsSeparator' => 41,], 76 => [')' => 95, 'nsSeparator' => 41,], 77 => [')' => 96, 'nsSeparator' => 41,], 80 => ['*' => 98,], 81 => ['**' => 100, '*' => 62, 'namePart' => 63,], 84 => ['namePart' => 101,], 87 => ['**' => 100, '*' => 62, 'namePart' => 63,], 90 => ['*' => 62, 'namePart' => 63,], 97 => [')' => 104,], 99 => ['(' => 105, '*' => 82, 'namePart' => 83, '|' => 84, 'nsSeparator' => -44,], 102 => ['*' => 82, 'namePart' => 83, '|' => 84, ')' => -44, '+' => -44, 'nsSeparator' => -44, '::' => -44, '->' => -44,], 103 => ['*' => 82, 'namePart' => 83, '|' => 84, ')' => -36, '(' => -36,], 104 => [':' => 106, ')' => -32,], 105 => ['*' => 98,], 106 => ['namePart' => 26,], 107 => [')' => 109,], 108 => ['nsSeparator' => 41, ')' => -33,], 109 => [':' => 110, ')' => -34,], 110 => ['namePart' => 26,], 111 => ['nsSeparator' => 41, ')' => -35,], 3 => ['$eof' => -5, '||' => -5, '&&' => -5, ')' => -5,], 5 => ['$eof' => -7, '||' => -7, '&&' => -7, ')' => -7,], 7 => ['$eof' => -9, '||' => -9, '&&' => -9, ')' => -9,], 8 => ['$eof' => -10, '||' => -10, '&&' => -10, ')' => -10,], 9 => ['$eof' => -11, '||' => -11, '&&' => -11, ')' => -11,], 10 => ['$eof' => -12, '||' => -12, '&&' => -12, ')' => -12,], 11 => ['$eof' => -13, '||' => -13, '&&' => -13, ')' => -13,], 12 => ['$eof' => -14, '||' => -14, '&&' => -14, ')' => -14,], 13 => ['$eof' => -15, '||' => -15, '&&' => -15, ')' => -15,], 14 => ['$eof' => -16, '||' => -16, '&&' => -16, ')' => -16,], 15 => ['$eof' => -17, '||' => -17, '&&' => -17, ')' => -17,], 16 => ['$eof' => -18, '||' => -18, '&&' => -18, ')' => -18,], 17 => ['$eof' => -19, '||' => -19, '&&' => -19, ')' => -19,], 26 => ['->' => -51, 'nsSeparator' => -51, ')' => -51,], 29 => ['$eof' => -6, '||' => -6, '&&' => -6, ')' => -6,], 43 => ['$eof' => -4, '||' => -4, '&&' => -4, ')' => -4,], 44 => ['$eof' => -8, '||' => -8, '&&' => -8, ')' => -8,], 46 => [')' => -31,], 49 => ['**' => -56, '*' => -56, 'namePart' => -56, '|' => -56, 'public' => -56, 'protected' => -56, 'private' => -56, 'final' => -56,], 50 => ['**' => -57, '*' => -57, 'namePart' => -57, '|' => -57, 'public' => -57, 'protected' => -57, 'private' => -57, 'final' => -57,], 51 => ['**' => -58, '*' => -58, 'namePart' => -58, '|' => -58, 'public' => -58, 'protected' => -58, 'private' => -58, 'final' => -58,], 52 => ['**' => -59, '*' => -59, 'namePart' => -59, '|' => -59, 'public' => -59, 'protected' => -59, 'private' => -59, 'final' => -59,], 60 => ['nsSeparator' => -42, ')' => -42, '+' => -42, '::' => -42, '->' => -42,], 62 => ['$eof' => -46, '||' => -46, '&&' => -46, ')' => -46, '(' => -46, 'nsSeparator' => -46, '*' => -46, 'namePart' => -46, '|' => -46, '+' => -46, '::' => -46, '->' => -46,], 63 => ['$eof' => -47, '||' => -47, '&&' => -47, ')' => -47, '(' => -47, 'nsSeparator' => -47, '*' => -47, 'namePart' => -47, '|' => -47, '+' => -47, '::' => -47, '->' => -47,], 68 => ['$eof' => -29, '||' => -29, '&&' => -29, ')' => -29,], 70 => ['->' => -52, 'nsSeparator' => -52, ')' => -52,], 71 => ['$eof' => -20, '||' => -20, '&&' => -20, ')' => -20,], 74 => ['**' => -54, '*' => -54, 'namePart' => -54,], 78 => ['$eof' => -21, '||' => -21, '&&' => -21, ')' => -21,], 79 => ['$eof' => -22, '||' => -22, '&&' => -22, ')' => -22,], 82 => ['$eof' => -48, '||' => -48, '&&' => -48, ')' => -48, '(' => -48, 'nsSeparator' => -48, '*' => -48, 'namePart' => -48, '|' => -48, '+' => -48, '::' => -48, '->' => -48,], 83 => ['$eof' => -49, '||' => -49, '&&' => -49, ')' => -49, '(' => -49, 'nsSeparator' => -49, '*' => -49, 'namePart' => -49, '|' => -49, '+' => -49, '::' => -49, '->' => -49,], 85 => ['$eof' => -23, '||' => -23, '&&' => -23, ')' => -23,], 86 => [')' => -38, '::' => -38, '->' => -38,], 88 => ['$eof' => -27, '||' => -27, '&&' => -27, ')' => -27,], 89 => ['$eof' => -28, '||' => -28, '&&' => -28, ')' => -28,], 91 => ['*' => -40, 'namePart' => -40,], 92 => ['*' => -41, 'namePart' => -41,], 93 => ['**' => -53, '*' => -53, 'namePart' => -53,], 94 => ['$eof' => -24, '||' => -24, '&&' => -24, ')' => -24,], 95 => ['$eof' => -25, '||' => -25, '&&' => -25, ')' => -25,], 96 => ['$eof' => -26, '||' => -26, '&&' => -26, ')' => -26,], 98 => [')' => -39,], 100 => ['nsSeparator' => -45, ')' => -45, '+' => -45, '::' => -45, '->' => -45,], 101 => ['$eof' => -50, '||' => -50, '&&' => -50, ')' => -50, '(' => -50, 'nsSeparator' => -50, '*' => -50, 'namePart' => -50, '|' => -50, '+' => -50, '::' => -50, '->' => -50,],], 'goto' => [0 => ['pointcutExpression' => 1, 'conjugatedExpression' => 2, 'negatedExpression' => 3, 'brakedExpression' => 5, 'singlePointcut' => 7, 'accessPointcut' => 8, 'annotatedAccessPointcut' => 9, 'executionPointcut' => 10, 'annotatedExecutionPointcut' => 11, 'withinPointcut' => 12, 'annotatedWithinPointcut' => 13, 'initializationPointcut' => 14, 'staticInitializationPointcut' => 15, 'matchInheritedPointcut' => 16, 'pointcutReference' => 17, 'namespaceName' => 25,], 4 => ['brakedExpression' => 29, 'singlePointcut' => 7, 'accessPointcut' => 8, 'annotatedAccessPointcut' => 9, 'executionPointcut' => 10, 'annotatedExecutionPointcut' => 11, 'withinPointcut' => 12, 'annotatedWithinPointcut' => 13, 'initializationPointcut' => 14, 'staticInitializationPointcut' => 15, 'matchInheritedPointcut' => 16, 'pointcutReference' => 17, 'namespaceName' => 25,], 6 => ['pointcutExpression' => 30, 'conjugatedExpression' => 2, 'negatedExpression' => 3, 'brakedExpression' => 5, 'singlePointcut' => 7, 'accessPointcut' => 8, 'annotatedAccessPointcut' => 9, 'executionPointcut' => 10, 'annotatedExecutionPointcut' => 11, 'withinPointcut' => 12, 'annotatedWithinPointcut' => 13, 'initializationPointcut' => 14, 'staticInitializationPointcut' => 15, 'matchInheritedPointcut' => 16, 'pointcutReference' => 17, 'namespaceName' => 25,], 27 => ['conjugatedExpression' => 42, 'negatedExpression' => 3, 'brakedExpression' => 5, 'singlePointcut' => 7, 'accessPointcut' => 8, 'annotatedAccessPointcut' => 9, 'executionPointcut' => 10, 'annotatedExecutionPointcut' => 11, 'withinPointcut' => 12, 'annotatedWithinPointcut' => 13, 'initializationPointcut' => 14, 'staticInitializationPointcut' => 15, 'matchInheritedPointcut' => 16, 'pointcutReference' => 17, 'namespaceName' => 25,], 28 => ['negatedExpression' => 43, 'brakedExpression' => 5, 'singlePointcut' => 7, 'accessPointcut' => 8, 'annotatedAccessPointcut' => 9, 'executionPointcut' => 10, 'annotatedExecutionPointcut' => 11, 'withinPointcut' => 12, 'annotatedWithinPointcut' => 13, 'initializationPointcut' => 14, 'staticInitializationPointcut' => 15, 'matchInheritedPointcut' => 16, 'pointcutReference' => 17, 'namespaceName' => 25,], 31 => ['propertyAccessReference' => 45, 'memberReference' => 46, 'memberModifiers' => 47, 'memberModifier' => 48,], 35 => ['methodExecutionReference' => 56, 'functionExecutionReference' => 57, 'memberReference' => 58, 'namespacePattern' => 59, 'memberModifiers' => 47, 'namePatternPart' => 61, 'memberModifier' => 48,], 36 => ['classFilter' => 64, 'namespacePattern' => 65, 'namePatternPart' => 61,], 37 => ['classFilter' => 66, 'namespacePattern' => 65, 'namePatternPart' => 61,], 38 => ['classFilter' => 67, 'namespacePattern' => 65, 'namePatternPart' => 61,], 40 => ['namePatternPart' => 69,], 47 => ['classFilter' => 72, 'namespacePattern' => 65, 'namePatternPart' => 61,], 48 => ['memberModifiers' => 74, 'memberModifier' => 48,], 53 => ['namespaceName' => 75,], 54 => ['namespaceName' => 76,], 55 => ['namespaceName' => 77,], 72 => ['memberAccessType' => 90,], 73 => ['memberModifiers' => 93, 'memberModifier' => 48,], 80 => ['argumentList' => 97,], 81 => ['namePatternPart' => 99,], 87 => ['namePatternPart' => 102,], 90 => ['namePatternPart' => 103,], 105 => ['argumentList' => 107,], 106 => ['namespaceName' => 108,], 110 => ['namespaceName' => 111,],]]; +return ['action' => [0 => ['!' => 4, '(' => 6, 'access' => 18, 'annotation' => 19, 'execution' => 20, 'within' => 21, 'initialization' => 22, 'staticinitialization' => 23, 'matchInherited' => 24, 'namePart' => 26,], 1 => ['||' => 27, '$eof' => 0,], 2 => ['&&' => 28, '$eof' => -3, '||' => -3, ')' => -3,], 4 => ['(' => 6, 'access' => 18, 'annotation' => 19, 'execution' => 20, 'within' => 21, 'initialization' => 22, 'staticinitialization' => 23, 'matchInherited' => 24, 'namePart' => 26,], 6 => ['!' => 4, '(' => 6, 'access' => 18, 'annotation' => 19, 'execution' => 20, 'within' => 21, 'initialization' => 22, 'staticinitialization' => 23, 'matchInherited' => 24, 'namePart' => 26,], 18 => ['(' => 31,], 19 => ['access' => 32, 'execution' => 33, 'within' => 34,], 20 => ['(' => 35,], 21 => ['(' => 36,], 22 => ['(' => 37,], 23 => ['(' => 38,], 24 => ['(' => 39,], 25 => ['->' => 40, 'nsSeparator' => 41,], 27 => ['!' => 4, '(' => 6, 'access' => 18, 'annotation' => 19, 'execution' => 20, 'within' => 21, 'initialization' => 22, 'staticinitialization' => 23, 'matchInherited' => 24, 'namePart' => 26,], 28 => ['!' => 4, '(' => 6, 'access' => 18, 'annotation' => 19, 'execution' => 20, 'within' => 21, 'initialization' => 22, 'staticinitialization' => 23, 'matchInherited' => 24, 'namePart' => 26,], 30 => [')' => 44, '||' => 27,], 31 => ['public' => 49, 'protected' => 50, 'private' => 51, 'final' => 52, 'readonly' => 53, 'private(set)' => 54, 'protected(set)' => 55,], 32 => ['(' => 56,], 33 => ['(' => 57,], 34 => ['(' => 58,], 35 => ['**' => 63, '*' => 65, 'namePart' => 66, 'public' => 49, 'protected' => 50, 'private' => 51, 'final' => 52, 'readonly' => 53, 'private(set)' => 54, 'protected(set)' => 55,], 36 => ['**' => 63, '*' => 65, 'namePart' => 66,], 37 => ['**' => 63, '*' => 65, 'namePart' => 66,], 38 => ['**' => 63, '*' => 65, 'namePart' => 66,], 39 => [')' => 71,], 40 => ['*' => 65, 'namePart' => 66,], 41 => ['namePart' => 73,], 42 => ['&&' => 28, '$eof' => -2, '||' => -2, ')' => -2,], 45 => [')' => 74,], 47 => ['**' => 63, '*' => 65, 'namePart' => 66,], 48 => ['|' => 76, 'public' => 49, 'protected' => 50, 'private' => 51, 'final' => 52, 'readonly' => 53, 'private(set)' => 54, 'protected(set)' => 55, '**' => -59, '*' => -59, 'namePart' => -59,], 56 => ['namePart' => 26,], 57 => ['namePart' => 26,], 58 => ['namePart' => 26,], 59 => [')' => 81,], 60 => [')' => 82,], 61 => ['(' => 83,], 62 => ['nsSeparator' => 84,], 64 => ['*' => 85, 'namePart' => 86, '|' => 87, 'nsSeparator' => -43, ')' => -43, '+' => -43, '::' => -43, '->' => -43,], 67 => [')' => 88,], 68 => ['+' => 89, 'nsSeparator' => 90, ')' => -37, '::' => -37, '->' => -37,], 69 => [')' => 91,], 70 => [')' => 92,], 72 => ['*' => 85, 'namePart' => 86, '|' => 87, '$eof' => -30, '||' => -30, '&&' => -30, ')' => -30,], 75 => ['::' => 94, '->' => 95,], 76 => ['public' => 49, 'protected' => 50, 'private' => 51, 'final' => 52, 'readonly' => 53, 'private(set)' => 54, 'protected(set)' => 55,], 78 => [')' => 97, 'nsSeparator' => 41,], 79 => [')' => 98, 'nsSeparator' => 41,], 80 => [')' => 99, 'nsSeparator' => 41,], 83 => ['*' => 101,], 84 => ['**' => 103, '*' => 65, 'namePart' => 66,], 87 => ['namePart' => 104,], 90 => ['**' => 103, '*' => 65, 'namePart' => 66,], 93 => ['*' => 65, 'namePart' => 66,], 100 => [')' => 107,], 102 => ['(' => 108, '*' => 85, 'namePart' => 86, '|' => 87, 'nsSeparator' => -44,], 105 => ['*' => 85, 'namePart' => 86, '|' => 87, ')' => -44, '+' => -44, 'nsSeparator' => -44, '::' => -44, '->' => -44,], 106 => ['*' => 85, 'namePart' => 86, '|' => 87, ')' => -36, '(' => -36,], 107 => [':' => 109, ')' => -32,], 108 => ['*' => 101,], 109 => ['namePart' => 26,], 110 => [')' => 114,], 111 => ['|' => 115, ')' => -33,], 112 => ['&' => 116, ')' => -55, '|' => -55,], 113 => ['nsSeparator' => 41, ')' => -53, '|' => -53, '&' => -53,], 114 => [':' => 117, ')' => -34,], 115 => ['namePart' => 26,], 116 => ['namePart' => 26,], 117 => ['namePart' => 26,], 118 => ['&' => 116, ')' => -56, '|' => -56,], 119 => ['nsSeparator' => 41, ')' => -54, '|' => -54, '&' => -54,], 120 => ['|' => 115, ')' => -35,], 3 => ['$eof' => -5, '||' => -5, '&&' => -5, ')' => -5,], 5 => ['$eof' => -7, '||' => -7, '&&' => -7, ')' => -7,], 7 => ['$eof' => -9, '||' => -9, '&&' => -9, ')' => -9,], 8 => ['$eof' => -10, '||' => -10, '&&' => -10, ')' => -10,], 9 => ['$eof' => -11, '||' => -11, '&&' => -11, ')' => -11,], 10 => ['$eof' => -12, '||' => -12, '&&' => -12, ')' => -12,], 11 => ['$eof' => -13, '||' => -13, '&&' => -13, ')' => -13,], 12 => ['$eof' => -14, '||' => -14, '&&' => -14, ')' => -14,], 13 => ['$eof' => -15, '||' => -15, '&&' => -15, ')' => -15,], 14 => ['$eof' => -16, '||' => -16, '&&' => -16, ')' => -16,], 15 => ['$eof' => -17, '||' => -17, '&&' => -17, ')' => -17,], 16 => ['$eof' => -18, '||' => -18, '&&' => -18, ')' => -18,], 17 => ['$eof' => -19, '||' => -19, '&&' => -19, ')' => -19,], 26 => ['->' => -51, 'nsSeparator' => -51, ')' => -51, '|' => -51, '&' => -51,], 29 => ['$eof' => -6, '||' => -6, '&&' => -6, ')' => -6,], 43 => ['$eof' => -4, '||' => -4, '&&' => -4, ')' => -4,], 44 => ['$eof' => -8, '||' => -8, '&&' => -8, ')' => -8,], 46 => [')' => -31,], 49 => ['**' => -60, '*' => -60, 'namePart' => -60, '|' => -60, 'public' => -60, 'protected' => -60, 'private' => -60, 'final' => -60, 'readonly' => -60, 'private(set)' => -60, 'protected(set)' => -60,], 50 => ['**' => -61, '*' => -61, 'namePart' => -61, '|' => -61, 'public' => -61, 'protected' => -61, 'private' => -61, 'final' => -61, 'readonly' => -61, 'private(set)' => -61, 'protected(set)' => -61,], 51 => ['**' => -62, '*' => -62, 'namePart' => -62, '|' => -62, 'public' => -62, 'protected' => -62, 'private' => -62, 'final' => -62, 'readonly' => -62, 'private(set)' => -62, 'protected(set)' => -62,], 52 => ['**' => -63, '*' => -63, 'namePart' => -63, '|' => -63, 'public' => -63, 'protected' => -63, 'private' => -63, 'final' => -63, 'readonly' => -63, 'private(set)' => -63, 'protected(set)' => -63,], 53 => ['**' => -64, '*' => -64, 'namePart' => -64, '|' => -64, 'public' => -64, 'protected' => -64, 'private' => -64, 'final' => -64, 'readonly' => -64, 'private(set)' => -64, 'protected(set)' => -64,], 54 => ['**' => -65, '*' => -65, 'namePart' => -65, '|' => -65, 'public' => -65, 'protected' => -65, 'private' => -65, 'final' => -65, 'readonly' => -65, 'private(set)' => -65, 'protected(set)' => -65,], 55 => ['**' => -66, '*' => -66, 'namePart' => -66, '|' => -66, 'public' => -66, 'protected' => -66, 'private' => -66, 'final' => -66, 'readonly' => -66, 'private(set)' => -66, 'protected(set)' => -66,], 63 => ['nsSeparator' => -42, ')' => -42, '+' => -42, '::' => -42, '->' => -42,], 65 => ['$eof' => -46, '||' => -46, '&&' => -46, ')' => -46, '(' => -46, 'nsSeparator' => -46, '*' => -46, 'namePart' => -46, '|' => -46, '+' => -46, '::' => -46, '->' => -46,], 66 => ['$eof' => -47, '||' => -47, '&&' => -47, ')' => -47, '(' => -47, 'nsSeparator' => -47, '*' => -47, 'namePart' => -47, '|' => -47, '+' => -47, '::' => -47, '->' => -47,], 71 => ['$eof' => -29, '||' => -29, '&&' => -29, ')' => -29,], 73 => ['->' => -52, 'nsSeparator' => -52, ')' => -52, '|' => -52, '&' => -52,], 74 => ['$eof' => -20, '||' => -20, '&&' => -20, ')' => -20,], 77 => ['**' => -58, '*' => -58, 'namePart' => -58,], 81 => ['$eof' => -21, '||' => -21, '&&' => -21, ')' => -21,], 82 => ['$eof' => -22, '||' => -22, '&&' => -22, ')' => -22,], 85 => ['$eof' => -48, '||' => -48, '&&' => -48, ')' => -48, '(' => -48, 'nsSeparator' => -48, '*' => -48, 'namePart' => -48, '|' => -48, '+' => -48, '::' => -48, '->' => -48,], 86 => ['$eof' => -49, '||' => -49, '&&' => -49, ')' => -49, '(' => -49, 'nsSeparator' => -49, '*' => -49, 'namePart' => -49, '|' => -49, '+' => -49, '::' => -49, '->' => -49,], 88 => ['$eof' => -23, '||' => -23, '&&' => -23, ')' => -23,], 89 => [')' => -38, '::' => -38, '->' => -38,], 91 => ['$eof' => -27, '||' => -27, '&&' => -27, ')' => -27,], 92 => ['$eof' => -28, '||' => -28, '&&' => -28, ')' => -28,], 94 => ['*' => -40, 'namePart' => -40,], 95 => ['*' => -41, 'namePart' => -41,], 96 => ['**' => -57, '*' => -57, 'namePart' => -57,], 97 => ['$eof' => -24, '||' => -24, '&&' => -24, ')' => -24,], 98 => ['$eof' => -25, '||' => -25, '&&' => -25, ')' => -25,], 99 => ['$eof' => -26, '||' => -26, '&&' => -26, ')' => -26,], 101 => [')' => -39,], 103 => ['nsSeparator' => -45, ')' => -45, '+' => -45, '::' => -45, '->' => -45,], 104 => ['$eof' => -50, '||' => -50, '&&' => -50, ')' => -50, '(' => -50, 'nsSeparator' => -50, '*' => -50, 'namePart' => -50, '|' => -50, '+' => -50, '::' => -50, '->' => -50,],], 'goto' => [0 => ['pointcutExpression' => 1, 'conjugatedExpression' => 2, 'negatedExpression' => 3, 'brakedExpression' => 5, 'singlePointcut' => 7, 'accessPointcut' => 8, 'annotatedAccessPointcut' => 9, 'executionPointcut' => 10, 'annotatedExecutionPointcut' => 11, 'withinPointcut' => 12, 'annotatedWithinPointcut' => 13, 'initializationPointcut' => 14, 'staticInitializationPointcut' => 15, 'matchInheritedPointcut' => 16, 'pointcutReference' => 17, 'namespaceName' => 25,], 4 => ['brakedExpression' => 29, 'singlePointcut' => 7, 'accessPointcut' => 8, 'annotatedAccessPointcut' => 9, 'executionPointcut' => 10, 'annotatedExecutionPointcut' => 11, 'withinPointcut' => 12, 'annotatedWithinPointcut' => 13, 'initializationPointcut' => 14, 'staticInitializationPointcut' => 15, 'matchInheritedPointcut' => 16, 'pointcutReference' => 17, 'namespaceName' => 25,], 6 => ['pointcutExpression' => 30, 'conjugatedExpression' => 2, 'negatedExpression' => 3, 'brakedExpression' => 5, 'singlePointcut' => 7, 'accessPointcut' => 8, 'annotatedAccessPointcut' => 9, 'executionPointcut' => 10, 'annotatedExecutionPointcut' => 11, 'withinPointcut' => 12, 'annotatedWithinPointcut' => 13, 'initializationPointcut' => 14, 'staticInitializationPointcut' => 15, 'matchInheritedPointcut' => 16, 'pointcutReference' => 17, 'namespaceName' => 25,], 27 => ['conjugatedExpression' => 42, 'negatedExpression' => 3, 'brakedExpression' => 5, 'singlePointcut' => 7, 'accessPointcut' => 8, 'annotatedAccessPointcut' => 9, 'executionPointcut' => 10, 'annotatedExecutionPointcut' => 11, 'withinPointcut' => 12, 'annotatedWithinPointcut' => 13, 'initializationPointcut' => 14, 'staticInitializationPointcut' => 15, 'matchInheritedPointcut' => 16, 'pointcutReference' => 17, 'namespaceName' => 25,], 28 => ['negatedExpression' => 43, 'brakedExpression' => 5, 'singlePointcut' => 7, 'accessPointcut' => 8, 'annotatedAccessPointcut' => 9, 'executionPointcut' => 10, 'annotatedExecutionPointcut' => 11, 'withinPointcut' => 12, 'annotatedWithinPointcut' => 13, 'initializationPointcut' => 14, 'staticInitializationPointcut' => 15, 'matchInheritedPointcut' => 16, 'pointcutReference' => 17, 'namespaceName' => 25,], 31 => ['propertyAccessReference' => 45, 'memberReference' => 46, 'memberModifiers' => 47, 'memberModifier' => 48,], 35 => ['methodExecutionReference' => 59, 'functionExecutionReference' => 60, 'memberReference' => 61, 'namespacePattern' => 62, 'memberModifiers' => 47, 'namePatternPart' => 64, 'memberModifier' => 48,], 36 => ['classFilter' => 67, 'namespacePattern' => 68, 'namePatternPart' => 64,], 37 => ['classFilter' => 69, 'namespacePattern' => 68, 'namePatternPart' => 64,], 38 => ['classFilter' => 70, 'namespacePattern' => 68, 'namePatternPart' => 64,], 40 => ['namePatternPart' => 72,], 47 => ['classFilter' => 75, 'namespacePattern' => 68, 'namePatternPart' => 64,], 48 => ['memberModifiers' => 77, 'memberModifier' => 48,], 56 => ['namespaceName' => 78,], 57 => ['namespaceName' => 79,], 58 => ['namespaceName' => 80,], 75 => ['memberAccessType' => 93,], 76 => ['memberModifiers' => 96, 'memberModifier' => 48,], 83 => ['argumentList' => 100,], 84 => ['namePatternPart' => 102,], 90 => ['namePatternPart' => 105,], 93 => ['namePatternPart' => 106,], 108 => ['argumentList' => 110,], 109 => ['returnTypePattern' => 111, 'returnTypeMember' => 112, 'namespaceName' => 113,], 115 => ['returnTypeMember' => 118, 'namespaceName' => 113,], 116 => ['namespaceName' => 119,], 117 => ['returnTypePattern' => 120, 'returnTypeMember' => 112, 'namespaceName' => 113,],],]; diff --git a/src/Aop/Pointcut/ReturnTypePointcut.php b/src/Aop/Pointcut/ReturnTypePointcut.php index e337bd8b..61aafb4f 100644 --- a/src/Aop/Pointcut/ReturnTypePointcut.php +++ b/src/Aop/Pointcut/ReturnTypePointcut.php @@ -21,41 +21,57 @@ use ReflectionProperty; /** - * Return type filter matcher methods and function with specific return type + * Return type filter that matches methods and functions with a specific return type. * - * Type name can contain wildcards '*', '**' and '?' + * Type name can contain wildcards '*', '**' and '?' (each applied per type member). * - * This implementation currently doesn't support properly matching of complex types, - * thus union/intersection/DNF types are not supported yet here. + * Union, intersection and DNF types are supported with the following semantics — both the + * pattern and the actual return type are normalized into sets of intersection groups (the + * declaration is split on '|' at parenthesis depth zero, each resulting member on '&'; + * parentheses and leading backslashes are normalized away; a leading '?' nullable marker on + * the ACTUAL type is expanded, '?Foo' being equivalent to 'Foo|null' — in the PATTERN, however, + * '?' keeps its historical single-character-wildcard meaning, so nullable patterns should be + * written as 'Foo|null'): + * + * - A single-type pattern (no '|' and no '&', e.g. 'string' or 'Some*Interface') matches if + * ANY member of the actual type matches it. For example, the pattern 'string' matches + * methods returning 'string', 'string|int', '?string' and 'string|null'. + * - A composite pattern (union and/or intersection, e.g. 'string|int' or 'Countable&Iterator') + * matches only when the pattern's member set corresponds one-to-one to the actual type's + * member set, regardless of the order of members ('int|string' matches 'string|int'). + * Each pattern member may still use wildcards ('Some*|null' matches 'SomeClass|null'). */ final readonly class ReturnTypePointcut implements Pointcut { /** - * Return type name to match, can contain wildcards *,? + * Normalized pattern: list of intersection groups, each group is a list of atomic patterns. + * + * @var list> */ - private string $typeName; + private array $patternGroups; /** - * Pattern for regular expression matching + * Whether the pattern consists of one single atomic type (no union/intersection). */ - private string $regexp; + private bool $isSingleAtomicPattern; /** - * Return type name matcher constructor accepts name or glob pattern of the type to match + * Return type name matcher constructor accepts name or glob pattern of the type to match. * - * @param string $returnTypeName + * The pattern may be a plain type name ('string'), contain wildcards ('Some*'), or be a + * union/intersection/DNF type declaration ('string|int', 'Countable&Iterator', + * '(Countable&Iterator)|null'). */ public function __construct(string $returnTypeName) { - $returnTypeName = trim($returnTypeName, '\\'); + $returnTypeName = trim($returnTypeName, " \t\\"); if (strlen($returnTypeName) === 0) { throw new \InvalidArgumentException("Return type name must not be empty"); } - $this->typeName = $returnTypeName; - $this->regexp = '/^(' . strtr(preg_quote($this->typeName, '/'), [ - '\\*' => '[^\\\\]+', - '\\?' => '.', - ]) . ')$/'; + // Note: '?' at the start of the pattern is a single-character wildcard, not a nullable + // marker (BC with historical behavior) — nullable patterns are written as 'Foo|null'. + $this->patternGroups = self::normalizeTypeExpression($returnTypeName, false); + $this->isSingleAtomicPattern = count($this->patternGroups) === 1 && count($this->patternGroups[0]) === 1; } public function matches( @@ -77,14 +93,151 @@ public function matches( return false; } - $returnType = (string) $reflector->getReturnType(); + $actualGroups = self::normalizeTypeExpression((string) $reflector->getReturnType()); + + // Single-type pattern: match if any member of the actual type matches + if ($this->isSingleAtomicPattern) { + $atomicPattern = $this->patternGroups[0][0]; + foreach ($actualGroups as $actualGroup) { + foreach ($actualGroup as $actualAtom) { + if (self::atomMatches($atomicPattern, $actualAtom)) { + return true; + } + } + } + + return false; + } - // Either we have exact type string match or type matches our regular expression - return ($returnType === $this->typeName) || preg_match($this->regexp, $returnType); + // Composite pattern: pattern member set must correspond one-to-one to the actual member set + return self::matchOneToOne( + $this->patternGroups, + $actualGroups, + static fn(array $patternGroup, array $actualGroup): bool => self::matchOneToOne( + $patternGroup, + $actualGroup, + static fn(string $patternAtom, string $actualAtom): bool => self::atomMatches($patternAtom, $actualAtom) + ) + ); } public function getKind(): int { return Pointcut::KIND_METHOD | Pointcut::KIND_FUNCTION; } + + /** + * Normalizes a type declaration into a sorted set of intersection groups. + * + * For actual reflection types ($expandNullableMarker = true), '?Foo' is normalized into + * 'Foo|null'. Parentheses around DNF groups are removed, and each atomic type is trimmed + * from whitespace and leading backslashes. + * + * @return list> List of intersection groups, each a sorted list of atomic types + */ + private static function normalizeTypeExpression(string $type, bool $expandNullableMarker = true): array + { + $type = trim($type); + if ($expandNullableMarker && str_starts_with($type, '?')) { + $type = substr($type, 1) . '|null'; + } + + $groups = []; + foreach (self::splitAtDepthZero($type) as $member) { + $member = trim($member); + if (str_starts_with($member, '(') && str_ends_with($member, ')')) { + $member = substr($member, 1, -1); + } + $atoms = []; + foreach (explode('&', $member) as $atom) { + $atom = ltrim(trim($atom), '\\'); + if ($atom !== '') { + $atoms[] = $atom; + } + } + if ($atoms !== []) { + sort($atoms, SORT_STRING); + $groups[] = $atoms; + } + } + usort($groups, static fn(array $left, array $right): int => implode('&', $left) <=> implode('&', $right)); + + return $groups; + } + + /** + * Splits a type declaration on '|' at parenthesis depth zero. + * + * @return list + */ + private static function splitAtDepthZero(string $type): array + { + $members = []; + $current = ''; + $depth = 0; + foreach (str_split($type) as $char) { + if ($char === '(') { + $depth++; + } elseif ($char === ')') { + $depth--; + } elseif ($char === '|' && $depth === 0) { + $members[] = $current; + $current = ''; + continue; + } + $current .= $char; + } + $members[] = $current; + + return $members; + } + + /** + * Checks whether one atomic type pattern (with possible '*', '?' wildcards) matches an atomic type. + */ + private static function atomMatches(string $pattern, string $actual): bool + { + if ($pattern === $actual) { + return true; + } + $regexp = '/^(' . strtr(preg_quote($pattern, '/'), [ + '\\*' => '[^\\\\]+', + '\\?' => '.', + ]) . ')$/'; + + return (bool) preg_match($regexp, $actual); + } + + /** + * Checks whether pattern items can be matched one-to-one (bijectively) onto actual items. + * + * Uses simple backtracking; item counts in real-world type declarations are tiny. + * + * @template TPattern + * @template TActual + * @param list $patternItems + * @param list $actualItems + * @param callable(TPattern, TActual): bool $matcher + */ + private static function matchOneToOne(array $patternItems, array $actualItems, callable $matcher): bool + { + if (count($patternItems) !== count($actualItems)) { + return false; + } + if ($patternItems === []) { + return true; + } + $patternItem = array_shift($patternItems); + foreach ($actualItems as $index => $actualItem) { + if ($matcher($patternItem, $actualItem)) { + $remaining = $actualItems; + unset($remaining[$index]); + if (self::matchOneToOne($patternItems, array_values($remaining), $matcher)) { + return true; + } + } + } + + return false; + } } diff --git a/tests/Aop/Pointcut/ModifierPointcutTest.php b/tests/Aop/Pointcut/ModifierPointcutTest.php index 61954e58..71ed50ce 100644 --- a/tests/Aop/Pointcut/ModifierPointcutTest.php +++ b/tests/Aop/Pointcut/ModifierPointcutTest.php @@ -14,10 +14,12 @@ use Go\Aop\Pointcut; use Go\Stubs\FirstStatic; +use Go\Stubs\StubPropertyModifiers; use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionFunction; use ReflectionMethod; +use ReflectionProperty; class ModifierPointcutTest extends TestCase { @@ -105,6 +107,37 @@ public static function reflectorProvider(): \Generator } } + /** + * The IS_READONLY mask must match only readonly properties (issue #604). + */ + public function testMatchesReadonlyPropertyModifier(): void + { + $reflectionClass = new ReflectionClass(StubPropertyModifiers::class); + $this->pointcut->andMatch(ReflectionProperty::IS_READONLY); + + $this->assertTrue($this->pointcut->matches($reflectionClass, $reflectionClass->getProperty('readonlyProp'))); + $this->assertFalse($this->pointcut->matches($reflectionClass, $reflectionClass->getProperty('plain'))); + } + + /** + * The IS_PRIVATE_SET / IS_PROTECTED_SET masks must match only properties with the + * corresponding asymmetric set-visibility (issue #604). + */ + public function testMatchesAsymmetricVisibilityPropertyModifiers(): void + { + $reflectionClass = new ReflectionClass(StubPropertyModifiers::class); + + $privateSetPointcut = new ModifierPointcut(ReflectionProperty::IS_PRIVATE_SET); + $this->assertTrue($privateSetPointcut->matches($reflectionClass, $reflectionClass->getProperty('privateSetProp'))); + $this->assertFalse($privateSetPointcut->matches($reflectionClass, $reflectionClass->getProperty('protectedSetProp'))); + $this->assertFalse($privateSetPointcut->matches($reflectionClass, $reflectionClass->getProperty('plain'))); + + $protectedSetPointcut = new ModifierPointcut(ReflectionProperty::IS_PROTECTED_SET); + $this->assertTrue($protectedSetPointcut->matches($reflectionClass, $reflectionClass->getProperty('protectedSetProp'))); + $this->assertFalse($protectedSetPointcut->matches($reflectionClass, $reflectionClass->getProperty('privateSetProp'))); + $this->assertFalse($protectedSetPointcut->matches($reflectionClass, $reflectionClass->getProperty('plain'))); + } + public function testAlwaysMatchesWithoutReflectorInstance(): void { $reflectionClass = new ReflectionClass(FirstStatic::class); diff --git a/tests/Aop/Pointcut/PointcutParserTest.php b/tests/Aop/Pointcut/PointcutParserTest.php index b276ef71..bbaa8541 100644 --- a/tests/Aop/Pointcut/PointcutParserTest.php +++ b/tests/Aop/Pointcut/PointcutParserTest.php @@ -14,6 +14,8 @@ use Dissect\Lexer\Lexer; use Go\Core\AspectContainer; +use Go\Stubs\StubPropertyModifiers; +use Go\Tests\TestProject\Application\ClassWithComplexTypes; use PHPUnit\Framework\TestCase; /** @@ -104,7 +106,78 @@ public static function validPointcutDefinitions(): array // This will match dynamic initialization pointcut ['initialization(Some\Specific\Class\**)'], + + // Union/intersection/DNF return types (issue #604) + ['execution(public Example->method(*): string|int)'], + ['execution(public Example->method(*): Countable&Iterator)'], + ['execution(public Example->method(*): Iterator|Countable&Iterator|null)'], + ['execution(Demo\Namespace\*(*): string|null)'], + + // readonly and asymmetric visibility modifiers (issue #604) + ['access(readonly Example\Aspect\*->property*)'], + ['access(private(set) Example\Aspect\*->property*)'], + ['access(protected(set) Example\Aspect\*->property*)'], + ['access(public|readonly **->*)'], + ['access(final readonly Example->*)'], ]; } + /** + * A parsed 'access(readonly ...)' pointcut must match only readonly properties. + */ + public function testParsedReadonlyPointcutMatchesOnlyReadonlyProperties(): void + { + $pointcut = $this->parser->parse($this->lexer->lex('access(readonly **->*)')); + + $class = new \ReflectionClass(StubPropertyModifiers::class); + $this->assertTrue($pointcut->matches($class, $class->getProperty('readonlyProp'))); + $this->assertFalse($pointcut->matches($class, $class->getProperty('plain'))); + } + + /** + * Parsed 'private(set)' / 'protected(set)' pointcuts must match only properties + * with the corresponding asymmetric set-visibility. + */ + public function testParsedAsymmetricVisibilityPointcutMatchesOnlyMatchingProperties(): void + { + $privateSet = $this->parser->parse($this->lexer->lex('access(private(set) **->*)')); + + $class = new \ReflectionClass(StubPropertyModifiers::class); + $this->assertTrue($privateSet->matches($class, $class->getProperty('privateSetProp'))); + $this->assertFalse($privateSet->matches($class, $class->getProperty('protectedSetProp'))); + $this->assertFalse($privateSet->matches($class, $class->getProperty('plain'))); + + $protectedSet = $this->parser->parse($this->lexer->lex('access(protected(set) **->*)')); + $this->assertTrue($protectedSet->matches($class, $class->getProperty('protectedSetProp'))); + $this->assertFalse($protectedSet->matches($class, $class->getProperty('privateSetProp'))); + $this->assertFalse($protectedSet->matches($class, $class->getProperty('plain'))); + } + + /** + * A parsed execution pointcut with a union return type must match the method + * with that union return type, member order being irrelevant. + */ + public function testParsedUnionReturnTypePointcutMatches(): void + { + $expression = 'execution(public **->publicMethodWithUnionTypeReturn(*): Closure|Exception)'; + $pointcut = $this->parser->parse($this->lexer->lex($expression)); + + $class = new \ReflectionClass(ClassWithComplexTypes::class); + $this->assertTrue($pointcut->matches($class, $class->getMethod('publicMethodWithUnionTypeReturn'))); + $this->assertFalse($pointcut->matches($class, $class->getMethod('publicMethodWithIntersectionTypeReturn'))); + } + + /** + * A parsed execution pointcut with an intersection return type must match the method + * with that intersection return type. + */ + public function testParsedIntersectionReturnTypePointcutMatches(): void + { + $expression = 'execution(public **->*(*): Countable&Exception)'; + $pointcut = $this->parser->parse($this->lexer->lex($expression)); + + $class = new \ReflectionClass(ClassWithComplexTypes::class); + $this->assertTrue($pointcut->matches($class, $class->getMethod('publicMethodWithIntersectionTypeReturn'))); + $this->assertFalse($pointcut->matches($class, $class->getMethod('publicMethodWithUnionTypeReturn'))); + } } diff --git a/tests/Aop/Pointcut/ReturnTypePointcutTest.php b/tests/Aop/Pointcut/ReturnTypePointcutTest.php index 867acc08..c661a2fc 100644 --- a/tests/Aop/Pointcut/ReturnTypePointcutTest.php +++ b/tests/Aop/Pointcut/ReturnTypePointcutTest.php @@ -4,7 +4,9 @@ use Go\Aop\Intercept\Joinpoint; use Go\Aop\Pointcut; +use Go\Instrument\ClassLoading\CachePathManager; use Go\Stubs\First; +use Go\Tests\TestProject\Application\ClassWithComplexTypes; use InvalidArgumentException; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -28,11 +30,44 @@ public function testMatches(string $typeName, ReflectionFunction|ReflectionMetho public static function returnTypeMatchesDataProvider(): array { + $unionMethod = new ReflectionMethod(ClassWithComplexTypes::class, 'publicMethodWithUnionTypeReturn'); + $intersectionMethod = new ReflectionMethod(ClassWithComplexTypes::class, 'publicMethodWithIntersectionTypeReturn'); + $dnfMethod = new ReflectionMethod(ClassWithComplexTypes::class, 'publicMethodWithDNFTypeReturn'); + $nullableMethod = new ReflectionMethod(CachePathManager::class, 'queryCacheState'); + return [ 'Exact match (int)' => ['int', new ReflectionFunction('strlen'), true], 'Star match (bool)' => ['b*l', new ReflectionMethod(ReturnTypePointcut::class, 'matches'), true], 'Question match (int)' => ['?nt', new ReflectionMethod(ReturnTypePointcut::class, 'getKind'), true], 'No match (int)' => ['array', new ReflectionFunction('strlen'), false], + + // Union return types (Exception|Closure) + 'Union exact match' => ['Exception|Closure', $unionMethod, true], + 'Union match is order-insensitive' => ['Closure|Exception', $unionMethod, true], + 'Union member matched by single-type pattern' => ['Exception', $unionMethod, true], + 'Union member matched by single-type wildcard' => ['Exc*', $unionMethod, true], + 'Union member with wildcard in composite pattern' => ['Exc*|Closure', $unionMethod, true], + 'Union pattern with extra member does not match' => ['Exception|Closure|null', $unionMethod, false], + 'Union pattern with missing member does not match' => ['Exception|Iterator', $unionMethod, false], + + // Intersection return types (Exception&Countable) + 'Intersection exact match' => ['Exception&Countable', $intersectionMethod, true], + 'Intersection match is order-insensitive' => ['Countable&Exception', $intersectionMethod, true], + 'Intersection member matched by single-type pattern' => ['Countable', $intersectionMethod, true], + 'Intersection not matched by union pattern' => ['Exception|Countable', $intersectionMethod, false], + + // DNF return types (Iterator|(Exception&Countable)) + 'DNF exact match' => ['Iterator|(Exception&Countable)', $dnfMethod, true], + 'DNF match is group-order-insensitive' => ['(Exception&Countable)|Iterator', $dnfMethod, true], + 'DNF matches parenthesis-free pattern' => ['Exception&Countable|Iterator', $dnfMethod, true], + 'DNF group member matched by single-type pattern' => ['Countable', $dnfMethod, true], + 'DNF flattened union pattern does not match' => ['Iterator|Exception|Countable', $dnfMethod, false], + + // Nullable return types (?array is equivalent to array|null) + 'Nullable actual matched by plain pattern' => ['array', $nullableMethod, true], + 'Nullable actual matched by union with null' => ['array|null', $nullableMethod, true], + 'Nullable actual matched by null pattern' => ['null', $nullableMethod, true], + 'Nullable actual not matched by other union' => ['array|false', $nullableMethod, false], ]; } diff --git a/tests/Stubs/StubPropertyModifiers.php b/tests/Stubs/StubPropertyModifiers.php new file mode 100644 index 00000000..0b068036 --- /dev/null +++ b/tests/Stubs/StubPropertyModifiers.php @@ -0,0 +1,33 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Stubs; + +/** + * Stub class with readonly and asymmetric-visibility properties, used to test + * the 'readonly', 'private(set)' and 'protected(set)' pointcut modifier predicates. + */ +class StubPropertyModifiers +{ + public string $plain = ''; + + public readonly int $readonlyProp; + + public private(set) string $privateSetProp = ''; + + public protected(set) string $protectedSetProp = ''; + + public function __construct() + { + $this->readonlyProp = 1; + } +} From 9a711aca7b052df44c8500578dbbbd3715327de8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:34:07 +0000 Subject: [PATCH 3/5] Document PHP 8.5 feature support and known limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/php85-limitations.md, modeled on docs/php84-limitations.md, capturing the PHP 8.5.10 / 8.6.0beta2 audit results from PR #597: Working: pipe operator |> in woven bodies, clone with, #[\NoDiscard] propagation, attributes on class constants (incl. #[\Deprecated]), closures/FCC as parameter defaults, final promoted properties and static asymmetric visibility on non-intercepted properties, and self/parent reflection resolution. Limited (tracked in their issues, several with fixes in flight): closures/FCC in attribute arguments (#601), promoted-property interception (#599), enum const-expression case values (#600), new in initializers under INTERCEPT_INITIALIZATIONS (#603), global constants in attribute args (#602), class-level attributes on woven classes (#598). Static properties (incl. 8.5 static asymmetric visibility) are never interceptable via access() — property hooks do not exist for static properties. README links the new document next to the PHP 8.4 one. Fixes #605 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WFMYyvE4hYRUoMS8mtKrHP --- README.md | 2 + docs/php85-limitations.md | 110 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 docs/php85-limitations.md diff --git a/README.md b/README.md index 9856b4cf..0506bcc2 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ if ($fieldAccess->getField()->isInitialized($this)) { ``` > **See also:** [PHP 8.4 Feature Support & Known Limitations](docs/php84-limitations.md) for detailed information about property hooks, readonly properties, lazy objects, and other PHP 8.4 features. +> +> [PHP 8.5 Feature Support & Known Limitations](docs/php85-limitations.md) covers the PHP 8.5 audit results. ### 🛠️ Developer Experience diff --git a/docs/php85-limitations.md b/docs/php85-limitations.md new file mode 100644 index 00000000..88ee3f4d --- /dev/null +++ b/docs/php85-limitations.md @@ -0,0 +1,110 @@ +# PHP 8.5 Feature Support & Known Limitations + +This document describes how Go! AOP Framework handles PHP 8.5 features in proxy generation and +interception, and what limitations exist. It is based on an audit of the framework on +PHP 8.5.10 and PHP 8.6.0beta2 (see PR #597). + +Several of the limitations below are actively being worked on — they are phrased as +"tracked in #NNN" rather than permanent facts, and may already be fixed on master. + +## What works + +### Pipe operator `|>` + +The [pipe operator](https://wiki.php.net/rfc/pipe-operator-v3) works inside woven method bodies. +Method bodies are moved verbatim into the `__AopProxied` trait, so any PHP 8.5 expression syntax +inside them is preserved. + +### `clone with` + +[`clone with`](https://wiki.php.net/rfc/clone_with_v2) expressions in woven code work as expected. + +### `#[\NoDiscard]` attribute + +The [`#[\NoDiscard]`](https://wiki.php.net/rfc/marking_return_value_as_important) attribute is +copied to the generated proxy method, and the join-point dispatch returns the intercepted +method's value, so the engine-level "return value not used" warning keeps firing correctly for +woven methods. + +### Attributes on class constants + +[Attributes on constants](https://wiki.php.net/rfc/attributes-on-constants) — including +`#[\Deprecated]` — are preserved: class constants stay in the woven trait together with their +attributes. + +### Closures and first-class callables as parameter defaults + +[Closures in constant expressions](https://wiki.php.net/rfc/closures_in_const_expr) used as +parameter default values survive weaving in proxy method signatures. + +### Final promoted properties and static asymmetric visibility (non-intercepted) + +`final` promoted constructor properties and `static` properties with asymmetric visibility +(`public static private(set)`) work on classes that are woven, as long as the properties +themselves are not targeted by `access(...)` pointcuts (see the static-property note below). + +### `self`/`parent` reflection resolution + +On PHP 8.5, `ReflectionNamedType::getName()` resolves `self`/`parent` return types to concrete +class names. The proxy generators compensate by reading the raw AST type node where available, +so `self`/`parent` keywords are preserved in generated proxies. + +## Known limitations + +### Closures / first-class callables in attribute arguments — tracked in [#601](https://github.com/goaop/framework/issues/601) + +Attribute arguments containing closures or first-class callable syntax (allowed since PHP 8.5) +are not yet correctly copied to generated proxies. + +### Promoted-property interception — tracked in [#599](https://github.com/goaop/framework/issues/599) + +Constructor-promoted properties (including `final` promoted properties, new in PHP 8.5) cannot be +intercepted via `access(...)` pointcuts. + +### Enum constant-expression case values — tracked in [#600](https://github.com/goaop/framework/issues/600) + +Backed enum cases declared with constant expressions (e.g. `case Negative = -1;`, +`case Shifted = 1 << 2;`, `case FromConst = self::SHIFT + 10;`) previously lost their values in +the generated proxy enum, producing a fatal error at load time. Fixed by re-emitting the original +case expression verbatim in the proxy enum. + +### `new` in initializers under `INTERCEPT_INITIALIZATIONS` — tracked in [#603](https://github.com/goaop/framework/issues/603) + +`new` expressions in property/parameter initializers do not work correctly when the +`INTERCEPT_INITIALIZATIONS` kernel feature is enabled. + +### Global constants in attribute arguments — tracked in [#602](https://github.com/goaop/framework/issues/602) + +Unqualified global constants used in attribute arguments may be mis-resolved when the attribute +is copied into the generated proxy file. + +### Class-level attributes on woven classes — tracked in [#598](https://github.com/goaop/framework/issues/598) + +Class-level attributes are not correctly carried over to woven classes in all cases. + +### Static properties are never interceptable + +Static properties — including PHP 8.5 `static` properties with asymmetric visibility — cannot be +intercepted via `access(...)` pointcuts. PHP property hooks do not exist for static properties, +so the framework has no interception mechanism for them; `AdviceMatcher` excludes static +properties from property join points. This is a PHP engine constraint, not a bug. + +## Summary Table + +| PHP 8.5 Feature | Interception / Weaving Support | Notes | +|---|:---:|---| +| Pipe operator `\|>` in method bodies | ✅ Works | Bodies are moved verbatim into the woven trait | +| `clone with` | ✅ Works | | +| `#[\NoDiscard]` | ✅ Propagated | Copied to proxy; join-point dispatch returns the value | +| Attributes on class constants (incl. `#[\Deprecated]`) | ✅ Preserved | Constants stay in the woven trait | +| Closures / FCC as parameter defaults | ✅ Works | | +| Final promoted properties (non-intercepted) | ✅ Works | Interception of promoted properties tracked in [#599](https://github.com/goaop/framework/issues/599) | +| Static asymmetric visibility (non-intercepted) | ✅ Preserved | Static properties are never interceptable via `access()` | +| `self`/`parent` reflection resolution | ✅ Handled | Raw AST type nodes used in proxy generation | +| Closures / FCC in attribute arguments | ❌ Limited | Tracked in [#601](https://github.com/goaop/framework/issues/601) | +| Promoted-property interception | ❌ Limited | Tracked in [#599](https://github.com/goaop/framework/issues/599) | +| Enum constant-expression case values | ❌→✅ Fixed | Tracked in [#600](https://github.com/goaop/framework/issues/600) | +| `new` in initializers + `INTERCEPT_INITIALIZATIONS` | ❌ Limited | Tracked in [#603](https://github.com/goaop/framework/issues/603) | +| Global constants in attribute arguments | ❌ Limited | Tracked in [#602](https://github.com/goaop/framework/issues/602) | +| Class-level attributes on woven classes | ❌ Limited | Tracked in [#598](https://github.com/goaop/framework/issues/598) | +| Static property interception via `access()` | ❌ Never | No property hooks for static properties (PHP engine constraint) | From baead8d50f6a17ecffcb55e3bfc955748e8ff9db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:34:16 +0000 Subject: [PATCH 4/5] Remove orphaned transformer fixtures, add PHP 8.5 PHPStan job, type glob() override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete 12 fixtures in tests/Instrument/Transformer/_files/ that no test or source file references (verified by grepping each basename across tests/ and src/): anonymous-class(-transformed), file-with-self(-transformed), file-with-self-no-namespace(-transformed), php80-file(-transformed), php81-file(-transformed), php82-file(-transformed). The yii_style.php / yii_style_output.php pair from the original list is NOT deleted — it is still used by FilterInjectorTransformerTest. - Run the PHPStan workflow on a PHP 8.4 + 8.5 matrix with per-version cache keys. - Add parameter and return types to the Symfony\Component\Finder glob() override in tests/functions.php; Finder calls it with a string pattern and an int flag bitmask, so the typed signature stays compatible. Partially addresses #610 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WFMYyvE4hYRUoMS8mtKrHP --- .github/workflows/phpstan.yml | 14 +- .../_files/anonymous-class-transformed.php | 27 ---- .../Transformer/_files/anonymous-class.php | 27 ---- ...ile-with-self-no-namespace-transformed.php | 61 -------- .../_files/file-with-self-no-namespace.php | 61 -------- .../_files/file-with-self-transformed.php | 63 --------- .../Transformer/_files/file-with-self.php | 63 --------- .../_files/php80-file-transformed.php | 131 ------------------ .../Transformer/_files/php80-file.php | 131 ------------------ .../_files/php81-file-transformed.php | 119 ---------------- .../Transformer/_files/php81-file.php | 119 ---------------- .../_files/php82-file-transformed.php | 94 ------------- .../Transformer/_files/php82-file.php | 94 ------------- tests/functions.php | 6 +- 14 files changed, 13 insertions(+), 997 deletions(-) delete mode 100644 tests/Instrument/Transformer/_files/anonymous-class-transformed.php delete mode 100644 tests/Instrument/Transformer/_files/anonymous-class.php delete mode 100644 tests/Instrument/Transformer/_files/file-with-self-no-namespace-transformed.php delete mode 100644 tests/Instrument/Transformer/_files/file-with-self-no-namespace.php delete mode 100644 tests/Instrument/Transformer/_files/file-with-self-transformed.php delete mode 100644 tests/Instrument/Transformer/_files/file-with-self.php delete mode 100644 tests/Instrument/Transformer/_files/php80-file-transformed.php delete mode 100644 tests/Instrument/Transformer/_files/php80-file.php delete mode 100644 tests/Instrument/Transformer/_files/php81-file-transformed.php delete mode 100644 tests/Instrument/Transformer/_files/php81-file.php delete mode 100644 tests/Instrument/Transformer/_files/php82-file-transformed.php delete mode 100644 tests/Instrument/Transformer/_files/php82-file.php diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 03fecfe3..0536e399 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -11,15 +11,21 @@ on: jobs: build: - name: "PHPStan analysis - PHP8.4" + name: "PHPStan analysis - PHP${{ matrix.php-version }}" runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: + - "8.4" + - "8.5" steps: - name: "Checkout" uses: actions/checkout@v7 - name: "Install PHP" uses: shivammathur/setup-php@v2 with: - php-version: "8.4" + php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 tools: composer:v2 - name: "Cache dependencies" @@ -28,8 +34,8 @@ jobs: path: | ~/.composer/cache vendor - key: "php-8.4" - restore-keys: "php-8.4" + key: "php-${{ matrix.php-version }}" + restore-keys: "php-${{ matrix.php-version }}" - name: "Install dependencies" run: "composer install --no-interaction --no-progress" - name: "Static analysis" diff --git a/tests/Instrument/Transformer/_files/anonymous-class-transformed.php b/tests/Instrument/Transformer/_files/anonymous-class-transformed.php deleted file mode 100644 index cc7e61ff..00000000 --- a/tests/Instrument/Transformer/_files/anonymous-class-transformed.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * This source file is subject to the license that is bundled - * with this source code in the file LICENSE. - */ -declare(strict_types=1); - -namespace Go\ParserReflection\Stub; - -class InAnonymousClass -{ - public function respond() - { - new class { - public const FOO = 'foo'; - - public function run() - { - return self::FOO; - } - }; - } -} diff --git a/tests/Instrument/Transformer/_files/anonymous-class.php b/tests/Instrument/Transformer/_files/anonymous-class.php deleted file mode 100644 index cc7e61ff..00000000 --- a/tests/Instrument/Transformer/_files/anonymous-class.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * This source file is subject to the license that is bundled - * with this source code in the file LICENSE. - */ -declare(strict_types=1); - -namespace Go\ParserReflection\Stub; - -class InAnonymousClass -{ - public function respond() - { - new class { - public const FOO = 'foo'; - - public function run() - { - return self::FOO; - } - }; - } -} diff --git a/tests/Instrument/Transformer/_files/file-with-self-no-namespace-transformed.php b/tests/Instrument/Transformer/_files/file-with-self-no-namespace-transformed.php deleted file mode 100644 index 25324abd..00000000 --- a/tests/Instrument/Transformer/_files/file-with-self-no-namespace-transformed.php +++ /dev/null @@ -1,61 +0,0 @@ - - * - * This source file is subject to the license that is bundled - * with this source code in the file LICENSE. - */ -declare(strict_types=1); - -namespace Go\ParserReflection\Stub; - -use Attribute; -use Go\ParserReflection\{ReflectionMethod, ReflectionProperty as P}; - -class ClassWithPhp80Features -{ - public function acceptsStringArrayDefaultToNull(array|string $iterable = null) : array {} -} - -/** - * @see https://php.watch/versions/8.0/named-parameters - */ -class ClassWithPHP80NamedCall -{ - public static function foo(string $key1 = '', string $key2 = ''): string - { - return $key1 . ':' . $key2; - } - - public static function namedCall(): array - { - return [ - 'key1' => \Go\ParserReflection\Stub\ClassWithPHP80NamedCall::foo(key1: 'bar'), - 'key2' => \Go\ParserReflection\Stub\ClassWithPHP80NamedCall::foo(key2: 'baz'), - 'keys' => \Go\ParserReflection\Stub\ClassWithPHP80NamedCall::foo(key1: 'A', key2: 'B'), - 'reverseKeys' => \Go\ParserReflection\Stub\ClassWithPHP80NamedCall::foo(key2: 'A', key1: 'B'), - 'unpack' => \Go\ParserReflection\Stub\ClassWithPHP80NamedCall::foo(...['key1' => 'C', 'key2' => 'D']), - ]; - } -} - -/** - * @see https://php.watch/versions/8.0/attributes - */ -#[Attribute(Attribute::TARGET_ALL | Attribute::IS_REPEATABLE)] -readonly class ClassPHP80Attribute -{ - private string $value; - - public function __construct(string $value) - { - $this->value = $value; - } - - public function getValue(): string - { - return $this->value; - } -} - -/** - * @see https://php.watch/versions/8.0/attributes - */ -#[ClassPHP80Attribute('class')] -class ClassPHP80WithAttribute -{ - #[ClassPHP80Attribute('first')] - #[ClassPHP80Attribute('second')] - public const PUBLIC_CONST = 1; - - #[ClassPHP80Attribute('property')] - private string $privateProperty = 'foo'; - - #[ClassPHP80Attribute('method')] - public function bar(#[ClassPHP80Attribute('parameter')] $parameter) - {} -} - -/** - * @see https://php.watch/versions/8.0/constructor-property-promotion - */ -class ClassPHP80WithPropertyPromotion -{ - public function __construct( - private string $privateStringValue, - private $privateNonTypedValue, - protected int $protectedIntValue = 42, - public array $publicArrayValue = [M_PI, M_E], - ) {} -} - -/** - * @see https://php.watch/versions/8.0/union-types - */ -class ClassWithPHP80UnionTypes -{ - public string|int|float|bool $scalarValue; - - public array|object|null $complexValueOrNull = null; - - /** - * Special case, internally iterable should be replaced with Traversable|array - */ - public iterable|object $iterableOrObject; - - public static function returnsUnionType(): object|array|null {} - - public static function acceptsUnionType(\stdClass|\Traversable|array $iterable): void {} -} - -/** - * @see https://php.watch/versions/8.0/mixed-type - */ -class ClassWithPHP80MixedType -{ - public mixed $someMixedPublicProperty; - - public static function returnsMixed(): mixed {} - - public static function acceptsMixed(mixed $value): void {} -} - -/** - * @see https://php.watch/versions/8.0/static-return-type - */ -class ClassWithPHP80StaticReturnType -{ - public static function create(): static {} -} \ No newline at end of file diff --git a/tests/Instrument/Transformer/_files/php80-file.php b/tests/Instrument/Transformer/_files/php80-file.php deleted file mode 100644 index 8b4ca732..00000000 --- a/tests/Instrument/Transformer/_files/php80-file.php +++ /dev/null @@ -1,131 +0,0 @@ - - * - * This source file is subject to the license that is bundled - * with this source code in the file LICENSE. - */ -declare(strict_types=1); - -namespace Go\ParserReflection\Stub; - -use Attribute; -use Go\ParserReflection\{ReflectionMethod, ReflectionProperty as P}; - -class ClassWithPhp80Features -{ - public function acceptsStringArrayDefaultToNull(array|string $iterable = null) : array {} -} - -/** - * @see https://php.watch/versions/8.0/named-parameters - */ -class ClassWithPHP80NamedCall -{ - public static function foo(string $key1 = '', string $key2 = ''): string - { - return $key1 . ':' . $key2; - } - - public static function namedCall(): array - { - return [ - 'key1' => self::foo(key1: 'bar'), - 'key2' => self::foo(key2: 'baz'), - 'keys' => self::foo(key1: 'A', key2: 'B'), - 'reverseKeys' => self::foo(key2: 'A', key1: 'B'), - 'unpack' => self::foo(...['key1' => 'C', 'key2' => 'D']), - ]; - } -} - -/** - * @see https://php.watch/versions/8.0/attributes - */ -#[Attribute(Attribute::TARGET_ALL | Attribute::IS_REPEATABLE)] -readonly class ClassPHP80Attribute -{ - private string $value; - - public function __construct(string $value) - { - $this->value = $value; - } - - public function getValue(): string - { - return $this->value; - } -} - -/** - * @see https://php.watch/versions/8.0/attributes - */ -#[ClassPHP80Attribute('class')] -class ClassPHP80WithAttribute -{ - #[ClassPHP80Attribute('first')] - #[ClassPHP80Attribute('second')] - public const PUBLIC_CONST = 1; - - #[ClassPHP80Attribute('property')] - private string $privateProperty = 'foo'; - - #[ClassPHP80Attribute('method')] - public function bar(#[ClassPHP80Attribute('parameter')] $parameter) - {} -} - -/** - * @see https://php.watch/versions/8.0/constructor-property-promotion - */ -class ClassPHP80WithPropertyPromotion -{ - public function __construct( - private string $privateStringValue, - private $privateNonTypedValue, - protected int $protectedIntValue = 42, - public array $publicArrayValue = [M_PI, M_E], - ) {} -} - -/** - * @see https://php.watch/versions/8.0/union-types - */ -class ClassWithPHP80UnionTypes -{ - public string|int|float|bool $scalarValue; - - public array|object|null $complexValueOrNull = null; - - /** - * Special case, internally iterable should be replaced with Traversable|array - */ - public iterable|object $iterableOrObject; - - public static function returnsUnionType(): object|array|null {} - - public static function acceptsUnionType(\stdClass|\Traversable|array $iterable): void {} -} - -/** - * @see https://php.watch/versions/8.0/mixed-type - */ -class ClassWithPHP80MixedType -{ - public mixed $someMixedPublicProperty; - - public static function returnsMixed(): mixed {} - - public static function acceptsMixed(mixed $value): void {} -} - -/** - * @see https://php.watch/versions/8.0/static-return-type - */ -class ClassWithPHP80StaticReturnType -{ - public static function create(): static {} -} \ No newline at end of file diff --git a/tests/Instrument/Transformer/_files/php81-file-transformed.php b/tests/Instrument/Transformer/_files/php81-file-transformed.php deleted file mode 100644 index 3d75adec..00000000 --- a/tests/Instrument/Transformer/_files/php81-file-transformed.php +++ /dev/null @@ -1,119 +0,0 @@ - - * - * This source file is subject to the license that is bundled - * with this source code in the file LICENSE. - */ -declare(strict_types=1); - -namespace Go\ParserReflection\Stub; - -/** - * @see https://php.watch/versions/8.1/readonly - */ -class ClassWithPhp81ReadOnlyProperties -{ - public readonly int $publicReadonlyInt; - - protected readonly array $protectedReadonlyArray; - - private readonly object $privateReadonlyObject; -} - -/** - * @see https://php.watch/versions/8.1/enums - */ -enum SimplePhp81EnumWithSuit { - case Clubs; - case Diamonds; - case Hearts; - case Spades; -} - -/** - * @see https://php.watch/versions/8.1/enums#enums-backed - */ -enum BackedPhp81EnumHTTPMethods: string -{ - case GET = 'get'; - case POST = 'post'; -} - -/** - * @see https://php.watch/versions/8.1/enums#enum-methods - */ -enum BackedPhp81EnumHTTPStatusWithMethod: int -{ - case OK = 200; - case ACCESS_DENIED = 403; - case NOT_FOUND = 404; - - public function label(): string { - return static::getLabel($this); - } - - public static function getLabel(\Go\ParserReflection\Stub\BackedPhp81EnumHTTPStatusWithMethod $value): string { - return match ($value) { - \Go\ParserReflection\Stub\BackedPhp81EnumHTTPStatusWithMethod::OK => 'OK', - \Go\ParserReflection\Stub\BackedPhp81EnumHTTPStatusWithMethod::ACCESS_DENIED => 'Access Denied', - \Go\ParserReflection\Stub\BackedPhp81EnumHTTPStatusWithMethod::NOT_FOUND => 'Page Not Found', - }; - } -} - -/** - * @see https://php.watch/versions/8.1/intersection-types - */ -class ClassWithPhp81IntersectionType implements \Countable -{ - private \Iterator&\Countable $countableIterator; - - public function __construct(\Iterator&\Countable $countableIterator) - { - $this->countableIterator = $countableIterator; - } - - public function count(): int - { - return count($this->countableIterator); - } -} - -/** - * @see https://php.watch/versions/8.1/intersection-types - */ -function functionWithPhp81IntersectionType(\Iterator&\Countable $value): \Iterator&\Countable { - foreach($value as $val) {} - count($value); - - return $value; -} - -/** - * @see https://php.watch/versions/8.1/never-return-type - */ -class ClassWithPhp81NeverReturnType -{ - public static function doThis(): never - { - throw new \RuntimeException('Not implemented'); - } -} - -/** - * @see https://php.watch/versions/8.1/never-return-type - */ -function functionWithPhp81NeverReturnType(): never -{ - throw new \RuntimeException('Not implemented'); -} - -/** - * @see https://php.watch/versions/8.1/final-class-const - */ -class ClassWithPhp81FinalClassConst { - final public const TEST = '1'; -} diff --git a/tests/Instrument/Transformer/_files/php81-file.php b/tests/Instrument/Transformer/_files/php81-file.php deleted file mode 100644 index b0809015..00000000 --- a/tests/Instrument/Transformer/_files/php81-file.php +++ /dev/null @@ -1,119 +0,0 @@ - - * - * This source file is subject to the license that is bundled - * with this source code in the file LICENSE. - */ -declare(strict_types=1); - -namespace Go\ParserReflection\Stub; - -/** - * @see https://php.watch/versions/8.1/readonly - */ -class ClassWithPhp81ReadOnlyProperties -{ - public readonly int $publicReadonlyInt; - - protected readonly array $protectedReadonlyArray; - - private readonly object $privateReadonlyObject; -} - -/** - * @see https://php.watch/versions/8.1/enums - */ -enum SimplePhp81EnumWithSuit { - case Clubs; - case Diamonds; - case Hearts; - case Spades; -} - -/** - * @see https://php.watch/versions/8.1/enums#enums-backed - */ -enum BackedPhp81EnumHTTPMethods: string -{ - case GET = 'get'; - case POST = 'post'; -} - -/** - * @see https://php.watch/versions/8.1/enums#enum-methods - */ -enum BackedPhp81EnumHTTPStatusWithMethod: int -{ - case OK = 200; - case ACCESS_DENIED = 403; - case NOT_FOUND = 404; - - public function label(): string { - return static::getLabel($this); - } - - public static function getLabel(self $value): string { - return match ($value) { - self::OK => 'OK', - self::ACCESS_DENIED => 'Access Denied', - self::NOT_FOUND => 'Page Not Found', - }; - } -} - -/** - * @see https://php.watch/versions/8.1/intersection-types - */ -class ClassWithPhp81IntersectionType implements \Countable -{ - private \Iterator&\Countable $countableIterator; - - public function __construct(\Iterator&\Countable $countableIterator) - { - $this->countableIterator = $countableIterator; - } - - public function count(): int - { - return count($this->countableIterator); - } -} - -/** - * @see https://php.watch/versions/8.1/intersection-types - */ -function functionWithPhp81IntersectionType(\Iterator&\Countable $value): \Iterator&\Countable { - foreach($value as $val) {} - count($value); - - return $value; -} - -/** - * @see https://php.watch/versions/8.1/never-return-type - */ -class ClassWithPhp81NeverReturnType -{ - public static function doThis(): never - { - throw new \RuntimeException('Not implemented'); - } -} - -/** - * @see https://php.watch/versions/8.1/never-return-type - */ -function functionWithPhp81NeverReturnType(): never -{ - throw new \RuntimeException('Not implemented'); -} - -/** - * @see https://php.watch/versions/8.1/final-class-const - */ -class ClassWithPhp81FinalClassConst { - final public const TEST = '1'; -} diff --git a/tests/Instrument/Transformer/_files/php82-file-transformed.php b/tests/Instrument/Transformer/_files/php82-file-transformed.php deleted file mode 100644 index 678ccc6f..00000000 --- a/tests/Instrument/Transformer/_files/php82-file-transformed.php +++ /dev/null @@ -1,94 +0,0 @@ - - * - * This source file is subject to the license that is bundled - * with this source code in the file LICENSE. - */ -declare(strict_types=1); - -namespace Go\ParserReflection\Stub; - -/** - * @see https://php.watch/versions/8.2/readonly-classes - */ -readonly class ClassWithPhp82ReadOnlyFlag -{ - public int $publicInt; -} - -/** - * @see https://php.watch/versions/8.2/dnf-types - */ -class ClassWithPhp82DNFType -{ - private (JSONResponse&SuccessResponse)|HTMLResponse|string $respond; - - public function __construct((JSONResponse&SuccessResponse)|HTMLResponse|string $respond) - { - $this->respond = $respond; - } - - public function respond(): (JSONResponse&SuccessResponse)|HTMLResponse|string - { - return $this->respond; - } -} - -/** - * @see https://php.watch/versions/8.2/null-false-types - * @see https://php.watch/versions/8.2/true-type - */ -class ClassWithPhp82NullFalseTypes -{ - private true $isTrue = true; - private false $isFalse = false; - private null $isNull = null; - - public function returnsFalse(): false - { - return false; - } - - public function returnsTrue(): true - { - return true; - } - - public function returnsNullExplicitly(): null - { - return null; - } - - public function acceptsTrue(true $acceptsTrue): void {} - public function acceptsFalse(false $acceptsFalse): void {} - public function acceptsNull(null $acceptsNull): void {} -} - -/** - * @see https://php.watch/versions/8.2/constants-in-traits - */ -trait TraitWithPhp82Constant -{ - protected const CURRENT_VERSION = '2.6'; - final protected const MIN_VERSION = '2.5'; - - protected function ensureVersion(): void - { - if (self::CURRENT_VERSION < self::MIN_VERSION) { - throw new \Exception('Current version is too old'); - } - } -} - -class ClassWithPhp82SensitiveAttribute -{ - private string $secret; - - public function __construct(#[\SensitiveParameter] string $secret = 'password') - { - $this->secret = $secret; - } -} \ No newline at end of file diff --git a/tests/Instrument/Transformer/_files/php82-file.php b/tests/Instrument/Transformer/_files/php82-file.php deleted file mode 100644 index 678ccc6f..00000000 --- a/tests/Instrument/Transformer/_files/php82-file.php +++ /dev/null @@ -1,94 +0,0 @@ - - * - * This source file is subject to the license that is bundled - * with this source code in the file LICENSE. - */ -declare(strict_types=1); - -namespace Go\ParserReflection\Stub; - -/** - * @see https://php.watch/versions/8.2/readonly-classes - */ -readonly class ClassWithPhp82ReadOnlyFlag -{ - public int $publicInt; -} - -/** - * @see https://php.watch/versions/8.2/dnf-types - */ -class ClassWithPhp82DNFType -{ - private (JSONResponse&SuccessResponse)|HTMLResponse|string $respond; - - public function __construct((JSONResponse&SuccessResponse)|HTMLResponse|string $respond) - { - $this->respond = $respond; - } - - public function respond(): (JSONResponse&SuccessResponse)|HTMLResponse|string - { - return $this->respond; - } -} - -/** - * @see https://php.watch/versions/8.2/null-false-types - * @see https://php.watch/versions/8.2/true-type - */ -class ClassWithPhp82NullFalseTypes -{ - private true $isTrue = true; - private false $isFalse = false; - private null $isNull = null; - - public function returnsFalse(): false - { - return false; - } - - public function returnsTrue(): true - { - return true; - } - - public function returnsNullExplicitly(): null - { - return null; - } - - public function acceptsTrue(true $acceptsTrue): void {} - public function acceptsFalse(false $acceptsFalse): void {} - public function acceptsNull(null $acceptsNull): void {} -} - -/** - * @see https://php.watch/versions/8.2/constants-in-traits - */ -trait TraitWithPhp82Constant -{ - protected const CURRENT_VERSION = '2.6'; - final protected const MIN_VERSION = '2.5'; - - protected function ensureVersion(): void - { - if (self::CURRENT_VERSION < self::MIN_VERSION) { - throw new \Exception('Current version is too old'); - } - } -} - -class ClassWithPhp82SensitiveAttribute -{ - private string $secret; - - public function __construct(#[\SensitiveParameter] string $secret = 'password') - { - $this->secret = $secret; - } -} \ No newline at end of file diff --git a/tests/functions.php b/tests/functions.php index c942c212..b0e0e7a7 100644 --- a/tests/functions.php +++ b/tests/functions.php @@ -19,11 +19,11 @@ * This helper function overrides the PHP glob() function so it is able to be run with virtual file system, * which is supported by Webmozart\Glob\Glob * - * @param $pattern - * @param null $flags + * The signature stays compatible with how Symfony Finder invokes glob(): + * a string pattern plus an int bitmask of GLOB_* flags. * * @return string[] */ -function glob($pattern, $flags = null) { +function glob(string $pattern, int $flags = 0): array { return \Webmozart\Glob\Glob::glob($pattern, $flags); } From d240b58ce78117400025397166ffdeeb2df720ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:55:20 +0000 Subject: [PATCH 5/5] Drop '?' single-character wildcard from pointcut name/type patterns The '?' wildcard collides with PHP's nullable-type syntax. NamePointcut and ReturnTypePointcut no longer treat '?' as a one-character wildcard; in return-type patterns a leading '?' is now a real nullable marker, '?Foo' being equivalent to 'Foo|null' on both the pattern and the actual type. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WFMYyvE4hYRUoMS8mtKrHP --- src/Aop/Pointcut/NamePointcut.php | 3 +-- src/Aop/Pointcut/ReturnTypePointcut.php | 23 ++++++++----------- tests/Aop/Pointcut/ReturnTypePointcutTest.php | 4 +++- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/Aop/Pointcut/NamePointcut.php b/src/Aop/Pointcut/NamePointcut.php index b54bf735..f43c0947 100644 --- a/src/Aop/Pointcut/NamePointcut.php +++ b/src/Aop/Pointcut/NamePointcut.php @@ -32,7 +32,7 @@ /** * Name matcher constructor * - * @param string $name Element name to match, can contain wildcards **,*,?,| + * @param string $name Element name to match, can contain wildcards **,*,| * @param bool $useContextForMatching Switch to matching context name instead of reflector */ public function __construct( @@ -46,7 +46,6 @@ public function __construct( [ '\\*' => '[^\\\\]+?', '\\*\\*' => '.+?', - '\\?' => '.', '\\|' => '|' ] ) . ')$/'; diff --git a/src/Aop/Pointcut/ReturnTypePointcut.php b/src/Aop/Pointcut/ReturnTypePointcut.php index 61aafb4f..99f4b1d8 100644 --- a/src/Aop/Pointcut/ReturnTypePointcut.php +++ b/src/Aop/Pointcut/ReturnTypePointcut.php @@ -23,15 +23,13 @@ /** * Return type filter that matches methods and functions with a specific return type. * - * Type name can contain wildcards '*', '**' and '?' (each applied per type member). + * Type name can contain wildcards '*' and '**' (each applied per type member). * * Union, intersection and DNF types are supported with the following semantics — both the * pattern and the actual return type are normalized into sets of intersection groups (the * declaration is split on '|' at parenthesis depth zero, each resulting member on '&'; - * parentheses and leading backslashes are normalized away; a leading '?' nullable marker on - * the ACTUAL type is expanded, '?Foo' being equivalent to 'Foo|null' — in the PATTERN, however, - * '?' keeps its historical single-character-wildcard meaning, so nullable patterns should be - * written as 'Foo|null'): + * parentheses and leading backslashes are normalized away; a leading '?' nullable marker is + * expanded on both sides, '?Foo' being equivalent to 'Foo|null'): * * - A single-type pattern (no '|' and no '&', e.g. 'string' or 'Some*Interface') matches if * ANY member of the actual type matches it. For example, the pattern 'string' matches @@ -68,9 +66,7 @@ public function __construct(string $returnTypeName) if (strlen($returnTypeName) === 0) { throw new \InvalidArgumentException("Return type name must not be empty"); } - // Note: '?' at the start of the pattern is a single-character wildcard, not a nullable - // marker (BC with historical behavior) — nullable patterns are written as 'Foo|null'. - $this->patternGroups = self::normalizeTypeExpression($returnTypeName, false); + $this->patternGroups = self::normalizeTypeExpression($returnTypeName); $this->isSingleAtomicPattern = count($this->patternGroups) === 1 && count($this->patternGroups[0]) === 1; } @@ -129,16 +125,16 @@ public function getKind(): int /** * Normalizes a type declaration into a sorted set of intersection groups. * - * For actual reflection types ($expandNullableMarker = true), '?Foo' is normalized into - * 'Foo|null'. Parentheses around DNF groups are removed, and each atomic type is trimmed + * A leading '?' nullable marker is expanded, '?Foo' being normalized into 'Foo|null'. + * Parentheses around DNF groups are removed, and each atomic type is trimmed * from whitespace and leading backslashes. * * @return list> List of intersection groups, each a sorted list of atomic types */ - private static function normalizeTypeExpression(string $type, bool $expandNullableMarker = true): array + private static function normalizeTypeExpression(string $type): array { $type = trim($type); - if ($expandNullableMarker && str_starts_with($type, '?')) { + if (str_starts_with($type, '?')) { $type = substr($type, 1) . '|null'; } @@ -193,7 +189,7 @@ private static function splitAtDepthZero(string $type): array } /** - * Checks whether one atomic type pattern (with possible '*', '?' wildcards) matches an atomic type. + * Checks whether one atomic type pattern (with a possible '*' wildcard) matches an atomic type. */ private static function atomMatches(string $pattern, string $actual): bool { @@ -202,7 +198,6 @@ private static function atomMatches(string $pattern, string $actual): bool } $regexp = '/^(' . strtr(preg_quote($pattern, '/'), [ '\\*' => '[^\\\\]+', - '\\?' => '.', ]) . ')$/'; return (bool) preg_match($regexp, $actual); diff --git a/tests/Aop/Pointcut/ReturnTypePointcutTest.php b/tests/Aop/Pointcut/ReturnTypePointcutTest.php index c661a2fc..aa70bd9c 100644 --- a/tests/Aop/Pointcut/ReturnTypePointcutTest.php +++ b/tests/Aop/Pointcut/ReturnTypePointcutTest.php @@ -38,7 +38,7 @@ public static function returnTypeMatchesDataProvider(): array return [ 'Exact match (int)' => ['int', new ReflectionFunction('strlen'), true], 'Star match (bool)' => ['b*l', new ReflectionMethod(ReturnTypePointcut::class, 'matches'), true], - 'Question match (int)' => ['?nt', new ReflectionMethod(ReturnTypePointcut::class, 'getKind'), true], + 'Question mark is not a wildcard' => ['?nt', new ReflectionFunction('strlen'), false], 'No match (int)' => ['array', new ReflectionFunction('strlen'), false], // Union return types (Exception|Closure) @@ -68,6 +68,8 @@ public static function returnTypeMatchesDataProvider(): array 'Nullable actual matched by union with null' => ['array|null', $nullableMethod, true], 'Nullable actual matched by null pattern' => ['null', $nullableMethod, true], 'Nullable actual not matched by other union' => ['array|false', $nullableMethod, false], + 'Nullable pattern matches nullable actual' => ['?array', $nullableMethod, true], + 'Nullable pattern does not match plain actual' => ['?int', new ReflectionFunction('strlen'), false], ]; }