diff --git a/phpunit/code/class-constant-expression-inline.php b/phpunit/code/class-constant-expression-inline.php new file mode 100644 index 00000000..0fa6fb28 --- /dev/null +++ b/phpunit/code/class-constant-expression-inline.php @@ -0,0 +1,51 @@ +compileFixture(); + + $leaf = $this->methodBody($code, 'php_classconstantexpressioninline__readleaf'); + self::assertDoesNotMatchRegularExpression('/php::(constant|classConstant)\(/', $leaf); + self::assertMatchesRegularExpression('/= \(1L+\);/', $leaf); + + $mask = $this->methodBody($code, 'php_classconstantexpressioninline__readmask'); + self::assertStringContainsString('php::constant(', $mask); + self::assertDoesNotMatchRegularExpression('/php::Var\(tmp_var_\d+\)/', $mask); + } + + public function testInterfaceConstantFetchOnImplementingClassUsesZendLookup(): void + { + $code = $this->compileFixture(); + + $combined = $this->methodBody( + $code, + 'php_classconstantexpressioninlineflaguser__readcombined' + ); + self::assertStringContainsString('php::constant(', $combined); + self::assertDoesNotMatchRegularExpression('/php::Var\(tmp_var_\d+\)/', $combined); + } + + public function testInterfaceConstantDefaultParameterEvaluatesAtCompileTime(): void + { + $this->compileFixture(); + + // The default-parameter (arginfo) path resolves the constant through + // getClassConstValue, which must stay independent of the method-body + // inline-eligibility gate: an expression-valued interface constant + // fetched on the implementing class must still evaluate to 3. + $default = $this->compiler->getClassConstValue( + new \PhpParser\Node\Expr\ClassConstFetch( + new \PhpParser\Node\Name('ClassConstantExpressionInlineFlagUser'), + 'COMBINED', + ), + 'ClassConstantExpressionInlineFlagUser', + 'COMBINED', + ); + + self::assertSame(3, $default); + } + + public function testNativeLiteralContainingTmpVarNameIsInlinedWithNoLiteralStrings(): void + { + $code = $this->compileFixture(['noLiteralStrings' => true]); + + // Native classes emit their methods as C++ member functions, so the + // body selector uses the qualified `Class::method` name. + $label = $this->methodBody( + $code, + 'php_classconstantexpressioninlinenativemarker::label' + ); + self::assertStringContainsString('ZEND_STRL("tmp_var_label")', $label); + self::assertDoesNotMatchRegularExpression('/php::(constant|classConstant)\(/', $label); + } + + /** + * @param array{noLiteralStrings?: bool} $options + */ + private function compileFixture(array $options = []): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + if (isset($options['noLiteralStrings'])) { + $property = new \ReflectionProperty(\TypePhp\CompilerBase::class, 'noLiteralStrings'); + $property->setValue($compiler, $options['noLiteralStrings']); + } + $this->compiler = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/class-constant-expression-inline.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + return $code; + } + + private function methodBody(string $code, string $function): string + { + $pattern = '/' . preg_quote($function, '/') . '\([^)]*\) \{[\s\S]*?\n\}/'; + preg_match($pattern, $code, $matches); + + self::assertSame(1, count($matches), "Missing body for {$function}"); + return $matches[0]; + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 6d8bd986..e439acb0 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -3375,6 +3375,20 @@ protected function findNativeClassConst( if (!$interfaceConstDef->codegenFinalized) { return false; } + // Inline-eligibility only: a value that depends on + // materialize-temporaries (tmp_var_N, recorded structurally at + // lowering time) cannot be inlined into the fetching function + // because those locals only exist in arrayExpr's prelude + // (C2065 / C2593). Fall back to the Zend lookup below. + // Native classes have no zend_class_entry, so the Zend fallback + // is unsupported for them: keep their constants on the + // compile-time inline path. + if (!$this->isNativeObjectClass($class) + && ($interfaceConstDef->arrayExpr !== '' + || $interfaceConstDef->hasMaterializedTemporaries) + ) { + return false; + } $expr->setAttribute('nativeConst', $interfaceConstDef); return $interfaceConstDef->value; } @@ -3400,6 +3414,24 @@ protected function findNativeClassConst( if (!$constDef->codegenFinalized) { return false; } + // Inline-eligibility only: a constant initializer that parse + // lowered into materialize temporaries (tmp_var_N, recorded + // structurally at lowering time) cannot be inlined: those + // temporaries are declared and assigned only in arrayExpr's + // prelude, which is emitted together with array constants. + // Inlining the value string into the fetching function references + // locals that do not exist there (C2065 / C2593). Fall back to + // the Zend class-constant lookup instead: it reads the + // already-evaluated literal that was registered for the constant + // and is cached in the convert phase. + // Native classes have no zend_class_entry, so the Zend fallback + // is unsupported for them: keep their constants on the + // compile-time inline path. + if (!$this->isNativeObjectClass($class) + && ($constDef->arrayExpr !== '' || $constDef->hasMaterializedTemporaries) + ) { + return false; + } $expr->setAttribute('nativeConst', $constDef); return $constDef->value; } diff --git a/src/Entity/ConstantDef.php b/src/Entity/ConstantDef.php index 89c23469..12a60cdb 100644 --- a/src/Entity/ConstantDef.php +++ b/src/Entity/ConstantDef.php @@ -23,6 +23,15 @@ class ConstantDef public ?NodeAbstract $valueExpr = null; /** True after the declaration AST has been lowered to C++ in convert. */ public bool $codegenFinalized = false; + /** + * True when lowering this constant's initializer materialized temporaries + * (tmp_var_N) into the local scope. Those temporaries only exist inside + * arrayExpr's prelude, so the value string must never be inlined into a + * fetching function. Set structurally at lowering time from the function + * context (never by inspecting the value text, which may legitimately + * contain substrings like "tmp_var_" inside string literals). + */ + public bool $hasMaterializedTemporaries = false; /** Explicit declared type (e.g. `const int FOO`); null for inferred/untyped constants. */ public ?string $declaredType = null; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 5a975491..165341ab 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -953,6 +953,11 @@ protected function finalizePreparedConstant(ConstantDef $constant, Node\Expr $ex $this->resetFunction(); $constant->arrayExpr = ''; $constant->value = $this->parseIdentifier($expression); + // resetFunction() installed a fresh context, so the temporary counter + // reflects only this constant's lowering — a structural record of + // whether the value depends on prelude temporaries. + $constant->hasMaterializedTemporaries = $this->context->tmpVarIndex > 0 + || $this->context->beforeStmtLines !== []; if ($this->context->beforeStmtLines) { if ($this->context->localVars) { $constant->arrayExpr .= $this->genScopeVarDecl(); @@ -2348,6 +2353,12 @@ private function parseClassLikeConstant(Node\Const_ $const, int $flags, string $ $constInfo->valueExpr = $const->value; $constInfo->declaredType = $declaredType; $constInfo->codegenFinalized = $this->compilerPhase === self::PHASE_CONVERT; + if ($constInfo->codegenFinalized) { + // Structural dependency record: computed from the lowering context + // (temporary counter / captured prelude), not from the value text. + $constInfo->hasMaterializedTemporaries = $this->context->tmpVarIndex > 0 + || $this->context->beforeStmtLines !== []; + } if ($constInfo->codegenFinalized && $this->context->beforeStmtLines) { $arrayExpr = ''; diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 8dd47da1..a88f7528 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -94,6 +94,27 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n // `K::CONST === E::Case` false through every dynamic path. return new EnumCaseRef($classDef->getNamespacedName(false), $name); } + // PHP also resolves a fetch like `self::MASK` on an implementing + // class through the class's interfaces when neither the class nor + // any parent declares the constant. Constant DEFINITION lookup + // must not depend on findNativeClassConst's inline-eligibility + // gate (which legitimately returns false for expression-valued + // constants whose value string cannot be inlined), so the + // interface search runs independently of any codegen decision and + // evaluates the retained AST. + foreach ($this->getClassImplementedInterfaces($classDef) as $interfaceName) { + $constant = $this->findCompileTimeInterfaceConstant($interfaceName, $name); + if ($constant !== null) { + [$interface, $interfaceConstDef] = $constant; + return $this->evaluateCompileTimeClassConstant( + $expr, + $interface, + $interfaceConstDef, + $name, + $interface, + ); + } + } } $this->fatalError($expr, "Class constant `{$class}::{$name}` not found"); }