From 9a1af9b6a95ff29d4ed253b11cf5abf34635c769 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 13:18:00 +0200 Subject: [PATCH 1/4] 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 408a56ad..7bd66d04 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'; /** @@ -1203,6 +1226,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; } @@ -1268,11 +1296,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 @@ -1346,6 +1403,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"); + } $this->classDef->enumCases[$caseName] = $v->expr?->value; break; case 'Stmt_ClassMethod': @@ -2031,6 +2101,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) { @@ -2186,6 +2261,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)) { @@ -2238,6 +2322,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 006b6a894f9f5e218cf6773bc605b1c0b2bdabf5 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 12:11:19 +0200 Subject: [PATCH 2/4] 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 5a5fd51d46837f6447bf3a269cee65e1fed74c27 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 10:01:19 +0200 Subject: [PATCH 3/4] 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 | 43 ++++++++++--- src/Resolver/ClassConstantValueTrait.php | 61 ++++++++++++++++++- src/Translator.php | 4 ++ .../backed-enum-case-value-expressions.phpt | 33 ++++++++++ 8 files changed, 161 insertions(+), 13 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 5d9bb163..be3f5770 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 7bd66d04..c026f01a 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. * @@ -1416,7 +1434,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"); } - $this->classDef->enumCases[$caseName] = $v->expr?->value; + 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); @@ -2261,15 +2292,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 90ec17e5..2f55d50b 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -58,6 +58,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]); + } $caseValue = $classDef->enumCases[$name]; return $caseValue ?? $name; } @@ -106,6 +117,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(); @@ -113,9 +133,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) { @@ -144,6 +162,43 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c return $evaluator->evaluateDirectly($valueExpr); } + /** + * 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 5b5bd0a0..d1eb5f34 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -6427,6 +6427,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 f679874cbb7b9b620be26f3ce5a89b5296d8f05b Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 14:45:09 +0200 Subject: [PATCH 4/4] 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 be3f5770..8fc20d64 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 c026f01a..217d0396 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1446,6 +1446,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 2f55d50b..ce18c4d6 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -11,10 +11,20 @@ use PhpParser\ConstExprEvaluator; use PhpParser\Node; use PhpParser\NodeAbstract; +use TypePhp\Entity\ClassDef; use TypePhp\Entity\ConstantDef; 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; @@ -62,12 +72,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); } $caseValue = $classDef->enumCases[$name]; return $caseValue ?? $name; @@ -76,6 +81,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 { @@ -138,7 +189,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. @@ -162,6 +213,28 @@ protected function evaluateConstantExpression(?NodeAbstract $origin, Node\Expr $ return $evaluator->evaluateDirectly($valueExpr); } + /** + * 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 d1eb5f34..bebd0c23 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -6468,17 +6468,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 97aeb91d..727bed32 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()); }