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 @@ +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_self_reference.php b/phpunit/code/enum_case_self_reference.php new file mode 100644 index 00000000..2ff346e6 --- /dev/null +++ b/phpunit/code/enum_case_self_reference.php @@ -0,0 +1,11 @@ +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/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/EnumCaseExprEvaluationTest.php b/phpunit/src/EnumCaseExprEvaluationTest.php new file mode 100644 index 00000000..c3f514c1 --- /dev/null +++ b/phpunit/src/EnumCaseExprEvaluationTest.php @@ -0,0 +1,117 @@ +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 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, + // 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('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); + } + + 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: 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('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); + } + + /** + * 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/phpunit/src/EnumDeclarationRulesTest.php b/phpunit/src/EnumDeclarationRulesTest.php new file mode 100644 index 00000000..db059505 --- /dev/null +++ b/phpunit/src/EnumDeclarationRulesTest.php @@ -0,0 +1,96 @@ +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'); + } + + 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..45d5279b 100644 --- a/src/Entity/ClassDef.php +++ b/src/Entity/ClassDef.php @@ -56,6 +56,31 @@ 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. 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 389f5107..59b33eb1 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -46,8 +46,49 @@ 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'; + /** + * 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. * @@ -1208,6 +1249,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 +1319,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,16 +1426,42 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum break; case 'Stmt_EnumCase': $caseName = $this->parseIdentifier($v->name); - // 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; + // 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"); + } + 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; + // 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; case 'Stmt_ClassMethod': $this->prepareClassMethod($v, $class); @@ -2089,6 +2190,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 +2350,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); + $this->assertEnumMayIncludeMethod($v, $name); $flags = $this->parseModifiers($v->flags); $abstract = $flags & Modifiers::ABSTRACT; if ($this->classDef->nativeObject && ($flags & Modifiers::STATIC)) { @@ -2296,6 +2403,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}"); } diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 649f7a5b..270a46f8 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -11,11 +11,24 @@ 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 "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 = []; + public function getDefinedConstants(): array { return $this->internalConstants; @@ -64,6 +77,12 @@ 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. + $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 // `K::CONST === E::Case` false through every dynamic path. @@ -73,6 +92,65 @@ 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 { + $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]); + } + /** @return array{bool, mixed} */ protected function resolveInheritedClassConst(string $class, string $name): array { @@ -117,6 +195,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,14 +211,12 @@ 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) { $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. @@ -149,12 +234,73 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c } 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 @@ -179,6 +325,65 @@ 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 + * (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..855d2276 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; } @@ -6509,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..4d38cfc1 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()); } @@ -3910,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; } @@ -3924,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); @@ -5141,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 { 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)