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; } 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..e98752a2 --- /dev/null +++ b/phpunit/src/EnumCaseExprEvaluationTest.php @@ -0,0 +1,78 @@ +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/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 5d9bb163..8fc20d64 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 408a56ad..217d0396 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. * @@ -1203,6 +1244,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 +1314,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,7 +1421,42 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum break; case 'Stmt_EnumCase': $caseName = $this->parseIdentifier($v->name); - $this->classDef->enumCases[$caseName] = $v->expr?->value; + // 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); @@ -2031,6 +2141,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 +2301,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)) { @@ -2238,6 +2354,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 90ec17e5..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; @@ -58,6 +68,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); + } $caseValue = $classDef->enumCases[$name]; return $caseValue ?? $name; } @@ -65,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 { @@ -106,6 +168,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,14 +184,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. @@ -144,6 +213,65 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c 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 + * (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..bebd0c23 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; } @@ -6464,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()); } 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)