From 73f63607e1c5c86ae86fb77790167c13b3f48209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=9F=E7=82=B9?= <467490186@qq.com> Date: Tue, 20 Oct 2026 21:04:11 +0800 Subject: [PATCH 1/3] fix(compiler): fall back to Zend lookup for expression-valued class constants findNativeClassConst inlines a constant's value string directly into the fetching expression. When the initializer is an expression, parse lowers it into materialize temporaries (tmp_var_N) whose declarations only exist in arrayExpr's prelude, which is emitted together with array constants. The inlined value therefore references locals the fetching function does not have: `const ALL = A | B` fetched inside a method body compiles to `((php::Var(tmp_var_0)) | (php::Var(tmp_var_1)))`, a self-referencing temporary plus an undeclared one (C2065 / C2593). Return false when the value string carries a materialize temporary or the constant has an array expression, so the existing Zend class-constant lookup is used instead. That lookup reads the already evaluated literal registered for the constant and is cached in the convert phase, the same shape as the existing forward-reference fallback. Literal constants keep being inlined. Interface-owned constants resolved through an implementing class take the same guard, since they flow through the identical inline path. Add a PHPUnit codegen fixture with a literal constant and an expression constant in the same class, asserting the leaf constant is still inlined while the expression constant goes through the Zend lookup and carries no inlined temporary. An interface constant fetched on an implementing class is covered by the same assertions. Validation: the new test fails on the parent commit and passes with the guard; the full suite keeps the pre-existing failures of master with an identical failing test list on both Windows and Linux, and an AOT build of the interface fixture links and prints the expected result. --- .../code/class-constant-expression-inline.php | 35 ++++++++++++ .../src/ClassConstantExpressionInlineTest.php | 56 +++++++++++++++++++ src/CompilerBase.php | 18 ++++++ 3 files changed, 109 insertions(+) create mode 100644 phpunit/code/class-constant-expression-inline.php create mode 100644 phpunit/src/ClassConstantExpressionInlineTest.php diff --git a/phpunit/code/class-constant-expression-inline.php b/phpunit/code/class-constant-expression-inline.php new file mode 100644 index 00000000..cf33a609 --- /dev/null +++ b/phpunit/code/class-constant-expression-inline.php @@ -0,0 +1,35 @@ +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); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $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..111ddae6 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -3375,6 +3375,13 @@ protected function findNativeClassConst( if (!$interfaceConstDef->codegenFinalized) { return false; } + // Same materialize-temporary guard as the class-constant + // branch below: inlining a tmp_var_N initializer would + // reference locals that do not exist in the fetching function + // (C2065 / C2593). + if ($interfaceConstDef->arrayExpr !== '' || str_contains($interfaceConstDef->value, 'tmp_var_')) { + return false; + } $expr->setAttribute('nativeConst', $interfaceConstDef); return $interfaceConstDef->value; } @@ -3400,6 +3407,17 @@ protected function findNativeClassConst( if (!$constDef->codegenFinalized) { return false; } + // A constant initializer that parse lowered into materialize + // temporaries (tmp_var_N) 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. + if ($constDef->arrayExpr !== '' || str_contains($constDef->value, 'tmp_var_')) { + return false; + } $expr->setAttribute('nativeConst', $constDef); return $constDef->value; } From 4053db7663bce1d155f1181cf22e3b13a146e998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=9F=E7=82=B9?= <467490186@qq.com> Date: Mon, 26 Oct 2026 11:02:16 +0800 Subject: [PATCH 2/3] fix(compiler): resolve interface constants independently of inline gate The inline-eligibility gate for expression-valued class constants returned before setting the nativeConst attribute, and getClassConstValue() relied on that side-channel to resolve a constant declared on an interface through an implementing class. resolveInheritedClassConst() walks only the class/parent chain, so an expression-valued interface constant used as a method parameter default (`int $value = self::MASK`) failed arginfo generation with "Class constant `MaskUser::MASK` not found". Search the implemented interfaces directly in getClassConstValue() once the class/parent chain misses, reusing findCompileTimeInterfaceConstant() and evaluating the retained AST. Constant DEFINITION lookup therefore no longer depends on any codegen inline decision, while method-body fetches keep falling back to the Zend class-constant lookup. Validation: the new default-parameter regression is red before this change (arginfo fatal) and green after; full suite parity holds at 3205 tests with the same 6 errors and 21 pre-existing failures. --- .../code/class-constant-expression-inline.php | 5 +++ .../src/ClassConstantExpressionInlineTest.php | 32 ++++++++++++++++++- src/Resolver/ClassConstantValueTrait.php | 21 ++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/phpunit/code/class-constant-expression-inline.php b/phpunit/code/class-constant-expression-inline.php index cf33a609..3375375e 100644 --- a/phpunit/code/class-constant-expression-inline.php +++ b/phpunit/code/class-constant-expression-inline.php @@ -32,4 +32,9 @@ public function readCombined(): int { return self::COMBINED; } + + public function readCombinedDefault(int $value = self::COMBINED): int + { + return $value; + } } diff --git a/phpunit/src/ClassConstantExpressionInlineTest.php b/phpunit/src/ClassConstantExpressionInlineTest.php index eb9ead56..000c7ebc 100644 --- a/phpunit/src/ClassConstantExpressionInlineTest.php +++ b/phpunit/src/ClassConstantExpressionInlineTest.php @@ -4,6 +4,8 @@ final class ClassConstantExpressionInlineTest extends \BaseTest { + private ?CompilerTest $compiler = null; + public function testLeafConstantIsInlinedButExpressionConstantUsesZendLookup(): void { $code = $this->compileFixture(); @@ -29,12 +31,40 @@ public function testInterfaceConstantFetchOnImplementingClassUsesZendLookup(): v self::assertDoesNotMatchRegularExpression('/php::Var\(tmp_var_\d+\)/', $combined); } - private function compileFixture(): string + 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); + } + + /** + * @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); 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"); } From 673ac231223967fe355c752b305745802e896806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=9F=E7=82=B9?= <467490186@qq.com> Date: Mon, 26 Oct 2026 11:02:43 +0800 Subject: [PATCH 3/3] fix(compiler): detect inline-ineligible constants structurally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expression-constant gate matched the literal substring "tmp_var_" against the lowered value text, so any string constant containing that text was misrouted to the Zend class-constant lookup. A #[Native] class constant such as `LABEL = 'tmp_var_label'` compiled with --no-literal-strings embeds the literal in a php::Str{ZEND_STRL(...)} value expression, hit the false positive, and was routed to php::constant(get_persistent_class(...)) — but Native classes have no zend_class_entry, so the lookup can never resolve. Record temporary materialization structurally at lowering time instead: ConstantDef::hasMaterializedTemporaries is set from the fresh per-constant function context (temporary counter and captured prelude) exactly where arrayExpr is built, and both gates in findNativeClassConst() now read only that flag together with the existing arrayExpr check. The gates are also skipped when the fetch target is a Native class, keeping C++-only Native constants on their compile-time inline path. Validation: the --no-literal-strings Native regression is red before this change and green after, with the generated code inlining the literal again; full suite parity holds at 3205 tests with the same 6 errors and 21 pre-existing failures, and both review repros now build and run (default parameter evaluates to 3; Native label prints tmp_var_label). --- .../code/class-constant-expression-inline.php | 11 +++++ .../src/ClassConstantExpressionInlineTest.php | 14 +++++++ src/CompilerBase.php | 42 ++++++++++++------- src/Entity/ConstantDef.php | 9 ++++ src/Preprocessor.php | 11 +++++ 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/phpunit/code/class-constant-expression-inline.php b/phpunit/code/class-constant-expression-inline.php index 3375375e..0fa6fb28 100644 --- a/phpunit/code/class-constant-expression-inline.php +++ b/phpunit/code/class-constant-expression-inline.php @@ -38,3 +38,14 @@ public function readCombinedDefault(int $value = self::COMBINED): int return $value; } } + +#[Native] +class ClassConstantExpressionInlineNativeMarker +{ + public const LABEL = 'tmp_var_label'; + + public function label(): string + { + return self::LABEL; + } +} diff --git a/phpunit/src/ClassConstantExpressionInlineTest.php b/phpunit/src/ClassConstantExpressionInlineTest.php index 000c7ebc..18dc40f0 100644 --- a/phpunit/src/ClassConstantExpressionInlineTest.php +++ b/phpunit/src/ClassConstantExpressionInlineTest.php @@ -51,6 +51,20 @@ public function testInterfaceConstantDefaultParameterEvaluatesAtCompileTime(): v 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 */ diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 111ddae6..e439acb0 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -3375,11 +3375,18 @@ protected function findNativeClassConst( if (!$interfaceConstDef->codegenFinalized) { return false; } - // Same materialize-temporary guard as the class-constant - // branch below: inlining a tmp_var_N initializer would - // reference locals that do not exist in the fetching function - // (C2065 / C2593). - if ($interfaceConstDef->arrayExpr !== '' || str_contains($interfaceConstDef->value, 'tmp_var_')) { + // 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); @@ -3407,15 +3414,22 @@ protected function findNativeClassConst( if (!$constDef->codegenFinalized) { return false; } - // A constant initializer that parse lowered into materialize - // temporaries (tmp_var_N) 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. - if ($constDef->arrayExpr !== '' || str_contains($constDef->value, 'tmp_var_')) { + // 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); 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 = '';