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/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) | 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/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/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..99f4b1d8 100644 --- a/src/Aop/Pointcut/ReturnTypePointcut.php +++ b/src/Aop/Pointcut/ReturnTypePointcut.php @@ -21,41 +21,53 @@ 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 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 + * 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, '/'), [ - '\\*' => '[^\\\\]+', - '\\?' => '.', - ]) . ')$/'; + $this->patternGroups = self::normalizeTypeExpression($returnTypeName); + $this->isSingleAtomicPattern = count($this->patternGroups) === 1 && count($this->patternGroups[0]) === 1; } public function matches( @@ -77,14 +89,150 @@ 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. + * + * 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): array + { + $type = trim($type); + if (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 a possible '*' wildcard) 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/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/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..aa70bd9c 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,46 @@ 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], + 'Question mark is not a wildcard' => ['?nt', new ReflectionFunction('strlen'), false], '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], + 'Nullable pattern matches nullable actual' => ['?array', $nullableMethod, true], + 'Nullable pattern does not match plain actual' => ['?int', new ReflectionFunction('strlen'), false], ]; } 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/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-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/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/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; + } +} 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; + } +} 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); }