From 9d6e8bdc4d33dc28e8b95d66fcd51cb8629ae505 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Thu, 3 Sep 2026 20:19:49 +0800 Subject: [PATCH] fix(enum): enforce case uniqueness and final semantics --- .../src/EnumCaseConstantExpressionTest.php | 16 +++ phpunit/src/EnumDeclarationRulesTest.php | 126 ++++++++++++++++++ src/Preprocessor.php | 21 ++- src/gen_stub.php | 9 +- .../enum/enum-final-case-sensitive.phpt | 24 ++++ 5 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 phpunit/src/EnumDeclarationRulesTest.php create mode 100644 tests/compiler/enum/enum-final-case-sensitive.phpt diff --git a/phpunit/src/EnumCaseConstantExpressionTest.php b/phpunit/src/EnumCaseConstantExpressionTest.php index 3806f207..4e5c2cca 100644 --- a/phpunit/src/EnumCaseConstantExpressionTest.php +++ b/phpunit/src/EnumCaseConstantExpressionTest.php @@ -66,6 +66,7 @@ final class EnumCaseConstantExpressionTest extends PHPUnit\Framework\TestCase string $expression, string $expected, string $declarations = '', + string $additionalCases = '', ): void { $root = sys_get_temp_dir() . '/typephp-enum-expression-' . bin2hex(random_bytes(8)); mkdir($root, 0777, true); @@ -76,6 +77,7 @@ final class EnumCaseConstantExpressionTest extends PHPUnit\Framework\TestCase enum InvalidEnum: int { case A = {$expression}; + {$additionalCases} } function main(): void {} @@ -128,6 +130,20 @@ PHP); 'Cannot declare self-referencing constant `FIRST`', 'const FIRST = SECOND; const SECOND = FIRST;', ]; + yield 'mutually recursive enum cases' => [ + 'self::B->value + 1', + 'Cannot declare self-referencing constant `InvalidEnum::A`', + '', + 'case B = self::A->value + 1;', + ]; + yield 'unknown enum case' => [ + 'self::Missing->value', + 'Class constant `InvalidEnum::Missing` not found', + ]; + yield 'evaluation error' => [ + '1 / 0', + 'backing value must be compile-time evaluable: Division by zero', + ]; } private function removeTree(string $root): void diff --git a/phpunit/src/EnumDeclarationRulesTest.php b/phpunit/src/EnumDeclarationRulesTest.php new file mode 100644 index 00000000..7a4d6491 --- /dev/null +++ b/phpunit/src/EnumDeclarationRulesTest.php @@ -0,0 +1,126 @@ +testRoot = sys_get_temp_dir() . '/typephp-enum-rules-' . bin2hex(random_bytes(8)); + mkdir($this->testRoot, 0777, true); + } + + protected function tearDown(): void + { + if (!is_dir($this->testRoot)) { + return; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->testRoot, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($this->testRoot); + } + + public function testDuplicateCaseIsRejectedDuringPrepare(): void + { + $compiler = $this->compilerFor(<<<'PHP' +expectException(TestError::class); + $this->expectExceptionMessage('Cannot redefine class constant Suit::Hearts'); + $compiler->prepareFile($this->testRoot . '/program.php'); + } + + /** @dataProvider caseConstantClashProvider */ + public function testCaseAndClassConstantCannotShareAName(string $members): void + { + $compiler = $this->compilerFor("expectException(TestError::class); + $this->expectExceptionMessage('Cannot redefine class constant Suit::Hearts'); + $compiler->prepareFile($this->testRoot . '/program.php'); + } + + public static function caseConstantClashProvider(): iterable + { + yield 'case then constant' => ['case Hearts; public const Hearts = 1;']; + yield 'constant then case' => ['public const Hearts = 1; case Hearts;']; + } + + public function testCaseNamesRemainCaseSensitive(): void + { + $compiler = $this->compilerFor(<<<'PHP' +testRoot . '/program.php'; + $compiler->prepareFile($file); + $compiler->convertFile($file); + + self::assertSame(['Hearts' => null, 'hearts' => null], $compiler->getClassDef('Suit')?->enumCases); + } + + public function testEnumIsImplicitlyFinal(): void + { + $compiler = $this->compilerFor(<<<'PHP' +prepareFile($this->testRoot . '/program.php'); + + $enum = $compiler->getClassDef('Suit'); + self::assertNotNull($enum); + self::assertNotSame(0, $enum->flags & Modifiers::FINAL); + } + + public function testClassCannotExtendEnum(): void + { + $compiler = $this->compilerFor(<<<'PHP' +testRoot . '/program.php'; + $compiler->prepareFile($file); + + $this->expectException(TestError::class); + $this->expectExceptionMessage('cannot extend final class `Suit`'); + $compiler->convertFile($file); + } + + private function compilerFor(string $source): CompilerTest + { + $file = $this->testRoot . '/program.php'; + file_put_contents($file, $source); + + global $translator; + $compiler = CompilerTest::create($this->testRoot); + $translator = $compiler; + $compiler->addFiles([$file]); + return $compiler; + } +} diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 64d357a3..7c310168 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1392,6 +1392,11 @@ class Preprocessor extends CompilerBase if ($class instanceof Node\Stmt\Class_) { $flags = $class->flags; + } elseif ($class instanceof Node\Stmt\Enum_) { + // PHP lowers every enum declaration as ZEND_ACC_ENUM | ZEND_ACC_FINAL. + // Keep the compiler model equally final for inheritance checks and + // only-safe-when-final static dispatch decisions. + $flags = Modifiers::PUBLIC | Modifiers::FINAL; } else { $flags = Modifiers::PUBLIC; } @@ -1536,12 +1541,16 @@ class Preprocessor extends CompilerBase 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. + if (array_key_exists($caseName, $this->classDef->enumCases) + || $this->classDef->hasConstant($caseName) + ) { + $enumName = $this->classDef->getNamespacedName(false); + $this->fatalError($v, "Cannot redefine class constant {$enumName}::{$caseName}"); + } + // Keep every backing expression until declaration + // finalization. Literal values also seed enumCases for + // declaration consumers, but code generation only accepts + // values finalized after the complete symbol graph exists. $this->classDef->enumCases[$caseName] = $v->expr instanceof Node\Scalar\Int_ || $v->expr instanceof Node\Scalar\String_ ? $v->expr->value diff --git a/src/gen_stub.php b/src/gen_stub.php index 651d82ac..133fa9d2 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -4214,7 +4214,10 @@ class ClassInfo { ? $this->enumBackingType->toTypeCode() : "IS_UNDEF"; $code .= "\tzend_class_entry *class_entry = zend_register_internal_enum(\"$name\", $backingType, $classMethods);\n"; if (!$flags->isEmpty()) { - $code .= $this->getFlagsByPhpVersion()->generateVersionDependentFlagCode("\tclass_entry->ce_flags = %s;\n", $this->phpVersionIdMinimumCompatibility); + // zend_register_internal_enum() has already installed + // ZEND_ACC_ENUM. Add TypePHP's implicit FINAL flag without + // replacing the enum bit or future flags owned by Zend. + $code .= $this->getFlagsByPhpVersion()->generateVersionDependentFlagCode("\tclass_entry->ce_flags |= %s;\n", $this->phpVersionIdMinimumCompatibility); } } else { $code .= "\tzend_class_entry ce, *class_entry;\n\n"; @@ -5885,7 +5888,9 @@ function parseClass( return new ClassInfo( $name, - $class instanceof Class_ ? $class->flags : 0, + $class instanceof Class_ + ? $class->flags + : ($class instanceof Enum_ ? Modifiers::FINAL : 0), $classKind, $alias, $class instanceof Enum_ && $class->scalarType !== null diff --git a/tests/compiler/enum/enum-final-case-sensitive.phpt b/tests/compiler/enum/enum-final-case-sensitive.phpt new file mode 100644 index 00000000..b5205336 --- /dev/null +++ b/tests/compiler/enum/enum-final-case-sensitive.phpt @@ -0,0 +1,24 @@ +--TEST-- +Enums are final and case names remain case-sensitive +--FILE-- +isFinal()); + foreach (Suit::cases() as $case) { + var_dump($case->name); + } +} +?> +--EXPECT-- +bool(true) +string(6) "Hearts" +string(6) "hearts"