Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions phpunit/code/enum-case-class-constant.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
enum CodegenEnum: int
{
case B = 4;
case A = 1 + 1;
}

enum CodegenTyped
{
case A;
}

class CodegenHolder
{
public const CB = CodegenEnum::B;
public const PICKED = true ? CodegenEnum::A : CodegenEnum::B;
public const CodegenTyped CASE_VALUE = CodegenTyped::A;
public const MODE = RoundingMode::HalfEven;
}

function main(): void
{
var_dump(CodegenHolder::CB === CodegenEnum::B);
}
120 changes: 120 additions & 0 deletions phpunit/src/EnumCaseAstConstantLifecycleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

use TypePhp\CompilerBase;
use TypePhp\CompilerTest;

/**
* Lifecycle contract for persistent enum-case AST class constants: Zend's
* internal-class teardown (destroy_zend_class) tolerates them only after the
* module's MSHUTDOWN released them. The generated module must therefore
* (a) refuse to start as a MODULE_TEMPORARY (dl()-loaded) module before any
* class is registered, because module_destructor() destroys temporary-module
* classes before the shutdown callback runs, and (b) order every fallible
* MINIT step before the first class registration, so a FAILURE return (which
* suppresses MSHUTDOWN) can never leave a foreign AST in the class table.
*/
final class EnumCaseAstConstantLifecycleTest extends \BaseTest
{
public function testModuleTemporaryGuardPrecedesEveryRegistrationStep(): void
{
$minit = $this->generateMinitBody('enum-case-class-constant.php', 'ast_lifecycle_guard');

$guardPos = strpos($minit, 'if (type == MODULE_TEMPORARY) {');
self::assertIsInt($guardPos, 'MINIT must reject dl()-loaded temporary modules');
self::assertStringContainsString(
'registers enum-case class constants that must be released by MSHUTDOWN',
$minit,
);
self::assertStringContainsString('Load the extension from php.ini instead.', $minit);

$handlersPos = strpos($minit, 'typephp_install_reflection_attribute_handlers()');
$firstRegisterPos = strpos($minit, 'register_class_');
self::assertIsInt($handlersPos);
self::assertIsInt($firstRegisterPos);
self::assertLessThan($handlersPos, $guardPos, 'the lifecycle guard must be the first MINIT statement');
self::assertLessThan($firstRegisterPos, $guardPos, 'the lifecycle guard must precede every class registration');
}

public function testAstConstantRegistrationIsOrderedAfterEveryFallibleMinitStep(): void
{
$minit = $this->generateMinitBody('enum-case-class-constant.php', 'ast_lifecycle_order');

$firstRegisterPos = strpos($minit, 'register_class_');
$lastFailurePos = strrpos($minit, 'return FAILURE;');
self::assertIsInt($firstRegisterPos);
self::assertIsInt($lastFailurePos);
self::assertLessThan(
$firstRegisterPos,
$lastFailurePos,
'no MINIT step after the first class registration may return FAILURE: '
. 'a failed MINIT never reaches MSHUTDOWN, so the persistent class '
. 'table would keep an AST that destroy_zend_class() cannot handle',
);
self::assertGreaterThan(
strrpos($minit, 'register_class_'),
strpos($minit, 'return SUCCESS;'),
);
}

public function testMshutdownReleasesAstConstantsBeforeAnyOtherTeardown(): void
{
$extension = $this->generateExtension('enum-case-class-constant.php', 'ast_lifecycle_shutdown');
$mshutdown = $this->sliceFunction($extension, 'PHP_MSHUTDOWN_FUNCTION', 'THREAD_LOCAL zval globals_array');

$releasePos = strpos($mshutdown, 'typephp_release_ast_constants_enum_case_class_constant();');
self::assertIsInt($releasePos, 'MSHUTDOWN must release the persistent AST constants');
// The release must run before anything else so the class table is
// Zend-safe no matter what the rest of the teardown does.
$firstStatementPos = strpos($mshutdown, ';');
self::assertSame($firstStatementPos, $releasePos + strlen('typephp_release_ast_constants_enum_case_class_constant();') - 1);
}

public function testModulesWithoutAstConstantsCarryNeitherGuardNorRelease(): void
{
$extension = $this->generateExtension('class-constant-codegen.php', 'ast_lifecycle_none');

self::assertStringNotContainsString('MODULE_TEMPORARY', $extension);
self::assertStringNotContainsString('typephp_release_ast_constants_', $extension);
}

private function generateMinitBody(string $fixture, string $target): string
{
return $this->sliceFunction(
$this->generateExtension($fixture, $target),
'PHP_MINIT_FUNCTION',
'PHP_MSHUTDOWN_FUNCTION',
);
}

private function generateExtension(string $fixture, string $target): string
{
global $translator;

$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$translator = $compiler;
$compiler->setBuildMode(CompilerBase::BUILD_MODE_EXT);
$compiler->setTargetName($target);
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/' . $fixture;
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$compiler->convertFile($source);
$extension = file_get_contents($compiler->genExtension());

self::assertIsString($extension);
return $extension;
}

/**
* The generated function bodies keep statements at column zero, so the
* closing brace is not recognizable; slice up to the next known emission
* instead.
*/
private function sliceFunction(string $extension, string $startMarker, string $endMarker): string
{
$start = strpos($extension, $startMarker);
self::assertIsInt($start, "generated extension must contain {$startMarker}");
$end = strpos($extension, $endMarker, $start + strlen($startMarker));
self::assertIsInt($end, "generated extension must contain {$endMarker}");
return substr($extension, $start, $end - $start);
}
}
61 changes: 61 additions & 0 deletions phpunit/src/EnumCaseClassConstantTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

