diff --git a/phpunit/src/EnumDeclarationRulesTest.php b/phpunit/src/EnumDeclarationRulesTest.php index ec708120..ef6fb308 100644 --- a/phpunit/src/EnumDeclarationRulesTest.php +++ b/phpunit/src/EnumDeclarationRulesTest.php @@ -195,6 +195,156 @@ PHP); $compiler->convertFile($file); } + public function testEnumCannotDeclareProperty(): void + { + $compiler = $this->compilerFor(<<<'PHP' +expectException(TestError::class); + $this->expectExceptionMessage('Enum Suit cannot include properties'); + $compiler->prepareFile($this->testRoot . '/program.php'); + } + + public function testEnumCannotImportTraitProperty(): void + { + $compiler = $this->compilerFor(<<<'PHP' +testRoot . '/program.php'; + $compiler->prepareFile($file); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('Enum Suit cannot include properties'); + $compiler->composeTraitDeclarations([$file]); + } + + public function testEnumBackingTypeMustBeIntOrString(): void + { + $compiler = $this->compilerFor(<<<'PHP' +expectException(TestError::class); + $this->expectExceptionMessage('Enum backing type must be int or string, bool given'); + $compiler->prepareFile($this->testRoot . '/program.php'); + } + + public function testAllowDynamicPropertiesCannotBeAppliedToEnum(): void + { + $compiler = $this->compilerFor(<<<'PHP' +expectException(TestError::class); + $this->expectExceptionMessage('Cannot apply #[AllowDynamicProperties] to enum `Suit`'); + $compiler->prepareFile($this->testRoot . '/program.php'); + } + + /** @dataProvider forbiddenEnumInterfaceProvider */ + public function testEnumCannotExplicitlyImplementReservedInterface( + string $declaration, + string $message, + ): void { + $compiler = $this->compilerFor("expectException(TestError::class); + $this->expectExceptionMessage($message); + $compiler->prepareFile($this->testRoot . '/program.php'); + } + + public static function forbiddenEnumInterfaceProvider(): iterable + { + yield 'pure UnitEnum' => [ + 'enum Suit implements UnitEnum { case Hearts; }', + 'Enum Suit cannot implement previously implemented interface UnitEnum', + ]; + yield 'pure BackedEnum' => [ + 'enum Suit implements BackedEnum { case Hearts; }', + 'Non-backed enum Suit cannot implement interface BackedEnum', + ]; + yield 'backed BackedEnum' => [ + 'enum Suit: string implements BackedEnum { case Hearts = "hearts"; }', + 'Enum Suit cannot implement previously implemented interface BackedEnum', + ]; + yield 'Serializable' => [ + 'enum Suit implements Serializable { case Hearts; public function serialize(): string { return ""; } public function unserialize(string $data): void {} }', + 'Enum Suit cannot implement interface Serializable', + ]; + } + + public function testEnumCannotImplementSerializableTransitively(): void + { + $compiler = $this->compilerFor(<<<'PHP' +expectException(TestError::class); + $this->expectExceptionMessage('Enum Suit cannot implement interface Serializable'); + $compiler->prepareFile($this->testRoot . '/program.php'); + } + + public function testOrdinaryClassCannotImplementUnitEnum(): void + { + $compiler = $this->compilerFor(<<<'PHP' +testRoot . '/program.php'; + $compiler->prepareFile($file); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('Class FakeEnum cannot implement interface UnitEnum'); + $compiler->convertFile($file); + } + + public function testEnumCaseCannotInitializeScalarTypedProperty(): void + { + $compiler = $this->compilerFor(<<<'PHP' +expectException(TestError::class); + $this->expectExceptionMessage('Cannot use Code as default value for property Response::$code of type int'); + $compiler->prepareFile($this->testRoot . '/program.php'); + } + + public function testEnumCaseMayInitializeCompatibleObjectProperty(): void + { + $compiler = $this->compilerFor(<<<'PHP' +testRoot . '/program.php'; + $compiler->prepareFile($file); + $compiler->convertFile($file); + + self::assertFileExists($compiler->getCppFile($file)); + } + private function compilerFor(string $source): CompilerTest { $file = $this->testRoot . '/program.php'; diff --git a/phpunit/src/EnumMethodDeclarationRulesTest.php b/phpunit/src/EnumMethodDeclarationRulesTest.php index 0b406c11..ee8fd1d9 100644 --- a/phpunit/src/EnumMethodDeclarationRulesTest.php +++ b/phpunit/src/EnumMethodDeclarationRulesTest.php @@ -143,6 +143,64 @@ PHP; $compiler->composeTraitDeclarations([$file]); } + /** @dataProvider reservedMethodProvider */ + public function testEnumCannotRedeclareBuiltinMethod(string $declaration, string $method): void + { + [$compiler, $file] = $this->compilerFor("expectException(TestError::class); + $this->expectExceptionMessage("Cannot redeclare Status::{$method}()"); + $compiler->prepareFile($file); + } + + public static function reservedMethodProvider(): iterable + { + yield 'cases on pure enum' => [ + 'enum Status { case Active; public static function cases(): array { return []; } }', + 'cases', + ]; + yield 'from on backed enum' => [ + 'enum Status: string { case Active = "active"; public static function from(string $value): self { return self::Active; } }', + 'from', + ]; + yield 'tryFrom is case insensitive' => [ + 'enum Status: int { case Active = 1; public static function TRYFROM(int $value): ?self { return null; } }', + 'TRYFROM', + ]; + } + + public function testPureEnumMayDeclareFromAndTryFrom(): void + { + [$compiler, $file] = $this->compilerFor(<<<'PHP' +prepareFile($file); + $compiler->convertFile($file); + + self::assertFileExists($compiler->getCppFile($file)); + } + + public function testTraitCannotInjectReservedEnumMethod(): void + { + [$compiler, $file] = $this->compilerFor(<<<'PHP' +prepareFile($file); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot redeclare Status::cases()'); + $compiler->composeTraitDeclarations([$file]); + } + public function testEnumCannotDeclareAbstractMethod(): void { $source = <<<'PHP' @@ -248,6 +306,69 @@ PHP; self::assertFileExists($compiler->getCppFile($file)); } + public function testEnumImplicitInterfacesParticipateInTypeCompatibility(): void + { + [$compiler, $file] = $this->compilerFor(<<<'PHP' +prepareFile($file); + $compiler->convertFile($file); + + self::assertFileExists($compiler->getCppFile($file)); + } + + public function testBuiltinCasesSatisfiesUserInterface(): void + { + [$compiler, $file] = $this->compilerFor(<<<'PHP' +prepareFile($file); + $compiler->convertFile($file); + + self::assertFileExists($compiler->getCppFile($file)); + } + + /** @dataProvider incompatibleBuiltinContractProvider */ + public function testBuiltinEnumMethodMustSatisfyInterfaceSignature(string $source, string $method): void + { + [$compiler, $file] = $this->compilerFor("prepareFile($file); + + $this->expectException(TestError::class); + $this->expectExceptionMessage("Declaration of `Status::{$method}()` must be compatible"); + $compiler->convertFile($file); + } + + public static function incompatibleBuiltinContractProvider(): iterable + { + yield 'cases has an argument' => [ + 'interface Contract { public static function cases(int $extra): array; } enum Status implements Contract { case Active; }', + 'cases', + ]; + yield 'cases must be static' => [ + 'interface Contract { public function cases(): array; } enum Status implements Contract { case Active; }', + 'cases', + ]; + yield 'from cannot accept bool contract' => [ + 'interface Contract { public static function from(bool $value): mixed; } enum Status: int implements Contract { case Active = 1; }', + 'from', + ]; + } + public function testEnumMustImplementInterfaceMethod(): void { $source = <<<'PHP' diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 7ede4171..c7780a3f 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -78,7 +78,22 @@ class Preprocessor extends CompilerBase */ protected function assertEnumMayIncludeMethod(Node $node, string $name): void { - if (!$this->classDef->enum || !isset(self::ENUM_FORBIDDEN_MAGIC_METHODS[strtolower($name)])) { + if (!$this->classDef->enum) { + return; + } + + $lowerName = strtolower($name); + $reserved = $lowerName === 'cases' + || ($this->classDef->enumBackingType !== null + && ($lowerName === 'from' || $lowerName === 'tryfrom')); + if ($reserved) { + $this->fatalError( + $node, + "Cannot redeclare {$this->classDef->getNamespacedName(false)}::{$name}()", + ); + } + + if (!isset(self::ENUM_FORBIDDEN_MAGIC_METHODS[$lowerName])) { return; } @@ -88,6 +103,38 @@ class Preprocessor extends CompilerBase ); } + /** + * UnitEnum and BackedEnum are attached by Zend itself. User declarations + * must not attach them a second time. Serializable is likewise forbidden + * for enums, including through an intermediate user interface. + */ + private function assertEnumAndUnitEnumInterfaceRules(Node\Stmt\Class_|Node\Stmt\Enum_ $class): void + { + $className = $this->classDef->getNamespacedName(false); + if ($this->classDef->enum) { + foreach ($this->classDef->implements as $interface) { + if (strcasecmp($interface, 'UnitEnum') === 0 + || ($this->classDef->enumBackingType !== null + && strcasecmp($interface, 'BackedEnum') === 0) + ) { + $this->fatalError( + $class, + "Enum {$className} cannot implement previously implemented interface {$interface}", + ); + } + if ($this->classDef->enumBackingType === null + && strcasecmp($interface, 'BackedEnum') === 0 + ) { + $this->fatalError($class, "Non-backed enum {$className} cannot implement interface BackedEnum"); + } + } + if ($this->isInheritedFrom($className, 'Serializable')) { + $this->fatalError($class, "Enum {$className} cannot implement interface Serializable"); + } + return; + } + } + /** * Discover Native class names before parsing any signatures or fields. * @@ -1446,16 +1493,20 @@ class Preprocessor extends CompilerBase if (isset($this->symbolDeclInFile[$fullClassNameLower])) { $this->fatalError($class, "Duplicate class `{$fullClassName}`"); } - // Dynamic properties and readonly semantics are mutually exclusive: + // Dynamic properties are forbidden on readonly classes and enums. // every property of a readonly class is readonly and declared, so // Zend rejects the attribute at compile time. - if ($class instanceof Node\Stmt\Class_ && ($flags & Modifiers::READONLY)) { + if (($class instanceof Node\Stmt\Class_ && ($flags & Modifiers::READONLY)) + || $class instanceof Node\Stmt\Enum_ + ) { foreach ($class->attrGroups as $group) { foreach ($group->attrs as $attribute) { if (strcasecmp($this->getResolvedPhpName($attribute->name), 'AllowDynamicProperties') === 0) { $this->fatalError( $attribute, - "Cannot apply #[AllowDynamicProperties] to readonly class `{$fullClassName}`", + $class instanceof Node\Stmt\Enum_ + ? "Cannot apply #[AllowDynamicProperties] to enum `{$fullClassName}`" + : "Cannot apply #[AllowDynamicProperties] to readonly class `{$fullClassName}`", ); } } @@ -1506,11 +1557,19 @@ class Preprocessor extends CompilerBase if ($class instanceof Node\Stmt\Enum_) { $this->classDef->enum = true; if ($class->scalarType !== null) { - $this->classDef->enumBackingType = $class->scalarType->name; + $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 = $backingType; } } if (!$class instanceof Node\Stmt\Trait_) { $this->classDef->implements = $this->parseImplements($class->implements); + $this->assertEnumAndUnitEnumInterfaceRules($class); } else { $this->classDef->trait = $class; // Trait members are compiled later in the consuming class, but @@ -1575,6 +1634,9 @@ class Preprocessor extends CompilerBase case 'Stmt_ClassConst': break; case 'Stmt_Property': + if ($this->classDef->enum) { + $this->fatalError($v, "Enum {$fullClassName} cannot include properties"); + } $this->parseClassPropertyDef($v); break; case 'Stmt_TraitUse': @@ -2125,7 +2187,7 @@ class Preprocessor extends CompilerBase // must not be removed merely because their source syntax resembles a // scalar constant expression. $type = $this->detectDefaultValueType($default); - return $type === null || $type === 'array'; + return $type === null || $type === 'array' || str_starts_with($type, 'enum:'); } /** @@ -2150,6 +2212,19 @@ class Preprocessor extends CompilerBase return; } + if (str_starts_with($valueType, 'enum:')) { + $enumClass = substr($valueType, strlen('enum:')); + if ($this->propertyTypeAcceptsEnumCase($typeNode, $enumClass)) { + return; + } + $className = $this->getFullClassName(); + $typeStr = $this->propertyTypeDeclToString($typeNode); + $this->fatalError( + $errorNode, + "Cannot use {$enumClass} as default value for property {$className}::\${$name} of type {$typeStr}", + ); + } + $allowed = $this->collectAllowedDefaultTypes($typeNode); if ($allowed === null) { // mixed / callable / otherwise unconstrained type declaration. @@ -2170,8 +2245,9 @@ class Preprocessor extends CompilerBase /** * Determine the PHP value type of a constant expression used as a default - * value. Returns one of int/float/string/true/false/array/null, or null when - * the type cannot be decided statically. + * value. Returns one of int/float/string/true/false/array/null, an + * `enum:ClassName` marker, or null when the type cannot be decided + * statically. */ protected function detectDefaultValueType(NodeAbstract $node, ?string $scopeClass = null, int $depth = 0): ?string { @@ -2221,6 +2297,9 @@ class Preprocessor extends CompilerBase return null; } $targetDef = $this->getClass($targetClass); + if ($targetDef->enum && array_key_exists($constName, $targetDef->enumCases)) { + return 'enum:' . $targetDef->getNamespacedName(false); + } if (!$targetDef->hasConstant($constName)) { return null; } @@ -2252,6 +2331,46 @@ class Preprocessor extends CompilerBase } } + private function propertyTypeAcceptsEnumCase(NodeAbstract $typeNode, string $enumClass): bool + { + if ($typeNode instanceof NullableType) { + return $this->propertyTypeAcceptsEnumCase($typeNode->type, $enumClass); + } + if ($typeNode instanceof UnionType) { + foreach ($typeNode->types as $member) { + if ($this->propertyTypeAcceptsEnumCase($member, $enumClass)) { + return true; + } + } + return false; + } + if ($typeNode instanceof IntersectionType) { + foreach ($typeNode->types as $member) { + if (!$this->propertyTypeAcceptsEnumCase($member, $enumClass)) { + return false; + } + } + return true; + } + + $typeName = $this->parseIdentifier($typeNode); + $lower = strtolower($typeName); + if ($lower === 'mixed' || $lower === 'any' || $lower === 'object') { + return true; + } + if (isset($this->zendTypeMap[$lower])) { + return false; + } + if ($lower === 'self') { + $expected = $this->getFullClassName(); + } elseif ($lower === 'parent') { + $expected = $this->classDef->extends; + } else { + $expected = $this->getNamespacedClassName($typeName); + } + return $expected !== '' && $this->isInheritedFrom($enumClass, $expected); + } + /** * Collect the set of value types accepted as a default for a declared type * node. Returns null when the type imposes no statically-checkable diff --git a/src/Translator.php b/src/Translator.php index e4b0f774..b3f65c44 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -5601,7 +5601,23 @@ CODE; private function checkInterfaceImplementations(Node\Stmt\Class_|Node\Stmt\Enum_ $classStmt): void { $classDef = $this->classDef; - foreach ($this->getClassImplementedInterfaces($classDef) as $interfaceName) { + $interfaces = $this->getClassImplementedInterfaces($classDef); + foreach ($interfaces as $interfaceName) { + if ($classDef->enum && strcasecmp($interfaceName, 'Serializable') === 0) { + $this->fatalError( + $classStmt, + "Enum {$classDef->getNamespacedName(false)} cannot implement interface Serializable", + ); + } + if (!$classDef->enum + && (strcasecmp($interfaceName, 'UnitEnum') === 0 + || strcasecmp($interfaceName, 'BackedEnum') === 0) + ) { + $this->fatalError( + $classStmt, + "Class {$classDef->getNamespacedName(false)} cannot implement interface {$interfaceName}", + ); + } $this->checkInterfaceImplementation($classStmt, $classDef, $interfaceName); } } @@ -5749,6 +5765,15 @@ CODE; foreach ($interfaceDef->methods as $methodName => $interfaceMethodDef) { $childMethodDef = $this->findClassMethodDef($classDef, $methodName, $classDef->isAbstract()); if ($childMethodDef === null) { + if ($this->enumProvidesBuiltinMethod($classDef, $methodName)) { + $this->validateBuiltinEnumMethodImplementation( + $node, + $classDef, + $interfaceName, + $interfaceMethodDef, + ); + continue; + } if ($classDef->isAbstract()) { continue; } @@ -5953,7 +5978,9 @@ CODE; $enumName = $enum->getNamespacedName(false); foreach ($enum->abstractMethodDefs as $methodDef) { $name = strtolower($methodDef->name); - if ($this->findClassMethodDef($enum, $methodDef->name, false) === null) { + if (!$this->enumProvidesBuiltinMethod($enum, $methodDef->name) + && $this->findClassMethodDef($enum, $methodDef->name, false) === null + ) { $requirements[$name] = "{$enumName}::{$methodDef->name}"; } } @@ -5967,6 +5994,7 @@ CODE; foreach ($interface->getMethods() as $method) { $name = strtolower($method->getName()); if (!isset($requirements[$name]) + && !$this->enumProvidesBuiltinMethod($enum, $method->getName()) && $this->findClassMethodDef($enum, $method->getName(), false) === null ) { $requirements[$name] = $method->getDeclaringClass()->getName() . '::' . $method->getName(); @@ -5980,6 +6008,7 @@ CODE; foreach ($this->getInterface($interfaceName)->methods as $methodDef) { $name = strtolower($methodDef->name); if (!isset($requirements[$name]) + && !$this->enumProvidesBuiltinMethod($enum, $methodDef->name) && $this->findClassMethodDef($enum, $methodDef->name, false) === null ) { $requirements[$name] = "{$interfaceName}::{$methodDef->name}"; @@ -5990,6 +6019,80 @@ CODE; return array_values($requirements); } + private function enumProvidesBuiltinMethod(ClassDef $enum, string $methodName): bool + { + if (!$enum->enum) { + return false; + } + $methodName = strtolower($methodName); + return $methodName === 'cases' + || ($enum->enumBackingType !== null + && ($methodName === 'from' || $methodName === 'tryfrom')); + } + + private function validateBuiltinEnumMethodImplementation( + NodeAbstract $node, + ClassDef $enum, + string $interfaceName, + MethodDef $contract, + ): void { + $function = $contract->functionDef; + if ($function === null) { + return; + } + + $methodName = strtolower($contract->name); + $parameterTypes = $methodName === 'cases' + ? [] + : [[['kind' => 'isInt'], ['kind' => 'isString']]]; + $required = $methodName === 'cases' ? 0 : 1; + $incompatible = !($contract->flags & Modifiers::STATIC) + || $function->returnsByRef + || $function->hasVariadicArg() + || $function->argCountRequired < $required + || count($function->argInfoList) > count($parameterTypes); + + foreach ($function->argInfoList as $index => $argument) { + if (!isset($parameterTypes[$index])) { + $incompatible = true; + break; + } + $accepted = $this->getParameterAcceptedTypes($argument); + if ($accepted === null + || !$this->isAcceptedTypeSubset($accepted, $parameterTypes[$index]) + || $argument->byRef + ) { + $incompatible = true; + break; + } + } + + if (!$function->returnTypeUndeclared) { + $builtinReturns = $methodName === 'cases' + ? [['kind' => 'isArray']] + : [['kind' => 'isStatic', 'class' => $enum->getNamespacedName(false)]]; + if ($methodName === 'tryfrom') { + $builtinReturns[] = ['kind' => 'isNull']; + } + $contractReturns = $this->getReturnAcceptedTypes($function, $interfaceName); + foreach ($builtinReturns as $builtinReturn) { + if (!$this->isReturnTypeCoveredBy($builtinReturn, $contractReturns)) { + $incompatible = true; + break; + } + } + } + + if ($incompatible) { + $this->fatalMethodOverrideIncompatible( + $node, + $enum->getNamespacedName(false), + $contract->name, + $interfaceName, + ); + } + } + private function getVisibilityRank(int $flags): int { if ($flags & Modifiers::PUBLIC) { @@ -6662,6 +6765,12 @@ CODE; $classDef->constants[$const->name] = $const; } foreach ($traitDef->properties as $prop) { + if ($classDef->enum) { + $this->fatalError( + $v, + "Enum {$classDef->getNamespacedName(false)} cannot include properties", + ); + } if ($classDef->hasProperty($prop->name)) { if (!$this->isCompatibleTraitProperty($classDef->getProperty($prop->name), $prop)) { $this->fatalError($v, "Trait `{$traitFullName}` property `{$prop->name}` conflicts with class `{$classDef->getNamespacedName(false)}`"); @@ -6707,6 +6816,12 @@ CODE; } } } elseif ($stmt instanceof Node\Stmt\Property) { + if ($this->classDef->enum) { + $this->fatalError( + $stmt, + "Enum {$this->classDef->getNamespacedName(false)} cannot include properties", + ); + } foreach ($stmt->props as $prop) { if (!$this->classDef->hasProperty($prop->name->toString())) { $origin = $stmt->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE); diff --git a/src/TypeSystem/NativeTypeCompatibilityTrait.php b/src/TypeSystem/NativeTypeCompatibilityTrait.php index bc25bcc9..ece51939 100644 --- a/src/TypeSystem/NativeTypeCompatibilityTrait.php +++ b/src/TypeSystem/NativeTypeCompatibilityTrait.php @@ -71,6 +71,16 @@ trait NativeTypeCompatibilityTrait return true; } $classDef = $this->getClass($class); + if ($classDef->enum) { + if (strcasecmp($expected, 'UnitEnum') === 0) { + return true; + } + if ($classDef->enumBackingType !== null + && strcasecmp($expected, 'BackedEnum') === 0 + ) { + return true; + } + } if ($classDef->nativeObject && strcasecmp($expected, 'Stringable') === 0 && $this->findNativeObjectMethod($class, '__toString') !== null diff --git a/src/gen_stub.php b/src/gen_stub.php index 133fa9d2..d2d43239 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -3913,15 +3913,34 @@ class EnumCaseInfo { private /* readonly */ string $enumClass; private /* readonly */ string $name; private /* readonly */ ?Expr $value; + /** @var AttributeInfo[] */ + private /* readonly */ array $attributes; + private /* readonly */ ?ExposedDocComment $exposedDocComment; - public function __construct(string $enumClass, string $name, ?Expr $value) { + /** @param AttributeInfo[] $attributes */ + public function __construct( + string $enumClass, + string $name, + ?Expr $value, + array $attributes, + ?ExposedDocComment $exposedDocComment, + ) { $this->enumClass = $enumClass; $this->name = $name; $this->value = $value; + $this->attributes = $attributes; + $this->exposedDocComment = $exposedDocComment; } - /** @param array $allConstInfos */ - public function getDeclaration(array $allConstInfos): string { + /** + * @param array $allConstInfos + * @param array $declaredStrings + */ + public function getDeclaration( + array $allConstInfos, + ?int $phpVersionIdMinimumCompatibility, + array &$declaredStrings, + ): string { $escapedName = addslashes($this->name); if ($this->value === null) { $code = "\n\tzend_enum_add_case_cstr(class_entry, \"$escapedName\", NULL);\n"; @@ -3944,6 +3963,23 @@ class EnumCaseInfo { $code .= "\tzend_enum_add_case_cstr(class_entry, \"$escapedName\", &$zvalName);\n"; } + if ($this->attributes !== [] || $this->exposedDocComment !== null) { + $id = 'enum_case_' . substr(sha1($this->enumClass . '::' . $this->name), 0, 16); + $code .= "\tzend_class_constant *{$id} = (zend_class_constant *) zend_hash_str_find_ptr(&class_entry->constants_table, \"$escapedName\", sizeof(\"$escapedName\") - 1);\n"; + if ($this->exposedDocComment !== null) { + $code .= "\t{$id}->doc_comment = " . $this->exposedDocComment->getInitCode() . "\n"; + } + foreach ($this->attributes as $key => $attribute) { + $code .= $attribute->generateCode( + "zend_add_class_constant_attribute(class_entry, {$id}", + "{$id}_{$key}", + $allConstInfos, + $phpVersionIdMinimumCompatibility, + refval($declaredStrings), + ); + } + } + return $code; } } @@ -4213,6 +4249,13 @@ class ClassInfo { $backingType = $this->enumBackingType ? $this->enumBackingType->toTypeCode() : "IS_UNDEF"; $code .= "\tzend_class_entry *class_entry = zend_register_internal_enum(\"$name\", $backingType, $classMethods);\n"; + // PHP 8.5 installs the enum handlers in + // zend_register_internal_enum(). PHP 8.4 does not, which would + // otherwise make an internal enum cloneable and give it ordinary + // object comparison semantics. + $code .= "#if PHP_VERSION_ID < 80500\n"; + $code .= "\tclass_entry->default_object_handlers = &zend_enum_object_handlers;\n"; + $code .= "#endif\n"; if (!$flags->isEmpty()) { // zend_register_internal_enum() has already installed // ZEND_ACC_ENUM. Add TypePHP's implicit FINAL flag without @@ -4258,8 +4301,13 @@ class ClassInfo { static fn (ConstInfo $const): string => $const->getDeclaration($allConstInfos) ); + $declaredStrings = []; foreach ($this->enumCaseInfos as $enumCase) { - $code .= $enumCase->getDeclaration($allConstInfos); + $code .= $enumCase->getDeclaration( + $allConstInfos, + $this->phpVersionIdMinimumCompatibility, + refval($declaredStrings), + ); } foreach ($this->propertyInfos as $property) { @@ -4289,8 +4337,6 @@ class ClassInfo { if ($this->alias) { $code .= "\tzend_register_class_alias(\"" . str_replace("\\", "\\\\", $this->alias) . "\", class_entry);\n"; } - $declaredStrings = []; - if (!empty($this->attributes)) { foreach ($this->attributes as $key => $attribute) { $code .= $attribute->generateCode( @@ -5157,7 +5203,12 @@ class FileInfo { ); } else if ($classStmt instanceof Stmt\EnumCase) { $enumCaseInfos[] = new EnumCaseInfo( - $className->toString(), $classStmt->name->toString(), $classStmt->expr); + $className->toString(), + $classStmt->name->toString(), + $classStmt->expr, + AttributeInfo::createFromGroups($classStmt->attrGroups), + ExposedDocComment::extractExposedComment($classStmt->getComments()), + ); } else if ($classStmt instanceof Stmt\TraitUse) { continue; } else { diff --git a/tests/compiler/enum/enum-runtime-handlers-and-case-attributes.phpt b/tests/compiler/enum/enum-runtime-handlers-and-case-attributes.phpt new file mode 100644 index 00000000..7faf14fc --- /dev/null +++ b/tests/compiler/enum/enum-runtime-handlers-and-case-attributes.phpt @@ -0,0 +1,53 @@ +--TEST-- +Enum cases use Zend enum handlers and retain case attributes +--FILE-- +getMessage(), "\n"; + } + + var_dump(Suit::Hearts < Suit::Spades); + var_dump(Suit::Hearts <=> Suit::Spades); + + $case = new ReflectionEnumUnitCase(Suit::class, 'Hearts'); + $attributes = $case->getAttributes(Marker::class); + var_dump(count($attributes)); + var_dump($attributes[0]->newInstance()->name); + var_dump(str_contains($case->getDocComment(), 'Hearts documentation.')); + var_dump((new Holder())->case === Suit::Hearts); +} +?> +--EXPECT-- +Trying to clone an uncloneable object of class Suit +bool(false) +int(1) +int(1) +string(6) "hearts" +bool(true) +bool(true)