fix(enum): enforce runtime and declaration invariants

master
韩天峰 1 day ago
parent cec03af291
commit e07016b0bd
  1. 150
      phpunit/src/EnumDeclarationRulesTest.php
  2. 121
      phpunit/src/EnumMethodDeclarationRulesTest.php
  3. 135
      src/Preprocessor.php
  4. 119
      src/Translator.php
  5. 10
      src/TypeSystem/NativeTypeCompatibilityTrait.php
  6. 65
      src/gen_stub.php
  7. 53
      tests/compiler/enum/enum-runtime-handlers-and-case-attributes.phpt

@ -195,6 +195,156 @@ PHP);
$compiler->convertFile($file); $compiler->convertFile($file);
} }
public function testEnumCannotDeclareProperty(): void
{
$compiler = $this->compilerFor(<<<'PHP'
<?php
enum Suit { case Hearts; public string $label; }
function main(): void {}
PHP);
$this->expectException(TestError::class);
$this->expectExceptionMessage('Enum Suit cannot include properties');
$compiler->prepareFile($this->testRoot . '/program.php');
}
public function testEnumCannotImportTraitProperty(): void
{
$compiler = $this->compilerFor(<<<'PHP'
<?php
trait HasLabel { public string $label; }
enum Suit { use HasLabel; case Hearts; }
function main(): void {}
PHP);
$file = $this->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'
<?php
enum Flag: bool { case Enabled = true; }
function main(): void {}
PHP);
$this->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'
<?php
#[AllowDynamicProperties]
enum Suit { case Hearts; }
function main(): void {}
PHP);
$this->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("<?php\n{$declaration}\nfunction main(): void {}\n");
$this->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'
<?php
interface LegacySerializable extends Serializable {}
enum Suit implements LegacySerializable {
case Hearts;
public function serialize(): string { return ''; }
public function unserialize(string $data): void {}
}
function main(): void {}
PHP);
$this->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'
<?php
class FakeEnum implements UnitEnum { public static function cases(): array { return []; } }
function main(): void {}
PHP);
$file = $this->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'
<?php
enum Code: int { case Ok = 200; }
class Response { public int $code = Code::Ok; }
function main(): void {}
PHP);
$this->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'
<?php
enum Code: int { case Ok = 200; }
class Response { public UnitEnum|Code $code = Code::Ok; }
function main(): void {}
PHP);
$file = $this->testRoot . '/program.php';
$compiler->prepareFile($file);
$compiler->convertFile($file);
self::assertFileExists($compiler->getCppFile($file));
}
private function compilerFor(string $source): CompilerTest private function compilerFor(string $source): CompilerTest
{ {
$file = $this->testRoot . '/program.php'; $file = $this->testRoot . '/program.php';

@ -143,6 +143,64 @@ PHP;
$compiler->composeTraitDeclarations([$file]); $compiler->composeTraitDeclarations([$file]);
} }
/** @dataProvider reservedMethodProvider */
public function testEnumCannotRedeclareBuiltinMethod(string $declaration, string $method): void
{
[$compiler, $file] = $this->compilerFor("<?php\n{$declaration}\nfunction main(): void {}\n");
$this->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'
<?php
enum Status {
case Active;
public static function from(string $value): self { return self::Active; }
public static function tryFrom(string $value): ?self { return null; }
}
function main(): void {}
PHP);
$compiler->prepareFile($file);
$compiler->convertFile($file);
self::assertFileExists($compiler->getCppFile($file));
}
public function testTraitCannotInjectReservedEnumMethod(): void
{
[$compiler, $file] = $this->compilerFor(<<<'PHP'
<?php
trait ListsCases { public static function listAll(): array { return []; } }
enum Status { use ListsCases { listAll as cases; } case Active; }
function main(): void {}
PHP);
$compiler->prepareFile($file);
$this->expectException(TestError::class);
$this->expectExceptionMessage('Cannot redeclare Status::cases()');
$compiler->composeTraitDeclarations([$file]);
}
public function testEnumCannotDeclareAbstractMethod(): void public function testEnumCannotDeclareAbstractMethod(): void
{ {
$source = <<<'PHP' $source = <<<'PHP'
@ -248,6 +306,69 @@ PHP;
self::assertFileExists($compiler->getCppFile($file)); self::assertFileExists($compiler->getCppFile($file));
} }
public function testEnumImplicitInterfacesParticipateInTypeCompatibility(): void
{
[$compiler, $file] = $this->compilerFor(<<<'PHP'
<?php
interface PureProvider { public function get(): UnitEnum; }
interface BackedProvider { public function get(): BackedEnum; }
enum PureStatus implements PureProvider {
case Active;
public function get(): self { return self::Active; }
}
enum HttpStatus: int implements BackedProvider {
case Ok = 200;
public function get(): self { return self::Ok; }
}
function main(): void {}
PHP);
$compiler->prepareFile($file);
$compiler->convertFile($file);
self::assertFileExists($compiler->getCppFile($file));
}
public function testBuiltinCasesSatisfiesUserInterface(): void
{
[$compiler, $file] = $this->compilerFor(<<<'PHP'
<?php
interface ListsCases { public static function cases(): array; }
enum Status implements ListsCases { case Active; }
function main(): void {}
PHP);
$compiler->prepareFile($file);
$compiler->convertFile($file);
self::assertFileExists($compiler->getCppFile($file));
}
/** @dataProvider incompatibleBuiltinContractProvider */
public function testBuiltinEnumMethodMustSatisfyInterfaceSignature(string $source, string $method): void
{
[$compiler, $file] = $this->compilerFor("<?php\n{$source}\nfunction main(): void {}\n");
$compiler->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 public function testEnumMustImplementInterfaceMethod(): void
{ {
$source = <<<'PHP' $source = <<<'PHP'

@ -78,7 +78,22 @@ class Preprocessor extends CompilerBase
*/ */
protected function assertEnumMayIncludeMethod(Node $node, string $name): void 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; 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. * Discover Native class names before parsing any signatures or fields.
* *
@ -1446,16 +1493,20 @@ class Preprocessor extends CompilerBase
if (isset($this->symbolDeclInFile[$fullClassNameLower])) { if (isset($this->symbolDeclInFile[$fullClassNameLower])) {
$this->fatalError($class, "Duplicate class `{$fullClassName}`"); $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 // every property of a readonly class is readonly and declared, so
// Zend rejects the attribute at compile time. // 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 ($class->attrGroups as $group) {
foreach ($group->attrs as $attribute) { foreach ($group->attrs as $attribute) {
if (strcasecmp($this->getResolvedPhpName($attribute->name), 'AllowDynamicProperties') === 0) { if (strcasecmp($this->getResolvedPhpName($attribute->name), 'AllowDynamicProperties') === 0) {
$this->fatalError( $this->fatalError(
$attribute, $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_) { if ($class instanceof Node\Stmt\Enum_) {
$this->classDef->enum = true; $this->classDef->enum = true;
if ($class->scalarType !== null) { 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_) { if (!$class instanceof Node\Stmt\Trait_) {
$this->classDef->implements = $this->parseImplements($class->implements); $this->classDef->implements = $this->parseImplements($class->implements);
$this->assertEnumAndUnitEnumInterfaceRules($class);
} else { } else {
$this->classDef->trait = $class; $this->classDef->trait = $class;
// Trait members are compiled later in the consuming class, but // Trait members are compiled later in the consuming class, but
@ -1575,6 +1634,9 @@ class Preprocessor extends CompilerBase
case 'Stmt_ClassConst': case 'Stmt_ClassConst':
break; break;
case 'Stmt_Property': case 'Stmt_Property':
if ($this->classDef->enum) {
$this->fatalError($v, "Enum {$fullClassName} cannot include properties");
}
$this->parseClassPropertyDef($v); $this->parseClassPropertyDef($v);
break; break;
case 'Stmt_TraitUse': case 'Stmt_TraitUse':
@ -2125,7 +2187,7 @@ class Preprocessor extends CompilerBase
// must not be removed merely because their source syntax resembles a // must not be removed merely because their source syntax resembles a
// scalar constant expression. // scalar constant expression.
$type = $this->detectDefaultValueType($default); $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; 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); $allowed = $this->collectAllowedDefaultTypes($typeNode);
if ($allowed === null) { if ($allowed === null) {
// mixed / callable / otherwise unconstrained type declaration. // 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 * 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 * value. Returns one of int/float/string/true/false/array/null, an
* the type cannot be decided statically. * `enum:ClassName` marker, or null when the type cannot be decided
* statically.
*/ */
protected function detectDefaultValueType(NodeAbstract $node, ?string $scopeClass = null, int $depth = 0): ?string protected function detectDefaultValueType(NodeAbstract $node, ?string $scopeClass = null, int $depth = 0): ?string
{ {
@ -2221,6 +2297,9 @@ class Preprocessor extends CompilerBase
return null; return null;
} }
$targetDef = $this->getClass($targetClass); $targetDef = $this->getClass($targetClass);
if ($targetDef->enum && array_key_exists($constName, $targetDef->enumCases)) {
return 'enum:' . $targetDef->getNamespacedName(false);
}
if (!$targetDef->hasConstant($constName)) { if (!$targetDef->hasConstant($constName)) {
return null; 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 * Collect the set of value types accepted as a default for a declared type
* node. Returns null when the type imposes no statically-checkable * node. Returns null when the type imposes no statically-checkable

@ -5601,7 +5601,23 @@ CODE;
private function checkInterfaceImplementations(Node\Stmt\Class_|Node\Stmt\Enum_ $classStmt): void private function checkInterfaceImplementations(Node\Stmt\Class_|Node\Stmt\Enum_ $classStmt): void
{ {
$classDef = $this->classDef; $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); $this->checkInterfaceImplementation($classStmt, $classDef, $interfaceName);
} }
} }
@ -5749,6 +5765,15 @@ CODE;
foreach ($interfaceDef->methods as $methodName => $interfaceMethodDef) { foreach ($interfaceDef->methods as $methodName => $interfaceMethodDef) {
$childMethodDef = $this->findClassMethodDef($classDef, $methodName, $classDef->isAbstract()); $childMethodDef = $this->findClassMethodDef($classDef, $methodName, $classDef->isAbstract());
if ($childMethodDef === null) { if ($childMethodDef === null) {
if ($this->enumProvidesBuiltinMethod($classDef, $methodName)) {
$this->validateBuiltinEnumMethodImplementation(
$node,
$classDef,
$interfaceName,
$interfaceMethodDef,
);
continue;
}
if ($classDef->isAbstract()) { if ($classDef->isAbstract()) {
continue; continue;
} }
@ -5953,7 +5978,9 @@ CODE;
$enumName = $enum->getNamespacedName(false); $enumName = $enum->getNamespacedName(false);
foreach ($enum->abstractMethodDefs as $methodDef) { foreach ($enum->abstractMethodDefs as $methodDef) {
$name = strtolower($methodDef->name); $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}"; $requirements[$name] = "{$enumName}::{$methodDef->name}";
} }
} }
@ -5967,6 +5994,7 @@ CODE;
foreach ($interface->getMethods() as $method) { foreach ($interface->getMethods() as $method) {
$name = strtolower($method->getName()); $name = strtolower($method->getName());
if (!isset($requirements[$name]) if (!isset($requirements[$name])
&& !$this->enumProvidesBuiltinMethod($enum, $method->getName())
&& $this->findClassMethodDef($enum, $method->getName(), false) === null && $this->findClassMethodDef($enum, $method->getName(), false) === null
) { ) {
$requirements[$name] = $method->getDeclaringClass()->getName() . '::' . $method->getName(); $requirements[$name] = $method->getDeclaringClass()->getName() . '::' . $method->getName();
@ -5980,6 +6008,7 @@ CODE;
foreach ($this->getInterface($interfaceName)->methods as $methodDef) { foreach ($this->getInterface($interfaceName)->methods as $methodDef) {
$name = strtolower($methodDef->name); $name = strtolower($methodDef->name);
if (!isset($requirements[$name]) if (!isset($requirements[$name])
&& !$this->enumProvidesBuiltinMethod($enum, $methodDef->name)
&& $this->findClassMethodDef($enum, $methodDef->name, false) === null && $this->findClassMethodDef($enum, $methodDef->name, false) === null
) { ) {
$requirements[$name] = "{$interfaceName}::{$methodDef->name}"; $requirements[$name] = "{$interfaceName}::{$methodDef->name}";
@ -5990,6 +6019,80 @@ CODE;
return array_values($requirements); 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 private function getVisibilityRank(int $flags): int
{ {
if ($flags & Modifiers::PUBLIC) { if ($flags & Modifiers::PUBLIC) {
@ -6662,6 +6765,12 @@ CODE;
$classDef->constants[$const->name] = $const; $classDef->constants[$const->name] = $const;
} }
foreach ($traitDef->properties as $prop) { foreach ($traitDef->properties as $prop) {
if ($classDef->enum) {
$this->fatalError(
$v,
"Enum {$classDef->getNamespacedName(false)} cannot include properties",
);
}
if ($classDef->hasProperty($prop->name)) { if ($classDef->hasProperty($prop->name)) {
if (!$this->isCompatibleTraitProperty($classDef->getProperty($prop->name), $prop)) { if (!$this->isCompatibleTraitProperty($classDef->getProperty($prop->name), $prop)) {
$this->fatalError($v, "Trait `{$traitFullName}` property `{$prop->name}` conflicts with class `{$classDef->getNamespacedName(false)}`"); $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) { } 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) { foreach ($stmt->props as $prop) {
if (!$this->classDef->hasProperty($prop->name->toString())) { if (!$this->classDef->hasProperty($prop->name->toString())) {
$origin = $stmt->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE); $origin = $stmt->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE);

@ -71,6 +71,16 @@ trait NativeTypeCompatibilityTrait
return true; return true;
} }
$classDef = $this->getClass($class); $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 if ($classDef->nativeObject
&& strcasecmp($expected, 'Stringable') === 0 && strcasecmp($expected, 'Stringable') === 0
&& $this->findNativeObjectMethod($class, '__toString') !== null && $this->findNativeObjectMethod($class, '__toString') !== null

@ -3913,15 +3913,34 @@ class EnumCaseInfo {
private /* readonly */ string $enumClass; private /* readonly */ string $enumClass;
private /* readonly */ string $name; private /* readonly */ string $name;
private /* readonly */ ?Expr $value; 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->enumClass = $enumClass;
$this->name = $name; $this->name = $name;
$this->value = $value; $this->value = $value;
$this->attributes = $attributes;
$this->exposedDocComment = $exposedDocComment;
} }
/** @param array<string, ConstInfo> $allConstInfos */ /**
public function getDeclaration(array $allConstInfos): string { * @param array<string, ConstInfo> $allConstInfos
* @param array<string, string> $declaredStrings
*/
public function getDeclaration(
array $allConstInfos,
?int $phpVersionIdMinimumCompatibility,
array &$declaredStrings,
): string {
$escapedName = addslashes($this->name); $escapedName = addslashes($this->name);
if ($this->value === null) { if ($this->value === null) {
$code = "\n\tzend_enum_add_case_cstr(class_entry, \"$escapedName\", NULL);\n"; $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"; $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; return $code;
} }
} }
@ -4213,6 +4249,13 @@ class ClassInfo {
$backingType = $this->enumBackingType $backingType = $this->enumBackingType
? $this->enumBackingType->toTypeCode() : "IS_UNDEF"; ? $this->enumBackingType->toTypeCode() : "IS_UNDEF";
$code .= "\tzend_class_entry *class_entry = zend_register_internal_enum(\"$name\", $backingType, $classMethods);\n"; $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()) { if (!$flags->isEmpty()) {
// zend_register_internal_enum() has already installed // zend_register_internal_enum() has already installed
// ZEND_ACC_ENUM. Add TypePHP's implicit FINAL flag without // ZEND_ACC_ENUM. Add TypePHP's implicit FINAL flag without
@ -4258,8 +4301,13 @@ class ClassInfo {
static fn (ConstInfo $const): string => $const->getDeclaration($allConstInfos) static fn (ConstInfo $const): string => $const->getDeclaration($allConstInfos)
); );
$declaredStrings = [];
foreach ($this->enumCaseInfos as $enumCase) { foreach ($this->enumCaseInfos as $enumCase) {
$code .= $enumCase->getDeclaration($allConstInfos); $code .= $enumCase->getDeclaration(
$allConstInfos,
$this->phpVersionIdMinimumCompatibility,
refval($declaredStrings),
);
} }
foreach ($this->propertyInfos as $property) { foreach ($this->propertyInfos as $property) {
@ -4289,8 +4337,6 @@ class ClassInfo {
if ($this->alias) { if ($this->alias) {
$code .= "\tzend_register_class_alias(\"" . str_replace("\\", "\\\\", $this->alias) . "\", class_entry);\n"; $code .= "\tzend_register_class_alias(\"" . str_replace("\\", "\\\\", $this->alias) . "\", class_entry);\n";
} }
$declaredStrings = [];
if (!empty($this->attributes)) { if (!empty($this->attributes)) {
foreach ($this->attributes as $key => $attribute) { foreach ($this->attributes as $key => $attribute) {
$code .= $attribute->generateCode( $code .= $attribute->generateCode(
@ -5157,7 +5203,12 @@ class FileInfo {
); );
} else if ($classStmt instanceof Stmt\EnumCase) { } else if ($classStmt instanceof Stmt\EnumCase) {
$enumCaseInfos[] = new EnumCaseInfo( $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) { } else if ($classStmt instanceof Stmt\TraitUse) {
continue; continue;
} else { } else {

@ -0,0 +1,53 @@
--TEST--
Enum cases use Zend enum handlers and retain case attributes
--FILE--
<?php
#[Attribute(Attribute::TARGET_CLASS_CONSTANT)]
final class Marker
{
public function __construct(public string $name) {}
}
enum Suit
{
/** @genstubs-expose-comment-block
* Hearts documentation.
*/
#[Marker('hearts')]
case Hearts;
case Spades;
}
final class Holder
{
public UnitEnum $case = Suit::Hearts;
}
function main(): void
{
try {
clone Suit::Hearts;
} catch (Error $error) {
echo $error->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)
Loading…
Cancel
Save