use TypePhp\CompilerTest;

/**
* A class constant valued by an enum case must register a persistent
* IS_CONSTANT_AST (`Enum::Case`) instead of a folded scalar: the engine then
* separates the constants table per request, evaluates the fetch there, and
* cleans it up — preserving case identity for static access, constant(), and
* reflection, safely under concurrent ZTS requests.
*/
final class EnumCaseClassConstantTest extends \BaseTest
{
private string $arginfo;

protected function setUp(): void
{
global $translator;
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$translator = $compiler;
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/enum-case-class-constant.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$compiler->convertFile($source);
$this->arginfo = file_get_contents(
TYPEPHP_ROOT_PATH . '/' . 'build/include/' . basename($compiler->getArgInfoHeaderFile($source))
);
}

public function testDirectCaseRegistersConstantAst(): void
{
self::assertStringContainsString('const_CB_value_fetch_ast->kind = ZEND_AST_CLASS_CONST;', $this->arginfo);
self::assertStringContainsString('zend_string_init_interned("CodegenEnum", sizeof("CodegenEnum") - 1, 1)', $this->arginfo);
self::assertStringNotContainsString('ZVAL_LONG(&const_CB_value', $this->arginfo);
}

public function testConstantExpressionFoldsToCaseIdentity(): void
{
// true ? A : B folds to the A case identity, not to a scalar.
self::assertMatchesRegularExpression(
'/const_PICKED_value_case_name = zend_string_init_interned\("A"/',
$this->arginfo,
);
}

public function testTypedConstantKeepsDeclaredTypeAndAstValue(): void
{
self::assertStringContainsString('const_CASE_VALUE_value_fetch_ast->kind = ZEND_AST_CLASS_CONST;', $this->arginfo);
self::assertStringContainsString('zend_declare_typed_class_constant(class_entry, const_CASE_VALUE_name', $this->arginfo);
}

public function testInternalEnumCaseRegistersConstantAst(): void
{
self::assertStringContainsString('zend_string_init_interned("RoundingMode", sizeof("RoundingMode") - 1, 1)', $this->arginfo);
}

public function testExpressionValuedBackedCaseRegistersComputedValue(): void
{
self::assertStringContainsString('ZVAL_LONG(&enum_case_A_value, 2);', $this->arginfo);
}
}
25 changes: 25 additions & 0 deletions src/Entity/EnumCaseRef.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php
/**
* This file is part of TypePHP.
*
* @link https://www.swoole.com/
* @contact service@swoole.com
*/

namespace TypePhp\Entity;

/**
* Compile-time identity of an enum case flowing through constant-expression
* evaluation. Enum case objects have request lifetime, so a constant whose
* value is a case cannot be folded to its backing scalar (identity would be
* lost) nor embedded in persistent class metadata as an object; carriers of
* this value register an IS_CONSTANT_AST the engine evaluates per request.
*/
final class EnumCaseRef
{
public function __construct(
public readonly string $enumClass,
public readonly string $caseName,
) {
}
}
37 changes: 36 additions & 1 deletion src/Parser/PropertyAccessTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,7 @@ protected function parsePropertyFetch(Expr\PropertyFetch $expr): string
return $this->getNativeObjectMemberReceiver($objectName)
. $this->getNativeObjectPropertyCppName($resolution->propertyDef, $resolution->classDef);
}
$objectVar = $objectName;
$objectVar = $this->parenthesizeOpenOperand($objectName);
$directMagic = !$update && !$this->isNativePropertyAccess($expr)
? $this->resolveDirectMagicPropertyAccess($expr, $objectVar, '__get')
: null;
Expand Down Expand Up @@ -1335,4 +1335,39 @@ private function emitNativeInstancePropertyTypedFetch(
return $result;
}


