fix(enum): enforce case uniqueness and final semantics

master
韩天峰 2 days ago
parent b46f62da30
commit 9d6e8bdc4d
  1. 16
      phpunit/src/EnumCaseConstantExpressionTest.php
  2. 126
      phpunit/src/EnumDeclarationRulesTest.php
  3. 21
      src/Preprocessor.php
  4. 9
      src/gen_stub.php
  5. 24
      tests/compiler/enum/enum-final-case-sensitive.phpt

@ -66,6 +66,7 @@ final class EnumCaseConstantExpressionTest extends PHPUnit\Framework\TestCase
string $expression, string $expression,
string $expected, string $expected,
string $declarations = '', string $declarations = '',
string $additionalCases = '',
): void { ): void {
$root = sys_get_temp_dir() . '/typephp-enum-expression-' . bin2hex(random_bytes(8)); $root = sys_get_temp_dir() . '/typephp-enum-expression-' . bin2hex(random_bytes(8));
mkdir($root, 0777, true); mkdir($root, 0777, true);
@ -76,6 +77,7 @@ final class EnumCaseConstantExpressionTest extends PHPUnit\Framework\TestCase
enum InvalidEnum: int enum InvalidEnum: int
{ {
case A = {$expression}; case A = {$expression};
{$additionalCases}
} }
function main(): void {} function main(): void {}
@ -128,6 +130,20 @@ PHP);
'Cannot declare self-referencing constant `FIRST`', 'Cannot declare self-referencing constant `FIRST`',
'const FIRST = SECOND; const SECOND = 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 private function removeTree(string $root): void

@ -0,0 +1,126 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
use PhpParser\Modifiers;
use TypePhp\CompilerTest;
use TypePhp\Exception\TestError;
/**
* @internal
* @coversNothing
*/
final class EnumDeclarationRulesTest extends PHPUnit\Framework\TestCase
{
private string $testRoot;
protected function setUp(): void
{
$this->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'
<?php
enum Suit { case Hearts; case Hearts; }
function main(): void {}
PHP);
$this->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("<?php\nenum Suit { {$members} }\nfunction main(): void {}\n");
$this->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'
<?php
enum Suit { case Hearts; case hearts; }
function main(): void {}
PHP);
$file = $this->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'
<?php
enum Suit { case Hearts; }
function main(): void {}
PHP);
$compiler->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'
<?php
enum Suit { case Hearts; }
class InvalidSuit extends Suit {}
function main(): void {}
PHP);
$file = $this->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;
}
}

@ -1392,6 +1392,11 @@ class Preprocessor extends CompilerBase
if ($class instanceof Node\Stmt\Class_) { if ($class instanceof Node\Stmt\Class_) {
$flags = $class->flags; $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 { } else {
$flags = Modifiers::PUBLIC; $flags = Modifiers::PUBLIC;
} }
@ -1536,12 +1541,16 @@ class Preprocessor extends CompilerBase
break; break;
case 'Stmt_EnumCase': case 'Stmt_EnumCase':
$caseName = $this->parseIdentifier($v->name); $caseName = $this->parseIdentifier($v->name);
// Only literal backing values are recorded here; an if (array_key_exists($caseName, $this->classDef->enumCases)
// expression-valued case (`case A = 1 + 1;`) cannot be || $this->classDef->hasConstant($caseName)
// evaluated while declarations are still being collected, ) {
// and no compile-time consumer needs the scalar: case $enumName = $this->classDef->getNamespacedName(false);
// identity flows as EnumCaseRef and gen_stub evaluates $this->fatalError($v, "Cannot redefine class constant {$enumName}::{$caseName}");
// the registration value from the AST itself. }
// 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] = $this->classDef->enumCases[$caseName] =
$v->expr instanceof Node\Scalar\Int_ || $v->expr instanceof Node\Scalar\String_ $v->expr instanceof Node\Scalar\Int_ || $v->expr instanceof Node\Scalar\String_
? $v->expr->value ? $v->expr->value

@ -4214,7 +4214,10 @@ class ClassInfo {
? $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";
if (!$flags->isEmpty()) { 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 { } else {
$code .= "\tzend_class_entry ce, *class_entry;\n\n"; $code .= "\tzend_class_entry ce, *class_entry;\n\n";
@ -5885,7 +5888,9 @@ function parseClass(
return new ClassInfo( return new ClassInfo(
$name, $name,
$class instanceof Class_ ? $class->flags : 0, $class instanceof Class_
? $class->flags
: ($class instanceof Enum_ ? Modifiers::FINAL : 0),
$classKind, $classKind,
$alias, $alias,
$class instanceof Enum_ && $class->scalarType !== null $class instanceof Enum_ && $class->scalarType !== null

@ -0,0 +1,24 @@
--TEST--
Enums are final and case names remain case-sensitive
--FILE--
<?php
enum Suit
{
case Hearts;
case hearts;
}
function main(): void
{
$reflection = new ReflectionClass(Suit::class);
var_dump($reflection->isFinal());
foreach (Suit::cases() as $case) {
var_dump($case->name);
}
}
?>
--EXPECT--
bool(true)
string(6) "Hearts"
string(6) "hearts"
Loading…
Cancel
Save