diff --git a/phpunit/code/enum-case-class-constant.php b/phpunit/code/enum-case-class-constant.php new file mode 100644 index 00000000..053e551d --- /dev/null +++ b/phpunit/code/enum-case-class-constant.php @@ -0,0 +1,24 @@ +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); + } +} diff --git a/src/Entity/EnumCaseRef.php b/src/Entity/EnumCaseRef.php new file mode 100644 index 00000000..d92206cd --- /dev/null +++ b/src/Entity/EnumCaseRef.php @@ -0,0 +1,25 @@ +getNativeObjectMemberReceiver($objectName) . $this->getNativeObjectPropertyCppName($resolution->propertyDef, $resolution->classDef); } - $objectVar = $objectName; + $objectVar = $this->parenthesizeOpenOperand($objectName); if ($this->usesTraitPropertyScope($objectVar)) { $getProperty = 'typephp_read_property_scoped(' . $objectVar . ', ' . $id . ', php::FakeScopeGuard::current(), ' . $this->escapeAttrMode($update) . ')'; @@ -1155,4 +1155,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; + } } diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 18b99a6e..9608cd55 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1331,7 +1331,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); diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 90ec17e5..649f7a5b 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -12,6 +12,7 @@ use PhpParser\Node; use PhpParser\NodeAbstract; use TypePhp\Entity\ConstantDef; +use TypePhp\Entity\EnumCaseRef; trait ClassConstantValueTrait { @@ -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); @@ -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"); @@ -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 { @@ -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)) { diff --git a/src/gen_stub.php b/src/gen_stub.php index 25533823..65d92a8d 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -2569,6 +2569,10 @@ class EvaluatedValue public SimpleType $type; public Expr $expr; public bool $isUnknownConstValue; + /** Case identity when the expression evaluates to an enum case; only + * class-constant registration may use it (persistent AST) — every other + * consumer sees the legacy scalar/object in $value. */ + public ?\TypePhp\Entity\EnumCaseRef $enumCaseRef = null; /** @var ConstInfo[] */ public array $originatingConsts; @@ -2728,13 +2732,25 @@ static function (Expr $expr) use ( $result = $evaluator->evaluateDirectly($expr); - return new EvaluatedValue( + $enumCaseRef = null; + if ($result instanceof \TypePhp\Entity\EnumCaseRef) { + // Property/parameter defaults and attribute arguments must keep + // consuming the legacy value (persistent tables reject refcounted + // zvals, and those paths have their own runtime restore + // machinery); only class-constant registration uses the identity. + $enumCaseRef = $result; + $result = getTranslator()->enumCaseLegacyValue($result); + } + + $evaluated = new EvaluatedValue( $result, // note: we are generally not interested in the actual value of $result, unless it's a bare value, without constants $constType ?? SimpleType::fromValue($result), $cConstName === null ? $expr : new Expr\ConstFetch(new Node\Name($cConstName)), $visitor->visitedConstants, $isUnknownConstValue ); + $evaluated->enumCaseRef = $enumCaseRef; + return $evaluated; } public static function null(): EvaluatedValue @@ -2755,8 +2771,11 @@ private function __construct($value, SimpleType $type, Expr $expr, array $origin $this->isUnknownConstValue = $isUnknownConstValue; } - public function initializeZval(string $zvalName, bool $alreadyExists = false, string $forStringDef = '', string $varName = ''): string + public function initializeZval(string $zvalName, bool $alreadyExists = false, string $forStringDef = '', string $varName = '', bool $allowConstantAst = false): string { + if ($this->enumCaseRef !== null && $allowConstantAst) { + return $this->initializeEnumCaseZval($zvalName, $alreadyExists); + } $cExpr = $this->getCExpr(); $code = ''; @@ -2803,6 +2822,51 @@ public function initializeZval(string $zvalName, bool $alreadyExists = false, st return $code; } + /** + * Initialize the zval as a persistent IS_CONSTANT_AST holding + * `EnumClass::CaseName`. Enum case objects have request lifetime and can + * never sit in the persistent class-entry tables, so the engine's own + * mechanism for internal enums is reused: declaring an AST constant makes + * Zend separate the class constants table into request-local mutable + * storage, evaluate the fetch there on first access, and clean it up at + * request shutdown. This keeps case identity intact for static access, + * constant(), and reflection, and is safe under concurrent ZTS requests. + */ + private function initializeEnumCaseZval(string $zvalName, bool $alreadyExists): string + { + $case = $this->enumCaseRef; + $enumCName = '"' . getTranslator()->escapeString(ltrim($case->enumClass, '\\')) . '"'; + $caseCName = '"' . getTranslator()->escapeString($case->caseName) . '"'; + $id = preg_replace('/[^A-Za-z0-9_]/', '_', $zvalName); + + $code = $alreadyExists ? '' : "\tzval $zvalName;\n"; + $code .= "\t{\n"; + $code .= "\t\tzend_string *{$id}_enum_name = zend_string_init_interned($enumCName, sizeof($enumCName) - 1, 1);\n"; + $code .= "\t\tzend_string *{$id}_case_name = zend_string_init_interned($caseCName, sizeof($caseCName) - 1, 1);\n"; + $code .= "\t\tzend_ast_zval *{$id}_class_ast = (zend_ast_zval *) pemalloc(sizeof(zend_ast_zval), 1);\n"; + $code .= "\t\t{$id}_class_ast->kind = ZEND_AST_ZVAL;\n"; + $code .= "\t\t{$id}_class_ast->attr = ZEND_NAME_FQ;\n"; + $code .= "\t\tZVAL_INTERNED_STR(&{$id}_class_ast->val, {$id}_enum_name);\n"; + $code .= "\t\tZ_LINENO({$id}_class_ast->val) = 0;\n"; + $code .= "\t\tzend_ast_zval *{$id}_const_ast = (zend_ast_zval *) pemalloc(sizeof(zend_ast_zval), 1);\n"; + $code .= "\t\t{$id}_const_ast->kind = ZEND_AST_ZVAL;\n"; + $code .= "\t\t{$id}_const_ast->attr = 0;\n"; + $code .= "\t\tZVAL_INTERNED_STR(&{$id}_const_ast->val, {$id}_case_name);\n"; + $code .= "\t\tZ_LINENO({$id}_const_ast->val) = 0;\n"; + $code .= "\t\tzend_ast_ref *{$id}_ast_ref = (zend_ast_ref *) pemalloc(sizeof(zend_ast_ref) + ZEND_MM_ALIGNED_SIZE(zend_ast_size(2)), 1);\n"; + $code .= "\t\tGC_SET_REFCOUNT({$id}_ast_ref, 1);\n"; + $code .= "\t\tGC_TYPE_INFO({$id}_ast_ref) = GC_CONSTANT_AST | ((GC_PERSISTENT | GC_IMMUTABLE) << GC_FLAGS_SHIFT);\n"; + $code .= "\t\tzend_ast *{$id}_fetch_ast = GC_AST({$id}_ast_ref);\n"; + $code .= "\t\t{$id}_fetch_ast->kind = ZEND_AST_CLASS_CONST;\n"; + $code .= "\t\t{$id}_fetch_ast->attr = 0;\n"; + $code .= "\t\t{$id}_fetch_ast->lineno = 0;\n"; + $code .= "\t\t{$id}_fetch_ast->child[0] = (zend_ast *) {$id}_class_ast;\n"; + $code .= "\t\t{$id}_fetch_ast->child[1] = (zend_ast *) {$id}_const_ast;\n"; + $code .= "\t\tZVAL_AST(&$zvalName, {$id}_ast_ref);\n"; + $code .= "\t}\n"; + return $code; + } + public function getCExpr(): ?string { // $this->expr has all its PHP constants replaced by C constants @@ -3224,7 +3288,7 @@ private function getClassConstDeclaration(EvaluatedValue $value): string { $constName = $this->name->getDeclarationName(); - $zvalCode = $value->initializeZval("const_{$constName}_value"); + $zvalCode = $value->initializeZval("const_{$constName}_value", allowConstantAst: true); $code = "\n" . $zvalCode; diff --git a/tests/compiler/enum/enum-case-class-constant.phpt b/tests/compiler/enum/enum-case-class-constant.phpt new file mode 100644 index 00000000..0436d07c --- /dev/null +++ b/tests/compiler/enum/enum-case-class-constant.phpt @@ -0,0 +1,57 @@ +--TEST-- +Class constants valued by enum cases keep case identity everywhere +--FILE-- +getValue() === TypedCase::A); + var_dump((string) (new ReflectionClassConstant('K', 'CASE_VALUE'))->getType()); + // Expression-valued backed case keeps its computed backing value + var_dump(E::A->value); + var_dump(K::VALUE->value); +} +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +string(9) "TypedCase" +int(2) +int(2)