/**
* A folded constant value can be a full C++ expression (e.g. the ternary
* of `const VALUE = cond ? E::A : E::B;`). Appending `.attr(...)` to it
* unparenthesized would bind the member access to the last operand only,
* so any operand with top-level operators is wrapped first. Simple
* identifiers and closed call chains stay untouched.
*/
private function parenthesizeOpenOperand(string $code): string
{
$depth = 0;
$inString = false;
$length = strlen($code);
for ($i = 0; $i < $length; $i++) {
$char = $code[$i];
if ($inString) {
if ($char === '\\') {
$i++;
} elseif ($char === '"') {
$inString = false;
}
continue;
}
if ($char === '"') {
$inString = true;
} elseif ($char === '(' || $char === '{' || $char === '[') {
$depth++;
} elseif ($char === ')' || $char === '}' || $char === ']') {
$depth--;
} elseif ($depth === 0 && ($char === ' ' || $char === '?')) {
return '(' . $code . ')';
}
}
return $code;
}
}
11 changes: 10 additions & 1 deletion src/Preprocessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -1346,7 +1346,16 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum
break;
case 'Stmt_EnumCase':
$caseName = $this->parseIdentifier($v->name);
$this->classDef->enumCases[$caseName] = $v->expr?->value;
// 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.
$this->classDef->enumCases[$caseName] =
$v->expr instanceof Node\Scalar\Int_ || $v->expr instanceof Node\Scalar\String_
? $v->expr->value
: null;
break;
case 'Stmt_ClassMethod':
$this->prepareClassMethod($v, $class);
Expand Down
43 changes: 39 additions & 4 deletions src/Resolver/ClassConstantValueTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use PhpParser\Node;
use PhpParser\NodeAbstract;
use TypePhp\Entity\ConstantDef;
use TypePhp\Entity\EnumCaseRef;

trait ClassConstantValueTrait
{
Expand Down Expand Up @@ -48,7 +49,12 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n
if ($this->isInternalClass($class)) {
$constName = $class . '::' . $name;
if (defined($constName)) {
return constant($constName);
$value = constant($constName);
// Internal enum cases (and internal constants holding one)
// must keep their identity through constant evaluation.
return $value instanceof \UnitEnum
? new EnumCaseRef(get_class($value), $value->name)
: $value;
}
}
[$inheritedFound, $inherited] = $this->resolveInheritedClassConst($class, $name);
Expand All @@ -58,8 +64,10 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n
if ($this->hasClass($class)) {
$classDef = $this->getClass($class);
if ($classDef->enum && array_key_exists($name, $classDef->enumCases)) {
$caseValue = $classDef->enumCases[$name];
return $caseValue ?? $name;
// The case IDENTITY is the constant's value; folding to the
// backing scalar (or the case name) would make
// `K::CONST === E::Case` false through every dynamic path.
return new EnumCaseRef($classDef->getNamespacedName(false), $name);
}
}
$this->fatalError($expr, "Class constant `{$class}::{$name}` not found");
Expand Down Expand Up @@ -89,7 +97,10 @@ protected function resolveInheritedClassConst(string $class, string $name): arra
} elseif (Reflection::isInternalClass($current)) {
$constName = $current . '::' . $name;
if (defined($constName)) {
return [true, constant($constName)];
$value = constant($constName);
return [true, $value instanceof \UnitEnum
? new EnumCaseRef(get_class($value), $value->name)
: $value];
}
break;
} else {
Expand Down Expand Up @@ -144,6 +155,30 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c
return $evaluator->evaluateDirectly($valueExpr);
}

/**
* The pre-AST representation of an enum case for consumers that cannot
* register an IS_CONSTANT_AST (property and parameter defaults, attribute
* arguments): internal enums degrade to the host case object, compiled
* enums to the literal backing value or the case name — exactly the
* values those paths consumed before case identity existed.
*/
public function enumCaseLegacyValue(\TypePhp\Entity\EnumCaseRef $ref): mixed
{
if ($this->isInternalClass($ref->enumClass)) {
$constName = $ref->enumClass . '::' . $ref->caseName;
if (defined($constName)) {
return constant($constName);
}
}
if ($this->hasClass($ref->enumClass)) {
$classDef = $this->getClass($ref->enumClass);
if (array_key_exists($ref->caseName, $classDef->enumCases)) {
return $classDef->enumCases[$ref->caseName] ?? $ref->caseName;
}
}
return $ref->caseName;
}

public function getConstValue(string $name): mixed
{
if ($this->isInternalConstant($name)) {
Expand Down
Loading
Loading