From 252168a1c52315548a2fe83b2fe34ab824d80977 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 13:18:00 +0200 Subject: [PATCH 1/6] fix(preprocessor): enforce Zend enum declaration rules Zend rejects at compile time, and TypePHP previously accepted silently: - properties in enums (instance, static, hooked): enum class entries have no property table ("Enum E cannot include properties") - magic methods other than __call/__callStatic/__invoke ("Enum E cannot include magic method __x"); the banned set was probed one by one against Zend 8.4.13 - a case value on a non-backed enum and a missing value on a backed enum ("Case A of ... enum E must (not) have a value") - duplicate case names and case/const name collisions: enum cases are class constants ("Cannot redefine class constant E::A") - a backing type other than int|string - explicitly implementing UnitEnum/BackedEnum, which Zend adds itself ("cannot implement previously implemented interface"), including the non-backed-enum BackedEnum variant - abstract methods in enum bodies: an enum can never be abstract Enum ClassDef flags now carry Modifiers::FINAL, mirroring ZEND_ACC_FINAL on enum class entries, so `class B extends E` is rejected by the existing final-class inheritance check without touching the Translator. --- src/Preprocessor.php | 89 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 389f5107..46edeafa 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -46,6 +46,29 @@ class Preprocessor extends CompilerBase { + /** + * Magic methods Zend rejects inside enum declarations. Enum cases are + * stateless singletons, so construction, destruction, cloning, (de)ser- + * ialization, string casting, and property magic are all forbidden; + * only __call, __callStatic, and __invoke remain legal. + */ + private const array ENUM_FORBIDDEN_MAGIC_METHODS = [ + '__construct' => true, + '__destruct' => true, + '__clone' => true, + '__get' => true, + '__set' => true, + '__unset' => true, + '__isset' => true, + '__sleep' => true, + '__wakeup' => true, + '__set_state' => true, + '__serialize' => true, + '__unserialize' => true, + '__tostring' => true, + '__debuginfo' => true, + ]; + protected string $targetName = 'app'; /** @@ -1208,6 +1231,11 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum if ($class instanceof Node\Stmt\Class_) { $flags = $class->flags; + } elseif ($class instanceof Node\Stmt\Enum_) { + // Zend marks every enum class entry ZEND_ACC_FINAL, which is what + // rejects `class B extends E`. Carrying the flag here lets the + // regular final-class inheritance check cover enums as well. + $flags = Modifiers::PUBLIC | Modifiers::FINAL; } else { $flags = Modifiers::PUBLIC; } @@ -1273,11 +1301,40 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum if ($class instanceof Node\Stmt\Enum_) { $this->classDef->enum = true; if ($class->scalarType !== null) { + $backingType = strtolower($class->scalarType->name); + if ($backingType !== 'int' && $backingType !== 'string') { + $this->fatalError( + $class->scalarType, + "Enum backing type must be `int` or `string`, `{$class->scalarType->name}` given", + ); + } $this->classDef->enumBackingType = $class->scalarType->name; } } if (!$class instanceof Node\Stmt\Trait_) { $this->classDef->implements = $this->parseImplements($class->implements); + if ($class instanceof Node\Stmt\Enum_) { + // Zend adds UnitEnum (and BackedEnum for backed enums) itself; + // naming either explicitly is a compile-time error. + foreach ($this->classDef->implements as $i => $interfaceName) { + $interfaceLower = strtolower($interfaceName); + if ($interfaceLower !== 'unitenum' && $interfaceLower !== 'backedenum') { + continue; + } + $errorNode = $class->implements[$i] ?? $class; + if ($interfaceLower === 'backedenum' && $this->classDef->enumBackingType === null) { + $this->fatalError( + $errorNode, + "Non-backed enum `{$fullClassName}` cannot implement interface `BackedEnum`", + ); + } + $interfaceDisplay = $interfaceLower === 'unitenum' ? 'UnitEnum' : 'BackedEnum'; + $this->fatalError( + $errorNode, + "Enum `{$fullClassName}` cannot implement previously implemented interface `{$interfaceDisplay}`", + ); + } + } } else { $this->classDef->trait = $class; // Trait members are compiled later in the consuming class, but @@ -1351,6 +1408,19 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum break; case 'Stmt_EnumCase': $caseName = $this->parseIdentifier($v->name); + // Enum cases are class constants in Zend: a case may collide + // with another case or with a `const` of the same name. + if (array_key_exists($caseName, $this->classDef->enumCases) + || $this->classDef->hasConstant($caseName) + ) { + $this->fatalError($v, "Cannot redefine class constant `{$fullClassName}::{$caseName}`"); + } + if ($v->expr !== null && $this->classDef->enumBackingType === null) { + $this->fatalError($v, "Case `{$caseName}` of non-backed enum `{$fullClassName}` must not have a value"); + } + if ($v->expr === null && $this->classDef->enumBackingType !== null) { + $this->fatalError($v, "Case `{$caseName}` of backed enum `{$fullClassName}` must have a value"); + } // Only literal backing values are recorded here; an // expression-valued case (`case A = 1 + 1;`) cannot be // evaluated while declarations are still being collected, @@ -2089,6 +2159,11 @@ protected function propertyTypeDeclToString(NodeAbstract $typeNode): string protected function parseClassPropertyDef(Node\Stmt\Property $v): void { + // Zend enum class entries have no property table at all: instance, + // static, and hooked properties are all rejected at compile time. + if ($this->classDef->enum) { + $this->fatalError($v, "Enum `{$this->classDef->getNamespacedName(false)}` cannot include properties"); + } $this->validateClassPropertyHookPlacement($v); $arrayDef = $this->parseArrayDefinition($v); if ($this->classDef->nativeObject) { @@ -2244,6 +2319,15 @@ protected function prepareClassMethod(Node\Stmt\ClassMethod $v, Node\Stmt\Class_ $this->method = $name; $this->assertKeywordMethodMayBeDeclared($v, $name, $this->classDef->nativeObject); $this->assertNativeMagicMethodSupported($v, $name); + // Zend forbids every magic method in enums except __call, __callStatic, + // and __invoke: enum cases are singletons without state, construction, + // cloning, serialization, or property access. + if ($this->classDef->enum && isset(self::ENUM_FORBIDDEN_MAGIC_METHODS[strtolower($name)])) { + $this->fatalError( + $v, + "Enum `{$this->classDef->getNamespacedName(false)}` cannot include magic method `{$name}`", + ); + } $flags = $this->parseModifiers($v->flags); $abstract = $flags & Modifiers::ABSTRACT; if ($this->classDef->nativeObject && ($flags & Modifiers::STATIC)) { @@ -2296,6 +2380,11 @@ protected function prepareClassMethod(Node\Stmt\ClassMethod $v, Node\Stmt\Class_ if ($v->stmts !== null) { $this->fatalError($v, "Abstract function `{$this->class}::{$name}()` cannot contain body"); } + // Enums can never be declared abstract, so an abstract method in an + // enum body can never be implemented (Zend rejects it at link time). + if ($class instanceof Node\Stmt\Enum_) { + $this->fatalError($v, "Enum `{$this->class}` cannot include abstract method `{$v->name}()`"); + } if (!$class instanceof Node\Stmt\Trait_ && isset($class->flags) && !($class->flags & Modifiers::ABSTRACT)) { $this->fatalError($v, "Non-abstract class {$this->class} contains abstract method {$v->name}"); } From 89e7061ed34251cbb99e15d80b31db88e9cfc3d9 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 12:11:19 +0200 Subject: [PATCH 2/6] test(preprocessor): cover enum declaration rules --- phpunit/code/enum_rule_abstract_method.php | 4 + phpunit/code/enum_rule_backing_type.php | 4 + phpunit/code/enum_rule_case_const_clash.php | 4 + phpunit/code/enum_rule_case_missing_value.php | 4 + .../code/enum_rule_case_value_nonbacked.php | 4 + phpunit/code/enum_rule_duplicate_case.php | 4 + phpunit/code/enum_rule_extends_enum.php | 5 ++ ...m_rule_implements_backedenum_nonbacked.php | 4 + .../code/enum_rule_implements_unitenum.php | 4 + phpunit/code/enum_rule_magic_construct.php | 4 + phpunit/code/enum_rule_magic_tostring.php | 4 + phpunit/code/enum_rule_property.php | 4 + phpunit/code/enum_rule_static_property.php | 4 + phpunit/code/enum_rule_valid.php | 4 + phpunit/src/EnumDeclarationRulesTest.php | 82 +++++++++++++++++++ 15 files changed, 139 insertions(+) create mode 100644 phpunit/code/enum_rule_abstract_method.php create mode 100644 phpunit/code/enum_rule_backing_type.php create mode 100644 phpunit/code/enum_rule_case_const_clash.php create mode 100644 phpunit/code/enum_rule_case_missing_value.php create mode 100644 phpunit/code/enum_rule_case_value_nonbacked.php create mode 100644 phpunit/code/enum_rule_duplicate_case.php create mode 100644 phpunit/code/enum_rule_extends_enum.php create mode 100644 phpunit/code/enum_rule_implements_backedenum_nonbacked.php create mode 100644 phpunit/code/enum_rule_implements_unitenum.php create mode 100644 phpunit/code/enum_rule_magic_construct.php create mode 100644 phpunit/code/enum_rule_magic_tostring.php create mode 100644 phpunit/code/enum_rule_property.php create mode 100644 phpunit/code/enum_rule_static_property.php create mode 100644 phpunit/code/enum_rule_valid.php create mode 100644 phpunit/src/EnumDeclarationRulesTest.php diff --git a/phpunit/code/enum_rule_abstract_method.php b/phpunit/code/enum_rule_abstract_method.php new file mode 100644 index 00000000..aa6d1465 --- /dev/null +++ b/phpunit/code/enum_rule_abstract_method.php @@ -0,0 +1,4 @@ +value; } public function __invoke(): string { return $this->label(); } } + +function main() {} diff --git a/phpunit/src/EnumDeclarationRulesTest.php b/phpunit/src/EnumDeclarationRulesTest.php new file mode 100644 index 00000000..78e8e8f8 --- /dev/null +++ b/phpunit/src/EnumDeclarationRulesTest.php @@ -0,0 +1,82 @@ +exec('Enum `Suit` cannot include properties', 'enum_rule_property.php'); + } + + public function testEnumCannotIncludeStaticProperties(): void + { + $this->exec('Enum `Suit` cannot include properties', 'enum_rule_static_property.php'); + } + + public function testEnumCannotIncludeConstructor(): void + { + $this->exec('Enum `Suit` cannot include magic method `__construct`', 'enum_rule_magic_construct.php'); + } + + public function testEnumCannotIncludeToString(): void + { + $this->exec('Enum `Suit` cannot include magic method `__toString`', 'enum_rule_magic_tostring.php'); + } + + public function testNonBackedCaseMustNotHaveValue(): void + { + $this->exec('Case `Hearts` of non-backed enum `Suit` must not have a value', 'enum_rule_case_value_nonbacked.php'); + } + + public function testBackedCaseMustHaveValue(): void + { + $this->exec('Case `Hearts` of backed enum `Suit` must have a value', 'enum_rule_case_missing_value.php'); + } + + public function testDuplicateCaseIsRejected(): void + { + $this->exec('Cannot redefine class constant `Suit::Hearts`', 'enum_rule_duplicate_case.php'); + } + + public function testCaseClashingWithConstantIsRejected(): void + { + $this->exec('Cannot redefine class constant `Suit::Hearts`', 'enum_rule_case_const_clash.php'); + } + + public function testBackingTypeMustBeIntOrString(): void + { + $this->exec('Enum backing type must be `int` or `string`, `float` given', 'enum_rule_backing_type.php'); + } + + public function testExplicitUnitEnumImplementsIsRejected(): void + { + $this->exec('Enum `Suit` cannot implement previously implemented interface `UnitEnum`', 'enum_rule_implements_unitenum.php'); + } + + public function testNonBackedEnumCannotImplementBackedEnum(): void + { + $this->exec('Non-backed enum `Suit` cannot implement interface `BackedEnum`', 'enum_rule_implements_backedenum_nonbacked.php'); + } + + public function testEnumCannotIncludeAbstractMethod(): void + { + $this->exec('Enum `Suit` cannot include abstract method `f()`', 'enum_rule_abstract_method.php'); + } + + public function testClassCannotExtendEnum(): void + { + // Enum ClassDef flags carry Modifiers::FINAL, so the regular + // final-class inheritance check rejects the extension. + $this->exec('Class `Deck` cannot extend final class `Suit`', 'enum_rule_extends_enum.php'); + } + + public function testWellFormedEnumStillCompiles(): void + { + $this->compile('enum_rule_valid.php'); + } +} From 47c8254381e2b0b12d95628f68cc140ed0a76eef Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 10:01:19 +0200 Subject: [PATCH 3/6] fix(enum): ban composed magic methods and evaluate backed case expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps against Zend 8.4 in the enum declaration rules: - The forbidden-magic-method check only ran in prepareClassMethod(), so a magic method arriving through a trait — either declared there or created by an alias adaptation that renames an ordinary method to a magic name — was silently accepted. Zend applies the ban to every method installed in the enum ("Enum Suit cannot include magic method __construct", probed on 8.4.13 for both the composed and the aliased form). The check is now centralized in assertEnumMayIncludeMethod() and also invoked from installComposedTraitMethod(), which sees post-adaptation names. - A backed case value beyond a scalar literal (case Two = 1 + 1, or a constant reference) read the nonexistent ->value off the expression node, warning "Undefined property" and recording null, which later made the constant folder resolve Number::Two to the case-name string. Zend accepts any constant expression here. The preprocessor now keeps the expression AST (the symbol environment is incomplete during prepare) in a ClassDef case-name => Expr map, and the convert phase evaluates it lazily with the existing constant-expression machinery in ClassConstantValueTrait, memoizing the result on first access. Plain constant fetches now also resolve program constants recorded by parseConstDef(), so `const TWO = 2; ... case Two = TWO;` folds to 2. --- phpunit/code/enum_rule_trait_alias_magic.php | 5 ++ .../code/enum_rule_trait_magic_construct.php | 5 ++ phpunit/src/EnumDeclarationRulesTest.php | 14 +++++ src/Entity/ClassDef.php | 9 +++ src/Preprocessor.php | 52 ++++++++++------ src/Resolver/ClassConstantValueTrait.php | 61 ++++++++++++++++++- src/Translator.php | 4 ++ .../backed-enum-case-value-expressions.phpt | 33 ++++++++++ 8 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 phpunit/code/enum_rule_trait_alias_magic.php create mode 100644 phpunit/code/enum_rule_trait_magic_construct.php create mode 100644 tests/compiler/enum/backed-enum-case-value-expressions.phpt diff --git a/phpunit/code/enum_rule_trait_alias_magic.php b/phpunit/code/enum_rule_trait_alias_magic.php new file mode 100644 index 00000000..da9e1c31 --- /dev/null +++ b/phpunit/code/enum_rule_trait_alias_magic.php @@ -0,0 +1,5 @@ +compile('enum_rule_valid.php'); } + + public function testTraitInjectedMagicMethodIsRejected(): void + { + // The forbidden-magic-method check must also cover methods composed + // into the enum from a trait, not only ones declared in its body. + $this->exec('Enum `Suit` cannot include magic method `__construct`', 'enum_rule_trait_magic_construct.php'); + } + + public function testTraitAliasToMagicNameIsRejected(): void + { + // A trait alias that renames an ordinary method to a forbidden magic + // name installs that magic method into the enum; Zend rejects it. + $this->exec('Enum `Suit` cannot include magic method `__destruct`', 'enum_rule_trait_alias_magic.php'); + } } diff --git a/src/Entity/ClassDef.php b/src/Entity/ClassDef.php index f62a4dde..c35bc6f1 100644 --- a/src/Entity/ClassDef.php +++ b/src/Entity/ClassDef.php @@ -56,6 +56,15 @@ class ClassDef extends ClassLikeDef * @var array */ public array $enumCases = []; + + /** + * Backed case values that are not scalar literals, keyed by case name. + * The expression AST is captured during prepare (the symbol environment + * is incomplete there) and evaluated+memoized into $enumCases on first + * convert-phase access. + * @var array + */ + public array $enumCaseExprs = []; /** * Abstract method name (lowercase) => flags * @var array diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 46edeafa..9136f480 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -71,6 +71,24 @@ class Preprocessor extends CompilerBase protected string $targetName = 'app'; + /** + * Zend forbids every magic method in enums except __call, __callStatic, + * and __invoke: enum cases are singletons without state, construction, + * cloning, serialization, or property access. The ban applies to every + * method that ends up in the enum: declared in the enum body, composed + * from a trait, or created by a trait alias that renames a method to a + * magic name — so trait composition must run this check as well. + */ + protected function assertEnumMayIncludeMethod(Node $node, string $name): void + { + if ($this->classDef->enum && isset(self::ENUM_FORBIDDEN_MAGIC_METHODS[strtolower($name)])) { + $this->fatalError( + $node, + "Enum `{$this->classDef->getNamespacedName(false)}` cannot include magic method `{$name}`", + ); + } + } + /** * Discover Native class names before parsing any signatures or fields. * @@ -1421,16 +1439,20 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum if ($v->expr === null && $this->classDef->enumBackingType !== null) { $this->fatalError($v, "Case `{$caseName}` of backed enum `{$fullClassName}` must have a value"); } - // Only literal backing values are recorded here; an - // expression-valued case (`case A = 1 + 1;`) cannot be - // evaluated while declarations are still being collected, - // and no compile-time consumer needs the scalar: case - // identity flows as EnumCaseRef and gen_stub evaluates - // the registration value from the AST itself. - $this->classDef->enumCases[$caseName] = - $v->expr instanceof Node\Scalar\Int_ || $v->expr instanceof Node\Scalar\String_ - ? $v->expr->value - : null; + if ($v->expr instanceof Node\Scalar\Int_ || $v->expr instanceof Node\Scalar\String_) { + $this->classDef->enumCases[$caseName] = $v->expr->value; + } else { + // A backed case value may be any constant expression + // (arithmetic, constant references, ...). The symbol + // environment is incomplete during prepare, so keep the + // expression AST and evaluate it lazily in the convert + // phase, where the constant-expression machinery has + // the full symbol table. + $this->classDef->enumCases[$caseName] = null; + if ($v->expr !== null) { + $this->classDef->enumCaseExprs[$caseName] = $v->expr; + } + } break; case 'Stmt_ClassMethod': $this->prepareClassMethod($v, $class); @@ -2319,15 +2341,7 @@ protected function prepareClassMethod(Node\Stmt\ClassMethod $v, Node\Stmt\Class_ $this->method = $name; $this->assertKeywordMethodMayBeDeclared($v, $name, $this->classDef->nativeObject); $this->assertNativeMagicMethodSupported($v, $name); - // Zend forbids every magic method in enums except __call, __callStatic, - // and __invoke: enum cases are singletons without state, construction, - // cloning, serialization, or property access. - if ($this->classDef->enum && isset(self::ENUM_FORBIDDEN_MAGIC_METHODS[strtolower($name)])) { - $this->fatalError( - $v, - "Enum `{$this->classDef->getNamespacedName(false)}` cannot include magic method `{$name}`", - ); - } + $this->assertEnumMayIncludeMethod($v, $name); $flags = $this->parseModifiers($v->flags); $abstract = $flags & Modifiers::ABSTRACT; if ($this->classDef->nativeObject && ($flags & Modifiers::STATIC)) { diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 649f7a5b..432e0694 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -64,6 +64,17 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n if ($this->hasClass($class)) { $classDef = $this->getClass($class); if ($classDef->enum && array_key_exists($name, $classDef->enumCases)) { + if (isset($classDef->enumCaseExprs[$name])) { + // A backed case value beyond a scalar literal was kept as + // an expression AST during prepare; the full symbol table + // exists now, so evaluate once and memoize the result. + $classDef->enumCases[$name] = $this->evaluateConstantExpression( + $expr, + $classDef->enumCaseExprs[$name], + $class, + ); + unset($classDef->enumCaseExprs[$name]); + } // The case IDENTITY is the constant's value; folding to the // backing scalar (or the case name) would make // `K::CONST === E::Case` false through every dynamic path. @@ -117,6 +128,15 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c $this->fatalError($origin, "Class constant `{$class}::{$name}` has no constant expression"); } + return $this->evaluateConstantExpression($origin, $valueExpr, $class); + } + + /** + * Evaluate a constant expression AST (class constant initializer, backed + * enum case value) with the complete symbol table of the convert phase. + */ + protected function evaluateConstantExpression(?NodeAbstract $origin, Node\Expr $valueExpr, string $class): mixed + { $evaluator = new ConstExprEvaluator(function (Node\Expr $expr) use ($origin, $class) { if ($expr instanceof Node\Expr\ConstFetch) { $constName = $expr->name->toString(); @@ -124,9 +144,7 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c 'true' => true, 'false' => false, 'null' => null, - default => defined($constName) - ? constant($constName) - : throw new \RuntimeException("Constant `{$constName}` not found"), + default => $this->resolveConstFetchConstantValue($origin, $expr, $class), }; } if ($expr instanceof Node\Expr\ClassConstFetch && $expr->class instanceof Node\Name) { @@ -179,6 +197,43 @@ public function enumCaseLegacyValue(\TypePhp\Entity\EnumCaseRef $ref): mixed return $ref->caseName; } + /** + * Resolve a plain constant fetch inside a constant expression: program + * constants declared with `const`/`define()` in the compiled sources win + * (their initializer ASTs are recorded by parseConstDef()); anything else + * falls back to constants defined in the compiler's own runtime + * (PHP_INT_MAX, M_PI, ...), mirroring the previous behavior. + */ + private function resolveConstFetchConstantValue(?NodeAbstract $origin, Node\Expr\ConstFetch $expr, string $class): mixed + { + $constName = $expr->name->toString(); + $candidates = []; + $resolved = $expr->name->getAttribute('resolvedName'); + if ($resolved instanceof Node\Name) { + $candidates[] = $resolved->toString(); + } + // Unqualified names in a namespace fall back to the global constant; + // the NameResolver records the namespaced candidate to try first. + $namespaced = $expr->name->getAttribute('namespacedName'); + if ($namespaced instanceof Node\Name) { + $candidates[] = $namespaced->toString(); + } + $candidates[] = ltrim($constName, '\\'); + foreach ($candidates as $candidate) { + if (!$this->hasConstant($candidate)) { + continue; + } + $constInfo = $this->constants[$this->escapeConstVar($candidate)]; + if ($constInfo->valueExpr instanceof Node\Expr) { + return $this->evaluateConstantExpression($origin, $constInfo->valueExpr, $class); + } + } + if (defined($constName)) { + return constant($constName); + } + throw new \RuntimeException("Constant `{$constName}` not found"); + } + public function getConstValue(string $name): mixed { if ($this->isInternalConstant($name)) { diff --git a/src/Translator.php b/src/Translator.php index 5cc28030..62a4b7df 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -6472,6 +6472,10 @@ private function installComposedTraitMethod(Node\Stmt\ClassMethod $methodStmt): { $name = $methodStmt->name->toString(); $this->assertNativeMagicMethodSupported($methodStmt, $name); + // Composed trait methods land in the consuming class's method table, + // so an enum picks up Zend's magic-method ban here too — including a + // trait alias that renames an ordinary method to a forbidden name. + $this->assertEnumMayIncludeMethod($methodStmt, $name); if ($this->classDef->hasMethod($name)) { return; } diff --git a/tests/compiler/enum/backed-enum-case-value-expressions.phpt b/tests/compiler/enum/backed-enum-case-value-expressions.phpt new file mode 100644 index 00000000..df42618e --- /dev/null +++ b/tests/compiler/enum/backed-enum-case-value-expressions.phpt @@ -0,0 +1,33 @@ +--TEST-- +Backed enum case values from constant expressions (arithmetic and constant references) +--FILE-- +value); + var_dump(Number::Three->value); + var_dump(Number::Four->value); + var_dump(Number::Two->name); + var_dump(Prefix::Greeting->value); + var_dump(Number::from(4) === Number::Four); +} +?> +--EXPECT-- +int(2) +int(3) +int(4) +string(3) "Two" +string(5) "hello" +bool(true) From 5a6fbd759c1b7a5ce76977553962ba3943e9f61d Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 14:45:09 +0200 Subject: [PATCH 4/6] fix(enum): guard lazy case-expression evaluation against cycles and foreign contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two constant-expression paths in the lazy backed-case evaluation were still incorrect: - No in-progress guard: a self-referencing case such as `enum E: int { case A = E::A; }` re-entered getClassConstValue() through the stored expression AST until the stack was exhausted, and mutual cycles (`case A = E::B; case B = E::A;`) did the same. The evaluation now marks the case in progress for its duration (mirroring CONST_RECURSIVE on a Zend class-constant fetch, with gen_stub's case-table evaluation playing Zend's unmarked outer access) and fails when a marked case is fetched again, reporting the same constant Zend names on 8.4.13: `E::A` for the self-cycle and `E::B` for the mutual cycle. The AST entry now survives until evaluation succeeds, so an aborted evaluation cannot leave a half-initialized null behind in enumCases. - Wrong resolution context: the first fetch of a case may happen while the translator is converting a different file (a constant initializer in namespace B referencing A\E::X), and names inside the stored expression were resolved against that context: the ClassConstFetch callback reduced the name node to a bare string, which getClassConstValue() then prefixed with the active namespace. The callback now prefers the NameResolver's resolvedName attribute (or the node's own fully qualified form), and the evaluation runs inside the enum's declaration context — its namespace plus the declaring file's use tables, captured at prepare like the trait ones — through withDeclarationNameContext(), factored out of withTraitNameContext() which needed the identical swap. gen_stub's processStubFile() wrapped every exception into a bare RuntimeException; TestError now passes through so compile diagnostics raised during stub generation keep their type for the test harness. Tests cover the self-cycle and mutual-cycle fatals (messages probed on Zend 8.4.13), a cross-namespace program with a decoy B\Helper constant, and a cross-file pair converted referencing-file-first, asserting the zvals emitted into the stub registration (values verified against Zend 8.4.13). --- phpunit/code/enum_case_cross_file_def.php | 20 +++++ phpunit/code/enum_case_cross_file_ref.php | 19 +++++ phpunit/code/enum_case_cross_namespace.php | 37 +++++++++ phpunit/code/enum_case_mutual_reference.php | 12 +++ phpunit/code/enum_case_self_reference.php | 11 +++ phpunit/src/EnumCaseExprEvaluationTest.php | 78 ++++++++++++++++++ src/Entity/ClassDef.php | 18 ++++- src/Preprocessor.php | 9 +++ src/Resolver/ClassConstantValueTrait.php | 87 +++++++++++++++++++-- src/Translator.php | 35 +++++++-- src/gen_stub.php | 5 ++ 11 files changed, 318 insertions(+), 13 deletions(-) create mode 100644 phpunit/code/enum_case_cross_file_def.php create mode 100644 phpunit/code/enum_case_cross_file_ref.php create mode 100644 phpunit/code/enum_case_cross_namespace.php create mode 100644 phpunit/code/enum_case_mutual_reference.php create mode 100644 phpunit/code/enum_case_self_reference.php create mode 100644 phpunit/src/EnumCaseExprEvaluationTest.php diff --git a/phpunit/code/enum_case_cross_file_def.php b/phpunit/code/enum_case_cross_file_def.php new file mode 100644 index 00000000..1de475d2 --- /dev/null +++ b/phpunit/code/enum_case_cross_file_def.php @@ -0,0 +1,20 @@ +value); + } +} diff --git a/phpunit/code/enum_case_cross_namespace.php b/phpunit/code/enum_case_cross_namespace.php new file mode 100644 index 00000000..e8c6115e --- /dev/null +++ b/phpunit/code/enum_case_cross_namespace.php @@ -0,0 +1,37 @@ +value); + } +} diff --git a/phpunit/code/enum_case_mutual_reference.php b/phpunit/code/enum_case_mutual_reference.php new file mode 100644 index 00000000..7f29c08f --- /dev/null +++ b/phpunit/code/enum_case_mutual_reference.php @@ -0,0 +1,12 @@ +exec('Cannot declare self-referencing constant `E::A`', 'enum_case_self_reference.php'); + } + + public function testMutuallyRecursiveCasesAreRejected(): void + { + // Zend reports the first constant fetched twice while walking the + // cycle (E::B for `case A = E::B; case B = E::A;`, probed on 8.4.13), + // not the case whose evaluation started the walk. + $this->exec('Cannot declare self-referencing constant `E::B`', 'enum_case_mutual_reference.php'); + } + + public function testCaseExprResolvesInDeclaringNamespace(): void + { + // Verified against Zend 8.4.13: B\Holder::REF and A\E::X->value are + // both 21 (A\Helper::V + 1); the decoy B\Helper::V is 999. + [$stub] = $this->convertFiles(['enum_case_cross_namespace.php']); + self::assertStringContainsString('ZVAL_LONG(&enum_case_X_value, 21)', $stub); + self::assertStringContainsString('ZVAL_LONG(&const_REF_value, 21)', $stub); + self::assertStringNotContainsString('1000', $stub); + } + + public function testCaseExprResolvesAcrossFiles(): void + { + // The referencing file converts first, so the lazy evaluation of + // A\E::X runs while namespace Consumer is active; Provider inside the + // case expression must still resolve through the declaring file's + // `use Lib\Provider`. Verified against Zend 8.4.13: both values are 42. + [$ref, $def] = $this->convertFiles([ + 'enum_case_cross_file_ref.php', + 'enum_case_cross_file_def.php', + ]); + self::assertStringContainsString('ZVAL_LONG(&const_REF_value, 42)', $ref); + self::assertStringContainsString('ZVAL_LONG(&enum_case_X_value, 42)', $def); + } + + /** + * Compile the given phpunit/code files as one program and return each + * file's generated stub registration code (where constant and enum case + * values are emitted), in argument order — conversion happens in that + * order, which the cross-context tests rely on. + * + * @param list $files + * @return list + */ + private function convertFiles(array $files): array + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $paths = array_map(static fn (string $file): string => TYPEPHP_ROOT_PATH . '/phpunit/code/' . $file, $files); + $compiler->addFiles($paths); + foreach ($paths as $path) { + $compiler->prepareFile($path); + } + $generated = []; + foreach ($paths as $path) { + $compiler->convertFile($path); + $generated[] = file_get_contents($compiler->getArgInfoHeaderFile($path)); + } + return $generated; + } +} diff --git a/src/Entity/ClassDef.php b/src/Entity/ClassDef.php index c35bc6f1..45d5279b 100644 --- a/src/Entity/ClassDef.php +++ b/src/Entity/ClassDef.php @@ -61,10 +61,26 @@ class ClassDef extends ClassLikeDef * Backed case values that are not scalar literals, keyed by case name. * The expression AST is captured during prepare (the symbol environment * is incomplete there) and evaluated+memoized into $enumCases on first - * convert-phase access. + * convert-phase access. The entry survives until evaluation succeeds. * @var array */ public array $enumCaseExprs = []; + + /** + * Lexical import context of the file declaring the enum, captured when a + * backed case value is kept as an expression AST. The lazy evaluation may + * run while the translator is converting a different file, so names in + * the stored expressions must resolve against the enum's own namespace + * and `use` imports rather than the current conversion context. + * @var list + */ + public array $enumUseNamespaces = []; + /** @var array */ + public array $enumUseAliases = []; + /** @var array */ + public array $enumUseFunctions = []; + /** @var array */ + public array $enumUseConstants = []; /** * Abstract method name (lowercase) => flags * @var array diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 9136f480..59b33eb1 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1451,6 +1451,15 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum $this->classDef->enumCases[$caseName] = null; if ($v->expr !== null) { $this->classDef->enumCaseExprs[$caseName] = $v->expr; + // The expression is evaluated lazily in the + // convert phase, possibly while another file is + // being converted. Keep the declaring file's + // import tables so names in the expression + // resolve in the enum's own lexical context. + $this->classDef->enumUseNamespaces = $this->useNamespaces; + $this->classDef->enumUseAliases = $this->useAliases; + $this->classDef->enumUseFunctions = $this->useFunctions; + $this->classDef->enumUseConstants = $this->useConstants; } } break; diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 432e0694..ac1590f3 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -11,11 +11,21 @@ use PhpParser\ConstExprEvaluator; use PhpParser\Node; use PhpParser\NodeAbstract; +use TypePhp\Entity\ClassDef; use TypePhp\Entity\ConstantDef; use TypePhp\Entity\EnumCaseRef; trait ClassConstantValueTrait { + /** + * Backed enum cases whose stored value expression is currently being + * evaluated, keyed by lowercased "Enum\Fqn::CaseName". Guards the lazy + * evaluation against self-referencing and mutually recursive case values, + * which would otherwise recurse until the stack is exhausted. + * @var array + */ + private array $enumCaseExprsInProgress = []; + public function getDefinedConstants(): array { return $this->internalConstants; @@ -68,12 +78,7 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n // A backed case value beyond a scalar literal was kept as // an expression AST during prepare; the full symbol table // exists now, so evaluate once and memoize the result. - $classDef->enumCases[$name] = $this->evaluateConstantExpression( - $expr, - $classDef->enumCaseExprs[$name], - $class, - ); - unset($classDef->enumCaseExprs[$name]); + $this->evaluateEnumCaseExpr($expr, $classDef, $class, $name); } // The case IDENTITY is the constant's value; folding to the // backing scalar (or the case name) would make @@ -84,6 +89,52 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n $this->fatalError($expr, "Class constant `{$class}::{$name}` not found"); } + /** + * Evaluate a backed enum case value kept as an expression AST during + * prepare (ClassDef::$enumCaseExprs) and memoize it into $enumCases. The + * stored AST survives until evaluation succeeds, so an aborted evaluation + * never leaves a half-initialized null behind, and two rules govern the + * evaluation itself: + * + * - Cycle guard: a case expression may (transitively) fetch the very case + * it declares. Zend detects this while updating the constant and fails + * with "Cannot declare self-referencing constant E::A"; without a guard + * the compiler would recurse here until the stack is exhausted. The + * case is marked in progress for the duration of its evaluation + * (mirroring CONST_RECURSIVE on a Zend class-constant fetch), so the + * reported name is the first case fetched again while its own value is + * still being computed: `E::A` for `case A = E::A;` and `E::B` for + * `case A = E::B; case B = E::A;` (both probed on Zend 8.4.13). + * + * - Declaration context: the first fetch of the case may happen while the + * translator is converting a different file. Names inside the stored + * expression must resolve against the namespace and `use` imports of + * the file declaring the enum, not the current conversion context. + */ + private function evaluateEnumCaseExpr(NodeAbstract $expr, ClassDef $classDef, string $class, string $name): void + { + $enumName = $classDef->getNamespacedName(false); + $key = strtolower($enumName . '::' . $name); + if (isset($this->enumCaseExprsInProgress[$key])) { + $this->fatalError($expr, "Cannot declare self-referencing constant `{$enumName}::{$name}`"); + } + $this->enumCaseExprsInProgress[$key] = true; + try { + $value = $this->withDeclarationNameContext( + $classDef->namespace, + $classDef->enumUseNamespaces, + $classDef->enumUseAliases, + $classDef->enumUseFunctions, + $classDef->enumUseConstants, + fn (): mixed => $this->evaluateConstantExpression($expr, $classDef->enumCaseExprs[$name], $class), + ); + } finally { + unset($this->enumCaseExprsInProgress[$key]); + } + $classDef->enumCases[$name] = $value; + unset($classDef->enumCaseExprs[$name]); + } + /** @return array{bool, mixed} */ protected function resolveInheritedClassConst(string $class, string $name): array { @@ -149,7 +200,7 @@ protected function evaluateConstantExpression(?NodeAbstract $origin, Node\Expr $ } if ($expr instanceof Node\Expr\ClassConstFetch && $expr->class instanceof Node\Name) { $constName = $expr->name->toString(); - $className = $expr->class->toString(); + $className = $this->constantExpressionClassName($expr->class); if (strcasecmp($constName, 'class') === 0) { // `::class` is a compile-time magic constant that resolves to the // fully qualified class name of the referenced class. @@ -197,6 +248,28 @@ public function enumCaseLegacyValue(\TypePhp\Entity\EnumCaseRef $ref): mixed return $ref->caseName; } + /** + * Class names inside a constant expression AST were already resolved by + * the NameResolver against the file that declared the expression. Prefer + * that resolution (the `resolvedName` attribute, or the node being fully + * qualified) over re-resolving the bare string, which would apply the + * namespace the translator happens to be converting when a stored + * expression is evaluated lazily. The leading backslash keeps + * getNamespacedClassName() from prefixing a namespace again; `self`, + * `parent` and `static` carry no resolution and stay as written. + */ + private function constantExpressionClassName(Node\Name $name): string + { + $resolved = $name->getAttribute('resolvedName'); + if ($resolved instanceof Node\Name) { + return '\\' . ltrim($resolved->toString(), '\\'); + } + if ($name instanceof Node\Name\FullyQualified) { + return '\\' . $name->toString(); + } + return $name->toString(); + } + /** * Resolve a plain constant fetch inside a constant expression: program * constants declared with `const`/`define()` in the compiled sources win diff --git a/src/Translator.php b/src/Translator.php index 62a4b7df..855d2276 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -6513,17 +6513,42 @@ private function withTraitNameContext(string $traitName, callable $callback): mi $this->error("Internal compiler error: `{$traitName}` is not a trait AST template"); } + return $this->withDeclarationNameContext( + $traitDef->namespace, + $traitDef->traitUseNamespaces, + $traitDef->traitUseAliases, + $traitDef->traitUseFunctions, + $traitDef->traitUseConstants, + $callback, + ); + } + + /** + * Run $callback with the translator's name-resolution state (namespace and + * `use` import tables) swapped to the lexical context of a declaration + * compiled outside its own file, e.g. a trait AST composed into a + * consuming class or an enum case expression evaluated on first access. + * The current context is restored even when the callback throws. + */ + private function withDeclarationNameContext( + string $namespace, + array $useNamespaces, + array $useAliases, + array $useFunctions, + array $useConstants, + callable $callback, + ): mixed { $savedNamespace = $this->namespace; $savedUseNamespaces = $this->useNamespaces; $savedUseAliases = $this->useAliases; $savedUseFunctions = $this->useFunctions; $savedUseConstants = $this->useConstants; - $this->namespace = $traitDef->namespace; - $this->useNamespaces = $traitDef->traitUseNamespaces; - $this->useAliases = $traitDef->traitUseAliases; - $this->useFunctions = $traitDef->traitUseFunctions; - $this->useConstants = $traitDef->traitUseConstants; + $this->namespace = $namespace; + $this->useNamespaces = $useNamespaces; + $this->useAliases = $useAliases; + $this->useFunctions = $useFunctions; + $this->useConstants = $useConstants; try { return $callback(); } finally { diff --git a/src/gen_stub.php b/src/gen_stub.php index fd977b65..983c7941 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -189,6 +189,11 @@ function processStubFile(string $stubFile, Context $context, bool $includeOnly = } return $fileInfo; + } catch (TypePhp\Exception\TestError $e) { + // Compile-time diagnostics raised while evaluating constant + // expressions during stub generation (e.g. self-referencing enum + // cases) must keep their type so the test harness can assert them. + throw $e; } catch (Exception $e) { throw new RuntimeException("In " . getTranslator()->getRelativePath($stubFile) . ": {$e->getMessage()}\n". $e->getTraceAsString()); } From 4b8d105c3b68c673e67e3582e6ced4a57e051804 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Thu, 3 Sep 2026 10:09:40 +0200 Subject: [PATCH 5/6] test(enum): case-object constants register as enum-case ASTs after rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the enum-case class-constant work merged, `const REF = \A\E::X` registers the case object as a persistent enum-case AST — matching Zend, where var_dump(\B\Holder::REF) prints enum(A\E::X) — instead of folding to the backing scalar. The cross-context tests now assert that registration; the enum's own backed value stays a ZVAL_LONG. --- phpunit/src/EnumCaseExprEvaluationTest.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/phpunit/src/EnumCaseExprEvaluationTest.php b/phpunit/src/EnumCaseExprEvaluationTest.php index e98752a2..0196bc12 100644 --- a/phpunit/src/EnumCaseExprEvaluationTest.php +++ b/phpunit/src/EnumCaseExprEvaluationTest.php @@ -26,11 +26,14 @@ public function testMutuallyRecursiveCasesAreRejected(): void public function testCaseExprResolvesInDeclaringNamespace(): void { - // Verified against Zend 8.4.13: B\Holder::REF and A\E::X->value are - // both 21 (A\Helper::V + 1); the decoy B\Helper::V is 999. + // Verified against Zend 8.4.13: A\E::X->value is 21 (A\Helper::V + 1, + // never the decoy B\Helper::V of 999), and B\Holder::REF is the case + // OBJECT enum(A\E::X) — registered as a persistent enum-case AST, not + // a folded scalar. [$stub] = $this->convertFiles(['enum_case_cross_namespace.php']); self::assertStringContainsString('ZVAL_LONG(&enum_case_X_value, 21)', $stub); - self::assertStringContainsString('ZVAL_LONG(&const_REF_value, 21)', $stub); + self::assertStringContainsString('const_REF_value_enum_name = zend_string_init_interned("A\\\\E"', $stub); + self::assertStringContainsString('const_REF_value_case_name = zend_string_init_interned("X"', $stub); self::assertStringNotContainsString('1000', $stub); } @@ -39,12 +42,15 @@ public function testCaseExprResolvesAcrossFiles(): void // The referencing file converts first, so the lazy evaluation of // A\E::X runs while namespace Consumer is active; Provider inside the // case expression must still resolve through the declaring file's - // `use Lib\Provider`. Verified against Zend 8.4.13: both values are 42. + // `use Lib\Provider`. Verified against Zend 8.4.13: the backing value + // is 42, and Consumer\Holder::REF is the case object enum(A\E::X) — + // registered as a persistent enum-case AST. [$ref, $def] = $this->convertFiles([ 'enum_case_cross_file_ref.php', 'enum_case_cross_file_def.php', ]); - self::assertStringContainsString('ZVAL_LONG(&const_REF_value, 42)', $ref); + self::assertStringContainsString('const_REF_value_enum_name = zend_string_init_interned("A\\\\E"', $ref); + self::assertStringContainsString('const_REF_value_case_name = zend_string_init_interned("X"', $ref); self::assertStringContainsString('ZVAL_LONG(&enum_case_X_value, 42)', $def); } From 9a8bd067dee9f58eeb8673db2254ac0c4d1bfd70 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Thu, 3 Sep 2026 10:32:39 +0200 Subject: [PATCH 6/6] fix(enum): resolve case property fetches and case-sensitive cycle keys Two compatibility gaps against Zend 8.4 in backed enum case values: - A case value fetching another case's property (`case Two = E::One->value + 1;`) failed at stub registration: the independent constant-expression evaluator in gen_stub.php rejects Expr_PropertyFetch. The constant-expression machinery in ClassConstantValueTrait now evaluates `Case->value` / `Case->name` (PHP 8.2 "fetch properties in const expressions": the object must itself be a constant expression evaluating to an enum case; property names stay case-sensitive), and EnumCaseInfo::getDeclaration() consumes the value resolved there for any non-literal case value instead of re-evaluating the AST independently, so one evaluator owns cycle detection and declaration-context name resolution. The registration entry resolves through the unguarded evaluation, keeping Zend's cycle reporting: the mutual cycle still names E::B, and a true cycle through a property fetch (`case A = G::A->value + 1;`) fails with "Cannot declare self-referencing constant G::A" (probed on 8.4.13). - The cycle-detection key lowercased the whole "Enum::Case" string, but only enum class names are case-insensitive: `case A` and `case a` are distinct cases, and resolving a constant that walks both at once (`const REF = F::A;` with `case A = F::a->value - 1; case a = 2 + 1;`) reported a false "self-referencing constant F::a". The key now lowercases only the enum name. Tests cover a property-fetch backed value (int arithmetic on ->value and string concatenation on ->name), distinct A/a cases with a dependency between their values held in the in-progress guard simultaneously, and the self-cycle through ->value; all fixtures verified against Zend 8.4.13. --- phpunit/code/enum_case_property_fetch.php | 19 ++++ .../enum_case_property_self_reference.php | 11 ++ phpunit/code/enum_case_sensitive_names.php | 25 +++++ phpunit/src/EnumCaseExprEvaluationTest.php | 33 ++++++ src/Resolver/ClassConstantValueTrait.php | 101 +++++++++++++++--- src/gen_stub.php | 30 +++++- 6 files changed, 204 insertions(+), 15 deletions(-) create mode 100644 phpunit/code/enum_case_property_fetch.php create mode 100644 phpunit/code/enum_case_property_self_reference.php create mode 100644 phpunit/code/enum_case_sensitive_names.php diff --git a/phpunit/code/enum_case_property_fetch.php b/phpunit/code/enum_case_property_fetch.php new file mode 100644 index 00000000..e2d19282 --- /dev/null +++ b/phpunit/code/enum_case_property_fetch.php @@ -0,0 +1,19 @@ +value + 1; +} + +enum S: string +{ + case A = 'a'; + case B = S::A->name . '!'; +} + +function main() +{ + var_dump(E::Two->value); + var_dump(S::B->value); +} diff --git a/phpunit/code/enum_case_property_self_reference.php b/phpunit/code/enum_case_property_self_reference.php new file mode 100644 index 00000000..7cd08e17 --- /dev/null +++ b/phpunit/code/enum_case_property_self_reference.php @@ -0,0 +1,11 @@ +value + 1; +} + +function main() +{ + var_dump(G::A); +} diff --git a/phpunit/code/enum_case_sensitive_names.php b/phpunit/code/enum_case_sensitive_names.php new file mode 100644 index 00000000..c4568f8d --- /dev/null +++ b/phpunit/code/enum_case_sensitive_names.php @@ -0,0 +1,25 @@ +value - 1; + case a = 2 + 1; +} + +function main() +{ + var_dump(Holder::REF); + var_dump(F::A->value); + var_dump(F::a->value); +} diff --git a/phpunit/src/EnumCaseExprEvaluationTest.php b/phpunit/src/EnumCaseExprEvaluationTest.php index 0196bc12..c3f514c1 100644 --- a/phpunit/src/EnumCaseExprEvaluationTest.php +++ b/phpunit/src/EnumCaseExprEvaluationTest.php @@ -24,6 +24,39 @@ public function testMutuallyRecursiveCasesAreRejected(): void $this->exec('Cannot declare self-referencing constant `E::B`', 'enum_case_mutual_reference.php'); } + public function testCaseValueMayFetchEnumCaseProperties(): void + { + // A backed case value may fetch `->value` and `->name` of another + // case (PHP 8.2 "fetch properties in const expressions"). Verified + // against Zend 8.4.13: E::Two->value is 2 (E::One->value + 1) and + // S::B->value is "A!" (S::A->name . '!'). + [$stub] = $this->convertFiles(['enum_case_property_fetch.php']); + self::assertStringContainsString('ZVAL_LONG(&enum_case_Two_value, 2)', $stub); + self::assertStringContainsString('enum_case_B_value_str = zend_string_init_interned("A!"', $stub); + } + + public function testCaseNamesAreCaseSensitive(): void + { + // Enum class names are case-insensitive but case names are not: `A` + // and `a` are distinct cases with a dependency between their values. + // Resolving Holder::REF holds F::A and F::a in the in-progress guard + // at once — a cycle key that lowercased the case name reported a + // false "self-referencing constant F::a" here. Verified against Zend + // 8.4.13: Holder::REF is enum(F::A), F::A->value 2, F::a->value 3. + [$stub] = $this->convertFiles(['enum_case_sensitive_names.php']); + self::assertStringContainsString('ZVAL_LONG(&enum_case_A_value, 2)', $stub); + self::assertStringContainsString('ZVAL_LONG(&enum_case_a_value, 3)', $stub); + self::assertStringContainsString('const_REF_value_case_name = zend_string_init_interned("A"', $stub); + } + + public function testSelfReferenceThroughPropertyFetchIsRejected(): void + { + // A true cycle through a property fetch (`case A = G::A->value + 1;`) + // must still be detected: Zend 8.4.13 fails with "Cannot declare + // self-referencing constant G::A". + $this->exec('Cannot declare self-referencing constant `G::A`', 'enum_case_property_self_reference.php'); + } + public function testCaseExprResolvesInDeclaringNamespace(): void { // Verified against Zend 8.4.13: A\E::X->value is 21 (A\Helper::V + 1, diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index ac1590f3..270a46f8 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -19,9 +19,12 @@ trait ClassConstantValueTrait { /** * Backed enum cases whose stored value expression is currently being - * evaluated, keyed by lowercased "Enum\Fqn::CaseName". Guards the lazy - * evaluation against self-referencing and mutually recursive case values, - * which would otherwise recurse until the stack is exhausted. + * evaluated, keyed by "enum\fqn::CaseName" — the enum name lowercased + * (class names are case-insensitive) but the case name kept as written + * (case names are case-sensitive: `case A` and `case a` coexist). Guards + * the lazy evaluation against self-referencing and mutually recursive + * case values, which would otherwise recurse until the stack is + * exhausted. * @var array */ private array $enumCaseExprsInProgress = []; @@ -114,23 +117,36 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n private function evaluateEnumCaseExpr(NodeAbstract $expr, ClassDef $classDef, string $class, string $name): void { $enumName = $classDef->getNamespacedName(false); - $key = strtolower($enumName . '::' . $name); + $key = strtolower($enumName) . '::' . $name; if (isset($this->enumCaseExprsInProgress[$key])) { $this->fatalError($expr, "Cannot declare self-referencing constant `{$enumName}::{$name}`"); } $this->enumCaseExprsInProgress[$key] = true; try { - $value = $this->withDeclarationNameContext( - $classDef->namespace, - $classDef->enumUseNamespaces, - $classDef->enumUseAliases, - $classDef->enumUseFunctions, - $classDef->enumUseConstants, - fn (): mixed => $this->evaluateConstantExpression($expr, $classDef->enumCaseExprs[$name], $class), - ); + $this->evaluateEnumCaseExprUnguarded($expr, $classDef, $class, $name); } finally { unset($this->enumCaseExprsInProgress[$key]); } + } + + /** + * The evaluation itself, without marking the case in progress. Direct + * consumers of the backing value (stub registration) enter here: Zend's + * enum case fetch does not set CONST_RECURSIVE either — only class + * constant fetches nested in the expression walk do — so the name a cycle + * reports stays the first case fetched again while its own value is being + * computed, never the case whose registration started the walk. + */ + private function evaluateEnumCaseExprUnguarded(NodeAbstract $expr, ClassDef $classDef, string $class, string $name): void + { + $value = $this->withDeclarationNameContext( + $classDef->namespace, + $classDef->enumUseNamespaces, + $classDef->enumUseAliases, + $classDef->enumUseFunctions, + $classDef->enumUseConstants, + fn (): mixed => $this->evaluateConstantExpression($expr, $classDef->enumCaseExprs[$name], $class), + ); $classDef->enumCases[$name] = $value; unset($classDef->enumCaseExprs[$name]); } @@ -218,12 +234,73 @@ protected function evaluateConstantExpression(?NodeAbstract $origin, Node\Expr $ } return $this->getClassConstValue($origin ?? $expr, $className, $constName, $class); } + if ($expr instanceof Node\Expr\PropertyFetch) { + return $this->evaluateConstantPropertyFetch($origin, $expr, $class); + } throw new \RuntimeException('Unsupported class constant expression'); }); return $evaluator->evaluateDirectly($valueExpr); } + /** + * `EnumCase->value` / `EnumCase->name` inside a constant expression (PHP + * 8.2 "fetch properties in const expressions"): the object must itself be + * a constant expression evaluating to an enum case; `->name` is the case + * name and `->value` the backing value of a backed case. Property names + * are case-sensitive (`->VALUE` is undefined), unlike class names and the + * case-insensitive constant fetches above. + */ + private function evaluateConstantPropertyFetch(?NodeAbstract $origin, Node\Expr\PropertyFetch $expr, string $class): mixed + { + if (!$expr->name instanceof Node\Identifier) { + throw new \RuntimeException('Unsupported class constant expression'); + } + $object = $this->evaluateConstantExpression($origin, $expr->var, $class); + if (!$object instanceof EnumCaseRef) { + $this->fatalError($origin ?? $expr, 'Fetching properties in constant expressions is only supported on enum cases'); + } + $property = $expr->name->toString(); + if ($property === 'name') { + return $object->caseName; + } + if ($property === 'value') { + $value = $this->resolvedEnumCaseValue($origin ?? $expr, $object->enumClass, $object->caseName); + if ($value !== null) { + return $value; + } + } + $this->fatalError($origin ?? $expr, "Undefined property `{$object->enumClass}::\${$property}`"); + } + + /** + * The backing value of a backed enum case, for consumers that need the + * scalar rather than the case identity (`->value` fetches above, stub + * registration in gen_stub.php). Triggers the lazy evaluation of a stored + * case value expression, so cycle detection and declaration-context name + * resolution apply no matter which consumer asks first. Null when the + * case exists but is not backed. + */ + public function resolvedEnumCaseValue(NodeAbstract $origin, string $enumClass, string $caseName): mixed + { + if ($this->isInternalClass($enumClass)) { + $constName = $enumClass . '::' . $caseName; + if (defined($constName)) { + $case = constant($constName); + return $case instanceof \BackedEnum ? $case->value : null; + } + } elseif ($this->hasClass($enumClass)) { + $classDef = $this->getClass($enumClass); + if ($classDef->enum && array_key_exists($caseName, $classDef->enumCases)) { + if (isset($classDef->enumCaseExprs[$caseName])) { + $this->evaluateEnumCaseExprUnguarded($origin, $classDef, $classDef->getNamespacedName(false), $caseName); + } + return $classDef->enumCases[$caseName]; + } + } + $this->fatalError($origin, "Enum case `{$enumClass}::{$caseName}` not found"); + } + /** * The pre-AST representation of an enum case for consumers that cannot * register an IS_CONSTANT_AST (property and parameter defaults, attribute diff --git a/src/gen_stub.php b/src/gen_stub.php index 983c7941..4d38cfc1 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -3915,10 +3915,12 @@ private function getTypeDefaultValueCode(string $zvalName): string } class EnumCaseInfo { + private /* readonly */ string $enumClass; private /* readonly */ string $name; private /* readonly */ ?Expr $value; - public function __construct(string $name, ?Expr $value) { + public function __construct(string $enumClass, string $name, ?Expr $value) { + $this->enumClass = $enumClass; $this->name = $name; $this->value = $value; } @@ -3929,7 +3931,29 @@ public function getDeclaration(array $allConstInfos): string { if ($this->value === null) { $code = "\n\tzend_enum_add_case_cstr(class_entry, \"$escapedName\", NULL);\n"; } else { - $value = EvaluatedValue::createFromExpression($this->value, null, null, $allConstInfos); + $expr = $this->value; + if (!$expr instanceof Node\Scalar\Int_ && !$expr instanceof String_) { + // A backed case value beyond a scalar literal (arithmetic, + // constant fetches, enum-case property fetches such as + // `E::One->value + 1`) is resolved by the translator's + // constant-expression machinery, which owns cycle detection + // and declaration-context name resolution. Registration + // consumes that resolved value instead of re-evaluating the + // AST with the independent evaluator here, which cannot see + // property fetches. + $resolved = getTranslator()->resolvedEnumCaseValue($expr, $this->enumClass, $this->name); + if (is_int($resolved)) { + $expr = new Node\Scalar\Int_($resolved); + } elseif (is_string($resolved)) { + $expr = new String_($resolved); + } else { + throw new Exception( + "Enum case {$this->enumClass}::{$this->name} must have an int or string backing value, " + . gettype($resolved) . " given" + ); + } + } + $value = EvaluatedValue::createFromExpression($expr, null, null, $allConstInfos); $zvalName = "enum_case_{$escapedName}_value"; $code = "\n" . $value->initializeZval($zvalName); @@ -5146,7 +5170,7 @@ private function handleStatements(array $stmts, PrettyPrinterAbstract $prettyPri ); } else if ($classStmt instanceof Stmt\EnumCase) { $enumCaseInfos[] = new EnumCaseInfo( - $classStmt->name->toString(), $classStmt->expr); + $className->toString(), $classStmt->name->toString(), $classStmt->expr); } else if ($classStmt instanceof Stmt\TraitUse) { continue; } else {