From 939d04d9dfba14a5e204369a952e9d38e9f0d24c Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Fri, 4 Sep 2026 08:17:53 +0800 Subject: [PATCH] fix(enum): reject duplicate backing values at compile time --- phpunit/src/EnumDeclarationRulesTest.php | 83 ++++++++++++++++++++++++ src/Preprocessor.php | 43 ++++++++++++ 2 files changed, 126 insertions(+) diff --git a/phpunit/src/EnumDeclarationRulesTest.php b/phpunit/src/EnumDeclarationRulesTest.php index 7a4d6491..ec708120 100644 --- a/phpunit/src/EnumDeclarationRulesTest.php +++ b/phpunit/src/EnumDeclarationRulesTest.php @@ -68,6 +68,89 @@ PHP); yield 'constant then case' => ['public const Hearts = 1; case Hearts;']; } + /** @dataProvider duplicateBackingValueProvider */ + public function testDuplicateBackingValueIsRejectedAfterConstantEvaluation( + string $declarations, + string $enum, + string $expectedCases, + ): void { + $compiler = $this->compilerFor(<<testRoot . '/program.php'; + $compiler->prepareFile($file); + + try { + $compiler->convertFile($file); + self::fail('Compilation unexpectedly succeeded'); + } catch (TestError $error) { + self::assertStringContainsString( + "Duplicate value in enum Code for cases {$expectedCases}", + $error->getMessage(), + ); + } + self::assertFileDoesNotExist($compiler->getCppFile($file)); + } + + public static function duplicateBackingValueProvider(): iterable + { + yield 'integer literals' => [ + '', + 'enum Code: int { case First = 1; case Second = 1; }', + 'First and Second', + ]; + yield 'integer constant expression' => [ + '', + 'enum Code: int { case First = 1 + 1; case Second = 2; }', + 'First and Second', + ]; + yield 'negative zero folds to zero' => [ + '', + 'enum Code: int { case First = -0; case Second = 0; }', + 'First and Second', + ]; + yield 'string constant expression' => [ + '', + "enum Code: string { case First = 'type' . 'php'; case Second = 'typephp'; }", + 'First and Second', + ]; + yield 'global and class constants' => [ + 'const VALUE = 4; class Provider { public const VALUE = VALUE; }', + 'enum Code: int { case First = VALUE; case Second = Provider::VALUE; }', + 'First and Second', + ]; + yield 'enum case value reference' => [ + '', + 'enum Code: int { case First = 5; case Second = self::First->value; }', + 'First and Second', + ]; + yield 'forward reference retains source order' => [ + '', + 'enum Code: int { case First = self::Third->value + 1; case Second = 7; case Third = 6; }', + 'First and Second', + ]; + } + + public function testDistinctNumericStringsRemainDistinct(): void + { + $compiler = $this->compilerFor(<<<'PHP' +testRoot . '/program.php'; + $compiler->prepareFile($file); + $compiler->convertFile($file); + + self::assertSame( + ['One' => '1', 'ZeroOne' => '01'], + $compiler->getClassDef('Code')?->enumCases, + ); + } + public function testCaseNamesRemainCaseSensitive(): void { $compiler = $this->compilerFor(<<<'PHP' diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 7c310168..a04109e5 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -623,6 +623,49 @@ class Preprocessor extends CompilerBase $this->finalizePreparedFunctionDefaults($statement, $this->methodDef->functionDef); } } + if ($class instanceof Node\Stmt\Enum_) { + $this->assertUniqueEnumBackingValues($class); + } + } + + /** + * Reject duplicate backed values once every case expression has been + * folded. Scanning the declaration AST preserves source order even when a + * forward reference caused a later case to be evaluated recursively. + */ + private function assertUniqueEnumBackingValues(Node\Stmt\Enum_ $enum): void + { + if ($this->classDef->enumBackingType === null) { + return; + } + + /** @var array $firstCaseByValue */ + $firstCaseByValue = []; + foreach ($enum->stmts as $statement) { + if (!$statement instanceof Node\Stmt\EnumCase) { + continue; + } + $caseName = $this->parseIdentifier($statement->name); + $value = $this->classDef->enumCases[$caseName] ?? null; + if (!is_int($value) && !is_string($value)) { + throw new \LogicException( + "Enum case `{$this->classDef->getNamespacedName(false)}::{$caseName}` was not finalized", + ); + } + + // Prefix the scalar type so numeric strings can never become + // integer array keys or collide with integer backing values. + $key = is_int($value) ? 'i:' . $value : 's:' . $value; + if (array_key_exists($key, $firstCaseByValue)) { + $enumName = $this->classDef->getNamespacedName(false); + $firstCase = $firstCaseByValue[$key]; + $this->fatalError( + $statement, + "Duplicate value in enum {$enumName} for cases {$firstCase} and {$caseName}", + ); + } + $firstCaseByValue[$key] = $caseName; + } } /**